stan преди 3 седмици
ревизия
052d6a480f
променени са 100 файла, в които са добавени 11098 реда и са изтрити 0 реда
  1. 39 0
      .clang-format
  2. 78 0
      .clang-tidy
  3. 17 0
      .editorconfig
  4. 11 0
      .gitignore
  5. 674 0
      LICENSE
  6. 82 0
      README.md
  7. 25 0
      Sunrise.sln
  8. 1041 0
      Sunrise/Sunrise.vcxproj
  9. 67 0
      Sunrise/resources/default_settings.json
  10. 16 0
      Sunrise/resources/resource.h
  11. 36 0
      Sunrise/resources/sunrise.rc
  12. 3 0
      Sunrise/src/client/content/activity/activity_tag_reader.cpp
  13. 26 0
      Sunrise/src/client/content/activity/source.h
  14. 47 0
      Sunrise/src/client/content/bootstrap/bootstrap_token_publish.cpp
  15. 8 0
      Sunrise/src/client/content/bootstrap/bootstrap_token_publish.h
  16. 71 0
      Sunrise/src/client/content/diagnostics/content_readiness_report.cpp
  17. 8 0
      Sunrise/src/client/content/diagnostics/content_readiness_report.h
  18. 138 0
      Sunrise/src/client/content/handles/handle_resolver.cpp
  19. 30 0
      Sunrise/src/client/content/handles/handle_resolver.h
  20. 47 0
      Sunrise/src/client/content/handles/layout.h
  21. 216 0
      Sunrise/src/client/content/hash_names/hash_name_build.cpp
  22. 18 0
      Sunrise/src/client/content/hash_names/hash_name_build.h
  23. 217 0
      Sunrise/src/client/content/hash_names/hash_name_matcher.cpp
  24. 115 0
      Sunrise/src/client/content/hash_names/hash_name_matcher.h
  25. 11 0
      Sunrise/src/client/content/investment/internal.h
  26. 122 0
      Sunrise/src/client/content/investment/investment_refresh.cpp
  27. 20 0
      Sunrise/src/client/content/investment/investment_source.cpp
  28. 47 0
      Sunrise/src/client/content/investment/layout.h
  29. 11 0
      Sunrise/src/client/content/investment/runtime.h
  30. 17 0
      Sunrise/src/client/content/investment/source.h
  31. 19 0
      Sunrise/src/client/content/investment/worker.h
  32. 79 0
      Sunrise/src/client/content/investment/worker/investment_refresh_worker.cpp
  33. 120 0
      Sunrise/src/client/content/items/details/configured_item_detail_extractor.cpp
  34. 27 0
      Sunrise/src/client/content/items/details/configured_item_detail_extractor.h
  35. 100 0
      Sunrise/src/client/content/items/details/definition_detail_reader.cpp
  36. 34 0
      Sunrise/src/client/content/items/details/definition_detail_reader.h
  37. 112 0
      Sunrise/src/client/content/items/details/layout.h
  38. 126 0
      Sunrise/src/client/content/items/details/memory.h
  39. 39 0
      Sunrise/src/client/content/items/details/relative.h
  40. 131 0
      Sunrise/src/client/content/items/details/socket_detail_reader.cpp
  41. 38 0
      Sunrise/src/client/content/items/details/socket_detail_reader.h
  42. 50 0
      Sunrise/src/client/content/items/layout.h
  43. 19 0
      Sunrise/src/client/content/items/packages/build.h
  44. 263 0
      Sunrise/src/client/content/items/packages/internal.h
  45. 220 0
      Sunrise/src/client/content/items/packages/package_ability_build.cpp
  46. 76 0
      Sunrise/src/client/content/items/packages/package_build_report.cpp
  47. 85 0
      Sunrise/src/client/content/items/packages/package_container_location.cpp
  48. 217 0
      Sunrise/src/client/content/items/packages/package_detail_build.cpp
  49. 167 0
      Sunrise/src/client/content/items/packages/package_item_build.cpp
  50. 100 0
      Sunrise/src/client/content/items/packages/package_item_rows.cpp
  51. 44 0
      Sunrise/src/client/content/items/packages/package_progression_build.cpp
  52. 129 0
      Sunrise/src/client/content/items/packages/package_root_tables.cpp
  53. 98 0
      Sunrise/src/client/content/items/packages/package_subclass_build.cpp
  54. 204 0
      Sunrise/src/client/content/scenarios/internal.h
  55. 114 0
      Sunrise/src/client/content/scenarios/scenario_build.cpp
  56. 17 0
      Sunrise/src/client/content/scenarios/scenario_build.h
  57. 258 0
      Sunrise/src/client/content/scenarios/scenario_collect.cpp
  58. 215 0
      Sunrise/src/client/content/scenarios/scenario_roster_build.cpp
  59. 220 0
      Sunrise/src/client/content/scenarios/scenario_roster_groups.cpp
  60. 72 0
      Sunrise/src/client/content/scenarios/scenario_roster_publish.cpp
  61. 184 0
      Sunrise/src/client/content/spawn_sets/spawn_set_build.cpp
  62. 18 0
      Sunrise/src/client/content/spawn_sets/spawn_set_build.h
  63. 672 0
      Sunrise/src/client/content/spawn_sets/spawn_set_catalog_builder.cpp
  64. 214 0
      Sunrise/src/client/content/spawn_sets/spawn_set_catalog_builder.h
  65. 32 0
      Sunrise/src/client/diagnostics/module_range.cpp
  66. 30 0
      Sunrise/src/client/diagnostics/module_range.h
  67. 86 0
      Sunrise/src/client/executable/image.cpp
  68. 24 0
      Sunrise/src/client/executable/image.h
  69. 207 0
      Sunrise/src/client/hooking/detour.cpp
  70. 71 0
      Sunrise/src/client/hooking/detour.h
  71. 136 0
      Sunrise/src/client/hooking/detour/protected_detour_uninstall.cpp
  72. 240 0
      Sunrise/src/client/hooking/detour/transaction/detour_thread_transaction.cpp
  73. 43 0
      Sunrise/src/client/hooking/detour/transaction/detour_thread_transaction.h
  74. 80 0
      Sunrise/src/client/hooks/assert_handler/assert_handler_lifecycle.cpp
  75. 17 0
      Sunrise/src/client/hooks/assert_handler/assert_handler_lifecycle.h
  76. 90 0
      Sunrise/src/client/hooks/assert_handler/assert_handler_observer.cpp
  77. 13 0
      Sunrise/src/client/hooks/assert_handler/assert_handler_observer.h
  78. 179 0
      Sunrise/src/client/hooks/banner/banner_bind.cpp
  79. 75 0
      Sunrise/src/client/hooks/banner/banner_bind.h
  80. 32 0
      Sunrise/src/client/hooks/banner/banner_hook_lifecycle.cpp
  81. 17 0
      Sunrise/src/client/hooks/banner/banner_hook_lifecycle.h
  82. 32 0
      Sunrise/src/client/hooks/bitmap/bitmap_hook_lifecycle.cpp
  83. 18 0
      Sunrise/src/client/hooks/bitmap/bitmap_hook_lifecycle.h
  84. 163 0
      Sunrise/src/client/hooks/bitmap/bitmap_ref_guard.cpp
  85. 76 0
      Sunrise/src/client/hooks/bitmap/bitmap_ref_guard.h
  86. 59 0
      Sunrise/src/client/hooks/bootflow/bootflow_hook_lifecycle.cpp
  87. 18 0
      Sunrise/src/client/hooks/bootflow/bootflow_hook_lifecycle.h
  88. 105 0
      Sunrise/src/client/hooks/bootflow/character_select_hold.cpp
  89. 144 0
      Sunrise/src/client/hooks/bootflow/composition_check.cpp
  90. 119 0
      Sunrise/src/client/hooks/bootflow/fade_release.cpp
  91. 131 0
      Sunrise/src/client/hooks/bootflow/internal.h
  92. 119 0
      Sunrise/src/client/hooks/bootflow/join_request_ready.cpp
  93. 87 0
      Sunrise/src/client/hooks/bootflow/orbit_handoff.cpp
  94. 103 0
      Sunrise/src/client/hooks/bootflow/orbit_slice_set.cpp
  95. 133 0
      Sunrise/src/client/hooks/bootflow/owner_activity_slot.cpp
  96. 130 0
      Sunrise/src/client/hooks/bootflow/profile_setup_skip.cpp
  97. 187 0
      Sunrise/src/client/hooks/bootflow/region_private.cpp
  98. 88 0
      Sunrise/src/client/hooks/bootflow/spawn/probe.h
  99. 162 0
      Sunrise/src/client/hooks/bootflow/spawn/spawn_gate_probe.cpp
  100. 113 0
      Sunrise/src/client/hooks/bootflow/spawn/spawn_gate_record_dump.cpp

+ 39 - 0
.clang-format

@@ -0,0 +1,39 @@
+---
+Language: Cpp
+BasedOnStyle: LLVM
+Standard: c++20
+ColumnLimit: 100
+IndentWidth: 4
+ContinuationIndentWidth: 4
+TabWidth: 4
+UseTab: Never
+BreakBeforeBraces: Attach
+NamespaceIndentation: None
+AccessModifierOffset: -4
+PointerAlignment: Left
+ReferenceAlignment: Pointer
+DerivePointerAlignment: false
+AllowShortBlocksOnASingleLine: Empty
+AllowShortFunctionsOnASingleLine: Empty
+AllowShortIfStatementsOnASingleLine: WithoutElse
+AllowShortLoopsOnASingleLine: false
+BinPackArguments: false
+BinPackParameters: false
+BreakBeforeBinaryOperators: NonAssignment
+BreakStringLiterals: true
+ReflowComments: true
+SortIncludes: CaseSensitive
+IncludeBlocks: Regroup
+IncludeCategories:
+  - Regex: '^<Windows\.h>'
+    Priority: 1
+  - Regex: '^<.*>'
+    Priority: 2
+  - Regex: '.*'
+    Priority: 3
+SpaceBeforeParens: ControlStatements
+SpacesInAngles: Never
+SpacesInCStyleCastParentheses: false
+SpacesInParentheses: false
+SpacesInSquareBrackets: false
+...

+ 78 - 0
.clang-tidy

@@ -0,0 +1,78 @@
+---
+Checks: >
+  -*,
+  clang-analyzer-core.*,
+  clang-analyzer-cplusplus.*,
+  clang-analyzer-deadcode.*,
+  clang-analyzer-security.*,
+  bugprone-assert-side-effect,
+  bugprone-bool-pointer-implicit-conversion,
+  bugprone-branch-clone,
+  bugprone-dangling-handle,
+  bugprone-forward-declaration-namespace,
+  bugprone-inaccurate-erase,
+  bugprone-infinite-loop,
+  bugprone-macro-parentheses,
+  bugprone-misplaced-operator-in-strlen-in-alloc,
+  bugprone-multiple-statement-macro,
+  bugprone-narrowing-conversions,
+  bugprone-not-null-terminated-result,
+  bugprone-posix-return,
+  bugprone-reserved-identifier,
+  bugprone-sizeof-container,
+  bugprone-sizeof-expression,
+  bugprone-string-constructor,
+  bugprone-string-integer-assignment,
+  bugprone-string-literal-with-embedded-nul,
+  bugprone-suspicious-enum-usage,
+  bugprone-suspicious-memset-usage,
+  bugprone-suspicious-missing-comma,
+  bugprone-suspicious-realloc-usage,
+  bugprone-suspicious-semicolon,
+  bugprone-swapped-arguments,
+  bugprone-terminating-continue,
+  bugprone-too-small-loop-variable,
+  bugprone-undefined-memory-manipulation,
+  bugprone-undelegated-constructor,
+  bugprone-unhandled-self-assignment,
+  bugprone-unused-raii,
+  bugprone-unused-return-value,
+  bugprone-use-after-move,
+  bugprone-virtual-near-miss,
+  concurrency-mt-unsafe,
+  performance-faster-string-find,
+  performance-for-range-copy,
+  performance-implicit-conversion-in-loop,
+  performance-inefficient-algorithm,
+  performance-inefficient-string-concatenation,
+  performance-inefficient-vector-operation,
+  performance-move-const-arg,
+  performance-move-constructor-init,
+  performance-no-automatic-move,
+  performance-no-int-to-ptr,
+  performance-noexcept-move-constructor,
+  performance-trivially-destructible,
+  performance-type-promotion-in-math-fn,
+  performance-unnecessary-copy-initialization,
+  performance-unnecessary-value-param,
+  portability-restrict-system-includes,
+  portability-simd-intrinsics,
+  portability-std-allocator-const,
+  portability-template-virtual-member-function,
+  readability-braces-around-statements,
+  readability-duplicate-include,
+  readability-inconsistent-declaration-parameter-name,
+  readability-misleading-indentation,
+  readability-redundant-control-flow,
+  readability-redundant-declaration,
+  readability-redundant-string-cstr,
+  readability-suspicious-call-argument
+WarningsAsErrors: '*'
+HeaderFilterRegex: '.*[\\/]prod_client[\\/]Sunrise[\\/]src[\\/].*'
+SystemHeaders: false
+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,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,new,optional,span,string_view,type_traits,utility,vector'
+...

+ 17 - 0
.editorconfig

@@ -0,0 +1,17 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = crlf
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.{cpp,h}]
+indent_style = space
+indent_size = 4
+tab_width = 4
+max_line_length = 100
+
+[*.{md,ps1,vcxproj}]
+indent_style = space
+indent_size = 2

+ 11 - 0
.gitignore

@@ -0,0 +1,11 @@
+build/
+.vs/
+.idea/
+.vscode/
+*.user
+*.suo
+*.userosscache
+*.sln.docstates
+*.aps
+logs/
+*.log

+ 674 - 0
LICENSE

@@ -0,0 +1,674 @@
+                    GNU GENERAL PUBLIC LICENSE
+                       Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+                            Preamble
+
+  The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+  The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works.  By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.  We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors.  You can apply it to
+your programs, too.
+
+  When we speak of free software, we are referring to freedom, not
+price.  Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+  To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights.  Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+  For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received.  You must make sure that they, too, receive
+or can get the source code.  And you must show them these terms so they
+know their rights.
+
+  Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+  For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software.  For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+  Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so.  This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software.  The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable.  Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products.  If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+  Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary.  To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+  The precise terms and conditions for copying, distribution and
+modification follow.
+
+                       TERMS AND CONDITIONS
+
+  0. Definitions.
+
+  "This License" refers to version 3 of the GNU General Public License.
+
+  "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+  "The Program" refers to any copyrightable work licensed under this
+License.  Each licensee is addressed as "you".  "Licensees" and
+"recipients" may be individuals or organizations.
+
+  To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy.  The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+  A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+  To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy.  Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+  To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies.  Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+  An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License.  If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+  1. Source Code.
+
+  The "source code" for a work means the preferred form of the work
+for making modifications to it.  "Object code" means any non-source
+form of a work.
+
+  A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+  The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form.  A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+  The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities.  However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work.  For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+  The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+  The Corresponding Source for a work in source code form is that
+same work.
+
+  2. Basic Permissions.
+
+  All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met.  This License explicitly affirms your unlimited
+permission to run the unmodified Program.  The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work.  This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+  You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force.  You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright.  Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+  Conveying under any other circumstances is permitted solely under
+the conditions stated below.  Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+  3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+  No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+  When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+  4. Conveying Verbatim Copies.
+
+  You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+  You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+  5. Conveying Modified Source Versions.
+
+  You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+    a) The work must carry prominent notices stating that you modified
+    it, and giving a relevant date.
+
+    b) The work must carry prominent notices stating that it is
+    released under this License and any conditions added under section
+    7.  This requirement modifies the requirement in section 4 to
+    "keep intact all notices".
+
+    c) You must license the entire work, as a whole, under this
+    License to anyone who comes into possession of a copy.  This
+    License will therefore apply, along with any applicable section 7
+    additional terms, to the whole of the work, and all its parts,
+    regardless of how they are packaged.  This License gives no
+    permission to license the work in any other way, but it does not
+    invalidate such permission if you have separately received it.
+
+    d) If the work has interactive user interfaces, each must display
+    Appropriate Legal Notices; however, if the Program has interactive
+    interfaces that do not display Appropriate Legal Notices, your
+    work need not make them do so.
+
+  A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit.  Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+  6. Conveying Non-Source Forms.
+
+  You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+    a) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by the
+    Corresponding Source fixed on a durable physical medium
+    customarily used for software interchange.
+
+    b) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by a
+    written offer, valid for at least three years and valid for as
+    long as you offer spare parts or customer support for that product
+    model, to give anyone who possesses the object code either (1) a
+    copy of the Corresponding Source for all the software in the
+    product that is covered by this License, on a durable physical
+    medium customarily used for software interchange, for a price no
+    more than your reasonable cost of physically performing this
+    conveying of source, or (2) access to copy the
+    Corresponding Source from a network server at no charge.
+
+    c) Convey individual copies of the object code with a copy of the
+    written offer to provide the Corresponding Source.  This
+    alternative is allowed only occasionally and noncommercially, and
+    only if you received the object code with such an offer, in accord
+    with subsection 6b.
+
+    d) Convey the object code by offering access from a designated
+    place (gratis or for a charge), and offer equivalent access to the
+    Corresponding Source in the same way through the same place at no
+    further charge.  You need not require recipients to copy the
+    Corresponding Source along with the object code.  If the place to
+    copy the object code is a network server, the Corresponding Source
+    may be on a different server (operated by you or a third party)
+    that supports equivalent copying facilities, provided you maintain
+    clear directions next to the object code saying where to find the
+    Corresponding Source.  Regardless of what server hosts the
+    Corresponding Source, you remain obligated to ensure that it is
+    available for as long as needed to satisfy these requirements.
+
+    e) Convey the object code using peer-to-peer transmission, provided
+    you inform other peers where the object code and Corresponding
+    Source of the work are being offered to the general public at no
+    charge under subsection 6d.
+
+  A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+  A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling.  In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage.  For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product.  A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+  "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source.  The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+  If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information.  But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+  The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed.  Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+  Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+  7. Additional Terms.
+
+  "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law.  If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+  When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it.  (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.)  You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+  Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+    a) Disclaiming warranty or limiting liability differently from the
+    terms of sections 15 and 16 of this License; or
+
+    b) Requiring preservation of specified reasonable legal notices or
+    author attributions in that material or in the Appropriate Legal
+    Notices displayed by works containing it; or
+
+    c) Prohibiting misrepresentation of the origin of that material, or
+    requiring that modified versions of such material be marked in
+    reasonable ways as different from the original version; or
+
+    d) Limiting the use for publicity purposes of names of licensors or
+    authors of the material; or
+
+    e) Declining to grant rights under trademark law for use of some
+    trade names, trademarks, or service marks; or
+
+    f) Requiring indemnification of licensors and authors of that
+    material by anyone who conveys the material (or modified versions of
+    it) with contractual assumptions of liability to the recipient, for
+    any liability that these contractual assumptions directly impose on
+    those licensors and authors.
+
+  All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10.  If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term.  If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+  If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+  Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+  8. Termination.
+
+  You may not propagate or modify a covered work except as expressly
+provided under this License.  Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+  However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+  Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+  Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License.  If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+  9. Acceptance Not Required for Having Copies.
+
+  You are not required to accept this License in order to receive or
+run a copy of the Program.  Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance.  However,
+nothing other than this License grants you permission to propagate or
+modify any covered work.  These actions infringe copyright if you do
+not accept this License.  Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+  10. Automatic Licensing of Downstream Recipients.
+
+  Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License.  You are not responsible
+for enforcing compliance by third parties with this License.
+
+  An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations.  If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+  You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License.  For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+  11. Patents.
+
+  A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based.  The
+work thus licensed is called the contributor's "contributor version".
+
+  A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version.  For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+  Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+  In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement).  To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+  If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients.  "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+  If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+  A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License.  You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+  Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+  12. No Surrender of Others' Freedom.
+
+  If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License.  If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all.  For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+  13. Use with the GNU Affero General Public License.
+
+  Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work.  The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+  14. Revised Versions of this License.
+
+  The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time.  Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+  Each version is given a distinguishing version number.  If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation.  If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+  If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+  Later license versions may give you additional or different
+permissions.  However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+  15. Disclaimer of Warranty.
+
+  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+  16. Limitation of Liability.
+
+  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+  17. Interpretation of Sections 15 and 16.
+
+  If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+                     END OF TERMS AND CONDITIONS
+
+            How to Apply These Terms to Your New Programs
+
+  If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+  To do so, attach the following notices to the program.  It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+    <one line to give the program's name and a brief idea of what it does.>
+    Copyright (C) <year>  <name of author>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+  If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+    <program>  Copyright (C) <year>  <name of author>
+    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+    This is free software, and you are welcome to redistribute it
+    under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License.  Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+  You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+<https://www.gnu.org/licenses/>.
+
+  The GNU General Public License does not permit incorporating your program
+into proprietary programs.  If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library.  If this is what you want to do, use the GNU Lesser General
+Public License instead of this License.  But first, please read
+<https://www.gnu.org/licenses/why-not-lgpl.html>.

+ 82 - 0
README.md

@@ -0,0 +1,82 @@
+## Sunrise
+Destiny 2 Offline Exploration Mod
+
+> This mod installs onto an old build of the game and allows you to play it offline, loading into destinations and exploring them.
+> 
+> No other features are currently supported. (Missions, Enemies, NPCs, Quests, Inventory Management, ...)
+
+### [Install Instructions](https://github.com/stanuwu/Sunrise/wiki/Installing)
+
+### [FAQ](https://github.com/stanuwu/Sunrise/wiki/FAQ) - [Common Issues](https://github.com/stanuwu/Sunrise/wiki/Common-Issues)
+
+### [Discord](https://discord.gg/jQYqhkuh7h)
+
+### Support Me
+If you want to support my open source work you can find the means on my [profile](https://github.com/stanuwu). Also consider donating to charity instead.
+
+All content released under this project is free and open source. If someone is trying to sell you something you are getting scammed.
+
+### WIP
+This mod is work in progress. Things might break or work in unexpected ways. There is also currently a lack of documentation. This will improve over the coming weeks.
+
+## Credits
+### Dependencies:
+- https://github.com/ocornut/imgui
+- https://github.com/microsoft/detours
+### Artwork:
+- [Solus](https://www.youtube.com/@Solus-yt)
+### Testing:
+- [Ferr](https://x.com/light_fades_awy)
+- [gage](https://x.com/_Quolu_)
+- [Jenka](https://youtube.com/@jenkad2oob?si=OQpCGeBCEJBS0zHx)
+- [Katie](https://github.com/Confetti3)
+- [Kody Ivie](https://x.com/Kody_Ivie)
+- [Solus](https://www.youtube.com/@Solus-yt)
+- Breshi
+- [Deltadog55](https://www.youtube.com/@deltadog55)
+- Moosh
+- [MoveableFormula](https://youtube.com/@movableformula)
+- Z
+- The Cube17
+### Inspiration/Helpful Repos
+- https://github.com/v4nguard/tiger-pkg
+- https://github.com/cohaereo/alkahest
+- https://codeberg.org/V4NGUARD/tachyscope
+- https://github.com/MontagueM/D2TagParser
+- https://github.com/MontagueM/DestinyUnpackerCPP
+- https://github.com/nblockbuster/D2TextureRipper
+- https://github.com/v4nguard/tiger-parse
+- https://github.com/Demonware-Custom-Server/demonware-cod4
+- https://github.com/hosseinpourziyaie/demonware-companion
+- https://github.com/jordam/demonbugger
+- https://github.com/project-bo4/shield-development
+- https://github.com/MontagueM/Charm
+- https://github.com/v4nguard/quicktag
+- https://github.com/nblockbuster/D2StaticDocs
+- https://github.com/MontagueM/D2Maps
+- https://github.com/MontagueM/DestinyMapmining
+- https://github.com/nblockbuster/tachyscope
+- https://github.com/cohaereo/destinydocs
+- https://github.com/MontagueM/DestinyUnpacker
+### Other:
+[Ginsor](https://x.com/GinsorKR) - Gave me some useful pointers
+
+> Want to be added to or removed from the credits? Let me know.
+
+## Content Disclaimer
+Sunrise is not:
+- A Crack
+- A Cheat
+- A Custom Server
+
+Everyone needs to provide their own copy of the game, no piracy is happening. The mod does not connect to any servers, it runs completely locally. We do not offer any servers or services.
+
+## Legal Disclaimer
+This project is not for profit. It does not affect live servers or newer versions of the game where research like this could pose a security risk. No game data will be included in the release so this is not a copyright violation. This is also not a circumvention of protective measures. Please do not file any DMCA or other copyright claims against this. Legal action will be taken for abuse of the copyright system to censor this work.
+
+## AI Disclaimer
+AI was used in the creation of this project. If you are not comfortable with the use of AI in programming projects beware.
+AI was NOT used to create any art or creative writing. Only for RE, development and documentation purposes. All AI work that is publicly released is reviewed by a human. AI is a tool and the user is responsible for the results it produces.
+
+## Affiliation Disclaimer
+This project is not affiliated with Bungie or Sony in any way.

+ 25 - 0
Sunrise.sln

@@ -0,0 +1,25 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 18
+VisualStudioVersion = 18.0.30412.183
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "Sunrise", "Sunrise\Sunrise.vcxproj", "{A665726B-97A2-4E35-B518-0FC207D3DA1B}"
+EndProject
+Global
+	GlobalSection(SolutionConfigurationPlatforms) = preSolution
+		Debug|x64 = Debug|x64
+		Release|x64 = Release|x64
+	EndGlobalSection
+	GlobalSection(ProjectConfigurationPlatforms) = postSolution
+		{A665726B-97A2-4E35-B518-0FC207D3DA1B}.Debug|x64.ActiveCfg = Debug|x64
+		{A665726B-97A2-4E35-B518-0FC207D3DA1B}.Debug|x64.Build.0 = Debug|x64
+		{A665726B-97A2-4E35-B518-0FC207D3DA1B}.Release|x64.ActiveCfg = Release|x64
+		{A665726B-97A2-4E35-B518-0FC207D3DA1B}.Release|x64.Build.0 = Release|x64
+	EndGlobalSection
+	GlobalSection(SolutionProperties) = preSolution
+		HideSolutionNode = FALSE
+	EndGlobalSection
+	GlobalSection(ExtensibilityGlobals) = postSolution
+		SolutionGuid = {2E4F8A17-63C9-4D0B-8B5E-9A1C7F30D2E6}
+	EndGlobalSection
+EndGlobal

+ 1041 - 0
Sunrise/Sunrise.vcxproj

@@ -0,0 +1,1041 @@
+<?xml version="1.0" encoding="utf-8"?>
+<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
+  <ItemGroup Label="ProjectConfigurations">
+    <ProjectConfiguration Include="Debug|x64">
+      <Configuration>Debug</Configuration>
+      <Platform>x64</Platform>
+    </ProjectConfiguration>
+    <ProjectConfiguration Include="Release|x64">
+      <Configuration>Release</Configuration>
+      <Platform>x64</Platform>
+    </ProjectConfiguration>
+  </ItemGroup>
+  <PropertyGroup Label="Globals">
+    <VCProjectVersion>17.0</VCProjectVersion>
+    <ProjectGuid>{A665726B-97A2-4E35-B518-0FC207D3DA1B}</ProjectGuid>
+    <RootNamespace>sunrise</RootNamespace>
+    <WindowsTargetPlatformVersion>10.0.26100.0</WindowsTargetPlatformVersion>
+    <ProjectName>Sunrise</ProjectName>
+  </PropertyGroup>
+  <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
+  <PropertyGroup Label="Configuration">
+    <ConfigurationType>DynamicLibrary</ConfigurationType>
+    <PlatformToolset>v145</PlatformToolset>
+    <CharacterSet>Unicode</CharacterSet>
+  </PropertyGroup>
+  <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration">
+    <UseDebugLibraries>true</UseDebugLibraries>
+  </PropertyGroup>
+  <PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration">
+    <UseDebugLibraries>false</UseDebugLibraries>
+    <WholeProgramOptimization>true</WholeProgramOptimization>
+  </PropertyGroup>
+  <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
+  <PropertyGroup>
+    <TargetName>steam_api64</TargetName>
+    <OutDir>$(ProjectDir)..\build\$(Platform)\$(Configuration)\</OutDir>
+    <IntDir>$(ProjectDir)..\build\obj\$(Platform)\$(Configuration)\</IntDir>
+    <ExternalIncludePath>$(ExternalIncludePath);$(ProjectDir)vendor\detours;$(ProjectDir)vendor\imgui;$(ProjectDir)vendor\imgui\backends</ExternalIncludePath>
+    <VcpkgApplocalDeps>false</VcpkgApplocalDeps>
+  </PropertyGroup>
+  <ItemDefinitionGroup>
+    <ClCompile>
+      <LanguageStandard>stdcpp20</LanguageStandard>
+      <WarningLevel>Level4</WarningLevel>
+      <TreatWarningAsError>true</TreatWarningAsError>
+      <ConformanceMode>true</ConformanceMode>
+      <MultiProcessorCompilation>true</MultiProcessorCompilation>
+      <AdditionalOptions>/utf-8 %(AdditionalOptions)</AdditionalOptions>
+      <AdditionalIncludeDirectories>$(ProjectDir)src;$(ProjectDir)vendor\detours;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
+      <TreatAngleIncludeAsExternal>true</TreatAngleIncludeAsExternal>
+      <ExternalWarningLevel>TurnOffAllWarnings</ExternalWarningLevel>
+      <PreprocessorDefinitions>WIN32;_WINDOWS;_USRDLL;WIN32_LEAN_AND_MEAN;NOMINMAX;IMGUI_USER_CONFIG=&quot;core/ui/imgui_user_config.h&quot;;%(PreprocessorDefinitions)</PreprocessorDefinitions>
+    </ClCompile>
+    <Link>
+      <SubSystem>Windows</SubSystem>
+      <AdditionalDependencies>kernel32.lib;bcrypt.lib;user32.lib;gdi32.lib;dwmapi.lib;d3dcompiler.lib;shell32.lib;ws2_32.lib;synchronization.lib;%(AdditionalDependencies)</AdditionalDependencies>
+    </Link>
+  </ItemDefinitionGroup>
+  <ItemDefinitionGroup Condition="'$(Configuration)'=='Debug'">
+    <ClCompile>
+      <Optimization>Disabled</Optimization>
+      <RuntimeLibrary>MultiThreadedDebug</RuntimeLibrary>
+      <PreprocessorDefinitions>_DEBUG;%(PreprocessorDefinitions)</PreprocessorDefinitions>
+    </ClCompile>
+    <Link>
+      <GenerateDebugInformation>true</GenerateDebugInformation>
+    </Link>
+  </ItemDefinitionGroup>
+  <ItemDefinitionGroup Condition="'$(Configuration)'=='Release'">
+    <ClCompile>
+      <Optimization>MaxSpeed</Optimization>
+      <FunctionLevelLinking>true</FunctionLevelLinking>
+      <IntrinsicFunctions>true</IntrinsicFunctions>
+      <RuntimeLibrary>MultiThreaded</RuntimeLibrary>
+      <PreprocessorDefinitions>NDEBUG;%(PreprocessorDefinitions)</PreprocessorDefinitions>
+    </ClCompile>
+    <Link>
+      <GenerateDebugInformation>true</GenerateDebugInformation>
+      <EnableCOMDATFolding>true</EnableCOMDATFolding>
+      <OptimizeReferences>true</OptimizeReferences>
+    </Link>
+  </ItemDefinitionGroup>
+  <ItemGroup>
+    <ResourceCompile Include="resources\sunrise.rc" />
+  </ItemGroup>
+  <ItemGroup>
+    <None Include="resources\default_settings.json" />
+    <None Include="vendor\detours\LICENSE.md" />
+    <None Include="vendor\imgui\LICENSE.txt" />
+  </ItemGroup>
+  <ItemGroup>
+    <ClCompile Include="src\dllmain.cpp" />
+    <ClCompile Include="src\core\logging\log.cpp" />
+    <ClCompile Include="src\core\logging\snapshot\log_snapshot_ring.cpp" />
+    <ClCompile Include="src\core\logging\view\log_snapshot_view.cpp" />
+    <ClCompile Include="src\core\filesystem\path.cpp" />
+    <ClCompile Include="src\core\filesystem\temporary_sibling.cpp" />
+    <ClCompile Include="src\core\settings\settings_parser.cpp" />
+    <ClCompile Include="src\core\settings\json_number.cpp" />
+    <ClCompile Include="src\core\settings\json_scalar.cpp" />
+    <ClCompile Include="src\core\settings\json_structure.cpp" />
+    <ClCompile Include="src\core\settings\settings_runtime.cpp" />
+    <ClCompile Include="src\core\settings\client\client_settings_parser.cpp" />
+    <ClCompile Include="src\core\settings\client\client_ui_settings_parser.cpp" />
+    <ClCompile Include="src\core\settings\server\server_settings_parser.cpp" />
+    <ClCompile Include="src\core\settings\server\entitlement_settings_parser.cpp" />
+    <ClCompile Include="src\core\settings\steam\steam_settings_parser.cpp" />
+    <ClCompile Include="src\core\settings\state\account_settings_parser.cpp" />
+    <ClCompile Include="src\core\settings\state\family5_override_parser.cpp" />
+    <ClCompile Include="src\core\settings\state\activity_default_destination_parser.cpp" />
+    <ClCompile Include="src\core\settings\state\inventory_parser.cpp" />
+    <ClCompile Include="src\core\settings\state\audio_parser.cpp" />
+    <ClCompile Include="src\core\settings\state\controls_parser.cpp" />
+    <ClCompile Include="src\core\settings\state\display_parser.cpp" />
+    <ClCompile Include="src\core\settings\state\interface_parser.cpp" />
+    <ClCompile Include="src\core\settings\state\key_bindings_parser.cpp" />
+    <ClCompile Include="src\core\settings\state\social_parser.cpp" />
+    <ClCompile Include="src\core\runtime\core_runtime.cpp" />
+    <ClCompile Include="src\core\ui\modules\ui_module_descriptor.cpp" />
+    <ClCompile Include="src\core\ui\modules\registry\ui_module_registry.cpp" />
+    <ClCompile Include="src\core\ui\modules\logs\logs_module_runtime.cpp" />
+    <ClCompile Include="src\core\ui\modules\logs\logs_clipboard_writer.cpp" />
+    <ClCompile Include="src\client\hooks\cursor\cursor_guard_replacements.cpp" />
+    <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\state\runtime\state_account_runtime.cpp" />
+    <ClCompile Include="src\core\ui\busy\ui_busy_overlay.cpp" />
+    <ClCompile Include="src\core\ui\busy\ui_busy_state.cpp" />
+    <ClCompile Include="src\core\ui\notice\ui_notice_overlay.cpp" />
+    <ClCompile Include="src\core\ui\modules\logs\logs_panel_render.cpp" />
+    <ClCompile Include="src\core\ui\modules\logs\filters\logs_filter_controls.cpp" />
+    <ClCompile Include="src\core\ui\runtime\ui_visibility_runtime.cpp" />
+    <ClCompile Include="src\core\ui\memory\fixed\ui_fixed_allocator.cpp" />
+    <ClCompile Include="src\core\ui\memory\fixed\ui_fixed_arena.cpp" />
+    <ClCompile Include="src\core\ui\animation\transition\ui_transition_animation.cpp" />
+    <ClCompile Include="src\core\ui\components\card\ui_card_component.cpp" />
+    <ClCompile Include="src\core\ui\components\filter\ui_filter_component.cpp" />
+    <ClCompile Include="src\core\ui\components\picker\ui_picker_component.cpp" />
+    <ClCompile Include="src\core\ui\components\navigation\ui_navigation_component.cpp" />
+    <ClCompile Include="src\core\ui\components\section\ui_section_component.cpp" />
+    <ClCompile Include="src\core\ui\components\toggle\ui_toggle_component.cpp" />
+    <ClCompile Include="src\core\ui\fonts\installed\ui_installed_font_reader.cpp" />
+    <ClCompile Include="src\core\ui\fonts\runtime\ui_runtime_font_lifecycle.cpp" />
+    <ClCompile Include="src\core\ui\scaling\dpi\ui_dpi_scaling.cpp" />
+    <ClCompile Include="src\core\ui\theme\sunrise_ui_theme.cpp" />
+    <ClCompile Include="src\core\ui\layout\ui_layout_lifecycle.cpp" />
+    <ClCompile Include="src\core\ui\layout\navigation\ui_layout_navigation.cpp" />
+    <ClCompile Include="src\core\ui\layout\ui_layout_render.cpp" />
+    <ClCompile Include="src\core\ui\layout\credits\sunrise_credits_badge.cpp" />
+    <ClCompile Include="src\client\runtime\client_hook_activation.cpp" />
+    <ClCompile Include="src\client\runtime\client_platform_hook_activation.cpp" />
+    <ClCompile Include="src\client\runtime\host\game_host_classification.cpp" />
+    <ClCompile Include="src\client\runtime\client_runtime_lifecycle.cpp" />
+    <ClCompile Include="src\client\hooks\graphics\discovery\graphics_target_discovery.cpp" />
+    <ClCompile Include="src\client\hooks\graphics\graphics_hook_lifecycle.cpp" />
+    <ClCompile Include="src\client\hooks\graphics\graphics_hook_replacements.cpp" />
+    <ClCompile Include="src\client\hooks\graphics\input\graphics_window_input.cpp" />
+    <ClCompile Include="src\client\hooks\graphics\input\graphics_raw_input_window.cpp" />
+    <ClCompile Include="src\client\hooks\polled_input\polled_input_lifecycle.cpp" />
+    <ClCompile Include="src\client\hooks\polled_input\polled_input_replacements.cpp" />
+    <ClCompile Include="src\client\hooks\banner\banner_bind.cpp" />
+    <ClCompile Include="src\client\hooks\banner\banner_hook_lifecycle.cpp" />
+    <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\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" />
+    <ClCompile Include="src\client\hooks\egress\dns\egress_dns_replacements.cpp" />
+    <ClCompile Include="src\client\hooks\egress\resolver\egress_resolver_forward.cpp" />
+    <ClCompile Include="src\client\hooks\egress\resolver\egress_resolver_reverse.cpp" />
+    <ClCompile Include="src\client\hooks\egress\extensions\egress_extension_replacements.cpp" />
+    <ClCompile Include="src\client\hooks\egress\lifecycle\egress_guard_lifecycle.cpp" />
+    <ClCompile Include="src\client\hooks\egress\lifecycle\egress_guard_modules.cpp" />
+    <ClCompile Include="src\client\hooks\egress\lifecycle\egress_guard_exports.cpp" />
+    <ClCompile Include="src\client\hooks\egress\policy\egress_policy_logging.cpp" />
+    <ClCompile Include="src\client\hooks\egress\policy\egress_policy_endpoint.cpp" />
+    <ClCompile Include="src\client\hooks\egress\winsock\connection\egress_connection_replacements.cpp" />
+    <ClCompile Include="src\client\hooks\egress\winsock\control\egress_control_replacement.cpp" />
+    <ClCompile Include="src\client\hooks\egress\winsock\discovery\egress_discovery_responder.cpp" />
+    <ClCompile Include="src\client\hooks\egress\winsock\reception\egress_reception_replacements.cpp" />
+    <ClCompile Include="src\client\hooks\egress\winsock\transmission\egress_transmission_replacements.cpp" />
+    <ClCompile Include="src\client\hooks\egress\winsock\transmission\egress_datagram_transmission.cpp" />
+    <ClCompile Include="src\client\hooks\egress\winsock\transmission\egress_message_transmission.cpp" />
+    <ClCompile Include="src\client\ui\runtime\client_ui_module_runtime.cpp" />
+    <ClCompile Include="src\client\ui\teleport\teleport_panel.cpp" />
+    <ClCompile Include="src\client\teleport\teleport_settings_store.cpp" />
+    <ClCompile Include="src\client\hooks\teleport\teleport_lifecycle.cpp" />
+    <ClCompile Include="src\client\hooks\teleport\teleport_move.cpp" />
+    <ClCompile Include="src\client\hooks\teleport\teleport_action_key.cpp" />
+    <ClCompile Include="src\client\hooking\detour.cpp" />
+    <ClCompile Include="src\client\hooking\detour\protected_detour_uninstall.cpp" />
+    <ClCompile Include="src\client\hooking\detour\transaction\detour_thread_transaction.cpp" />
+    <ClCompile Include="src\client\executable\image.cpp" />
+    <ClCompile Include="src\client\memory\current_process_memory.cpp" />
+    <ClCompile Include="src\client\hooks\network\investment\investment_derived_rebuild.cpp" />
+    <ClCompile Include="src\client\hooks\network\investment\investment_family5_rearm.cpp" />
+    <ClCompile Include="src\client\hooks\bitmap\bitmap_hook_lifecycle.cpp" />
+    <ClCompile Include="src\client\hooks\bitmap\bitmap_ref_guard.cpp" />
+    <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\character_select_hold.cpp" />
+    <ClCompile Include="src\client\hooks\bootflow\composition_check.cpp" />
+    <ClCompile Include="src\client\hooks\bootflow\orbit_handoff.cpp" />
+    <ClCompile Include="src\client\hooks\bootflow\owner_activity_slot.cpp" />
+    <ClCompile Include="src\client\hooks\bootflow\orbit_slice_set.cpp" />
+    <ClCompile Include="src\client\hooks\bootflow\region_private.cpp" />
+    <ClCompile Include="src\client\hooks\bootflow\profile_setup_skip.cpp" />
+    <ClCompile Include="src\client\hooks\bootflow\join_request_ready.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\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" />
+    <ClCompile Include="src\core\settings\state\activity_arrival_override_parser.cpp" />
+    <ClCompile Include="src\client\hooks\queuez\family0_subscription.cpp" />
+    <ClCompile Include="src\client\hooks\queuez\svc123_null_payload_guard.cpp" />
+    <ClCompile Include="src\client\hooks\queuez\queuez_hook_lifecycle.cpp" />
+    <ClCompile Include="src\client\content\diagnostics\content_readiness_report.cpp" />
+    <ClCompile Include="src\client\hooks\network\bubble_authority\bubble_authority_replacements.cpp" />
+    <ClCompile Include="src\client\hooks\network\bubble_authority\scope\bubble_authority_scope.cpp" />
+    <ClCompile Include="src\client\hooks\network\http\http_descriptor_route.cpp" />
+    <ClCompile Include="src\client\hooks\network\http\http_executor_replacement.cpp" />
+    <ClCompile Include="src\client\hooks\network\http\http_worker_completion.cpp" />
+    <ClCompile Include="src\client\hooks\network\signon\signon_readiness_replacements.cpp" />
+    <ClCompile Include="src\client\patterns\game\signon\signon_readiness_signature_bytes.cpp" />
+    <ClCompile Include="src\core\settings\client\external\client_external_settings_parser.cpp" />
+    <ClCompile Include="src\client\targets\game\game_retail_log_targets.cpp" />
+    <ClCompile Include="src\client\patterns\game\retail_log\retail_log_signature_bytes.cpp" />
+    <ClCompile Include="src\client\hooks\retail_log\retail_log_enqueue_observer.cpp" />
+    <ClCompile Include="src\client\hooks\retail_log\retail_log_lifecycle.cpp" />
+    <ClCompile Include="src\client\hooks\assert_handler\assert_handler_lifecycle.cpp" />
+    <ClCompile Include="src\client\hooks\assert_handler\assert_handler_observer.cpp" />
+    <ClCompile Include="src\client\targets\game\assert_handler\game_assert_targets.cpp" />
+    <ClCompile Include="src\client\targets\game\packages\game_package_targets.cpp" />
+    <ClCompile Include="src\client\targets\game\config_getter\game_config_getter_targets.cpp" />
+    <ClCompile Include="src\client\patterns\game\config_getter\config_getter_signature_bytes.cpp" />
+    <ClCompile Include="src\client\hooks\config_getter\config_getter_lifecycle.cpp" />
+    <ClCompile Include="src\client\hooks\config_getter\config_getter_answers.cpp" />
+    <ClCompile Include="src\client\patterns\game\packages\package_key_signature_bytes.cpp" />
+    <ClCompile Include="src\client\patterns\game\assert_handler\assert_signature_bytes.cpp" />
+    <ClCompile Include="src\client\hooks\network\coordinator\network_call_coordinator.cpp" />
+    <ClCompile Include="src\client\hooks\network\network_call_quiescence.cpp" />
+    <ClCompile Include="src\client\hooks\network\content_config\coordinator\content_config_call_coordinator.cpp" />
+    <ClCompile Include="src\client\hooks\network\content_config\gate\content_config_signature_gate.cpp" />
+    <ClCompile Include="src\client\hooks\network\content_config\lifecycle\content_config_hook_install.cpp" />
+    <ClCompile Include="src\client\hooks\network\content_config\lifecycle\content_config_hook_lifecycle.cpp" />
+    <ClCompile Include="src\client\hooks\network\content_config\lifecycle\content_config_hook_storage.cpp" />
+    <ClCompile Include="src\client\hooks\network\content_config\lifecycle\content_config_hook_uninstall.cpp" />
+    <ClCompile Include="src\client\hooks\network\content_config\request\content_config_get_replacement.cpp" />
+    <ClCompile Include="src\client\hooks\network\content_config\tick\content_config_tick_replacement.cpp" />
+    <ClCompile Include="src\client\hooks\network\lifecycle\network_hook_group.cpp" />
+    <ClCompile Include="src\client\hooks\network\lifecycle\network_hook_entries.cpp" />
+    <ClCompile Include="src\client\hooks\network\lifecycle\network_hook_lifecycle.cpp" />
+    <ClCompile Include="src\client\hooks\network\network_consumer_registration.cpp" />
+    <ClCompile Include="src\client\hooks\network\platform.cpp" />
+    <ClCompile Include="src\client\patterns\registry.cpp" />
+    <ClCompile Include="src\client\patterns\image_scan.cpp" />
+    <ClCompile Include="src\client\patterns\game_signatures.cpp" />
+    <ClCompile Include="src\client\patterns\steam_signatures.cpp" />
+    <ClCompile Include="src\client\targets\steam_targets.cpp" />
+    <ClCompile Include="src\client\targets\game\game_content_targets.cpp" />
+    <ClCompile Include="src\client\targets\game\game_network_targets.cpp" />
+    <ClCompile Include="src\client\targets\game\network\network_content_derivation.cpp" />
+    <ClCompile Include="src\client\targets\game\resolution\game_target_resolution.cpp" />
+    <ClCompile Include="vendor\detours\detours.cpp">
+      <WarningLevel>Level3</WarningLevel>
+      <TreatWarningAsError>false</TreatWarningAsError>
+      <ExcludedFromBuild Condition="'$(SunriseRunClangTidy)'=='true'">true</ExcludedFromBuild>
+    </ClCompile>
+    <ClCompile Include="vendor\detours\disasm.cpp">
+      <WarningLevel>Level3</WarningLevel>
+      <TreatWarningAsError>false</TreatWarningAsError>
+      <ExcludedFromBuild Condition="'$(SunriseRunClangTidy)'=='true'">true</ExcludedFromBuild>
+    </ClCompile>
+    <ClCompile Include="vendor\detours\modules.cpp">
+      <WarningLevel>Level3</WarningLevel>
+      <TreatWarningAsError>false</TreatWarningAsError>
+      <ExcludedFromBuild Condition="'$(SunriseRunClangTidy)'=='true'">true</ExcludedFromBuild>
+    </ClCompile>
+    <ClCompile Include="vendor\imgui\imgui.cpp">
+      <WarningLevel>Level3</WarningLevel>
+      <TreatWarningAsError>false</TreatWarningAsError>
+      <ExcludedFromBuild Condition="'$(SunriseRunClangTidy)'=='true'">true</ExcludedFromBuild>
+    </ClCompile>
+    <ClCompile Include="vendor\imgui\imgui_draw.cpp">
+      <WarningLevel>Level3</WarningLevel>
+      <TreatWarningAsError>false</TreatWarningAsError>
+      <ExcludedFromBuild Condition="'$(SunriseRunClangTidy)'=='true'">true</ExcludedFromBuild>
+    </ClCompile>
+    <ClCompile Include="vendor\imgui\imgui_tables.cpp">
+      <WarningLevel>Level3</WarningLevel>
+      <TreatWarningAsError>false</TreatWarningAsError>
+      <ExcludedFromBuild Condition="'$(SunriseRunClangTidy)'=='true'">true</ExcludedFromBuild>
+    </ClCompile>
+    <ClCompile Include="vendor\imgui\imgui_widgets.cpp">
+      <WarningLevel>Level3</WarningLevel>
+      <TreatWarningAsError>false</TreatWarningAsError>
+      <ExcludedFromBuild Condition="'$(SunriseRunClangTidy)'=='true'">true</ExcludedFromBuild>
+    </ClCompile>
+    <ClCompile Include="vendor\imgui\backends\imgui_impl_win32.cpp">
+      <WarningLevel>Level3</WarningLevel>
+      <TreatWarningAsError>false</TreatWarningAsError>
+      <ExcludedFromBuild Condition="'$(SunriseRunClangTidy)'=='true'">true</ExcludedFromBuild>
+    </ClCompile>
+    <ClCompile Include="vendor\imgui\backends\imgui_impl_dx11.cpp">
+      <WarningLevel>Level3</WarningLevel>
+      <TreatWarningAsError>false</TreatWarningAsError>
+      <ExcludedFromBuild Condition="'$(SunriseRunClangTidy)'=='true'">true</ExcludedFromBuild>
+    </ClCompile>
+    <ClCompile Include="src\steam\runtime\callbacks\callback_dispatch.cpp" />
+    <ClCompile Include="src\steam\runtime\callbacks\callback_registry.cpp" />
+    <ClCompile Include="src\steam\runtime\steam_lifecycle.cpp" />
+    <ClCompile Include="src\steam\runtime\context\steam_context_state.cpp" />
+    <ClCompile Include="src\steam\interfaces\methods\common.cpp" />
+    <ClCompile Include="src\steam\interfaces\methods\apps_dlc.cpp" />
+    <ClCompile Include="src\steam\interfaces\methods\apps_metadata.cpp" />
+    <ClCompile Include="src\steam\interfaces\methods\friends.cpp" />
+    <ClCompile Include="src\steam\interfaces\methods\signon_inputs.cpp" />
+    <ClCompile Include="src\steam\interfaces\methods\app_ticket.cpp" />
+    <ClCompile Include="src\steam\interfaces\methods\matchmaking.cpp" />
+    <ClCompile Include="src\steam\interfaces\methods\serialized_networking.cpp" />
+    <ClCompile Include="src\steam\interfaces\tables\common_tables.cpp" />
+    <ClCompile Include="src\steam\interfaces\tables\network_tables.cpp" />
+    <ClCompile Include="src\steam\interfaces\tables\table_runtime.cpp" />
+    <ClCompile Include="src\steam\interfaces\steam_interface_factory.cpp" />
+    <ClCompile Include="src\state\runtime\state_runtime.cpp" />
+    <ClCompile Include="src\state\activity\activity_session_lookup.cpp" />
+    <ClCompile Include="src\state\activity\activity_world_arrival.cpp" />
+    <ClCompile Include="src\state\activity\membership\activity_membership_query.cpp" />
+    <ClCompile Include="src\state\unlocks\unlocks_runtime.cpp" />
+    <ClCompile Include="src\state\entitlements\entitlement_defaults.cpp" />
+    <ClCompile Include="src\state\entitlements\entitlement_runtime.cpp" />
+    <ClCompile Include="src\state\entitlements\entitlement_validation.cpp" />
+    <ClCompile Include="src\state\activity\defaults\activity_defaults_snapshot.cpp" />
+    <ClCompile Include="src\state\activity\defaults\activity_defaults_validation.cpp" />
+    <ClCompile Include="src\state\activity\destination\activity_destination_snapshot.cpp" />
+    <ClCompile Include="src\state\activity\destination\activity_destination_validation.cpp" />
+    <ClCompile Include="src\state\activity\transactions\activity_session_prepare.cpp" />
+    <ClCompile Include="src\state\activity\transactions\activity_session_commit.cpp" />
+    <ClCompile Include="src\state\activity\entity_slots\transactions\activity_entity_slot_prepare.cpp" />
+    <ClCompile Include="src\state\activity\entity_slots\transactions\activity_entity_slot_commit.cpp" />
+    <ClCompile Include="src\state\activity\bubble_authority\transactions\activity_bubble_authority_grant.cpp" />
+    <ClCompile Include="src\state\activity\membership\transactions\activity_membership_prepare_base.cpp" />
+    <ClCompile Include="src\state\activity\membership\transactions\activity_membership_authoritative_prepare.cpp" />
+    <ClCompile Include="src\state\activity\membership\transactions\activity_membership_authoritative_commit.cpp" />
+    <ClCompile Include="src\state\activity\membership\transactions\activity_membership_prepare.cpp" />
+    <ClCompile Include="src\state\activity\membership\transactions\activity_membership_commit.cpp" />
+    <ClCompile Include="src\state\matchmaking\matchmaking_state.cpp" />
+    <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\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\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" />
+    <ClCompile Include="src\state\content_manifest\cache\content_manifest_cache_reader.cpp" />
+    <ClCompile Include="src\state\content_manifest\cache\content_manifest_cache_writer.cpp" />
+    <ClCompile Include="src\state\content_manifest\fingerprint\content_manifest_fingerprint.cpp" />
+    <ClCompile Include="src\state\content_manifest\content_manifest_state_runtime.cpp" />
+    <ClCompile Include="src\state\content_manifest\scanner\content_manifest_directory_scan.cpp" />
+    <ClCompile Include="src\state\content_manifest\scanner\content_manifest_header_extraction.cpp" />
+    <ClCompile Include="src\state\content_manifest\scanner\package\content_manifest_package_header.cpp" />
+    <ClCompile Include="src\state\content_manifest\scanner\package\content_manifest_package_name.cpp" />
+    <ClCompile Include="src\state\content_manifest\content_manifest_row_validation.cpp" />
+    <ClCompile Include="src\state\build_data\build_data_runtime.cpp" />
+    <ClCompile Include="src\state\build_data\runtime\persistence\build_data_persistence.cpp" />
+    <ClCompile Include="src\state\build_data\runtime\persistence\publication_transaction.cpp" />
+    <ClCompile Include="src\state\build_data\cache\read\cache_file_reader.cpp" />
+    <ClCompile Include="src\state\build_data\cache\read\cache_payload_reader.cpp" />
+    <ClCompile Include="src\state\build_data\cache\records\cache_domain_validation.cpp" />
+    <ClCompile Include="src\state\build_data\cache\records\cache_record_codec.cpp" />
+    <ClCompile Include="src\state\build_data\cache\records\cache_scenario_records.cpp" />
+    <ClCompile Include="src\state\build_data\cache\records\cache_spawn_set_records.cpp" />
+    <ClCompile Include="src\state\build_data\cache\records\cache_hash_name_records.cpp" />
+    <ClCompile Include="src\state\build_data\cache\records\cache_detail_links.cpp" />
+    <ClCompile Include="src\state\build_data\scenarios\scenario_catalog.cpp" />
+    <ClCompile Include="src\state\build_data\spawn_sets\spawn_set_catalog.cpp" />
+    <ClCompile Include="src\state\build_data\hash_names\hash_name_catalog.cpp" />
+    <ClCompile Include="src\state\activity\forced\activity_forced_destination.cpp" />
+    <ClCompile Include="src\state\build_data\runtime\build_data_roster_runtime.cpp" />
+    <ClCompile Include="src\client\content\scenarios\scenario_build.cpp" />
+    <ClCompile Include="src\client\content\scenarios\scenario_collect.cpp" />
+    <ClCompile Include="src\client\content\spawn_sets\spawn_set_build.cpp" />
+    <ClCompile Include="src\client\content\hash_names\hash_name_build.cpp" />
+    <ClCompile Include="src\client\content\hash_names\hash_name_matcher.cpp" />
+    <ClCompile Include="src\client\content\spawn_sets\spawn_set_catalog_builder.cpp" />
+    <ClCompile Include="src\client\content\scenarios\scenario_roster_build.cpp" />
+    <ClCompile Include="src\client\content\scenarios\scenario_roster_groups.cpp" />
+    <ClCompile Include="src\client\content\scenarios\scenario_roster_publish.cpp" />
+    <ClCompile Include="src\state\build_data\cache\write\cache_file_writer.cpp" />
+    <ClCompile Include="src\state\build_data\cache\write\temporary\temporary_cache_file.cpp" />
+    <ClCompile Include="src\state\build_data\cache\write\validation\cache_file_comparison.cpp" />
+    <ClCompile Include="src\state\build_data\cache\write\cache_payload_writer.cpp" />
+    <ClCompile Include="src\state\build_data\items\item_catalog.cpp" />
+    <ClCompile Include="src\state\build_data\items\item_build_data_runtime.cpp" />
+    <ClCompile Include="src\state\build_data\items\details\item_detail_catalog.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" />
+    <ClCompile Include="src\state\build_data\runtime\domain_markers.cpp" />
+    <ClCompile Include="src\state\build_data\inventory\buckets\inventory_bucket_catalog.cpp" />
+    <ClCompile Include="src\state\build_data\runtime\build_data_catalog_runtime.cpp" />
+    <ClCompile Include="src\state\build_data\named_build_data_runtime.cpp" />
+    <ClCompile Include="src\state\build_data\socket_entry_lists\socket_entry_list_catalog.cpp" />
+    <ClCompile Include="src\client\content\handles\handle_resolver.cpp" />
+    <ClCompile Include="src\client\content\activity\activity_tag_reader.cpp" />
+    <ClCompile Include="src\client\content\bootstrap\bootstrap_token_publish.cpp" />
+    <ClCompile Include="src\client\content\investment\investment_source.cpp" />
+    <ClCompile Include="src\client\content\investment\investment_refresh.cpp" />
+    <ClCompile Include="src\client\content\investment\worker\investment_refresh_worker.cpp" />
+    <ClCompile Include="src\client\content\items\details\configured_item_detail_extractor.cpp" />
+    <ClCompile Include="src\client\content\items\details\definition_detail_reader.cpp" />
+    <ClCompile Include="src\client\content\items\details\socket_detail_reader.cpp" />
+    <ClCompile Include="src\middleware\datagen\character_record\character_record_encoder.cpp" />
+    <ClCompile Include="src\middleware\datagen\character_record\appearance\character_appearance_stats.cpp" />
+    <ClCompile Include="src\middleware\datagen\character_record\appearance\character_appearance_abilities.cpp" />
+    <ClCompile Include="src\middleware\datagen\character_record\appearance\character_appearance_banks.cpp" />
+    <ClCompile Include="src\middleware\datagen\character_record\appearance\character_appearance_render.cpp" />
+    <ClCompile Include="src\middleware\datagen\character_record\appearance\character_appearance_sentinels.cpp" />
+    <ClCompile Include="src\middleware\datagen\family4\loadout\loadout_item_resolver.cpp" />
+    <ClCompile Include="src\middleware\datagen\family4\loadout\loadout_resolver.cpp" />
+    <ClCompile Include="src\middleware\datagen\family4\account\selection_patch\account_selection_patch_encoder.cpp" />
+    <ClCompile Include="src\server\runtime\server_runtime.cpp" />
+    <ClCompile Include="src\server\ui\runtime\server_ui_module_runtime.cpp" />
+    <ClCompile Include="src\server\ui\activity_override\activity_override_panel.cpp" />
+    <ClCompile Include="src\server\ui\activity_override\activity_override_lists.cpp" />
+    <ClCompile Include="src\server\transport\bap_listener.cpp" />
+    <ClCompile Include="src\server\bap\bap_route.cpp" />
+    <ClCompile Include="src\server\bap\plaintext.cpp" />
+    <ClCompile Include="src\server\bap\encrypted\encrypted_runtime.cpp" />
+    <ClCompile Include="src\server\bap\encrypted\activity_transaction\activity_transaction_notifications.cpp" />
+    <ClCompile Include="src\server\bap\encrypted\body\bap_service_body.cpp" />
+    <ClCompile Include="src\server\bap\encrypted\routing\bap_service_routing.cpp" />
+    <ClCompile Include="src\server\bap\encrypted\routing\bap_service_failure.cpp" />
+    <ClCompile Include="src\server\bap\encrypted\reply\bap_encrypted_reply.cpp" />
+    <ClCompile Include="src\server\bap\encrypted\activity_host_manager\activity_host_manager_route.cpp" />
+    <ClCompile Include="src\server\bap\encrypted\activity_message\activity_message_route.cpp" />
+    <ClCompile Include="src\server\bap\encrypted\activity_message\patch_epoch\activity_patch_epoch_route.cpp" />
+    <ClCompile Include="src\server\bap\encrypted\activity_message\membership\activity_membership_route.cpp" />
+    <ClCompile Include="src\server\bap\encrypted\matchmaking\matchmaking_route.cpp" />
+    <ClCompile Include="src\server\bap\encrypted\transactions\service_outcome_commit.cpp" />
+    <ClCompile Include="src\server\bap\encrypted\queuez\queuez_state_validation.cpp" />
+    <ClCompile Include="src\server\bap\encrypted\queuez\staging\queuez_family_staging.cpp" />
+    <ClCompile Include="src\server\bap\encrypted\queuez\staging\queuez_character_staging.cpp" />
+    <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_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" />
+    <ClCompile Include="src\server\bap\encrypted\push\activity\activity_message_push.cpp" />
+    <ClCompile Include="src\server\bap\encrypted\push\activity\activity_roster_push.cpp" />
+    <ClCompile Include="src\server\bap\encrypted\push\activity\activity_roster_snapshot.cpp" />
+    <ClCompile Include="src\server\bap\encrypted\push\activity\activity_roster_report.cpp" />
+    <ClCompile Include="src\server\bap\encrypted\push\activity\activity_keepalive_seed.cpp" />
+    <ClCompile Include="src\server\bap\encrypted\bap_connection_publication.cpp" />
+    <ClCompile Include="src\server\bap\encrypted\push\activity\activity_membership_push.cpp" />
+    <ClCompile Include="src\server\bap\encrypted\push\activity\activity_global_state_push.cpp" />
+    <ClCompile Include="src\server\bap\encrypted\push\activity\activity_keepalive_push.cpp" />
+    <ClCompile Include="src\server\bap\encrypted\push\activity\activity_notification_frame.cpp" />
+    <ClCompile Include="src\server\bap\encrypted\push\snapshot\snapshot_object_compressor.cpp" />
+    <ClCompile Include="src\server\bap\encrypted\push\snapshot\family4_selection_resolver.cpp" />
+    <ClCompile Include="src\server\bap\encrypted\push\snapshot\family4_snapshot_preparer.cpp" />
+    <ClCompile Include="src\server\bap\encrypted\push\snapshot\family4_selection_move.cpp" />
+    <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\snapshot_storage.cpp" />
+    <ClCompile Include="src\server\web_service\opcode_routes.cpp" />
+    <ClCompile Include="src\server\web_service\web_service_runtime.cpp" />
+    <ClCompile Include="src\server\http\server_http.cpp" />
+    <ClCompile Include="src\middleware\runtime\middleware_runtime.cpp" />
+    <ClCompile Include="src\middleware\protobuf\protobuf_measure.cpp" />
+    <ClCompile Include="src\middleware\protobuf\protobuf_reader.cpp" />
+    <ClCompile Include="src\middleware\protobuf\protobuf_wire.cpp" />
+    <ClCompile Include="src\middleware\protobuf\protobuf_writer.cpp" />
+    <ClCompile Include="src\middleware\bap\bap_frame.cpp" />
+    <ClCompile Include="src\middleware\bap\account_translation\account_translation_response.cpp" />
+    <ClCompile Include="src\middleware\bap\activity_host\activity_host_response.cpp" />
+    <ClCompile Include="src\middleware\bap\activity_host_manager\request\activity_manager_request.cpp" />
+    <ClCompile Include="src\middleware\bap\activity_host_manager\request\selection\activity_manager_selection_descriptor.cpp" />
+    <ClCompile Include="src\middleware\bap\activity_host_manager\request\selection\activity_manager_selection_parser.cpp" />
+    <ClCompile Include="src\middleware\bap\activity_host_manager\request\selection\activity_manager_descriptor_parser.cpp" />
+    <ClCompile Include="src\middleware\bap\activity_host_manager\request\selection\activity_manager_descriptor_reader.cpp" />
+    <ClCompile Include="src\middleware\bap\activity_host_manager\response\activity_manager_response.cpp" />
+    <ClCompile Include="src\middleware\bap\activity_message\activity_message_request_parser.cpp" />
+    <ClCompile Include="src\middleware\bap\activity_message\activity_sensor_auth_encoder.cpp" />
+    <ClCompile Include="src\middleware\bap\activity_message\activity_sensor_auth_blocks.cpp" />
+    <ClCompile Include="src\middleware\bap\activity_message\activity_sensor_auth_bodies.cpp" />
+    <ClCompile Include="src\middleware\bap\activity_message\activity_authoritative_data_parser.cpp" />
+    <ClCompile Include="src\middleware\bap\activity_message\activity_authoritative_opaque_skip.cpp" />
+    <ClCompile Include="src\middleware\bap\activity_message\activity_client_keepalive_validator.cpp" />
+    <ClCompile Include="src\middleware\bap\activity_message\activity_high_water_validator.cpp" />
+    <ClCompile Include="src\middleware\bap\activity_message\activity_join_request_parser.cpp" />
+    <ClCompile Include="src\middleware\bap\activity_message\activity_client_identity_parser.cpp" />
+    <ClCompile Include="src\middleware\bap\activity_message\activity_state_refresh_parser.cpp" />
+    <ClCompile Include="src\middleware\bap\activity_message\activity_membership_acknowledgement_parser.cpp" />
+    <ClCompile Include="src\middleware\bap\activity_message\activity_global_state_encoder.cpp" />
+    <ClCompile Include="src\middleware\bap\activity_message\activity_patch_epoch_parser.cpp" />
+    <ClCompile Include="src\middleware\bap\activity_message\activity_membership_member_writer.cpp" />
+    <ClCompile Include="src\middleware\bap\activity_message\activity_membership_region_writer.cpp" />
+    <ClCompile Include="src\middleware\bap\activity_message\activity_replicate_membership_encoder.cpp" />
+    <ClCompile Include="src\middleware\bap\activity_message\activity_message_notification_encoder.cpp" />
+    <ClCompile Include="src\middleware\bap\activity_message\activity_join_result_encoder.cpp" />
+    <ClCompile Include="src\middleware\bap\activity_message\activity_entity_slot_request_parser.cpp" />
+    <ClCompile Include="src\middleware\bap\activity_message\activity_entity_slots_decoder.cpp" />
+    <ClCompile Include="src\middleware\bap\activity_message\activity_entity_slots_encoder.cpp" />
+    <ClCompile Include="src\middleware\bap\certificate.cpp" />
+    <ClCompile Include="src\middleware\bap\client_config\client_config_response.cpp" />
+    <ClCompile Include="src\middleware\bap\family_subscription.cpp" />
+    <ClCompile Include="src\middleware\bap\family_unsubscription.cpp" />
+    <ClCompile Include="src\middleware\bap\matchmaking\request\matchmaking_request_parser.cpp" />
+    <ClCompile Include="src\middleware\bap\matchmaking\request\matchmaking_field_selection.cpp" />
+    <ClCompile Include="src\middleware\bap\matchmaking\request\matchmaking_nested_selection.cpp" />
+    <ClCompile Include="src\middleware\bap\matchmaking\response\matchmaking_response_encoder.cpp" />
+    <ClCompile Include="src\middleware\bap\matchmaking\response\matchmaking_dynamic_response.cpp" />
+    <ClCompile Include="src\middleware\bap\user_message\user_message_response.cpp" />
+    <ClCompile Include="src\middleware\compression\oodle\oodle_runtime.cpp" />
+    <ClCompile Include="src\middleware\compression\oodle\oodle_installed.cpp" />
+    <ClCompile Include="src\middleware\datagen\family3\family3_roster.cpp" />
+    <ClCompile Include="src\middleware\datagen\family4\account\account_encoder.cpp" />
+    <ClCompile Include="src\middleware\datagen\family4\progression\progression_bank_keys.cpp" />
+    <ClCompile Include="src\middleware\datagen\family4\account\preferences\preferences_encoder.cpp" />
+    <ClCompile Include="src\middleware\datagen\family4\character\character_encoder.cpp" />
+    <ClCompile Include="src\middleware\datagen\family4\character\equipment_summary_builder.cpp" />
+    <ClCompile Include="src\middleware\datagen\family4\instance\instance_encoder.cpp" />
+    <ClCompile Include="src\middleware\datagen\roster\queuez_roster.cpp" />
+    <ClCompile Include="src\middleware\content\packages\named_tags\named_tag_parser.cpp" />
+    <ClCompile Include="src\middleware\content\packages\named_tags\named_tag_file.cpp" />
+    <ClCompile Include="src\middleware\content\packages\named_tags\named_tag_patch.cpp" />
+    <ClCompile Include="src\middleware\content\packages\package_catalog.cpp" />
+    <ClCompile Include="src\middleware\content\packages\reader\package_class_scan.cpp" />
+    <ClCompile Include="src\middleware\content\packages\reader\package_parallel_read.cpp" />
+    <ClCompile Include="src\middleware\content\packages\reader\package_entry_reader.cpp" />
+    <ClCompile Include="src\middleware\content\packages\reader\package_header.cpp" />
+    <ClCompile Include="src\middleware\content\packages\tables\bubble_state_reader.cpp" />
+    <ClCompile Include="src\middleware\content\packages\tables\definition_index_table.cpp" />
+    <ClCompile Include="src\middleware\content\packages\tables\item_definition_reader.cpp" />
+    <ClCompile Include="src\middleware\content\packages\tables\region_reader.cpp" />
+    <ClCompile Include="src\middleware\content\packages\tables\roster_intersection.cpp" />
+    <ClCompile Include="src\middleware\content\packages\tables\scenario_reader.cpp" />
+    <ClCompile Include="src\middleware\content\packages\tables\scenario_walk.cpp" />
+    <ClCompile Include="src\middleware\content\packages\tables\slot_descriptor_reader.cpp" />
+    <ClCompile Include="src\middleware\content\packages\tables\spawn_reader.cpp" />
+    <ClCompile Include="src\middleware\content\packages\tables\component_container_reader.cpp" />
+    <ClCompile Include="src\middleware\content\packages\tables\item_appearance_reader.cpp" />
+    <ClCompile Include="src\middleware\content\packages\tables\ability_pool_reader.cpp" />
+    <ClCompile Include="src\client\content\items\packages\package_item_build.cpp" />
+    <ClCompile Include="src\client\content\items\packages\package_detail_build.cpp" />
+    <ClCompile Include="src\client\content\items\packages\package_ability_build.cpp" />
+    <ClCompile Include="src\client\content\items\packages\package_subclass_build.cpp" />
+    <ClCompile Include="src\client\content\items\packages\package_progression_build.cpp" />
+    <ClCompile Include="src\middleware\content\packages\reader\package_file_locator.cpp" />
+    <ClCompile Include="src\middleware\crypto\aes_gcm_decrypt.cpp" />
+    <ClCompile Include="src\middleware\content\manifest\content_manifest_encoder.cpp" />
+    <ClCompile Include="src\middleware\content\manifest\manifest_entitlement_encoder.cpp" />
+    <ClCompile Include="src\middleware\encoding\bit_writer.cpp" />
+    <ClCompile Include="src\middleware\encoding\bit_reader.cpp" />
+    <ClCompile Include="src\middleware\queuez\queuez_update.cpp" />
+    <ClCompile Include="src\middleware\web_service\web_service_envelope.cpp" />
+    <ClCompile Include="src\middleware\web_service\status_fields.cpp" />
+    <ClCompile Include="src\middleware\web_service\messages\family5\family5_codec.cpp" />
+    <ClCompile Include="src\middleware\web_service\messages\opcode205\opcode205_codec.cpp" />
+    <ClCompile Include="src\middleware\web_service\messages\opcode503\opcode503_codec.cpp" />
+    <ClCompile Include="src\middleware\web_service\messages\opcode206_codec.cpp" />
+    <ClCompile Include="src\middleware\web_service\messages\opcode501_codec.cpp" />
+    <ClCompile Include="src\middleware\web_service\messages\opcode505\opcode505_codec.cpp" />
+    <ClCompile Include="src\core\settings\state_settings.cpp" />
+    <ClCompile Include="src\middleware\secure_channel\envelope.cpp" />
+    <ClCompile Include="src\middleware\secure_channel\encrypted_frame.cpp" />
+    <ClCompile Include="src\middleware\signon\response.cpp" />
+    <ClCompile Include="src\middleware\signon\extended\signon_extended_fields.cpp" />
+    <ClCompile Include="src\middleware\signon\config\signon_config_blob.cpp" />
+    <ClCompile Include="src\middleware\signon\ownership\signon_ownership_encoder.cpp" />
+    <ClCompile Include="src\server\transport\bap_peer_session.cpp" />
+    <ClCompile Include="src\client\hooks\queuez\family0\family0_source_seed.cpp" />
+    <ClCompile Include="src\middleware\content\packages\reader\package_locator_cache.cpp" />
+    <ClCompile Include="src\middleware\content\packages\reader\package_block_cache.cpp" />
+    <ClCompile Include="src\middleware\content\packages\reader\package_handle_cache.cpp" />
+    <ClCompile Include="src\middleware\content\packages\reader\package_table_cache.cpp" />
+    <ClCompile Include="src\state\build_data\runtime\build_data_routing_catalogs.cpp" />
+    <ClCompile Include="src\state\build_data\cache\records\cache_investment_records.cpp" />
+    <ClCompile Include="src\server\bap\encrypted\push\snapshot\family4_object_staging.cpp" />
+    <ClCompile Include="src\core\settings\state\account_rows_parser.cpp" />
+    <ClCompile Include="src\server\bap\encrypted\push\queuez\queuez_banner_push.cpp" />
+    <ClCompile Include="src\server\bap\encrypted\push\queuez\queuez_push_reporting.cpp" />
+    <ClCompile Include="src\client\content\items\packages\package_container_location.cpp" />
+    <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\module_range.cpp" />
+    <ClCompile Include="src\client\process\freeze\client_process_freeze.cpp" />
+  </ItemGroup>
+  <ItemGroup Condition="'$(SunriseRunClangTidy)'=='true'">
+    <ClCompile Remove="vendor\detours\detours.cpp" />
+    <ClCompile Remove="vendor\detours\disasm.cpp" />
+    <ClCompile Remove="vendor\detours\modules.cpp" />
+    <ClCompile Remove="vendor\imgui\imgui.cpp" />
+    <ClCompile Remove="vendor\imgui\imgui_draw.cpp" />
+    <ClCompile Remove="vendor\imgui\imgui_tables.cpp" />
+    <ClCompile Remove="vendor\imgui\imgui_widgets.cpp" />
+    <ClCompile Remove="vendor\imgui\backends\imgui_impl_win32.cpp" />
+    <ClCompile Remove="vendor\imgui\backends\imgui_impl_dx11.cpp" />
+  </ItemGroup>
+  <ItemGroup>
+    <ClInclude Include="resources\resource.h" />
+    <ClInclude Include="src\core\logging\log.h" />
+    <ClInclude Include="src\core\logging\snapshot\snapshot.h" />
+    <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\filesystem\temporary_sibling.h" />
+    <ClInclude Include="src\core\settings\settings.h" />
+    <ClInclude Include="src\core\settings\parser.h" />
+    <ClInclude Include="src\core\settings\client\definition.h" />
+    <ClInclude Include="src\core\settings\server\definition.h" />
+    <ClInclude Include="src\core\settings\steam\definition.h" />
+    <ClInclude Include="src\core\runtime\core_runtime.h" />
+    <ClInclude Include="src\core\ui\imgui_user_config.h" />
+    <ClInclude Include="src\core\ui\modules\ui_module_descriptor.h" />
+    <ClInclude Include="src\core\ui\modules\registry\ui_module_registry.h" />
+    <ClInclude Include="src\core\ui\modules\logs\logs.h" />
+    <ClInclude Include="src\client\hooks\cursor\cursor_guard_replacements.h" />
+    <ClInclude Include="src\client\hooks\cursor\runtime.h" />
+    <ClInclude Include="src\client\hooks\polled_input\polled_input_replacements.h" />
+    <ClInclude Include="src\client\hooks\polled_input\runtime.h" />
+    <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\core\ui\busy\busy.h" />
+    <ClInclude Include="src\core\ui\busy\ui_busy_state.h" />
+    <ClInclude Include="src\core\ui\notice\ui_notice_overlay.h" />
+    <ClInclude Include="src\core\ui\modules\logs\internal.h" />
+    <ClInclude Include="src\core\ui\modules\logs\filters\logs_filter_controls.h" />
+    <ClInclude Include="src\core\ui\runtime\ui_visibility_runtime.h" />
+    <ClInclude Include="src\core\ui\runtime\settings.h" />
+    <ClInclude Include="src\core\ui\memory\allocator.h" />
+    <ClInclude Include="src\core\ui\memory\fixed\internal.h" />
+    <ClInclude Include="src\core\ui\animation\animation.h" />
+    <ClInclude Include="src\core\ui\animation\transition\ui_transition_animation.h" />
+    <ClInclude Include="src\core\ui\components\components.h" />
+    <ClInclude Include="src\core\ui\components\card\ui_card_component.h" />
+    <ClInclude Include="src\core\ui\components\drawing\drawing.h" />
+    <ClInclude Include="src\core\ui\components\filter\ui_filter_component.h" />
+    <ClInclude Include="src\core\ui\components\picker\ui_picker_component.h" />
+    <ClInclude Include="src\core\ui\components\navigation\ui_navigation_component.h" />
+    <ClInclude Include="src\core\ui\components\section\ui_section_component.h" />
+    <ClInclude Include="src\core\ui\components\toggle\ui_toggle_component.h" />
+    <ClInclude Include="src\core\ui\fonts\fonts.h" />
+    <ClInclude Include="src\core\ui\fonts\installed\ui_installed_font_reader.h" />
+    <ClInclude Include="src\core\ui\fonts\runtime\ui_runtime_font_lifecycle.h" />
+    <ClInclude Include="src\core\ui\scaling\scaling.h" />
+    <ClInclude Include="src\core\ui\scaling\dpi\ui_dpi_scaling.h" />
+    <ClInclude Include="src\core\ui\theme\sunrise_ui_theme.h" />
+    <ClInclude Include="src\core\ui\layout\layout.h" />
+    <ClInclude Include="src\core\ui\layout\ui_layout_lifecycle.h" />
+    <ClInclude Include="src\core\ui\layout\navigation\ui_layout_navigation.h" />
+    <ClInclude Include="src\core\ui\layout\credits\sunrise_credits_badge.h" />
+    <ClInclude Include="src\client\runtime\runtime.h" />
+    <ClInclude Include="src\client\teleport\teleport_settings_store.h" />
+    <ClInclude Include="src\client\hooks\teleport\internal.h" />
+    <ClInclude Include="src\client\hooks\teleport\runtime.h" />
+    <ClInclude Include="src\client\ui\teleport\teleport_panel.h" />
+    <ClInclude Include="src\client\runtime\internal.h" />
+    <ClInclude Include="src\client\runtime\host\game_host_classification.h" />
+    <ClInclude Include="src\client\hooks\graphics\graphics_hook_lifecycle.h" />
+    <ClInclude Include="src\client\hooks\graphics\graphics_hook_replacements.h" />
+    <ClInclude Include="src\client\hooks\graphics\input\input.h" />
+    <ClInclude Include="src\client\hooks\graphics\renderer\renderer.h" />
+    <ClInclude Include="src\client\hooks\graphics\renderer\state.h" />
+    <ClInclude Include="src\client\ui\runtime\client_ui_module_runtime.h" />
+    <ClInclude Include="src\client\memory\current_process_memory.h" />
+    <ClInclude Include="src\client\content\handles\layout.h" />
+    <ClInclude Include="src\client\content\handles\handle_resolver.h" />
+    <ClInclude Include="src\client\content\activity\source.h" />
+    <ClInclude Include="src\client\content\scenarios\scenario_build.h" />
+    <ClInclude Include="src\client\content\scenarios\internal.h" />
+    <ClInclude Include="src\client\content\spawn_sets\spawn_set_build.h" />
+    <ClInclude Include="src\client\content\hash_names\hash_name_build.h" />
+    <ClInclude Include="src\client\content\hash_names\hash_name_matcher.h" />
+    <ClInclude Include="src\client\content\spawn_sets\spawn_set_catalog_builder.h" />
+    <ClInclude Include="src\client\content\investment\layout.h" />
+    <ClInclude Include="src\client\content\investment\internal.h" />
+    <ClInclude Include="src\client\content\investment\source.h" />
+    <ClInclude Include="src\client\content\investment\worker.h" />
+    <ClInclude Include="src\client\content\items\layout.h" />
+    <ClInclude Include="src\client\content\items\details\configured_item_detail_extractor.h" />
+    <ClInclude Include="src\client\content\items\details\layout.h" />
+    <ClInclude Include="src\client\content\items\details\memory.h" />
+    <ClInclude Include="src\client\content\items\details\definition_detail_reader.h" />
+    <ClInclude Include="src\client\content\items\details\relative.h" />
+    <ClInclude Include="src\client\content\items\details\socket_detail_reader.h" />
+    <ClInclude Include="src\client\network\consumer.h" />
+    <ClInclude Include="src\client\hooking\detour.h" />
+    <ClInclude Include="src\client\hooking\detour\transaction\detour_thread_transaction.h" />
+    <ClInclude Include="src\client\executable\image.h" />
+    <ClInclude Include="src\client\hooks\network\investment\investment_derived_rebuild.h" />
+    <ClInclude Include="src\client\hooks\network\runtime.h" />
+    <ClInclude Include="src\client\hooks\network\bubble_authority\bubble_authority_replacements.h" />
+    <ClInclude Include="src\client\hooks\network\bubble_authority\scope\bubble_authority_scope.h" />
+    <ClInclude Include="src\client\hooks\network\coordinator\network_call_coordinator.h" />
+    <ClInclude Include="src\client\hooks\network\content_config\coordinator\content_config_call_coordinator.h" />
+    <ClInclude Include="src\client\hooks\network\content_config\gate\content_config_signature_gate.h" />
+    <ClInclude Include="src\client\hooks\network\content_config\internal.h" />
+    <ClInclude Include="src\client\hooks\network\content_config\lifecycle\content_config_hook_storage.h" />
+    <ClInclude Include="src\client\hooks\network\content_config\protocol.h" />
+    <ClInclude Include="src\client\hooks\network\content_config\runtime.h" />
+    <ClInclude Include="src\client\hooks\network\platform.h" />
+    <ClInclude Include="src\client\hooks\network\network_call_quiescence.h" />
+    <ClInclude Include="src\client\hooks\network\http\internal.h" />
+    <ClInclude Include="src\client\hooks\network\lifecycle\network_hook_group.h" />
+    <ClInclude Include="src\client\hooks\network\lifecycle\network_hook_entries.h" />
+    <ClInclude Include="src\client\hooks\egress\dns\egress_dns_replacements.h" />
+    <ClInclude Include="src\client\hooks\egress\extensions\egress_extension_replacements.h" />
+    <ClInclude Include="src\client\hooks\egress\internal.h" />
+    <ClInclude Include="src\client\hooks\egress\platform\abi.h" />
+    <ClInclude Include="src\client\hooks\egress\platform\sdk.h" />
+    <ClInclude Include="src\client\hooks\egress\policy\policy.h" />
+    <ClInclude Include="src\client\hooks\egress\resolver\replacements.h" />
+    <ClInclude Include="src\client\hooks\egress\runtime.h" />
+    <ClInclude Include="src\client\hooks\egress\winsock\replacements.h" />
+    <ClInclude Include="src\client\hooks\egress\winsock\connection\egress_connection_replacements.h" />
+    <ClInclude Include="src\client\hooks\egress\winsock\control\egress_control_replacement.h" />
+    <ClInclude Include="src\client\hooks\egress\winsock\discovery\egress_discovery_responder.h" />
+    <ClInclude Include="src\client\hooks\egress\winsock\reception\egress_reception_replacements.h" />
+    <ClInclude Include="src\client\hooks\egress\winsock\transmission\replacements.h" />
+    <ClInclude Include="src\client\patterns\registry.h" />
+    <ClInclude Include="src\client\patterns\signature_text.h" />
+    <ClInclude Include="src\client\patterns\game.h" />
+    <ClInclude Include="src\client\patterns\steam.h" />
+    <ClInclude Include="src\client\targets\steam_targets.h" />
+    <ClInclude Include="src\client\targets\game.h" />
+    <ClInclude Include="src\client\targets\game\content.h" />
+    <ClInclude Include="src\client\targets\game\network.h" />
+    <ClInclude Include="src\client\targets\game\network\network_content_derivation.h" />
+    <ClInclude Include="src\client\targets\game\relative.h" />
+    <ClInclude Include="src\client\targets\game\resolution\internal.h" />
+    <ClInclude Include="src\client\targets\game\resolution\game_target_resolution.h" />
+    <ClInclude Include="src\steam\runtime\runtime.h" />
+    <ClInclude Include="src\steam\runtime\callbacks\callback_registry.h" />
+    <ClInclude Include="src\steam\runtime\internal.h" />
+    <ClInclude Include="src\steam\interfaces\steam_interface_factory.h" />
+    <ClInclude Include="src\steam\interfaces\internal.h" />
+    <ClInclude Include="src\steam\interfaces\tables\internal.h" />
+    <ClInclude Include="src\state\unlocks\definition.h" />
+    <ClInclude Include="src\state\unlocks\unlocks_runtime.h" />
+    <ClInclude Include="src\state\runtime\runtime.h" />
+    <ClInclude Include="src\state\runtime\state.h" />
+    <ClInclude Include="src\state\runtime\storage\internal.h" />
+    <ClInclude Include="src\state\activity\definition.h" />
+    <ClInclude Include="src\state\activity\defaults\definition.h" />
+    <ClInclude Include="src\state\activity\defaults\activity_defaults_snapshot.h" />
+    <ClInclude Include="src\state\activity\defaults\activity_defaults_validation.h" />
+    <ClInclude Include="src\state\activity\destination\definition.h" />
+    <ClInclude Include="src\state\activity\forced\definition.h" />
+    <ClInclude Include="src\state\activity\forced\activity_forced_destination.h" />
+    <ClInclude Include="src\state\activity\destination\activity_destination_snapshot.h" />
+    <ClInclude Include="src\state\activity\destination\activity_destination_validation.h" />
+    <ClInclude Include="src\state\activity\entity_slots\definition.h" />
+    <ClInclude Include="src\state\activity\entity_slots\runtime.h" />
+    <ClInclude Include="src\state\activity\entity_slots\transactions\internal.h" />
+    <ClInclude Include="src\state\activity\bubble_authority\definition.h" />
+    <ClInclude Include="src\state\activity\bubble_authority\runtime.h" />
+    <ClInclude Include="src\state\activity\bubble_authority\transactions\internal.h" />
+    <ClInclude Include="src\state\activity\membership\definition.h" />
+    <ClInclude Include="src\state\activity\membership\activity_membership_query.h" />
+    <ClInclude Include="src\state\activity\membership\transactions\internal.h" />
+    <ClInclude Include="src\state\activity\runtime.h" />
+    <ClInclude Include="src\state\activity\transactions\internal.h" />
+    <ClInclude Include="src\state\matchmaking\definition.h" />
+    <ClInclude Include="src\state\matchmaking\matchmaking_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\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\key_bindings.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" />
+    <ClInclude Include="src\state\investment\investment.h" />
+    <ClInclude Include="src\state\content\content_catalog.h" />
+    <ClInclude Include="src\state\content_manifest\cache\format.h" />
+    <ClInclude Include="src\state\content_manifest\cache\internal.h" />
+    <ClInclude Include="src\state\content_manifest\definition.h" />
+    <ClInclude Include="src\state\content_manifest\fingerprint\content_manifest_fingerprint.h" />
+    <ClInclude Include="src\state\content_manifest\content_manifest_state_runtime.h" />
+    <ClInclude Include="src\state\content_manifest\scanner\internal.h" />
+    <ClInclude Include="src\state\content_manifest\scanner\package\content_manifest_package_header.h" />
+    <ClInclude Include="src\state\content_manifest\scanner\package\content_manifest_package_name.h" />
+    <ClInclude Include="src\state\content_manifest\content_manifest_row_validation.h" />
+    <ClInclude Include="src\state\build_data\definition.h" />
+    <ClInclude Include="src\state\build_data\runtime.h" />
+    <ClInclude Include="src\state\build_data\table.h" />
+    <ClInclude Include="src\state\build_data\scenarios\scenario_catalog.h" />
+    <ClInclude Include="src\state\build_data\spawn_sets\spawn_set_catalog.h" />
+    <ClInclude Include="src\state\build_data\hash_names\hash_name_catalog.h" />
+    <ClInclude Include="src\state\build_data\hash_names\definition.h" />
+    <ClInclude Include="src\state\build_data\spawn_sets\definition.h" />
+    <ClInclude Include="src\state\build_data\scenarios\definition.h" />
+    <ClInclude Include="src\state\build_data\runtime\persistence\build_data_persistence.h" />
+    <ClInclude Include="src\state\build_data\runtime\persistence\publication_transaction.h" />
+    <ClInclude Include="src\state\build_data\cache\internal.h" />
+    <ClInclude Include="src\state\build_data\cache\read\cache_payload_reader.h" />
+    <ClInclude Include="src\state\build_data\cache\records\codec.h" />
+    <ClInclude Include="src\state\build_data\cache\records\domains.h" />
+    <ClInclude Include="src\state\build_data\cache\records\format.h" />
+    <ClInclude Include="src\state\build_data\cache\records\validation.h" />
+    <ClInclude Include="src\state\build_data\cache\write\cache_payload_writer.h" />
+    <ClInclude Include="src\state\build_data\cache\write\temporary\temporary_cache_file.h" />
+    <ClInclude Include="src\state\build_data\cache\write\validation\cache_file_comparison.h" />
+    <ClInclude Include="src\state\build_data\items\item_catalog.h" />
+    <ClInclude Include="src\state\build_data\items\details\definition.h" />
+    <ClInclude Include="src\state\build_data\items\details\item_detail_catalog.h" />
+    <ClInclude Include="src\state\build_data\inventory\buckets\definition.h" />
+    <ClInclude Include="src\state\build_data\inventory\buckets\inventory_bucket_catalog.h" />
+    <ClInclude Include="src\state\build_data\socket_entry_lists\definition.h" />
+    <ClInclude Include="src\state\build_data\socket_entry_lists\socket_entry_list_catalog.h" />
+    <ClInclude Include="src\state\build_data\runtime\build_data_catalog_runtime.h" />
+    <ClInclude Include="src\state\build_data\runtime\domain_markers.h" />
+    <ClInclude Include="src\server\transport\bap_listener.h" />
+    <ClInclude Include="src\server\runtime\server_runtime.h" />
+    <ClInclude Include="src\server\ui\runtime\server_ui_module_runtime.h" />
+    <ClInclude Include="src\server\ui\activity_override\activity_override_panel.h" />
+    <ClInclude Include="src\server\ui\activity_override\activity_override_lists.h" />
+    <ClInclude Include="src\server\bap\runtime.h" />
+    <ClInclude Include="src\server\bap\internal.h" />
+    <ClInclude Include="src\server\bap\encrypted\internal.h" />
+    <ClInclude Include="src\server\bap\encrypted\activity_transaction\activity_transaction_notifications.h" />
+    <ClInclude Include="src\server\bap\encrypted\activity_host_manager\activity_host_manager_route.h" />
+    <ClInclude Include="src\server\bap\encrypted\activity_message\definition.h" />
+    <ClInclude Include="src\server\bap\encrypted\activity_message\activity_message_route.h" />
+    <ClInclude Include="src\server\bap\encrypted\activity_message\patch_epoch\activity_patch_epoch_route.h" />
+    <ClInclude Include="src\server\bap\encrypted\activity_message\membership\activity_membership_route.h" />
+    <ClInclude Include="src\server\bap\encrypted\matchmaking\matchmaking_route.h" />
+    <ClInclude Include="src\server\bap\encrypted\transactions\definition.h" />
+    <ClInclude Include="src\server\bap\encrypted\transactions\service_outcome_commit.h" />
+    <ClInclude Include="src\server\bap\encrypted\queuez\definition.h" />
+    <ClInclude Include="src\server\bap\encrypted\queuez\queuez_state_validation.h" />
+    <ClInclude Include="src\server\bap\encrypted\queuez\queuez_outcome_staging.h" />
+    <ClInclude Include="src\server\bap\encrypted\queuez\staging\queuez_family_staging.h" />
+    <ClInclude Include="src\server\bap\encrypted\push\queuez\queuez_update_frame.h" />
+    <ClInclude Include="src\server\bap\encrypted\push\activity\activity_message_push.h" />
+    <ClInclude Include="src\server\bap\encrypted\push\activity\activity_roster_push.h" />
+    <ClInclude Include="src\server\bap\encrypted\push\activity\activity_global_state_push.h" />
+    <ClInclude Include="src\server\bap\encrypted\push\activity\activity_keepalive_push.h" />
+    <ClInclude Include="src\server\bap\encrypted\bap_connection_publication.h" />
+    <ClInclude Include="src\server\bap\encrypted\push\activity\activity_membership_push.h" />
+    <ClInclude Include="src\server\bap\encrypted\push\activity\activity_notification_frame.h" />
+    <ClInclude Include="src\server\bap\encrypted\push\activity\activity_arrival.h" />
+    <ClInclude Include="src\server\bap\encrypted\push\activity\internal.h" />
+    <ClInclude Include="src\server\bap\encrypted\push\snapshot\internal.h" />
+    <ClInclude Include="src\server\bap\encrypted\push\snapshot\snapshot.h" />
+    <ClInclude Include="src\server\bap\encrypted\push\snapshot\snapshot_storage.h" />
+    <ClInclude Include="src\server\web_service\opcode_routes.h" />
+    <ClInclude Include="src\server\web_service\web_service_runtime.h" />
+    <ClInclude Include="src\server\http\server_http.h" />
+    <ClInclude Include="src\middleware\runtime\middleware_runtime.h" />
+    <ClInclude Include="src\middleware\protobuf\codec.h" />
+    <ClInclude Include="src\middleware\protobuf\protobuf_wire.h" />
+    <ClInclude Include="src\middleware\bap\frame.h" />
+    <ClInclude Include="src\middleware\bap\account_translation\account_translation_response.h" />
+    <ClInclude Include="src\middleware\bap\activity_host\activity_host_response.h" />
+    <ClInclude Include="src\middleware\bap\activity_host_manager\definition.h" />
+    <ClInclude Include="src\middleware\bap\activity_host_manager\request\activity_manager_request.h" />
+    <ClInclude Include="src\middleware\bap\activity_host_manager\request\selection\definition.h" />
+    <ClInclude Include="src\middleware\bap\activity_host_manager\request\selection\internal.h" />
+    <ClInclude Include="src\middleware\bap\activity_host_manager\request\selection\activity_manager_selection_parser.h" />
+    <ClInclude Include="src\middleware\bap\activity_host_manager\response\activity_manager_response.h" />
+    <ClInclude Include="src\middleware\bap\activity_message\definition.h" />
+    <ClInclude Include="src\middleware\bap\activity_message\activity_message_request_parser.h" />
+    <ClInclude Include="src\middleware\bap\activity_message\sensor_auth_update.h" />
+    <ClInclude Include="src\middleware\bap\activity_message\activity_global_state_encoder.h" />
+    <ClInclude Include="src\middleware\bap\activity_message\client_authoritative_data.h" />
+    <ClInclude Include="src\middleware\bap\activity_message\activity_client_keepalive_validator.h" />
+    <ClInclude Include="src\middleware\bap\activity_message\activity_high_water_validator.h" />
+    <ClInclude Include="src\middleware\bap\activity_message\activity_join_request_parser.h" />
+    <ClInclude Include="src\middleware\bap\activity_message\activity_client_identity_parser.h" />
+    <ClInclude Include="src\middleware\bap\activity_message\activity_state_refresh_parser.h" />
+    <ClInclude Include="src\middleware\bap\activity_message\activity_membership_acknowledgement_parser.h" />
+    <ClInclude Include="src\middleware\bap\activity_message\activity_patch_epoch_parser.h" />
+    <ClInclude Include="src\middleware\bap\activity_message\replicate_membership.h" />
+    <ClInclude Include="src\middleware\bap\activity_message\activity_message_notification_encoder.h" />
+    <ClInclude Include="src\middleware\bap\activity_message\activity_join_result_encoder.h" />
+    <ClInclude Include="src\middleware\bap\activity_message\activity_entity_slot_request_parser.h" />
+    <ClInclude Include="src\middleware\bap\activity_message\entity_slots.h" />
+    <ClInclude Include="src\middleware\bap\certificate.h" />
+    <ClInclude Include="src\middleware\bap\client_config\client_config_response.h" />
+    <ClInclude Include="src\middleware\bap\family_subscription.h" />
+    <ClInclude Include="src\middleware\bap\family_unsubscription.h" />
+    <ClInclude Include="src\middleware\bap\matchmaking\definition.h" />
+    <ClInclude Include="src\middleware\bap\matchmaking\request\matchmaking_request_parser.h" />
+    <ClInclude Include="src\middleware\bap\matchmaking\request\field_selection.h" />
+    <ClInclude Include="src\middleware\bap\matchmaking\response\matchmaking_response_encoder.h" />
+    <ClInclude Include="src\middleware\bap\matchmaking\response\matchmaking_dynamic_response.h" />
+    <ClInclude Include="src\middleware\bap\user_message\user_message_response.h" />
+    <ClInclude Include="src\middleware\queuez\subscription.h" />
+    <ClInclude Include="src\middleware\compression\oodle\runtime.h" />
+    <ClInclude Include="src\middleware\datagen\character_record\layout.h" />
+    <ClInclude Include="src\middleware\datagen\character_record\character_record_encoder.h" />
+    <ClInclude Include="src\middleware\datagen\family3\family3_roster.h" />
+    <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" />
+    <ClInclude Include="src\middleware\datagen\family4\loadout\loadout_resolver.h" />
+    <ClInclude Include="src\middleware\datagen\family4\loadout\loadout_item_resolver.h" />
+    <ClInclude Include="src\middleware\datagen\family4\loadout\subclass_socket_selection.h" />
+    <ClInclude Include="src\middleware\datagen\family4\character\character_encoder.h" />
+    <ClInclude Include="src\middleware\datagen\family4\character\equipment_summary_builder.h" />
+    <ClInclude Include="src\middleware\datagen\family4\character\layout.h" />
+    <ClInclude Include="src\middleware\datagen\family4\instance\abi.h" />
+    <ClInclude Include="src\middleware\datagen\family4\instance\instance_encoder.h" />
+    <ClInclude Include="src\middleware\datagen\family4\instance\layout.h" />
+    <ClInclude Include="src\middleware\datagen\family4\inventory\layout.h" />
+    <ClInclude Include="src\middleware\datagen\family4\progression\layout.h" />
+    <ClInclude Include="src\middleware\datagen\roster\queuez_roster.h" />
+    <ClInclude Include="src\middleware\content\packages\named_tags.h" />
+    <ClInclude Include="src\middleware\content\packages\named_tags\internal.h" />
+    <ClInclude Include="src\middleware\content\packages\package_catalog.h" />
+    <ClInclude Include="src\middleware\content\manifest\encoder.h" />
+    <ClInclude Include="src\middleware\encoding\bit_writer.h" />
+    <ClInclude Include="src\middleware\encoding\bit_reader.h" />
+    <ClInclude Include="src\middleware\encoding\byte_order.h" />
+    <ClInclude Include="src\middleware\queuez\queuez_update.h" />
+    <ClInclude Include="src\middleware\web_service\web_service_envelope.h" />
+    <ClInclude Include="src\middleware\web_service\status_fields.h" />
+    <ClInclude Include="src\middleware\web_service\messages\family5\family5_codec.h" />
+    <ClInclude Include="src\middleware\web_service\messages\opcode205.h" />
+    <ClInclude Include="src\middleware\web_service\messages\opcode503.h" />
+    <ClInclude Include="src\middleware\web_service\messages\opcode206.h" />
+    <ClInclude Include="src\middleware\web_service\messages\opcode501_codec.h" />
+    <ClInclude Include="src\middleware\web_service\messages\opcode505\opcode505_codec.h" />
+    <ClInclude Include="src\middleware\datagen\family4\account\selection_patch\account_selection_patch_encoder.h" />
+    <ClInclude Include="src\middleware\secure_channel\runtime.h" />
+    <ClInclude Include="src\middleware\signon\response.h" />
+    <ClInclude Include="vendor\detours\detours.h" />
+    <ClInclude Include="vendor\imgui\imconfig.h" />
+    <ClInclude Include="vendor\imgui\imgui.h" />
+    <ClInclude Include="vendor\imgui\imgui_internal.h" />
+    <ClInclude Include="vendor\imgui\imstb_rectpack.h" />
+    <ClInclude Include="vendor\imgui\imstb_textedit.h" />
+    <ClInclude Include="vendor\imgui\imstb_truetype.h" />
+    <ClInclude Include="vendor\imgui\backends\imgui_impl_win32.h" />
+    <ClInclude Include="vendor\imgui\backends\imgui_impl_dx11.h" />
+    <ClInclude Include="src\client\targets\game\retail_log.h" />
+    <ClInclude Include="src\client\patterns\game\retail_log\retail_log_signature_bytes.h" />
+    <ClInclude Include="src\client\hooks\retail_log\retail_log_enqueue_observer.h" />
+    <ClInclude Include="src\client\hooks\retail_log\retail_log_lifecycle.h" />
+    <ClInclude Include="src\client\hooks\egress\resolver\redirect.h" />
+    <ClInclude Include="src\core\settings\client\external\definition.h" />
+    <ClInclude Include="src\client\hooks\external_server\internal.h" />
+    <ClInclude Include="src\client\hooks\external_server\route.h" />
+    <ClInclude Include="src\client\content\bootstrap\bootstrap_token_publish.h" />
+    <ClInclude Include="src\steam\runtime\context\steam_context_state.h" />
+    <ClInclude Include="src\client\content\diagnostics\content_readiness_report.h" />
+    <ClInclude Include="src\client\content\investment\runtime.h" />
+    <ClInclude Include="src\client\content\items\packages\build.h" />
+    <ClInclude Include="src\client\content\items\packages\internal.h" />
+    <ClInclude Include="src\client\hooks\assert_handler\assert_handler_observer.h" />
+    <ClInclude Include="src\client\hooks\assert_handler\assert_handler_lifecycle.h" />
+    <ClInclude Include="src\client\hooks\bitmap\bitmap_ref_guard.h" />
+    <ClInclude Include="src\client\hooks\bitmap\bitmap_hook_lifecycle.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\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" />
+    <ClInclude Include="src\client\hooks\config_getter\config_getter_lifecycle.h" />
+    <ClInclude Include="src\client\hooks\egress\lifecycle\internal.h" />
+    <ClInclude Include="src\client\hooks\egress\policy\egress_policy_logging.h" />
+    <ClInclude Include="src\client\hooks\network\investment\internal.h" />
+    <ClInclude Include="src\client\hooks\queuez\internal.h" />
+    <ClInclude Include="src\client\hooks\queuez\queuez_hook_lifecycle.h" />
+    <ClInclude Include="src\client\patterns\game\assert_handler\assert_signature_bytes.h" />
+    <ClInclude Include="src\client\patterns\game\config_getter\config_getter_signature_bytes.h" />
+    <ClInclude Include="src\client\patterns\game\packages\package_key_signature_bytes.h" />
+    <ClInclude Include="src\client\patterns\game\signon\signon_readiness_signature_bytes.h" />
+    <ClInclude Include="src\middleware\content\packages\tables\spawn_reader.h" />
+    <ClInclude Include="src\middleware\content\packages\tables\component_container_reader.h" />
+    <ClInclude Include="src\client\patterns\image_scan.h" />
+    <ClInclude Include="src\client\targets\game\assert_handler\game_assert_targets.h" />
+    <ClInclude Include="src\client\targets\game\assert_handler.h" />
+    <ClInclude Include="src\client\targets\game\config_getter\game_config_getter_targets.h" />
+    <ClInclude Include="src\client\targets\game\config_getter.h" />
+    <ClInclude Include="src\client\targets\game\packages\game_package_targets.h" />
+    <ClInclude Include="src\client\targets\game\packages.h" />
+    <ClInclude Include="src\middleware\content\packages\reader\internal.h" />
+    <ClInclude Include="src\middleware\content\packages\reader\layout.h" />
+    <ClInclude Include="src\middleware\content\packages\reader\reader.h" />
+    <ClInclude Include="src\middleware\content\packages\reader\parallel.h" />
+    <ClInclude Include="src\middleware\content\packages\reader\block_cache.h" />
+    <ClInclude Include="src\middleware\content\packages\reader\handle_cache.h" />
+    <ClInclude Include="src\middleware\content\packages\reader\package_table_cache.h" />
+    <ClInclude Include="src\middleware\content\packages\tables\ability_pool_reader.h" />
+    <ClInclude Include="src\middleware\content\packages\tables\bubble_state_reader.h" />
+    <ClInclude Include="src\middleware\content\packages\tables\internal.h" />
+    <ClInclude Include="src\middleware\content\packages\tables\items.h" />
+    <ClInclude Include="src\middleware\content\packages\tables\region_reader.h" />
+    <ClInclude Include="src\middleware\content\packages\tables\roster_intersection.h" />
+    <ClInclude Include="src\middleware\content\packages\tables\scenario_reader.h" />
+    <ClInclude Include="src\middleware\content\packages\tables\slot_descriptor_reader.h" />
+    <ClInclude Include="src\middleware\content\packages\tables\definition_index_table.h" />
+    <ClInclude Include="src\middleware\content\packages\tables\scenario_walk.h" />
+    <ClInclude Include="src\middleware\crypto\aes_gcm_decrypt.h" />
+    <ClInclude Include="src\middleware\datagen\character_record\appearance\internal.h" />
+    <ClInclude Include="src\middleware\datagen\definitions.h" />
+    <ClInclude Include="src\middleware\datagen\family4\progression\progression_bank_keys.h" />
+    <ClInclude Include="src\middleware\signon\config\signon_config_blob.h" />
+    <ClInclude Include="src\middleware\signon\extended\signon_extended_fields.h" />
+    <ClInclude Include="src\middleware\signon\internal.h" />
+    <ClInclude Include="src\middleware\signon\ownership\signon_ownership_encoder.h" />
+    <ClInclude Include="src\state\build_data\abilities\ability_bucket_catalog.h" />
+    <ClInclude Include="src\state\build_data\abilities\definition.h" />
+    <ClInclude Include="src\state\build_data\constants\investment_constant_catalog.h" />
+    <ClInclude Include="src\state\build_data\constants\definition.h" />
+    <ClInclude Include="src\state\build_data\progressions\progression_catalog.h" />
+    <ClInclude Include="src\state\build_data\progressions\definition.h" />
+    <ClInclude Include="src\state\entitlements\definition.h" />
+    <ClInclude Include="src\state\entitlements\entitlement_runtime.h" />
+    <ClInclude Include="src\state\entitlements\validation.h" />
+    <ClInclude Include="src\server\transport\internal.h" />
+    <ClInclude Include="src\client\hooks\queuez\family0\family0_source_seed.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\module_range.h" />
+    <ClInclude Include="src\client\process\freeze\client_process_freeze.h" />
+  </ItemGroup>
+  <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
+</Project>

Файловите разлики са ограничени, защото са твърде много
+ 67 - 0
Sunrise/resources/default_settings.json


+ 16 - 0
Sunrise/resources/resource.h

@@ -0,0 +1,16 @@
+#pragma once
+
+/** The first module-local RCDATA identifier owns the bundled default settings document. */
+#define IDR_DEFAULT_SETTINGS 101
+/** The next module-local RCDATA identifier embeds the required Dear ImGui MIT notice. */
+#define IDR_IMGUI_LICENSE 102
+/** The next module-local RCDATA identifier embeds the required Microsoft Detours notice. */
+#define IDR_DETOURS_LICENSE 103
+
+/** The four numeric fields of the version resource, in FILEVERSION order. */
+#define SUNRISE_VER_MAJOR 0
+#define SUNRISE_VER_MINOR 1
+#define SUNRISE_VER_PATCH 0
+#define SUNRISE_VER_BUILD 0
+/** The same version as display text. Windows shows this string, not the four fields. */
+#define SUNRISE_VER_STRING "0.1.0.0"

+ 36 - 0
Sunrise/resources/sunrise.rc

@@ -0,0 +1,36 @@
+#include <winresrc.h>
+
+#include "resource.h"
+
+IDR_DEFAULT_SETTINGS RCDATA "default_settings.json"
+IDR_IMGUI_LICENSE RCDATA "..\\vendor\\imgui\\LICENSE.txt"
+IDR_DETOURS_LICENSE RCDATA "..\\vendor\\detours\\LICENSE.md"
+
+VS_VERSION_INFO VERSIONINFO
+FILEVERSION SUNRISE_VER_MAJOR, SUNRISE_VER_MINOR, SUNRISE_VER_PATCH, SUNRISE_VER_BUILD
+PRODUCTVERSION SUNRISE_VER_MAJOR, SUNRISE_VER_MINOR, SUNRISE_VER_PATCH, SUNRISE_VER_BUILD
+FILEFLAGSMASK VS_FFI_FILEFLAGSMASK
+FILEFLAGS 0x0L
+FILEOS VOS_NT_WINDOWS32
+FILETYPE VFT_DLL
+FILESUBTYPE VFT2_UNKNOWN
+BEGIN
+    BLOCK "StringFileInfo"
+    BEGIN
+        // 0409 is US English and 04B0 is UTF-16, which is the pair the VarFileInfo block declares.
+        BLOCK "040904B0"
+        BEGIN
+            VALUE "FileDescription", "Sunrise"
+            VALUE "FileVersion", SUNRISE_VER_STRING
+            VALUE "InternalName", "Sunrise"
+            // The shipped file carries the name the game delay-imports, not the module's own.
+            VALUE "OriginalFilename", "steam_api64.dll"
+            VALUE "ProductName", "Sunrise"
+            VALUE "ProductVersion", SUNRISE_VER_STRING
+        END
+    END
+    BLOCK "VarFileInfo"
+    BEGIN
+        VALUE "Translation", 0x409, 1200
+    END
+END

+ 3 - 0
Sunrise/src/client/content/activity/activity_tag_reader.cpp

@@ -0,0 +1,3 @@
+#include "source.h"
+
+namespace sunrise::client::content::activity {} // namespace sunrise::client::content::activity

+ 26 - 0
Sunrise/src/client/content/activity/source.h

@@ -0,0 +1,26 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+#include "../../../middleware/content/packages/reader/reader.h"
+#include "../../../middleware/content/packages/tables/scenario_walk.h"
+
+namespace sunrise::client::content::activity {
+
+namespace packages = sunrise::middleware::content::packages;
+
+/** One buffer per read slot. */
+inline constexpr std::size_t kSlotCount =
+    static_cast<std::size_t>(packages::tables::ReadSlot::count);
+
+/** Lock-owned storage for one scenario walk. */
+struct ScenarioSource {
+    const packages::reader::Source* source{};
+    packages::reader::Scratch scratch{};
+    std::array<std::vector<std::byte>, kSlotCount> slots{};
+};
+
+} // namespace sunrise::client::content::activity

+ 47 - 0
Sunrise/src/client/content/bootstrap/bootstrap_token_publish.cpp

@@ -0,0 +1,47 @@
+#include "bootstrap_token_publish.h"
+
+#include <Windows.h>
+
+#include <array>
+#include <cstddef>
+
+#include "../../../core/logging/log.h"
+#include "../../../state/runtime/runtime.h"
+#include "../../targets/game.h"
+
+namespace sunrise::client::content::bootstrap {
+
+/** Copies the bootstrap content-id token out of the installed client into State. */
+bool publish_token() noexcept {
+    const std::byte* const source = targets::game::network::get().contentIdToken;
+    if (source == nullptr) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::error,
+                         "ev=bootstrap stage=token result=fail reason=target");
+        return false;
+    }
+    std::array<std::byte, state::kBootstrapTokenSize> token{};
+    __try {
+        for (std::size_t index = 0; index < token.size(); ++index) {
+            token[index] = source[index];
+        }
+    } __except (EXCEPTION_EXECUTE_HANDLER) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::error,
+                         "ev=bootstrap stage=token result=fail reason=read");
+        return false;
+    }
+    if (!state::publish_bootstrap_token(token)) {
+        SecureZeroMemory(token.data(), token.size());
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::error,
+                         "ev=bootstrap stage=token result=fail reason=publish");
+        return false;
+    }
+    SecureZeroMemory(token.data(), token.size());
+    core::log::write(
+        core::log::Channel::client, core::log::Level::info, "ev=bootstrap stage=token result=ok");
+    return true;
+}
+
+} // namespace sunrise::client::content::bootstrap

+ 8 - 0
Sunrise/src/client/content/bootstrap/bootstrap_token_publish.h

@@ -0,0 +1,8 @@
+#pragma once
+
+namespace sunrise::client::content::bootstrap {
+
+/** Publishes the installed client's bootstrap content-id token into State. */
+[[nodiscard]] bool publish_token() noexcept;
+
+} // namespace sunrise::client::content::bootstrap

+ 71 - 0
Sunrise/src/client/content/diagnostics/content_readiness_report.cpp

@@ -0,0 +1,71 @@
+#include "content_readiness_report.h"
+
+#include <array>
+#include <atomic>
+#include <cstdio>
+
+#include "../../../core/logging/log.h"
+#include "../../../state/build_data/runtime.h"
+
+namespace sunrise::client::content::diagnostics {
+namespace {
+
+/** How many build-data domains the cache-write report covers. */
+constexpr std::size_t kDomainCount = 8;
+
+/** One bit per build-data domain, in report order. */
+[[nodiscard]] unsigned readiness_mask() noexcept {
+    const std::array<bool, kDomainCount> ready{
+        state::build_data::named_catalog_ready(),
+        state::build_data::item_definitions_ready(),
+        state::build_data::configured_item_details_ready(),
+        state::build_data::inventory_bucket_descriptors_ready(),
+        state::build_data::socket_entry_lists_ready(),
+        state::build_data::ability_buckets_ready(),
+        state::build_data::progression_definitions_ready(),
+        state::build_data::scenario_layouts_ready(),
+    };
+    unsigned mask = 0;
+    for (std::size_t index = 0; index < ready.size(); ++index) {
+        mask |= static_cast<unsigned>(ready[index]) << index;
+    }
+    return mask;
+}
+
+/** Sentinel for a mask that has never been reported. */
+constexpr unsigned kUnreported = ~0U;
+/** One line covers all eight flags. */
+constexpr std::size_t kLineCapacity = 192;
+
+std::atomic<unsigned> g_reported{kUnreported};
+
+} // namespace
+
+/** Reports build-data domain readiness, once per change. */
+void report_readiness() noexcept {
+    const unsigned mask = readiness_mask();
+    if (g_reported.exchange(mask, std::memory_order_relaxed) == mask) {
+        return;
+    }
+    std::array<char, kLineCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=build_data stage=domains named=%u items=%u details=%u "
+                                      "buckets=%u sockets=%u abilities=%u progressions=%u "
+                                      "scenarios=%u",
+                                      mask & 1U,
+                                      (mask >> 1U) & 1U,
+                                      (mask >> 2U) & 1U,
+                                      (mask >> 3U) & 1U,
+                                      (mask >> 4U) & 1U,
+                                      (mask >> 5U) & 1U,
+                                      (mask >> 6U) & 1U,
+                                      (mask >> 7U) & 1U);
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::info,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+} // namespace sunrise::client::content::diagnostics

+ 8 - 0
Sunrise/src/client/content/diagnostics/content_readiness_report.h

@@ -0,0 +1,8 @@
+#pragma once
+
+namespace sunrise::client::content::diagnostics {
+
+/** Reports build-data domain readiness, once per change. */
+void report_readiness() noexcept;
+
+} // namespace sunrise::client::content::diagnostics

+ 138 - 0
Sunrise/src/client/content/handles/handle_resolver.cpp

@@ -0,0 +1,138 @@
+#include "handle_resolver.h"
+
+#include <limits>
+
+#include "layout.h"
+
+namespace sunrise::client::content::handles {
+namespace {
+
+/** The 13 low handle bits pick one record within its table. */
+constexpr std::uint32_t kRecordIndexBits = 13;
+/** The record-index mask covers all 13 encoded low bits. */
+constexpr std::uint32_t kRecordIndexMask = (1U << kRecordIndexBits) - 1U;
+/** The top handle bit picks the extended 14-bit datum-table namespace. */
+constexpr std::uint32_t kHandleSignBit = 1U << ((std::numeric_limits<std::uint32_t>::digits) - 1U);
+/** Explicit sign-extension bits copy the datum selector's arithmetic shift. */
+constexpr std::uint32_t kSignedHighFill =
+    ~((std::numeric_limits<std::uint32_t>::max)() >> kRecordIndexBits);
+/** The encoded table sum fills the fixed high guard bits before shifting. */
+constexpr std::uint64_t kTableMaskFill = 0x0FFC0000ULL;
+/** The datum selector stores its table-width mask above bit 17. */
+constexpr unsigned kTableMaskShift = 18;
+/** High-bit datum handles hold a 14-bit descriptor-table id. */
+constexpr unsigned kHighBitTableIndexBits = 14;
+/** Content and schema datum tables are registered from descriptor id 1024. */
+constexpr std::uint64_t kMinimumContentTableIndex = 1024;
+/** 14 selector bits bound the process handle-table descriptor array. */
+constexpr std::uint64_t kMaximumTableIndex = (1ULL << kHighBitTableIndexBits) - 1ULL;
+/** All-one handle bits mean the loaded content is not there. */
+constexpr std::uint32_t kUnavailableContentHandle = (std::numeric_limits<std::uint32_t>::max)();
+
+/**
+ * Adds two address parts without wrapping the address space.
+ * @param left Base address.
+ * @param right Byte offset.
+ * @param result Receives the whole address.
+ * @return True when the sum fits.
+ */
+[[nodiscard]] bool
+add_fits(std::uintptr_t left, std::uintptr_t right, std::uintptr_t& result) noexcept {
+    if (right > (std::numeric_limits<std::uintptr_t>::max)() - left) {
+        return false;
+    }
+    result = left + right;
+    return true;
+}
+
+/**
+ * Multiplies two address parts without wrapping.
+ * @param result Receives the product.
+ * @return True when the product fits.
+ */
+[[nodiscard]] bool
+multiply_fits(std::uintptr_t left, std::uintptr_t right, std::uintptr_t& result) noexcept {
+    if (left != 0 && right > (std::numeric_limits<std::uintptr_t>::max)() / left) {
+        return false;
+    }
+    result = left * right;
+    return true;
+}
+
+/**
+ * Reads one whole scalar through the caller's bounded memory source.
+ * @tparam Value Trivially copied scalar or layout object.
+ * @param value Receives the whole value.
+ * @return True when the callback reads every byte.
+ */
+template <typename Value>
+[[nodiscard]] bool read_value(const Source& source, std::uintptr_t address, Value& value) noexcept {
+    return source.read != nullptr
+           && source.read(source.context,
+                          address,
+                          std::span(reinterpret_cast<std::byte*>(&value), sizeof value));
+}
+
+} // namespace
+
+/** Finds one loaded content handle without holding on to a game pointer. */
+bool resolve(const Source& source, std::uint32_t handle, std::uintptr_t& address) noexcept {
+    address = 0;
+    if (source.tablesSlot == 0 || source.read == nullptr || handle == kUnavailableContentHandle) {
+        return false;
+    }
+
+    std::uintptr_t tablesObject = 0;
+    std::uintptr_t tableBase = 0;
+    if (!read_value(source, source.tablesSlot, tablesObject) || tablesObject == 0
+        || !read_value(source, tablesObject, tableBase) || tableBase == 0) {
+        return false;
+    }
+
+    // Explicit sign extension keeps content table 1024 from aliasing generic table zero.
+    const std::uint32_t encodedHigh =
+        (handle >> kRecordIndexBits) | (((handle & kHandleSignBit) != 0) ? kSignedHighFill : 0U);
+    const std::uint64_t tableMask =
+        (static_cast<std::uint64_t>(encodedHigh) | kTableMaskFill) >> kTableMaskShift;
+    const std::uint64_t tableIndex = static_cast<std::uint16_t>(encodedHigh) & tableMask;
+    if (tableIndex < kMinimumContentTableIndex || tableIndex > kMaximumTableIndex) {
+        return false;
+    }
+
+    std::uintptr_t tableOffset = 0;
+    std::uintptr_t tableAddress = 0;
+    if (!multiply_fits(
+            static_cast<std::uintptr_t>(tableIndex), sizeof(layout::TableDescriptor), tableOffset)
+        || !add_fits(tableBase, tableOffset, tableAddress)) {
+        return false;
+    }
+
+    layout::TableDescriptor table{};
+    if (!read_value(source, tableAddress, table) || table.recordArray == 0
+        || table.recordStride == 0) {
+        return false;
+    }
+
+    const std::uint32_t recordIndex = handle & kRecordIndexMask;
+    std::uintptr_t recordOffset = 0;
+    std::uintptr_t recordAddress = 0;
+    layout::RecordPrefix record{};
+    // The record offset is a 32-bit ABI product, so an overflowing one is rejected.
+    if (!multiply_fits(recordIndex, table.recordStride, recordOffset)
+        || recordOffset > (std::numeric_limits<std::uint32_t>::max)()
+        || !add_fits(table.recordArray, recordOffset, recordAddress)
+        || !read_value(source, recordAddress, record)) {
+        return false;
+    }
+
+    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;
+    }
+    address = recordAddress - static_cast<std::uintptr_t>(correction);
+    return address != 0;
+}
+
+} // namespace sunrise::client::content::handles

+ 30 - 0
Sunrise/src/client/content/handles/handle_resolver.h

@@ -0,0 +1,30 @@
+#pragma once
+
+#include <cstdint>
+#include <span>
+
+namespace sunrise::client::content::handles {
+
+/** Memory-reader callback used by live and synthetic handle sources. */
+using Reader = bool (*)(void* context,
+                        std::uintptr_t address,
+                        std::span<std::byte> output) noexcept;
+
+/** Runtime addresses needed to find one loaded content handle. */
+struct Source {
+    std::uintptr_t tablesSlot{};
+    void* context{};
+    Reader read{};
+};
+
+/**
+ * Finds one loaded content handle without holding on to a game pointer.
+ * @param source Bounded runtime-memory source.
+ * @param handle Encoded schema or package-content handle.
+ * @param address Receives the loaded definition address.
+ * @return True when every table field is read and the result is nonzero.
+ */
+[[nodiscard]] bool
+resolve(const Source& source, std::uint32_t handle, std::uintptr_t& address) noexcept;
+
+} // namespace sunrise::client::content::handles

+ 47 - 0
Sunrise/src/client/content/handles/layout.h

@@ -0,0 +1,47 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <type_traits>
+
+namespace sunrise::client::content::handles::layout {
+
+/** A loaded handle-table descriptor keeps 32 opaque middle bytes. */
+inline constexpr std::size_t kDescriptorMiddleByteCount = 32;
+/** A loaded handle-table descriptor keeps 8 opaque trailing bytes. */
+inline constexpr std::size_t kDescriptorTailByteCount = 8;
+/** One loaded handle-table descriptor is 64 bytes. */
+inline constexpr std::size_t kTableDescriptorSize = 64;
+/** One loaded handle record holds its correction source in a 16-byte prefix. */
+inline constexpr std::size_t kRecordPrefixSize = 16;
+
+/** Runtime descriptor for one encoded loaded-content handle table. */
+struct TableDescriptor {
+    std::uint64_t opaque00{};
+    std::uintptr_t recordArray{};
+    std::array<std::byte, kDescriptorMiddleByteCount> opaque16{};
+    std::uint32_t recordStride{};
+    std::int32_t correctionMask{};
+    std::array<std::byte, kDescriptorTailByteCount> opaque56{};
+};
+
+/** Runtime prefix used to recover one loaded definition address. */
+struct RecordPrefix {
+    std::uint64_t opaque00{};
+    std::uint64_t correctionSource{};
+};
+
+static_assert(sizeof(std::uintptr_t) == sizeof(std::uint64_t));
+static_assert(offsetof(TableDescriptor, recordArray) == sizeof(std::uint64_t));
+static_assert(offsetof(TableDescriptor, recordStride)
+              == sizeof(std::uint64_t) + sizeof(std::uintptr_t) + kDescriptorMiddleByteCount);
+static_assert(offsetof(TableDescriptor, correctionMask)
+              == offsetof(TableDescriptor, recordStride) + sizeof(std::uint32_t));
+static_assert(offsetof(RecordPrefix, correctionSource) == sizeof(std::uint64_t));
+static_assert(sizeof(TableDescriptor) == kTableDescriptorSize);
+static_assert(sizeof(RecordPrefix) == kRecordPrefixSize);
+static_assert(std::is_trivially_copyable_v<TableDescriptor>);
+static_assert(std::is_trivially_copyable_v<RecordPrefix>);
+
+} // namespace sunrise::client::content::handles::layout

+ 216 - 0
Sunrise/src/client/content/hash_names/hash_name_build.cpp

@@ -0,0 +1,216 @@
+#include "hash_name_build.h"
+
+#include <Windows.h>
+
+#include <array>
+#include <cstdio>
+#include <span>
+#include <string_view>
+#include <vector>
+
+#include "../../../core/logging/log.h"
+#include "../../../middleware/content/packages/named_tags.h"
+#include "../../../middleware/content/packages/reader/parallel.h"
+#include "../../../state/build_data/runtime.h"
+#include "hash_name_matcher.h"
+
+namespace sunrise::client::content::hash_names {
+namespace {
+
+namespace packages = middleware::content::packages;
+namespace reader = middleware::content::packages::reader;
+
+/**
+ * Tag class of a named installed object.
+ * Its blobs end with the object's own id, and a bubble name is usually one token of it.
+ * This class gives all but a handful of the names the packages can produce.
+ */
+constexpr std::uint32_t kNamedObjectClass = 0x80809C36U;
+
+/** @param context Pass storage. @param tag Swept tag. @return True when the tag fits. */
+[[nodiscard]] bool collect_tag(void* context, std::uint32_t tag) noexcept {
+    return add_tag(*static_cast<Storage*>(context), tag);
+}
+
+/** Rows one reader fills. Only the reader holding that index ever writes them. */
+using Rows = std::array<names_state::Name, names_state::kNameCapacity>;
+
+/**
+ * One row set per reader, kept across batches so a reader builds up its own best names.
+ * Each is a quarter of a megabyte, so they are allocated rather than declared. The pass frees
+ * them when it publishes.
+ */
+std::vector<Rows> g_rows{};
+
+/** What the readers need to reach the pass. Every reader reads it and none writes it. */
+struct BatchContext {
+    Storage* storage{};
+};
+
+/** @param storage Pass storage. @param workers Readers wanted. @return True when rows are ready. */
+[[nodiscard]] bool prepare_rows(const Storage& storage, std::size_t workers) noexcept {
+    if (storage.targetCount > names_state::kNameCapacity) {
+        return false;
+    }
+    if (g_rows.size() < workers) {
+        g_rows.resize(workers);
+    }
+    return g_rows.size() >= workers;
+}
+
+/** Frees the reader rows. */
+void release_rows() noexcept {
+    g_rows.clear();
+    g_rows.shrink_to_fit();
+}
+
+/**
+ * Matches one blob on the reader thread that read it.
+ * It touches the pass's read-only targets and the rows of its own reader index. No two readers
+ * meet, so nothing here locks.
+ * @param worker Reader index.
+ * @param blob Whole object bytes.
+ */
+void collect_blob(void* context,
+                  std::size_t worker,
+                  std::uint32_t,
+                  std::span<const std::byte> blob) noexcept {
+    const Storage& storage = *static_cast<BatchContext*>(context)->storage;
+    if (worker >= g_rows.size()) {
+        return;
+    }
+    MatchState state{{storage.targets.data(), storage.targetCount},
+                     {g_rows[worker].data(), storage.targetCount}};
+    offer_blob(state, blob);
+}
+
+/**
+ * Offers one package directory name, which costs no blob read.
+ * @param context Pass storage.
+ * @param entry Named definition.
+ * @return Always true, so one unusable name cannot stop the directory pass.
+ */
+[[nodiscard]] bool collect_named(void* context, const packages::named_tags::Entry& entry) noexcept {
+    auto& storage = *static_cast<Storage*>(context);
+    MatchState state = match_state(storage);
+    const std::string_view name(entry.name.data(), entry.nameLength);
+    std::size_t start = 0;
+    for (std::size_t index = 0; index <= name.size(); ++index) {
+        // A definition name is a path of ids, so each piece is offered on its own.
+        if (index != name.size() && state::build_data::hash_names::name_character(name[index])) {
+            continue;
+        }
+        if (index > start) {
+            offer(state, name.substr(start, index - start));
+        }
+        start = index + 1;
+    }
+    return true;
+}
+
+/**
+ * Reports the pass so a boot with no bubble names says which step lost them.
+ * @param storage Pass storage holding every count.
+ * @param result Outcome text for the log line.
+ */
+void report(const Storage& storage, const char* result) noexcept {
+    std::array<char, core::log::kLineCapacity> line{};
+    const std::uint64_t elapsed =
+        storage.startedTick == 0 ? 0 : GetTickCount64() - storage.startedTick;
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=build_data stage=hash_names hashes=%zu objects=%zu "
+                                      "read=%zu named=%zu ms=%llu result=%s",
+                                      storage.targetCount,
+                                      storage.tagCount,
+                                      storage.cursor,
+                                      storage.resolvedCount,
+                                      static_cast<unsigned long long>(elapsed),
+                                      result);
+    if (written > 0) {
+        core::log::write(core::log::Channel::state,
+                         storage.resolvedCount != 0 ? core::log::Level::info
+                                                    : core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/**
+ * Publishes whatever the pass has found.
+ * @param storage Pass storage.
+ * @param result Outcome text for the log line.
+ * @return True when the table published.
+ */
+[[nodiscard]] bool publish(Storage& storage, const char* result) noexcept {
+    finish(storage);
+    const bool published = state::build_data::publish_hash_names(
+        std::span(storage.resolved).first(storage.resolvedCount));
+    report(storage, published ? result : "publish");
+    return published;
+}
+
+} // namespace
+
+/** Finds the internal name of every destination bubble it can, once. */
+bool build(const reader::Source& source, reader::Scratch&) noexcept {
+    if (state::build_data::hash_names_ready()) {
+        return true;
+    }
+    // The destination layouts and the spawn-set catalogue carry the hashes to look for, so there
+    // is nothing to do before both of them.
+    if (!state::build_data::scenario_layouts_ready() || !state::build_data::spawn_sets_ready()) {
+        return false;
+    }
+    static Storage storage{};
+    if (!storage.collected) {
+        reset(storage);
+        storage.startedTick = GetTickCount64();
+        if (!collect_targets(storage)) {
+            report(storage, "targets");
+            reset(storage);
+            return false;
+        }
+        // The package directory names cost no blob read, so they are taken first and a pass that
+        // cannot sweep still publishes them.
+        packages::named_tags::DirectoryResult directory{};
+        (void)packages::named_tags::extract_directory(
+            source.directory, &collect_named, &storage, directory);
+        reader::ScanResult scan{};
+        if (!reader::scan_class(source.directory, kNamedObjectClass, &collect_tag, &storage, scan)
+            || scan.matches != storage.tagCount) {
+            return publish(storage, "sweep");
+        }
+        storage.collected = true;
+        report(storage, "collected");
+        return false;
+    }
+
+    // The readers are what make this affordable: a block waits out the disk before it decodes,
+    // and one reader per share overlaps those waits. The call returns once all of them have
+    // joined, so nothing outlives the batch.
+    const std::size_t workers = reader::parallel::worker_count();
+    if (!prepare_rows(storage, workers)) {
+        return publish(storage, "readers");
+    }
+    const std::size_t batch = (std::min)(kBatchTags, storage.tagCount - storage.cursor);
+    BatchContext context{&storage};
+    if (!reader::parallel::read_tags(
+            source, {storage.tags.data() + storage.cursor, batch}, &collect_blob, &context)) {
+        return publish(storage, "readers");
+    }
+    for (std::size_t worker = 0; worker < workers; ++worker) {
+        merge(storage, {g_rows[worker].data(), storage.targetCount});
+    }
+    storage.cursor += batch;
+    if (storage.cursor < storage.tagCount) {
+        return false;
+    }
+    const bool published = publish(storage, "ok");
+    if (published) {
+        release_rows();
+        reader::parallel::release();
+    }
+    return published;
+}
+
+} // namespace sunrise::client::content::hash_names

+ 18 - 0
Sunrise/src/client/content/hash_names/hash_name_build.h

@@ -0,0 +1,18 @@
+#pragma once
+
+#include "../../../middleware/content/packages/reader/reader.h"
+
+namespace sunrise::client::content::hash_names {
+
+/**
+ * Finds the internal name of every destination bubble it can, once.
+ * A bubble stores only the hash of its name, so names are recovered by hashing the ids the
+ * installed objects carry. Runs after the destination layouts publish, which give the hashes.
+ * @param source Package directory and borrowed block keys.
+ * @param scratch Lock-owned block storage shared with the item build.
+ * @return True when State already holds the table or a full pass publishes it.
+ */
+[[nodiscard]] bool build(const middleware::content::packages::reader::Source& source,
+                         middleware::content::packages::reader::Scratch& scratch) noexcept;
+
+} // namespace sunrise::client::content::hash_names

+ 217 - 0
Sunrise/src/client/content/hash_names/hash_name_matcher.cpp

@@ -0,0 +1,217 @@
+#include "hash_name_matcher.h"
+
+#include <algorithm>
+
+#include "../../../state/build_data/runtime.h"
+
+namespace sunrise::client::content::hash_names {
+namespace {
+
+namespace layouts = state::build_data::scenarios;
+namespace spawn_state = state::build_data::spawn_sets;
+
+/** FNV-1 32-bit offset basis. The engine hashes names with FNV-1, not FNV-1a. */
+constexpr std::uint32_t kHashBasis = 0x811C9DC5U;
+/** FNV-1 32-bit prime. */
+constexpr std::uint32_t kHashPrime = 0x01000193U;
+
+/** @return The FNV-1 hash of text, which is what a bubble carries. */
+[[nodiscard]] std::uint32_t hash_of(std::string_view text) noexcept {
+    std::uint32_t value = kHashBasis;
+    for (const char character : text) {
+        value = (value * kHashPrime) ^ static_cast<std::uint8_t>(character);
+    }
+    return value;
+}
+
+/**
+ * Records one candidate against its target row.
+ * @param state One reader's targets and rows.
+ * @param text Candidate id that hashes to a target.
+ * @param slot Target row index.
+ */
+void keep(MatchState& state, std::string_view text, std::size_t slot) noexcept {
+    names_state::Name& row = state.resolved[slot];
+    // The shortest candidate is the bare name; a longer one is a suffix of some object's name.
+    if (row.nameLength != 0 && row.nameLength <= text.size()) {
+        return;
+    }
+    row = {};
+    row.hash = state.targets[slot];
+    std::copy_n(text.begin(), text.size(), row.name.begin());
+    row.nameLength = static_cast<std::uint8_t>(text.size());
+}
+
+/**
+ * Finds one hash in the ascending target set.
+ * @param state One reader's targets and rows.
+ * @param slot Receives the target row index.
+ * @return True when the hash is a target.
+ */
+[[nodiscard]] bool
+target_slot(const MatchState& state, std::uint32_t hash, std::size_t& slot) noexcept {
+    const auto first = state.targets.begin();
+    const auto last = state.targets.end();
+    const auto found = std::lower_bound(first, last, hash);
+    if (found == last || *found != hash) {
+        return false;
+    }
+    slot = static_cast<std::size_t>(found - first);
+    return true;
+}
+
+} // namespace
+
+/** Clears one extraction pass without freeing process-owned outer storage. */
+void reset(Storage& storage) noexcept {
+    storage.targets.fill(0);
+    storage.targetCount = 0;
+    storage.resolved.fill(names_state::Name{});
+    storage.resolvedCount = 0;
+    storage.tagCount = 0;
+    storage.cursor = 0;
+    storage.collected = false;
+    storage.invalid = false;
+}
+
+/** Records every hash the published domains declare: bubble names and spawn-set names. */
+bool collect_targets(Storage& storage) noexcept {
+    storage.targets.fill(0);
+    storage.targetCount = 0;
+    // One whole domain of fixed rows, so the snapshot is static rather than a local.
+    static std::array<layouts::Definition, layouts::kDefinitionCapacity> scratch{};
+    std::size_t rows = 0;
+    if (!state::build_data::snapshot_scenario_layouts(scratch, rows)) {
+        return false;
+    }
+    for (std::size_t row = 0; row < rows; ++row) {
+        const layouts::Definition& layout = scratch[row];
+        const std::size_t declared =
+            (std::min)(static_cast<std::size_t>(layout.bubbleCount), layout.bubbleHashes.size());
+        for (std::size_t bubble = 0; bubble < declared; ++bubble) {
+            const std::uint32_t hash = layout.bubbleHashes[bubble];
+            if (hash == 0 || storage.targetCount >= storage.targets.size()) {
+                continue;
+            }
+            storage.targets[storage.targetCount++] = hash;
+        }
+    }
+    // The spawn sets name themselves the same way, and this pass already reads every blob that
+    // could carry one, so their hashes ride along at no extra read.
+    static std::array<spawn_state::NameHash, spawn_state::kNameHashCapacity> spawns{};
+    std::size_t spawnCount = 0;
+    if (state::build_data::snapshot_spawn_name_hashes(spawns, spawnCount)) {
+        for (std::size_t row = 0; row < spawnCount; ++row) {
+            if (spawns[row].value != 0 && storage.targetCount < storage.targets.size()) {
+                storage.targets[storage.targetCount++] = spawns[row].value;
+            }
+        }
+    }
+    auto end = storage.targets.begin() + static_cast<std::ptrdiff_t>(storage.targetCount);
+    std::sort(storage.targets.begin(), end);
+    storage.targetCount = static_cast<std::size_t>(std::unique(storage.targets.begin(), end)
+                                                   - storage.targets.begin());
+    return storage.targetCount != 0;
+}
+
+/** Offers one id to the pass, keeping the shortest name that gives a target hash. */
+void offer(MatchState& state, std::string_view text) noexcept {
+    if (text.empty() || text.size() > kRunCapacity) {
+        return;
+    }
+    // Token boundaries, so every underscore-bounded prefix and suffix can be tried.
+    std::array<std::size_t, kTokenCapacity> starts{};
+    std::size_t tokens = 0;
+    starts[tokens++] = 0;
+    for (std::size_t index = 0; index < text.size() && tokens < starts.size(); ++index) {
+        if (text[index] == '_') {
+            starts[tokens++] = index + 1;
+        }
+    }
+    for (std::size_t first = 0; first < tokens; ++first) {
+        for (std::size_t last = tokens; last > first; --last) {
+            const std::size_t begin = starts[first];
+            // A suffix run ends at the token separator before the next start, or at the end.
+            const std::size_t end = last == tokens ? text.size() : starts[last] - 1;
+            if (end <= begin) {
+                continue;
+            }
+            const std::string_view candidate = text.substr(begin, end - begin);
+            std::size_t slot = 0;
+            if (target_slot(state, hash_of(candidate), slot)) {
+                keep(state, candidate, slot);
+            }
+        }
+    }
+}
+
+/** Offers every id inside one blob. */
+void offer_blob(MatchState& state, std::span<const std::byte> blob) noexcept {
+    std::size_t start = 0;
+    std::size_t length = 0;
+    for (std::size_t index = 0; index <= blob.size(); ++index) {
+        const char character = index < blob.size() ? static_cast<char>(blob[index]) : '\0';
+        if (index < blob.size() && names_state::name_character(character)) {
+            if (length == 0) {
+                start = index;
+            }
+            ++length;
+            continue;
+        }
+        if (length != 0 && length <= kRunCapacity) {
+            offer(state,
+                  std::string_view(reinterpret_cast<const char*>(blob.data()) + start, length));
+        }
+        length = 0;
+    }
+}
+
+/** @param storage Pass storage. @return Its targets and its own rows. */
+MatchState match_state(Storage& storage) noexcept {
+    return MatchState{{storage.targets.data(), storage.targetCount},
+                      {storage.resolved.data(), storage.targetCount}};
+}
+
+/** Folds one reader's rows into the pass, keeping the shortest name for every target. */
+void merge(Storage& storage, std::span<const names_state::Name> rows) noexcept {
+    const std::size_t count = (std::min)(rows.size(), storage.targetCount);
+    for (std::size_t slot = 0; slot < count; ++slot) {
+        const names_state::Name& candidate = rows[slot];
+        names_state::Name& row = storage.resolved[slot];
+        if (candidate.nameLength == 0
+            || (row.nameLength != 0 && row.nameLength <= candidate.nameLength)) {
+            continue;
+        }
+        row = candidate;
+    }
+}
+
+/** Adds one swept tag to an unfinished pass. */
+bool add_tag(Storage& storage, std::uint32_t tag) noexcept {
+    if (storage.collected || storage.invalid || tag == 0) {
+        return false;
+    }
+    if (storage.tagCount >= storage.tags.size()) {
+        storage.invalid = true;
+        return false;
+    }
+    storage.tags[storage.tagCount++] = tag;
+    return true;
+}
+
+/** Packs the resolved rows into ascending hash order for publishing. */
+void finish(Storage& storage) noexcept {
+    std::size_t kept = 0;
+    for (std::size_t row = 0; row < storage.targetCount; ++row) {
+        if (storage.resolved[row].nameLength != 0) {
+            storage.resolved[kept++] = storage.resolved[row];
+        }
+    }
+    // The targets are already ascending and unique, so the kept rows keep that order.
+    for (std::size_t row = kept; row < storage.resolved.size(); ++row) {
+        storage.resolved[row] = {};
+    }
+    storage.resolvedCount = kept;
+}
+
+} // namespace sunrise::client::content::hash_names

+ 115 - 0
Sunrise/src/client/content/hash_names/hash_name_matcher.h

@@ -0,0 +1,115 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <span>
+#include <string_view>
+
+#include "../../../state/build_data/hash_names/definition.h"
+
+namespace sunrise::client::content::hash_names {
+
+namespace names_state = state::build_data::hash_names;
+
+/**
+ * Named objects the class sweep finds. The measured live count is 241,586.
+ * Neither a bubble nor a spawn set stores its own name, so names are recovered by hashing every
+ * id the installed objects carry and matching it against the hashes they declare.
+ */
+inline constexpr std::size_t kTagCapacity = 262'144;
+/**
+ * Tags one slice reads, shared out between the readers.
+ * The slice waits for every reader, so this bounds how long it blocks the frame. At the measured
+ * cost of a read that is a few tens of milliseconds.
+ */
+inline constexpr std::size_t kBatchTags = 2048;
+/** Longest id worth hashing. Anything longer cannot be a bubble name. */
+inline constexpr std::size_t kRunCapacity = names_state::kNameLength;
+/** Underscore-separated tokens one id may carry. */
+inline constexpr std::size_t kTokenCapacity = 12;
+
+/**
+ * The target hashes and the rows one reader resolves against them.
+ * Several readers match at once. The targets are shared and never written; the rows belong to one
+ * reader alone and are merged when it joins, so nothing here locks.
+ */
+struct MatchState {
+    /** Ascending unique target hashes, shared and read-only. */
+    std::span<const std::uint32_t> targets;
+    /** Rows parallel to the targets, written only by the reader that owns them. */
+    std::span<names_state::Name> resolved;
+};
+
+/** Fixed working storage for one extraction pass, kept off the caller stack. */
+struct Storage {
+    /** Hashes the published destinations declare, ascending and unique. */
+    std::array<std::uint32_t, names_state::kNameCapacity> targets{};
+    std::size_t targetCount{};
+    /** Resolved rows, parallel to targets. An unresolved row keeps a zero length. */
+    std::array<names_state::Name, names_state::kNameCapacity> resolved{};
+    std::size_t resolvedCount{};
+    /** Named-object tags the sweep found, read in order so the block cache keeps hitting. */
+    std::array<std::uint32_t, kTagCapacity> tags{};
+    std::size_t tagCount{};
+    /** Tag the next batch resumes from, so the pass continues across calls. */
+    std::size_t cursor{};
+    /** Tick the pass started on, so every report says what the pass cost. */
+    std::uint64_t startedTick{};
+    /** Set once the targets, the free name pass, and the sweep are done. */
+    bool collected{};
+    /** Set when the pass found more objects than storage holds, which no retry can fix. */
+    bool invalid{};
+};
+
+/** Clears one extraction pass without freeing process-owned outer storage. */
+void reset(Storage& storage) noexcept;
+
+/**
+ * Records every hash the published domains declare: bubble names and spawn-set names.
+ * @param storage Pass storage receiving the ascending unique target set.
+ * @return True when both domains are readable and their hashes fit.
+ */
+[[nodiscard]] bool collect_targets(Storage& storage) noexcept;
+
+/**
+ * Offers one id, keeping the shortest name that gives a target hash.
+ * Every underscore-bounded prefix and suffix of the id is tried, because a bubble name is usually
+ * one token of a longer object name.
+ * @param state One reader's targets and rows.
+ * @param text Candidate id.
+ */
+void offer(MatchState& state, std::string_view text) noexcept;
+
+/**
+ * Offers every id inside one blob.
+ * @param state One reader's targets and rows.
+ * @param blob Whole object bytes.
+ */
+void offer_blob(MatchState& state, std::span<const std::byte> blob) noexcept;
+
+/** @param storage Pass storage. @return Its targets and its own rows. */
+[[nodiscard]] MatchState match_state(Storage& storage) noexcept;
+
+/**
+ * Folds one reader's rows into the pass, keeping the shortest name for every target.
+ * @param storage Pass storage.
+ * @param rows Rows one reader resolved, parallel to the targets.
+ */
+void merge(Storage& storage, std::span<const names_state::Name> rows) noexcept;
+
+/**
+ * Adds one swept tag to an unfinished pass.
+ * @param storage Pass storage.
+ * @param tag Named-object tag.
+ * @return False when the pass is finished or storage is full.
+ */
+[[nodiscard]] bool add_tag(Storage& storage, std::uint32_t tag) noexcept;
+
+/**
+ * Packs the resolved rows into ascending hash order for publishing.
+ * @param storage Pass storage whose resolved count is set here.
+ */
+void finish(Storage& storage) noexcept;
+
+} // namespace sunrise::client::content::hash_names

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

@@ -0,0 +1,11 @@
+#pragma once
+
+#include "../../../state/account/account_state.h"
+#include "source.h"
+
+namespace sunrise::client::content::investment {
+
+/** @return True when the next refresh slice must hold the process for a package sweep. */
+[[nodiscard]] bool requires_process_freeze() noexcept;
+
+} // namespace sunrise::client::content::investment

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

@@ -0,0 +1,122 @@
+#include <Windows.h>
+
+#include <array>
+#include <cstdio>
+#include <string_view>
+
+#include "../../../core/logging/log.h"
+#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 "../../process/freeze/client_process_freeze.h"
+#include "../items/packages/build.h"
+#include "internal.h"
+#include "runtime.h"
+
+namespace sunrise::client::content::investment {
+namespace {
+
+SRWLOCK g_refreshLock{SRWLOCK_INIT};
+
+/** One line reports the freeze, so a run that could not hold the game is visible. */
+constexpr std::size_t kLineLimit = 96;
+
+/**
+ * @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.
+ */
+[[nodiscard]] bool ready() noexcept {
+    return state::build_data::named_catalog_ready() && state::build_data::item_definitions_ready()
+           && state::build_data::configured_item_details_ready()
+           && state::build_data::inventory_bucket_descriptors_ready()
+           && state::build_data::socket_entry_lists_ready()
+           && state::build_data::ability_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();
+}
+
+/**
+ * Reports the outcome of one freeze attempt.
+ * @param frozen True when the game was held.
+ * @param threadCount Threads that were suspended.
+ */
+void report_freeze(bool frozen, std::size_t threadCount) noexcept {
+    std::array<char, kLineLimit> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=extract stage=freeze result=%s threads=%zu",
+                                      frozen ? "ok" : "fail",
+                                      threadCount);
+    if (written <= 0) {
+        return;
+    }
+    const auto length = static_cast<std::size_t>(written) < line.size()
+                            ? static_cast<std::size_t>(written)
+                            : line.size() - 1;
+    // The pass runs in slices, so a working freeze reports at debug and only a failure is loud.
+    core::log::write(core::log::Channel::client,
+                     frozen ? core::log::Level::debug : core::log::Level::warn,
+                     std::string_view(line.data(), length));
+}
+
+} // namespace
+
+/** @return True when the next refresh slice must hold the process for a package sweep. */
+bool requires_process_freeze() noexcept {
+    return !state::build_data::item_definitions_ready() && items::packages::readable();
+}
+
+/** Publishes every installed equipment mapping domain. */
+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 bool persisted = 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);
+    // Only the item sweep holds the game, and only once the block keys exist. The destination and
+    // spawn-set passes after it are tens of thousands of tag reads over many slices, so the
+    // overlay covers the whole pass: without it the longest stall of the boot has nothing on
+    // screen.
+    const bool sweeping = requires_process_freeze();
+    process::freeze::Held held{};
+    bool frozen = false;
+    std::size_t heldThreads = 0;
+    if (sweeping) {
+        // The overlay reaches the screen before the freeze stops the frame loop. A held game
+        // cannot time its connection out, which a slow disk otherwise causes here.
+        core::ui::busy::begin(core::ui::busy::Task::contentExtraction);
+        frozen = process::freeze::hold(held);
+        heldThreads = held.count;
+    } else {
+        core::ui::busy::raise(core::ui::busy::Task::contentExtraction);
+    }
+    // The package pass owns the item table and must not wait on runtime content lookups.
+    (void)items::packages::build();
+    const bool complete = ready() && state::build_data::persist();
+    process::freeze::release(held);
+    // 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);
+    if (sweeping) {
+        report_freeze(frozen, heldThreads);
+    }
+    return complete;
+}
+
+} // namespace sunrise::client::content::investment

+ 20 - 0
Sunrise/src/client/content/investment/investment_source.cpp

@@ -0,0 +1,20 @@
+#include <array>
+#include <cstddef>
+#include <cstdint>
+
+#include "../../../state/content/content_catalog.h"
+#include "../../memory/current_process_memory.h"
+#include "../../targets/game.h"
+#include "internal.h"
+
+namespace sunrise::client::content::investment {
+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;
+
+} // namespace
+
+} // namespace sunrise::client::content::investment

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

@@ -0,0 +1,47 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+
+namespace sunrise::client::content::investment::layout {
+
+/** The globals blob stores its investment-root tag after 16 ABI bytes. */
+inline constexpr std::size_t kGlobalsRootTagOffset = 16;
+/** The runtime bucket-table handle sits at byte 280 of the investment root. */
+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 socket-entry-list table handle sits at byte 1,560 of the investment root. */
+inline constexpr std::size_t kSocketEntryListTableTagOffset = 1560;
+
+/** Fixed prefix of the loaded investment-globals blob. */
+struct InvestmentGlobals {
+    std::array<std::byte, kGlobalsRootTagOffset> opaqueBeforeInvestmentRootTag{};
+    std::uint32_t investmentRootTag{};
+};
+
+/**
+ * Fixed investment-root prefix shared by the numeric runtime-table extractors.
+ * One layout for the three verified handles stops modules quietly giving the same native prefix
+ * different meanings.
+ */
+struct InvestmentRoot {
+    std::array<std::byte, kInventoryBucketTableTagOffset> opaqueBeforeInventoryBucketTableTag{};
+    std::uint32_t inventoryBucketTableTag{};
+    std::array<std::byte,
+               kItemTableTagOffset - kInventoryBucketTableTagOffset - sizeof(std::uint32_t)>
+        opaqueBeforeItemTableTag{};
+    std::uint32_t itemTableTag{};
+    std::array<std::byte,
+               kSocketEntryListTableTagOffset - kItemTableTagOffset - sizeof(std::uint32_t)>
+        opaqueBeforeSocketEntryListTableTag{};
+    std::uint32_t socketEntryListTableTag{};
+};
+
+static_assert(offsetof(InvestmentGlobals, investmentRootTag) == kGlobalsRootTagOffset);
+static_assert(offsetof(InvestmentRoot, inventoryBucketTableTag) == kInventoryBucketTableTagOffset);
+static_assert(offsetof(InvestmentRoot, itemTableTag) == kItemTableTagOffset);
+static_assert(offsetof(InvestmentRoot, socketEntryListTableTag) == kSocketEntryListTableTagOffset);
+
+} // namespace sunrise::client::content::investment::layout

+ 11 - 0
Sunrise/src/client/content/investment/runtime.h

@@ -0,0 +1,11 @@
+#pragma once
+
+namespace sunrise::client::content::investment {
+
+/**
+ * Publishes installed mappings and the process-only selected-character light scalar.
+ * @return True when every mapping is durable and the current scalar is ready when needed.
+ */
+[[nodiscard]] bool refresh() noexcept;
+
+} // namespace sunrise::client::content::investment

+ 17 - 0
Sunrise/src/client/content/investment/source.h

@@ -0,0 +1,17 @@
+#pragma once
+
+#include <cstdint>
+
+#include "../handles/handle_resolver.h"
+
+namespace sunrise::client::content::investment {
+
+/** Runtime inputs shared by the installed investment-table extractors. */
+struct Source {
+    /** Installed runtime tag for the investment-globals blob. */
+    std::uint32_t investmentGlobalsTag{};
+    /** Loaded-content handle tables and bounded memory reader. */
+    handles::Source handles{};
+};
+
+} // namespace sunrise::client::content::investment

+ 19 - 0
Sunrise/src/client/content/investment/worker.h

@@ -0,0 +1,19 @@
+#pragma once
+
+#include <cstdint>
+
+namespace sunrise::client::content::investment::worker {
+
+/** Allows cooperative investment refresh slices on the caller-owned game thread. */
+void activate() noexcept;
+
+/**
+ * Runs one due bounded refresh slice on the caller-owned game thread.
+ * @param nowMilliseconds Current monotonic process tick in milliseconds.
+ */
+void service(std::uint64_t nowMilliseconds) noexcept;
+
+/** Stops taking refresh slices and clears the pending overlay. */
+void reset() noexcept;
+
+} // namespace sunrise::client::content::investment::worker

+ 79 - 0
Sunrise/src/client/content/investment/worker/investment_refresh_worker.cpp

@@ -0,0 +1,79 @@
+#include <Windows.h>
+
+#include "../../../../core/ui/busy/busy.h"
+#include "../../../targets/game/content.h"
+#include "../../diagnostics/content_readiness_report.h"
+#include "../internal.h"
+#include "../runtime.h"
+#include "../worker.h"
+
+namespace sunrise::client::content::investment::worker {
+namespace {
+
+/**
+ * Delay between bounded refresh slices.
+ * A slice runs on every pump. The extraction is hundreds of slices and each bounds its own length,
+ * so a delay on top only added waiting: at 50 ms it was most of what the boot spent extracting.
+ */
+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{};
+
+} // 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);
+}
+
+/** 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_process_freeze()) {
+        g_overlayPending = true;
+        if (sunrise::core::ui::busy::raise_early(
+                sunrise::core::ui::busy::Task::contentExtraction)) {
+            ReleaseSRWLockExclusive(&g_lifecycleLock);
+            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;
+    }
+
+    g_complete = sunrise::client::content::investment::refresh();
+    sunrise::client::content::diagnostics::report_readiness();
+    g_overlayPending = false;
+    ReleaseSRWLockExclusive(&g_lifecycleLock);
+}
+
+/** 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);
+}
+
+} // namespace sunrise::client::content::investment::worker

+ 120 - 0
Sunrise/src/client/content/items/details/configured_item_detail_extractor.cpp

@@ -0,0 +1,120 @@
+#include "configured_item_detail_extractor.h"
+
+#include <algorithm>
+#include <array>
+#include <bitset>
+#include <cstddef>
+#include <cstdint>
+
+#include "../../../../state/build_data/items/item_catalog.h"
+#include "../../../../state/build_data/socket_entry_lists/definition.h"
+#include "../../investment/layout.h"
+#include "../layout.h"
+#include "definition_detail_reader.h"
+#include "memory.h"
+
+namespace sunrise::client::content::items::details {
+namespace {
+
+namespace build_details = state::build_data::items::details;
+namespace build_items = state::build_data::items;
+namespace build_socket_lists = state::build_data::socket_entry_lists;
+namespace dense_layout = sunrise::client::content::items::layout;
+namespace investment_layout = sunrise::client::content::investment::layout;
+
+/**
+ * Finds the installed item table once and checks its starting row count.
+ * @param source Installed investment globals and bounded memory source.
+ * @param table Receives the table address, first-row address, and starting count.
+ * @return True when all three handles are found and the dense count fits native State.
+ */
+[[nodiscard]] bool resolve_table(const investment::Source& source, reader::Table& table) noexcept {
+    std::uintptr_t globals = 0;
+    std::uintptr_t root = 0;
+    if (!handles::resolve(source.handles, source.investmentGlobalsTag, globals)
+        || !memory::resolve_member(
+            source,
+            globals,
+            offsetof(investment_layout::InvestmentGlobals, investmentRootTag),
+            root)
+        || !memory::resolve_member(source,
+                                   root,
+                                   offsetof(investment_layout::InvestmentRoot, itemTableTag),
+                                   table.tableAddress)) {
+        return false;
+    }
+
+    std::uint64_t rowCount = 0;
+    if (!memory::read_member(
+            source, table.tableAddress, offsetof(dense_layout::ItemTable, rowCount), rowCount)
+        || rowCount == 0 || rowCount > static_cast<std::uint64_t>(build_items::kDefinitionCapacity)
+        || !memory::member_address(
+            table.tableAddress, offsetof(dense_layout::ItemTable, firstRow), table.rowsAddress)) {
+        return false;
+    }
+    table.rowCount = static_cast<std::size_t>(rowCount);
+    return true;
+}
+
+/**
+ * Confirms the dense table count still matches the value used for every row bound.
+ * @param source Installed item-detail source.
+ * @param table Found table and its starting row count.
+ * @return True when a last full count read is unchanged.
+ */
+[[nodiscard]] bool stable_count(const investment::Source& source,
+                                const reader::Table& table) noexcept {
+    std::uint64_t finalCount = 0;
+    return memory::read_member(
+               source, table.tableAddress, offsetof(dense_layout::ItemTable, rowCount), finalCount)
+           && finalCount == static_cast<std::uint64_t>(table.rowCount);
+}
+
+} // namespace
+
+/** Extracts full native details for configured definition indices. */
+bool extract(const investment::Source& source,
+             std::span<const std::uint16_t> requestedDefinitionIndices,
+             std::size_t socketEntryListRowCount,
+             std::span<build_details::Definition> output,
+             std::size_t& count) noexcept {
+    count = 0;
+    if (requestedDefinitionIndices.empty()) {
+        return true;
+    }
+    if (requestedDefinitionIndices.size() > build_details::kDefinitionCapacity
+        || requestedDefinitionIndices.size() > output.size() || socketEntryListRowCount == 0
+        || socketEntryListRowCount > build_socket_lists::kDefinitionCapacity) {
+        return false;
+    }
+
+    reader::Table table{};
+    if (!resolve_table(source, table)) {
+        return false;
+    }
+
+    std::bitset<build_items::kDefinitionCapacity> seen{};
+    std::array<build_details::Definition, build_details::kDefinitionCapacity> staged{};
+    for (std::size_t position = 0; position < requestedDefinitionIndices.size(); ++position) {
+        const std::uint16_t definitionIndex = requestedDefinitionIndices[position];
+        if (static_cast<std::size_t>(definitionIndex) >= table.rowCount
+            || seen.test(definitionIndex)) {
+            return false;
+        }
+        seen.set(definitionIndex);
+        if (!reader::read_definition(
+                source, table, definitionIndex, socketEntryListRowCount, staged[position])) {
+            return false;
+        }
+    }
+
+    // The final count read is the commit gate for the staged caller snapshot.
+    if (!stable_count(source, table)) {
+        return false;
+    }
+    std::copy_n(staged.begin(), requestedDefinitionIndices.size(), output.begin());
+    count = requestedDefinitionIndices.size();
+    return true;
+}
+
+} // namespace sunrise::client::content::items::details

+ 27 - 0
Sunrise/src/client/content/items/details/configured_item_detail_extractor.h

@@ -0,0 +1,27 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+#include "../../../../state/build_data/items/details/definition.h"
+#include "../../investment/source.h"
+
+namespace sunrise::client::content::items::details {
+
+/**
+ * Extracts full native details for configured definition indices.
+ * @param source Installed investment globals and bounded memory source.
+ * @param requestedDefinitionIndices Unique native indices in caller order.
+ * @param socketEntryListRowCount Current socket-entry-list table bound.
+ * @param output Caller storage left unchanged on failure.
+ * @param count Receives the detail count, or zero on failure.
+ * @return True when the item-table count stays stable and every detail passes its checks.
+ */
+[[nodiscard]] bool extract(const investment::Source& source,
+                           std::span<const std::uint16_t> requestedDefinitionIndices,
+                           std::size_t socketEntryListRowCount,
+                           std::span<state::build_data::items::details::Definition> output,
+                           std::size_t& count) noexcept;
+
+} // namespace sunrise::client::content::items::details

+ 100 - 0
Sunrise/src/client/content/items/details/definition_detail_reader.cpp

@@ -0,0 +1,100 @@
+#include "definition_detail_reader.h"
+
+#include <cstddef>
+#include <cstdint>
+
+#include "../../../../state/build_data/items/item_catalog.h"
+#include "../layout.h"
+#include "layout.h"
+#include "memory.h"
+#include "relative.h"
+#include "socket_detail_reader.h"
+
+namespace sunrise::client::content::items::details::reader {
+namespace {
+
+namespace build_details = state::build_data::items::details;
+namespace dense_layout = sunrise::client::content::items::layout;
+
+/**
+ * Reads the optional signed equipment slot from one item definition.
+ * @param source Installed item-detail source.
+ * @param definition Loaded item definition address.
+ * @param output Detail receiving the checked slot, if there is one.
+ * @return True when the optional block is absent or carries slot 0 through 19.
+ */
+[[nodiscard]] bool read_equipment_slot(const investment::Source& source,
+                                       std::uintptr_t definition,
+                                       build_details::Definition& output) noexcept {
+    bool present = false;
+    std::uintptr_t block = 0;
+    if (!relative::resolve_block(source,
+                                 definition,
+                                 offsetof(layout::ItemDefinition, equipmentBlockRelative),
+                                 present,
+                                 block)) {
+        return false;
+    }
+    if (!present) {
+        output.equipmentSlot.reset();
+        return true;
+    }
+
+    std::int8_t slot = 0;
+    if (!memory::read_member(source, block, offsetof(layout::EquipmentBlock, slot), slot)
+        || slot < 0 || static_cast<std::size_t>(slot) >= build_details::kEquipmentSlotCount) {
+        return false;
+    }
+    output.equipmentSlot = slot;
+    return true;
+}
+
+} // namespace
+
+/** Reads and checks one requested native item definition. */
+bool read_definition(const investment::Source& source,
+                     const Table& table,
+                     std::uint16_t definitionIndex,
+                     std::size_t socketEntryListRowCount,
+                     build_details::Definition& output) noexcept {
+    std::uintptr_t rowOffset = 0;
+    std::uintptr_t rowAddress = 0;
+    dense_layout::ItemIndexRow row{};
+    if (!memory::multiply_fits(definitionIndex, sizeof(dense_layout::ItemIndexRow), rowOffset)
+        || !memory::add_fits(table.rowsAddress, rowOffset, rowAddress)
+        || !memory::read_value(source, rowAddress, row)) {
+        return false;
+    }
+
+    std::uintptr_t target = 0;
+    if (!handles::resolve(source.handles, row.targetHandle, target)) {
+        return false;
+    }
+
+    build_details::Definition candidate{};
+    candidate.definitionIndex = definitionIndex;
+    std::uint8_t instancedDefinitionPredicate = 0;
+    if (!memory::read_member(
+            source, target, offsetof(layout::ItemDefinition, maxStackSize), candidate.maxStackSize)
+        || !memory::read_member(
+            source, target, offsetof(layout::ItemDefinition, bucketId), candidate.bucketId)
+        || !memory::read_member(source,
+                                target,
+                                offsetof(layout::ItemDefinition, instancedDefinitionPredicate),
+                                instancedDefinitionPredicate)
+        || candidate.maxStackSize <= 0
+        || candidate.bucketId == state::build_data::items::kUnresolvedBucketId
+        || !read_equipment_slot(source, target, candidate)
+        || !socket_reader::read_ordinary(source, target, table.rowCount, candidate)
+        || !socket_reader::read_list_index(source, target, socketEntryListRowCount, candidate)) {
+        return false;
+    }
+    candidate.instancedDefinitionState = instancedDefinitionPredicate == 0
+                                             ? build_details::InstancedDefinitionState::stackable
+                                             : build_details::InstancedDefinitionState::instanced;
+
+    output = candidate;
+    return true;
+}
+
+} // namespace sunrise::client::content::items::details::reader

+ 34 - 0
Sunrise/src/client/content/items/details/definition_detail_reader.h

@@ -0,0 +1,34 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+
+#include "configured_item_detail_extractor.h"
+
+namespace sunrise::client::content::items::details::reader {
+
+namespace build_details = state::build_data::items::details;
+
+/** Resolved dense item table held only for one bounded extraction call. */
+struct Table {
+    std::uintptr_t tableAddress{};
+    std::uintptr_t rowsAddress{};
+    std::size_t rowCount{};
+};
+
+/**
+ * Reads and checks one requested native item definition.
+ * @param source Installed item-detail source.
+ * @param table Stable-call table address, row start, and starting count.
+ * @param definitionIndex Requested native row index.
+ * @param socketEntryListRowCount Current socket-entry-list table bound.
+ * @param output Receives a full detail only on success.
+ * @return True when the target and every needed block pass their checks.
+ */
+[[nodiscard]] bool read_definition(const investment::Source& source,
+                                   const Table& table,
+                                   std::uint16_t definitionIndex,
+                                   std::size_t socketEntryListRowCount,
+                                   build_details::Definition& output) noexcept;
+
+} // namespace sunrise::client::content::items::details::reader

+ 112 - 0
Sunrise/src/client/content/items/details/layout.h

@@ -0,0 +1,112 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+
+namespace sunrise::client::content::items::details::layout {
+
+/** The optional equipment block is self-relative from definition byte 16. */
+inline constexpr std::size_t kEquipmentBlockRelativeOffset = 16;
+/** The optional ordinary-socket block is self-relative from definition byte 104. */
+inline constexpr std::size_t kOrdinarySocketBlockRelativeOffset = 104;
+/** The optional socket-list block is self-relative from definition byte 128. */
+inline constexpr std::size_t kSocketListBlockRelativeOffset = 128;
+/** The native signed stack limit sits at definition byte 180. */
+inline constexpr std::size_t kMaxStackSizeOffset = 180;
+/** The native inventory bucket id sits at definition byte 184. */
+inline constexpr std::size_t kBucketIdOffset = 184;
+/** Definition byte 187 is zero for stackable rows and nonzero for instanced rows. */
+inline constexpr std::size_t kInstancedDefinitionPredicateOffset = 187;
+/** The equipment block stores its signed slot id at byte 24. */
+inline constexpr std::size_t kEquipmentSlotOffset = 24;
+/** The array header begins relative to the offset member at array-field byte 8. */
+inline constexpr std::size_t kArrayHeaderRelativeOffset = 8;
+/** The array marker sits in the 4 bytes right before the resolved header. */
+inline constexpr std::size_t kArrayMarkerBackOffset = sizeof(std::uint32_t);
+/** Loaded native arrays use this marker before their resolved header. */
+inline constexpr std::uint32_t kArrayHeaderMarker = 0x80809FBDU;
+/** Ordinary socket arrays declare this native element class. */
+inline constexpr std::uint32_t kOrdinarySocketElementClass = 0x808077C4U;
+/** Array entries start 16 bytes after the resolved header. */
+inline constexpr std::size_t kArrayFirstEntryOffset = 16;
+/** One ordinary socket entry is 80 native bytes. */
+inline constexpr std::size_t kOrdinarySocketEntrySize = 80;
+/** An ordinary socket entry stores its initial plug index at byte 2. */
+inline constexpr std::size_t kInitialPlugIndexOffset = 2;
+
+/** Self-relative array field at the start of the ordinary-socket block. */
+struct RelativeArray {
+    std::uint64_t count{};
+    std::int64_t headerRelative{};
+};
+
+/** Fixed item-definition fields used by configured detail extraction. */
+struct ItemDefinition {
+    std::array<std::byte, kEquipmentBlockRelativeOffset> opaqueBeforeEquipmentBlock{};
+    std::int64_t equipmentBlockRelative{};
+    std::array<std::byte,
+               kOrdinarySocketBlockRelativeOffset - kEquipmentBlockRelativeOffset
+                   - sizeof(std::int64_t)>
+        opaqueBeforeOrdinarySocketBlock{};
+    std::int64_t ordinarySocketBlockRelative{};
+    /** Stats are read from the package blob, never from loaded memory, so this stays opaque. */
+    std::array<std::byte,
+               kSocketListBlockRelativeOffset - kOrdinarySocketBlockRelativeOffset
+                   - sizeof(std::int64_t)>
+        opaqueBeforeSocketListBlock{};
+    std::int64_t socketListBlockRelative{};
+    std::array<std::byte,
+               kMaxStackSizeOffset - kSocketListBlockRelativeOffset - sizeof(std::int64_t)>
+        opaqueBeforeMaxStackSize{};
+    std::int32_t maxStackSize{};
+    std::uint8_t bucketId{};
+    std::array<std::byte,
+               kInstancedDefinitionPredicateOffset - kBucketIdOffset - sizeof(std::uint8_t)>
+        opaqueBeforeInstancedDefinitionPredicate{};
+    std::uint8_t instancedDefinitionPredicate{};
+};
+
+/** Fixed prefix of the optional equipment block. */
+struct EquipmentBlock {
+    std::array<std::byte, kEquipmentSlotOffset> opaqueBeforeSlot{};
+    std::int8_t slot{};
+};
+
+/** One fixed native ordinary-socket entry. */
+struct OrdinarySocketEntry {
+    std::uint16_t socketTypeIndex{};
+    std::uint16_t initialPlugIndex{};
+    std::array<std::byte,
+               kOrdinarySocketEntrySize - kInitialPlugIndexOffset - sizeof(std::uint16_t)>
+        opaqueAfterInitialPlug{};
+};
+
+/** Resolved fixed native array header before its ordinary-socket entries. */
+struct ArrayHeader {
+    std::uint64_t repeatedCount{};
+    std::uint32_t elementClass{};
+    std::array<std::byte, kArrayFirstEntryOffset - sizeof(std::uint64_t) - sizeof(std::uint32_t)>
+        opaqueBeforeFirstEntry{};
+};
+
+/** Fixed prefix of the optional socket-entry-list selection block. */
+struct SocketListBlock {
+    std::uint16_t index{};
+};
+
+static_assert(offsetof(ItemDefinition, equipmentBlockRelative) == kEquipmentBlockRelativeOffset);
+static_assert(offsetof(ItemDefinition, ordinarySocketBlockRelative)
+              == kOrdinarySocketBlockRelativeOffset);
+static_assert(offsetof(ItemDefinition, socketListBlockRelative) == kSocketListBlockRelativeOffset);
+static_assert(offsetof(ItemDefinition, maxStackSize) == kMaxStackSizeOffset);
+static_assert(offsetof(ItemDefinition, bucketId) == kBucketIdOffset);
+static_assert(offsetof(ItemDefinition, instancedDefinitionPredicate)
+              == kInstancedDefinitionPredicateOffset);
+static_assert(offsetof(EquipmentBlock, slot) == kEquipmentSlotOffset);
+static_assert(offsetof(RelativeArray, headerRelative) == kArrayHeaderRelativeOffset);
+static_assert(offsetof(OrdinarySocketEntry, initialPlugIndex) == kInitialPlugIndexOffset);
+static_assert(sizeof(OrdinarySocketEntry) == kOrdinarySocketEntrySize);
+static_assert(sizeof(ArrayHeader) == kArrayFirstEntryOffset);
+
+} // namespace sunrise::client::content::items::details::layout

+ 126 - 0
Sunrise/src/client/content/items/details/memory.h

@@ -0,0 +1,126 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <limits>
+#include <span>
+
+#include "../../investment/source.h"
+#include "../layout.h"
+
+namespace sunrise::client::content::items::details::memory {
+
+[[nodiscard]] inline bool
+add_fits(std::uintptr_t left, std::uintptr_t right, std::uintptr_t& result) noexcept {
+    if (right > (std::numeric_limits<std::uintptr_t>::max)() - left) {
+        return false;
+    }
+    result = left + right;
+    return true;
+}
+
+/**
+ * Adds a signed self-relative offset without wrapping the address space.
+ * @param base Address of the relative-offset field.
+ * @param relative Signed byte offset from that field.
+ * @param result Receives the address found.
+ * @return True when the signed sum fits.
+ */
+[[nodiscard]] inline bool
+add_signed_fits(std::uintptr_t base, std::int64_t relative, std::uintptr_t& result) noexcept {
+    if (relative >= 0) {
+        const auto positive = static_cast<std::uint64_t>(relative);
+        if (positive > (std::numeric_limits<std::uintptr_t>::max)()) {
+            return false;
+        }
+        return add_fits(base, static_cast<std::uintptr_t>(positive), result);
+    }
+
+    // Adding one before negation keeps the most-negative signed value representable.
+    const std::uint64_t magnitude = static_cast<std::uint64_t>(-(relative + 1)) + 1U;
+    if (magnitude > (std::numeric_limits<std::uintptr_t>::max)()
+        || static_cast<std::uintptr_t>(magnitude) > base) {
+        return false;
+    }
+    result = base - static_cast<std::uintptr_t>(magnitude);
+    return true;
+}
+
+/**
+ * Multiplies two address parts without wrapping the address space.
+ * @param result Receives the whole product.
+ * @return True when the product fits.
+ */
+[[nodiscard]] inline bool
+multiply_fits(std::uintptr_t left, std::uintptr_t right, std::uintptr_t& result) noexcept {
+    if (left != 0 && right > (std::numeric_limits<std::uintptr_t>::max)() / left) {
+        return false;
+    }
+    result = left * right;
+    return true;
+}
+
+/**
+ * Reads one whole scalar through the caller's bounded memory source.
+ * @tparam Value Trivially copied scalar or layout object.
+ * @param source Installed item-detail source.
+ * @param value Receives the whole value.
+ * @return True when the callback reads every byte.
+ */
+template <typename Value>
+[[nodiscard]] bool
+read_value(const investment::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));
+}
+
+/**
+ * Works out a checked address for one typed member of a loaded blob.
+ * @param base Loaded blob address.
+ * @param offset Byte offset of the member.
+ * @param address Receives the member address.
+ * @return True when the member address fits.
+ */
+[[nodiscard]] inline bool
+member_address(std::uintptr_t base, std::size_t offset, std::uintptr_t& address) noexcept {
+    return add_fits(base, static_cast<std::uintptr_t>(offset), address);
+}
+
+/**
+ * Reads one typed member at a checked offset from a loaded blob.
+ * @tparam Value Trivially copied member type.
+ * @param source Installed item-detail source.
+ * @param base Loaded blob address.
+ * @param offset Byte offset of the member.
+ * @param value Receives the whole member.
+ * @return True when the address fits and the read succeeds.
+ */
+template <typename Value>
+[[nodiscard]] bool read_member(const investment::Source& source,
+                               std::uintptr_t base,
+                               std::size_t offset,
+                               Value& value) noexcept {
+    std::uintptr_t address = 0;
+    return member_address(base, offset, address) && read_value(source, address, value);
+}
+
+/**
+ * Reads one handle member and finds the blob it points to.
+ * @param source Installed item-detail source.
+ * @param parent Loaded parent blob address.
+ * @param offset Byte offset of the handle member.
+ * @param child Receives the loaded child blob address.
+ * @return True when the member reads and its loaded handle is found.
+ */
+[[nodiscard]] inline bool resolve_member(const investment::Source& source,
+                                         std::uintptr_t parent,
+                                         std::size_t offset,
+                                         std::uintptr_t& child) noexcept {
+    std::uint32_t handle = 0;
+    return read_member(source, parent, offset, handle)
+           && handles::resolve(source.handles, handle, child);
+}
+
+} // namespace sunrise::client::content::items::details::memory

+ 39 - 0
Sunrise/src/client/content/items/details/relative.h

@@ -0,0 +1,39 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+
+#include "memory.h"
+
+namespace sunrise::client::content::items::details::relative {
+
+/**
+ * Finds one optional self-relative definition member.
+ * @param source Installed item-detail source.
+ * @param definition Loaded item definition address.
+ * @param memberOffset Byte offset of the signed relative member.
+ * @param present Receives whether the relative member is nonzero.
+ * @param block Receives the block address when present.
+ * @return True when the member reads and any nonzero offset is safe.
+ */
+[[nodiscard]] inline bool resolve_block(const investment::Source& source,
+                                        std::uintptr_t definition,
+                                        std::size_t memberOffset,
+                                        bool& present,
+                                        std::uintptr_t& block) noexcept {
+    present = false;
+    block = 0;
+    std::int64_t displacement = 0;
+    std::uintptr_t memberAddress = 0;
+    if (!memory::member_address(definition, memberOffset, memberAddress)
+        || !memory::read_value(source, memberAddress, displacement)) {
+        return false;
+    }
+    if (displacement == 0) {
+        return true;
+    }
+    present = true;
+    return memory::add_signed_fits(memberAddress, displacement, block);
+}
+
+} // namespace sunrise::client::content::items::details::relative

+ 131 - 0
Sunrise/src/client/content/items/details/socket_detail_reader.cpp

@@ -0,0 +1,131 @@
+#include "socket_detail_reader.h"
+
+#include <cstddef>
+#include <cstdint>
+
+#include "layout.h"
+#include "memory.h"
+#include "relative.h"
+
+namespace sunrise::client::content::items::details::socket_reader {
+namespace {
+
+namespace build_details = state::build_data::items::details;
+
+/**
+ * Finds and checks an ordinary-socket array header.
+ * @param source Installed item-detail source.
+ * @param block Loaded ordinary-socket block address.
+ * @param field Receives the self-relative array field.
+ * @param headerAddress Receives the array header address.
+ * @param header Receives the whole fixed header prefix.
+ * @return True when count, marker, repeated count, and element class match the ABI.
+ */
+[[nodiscard]] bool read_header(const investment::Source& source,
+                               std::uintptr_t block,
+                               layout::RelativeArray& field,
+                               std::uintptr_t& headerAddress,
+                               layout::ArrayHeader& header) noexcept {
+    if (!memory::read_value(source, block, field)
+        || field.count > static_cast<std::uint64_t>(build_details::kInitialPlugCapacity)) {
+        return false;
+    }
+
+    std::uintptr_t relativeAddress = 0;
+    std::uintptr_t markerAddress = 0;
+    if (!memory::member_address(
+            block, offsetof(layout::RelativeArray, headerRelative), relativeAddress)
+        || !memory::add_signed_fits(relativeAddress, field.headerRelative, headerAddress)
+        || !memory::add_signed_fits(headerAddress,
+                                    -static_cast<std::int64_t>(layout::kArrayMarkerBackOffset),
+                                    markerAddress)) {
+        return false;
+    }
+
+    std::uint32_t marker = 0;
+    return memory::read_value(source, markerAddress, marker) && marker == layout::kArrayHeaderMarker
+           && memory::read_value(source, headerAddress, header)
+           && header.repeatedCount == field.count
+           && header.elementClass == layout::kOrdinarySocketElementClass;
+}
+
+} // namespace
+
+/** Reads the optional ordinary-socket block and its initial plug indices. */
+bool read_ordinary(const investment::Source& source,
+                   std::uintptr_t definition,
+                   std::size_t itemRowCount,
+                   build_details::Definition& output) noexcept {
+    bool present = false;
+    std::uintptr_t block = 0;
+    if (!relative::resolve_block(source,
+                                 definition,
+                                 offsetof(layout::ItemDefinition, ordinarySocketBlockRelative),
+                                 present,
+                                 block)) {
+        return false;
+    }
+    if (!present) {
+        output.ordinarySocketState = build_details::OrdinarySocketState::absent;
+        output.ordinarySocketCount = 0;
+        return true;
+    }
+
+    layout::RelativeArray field{};
+    layout::ArrayHeader header{};
+    std::uintptr_t headerAddress = 0;
+    if (!read_header(source, block, field, headerAddress, header)) {
+        return false;
+    }
+
+    std::uintptr_t entriesAddress = 0;
+    if (!memory::member_address(headerAddress, sizeof(layout::ArrayHeader), entriesAddress)) {
+        return false;
+    }
+    for (std::size_t index = 0; index < static_cast<std::size_t>(field.count); ++index) {
+        std::uintptr_t entryOffset = 0;
+        std::uintptr_t entryAddress = 0;
+        layout::OrdinarySocketEntry entry{};
+        if (!memory::multiply_fits(index, sizeof(layout::OrdinarySocketEntry), entryOffset)
+            || !memory::add_fits(entriesAddress, entryOffset, entryAddress)
+            || !memory::read_value(source, entryAddress, entry)
+            || (entry.initialPlugIndex != build_details::kUnavailableItemIndex
+                && static_cast<std::size_t>(entry.initialPlugIndex) >= itemRowCount)) {
+            return false;
+        }
+        output.initialPlugIndices[index] = entry.initialPlugIndex;
+    }
+    output.ordinarySocketState = build_details::OrdinarySocketState::present;
+    output.ordinarySocketCount = static_cast<std::uint8_t>(field.count);
+    return true;
+}
+
+/** Reads the optional socket-entry-list row selection. */
+bool read_list_index(const investment::Source& source,
+                     std::uintptr_t definition,
+                     std::size_t rowCount,
+                     build_details::Definition& output) noexcept {
+    bool present = false;
+    std::uintptr_t block = 0;
+    if (!relative::resolve_block(source,
+                                 definition,
+                                 offsetof(layout::ItemDefinition, socketListBlockRelative),
+                                 present,
+                                 block)) {
+        return false;
+    }
+    if (!present) {
+        output.socketEntryListIndex = build_details::kEmptySocketEntryListIndex;
+        return true;
+    }
+
+    std::uint16_t index = 0;
+    if (!memory::read_member(source, block, offsetof(layout::SocketListBlock, index), index)
+        || static_cast<std::size_t>(index) >= rowCount) {
+        return false;
+    }
+    output.socketEntryListIndex = index;
+    return true;
+}
+
+} // namespace sunrise::client::content::items::details::socket_reader

+ 38 - 0
Sunrise/src/client/content/items/details/socket_detail_reader.h

@@ -0,0 +1,38 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+
+#include "configured_item_detail_extractor.h"
+
+namespace sunrise::client::content::items::details::socket_reader {
+
+namespace build_details = state::build_data::items::details;
+
+/**
+ * Reads the optional ordinary-socket block and its initial plug indices.
+ * @param source Installed item-detail source.
+ * @param definition Loaded item definition address.
+ * @param itemRowCount Current dense item-table row bound.
+ * @param output Detail receiving block state, count, and initial plugs.
+ * @return True when the optional array is absent or fully good.
+ */
+[[nodiscard]] bool read_ordinary(const investment::Source& source,
+                                 std::uintptr_t definition,
+                                 std::size_t itemRowCount,
+                                 build_details::Definition& output) noexcept;
+
+/**
+ * Reads the optional socket-entry-list row selection.
+ * @param source Installed item-detail source.
+ * @param definition Loaded item definition address.
+ * @param rowCount Current socket-entry-list table bound.
+ * @param output Detail receiving row 0 or the checked pick.
+ * @return True when the optional block is absent or picks an in-range row.
+ */
+[[nodiscard]] bool read_list_index(const investment::Source& source,
+                                   std::uintptr_t definition,
+                                   std::size_t rowCount,
+                                   build_details::Definition& output) noexcept;
+
+} // namespace sunrise::client::content::items::details::socket_reader

+ 50 - 0
Sunrise/src/client/content/items/layout.h

@@ -0,0 +1,50 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+
+namespace sunrise::client::content::items::layout {
+
+/** A loaded item-table blob stores its row count after 8 header bytes. */
+inline constexpr std::size_t kTableRowCountOffset = 8;
+/** Item index rows begin 48 bytes into the loaded table blob. */
+inline constexpr std::size_t kTableFirstRowOffset = 48;
+/** Each item index row stores its target handle at byte 16. */
+inline constexpr std::size_t kRowTargetHandleOffset = 16;
+/** Native item index rows are 24 bytes. */
+inline constexpr std::size_t kItemIndexRowSize = 24;
+/** A loaded gameplay definition stores its inventory bucket at byte 184. */
+inline constexpr std::size_t kDefinitionBucketOffset = 184;
+
+/** One dense native item index row. */
+struct ItemIndexRow {
+    std::uint32_t definitionHash{};
+    std::array<std::byte, kRowTargetHandleOffset - sizeof(std::uint32_t)> opaque04{};
+    std::uint32_t targetHandle{};
+    std::array<std::byte, kItemIndexRowSize - kRowTargetHandleOffset - sizeof(std::uint32_t)>
+        opaque20{};
+};
+
+/** Fixed header and first row of the loaded item table. */
+struct ItemTable {
+    std::array<std::byte, kTableRowCountOffset> opaque00{};
+    std::uint64_t rowCount{};
+    std::array<std::byte, kTableFirstRowOffset - kTableRowCountOffset - sizeof(std::uint64_t)>
+        opaque16{};
+    ItemIndexRow firstRow{};
+};
+
+/** Fixed prefix through the gameplay definition's bucket field. */
+struct ItemDefinition {
+    std::array<std::byte, kDefinitionBucketOffset> opaque00{};
+    std::uint8_t bucketId{};
+};
+
+static_assert(offsetof(ItemIndexRow, targetHandle) == kRowTargetHandleOffset);
+static_assert(sizeof(ItemIndexRow) == kItemIndexRowSize);
+static_assert(offsetof(ItemTable, rowCount) == kTableRowCountOffset);
+static_assert(offsetof(ItemTable, firstRow) == kTableFirstRowOffset);
+static_assert(offsetof(ItemDefinition, bucketId) == kDefinitionBucketOffset);
+
+} // namespace sunrise::client::content::items::layout

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

@@ -0,0 +1,19 @@
+#pragma once
+
+namespace sunrise::client::content::items::packages {
+
+/**
+ * Publishes the dense item table from the installed packages, once.
+ * @return True when State already holds the table or a full pass publishes it.
+ */
+[[nodiscard]] bool build() noexcept;
+
+/**
+ * Reports whether the pass can read anything yet.
+ * It needs the bootstrap token and the installed key table, both published before the Client
+ * worker starts. Until both are there, every call returns at once.
+ * @return True when the block keys the pass borrows are there.
+ */
+[[nodiscard]] bool readable() noexcept;
+
+} // namespace sunrise::client::content::items::packages

+ 263 - 0
Sunrise/src/client/content/items/packages/internal.h

@@ -0,0 +1,263 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <span>
+#include <vector>
+
+#include "../../../../core/filesystem/path.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/build_data/abilities/definition.h"
+#include "../../../../state/build_data/constants/definition.h"
+#include "../../../../state/build_data/items/details/definition.h"
+#include "../../../../state/build_data/items/item_catalog.h"
+#include "../../../../state/build_data/progressions/definition.h"
+
+namespace sunrise::client::content::items::packages {
+
+namespace reader = middleware::content::packages::reader;
+namespace tables = middleware::content::packages::tables;
+
+/** Configured equipment rows plus every plug they socket. */
+inline constexpr std::size_t kDetailCapacity =
+    state::build_data::items::details::kDefinitionCapacity;
+
+/** Authored definition hashes one pass looks for while walking the item index table. */
+struct AuthoredHashes {
+    std::array<std::uint32_t, kDetailCapacity> values{};
+    std::size_t count{};
+};
+
+/** Everything the detail pass needs to read one requested definition from the packages. */
+struct DetailSource {
+    const reader::Source* source{};
+    reader::Scratch* scratch{};
+    /** Item index table blob owning the located array. */
+    std::span<const std::byte> table{};
+    tables::Array array{};
+    std::vector<std::byte>* definition{};
+};
+
+/** The container name is not always unique, so every match is a candidate. */
+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{};
+    std::vector<std::byte> container{};
+    std::vector<std::byte> child{};
+    std::vector<std::byte> root{};
+    std::vector<std::byte> definition{};
+    std::array<std::uint16_t, kDetailCapacity> requested{};
+    std::array<state::build_data::items::details::Definition, kDetailCapacity> details{};
+    AuthoredHashes authoredHashes{};
+    std::vector<std::byte> abilityTable{};
+    std::vector<std::byte> abilityPool{};
+    std::array<state::build_data::abilities::Definition,
+               state::build_data::abilities::kDefinitionCapacity>
+        abilityRows{};
+    std::array<state::build_data::progressions::Definition,
+               state::build_data::progressions::kDefinitionCapacity>
+        progressionRows{};
+    std::array<state::build_data::items::Definition, state::build_data::items::kDefinitionCapacity>
+        rows{};
+};
+
+/**
+ * Collects the authored equipment and plug hashes every configured character names.
+ * Hashes are sorted so the index-table walk can test each row by binary search instead of
+ * rescanning the whole authored set per row.
+ * @param output Receives the sorted unique authored hashes.
+ * @return True when the account is good and every hash fits.
+ */
+[[nodiscard]] bool collect_authored_hashes(AuthoredHashes& output) noexcept;
+
+/** @param hashes Sorted authored hashes. @param hash Row hash. @return True when authored. */
+[[nodiscard]] bool authored(const AuthoredHashes& hashes, std::uint32_t hash) noexcept;
+
+/**
+ * Adds one definition index to the requested set.
+ * @param definitionIndex Native item index.
+ * @param requested Requested-set storage.
+ * @param count Used entries, advanced on success.
+ * @return True when the index fits.
+ */
+[[nodiscard]] bool request(std::uint16_t definitionIndex,
+                           std::span<std::uint16_t> requested,
+                           std::size_t& count) noexcept;
+
+/**
+ * Adds every socket lane's initial plug to the requested set.
+ * A lane the authored loadout leaves unset falls back to this plug, so its detail must exist.
+ * @param row Item row already read from its definition blob.
+ * @param requested Requested-set storage.
+ * @param count Used entries, advanced per added lane.
+ * @return True when every lane fits.
+ */
+[[nodiscard]] bool append_initial_plugs(const tables::items::Row& row,
+                                        std::span<std::uint16_t> requested,
+                                        std::size_t& count) noexcept;
+
+/** @param requested Requested-set storage. @param count Sorted and deduplicated in place. */
+void compact_requested(std::span<std::uint16_t> requested, std::size_t& count) noexcept;
+
+/**
+ * Reads one requested definition and turns it into its cached detail form.
+ * @param source Located item index table and package reader state.
+ * @param definitionIndex Native item index.
+ * @param detail Receives the cached detail.
+ * @return True when the row is found and its definition blob reads.
+ */
+[[nodiscard]] bool build_detail(const DetailSource& source,
+                                std::uint16_t definitionIndex,
+                                state::build_data::items::details::Definition& detail) noexcept;
+
+/**
+ * Reads the stat rows the installed investment constants blob names.
+ * @param source Package source.
+ * @param scratch Reader scratch.
+ * @param root Investment root bytes.
+ * @param blob Scratch storage for the constants blob.
+ * @param output Receives the extracted stat rows.
+ * @return True when the blob reads and carries every named row.
+ */
+[[nodiscard]] bool
+read_investment_constants(const reader::Source& source,
+                          reader::Scratch& scratch,
+                          std::span<const std::byte> root,
+                          std::vector<std::byte>& blob,
+                          state::build_data::constants::InvestmentConstants& output) noexcept;
+
+/**
+ * Builds the ability buckets one subclass publishes under one movement selection.
+ * @param source Package source.
+ * @param scratch Reader scratch.
+ * @param listDefinition Socket-entry-list definition bytes of the subclass.
+ * @param blob Scratch storage reused for every pool blob.
+ * @param movementEntry Selected movement entry.
+ * @param output Receives the 12 buckets and the overflow bank.
+ * @return True when every selected entry reaches a bucket of its own.
+ */
+[[nodiscard]] bool build_ability_buckets(const reader::Source& source,
+                                         reader::Scratch& scratch,
+                                         std::span<const std::byte> listDefinition,
+                                         std::vector<std::byte>& blob,
+                                         std::uint8_t movementEntry,
+                                         state::build_data::abilities::Definition& output) noexcept;
+
+/**
+ * Builds one ability bucket row per distinct subclass and movement selection in use.
+ * Two characters on the same subclass with the same movement selection publish identical
+ * buckets, so the row is keyed by both and built once.
+ * @param source Package source.
+ * @param scratch Reader scratch.
+ * @param root Investment root bytes.
+ * @param table Scratch storage for the socket entry list table.
+ * @param definition Scratch storage for one socket entry list definition.
+ * @param blob Scratch storage reused for every pool blob.
+ * @param output Row storage.
+ * @param count Receives the number of rows built.
+ * @return True when the table reads; a subclass that fails is skipped, not fatal.
+ */
+[[nodiscard]] bool
+build_character_abilities(const reader::Source& source,
+                          reader::Scratch& scratch,
+                          std::span<const std::byte> root,
+                          std::vector<std::byte>& table,
+                          std::vector<std::byte>& definition,
+                          std::vector<std::byte>& blob,
+                          std::span<state::build_data::abilities::Definition> output,
+                          std::size_t& count) 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.
+ */
+[[nodiscard]] bool build_progressions(const reader::Source& source,
+                                      reader::Scratch& scratch,
+                                      std::span<const std::byte> root,
+                                      std::vector<std::byte>& blob,
+                                      std::span<state::build_data::progressions::Definition> output,
+                                      std::size_t& count) noexcept;
+
+/**
+ * Copies the block key material this pass borrows.
+ * @param keys Receives the primary, alternate and nonce material.
+ * @return True when the installed key table and the bootstrap token are both there.
+ */
+[[nodiscard]] bool collect_keys(reader::BlockKeys& keys) noexcept;
+
+/**
+ * Collects every catalogue tag carrying the container name.
+ * @param candidates Receives the candidate tags.
+ * @param count Receives the number of candidates.
+ * @return True when the catalogue names at least one.
+ */
+[[nodiscard]] bool
+investment_globals_tags(std::array<std::uint32_t, kContainerCandidates>& candidates,
+                        std::size_t& count) noexcept;
+
+/** @param directory Receives the installed packages directory. @return True when it exists. */
+[[nodiscard]] bool package_directory(core::path::Buffer& directory) noexcept;
+
+/** @param slot Requested-set position. @param definitionIndex Native item index that failed. */
+void report_detail_failure(std::size_t slot, std::uint16_t definitionIndex) noexcept;
+
+/** @param count Ability bucket rows the pass built, one per subclass and movement selection. */
+void report_ability_count(std::size_t count) noexcept;
+
+/** @param count Detail rows the pass built, covering equipped items and every plug they socket. */
+void report_detail_count(std::size_t count) noexcept;
+
+/** Reports the pass outcome once. @param published Rows published, or zero on failure. */
+void report(std::size_t published, const char* reason) noexcept;
+
+/**
+ * Publishes the inventory bucket descriptors from the root's bucket table.
+ * @param source Package source.
+ * @param storage Pass storage.
+ * @param root Investment root bytes.
+ * @return True when the table reads and publishes.
+ */
+[[nodiscard]] bool build_buckets(const reader::Source& source,
+                                 Storage& storage,
+                                 std::span<const std::byte> root) noexcept;
+
+/**
+ * Publishes the socket entry list table from the root.
+ * @param source Package source.
+ * @param storage Pass storage.
+ * @param root Investment root bytes.
+ * @return True when the table reads and publishes.
+ */
+[[nodiscard]] bool build_socket_entry_lists(const reader::Source& source,
+                                            Storage& storage,
+                                            std::span<const std::byte> root) noexcept;
+
+/**
+ * Walks the located item index table, then publishes every domain that depends on it.
+ * @param source Package source.
+ * @param storage Pass storage holding the located table blob.
+ * @param table Located item index array.
+ * @param rowCount Receives the dense item rows published.
+ * @param reason Receives the step name when a stage fails.
+ * @return True when the dense table and every dependent domain publish.
+ */
+[[nodiscard]] bool build_item_rows(const reader::Source& source,
+                                   Storage& storage,
+                                   const tables::Array& table,
+                                   std::size_t& rowCount,
+                                   const char*& reason) noexcept;
+
+} // namespace sunrise::client::content::items::packages

+ 220 - 0
Sunrise/src/client/content/items/packages/package_ability_build.cpp

@@ -0,0 +1,220 @@
+#include <algorithm>
+#include <array>
+
+#include "../../../../middleware/content/packages/tables/ability_pool_reader.h"
+#include "../../../../state/account/account_state.h"
+#include "../../../../state/build_data/runtime.h"
+#include "../../../../state/runtime/runtime.h"
+#include "internal.h"
+
+namespace sunrise::client::content::items::packages {
+namespace {
+
+namespace pool = middleware::content::packages::tables::abilities;
+namespace domain = state::build_data::abilities;
+
+/**
+ * Socket-entry-list entries the character sheet's summary selects.
+ * They are sprint, class ability, movement, grenade, super and melee. Only movement varies, and
+ * the character's authored selection replaces this placeholder before the walk runs.
+ */
+constexpr std::uint8_t kSummaryEntries[]{1, 2, 4, 7, 10, 11};
+/** Position of the movement entry inside the summary selection. */
+constexpr std::size_t kMovementSummarySlot = 2;
+/** Entry kind of the super, which stays active without a plug source of its own. */
+constexpr std::uint8_t kSuperKind = 34;
+/** A selector chain longer than this is a cycle, not a chain. */
+constexpr std::size_t kSelectorChainLimit = 8;
+
+/** Everything one subclass walk needs to read its entries and their pools. */
+struct Walk {
+    const reader::Source* source{};
+    reader::Scratch* scratch{};
+    std::vector<std::byte>* blob{};
+    std::array<pool::Entry, pool::kEntryCapacity> entries{};
+    std::size_t entryCount{};
+    std::array<std::uint8_t, std::size(kSummaryEntries)> selected{};
+};
+
+/**
+ * Reads one entry's pool records.
+ * @param walk Subclass walk state.
+ * @param entry Entry naming the pool.
+ * @param subgroup Pool subgroup ordinal.
+ * @param records Record storage.
+ * @return The number of records read.
+ */
+[[nodiscard]] std::size_t records_of(const Walk& walk,
+                                     const pool::Entry& entry,
+                                     std::uint8_t subgroup,
+                                     std::span<pool::PoolRecord> records) noexcept {
+    if (entry.poolTag == 0
+        || !reader::read_tag(*walk.source, *walk.scratch, entry.poolTag, *walk.blob)) {
+        return 0;
+    }
+    return pool::read_pool_records(
+        std::span<const std::byte>{*walk.blob}, entry, subgroup, records);
+}
+
+/**
+ * Follows one entry's selector chain to the bucket it lands in.
+ * A record either names its destination bucket or links on to another entry, subgroup and element.
+ * @param walk Subclass walk state.
+ * @param entryIndex Entry the chain starts at.
+ * @param bucket Receives the destination bucket.
+ * @return True when the chain reaches a destination inside the bucket range.
+ */
+[[nodiscard]] bool
+selector_destination(const Walk& walk, std::uint8_t entryIndex, std::uint8_t& bucket) noexcept {
+    std::uint8_t subgroup = 0;
+    std::uint8_t element = 0;
+    for (std::size_t step = 0; step < kSelectorChainLimit; ++step) {
+        std::array<pool::PoolRecord, pool::kPoolRecordCapacity> records{};
+        const std::size_t count =
+            entryIndex < walk.entryCount
+                ? records_of(walk, walk.entries[entryIndex], subgroup, records)
+                : 0;
+        if (element >= count) {
+            return false;
+        }
+        const pool::PoolRecord& record = records[element];
+        if (record.destination != pool::kEmptyByte) {
+            bucket = record.destination;
+            return bucket < domain::kBucketCapacity;
+        }
+        if (record.linkEntry == pool::kEmptyByte || record.linkSubgroup == pool::kEmptyByte
+            || record.linkElement == pool::kEmptyByte) {
+            return false;
+        }
+        entryIndex = record.linkEntry;
+        subgroup = record.linkSubgroup;
+        element = record.linkElement;
+    }
+    return false;
+}
+
+/**
+ * Chooses the active plug source of every entry group.
+ * An entry group holds alternatives, and the summary selection names which one the character has.
+ * @param walk Subclass walk state.
+ * @param sources Receives one active plug source per group, keyed by group.
+ */
+void chosen_sources(const Walk& walk, std::array<std::uint32_t, 256>& sources) noexcept {
+    sources.fill(pool::kNoPlugSource);
+    for (const std::uint8_t entryIndex : walk.selected) {
+        if (entryIndex >= walk.entryCount) {
+            continue;
+        }
+        const pool::Entry& entry = walk.entries[entryIndex];
+        if (entry.plugSource != pool::kNoPlugSource
+            && sources[entry.group] == pool::kNoPlugSource) {
+            sources[entry.group] = entry.plugSource;
+        }
+    }
+}
+
+/**
+ * Decides whether one entry contributes its pool's hashes.
+ * @param entry Candidate entry.
+ * @param sources Active plug source per group.
+ * @return True when the entry is the group's active alternative, or is the super.
+ */
+[[nodiscard]] bool active(const pool::Entry& entry,
+                          const std::array<std::uint32_t, 256>& sources) noexcept {
+    if (entry.plugSource == pool::kNoPlugSource) {
+        return entry.kind == kSuperKind;
+    }
+    return sources[entry.group] == entry.plugSource;
+}
+
+/**
+ * Assigns each selected entry's bucket and the kind that bucket collects.
+ * @param walk Subclass walk state.
+ * @param output Receives the twelve bucket kinds.
+ * @return True when every selected entry reaches a distinct bucket.
+ */
+[[nodiscard]] bool assign_kinds(const Walk& walk, domain::Definition& output) noexcept {
+    for (const std::uint8_t entryIndex : walk.selected) {
+        if (entryIndex >= walk.entryCount) {
+            return false;
+        }
+        std::array<pool::PoolRecord, pool::kPoolRecordCapacity> records{};
+        std::uint8_t bucket = 0;
+        if (records_of(walk, walk.entries[entryIndex], 0, records) == 0
+            || records[0].kind == pool::kEmptyByte
+            || !selector_destination(walk, entryIndex, bucket)
+            || output.buckets[bucket].kind != domain::kEmptyBucketKind) {
+            return false;
+        }
+        output.buckets[bucket].kind = records[0].kind;
+    }
+    return true;
+}
+
+/**
+ * Files one pool record's hash into the bucket its category names, or into the overflow bank.
+ * @param record Pool record carrying a definition hash.
+ * @param output Row receiving the hash.
+ */
+void file_hash(const pool::PoolRecord& record, domain::Definition& output) noexcept {
+    if (record.definitionHash == pool::kNoPlugSource) {
+        return;
+    }
+    if (record.category == pool::kEmptyByte) {
+        if (output.overflowCount < output.overflow.size()) {
+            output.overflow[output.overflowCount++] = record.definitionHash;
+        }
+        return;
+    }
+    for (domain::Bucket& bucket : output.buckets) {
+        if (bucket.kind == record.category && bucket.hashCount < bucket.hashes.size()) {
+            bucket.hashes[bucket.hashCount++] = record.definitionHash;
+        }
+    }
+}
+
+} // namespace
+
+/** Builds the ability buckets one subclass publishes under one movement selection. */
+bool build_ability_buckets(const reader::Source& source,
+                           reader::Scratch& scratch,
+                           std::span<const std::byte> listDefinition,
+                           std::vector<std::byte>& blob,
+                           std::uint8_t movementEntry,
+                           state::build_data::abilities::Definition& output) noexcept {
+    Walk walk{};
+    walk.source = &source;
+    walk.scratch = &scratch;
+    walk.blob = &blob;
+    walk.entryCount = pool::read_entries(listDefinition, walk.entries);
+    if (walk.entryCount == 0) {
+        return false;
+    }
+    std::copy(std::begin(kSummaryEntries), std::end(kSummaryEntries), walk.selected.begin());
+    walk.selected[kMovementSummarySlot] = movementEntry;
+
+    for (domain::Bucket& bucket : output.buckets) {
+        bucket = {};
+    }
+    output.overflowCount = 0;
+    if (!assign_kinds(walk, output)) {
+        return false;
+    }
+    // Kinds must be complete before any hash is filed, because a hash is routed by matching its
+    // category against a bucket's kind.
+    std::array<std::uint32_t, 256> sources{};
+    chosen_sources(walk, sources);
+    for (std::size_t entryIndex = 0; entryIndex < walk.entryCount; ++entryIndex) {
+        if (!active(walk.entries[entryIndex], sources)) {
+            continue;
+        }
+        std::array<pool::PoolRecord, pool::kPoolRecordCapacity> records{};
+        const std::size_t count = records_of(walk, walk.entries[entryIndex], 0, records);
+        for (std::size_t entry = 0; entry < count; ++entry) {
+            file_hash(records[entry], output);
+        }
+    }
+    return true;
+}
+
+} // namespace sunrise::client::content::items::packages

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

@@ -0,0 +1,76 @@
+#include <array>
+#include <atomic>
+#include <cstdio>
+
+#include "../../../../core/logging/log.h"
+#include "internal.h"
+
+namespace sunrise::client::content::items::packages {
+namespace {
+
+std::atomic<bool> g_reported{};
+
+} // namespace
+
+/** @param slot Requested-set position. @param definitionIndex Native item index that failed. */
+void report_detail_failure(std::size_t slot, std::uint16_t definitionIndex) noexcept {
+    std::array<char, 96> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=pkg stage=details result=fail slot=%zu index=%u",
+                                      slot,
+                                      static_cast<unsigned>(definitionIndex));
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/** @param count Ability bucket rows the pass built, one per subclass and movement selection. */
+void report_ability_count(std::size_t count) noexcept {
+    std::array<char, 96> line{};
+    const int written =
+        std::snprintf(line.data(), line.size(), "ev=pkg stage=abilities result=ok rows=%zu", count);
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::info,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/** @param count Detail rows the pass built, covering equipped items and every plug they socket. */
+void report_detail_count(std::size_t count) noexcept {
+    std::array<char, 96> line{};
+    const int written =
+        std::snprintf(line.data(), line.size(), "ev=pkg stage=details result=ok rows=%zu", count);
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::info,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/** Reports the pass outcome once. @param published Rows published, or zero on failure. */
+void report(std::size_t published, const char* reason) noexcept {
+    if (g_reported.exchange(true, std::memory_order_relaxed)) {
+        return;
+    }
+    std::array<char, 96> line{};
+    const int written = published != 0
+                            ? std::snprintf(line.data(),
+                                            line.size(),
+                                            "ev=build_data stage=items result=ok rows=%zu",
+                                            published)
+                            : std::snprintf(line.data(),
+                                            line.size(),
+                                            "ev=build_data stage=items result=fail reason=%s",
+                                            reason);
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         published != 0 ? core::log::Level::info : core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+} // namespace sunrise::client::content::items::packages

+ 85 - 0
Sunrise/src/client/content/items/packages/package_container_location.cpp

@@ -0,0 +1,85 @@
+#include <Windows.h>
+
+#include <array>
+
+#include "../../../../core/filesystem/path.h"
+#include "../../../../state/content/content_catalog.h"
+#include "../../../../state/runtime/runtime.h"
+#include "../../../memory/current_process_memory.h"
+#include "../../../targets/game.h"
+#include "build.h"
+#include "internal.h"
+
+namespace sunrise::client::content::items::packages {
+namespace {
+
+/** Installed packages sit beside the main executable. */
+constexpr std::wstring_view kPackageDirectory = L"packages";
+/** Compact name id of the container that names every investment definition table. */
+constexpr std::uint32_t kInvestmentGlobalsNameHash = 0x6F7125CBU;
+
+} // namespace
+
+/** Reports whether the pass can read anything yet. */
+bool readable() noexcept {
+    reader::BlockKeys keys{};
+    const bool available = collect_keys(keys);
+    SecureZeroMemory(&keys, sizeof keys);
+    return available;
+}
+
+/** Copies the block key material this pass borrows. */
+bool collect_keys(reader::BlockKeys& keys) noexcept {
+    keys = {};
+    const state::SignOnState& signOn = state::sign_on();
+    targets::game::packages::KeyTable table{};
+    if (!signOn.bootstrapTokenPresent) {
+        return false;
+    }
+    const bool read = targets::game::packages::read(table);
+    if (!read) {
+        SecureZeroMemory(&table, sizeof table);
+        return false;
+    }
+    keys.alternate = table.alternateKey;
+    keys.nonceBase = table.nonceBase;
+    // The primary key comes straight from the bootstrap token and the installed identity
+    // constant, byte by byte, without reading a native derived-identity buffer.
+    for (std::size_t index = 0; index < keys.primary.size(); ++index) {
+        const auto tokenByte = static_cast<unsigned char>(signOn.bootstrapToken[index]);
+        const auto constantByte = static_cast<unsigned char>(table.identityConstant[index]);
+        keys.primary[index] = static_cast<std::byte>(tokenByte + constantByte);
+    }
+    SecureZeroMemory(&table, sizeof table);
+    return true;
+}
+
+/** Collects every catalogue tag carrying the container name. */
+bool investment_globals_tags(std::array<std::uint32_t, kContainerCandidates>& candidates,
+                             std::size_t& count) noexcept {
+    candidates = {};
+    count = 0;
+    std::array<state::content::Definition, kContainerCandidates> matches{};
+    std::size_t matched = 0;
+    if (!state::content::lookup_hash(kInvestmentGlobalsNameHash, matches, matched)) {
+        return false;
+    }
+    for (std::size_t index = 0; index < matched; ++index) {
+        if (matches[index].tag != 0) {
+            candidates[count++] = matches[index].tag;
+        }
+    }
+    return count != 0;
+}
+
+/** @param directory Receives the installed packages directory. @return True when it exists. */
+bool package_directory(core::path::Buffer& directory) noexcept {
+    if (!core::path::module_directory(GetModuleHandleW(nullptr), directory)
+        || !core::path::append(directory, kPackageDirectory)) {
+        return false;
+    }
+    const DWORD attributes = GetFileAttributesW(directory.chars.data());
+    return attributes != INVALID_FILE_ATTRIBUTES && (attributes & FILE_ATTRIBUTE_DIRECTORY) != 0;
+}
+
+} // namespace sunrise::client::content::items::packages

+ 217 - 0
Sunrise/src/client/content/items/packages/package_detail_build.cpp

@@ -0,0 +1,217 @@
+#include <algorithm>
+#include <array>
+#include <cstring>
+#include <optional>
+#include <utility>
+
+#include "../../../../state/account/account_state.h"
+#include "../../../../state/runtime/runtime.h"
+#include "internal.h"
+
+namespace sunrise::client::content::items::packages {
+namespace {
+
+namespace domain = state::build_data::items::details;
+
+/** Equipment slot for each equippable inventory bucket. */
+constexpr std::array<std::pair<std::uint8_t, std::int8_t>, 16> kEquipmentSlotOfBucket{{
+    {16, 0},
+    {3, 1},
+    {4, 2},
+    {5, 4},
+    {6, 5},
+    {7, 6},
+    {0, 7},
+    {1, 8},
+    {2, 9},
+    {10, 10},
+    {9, 11},
+    {8, 12},
+    {27, 13},
+    {41, 14},
+    {17, 15},
+    {47, 17},
+}};
+
+/** @param bucketId Inventory bucket. @return Its equipment slot, or none when not equippable. */
+[[nodiscard]] std::optional<std::int8_t> equipment_slot(std::uint8_t bucketId) noexcept {
+    for (const auto& entry : kEquipmentSlotOfBucket) {
+        if (entry.first == bucketId) {
+            return entry.second;
+        }
+    }
+    return std::nullopt;
+}
+
+/** @param row Package row. @return Its cached detail form. */
+[[nodiscard]] domain::Definition to_detail(const tables::items::Row& row) noexcept {
+    domain::Definition detail{};
+    detail.definitionIndex = row.definitionIndex;
+    detail.definitionHash = row.definitionHash;
+    detail.bucketId = row.bucketId;
+    detail.maxStackSize = row.maxStackSize;
+    detail.instancedDefinitionState = row.instanced ? domain::InstancedDefinitionState::instanced
+                                                    : domain::InstancedDefinitionState::stackable;
+    detail.equipmentSlot = equipment_slot(row.bucketId);
+    detail.ordinarySocketState =
+        row.hasSockets ? domain::OrdinarySocketState::present : domain::OrdinarySocketState::absent;
+    detail.ordinarySocketCount = row.socketCount;
+    for (std::size_t lane = 0; lane < detail.initialPlugIndices.size(); ++lane) {
+        detail.initialPlugIndices[lane] = row.initialPlugs[lane];
+        detail.socketTypes[lane] = row.socketTypes[lane];
+    }
+    detail.socketEntryListIndex = row.socketEntryListIndex;
+    // Every field below comes from the package blob only; the loaded definition is never read.
+    const std::size_t stats =
+        row.statCount < detail.stats.size() ? row.statCount : detail.stats.size();
+    for (std::size_t entry = 0; entry < stats; ++entry) {
+        detail.stats[entry] = {row.statRows[entry], row.statValues[entry]};
+    }
+    detail.statCount = static_cast<std::uint8_t>(stats);
+    detail.gearArtIndex = row.gearArtIndex;
+    detail.artArrangementIndex = row.artArrangementIndex;
+    const std::size_t perks = row.sandboxPerkCount < detail.sandboxPerks.size()
+                                  ? row.sandboxPerkCount
+                                  : detail.sandboxPerks.size();
+    for (std::size_t entry = 0; entry < perks; ++entry) {
+        detail.sandboxPerks[entry] = row.sandboxPerks[entry];
+    }
+    detail.sandboxPerkCount = static_cast<std::uint8_t>(perks);
+    const std::size_t overrides = row.renderOverrideCount < detail.renderOverrides.size()
+                                      ? row.renderOverrideCount
+                                      : detail.renderOverrides.size();
+    for (std::size_t entry = 0; entry < overrides; ++entry) {
+        detail.renderOverrides[entry] = {row.renderOverrides[entry].stage,
+                                         row.renderOverrides[entry].key,
+                                         row.renderOverrides[entry].value};
+    }
+    detail.renderOverrideCount = static_cast<std::uint8_t>(overrides);
+    return detail;
+}
+
+/** The constants blob's own 8-byte prefix comes before every offset the client quotes. */
+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;
+/**
+ * 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.
+ */
+constexpr std::size_t kCharacterStatRowOffsets[]{593, 594, 595, 622, 623, 624};
+
+} // namespace
+
+/** Collects the authored equipment and plug hashes every configured character names. */
+bool collect_authored_hashes(AuthoredHashes& output) noexcept {
+    output = {};
+    const state::AccountState account = state::account_snapshot();
+    if (!state::account::valid(account)) {
+        return false;
+    }
+    for (std::size_t character = 0; character < account.characterCount; ++character) {
+        for (const auto& item : account.characters[character].equipment.slots) {
+            if (!item.has_value() || output.count >= output.values.size()) {
+                continue;
+            }
+            output.values[output.count++] = item->definitionHash;
+            for (std::size_t lane = 0; lane < item->sockets.plugCount; ++lane) {
+                if (item->sockets.plugs[lane].has_value() && output.count < output.values.size()) {
+                    output.values[output.count++] = *item->sockets.plugs[lane];
+                }
+            }
+        }
+    }
+    const auto end = output.values.begin() + static_cast<std::ptrdiff_t>(output.count);
+    std::sort(output.values.begin(), end);
+    output.count =
+        static_cast<std::size_t>(std::unique(output.values.begin(), end) - output.values.begin());
+    return output.count != 0;
+}
+
+/** @param hashes Sorted authored hashes. @param hash Row hash. @return True when authored. */
+bool authored(const AuthoredHashes& hashes, std::uint32_t hash) noexcept {
+    const auto begin = hashes.values.begin();
+    const auto end = begin + static_cast<std::ptrdiff_t>(hashes.count);
+    return std::binary_search(begin, end, hash);
+}
+
+/** Adds one definition index to the requested set. */
+bool request(std::uint16_t definitionIndex,
+             std::span<std::uint16_t> requested,
+             std::size_t& count) noexcept {
+    if (count >= requested.size()) {
+        return false;
+    }
+    requested[count++] = definitionIndex;
+    return true;
+}
+
+/** Adds every socket lane's initial plug to the requested set. */
+bool append_initial_plugs(const tables::items::Row& row,
+                          std::span<std::uint16_t> requested,
+                          std::size_t& count) noexcept {
+    for (std::size_t lane = 0; lane < row.socketCount; ++lane) {
+        if (row.initialPlugs[lane] == tables::items::kUnavailablePlug) {
+            continue;
+        }
+        if (!request(row.initialPlugs[lane], requested, count)) {
+            return false;
+        }
+    }
+    return true;
+}
+
+/** @param requested Requested-set storage. @param count Sorted and deduplicated in place. */
+void compact_requested(std::span<std::uint16_t> requested, std::size_t& count) noexcept {
+    auto end = requested.begin() + static_cast<std::ptrdiff_t>(count);
+    std::sort(requested.begin(), end);
+    end = std::unique(requested.begin(), end);
+    count = static_cast<std::size_t>(end - requested.begin());
+}
+
+/** Reads one requested definition and turns it into its cached detail form. */
+bool build_detail(const DetailSource& source,
+                  std::uint16_t definitionIndex,
+                  domain::Definition& detail) noexcept {
+    tables::IndexRow indexRow{};
+    tables::items::Row item{};
+    item.definitionIndex = definitionIndex;
+    if (!tables::index_row(source.table, source.array, definitionIndex, indexRow)
+        || !reader::read_tag(
+            *source.source, *source.scratch, indexRow.targetTag, *source.definition)
+        || !tables::items::read_definition(std::span<const std::byte>{*source.definition}, item)) {
+        return false;
+    }
+    item.definitionHash = indexRow.definitionHash;
+    detail = to_detail(item);
+    return true;
+}
+
+/** Reads the stat rows the installed investment constants blob names. */
+bool read_investment_constants(const reader::Source& source,
+                               reader::Scratch& scratch,
+                               std::span<const std::byte> root,
+                               std::vector<std::byte>& blob,
+                               state::build_data::constants::InvestmentConstants& output) noexcept {
+    output = {};
+    std::uint32_t tag = 0;
+    if (!tables::slot_tag(root, tables::kInvestmentConstantsSlot, tag) || tag == 0
+        || !reader::read_tag(source, scratch, tag, blob)) {
+        return false;
+    }
+    const std::size_t last =
+        kConstantsPrefix + kCharacterStatRowOffsets[std::size(kCharacterStatRowOffsets) - 1];
+    if (blob.size() <= last) {
+        return false;
+    }
+    output.lightStatRow =
+        std::to_integer<std::uint8_t>(blob[kConstantsPrefix + kLightStatRowOffset]);
+    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;
+}
+
+} // namespace sunrise::client::content::items::packages

+ 167 - 0
Sunrise/src/client/content/items/packages/package_item_build.cpp

@@ -0,0 +1,167 @@
+#include <Windows.h>
+
+#include <array>
+
+#include "../../../../core/filesystem/path.h"
+#include "../../../../core/logging/log.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 "../../hash_names/hash_name_build.h"
+#include "../../scenarios/scenario_build.h"
+#include "../../spawn_sets/spawn_set_build.h"
+#include "build.h"
+#include "internal.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::configured_item_details_ready()
+           && state::build_data::inventory_bucket_descriptors_ready()
+           && state::build_data::socket_entry_lists_ready()
+           && state::build_data::ability_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();
+}
+
+/** @return True when every item and investment-root domain is published. */
+[[nodiscard]] bool root_domains_ready() noexcept {
+    return state::build_data::item_definitions_ready()
+           && state::build_data::configured_item_details_ready()
+           && state::build_data::inventory_bucket_descriptors_ready()
+           && state::build_data::socket_entry_lists_ready()
+           && state::build_data::ability_buckets_ready()
+           && state::build_data::progression_definitions_ready()
+           && state::build_data::investment_constants_ready();
+}
+
+} // namespace
+
+/** 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{};
+    if (!collect_keys(keys)) {
+        report(0, "keys");
+        return false;
+    }
+    std::size_t rowCount = 0;
+    const char* reason = "directory";
+    if (!package_directory(directory)) {
+        SecureZeroMemory(&keys, sizeof keys);
+        report(0, reason);
+        return false;
+    }
+    // The destination layouts and the spawn sets share this pass's directory, keys, and block
+    // storage. Both are independent of the item table, so a failure here leaves it alone.
+    {
+        const reader::Source packageSource{directory.chars.data(), &keys};
+        (void)content::scenarios::build(packageSource, storage.scratch);
+        (void)content::spawn_sets::build(packageSource, storage.scratch);
+        (void)content::hash_names::build(packageSource, storage.scratch);
+    }
+    if (root_domains_ready()) {
+        SecureZeroMemory(&keys, sizeof keys);
+        return true;
+    }
+    reason = "tag";
+    std::array<std::uint32_t, kContainerCandidates> candidates{};
+    std::size_t candidateCount = 0;
+    const bool named = investment_globals_tags(candidates, candidateCount);
+    if (named) {
+        const reader::Source source{directory.chars.data(), &keys};
+        tables::Array table{};
+        bool located = false;
+        reason = "read";
+        for (std::size_t candidate = 0; candidate < candidateCount && !located; ++candidate) {
+            if (!reader::read_tag(
+                    source, storage.scratch, candidates[candidate], storage.container)) {
+                continue;
+            }
+            // Fixed navigation: globals child zero is the investment root, whose slot holds the
+            // item table, whose array descriptor sits at a fixed offset.
+            std::uint32_t rootTag = 0;
+            std::uint32_t tableTag = 0;
+            reason = "root";
+            if (!tables::child_tag(std::span<const std::byte>{storage.container},
+                                   tables::kInvestmentRootChild,
+                                   rootTag)
+                || rootTag == 0
+                || !reader::read_tag(source, storage.scratch, rootTag, storage.child)) {
+                continue;
+            }
+            // The same root names the bucket and socket-list tables.
+            storage.root = storage.child;
+            (void)build_buckets(source, storage, std::span<const std::byte>{storage.root});
+            (void)build_socket_entry_lists(
+                source, storage, std::span<const std::byte>{storage.root});
+            if (!state::build_data::progression_definitions_ready()) {
+                std::size_t progressionCount = 0;
+                if (build_progressions(source,
+                                       storage.scratch,
+                                       std::span<const std::byte>{storage.root},
+                                       storage.child,
+                                       storage.progressionRows,
+                                       progressionCount)) {
+                    (void)state::build_data::publish_progression_definitions(
+                        std::span(storage.progressionRows).first(progressionCount));
+                }
+            }
+            if (!state::build_data::investment_constants_ready()) {
+                state::build_data::constants::InvestmentConstants extracted{};
+                if (read_investment_constants(source,
+                                              storage.scratch,
+                                              std::span<const std::byte>{storage.root},
+                                              storage.child,
+                                              extracted)) {
+                    (void)state::build_data::publish_investment_constants(extracted);
+                }
+            }
+            reason = "slot";
+            if (!tables::slot_tag(
+                    std::span<const std::byte>{storage.root}, tables::kItemTableSlot, tableTag)
+                || tableTag == 0
+                || !reader::read_tag(source, storage.scratch, tableTag, storage.child)) {
+                continue;
+            }
+            reason = "table";
+            located = tables::find_array_at(std::span<const std::byte>{storage.child},
+                                            tables::kTableArrayDescriptor,
+                                            table)
+                      && table.elementClass == tables::kItemIndexTableClass;
+        }
+        if (located) {
+            (void)build_item_rows(source, storage, table, rowCount, reason);
+        }
+    }
+    SecureZeroMemory(&keys, sizeof keys);
+    const bool complete = package_domains_ready();
+    if (complete) {
+        // Nothing reads a package again until the next boot, so this reader's files go back now.
+        reader::close_files(storage.scratch);
+    }
+    report(complete ? state::build_data::item_definition_count() : 0, reason);
+    return complete;
+}
+
+} // namespace sunrise::client::content::items::packages

+ 100 - 0
Sunrise/src/client/content/items/packages/package_item_rows.cpp

@@ -0,0 +1,100 @@
+#include <array>
+#include <span>
+#include <vector>
+
+#include "../../../../state/build_data/runtime.h"
+#include "internal.h"
+
+namespace sunrise::client::content::items::packages {
+
+/** Walks the located item index table, then publishes every domain that depends on it. */
+bool build_item_rows(const reader::Source& source,
+                     Storage& storage,
+                     const tables::Array& table,
+                     std::size_t& rowCount,
+                     const char*& reason) noexcept {
+    const bool needDefinitions = !state::build_data::item_definitions_ready();
+    const bool needDetails = !state::build_data::configured_item_details_ready();
+    const bool needRows = needDefinitions || needDetails;
+    bool published = !needRows;
+    const std::span<const std::byte> container{storage.child};
+    reason = "rows";
+    // The requested detail set is gathered during this one walk. An authored row is matched by
+    // hash, because the index table that maps a hash to its index is what this loop is building.
+    const bool haveAuthored = needRows && collect_authored_hashes(storage.authoredHashes);
+    std::size_t detailCount = 0;
+    for (std::uint64_t index = 0; needRows && index < table.count && rowCount < storage.rows.size();
+         ++index) {
+        tables::IndexRow row{};
+        if (!tables::index_row(container, table, index, row)) {
+            break;
+        }
+        tables::items::Row item{};
+        item.definitionHash = row.definitionHash;
+        item.definitionIndex = static_cast<std::uint16_t>(index);
+        if (!reader::read_tag(source, storage.scratch, row.targetTag, storage.definition)
+            || !tables::items::read_definition(std::span<const std::byte>{storage.definition},
+                                               item)) {
+            continue;
+        }
+        storage.rows[rowCount++] = state::build_data::items::Definition{
+            item.definitionHash, item.definitionIndex, item.bucketId};
+        if (haveAuthored && authored(storage.authoredHashes, item.definitionHash)) {
+            (void)request(item.definitionIndex, storage.requested, detailCount);
+            (void)append_initial_plugs(item, storage.requested, detailCount);
+        }
+    }
+    if (needRows) {
+        compact_requested(storage.requested, detailCount);
+        published = rowCount != 0;
+    }
+    if (published && needDefinitions) {
+        published =
+            state::build_data::publish_item_definitions(std::span(storage.rows).first(rowCount));
+    }
+    if (!published) {
+        reason = "publish";
+    }
+    // Configured rows and every plug they socket each need a detail record, and they are found
+    // through the table this pass just published.
+    if (published && needDetails) {
+        reason = "details";
+        const DetailSource detailSource{
+            &source, &storage.scratch, container, table, &storage.definition};
+        for (std::size_t slot = 0; published && slot < detailCount; ++slot) {
+            published = build_detail(detailSource, storage.requested[slot], storage.details[slot]);
+            if (!published) {
+                report_detail_failure(slot, storage.requested[slot]);
+            }
+        }
+        published = published
+                    && state::build_data::publish_configured_item_details(
+                        std::span(storage.details).first(detailCount));
+        report_detail_count(detailCount);
+    }
+    // Ability buckets read the socket entry list table again and depend on the detail domain, so
+    // they run last.
+    if (published && !state::build_data::ability_buckets_ready()) {
+        reason = "abilities";
+        std::size_t abilityCount = 0;
+        const bool built = build_character_abilities(source,
+                                                     storage.scratch,
+                                                     std::span<const std::byte>{storage.root},
+                                                     storage.abilityTable,
+                                                     storage.definition,
+                                                     storage.abilityPool,
+                                                     storage.abilityRows,
+                                                     abilityCount);
+        published = built
+                    && state::build_data::publish_ability_buckets(
+                        std::span(storage.abilityRows).first(abilityCount));
+        if (built) {
+            report_ability_count(abilityCount);
+        }
+    }
+    return published && state::build_data::item_definitions_ready()
+           && state::build_data::configured_item_details_ready()
+           && state::build_data::ability_buckets_ready();
+}
+
+} // namespace sunrise::client::content::items::packages

+ 44 - 0
Sunrise/src/client/content/items/packages/package_progression_build.cpp

@@ -0,0 +1,44 @@
+#include "internal.h"
+
+namespace sunrise::client::content::items::packages {
+
+/** Reads the progression definition table and the object array each definition routes to. */
+bool build_progressions(const reader::Source& source,
+                        reader::Scratch& scratch,
+                        std::span<const std::byte> root,
+                        std::vector<std::byte>& blob,
+                        std::span<state::build_data::progressions::Definition> output,
+                        std::size_t& count) noexcept {
+    namespace domain = state::build_data::progressions;
+    count = 0;
+    std::uint32_t tableTag = 0;
+    tables::Array rows{};
+    if (!tables::slot_tag(root, tables::kProgressionTableSlot, tableTag) || tableTag == 0
+        || !reader::read_tag(source, scratch, tableTag, blob)
+        || !tables::find_array_at(
+            std::span<const std::byte>{blob}, tables::kTableArrayDescriptor, rows)
+        || rows.elementClass != tables::kProgressionTableClass || rows.count > output.size()) {
+        return false;
+    }
+    const std::span<const std::byte> table{blob};
+    for (std::uint64_t row = 0; row < rows.count; ++row) {
+        const std::size_t at = rows.dataOffset
+                               + static_cast<std::size_t>(row) * tables::kProgressionRowStride
+                               + tables::kProgressionScopeOffset;
+        if (at >= table.size()) {
+            count = 0;
+            return false;
+        }
+        const auto scope = std::to_integer<std::uint8_t>(table[at]);
+        // Any scope beyond the two replicated objects belongs to an object this server does not
+        // push, so it claims no slot in either array.
+        output[count] = {static_cast<std::uint16_t>(row),
+                         scope <= static_cast<std::uint8_t>(domain::Scope::character)
+                             ? static_cast<domain::Scope>(scope)
+                             : domain::Scope::unreplicated};
+        ++count;
+    }
+    return count != 0;
+}
+
+} // namespace sunrise::client::content::items::packages

+ 129 - 0
Sunrise/src/client/content/items/packages/package_root_tables.cpp

@@ -0,0 +1,129 @@
+#include <cstring>
+#include <vector>
+
+#include "../../../../state/build_data/inventory/buckets/definition.h"
+#include "../../../../state/build_data/runtime.h"
+#include "../../../../state/build_data/socket_entry_lists/definition.h"
+#include "internal.h"
+
+namespace sunrise::client::content::items::packages {
+
+/** Publishes the inventory bucket descriptors from the root's bucket table. */
+bool build_buckets(const reader::Source& source,
+                   Storage& storage,
+                   std::span<const std::byte> root) noexcept {
+    namespace buckets = state::build_data::inventory::buckets;
+    if (state::build_data::inventory_bucket_descriptors_ready()) {
+        return true;
+    }
+    std::uint32_t tableTag = 0;
+    if (!tables::slot_tag(root, tables::kBucketTableSlot, tableTag) || tableTag == 0
+        || !reader::read_tag(source, storage.scratch, tableTag, storage.child)) {
+        return false;
+    }
+    const std::span<const std::byte> blob{storage.child};
+    std::int32_t count = 0;
+    if (blob.size() < tables::kBucketFirstDescriptor + sizeof count) {
+        return false;
+    }
+    std::memcpy(&count, blob.data() + tables::kBucketCountOffset, sizeof count);
+    if (count <= 0 || static_cast<std::size_t>(count) > buckets::kDescriptorCapacity) {
+        return false;
+    }
+    std::vector<buckets::Descriptor> rows(static_cast<std::size_t>(count));
+    for (std::size_t index = 0; index < rows.size(); ++index) {
+        const std::size_t base =
+            tables::kBucketFirstDescriptor + index * tables::kBucketDescriptorSize;
+        if (base + tables::kBucketDescriptorSize > blob.size()) {
+            return false;
+        }
+        std::int32_t firstSlot = 0;
+        std::int32_t slotCount = 0;
+        std::memcpy(
+            &firstSlot, blob.data() + base + tables::kBucketFirstSlotOffset, sizeof firstSlot);
+        std::memcpy(
+            &slotCount, blob.data() + base + tables::kBucketSlotCountOffset, sizeof slotCount);
+        rows[index].bucketId = std::to_integer<std::uint8_t>(blob[base]);
+        rows[index].firstSlot = static_cast<std::uint16_t>(firstSlot);
+        rows[index].slotCount = static_cast<std::uint16_t>(slotCount);
+        rows[index].arraySelector = static_cast<buckets::ArraySelector>(
+            std::to_integer<std::uint8_t>(blob[base + tables::kBucketArraySelectorOffset]));
+    }
+    return state::build_data::publish_inventory_bucket_descriptors(rows);
+}
+
+/** Publishes the socket entry list table from the root. */
+bool build_socket_entry_lists(const reader::Source& source,
+                              Storage& storage,
+                              std::span<const std::byte> root) noexcept {
+    namespace lists = state::build_data::socket_entry_lists;
+    if (state::build_data::socket_entry_lists_ready()) {
+        return true;
+    }
+    std::uint32_t tableTag = 0;
+    tables::Array table{};
+    if (!tables::slot_tag(root, tables::kSocketEntryListTableSlot, tableTag) || tableTag == 0
+        || !reader::read_tag(source, storage.scratch, tableTag, storage.child)
+        || !tables::find_array_at(
+            std::span<const std::byte>{storage.child}, tables::kTableArrayDescriptor, table)
+        || table.elementClass != tables::kSocketEntryListTableClass) {
+        return false;
+    }
+    const std::span<const std::byte> blob{storage.child};
+    std::vector<lists::Definition> rows(static_cast<std::size_t>(table.count));
+    std::vector<lists::EntryTable> entryTables;
+    for (std::size_t index = 0; index < rows.size(); ++index) {
+        lists::EntryTable entryTable{};
+        bool carriesSuperLane = false;
+        tables::IndexRow row{};
+        if (!tables::index_row(blob, table, index, row)) {
+            return false;
+        }
+        rows[index].definitionHash = row.definitionHash;
+        rows[index].definitionIndex = static_cast<std::uint16_t>(index);
+        if (row.targetTag == 0
+            || !reader::read_tag(source, storage.scratch, row.targetTag, storage.definition)) {
+            continue;
+        }
+        const std::span<const std::byte> target{storage.definition};
+        tables::Array entries{};
+        if (!tables::find_array_at(target, tables::kSocketEntryArrayDescriptor, entries)) {
+            continue;
+        }
+        rows[index].entryCount = static_cast<std::uint8_t>(entries.count);
+        for (std::uint64_t entry = 0; entry < entries.count && entry < 64U; ++entry) {
+            const std::size_t base =
+                entries.dataOffset + static_cast<std::size_t>(entry) * tables::kSocketEntrySize;
+            std::uint32_t plugSource = 0;
+            if (base + tables::kSocketEntryKind + 1 > target.size()) {
+                break;
+            }
+            std::memcpy(&plugSource,
+                        target.data() + base + tables::kSocketEntryPlugSource,
+                        sizeof plugSource);
+            if (plugSource != tables::kNoPlugSource) {
+                rows[index].readyMask |= std::uint64_t{1} << entry;
+            }
+            // The group and kind decide which entries the character's selection makes active.
+            if (entry < lists::kEntryCapacity) {
+                lists::Entry& record = entryTable.entries[static_cast<std::size_t>(entry)];
+                record.plugSource = plugSource;
+                std::memcpy(&record.group,
+                            target.data() + base + tables::kSocketEntryGroup,
+                            sizeof record.group);
+                std::memcpy(&record.kind,
+                            target.data() + base + tables::kSocketEntryKind,
+                            sizeof record.kind);
+                carriesSuperLane = carriesSuperLane || record.kind == lists::kSuperEntryKind;
+            }
+        }
+        // Only a list with a super lane belongs to a subclass, and only those are selected.
+        if (carriesSuperLane && entryTables.size() < lists::kEntryTableCapacity) {
+            entryTable.definitionIndex = static_cast<std::uint16_t>(index);
+            entryTables.push_back(entryTable);
+        }
+    }
+    return state::build_data::publish_socket_entry_lists(rows, entryTables);
+}
+
+} // namespace sunrise::client::content::items::packages

+ 98 - 0
Sunrise/src/client/content/items/packages/package_subclass_build.cpp

@@ -0,0 +1,98 @@
+#include <array>
+
+#include "../../../../state/account/account_state.h"
+#include "../../../../state/build_data/runtime.h"
+#include "../../../../state/runtime/runtime.h"
+#include "internal.h"
+
+namespace sunrise::client::content::items::packages {
+namespace {
+
+namespace domain = state::build_data::abilities;
+
+/** The authored equipment slot that holds the subclass. */
+constexpr std::size_t kSubclassSlot =
+    static_cast<std::size_t>(state::account::inventory::EquipmentSlot::subclass);
+
+/**
+ * Finds the socket entry list that carries one character's subclass abilities.
+ * @param character Authored character.
+ * @param socketEntryListIndex Receives the subclass's socket-entry-list index.
+ * @return True when the character equips a subclass whose detail is published.
+ */
+[[nodiscard]] bool subclass_list(const state::CharacterState& character,
+                                 std::uint16_t& socketEntryListIndex) noexcept {
+    const auto& slot = character.equipment.slots[kSubclassSlot];
+    state::build_data::items::Definition item{};
+    state::build_data::items::details::Definition detail{};
+    if (!slot.has_value()
+        || !state::build_data::find_item_definition_hash(slot->definitionHash, item)
+        || !state::build_data::find_configured_item_detail(item.definitionIndex, detail)) {
+        return false;
+    }
+    socketEntryListIndex = detail.socketEntryListIndex;
+    return true;
+}
+
+/** @param rows Rows built so far. @return True when the candidate's key is already held. */
+[[nodiscard]] bool held(std::span<const domain::Definition> rows,
+                        const domain::Definition& row) noexcept {
+    for (const domain::Definition& existing : rows) {
+        if (existing.socketEntryListIndex == row.socketEntryListIndex
+            && existing.movementEntry == row.movementEntry) {
+            return true;
+        }
+    }
+    return false;
+}
+
+} // namespace
+
+/** Builds one ability bucket row per distinct subclass and movement selection in use. */
+bool build_character_abilities(const reader::Source& source,
+                               reader::Scratch& scratch,
+                               std::span<const std::byte> root,
+                               std::vector<std::byte>& table,
+                               std::vector<std::byte>& definition,
+                               std::vector<std::byte>& blob,
+                               std::span<state::build_data::abilities::Definition> output,
+                               std::size_t& count) noexcept {
+    count = 0;
+    std::uint32_t tableTag = 0;
+    tables::Array rows{};
+    if (!tables::slot_tag(root, tables::kSocketEntryListTableSlot, tableTag) || tableTag == 0
+        || !reader::read_tag(source, scratch, tableTag, table)
+        || !tables::find_array_at(
+            std::span<const std::byte>{table}, tables::kTableArrayDescriptor, rows)) {
+        return false;
+    }
+    const state::AccountState account = state::account_snapshot();
+    for (std::size_t character = 0; character < account.characterCount && count < output.size();
+         ++character) {
+        domain::Definition row{};
+        if (!subclass_list(account.characters[character], row.socketEntryListIndex)) {
+            continue;
+        }
+        row.movementEntry = account.characters[character].movementAbilityEntry;
+        if (held(output.first(count), row)) {
+            continue;
+        }
+        tables::IndexRow indexRow{};
+        if (!tables::index_row(
+                std::span<const std::byte>{table}, rows, row.socketEntryListIndex, indexRow)
+            || indexRow.targetTag == 0
+            || !reader::read_tag(source, scratch, indexRow.targetTag, definition)
+            || !build_ability_buckets(source,
+                                      scratch,
+                                      std::span<const std::byte>{definition},
+                                      blob,
+                                      row.movementEntry,
+                                      row)) {
+            continue;
+        }
+        output[count++] = row;
+    }
+    return true;
+}
+
+} // namespace sunrise::client::content::items::packages

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

@@ -0,0 +1,204 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <span>
+#include <vector>
+
+#include "../../../middleware/content/packages/reader/reader.h"
+#include "../../../middleware/content/packages/tables/roster_intersection.h"
+#include "../../../state/build_data/scenarios/definition.h"
+
+namespace sunrise::client::content::scenarios {
+
+namespace layouts = state::build_data::scenarios;
+namespace reader = middleware::content::packages::reader;
+
+/**
+ * Placed-object tags the memo holds. The walk reaches 5,826 distinct objects over the installed
+ * packages, and the table needs headroom to stay a cheap open-addressed probe.
+ */
+inline constexpr std::size_t kObjectMemoCapacity = 16'384;
+/** Memo value for an object that declares no roster slot type. */
+inline constexpr std::uint16_t kNotARosterGroup = 0xFFFF;
+/** Slot types run from 1 through the widest the packages declare. */
+inline constexpr std::size_t kSlotTypeSpan = layouts::kMaximumSlotType + 1;
+
+/** One memo row: a placed-object tag and the roster group it produced. */
+struct ObjectMemo {
+    std::uint32_t tag{};
+    std::uint16_t group{kNotARosterGroup};
+};
+
+/** Fixed working storage for one roster pass, kept off the caller stack. */
+struct RosterStorage {
+    std::vector<std::byte> scenario;
+    std::vector<std::byte> entry;
+    std::vector<std::byte> registry;
+    std::vector<std::byte> object;
+    std::vector<std::byte> chain;
+    std::array<ObjectMemo, kObjectMemoCapacity> memo{};
+    std::array<layouts::RosterGroup, layouts::kRosterGroupCapacity> groups{};
+    std::size_t groupCount{};
+    /** Slot flags per slot type, read from a group object's descriptor chain. */
+    std::array<std::uint8_t, kSlotTypeSpan> slotFlags{};
+    std::array<std::uint8_t, kSlotTypeSpan> slotFlagsKnown{};
+    /** Group objects whose descriptor chain did not give every slot type they declare. */
+    std::size_t unresolvedGroups{};
+    /** Destinations walked so far. The walk resumes here on the next call. */
+    std::size_t cursor{};
+    /** Tag reads spent in the current call, which is what bounds how long it blocks. */
+    std::size_t reads{};
+};
+
+/** Tag-read budget bounds one process-freeze interval and keeps worker shutdown responsive. */
+inline constexpr std::size_t kRosterReadBudget = 150;
+
+/** Live scenario tags found by the class sweep. The measured live count is 468. */
+inline constexpr std::size_t kLiveTagCapacity = 1'024;
+/**
+ * How long the collection keeps retrying the destinations that have not read yet.
+ * Packages register during the boot, so an early attempt reads fewer of them. One run latched at
+ * 417 of 466 and the destination it dropped was the Tower.
+ */
+inline constexpr std::uint64_t kResolveWindowMs = 15'000;
+/** Tag reads one collection call may spend, for the same reason the roster walk is bounded. */
+inline constexpr std::size_t kResolveReadBudget = 150;
+
+/** One live scenario tag and the map-package stem of the package that carries it. */
+struct LiveTag {
+    std::uint32_t tag{};
+    std::array<char, layouts::kSpawnStemCapacity> stem{};
+    std::uint8_t stemLength{};
+};
+
+/** One pass of fixed storage, kept off the caller stack. */
+struct Storage {
+    std::array<LiveTag, kLiveTagCapacity> liveTags{};
+    std::size_t liveTagCount{};
+    std::array<layouts::Definition, layouts::kDefinitionCapacity> rows{};
+    std::size_t rowCount{};
+    /** Patch index each row's tag came from, so a later patch replaces an earlier one. */
+    std::array<std::uint32_t, layouts::kDefinitionCapacity> rowPatch{};
+    /** One byte per row: set once its bubble layout has read. */
+    std::array<std::uint8_t, layouts::kDefinitionCapacity> resolved{};
+    std::size_t resolvedCount{};
+    /** Rows whose tag the class sweep still carries. Only these can ever read. */
+    std::size_t liveRowCount{};
+    /** Where the next resolve pass starts, so every pending row is retried in turn. */
+    std::size_t resolveCursor{};
+    /** Tick after which the collection stops waiting for the rows that have not read. */
+    std::uint64_t resolveDeadlineTick{};
+    std::vector<std::byte> blob{};
+    RosterStorage roster{};
+    /** Resolved rows, moved to the front. The roster walk runs over exactly these. */
+    std::size_t keptCount{};
+    /** Retried rounds of the resolve window, so a boot that never reads reports it once. */
+    std::uint32_t resolveRounds{};
+    /** Set once the sweep and the name match are done, so they run once per boot. */
+    bool collected{};
+    /** Set once the resolved rows are compacted and the roster walk may start. */
+    bool compacted{};
+};
+
+/**
+ * Sweeps the installed packages for scenario tags and matches them to destination names.
+ * @param source Package directory and borrowed block keys.
+ * @param storage Pass storage receiving the live tags and the named rows.
+ * @param reason Receives the step that refused, or stays null.
+ * @return True when both steps finished.
+ */
+[[nodiscard]] bool
+collect_rows(const reader::Source& source, Storage& storage, const char*& reason) noexcept;
+
+/**
+ * Reads the bubble layout of rows that have not read yet, within this call's budget.
+ * @param source Package directory and borrowed block keys.
+ * @param scratch Lock-owned block storage.
+ * @param storage Pass storage carrying the resolve cursor.
+ * @return True when the collection has settled and may be compacted.
+ */
+[[nodiscard]] bool
+resolve_pending(const reader::Source& source, reader::Scratch& scratch, Storage& storage) noexcept;
+
+/**
+ * Moves every resolved row to the front of the row array.
+ * @param storage Pass storage whose kept count is set here.
+ */
+void compact_rows(Storage& storage) noexcept;
+
+/**
+ * Re-arms the resolve window so a pass that read nothing tries the whole row set again.
+ * @param storage Pass storage whose resolve state is cleared.
+ */
+void rearm_resolve(Storage& storage) noexcept;
+
+/** One candidate group of one destination, with what its publish order is sorted on. */
+struct Candidate {
+    std::uint16_t group{};
+    std::uint32_t key{};
+    bool bindsPlayer{};
+    bool reportsLifetime{};
+    bool primaryRegistry{};
+};
+
+/** @return True when both groups carry the same registry key and full wire slot layout. */
+[[nodiscard]] constexpr bool same_group_layout(const layouts::RosterGroup& left,
+                                               const layouts::RosterGroup& right) noexcept {
+    if (left.registryKey != right.registryKey || left.slotCount != right.slotCount) {
+        return false;
+    }
+    for (std::size_t slot = 0; slot < left.slotCount; ++slot) {
+        if (left.slotTypes[slot] != right.slotTypes[slot]
+            || left.slotFlags[slot] != right.slotFlags[slot]) {
+            return false;
+        }
+    }
+    return true;
+}
+
+/** Everything one destination's walk builds up. */
+struct Walk {
+    middleware::content::packages::tables::RosterIntersection intersection{};
+    std::array<Candidate, middleware::content::packages::tables::kRosterKeyCapacity> candidates{};
+    std::size_t candidateCount{};
+};
+
+/**
+ * Keeps the candidates whose key is in every slice set and writes them into the destination row.
+ * @param walk Accumulator for one destination.
+ * @param row Destination row receiving its group indices.
+ */
+void publish_safe(Walk& walk, layouts::Definition& row) noexcept;
+
+/**
+ * Finds the roster group of one placed object, reading it only the first time it is seen.
+ * @param source Package directory and borrowed block keys.
+ * @param scratch Lock-owned block storage.
+ * @param storage Working storage for this pass.
+ * @param objectTag Tag from an object registry.
+ * @param group Receives the roster group index, or the not-a-group sentinel.
+ * @return True when the object was read or was already known.
+ */
+[[nodiscard]] bool resolve_object(const reader::Source& source,
+                                  reader::Scratch& scratch,
+                                  RosterStorage& storage,
+                                  std::uint32_t objectTag,
+                                  std::uint16_t& group) noexcept;
+
+/**
+ * Walks the next batch of destination rows for their roster groups.
+ * One call spends at most the read budget and then returns, so the pass resumes across calls.
+ * @param source Package directory and borrowed block keys.
+ * @param scratch Lock-owned block storage.
+ * @param storage Working storage carrying the cursor between calls.
+ * @param rows Destination rows whose tag is already set, updated in place.
+ * @return True when every row has been walked.
+ */
+[[nodiscard]] bool build_rosters(const reader::Source& source,
+                                 reader::Scratch& scratch,
+                                 RosterStorage& storage,
+                                 std::span<layouts::Definition> rows) noexcept;
+
+} // namespace sunrise::client::content::scenarios

+ 114 - 0
Sunrise/src/client/content/scenarios/scenario_build.cpp

@@ -0,0 +1,114 @@
+#include "scenario_build.h"
+
+#include <array>
+#include <cstdio>
+
+#include "../../../core/logging/log.h"
+#include "../../../state/build_data/runtime.h"
+#include "internal.h"
+
+namespace sunrise::client::content::scenarios {
+namespace {
+
+namespace packages = middleware::content::packages;
+
+/**
+ * Reports the pass so a boot that falls back to authored defaults says which step lost the rows.
+ * Each count is separate on purpose: a sweep that finds tags, a name match that finds none, and a
+ * blob read that drops every row all end with no domain and need different fixes.
+ * @param storage Pass storage holding every count.
+ * @param kept Rows whose blob read and parsed.
+ * @param rostered Rows that published at least one roster group.
+ * @param result Outcome text for the log line.
+ */
+void report(const Storage& storage,
+            std::size_t kept,
+            std::size_t rostered,
+            const char* result) noexcept {
+    std::array<char, core::log::kLineCapacity> line{};
+    const int written =
+        std::snprintf(line.data(),
+                      line.size(),
+                      "ev=build_data stage=scenarios tags=%zu named=%zu live=%zu kept=%zu "
+                      "groups=%zu dropped=%zu rostered=%zu result=%s",
+                      storage.liveTagCount,
+                      storage.rowCount,
+                      storage.liveRowCount,
+                      kept,
+                      storage.roster.groupCount,
+                      storage.roster.unresolvedGroups,
+                      rostered,
+                      result);
+    if (written > 0) {
+        core::log::write(core::log::Channel::state,
+                         kept != 0 ? core::log::Level::info : core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/**
+ * Walks the next batch of rosters and publishes the domain once the walk finishes.
+ * @param source Package directory and borrowed block keys.
+ * @param scratch Lock-owned block storage.
+ * @param storage Pass storage holding the collected rows and the walk cursor.
+ * @return True only when the whole domain is published.
+ */
+[[nodiscard]] bool walk_rosters(const packages::reader::Source& source,
+                                packages::reader::Scratch& scratch,
+                                Storage& storage) noexcept {
+    const auto rows = std::span(storage.rows).first(storage.keptCount);
+    if (!build_rosters(source, scratch, storage.roster, rows)) {
+        return false;
+    }
+    std::size_t rostered = 0;
+    for (const layouts::Definition& row : rows) {
+        rostered += row.rosterGroupCount != 0 ? 1U : 0U;
+    }
+    const bool published = state::build_data::publish_scenario_layouts(
+        rows, std::span(storage.roster.groups).first(storage.roster.groupCount));
+    report(storage, storage.keptCount, rostered, published ? "ok" : "publish");
+    storage.roster = {};
+    return published;
+}
+
+} // namespace
+
+/** Extracts every destination's bubble layout and roster from the installed packages, once. */
+bool build(const packages::reader::Source& source, packages::reader::Scratch& scratch) noexcept {
+    if (state::build_data::scenario_layouts_ready()) {
+        return true;
+    }
+    static Storage storage{};
+    if (!storage.collected) {
+        const char* reason = nullptr;
+        if (!collect_rows(source, storage, reason)) {
+            report(storage, 0, 0, reason);
+            return false;
+        }
+        storage.collected = true;
+        report(storage, 0, 0, "collecting");
+        return false;
+    }
+    if (!storage.compacted) {
+        if (!resolve_pending(source, scratch, storage)) {
+            return false;
+        }
+        compact_rows(storage);
+        // An empty result is never a finished pass. Every blob read needs the block keys, and
+        // those arrive during the boot, so a window that closes first reads nothing. Latching it
+        // would publish a domain with no destinations for the whole run. Only the first empty
+        // round reports, because the retry runs on every worker slice.
+        if (storage.keptCount == 0) {
+            if (storage.resolveRounds == 0) {
+                report(storage, 0, 0, "empty");
+            }
+            rearm_resolve(storage);
+            return false;
+        }
+        report(storage, storage.keptCount, 0, "collected");
+        storage.compacted = true;
+    }
+    return walk_rosters(source, scratch, storage);
+}
+
+} // namespace sunrise::client::content::scenarios

+ 17 - 0
Sunrise/src/client/content/scenarios/scenario_build.h

@@ -0,0 +1,17 @@
+#pragma once
+
+#include "../../../middleware/content/packages/reader/reader.h"
+
+namespace sunrise::client::content::scenarios {
+
+/**
+ * Extracts every destination's bubble layout from the installed packages, once.
+ * Each destination costs one tag read, because activity message 1 reads only the scenario blob.
+ * @param source Package directory and borrowed block keys.
+ * @param scratch Lock-owned block storage shared with the item build.
+ * @return True when State already holds the domain or a full pass publishes it.
+ */
+[[nodiscard]] bool build(const middleware::content::packages::reader::Source& source,
+                         middleware::content::packages::reader::Scratch& scratch) noexcept;
+
+} // namespace sunrise::client::content::scenarios

+ 258 - 0
Sunrise/src/client/content/scenarios/scenario_collect.cpp

@@ -0,0 +1,258 @@
+#include <Windows.h>
+
+#include <algorithm>
+#include <string_view>
+
+#include "../../../middleware/content/packages/named_tags.h"
+#include "../../../middleware/content/packages/tables/bubble_state_reader.h"
+#include "../../../middleware/content/packages/tables/scenario_reader.h"
+#include "../spawn_sets/spawn_set_catalog_builder.h"
+#include "internal.h"
+
+namespace sunrise::client::content::scenarios {
+namespace {
+
+namespace packages = middleware::content::packages;
+
+/** The name suffix every destination's client scenario carries. */
+constexpr std::string_view kNameSuffix = ":scenario_client";
+
+// A destination's stem is the key its spawn sets are looked up by, so the two storages must
+// agree; a shorter one here would truncate the name and find nothing.
+static_assert(layouts::kSpawnStemCapacity == state::build_data::spawn_sets::kStemNameCapacity);
+
+/**
+ * Records one live scenario tag and the map stem of the package it came from.
+ * The stem is the key the destination's spawn sets are grouped under.
+ * @param context Storage.
+ * @param entry Live scenario tag with its package family.
+ * @return False only when storage is full.
+ */
+bool collect_tag(void* context, const reader::ClassEntry& entry) noexcept {
+    auto& storage = *static_cast<Storage*>(context);
+    if (storage.liveTagCount >= storage.liveTags.size()) {
+        return false;
+    }
+    LiveTag& live = storage.liveTags[storage.liveTagCount++];
+    live.tag = entry.tag;
+    // A package family with no representable stem leaves the row's stem empty, which costs the
+    // destination its spawn-set list and nothing else.
+    if (!spawn_sets::normalize_stem(entry.packageFamily, live.stem, live.stemLength)) {
+        live.stem = {};
+        live.stemLength = 0;
+    }
+    return true;
+}
+
+/**
+ * Finds one row's tag in the class sweep.
+ * @param storage Pass storage.
+ * @return Its live-tag row, or null when the sweep does not carry it.
+ */
+[[nodiscard]] const LiveTag* live_tag(const Storage& storage, std::uint32_t tag) noexcept {
+    for (std::size_t index = 0; index < storage.liveTagCount; ++index) {
+        if (storage.liveTags[index].tag == tag) {
+            return &storage.liveTags[index];
+        }
+    }
+    return nullptr;
+}
+
+/**
+ * Records one named scenario, keeping the newest patch of each destination.
+ * A destination is named once per patch of its package and the tag differs between them. The
+ * walk visits patch zero first, so keeping the first one seen would keep a stale tag.
+ * @param context Storage.
+ * @param entry Named definition from the package directory.
+ * @return False only when row storage is full.
+ */
+bool collect_named(void* context, const packages::named_tags::Entry& entry) noexcept {
+    auto& storage = *static_cast<Storage*>(context);
+    const std::string_view name{entry.name.data(), entry.nameLength};
+    if (entry.classId != packages::tables::kScenarioClass || !name.ends_with(kNameSuffix)) {
+        return true;
+    }
+    const std::string_view destination = name.substr(0, name.size() - kNameSuffix.size());
+    // A destination longer than the selection field could never be matched at lookup time.
+    if (destination.empty() || destination.size() > layouts::kNameCapacity) {
+        return true;
+    }
+    for (std::size_t row = 0; row < storage.rowCount; ++row) {
+        layouts::Definition& existing = storage.rows[row];
+        if (std::string_view(existing.name.data(), existing.nameLength) != destination) {
+            continue;
+        }
+        if (entry.patchIndex >= storage.rowPatch[row]) {
+            storage.rowPatch[row] = entry.patchIndex;
+            existing.tag = entry.tag;
+        }
+        return true;
+    }
+    if (storage.rowCount >= storage.rows.size()) {
+        return false;
+    }
+    layouts::Definition& definition = storage.rows[storage.rowCount];
+    std::copy(destination.begin(), destination.end(), definition.name.begin());
+    definition.nameLength = static_cast<std::uint8_t>(destination.size());
+    definition.tag = entry.tag;
+    storage.rowPatch[storage.rowCount] = entry.patchIndex;
+    ++storage.rowCount;
+    return true;
+}
+
+/**
+ * Adds one package id a destination loads.
+ * @param definition Destination row.
+ */
+void add_package(layouts::Definition& definition, std::uint16_t packageId) noexcept {
+    if (packageId == packages::tables::kAbsentPackageId
+        || definition.packageCount >= definition.packages.size()) {
+        return;
+    }
+    for (std::size_t index = 0; index < definition.packageCount; ++index) {
+        if (definition.packages[index] == packageId) {
+            return;
+        }
+    }
+    definition.packages[definition.packageCount++] = packageId;
+}
+
+/**
+ * Reads one row's bubble layout.
+ * @param source Package directory and borrowed block keys.
+ * @param scratch Lock-owned block storage.
+ * @param storage Pass storage owning the blob buffer.
+ * @param definition Row to fill.
+ * @return True when the blob read and parsed as a scenario.
+ */
+[[nodiscard]] bool resolve_row(const reader::Source& source,
+                               reader::Scratch& scratch,
+                               Storage& storage,
+                               layouts::Definition& definition) noexcept {
+    packages::tables::BubbleStates states{};
+    if (!reader::read_tag(source, scratch, definition.tag, storage.blob)
+        || !packages::tables::bubble_states(storage.blob, states)) {
+        return false;
+    }
+    definition.bubbleCount = static_cast<std::uint8_t>(states.count);
+    definition.truncated = states.truncated ? 1U : 0U;
+    std::copy(states.bytes.begin(), states.bytes.end(), definition.bubbleStates.begin());
+    std::copy(states.hashes.begin(), states.hashes.end(), definition.bubbleHashes.begin());
+    std::copy(
+        states.stateCounts.begin(), states.stateCounts.end(), definition.bubbleStateCounts.begin());
+    std::copy(
+        states.mapIndices.begin(), states.mapIndices.end(), definition.bubbleMapIndices.begin());
+    definition.packageCount = 0;
+    // The destination's own package is loaded too, and a scenario whose bubbles name none would
+    // otherwise claim it loads nothing at all.
+    add_package(definition, packages::tables::package_of(definition.tag));
+    for (std::size_t index = 0; index < states.packageCount; ++index) {
+        add_package(definition, states.packages[index]);
+    }
+    return true;
+}
+
+} // namespace
+
+/**
+ * Sweeps the installed packages for scenario tags and matches them to destination names.
+ * @param source Package directory and borrowed block keys.
+ * @param storage Pass storage receiving the live tags and the named rows.
+ * @param reason Receives the step that refused, or stays null.
+ * @return True when both steps finished.
+ */
+bool collect_rows(const reader::Source& source, Storage& storage, const char*& reason) noexcept {
+    reason = nullptr;
+    storage.liveTagCount = 0;
+    storage.rowCount = 0;
+    storage.rowPatch.fill(0);
+    packages::reader::ScanResult scan{};
+    if (!packages::reader::scan_class_entries(
+            source.directory, packages::tables::kScenarioClass, &collect_tag, &storage, scan)) {
+        reason = "sweep";
+        return false;
+    }
+    packages::named_tags::DirectoryResult names{};
+    if (!packages::named_tags::extract_directory(
+            source.directory, &collect_named, &storage, names)) {
+        reason = "names";
+        return false;
+    }
+    storage.resolved.fill(0);
+    storage.resolvedCount = 0;
+    storage.resolveCursor = 0;
+    storage.liveRowCount = 0;
+    for (std::size_t row = 0; row < storage.rowCount; ++row) {
+        const LiveTag* const live = live_tag(storage, storage.rows[row].tag);
+        if (live == nullptr) {
+            continue;
+        }
+        ++storage.liveRowCount;
+        storage.rows[row].spawnStem = live->stem;
+        storage.rows[row].spawnStemLength = live->stemLength;
+    }
+    storage.resolveDeadlineTick = GetTickCount64() + kResolveWindowMs;
+    return true;
+}
+
+/**
+ * Reads the bubble layout of rows that have not read yet, within this call's budget.
+ * @param source Package directory and borrowed block keys.
+ * @param scratch Lock-owned block storage.
+ * @param storage Pass storage carrying the resolve cursor.
+ * @return True when the collection has settled and may be compacted.
+ */
+bool resolve_pending(const reader::Source& source,
+                     reader::Scratch& scratch,
+                     Storage& storage) noexcept {
+    std::size_t reads = 0;
+    std::size_t visited = 0;
+    while (visited < storage.rowCount && reads < kResolveReadBudget) {
+        const std::size_t row = storage.resolveCursor;
+        storage.resolveCursor = (storage.resolveCursor + 1) % storage.rowCount;
+        ++visited;
+        if (storage.resolved[row] != 0) {
+            continue;
+        }
+        ++reads;
+        if (resolve_row(source, scratch, storage, storage.rows[row])) {
+            storage.resolved[row] = 1;
+            ++storage.resolvedCount;
+        }
+    }
+    // Every row the sweep still carries has read, or the window has closed on the rest.
+    return storage.resolvedCount >= storage.liveRowCount
+           || GetTickCount64() >= storage.resolveDeadlineTick;
+}
+
+/**
+ * Re-arms the resolve window so a pass that read nothing tries the whole row set again.
+ * The rows and the name match are kept: only the per-row read outcome is cleared, because every
+ * read failed for one reason, the block keys were not ready yet.
+ * @param storage Pass storage whose resolve state is cleared.
+ */
+void rearm_resolve(Storage& storage) noexcept {
+    storage.resolved.fill(0);
+    storage.resolvedCount = 0;
+    storage.resolveCursor = 0;
+    storage.resolveDeadlineTick = GetTickCount64() + kResolveWindowMs;
+    ++storage.resolveRounds;
+}
+
+/**
+ * Moves every resolved row to the front of the row array.
+ * @param storage Pass storage whose kept count is set here.
+ */
+void compact_rows(Storage& storage) noexcept {
+    std::size_t kept = 0;
+    for (std::size_t row = 0; row < storage.rowCount; ++row) {
+        if (storage.resolved[row] != 0) {
+            storage.rows[kept++] = storage.rows[row];
+        }
+    }
+    storage.keptCount = kept;
+    storage.blob.clear();
+    storage.blob.shrink_to_fit();
+}
+
+} // namespace sunrise::client::content::scenarios

+ 215 - 0
Sunrise/src/client/content/scenarios/scenario_roster_build.cpp

@@ -0,0 +1,215 @@
+#include <array>
+#include <cstdio>
+
+#include "../../../core/logging/log.h"
+#include "../../../middleware/content/packages/tables/roster_intersection.h"
+#include "../../../middleware/content/packages/tables/scenario_reader.h"
+#include "internal.h"
+
+namespace sunrise::client::content::scenarios {
+namespace {
+
+namespace tables = middleware::content::packages::tables;
+
+/** The three registry descriptors, walked in this order to match the reference walk. */
+constexpr std::array<std::size_t, 3> kRegistryDescriptors = {
+    tables::kRegistryFirstDescriptor,
+    tables::kRegistrySecondDescriptor,
+    tables::kRegistryThirdDescriptor,
+};
+
+/** Destinations between progress lines. */
+constexpr std::size_t kRosterProgressInterval = 64;
+
+/**
+ * Reports how far the walk has reached.
+ * @param walked Destinations walked so far.
+ * @param total Destinations to walk.
+ * @param groups Roster groups found so far.
+ */
+void report_progress(std::size_t walked, std::size_t total, std::size_t groups) noexcept {
+    std::array<char, core::log::kLineCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=build_data stage=roster walked=%zu of=%zu groups=%zu",
+                                      walked,
+                                      total,
+                                      groups);
+    if (written > 0) {
+        core::log::write(core::log::Channel::state,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/** The two slot types a usable roster must carry between all of its groups. */
+constexpr std::uint8_t kSlotTypeParticipation = 13;
+constexpr std::uint8_t kSlotTypeLifetime = 17;
+
+/**
+ * Records one candidate group, or updates the one already recorded for its key.
+ * @param walk Accumulator for one destination.
+ * @param storage Working storage holding the group table.
+ * @param group Roster group index.
+ * @param primary True when the object was reached through the destination's own registry array.
+ */
+void note_candidate(Walk& walk,
+                    const RosterStorage& storage,
+                    std::uint16_t group,
+                    bool primary) noexcept {
+    for (std::size_t index = 0; index < walk.candidateCount; ++index) {
+        if (walk.candidates[index].group == group) {
+            walk.candidates[index].primaryRegistry =
+                walk.candidates[index].primaryRegistry || primary;
+            return;
+        }
+    }
+    if (walk.candidateCount == walk.candidates.size()) {
+        return;
+    }
+    const layouts::RosterGroup& row = storage.groups[group];
+    Candidate& candidate = walk.candidates[walk.candidateCount++];
+    candidate.group = group;
+    candidate.key = row.registryKey;
+    candidate.primaryRegistry = primary;
+    for (std::size_t slot = 0; slot < row.slotCount; ++slot) {
+        candidate.bindsPlayer =
+            candidate.bindsPlayer || row.slotTypes[slot] == kSlotTypeParticipation;
+        candidate.reportsLifetime =
+            candidate.reportsLifetime || row.slotTypes[slot] == kSlotTypeLifetime;
+    }
+}
+
+/**
+ * Walks one slice-set state to every placed object its registry names.
+ * @param source Package directory and borrowed block keys.
+ * @param scratch Lock-owned block storage.
+ * @param storage Working storage for this pass.
+ * @param walk Accumulator for one destination.
+ * @param sliceSetIndex Slice-set index the entry reported.
+ * @return True when the registry walked without running out of fixed storage.
+ */
+[[nodiscard]] bool walk_registry(const reader::Source& source,
+                                 reader::Scratch& scratch,
+                                 RosterStorage& storage,
+                                 Walk& walk,
+                                 std::uint32_t sliceSetIndex) noexcept {
+    for (std::size_t descriptor = 0; descriptor < kRegistryDescriptors.size(); ++descriptor) {
+        tables::Array objects{};
+        if (!tables::registry_objects(
+                storage.registry, kRegistryDescriptors[descriptor], objects)) {
+            // A registry declaring only some of its three arrays is ordinary.
+            continue;
+        }
+        for (std::uint64_t index = 0; index < objects.count; ++index) {
+            std::uint32_t objectTag = 0;
+            if (!tables::registry_object_at(storage.registry, objects, index, objectTag)) {
+                return false;
+            }
+            std::uint16_t group = kNotARosterGroup;
+            if (!resolve_object(source, scratch, storage, objectTag, group)) {
+                return false;
+            }
+            if (group == kNotARosterGroup) {
+                continue;
+            }
+            note_candidate(walk, storage, group, descriptor == 0);
+            if (!tables::observe_roster_key(
+                    walk.intersection, sliceSetIndex, storage.groups[group].registryKey)) {
+                return true;
+            }
+        }
+    }
+    return true;
+}
+
+/**
+ * Walks one destination's scenario to every slice-set state it declares.
+ * @param source Package directory and borrowed block keys.
+ * @param scratch Lock-owned block storage.
+ * @param storage Working storage for this pass.
+ * @param walk Accumulator for this destination.
+ * @return True when the scenario read and fixed storage held.
+ */
+[[nodiscard]] bool walk_destination(const reader::Source& source,
+                                    reader::Scratch& scratch,
+                                    RosterStorage& storage,
+                                    Walk& walk) noexcept {
+    tables::Array bubbles{};
+    if (!tables::scenario_bubbles(storage.scenario, bubbles)) {
+        return false;
+    }
+    for (std::uint64_t bubbleIndex = 0; bubbleIndex < bubbles.count; ++bubbleIndex) {
+        tables::Bubble bubble{};
+        if (!tables::bubble_at(storage.scenario, bubbles, bubbleIndex, bubble)) {
+            return false;
+        }
+        for (std::uint64_t stateIndex = 0; stateIndex < bubble.stateCount; ++stateIndex) {
+            tables::SliceState state{};
+            if (!tables::slice_state_at(storage.scenario, bubble, stateIndex, state)) {
+                return false;
+            }
+            tables::SliceEntry entry{};
+            ++storage.reads;
+            if (!reader::read_tag(source, scratch, state.entryTag, storage.entry)
+                || !tables::slice_entry(storage.entry, entry)) {
+                // The destination still transitions into that slice set, and no key can be proved
+                // present in it, so nothing of this destination stays safe.
+                tables::observe_unresolved_slice_set(walk.intersection);
+                continue;
+            }
+            const std::uint32_t sliceSetIndex = entry.index * tables::kSliceSetIndexFactor;
+            if (!tables::observe_slice_set(walk.intersection, sliceSetIndex)) {
+                return true;
+            }
+            ++storage.reads;
+            if (!reader::read_tag(source, scratch, entry.registryTag, storage.registry)) {
+                tables::observe_unresolved_slice_set(walk.intersection);
+                continue;
+            }
+            if (!walk_registry(source, scratch, storage, walk, sliceSetIndex)) {
+                return false;
+            }
+        }
+    }
+    return true;
+}
+
+} // namespace
+
+/**
+ * Builds the roster half of the domain for every destination row.
+ * @param source Package directory and borrowed block keys.
+ * @param scratch Lock-owned block storage.
+ * @param storage Working storage for this pass.
+ * @param rows Destination rows whose tag is already set, updated in place.
+ * @return True when every row was walked.
+ */
+bool build_rosters(const reader::Source& source,
+                   reader::Scratch& scratch,
+                   RosterStorage& storage,
+                   std::span<layouts::Definition> rows) noexcept {
+    storage.reads = 0;
+    while (storage.cursor < rows.size() && storage.reads < kRosterReadBudget) {
+        // The walk is thousands of tag reads, so it reports progress rather than going quiet.
+        if (storage.cursor % kRosterProgressInterval == 0) {
+            report_progress(storage.cursor, rows.size(), storage.groupCount);
+        }
+        layouts::Definition& row = rows[storage.cursor];
+        ++storage.cursor;
+        row.rosterGroupCount = 0;
+        row.rosterGroups = {};
+        ++storage.reads;
+        if (!reader::read_tag(source, scratch, row.tag, storage.scenario)) {
+            continue;
+        }
+        Walk walk{};
+        if (!walk_destination(source, scratch, storage, walk)) {
+            continue;
+        }
+        publish_safe(walk, row);
+    }
+    return storage.cursor >= rows.size();
+}
+
+} // namespace sunrise::client::content::scenarios

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

@@ -0,0 +1,220 @@
+#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"
+#include "internal.h"
+
+namespace sunrise::client::content::scenarios {
+namespace {
+
+namespace tables = middleware::content::packages::tables;
+
+/** How many hops the chain from a handle to a descriptor blob may take. */
+constexpr std::size_t kChainDepthLimit = 8;
+
+/**
+ * Records one descriptor's schemas against its slot type.
+ * @param context Roster storage.
+ * @param descriptor Descriptor read from a placed-object blob.
+ * @return Always true, because a descriptor of an unknown type is ordinary.
+ */
+bool record_flags(void* context, const tables::SlotDescriptor& descriptor) noexcept {
+    auto& storage = *static_cast<RosterStorage*>(context);
+    if (descriptor.slotType >= kSlotTypeSpan) {
+        return true;
+    }
+    std::uint8_t flags = 0;
+    if (descriptor.authSchema != tables::kAbsentSchema) {
+        flags |= layouts::kSlotAuthFlag;
+    }
+    if (descriptor.senseSchema != tables::kAbsentSchema) {
+        flags |= layouts::kSlotSenseFlag;
+    }
+    storage.slotFlags[descriptor.slotType] = flags;
+    storage.slotFlagsKnown[descriptor.slotType] = 1;
+    return true;
+}
+
+/**
+ * Follows one placed handle to its descriptor blob and records what it declares.
+ * @param source Package directory and borrowed block keys.
+ * @param scratch Lock-owned block storage.
+ * @param storage Working storage for this pass.
+ * @param handle Tag from a placed object's per-bubble sub-block.
+ * @param registryKey Registry key the descriptors must name.
+ */
+void follow_handle(const reader::Source& source,
+                   reader::Scratch& scratch,
+                   RosterStorage& storage,
+                   std::uint32_t handle,
+                   std::uint32_t registryKey) noexcept {
+    std::uint32_t tag = handle;
+    for (std::size_t depth = 0; depth < kChainDepthLimit; ++depth) {
+        std::uint32_t classId = 0;
+        ++storage.reads;
+        if (!reader::read_tag(source, scratch, tag, storage.chain, classId)) {
+            return;
+        }
+        if (classId == tables::kPlacedObjectClass) {
+            (void)tables::visit_slot_descriptors(
+                storage.chain, tag, registryKey, &record_flags, &storage);
+            return;
+        }
+        std::uint32_t next = 0;
+        if (!tables::next_descriptor_tag(storage.chain, classId, next)) {
+            return;
+        }
+        tag = next;
+    }
+}
+
+/** @param group Candidate group. @return True when every slot type is known. */
+[[nodiscard]] bool flags_complete(const RosterStorage& storage,
+                                  const layouts::RosterGroup& group) noexcept {
+    for (std::size_t slot = 0; slot < group.slotCount; ++slot) {
+        if (storage.slotFlagsKnown[group.slotTypes[slot]] == 0) {
+            return false;
+        }
+    }
+    return true;
+}
+
+/**
+ * Reads the slot flags one group object declares, stopping once every type is known.
+ * @param source Package directory and borrowed block keys.
+ * @param scratch Lock-owned block storage.
+ * @param storage Working storage for this pass.
+ * @param objectBlob Whole placed-object bytes.
+ * @param group Candidate group whose slot types are already filled.
+ */
+void resolve_flags(const reader::Source& source,
+                   reader::Scratch& scratch,
+                   RosterStorage& storage,
+                   std::span<const std::byte> objectBlob,
+                   const layouts::RosterGroup& group) noexcept {
+    tables::Array bubbles{};
+    if (flags_complete(storage, group) || !tables::object_bubbles(objectBlob, bubbles)) {
+        return;
+    }
+    for (std::uint64_t index = 0; index < bubbles.count; ++index) {
+        tables::ObjectBubble bubble{};
+        if (!tables::object_bubble_at(objectBlob, bubbles, index, bubble)) {
+            return;
+        }
+        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)) {
+                return;
+            }
+            follow_handle(source, scratch, storage, handle, group.registryKey);
+            if (flags_complete(storage, group)) {
+                return;
+            }
+        }
+    }
+}
+
+/**
+ * Fills one candidate group's slot types from the object's own slot array.
+ * @param objectBlob Whole placed-object bytes.
+ * @param group Receives the key and slot types.
+ * @return True when the object declares a usable slot array.
+ */
+[[nodiscard]] bool fill_slots(std::span<const std::byte> objectBlob,
+                              layouts::RosterGroup& group) noexcept {
+    tables::Array slots{};
+    if (!tables::object_slots(objectBlob, slots) || slots.count == 0
+        || slots.count > layouts::kRosterSlotCapacity) {
+        return false;
+    }
+    for (std::uint64_t index = 0; index < slots.count; ++index) {
+        tables::Slot slot{};
+        if (!tables::object_slot_at(objectBlob, slots, index, slot) || slot.type == 0
+            || slot.type > layouts::kMaximumSlotType) {
+            return false;
+        }
+        group.slotTypes[index] = static_cast<std::uint8_t>(slot.type);
+    }
+    group.slotCount = static_cast<std::uint16_t>(slots.count);
+    return true;
+}
+
+/** @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;
+    for (std::size_t step = 0; step < kObjectMemoCapacity; ++step) {
+        if (storage.memo[probe].tag == 0 || storage.memo[probe].tag == tag) {
+            return probe;
+        }
+        probe = (probe + 1) % kObjectMemoCapacity;
+    }
+    return kObjectMemoCapacity;
+}
+
+} // namespace
+
+/**
+ * Finds the roster group of one placed object, reading it only the first time it is seen.
+ * @param source Package directory and borrowed block keys.
+ * @param scratch Lock-owned block storage.
+ * @param storage Working storage for this pass.
+ * @param objectTag Tag from an object registry.
+ * @param group Receives the roster group index, or the not-a-group sentinel.
+ * @return True when the object was read or was already known.
+ */
+bool resolve_object(const reader::Source& source,
+                    reader::Scratch& scratch,
+                    RosterStorage& storage,
+                    std::uint32_t objectTag,
+                    std::uint16_t& group) noexcept {
+    group = kNotARosterGroup;
+    const std::size_t slot = memo_slot(storage, objectTag);
+    if (slot == kObjectMemoCapacity) {
+        return false;
+    }
+    if (storage.memo[slot].tag == objectTag) {
+        group = storage.memo[slot].group;
+        return true;
+    }
+    storage.memo[slot].tag = objectTag;
+    storage.memo[slot].group = kNotARosterGroup;
+    ++storage.reads;
+    if (!reader::read_tag(source, scratch, objectTag, storage.object)
+        || !tables::carries_roster_slot(storage.object)) {
+        return true;
+    }
+
+    layouts::RosterGroup candidate{};
+    if (!tables::object_key(storage.object, candidate.registryKey) || candidate.registryKey == 0
+        || !fill_slots(storage.object, candidate)) {
+        return true;
+    }
+    candidate.objectTag = objectTag;
+    resolve_flags(source, scratch, storage, storage.object, candidate);
+    if (!flags_complete(storage, candidate)) {
+        // A slot whose flags are unknown would be encoded with the wrong reset bits, and phase 2
+        // has no resync point, so the whole group is dropped instead.
+        ++storage.unresolvedGroups;
+        return true;
+    }
+    for (std::size_t index = 0; index < candidate.slotCount; ++index) {
+        candidate.slotFlags[index] = storage.slotFlags[candidate.slotTypes[index]];
+    }
+    // One key may carry different layouts in different activities, so only exact layouts reuse.
+    for (std::size_t index = 0; index < storage.groupCount; ++index) {
+        if (same_group_layout(storage.groups[index], candidate)) {
+            storage.memo[slot].group = static_cast<std::uint16_t>(index);
+            group = storage.memo[slot].group;
+            return true;
+        }
+    }
+    if (storage.groupCount == layouts::kRosterGroupCapacity) {
+        return false;
+    }
+    storage.groups[storage.groupCount] = candidate;
+    storage.memo[slot].group = static_cast<std::uint16_t>(storage.groupCount);
+    group = storage.memo[slot].group;
+    ++storage.groupCount;
+    return true;
+}
+
+} // namespace sunrise::client::content::scenarios

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

@@ -0,0 +1,72 @@
+#include <algorithm>
+#include <cstddef>
+
+#include "../../../middleware/content/packages/tables/roster_intersection.h"
+#include "internal.h"
+
+namespace sunrise::client::content::scenarios {
+namespace {
+
+namespace tables = middleware::content::packages::tables;
+
+/**
+ * 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
+ * destination's own registry array, then the lower key.
+ * @return True when left publishes before right.
+ */
+[[nodiscard]] bool publishes_first(const Candidate& left, const Candidate& right) noexcept {
+    const bool leftFilled = left.bindsPlayer || left.reportsLifetime;
+    const bool rightFilled = right.bindsPlayer || right.reportsLifetime;
+    if (leftFilled != rightFilled) {
+        return leftFilled;
+    }
+    if (left.primaryRegistry != right.primaryRegistry) {
+        return left.primaryRegistry;
+    }
+    return left.key < right.key;
+}
+
+} // namespace
+
+/**
+ * Keeps the candidates whose key is in every slice set and writes them into the destination row.
+ * @param walk Accumulator for one destination.
+ * @param row Destination row receiving its group indices.
+ */
+void publish_safe(Walk& walk, layouts::Definition& row) noexcept {
+    row.rosterGroupCount = 0;
+    row.rosterGroups = {};
+    std::array<std::uint32_t, tables::kRosterKeyCapacity> safe{};
+    std::size_t safeCount = 0;
+    if (!tables::safe_roster_keys(walk.intersection, safe, safeCount) || safeCount == 0) {
+        return;
+    }
+    std::array<Candidate, tables::kRosterKeyCapacity> kept{};
+    std::size_t keptCount = 0;
+    for (std::size_t index = 0; index < walk.candidateCount; ++index) {
+        const Candidate& candidate = walk.candidates[index];
+        const auto last = safe.begin() + static_cast<std::ptrdiff_t>(safeCount);
+        if (std::find(safe.begin(), last, candidate.key) != last && keptCount < kept.size()) {
+            kept[keptCount++] = candidate;
+        }
+    }
+    std::sort(kept.begin(), kept.begin() + static_cast<std::ptrdiff_t>(keptCount), publishes_first);
+    // A roster missing either filled type seeds nothing the spawn gate reads, so publish none.
+    bool binds = false;
+    bool reports = false;
+    for (std::size_t index = 0; index < keptCount; ++index) {
+        binds = binds || kept[index].bindsPlayer;
+        reports = reports || kept[index].reportsLifetime;
+    }
+    if (!binds || !reports) {
+        return;
+    }
+    const std::size_t published = (std::min)(keptCount, layouts::kDestinationGroupCapacity);
+    for (std::size_t index = 0; index < published; ++index) {
+        row.rosterGroups[index] = kept[index].group;
+    }
+    row.rosterGroupCount = static_cast<std::uint8_t>(published);
+}
+
+} // namespace sunrise::client::content::scenarios

+ 184 - 0
Sunrise/src/client/content/spawn_sets/spawn_set_build.cpp

@@ -0,0 +1,184 @@
+#include "spawn_set_build.h"
+
+#include <Windows.h>
+
+#include <array>
+#include <cstdio>
+#include <span>
+#include <vector>
+
+#include "../../../core/logging/log.h"
+#include "../../../middleware/content/packages/reader/parallel.h"
+#include "../../../middleware/content/packages/tables/component_container_reader.h"
+#include "../../../middleware/content/packages/tables/spawn_reader.h"
+#include "../../../state/build_data/runtime.h"
+#include "spawn_set_catalog_builder.h"
+
+namespace sunrise::client::content::spawn_sets {
+namespace {
+
+namespace reader = middleware::content::packages::reader;
+namespace tables = middleware::content::packages::tables;
+
+/** Package-reader inputs adapted to the injected blob-reader boundary. */
+struct ReadContext {
+    const reader::Source* source{};
+    reader::Scratch* scratch{};
+    std::vector<std::byte>* bytes{};
+    /** Blobs the last read-ahead kept, in the order the pass asks for them. */
+    std::vector<reader::parallel::Held>* kept{};
+    /** Row of that run the pass has reached. */
+    std::size_t keptCursor{};
+};
+
+/** @param context Pass storage. @param entry Scan match. @return True when the row fits. */
+[[nodiscard]] bool collect(void* context, const reader::ClassEntry& entry) noexcept {
+    return add_entry(*static_cast<Storage*>(context), entry);
+}
+
+/** @param context Pass storage. @param tag Component tag. @return True when the row fits. */
+[[nodiscard]] bool collect_component(void* context, std::uint32_t tag) noexcept {
+    return add_component(*static_cast<Storage*>(context), tag);
+}
+
+/** @param context Pass storage. @param tag Container tag. @return True when the row fits. */
+[[nodiscard]] bool collect_container(void* context, std::uint32_t tag) noexcept {
+    return add_container(*static_cast<Storage*>(context), tag);
+}
+
+/**
+ * Reads a run of tags the pass is about to ask for, on several readers at once.
+ * @param context Package source and kept blobs.
+ * @param tags Tags the pass will ask for, in order.
+ */
+void prefetch_blobs(void* context, std::span<const std::uint32_t> tags) noexcept {
+    auto& read = *static_cast<ReadContext*>(context);
+    read.keptCursor = 0;
+    if (!reader::parallel::read_kept(*read.source, tags, *read.kept)) {
+        read.kept->clear();
+    }
+}
+
+/**
+ * Reads one spawn-set tag through the production package reader.
+ * A blob the read-ahead already holds is served from it. Anything else reads here, so a tag the
+ * read-ahead missed or could not read behaves exactly as it always did.
+ * @param context Package source, scratch, output bytes and kept blobs.
+ * @param tag Spawn-set tag.
+ * @param blob Receives bytes live until the next read.
+ * @return True when the installed entry decodes.
+ */
+[[nodiscard]] bool
+read_blob(void* context, std::uint32_t tag, std::span<const std::byte>& blob) noexcept {
+    auto& read = *static_cast<ReadContext*>(context);
+    blob = {};
+    // The kept rows are in the order the pass asks for them, so one cursor serves them.
+    if (read.keptCursor < read.kept->size() && (*read.kept)[read.keptCursor].tag == tag) {
+        blob = (*read.kept)[read.keptCursor].blob;
+        ++read.keptCursor;
+        return true;
+    }
+    if (!reader::read_tag(*read.source, *read.scratch, tag, *read.bytes)) {
+        return false;
+    }
+    blob = *read.bytes;
+    return true;
+}
+
+/**
+ * Reports the pass so a boot that shows no spawn set says which step lost the rows.
+ * @param storage Pass storage holding every count.
+ * @param result Outcome text for the log line.
+ */
+void report(const Storage& storage, const char* result) noexcept {
+    std::array<char, core::log::kLineCapacity> line{};
+    const std::uint64_t elapsed =
+        storage.startedTick == 0 ? 0 : GetTickCount64() - storage.startedTick;
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=build_data stage=spawn_sets tags=%zu stems=%zu "
+                                      "hashes=%zu bound=%zu components=%zu containers=%zu "
+                                      "read=%zu skipped=%zu ms=%llu result=%s",
+                                      storage.entryCount,
+                                      storage.stemCount,
+                                      storage.nameHashCount,
+                                      storage.spawnComponentCount,
+                                      storage.componentCount,
+                                      storage.containerCount,
+                                      storage.cursor,
+                                      storage.skipped,
+                                      static_cast<unsigned long long>(elapsed),
+                                      result);
+    if (written > 0) {
+        core::log::write(core::log::Channel::state,
+                         storage.stemCount != 0 ? core::log::Level::info : core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+} // namespace
+
+/** Extracts and publishes the spawn-set catalogue from the installed packages. */
+bool build(const reader::Source& source, reader::Scratch& scratch) noexcept {
+    if (state::build_data::spawn_sets_ready()) {
+        return true;
+    }
+    static Storage storage{};
+    static std::vector<std::byte> bytes{};
+    static std::uint64_t readDeadlineTick{};
+    if (!storage.collected) {
+        reset(storage);
+        storage.startedTick = GetTickCount64();
+        reader::ScanResult result{};
+        // The sweep reads every installed package header and entry table, so it runs once. A pass
+        // that re-swept on each retry cost minutes of boot and read nothing new.
+        reader::ScanResult components{};
+        reader::ScanResult containers{};
+        if (!reader::scan_class_entries(
+                source.directory, tables::kSpawnSetClass, &collect, &storage, result)
+            || result.matches != storage.entryCount
+            || !reader::scan_class(
+                source.directory, tables::kComponentClass, &collect_component, &storage, components)
+            || !reader::scan_class(
+                source.directory, tables::kContainerClass, &collect_container, &storage, containers)
+            || !finish_collection(storage)) {
+            // A build that installs no spawn set has a whole empty catalogue, not a failed pass.
+            report(storage, storage.entryCount == 0 ? "empty" : "sweep");
+            reset(storage);
+            return state::build_data::publish_spawn_sets({}, {});
+        }
+        readDeadlineTick = GetTickCount64() + kReadWindowMs;
+        report(storage, "collected");
+        return false;
+    }
+    static std::vector<reader::parallel::Held> kept{};
+    ReadContext context{&source, &scratch, &bytes, &kept, 0};
+    const AdvanceResult result = advance(storage,
+                                         &read_blob,
+                                         &prefetch_blobs,
+                                         &context,
+                                         kReadBudget,
+                                         GetTickCount64() >= readDeadlineTick);
+    if (result == AdvanceResult::invalid) {
+        // A retry cannot help, and an empty catalogue still finishes the pipeline.
+        report(storage, "read");
+        reset(storage);
+        return state::build_data::publish_spawn_sets({}, {});
+    }
+    if (result != AdvanceResult::complete) {
+        return false;
+    }
+    const bool published = state::build_data::publish_spawn_sets(
+        std::span(storage.stems).first(storage.stemCount),
+        std::span(storage.nameHashes).first(storage.nameHashCount));
+    report(storage, published ? "ok" : "publish");
+    if (published) {
+        bytes.clear();
+        bytes.shrink_to_fit();
+        kept.clear();
+        kept.shrink_to_fit();
+    }
+    return published;
+}
+
+} // namespace sunrise::client::content::spawn_sets

+ 18 - 0
Sunrise/src/client/content/spawn_sets/spawn_set_build.h

@@ -0,0 +1,18 @@
+#pragma once
+
+#include "../../../middleware/content/packages/reader/reader.h"
+
+namespace sunrise::client::content::spawn_sets {
+
+/**
+ * Extracts the spawn-set catalogue from the installed packages, once.
+ * Rows are grouped by map-package stem, which is the key a destination row carries. Each row also
+ * carries the mask of the bubbles that offer the set.
+ * @param source Package directory and borrowed block keys.
+ * @param scratch Lock-owned block storage shared with the item build.
+ * @return True when State already holds the catalogue or a full pass publishes it.
+ */
+[[nodiscard]] bool build(const middleware::content::packages::reader::Source& source,
+                         middleware::content::packages::reader::Scratch& scratch) noexcept;
+
+} // namespace sunrise::client::content::spawn_sets

+ 672 - 0
Sunrise/src/client/content/spawn_sets/spawn_set_catalog_builder.cpp

@@ -0,0 +1,672 @@
+#include "spawn_set_catalog_builder.h"
+
+#include <algorithm>
+#include <limits>
+#include <string_view>
+
+#include "../../../middleware/content/packages/tables/component_container_reader.h"
+#include "../../../middleware/content/packages/tables/spawn_reader.h"
+#include "../../../state/build_data/spawn_sets/spawn_set_catalog.h"
+
+namespace sunrise::client::content::spawn_sets {
+namespace {
+
+namespace tables = middleware::content::packages::tables;
+
+static_assert((kAccumulatorCapacity & (kAccumulatorCapacity - 1U)) == 0);
+
+/** @param value Character. @return True for an ASCII hex digit. */
+[[nodiscard]] bool hexadecimal(wchar_t value) noexcept {
+    return (value >= L'0' && value <= L'9') || (value >= L'a' && value <= L'f')
+           || (value >= L'A' && value <= L'F');
+}
+
+/** @param value Character. @return Its lowercase ASCII form, or zero when it has none. */
+[[nodiscard]] char lowercase_ascii(wchar_t value) noexcept {
+    if (value >= L'A' && value <= L'Z') {
+        return static_cast<char>(value - L'A' + L'a');
+    }
+    if ((value >= L'a' && value <= L'z') || (value >= L'0' && value <= L'9') || value == L'_') {
+        return static_cast<char>(value);
+    }
+    return '\0';
+}
+
+/** @param entry Scan row. @return Its bounded normalized stem. */
+[[nodiscard]] std::string_view stem_of(const Entry& entry) noexcept {
+    return {entry.stem.data(), entry.stemLength};
+}
+
+/** @param stem State row. @return Its bounded normalized stem. */
+[[nodiscard]] std::string_view stem_of(const spawn_state::Stem& stem) noexcept {
+    return {stem.name.data(), stem.nameLength};
+}
+
+/** @return Canonical stem then tag order. */
+[[nodiscard]] bool entry_less(const Entry& left, const Entry& right) noexcept {
+    if (stem_of(left) != stem_of(right)) {
+        return stem_of(left) < stem_of(right);
+    }
+    return left.tag < right.tag;
+}
+
+/** @return Stem then hash order. */
+[[nodiscard]] bool hash_less(const spawn_state::NameHash& left,
+                             const spawn_state::NameHash& right) noexcept {
+    return left.stemIndex != right.stemIndex ? left.stemIndex < right.stemIndex
+                                             : left.value < right.value;
+}
+
+/** @param stem Stem index. @param value Name hash. @return First probe slot. */
+[[nodiscard]] std::size_t hash_slot(std::uint16_t stem, std::uint32_t value) noexcept {
+    /** Fixed odd mixer keeps the power-of-two accumulator probe deterministic. */
+    constexpr std::uint64_t kMixer = 0x9E3779B185EBCA87ULL;
+    const std::uint64_t key = (static_cast<std::uint64_t>(stem) << 32U) | value;
+    return static_cast<std::size_t>((key * kMixer) & (kAccumulatorCapacity - 1U));
+}
+
+/**
+ * Records which package one point's set tag came from.
+ * A map package loads with the geometry. An activity package loads only for the destinations whose
+ * own slice-set entries name it, so those ids are kept.
+ * @param row Accumulator slot.
+ * @param entry Set-tag row the point came from.
+ */
+void record_package(HashSlot& row, const Entry& entry) noexcept {
+    if (entry.activityPackage == 0) {
+        row.inMapPackage = 1;
+        return;
+    }
+    for (std::size_t index = 0; index < row.activityPackageCount; ++index) {
+        if (row.activityPackages[index] == entry.packageId) {
+            return;
+        }
+    }
+    if (row.activityPackageCount >= row.activityPackages.size()) {
+        row.activityPackageOverflow = 1;
+        return;
+    }
+    row.activityPackages[row.activityPackageCount++] = entry.packageId;
+}
+
+/**
+ * Adds one point to the fixed hash accumulator.
+ * The mask and the unbound flag belong to the set tag the point came from, and a hash declared by
+ * several tags takes the union of theirs.
+ * @param storage Pass storage.
+ * @param stemIndex Owning map-package stem row.
+ * @param value Spawn-set name hash.
+ * @param entryIndex Set-tag row the point came from.
+ * @return True when the point count stays in range.
+ */
+[[nodiscard]] bool record_point(Storage& storage,
+                                std::uint16_t stemIndex,
+                                std::uint32_t value,
+                                std::size_t entryIndex) noexcept {
+    const Entry& entry = storage.entries[entryIndex];
+    const BubbleMask& mask = storage.tagMasks[entryIndex];
+    const std::uint8_t unbound = storage.tagBound[entryIndex] == 0 ? 1U : 0U;
+    std::size_t slot = hash_slot(stemIndex, value);
+    for (std::size_t probe = 0; probe < storage.accumulator.size(); ++probe) {
+        HashSlot& row = storage.accumulator[slot];
+        if (!row.occupied) {
+            if (storage.accumulatedCount >= spawn_state::kNameHashCapacity) {
+                return false;
+            }
+            row = {value, 1, stemIndex, true, mask, unbound};
+            record_package(row, entry);
+            ++storage.accumulatedCount;
+            return true;
+        }
+        if (row.stemIndex == stemIndex && row.value == value) {
+            if (row.pointCount == (std::numeric_limits<std::uint32_t>::max)()) {
+                return false;
+            }
+            ++row.pointCount;
+            for (std::size_t byte = 0; byte < row.mask.bytes.size(); ++byte) {
+                row.mask.bytes[byte] |= mask.bytes[byte];
+            }
+            row.unbound |= unbound;
+            record_package(row, entry);
+            return true;
+        }
+        slot = (slot + 1U) & (storage.accumulator.size() - 1U);
+    }
+    return false;
+}
+
+/**
+ * Finds the set-tag row that carries one wrapped tag.
+ * @param storage Pass storage holding the tag order.
+ * @param tag Wrapped resource tag.
+ * @param entryIndex Receives the set-tag row.
+ * @return True when the tag is one of the swept spawn sets.
+ */
+[[nodiscard]] bool
+find_entry(const Storage& storage, std::uint32_t tag, std::size_t& entryIndex) noexcept {
+    entryIndex = 0;
+    std::size_t low = 0;
+    std::size_t high = storage.entryCount;
+    while (low < high) {
+        const std::size_t middle = low + (high - low) / 2;
+        const std::uint32_t candidate = storage.entries[storage.tagOrder[middle]].tag;
+        if (candidate == tag) {
+            entryIndex = storage.tagOrder[middle];
+            return true;
+        }
+        if (candidate < tag) {
+            low = middle + 1;
+        } else {
+            high = middle;
+        }
+    }
+    return false;
+}
+
+/**
+ * Orders the collected spawn components by tag, so a member lookup is a search rather than a scan.
+ * Every container member is looked up, and the containers hold hundreds of thousands of them.
+ * @param storage Pass storage.
+ */
+void order_spawn_components(Storage& storage) noexcept {
+    const auto first = storage.spawnComponents.begin();
+    const auto last = first + static_cast<std::ptrdiff_t>(storage.spawnComponentCount);
+    std::sort(first, last, [](const SpawnComponent& row, const SpawnComponent& other) noexcept {
+        return row.tag < other.tag;
+    });
+}
+
+/**
+ * Finds the spawn component one member tag names.
+ * @param storage Pass storage holding the spawn components in tag order.
+ * @param tag Container member tag.
+ * @param entryIndex Receives the set-tag row that component wraps.
+ * @return True when the member is a component wrapping a swept spawn set.
+ */
+[[nodiscard]] bool
+find_spawn_component(const Storage& storage, std::uint32_t tag, std::size_t& entryIndex) noexcept {
+    entryIndex = 0;
+    const auto first = storage.spawnComponents.begin();
+    const auto last = first + static_cast<std::ptrdiff_t>(storage.spawnComponentCount);
+    const auto found = std::lower_bound(
+        first, last, tag, [](const SpawnComponent& row, std::uint32_t value) noexcept {
+            return row.tag < value;
+        });
+    if (found == last || found->tag != tag) {
+        return false;
+    }
+    entryIndex = found->entryIndex;
+    return true;
+}
+
+/**
+ * Records one component that wraps a swept spawn set.
+ * @param storage Pass storage.
+ * @param tag Component tag.
+ * @param blob Whole component bytes.
+ * @return True unless the component list is full.
+ */
+[[nodiscard]] bool
+consume_component(Storage& storage, std::uint32_t tag, std::span<const std::byte> blob) noexcept {
+    std::uint32_t resource = 0;
+    std::size_t entryIndex = 0;
+    // Components wrap every kind of resource, so one that names something else is ordinary.
+    if (!tables::component_resource(blob, resource) || !find_entry(storage, resource, entryIndex)) {
+        return true;
+    }
+    if (storage.spawnComponentCount >= storage.spawnComponents.size()) {
+        return false;
+    }
+    storage.spawnComponents[storage.spawnComponentCount++] = {
+        tag, static_cast<std::uint16_t>(entryIndex)};
+    return true;
+}
+
+/**
+ * Applies one container's bubble mask to every spawn set it holds.
+ * @param storage Pass storage.
+ * @param blob Whole container bytes.
+ * @return True unless the container is malformed.
+ */
+[[nodiscard]] bool consume_container(Storage& storage, std::span<const std::byte> blob) noexcept {
+    BubbleMask mask{};
+    tables::Array members{};
+    // A container with no mask or no member array holds nothing this pass needs.
+    if (!tables::container_bubble_mask(blob, mask.bytes)
+        || !tables::container_members(blob, members)
+        || (members.count != 0 && members.elementClass != tables::kContainerMemberClass)) {
+        return true;
+    }
+    for (std::uint64_t index = 0; index < members.count; ++index) {
+        std::uint32_t member = 0;
+        if (!tables::container_member_at(blob, members, index, member)) {
+            break;
+        }
+        std::size_t entryIndex = 0;
+        if (!find_spawn_component(storage, member, entryIndex)) {
+            continue;
+        }
+        BubbleMask& target = storage.tagMasks[entryIndex];
+        for (std::size_t byte = 0; byte < target.bytes.size(); ++byte) {
+            target.bytes[byte] |= mask.bytes[byte];
+        }
+        storage.tagBound[entryIndex] = 1;
+    }
+    return true;
+}
+
+/**
+ * Parses and folds in one whole spawn-set blob.
+ * @param storage Pass storage.
+ * @param entry Scanned tag and owning stem.
+ * @param blob Whole spawn-set bytes.
+ * @return True when every point is canonical and fits the catalogue.
+ */
+[[nodiscard]] bool
+consume(Storage& storage, const Entry& entry, std::span<const std::byte> blob) noexcept {
+    tables::Array points{};
+    // A set whose descriptor is missing, or whose array holds another element class, carries no
+    // spawn point for this stem. It is skipped, never fatal: the packages hold such sets and
+    // failing on one would drop the whole catalogue.
+    if (!tables::spawn_points(blob, points)
+        || (points.count != 0 && points.elementClass != tables::kSpawnPointClass)) {
+        return true;
+    }
+    if (points.count
+        > (std::numeric_limits<std::uint32_t>::max)() - storage.stems[entry.stemIndex].pointCount) {
+        return false;
+    }
+    std::uint32_t recorded = 0;
+    const std::size_t entryIndex = static_cast<std::size_t>(&entry - storage.entries.data());
+    for (std::uint64_t index = 0; index < points.count; ++index) {
+        tables::SpawnPoint point{};
+        // A point past the end of the blob ends this set. Its array header claimed more than the
+        // entry holds, which says nothing about the sets around it.
+        if (!tables::spawn_point_at(blob, points, static_cast<std::size_t>(index), point)) {
+            break;
+        }
+        // A full accumulator is a capacity limit, not bad data, and no later set can fit either.
+        if (!record_point(storage, entry.stemIndex, point.nameHash, entryIndex)) {
+            return false;
+        }
+        ++recorded;
+    }
+    storage.stems[entry.stemIndex].pointCount += recorded;
+    return true;
+}
+
+/** What one bounded batch of one stage produced. */
+enum class BatchResult {
+    pending,
+    done,
+    invalid,
+};
+
+/**
+ * Reads one bounded batch of tags and hands each blob to a consumer.
+ * @tparam Consumer Callable taking the pass, the tag and its blob, returning false when fatal.
+ * @param storage Pass storage carrying the skip counter.
+ * @param reader Injected tag reader.
+ * @param prefetch Injected read-ahead, or null when the reader has none.
+ * @param context Reader context.
+ * @param tags Tags of this stage.
+ * @param cursor Tag the batch resumes from, advanced here.
+ * @param reads Reads already spent in this call, advanced here.
+ * @param budget Maximum reads for the whole call.
+ * @param skipUnreadable True once the caller's read window has closed.
+ * @param consume Consumer applied to every blob that read.
+ * @return Done when the stage is finished, pending when it is not, invalid when the data is
+ * unusable.
+ */
+template <typename Consumer>
+[[nodiscard]] BatchResult read_batch(Storage& storage,
+                                     BlobReader reader,
+                                     BlobPrefetch prefetch,
+                                     void* context,
+                                     std::span<const std::uint32_t> tags,
+                                     std::size_t& cursor,
+                                     std::size_t& reads,
+                                     std::size_t budget,
+                                     bool skipUnreadable,
+                                     Consumer consume) noexcept {
+    // The whole window is asked for at once, then parsed one blob at a time exactly as before.
+    // Whether the read-ahead actually held a blob changes nothing here: the reader answers either
+    // way, so the stage reads the same tags in the same order.
+    if (prefetch != nullptr && cursor < tags.size() && reads < budget) {
+        const std::size_t window = (std::min)(budget - reads, tags.size() - cursor);
+        prefetch(context, tags.subspan(cursor, window));
+    }
+    while (cursor < tags.size() && reads < budget) {
+        std::span<const std::byte> blob{};
+        ++reads;
+        if (!reader(context, tags[cursor], blob)) {
+            // Block keys resolve during the boot, so an early read failure is ordinary and the
+            // stage waits. Once the window has closed the tag is dropped instead.
+            if (!skipUnreadable) {
+                return BatchResult::pending;
+            }
+            ++storage.skipped;
+            ++cursor;
+            continue;
+        }
+        if (!consume(storage, tags[cursor], blob)) {
+            return BatchResult::invalid;
+        }
+        ++cursor;
+    }
+    return cursor == tags.size() ? BatchResult::done : BatchResult::pending;
+}
+
+/**
+ * Converts one unfinished stage result into the pass result.
+ * @param storage Pass storage, marked invalid when the stage was.
+ * @param batch Stage result that is not done.
+ * @return The matching pass result.
+ */
+[[nodiscard]] AdvanceResult result_of(Storage& storage, BatchResult batch) noexcept {
+    if (batch != BatchResult::invalid) {
+        return AdvanceResult::pending;
+    }
+    storage.invalid = true;
+    return AdvanceResult::invalid;
+}
+
+/** @param storage Completed accumulator. @return True when its flat bank is canonical. */
+[[nodiscard]] bool finalize(Storage& storage) noexcept {
+    storage.nameHashCount = 0;
+    for (const HashSlot& slot : storage.accumulator) {
+        if (slot.occupied) {
+            if (storage.nameHashCount >= storage.nameHashes.size()) {
+                return false;
+            }
+            storage.nameHashes[storage.nameHashCount++] = {slot.value,
+                                                           slot.pointCount,
+                                                           slot.stemIndex,
+                                                           slot.mask.bytes,
+                                                           slot.unbound,
+                                                           slot.inMapPackage,
+                                                           slot.activityPackageCount,
+                                                           slot.activityPackageOverflow,
+                                                           slot.activityPackages};
+        }
+    }
+    auto end = storage.nameHashes.begin() + static_cast<std::ptrdiff_t>(storage.nameHashCount);
+    std::sort(storage.nameHashes.begin(), end, hash_less);
+    std::size_t cursor = 0;
+    for (std::size_t stemIndex = 0; stemIndex < storage.stemCount; ++stemIndex) {
+        spawn_state::Stem& stem = storage.stems[stemIndex];
+        stem.nameHashOffset = static_cast<std::uint16_t>(cursor);
+        while (cursor < storage.nameHashCount
+               && storage.nameHashes[cursor].stemIndex == stemIndex) {
+            ++stem.nameHashCount;
+            ++cursor;
+        }
+    }
+    storage.finalized =
+        cursor == storage.nameHashCount
+        && spawn_state::valid(std::span(storage.stems).first(storage.stemCount),
+                              std::span(storage.nameHashes).first(storage.nameHashCount));
+    return storage.finalized;
+}
+
+} // namespace
+
+/** Clears one extraction pass without freeing process-owned outer storage. */
+void reset(Storage& storage) noexcept {
+    storage.entries.fill(Entry{});
+    storage.entryCount = 0;
+    storage.tagOrder.fill(0);
+    storage.tagMasks.fill(BubbleMask{});
+    storage.tagBound.fill(0);
+    storage.componentTags.fill(0);
+    storage.componentCount = 0;
+    storage.containerTags.fill(0);
+    storage.containerCount = 0;
+    storage.spawnComponents.fill(SpawnComponent{});
+    storage.spawnComponentCount = 0;
+    storage.componentsOrdered = false;
+    storage.componentCursor = 0;
+    storage.containerCursor = 0;
+    storage.stems.fill(spawn_state::Stem{});
+    storage.stemCount = 0;
+    storage.accumulator.fill(HashSlot{});
+    storage.accumulatedCount = 0;
+    storage.nameHashes.fill(spawn_state::NameHash{});
+    storage.nameHashCount = 0;
+    storage.cursor = 0;
+    storage.skipped = 0;
+    storage.collected = false;
+    storage.finalized = false;
+    storage.invalid = false;
+}
+
+/** Converts one package family into the map stem its spawn-set tags are grouped under. */
+bool normalize_stem(std::wstring_view packageFamily,
+                    std::span<char> output,
+                    std::uint8_t& length) noexcept {
+    length = 0;
+    std::fill(output.begin(), output.end(), '\0');
+    if (packageFamily.empty()) {
+        return false;
+    }
+    std::wstring_view stem = packageFamily;
+    /** Installed package-family grammar used by the package path index. */
+    constexpr std::wstring_view kPrefix = L"w64_";
+    constexpr std::wstring_view kActivities = L"_activities";
+    constexpr std::wstring_view kUnpacked = L"_unp";
+    constexpr std::size_t kPackageIdDigits = 4;
+    if (stem.starts_with(kPrefix) && stem.size() > kPrefix.size() + kPackageIdDigits + 1U) {
+        const std::size_t idSeparator = stem.size() - kPackageIdDigits - 1U;
+        const bool packageId =
+            stem[idSeparator] == L'_'
+            && std::all_of(stem.begin() + static_cast<std::ptrdiff_t>(idSeparator + 1U),
+                           stem.end(),
+                           hexadecimal);
+        if (packageId) {
+            stem = stem.substr(kPrefix.size(), idSeparator - kPrefix.size());
+            if (stem.ends_with(kActivities)) {
+                stem.remove_suffix(kActivities.size());
+            } else {
+                const std::size_t suffix = stem.rfind(kUnpacked);
+                if (suffix != std::wstring_view::npos && suffix + kUnpacked.size() < stem.size()
+                    && std::all_of(stem.begin()
+                                       + static_cast<std::ptrdiff_t>(suffix + kUnpacked.size()),
+                                   stem.end(),
+                                   [](wchar_t value) { return value >= L'0' && value <= L'9'; })) {
+                    stem = stem.substr(0, suffix);
+                }
+            }
+        }
+    }
+    if (stem.empty() || stem.size() > output.size()
+        || stem.size() > (std::numeric_limits<std::uint8_t>::max)()) {
+        return false;
+    }
+    for (std::size_t index = 0; index < stem.size(); ++index) {
+        output[index] = lowercase_ascii(stem[index]);
+        if (output[index] == '\0') {
+            std::fill(output.begin(), output.end(), '\0');
+            return false;
+        }
+    }
+    length = static_cast<std::uint8_t>(stem.size());
+    return true;
+}
+
+/** Tells a map package from an activity one. */
+bool activity_family(std::wstring_view packageFamily) noexcept {
+    /** Package families that hold one activity's own content carry this before the package id. */
+    constexpr std::wstring_view kActivities = L"_activities";
+    /** The package id and its separator follow the marker. */
+    constexpr std::size_t kPackageIdSuffix = 5;
+    const std::size_t marker = packageFamily.rfind(kActivities);
+    return marker != std::wstring_view::npos
+           && marker + kActivities.size() + kPackageIdSuffix == packageFamily.size();
+}
+
+/** Adds one class-scan match to an unfinished pass. */
+bool add_entry(Storage& storage, const package_reader::ClassEntry& entry) noexcept {
+    if (storage.collected || storage.invalid || entry.tag == 0
+        || storage.entryCount >= storage.entries.size()) {
+        return false;
+    }
+    Entry& output = storage.entries[storage.entryCount];
+    if (!normalize_stem(entry.packageFamily, output.stem, output.stemLength)) {
+        return false;
+    }
+    output.tag = entry.tag;
+    output.packageId = tables::package_of(entry.tag);
+    output.activityPackage = activity_family(entry.packageFamily) ? 1U : 0U;
+    ++storage.entryCount;
+    return true;
+}
+
+/** Adds one component tag the class sweep found. */
+bool add_component(Storage& storage, std::uint32_t tag) noexcept {
+    if (storage.collected || storage.invalid || tag == 0
+        || storage.componentCount >= storage.componentTags.size()) {
+        return false;
+    }
+    storage.componentTags[storage.componentCount++] = tag;
+    return true;
+}
+
+/** Adds one container tag the class sweep found. */
+bool add_container(Storage& storage, std::uint32_t tag) noexcept {
+    if (storage.collected || storage.invalid || tag == 0
+        || storage.containerCount >= storage.containerTags.size()) {
+        return false;
+    }
+    storage.containerTags[storage.containerCount++] = tag;
+    return true;
+}
+
+/** Sorts the scanned tags and builds their canonical stem groups. */
+bool finish_collection(Storage& storage) noexcept {
+    if (storage.collected || storage.invalid || storage.entryCount == 0) {
+        return false;
+    }
+    std::array<std::uint32_t, kTagCapacity> tags{};
+    for (std::size_t index = 0; index < storage.entryCount; ++index) {
+        tags[index] = storage.entries[index].tag;
+    }
+    auto tagEnd = tags.begin() + static_cast<std::ptrdiff_t>(storage.entryCount);
+    std::sort(tags.begin(), tagEnd);
+    if (std::adjacent_find(tags.begin(), tagEnd) != tagEnd) {
+        storage.invalid = true;
+        return false;
+    }
+    auto end = storage.entries.begin() + static_cast<std::ptrdiff_t>(storage.entryCount);
+    std::sort(storage.entries.begin(), end, entry_less);
+    for (std::size_t index = 0; index < storage.entryCount; ++index) {
+        Entry& entry = storage.entries[index];
+        const bool newStem = storage.stemCount == 0
+                             || stem_of(storage.stems[storage.stemCount - 1]) != stem_of(entry);
+        if (newStem) {
+            if (storage.stemCount >= storage.stems.size()) {
+                storage.invalid = true;
+                return false;
+            }
+            spawn_state::Stem& stem = storage.stems[storage.stemCount++];
+            std::copy(entry.stem.begin(), entry.stem.end(), stem.name.begin());
+            stem.nameLength = entry.stemLength;
+        }
+        entry.stemIndex = static_cast<std::uint16_t>(storage.stemCount - 1U);
+        ++storage.stems[entry.stemIndex].setCount;
+    }
+    for (std::size_t index = 0; index < storage.entryCount; ++index) {
+        storage.tagOrder[index] = static_cast<std::uint16_t>(index);
+    }
+    auto orderEnd = storage.tagOrder.begin() + static_cast<std::ptrdiff_t>(storage.entryCount);
+    std::sort(storage.tagOrder.begin(),
+              orderEnd,
+              [&storage](std::uint16_t firstRow, std::uint16_t secondRow) noexcept {
+                  return storage.entries[firstRow].tag < storage.entries[secondRow].tag;
+              });
+    storage.collected = true;
+    return true;
+}
+
+/** Reads and aggregates the next bounded batch of blobs. */
+AdvanceResult advance(Storage& storage,
+                      BlobReader reader,
+                      BlobPrefetch prefetch,
+                      void* context,
+                      std::size_t budget,
+                      bool skipUnreadable) noexcept {
+    if (storage.invalid || !storage.collected || reader == nullptr || budget == 0) {
+        return AdvanceResult::invalid;
+    }
+    if (storage.finalized) {
+        return AdvanceResult::complete;
+    }
+    std::size_t reads = 0;
+    const BatchResult components =
+        read_batch(storage,
+                   reader,
+                   prefetch,
+                   context,
+                   std::span(storage.componentTags).first(storage.componentCount),
+                   storage.componentCursor,
+                   reads,
+                   budget,
+                   skipUnreadable,
+                   [](Storage& pass, std::uint32_t tag, std::span<const std::byte> blob) noexcept {
+                       return consume_component(pass, tag, blob);
+                   });
+    if (components != BatchResult::done) {
+        return result_of(storage, components);
+    }
+    // Every component has been read by here, so the order is final and built once.
+    if (!storage.componentsOrdered) {
+        order_spawn_components(storage);
+        storage.componentsOrdered = true;
+    }
+    const BatchResult containers =
+        read_batch(storage,
+                   reader,
+                   prefetch,
+                   context,
+                   std::span(storage.containerTags).first(storage.containerCount),
+                   storage.containerCursor,
+                   reads,
+                   budget,
+                   skipUnreadable,
+                   [](Storage& pass, std::uint32_t, std::span<const std::byte> blob) noexcept {
+                       return consume_container(pass, blob);
+                   });
+    if (containers != BatchResult::done) {
+        return result_of(storage, containers);
+    }
+    while (storage.cursor < storage.entryCount && reads < budget) {
+        const Entry& entry = storage.entries[storage.cursor];
+        std::span<const std::byte> blob{};
+        ++reads;
+        if (!reader(context, entry.tag, blob)) {
+            // Block keys resolve during the boot, so an early read failure is ordinary and the
+            // pass waits. Once the window has closed the tag is dropped instead, because a pass
+            // that waits forever is one the whole content pipeline waits behind.
+            if (!skipUnreadable) {
+                return AdvanceResult::pending;
+            }
+            ++storage.skipped;
+            ++storage.cursor;
+            continue;
+        }
+        if (!consume(storage, entry, blob)) {
+            storage.invalid = true;
+            return AdvanceResult::invalid;
+        }
+        ++storage.cursor;
+    }
+    if (storage.cursor != storage.entryCount) {
+        return AdvanceResult::pending;
+    }
+    if (!finalize(storage)) {
+        storage.invalid = true;
+        return AdvanceResult::invalid;
+    }
+    return AdvanceResult::complete;
+}
+
+} // namespace sunrise::client::content::spawn_sets

+ 214 - 0
Sunrise/src/client/content/spawn_sets/spawn_set_catalog_builder.h

@@ -0,0 +1,214 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+#include "../../../middleware/content/packages/reader/reader.h"
+#include "../../../state/build_data/spawn_sets/definition.h"
+
+namespace sunrise::client::content::spawn_sets {
+
+namespace spawn_state = state::build_data::spawn_sets;
+namespace package_reader = middleware::content::packages::reader;
+
+/** Spawn-set tags the class sweep finds. The measured live count is 386. */
+inline constexpr std::size_t kTagCapacity = 1'024;
+/** Component tags the class sweep finds. The measured live count is 14,838. */
+inline constexpr std::size_t kComponentCapacity = 20'480;
+/** Container tags the class sweep finds. The measured live count is 5,516. */
+inline constexpr std::size_t kContainerCapacity = 8'192;
+/** Components that wrap a spawn set. Every installed set has one, and a few have two. */
+inline constexpr std::size_t kSpawnComponentCapacity = 2'048;
+/**
+ * Slots of the open-addressed accumulator that counts points per stem and name hash.
+ * The measured distinct-hash count is 2,090, and a power of two keeps the probe a mask.
+ */
+inline constexpr std::size_t kAccumulatorCapacity = 8'192;
+/**
+ * Tag reads one slice may spend, which the read-ahead fetches as one run.
+ * The slice waits for every reader, so this bounds how long it blocks the frame. At the measured
+ * cost of a read that is a few tens of milliseconds.
+ */
+inline constexpr std::size_t kReadBudget = 512;
+/**
+ * How long the pass keeps waiting for a tag that will not read.
+ * Block keys resolve during the boot, so an early read fails for a reason that fixes itself. After
+ * this the tag is dropped, because the whole content pipeline waits behind this pass.
+ */
+inline constexpr std::uint64_t kReadWindowMs = 15'000;
+
+/** One scanned spawn-set tag and the map-package stem that owns it. */
+struct Entry {
+    std::uint32_t tag{};
+    std::array<char, spawn_state::kStemNameCapacity> stem{};
+    std::uint16_t stemIndex{};
+    std::uint8_t stemLength{};
+    /** Package the tag lives in, which decides whether a destination streams it. */
+    std::uint16_t packageId{};
+    /** One when that package is an activity package rather than a map one. */
+    std::uint8_t activityPackage{};
+};
+
+/** The bubbles one container names, as one bit per map-global bubble index. */
+struct BubbleMask {
+    std::array<std::uint8_t, spawn_state::kBubbleMaskBytes> bytes{};
+};
+
+/** One component that wraps a spawn set, and the set-tag row it wraps. */
+struct SpawnComponent {
+    std::uint32_t tag{};
+    std::uint16_t entryIndex{};
+};
+
+/** One accumulator slot: a stem, a name hash, and the points carrying it. */
+struct HashSlot {
+    std::uint32_t value{};
+    std::uint32_t pointCount{};
+    std::uint16_t stemIndex{};
+    bool occupied{};
+    /** Union of the bubble masks of every set tag of this stem declaring the hash. */
+    BubbleMask mask{};
+    /** One when some tag declaring the hash has no owning container. */
+    std::uint8_t unbound{};
+    std::uint8_t inMapPackage{};
+    std::uint8_t activityPackageCount{};
+    std::uint8_t activityPackageOverflow{};
+    std::array<std::uint16_t, spawn_state::kPackageCapacity> activityPackages{};
+};
+
+/** Fixed working storage for one extraction pass, kept off the caller stack. */
+struct Storage {
+    std::array<Entry, kTagCapacity> entries{};
+    std::size_t entryCount{};
+    /** Set-tag rows in ascending tag order, so a wrapped tag is found by search. */
+    std::array<std::uint16_t, kTagCapacity> tagOrder{};
+    /** Bubbles each set tag is offered in, from the container that holds its component. */
+    std::array<BubbleMask, kTagCapacity> tagMasks{};
+    /** One per set tag whose component a container holds. The rest carry no mask. */
+    std::array<std::uint8_t, kTagCapacity> tagBound{};
+    std::array<std::uint32_t, kComponentCapacity> componentTags{};
+    std::size_t componentCount{};
+    std::array<std::uint32_t, kContainerCapacity> containerTags{};
+    std::size_t containerCount{};
+    std::array<SpawnComponent, kSpawnComponentCapacity> spawnComponents{};
+    std::size_t spawnComponentCount{};
+    /** Component the next batch resumes from. */
+    std::size_t componentCursor{};
+    /** Container the next batch resumes from, read only once every component has. */
+    std::size_t containerCursor{};
+    std::array<spawn_state::Stem, spawn_state::kStemCapacity> stems{};
+    std::size_t stemCount{};
+    std::array<HashSlot, kAccumulatorCapacity> accumulator{};
+    std::size_t accumulatedCount{};
+    std::array<spawn_state::NameHash, spawn_state::kNameHashCapacity> nameHashes{};
+    std::size_t nameHashCount{};
+    /** Entry the next batch resumes from, so the pass continues across calls. */
+    std::size_t cursor{};
+    /** Entries whose blob never read inside the window, so the pass moved past them. */
+    std::size_t skipped{};
+    /** Tick the pass started on, so every report says what the pass cost. */
+    std::uint64_t startedTick{};
+    /** Set once the spawn components are in tag order, which the member lookup needs. */
+    bool componentsOrdered{};
+    /** Set once the sweep and its stem grouping are done, so they run once per boot. */
+    bool collected{};
+    /** Set once the flat bank is built and checked. */
+    bool finalized{};
+    /** Set when the pass found data it cannot represent, which no retry can fix. */
+    bool invalid{};
+};
+
+/** What one bounded batch of blob reads produced. */
+enum class AdvanceResult {
+    pending,
+    complete,
+    invalid,
+};
+
+/** Injected tag reader. The blob stays live until the next call. */
+using BlobReader = bool (*)(void* context,
+                            std::uint32_t tag,
+                            std::span<const std::byte>& blob) noexcept;
+
+/**
+ * Injected read-ahead for a run of tags the pass is about to ask for, in order.
+ * A blob waits out the disk before it decodes, so fetching a run at once hides most of that
+ * wait. The pass still parses one blob at a time, on its own thread. No read-ahead means null.
+ */
+using BlobPrefetch = void (*)(void* context, std::span<const std::uint32_t> tags) noexcept;
+
+/** Clears one extraction pass without freeing process-owned outer storage. */
+void reset(Storage& storage) noexcept;
+
+/**
+ * Converts one package family into the map stem its spawn-set tags are grouped under.
+ * @param packageFamily Leaf name without patch suffix or extension.
+ * @param output Receives lowercase ASCII stem bytes.
+ * @param length Receives the stem length.
+ * @return True when the package family has a usable canonical stem.
+ */
+[[nodiscard]] bool normalize_stem(std::wstring_view packageFamily,
+                                  std::span<char> output,
+                                  std::uint8_t& length) noexcept;
+
+/**
+ * Tells a map package from an activity one.
+ * A destination always loads its map packages, and loads an activity package only when its own
+ * slice-set entries name it.
+ * @param packageFamily Leaf name without patch suffix or extension.
+ * @return True when the family is an activity package.
+ */
+[[nodiscard]] bool activity_family(std::wstring_view packageFamily) noexcept;
+
+/**
+ * Adds one class-scan match to an unfinished pass.
+ * @param storage Pass storage.
+ * @param entry Scan match carrying its package family.
+ * @return False when the pass is finished, the stem is unusable, or storage is full.
+ */
+[[nodiscard]] bool add_entry(Storage& storage, const package_reader::ClassEntry& entry) noexcept;
+
+/**
+ * Adds one component tag the class sweep found.
+ * @param storage Pass storage.
+ * @param tag Component tag.
+ * @return False when the pass is finished or storage is full.
+ */
+[[nodiscard]] bool add_component(Storage& storage, std::uint32_t tag) noexcept;
+
+/**
+ * Adds one container tag the class sweep found.
+ * @param storage Pass storage.
+ * @param tag Container tag.
+ * @return False when the pass is finished or storage is full.
+ */
+[[nodiscard]] bool add_container(Storage& storage, std::uint32_t tag) noexcept;
+
+/**
+ * Sorts the scanned tags and builds their canonical stem groups.
+ * @param storage Pass storage.
+ * @return True when the tags are unique and every stem fits.
+ */
+[[nodiscard]] bool finish_collection(Storage& storage) noexcept;
+
+/**
+ * Reads and aggregates the next bounded batch of spawn-set blobs.
+ * @param storage Pass storage carrying its cursor and accumulators.
+ * @param reader Injected tag reader.
+ * @param prefetch Injected read-ahead, or null when the reader has none.
+ * @param context Reader context.
+ * @param budget Maximum tag reads in this call.
+ * @param skipUnreadable True once the caller's read window has closed, which moves the cursor past
+ *        a tag that will not read instead of waiting for it again.
+ * @return Pending, complete, or invalid without partial publication.
+ */
+[[nodiscard]] AdvanceResult advance(Storage& storage,
+                                    BlobReader reader,
+                                    BlobPrefetch prefetch,
+                                    void* context,
+                                    std::size_t budget,
+                                    bool skipUnreadable) noexcept;
+
+} // namespace sunrise::client::content::spawn_sets

+ 32 - 0
Sunrise/src/client/diagnostics/module_range.cpp

@@ -0,0 +1,32 @@
+#include "module_range.h"
+
+#include <cstddef>
+
+namespace sunrise::client::diagnostics {
+
+/** Reads one loaded image's address range from its mapped PE headers. */
+bool module_range(HMODULE module, ModuleRange& output) noexcept {
+    if (module == nullptr) {
+        return false;
+    }
+    // The headers are reached by walking the module pointer, never by casting its integer value.
+    const auto* bytes = reinterpret_cast<const std::byte*>(module);
+    const auto base = reinterpret_cast<std::uintptr_t>(module);
+    const auto* dos = reinterpret_cast<const IMAGE_DOS_HEADER*>(bytes);
+    if (dos->e_magic != IMAGE_DOS_SIGNATURE) {
+        return false;
+    }
+    const auto* headers = reinterpret_cast<const IMAGE_NT_HEADERS64*>(bytes + dos->e_lfanew);
+    if (headers->Signature != IMAGE_NT_SIGNATURE) {
+        return false;
+    }
+    output = ModuleRange{base, base + headers->OptionalHeader.SizeOfImage};
+    return true;
+}
+
+/** Tests one address against an image range. */
+bool contains(const ModuleRange& range, std::uintptr_t address) noexcept {
+    return range.end != 0 && address >= range.base && address < range.end;
+}
+
+} // namespace sunrise::client::diagnostics

+ 30 - 0
Sunrise/src/client/diagnostics/module_range.h

@@ -0,0 +1,30 @@
+#pragma once
+
+#include <Windows.h>
+
+#include <cstdint>
+
+namespace sunrise::client::diagnostics {
+
+/** Half-open address range of one loaded image. */
+struct ModuleRange {
+    std::uintptr_t base{};
+    std::uintptr_t end{};
+};
+
+/**
+ * Reads one loaded image's address range from its mapped PE headers.
+ * @param module Loaded image.
+ * @param output Receives the half-open range only on success.
+ * @return True when the headers are a valid PE image.
+ */
+[[nodiscard]] bool module_range(HMODULE module, ModuleRange& output) noexcept;
+
+/**
+ * Tests one address against an image range.
+ * @param range Candidate image range, or a cleared range that holds nothing.
+ * @return True when the address lies inside the image.
+ */
+[[nodiscard]] bool contains(const ModuleRange& range, std::uintptr_t address) noexcept;
+
+} // namespace sunrise::client::diagnostics

+ 86 - 0
Sunrise/src/client/executable/image.cpp

@@ -0,0 +1,86 @@
+#include "image.h"
+
+#include <Windows.h>
+
+namespace sunrise::client::executable {
+namespace {
+
+/** @return True when adding both values stays within the supplied limit. */
+[[nodiscard]] bool add_fits(std::size_t left, std::size_t right, std::size_t limit) noexcept {
+    return left <= limit && right <= limit - left;
+}
+
+/**
+ * Copies mapped-image bytes through the Windows memory reader.
+ * @param output Caller-owned destination.
+ * @param input Mapped process address.
+ * @param size Required byte count.
+ * @return True only when Windows copies the whole range.
+ */
+[[nodiscard]] bool read_memory(void* output, const void* input, std::size_t size) noexcept {
+    SIZE_T copied = 0;
+    return ReadProcessMemory(GetCurrentProcess(), input, output, size, &copied) != FALSE
+           && copied == size;
+}
+
+} // namespace
+
+/** Checks a mapped PE image and collects executable section spans. */
+bool inspect(std::byte* base, ExecutableImage& output) noexcept {
+    output = {};
+    if (base == nullptr) {
+        return false;
+    }
+
+    IMAGE_DOS_HEADER dos{};
+    if (!read_memory(&dos, base, sizeof dos) || dos.e_magic != IMAGE_DOS_SIGNATURE
+        || dos.e_lfanew <= 0) {
+        return false;
+    }
+
+    IMAGE_NT_HEADERS64 nt{};
+    if (!read_memory(&nt, base + dos.e_lfanew, sizeof nt) || nt.Signature != IMAGE_NT_SIGNATURE
+        || nt.OptionalHeader.Magic != IMAGE_NT_OPTIONAL_HDR64_MAGIC
+        || nt.FileHeader.NumberOfSections == 0
+        || nt.FileHeader.NumberOfSections > kPeSectionLimit) {
+        return false;
+    }
+
+    const std::size_t imageSize = nt.OptionalHeader.SizeOfImage;
+    const std::size_t sectionOffset = static_cast<std::size_t>(dos.e_lfanew) + sizeof(DWORD)
+                                      + sizeof(IMAGE_FILE_HEADER)
+                                      + nt.FileHeader.SizeOfOptionalHeader;
+    const std::size_t sectionBytes =
+        static_cast<std::size_t>(nt.FileHeader.NumberOfSections) * sizeof(IMAGE_SECTION_HEADER);
+    if (imageSize == 0 || !add_fits(sectionOffset, sectionBytes, imageSize)) {
+        return false;
+    }
+
+    for (std::size_t index = 0; index < nt.FileHeader.NumberOfSections; ++index) {
+        IMAGE_SECTION_HEADER section{};
+        const std::size_t offset = sectionOffset + index * sizeof(IMAGE_SECTION_HEADER);
+        if (!read_memory(&section, base + offset, sizeof section)) {
+            return false;
+        }
+        // Only executable sections can hold callable hook targets.
+        if ((section.Characteristics & IMAGE_SCN_MEM_EXECUTE) == 0
+            || section.Misc.VirtualSize == 0) {
+            continue;
+        }
+
+        const std::size_t virtualAddress = section.VirtualAddress;
+        const std::size_t virtualSize = section.Misc.VirtualSize;
+        if (!add_fits(virtualAddress, virtualSize, imageSize)) {
+            return false;
+        }
+        output.sections[output.count++] = std::span(base + virtualAddress, virtualSize);
+    }
+    return output.count != 0;
+}
+
+/** Inspects the current process executable image. */
+bool inspect_main_module(ExecutableImage& output) noexcept {
+    return inspect(reinterpret_cast<std::byte*>(GetModuleHandleW(nullptr)), output);
+}
+
+} // namespace sunrise::client::executable

+ 24 - 0
Sunrise/src/client/executable/image.h

@@ -0,0 +1,24 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <span>
+
+namespace sunrise::client::executable {
+
+/** Maximum section count accepted by the PE/COFF format. */
+inline constexpr std::size_t kPeSectionLimit = 96;
+
+/** Non-writable executable spans extracted from one mapped PE image. */
+struct ExecutableImage {
+    std::array<std::span<std::byte>, kPeSectionLimit> sections{};
+    std::size_t count{};
+};
+
+/** Reads executable spans from mapped PE headers. */
+[[nodiscard]] bool inspect(std::byte* base, ExecutableImage& output) noexcept;
+
+/** Reads executable spans from the main process module. */
+[[nodiscard]] bool inspect_main_module(ExecutableImage& output) noexcept;
+
+} // namespace sunrise::client::executable

+ 207 - 0
Sunrise/src/client/hooking/detour.cpp

@@ -0,0 +1,207 @@
+#include "detour.h"
+
+#include <Windows.h>
+
+#include <array>
+#include <detours.h>
+
+#include "detour/transaction/detour_thread_transaction.h"
+
+namespace sunrise::client::hooking::detour {
+namespace {
+
+/** 32 hook slots bound each fixed-storage Detours transaction. */
+constexpr std::size_t kBatchLimit = 32;
+
+/**
+ * Checks every detour description and output slot before transaction setup.
+ * @param specs Target and replacement pairs in stable slot order.
+ * @param outputs Output handles paired with the descriptions.
+ * @return True when the batch can be attached without overwriting state.
+ */
+[[nodiscard]] bool valid_install_batch(std::span<const Spec> specs,
+                                       std::span<const Handle> outputs) noexcept {
+    if (specs.empty() || specs.size() != outputs.size() || specs.size() > kBatchLimit) {
+        return false;
+    }
+    for (std::size_t index = 0; index < specs.size(); ++index) {
+        if (outputs[index].attached || specs[index].target == nullptr
+            || specs[index].replacement == nullptr) {
+            return false;
+        }
+    }
+    return true;
+}
+
+/**
+ * Checks every detour handle before a removal transaction begins.
+ * @param handles Handles that must all describe attached detours.
+ * @return True when every handle can take part in one fixed transaction.
+ */
+[[nodiscard]] bool valid_uninstall_batch(std::span<const Handle> handles) noexcept {
+    if (handles.empty() || handles.size() > kBatchLimit) {
+        return false;
+    }
+    for (const Handle& handle : handles) {
+        if (!handle.attached || handle.original == nullptr || handle.replacement == nullptr) {
+            return false;
+        }
+    }
+    return true;
+}
+
+/**
+ * Queues every detour removal against temporary trampoline pointers.
+ * @param handles Attached handles in stable transaction order.
+ * @param originals Receives pointer values used by Detours through commit.
+ * @return True when every removal was accepted by the active transaction.
+ */
+[[nodiscard]] bool queue_detach(std::span<const Handle> handles,
+                                std::array<void*, kBatchLimit>& originals) noexcept {
+    originals = {};
+    for (std::size_t index = 0; index < handles.size(); ++index) {
+        originals[index] = handles[index].original;
+        if (DetourDetach(&originals[index], handles[index].replacement) != NO_ERROR) {
+            return false;
+        }
+    }
+    return true;
+}
+
+/**
+ * Clears handle state only after a removal transaction commits.
+ * @param handles Removed handles to clear.
+ */
+void clear_handles(std::span<Handle> handles) noexcept {
+    for (Handle& handle : handles) {
+        handle = {};
+    }
+}
+
+/**
+ * Detaches a batch without checking caller-owned protected functions.
+ * @param handles Checked attached handles.
+ * @return True when every hook was removed and its handle was cleared.
+ */
+[[nodiscard]] bool uninstall_unprotected(std::span<Handle> handles) noexcept {
+    if (!valid_uninstall_batch(handles)) {
+        return false;
+    }
+
+    transaction::Threads threads;
+    if (!transaction::begin(threads)) {
+        return false;
+    }
+
+    std::array<void*, kBatchLimit> originals{};
+    if (!queue_detach(handles, originals)) {
+        (void)transaction::abort(threads);
+        return false;
+    }
+    if (!transaction::commit(threads)) {
+        return false;
+    }
+
+    clear_handles(handles);
+    return true;
+}
+
+} // namespace
+
+/**
+ * Attaches one detour through the batch transaction path.
+ * @param spec Target and replacement pair.
+ * @param output Receives the committed trampoline.
+ * @return True when the detour is attached.
+ */
+bool install(const Spec& spec, Handle& output) noexcept {
+    return install(std::span(&spec, 1), std::span(&output, 1));
+}
+
+/**
+ * Detaches one detour without caller-owned protected function checks.
+ * @param handle Attached detour handle.
+ * @return True when the detour is detached.
+ */
+bool uninstall(Handle& handle) noexcept {
+    return uninstall(std::span(&handle, 1));
+}
+
+/**
+ * Detaches one hook unless a suspended instruction pointer sits inside its replacement.
+ * @param handle Attached detour handle.
+ * @param replacementActive Receives true when the replacement prevented removal.
+ * @return True when the detour is detached.
+ */
+bool uninstall(Handle& handle, bool& replacementActive) noexcept {
+    return uninstall(std::span(&handle, 1), replacementActive);
+}
+
+/**
+ * Attaches a checked detour batch in one transaction, all or nothing.
+ * @param specs Target and replacement pairs in stable slot order.
+ * @param outputs Receives trampolines only after a successful commit.
+ * @return True when every detour is attached.
+ */
+bool install(std::span<const Spec> specs, std::span<Handle> outputs) noexcept {
+    if (!valid_install_batch(specs, outputs)) {
+        return false;
+    }
+
+    transaction::Threads threads;
+    if (!transaction::begin(threads)) {
+        return false;
+    }
+
+    std::array<void*, kBatchLimit> originals{};
+    for (std::size_t index = 0; index < specs.size(); ++index) {
+        originals[index] = specs[index].target;
+        if (DetourAttach(&originals[index], specs[index].replacement) != NO_ERROR) {
+            // Abort keeps every output detached when one attachment is rejected.
+            (void)transaction::abort(threads);
+            return false;
+        }
+    }
+    if (!transaction::commit(threads)) {
+        return false;
+    }
+
+    for (std::size_t index = 0; index < specs.size(); ++index) {
+        outputs[index].original = originals[index];
+        outputs[index].replacement = specs[index].replacement;
+        outputs[index].attached = true;
+    }
+    return true;
+}
+
+/**
+ * Detaches a checked batch without caller-owned protected function checks.
+ * @param handles Attached handles in their original transaction order.
+ * @return True when every detour is removed and every handle is cleared.
+ */
+bool uninstall(std::span<Handle> handles) noexcept {
+    return uninstall_unprotected(handles);
+}
+
+/**
+ * Detaches a batch unless a suspended instruction pointer sits inside a replacement.
+ * @param handles Attached handles in their original transaction order.
+ * @param replacementActive Receives true when a replacement prevented removal.
+ * @return True when every detour is removed and every handle is cleared.
+ */
+bool uninstall(std::span<Handle> handles, bool& replacementActive) noexcept {
+    replacementActive = false;
+    if (handles.empty() || handles.size() > kBatchLimit) {
+        return false;
+    }
+
+    std::array<ProtectedCodeEntry, kBatchLimit> entries{};
+    for (std::size_t index = 0; index < handles.size(); ++index) {
+        entries[index].address = handles[index].replacement;
+    }
+    const UninstallResult result = uninstall(handles, std::span(entries.data(), handles.size()));
+    replacementActive = result == UninstallResult::protectedCodeActive;
+    return result == UninstallResult::removed;
+}
+
+} // namespace sunrise::client::hooking::detour

+ 71 - 0
Sunrise/src/client/hooking/detour.h

@@ -0,0 +1,71 @@
+#pragma once
+
+#include <span>
+
+namespace sunrise::client::hooking::detour {
+
+/** Describes one target-to-replacement attachment. */
+struct Spec {
+    void* target{};
+    void* replacement{};
+};
+
+/** Owns the trampoline state returned by an attached detour. */
+struct Handle {
+    void* original{};
+    void* replacement{};
+    bool attached{};
+};
+
+/** One function that must be idle before a detour is removed. */
+struct ProtectedCodeEntry {
+    void* address{};
+};
+
+/** Lock-free, non-waiting idle test, run after Detours suspends the transaction threads. */
+using IdleCheck = bool (*)() noexcept;
+
+/** Final state of a protected detour removal request. */
+enum class UninstallResult {
+    removed,
+    protectedCodeActive,
+    failed,
+};
+
+/** Attaches one detour and publishes its trampoline in output. */
+[[nodiscard]] bool install(const Spec& spec, Handle& output) noexcept;
+
+/** Detaches one detour and clears its handle on success. */
+[[nodiscard]] bool uninstall(Handle& handle) noexcept;
+
+/** Detaches one detour and reports a suspended call inside its replacement body. */
+[[nodiscard]] bool uninstall(Handle& handle, bool& replacementActive) noexcept;
+
+/** Detaches one detour only when every protected function is idle. */
+[[nodiscard]] UninstallResult
+uninstall(Handle& handle, std::span<const ProtectedCodeEntry> protectedEntries) noexcept;
+
+/** Detaches one detour only when protected code and caller-owned work are idle. */
+[[nodiscard]] UninstallResult uninstall(Handle& handle,
+                                        std::span<const ProtectedCodeEntry> protectedEntries,
+                                        IdleCheck idleCheck) noexcept;
+
+/** Attaches all specs in one transaction or leaves every output detached. */
+[[nodiscard]] bool install(std::span<const Spec> specs, std::span<Handle> outputs) noexcept;
+
+/** Detaches all handles in one transaction. */
+[[nodiscard]] bool uninstall(std::span<Handle> handles) noexcept;
+
+/** Detaches all handles and reports a suspended call inside any replacement body. */
+[[nodiscard]] bool uninstall(std::span<Handle> handles, bool& replacementActive) noexcept;
+
+/** Detaches all handles only when every protected function is idle. */
+[[nodiscard]] UninstallResult
+uninstall(std::span<Handle> handles, std::span<const ProtectedCodeEntry> protectedEntries) noexcept;
+
+/** Detaches all handles only when protected code and caller-owned work are idle. */
+[[nodiscard]] UninstallResult uninstall(std::span<Handle> handles,
+                                        std::span<const ProtectedCodeEntry> protectedEntries,
+                                        IdleCheck idleCheck) noexcept;
+
+} // namespace sunrise::client::hooking::detour

+ 136 - 0
Sunrise/src/client/hooking/detour/protected_detour_uninstall.cpp

@@ -0,0 +1,136 @@
+#include <Windows.h>
+
+#include <array>
+#include <detours.h>
+
+#include "../detour.h"
+#include "transaction/detour_thread_transaction.h"
+
+namespace sunrise::client::hooking::detour {
+namespace {
+
+/** 32 hook slots bound each fixed-storage Detours transaction. */
+constexpr std::size_t kBatchLimit = 32;
+
+/** @param handles Detours handles. @return True when the protected batch is usable. */
+[[nodiscard]] bool valid_batch(std::span<const Handle> handles) noexcept {
+    if (handles.empty() || handles.size() > kBatchLimit) {
+        return false;
+    }
+    for (const Handle& handle : handles) {
+        if (!handle.attached || handle.original == nullptr || handle.replacement == nullptr) {
+            return false;
+        }
+    }
+    return true;
+}
+
+/**
+ * Queues protected detour removal against transaction-local trampoline pointers.
+ * @param handles Attached handles in stable order.
+ * @param originals Receives trampoline pointers held through commit.
+ * @return True when every removal was accepted by Detours.
+ */
+[[nodiscard]] bool queue_detach(std::span<const Handle> handles,
+                                std::array<void*, kBatchLimit>& originals) noexcept {
+    originals = {};
+    for (std::size_t index = 0; index < handles.size(); ++index) {
+        originals[index] = handles[index].original;
+        if (DetourDetach(&originals[index], handles[index].replacement) != NO_ERROR) {
+            return false;
+        }
+    }
+    return true;
+}
+
+/** @param handles Successfully removed handles to clear. */
+void clear_handles(std::span<Handle> handles) noexcept {
+    for (Handle& handle : handles) {
+        handle = {};
+    }
+}
+
+} // namespace
+
+/**
+ * Detaches one hook only once every given protected function is idle.
+ * @param handle Attached detour handle.
+ * @param protectedEntries Function entries that must be idle before removal.
+ * @return Removed, active, or failed without changing the handle on failure.
+ */
+UninstallResult uninstall(Handle& handle,
+                          std::span<const ProtectedCodeEntry> protectedEntries) noexcept {
+    return uninstall(std::span(&handle, 1), protectedEntries);
+}
+
+/**
+ * Detaches one hook only once protected code and caller-owned work are idle.
+ * @param handle Attached detour handle.
+ * @param protectedEntries Function entries that must be idle before removal.
+ * @param idleCheck Lock-free idle test, run with the transaction threads suspended.
+ * @return Removed, active, or failed without changing the handle on failure.
+ */
+UninstallResult uninstall(Handle& handle,
+                          std::span<const ProtectedCodeEntry> protectedEntries,
+                          IdleCheck idleCheck) noexcept {
+    return uninstall(std::span(&handle, 1), protectedEntries, idleCheck);
+}
+
+/**
+ * Detaches a batch only when every given protected function is idle.
+ * @param handles Attached handles in their original transaction order.
+ * @param protectedEntries Function entries that must be idle before removal.
+ * @return Removed, active, or failed. Handles are kept unless they were removed.
+ */
+UninstallResult uninstall(std::span<Handle> handles,
+                          std::span<const ProtectedCodeEntry> protectedEntries) noexcept {
+    return uninstall(handles, protectedEntries, nullptr);
+}
+
+/**
+ * Detaches a batch only when protected code and caller-owned work are idle.
+ * @param handles Attached handles in their original transaction order.
+ * @param protectedEntries Function entries that must be idle before removal.
+ * @param idleCheck Lock-free idle test, run with the transaction threads suspended.
+ * @return Removed, active, or failed. Handles are kept unless they were removed.
+ */
+UninstallResult uninstall(std::span<Handle> handles,
+                          std::span<const ProtectedCodeEntry> protectedEntries,
+                          IdleCheck idleCheck) noexcept {
+    if (!valid_batch(handles) || protectedEntries.empty()) {
+        return UninstallResult::failed;
+    }
+
+    transaction::Threads threads;
+    if (!transaction::begin(threads)) {
+        return UninstallResult::failed;
+    }
+
+    const transaction::InspectionResult inspection =
+        transaction::inspect(threads, protectedEntries);
+    if (inspection != transaction::InspectionResult::clear) {
+        const bool aborted = transaction::abort(threads);
+        if (!aborted || inspection == transaction::InspectionResult::failed) {
+            return UninstallResult::failed;
+        }
+        return UninstallResult::protectedCodeActive;
+    }
+    if (idleCheck != nullptr && !idleCheck()) {
+        return transaction::abort(threads) ? UninstallResult::protectedCodeActive
+                                           : UninstallResult::failed;
+    }
+
+    std::array<void*, kBatchLimit> originals{};
+    if (!queue_detach(handles, originals)) {
+        (void)transaction::abort(threads);
+        return UninstallResult::failed;
+    }
+    if (!transaction::commit(threads)) {
+        return UninstallResult::failed;
+    }
+
+    clear_handles(handles);
+    return UninstallResult::removed;
+}
+
+} // namespace sunrise::client::hooking::detour

+ 240 - 0
Sunrise/src/client/hooking/detour/transaction/detour_thread_transaction.cpp

@@ -0,0 +1,240 @@
+#include "detour_thread_transaction.h"
+
+#include <Windows.h>
+
+#include <TlHelp32.h>
+#include <detours.h>
+
+#include "../../../process/freeze/client_process_freeze.h"
+
+namespace sunrise::client::hooking::detour::transaction {
+namespace {
+
+/** 4 protected functions per hook bound the fixed range storage, so no heap is used. */
+constexpr std::size_t kProtectedCodeLimit = 64;
+
+/** Exact executable range described by one x64 unwind record. */
+struct CodeRange {
+    DWORD64 begin{};
+    DWORD64 end{};
+};
+
+/**
+ * Closes every held thread handle after Detours resumes the threads.
+ * @param threads Thread handles to close and clear.
+ */
+void close_threads(Threads& threads) noexcept {
+    for (std::size_t index = 0; index < threads.count; ++index) {
+        CloseHandle(threads.handles[index]);
+    }
+    threads = {};
+}
+
+/**
+ * Checks whether one thread id was already enlisted by an earlier snapshot.
+ * @param threads Threads kept suspended by the active transaction.
+ * @param threadId Candidate process thread id.
+ * @return True when the thread is already enlisted.
+ */
+[[nodiscard]] bool contains(const Threads& threads, DWORD threadId) noexcept {
+    for (std::size_t index = 0; index < threads.count; ++index) {
+        if (threads.ids[index] == threadId) {
+            return true;
+        }
+    }
+    return false;
+}
+
+/**
+ * Enlists every unseen thread present in one process-wide snapshot.
+ * @param threads Receives handles that stay suspended until the transaction ends.
+ * @param foundUnseen Receives true when this pass saw any new thread id.
+ * @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;
+    }
+
+    THREADENTRY32 entry{};
+    entry.dwSize = sizeof(entry);
+    BOOL available = Thread32First(snapshot, &entry);
+    const DWORD processId = GetCurrentProcessId();
+    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;
+            }
+        }
+        available = Thread32Next(snapshot, &entry);
+    }
+
+    if (succeeded && available == FALSE && GetLastError() != ERROR_NO_MORE_FILES) {
+        succeeded = false;
+    }
+    CloseHandle(snapshot);
+    return succeeded;
+}
+
+/**
+ * Enlists new process threads until a full snapshot 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)) {
+            return false;
+        }
+        // Earlier handles stay suspended while a later pass finds newly created threads.
+    } while (foundUnseen);
+    return true;
+}
+
+/** @param protection Windows page protection. @return True for executable page types. */
+[[nodiscard]] bool is_executable(DWORD protection) noexcept {
+    /** The low byte stores PAGE_* type while higher bits store modifiers. */
+    constexpr DWORD kPageTypeMask = 0xFF;
+    switch (protection & kPageTypeMask) {
+    case PAGE_EXECUTE:
+    case PAGE_EXECUTE_READ:
+    case PAGE_EXECUTE_READWRITE:
+    case PAGE_EXECUTE_WRITECOPY:
+        return true;
+    default:
+        return false;
+    }
+}
+
+/**
+ * Finds the canonical unwind-backed function range of one protected entry.
+ * @param entry Protected function entry given by the hook owner.
+ * @param range Receives the exact executable range.
+ * @return True when both the entry and canonical code have a valid x64 unwind record.
+ */
+[[nodiscard]] bool resolve_range(const ProtectedCodeEntry& entry, CodeRange& range) noexcept {
+    range = {};
+    if (entry.address == nullptr) {
+        return false;
+    }
+
+    MEMORY_BASIC_INFORMATION entryMemory{};
+    if (VirtualQuery(entry.address, &entryMemory, sizeof(entryMemory)) != sizeof(entryMemory)
+        || entryMemory.State != MEM_COMMIT || !is_executable(entryMemory.Protect)) {
+        return false;
+    }
+
+    void* const code = DetourCodeFromPointer(entry.address, nullptr);
+    MEMORY_BASIC_INFORMATION codeMemory{};
+    if (code == nullptr || VirtualQuery(code, &codeMemory, sizeof(codeMemory)) != sizeof(codeMemory)
+        || codeMemory.State != MEM_COMMIT || !is_executable(codeMemory.Protect)) {
+        return false;
+    }
+
+    const DWORD64 codeAddress = reinterpret_cast<DWORD64>(code);
+    DWORD64 imageBase{};
+    const RUNTIME_FUNCTION* function = RtlLookupFunctionEntry(codeAddress, &imageBase, nullptr);
+    if (function == nullptr) {
+        return false;
+    }
+
+    range = {imageBase + function->BeginAddress, imageBase + function->EndAddress};
+    return range.begin < range.end && codeAddress >= range.begin && codeAddress < range.end;
+}
+
+} // namespace
+
+/** Starts a Detours transaction and enlists process threads to a stable snapshot. */
+bool begin(Threads& threads) noexcept {
+    threads = {};
+    // Detours suspends these threads at commit. Another suspender running at the same time
+    // would freeze this thread, and then neither side can finish.
+    process::freeze::enter_exclusive();
+    if (DetourTransactionBegin() != NO_ERROR) {
+        process::freeze::leave_exclusive();
+        return false;
+    }
+    if (DetourUpdateThread(GetCurrentThread()) != NO_ERROR || !enlist_until_stable(threads)) {
+        (void)DetourTransactionAbort();
+        close_threads(threads);
+        process::freeze::leave_exclusive();
+        return false;
+    }
+    return true;
+}
+
+/** Aborts the active Detours transaction before releasing enlisted thread handles. */
+bool abort(Threads& threads) noexcept {
+    const bool aborted = DetourTransactionAbort() == NO_ERROR;
+    close_threads(threads);
+    process::freeze::leave_exclusive();
+    return aborted;
+}
+
+/** Commits the active Detours transaction before releasing enlisted thread handles. */
+bool commit(Threads& threads) noexcept {
+    const bool committed = DetourTransactionCommit() == NO_ERROR;
+    close_threads(threads);
+    process::freeze::leave_exclusive();
+    return committed;
+}
+
+/** Finds the protected function ranges and checks every suspended instruction pointer. */
+InspectionResult inspect(const Threads& threads,
+                         std::span<const ProtectedCodeEntry> entries) noexcept {
+    if (entries.empty() || entries.size() > kProtectedCodeLimit) {
+        return InspectionResult::failed;
+    }
+
+    std::array<CodeRange, kProtectedCodeLimit> ranges{};
+    for (std::size_t index = 0; index < entries.size(); ++index) {
+        if (!resolve_range(entries[index], ranges[index])) {
+            return InspectionResult::failed;
+        }
+    }
+
+    for (std::size_t threadIndex = 0; threadIndex < threads.count; ++threadIndex) {
+        CONTEXT context{};
+        context.ContextFlags = CONTEXT_CONTROL;
+        if (GetThreadContext(threads.handles[threadIndex], &context) == FALSE) {
+            return InspectionResult::failed;
+        }
+        for (std::size_t rangeIndex = 0; rangeIndex < entries.size(); ++rangeIndex) {
+            const CodeRange range = ranges[rangeIndex];
+            if (context.Rip >= range.begin && context.Rip < range.end) {
+                return InspectionResult::protectedCodeActive;
+            }
+        }
+    }
+    return InspectionResult::clear;
+}
+
+} // namespace sunrise::client::hooking::detour::transaction

+ 43 - 0
Sunrise/src/client/hooking/detour/transaction/detour_thread_transaction.h

@@ -0,0 +1,43 @@
+#pragma once
+
+#include <Windows.h>
+
+#include <array>
+#include <cstddef>
+#include <span>
+
+#include "../../detour.h"
+
+namespace sunrise::client::hooking::detour::transaction {
+
+/** Fixed limit on the handles held while process threads are suspended. */
+constexpr std::size_t kThreadLimit = 1024;
+
+/** Owns the process thread handles enlisted in one Detours transaction. */
+struct Threads {
+    std::array<HANDLE, kThreadLimit> handles{};
+    std::array<DWORD, kThreadLimit> ids{};
+    std::size_t count{};
+};
+
+/** Result of checking suspended instruction pointers. */
+enum class InspectionResult {
+    clear,
+    protectedCodeActive,
+    failed,
+};
+
+/** Starts a transaction and suspends every observed process thread. */
+[[nodiscard]] bool begin(Threads& threads) noexcept;
+
+/** Aborts the current transaction and releases its thread handles. */
+[[nodiscard]] bool abort(Threads& threads) noexcept;
+
+/** Commits the current transaction and releases its thread handles. */
+[[nodiscard]] bool commit(Threads& threads) noexcept;
+
+/** Checks all suspended instruction pointers against protected function ranges. */
+[[nodiscard]] InspectionResult inspect(const Threads& threads,
+                                       std::span<const ProtectedCodeEntry> entries) noexcept;
+
+} // namespace sunrise::client::hooking::detour::transaction

+ 80 - 0
Sunrise/src/client/hooks/assert_handler/assert_handler_lifecycle.cpp

@@ -0,0 +1,80 @@
+#include "assert_handler_lifecycle.h"
+
+#include "../../../core/logging/log.h"
+#include "../../targets/game/assert_handler.h"
+#include "assert_handler_observer.h"
+
+namespace sunrise::client::hooks::assert_handler {
+
+SRWLOCK g_lock{SRWLOCK_INIT};
+bool g_installed{};
+
+namespace {
+
+/**
+ * Exchanges the handler slot under its own page protection.
+ * @param slot Resolved handler slot.
+ * @param expected Value the slot must currently hold.
+ * @param value Replacement pointer.
+ * @return True when the slot holds the replacement after the call.
+ */
+[[nodiscard]] bool exchange(std::byte** slot, const void* expected, void* value) noexcept {
+    if (slot == nullptr || *slot != expected) {
+        return false;
+    }
+    // The slot is a writable data global, so the write needs no protection change. The game's own
+    // setter stores it with a plain move. TODO: call that setter instead of writing the slot.
+    *slot = static_cast<std::byte*>(value);
+    return true;
+}
+
+} // namespace
+
+/** Replaces the game's fatal assert handler so an assert reports instead of halting. */
+bool install() noexcept {
+    AcquireSRWLockExclusive(&g_lock);
+    if (g_installed) {
+        ReleaseSRWLockExclusive(&g_lock);
+        return true;
+    }
+    if (!targets::game::assert_handler::is_resolved()) {
+        ReleaseSRWLockExclusive(&g_lock);
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::warn,
+                         "ev=assert stage=install result=fail reason=target");
+        return true;
+    }
+    const targets::game::assert_handler::Targets& resolved = targets::game::assert_handler::get();
+    const bool installed = exchange(resolved.slot, resolved.original, handler_entry_point());
+    g_installed = installed;
+    ReleaseSRWLockExclusive(&g_lock);
+    core::log::write(core::log::Channel::client,
+                     installed ? core::log::Level::info : core::log::Level::warn,
+                     installed ? "ev=assert stage=install result=ok"
+                               : "ev=assert stage=install result=fail reason=slot");
+    return installed;
+}
+
+/** Restores the game's own handler so a later assert cannot call into unmapped code. */
+bool uninstall() noexcept {
+    AcquireSRWLockExclusive(&g_lock);
+    if (!g_installed) {
+        ReleaseSRWLockExclusive(&g_lock);
+        return true;
+    }
+    const targets::game::assert_handler::Targets& resolved = targets::game::assert_handler::get();
+    const bool restored = exchange(resolved.slot, handler_entry_point(), resolved.original);
+    g_installed = !restored;
+    ReleaseSRWLockExclusive(&g_lock);
+    return restored;
+}
+
+/** @return True while Sunrise's handler owns the slot. */
+bool is_installed() noexcept {
+    AcquireSRWLockShared(&g_lock);
+    const bool installed = g_installed;
+    ReleaseSRWLockShared(&g_lock);
+    return installed;
+}
+
+} // namespace sunrise::client::hooks::assert_handler

+ 17 - 0
Sunrise/src/client/hooks/assert_handler/assert_handler_lifecycle.h

@@ -0,0 +1,17 @@
+#pragma once
+
+namespace sunrise::client::hooks::assert_handler {
+
+/**
+ * Replaces the game's fatal assert handler so an assert reports instead of halting.
+ * @return True when the handler is installed or its target was never found.
+ */
+[[nodiscard]] bool install() noexcept;
+
+/** Restores the game's own handler. */
+[[nodiscard]] bool uninstall() noexcept;
+
+/** @return True while Sunrise's handler owns the slot. */
+[[nodiscard]] bool is_installed() noexcept;
+
+} // namespace sunrise::client::hooks::assert_handler

+ 90 - 0
Sunrise/src/client/hooks/assert_handler/assert_handler_observer.cpp

@@ -0,0 +1,90 @@
+#include "assert_handler_observer.h"
+
+#include <array>
+#include <atomic>
+#include <cstdarg>
+#include <cstdio>
+#include <cstring>
+
+#include "../../../core/logging/log.h"
+
+namespace sunrise::client::hooks::assert_handler {
+namespace {
+
+/** The game formats assert text into a buffer of this size, so it bounds ours too. */
+constexpr std::size_t kTextCapacity = 1024;
+/** One log line carries the message plus its fixed prefix. */
+constexpr std::size_t kLineCapacity = 1152;
+/** A per-frame assert must not fill the log; later hits are counted only. */
+constexpr std::uint32_t kReportLimit = 200;
+/** Used when the game's own format string cannot be printed. */
+constexpr char kUnformattable[] = "<unformattable>";
+
+std::atomic<std::uint32_t> g_reports{};
+
+/**
+ * Records one assert without letting logging failures reach the game.
+ * @param code Native first argument, always zero at the observed sites.
+ * @param text Already-formatted assert message.
+ */
+void report(int code, const char* text) noexcept {
+    const std::uint32_t seen = g_reports.fetch_add(1, std::memory_order_relaxed) + 1;
+    if (seen > kReportLimit) {
+        return;
+    }
+    std::array<char, kLineCapacity> line{};
+    const int written = std::snprintf(
+        line.data(), line.size(), "ev=assert stage=hit n=%u arg0=%d text=%s", seen, code, text);
+    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::error, {line.data(), length});
+    if (seen == kReportLimit) {
+        core::log::write(
+            core::log::Channel::client, core::log::Level::warn, "ev=assert stage=cap result=ok");
+    }
+}
+
+/**
+ * Replacement assert handler. The sites call this slot as a printf-style callback, and returning
+ * without calling the game's own handler is what makes the assert non-fatal: the shipped handler
+ * builds a crash ticket, shows a dialog and blocks.
+ * @param code Native first argument.
+ * @param format Native printf-style format string.
+ */
+void __cdecl handler_body(int code, const char* format, ...) noexcept {
+    std::array<char, kTextCapacity> text{};
+    if (format == nullptr) {
+        std::memcpy(text.data(), kUnformattable, sizeof kUnformattable);
+    } else {
+        va_list arguments;
+        va_start(arguments, format);
+        const int written = std::vsnprintf(text.data(), text.size(), format, arguments);
+        va_end(arguments);
+        if (written < 0) {
+            std::memcpy(text.data(), kUnformattable, sizeof kUnformattable);
+        }
+    }
+    text.back() = '\0';
+
+    // An assert raised by our own logging must not re-enter this body.
+    static thread_local bool inside = false;
+    if (inside) {
+        return;
+    }
+    inside = true;
+    report(code, text.data());
+    inside = false;
+}
+
+} // namespace
+
+/** @return Address of the internal assert handler body. */
+void* handler_entry_point() noexcept {
+    return reinterpret_cast<void*>(&handler_body);
+}
+
+} // namespace sunrise::client::hooks::assert_handler

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

@@ -0,0 +1,13 @@
+#pragma once
+
+#include <Windows.h>
+
+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;
+
+} // namespace sunrise::client::hooks::assert_handler

+ 179 - 0
Sunrise/src/client/hooks/banner/banner_bind.cpp

@@ -0,0 +1,179 @@
+/**
+ * Bind for the orbit banner component. Its tick calls the update body only when a dirty byte is
+ * set, and an unbound component can never become dirty, so it keeps the constructor's values for
+ * the whole run: power zero, no art, emblem state 2. The bind writes the identity triple State
+ * already publishes and marks the component dirty once.
+ */
+
+#include "banner_bind.h"
+
+#include <array>
+#include <atomic>
+#include <cstddef>
+#include <cstdint>
+#include <cstdio>
+#include <cstring>
+
+#include "../../../core/logging/log.h"
+#include "../../../state/runtime/runtime.h"
+#include "../../hooking/detour.h"
+
+namespace sunrise::client::hooks::banner {
+namespace {
+
+using BannerTick = std::int64_t(__fastcall*)(void*, std::int64_t);
+
+hooking::detour::Handle g_handle{};
+std::atomic<BannerTick> g_original{nullptr};
+std::atomic<unsigned> g_reported{0};
+
+/** What the update body left in the component, read after it has run. */
+struct Published {
+    std::int32_t light{};
+    std::uint16_t definition{};
+    std::uint32_t art{};
+    std::uint32_t state{};
+};
+
+/** One resolved identity the banner can be bound to. */
+struct Identity {
+    std::uint64_t account{};
+    std::uint64_t character{};
+    bool resolved{};
+};
+
+/** @return The account and selected-character keys State publishes, when both are set. */
+[[nodiscard]] Identity selected_identity() noexcept {
+    const state::AccountState account = state::account_snapshot();
+    if (account.primarySoid == 0) {
+        return Identity{};
+    }
+    for (std::size_t index = 0; index < account.characterCount; ++index) {
+        const state::CharacterState& character = account.characters[index];
+        if (character.selected && character.soid != 0) {
+            return Identity{account.primarySoid, character.soid, true};
+        }
+    }
+    return Identity{};
+}
+
+/**
+ * Reports one bind and what the update body then published, so a boot says whether the bind was
+ * enough, not just that it happened.
+ * @param identity Keys the component was bound to.
+ * @param self Component address, which separates one instance from another.
+ * @param published What the update body wrote once it had run.
+ */
+void report(const Identity& identity, const void* self, const Published& published) noexcept {
+    const unsigned emitted = g_reported.load(std::memory_order_relaxed);
+    if (emitted >= kMaxBindReports) {
+        return;
+    }
+    g_reported.store(emitted + 1, std::memory_order_relaxed);
+    std::array<char, kReportCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=banner stage=bind this=0x%016llX character=0x%016llX"
+                                      " light=%d emblem_def=%u emblem_art=0x%08X emblem_state=%u"
+                                      " result=bound",
+                                      reinterpret_cast<unsigned long long>(self),
+                                      static_cast<unsigned long long>(identity.character),
+                                      static_cast<int>(published.light),
+                                      static_cast<unsigned>(published.definition),
+                                      static_cast<unsigned>(published.art),
+                                      static_cast<unsigned>(published.state));
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::info,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/**
+ * Binds an unbound component before its tick reads the triple, then runs the original.
+ * @param self Borrowed banner component.
+ * @param frame Frame argument the tick forwards unchanged.
+ * @return The tick's own result.
+ */
+__declspec(noinline) std::int64_t __fastcall tick(void* self, std::int64_t frame) noexcept {
+    const BannerTick original = g_original.load(std::memory_order_acquire);
+    if (original == nullptr) {
+        return 0;
+    }
+    if (self == nullptr) {
+        return original(self, frame);
+    }
+    std::byte* const component = static_cast<std::byte*>(self);
+    std::uint64_t account = 0;
+    std::uint8_t family = 0;
+    std::memcpy(&account, component + BannerLayout::accountKey, sizeof account);
+    std::memcpy(&family, component + BannerLayout::family, sizeof family);
+    if (account != 0 && family != kBannerFamilyNone) {
+        return original(self, frame);
+    }
+    const Identity identity = selected_identity();
+    if (!identity.resolved) {
+        return original(self, frame);
+    }
+    std::memcpy(component + BannerLayout::accountKey, &identity.account, sizeof identity.account);
+    std::memcpy(
+        component + BannerLayout::characterKey, &identity.character, sizeof identity.character);
+    std::memcpy(component + BannerLayout::family, &kBannerFamily, sizeof kBannerFamily);
+    std::memcpy(component + BannerLayout::dirty, &kBannerDirty, sizeof kBannerDirty);
+    // The update body runs inside this call, so its published fields are read after it returns.
+    const std::int64_t result = original(self, frame);
+    Published published{};
+    std::memcpy(&published.light, component + BannerLayout::light, sizeof published.light);
+    std::memcpy(&published.definition,
+                component + BannerLayout::emblemDefinition,
+                sizeof published.definition);
+    std::memcpy(&published.art, component + BannerLayout::emblemArt, sizeof published.art);
+    std::memcpy(&published.state, component + BannerLayout::emblemState, sizeof published.state);
+    report(identity, self, published);
+    return result;
+}
+
+/** @param reason Key naming the step that failed. @return False, for a direct return. */
+bool fail_install(const char* reason) noexcept {
+    std::array<char, kReportCapacity> line{};
+    const int written = std::snprintf(
+        line.data(), line.size(), "ev=banner stage=bind result=fail reason=%s", reason);
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+    return false;
+}
+
+} // namespace
+
+/** Finds the banner tick and attaches the bind. */
+bool install_banner_bind() noexcept {
+    if (g_handle.attached) {
+        return true;
+    }
+    std::byte* const target = scan_main_image_unique(kBannerTickSignature, "banner_tick");
+    if (target == nullptr) {
+        return fail_install("target");
+    }
+    const hooking::detour::Spec spec{target, reinterpret_cast<void*>(&tick)};
+    if (!hooking::detour::install(spec, g_handle)) {
+        return fail_install("attach");
+    }
+    g_original.store(reinterpret_cast<BannerTick>(g_handle.original), std::memory_order_release);
+    core::log::write(
+        core::log::Channel::client, core::log::Level::info, "ev=banner stage=bind result=ok");
+    return true;
+}
+
+/** Detaches the banner bind and drops the trampoline it got. */
+void uninstall_banner_bind() noexcept {
+    if (g_handle.attached) {
+        (void)hooking::detour::uninstall(g_handle);
+    }
+    g_original.store(nullptr, std::memory_order_release);
+    g_reported.store(0, std::memory_order_release);
+}
+
+} // namespace sunrise::client::hooks::banner

+ 75 - 0
Sunrise/src/client/hooks/banner/banner_bind.h

@@ -0,0 +1,75 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <string_view>
+
+#include "../../patterns/image_scan.h"
+
+namespace sunrise::client::hooks::banner {
+
+using patterns::scan_main_image_unique;
+using patterns::signature;
+using patterns::signature_length;
+
+/** The orbit banner component's per-frame tick, matched on its prologue and argument moves. */
+inline constexpr std::string_view kBannerTickSignatureText =
+    "48 89 5C 24 ? 55 56 57 41 55 41 56 48 83 EC ? 4C 8B F2 48 8B D9";
+/** Compiled pattern bytes of the signature text above. */
+inline constexpr auto kBannerTickSignature =
+    signature<signature_length(kBannerTickSignatureText)>(kBannerTickSignatureText);
+
+/**
+ * Fields of the banner component the bind writes, as byte offsets from its base. The tick calls
+ * its update body only when a dirty byte is set, and an unbound component never becomes dirty.
+ */
+struct BannerLayout {
+    /** Account key of the identity triple the update body looks up. */
+    static constexpr std::size_t accountKey = 136;
+    /** Character key of the same triple. */
+    static constexpr std::size_t characterKey = 144;
+    /** Queuez family the triple is resolved against, read as a signed byte. */
+    static constexpr std::size_t family = 152;
+    /** Dirty byte the tick tests and then clears. */
+    static constexpr std::size_t dirty = 1196;
+    /** Light the update body publishes. Read only, to report whether the bind produced one. */
+    static constexpr std::size_t light = 548;
+    /**
+     * Emblem fields the update body publishes, as `a3 + 492/504/512` where `a3` is this base plus
+     * 160: the item definition index, the resolved art entry, and the state. State 2 is the
+     * unresolved branch, which writes -1 into both of the others.
+     */
+    static constexpr std::size_t emblemDefinition = 652;
+    /** Resolved art entry, written into both halves of a qword. */
+    static constexpr std::size_t emblemArt = 664;
+    /** Emblem state, 0 when resolved and 2 when the update body found no item. */
+    static constexpr std::size_t emblemState = 672;
+};
+
+/**
+ * Family 4 carries the objects whose banners do update, and it picks the update arm that finds
+ * both the emblem and the light. The registrar refuses anything above 7.
+ */
+inline constexpr std::uint8_t kBannerFamily = 4;
+/** Family value of an unbound component. It is read signed, so it sign-extends to -1. */
+inline constexpr std::uint8_t kBannerFamilyNone = 0xFF;
+/** Value written into the dirty byte so the tick runs its update body once. */
+inline constexpr std::uint8_t kBannerDirty = 1;
+
+/** Bind lines allowed per run. The tick runs every frame, so an uncapped report buries the log. */
+inline constexpr unsigned kMaxBindReports = 12;
+
+/** Bounds one report line at the width of its fields. */
+inline constexpr std::size_t kReportCapacity = 224;
+
+/**
+ * Attaches the banner bind, which gives the orbit banner component an identity so its update body
+ * runs. Without it the component keeps its constructor values: power zero, no art, emblem state 2.
+ * @return True when the target is found and the detour attaches.
+ */
+[[nodiscard]] bool install_banner_bind() noexcept;
+
+/** Detaches the banner bind. */
+void uninstall_banner_bind() noexcept;
+
+} // namespace sunrise::client::hooks::banner

+ 32 - 0
Sunrise/src/client/hooks/banner/banner_hook_lifecycle.cpp

@@ -0,0 +1,32 @@
+#include "banner_hook_lifecycle.h"
+
+#include <atomic>
+
+#include "banner_bind.h"
+
+namespace sunrise::client::hooks::banner {
+namespace {
+
+std::atomic_bool g_installed{false};
+
+} // namespace
+
+/** Attaches the banner identity bind. */
+bool install() noexcept {
+    const bool bind = install_banner_bind();
+    g_installed.store(bind, std::memory_order_release);
+    return bind;
+}
+
+/** Detaches the banner chain, in the reverse order of install. */
+void uninstall() noexcept {
+    uninstall_banner_bind();
+    g_installed.store(false, std::memory_order_release);
+}
+
+/** @return True while the banner bind is attached. */
+bool is_installed() noexcept {
+    return g_installed.load(std::memory_order_acquire);
+}
+
+} // namespace sunrise::client::hooks::banner

+ 17 - 0
Sunrise/src/client/hooks/banner/banner_hook_lifecycle.h

@@ -0,0 +1,17 @@
+#pragma once
+
+namespace sunrise::client::hooks::banner {
+
+/**
+ * Attaches the orbit banner component bind.
+ * @return True when the bind attached.
+ */
+[[nodiscard]] bool install() noexcept;
+
+/** Detaches the banner component bind. */
+void uninstall() noexcept;
+
+/** @return True while the banner bind is attached. */
+[[nodiscard]] bool is_installed() noexcept;
+
+} // namespace sunrise::client::hooks::banner

+ 32 - 0
Sunrise/src/client/hooks/bitmap/bitmap_hook_lifecycle.cpp

@@ -0,0 +1,32 @@
+#include "bitmap_hook_lifecycle.h"
+
+#include <atomic>
+
+#include "bitmap_ref_guard.h"
+
+namespace sunrise::client::hooks::bitmap {
+namespace {
+
+std::atomic_bool g_installed{false};
+
+} // namespace
+
+/** Attaches the bitmap crash guards. */
+bool install() noexcept {
+    const bool refGuard = install_bitmap_ref_guard();
+    g_installed.store(refGuard, std::memory_order_release);
+    return refGuard;
+}
+
+/** Detaches every bitmap crash guard, in the reverse order of install. */
+void uninstall() noexcept {
+    uninstall_bitmap_ref_guard();
+    g_installed.store(false, std::memory_order_release);
+}
+
+/** @return True while at least one bitmap crash guard is attached. */
+bool is_installed() noexcept {
+    return g_installed.load(std::memory_order_acquire);
+}
+
+} // namespace sunrise::client::hooks::bitmap

+ 18 - 0
Sunrise/src/client/hooks/bitmap/bitmap_hook_lifecycle.h

@@ -0,0 +1,18 @@
+#pragma once
+
+namespace sunrise::client::hooks::bitmap {
+
+/**
+ * Attaches the bitmap crash guards. These are required fixes, so they carry no settings switch.
+ * Each guard reports its own outcome, so a single miss never disables the others.
+ * @return True when every guard attached.
+ */
+[[nodiscard]] bool install() noexcept;
+
+/** Detaches every bitmap crash guard. */
+void uninstall() noexcept;
+
+/** @return True while at least one bitmap crash guard is attached. */
+[[nodiscard]] bool is_installed() noexcept;
+
+} // namespace sunrise::client::hooks::bitmap

+ 163 - 0
Sunrise/src/client/hooks/bitmap/bitmap_ref_guard.cpp

@@ -0,0 +1,163 @@
+/**
+ * Guard on the widget bitmap-reference setter. A reference that is not a tag passes the setter's
+ * own `!= 0xFFFFFFFF` gate, decodes to a table slot that was never made, and faults the reader.
+ * The guard puts the none sentinel in its place, so a bad reference costs one bitmap, not the
+ * process.
+ */
+
+#include "bitmap_ref_guard.h"
+
+#include <array>
+#include <atomic>
+#include <cstddef>
+#include <cstdint>
+#include <cstdio>
+#include <cstring>
+
+#include "../../../core/logging/log.h"
+#include "../../hooking/detour.h"
+
+namespace sunrise::client::hooks::bitmap {
+namespace {
+
+using SetBitmapRef = void(__fastcall*)(void*, void*);
+
+hooking::detour::Handle g_handle{};
+std::atomic<SetBitmapRef> g_original{nullptr};
+std::atomic<unsigned> g_reported{0};
+std::atomic<unsigned> g_seenCount{0};
+std::array<std::atomic<std::uint32_t>, kMaxSeenReports> g_seen{};
+
+/**
+ * Reports each new reference once, so a boot shows whether a bitmap ever reaches a widget. The
+ * table is small and append-only, so a linear scan beats an index.
+ */
+void observe(std::uint32_t reference) noexcept {
+    const unsigned count = g_seenCount.load(std::memory_order_acquire);
+    for (unsigned index = 0; index < count && index < g_seen.size(); ++index) {
+        if (g_seen[index].load(std::memory_order_relaxed) == reference) {
+            return;
+        }
+    }
+    if (count >= g_seen.size()) {
+        return;
+    }
+    g_seen[count].store(reference, std::memory_order_relaxed);
+    g_seenCount.store(count + 1, std::memory_order_release);
+    std::array<char, kReportCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=bitmap stage=seen handle=0x%08X result=ok",
+                                      static_cast<unsigned>(reference));
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/** @return True when the reference is in tag space. */
+bool looks_like_tag(std::uint32_t reference) noexcept {
+    const std::uint32_t top = reference >> kTopByteShift;
+    return top == kTagTopByteLow || top == kTagTopByteHigh;
+}
+
+/** @param triple Rejected triple, with the reference word already replaced. */
+void report(const std::uint32_t (&triple)[BitmapRefWords::count],
+            std::uint32_t reference) noexcept {
+    const unsigned emitted = g_reported.load(std::memory_order_relaxed);
+    if (emitted >= kMaxReports) {
+        return;
+    }
+    g_reported.store(emitted + 1, std::memory_order_relaxed);
+    std::array<char, kReportCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=bitmap stage=ref_guard handle=0x%08X tag=0x%08X"
+                                      " state=%u result=sentinel",
+                                      static_cast<unsigned>(reference),
+                                      static_cast<unsigned>(triple[BitmapRefWords::tag]),
+                                      static_cast<unsigned>(triple[BitmapRefWords::state]));
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/**
+ * Puts the none sentinel in place of a reference that is not a tag. The caller owns its triple, so
+ * the sentinel goes in a copy and the original bytes are never written.
+ * @param self Borrowed widget the setter writes into.
+ * @param ref Borrowed reference triple.
+ */
+__declspec(noinline) void __fastcall setter(void* self, void* ref) noexcept {
+    const SetBitmapRef original = g_original.load(std::memory_order_acquire);
+    if (original == nullptr) {
+        return;
+    }
+    if (ref == nullptr) {
+        original(self, ref);
+        return;
+    }
+    std::uint32_t triple[BitmapRefWords::count]{};
+    std::memcpy(triple, ref, sizeof triple);
+    const std::uint32_t reference = triple[BitmapRefWords::reference];
+    observe(reference);
+    if (reference == kNoneReference || looks_like_tag(reference)) {
+        original(self, ref);
+        return;
+    }
+    triple[BitmapRefWords::reference] = kNoneReference;
+    original(self, triple);
+    report(triple, reference);
+}
+
+/** @param reason Key naming the step that failed. @return False, for a direct return. */
+bool fail_install(const char* reason) noexcept {
+    std::array<char, kReportCapacity> line{};
+    const int written = std::snprintf(
+        line.data(), line.size(), "ev=bitmap stage=ref_guard result=fail reason=%s", reason);
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+    return false;
+}
+
+} // namespace
+
+/** Finds the setter and attaches the guard. */
+bool install_bitmap_ref_guard() noexcept {
+    if (g_handle.attached) {
+        return true;
+    }
+    std::byte* const target = scan_main_image_unique(kSetterSignature, "bitmap_set_ref");
+    if (target == nullptr) {
+        return fail_install("target");
+    }
+    const hooking::detour::Spec spec{target, reinterpret_cast<void*>(&setter)};
+    if (!hooking::detour::install(spec, g_handle)) {
+        return fail_install("attach");
+    }
+    g_original.store(reinterpret_cast<SetBitmapRef>(g_handle.original), std::memory_order_release);
+    core::log::write(
+        core::log::Channel::client, core::log::Level::info, "ev=bitmap stage=ref_guard result=ok");
+    return true;
+}
+
+/** Detaches the bitmap-reference guard and drops its trampoline. */
+void uninstall_bitmap_ref_guard() noexcept {
+    if (g_handle.attached) {
+        (void)hooking::detour::uninstall(g_handle);
+    }
+    g_original.store(nullptr, std::memory_order_release);
+    g_reported.store(0, std::memory_order_release);
+    g_seenCount.store(0, std::memory_order_release);
+    for (std::atomic<std::uint32_t>& entry : g_seen) {
+        entry.store(0, std::memory_order_relaxed);
+    }
+}
+
+} // namespace sunrise::client::hooks::bitmap

+ 76 - 0
Sunrise/src/client/hooks/bitmap/bitmap_ref_guard.h

@@ -0,0 +1,76 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <string_view>
+
+#include "../../patterns/image_scan.h"
+
+namespace sunrise::client::hooks::bitmap {
+
+using patterns::scan_main_image_unique;
+using patterns::signature;
+using patterns::signature_length;
+
+/**
+ * The bitmap-reference setter. Seven sibling widget classes share its first 16 bytes and differ
+ * only in the stored-field offset, so the signature must reach the `+0x128` compare.
+ */
+inline constexpr std::string_view kSetterSignatureText =
+    "40 53 48 83 EC ? F2 0F 10 02 48 8B D9 8B 4A 08 "
+    "F2 0F 11 44 24 ? 8B 44 24 ? 39 83 28 01 00 00";
+/** Compiled pattern bytes of the signature text above. */
+inline constexpr auto kSetterSignature =
+    signature<signature_length(kSetterSignatureText)>(kSetterSignatureText);
+
+/** Words of the 12-byte reference triple the setter writes. */
+struct BitmapRefWords {
+    /** Definition tag of the bound property. */
+    static constexpr std::size_t tag = 0;
+    /** The bitmap reference itself. Only this word is guarded. */
+    static constexpr std::size_t reference = 1;
+    /** Draw state the widget stores beside the reference. */
+    static constexpr std::size_t state = 2;
+    /** Words in one triple. */
+    static constexpr std::size_t count = 3;
+};
+
+/** Reference meaning no bitmap. The reader skips its decode, so it can never fault. */
+inline constexpr std::uint32_t kNoneReference = 0xFFFFFFFFu;
+
+/**
+ * Tag space is the whole `0x80` and `0x81` top byte. A narrower test blanks real bitmaps:
+ * `0x80A146C7` and its neighbours are real. A zero or a definition hash sits below `0x80`.
+ */
+inline constexpr std::uint32_t kTagTopByteLow = 0x80u;
+/** Upper half of tag space. */
+inline constexpr std::uint32_t kTagTopByteHigh = 0x81u;
+/** Aligns a reference's top byte down for the tag test. */
+inline constexpr int kTopByteShift = 24;
+
+/**
+ * Guard lines allowed per run. A rejected reference repeats on every widget refresh, so an
+ * uncapped guard buries the rest of the log. The first few carry the reference and its tag.
+ */
+inline constexpr unsigned kMaxReports = 8;
+
+/** Size of one guard line, set by its reference, tag and state fields. */
+inline constexpr std::size_t kReportCapacity = 128;
+
+/**
+ * New references reported per run while watching is on. It answers one question: does a bitmap
+ * ever reach a widget. Repeats are dropped, so the cap counts distinct references.
+ */
+inline constexpr unsigned kMaxSeenReports = 40;
+
+/**
+ * Attaches the bitmap-reference guard. It keeps a reference that is not a tag out of the widget
+ * field a later reader decodes.
+ * @return True when the target is found and the detour attaches.
+ */
+[[nodiscard]] bool install_bitmap_ref_guard() noexcept;
+
+/** Detaches the bitmap-reference guard. */
+void uninstall_bitmap_ref_guard() noexcept;
+
+} // namespace sunrise::client::hooks::bitmap

+ 59 - 0
Sunrise/src/client/hooks/bootflow/bootflow_hook_lifecycle.cpp

@@ -0,0 +1,59 @@
+#include "bootflow_hook_lifecycle.h"
+
+#include <atomic>
+
+#include "internal.h"
+
+namespace sunrise::client::hooks::bootflow {
+namespace {
+
+std::atomic_bool g_installed{false};
+
+} // 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.
+ * @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 joinReady = install_join_request_ready();
+    const bool ownerSlot = install_owner_activity_slot();
+    const bool regionPrivate = install_region_private();
+    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 || joinReady || ownerSlot
+                        || regionPrivate || worldStep || spawn || fade;
+    g_installed.store(anyFix, std::memory_order_release);
+    return hold && sliceSet && skip && composition && handoff && joinReady && ownerSlot
+           && regionPrivate && worldStep && spawn && fade;
+}
+
+/** Detaches every boot-step fix, in the reverse order of install. */
+void uninstall() noexcept {
+    uninstall_fade_release();
+    uninstall_spawn_hold();
+    uninstall_world_step();
+    uninstall_region_private();
+    uninstall_owner_activity_slot();
+    uninstall_join_request_ready();
+    uninstall_orbit_handoff();
+    uninstall_composition_check();
+    uninstall_profile_setup_skip();
+    uninstall_orbit_slice_set();
+    uninstall_character_select_hold();
+    g_installed.store(false, std::memory_order_release);
+}
+
+/** @return True while at least one boot-step fix is attached. */
+bool is_installed() noexcept {
+    return g_installed.load(std::memory_order_acquire);
+}
+
+} // namespace sunrise::client::hooks::bootflow

+ 18 - 0
Sunrise/src/client/hooks/bootflow/bootflow_hook_lifecycle.h

@@ -0,0 +1,18 @@
+#pragma once
+
+namespace sunrise::client::hooks::bootflow {
+
+/**
+ * Attaches the boot-step fixes that carry sign-in through to character select.
+ * Each fix reports its own outcome, so a single miss never disables the others.
+ * @return True when every fix attached.
+ */
+[[nodiscard]] bool install() noexcept;
+
+/** Detaches every boot-step fix. */
+void uninstall() noexcept;
+
+/** @return True while at least one boot-step fix is attached. */
+[[nodiscard]] bool is_installed() noexcept;
+
+} // namespace sunrise::client::hooks::bootflow

+ 105 - 0
Sunrise/src/client/hooks/bootflow/character_select_hold.cpp

@@ -0,0 +1,105 @@
+#include <array>
+#include <atomic>
+#include <cstddef>
+#include <cstdint>
+#include <cstring>
+#include <string_view>
+
+#include "../../../core/logging/log.h"
+#include "../../hooking/detour.h"
+#include "internal.h"
+
+namespace sunrise::client::hooks::bootflow {
+namespace {
+
+/**
+ * The character sign-in step's enter handler. Anchored on its two setup stores, which write the
+ * step's latch field and the paired setup words, so it cannot match a sibling step's handler.
+ */
+constexpr std::string_view kEnterSignatureText =
+    "40 53 48 83 EC ? 48 8B D9 C7 41 38 FF FF FF FF 66 C7 41 3C 00 00 33 D2";
+/** Compiled pattern bytes of the signature text above. */
+constexpr auto kEnterSignature =
+    signature<signature_length(kEnterSignatureText)>(kEnterSignatureText);
+
+/** Fields of the boot step this hook touches, as byte offsets from the step's own base. */
+struct StepLayout {
+    /**
+     * The stay flag. The exit gate needs it non-zero to stay on the select screen. The enter
+     * handler clears it, so the step would skip selection on its first frame.
+     */
+    static constexpr std::size_t stayFlag = 72;
+};
+
+/** Value the step's own listener writes to hold the screen. Matched here. */
+constexpr std::uint8_t kHold = 1;
+
+using EnterHandler = void(__fastcall*)(std::byte*);
+
+hooking::detour::Handle g_handle{};
+std::atomic<EnterHandler> g_original{nullptr};
+std::atomic_bool g_reported{false};
+
+/**
+ * Holds the character-select screen after the step sets itself up.
+ * Runs once per boot on the boot-step thread, after the original writes its defaults, so the
+ * original cannot overwrite this write.
+ * @param step Borrowed boot-step base pointer.
+ */
+__declspec(noinline) void __fastcall enter_handler(std::byte* step) noexcept {
+    const EnterHandler original = g_original.load(std::memory_order_acquire);
+    if (original != nullptr) {
+        original(step);
+    }
+    if (step == nullptr) {
+        return;
+    }
+    std::memcpy(step + StepLayout::stayFlag, &kHold, sizeof kHold);
+    if (!g_reported.exchange(true, std::memory_order_relaxed)) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::info,
+                         "ev=bootflow stage=character_select result=held");
+    }
+}
+
+} // namespace
+
+/**
+ * Attaches the character-select hold.
+ * @return True when the target is found and the detour attaches.
+ */
+bool install_character_select_hold() noexcept {
+    if (g_handle.attached) {
+        return true;
+    }
+    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;
+    }
+    const hooking::detour::Spec spec{target, reinterpret_cast<void*>(&enter_handler)};
+    if (!hooking::detour::install(spec, g_handle)) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::warn,
+                         "ev=bootflow stage=character_select result=fail reason=attach");
+        return false;
+    }
+    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. */
+void uninstall_character_select_hold() noexcept {
+    if (g_handle.attached) {
+        (void)hooking::detour::uninstall(g_handle);
+    }
+    g_original.store(nullptr, std::memory_order_release);
+    g_reported.store(false, std::memory_order_release);
+}
+
+} // namespace sunrise::client::hooks::bootflow

+ 144 - 0
Sunrise/src/client/hooks/bootflow/composition_check.cpp

@@ -0,0 +1,144 @@
+#include <array>
+#include <atomic>
+#include <cstddef>
+#include <cstdint>
+#include <cstdio>
+#include <cstring>
+#include <string_view>
+
+#include "../../../core/logging/log.h"
+#include "../../hooking/detour.h"
+#include "internal.h"
+
+namespace sunrise::client::hooks::bootflow {
+namespace {
+
+/**
+ * The matchmaking composition check. Its prologue repeats across the image, so the pattern runs
+ * on to the properties byte read, which is unique to this function. Every branch displacement is
+ * wildcarded.
+ */
+constexpr std::string_view kCheckSignatureText =
+    "48 89 5C 24 ? 57 48 83 EC ? 48 8B DA 48 8B F9 48 85 C9 0F 84 ? ? ? ? 48 85 D2 0F 84 ? ? ? ? "
+    "0F B6 82 A2 02 00 00";
+/** Compiled pattern bytes of the signature text above. */
+constexpr auto kCheckSignature =
+    signature<signature_length(kCheckSignatureText)>(kCheckSignatureText);
+
+/** Fields of the composition properties this hook touches, as byte offsets from their base. */
+struct PropertiesLayout {
+    /**
+     * Big fireteam count, signed 32-bit. The check fails the composition when it is over the set
+     * cap. Both operands are local, so only this field can change the result.
+     */
+    static constexpr std::size_t bigFireteamCount = 20;
+};
+
+/** Count that clears the cap comparison for every configured cap. */
+constexpr std::int32_t kSolo = 0;
+
+/** Check result meaning an argument was null; also returned when the trampoline is gone. */
+constexpr std::int64_t kNullArgument = 3;
+
+/**
+ * Lines allowed per run. The check runs on every composition test, so an uncapped report buries
+ * the rest of the log. This budget still shows the count the boot started with.
+ */
+constexpr unsigned kMaxReports = 4;
+
+/** Size of one zeroing line, set by its stage and count fields. */
+constexpr std::size_t kLineCapacity = 96;
+
+using Check = std::int64_t(__fastcall*)(void*, std::byte*);
+
+hooking::detour::Handle g_handle{};
+std::atomic<Check> g_original{nullptr};
+std::atomic<unsigned> g_reported{0};
+
+/**
+ * Emits one zeroing event while the per-run budget lasts.
+ * @param count The count that was replaced.
+ */
+void report(std::int32_t count) noexcept {
+    // One atomic claim per line, so a concurrent check cannot reuse a budget slot.
+    if (g_reported.fetch_add(1, std::memory_order_relaxed) >= kMaxReports) {
+        return;
+    }
+    std::array<char, kLineCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=bootflow stage=composition result=zeroed count=%d",
+                                      static_cast<int>(count));
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::info,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/**
+ * Clears the big fireteam count so the composition check passes.
+ * The write repeats on every call because the field's producer rewrites it each run.
+ * Nothing between entry and the compare rebuilds it, so the entry write is the one compared.
+ * @param config Borrowed composition config, passed through untouched.
+ * @param props Borrowed composition properties whose count is cleared.
+ * @return The check's own result, or the null-argument result when the trampoline is gone.
+ */
+__declspec(noinline) std::int64_t __fastcall check(void* config, std::byte* props) noexcept {
+    const Check original = g_original.load(std::memory_order_acquire);
+    if (original == nullptr) {
+        return kNullArgument;
+    }
+    if (props == nullptr) {
+        return original(config, props);
+    }
+    std::int32_t count = 0;
+    std::memcpy(&count, props + PropertiesLayout::bigFireteamCount, sizeof count);
+    std::memcpy(props + PropertiesLayout::bigFireteamCount, &kSolo, sizeof kSolo);
+    if (count != kSolo) {
+        report(count);
+    }
+    return original(config, props);
+}
+
+} // namespace
+
+/**
+ * Attaches the solo composition fix.
+ * @return True when the target is found and the detour attaches.
+ */
+bool install_composition_check() noexcept {
+    if (g_handle.attached) {
+        return true;
+    }
+    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;
+    }
+    const hooking::detour::Spec spec{target, reinterpret_cast<void*>(&check)};
+    if (!hooking::detour::install(spec, g_handle)) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::warn,
+                         "ev=bootflow stage=composition result=fail reason=attach");
+        return false;
+    }
+    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. */
+void uninstall_composition_check() noexcept {
+    if (g_handle.attached) {
+        (void)hooking::detour::uninstall(g_handle);
+    }
+    g_original.store(nullptr, std::memory_order_release);
+    g_reported.store(0, std::memory_order_release);
+}
+
+} // namespace sunrise::client::hooks::bootflow

+ 119 - 0
Sunrise/src/client/hooks/bootflow/fade_release.cpp

@@ -0,0 +1,119 @@
+#include <array>
+#include <atomic>
+#include <cstddef>
+#include <cstdint>
+#include <cstdio>
+#include <string_view>
+
+#include "../../../core/logging/log.h"
+#include "../../../core/settings/settings.h"
+#include "internal.h"
+
+namespace sunrise::client::hooks::bootflow {
+namespace {
+
+using core::log::kLineCapacity;
+
+/**
+ * The narrow channel release: one channel, with a blend time, so the world fades in.
+ * Anchored on the load of the channel key, then run on through the argument spills, because the
+ * wildcarded frame size leaves the head too short to be unique.
+ */
+constexpr std::string_view kReleaseSignatureText =
+    "48 83 EC ? 8B 02 0F 57 C0 F3 0F 10 0D ? ? ? ? 48 8D 54 24 ? F3 0F 11 44 24";
+/** Compiled pattern bytes of the signature text above. */
+constexpr auto kReleaseSignature =
+    signature<signature_length(kReleaseSignatureText)>(kReleaseSignatureText);
+
+/**
+ * The fade manager accessor: one load-effective-address of its static object, then a return.
+ * Every displacement is wildcarded, so the match runs past the return to stay unique. The bytes
+ * after it are the next function's prologue and its thread-block read in this build.
+ */
+constexpr std::string_view kAccessorSignatureText =
+    "48 8D 05 ? ? ? ? C3 48 63 C0 E9 ? ? ? ? 48 83 EC ? 65 48 8B 04 25 58 00 00 00";
+/** Compiled pattern bytes of the signature text above. */
+constexpr auto kAccessorSignature =
+    signature<signature_length(kAccessorSignatureText)>(kAccessorSignatureText);
+
+/** Byte offsets inside the accessor, used to decode the object address from its operand. */
+struct AccessorLayout {
+    /** The 4-byte displacement follows the 2-byte load opcode. */
+    static constexpr std::size_t displacement = 3;
+    /** The instruction after the load, which the displacement is relative to. */
+    static constexpr std::size_t nextInstruction = 7;
+};
+
+/** The world-transition fade channel. Stage two of the transition arms it with opaque black. */
+constexpr std::uint32_t kWorldTransitionChannel = 0x57572DAC;
+/** The channel's colour is a static initialiser, so it needs no lookup. */
+constexpr std::array<float, 4> kOpaqueBlack{0.0F, 0.0F, 0.0F, 1.0F};
+/** Blend seconds, so the world fades in rather than popping. */
+constexpr float kFadeInSeconds = 0.5F;
+
+using ReleaseChannel = std::int64_t(__fastcall*)(void*, std::uint32_t*, float*, float) noexcept;
+
+void* g_manager{nullptr};
+std::atomic<ReleaseChannel> g_release{nullptr};
+std::atomic_bool g_logged{false};
+
+} // namespace
+
+/** Re-arms the one line the release logs, so the next load reports its own. */
+void rearm_fade_release() noexcept {
+    g_logged.store(false, std::memory_order_release);
+}
+
+/** Releases the world-transition fade channel. The spawn gate decides when. */
+void release_world_fade() noexcept {
+    const ReleaseChannel release = g_release.load(std::memory_order_acquire);
+    if (release == nullptr || g_manager == nullptr || !core::settings::get().client.fadeRelease) {
+        return;
+    }
+    std::uint32_t channel = kWorldTransitionChannel;
+    std::array<float, 4> colour = kOpaqueBlack;
+    (void)release(g_manager, &channel, colour.data(), kFadeInSeconds);
+    if (g_logged.exchange(true, std::memory_order_relaxed)) {
+        return;
+    }
+    std::array<char, kLineCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=bootflow stage=fade_release result=issued channel=0x%X",
+                                      kWorldTransitionChannel);
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::info,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/** Finds the fade release and its manager object. */
+bool install_fade_release() noexcept {
+    std::byte* const release = scan_main_image_unique(kReleaseSignature, "fade_release_channel");
+    std::byte* const accessor = scan_main_image_unique(kAccessorSignature, "fade_manager_accessor");
+    if (release == nullptr || accessor == nullptr) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::warn,
+                         "ev=bootflow stage=fade_release result=fail reason=target");
+        return false;
+    }
+    // The manager is the static object the accessor returns. The address comes from that
+    // instruction's own operand, not from a stored offset.
+    g_manager = resolve_relative(accessor + AccessorLayout::displacement,
+                                 accessor + AccessorLayout::nextInstruction);
+    g_release.store(reinterpret_cast<ReleaseChannel>(release), std::memory_order_release);
+    core::log::write(core::log::Channel::client,
+                     core::log::Level::info,
+                     "ev=bootflow stage=fade_release result=ok");
+    return true;
+}
+
+/** Clears the fade release it found. */
+void uninstall_fade_release() noexcept {
+    g_release.store(nullptr, std::memory_order_release);
+    g_manager = nullptr;
+    g_logged.store(false, std::memory_order_release);
+}
+
+} // namespace sunrise::client::hooks::bootflow

+ 131 - 0
Sunrise/src/client/hooks/bootflow/internal.h

@@ -0,0 +1,131 @@
+#pragma once
+
+#include "../../patterns/image_scan.h"
+
+namespace sunrise::client::hooks::bootflow {
+
+using patterns::resolve_relative;
+using patterns::scan_main_image_unique;
+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.
+ */
+[[nodiscard]] bool install_character_select_hold() 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.
+ */
+[[nodiscard]] bool install_profile_setup_skip() 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.
+ */
+[[nodiscard]] bool install_orbit_slice_set() 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.
+ */
+[[nodiscard]] bool install_composition_check() 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.
+ */
+[[nodiscard]] bool install_orbit_handoff() noexcept;
+
+/** Detaches the orbit handoff release. */
+void uninstall_orbit_handoff() noexcept;
+
+/**
+ * Attaches the join-request readiness force, which moves the activity session to status 6.
+ * Two of the gate's five terms are client flags with no host input.
+ * @return True when the target is found and the detour attaches.
+ */
+[[nodiscard]] bool install_join_request_ready() noexcept;
+
+/** Detaches the join-request readiness force. */
+void uninstall_join_request_ready() noexcept;
+
+/**
+ * Attaches 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.
+ */
+[[nodiscard]] bool install_owner_activity_slot() 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.
+ * 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.
+ */
+[[nodiscard]] bool install_region_private() noexcept;
+
+/** Detaches the private-region force. */
+void uninstall_region_private() noexcept;
+
+/**
+ * Finds the boot-flow step accessor, the only input to the world phase.
+ * Nothing is detoured: the accessor is called, so a miss leaves the phase idle.
+ * @return True when the target was found.
+ */
+[[nodiscard]] bool install_world_step() noexcept;
+
+/** Clears the boot-flow step accessor it found. */
+void uninstall_world_step() noexcept;
+
+/**
+ * Maps the client's own boot-flow step onto the world phase.
+ * Runs on the spawn gate poll, which is the only tick the phase is read on.
+ */
+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.
+ */
+[[nodiscard]] bool install_spawn_hold() noexcept;
+
+/** Detaches the spawn hold. */
+void uninstall_spawn_hold() noexcept;
+
+/**
+ * Finds the world-transition fade release and its manager object.
+ * Nothing is detoured: both are called, so a miss leaves the feature off, not the client changed.
+ * @return True when both targets were found.
+ */
+[[nodiscard]] bool install_fade_release() noexcept;
+
+/** Clears the fade release it found. */
+void uninstall_fade_release() noexcept;
+
+/**
+ * Releases the world-transition fade channel.
+ * The spawn gate owns the timing. Does nothing unless `client.fade_release` is set.
+ */
+void release_world_fade() noexcept;
+
+/** Re-arms the one line the release logs, so the next load reports its own. */
+void rearm_fade_release() noexcept;
+
+} // namespace sunrise::client::hooks::bootflow

+ 119 - 0
Sunrise/src/client/hooks/bootflow/join_request_ready.cpp

@@ -0,0 +1,119 @@
+#include <array>
+#include <atomic>
+#include <cstddef>
+#include <cstdio>
+#include <string_view>
+
+#include "../../../core/logging/log.h"
+#include "../../../core/settings/settings.h"
+#include "../../hooking/detour.h"
+#include "internal.h"
+
+namespace sunrise::client::hooks::bootflow {
+namespace {
+
+using core::log::kLineCapacity;
+
+/**
+ * The one call to the status 5-to-6 readiness predicate, in the join-request builder. The
+ * predicate's entry is a 5-byte jump with no pattern, so the target comes from this call's own
+ * operand. Every displacement is wildcarded, so the match runs on to stay unique.
+ */
+constexpr std::string_view kCallSignatureText =
+    "E8 ? ? ? ? 84 C0 0F 85 ? ? ? ? E8 ? ? ? ? BA 1C 00 00 00 48 8B CE 4C 63 40 24";
+/** Compiled pattern bytes of the signature text above. */
+constexpr auto kCallSignature = signature<signature_length(kCallSignatureText)>(kCallSignatureText);
+
+/** Offset of the 4-byte displacement inside the matched call instruction. */
+constexpr std::size_t kCallOperandOffset = 1;
+/** Length of the matched call instruction. */
+constexpr std::size_t kCallLength = 5;
+
+/** Answer that lets the join request advance the session to status 6. */
+constexpr bool kReady = true;
+
+using JoinRequestReady = bool(__fastcall*)(void*) noexcept;
+
+hooking::detour::Handle g_handle{};
+std::atomic<JoinRequestReady> g_original{nullptr};
+std::atomic_bool g_seen{false};
+std::atomic_bool g_lastNative{false};
+
+/**
+ * Logs the native answer when it changes, so a run shows whether the force was needed.
+ * @param native What the predicate answered on its own.
+ */
+void report(bool native) noexcept {
+    std::array<char, kLineCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=bootflow stage=join_ready result=%s",
+                                      native ? "native" : "forced");
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::info,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/**
+ * Reports the activity session ready to move from status 5 to status 6.
+ * The native five-term predicate should pass from activity state, so this force is temporary.
+ * The predicate has one call site, so nothing else sees the forced answer.
+ * @param client Borrowed activity client; the forced answer does not depend on it.
+ * @return The native answer, or ready when the native answer refuses.
+ */
+__declspec(noinline) bool __fastcall join_request_ready(void* client) noexcept {
+    const JoinRequestReady original = g_original.load(std::memory_order_acquire);
+    const bool native = original != nullptr && original(client);
+    if (g_lastNative.exchange(native, std::memory_order_relaxed) != native
+        || !g_seen.exchange(true, std::memory_order_relaxed)) {
+        report(native);
+    }
+    if (native || !core::settings::get().client.forceJoinRequestReady) {
+        return native;
+    }
+    return kReady;
+}
+
+} // namespace
+
+/** Attaches the join-request readiness force. */
+bool install_join_request_ready() noexcept {
+    if (g_handle.attached) {
+        return true;
+    }
+    std::byte* const call = scan_main_image_unique(kCallSignature, "join_request_ready_call");
+    if (call == nullptr) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::warn,
+                         "ev=bootflow stage=join_ready result=fail reason=target");
+        return false;
+    }
+    std::byte* const target = resolve_relative(call + kCallOperandOffset, call + kCallLength);
+    const hooking::detour::Spec spec{target, reinterpret_cast<void*>(&join_request_ready)};
+    if (!hooking::detour::install(spec, g_handle)) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::warn,
+                         "ev=bootflow stage=join_ready result=fail reason=attach");
+        return false;
+    }
+    g_original.store(reinterpret_cast<JoinRequestReady>(g_handle.original),
+                     std::memory_order_release);
+    core::log::write(core::log::Channel::client,
+                     core::log::Level::info,
+                     "ev=bootflow stage=join_ready result=ok");
+    return true;
+}
+
+/** Detaches the join-request readiness force. */
+void uninstall_join_request_ready() noexcept {
+    if (g_handle.attached) {
+        (void)hooking::detour::uninstall(g_handle);
+    }
+    g_original.store(nullptr, std::memory_order_release);
+    g_seen.store(false, std::memory_order_release);
+    g_lastNative.store(false, std::memory_order_release);
+}
+
+} // namespace sunrise::client::hooks::bootflow

+ 87 - 0
Sunrise/src/client/hooks/bootflow/orbit_handoff.cpp

@@ -0,0 +1,87 @@
+#include <array>
+#include <atomic>
+#include <cstddef>
+#include <string_view>
+
+#include "../../../core/logging/log.h"
+#include "../../hooking/detour.h"
+#include "internal.h"
+
+namespace sunrise::client::hooks::bootflow {
+namespace {
+
+/**
+ * The destination-hold predicate of the orbit setup step. Its prologue repeats across the image,
+ * so the pattern runs on through the call and the flag test that follow. Every displacement is
+ * wildcarded.
+ */
+constexpr std::string_view kHoldSignatureText =
+    "48 89 5C 24 ? 57 48 83 EC ? 48 8B D9 E8 ? ? ? ? 80 3D ? ? ? ? 00 48 8B F8 75 ?";
+/** Compiled pattern bytes of the signature text above. */
+constexpr auto kHoldSignature = signature<signature_length(kHoldSignatureText)>(kHoldSignatureText);
+
+/**
+ * Answer that lets the handoff test pass. The native predicate holds for an armed pending
+ * destination, or for a cinematic under a 5,000 ms timer. This answer skips that wait. It has one
+ * call site, the step's own update, so nothing else sees the change.
+ */
+constexpr bool kReleased = false;
+
+hooking::detour::Handle g_handle{};
+std::atomic_bool g_reported{false};
+
+/**
+ * Releases the destination hold. The original is never called: blocking is the only answer it
+ * gives here, so answering directly gives the same result with no call.
+ * @param stepCtx Borrowed step context; the answer does not depend on it.
+ * @return The released answer, always.
+ */
+__declspec(noinline) bool __fastcall destination_hold(void* stepCtx) noexcept {
+    (void)stepCtx;
+    if (!g_reported.exchange(true, std::memory_order_relaxed)) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::info,
+                         "ev=bootflow stage=orbit_handoff result=released");
+    }
+    return kReleased;
+}
+
+} // namespace
+
+/**
+ * Attaches the orbit handoff release.
+ * @return True when the target is found and the detour attaches.
+ */
+bool install_orbit_handoff() noexcept {
+    if (g_handle.attached) {
+        return true;
+    }
+    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;
+    }
+    const hooking::detour::Spec spec{target, reinterpret_cast<void*>(&destination_hold)};
+    if (!hooking::detour::install(spec, g_handle)) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::warn,
+                         "ev=bootflow stage=orbit_handoff result=fail reason=attach");
+        return false;
+    }
+    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. */
+void uninstall_orbit_handoff() noexcept {
+    if (g_handle.attached) {
+        (void)hooking::detour::uninstall(g_handle);
+    }
+    g_reported.store(false, std::memory_order_release);
+}
+
+} // namespace sunrise::client::hooks::bootflow

+ 103 - 0
Sunrise/src/client/hooks/bootflow/orbit_slice_set.cpp

@@ -0,0 +1,103 @@
+#include <Windows.h>
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <string_view>
+
+#include "../../../core/logging/log.h"
+#include "../../hooking/detour.h"
+#include "internal.h"
+
+namespace sunrise::client::hooks::bootflow {
+namespace {
+
+/**
+ * The slice-set name-hash picker. Anchored on the unset-sentinel store it writes into its output
+ * and on the read of the loader context's target field. Together they are unique to it.
+ */
+constexpr std::string_view kPickerSignatureText = "48 89 5C 24 ? 57 48 83 EC ? 48 8B DA C7 02 C5 "
+                                                  "9D 1C 81 48 8B F9 E8 ? ? ? ? 8B 97 04 01 00 00";
+/** Compiled pattern bytes of the signature text above. */
+constexpr auto kPickerSignature =
+    signature<signature_length(kPickerSignatureText)>(kPickerSignatureText);
+
+/**
+ * Name hash of the orbit slice set, given for the one target the picker cannot find.
+ * That target otherwise asks for a hash this build does not carry, so the map load fails and the
+ * world is torn down.
+ */
+constexpr std::uint32_t kOrbitSliceSetHash = 0x8265820B;
+/** Target selector the loader carries on the way to orbit. */
+constexpr std::uint32_t kOrbitTargetSelector = 0;
+/** The picker stores this first. It survives when the registry lookup misses. */
+constexpr std::uint32_t kUnresolvedNameHash = 0x811C9DC5;
+/** The loader context reserves this many opaque bytes before its target selector. */
+constexpr std::size_t kLoaderContextPrefixSize = 0x104;
+
+/** Slice-set loader context, as far as the picker reads it. */
+struct LoaderContext {
+    std::array<std::byte, kLoaderContextPrefixSize> opaque00{};
+    std::uint32_t targetSelector{};
+};
+
+static_assert(offsetof(LoaderContext, targetSelector) == kLoaderContextPrefixSize);
+
+/** Picker ABI: loader context in, name hash out, the output returned. */
+using PickTarget = std::uint32_t*(__fastcall*)(LoaderContext*, std::uint32_t*) noexcept;
+
+hooking::detour::Handle g_handle{};
+
+/**
+ * Supplies the orbit slice set for the one target the picker leaves unset.
+ * The original runs first and finds every other target itself. It writes the unset sentinel
+ * before it starts, so a surviving sentinel means the registry lookup missed.
+ * @param context Loader context carrying the target selector.
+ * @param selected Receives the slice-set name hash.
+ * @return The output the original returned.
+ */
+std::uint32_t* __fastcall pick_target(LoaderContext* context, std::uint32_t* selected) noexcept {
+    const auto original = reinterpret_cast<PickTarget>(g_handle.original);
+    std::uint32_t* const result = original(context, selected);
+    if (context != nullptr && selected != nullptr && context->targetSelector == kOrbitTargetSelector
+        && *selected == kUnresolvedNameHash) {
+        *selected = kOrbitSliceSetHash;
+    }
+    return result;
+}
+
+} // namespace
+
+/** Attaches the picker so the orbit target is found. */
+bool install_orbit_slice_set() noexcept {
+    if (g_handle.attached) {
+        return true;
+    }
+    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;
+    }
+    const hooking::detour::Spec spec{picker, reinterpret_cast<void*>(&pick_target)};
+    if (!hooking::detour::install(spec, g_handle)) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::warn,
+                         "ev=bootflow stage=slice_set result=fail reason=attach");
+        return false;
+    }
+    core::log::write(core::log::Channel::client,
+                     core::log::Level::info,
+                     "ev=bootflow stage=slice_set result=ok");
+    return true;
+}
+
+/** Detaches the picker. */
+void uninstall_orbit_slice_set() noexcept {
+    if (g_handle.attached) {
+        (void)hooking::detour::uninstall(g_handle);
+    }
+}
+
+} // namespace sunrise::client::hooks::bootflow

+ 133 - 0
Sunrise/src/client/hooks/bootflow/owner_activity_slot.cpp

@@ -0,0 +1,133 @@
+#include <array>
+#include <atomic>
+#include <cstddef>
+#include <cstdint>
+#include <cstdio>
+#include <string_view>
+
+#include "../../../core/logging/log.h"
+#include "../../../core/settings/settings.h"
+#include "../../hooking/detour.h"
+#include "internal.h"
+
+namespace sunrise::client::hooks::bootflow {
+namespace {
+
+/**
+ * `World_CheckActivityBubbles`* @ `0x7FF74208DB80`. Matched from its prologue through the activity
+ * object load and the handle shift, which is unique in the image.
+ */
+constexpr std::string_view kCheckSignatureText =
+    "48 89 5C 24 ? 48 89 6C 24 ? 48 89 74 24 ? 57 41 56 41 57 48 83 EC ? 48 8B 79 10 41 8B C0 "
+    "41 8B D8 C1 F8 0D";
+/** Compiled pattern bytes of the signature text above. */
+constexpr auto kCheckSignature =
+    signature<signature_length(kCheckSignatureText)>(kCheckSignatureText);
+
+/**
+ * Slot written into the container. Any non-zero value pins the record. 1 is what the game's own
+ * activity-swap path passes, so nothing downstream sees a number it cannot make itself.
+ */
+constexpr std::int32_t kRemoteSlot = 1;
+
+/** Returned when the trampoline is gone. The caller reads it as "not armed" and rolls back. */
+constexpr std::uint8_t kNotArmed = 0;
+
+/** Lines allowed per run. The check runs once per activity container. */
+constexpr unsigned kMaxReports = 4;
+
+/** Size of one forcing line, set by its slot fields. */
+constexpr std::size_t kLineCapacity = 96;
+
+using CheckBubbles =
+    std::uint8_t(__fastcall*)(void*, void*, std::int32_t, void*, std::int64_t, std::int32_t);
+
+hooking::detour::Handle g_handle{};
+std::atomic<CheckBubbles> g_original{nullptr};
+std::atomic<unsigned> g_reported{0};
+
+/**
+ * Emits one forcing event while the per-run budget lasts.
+ * @param slot The slot the caller passed.
+ */
+void report(std::int32_t slot) noexcept {
+    // One atomic claim per line, so a concurrent check cannot reuse a budget slot.
+    if (g_reported.fetch_add(1, std::memory_order_relaxed) >= kMaxReports) {
+        return;
+    }
+    std::array<char, kLineCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=bootflow stage=owner_slot result=forced was=%d now=%d",
+                                      static_cast<int>(slot),
+                                      static_cast<int>(kRemoteSlot));
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::info,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/**
+ * Passes a non-zero owner activity slot into the roster container.
+ * Argument 6 is the only writer of the slot, at `0x7FF74208DC13`. Every other argument is passed
+ * on untouched, including argument 5, whose own arm has nothing to do with the record.
+ * @return The check's own result, or the not-armed result when the trampoline is gone.
+ */
+__declspec(noinline) std::uint8_t __fastcall check(void* container,
+                                                   void* reporter,
+                                                   std::int32_t activity,
+                                                   void* prefix,
+                                                   std::int64_t roleIsLocal,
+                                                   std::int32_t slot) noexcept {
+    const CheckBubbles original = g_original.load(std::memory_order_acquire);
+    if (original == nullptr) {
+        return kNotArmed;
+    }
+    if (!core::settings::get().client.pinReplicatedRecord) {
+        return original(container, reporter, activity, prefix, roleIsLocal, slot);
+    }
+    if (slot != kRemoteSlot) {
+        report(slot);
+    }
+    return original(container, reporter, activity, prefix, roleIsLocal, kRemoteSlot);
+}
+
+} // namespace
+
+/** Attaches the owner activity slot force. */
+bool install_owner_activity_slot() noexcept {
+    if (g_handle.attached) {
+        return true;
+    }
+    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;
+    }
+    const hooking::detour::Spec spec{target, reinterpret_cast<void*>(&check)};
+    if (!hooking::detour::install(spec, g_handle)) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::warn,
+                         "ev=bootflow stage=owner_slot result=fail reason=attach");
+        return false;
+    }
+    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. */
+void uninstall_owner_activity_slot() noexcept {
+    if (g_handle.attached) {
+        (void)hooking::detour::uninstall(g_handle);
+    }
+    g_original.store(nullptr, std::memory_order_release);
+    g_reported.store(0, std::memory_order_release);
+}
+
+} // namespace sunrise::client::hooks::bootflow

+ 130 - 0
Sunrise/src/client/hooks/bootflow/profile_setup_skip.cpp

@@ -0,0 +1,130 @@
+#include <array>
+#include <atomic>
+#include <cstddef>
+#include <cstdint>
+#include <cstring>
+#include <string_view>
+
+#include "../../../core/logging/log.h"
+#include "../../hooking/detour.h"
+#include "internal.h"
+
+namespace sunrise::client::hooks::bootflow {
+namespace {
+
+/**
+ * The profile-setup step's update. Anchored on the screen-settled call, its taken branch and the
+ * read of the step's own state field. Together they are unique to it. Every displacement is
+ * wildcarded.
+ */
+constexpr std::string_view kUpdateSignatureText =
+    "40 53 48 83 EC ? 48 8B 05 ? ? ? ? 48 33 C4 48 89 44 24 ? 48 8B D9 E8 ? ? ? ? 84 C0 0F 84 ? "
+    "? ? ? 8B 43 38 85 C0 75 ?";
+/** Compiled pattern bytes of the signature text above. */
+constexpr auto kUpdateSignature =
+    signature<signature_length(kUpdateSignatureText)>(kUpdateSignatureText);
+
+/** Fields of the boot step this hook touches, as byte offsets from the step's own base. */
+struct StepLayout {
+    /** The step's state, which its update switches on. */
+    static constexpr std::size_t state = 56;
+};
+
+/**
+ * States with no self-advance. The update posts a setup screen and parks in one of these until
+ * the UI raises the investment event that acknowledges it. State 0 is the entry that posts the
+ * first one.
+ */
+constexpr std::array<std::uint32_t, 5> kWaitingStates{0, 1, 3, 5, 7};
+
+/**
+ * State that matches no case in the update's dispatch, so it falls to the last block. That block
+ * marks the profile record and hands off to the next step. It is the same exit the stock game
+ * takes once every setup screen is acknowledged.
+ */
+constexpr std::uint32_t kHandoffState = 9;
+
+/** The update's not-complete answer, returned when the trampoline is gone. */
+constexpr char kStepIncomplete = 0;
+
+using Update = char(__fastcall*)(std::byte*);
+
+hooking::detour::Handle g_handle{};
+std::atomic<Update> g_original{nullptr};
+std::atomic_bool g_reported{false};
+
+/** @return True when the step cannot advance on its own. */
+[[nodiscard]] bool is_waiting(std::uint32_t state) noexcept {
+    for (const std::uint32_t waiting : kWaitingStates) {
+        if (state == waiting) {
+            return true;
+        }
+    }
+    return false;
+}
+
+/**
+ * Skips the startup setup screens. A UI event this setup never raises acknowledges them, so the
+ * step would park forever. Moving it to the handoff state before the update runs takes the step's
+ * own exit without posting a screen.
+ * @param step Borrowed boot-step base pointer.
+ * @return The update's own result.
+ */
+__declspec(noinline) char __fastcall update(std::byte* step) noexcept {
+    const Update original = g_original.load(std::memory_order_acquire);
+    if (step != nullptr) {
+        std::uint32_t state = 0;
+        std::memcpy(&state, step + StepLayout::state, sizeof state);
+        if (is_waiting(state)) {
+            std::memcpy(step + StepLayout::state, &kHandoffState, sizeof kHandoffState);
+            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=skipped");
+            }
+        }
+    }
+    return original != nullptr ? original(step) : kStepIncomplete;
+}
+
+} // namespace
+
+/**
+ * Attaches the profile-setup skip.
+ * @return True when the target is found and the detour attaches.
+ */
+bool install_profile_setup_skip() noexcept {
+    if (g_handle.attached) {
+        return true;
+    }
+    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;
+    }
+    const hooking::detour::Spec spec{target, reinterpret_cast<void*>(&update)};
+    if (!hooking::detour::install(spec, g_handle)) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::warn,
+                         "ev=bootflow stage=profile_setup result=fail reason=attach");
+        return false;
+    }
+    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. */
+void uninstall_profile_setup_skip() noexcept {
+    if (g_handle.attached) {
+        (void)hooking::detour::uninstall(g_handle);
+    }
+    g_original.store(nullptr, std::memory_order_release);
+    g_reported.store(false, std::memory_order_release);
+}
+
+} // namespace sunrise::client::hooks::bootflow

+ 187 - 0
Sunrise/src/client/hooks/bootflow/region_private.cpp

@@ -0,0 +1,187 @@
+#include <array>
+#include <atomic>
+#include <cstddef>
+#include <cstdint>
+#include <cstdio>
+#include <intrin.h>
+#include <string_view>
+
+#include "../../../core/logging/log.h"
+#include "../../hooking/detour.h"
+#include "internal.h"
+
+namespace sunrise::client::hooks::bootflow {
+namespace {
+
+/**
+ * The bubble public-flag reader. The pattern is its whole body: a call to the state-byte getter,
+ * then a cmovnz that turns the byte into a bool.
+ */
+constexpr std::string_view kReaderSignatureText =
+    "48 83 EC 28 E8 ? ? ? ? 48 8B C8 32 C0 48 85 C9 74 ? 80 39 00 BA 01 00 00 00 0F B6 C0 0F 45 "
+    "C2 48 83 C4 28 C3";
+/** Compiled pattern bytes of the signature text above. */
+constexpr auto kReaderSignature =
+    signature<signature_length(kReaderSignatureText)>(kReaderSignatureText);
+
+/**
+ * The region transition starter. Anchored on its stack-cookie prologue and the read of the
+ * manager's phase byte, which no other function pairs this way.
+ */
+constexpr std::string_view kStarterSignatureText =
+    "44 89 44 24 18 55 53 56 57 41 54 41 56 41 57 48 8D AC 24 ? ? ? ? 48 81 EC ? ? ? ? 48 8B 05 "
+    "? ? ? ? 48 33 C4 48 89 85 ? ? ? ? 0F B6 81 09 02 00 00 4D 8B E1 FE C8 4C 63 F2 48 8B F1";
+/** Compiled pattern bytes of the signature text above. */
+constexpr auto kStarterSignature =
+    signature<signature_length(kStarterSignatureText)>(kStarterSignatureText);
+
+/** `call rel32`, the encoding the starter uses to reach the reader. */
+constexpr std::byte kCallOpcode{0xE8};
+/** The call's displacement follows its opcode byte. */
+constexpr std::size_t kCallOperandOffset = 1;
+/** A near call is its opcode plus a signed 32-bit displacement. */
+constexpr std::size_t kCallLength = kCallOperandOffset + 4;
+/**
+ * Bytes of the starter searched for that call. The body is shorter than this, and the search
+ * needs one match, so a second hit fails the install instead of picking one.
+ */
+constexpr std::size_t kStarterSearchBytes = 0x600;
+
+/** Lines allowed per run. Region transitions are rare, so this shows every one a boot makes. */
+constexpr unsigned kMaxReports = 8;
+/** Size of one line, set by its stage and slice-set fields. */
+constexpr std::size_t kLineCapacity = 96;
+
+using Reader = bool(__fastcall*)(std::uint32_t);
+
+hooking::detour::Handle g_handle{};
+std::atomic<Reader> g_original{nullptr};
+std::atomic<const std::byte*> g_returnSite{nullptr};
+std::atomic<unsigned> g_forced{0};
+
+/**
+ * Finds the return address of the starter's own call to the reader.
+ * A stray opcode byte inside another instruction can decode to the reader, so the whole window is
+ * swept and an unclear result is rejected.
+ * @return Address after the single matching call, or null when there is not exactly one.
+ */
+[[nodiscard]] const std::byte* find_return_site(const std::byte* starter,
+                                                const std::byte* reader) noexcept {
+    const std::byte* found = nullptr;
+    for (std::size_t offset = 0; offset + kCallLength <= kStarterSearchBytes; ++offset) {
+        const std::byte* const site = starter + offset;
+        if (*site != kCallOpcode) {
+            continue;
+        }
+        const std::byte* const next = site + kCallLength;
+        if (resolve_relative(site + kCallOperandOffset, next) != reader) {
+            continue;
+        }
+        if (found != nullptr) {
+            return nullptr;
+        }
+        found = next;
+    }
+    return found;
+}
+
+/**
+ * Emits one forcing event while the per-run budget lasts.
+ * @param sliceSet Slice-set index whose bubble was reported private.
+ */
+void report(std::uint32_t sliceSet) noexcept {
+    // One atomic claim per line, so a concurrent transition cannot reuse a budget slot.
+    if (g_forced.fetch_add(1, std::memory_order_relaxed) >= kMaxReports) {
+        return;
+    }
+    std::array<char, kLineCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=bootflow stage=region result=forced slice_set=%u",
+                                      static_cast<unsigned>(sliceSet));
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::info,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/**
+ * Reports a bubble as private, for the region transition's own call only.
+ * The starter stores this result in the manager's public flag. A public region then holds its
+ * slice-set switch until a public activity host connects, which solo play never gets.
+ * @return False on the starter's call, otherwise the reader's own answer.
+ */
+__declspec(noinline) bool __fastcall reader(std::uint32_t sliceSet) noexcept {
+    const Reader original = g_original.load(std::memory_order_acquire);
+    // The detour is live for a few instructions before install publishes its trampoline.
+    if (original == nullptr) {
+        return false;
+    }
+    if (!original(sliceSet)) {
+        return false;
+    }
+    const auto* const caller = static_cast<const std::byte*>(_ReturnAddress());
+    if (caller != g_returnSite.load(std::memory_order_acquire)) {
+        return true;
+    }
+    report(sliceSet);
+    return false;
+}
+
+/** @param reason Key naming the step that failed. @return False, for a direct return. */
+[[nodiscard]] bool fail(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);
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         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 {
+    if (g_handle.attached) {
+        return true;
+    }
+    std::byte* const target = scan_main_image_unique(kReaderSignature, "slice_set_is_public");
+    if (target == nullptr) {
+        return fail("reader");
+    }
+    const std::byte* const starter =
+        scan_main_image_unique(kStarterSignature, "region_start_transition");
+    if (starter == nullptr) {
+        return fail("starter");
+    }
+    const std::byte* const returnSite = find_return_site(starter, target);
+    if (returnSite == nullptr) {
+        return fail("call_site");
+    }
+    // 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");
+    }
+    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. */
+void uninstall_region_private() noexcept {
+    if (g_handle.attached) {
+        (void)hooking::detour::uninstall(g_handle);
+    }
+    g_original.store(nullptr, std::memory_order_release);
+    g_returnSite.store(nullptr, std::memory_order_release);
+    g_forced.store(0, std::memory_order_release);
+}
+
+} // namespace sunrise::client::hooks::bootflow

+ 88 - 0
Sunrise/src/client/hooks/bootflow/spawn/probe.h

@@ -0,0 +1,88 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+namespace sunrise::client::hooks::bootflow::spawn {
+
+/** Which gate condition refused, in the gate's own order. */
+enum class Refusal : std::uint8_t {
+    /** Nothing tested here refused, so the remaining dev-switch arm is what did. */
+    devHold,
+    /** The gate was not asked, or its predicates were not found. */
+    unknown,
+    /** G0: the caller passed the absent player datum. */
+    datum,
+    /** G1: no world is present. */
+    world,
+    /** G2: the current slice-set index is not addressable. */
+    sliceSet,
+    /** G3: the datum has no participation record at all. */
+    participationMissing,
+    /** G3: the record exists and its `+10` byte is clear. */
+    participation,
+    /** G4: the activity lifetime state is outside the passing set. */
+    lifetime,
+    /** G5: the world-controller handles are not ready. */
+    worldControl,
+    /** G6a: the team state or team block is absent. */
+    teamMissing,
+    /** G6b: the host published a nonzero team-state byte. */
+    teamState,
+    /** G6c: no team index is assigned. */
+    teamIndex,
+    /** G7: the host published the spawn-suppressed state. */
+    suppressed,
+    /** Nothing refused, so the gate allowed. */
+    none,
+};
+
+/** Everything one probe pass read, so the log can name values, not just a result. */
+struct Reading {
+    Refusal refusal{Refusal::unknown};
+    /** True when the datum has a participation record, which msg 5's type-13 body fills. */
+    bool hasRecord{};
+    /** Record `+8`, the step-36 task-9 latch. */
+    std::uint8_t recordTask9{};
+    /** Record `+10`, the byte G3 requires to be set. */
+    std::uint8_t recordReady{};
+    /** Activity lifetime state the gate switched on, or -1 when it was not read. */
+    std::int32_t lifetimeState{-1};
+    /** The team-state byte at `+1`, whose four low bits are the host's suppression reasons. */
+    std::uint8_t teamStateBits{};
+    /** The team index at identity `+9`; 0xFF means none was assigned. */
+    std::uint8_t teamIndex{};
+    /** True once the client reference manager holds a type-13 entry, which arms the team block. */
+    bool hasType13{};
+};
+
+/**
+ * Finds the gate's own predicates from the call operands inside it.
+ * Every target is read out of the gate body, so this adds no signature of its own.
+ * @param gate Base of the spawn gate, found by pattern.
+ * @return True when every call site held the expected opcode and decoded.
+ */
+[[nodiscard]] bool resolve(const std::byte* gate) noexcept;
+
+/** Clears the predicates it found. */
+void forget() noexcept;
+
+/**
+ * Re-runs the gate's conditions in order and reports the first that refuses.
+ * Calls only the gate's own read-only accessors, with its argument, on its thread and inside its
+ * call, so it sees what the gate saw.
+ * @param datum Player datum handle the gate was called with.
+ * @return What the pass read; `unknown` when the predicates were not found.
+ */
+[[nodiscard]] Reading examine(std::int32_t datum) noexcept;
+
+/**
+ * Formats one reading as log fields.
+ * @param reading Result of a probe pass.
+ * @param output Caller-owned character storage.
+ * @return Characters written, not counting the null.
+ */
+[[nodiscard]] std::size_t describe(const Reading& reading, std::span<char> output) noexcept;
+
+} // namespace sunrise::client::hooks::bootflow::spawn

+ 162 - 0
Sunrise/src/client/hooks/bootflow/spawn/spawn_gate_probe.cpp

@@ -0,0 +1,162 @@
+#include <array>
+#include <cstdio>
+
+#include "probe.h"
+#include "spawn_gate_record_dump.h"
+
+namespace sunrise::client::hooks::bootflow::spawn {
+namespace {
+
+/** The absent player datum the gate rejects outright. */
+constexpr std::int32_t kAbsentDatum = -1;
+/** Participation record byte the gate needs set. */
+constexpr std::size_t kRecordReadyOffset = 10;
+/** Participation record byte step 36's task 9 reads, carried on the same body. */
+constexpr std::size_t kRecordTask9Offset = 8;
+/** Identity byte holding the team index, and the value meaning none was assigned. */
+constexpr std::size_t kIdentityTeamOffset = 9;
+constexpr std::uint8_t kNoTeamIndex = 0xFF;
+/** Team-state byte offset; every set bit is a host-published suppression reason. */
+constexpr std::size_t kTeamStateOffset = 1;
+/** Lifetime states that reach the passing arm of the gate's jump table. */
+constexpr std::array<std::int32_t, 3> kPassingLifetimeStates{3, 6, 10};
+
+/** Log names for each refusal, in the enum's own order. */
+constexpr std::array<const char*, 14> kRefusalNames{"dev_hold",
+                                                    "unknown",
+                                                    "datum",
+                                                    "world",
+                                                    "slice_set",
+                                                    "no_record",
+                                                    "participation",
+                                                    "lifetime",
+                                                    "world_control",
+                                                    "team_missing",
+                                                    "team_state",
+                                                    "team_index",
+                                                    "suppressed",
+                                                    "none"};
+
+/** @return True when the lifetime state reaches the passing arm. */
+[[nodiscard]] bool passing_lifetime(std::int32_t state) noexcept {
+    for (const std::int32_t passing : kPassingLifetimeStates) {
+        if (state == passing) {
+            return true;
+        }
+    }
+    return false;
+}
+
+/**
+ * Runs the team block, which the gate reaches only once a type-13 entry exists.
+ * @param reading Receives the team bytes read.
+ * @return The refusal, or none when every team condition passes.
+ */
+[[nodiscard]] Refusal examine_team(std::int32_t datum, Reading& reading) noexcept {
+    const std::uint8_t* const identity = g_calls.identity(datum);
+    const std::uint8_t* const teamState = g_calls.teamState(datum);
+    const std::uint8_t* const teamBlock = g_calls.teamBlock(datum);
+    if (teamState == nullptr || teamBlock == nullptr) {
+        return Refusal::teamMissing;
+    }
+    reading.teamStateBits = teamState[kTeamStateOffset];
+    if (reading.teamStateBits != 0) {
+        return Refusal::teamState;
+    }
+    reading.teamIndex = identity != nullptr ? identity[kIdentityTeamOffset] : kNoTeamIndex;
+    return reading.teamIndex == kNoTeamIndex ? Refusal::teamIndex : Refusal::none;
+}
+
+} // namespace
+
+/** Re-runs the gate's conditions in order and reports the first that refuses. */
+Reading examine(std::int32_t datum) noexcept {
+    Reading reading{};
+    if (!g_ready.load(std::memory_order_acquire)) {
+        return reading;
+    }
+    // Read the type-13 and type-17 witnesses before the ordered walk, because the walk returns at
+    // the first refusal and these say which auth body applied. The lifetime state is the type-17
+    // body's own value; the reference is what the type-13 roster slot registers.
+    std::int32_t witness = 0;
+    reading.lifetimeState = g_calls.lifetimeState(&witness) ? witness : -1;
+    reading.hasType13 = g_calls.hasAnyType13();
+    if (datum == kAbsentDatum) {
+        reading.refusal = Refusal::datum;
+        return reading;
+    }
+    if (!g_calls.worldPresent(g_calls.sliceSetManager())) {
+        reading.refusal = Refusal::world;
+        return reading;
+    }
+    std::int32_t sliceSetIndex = 0;
+    void* const current = g_calls.currentSliceSet(g_calls.sliceSetManager(), &sliceSetIndex);
+    if (!g_calls.sliceSetAddressable(current)) {
+        reading.refusal = Refusal::sliceSet;
+        return reading;
+    }
+    const std::uint8_t* const record = g_calls.participationRecord(datum);
+    reading.hasRecord = record != nullptr;
+    if (record == nullptr) {
+        reading.refusal = Refusal::participationMissing;
+        return reading;
+    }
+    dump_record(record);
+    reading.recordTask9 = record[kRecordTask9Offset];
+    reading.recordReady = record[kRecordReadyOffset];
+    if (reading.recordReady == 0) {
+        reading.refusal = Refusal::participation;
+        return reading;
+    }
+    if (reading.lifetimeState < 0 || !passing_lifetime(reading.lifetimeState)) {
+        reading.refusal = Refusal::lifetime;
+        return reading;
+    }
+    if (!g_calls.worldControl()) {
+        reading.refusal = Refusal::worldControl;
+        return reading;
+    }
+    if (reading.hasType13) {
+        const Refusal team = examine_team(datum, reading);
+        if (team != Refusal::none) {
+            reading.refusal = team;
+            return reading;
+        }
+    }
+    if (g_calls.spawnSuppressed()) {
+        reading.refusal = Refusal::suppressed;
+        return reading;
+    }
+    // Everything this probe can test passed, so the remaining dev-switch arm is what refused.
+    reading.refusal = Refusal::devHold;
+    return reading;
+}
+
+/** Formats one reading as log fields. */
+std::size_t describe(const Reading& reading, std::span<char> output) noexcept {
+    if (output.empty()) {
+        return 0;
+    }
+    const auto index = static_cast<std::size_t>(reading.refusal);
+    const char* const name = index < kRefusalNames.size() ? kRefusalNames[index] : "unknown";
+    const int written = std::snprintf(output.data(),
+                                      output.size(),
+                                      "reason=%s rec=%u rec8=%u rec10=%u lifetime=%d type13=%u "
+                                      "team_bits=0x%02X "
+                                      "faction=%c sync=%c picker=%c side=%c team_idx=0x%02X",
+                                      name,
+                                      reading.hasRecord ? 1U : 0U,
+                                      reading.recordTask9,
+                                      reading.recordReady,
+                                      reading.lifetimeState,
+                                      reading.hasType13 ? 1U : 0U,
+                                      reading.teamStateBits,
+                                      (reading.teamStateBits & 1U) != 0 ? 'Y' : 'N',
+                                      (reading.teamStateBits & 2U) != 0 ? 'Y' : 'N',
+                                      (reading.teamStateBits & 4U) != 0 ? 'Y' : 'N',
+                                      (reading.teamStateBits & 8U) != 0 ? 'Y' : 'N',
+                                      reading.teamIndex);
+    return written > 0 ? static_cast<std::size_t>(written) : 0;
+}
+
+} // namespace sunrise::client::hooks::bootflow::spawn

+ 113 - 0
Sunrise/src/client/hooks/bootflow/spawn/spawn_gate_record_dump.cpp

@@ -0,0 +1,113 @@
+#include "spawn_gate_record_dump.h"
+
+#include <Windows.h>
+
+#include <array>
+#include <atomic>
+#include <cstdio>
+
+#include "../../../../core/logging/log.h"
+#include "../../../../state/account/account_state.h"
+#include "../../../../state/runtime/runtime.h"
+
+namespace sunrise::client::hooks::bootflow::spawn {
+namespace {
+
+/**
+ * Head of the participation record to dump once, in bytes.
+ * The record heads a 760-byte type-13 snapshot, so this stays well inside it. 96 bytes covers
+ * every small field plus the player key at `+72`, the known value that locates the run.
+ */
+constexpr std::size_t kDumpBytes = 96;
+/** Bytes of the component head to dump, which covers the object identity and its registry key. */
+constexpr std::size_t kHeadBytes = 32;
+/** Two hex digits per byte for both windows, plus the fixed prefix. */
+constexpr std::size_t kDumpCapacity = (kDumpBytes + kHeadBytes) * 3 + 96;
+
+/**
+ * Bytes swept each side of the record for the player key.
+ * Record-relative, because the record's own offset is the question: local authority binds `+1256`,
+ * the proxy arm `+496`. A key at `+72` means the body is the record, `-688` that it is not.
+ */
+constexpr std::int32_t kSweepBack = 768;
+constexpr std::int32_t kSweepForward = 768;
+/** Head window dumped ahead of the record, which covers whichever base is in play. */
+constexpr std::size_t kRecordComponentOffset = 496;
+/** Reported when the sweep finds no copy of the key. */
+constexpr std::int32_t kKeyAbsent = 0x7FFFFFFF;
+
+std::atomic_bool g_dumped{false};
+
+/**
+ * Finds the player key the roster publishes, near the record.
+ * The key is the one value in the type-13 body with no other producer, so where it lands says
+ * whether that body reached this object and which arm bound the record.
+ * @return Record-relative offset of the key, or `kKeyAbsent` when it is not near it.
+ */
+[[nodiscard]] std::int32_t find_player_key(const std::uint8_t* record) noexcept {
+    const state::AccountState account = state::account_snapshot();
+    // A backward sweep can leave the allocation on the proxy arm, so a fault ends the sweep, not
+    // the process.
+    __try {
+        for (std::int32_t offset = -kSweepBack; offset <= kSweepForward; ++offset) {
+            std::uint64_t candidate = 0;
+            for (std::size_t byte = 0; byte < sizeof candidate; ++byte) {
+                candidate |= static_cast<std::uint64_t>(record[offset + static_cast<int>(byte)])
+                             << (8U * byte);
+            }
+            // The roster publishes the character the join named, which is not always the selected
+            // one, so any authored character means the body landed.
+            for (std::size_t index = 0; index < account.characterCount; ++index) {
+                if (candidate != 0 && candidate == account.characters[index].soid) {
+                    return offset;
+                }
+            }
+        }
+    } __except (EXCEPTION_EXECUTE_HANDLER) {
+        return kKeyAbsent;
+    }
+    return kKeyAbsent;
+}
+
+} // namespace
+
+/** Dumps the head of the participation record once per run, with the player key's own offset. */
+void dump_record(const std::uint8_t* record) noexcept {
+    if (g_dumped.exchange(true, std::memory_order_relaxed)) {
+        return;
+    }
+    // The component head carries the object's own identity, including the registry key at its
+    // `+12`. That key says whether this object is in the group message 5 publishes.
+    const std::uint8_t* const component = record - kRecordComponentOffset;
+    std::array<char, kDumpCapacity> line{};
+    int written = std::snprintf(line.data(),
+                                line.size(),
+                                "ev=bootflow stage=spawn_record key_at=%d head=",
+                                find_player_key(record));
+    for (std::size_t index = 0; index < kHeadBytes && written > 0; ++index) {
+        const int step = std::snprintf(line.data() + written,
+                                       line.size() - static_cast<std::size_t>(written),
+                                       "%02X",
+                                       component[index]);
+        written = step > 0 ? written + step : 0;
+    }
+    if (written > 0) {
+        const int step = std::snprintf(
+            line.data() + written, line.size() - static_cast<std::size_t>(written), " bytes=");
+        written = step > 0 ? written + step : 0;
+    }
+    for (std::size_t index = 0; index < kDumpBytes && written > 0; ++index) {
+        const int step = std::snprintf(line.data() + written,
+                                       line.size() - static_cast<std::size_t>(written),
+                                       "%02X",
+                                       record[index]);
+        written = step > 0 ? written + step : 0;
+    }
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+} // namespace sunrise::client::hooks::bootflow::spawn

Някои файлове не бяха показани, защото твърде много файлове са промени