72 Коміти f1e7c66baf ... ec2df8bd0f

Автор SHA1 Опис Дата
  stan ec2df8bd0f Merge pull request #123 from Fozkais/sdk-dialogue-definitions 4 днів тому
  stan 2a75db014d Count the string fields in the catalog contract text 4 днів тому
  stan e16a91cd7b Document the dialogue list reader and the millisecond rounding 4 днів тому
  stan f86409ece2 Move the unresourced scene flags next to their caller 4 днів тому
  stan f1fa282197 Share one sorted-range lookup in the catalog 4 днів тому
  stan 2bfaefb914 Remove the unused dialogue group spans 4 днів тому
  stan b73744a321 Use the shared dialogue constants 4 днів тому
  stan 5ebaf16767 Name the cue window window_ms 4 днів тому
  stan 32e0892856 Keep cue text when the authored window disagrees 4 днів тому
  Fozkais d19ec1bae2 Keep the Dialogue page to the cues it draws 1 місяць тому
  Fozkais 6568f05df6 Keep the cue row on the shared authored window 1 місяць тому
  Fozkais fa16187295 Read the cue definitions through the shared dialogue bank reader 1 місяць тому
  Fozkais 72677efe14 Publish authored dialogue definitions in the SDK pack and mission Lua 1 місяць тому
  Fozkais ec500b0816 Read authored dialogue lists structurally and accept shared definition hashes 1 місяць тому
  stan f8857e398a Merge pull request #129 from T4tsuk3i/feature/character-lifecycle 4 днів тому
  stan 6f71960dfc Merge pull request #146 from throgsoft/runtime-item-definitions 4 днів тому
  stan 910243eacc Read the create-character header into a layout struct 4 днів тому
  stan 7f2f24dba4 Drop the ability refresh when no character is picked 4 днів тому
  stan 9127252b96 Create and delete characters inside the investment transaction 4 днів тому
  stan ea48909591 Seed created characters from the package new-character table and a starter table 4 днів тому
  stan 17e77228da Give every default character the appearance header columns 4 днів тому
  stan b8e4cda716 Merge pull request #128 from Confetti3/strange-terrain-pr 4 днів тому
  stan 6d1aec7bb6 List the trace level in the log filter 4 днів тому
  stan 81264a2bc7 Split the Sense body readers from the msg-6 packet framing 4 днів тому
  stan 5eff8d014e Name the new wire literals and log every unmatched damage level 4 днів тому
  stan 9437b18438 Remove the catalog publication lock 4 днів тому
  stan d7fc96d4c1 Restore the trigger, squad and object edge rules for every activity 4 днів тому
  stan 90a9d8885b Accept only complete msg-6 packets as mission observations again 4 днів тому
  stan 45f251d6a9 Restore the msg-6 Sense capacities and their stack storage 4 днів тому
  stan 5bae2ccf3a Restore the roster Sense echo on every rebuild 4 днів тому
  stan 73400ca9b5 Remove the squad attachment ownership model and the hop-on bindings 4 днів тому
  T4tsuk3i a00d7e28b0 fix: hold the character-select step with the server, not a client hook 1 тиждень тому
  T4tsuk3i 9b4df7d870 feat: capture and encode real per-character appearance 1 місяць тому
  T4tsuk3i 2a80800494 fix: hold the character-select screen instead of falling through to orbit 1 місяць тому
  T4tsuk3i b940413090 fix: give each created character its own class's gear, not one shared set 1 місяць тому
  T4tsuk3i 6a8103a564 fix: seed a starter loadout so created characters can publish 1 місяць тому
  T4tsuk3i 7473278539 fix: stage the Family-4 select-character publish for create/delete 1 місяць тому
  T4tsuk3i 9e11c57115 feat: create and delete characters at runtime 1 місяць тому
  stan 8711e9a9a4 Rebase onto master: replace the removed item index stride 4 днів тому
  Zach a0f931ad7f Keep repeated perk draws out of the stack merge 3 тижнів тому
  Zach 18cb9e4a55 Defer draw overflows, merge repeated stack draws, keep child wrapper flags, and match rewards to the item catalog 3 тижнів тому
  Zach 31509bbabe Open nested reward wrappers, retry transient failures, and bound each change list 3 тижнів тому
  Zach 0a014fc830 Defer unlock-gated rewards, draw record rewards in full frames, and walk owed pass perks 3 тижнів тому
  Zach 38ff6521e0 Bump the cache format version past upstream 3 тижнів тому
  Zach 86d0f8004f Retire unresolvable world rewards and load cached pass rows 3 тижнів тому
  Zach 6961c3c156 Share one unlock-expression evaluator and tighten reward layering 1 місяць тому
  Zach fb0e27c62e Resolve supplemental reward rows and drop the unused entry discriminator 1 місяць тому
  Zach 479c12c968 Preserve required reward mappings and finish review cleanup 1 місяць тому
  Zach 37785c819b Preserve queued grants on presentation failure and correct pool selection 1 місяць тому
  Zach 8f35d7aacc Tighten reward definition ownership and validation 1 місяць тому
  Zach 9f9e4b9330 Extract runtime reward definitions and resolve server grants 1 місяць тому
  stan 89db701091 Merge pull request #137 from saulob/feat/main-overlay-ux 4 днів тому
  stan bfe60e8ec4 Merge pull request #154 from gagefulwood/fix/social-roster-power 4 днів тому
  Gage Fulwood ba84ed2b23 Clarify social roster wire comments 5 днів тому
  Gage Fulwood df19698052 Show the Triumph score on the fireteam roster card 5 днів тому
  Gage Fulwood 1b06bc4e36 Show the selected character's Power on the fireteam roster card 5 днів тому
  Confetti3 b3d15ddc92 Merge upstream master into strange-terrain-pr 1 тиждень тому
  Confetti3 74d62cd7e3 Merge upstream master into strange-terrain-pr 3 тижнів тому
  saulo benigno e80f8cdd81 fix: address main overlay UX review 3 тижнів тому
  saulo benigno a2a1ba4c74 fix: avoid startup hint overlap 1 місяць тому
  saulo benigno 50a9020818 docs: document how to open the overlay 1 місяць тому
  saulo benigno 77dbb6bc38 feat: add startup overlay hint 1 місяць тому
  saulo benigno 17ba90f021 feat: improve main overlay close behavior 1 місяць тому
  Confetti3 b59c1aab21 Merge upstream master into strange-terrain-pr 3 тижнів тому
  Confetti3 cc94cdde7a Merge upstream master into strange-terrain-pr 3 тижнів тому
  Confetti3 8b3a88cc93 Address review of the Strange Terrain runtime 3 тижнів тому
  Confetti3 9b018e9910 Report an authored actor's replicated damage levels to its mission as damageState 1 місяць тому
  Confetti3 4a9b683893 Leave a removed child out of its parent's group when writing a batch 1 місяць тому
  Confetti3 0249ac2a8c Detach descendants that receive no record, as the native anchor pass does 1 місяць тому
  Confetti3 f9a63df2e2 Decode the reflected field shapes lane 2 uses after the relic Knight dies 1 місяць тому
  Confetti3 85ed609d1c Add squad attachments, volume toggles and trigger state to the mission runtime 1 місяць тому
  Confetti3 1f9ec53c08 Emit rule-less squads and the attachment capability in the activity SDK 1 місяць тому
100 змінених файлів з 4544 додано та 1197 видалено
  1. 8 0
      README.md
  2. 32 2
      Sunrise/Sunrise.vcxproj
  3. 23 0
      Sunrise/resources/database/character_starter.sql
  4. 3 3
      Sunrise/resources/database/investment_defaults.sql
  5. 4 2
      Sunrise/resources/database/investment_schema.sql
  6. 3 0
      Sunrise/resources/resource.h
  7. 2 0
      Sunrise/resources/sunrise.rc
  8. 18 2
      Sunrise/src/client/content/activity/activity_sdk_authored_scene_inventory.h
  9. 0 67
      Sunrise/src/client/content/activity/activity_sdk_dialogue_group_index.cpp
  10. 0 17
      Sunrise/src/client/content/activity/activity_sdk_dialogue_group_index.h
  11. 300 0
      Sunrise/src/client/content/activity/activity_sdk_dialogue_list.cpp
  12. 67 0
      Sunrise/src/client/content/activity/activity_sdk_dialogue_list.h
  13. 3 0
      Sunrise/src/client/content/activity/activity_sdk_lua_activities.cpp
  14. 1 0
      Sunrise/src/client/content/activity/activity_sdk_lua_artifacts.h
  15. 13 4
      Sunrise/src/client/content/activity/activity_sdk_lua_catalog_contract.cpp
  16. 1 0
      Sunrise/src/client/content/activity/activity_sdk_lua_contract.cpp
  17. 20 12
      Sunrise/src/client/content/activity/activity_sdk_lua_missions.cpp
  18. 2 0
      Sunrise/src/client/content/activity/activity_sdk_lua_missions_internal.h
  19. 1 0
      Sunrise/src/client/content/activity/activity_sdk_lua_publish.cpp
  20. 2 2
      Sunrise/src/client/content/activity/activity_sdk_native_pack_internal.h
  21. 27 4
      Sunrise/src/client/content/activity/activity_sdk_native_pack_pipeline.cpp
  22. 186 71
      Sunrise/src/client/content/activity/activity_sdk_native_pack_text.cpp
  23. 5 0
      Sunrise/src/client/content/activity/activity_sdk_pack_composer.cpp
  24. 19 1
      Sunrise/src/client/content/activity/activity_sdk_pack_composer_rows.cpp
  25. 48 0
      Sunrise/src/client/content/activity/activity_sdk_spawn_reference.h
  26. 2 0
      Sunrise/src/client/content/activity/activity_sdk_squad_config_parse.cpp
  27. 5 0
      Sunrise/src/client/content/activity/activity_sdk_squad_graph.cpp
  28. 2 0
      Sunrise/src/client/content/activity/activity_sdk_squad_graph.h
  29. 3 0
      Sunrise/src/client/content/activity/activity_sdk_squad_inventory.h
  30. 193 0
      Sunrise/src/client/content/activity/activity_sdk_squad_inventory_link.cpp
  31. 24 8
      Sunrise/src/client/content/items/packages/internal.h
  32. 24 8
      Sunrise/src/client/content/items/packages/package_item_build.cpp
  33. 31 5
      Sunrise/src/client/content/items/packages/package_item_rows.cpp
  34. 4 2
      Sunrise/src/client/content/items/packages/package_node_build.cpp
  35. 808 0
      Sunrise/src/client/content/items/packages/package_reward_build.cpp
  36. 116 0
      Sunrise/src/client/content/items/packages/package_reward_build.h
  37. 96 102
      Sunrise/src/client/content/items/packages/package_season_pass_build.cpp
  38. 135 0
      Sunrise/src/client/content/items/packages/package_starter_set_build.cpp
  39. 1 1
      Sunrise/src/core/logging/log.cpp
  40. 2 0
      Sunrise/src/core/logging/log.h
  41. 10 0
      Sunrise/src/core/settings/client/client_ui_settings_parser.cpp
  42. 3 0
      Sunrise/src/core/settings/parser.h
  43. 2 0
      Sunrise/src/core/settings/settings_parser.cpp
  44. 2 0
      Sunrise/src/core/ui/hud/overlay.h
  45. 82 0
      Sunrise/src/core/ui/hud/overlays/ui_hud_startup_hint_overlay.cpp
  46. 15 0
      Sunrise/src/core/ui/hud/overlays/ui_hud_startup_hint_overlay.h
  47. 31 6
      Sunrise/src/core/ui/hud/ui_hud_overlay_stack.cpp
  48. 45 3
      Sunrise/src/core/ui/layout/ui_layout_render.cpp
  49. 2 1
      Sunrise/src/core/ui/modules/logs/filters/logs_filter_controls.cpp
  50. 7 0
      Sunrise/src/core/ui/runtime/ui_visibility_runtime.cpp
  51. 3 0
      Sunrise/src/core/ui/runtime/ui_visibility_runtime.h
  52. 43 0
      Sunrise/src/middleware/bap/activity_message/activity_scriptable_auth_fixed_body_codec.cpp
  53. 1 1
      Sunrise/src/middleware/bap/activity_message/activity_scriptable_auth_mission_body_codec.cpp
  54. 686 0
      Sunrise/src/middleware/bap/activity_message/activity_sense_body_decoder.cpp
  55. 5 672
      Sunrise/src/middleware/bap/activity_message/activity_sense_update_decoder.cpp
  56. 1 1
      Sunrise/src/middleware/bap/activity_message/scriptable_auth_body.h
  57. 97 0
      Sunrise/src/middleware/bap/activity_message/sense_update_body.h
  58. 35 0
      Sunrise/src/middleware/bap/activity_message/volume_toggle_auth.h
  59. 7 0
      Sunrise/src/middleware/bap/activity_message/wire_schema/activity_wire_codec.h
  60. 13 4
      Sunrise/src/middleware/bap/activity_message/wire_schema/activity_wire_codec_decode_runtime.h
  61. 13 4
      Sunrise/src/middleware/bap/activity_message/wire_schema/activity_wire_codec_encode_runtime.h
  62. 5 0
      Sunrise/src/middleware/bap/activity_message/wire_schema/activity_wire_codec_internal.h
  63. 1 20
      Sunrise/src/middleware/content/packages/tables/activity_table.h
  64. 13 1
      Sunrise/src/middleware/content/packages/tables/definition_index_table.cpp
  65. 19 4
      Sunrise/src/middleware/content/packages/tables/definition_index_table.h
  66. 49 0
      Sunrise/src/middleware/content/packages/tables/field_reader.h
  67. 2 19
      Sunrise/src/middleware/content/packages/tables/internal.h
  68. 3 3
      Sunrise/src/middleware/datagen/character_record/character_record_encoder.cpp
  69. 4 3
      Sunrise/src/middleware/datagen/family4/character/character_encoder.cpp
  70. 38 0
      Sunrise/src/middleware/gameplay/external/composite_entity_baseline_registry.cpp
  71. 85 30
      Sunrise/src/middleware/gameplay/external/composite_entity_codec.cpp
  72. 2 0
      Sunrise/src/middleware/gameplay/external/composite_entity_codec.h
  73. 8 0
      Sunrise/src/middleware/gameplay/external/composite_entity_codec_internal.h
  74. 29 0
      Sunrise/src/middleware/gameplay/external/external_entity_codec.cpp
  75. 6 0
      Sunrise/src/middleware/gameplay/external/external_entity_codec.h
  76. 2 4
      Sunrise/src/middleware/web_service/messages/opcode501_codec.cpp
  77. 4 1
      Sunrise/src/middleware/web_service/messages/opcode501_codec.h
  78. 156 0
      Sunrise/src/middleware/web_service/messages/opcode501_request_codec.cpp
  79. 29 0
      Sunrise/src/middleware/web_service/messages/opcode501_request_codec.h
  80. 30 0
      Sunrise/src/middleware/web_service/messages/opcode502.cpp
  81. 25 0
      Sunrise/src/middleware/web_service/messages/opcode502.h
  82. 16 1
      Sunrise/src/server/activity/activity_sdk_device_prepare.cpp
  83. 8 4
      Sunrise/src/server/activity/activity_sdk_squad_runtime.cpp
  84. 54 5
      Sunrise/src/server/activity/host_runtime.h
  85. 8 5
      Sunrise/src/server/activity/host_runtime_incidents.cpp
  86. 19 0
      Sunrise/src/server/activity/host_runtime_names.cpp
  87. 8 5
      Sunrise/src/server/activity/host_runtime_sense.cpp
  88. 74 7
      Sunrise/src/server/activity/mission/mission_api_events.h
  89. 33 1
      Sunrise/src/server/activity/mission/mission_api_slot.h
  90. 1 2
      Sunrise/src/server/activity/mission/mission_script_actor_command_policy.cpp
  91. 76 16
      Sunrise/src/server/activity/mission/mission_script_lua_device_api.cpp
  92. 2 0
      Sunrise/src/server/activity/mission/mission_script_lua_event_api.cpp
  93. 63 1
      Sunrise/src/server/activity/mission/mission_script_lua_event_fields.cpp
  94. 52 0
      Sunrise/src/server/activity/mission/mission_script_lua_slot_api.cpp
  95. 46 0
      Sunrise/src/server/activity/mission/mission_script_lua_state_api.cpp
  96. 11 0
      Sunrise/src/server/activity/mission/mission_script_object_sense.h
  97. 44 0
      Sunrise/src/server/activity/mission/mission_script_runtime.cpp
  98. 12 0
      Sunrise/src/server/activity/mission/mission_script_runtime.h
  99. 86 34
      Sunrise/src/server/activity/mission/mission_script_runtime_combatant_damage.cpp
  100. 84 26
      Sunrise/src/server/activity/mission/mission_script_runtime_edges.cpp

+ 8 - 0
README.md

@@ -12,6 +12,14 @@ Destiny 2 Offline Preservation Mod
 - [Documentation](https://projectsunrise.dev/docs/)
 - [Discord](https://discord.gg/22JS6et5k9)
 
+## Opening the overlay
+
+After launching Destiny 2, press Insert to open or close the Sunrise overlay
+
+Insert is the default key and can be changed in `bin\x64\Sunrise\settings.json`
+
+See the [FAQ](https://projectsunrise.dev/faq/) for more information
+
 ## Features
 - Load into any Destination (matchmade activities are currently broken)
 - Script Missions

+ 32 - 2
Sunrise/Sunrise.vcxproj

@@ -99,6 +99,11 @@
     <None Include="vendor\lua\UPSTREAM_README" />
   </ItemGroup>
   <ItemGroup>
+    <ClCompile Include="src\state\build_data\cache\records\cache_reward_records.cpp" />
+    <ClCompile Include="src\client\content\items\packages\package_reward_build.cpp" />
+    <ClCompile Include="src\state\rewards\reward_resolver.cpp" />
+    <ClCompile Include="src\state\build_data\rewards\reward_catalog.cpp" />
+    <ClCompile Include="src\state\build_data\rewards\reward_build_data_runtime.cpp" />
     <ClCompile Include="src\client\diagnostics\studio\studio_link.cpp" />
     <ClCompile Include="src\client\diagnostics\studio\studio_world_reader.cpp" />
     <ClCompile Include="src\client\diagnostics\studio\studio_world_link.cpp" />
@@ -197,7 +202,10 @@
     <ClCompile Include="src\middleware\web_service\messages\opcode702_codec.cpp" />
     <ClCompile Include="src\middleware\web_service\messages\opcode406_codec.cpp" />
     <ClCompile Include="src\middleware\web_service\messages\opcode1820_codec.cpp" />
+    <ClCompile Include="src\middleware\web_service\messages\opcode501_request_codec.cpp" />
+    <ClCompile Include="src\middleware\web_service\messages\opcode502.cpp" />
     <ClCompile Include="src\state\runtime\state_account_runtime.cpp" />
+    <ClCompile Include="src\state\runtime\state_account_roster_runtime.cpp" />
     <ClCompile Include="src\state\runtime\state_account_acquisition_runtime.cpp" />
     <ClCompile Include="src\state\runtime\state_account_reward_grant_runtime.cpp" />
     <ClCompile Include="src\state\runtime\state_account_dismantle_runtime.cpp" />
@@ -217,6 +225,7 @@
     <ClCompile Include="src\core\ui\hud\overlays\ui_hud_mission_script_overlay.cpp" />
     <ClCompile Include="src\core\ui\hud\overlays\ui_hud_sensor_events_overlay.cpp" />
     <ClCompile Include="src\core\ui\hud\overlays\ui_hud_session_overlay.cpp" />
+    <ClCompile Include="src\core\ui\hud\overlays\ui_hud_startup_hint_overlay.cpp" />
     <ClCompile Include="src\core\ui\hud\overlays\ui_hud_status_overlay.cpp" />
     <ClCompile Include="src\core\ui\hud\store\hud_settings_store.cpp" />
     <ClCompile Include="src\core\ui\textures\ui_texture_slots.cpp" />
@@ -721,7 +730,7 @@
     <ClCompile Include="src\state\build_data\cache\records\cache_vendor_records.cpp" />
     <ClCompile Include="src\state\build_data\vendors\vendor_catalog.cpp" />
     <ClCompile Include="src\state\build_data\vendors\artifact_sale_view.cpp" />
-    <ClCompile Include="src\state\build_data\vendors\vendor_expression.cpp" />
+    <ClCompile Include="src\state\unlocks\unlocks_expression.cpp" />
     <ClCompile Include="src\client\content\vendors\package_vendor_build.cpp" />
     <ClCompile Include="src\state\build_data\cache\records\cache_hash_name_records.cpp" />
     <ClCompile Include="src\state\build_data\cache\records\cache_entity_metadata_records.cpp" />
@@ -765,6 +774,8 @@
     <ClCompile Include="src\state\build_data\season_pass\season_pass_build_data_runtime.cpp" />
     <ClCompile Include="src\state\build_data\bounties\bounty_catalog.cpp" />
     <ClCompile Include="src\state\build_data\bounties\bounty_build_data_runtime.cpp" />
+    <ClCompile Include="src\state\build_data\starter_sets\starter_set_catalog.cpp" />
+    <ClCompile Include="src\state\build_data\starter_sets\starter_set_build_data_runtime.cpp" />
     <ClCompile Include="src\state\build_data\scriptables\scriptable_catalog.cpp" />
     <ClCompile Include="src\state\build_data\runtime\domain_markers.cpp" />
     <ClCompile Include="src\state\build_data\inventory\buckets\inventory_bucket_catalog.cpp" />
@@ -808,6 +819,7 @@
     <ClCompile Include="src\client\content\activity\activity_sdk_native_pack_pipeline.cpp" />
     <ClCompile Include="src\client\content\activity\activity_sdk_dialogue_group_index.cpp" />
     <ClCompile Include="src\client\content\activity\activity_sdk_actor_ability_inventory.cpp" />
+    <ClCompile Include="src\client\content\activity\activity_sdk_dialogue_list.cpp" />
     <ClCompile Include="src\client\content\activity\activity_sdk_pack_composer.cpp" />
     <ClCompile Include="src\client\content\activity\activity_sdk_pack_composer_rows.cpp" />
     <ClCompile Include="src\client\content\activity\activity_sdk_pack_composer_validate.cpp" />
@@ -1158,6 +1170,7 @@
     <ClCompile Include="src\client\content\items\packages\package_collectible_build.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_material_requirement_build.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_detail_build.cpp" />
+    <ClCompile Include="src\client\content\items\packages\package_starter_set_build.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_catalyst_condition_reader.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_socket_plug_build.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_ability_build.cpp" />
@@ -1280,6 +1293,7 @@
     <ClCompile Include="src\middleware\bap\activity_message\activity_telemetry_parser.cpp" />
     <ClCompile Include="src\middleware\bap\activity_message\activity_start_activity_parser.cpp" />
     <ClCompile Include="src\middleware\bap\activity_message\activity_sense_update_decoder.cpp" />
+    <ClCompile Include="src\middleware\bap\activity_message\activity_sense_body_decoder.cpp" />
     <ClCompile Include="src\middleware\bap\activity_message\activity_sense_roster_delta.cpp" />
     <ClCompile Include="src\server\bap\encrypted\activity_message\receipts\activity_message_receipts.cpp" />
     <ClCompile Include="src\server\bap\encrypted\activity_message\receipts\activity_incident_grants.cpp" />
@@ -1339,6 +1353,11 @@
     <ClCompile Remove="vendor\lua\lzio.c" />
   </ItemGroup>
   <ItemGroup>
+    <ClInclude Include="src\middleware\content\packages\tables\field_reader.h" />
+    <ClInclude Include="src\client\content\items\packages\package_reward_build.h" />
+    <ClInclude Include="src\state\rewards\reward_resolver.h" />
+    <ClInclude Include="src\state\build_data\rewards\reward_catalog.h" />
+    <ClInclude Include="src\state\build_data\rewards\definition.h" />
     <ClInclude Include="resources\resource.h" />
     <ClInclude Include="src\core\logging\log.h" />
     <ClInclude Include="src\core\logging\snapshot\snapshot.h" />
@@ -1404,6 +1423,7 @@
     <ClInclude Include="src\core\ui\hud\overlays\ui_hud_mission_script_overlay.h" />
     <ClInclude Include="src\core\ui\hud\overlays\ui_hud_sensor_events_overlay.h" />
     <ClInclude Include="src\core\ui\hud\overlays\ui_hud_session_overlay.h" />
+    <ClInclude Include="src\core\ui\hud\overlays\ui_hud_startup_hint_overlay.h" />
     <ClInclude Include="src\core\ui\hud\overlays\ui_hud_status_overlay.h" />
     <ClInclude Include="src\core\ui\hud\store\hud_settings_store.h" />
     <ClInclude Include="src\core\ui\textures\ui_texture_slots.h" />
@@ -1626,7 +1646,7 @@
     <ClInclude Include="src\state\build_data\spawn_sets\definition.h" />
     <ClInclude Include="src\state\build_data\vendors\definition.h" />
     <ClInclude Include="src\state\build_data\vendors\vendor_catalog.h" />
-    <ClInclude Include="src\state\build_data\vendors\vendor_expression.h" />
+    <ClInclude Include="src\state\unlocks\unlocks_expression.h" />
     <ClInclude Include="src\client\content\vendors\layout.h" />
     <ClInclude Include="src\client\content\vendors\vendor_build.h" />
     <ClInclude Include="src\state\build_data\scenarios\definition.h" />
@@ -1890,6 +1910,8 @@
     <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\opcode501_request_codec.h" />
+    <ClInclude Include="src\middleware\web_service\messages\opcode502.h" />
     <ClInclude Include="src\middleware\web_service\messages\opcode505\opcode505_codec.h" />
     <ClInclude Include="src\middleware\web_service\messages\opcode601\opcode601_codec.h" />
     <ClInclude Include="src\middleware\datagen\family4\account\selection_patch\account_selection_patch_encoder.h" />
@@ -2027,6 +2049,7 @@
     <ClInclude Include="src\client\content\activity\activity_sdk_native_pack_pipeline.h" />
     <ClInclude Include="src\client\content\activity\activity_sdk_dialogue_group_index.h" />
     <ClInclude Include="src\client\content\activity\activity_sdk_actor_ability_inventory.h" />
+    <ClInclude Include="src\client\content\activity\activity_sdk_dialogue_list.h" />
     <ClInclude Include="src\client\content\activity\activity_sdk_pack_composer.h" />
     <ClInclude Include="src\client\content\activity\activity_sdk_pack_composer_internal.h" />
     <ClInclude Include="src\client\content\activity\activity_sdk_policy_input_adapter.h" />
@@ -2087,6 +2110,8 @@
     <ClInclude Include="src\state\build_data\season_pass\season_pass_catalog.h" />
     <ClInclude Include="src\state\build_data\bounties\definition.h" />
     <ClInclude Include="src\state\build_data\bounties\bounty_catalog.h" />
+    <ClInclude Include="src\state\build_data\starter_sets\definition.h" />
+    <ClInclude Include="src\state\build_data\starter_sets\starter_set_catalog.h" />
     <ClInclude Include="src\state\build_data\vendors\repeatable_triggers.h" />
     <ClInclude Include="src\state\build_data\progressions\definition.h" />
     <ClInclude Include="src\state\entitlements\definition.h" />
@@ -2195,6 +2220,7 @@
     <ClInclude Include="src\middleware\bap\activity_message\start_activity.h" />
     <ClInclude Include="src\middleware\bap\activity_message\sense_roster_delta.h" />
     <ClInclude Include="src\middleware\bap\activity_message\sense_update.h" />
+    <ClInclude Include="src\middleware\bap\activity_message\sense_update_body.h" />
     <ClInclude Include="src\server\bap\encrypted\activity_message\receipts\activity_message_receipts.h" />
     <ClInclude Include="src\server\bap\encrypted\activity_message\receipts\activity_incident_grants.h" />
     <ClInclude Include="src\state\activity\receipts\definition.h" />
@@ -2215,6 +2241,9 @@
     <ClInclude Include="src\middleware\bap\activity_message\interactable_object_auth.h" />
     <ClInclude Include="src\middleware\bap\activity_message\mission_auth_patch.h" />
     <ClInclude Include="src\middleware\bap\activity_message\mission_effect_auth.h" />
+    <ClInclude Include="src\client\content\activity\activity_sdk_spawn_reference.h" />
+    <ClInclude Include="src\middleware\bap\activity_message\volume_toggle_auth.h" />
+    <ClInclude Include="src\server\activity\mission\mission_trigger_observation.h" />
     <ClInclude Include="src\middleware\bap\activity_message\music_section_auth.h" />
     <ClInclude Include="src\middleware\bap\activity_message\roster_presence.h" />
     <ClInclude Include="src\middleware\bap\activity_message\scene_events_auth.h" />
@@ -2320,6 +2349,7 @@
     <ClInclude Include="src\state\account\inventory\seen_state.h" />
     <None Include="resources\database\account_settings_schema.sql" />
     <None Include="resources\database\account_settings_defaults.sql" />
+    <None Include="resources\database\character_starter.sql" />
   </ItemGroup>
   <ItemGroup>
     <ClInclude Include="src\state\activity_sdk\actor_sequences.h" />

+ 23 - 0
Sunrise/resources/database/character_starter.sql

@@ -0,0 +1,23 @@
+-- Sunrise's own starting state for a created character. Each value is the one the default save
+-- gives its characters. The items go ahead of the packages' new-character table, which names no
+-- subclass. Classes: 0 Titan, 1 Hunter, 2 Warlock.
+CREATE TABLE starter_character (
+    id INTEGER PRIMARY KEY CHECK (id = 1),
+    level INTEGER NOT NULL CHECK (level BETWEEN 0 AND 255),
+    preview_available INTEGER NOT NULL CHECK (preview_available IN (0, 1)),
+    appearance_value REAL NOT NULL,
+    last_orbited_destination INTEGER NOT NULL,
+    content_bypass INTEGER NOT NULL CHECK (content_bypass IN (0, 1))
+) STRICT;
+
+CREATE TABLE starter_items (
+    class INTEGER NOT NULL CHECK (class BETWEEN 0 AND 2),
+    position INTEGER NOT NULL CHECK (position >= 0),
+    definition_hash INTEGER NOT NULL CHECK (definition_hash BETWEEN 1 AND 4294967295),
+    PRIMARY KEY (class, position)
+) STRICT;
+
+INSERT INTO starter_character VALUES (1, 50, 1, 1.0, 308080871, 1);
+INSERT INTO starter_items VALUES (0, 0, 3105935002);
+INSERT INTO starter_items VALUES (1, 0, 3635991036);
+INSERT INTO starter_items VALUES (2, 0, 3481861797);

+ 3 - 3
Sunrise/resources/database/investment_defaults.sql

@@ -1,5 +1,5 @@
 INSERT INTO account VALUES (1, -7013740688821976832, 1);
-INSERT INTO characters VALUES (0, -7013740688821976831, 0, 0, 1, 50, 1, 1.0, 308080871, 1, 65535, -1, 57);
+INSERT INTO characters VALUES (0, -7013740688821976831, 0, 0, 1, 50, 1, 1.0, 308080871, 1, 65535, -1, 57, X'000000000000000000000000000000000000000000000000000000000000000000000000', 0);
 INSERT INTO items VALUES (0, 0, 0, 4611686018427387905, 3843477312, 106, 1, 0, 0, 1, 12, 4, 7, 10, 11, 2, 0);
 INSERT INTO sockets VALUES (4611686018427387905, 0, 2874284214);
 INSERT INTO sockets VALUES (4611686018427387905, 1, 3433396027);
@@ -145,7 +145,7 @@ INSERT INTO items VALUES (0, 1, 36, 4611686018427387920, 4012203003, 0, 1, 53, 0
 INSERT INTO items VALUES (0, 1, 37, 4611686018427387957, 95537029, 0, 1, 54, 0, 0, 0, 4, 7, 10, 11, 2, 0);
 INSERT INTO items VALUES (0, 1, 38, 4611686018427387958, 2748215252, 0, 1, 55, 0, 0, 0, 4, 7, 10, 11, 2, 0);
 INSERT INTO items VALUES (0, 1, 39, 4611686018427387959, 3769820799, 0, 1, 56, 0, 0, 0, 4, 7, 10, 11, 2, 0);
-INSERT INTO characters VALUES (1, -7013740688821976830, 0, 0, 0, 50, 1, 1.0, 308080871, 1, 65535, -1, 57);
+INSERT INTO characters VALUES (1, -7013740688821976830, 0, 0, 0, 50, 1, 1.0, 308080871, 1, 65535, -1, 57, X'000000000000000000000000000000000000000000000000000000000000000000000000', 0);
 INSERT INTO items VALUES (1, 0, 0, 4611686018427388005, 3843477312, 106, 1, 0, 0, 1, 12, 4, 7, 10, 11, 2, 0);
 INSERT INTO sockets VALUES (4611686018427388005, 0, 2874284214);
 INSERT INTO sockets VALUES (4611686018427388005, 1, 3433396027);
@@ -291,7 +291,7 @@ INSERT INTO items VALUES (1, 1, 36, 4611686018427388020, 2854370169, 0, 1, 53, 0
 INSERT INTO items VALUES (1, 1, 37, 4611686018427388057, 530754878, 0, 1, 54, 0, 0, 0, 4, 7, 10, 11, 2, 0);
 INSERT INTO items VALUES (1, 1, 38, 4611686018427388058, 815877596, 0, 1, 55, 0, 0, 0, 4, 7, 10, 11, 2, 0);
 INSERT INTO items VALUES (1, 1, 39, 4611686018427388059, 1188569234, 0, 1, 56, 0, 0, 0, 4, 7, 10, 11, 2, 0);
-INSERT INTO characters VALUES (2, -7013740688821976829, 0, 0, 2, 50, 1, 1.0, 308080871, 1, 65535, -1, 57);
+INSERT INTO characters VALUES (2, -7013740688821976829, 0, 0, 2, 50, 1, 1.0, 308080871, 1, 65535, -1, 57, X'000000000000000000000000000000000000000000000000000000000000000000000000', 0);
 INSERT INTO items VALUES (2, 0, 0, 4611686018427388105, 3843477312, 106, 1, 0, 0, 1, 12, 4, 7, 10, 11, 2, 0);
 INSERT INTO sockets VALUES (4611686018427388105, 0, 2874284214);
 INSERT INTO sockets VALUES (4611686018427388105, 1, 3433396027);

+ 4 - 2
Sunrise/resources/database/investment_schema.sql

@@ -1,5 +1,5 @@
 PRAGMA application_id = 1397902921;
-PRAGMA user_version = 2;
+PRAGMA user_version = 3;
 
 CREATE TABLE account (
     id INTEGER PRIMARY KEY CHECK (id = 1),
@@ -20,7 +20,9 @@ CREATE TABLE characters (
     content_bypass INTEGER NOT NULL CHECK (content_bypass IN (0, 1)),
     equipped_title INTEGER NOT NULL CHECK (equipped_title BETWEEN 0 AND 65535),
     acquired_subclass_mask INTEGER NOT NULL,
-    next_inventory_serial INTEGER NOT NULL CHECK (next_inventory_serial BETWEEN 0 AND 4294967295)
+    next_inventory_serial INTEGER NOT NULL CHECK (next_inventory_serial BETWEEN 0 AND 4294967295),
+    appearance_header BLOB NOT NULL CHECK (length(appearance_header) = 36),
+    appearance_header_valid INTEGER NOT NULL CHECK (appearance_header_valid IN (0, 1))
 ) STRICT;
 
 CREATE TABLE items (

+ 3 - 0
Sunrise/resources/resource.h

@@ -47,3 +47,6 @@
 /** Account preferences have their own schema and seed data in the save. */
 #define IDR_ACCOUNT_SETTINGS_SCHEMA 109
 #define IDR_ACCOUNT_SETTINGS_DEFAULTS 110
+
+/** A created character's starting state has its own tables and rows in the save. */
+#define IDR_CHARACTER_STARTER 111

+ 2 - 0
Sunrise/resources/sunrise.rc

@@ -61,3 +61,5 @@ IDR_SQLITE_NOTICE RCDATA "../vendor/sqlite/NOTICE.txt"
 
 IDR_ACCOUNT_SETTINGS_SCHEMA RCDATA "database/account_settings_schema.sql"
 IDR_ACCOUNT_SETTINGS_DEFAULTS RCDATA "database/account_settings_defaults.sql"
+
+IDR_CHARACTER_STARTER RCDATA "database/character_starter.sql"

+ 18 - 2
Sunrise/src/client/content/activity/activity_sdk_authored_scene_inventory.h

@@ -77,7 +77,19 @@ struct TaskTarget final {
     std::uint32_t reserved{};
 };
 
-/** One localized candidate found below an authored type-53 cue. */
+/** One type-53 cue read from the list its slot's descriptor references. */
+struct DialogueCue final {
+    Text id{};
+    std::uint32_t slotIndex{};
+    std::uint32_t cueIndex{};
+    std::uint32_t listTag{};
+    std::uint32_t definitionHash{};
+    float authoredWindowSeconds{};
+    std::uint32_t lineCount{};
+    std::uint32_t flags{};
+};
+
+/** One localized take of one line of a type-53 cue. */
 struct DialogueCueText final {
     Text id{};
     Text text{};
@@ -86,6 +98,10 @@ struct DialogueCueText final {
     std::uint32_t definitionHash{};
     std::uint32_t containerTag{};
     std::uint32_t stringHash{};
+    std::uint32_t lineIndex{};
+    std::uint32_t takeIndex{};
+    std::uint32_t audioTag{};
+    std::uint32_t durationMs{};
 };
 
 /** One exact type-68 HUD element and its authored localized fields; absent ones stay empty. */
@@ -122,8 +138,8 @@ struct Snapshot final {
     std::vector<EventKey> eventKeys{};
     std::vector<SquadEdge> squadEdges{};
     std::vector<TaskTarget> taskTargets{};
+    std::vector<DialogueCue> dialogueCues{};
     std::vector<DialogueCueText> dialogueCueTexts{};
-    std::vector<format::DialogueCue> dialogueCues{};
     std::vector<format::CombatObjectiveGroup> combatObjectiveGroups{};
     std::vector<DirectiveElement> directiveElements{};
     /**

+ 0 - 67
Sunrise/src/client/content/activity/activity_sdk_dialogue_group_index.cpp

@@ -1,6 +1,5 @@
 #include "activity_sdk_dialogue_group_index.h"
 
-#include <algorithm>
 #include <cmath>
 #include <cstring>
 #include <limits>
@@ -10,8 +9,6 @@
 namespace sunrise::client::content::activity::sdk_generation::dialogue_group_index {
 namespace {
 
-// One dialogue group row is 16 bytes in the blob.
-constexpr std::size_t kGroupStride = 16U;
 // The bank stores a counted definition array at +8 with an eight-byte row stride.
 constexpr std::size_t kDefinitionsField = 8U;
 constexpr std::size_t kDefinitionStride = 8U;
@@ -96,68 +93,4 @@ bool definitions(std::span<const std::byte> bytes, std::vector<Definition>& outp
     }
 }
 
-/** Builds the sorted group index over one dialogue blob. @return False when it is malformed. */
-bool build(std::span<const std::byte> bytes,
-           std::size_t groupRows,
-           std::size_t groupCount,
-           std::vector<Span>& output) noexcept {
-    output.clear();
-    if (groupRows > bytes.size() || groupCount > (bytes.size() - groupRows) / kGroupStride) {
-        return false;
-    }
-    try {
-        output.reserve(groupCount);
-        for (std::size_t index = 0; index < groupCount; ++index) {
-            const std::size_t row = groupRows + index * kGroupStride;
-            Span group{};
-            std::int64_t relative = 0;
-            if (!read_value(bytes, row, group.definitionHash)
-                || !read_value(bytes, row + 8U, relative)
-                || !add_relative(row + 8U, relative, group.begin) || group.begin > bytes.size()) {
-                output.clear();
-                return false;
-            }
-            output.push_back(group);
-        }
-        for (Span& group : output) {
-            group.end = bytes.size();
-            for (const Span& candidate : output) {
-                if (candidate.begin > group.begin && candidate.begin < group.end) {
-                    group.end = candidate.begin;
-                }
-            }
-        }
-        std::sort(output.begin(), output.end(), [](const Span& first, const Span& second) {
-            return first.definitionHash < second.definitionHash;
-        });
-        if (std::adjacent_find(output.begin(),
-                               output.end(),
-                               [](const Span& first, const Span& second) {
-                                   return first.definitionHash == second.definitionHash;
-                               })
-            != output.end()) {
-            output.clear();
-            return false;
-        }
-        return true;
-    } catch (...) {
-        output.clear();
-        return false;
-    }
-}
-
-/** Finds one group by definition hash in the sorted index. */
-bool find(std::span<const Span> groups, std::uint32_t definitionHash, Span& output) noexcept {
-    output = {};
-    const auto row = std::lower_bound(
-        groups.begin(), groups.end(), definitionHash, [](const Span& group, std::uint32_t hash) {
-            return group.definitionHash < hash;
-        });
-    if (row == groups.end() || row->definitionHash != definitionHash) {
-        return false;
-    }
-    output = *row;
-    return true;
-}
-
 } // namespace sunrise::client::content::activity::sdk_generation::dialogue_group_index

+ 0 - 17
Sunrise/src/client/content/activity/activity_sdk_dialogue_group_index.h

@@ -7,13 +7,6 @@
 
 namespace sunrise::client::content::activity::sdk_generation::dialogue_group_index {
 
-/** One content range keyed by its authored dialogue definition hash. */
-struct Span final {
-    std::uint32_t definitionHash{};
-    std::size_t begin{};
-    std::size_t end{};
-};
-
 /** One cue definition's delayed-request expiration window, in seconds. */
 struct Definition final {
     std::uint32_t hash{};
@@ -26,14 +19,4 @@ struct Definition final {
 [[nodiscard]] bool definitions(std::span<const std::byte> bytes,
                                std::vector<Definition>& output) noexcept;
 
-/** Builds a hash-sorted index from the native 16-byte dialogue group rows. */
-[[nodiscard]] bool build(std::span<const std::byte> bytes,
-                         std::size_t groupRows,
-                         std::size_t groupCount,
-                         std::vector<Span>& output) noexcept;
-
-/** Finds the content range for one cue definition hash. */
-[[nodiscard]] bool
-find(std::span<const Span> groups, std::uint32_t definitionHash, Span& output) noexcept;
-
 } // namespace sunrise::client::content::activity::sdk_generation::dialogue_group_index

+ 300 - 0
Sunrise/src/client/content/activity/activity_sdk_dialogue_list.cpp

@@ -0,0 +1,300 @@
+#include "activity_sdk_dialogue_list.h"
+
+#include <algorithm>
+#include <cmath>
+#include <cstring>
+#include <limits>
+
+#include "../../../state/activity_sdk/format.h"
+#include "activity_sdk_dialogue_group_index.h"
+
+namespace sunrise::client::content::activity::sdk_generation::dialogue_list {
+namespace {
+
+namespace format = state::activity_sdk::format;
+
+// The cue definitions come in cue order; the content trees that follow are sorted by hash.
+constexpr std::size_t kTreeField = 0x18;
+constexpr std::size_t kTreeStride = 16;
+// Every tree object repeats the definition hash it belongs to, after a per-object id.
+constexpr std::size_t kObjectHashOffset = 4;
+// A sequence plays its children in order through an array of relative object pointers.
+constexpr std::uint32_t kSequenceClass = 0x80808D1DU;
+constexpr std::size_t kSequenceChildField = 0x18;
+constexpr std::uint32_t kPointerArrayClass = 0x80808D20U;
+constexpr std::size_t kPointerStride = 8;
+// A selection plays one entry; each entry holds its conditions and a pointer to its child.
+constexpr std::uint32_t kSelectionClass = 0x80808D1AU;
+constexpr std::size_t kSelectionEntryField = 0x20;
+constexpr std::uint32_t kSelectionEntryClass = 0x80808D1FU;
+constexpr std::size_t kSelectionEntryStride = 0x38;
+constexpr std::size_t kSelectionChildOffset = 0x30;
+// A line carries two takes of the same text.
+constexpr std::uint32_t kLineClass = 0x80808D23U;
+constexpr std::size_t kFirstTakeOffset = 0x18;
+constexpr std::size_t kSecondTakeOffset = 0x28;
+constexpr std::size_t kLineByteCount = 0x38;
+// Ceilings that stop a corrupt or cyclic tree from driving an unbounded walk.
+constexpr std::size_t kMaximumDepth = 16;
+constexpr std::size_t kMaximumObjects = 1024;
+constexpr std::size_t kMaximumArrayRows = 4096;
+// Authored totals are float sums of the take lengths, so they match within float rounding only.
+constexpr float kSecondsTolerance = 0.001F;
+
+template <typename Value>
+[[nodiscard]] bool
+read_value(std::span<const std::byte> bytes, std::size_t offset, Value& output) noexcept {
+    output = {};
+    if (offset > bytes.size() || sizeof output > bytes.size() - offset) {
+        return false;
+    }
+    std::memcpy(&output, bytes.data() + offset, sizeof output);
+    return true;
+}
+
+/** Follows one signed self-relative pointer stored at `member`. @return False outside the blob. */
+[[nodiscard]] bool
+follow(std::span<const std::byte> bytes, std::size_t member, std::size_t& target) noexcept {
+    std::int64_t relative = 0;
+    if (!read_value(bytes, member, relative)) {
+        return false;
+    }
+    if (relative >= 0) {
+        const auto distance = static_cast<std::uint64_t>(relative);
+        if (distance > bytes.size() - member) {
+            return false;
+        }
+        target = member + static_cast<std::size_t>(distance);
+        return true;
+    }
+    const auto distance = static_cast<std::uint64_t>(-(relative + 1)) + 1U;
+    if (distance > member) {
+        return false;
+    }
+    target = member - static_cast<std::size_t>(distance);
+    return true;
+}
+
+/** Reads one array field's data offset and count, checking its element class and stride. */
+[[nodiscard]] bool read_array(std::span<const std::byte> bytes,
+                              std::size_t field,
+                              std::size_t stride,
+                              std::uint32_t elementClass,
+                              std::size_t& data,
+                              std::size_t& count) noexcept {
+    data = 0;
+    count = 0;
+    std::uint64_t rawCount = 0;
+    std::size_t header = 0;
+    std::uint64_t repeated = 0;
+    std::uint32_t classId = 0;
+    std::uint32_t padding = 0;
+    if (!read_value(bytes, field, rawCount) || rawCount > kMaximumArrayRows) {
+        return false;
+    }
+    if (rawCount == 0) {
+        return true;
+    }
+    if (!follow(bytes, field + 8U, header) || !read_value(bytes, header, repeated)
+        || repeated != rawCount || !read_value(bytes, header + 8U, classId)
+        || classId != elementClass || !read_value(bytes, header + 12U, padding) || padding != 0) {
+        return false;
+    }
+    data = header + 16U;
+    count = static_cast<std::size_t>(rawCount);
+    return data <= bytes.size() && count * stride <= bytes.size() - data;
+}
+
+/** Reads one take stored at `offset`. */
+[[nodiscard]] bool
+read_take(std::span<const std::byte> bytes, std::size_t offset, Take& output) noexcept {
+    return read_value(bytes, offset, output.audioTag)
+           && read_value(bytes, offset + 4U, output.containerTag)
+           && read_value(bytes, offset + 8U, output.stringHash)
+           && read_value(bytes, offset + 12U, output.seconds) && std::isfinite(output.seconds)
+           && output.seconds >= 0.0F;
+}
+
+/** Walk state shared by one tree: its hash, its object budget, and the lines it collects. */
+struct Walk final {
+    std::uint32_t definitionHash{};
+    std::size_t objects{};
+    std::vector<Line> lines{};
+};
+
+/**
+ * Collects the lines below one tree object and computes how long it plays.
+ * @return False when the object or any descendant is malformed.
+ */
+[[nodiscard]] bool walk_object(std::span<const std::byte> bytes,
+                               std::size_t object,
+                               std::size_t depth,
+                               Walk& walk,
+                               float& seconds) {
+    seconds = 0.0F;
+    std::uint32_t classId = 0;
+    std::uint32_t hash = 0;
+    if (depth > kMaximumDepth || ++walk.objects > kMaximumObjects || object < sizeof classId
+        || !read_value(bytes, object - sizeof classId, classId)
+        || !read_value(bytes, object + kObjectHashOffset, hash) || hash != walk.definitionHash) {
+        return false;
+    }
+    if (classId == kLineClass) {
+        Line line{};
+        if (object + kLineByteCount > bytes.size()
+            || !read_take(bytes, object + kFirstTakeOffset, line.first)
+            || !read_take(bytes, object + kSecondTakeOffset, line.second)) {
+            return false;
+        }
+        seconds = (std::max)(line.first.seconds, line.second.seconds);
+        walk.lines.push_back(line);
+        return true;
+    }
+    std::size_t data = 0;
+    std::size_t count = 0;
+    if (classId == kSequenceClass) {
+        if (!read_array(bytes,
+                        object + kSequenceChildField,
+                        kPointerStride,
+                        kPointerArrayClass,
+                        data,
+                        count)) {
+            return false;
+        }
+        for (std::size_t index = 0; index < count; ++index) {
+            std::size_t child = 0;
+            float childSeconds = 0.0F;
+            if (!follow(bytes, data + index * kPointerStride, child)
+                || !walk_object(bytes, child, depth + 1U, walk, childSeconds)) {
+                return false;
+            }
+            seconds += childSeconds;
+        }
+        return true;
+    }
+    if (classId == kSelectionClass) {
+        if (!read_array(bytes,
+                        object + kSelectionEntryField,
+                        kSelectionEntryStride,
+                        kSelectionEntryClass,
+                        data,
+                        count)) {
+            return false;
+        }
+        for (std::size_t index = 0; index < count; ++index) {
+            std::size_t child = 0;
+            float childSeconds = 0.0F;
+            if (!follow(bytes, data + index * kSelectionEntryStride + kSelectionChildOffset, child)
+                || !walk_object(bytes, child, depth + 1U, walk, childSeconds)) {
+                return false;
+            }
+            seconds = (std::max)(seconds, childSeconds);
+        }
+        return true;
+    }
+    return false;
+}
+
+/** One tree root and its hash, in blob order. */
+struct Tree final {
+    std::uint32_t definitionHash{};
+    std::size_t root{};
+};
+
+} // namespace
+
+/**
+ * Matches each cue definition to the content trees that carry its hash.
+ * @param list Authored dialogue list blob.
+ * @param output Receives the cues, their lines and the shared hashes; reset first.
+ * @return False when the definition or tree arrays are malformed.
+ */
+bool read(std::span<const std::byte> list, Snapshot& output) {
+    output = {};
+    std::vector<dialogue_group_index::Definition> definitions{};
+    std::size_t treeRows = 0;
+    std::size_t treeCount = 0;
+    if (!dialogue_group_index::definitions(list, definitions)
+        || !read_array(
+            list, kTreeField, kTreeStride, format::kDialogueGroupArrayClass, treeRows, treeCount)) {
+        return false;
+    }
+    std::vector<Tree> trees(treeCount);
+    for (std::size_t index = 0; index < treeCount; ++index) {
+        const std::size_t row = treeRows + index * kTreeStride;
+        if (!read_value(list, row, trees[index].definitionHash)
+            || !follow(list, row + 8U, trees[index].root)) {
+            return false;
+        }
+    }
+    std::stable_sort(trees.begin(), trees.end(), [](const Tree& left, const Tree& right) {
+        return left.definitionHash < right.definitionHash;
+    });
+    for (auto first = trees.begin(); first != trees.end();) {
+        const auto last = std::find_if(first, trees.end(), [&](const Tree& tree) {
+            return tree.definitionHash != first->definitionHash;
+        });
+        if (last - first > 1) {
+            output.sharedHashes.push_back(
+                {first->definitionHash, static_cast<std::uint32_t>(last - first)});
+        }
+        first = last;
+    }
+
+    output.cues.resize(definitions.size());
+    for (std::size_t index = 0; index < definitions.size(); ++index) {
+        Cue& cue = output.cues[index];
+        cue.definitionHash = definitions[index].hash;
+        cue.windowSeconds = definitions[index].authoredWindowSeconds;
+        const auto range = std::equal_range(trees.begin(),
+                                            trees.end(),
+                                            Tree{cue.definitionHash, 0},
+                                            [](const Tree& left, const Tree& right) {
+                                                return left.definitionHash < right.definitionHash;
+                                            });
+        std::size_t matches = 0;
+        bool malformed = false;
+        std::vector<Line> matched{};
+        std::vector<Line> unmatched{};
+        for (auto tree = range.first; tree != range.second; ++tree) {
+            Walk walk{};
+            walk.definitionHash = cue.definitionHash;
+            float seconds = 0.0F;
+            if (!walk_object(list, tree->root, 0, walk, seconds)) {
+                malformed = true;
+                continue;
+            }
+            if (std::fabs(seconds - cue.windowSeconds) <= kSecondsTolerance) {
+                ++matches;
+                matched = std::move(walk.lines);
+            } else {
+                unmatched = std::move(walk.lines);
+            }
+        }
+        const std::size_t candidates = static_cast<std::size_t>(range.second - range.first);
+        if (candidates == 0) {
+            cue.status = CueStatus::missing;
+        } else if (matches == 1) {
+            cue.status = CueStatus::resolved;
+        } else if (malformed) {
+            cue.status = CueStatus::malformed;
+        } else if (candidates == 1) {
+            // A sole tree is the cue's content even when its length disagrees; keep its lines.
+            cue.status = CueStatus::mismatched;
+            matched = std::move(unmatched);
+        } else {
+            cue.status = CueStatus::ambiguous;
+        }
+        if ((cue.status != CueStatus::resolved && cue.status != CueStatus::mismatched)
+            || matched.size() > (std::numeric_limits<std::uint32_t>::max)()
+            || output.lines.size() > (std::numeric_limits<std::uint32_t>::max)() - matched.size()) {
+            continue;
+        }
+        cue.firstLine = static_cast<std::uint32_t>(output.lines.size());
+        cue.lineCount = static_cast<std::uint32_t>(matched.size());
+        output.lines.insert(output.lines.end(), matched.begin(), matched.end());
+    }
+    return true;
+}
+
+} // namespace sunrise::client::content::activity::sdk_generation::dialogue_list

+ 67 - 0
Sunrise/src/client/content/activity/activity_sdk_dialogue_list.h

@@ -0,0 +1,67 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <span>
+#include <vector>
+
+namespace sunrise::client::content::activity::sdk_generation::dialogue_list {
+
+/** One recorded take of a line: its audio, its localized text, and its length. */
+struct Take final {
+    std::uint32_t audioTag{};
+    std::uint32_t containerTag{};
+    std::uint32_t stringHash{};
+    float seconds{};
+};
+
+/** One line. Both takes carry the same text; the line lasts as long as the longer take. */
+struct Line final {
+    Take first{};
+    Take second{};
+};
+
+/** How one cue definition was matched to the content tree the list authors for it. */
+enum class CueStatus : std::uint8_t {
+    /** Exactly one tree carrying the definition hash matches the authored duration. */
+    resolved,
+    /** Several trees share the definition hash and none or several match the authored duration. */
+    ambiguous,
+    /** No tree carries the definition hash. */
+    missing,
+    /** A tree carrying the definition hash is malformed. */
+    malformed,
+    /** The one tree carrying the definition hash differs from the authored duration. */
+    mismatched,
+};
+
+/** One cue in list order. Lines are present only for resolved and mismatched cues. */
+struct Cue final {
+    std::uint32_t definitionHash{};
+    /** Authored window from the definition row; a tree must last as long to resolve. */
+    float windowSeconds{};
+    std::uint32_t firstLine{};
+    std::uint32_t lineCount{};
+    CueStatus status{CueStatus::missing};
+};
+
+/** One definition hash carried by more than one content tree. */
+struct SharedHash final {
+    std::uint32_t definitionHash{};
+    std::uint32_t treeCount{};
+};
+
+/** Every cue of one dialogue list, its lines in cue order, and the hashes several trees share. */
+struct Snapshot final {
+    std::vector<Cue> cues{};
+    std::vector<Line> lines{};
+    std::vector<SharedHash> sharedHashes{};
+};
+
+/**
+ * Reads one dialogue list blob.
+ * @return False when the definition or tree arrays are malformed; a bad tree only marks its cues.
+ */
+[[nodiscard]] bool read(std::span<const std::byte> list, Snapshot& output);
+
+} // namespace sunrise::client::content::activity::sdk_generation::dialogue_list

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

@@ -147,6 +147,9 @@ namespace {
     for (std::uint32_t index = 0; index < source.taskTargets.size(); ++index) {
         output.tasksBySlot[source.taskTargets[index].taskSlotIndex].push_back(index);
     }
+    for (std::uint32_t index = 0; index < source.dialogueCues.size(); ++index) {
+        output.dialogueCuesBySlot.emplace(source.dialogueCues[index].slotIndex, index);
+    }
     for (std::uint32_t index = 0; index < source.dialogueCueTexts.size(); ++index) {
         output.dialogueBySlot[source.dialogueCueTexts[index].slotIndex].push_back(index);
     }

+ 1 - 0
Sunrise/src/client/content/activity/activity_sdk_lua_artifacts.h

@@ -80,6 +80,7 @@ struct Source final {
     std::span<const format::CombatObjectiveGroup> combatObjectiveGroups{};
     std::span<const format::ActorAbility> actorAbilities{};
     std::span<const format::AuthoredSceneEventKey> authoredSceneEventKeys{};
+    std::span<const format::DialogueCue> dialogueCues{};
 };
 
 /** One readable generated Lua module with a filesystem-safe canonical name. */

+ 13 - 4
Sunrise/src/client/content/activity/activity_sdk_lua_catalog_contract.cpp

@@ -20,14 +20,14 @@ struct RowSpec final {
 };
 
 /** Every StringRef field declared by the row table below. */
-constexpr std::size_t kDecodedStringFieldCount = 55;
+constexpr std::size_t kDecodedStringFieldCount = 56;
 /** Section index of the activity-owned binding-tag rows, which have no direct collection. */
 constexpr std::uint32_t kBindingTagSectionIndex = 24;
 /** Section index of the binding-locator rows, projected both directly and per activity. */
 constexpr std::uint32_t kBindingLocatorSectionIndex = 25;
 
 /** Compact type:name tokens are the single native inventory for all raw pack rows. */
-constexpr std::array<RowSpec, 40> kRows{{
+constexpr std::array<RowSpec, 41> kRows{{
     {1,
      "activities",
      "activities",
@@ -168,7 +168,7 @@ constexpr std::array<RowSpec, 40> kRows{{
      "dialogue_cue_texts",
      "CatalogDialogueCueTextView",
      "s:id;s:text;u32:slot_index;u32:cue_index;u32:definition_hash;u32:container_tag;"
-     "u32:string_hash"},
+     "u32:string_hash;u32:line_index;u32:take_index;u32:audio_tag;u32:duration_ms"},
     {23,
      "directive_elements",
      "directive_elements",
@@ -288,6 +288,12 @@ constexpr std::array<RowSpec, 40> kRows{{
      "s:name;u32:codec_families;u32:type_code;u32:decoded_size;u32:fixed_bits;"
      "u32:minimum_bits;u32:maximum_bits;u32:flags;u32:reserved;"
      "u64:writer_evidence_address;u64:reader_evidence_address"},
+    {46,
+     "dialogue_cues",
+     "dialogue_cues",
+     "CatalogDialogueCueView",
+     "u32:slot_index;u32:cue_index;u32:definition_hash;u32:authored_window_seconds_bits;s:id;"
+     "u32:list_tag;u32:line_count;u32:flags;u32:reserved"},
 }};
 
 /** Splits the semicolon list into type:name pairs; false on a bad token or a refusing callback. */
@@ -411,12 +417,15 @@ append_raw_field(Value::Object& fields, std::string_view code, std::string_view
 
 /** Ledger entry for the strings section; the view field appears only in the projection ledger. */
 [[nodiscard]] Value strings_projection(bool includeView) {
+    std::string surface("context.sdk.catalog.<all ");
+    surface.append(std::to_string(kDecodedStringFieldCount));
+    surface.append(" StringRef value/offset/length fields>");
     Value::Object fields{
         {"projection_mode", string("authenticated_parent_bounded_string_refs")},
         {"section", string("strings")},
         {"section_index", number(0)},
         {"source_artifact", string("activity_sdk.runtime_pack")},
-        {"surface", string("context.sdk.catalog.<all 54 StringRef value/offset/length fields>")},
+        {"surface", string(surface)},
     };
     if (includeView) {
         fields.push_back({"view", string("decoded_utf8_string_or_nil_with_raw_StringRef")});

+ 1 - 0
Sunrise/src/client/content/activity/activity_sdk_lua_contract.cpp

@@ -50,6 +50,7 @@ bool render_contract_files(const Source& source, Bundle& output) noexcept {
                  {"actor_sequence_entries", number(source.actorSequenceEntries.size())},
                  {"actor_sequence_bindings", number(source.actorSequenceBindings.size())},
                  {"authored_scene_event_keys", number(source.authoredSceneEventKeys.size())},
+                 {"dialogue_cues", number(source.dialogueCues.size())},
              })},
         });
         if (!render_json(manifest, 0, output.manifestJson)) {

+ 20 - 12
Sunrise/src/client/content/activity/activity_sdk_lua_missions.cpp

@@ -13,6 +13,7 @@
 #include <vector>
 
 #include "../../../middleware/bap/activity_message/auth_schema_catalog.h"
+#include "../../../state/activity_sdk/runtime.h"
 #include "../../../state/activity_sdk/scene_triggers.h"
 #include "activity_sdk_lua_missions_internal.h"
 
@@ -520,6 +521,7 @@ bool render_mission(const Source& source,
     std::string dialogueDefinitionConstants = "mission.DialogueDefinition = {\n";
     for (const std::uint32_t slot : slots) {
         const auto found = index.dialogueBySlot.find(slot);
+        const auto cues = index.dialogueCuesBySlot.find(slot);
         const auto slotKey = slotKeyByRow.find(slot);
         if (slotKey == slotKeyByRow.end() || slot >= source.slots.size()) {
             continue;
@@ -544,18 +546,24 @@ bool render_mission(const Source& source,
             append_uint(dialogueCueConstants, cue);
             dialogueCueConstants.append(",\n");
 
-            if (found != index.dialogueBySlot.end()) {
-                for (const std::uint32_t rowIndex : found->second) {
-                    const format::DialogueCueText& row = source.dialogueCueTexts[rowIndex];
-                    if (row.cueIndex == cue) {
-                        dialogueDefinitionConstants.append("        [");
-                        append_uint(dialogueDefinitionConstants, cue);
-                        dialogueDefinitionConstants.append("] = ");
-                        append_hex(dialogueDefinitionConstants, row.definitionHash);
-                        dialogueDefinitionConstants.append(",\n");
-                        break;
-                    }
-                }
+            // Cue rows are contiguous per slot and indexed by cue, so the cue offsets the slot's
+            // first row.
+            if (cues != index.dialogueCuesBySlot.end()
+                && cues->second + cue < source.dialogueCues.size()
+                && source.dialogueCues[cues->second + cue].slotIndex == slot
+                && source.dialogueCues[cues->second + cue].cueIndex == cue) {
+                const format::DialogueCue& row = source.dialogueCues[cues->second + cue];
+                dialogueDefinitionConstants.append("        [");
+                append_uint(dialogueDefinitionConstants, cue);
+                dialogueDefinitionConstants.append("] = { hash = ");
+                append_hex(dialogueDefinitionConstants, row.definitionHash);
+                // The authored window bounds a delayed cue; it is not a playback length.
+                dialogueDefinitionConstants.append(", window_ms = ");
+                append_uint(dialogueDefinitionConstants,
+                            state::activity_sdk::authored_milliseconds(row.authoredWindowSeconds));
+                dialogueDefinitionConstants.append(", lines = ");
+                append_uint(dialogueDefinitionConstants, row.lineCount);
+                dialogueDefinitionConstants.append(" },\n");
             }
 
             dialogueCueTextConstants.append("        [");

+ 2 - 0
Sunrise/src/client/content/activity/activity_sdk_lua_missions_internal.h

@@ -17,6 +17,8 @@ struct RenderIndex final {
     std::vector<std::vector<std::uint32_t>> squadsByScenario{};
     std::unordered_map<std::uint32_t, std::vector<std::uint32_t>> scenesBySlot{};
     std::unordered_map<std::uint32_t, std::vector<std::uint32_t>> tasksBySlot{};
+    /** First cue row of each type-53 slot; a slot's cue rows are contiguous and cue-indexed. */
+    std::unordered_map<std::uint32_t, std::uint32_t> dialogueCuesBySlot{};
     std::unordered_map<std::uint32_t, std::vector<std::uint32_t>> dialogueBySlot{};
     std::unordered_map<std::uint32_t, std::vector<std::uint32_t>> directivesBySlot{};
     std::unordered_map<std::uint32_t, std::vector<std::uint32_t>> combatGroupsBySlot{};

+ 1 - 0
Sunrise/src/client/content/activity/activity_sdk_lua_publish.cpp

@@ -407,6 +407,7 @@ bool is_current(const wchar_t* sdkDirectory, const state::activity_sdk::Catalog&
         source.actorSequenceEntries = catalog.actor_sequence_entries();
         source.actorSequenceBindings = catalog.actor_sequence_bindings();
         source.authoredSceneEventKeys = catalog.authored_scene_event_keys();
+        source.dialogueCues = catalog.dialogue_cues();
         Bundle expected{};
         if (!internal::render_contract_files(source, expected)) {
             return false;

+ 2 - 2
Sunrise/src/client/content/activity/activity_sdk_native_pack_internal.h

@@ -47,6 +47,7 @@ struct PackageContext final {
 
 /** Extracts localized dialogue aliases and safe authored directive elements. */
 [[nodiscard]] bool attach_authored_text(const topology_inventory::Snapshot& topology,
+                                        const topology_enrichment::Snapshot& enrichment,
                                         const squads::Facts& facts,
                                         PackageContext& packageContext,
                                         authored_scene::Snapshot& output);
@@ -61,8 +62,7 @@ struct PackageContext final {
 [[nodiscard]] bool attach_dialogue_cue_counts(const topology_inventory::Snapshot& topology,
                                               const squads::Facts& facts,
                                               PackageContext& packageContext,
-                                              topology_enrichment::Snapshot& enrichment,
-                                              authored_scene::Snapshot& authored);
+                                              topology_enrichment::Snapshot& enrichment);
 
 /** Marks the SDK slot row of every type-43 scene whose descriptor names no resource. */
 [[nodiscard]] bool attach_unresourced_scenes(const topology_inventory::Snapshot& topology,

+ 27 - 4
Sunrise/src/client/content/activity/activity_sdk_native_pack_pipeline.cpp

@@ -214,11 +214,34 @@ lua_source(const state::activity_sdk::identity::Expected& identity,
             worldSources,
             storage.combatObjectiveGroups,
             storage.actorAbilities,
-            storage.authoredSceneEventKeys};
+            storage.authoredSceneEventKeys,
+            storage.dialogueCues};
 }
 
 } // namespace
 
+/** Flags the slot rows of the scenes the inventory found without a resource. */
+bool attach_unresourced_scenes(const topology_inventory::Snapshot& topology,
+                               const authored_scene::Snapshot& scenes,
+                               topology_enrichment::Snapshot& enrichment) noexcept {
+    if (enrichment.slots.size() != topology.slots.size()) {
+        return false;
+    }
+    for (const std::uint32_t slotRow : scenes.unresourcedSlots) {
+        if (slotRow >= topology.slots.size()
+            || topology.slots[slotRow].slotType != format::kAuthoredSceneSlotType) {
+            return false;
+        }
+        topology_enrichment::Slot& enriched = enrichment.slots[slotRow];
+        if (enriched.componentClass != format::kAuthoredSceneComponentClass
+            || (enriched.flags & format::kSlotSchemaJoinExact) == 0) {
+            return false;
+        }
+        enriched.flags |= format::kSlotAuthoredSceneUnresourced;
+    }
+    return true;
+}
+
 /** Returns the stable log token for each pipeline result. */
 const char* status_name(Status value) noexcept {
     switch (value) {
@@ -372,13 +395,13 @@ Status stage(const wchar_t* sdkDirectory,
             return Status::authoredSceneLinks;
         }
         report(progress, progressContext, Phase::dialogueCues);
-        if (!attach_dialogue_cue_counts(
-                topology, squadFacts, packageContext, topologyDetails, sceneRows)) {
+        if (!attach_dialogue_cue_counts(topology, squadFacts, packageContext, topologyDetails)) {
             return cancelled(cancel, cancelContext) ? Status::cancelled : Status::dialogueCues;
         }
         report(progress, progressContext, Phase::authoredText);
         if (!attach_combat_objective_groups(topology, squadFacts, packageContext, sceneRows)
-            || !attach_authored_text(topology, squadFacts, packageContext, sceneRows)) {
+            || !attach_authored_text(
+                topology, topologyDetails, squadFacts, packageContext, sceneRows)) {
             return cancelled(cancel, cancelContext) ? Status::cancelled : Status::authoredText;
         }
         if (cancelled(cancel, cancelContext)) {

+ 186 - 71
Sunrise/src/client/content/activity/activity_sdk_native_pack_text.cpp

@@ -10,12 +10,15 @@
 #include <string>
 #include <string_view>
 #include <unordered_map>
+#include <unordered_set>
 #include <utility>
 #include <vector>
 
 #include "../../../core/logging/log.h"
 #include "../../../middleware/content/packages/tables/activity_display_name_reader.h"
+#include "../../../state/activity_sdk/runtime.h"
 #include "activity_sdk_dialogue_group_index.h"
+#include "activity_sdk_dialogue_list.h"
 #include "activity_sdk_native_pack_internal.h"
 
 namespace sunrise::client::content::activity::sdk_generation::native_pack_pipeline {
@@ -136,6 +139,10 @@ struct AuthoredTextCandidate final {
     std::uint32_t slotIndex{};
     std::uint32_t cueIndex{format::kAbsentIndex};
     std::uint32_t definitionHash{};
+    std::uint32_t lineIndex{};
+    std::uint32_t takeIndex{};
+    std::uint32_t audioTag{};
+    std::uint32_t durationMs{};
 };
 
 /** The localized fields are one directive element, not several selectable directives. */
@@ -150,6 +157,51 @@ struct AuthoredDirectiveCandidate final {
     std::uint32_t elementCount{};
 };
 
+/** Logs one dialogue list fact that changes which cues carry lines, with its context. */
+void log_dialogue_list(const squads::DescriptorFact& descriptor,
+                       std::uint32_t listTag,
+                       const char* result,
+                       std::uint32_t definitionHash,
+                       std::uint32_t count,
+                       core::log::Level level) noexcept {
+    std::array<char, 192> line{};
+    const int written =
+        std::snprintf(line.data(),
+                      line.size(),
+                      "ev=activity_sdk_dialogue_list result=%s config=0x%08X offset=0x%X "
+                      "slot_row=%u list=0x%08X definition=0x%08X count=%u",
+                      result,
+                      static_cast<unsigned>(descriptor.configTag),
+                      static_cast<unsigned>(descriptor.descriptorOffset),
+                      static_cast<unsigned>(descriptor.slotIndex),
+                      static_cast<unsigned>(listTag),
+                      static_cast<unsigned>(definitionHash),
+                      static_cast<unsigned>(count));
+    if (written > 0) {
+        core::log::write(
+            core::log::Channel::client,
+            level,
+            {line.data(), (std::min)(static_cast<std::size_t>(written), line.size() - 1U)});
+    }
+}
+
+/** @return The log result name of one cue status. */
+const char* cue_status_name(dialogue_list::CueStatus status) noexcept {
+    switch (status) {
+    case dialogue_list::CueStatus::resolved:
+        return "cue_resolved";
+    case dialogue_list::CueStatus::ambiguous:
+        return "cue_ambiguous";
+    case dialogue_list::CueStatus::missing:
+        return "cue_missing";
+    case dialogue_list::CueStatus::malformed:
+        return "cue_malformed";
+    case dialogue_list::CueStatus::mismatched:
+        return "cue_window_mismatch";
+    }
+    return "cue_unknown";
+}
+
 } // namespace
 
 /**
@@ -234,8 +286,9 @@ bool attach_combat_objective_groups(const topology_inventory::Snapshot& topology
     }
 }
 
-/** Extracts localized dialogue aliases and safe authored directive elements. */
+/** Extracts dialogue cues, their localized lines, and safe authored directive elements. */
 bool attach_authored_text(const topology_inventory::Snapshot& topology,
+                          const topology_enrichment::Snapshot& enrichment,
                           const squads::Facts& facts,
                           PackageContext& packageContext,
                           authored_scene::Snapshot& output) {
@@ -267,8 +320,15 @@ bool attach_authored_text(const topology_inventory::Snapshot& topology,
                && container->classId == display::kStringContainerClass;
     };
     try {
+        if (enrichment.slots.size() != topology.slots.size()) {
+            return false;
+        }
         std::vector<AuthoredTextCandidate> dialogueCandidates{};
         std::vector<AuthoredDirectiveCandidate> directiveCandidates{};
+        // The list each type-53 slot's cues were taken from; a second descriptor of the slot that
+        // references another list makes the slot's cues unknowable, so the slot keeps none.
+        std::unordered_map<std::uint32_t, std::uint32_t> dialogueListBySlot{};
+        std::unordered_set<std::uint32_t> conflictingDialogueSlots{};
         for (const squads::DescriptorFact& descriptor : facts.descriptors) {
             if (descriptor.slotIndex >= topology.slots.size()) {
                 continue;
@@ -294,51 +354,114 @@ bool attach_authored_text(const topology_inventory::Snapshot& topology,
                 if (resource->classId != format::kDialogueAuthoredListClass) {
                     continue;
                 }
-                std::size_t groups = 0;
-                std::size_t groupCount = 0;
-                if (!read_array(
-                        bytes, 0x18U, 16U, format::kDialogueGroupArrayClass, groups, groupCount)) {
+                const auto [known, firstList] =
+                    dialogueListBySlot.emplace(descriptor.slotIndex, resourceTag);
+                if (!firstList) {
+                    if (known->second != resourceTag) {
+                        conflictingDialogueSlots.insert(descriptor.slotIndex);
+                        log_dialogue_list(descriptor,
+                                          resourceTag,
+                                          "list_conflict",
+                                          known->second,
+                                          0,
+                                          core::log::Level::warn);
+                    }
                     continue;
                 }
-                std::size_t definitions = 0;
-                std::size_t definitionCount = 0;
-                if (!read_array(bytes,
-                                8U,
-                                8U,
-                                format::kDialogueDefinitionArrayClass,
-                                definitions,
-                                definitionCount)) {
+                dialogue_list::Snapshot list{};
+                if (!dialogue_list::read(bytes, list)) {
+                    log_dialogue_list(
+                        descriptor, resourceTag, "malformed", 0, 0, core::log::Level::warn);
                     continue;
                 }
-                std::vector<dialogue_groups::Span> groupIndex{};
-                if (!dialogue_groups::build(bytes, groups, groupCount, groupIndex)) {
+                for (const dialogue_list::SharedHash& shared : list.sharedHashes) {
+                    log_dialogue_list(descriptor,
+                                      resourceTag,
+                                      "shared_hash",
+                                      shared.definitionHash,
+                                      shared.treeCount,
+                                      core::log::Level::info);
+                }
+                // An empty list authors no cue, so its slot never gets an exact cue count.
+                if (list.cues.empty()) {
                     continue;
                 }
-                for (std::size_t cue = 0; cue < definitionCount; ++cue) {
-                    std::uint32_t definitionHash = 0;
-                    if (!read_value(bytes, definitions + cue * 8U, definitionHash)
-                        || definitionHash == 0 || definitionHash == 0x811C9DC5U) {
-                        continue;
+                // Cue rows follow the slot's cue count, which the same list gave.
+                const topology_enrichment::Slot& enriched = enrichment.slots[descriptor.slotIndex];
+                if ((enriched.flags & format::kSlotDialogueCuesExact) == 0
+                    || enriched.dialogueCueCount != list.cues.size()) {
+                    log_dialogue_list(descriptor,
+                                      resourceTag,
+                                      "count_mismatch",
+                                      0,
+                                      static_cast<std::uint32_t>(list.cues.size()),
+                                      core::log::Level::warn);
+                    continue;
+                }
+                for (std::size_t cue = 0; cue < list.cues.size(); ++cue) {
+                    const dialogue_list::Cue& row = list.cues[cue];
+                    authored_scene::DialogueCue cueRow{};
+                    char cueId[64]{};
+                    const int cueIdLength = std::snprintf(cueId,
+                                                          sizeof cueId,
+                                                          "dialogue_cue/%08x/%u",
+                                                          descriptor.slotIndex,
+                                                          static_cast<unsigned>(cue));
+                    if (cueIdLength <= 0 || static_cast<std::size_t>(cueIdLength) >= sizeof cueId
+                        || !copy_text(
+                            std::string_view(cueId, static_cast<std::size_t>(cueIdLength)),
+                            cueRow.id)) {
+                        return false;
                     }
-                    dialogue_groups::Span group{};
-                    if (!dialogue_groups::find(groupIndex, definitionHash, group)) {
+                    cueRow.slotIndex = descriptor.slotIndex;
+                    cueRow.cueIndex = static_cast<std::uint32_t>(cue);
+                    cueRow.listTag = resourceTag;
+                    cueRow.definitionHash = row.definitionHash;
+                    cueRow.authoredWindowSeconds = row.windowSeconds;
+                    const bool mismatched = row.status == dialogue_list::CueStatus::mismatched;
+                    const bool hasLines =
+                        mismatched || row.status == dialogue_list::CueStatus::resolved;
+                    if (hasLines) {
+                        cueRow.lineCount = row.lineCount;
+                        cueRow.flags = format::kDialogueCueLinesExact
+                                       | (mismatched ? format::kDialogueCueWindowMismatch : 0U);
+                    }
+                    output.dialogueCues.push_back(cueRow);
+                    if (row.status != dialogue_list::CueStatus::resolved) {
+                        log_dialogue_list(descriptor,
+                                          resourceTag,
+                                          cue_status_name(row.status),
+                                          row.definitionHash,
+                                          static_cast<std::uint32_t>(cue),
+                                          core::log::Level::warn);
+                    }
+                    if (!hasLines) {
                         continue;
                     }
-                    for (std::size_t offset = group.begin; offset + 8U <= group.end; offset += 4U) {
-                        std::uint32_t containerTag = 0;
-                        std::uint32_t stringHash = 0;
-                        const CachedTag* container = nullptr;
-                        if (!read_value(bytes, offset, containerTag)
-                            || !read_value(bytes, offset + 4U, stringHash)
-                            || containerTag < 0x80A12000U || containerTag >= 0xC0000000U
-                            || !package(containerTag, container) || container == nullptr
-                            || container->classId != display::kStringContainerClass) {
-                            continue;
+                    // Both takes of a line normally reference the same text; each is still its
+                    // own row, and the Lua publisher folds equal texts.
+                    for (std::uint32_t lineIndex = 0; lineIndex < row.lineCount; ++lineIndex) {
+                        const dialogue_list::Line& line = list.lines[row.firstLine + lineIndex];
+                        const std::array<const dialogue_list::Take*, format::kDialogueTakeCount>
+                            takes{&line.first, &line.second};
+                        for (std::uint32_t takeIndex = 0; takeIndex < takes.size(); ++takeIndex) {
+                            const dialogue_list::Take& take = *takes[takeIndex];
+                            const CachedTag* container = nullptr;
+                            if (take.containerTag == 0 || take.containerTag == format::kAbsentIndex
+                                || !package(take.containerTag, container) || container == nullptr
+                                || container->classId != display::kStringContainerClass) {
+                                continue;
+                            }
+                            dialogueCandidates.push_back(
+                                {{take.containerTag, take.stringHash},
+                                 descriptor.slotIndex,
+                                 static_cast<std::uint32_t>(cue),
+                                 row.definitionHash,
+                                 lineIndex,
+                                 takeIndex,
+                                 take.audioTag,
+                                 state::activity_sdk::authored_milliseconds(take.seconds)});
                         }
-                        dialogueCandidates.push_back({{containerTag, stringHash},
-                                                      descriptor.slotIndex,
-                                                      static_cast<std::uint32_t>(cue),
-                                                      definitionHash});
                     }
                 }
             } else {
@@ -441,6 +564,18 @@ bool attach_authored_text(const topology_inventory::Snapshot& topology,
             references.push_back(row.description);
             references.push_back(row.progress);
         }
+        const auto conflicting = [&](const auto& row) {
+            return conflictingDialogueSlots.contains(row.slotIndex);
+        };
+        output.dialogueCues.erase(
+            std::remove_if(output.dialogueCues.begin(), output.dialogueCues.end(), conflicting),
+            output.dialogueCues.end());
+        std::sort(output.dialogueCues.begin(),
+                  output.dialogueCues.end(),
+                  [](const auto& first, const auto& second) {
+                      return std::tie(first.slotIndex, first.cueIndex)
+                             < std::tie(second.slotIndex, second.cueIndex);
+                  });
         if (references.empty()) {
             return true;
         }
@@ -454,16 +589,19 @@ bool attach_authored_text(const topology_inventory::Snapshot& topology,
             if (name.authoredEmpty || name.length == 0) {
                 continue;
             }
+            const AuthoredTextCandidate& candidate = dialogueCandidates[index];
+            if (conflictingDialogueSlots.contains(candidate.slotIndex)) {
+                continue;
+            }
             const std::string_view text(name.value.data(), name.length);
             char id[96]{};
-            const AuthoredTextCandidate& candidate = dialogueCandidates[index];
             const int length = std::snprintf(id,
                                              sizeof id,
-                                             "dialogue/%08x/%u/%08x/%08x",
+                                             "dialogue/%08x/%u/%u/%u",
                                              candidate.slotIndex,
                                              candidate.cueIndex,
-                                             candidate.definitionHash,
-                                             candidate.reference.stringHash);
+                                             candidate.lineIndex,
+                                             candidate.takeIndex);
             authored_scene::DialogueCueText row{};
             if (length <= 0 || static_cast<std::size_t>(length) >= sizeof id
                 || !copy_text(std::string_view(id, static_cast<std::size_t>(length)), row.id)
@@ -475,6 +613,10 @@ bool attach_authored_text(const topology_inventory::Snapshot& topology,
             row.definitionHash = candidate.definitionHash;
             row.containerTag = candidate.reference.containerTag;
             row.stringHash = candidate.reference.stringHash;
+            row.lineIndex = candidate.lineIndex;
+            row.takeIndex = candidate.takeIndex;
+            row.audioTag = candidate.audioTag;
+            row.durationMs = candidate.durationMs;
             output.dialogueCueTexts.push_back(row);
         }
         std::size_t resolved = dialogueCandidates.size();
@@ -530,10 +672,11 @@ bool attach_authored_text(const topology_inventory::Snapshot& topology,
             row.flags = candidate.flags;
             output.directiveElements.push_back(row);
         }
+        // Lines keep their play order inside a cue.
         auto dialogueLess = [](const auto& first, const auto& second) {
-            return std::tie(first.slotIndex, first.cueIndex, first.definitionHash, first.stringHash)
+            return std::tie(first.slotIndex, first.cueIndex, first.lineIndex, first.takeIndex)
                    < std::tie(
-                       second.slotIndex, second.cueIndex, second.definitionHash, second.stringHash);
+                       second.slotIndex, second.cueIndex, second.lineIndex, second.takeIndex);
         };
         auto directiveLess = [](const auto& first, const auto& second) {
             return std::tie(first.slotIndex, first.nameHash, first.elementIndex)
@@ -565,8 +708,7 @@ bool attach_authored_text(const topology_inventory::Snapshot& topology,
 bool attach_dialogue_cue_counts(const topology_inventory::Snapshot& topology,
                                 const squads::Facts& facts,
                                 PackageContext& packageContext,
-                                topology_enrichment::Snapshot& enrichment,
-                                authored_scene::Snapshot& authoredRows) {
+                                topology_enrichment::Snapshot& enrichment) {
     if (topology.slots.size() != enrichment.slots.size()) {
         return false;
     }
@@ -648,11 +790,6 @@ bool attach_dialogue_cue_counts(const topology_inventory::Snapshot& topology,
             if (resolved && sawDescriptor && agreedCount != 0) {
                 enriched.dialogueCueCount = static_cast<std::uint32_t>(agreedCount);
                 enriched.flags |= format::kSlotDialogueCuesExact;
-                for (std::uint32_t cue = 0; cue < agreedDefinitions.size(); ++cue) {
-                    const auto& definition = agreedDefinitions[cue];
-                    authoredRows.dialogueCues.push_back(
-                        {slotRow, cue, definition.hash, definition.authoredWindowSeconds});
-                }
             }
         }
         return true;
@@ -661,26 +798,4 @@ bool attach_dialogue_cue_counts(const topology_inventory::Snapshot& topology,
     }
 }
 
-/** Flags the slot rows of the scenes the inventory found without a resource. */
-bool attach_unresourced_scenes(const topology_inventory::Snapshot& topology,
-                               const authored_scene::Snapshot& scenes,
-                               topology_enrichment::Snapshot& enrichment) noexcept {
-    if (enrichment.slots.size() != topology.slots.size()) {
-        return false;
-    }
-    for (const std::uint32_t slotRow : scenes.unresourcedSlots) {
-        if (slotRow >= topology.slots.size()
-            || topology.slots[slotRow].slotType != format::kAuthoredSceneSlotType) {
-            return false;
-        }
-        topology_enrichment::Slot& enriched = enrichment.slots[slotRow];
-        if (enriched.componentClass != format::kAuthoredSceneComponentClass
-            || (enriched.flags & format::kSlotSchemaJoinExact) == 0) {
-            return false;
-        }
-        enriched.flags |= format::kSlotAuthoredSceneUnresourced;
-    }
-    return true;
-}
-
 } // namespace sunrise::client::content::activity::sdk_generation::native_pack_pipeline

+ 5 - 0
Sunrise/src/client/content/activity/activity_sdk_pack_composer.cpp

@@ -654,6 +654,11 @@ template <typename Text> [[nodiscard]] bool keep_legacy(StringLinker& linker, co
             return false;
         }
     }
+    for (const authored_scene_inventory::DialogueCue& row : authoredScenes.dialogueCues) {
+        if (!keep_legacy(linker, row.id)) {
+            return false;
+        }
+    }
     for (const authored_scene_inventory::DialogueCueText& row : authoredScenes.dialogueCueTexts) {
         if (!keep_legacy(linker, row.id) || !keep_legacy(linker, row.text)) {
             return false;

+ 19 - 1
Sunrise/src/client/content/activity/activity_sdk_pack_composer_rows.cpp

@@ -649,8 +649,22 @@ translate_native_rows(const Inputs& inputs, const detail::StringResolver& linker
             return false;
         }
     }
-    output.dialogueCues = authoredScenes.dialogueCues;
     output.combatObjectiveGroups = authoredScenes.combatObjectiveGroups;
+    output.dialogueCues.resize(authoredScenes.dialogueCues.size());
+    for (std::size_t index = 0; index < authoredScenes.dialogueCues.size(); ++index) {
+        const authored_scene_inventory::DialogueCue& input = authoredScenes.dialogueCues[index];
+        format::DialogueCue& target = output.dialogueCues[index];
+        target.slotIndex = input.slotIndex;
+        target.cueIndex = input.cueIndex;
+        target.listTag = input.listTag;
+        target.definitionHash = input.definitionHash;
+        target.authoredWindowSeconds = input.authoredWindowSeconds;
+        target.lineCount = input.lineCount;
+        target.flags = input.flags;
+        if (!link_text(linker, input.id, target.id)) {
+            return false;
+        }
+    }
     output.dialogueCueTexts.resize(authoredScenes.dialogueCueTexts.size());
     for (std::size_t index = 0; index < authoredScenes.dialogueCueTexts.size(); ++index) {
         const authored_scene_inventory::DialogueCueText& input =
@@ -661,6 +675,10 @@ translate_native_rows(const Inputs& inputs, const detail::StringResolver& linker
         target.definitionHash = input.definitionHash;
         target.containerTag = input.containerTag;
         target.stringHash = input.stringHash;
+        target.lineIndex = input.lineIndex;
+        target.takeIndex = input.takeIndex;
+        target.audioTag = input.audioTag;
+        target.durationMs = input.durationMs;
         if (!link_text(linker, input.id, target.id)
             || !link_text(linker, input.text, target.text)) {
             return false;

+ 48 - 0
Sunrise/src/client/content/activity/activity_sdk_spawn_reference.h

@@ -0,0 +1,48 @@
+#pragma once
+
+#include <cstdint>
+
+namespace sunrise::client::content::activity::sdk_generation::squad_inventory {
+
+/** How one raw 64-bit spawn reference (key32, type8, padding8, index16) reads. */
+enum class SpawnReferenceState : std::uint8_t {
+    invalid,
+    absent,
+    present,
+};
+
+/**
+ * The client treats a reference as unset when its type is 0xFF or its index is 0xFFFF. Nonzero
+ * padding or an out-of-range type is malformed, which is not the same as absent.
+ */
+[[nodiscard]] constexpr SpawnReferenceState spawn_reference_state(std::uint64_t raw) noexcept {
+    const auto key = static_cast<std::uint32_t>(raw);
+    const auto type = static_cast<std::uint8_t>(raw >> 32U);
+    const auto padding = static_cast<std::uint8_t>(raw >> 40U);
+    const auto index = static_cast<std::uint16_t>(raw >> 48U);
+    if (padding != 0) {
+        return SpawnReferenceState::invalid;
+    }
+    if (type == 0xFFU || index == 0xFFFFU) {
+        return SpawnReferenceState::absent;
+    }
+    return key != 0 && key != 0xFFFFFFFFU && type >= 1 && type <= 72 ? SpawnReferenceState::present
+                                                                     : SpawnReferenceState::invalid;
+}
+
+/**
+ * A spawner needs a selected rule when both of its rule references (at offsets 0x98 and 0xA0) are
+ * absent and a complete parse found no inline point set. This proves only the source; the selected
+ * rule and its occurrence are validated separately.
+ */
+[[nodiscard]] constexpr bool requires_selected_spawn_rule(std::uint64_t reference98,
+                                                          std::uint64_t referenceA0,
+                                                          bool complete,
+                                                          bool inlinePointSetInspected,
+                                                          bool hasInlinePointSet) noexcept {
+    return complete && inlinePointSetInspected && !hasInlinePointSet
+           && spawn_reference_state(reference98) == SpawnReferenceState::absent
+           && spawn_reference_state(referenceA0) == SpawnReferenceState::absent;
+}
+
+} // namespace sunrise::client::content::activity::sdk_generation::squad_inventory

+ 2 - 0
Sunrise/src/client/content/activity/activity_sdk_squad_config_parse.cpp

@@ -59,6 +59,7 @@ namespace tables = middleware::content::packages::tables;
         || left.secondaryComponentClass != right.secondaryComponentClass
         || left.members.size() != right.members.size()
         || left.hasInlinePointSet != right.hasInlinePointSet
+        || left.inlinePointSetInspected != right.inlinePointSetInspected
         || left.inlinePointSetOffset != right.inlinePointSetOffset
         || left.inlinePlacementComponentOffset != right.inlinePlacementComponentOffset
         || left.inlineInitialPointIndex != right.inlineInitialPointIndex
@@ -132,6 +133,7 @@ parse_spawner(std::uint32_t tag, std::span<const std::byte> blob, SpawnerFact& o
         if (!tables::authored_squad_inline_point_set(blob, tag, source, inlinePresent, inlineSet)) {
             return false;
         }
+        output.inlinePointSetInspected = true;
         if (inlinePresent) {
             if (inlineSet.points.count >= format::kAbsentIndex) {
                 return false;

+ 5 - 0
Sunrise/src/client/content/activity/activity_sdk_squad_graph.cpp

@@ -49,6 +49,11 @@ template <typename Row>
         row.members.count = static_cast<std::uint32_t>(source.members.size());
         row.complete = source.complete;
         row.hasInlinePointSet = source.hasInlinePointSet;
+        row.requiresSelectedRule = requires_selected_spawn_rule(source.rawReference98,
+                                                                source.rawReferenceA0,
+                                                                source.complete,
+                                                                source.inlinePointSetInspected,
+                                                                source.hasInlinePointSet);
         if (!insert_config(byConfig, row, spawnerRow)) {
             return false;
         }

+ 2 - 0
Sunrise/src/client/content/activity/activity_sdk_squad_graph.h

@@ -52,6 +52,8 @@ struct GraphSpawner final {
     bool complete{};
     /** The rule row standing for the spawner's own point set, when it carries one. */
     bool hasInlinePointSet{};
+    /** Both native references are absent and canonical parsing proved no inline fallback. */
+    bool requiresSelectedRule{};
     std::uint32_t inlineRuleRow{format::kAbsentIndex};
 };
 

+ 3 - 0
Sunrise/src/client/content/activity/activity_sdk_squad_inventory.h

@@ -10,6 +10,7 @@
 
 #include "../../../state/activity_sdk/format.h"
 #include "activity_sdk_external_placements.h"
+#include "activity_sdk_spawn_reference.h"
 #include "activity_sdk_topology_inventory.h"
 
 namespace sunrise::client::content::activity::sdk_generation::squad_inventory {
@@ -90,6 +91,8 @@ struct SpawnerFact final {
     std::uint32_t secondaryComponentClass{};
     /** Set when the spawner carries its own point set instead of a type-66 reference. */
     bool hasInlinePointSet{};
+    /** Successful canonical parsing distinguishes absent from unread inline data. */
+    bool inlinePointSetInspected{};
     std::uint64_t inlinePointSetOffset{};
     std::uint64_t inlinePlacementComponentOffset{};
     std::uint32_t inlineInitialPointIndex{};

+ 193 - 0
Sunrise/src/client/content/activity/activity_sdk_squad_inventory_link.cpp

@@ -196,6 +196,194 @@ struct ConfigOccurrenceCountKeyHash final {
                       value.row.spawnRuleConfigTag);
 }
 
+/**
+ * Tests that one descriptor's whole schema tuple matches its slot's proven schema. The slot type
+ * alone never implies a schema.
+ */
+[[nodiscard]] bool unbound_descriptor_exact(
+    const topology::Snapshot& topology,
+    const std::unordered_map<std::uint32_t, const GraphSlotSchemaProvenance*>& schemas,
+    const GraphDescriptor& descriptor) {
+    if (!descriptor.complete || descriptor.slotIndex >= topology.slots.size()
+        || descriptor.objectIndex >= topology.objects.size()) {
+        return false;
+    }
+    const auto& slot = topology.slots[descriptor.slotIndex];
+    if (slot.objectIndex != descriptor.objectIndex || slot.slotType != format::kSquadSlotType) {
+        return false;
+    }
+    const auto found = schemas.find(descriptor.slotIndex);
+    const auto* match = found == schemas.end() ? nullptr : found->second;
+    return match != nullptr && match->exact && match->componentClass == descriptor.componentClass
+           && match->senseSchema == descriptor.senseSchema
+           && match->authSchema == descriptor.authSchema;
+}
+
+/**
+ * Tests that one occurrence places the descriptor's config exactly once. A config path repeated
+ * in one occurrence is ambiguous, even when every copy has the same coordinates.
+ */
+[[nodiscard]] bool unbound_occurrence_exact(const topology::Snapshot& topology,
+                                            const std::vector<const GraphConfigContext*>& contexts,
+                                            const GraphDescriptor& descriptor,
+                                            std::uint32_t occurrenceIndex) {
+    if (occurrenceIndex >= topology.occurrences.size()) {
+        return false;
+    }
+    const auto& occurrence = topology.occurrences[occurrenceIndex];
+    if (occurrence.objectIndex != descriptor.objectIndex) {
+        return false;
+    }
+    std::uint32_t count = 0;
+    bool exact = false;
+    for (const GraphConfigContext* context : contexts) {
+        if (context->configTag != descriptor.configTag
+            || context->occurrenceIndex != occurrenceIndex) {
+            continue;
+        }
+        ++count;
+        exact = context->complete && context->objectIndex == descriptor.objectIndex
+                && context->scenarioIndex == occurrence.scenarioIndex;
+    }
+    return count == 1 && exact;
+}
+
+/**
+ * Appends one squad per exact occurrence of a spawner that names no rule of its own. Such a squad
+ * has no rule edge or anchors, so it is placeable only with a selected type-66 rule slot. The
+ * authored graph's edges are left untouched.
+ */
+[[nodiscard]] bool project_unbound_squads(const topology::Snapshot& topology,
+                                          const Facts& facts,
+                                          const GraphSnapshot& graph,
+                                          SquadIdHasher& hasher,
+                                          ActorResolver actorResolver,
+                                          void* actorContext,
+                                          std::vector<PendingSquad>& pending) {
+    std::unordered_map<std::uint32_t, const GraphSlotSchemaProvenance*> schemas{};
+    for (const auto& schema : graph.slotSchemas) {
+        if (!schemas.emplace(schema.slotIndex, &schema).second) {
+            return false;
+        }
+    }
+    std::unordered_map<std::uint32_t, std::size_t> spawnersByConfig{};
+    std::unordered_map<std::uint32_t, std::vector<const GraphConfigContext*>> contextsByConfig{};
+    for (std::size_t index = 0; index < graph.spawners.size(); ++index) {
+        if (!spawnersByConfig.emplace(graph.spawners[index].configTag, index).second) {
+            return false;
+        }
+    }
+    for (const auto& context : graph.configContexts) {
+        contextsByConfig[context.configTag].push_back(&context);
+    }
+    for (const auto& descriptor : graph.descriptors) {
+        const auto foundSpawner = spawnersByConfig.find(descriptor.configTag);
+        if (foundSpawner == spawnersByConfig.end()) {
+            continue;
+        }
+        const std::size_t spawnerIndex = foundSpawner->second;
+        if (spawnerIndex >= facts.spawners.size()) {
+            return false;
+        }
+        const GraphSpawner& spawner = graph.spawners[spawnerIndex];
+        const SpawnerFact& source = facts.spawners[spawnerIndex];
+        if (!spawner.requiresSelectedRule) {
+            continue;
+        }
+        if (spawner.configTag != source.configTag) {
+            return false;
+        }
+        if (descriptor.componentClass != format::kSquadComponentClass
+            || descriptor.senseSchema != format::kSquadSenseSchema
+            || descriptor.authSchema != format::kSquadAuthSchema
+            || !unbound_descriptor_exact(topology, schemas, descriptor)) {
+            continue;
+        }
+        const auto foundContexts = contextsByConfig.find(descriptor.configTag);
+        if (foundContexts == contextsByConfig.end()) {
+            continue;
+        }
+        std::vector<std::uint32_t> occurrenceIndexes{};
+        for (const GraphConfigContext* context : foundContexts->second) {
+            occurrenceIndexes.push_back(context->occurrenceIndex);
+        }
+        std::sort(occurrenceIndexes.begin(), occurrenceIndexes.end());
+        occurrenceIndexes.erase(std::unique(occurrenceIndexes.begin(), occurrenceIndexes.end()),
+                                occurrenceIndexes.end());
+        for (const std::uint32_t occurrenceIndex : occurrenceIndexes) {
+            if (!unbound_occurrence_exact(
+                    topology, foundContexts->second, descriptor, occurrenceIndex)) {
+                continue;
+            }
+            const auto& occurrence = topology.occurrences[occurrenceIndex];
+            std::string_view occurrenceId{};
+            if (!text_view(occurrence.id, occurrenceId)) {
+                return false;
+            }
+            const std::array<std::string_view, 2> parts{descriptor.id, occurrenceId};
+            std::string digest{};
+            if (!domain_hash(hasher, "sunrise-runtime-unbound-squad-v1", parts, digest)) {
+                return false;
+            }
+            PendingSquad squad{};
+            squad.row.id = "squad/" + digest;
+            squad.row.scenarioIndex = occurrence.scenarioIndex;
+            squad.row.objectIndex = descriptor.objectIndex;
+            squad.row.slotIndex = descriptor.slotIndex;
+            squad.row.spawnerConfigTag = descriptor.configTag;
+            squad.row.spawnRuleConfigTag = format::kAbsentIndex;
+            squad.row.occurrenceIndex = occurrenceIndex;
+            squad.row.flags = format::kSquadRequiresSelectedRule
+                              | format::kSquadSourceDescriptorExact
+                              | format::kSquadScenarioOccurrenceExact;
+            bool sourceActorLinksComplete = true;
+            for (std::uint32_t memberIndex = 0; memberIndex < source.members.size();
+                 ++memberIndex) {
+                SquadMember member{};
+                if (!detail::build_member(source.members[memberIndex],
+                                          digest,
+                                          memberIndex,
+                                          actorResolver,
+                                          actorContext,
+                                          member,
+                                          sourceActorLinksComplete)) {
+                    return false;
+                }
+                squad.members.push_back(std::move(member));
+            }
+            // An unresolved rule-less squad is skipped; it must not fail the authored squads.
+            if (!sourceActorLinksComplete) {
+                continue;
+            }
+            const bool countValid = squad.members.size() >= format::kSquadMinimumMemberCount
+                                    && squad.members.size() <= format::kSquadMaximumMemberCount;
+            if (countValid) {
+                squad.row.flags |= format::kSquadMemberCountValid;
+            }
+            if (countValid
+                && std::all_of(
+                    squad.members.begin(), squad.members.end(), [](const SquadMember& member) {
+                        return (member.flags & format::kSquadMemberInvariantReadyMask)
+                                   == format::kSquadMemberInvariantReadyMask
+                               && member.defaultCount > 0;
+                    })) {
+                squad.row.flags |= format::kSquadCandidateCountsInvariantComplete;
+            }
+            std::vector<std::uint32_t> memberKeys{};
+            for (const SquadMember& member : squad.members) {
+                memberKeys.push_back(member.memberKey);
+            }
+            std::sort(memberKeys.begin(), memberKeys.end());
+            if (std::adjacent_find(memberKeys.begin(), memberKeys.end()) != memberKeys.end()) {
+                return false;
+            }
+            // No rule edge or anchors: placement refuses this squad unless a rule slot is selected.
+            pending.push_back(std::move(squad));
+        }
+    }
+    return true;
+}
+
 } // namespace
 
 /** Normalizes the authored graph once, then derives the narrower runnable squad projection. */
@@ -521,6 +709,11 @@ bool link(const topology::Snapshot& topology,
             }
         }
 
+        if (!project_unbound_squads(
+                topology, facts, graph, squadIdHasher, actorResolver, actorContext, pending)) {
+            return false;
+        }
+
         std::sort(pending.begin(),
                   pending.end(),
                   [](const PendingSquad& first, const PendingSquad& second) {

+ 24 - 8
Sunrise/src/client/content/items/packages/internal.h

@@ -24,6 +24,7 @@
 #include "../../../../state/build_data/runtime.h"
 #include "../../../../state/build_data/season_pass/definition.h"
 #include "../../../../state/build_data/sobjects/sobject_catalog.h"
+#include "package_reward_build.h"
 
 namespace sunrise::client::content::items::packages {
 
@@ -70,9 +71,10 @@ static_assert(kUnmappedSlot
 /** Bank index per unlock slot, indexed by slot. The first mapping row of a slot wins. */
 using SlotMap = std::array<std::uint16_t, kSlotSpace>;
 
-/** The four maps one root's two unlock mapping tables carry. 256 KiB together. */
+/** Saved-bank indices resolved from the root's flag and value mapping tables. */
 struct SlotMaps {
     SlotMap accountFlag{};
+    SlotMap profileFlag{};
     SlotMap characterFlag{};
     SlotMap accountValue{};
     SlotMap characterValue{};
@@ -83,6 +85,8 @@ struct Storage {
     reader::Scratch scratch{};
     /** Read once per root. Every domain resolves its unlock slots through these. */
     SlotMaps slotMaps{};
+    /** Reward pools, wrappers and condition tables read alongside the item walk. */
+    RewardBuild rewardBuild{};
     /** Node rows held until the value slot and owned records are resolved. */
     std::array<state::build_data::nodes::Definition, state::build_data::nodes::kDefinitionCapacity>
         nodeRows{};
@@ -153,15 +157,11 @@ struct Storage {
                state::build_data::progressions::kStepCapacity>
         progressionSteps{};
     std::size_t progressionStepCount{};
-    /** Season pass reward rows and the wrapper items they grant. */
+    /** Season pass reward rows in native claim order. */
     std::array<state::build_data::season_pass::Reward,
                state::build_data::season_pass::kRewardCapacity>
         seasonPassRewards{};
-    std::array<state::build_data::season_pass::Package,
-               state::build_data::season_pass::kPackageCapacity>
-        seasonPassPackages{};
     std::size_t seasonPassRewardCount{};
-    std::size_t seasonPassPackageCount{};
     /** Repeatable bounty rows, keyed by the item-type pair their pool shares. */
     std::array<state::build_data::bounties::Definition,
                state::build_data::bounties::kDefinitionCapacity>
@@ -206,6 +206,9 @@ struct Storage {
 /** @return True when the catalyst catalog is published or cannot exist on this executable. */
 [[nodiscard]] bool exotic_catalysts_settled() noexcept;
 
+/** @return True when the reward graph is published or can never be. */
+[[nodiscard]] bool reward_definitions_settled() noexcept;
+
 /** Adds one native definition index to the deduplicated request set. */
 void request(std::uint16_t definitionIndex, DetailRequests& requested) noexcept;
 
@@ -260,6 +263,19 @@ read_investment_constants(const reader::Source& source,
                           std::vector<std::byte>& blob,
                           state::build_data::constants::InvestmentConstants& output) noexcept;
 
+/**
+ * Reads and publishes the new-character table: per class, the items a new character gets.
+ * @param source Package source.
+ * @param scratch Reader scratch.
+ * @param root Investment root bytes.
+ * @param blob Scratch storage for the table blob.
+ * @return False only when the tag does not read, so a later slice retries it.
+ */
+[[nodiscard]] bool build_starter_sets(const reader::Source& source,
+                                      reader::Scratch& scratch,
+                                      std::span<const std::byte> root,
+                                      std::vector<std::byte>& blob) noexcept;
+
 /**
  * Builds the ability buckets one subclass publishes under one ability selection.
  * @param source Package source.
@@ -323,7 +339,7 @@ read_investment_constants(const reader::Source& source,
 /**
  * Reads one root's two unlock mapping tables into the pass slot maps.
  * @param source Package source.
- * @param storage Pass storage receiving the four maps.
+ * @param storage Pass storage receiving the five maps.
  * @param root Investment root bytes.
  * @return True when both account maps read. A character map may stay unmapped.
  */
@@ -378,7 +394,7 @@ read_investment_constants(const reader::Source& source,
                                       std::span<state::build_data::progressions::Step> steps,
                                       std::size_t& stepCount) noexcept;
 
-/** Reads the season pass reward list and the wrapper items it grants. */
+/** Reads the season pass reward list, keeping an unreadable row as an unavailable one. */
 [[nodiscard]] bool build_season_pass(const reader::Source& source,
                                      Storage& storage,
                                      std::span<const std::byte> root) noexcept;

+ 24 - 8
Sunrise/src/client/content/items/packages/package_item_build.cpp

@@ -17,6 +17,7 @@
 #include "../../vendors/vendor_build.h"
 #include "build.h"
 #include "internal.h"
+#include "package_reward_build.h"
 #include "package_socket_plug_build.h"
 
 namespace sunrise::client::content::items::packages {
@@ -34,12 +35,13 @@ namespace {
            && state::build_data::ability_buckets_ready()
            && state::build_data::socket_entry_buckets_ready()
            && state::build_data::progression_definitions_ready()
-           && state::build_data::season_pass_ready()
+           && state::build_data::season_pass_ready() && reward_definitions_settled()
            && state::build_data::repeatable_bounties_ready()
            && state::build_data::record_definitions_ready()
            && state::build_data::node_definitions_ready()
            && state::build_data::sobject_definitions_ready()
-           && state::build_data::investment_constants_ready() && exotic_catalysts_settled();
+           && state::build_data::investment_constants_ready()
+           && state::build_data::starter_sets_ready() && exotic_catalysts_settled();
 }
 
 } // namespace
@@ -126,7 +128,8 @@ bool build() noexcept {
             if (!state::build_data::item_definitions_ready()
                 || !state::build_data::record_definitions_ready()
                 || !state::build_data::node_definitions_ready()
-                || !state::build_data::season_pass_ready() || !exotic_catalysts_settled()) {
+                || !state::build_data::season_pass_ready() || !reward_definitions_settled()
+                || !exotic_catalysts_settled()) {
                 reason = "unlock_maps";
                 if (!read_unlock_slot_maps(
                         source, storage, std::span<const std::byte>{storage.root})) {
@@ -184,11 +187,18 @@ bool build() noexcept {
                         std::span(storage.progressionSteps).first(storage.progressionStepCount));
                 }
             }
-            if (!state::build_data::season_pass_ready()
-                && build_season_pass(source, storage, std::span<const std::byte>{storage.root})) {
-                (void)state::build_data::publish_season_pass(
-                    std::span(storage.seasonPassRewards).first(storage.seasonPassRewardCount),
-                    std::span(storage.seasonPassPackages).first(storage.seasonPassPackageCount));
+            // Pass rows bind through the reward condition tables, which load before the pools.
+            if (!reward_definitions_settled() || !state::build_data::season_pass_ready()) {
+                (void)storage.rewardBuild.load(
+                    source, storage.scratch, storage.root, storage.slotMaps);
+            }
+            if (!state::build_data::season_pass_ready() && storage.rewardBuild.conditions.loaded()
+                && build_season_pass(source, storage, std::span<const std::byte>{storage.root})
+                && !state::build_data::publish_season_pass(
+                    std::span(storage.seasonPassRewards).first(storage.seasonPassRewardCount))) {
+                core::log::write(core::log::Channel::client,
+                                 core::log::Level::warn,
+                                 "ev=pkg stage=season_pass result=fail reason=publish");
             }
             // Records are read before nodes: a node's lore-book flag and its parent bar come
             // from the records it owns, so the record rows must already be in pass storage.
@@ -219,6 +229,12 @@ bool build() noexcept {
                     (void)state::build_data::publish_investment_constants(extracted);
                 }
             }
+            if (!state::build_data::starter_sets_ready()) {
+                (void)build_starter_sets(source,
+                                         storage.scratch,
+                                         std::span<const std::byte>{storage.root},
+                                         storage.child);
+            }
             reason = "slot";
             if (!tables::slot_tag(
                     std::span<const std::byte>{storage.root}, tables::kItemTableSlot, tableTag)

+ 31 - 5
Sunrise/src/client/content/items/packages/package_item_rows.cpp

@@ -54,13 +54,16 @@ bool exotic_catalysts_settled() noexcept {
  * @param table Located item index array within storage.child.
  * @param rowCount Starts at zero; receives the rows retained even if publication fails.
  * @param reason Receives the last stage reached or its failure reason.
- * @return True when this pass's required item domains are ready; failure may retain prior results.
+ * @return True when required non-reward item domains are ready. Failure may retain prior results.
  */
 bool build_item_rows(const reader::Source& source,
                      Storage& storage,
                      const tables::Array& table,
                      std::size_t& rowCount,
                      const char*& reason) noexcept {
+    const bool needRewards = !reward_definitions_settled();
+    const bool rewardStorageReady =
+        needRewards && storage.rewardBuild.begin_items(static_cast<std::size_t>(table.count));
     const bool needDefinitions = !state::build_data::item_definitions_ready();
     const bool needDetails = !state::build_data::configured_item_details_ready();
     const bool needSocketPlugs = !state::build_data::socket_plug_rules_ready();
@@ -71,7 +74,7 @@ bool build_item_rows(const reader::Source& source,
     const bool needDetailRows = needDetails || needSocketRows;
     // Bucket equipment slots are derived from this same complete item walk, so a partial retry
     // must still revisit the table even when definitions and detail domains already published.
-    const bool needRows = needDefinitions || needDetailRows || needBuckets;
+    const bool needRows = needDefinitions || needDetailRows || needBuckets || rewardStorageReady;
     bool published = !needRows;
     if (retainDetails && storage.details.size() != kDetailCapacity) {
         storage.details.assign(kDetailCapacity, build_details::Definition{});
@@ -107,11 +110,23 @@ bool build_item_rows(const reader::Source& source,
         item.definitionHash = row.definitionHash;
         item.definitionIndex = static_cast<std::uint16_t>(index);
         std::uint32_t itemClass = 0;
-        if (!reader::read_tag(source, storage.scratch, row.targetTag, storage.definition, itemClass)
-            || !tables::items::read_definition(std::span<const std::byte>{storage.definition},
-                                               item)) {
+        if (!reader::read_tag(
+                source, storage.scratch, row.targetTag, storage.definition, itemClass)) {
+            // The published catalog read this row before, so its reward waits for a retry; a row
+            // the catalog lacks stays pruned from both.
+            state::build_data::items::Definition cataloged{};
+            if (rewardStorageReady
+                && state::build_data::find_item_definition_index(item.definitionIndex, cataloged)) {
+                storage.rewardBuild.item_unread(item.definitionIndex);
+            }
+            continue;
+        }
+        if (!tables::items::read_definition(std::span<const std::byte>{storage.definition}, item)) {
             continue;
         }
+        if (rewardStorageReady && itemClass == tables::kItemDefinitionClass) {
+            storage.rewardBuild.item(item.definitionIndex, item.definitionHash, storage.definition);
+        }
         const std::uint32_t plugCategoryHash =
             corrected_plug_category(item.definitionHash, item.plugCategoryHash);
         build_items::QuestInitialization quest{};
@@ -175,6 +190,13 @@ bool build_item_rows(const reader::Source& source,
         published =
             state::build_data::publish_item_definitions(std::span(storage.rows).first(rowCount));
     }
+    // A reward refusal is reported by RewardBuild and does not block the other item domains.
+    // Rewards publish only beside the item catalog and at its row count, so both omit the same
+    // unread rows.
+    const bool rewardsPublished =
+        !needRewards
+        || (rewardStorageReady && state::build_data::item_definitions_ready()
+            && storage.rewardBuild.publish(state::build_data::item_definition_count()));
     if (!published) {
         reason = !detailStorageReady ? "detail_storage"
                  : !requestsFit      ? "detail_capacity"
@@ -301,6 +323,10 @@ bool build_item_rows(const reader::Source& source,
             report_ability_count(abilityCount);
         }
     }
+    // Named last; later stages overwrite the reason as they start.
+    if (published && !rewardsPublished) {
+        reason = "rewards";
+    }
     return published && state::build_data::item_definitions_ready()
            && state::build_data::configured_item_details_ready()
            && state::build_data::socket_plug_rules_ready() && exotic_catalysts_settled()

+ 4 - 2
Sunrise/src/client/content/items/packages/package_node_build.cpp

@@ -30,9 +30,10 @@ void clear_slot_map(SlotMap& output) noexcept {
     output.fill(kUnmappedSlot);
 }
 
-/** Clears all four so an unread table cannot leave a prior pass's indexes addressable. */
+/** An unread table must not leave a prior pass's indices addressable. */
 void clear_slot_maps(SlotMaps& maps) noexcept {
     clear_slot_map(maps.accountFlag);
+    clear_slot_map(maps.profileFlag);
     clear_slot_map(maps.characterFlag);
     clear_slot_map(maps.accountValue);
     clear_slot_map(maps.characterValue);
@@ -113,7 +114,7 @@ read_slot_map(std::span<const std::byte> blob, std::size_t descriptor, SlotMap&
 /**
  * A gate names a slot; its saved bank index is the mapping row that names that slot.
  * @param source Borrowed package source.
- * @param storage Receives four slot maps and retained value-map bytes; may be partial on failure.
+ * @param storage Receives slot maps and retained value-map bytes; may be partial on failure.
  * @param root Investment root bytes naming the flag and value mapping tables.
  * @return True when both account maps read; a character map may remain unmapped.
  */
@@ -131,6 +132,7 @@ bool read_unlock_slot_maps(const reader::Source& source,
     const std::span<const std::byte> flagMap{storage.child};
     const bool flagMapRead =
         read_slot_map(flagMap, tables::kAccountFlagMapDescriptor, maps.accountFlag);
+    (void)read_slot_map(flagMap, tables::kProfileFlagMapDescriptor, maps.profileFlag);
     (void)read_slot_map(flagMap, tables::kCharacterFlagMapDescriptor, maps.characterFlag);
 
     std::uint32_t valueMapTag = 0;

+ 808 - 0
Sunrise/src/client/content/items/packages/package_reward_build.cpp

@@ -0,0 +1,808 @@
+#include "package_reward_build.h"
+
+#include <algorithm>
+#include <cmath>
+#include <vector>
+
+#include "../../../../core/logging/log.h"
+#include "../../../../middleware/content/packages/tables/definition_index_table.h"
+#include "../../../../middleware/content/packages/tables/field_reader.h"
+#include "../../../../state/build_data/runtime.h"
+#include "../../../../state/unlocks/unlocks_expression.h"
+#include "internal.h"
+
+namespace sunrise::client::content::items::packages {
+namespace {
+
+namespace reader = middleware::content::packages::reader;
+namespace tables = middleware::content::packages::tables;
+namespace domain = state::build_data::rewards;
+namespace unlocks = state::unlocks;
+
+/** Investment-root slots identify reward pools and unlock-slot bindings. */
+constexpr std::size_t kSupplementalRewardSlot = 84;
+constexpr std::size_t kPoolSlot = 88;
+constexpr std::size_t kExpressionSlot = 109;
+constexpr std::size_t kFlagSlot = 112;
+constexpr std::size_t kValueSlot = 114;
+/** Only the omitted-bank sentinel permits a no-op; other invalid tags leave effects unresolved. */
+constexpr std::uint32_t kAbsentTableTag = 0xFFFFFFFFU;
+/** Native reward schema classes and fixed row sizes. */
+constexpr std::uint32_t kPoolClass = 0x80807553U;
+constexpr std::uint32_t kPoolRowClass = 0x8080748CU;
+constexpr std::uint32_t kEntryClass = 0x8080748EU;
+constexpr std::uint32_t kExpressionClass = 0x80807D31U;
+constexpr std::uint32_t kModifierClass = 0x80807490U;
+constexpr std::uint32_t kSocketClass = 0x80803062U;
+constexpr std::uint32_t kWrapperClass = 0x808077CCU;
+constexpr std::uint32_t kSelectionClass = 0x808077CFU;
+constexpr std::uint32_t kFlagTableClass = 0x80807D49U;
+constexpr std::uint32_t kFlagRowClass = 0x80807D4FU;
+constexpr std::uint32_t kValueTableClass = 0x80807C92U;
+constexpr std::uint32_t kValueRowClass = 0x80807C96U;
+constexpr std::uint32_t kExpressionTableClass = 0x80807C49U;
+constexpr std::uint32_t kExpressionRowClass = 0x80807C4FU;
+constexpr std::uint32_t kConditionClass = 0x80807D2FU;
+constexpr std::size_t kPoolStride = 24;
+constexpr std::size_t kEntryStride = 80;
+constexpr std::size_t kModifierStride = 24;
+constexpr std::size_t kBindingStride = 8;
+constexpr std::size_t kExpressionRowStride = 24;
+constexpr std::size_t kSocketStride = 12;
+constexpr std::size_t kSelectionStride = 12;
+/** Item headers hold a relative wrapper pointer and an acquired-unlock slot. */
+constexpr std::size_t kWrapperField = 0x58;
+constexpr std::size_t kAcquiredFlagField = 0xDA;
+
+/** Byte offsets inside the serialized reward rows. */
+constexpr std::size_t kEntryQuantityOffset = 4;
+constexpr std::size_t kEntryPoolOffset = 8;
+constexpr std::size_t kEntrySupplementalOffset = 10;
+constexpr std::size_t kEntryCategoryOffset = 20;
+constexpr std::size_t kEntryWeightOffset = 24;
+constexpr std::size_t kEntryConditionOffset = 32;
+constexpr std::size_t kEntryModifiersOffset = 48;
+constexpr std::size_t kEntrySocketsOffset = 64;
+constexpr std::size_t kModifierValueIndexOffset = 16;
+constexpr std::size_t kModifierValueOffset = 20;
+constexpr std::size_t kPoolEntriesOffset = 8;
+constexpr std::size_t kExpressionBodyOffset = 8;
+constexpr std::size_t kWrapperSelectionsOffset = 8;
+constexpr std::size_t kWrapperFlagsOffset = 24;
+constexpr std::size_t kSelectionCountOffset = 4;
+constexpr std::size_t kSocketPlugOffset = 2;
+constexpr std::size_t kSocketPlugSetOffset = 4;
+constexpr std::size_t kSocketRollSetOffset = 6;
+constexpr std::size_t kSocketSelectionOffset = 8;
+/** Relative definition bodies are preceded by their four-byte schema class. */
+constexpr std::size_t kDefinitionClassPrefixSize = sizeof(std::uint32_t);
+
+/** Class rows name a default finisher whose use gate supplies the computed class flag. */
+constexpr std::size_t kClassSlot = 12;
+constexpr std::size_t kClassStride = 32;
+constexpr std::size_t kDefaultFinisherOffset = 24;
+constexpr std::uint32_t kClassTable = 0x808075BEU;
+constexpr std::uint32_t kClassRow = 0x808074FAU;
+constexpr std::size_t kUseConditionPointer = 24;
+constexpr std::uint32_t kUseConditionClass = 0x80802980U;
+
+/** Set when the installed data refuses the reward tables or graph; no retry can change that. */
+bool g_rewardsUnsupported = false;
+
+/** Logs one refused reward load, sizing or publication. */
+void report_refusal(const char* reason) noexcept {
+    core::log::writef(core::log::Channel::client,
+                      core::log::Level::warn,
+                      "ev=pkg stage=rewards result=fail reason=%s",
+                      reason);
+}
+
+/** Logs a refusal the installed data decides and settles the domain once. */
+void settle_unsupported(const char* reason) noexcept {
+    report_refusal(reason);
+    if (!g_rewardsUnsupported) {
+        g_rewardsUnsupported = true;
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::warn,
+                         "ev=pkg stage=rewards result=unsupported");
+    }
+}
+
+/** An unread item row keeps a zero hash. */
+[[nodiscard]] bool item_read(std::span<const domain::Item> items, std::uint16_t index) noexcept {
+    return index < items.size() && items[index].definitionHash != 0;
+}
+
+/** Appends within a bank's capacity; an allocation failure is refused rather than thrown. */
+template <typename T> bool append(std::vector<T>& bank, T value, std::size_t capacity) noexcept {
+    if (bank.size() >= capacity) {
+        return false;
+    }
+    try {
+        bank.push_back(value);
+        return true;
+    } catch (...) {
+        return false;
+    }
+}
+
+/** Package reads can fail temporarily. Only decoded schema failures settle a domain. */
+enum class TableRead { loaded, readFailed, unsupported };
+
+/** Reads the table an investment-root slot names, checking its class when one is expected. */
+TableRead root_table(const reader::Source& source,
+                     reader::Scratch& scratch,
+                     std::span<const std::byte> root,
+                     std::size_t slot,
+                     std::vector<std::byte>& blob,
+                     std::uint32_t expectedClass) noexcept {
+    std::uint32_t tag = 0;
+    std::uint32_t cls = 0;
+    if (!tables::slot_tag(root, slot, tag) || tables::package_of(tag) == tables::kAbsentPackageId) {
+        return TableRead::unsupported;
+    }
+    if (!reader::read_tag(source, scratch, tag, blob, cls)) {
+        return TableRead::readFailed;
+    }
+    return expectedClass == 0 || cls == expectedClass ? TableRead::loaded : TableRead::unsupported;
+}
+
+/** Required reward tables settle only after a decoded schema failure, leaving reads retryable. */
+bool load_reward_table(const reader::Source& source,
+                       reader::Scratch& scratch,
+                       std::span<const std::byte> root,
+                       std::size_t slot,
+                       std::vector<std::byte>& blob,
+                       std::uint32_t expectedClass,
+                       const char* reason) noexcept {
+    const auto result = root_table(source, scratch, root, slot, blob, expectedClass);
+    if (result == TableRead::unsupported) {
+        settle_unsupported(reason);
+    } else if (result == TableRead::readFailed) {
+        report_refusal(reason);
+    }
+    return result == TableRead::loaded;
+}
+
+/** Reads one entry's fixed fields; a negative or non-finite weight is refused. */
+bool read_reward_entry(std::span<const std::byte> blob,
+                       std::size_t at,
+                       domain::Entry& entry) noexcept {
+    return tables::read(blob, at, entry.itemIndex)
+           && tables::read(blob, at + kEntryQuantityOffset, entry.quantity)
+           && tables::read(blob, at + kEntryPoolOffset, entry.poolIndex)
+           && tables::read(blob, at + kEntrySupplementalOffset, entry.supplementalIndex)
+           && tables::read(blob, at + kEntryCategoryOffset, entry.categoryHash)
+           && tables::read(blob, at + kEntryWeightOffset, entry.weight)
+           && std::isfinite(entry.weight) && entry.weight >= 0;
+}
+
+/** Reads the single flag that gates use of the class's default finisher. */
+bool read_class_flag(std::span<const std::byte> blob, std::uint16_t& output) noexcept {
+    std::size_t at = 0;
+    std::uint32_t cls = 0;
+    tables::Array expression{};
+    if (!tables::relative(blob, kUseConditionPointer, at) || at < kDefinitionClassPrefixSize
+        || !tables::read(blob, at - kDefinitionClassPrefixSize, cls) || cls != kUseConditionClass
+        || !tables::read_array(
+            blob, at, kExpressionClass, tables::kUnlockInstructionStride, expression)
+        || expression.count != 1) {
+        return false;
+    }
+    std::uint32_t opcode = 0;
+    std::uint32_t flag = domain::kAbsent;
+    if (!tables::read(blob, expression.dataOffset, opcode)
+        || opcode != tables::kUnlockReadFlagOpcode
+        || !tables::read(
+            blob, expression.dataOffset + tables::kUnlockInstructionOperandOffset, flag)
+        || flag >= domain::kAbsent) {
+        return false;
+    }
+    output = static_cast<std::uint16_t>(flag);
+    return true;
+}
+
+} // namespace
+
+/** @return True when the reward graph is published or can never be. */
+bool reward_definitions_settled() noexcept {
+    return state::build_data::reward_definitions_ready() || g_rewardsUnsupported;
+}
+
+bool RewardBuild::entry(std::span<const std::byte> blob,
+                        std::size_t at,
+                        BankFailure& failure,
+                        const char*& reason) noexcept {
+    domain::Entry out{};
+    failure = BankFailure::none;
+    if (!read_reward_entry(blob, at, out)
+        || !conditions.read(
+            blob, at + kEntryConditionOffset, instructions_, out.condition, failure)) {
+        reason = failure == BankFailure::full ? "instruction_capacity" : "instruction_storage";
+        return false;
+    }
+    out.supplementalMissing = supplementalMissing_ && out.supplementalIndex != domain::kAbsent;
+    tables::Array rows{};
+    if (!tables::read_array(
+            blob, at + kEntryModifiersOffset, kModifierClass, kModifierStride, rows)) {
+        return false;
+    }
+    out.modifiers = {static_cast<std::uint32_t>(modifiers_.size()),
+                     static_cast<std::uint32_t>(rows.count)};
+    for (std::size_t i = 0; i < rows.count; ++i) {
+        const auto offset = rows.dataOffset + i * kModifierStride;
+        domain::Modifier modifier{};
+        if (!conditions.read(blob, offset, instructions_, modifier.condition, failure)
+            || !tables::read(blob, offset + kModifierValueIndexOffset, modifier.valueIndex)
+            || !tables::read(blob, offset + kModifierValueOffset, modifier.value)
+            || !std::isfinite(modifier.value)) {
+            reason = failure == BankFailure::full ? "instruction_capacity" : "instruction_storage";
+            return false;
+        }
+        if (modifiers_.size() >= domain::kModifierCapacity) {
+            failure = BankFailure::full;
+            reason = "modifier_capacity";
+            return false;
+        }
+        if (!append(modifiers_, modifier, domain::kModifierCapacity)) {
+            failure = BankFailure::storage;
+            reason = "modifier_storage";
+            return false;
+        }
+    }
+    std::array<domain::SocketOverride, domain::kSocketsPerItem> overrides{};
+    std::size_t count = 0;
+    if (!read_reward_sockets(blob, at + kEntrySocketsOffset, overrides, count)) {
+        return false;
+    }
+    if (count > domain::kSocketOverrideCapacity - sockets_.size()) {
+        failure = BankFailure::full;
+        reason = "socket_capacity";
+        return false;
+    }
+    out.sockets = {static_cast<std::uint32_t>(sockets_.size()), static_cast<std::uint32_t>(count)};
+    for (std::size_t i = 0; i < count; ++i) {
+        if (overrides[i].socketType == domain::kAbsent) {
+            return false;
+        }
+        if (!append(sockets_, overrides[i], domain::kSocketOverrideCapacity)) {
+            failure = BankFailure::storage;
+            reason = "socket_storage";
+            return false;
+        }
+    }
+    if (entries_.size() >= domain::kEntryCapacity) {
+        failure = BankFailure::full;
+        reason = "entry_capacity";
+        return false;
+    }
+    if (!append(entries_, out, domain::kEntryCapacity)) {
+        failure = BankFailure::storage;
+        reason = "entry_storage";
+        return false;
+    }
+    return true;
+}
+
+bool RewardConditions::load_class_flags(const reader::Source& source,
+                                        reader::Scratch& scratch,
+                                        std::span<const std::byte> root) noexcept {
+    classFlags_.fill(domain::kAbsent);
+    std::vector<std::byte> classes;
+    std::vector<std::byte> index;
+    std::vector<std::byte> blob;
+    tables::Array classRows{};
+    tables::Array itemRows{};
+    if (!load_reward_table(source, scratch, root, kClassSlot, classes, kClassTable, "class_table")
+        || !load_reward_table(
+            source, scratch, root, tables::kItemTableSlot, index, 0, "class_item_table")) {
+        return false;
+    }
+    if (!tables::read_array(
+            classes, tables::kTableArrayDescriptor, kClassRow, kClassStride, classRows)
+        || classRows.count != classFlags_.size()
+        || !tables::find_array_at(index, tables::kTableArrayDescriptor, itemRows)
+        || itemRows.elementClass != tables::kItemIndexTableClass) {
+        settle_unsupported("class_schema");
+        return false;
+    }
+    for (std::size_t i = 0; i < classFlags_.size(); ++i) {
+        std::uint16_t itemIndex = domain::kAbsent;
+        tables::IndexRow item{};
+        std::uint32_t cls = 0;
+        if (!tables::read(std::span<const std::byte>{classes},
+                          classRows.dataOffset + i * kClassStride + kDefaultFinisherOffset,
+                          itemIndex)
+            || !tables::index_row(index, itemRows, itemIndex, item)) {
+            settle_unsupported("class_finisher_index");
+            return false;
+        }
+        if (!reader::read_tag(source, scratch, item.targetTag, blob, cls)) {
+            core::log::writef(core::log::Channel::client,
+                              core::log::Level::warn,
+                              "ev=pkg stage=class_flags class=%zu result=fail reason=finisher_read",
+                              i);
+            return false;
+        }
+        if (cls != tables::kItemDefinitionClass || !read_class_flag(blob, classFlags_[i])) {
+            settle_unsupported("class_finisher_schema");
+            return false;
+        }
+    }
+    return true;
+}
+
+bool RewardConditions::load(const reader::Source& source,
+                            reader::Scratch& scratch,
+                            std::span<const std::byte> root,
+                            const SlotMaps& maps) noexcept {
+    loaded_ = false;
+    maps_ = &maps;
+    if (!load_class_flags(source, scratch, root)
+        || !load_reward_table(
+            source, scratch, root, kFlagSlot, flags_, kFlagTableClass, "flag_table")
+        || !load_reward_table(
+            source, scratch, root, kValueSlot, values_, kValueTableClass, "value_table")
+        || !load_reward_table(source,
+                              scratch,
+                              root,
+                              kExpressionSlot,
+                              expressions_,
+                              kExpressionTableClass,
+                              "expression_table")) {
+        return false;
+    }
+    // Shared expressions are expanded before the runtime evaluates reward conditions.
+    if (!tables::read_array(
+            flags_, tables::kTableArrayDescriptor, kFlagRowClass, kBindingStride, flagRows_)
+        || !tables::read_array(
+            values_, tables::kTableArrayDescriptor, kValueRowClass, kBindingStride, valueRows_)
+        || !tables::read_array(expressions_,
+                               tables::kTableArrayDescriptor,
+                               kExpressionRowClass,
+                               kExpressionRowStride,
+                               expressionRows_)) {
+        settle_unsupported("condition_schema");
+        return false;
+    }
+    loaded_ = true;
+    return true;
+}
+
+bool RewardConditions::loaded() const noexcept {
+    return loaded_;
+}
+
+bool RewardConditions::bind(std::uint32_t native,
+                            std::uint32_t operand,
+                            domain::Instruction& instruction) const noexcept {
+    const bool flag = native == tables::kUnlockReadFlagOpcode;
+    if (!flag && native != tables::kUnlockReadValueOpcode) {
+        instruction = {{}, unlocks::Bank::none, operand};
+        return unlocks::decode_opcode(native, instruction.opcode);
+    }
+    const auto& rows = flag ? flagRows_ : valueRows_;
+    const std::span<const std::byte> blob = flag ? flags_ : values_;
+    std::uint32_t hash = 0;
+    if (operand >= rows.count
+        || !tables::read(blob, rows.dataOffset + operand * kBindingStride, hash)) {
+        return false;
+    }
+    instruction.opcode = flag ? unlocks::Opcode::flag : unlocks::Opcode::loadValue;
+    if (flag) {
+        for (std::size_t characterClass = 0; characterClass < classFlags_.size();
+             ++characterClass) {
+            if (classFlags_[characterClass] != domain::kAbsent
+                && operand == classFlags_[characterClass]) {
+                instruction.bank = unlocks::Bank::characterClass;
+                instruction.operand = static_cast<std::uint32_t>(characterClass);
+                return true;
+            }
+        }
+    }
+    const auto slot = static_cast<std::int32_t>(operand);
+    const auto accountIndex = bank_index(flag ? maps_->accountFlag : maps_->accountValue, slot);
+    const auto profileIndex = flag ? bank_index(maps_->profileFlag, slot) : kUnmappedSlot;
+    const auto characterIndex =
+        bank_index(flag ? maps_->characterFlag : maps_->characterValue, slot);
+    if (accountIndex != kUnmappedSlot) {
+        instruction.bank = unlocks::Bank::account;
+        instruction.operand = accountIndex;
+    } else if (profileIndex != kUnmappedSlot) {
+        instruction.bank = unlocks::Bank::profile;
+        instruction.operand = profileIndex;
+    } else if (characterIndex != kUnmappedSlot) {
+        instruction.bank = unlocks::Bank::character;
+        instruction.operand = characterIndex;
+    } else {
+        instruction.bank = unlocks::Bank::external;
+        instruction.operand = hash;
+    }
+    return true;
+}
+
+bool RewardConditions::append_expression(std::span<const std::byte> blob,
+                                         std::size_t at,
+                                         std::vector<domain::Instruction>& bank,
+                                         std::size_t depth,
+                                         BankFailure& failure) const noexcept {
+    tables::Array rows{};
+    if (depth >= domain::kTraversalDepth
+        || !tables::read_array(
+            blob, at, kExpressionClass, tables::kUnlockInstructionStride, rows)) {
+        return false;
+    }
+    for (std::size_t i = 0; i < rows.count; ++i) {
+        const std::size_t row = rows.dataOffset + i * tables::kUnlockInstructionStride;
+        std::uint32_t native = 0;
+        std::uint32_t operand = 0;
+        if (!tables::read(blob, row, native)
+            || !tables::read(blob, row + tables::kUnlockInstructionOperandOffset, operand)) {
+            return false;
+        }
+        if (native == tables::kUnlockExpressionOpcode) {
+            const auto before = bank.size();
+            if (operand >= expressionRows_.count
+                || !append_expression(expressions_,
+                                      expressionRows_.dataOffset + operand * kExpressionRowStride
+                                          + kExpressionBodyOffset,
+                                      bank,
+                                      depth + 1,
+                                      failure)
+                || bank.size() == before) {
+                return false;
+            }
+            continue;
+        }
+        domain::Instruction instruction{};
+        if (!bind(native, operand, instruction) || !unlocks::valid(instruction)) {
+            return false;
+        }
+        if (bank.size() >= domain::kInstructionCapacity) {
+            failure = BankFailure::full;
+            return false;
+        }
+        if (!append(bank, instruction, domain::kInstructionCapacity)) {
+            failure = BankFailure::storage;
+            return false;
+        }
+    }
+    return true;
+}
+
+bool RewardConditions::read(std::span<const std::byte> blob,
+                            std::size_t at,
+                            std::vector<domain::Instruction>& bank,
+                            domain::Range& range,
+                            BankFailure& failure) const noexcept {
+    const auto first = bank.size();
+    failure = BankFailure::none;
+    if (!append_expression(blob, at, bank, 0, failure)) {
+        bank.resize(first);
+        return false;
+    }
+    range = {static_cast<std::uint32_t>(first), static_cast<std::uint32_t>(bank.size() - first)};
+    return true;
+}
+
+bool RewardConditions::read_list(std::span<const std::byte> blob,
+                                 std::size_t at,
+                                 std::span<domain::Instruction> output,
+                                 std::size_t& count,
+                                 BankFailure& failure) const noexcept {
+    count = 0;
+    failure = BankFailure::none;
+    tables::Array rows{};
+    std::vector<domain::Instruction> instructions;
+    if (!tables::read_array(blob, at, kConditionClass, tables::kUnlockExpressionFieldSize, rows)) {
+        return false;
+    }
+    for (std::size_t i = 0; i < rows.count; ++i) {
+        domain::Range expression{};
+        if (!read(blob,
+                  rows.dataOffset + i * tables::kUnlockExpressionFieldSize,
+                  instructions,
+                  expression,
+                  failure)
+            || expression.count == 0) {
+            return false;
+        }
+        // Every expression attached to a progression reward must hold.
+        if (i != 0) {
+            if (instructions.size() >= output.size()) {
+                return false;
+            }
+            if (!append(instructions,
+                        domain::Instruction{unlocks::Opcode::logicalAnd, unlocks::Bank::none, 0},
+                        output.size())) {
+                failure = BankFailure::storage;
+                return false;
+            }
+        }
+        if (instructions.size() > output.size()) {
+            return false;
+        }
+    }
+    std::copy(instructions.begin(), instructions.end(), output.begin());
+    count = instructions.size();
+    return true;
+}
+
+bool read_reward_sockets(std::span<const std::byte> blob,
+                         std::size_t at,
+                         std::span<domain::SocketOverride> output,
+                         std::size_t& count) noexcept {
+    count = 0;
+    tables::Array rows{};
+    if (!tables::read_array(blob, at, kSocketClass, kSocketStride, rows)
+        || rows.count > output.size()) {
+        return false;
+    }
+    for (std::size_t i = 0; i < rows.count; ++i) {
+        const std::size_t p = rows.dataOffset + i * kSocketStride;
+        auto& socket = output[i];
+        if (!tables::read(blob, p, socket.socketType)
+            || !tables::read(blob, p + kSocketPlugOffset, socket.plugItem)
+            || !tables::read(blob, p + kSocketPlugSetOffset, socket.plugSet)
+            || !tables::read(blob, p + kSocketRollSetOffset, socket.rollSet)
+            || !tables::read(blob, p + kSocketSelectionOffset, socket.selection)) {
+            return false;
+        }
+    }
+    count = static_cast<std::size_t>(rows.count);
+    return true;
+}
+
+bool RewardBuild::load(const reader::Source& source,
+                       reader::Scratch& scratch,
+                       std::span<const std::byte> root,
+                       const SlotMaps& maps) noexcept {
+    loaded_ = false;
+    supplementalMissing_ = false;
+    pools_.clear();
+    entries_.clear();
+    instructions_.clear();
+    modifiers_.clear();
+    sockets_.clear();
+    std::vector<std::byte> blob;
+    tables::Array rows{};
+    std::uint32_t supplementalTag = 0;
+    // Conditions load first; the season pass binds through them even if the pools are refused.
+    if (!conditions.load(source, scratch, root, maps)) {
+        return false;
+    }
+    if (!tables::slot_tag(root, kSupplementalRewardSlot, supplementalTag)) {
+        settle_unsupported("supplemental_slot");
+        return false;
+    }
+    if (!load_reward_table(source, scratch, root, kPoolSlot, blob, kPoolClass, "pool_table")) {
+        return false;
+    }
+    if (!tables::read_array(
+            blob, tables::kTableArrayDescriptor, kPoolRowClass, kPoolStride, rows)) {
+        settle_unsupported("pool_schema");
+        return false;
+    }
+    if (rows.count == 0 || rows.count > domain::kPoolCapacity) {
+        settle_unsupported("pool_count");
+        return false;
+    }
+    supplementalMissing_ = supplementalTag == kAbsentTableTag;
+    std::size_t skipped = 0;
+    for (std::size_t i = 0; i < rows.count; ++i) {
+        const auto at = rows.dataOffset + i * kPoolStride;
+        domain::Pool pool{};
+        tables::Array members{};
+        if (tables::read(blob, at, pool.definitionHash) && pool.definitionHash != 0
+            && tables::read_array(
+                blob, at + kPoolEntriesOffset, kEntryClass, kEntryStride, members)) {
+            pool.entries.first = static_cast<std::uint32_t>(entries_.size());
+            for (std::size_t j = 0; j < members.count; ++j) {
+                const auto beforeInstructions = instructions_.size();
+                const auto beforeModifiers = modifiers_.size();
+                const auto beforeSockets = sockets_.size();
+                auto failure = BankFailure::none;
+                const char* reason = nullptr;
+                if (!entry(blob, members.dataOffset + j * kEntryStride, failure, reason)) {
+                    // Skipping a bank failure would publish a pool missing some of its entries.
+                    if (failure == BankFailure::full) {
+                        settle_unsupported(reason);
+                        return false;
+                    }
+                    if (failure == BankFailure::storage) {
+                        report_refusal(reason);
+                        return false;
+                    }
+                    instructions_.resize(beforeInstructions);
+                    modifiers_.resize(beforeModifiers);
+                    sockets_.resize(beforeSockets);
+                    ++skipped;
+                }
+            }
+            pool.entries.count = static_cast<std::uint32_t>(entries_.size()) - pool.entries.first;
+        } else {
+            pool = {};
+            ++skipped;
+        }
+        if (!append(pools_, pool, domain::kPoolCapacity)) {
+            report_refusal("pool_storage");
+            return false;
+        }
+    }
+    core::log::writef(core::log::Channel::client,
+                      skipped == 0 ? core::log::Level::info : core::log::Level::warn,
+                      "ev=pkg stage=rewards pools=%zu entries=%zu instructions=%zu modifiers=%zu "
+                      "sockets=%zu skipped=%zu",
+                      pools_.size(),
+                      entries_.size(),
+                      instructions_.size(),
+                      modifiers_.size(),
+                      sockets_.size(),
+                      skipped);
+    loaded_ = true;
+    return true;
+}
+
+bool RewardBuild::read_item(std::uint32_t hash,
+                            std::span<const std::byte> blob,
+                            domain::Item& item) noexcept {
+    std::uint16_t acquired = domain::kAbsent;
+    std::int64_t wrapper = 0;
+    std::uint32_t cls = 0;
+    if (!tables::read(blob, kWrapperField, wrapper)
+        || !tables::read(blob, kAcquiredFlagField, acquired)) {
+        return false;
+    }
+    item.definitionHash = hash;
+    if (acquired != domain::kAbsent) {
+        domain::Instruction flag{};
+        if (!conditions.bind(tables::kUnlockReadFlagOpcode, acquired, flag)
+            || !unlocks::valid(flag)) {
+            // The grant is kept; only its acquisition flag is unknown.
+            ++unboundAcquiredFlags_;
+        } else if (flag.bank == unlocks::Bank::account) {
+            item.acquiredFlag = static_cast<std::uint16_t>(flag.operand);
+        } else {
+            ++discardedAcquiredFlags_;
+        }
+    }
+    // A zero delta means the item is not a wrapper.
+    if (wrapper != 0) {
+        std::size_t at = 0;
+        tables::Array selections{};
+        if (!tables::relative(blob, kWrapperField, at) || at < kDefinitionClassPrefixSize
+            || !tables::read(blob, at - kDefinitionClassPrefixSize, cls) || cls != kWrapperClass
+            || !tables::read(blob, at, item.poolIndex)
+            || !tables::read(blob, at + kWrapperFlagsOffset, item.flags)
+            || !tables::read_array(
+                blob, at + kWrapperSelectionsOffset, kSelectionClass, kSelectionStride, selections)
+            || selections.count > item.selections.size()) {
+            return false;
+        }
+        item.selectionCount = static_cast<std::uint8_t>(selections.count);
+        for (std::size_t j = 0; j < selections.count; ++j) {
+            auto& selection = item.selections[j];
+            const auto p = selections.dataOffset + j * kSelectionStride;
+            if (!tables::read(blob, p, selection.categoryHash)
+                || !tables::read(blob, p + kSelectionCountOffset, selection.count)) {
+                return false;
+            }
+        }
+    }
+    return true;
+}
+
+bool RewardBuild::begin_items(std::size_t count) noexcept {
+    unboundAcquiredFlags_ = 0;
+    discardedAcquiredFlags_ = 0;
+    unreadItems_ = 0;
+    // A refused load has already reported itself.
+    if (!loaded_) {
+        return false;
+    }
+    try {
+        if (count == 0 || count > domain::kItemCapacity) {
+            report_refusal("item_storage");
+            return false;
+        }
+        items_.assign(count, {});
+        return true;
+    } catch (...) {
+        report_refusal("item_storage");
+        return false;
+    }
+}
+
+void RewardBuild::item(std::uint16_t index,
+                       std::uint32_t hash,
+                       std::span<const std::byte> blob) noexcept {
+    domain::Item parsed{};
+    if (index < items_.size() && read_item(hash, blob, parsed)) {
+        items_[index] = parsed;
+    }
+}
+
+void RewardBuild::item_unread(std::uint16_t index) noexcept {
+    if (unreadItems_++ == 0) {
+        firstUnreadItem_ = index;
+    }
+}
+
+bool RewardBuild::publish(std::size_t catalogItemCount) noexcept {
+    if (!loaded_) {
+        return false;
+    }
+    // Publishing now would prune every entry that names an unread item.
+    if (unreadItems_ != 0) {
+        core::log::writef(core::log::Channel::client,
+                          core::log::Level::warn,
+                          "ev=pkg stage=rewards result=fail reason=item_read unread_items=%zu "
+                          "first_index=%u",
+                          unreadItems_,
+                          static_cast<unsigned>(firstUnreadItem_));
+        return false;
+    }
+    // A catalog that lost trailing rows is shorter than the table, and the cache pairs each of its
+    // items with one reward row.
+    if (catalogItemCount < items_.size()) {
+        items_.resize(catalogItemCount);
+    }
+    // Cleared first so the entry pass also drops draws of a cleared wrapper.
+    for (auto& item : items_) {
+        if (item.poolIndex != domain::kAbsent && item.poolIndex >= pools_.size()) {
+            item = {};
+        }
+    }
+    std::size_t write = 0;
+    std::size_t socketWrite = 0;
+    for (auto& pool : pools_) {
+        const auto range = pool.entries;
+        pool.entries.first = static_cast<std::uint32_t>(write);
+        for (std::size_t i = range.first; i < range.first + range.count; ++i) {
+            const auto& row = entries_[i];
+            bool valid = (row.itemIndex == domain::kAbsent || item_read(items_, row.itemIndex))
+                         && (row.poolIndex == domain::kAbsent || row.poolIndex < pools_.size());
+            for (const auto& socket :
+                 std::span(sockets_).subspan(row.sockets.first, row.sockets.count)) {
+                valid &=
+                    domain::valid_socket(socket, items_.size())
+                    && (socket.plugItem == domain::kAbsent || item_read(items_, socket.plugItem));
+            }
+            if (valid) {
+                auto retained = row;
+                retained.sockets.first = static_cast<std::uint32_t>(socketWrite);
+                for (const auto& socket :
+                     std::span(sockets_).subspan(row.sockets.first, row.sockets.count)) {
+                    sockets_[socketWrite++] = socket;
+                }
+                entries_[write++] = retained;
+            }
+        }
+        pool.entries.count = static_cast<std::uint32_t>(write) - pool.entries.first;
+    }
+    const std::size_t droppedEntries = entries_.size() - write;
+    entries_.resize(write);
+    sockets_.resize(socketWrite);
+    if (unboundAcquiredFlags_ != 0 || discardedAcquiredFlags_ != 0 || droppedEntries != 0) {
+        core::log::writef(core::log::Channel::client,
+                          core::log::Level::warn,
+                          "ev=pkg stage=rewards result=partial unbound_acquired_flags=%zu "
+                          "discarded_acquired_flags=%zu dropped_entries=%zu",
+                          unboundAcquiredFlags_,
+                          discardedAcquiredFlags_,
+                          droppedEntries);
+    }
+    const domain::View graph{pools_, entries_, items_, instructions_, modifiers_, sockets_};
+    if (!state::build_data::valid_reward_definitions(graph)) {
+        settle_unsupported("graph");
+        return false;
+    }
+    // A cache write can still fail, so this stays retryable.
+    if (!state::build_data::publish_reward_definitions(graph)) {
+        report_refusal("publish");
+        return false;
+    }
+    return true;
+}
+
+} // namespace sunrise::client::content::items::packages

+ 116 - 0
Sunrise/src/client/content/items/packages/package_reward_build.h

@@ -0,0 +1,116 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <span>
+#include <vector>
+
+#include "../../../../middleware/content/packages/reader/reader.h"
+#include "../../../../middleware/content/packages/tables/definition_index_table.h"
+#include "../../../../state/account/account_state.h"
+#include "../../../../state/build_data/rewards/definition.h"
+
+namespace sunrise::client::content::items::packages {
+
+struct SlotMaps;
+
+/** A full reward bank settles the domain; failed storage is retried on a later pass. */
+enum class BankFailure : std::uint8_t { none, full, storage };
+
+/** Resolves expression references and unlock slots once for a package pass. */
+class RewardConditions {
+public:
+    /** Reads the root's flag, value, expression and class tables; false when one is missing. */
+    [[nodiscard]] bool load(const middleware::content::packages::reader::Source& source,
+                            middleware::content::packages::reader::Scratch& scratch,
+                            std::span<const std::byte> root,
+                            const SlotMaps& maps) noexcept;
+    /** @return True when the last load() succeeded. */
+    [[nodiscard]] bool loaded() const noexcept;
+    /** Binds one native instruction; flag and value slots resolve to their saved bank. */
+    [[nodiscard]] bool bind(std::uint32_t native,
+                            std::uint32_t operand,
+                            state::build_data::rewards::Instruction& instruction) const noexcept;
+    /** Appends one expanded expression; the bank is unchanged on failure. */
+    [[nodiscard]] bool read(std::span<const std::byte> blob,
+                            std::size_t at,
+                            std::vector<state::build_data::rewards::Instruction>& bank,
+                            state::build_data::rewards::Range& range,
+                            BankFailure& failure) const noexcept;
+    /** Expands a list of expressions into one program where every expression must hold. */
+    [[nodiscard]] bool read_list(std::span<const std::byte> blob,
+                                 std::size_t at,
+                                 std::span<state::build_data::rewards::Instruction> output,
+                                 std::size_t& count,
+                                 BankFailure& failure) const noexcept;
+
+private:
+    [[nodiscard]] bool append_expression(std::span<const std::byte> blob,
+                                         std::size_t at,
+                                         std::vector<state::build_data::rewards::Instruction>& bank,
+                                         std::size_t depth,
+                                         BankFailure& failure) const noexcept;
+    /** Reads every class binding before any reward condition can be published. */
+    [[nodiscard]] bool load_class_flags(const middleware::content::packages::reader::Source& source,
+                                        middleware::content::packages::reader::Scratch& scratch,
+                                        std::span<const std::byte> root) noexcept;
+    std::array<std::uint16_t, state::kCharacterClassCount> classFlags_{};
+    const SlotMaps* maps_{};
+    bool loaded_{};
+    std::vector<std::byte> flags_;
+    std::vector<std::byte> values_;
+    std::vector<std::byte> expressions_;
+    middleware::content::packages::tables::Array flagRows_{};
+    middleware::content::packages::tables::Array valueRows_{};
+    middleware::content::packages::tables::Array expressionRows_{};
+};
+
+/** Reward metadata collected alongside the existing item-definition walk. */
+class RewardBuild {
+public:
+    RewardConditions conditions;
+    /** Reads every reward pool; unreadable entries are skipped, a full bank refuses the load. */
+    [[nodiscard]] bool load(const middleware::content::packages::reader::Source& source,
+                            middleware::content::packages::reader::Scratch& scratch,
+                            std::span<const std::byte> root,
+                            const SlotMaps& maps) noexcept;
+    /** Sizes the dense item rows before the item walk. */
+    [[nodiscard]] bool begin_items(std::size_t count) noexcept;
+    /** Records one item's wrapper and acquisition flag; an unreadable item stays unavailable. */
+    void item(std::uint16_t index, std::uint32_t hash, std::span<const std::byte> blob) noexcept;
+    /** Notes an item whose definition failed to read; publication waits for a clean walk. */
+    void item_unread(std::uint16_t index) noexcept;
+    /** Trims item rows to the catalog, drops rows naming unread items or pools, then publishes. */
+    [[nodiscard]] bool publish(std::size_t catalogItemCount) noexcept;
+
+private:
+    /** Appends one entry; a bank failure also names the bank and what refused it. */
+    [[nodiscard]] bool entry(std::span<const std::byte> blob,
+                             std::size_t at,
+                             BankFailure& failure,
+                             const char*& reason) noexcept;
+    [[nodiscard]] bool read_item(std::uint32_t hash,
+                                 std::span<const std::byte> blob,
+                                 state::build_data::rewards::Item& item) noexcept;
+    bool loaded_{};
+    bool supplementalMissing_{};
+    std::size_t unboundAcquiredFlags_{};
+    std::size_t discardedAcquiredFlags_{};
+    std::size_t unreadItems_{};
+    std::uint16_t firstUnreadItem_{};
+    std::vector<state::build_data::rewards::Pool> pools_;
+    std::vector<state::build_data::rewards::Entry> entries_;
+    std::vector<state::build_data::rewards::Item> items_;
+    std::vector<state::build_data::rewards::Instruction> instructions_;
+    std::vector<state::build_data::rewards::Modifier> modifiers_;
+    std::vector<state::build_data::rewards::SocketOverride> sockets_;
+};
+
+/** Progression rewards and pool entries use the same socket-override layout. */
+[[nodiscard]] bool read_reward_sockets(std::span<const std::byte> blob,
+                                       std::size_t at,
+                                       std::span<state::build_data::rewards::SocketOverride> output,
+                                       std::size_t& count) noexcept;
+
+} // namespace sunrise::client::content::items::packages

+ 96 - 102
Sunrise/src/client/content/items/packages/package_season_pass_build.cpp

@@ -1,73 +1,94 @@
 #include <algorithm>
-#include <cstring>
 #include <limits>
 
+#include "../../../../core/logging/log.h"
+#include "../../../../middleware/content/packages/tables/field_reader.h"
+#include "../../../../state/progression/season_pass_reward_catalog.h"
 #include "internal.h"
+#include "package_reward_build.h"
 
 namespace sunrise::client::content::items::packages {
 namespace {
 
 namespace domain = state::build_data::season_pass;
 
-/** Account progression the installed Season of Arrivals pass declares its rewards on. */
-constexpr std::uint16_t kPassProgressionIndex = 40;
-
-/** @param blob Source bytes. @param offset Field offset. @param value Receives the field. */
-template <typename Value>
-[[nodiscard]] bool
-read(std::span<const std::byte> blob, std::size_t offset, Value& value) noexcept {
-    if (offset > blob.size() || blob.size() - offset < sizeof value) {
-        return false;
-    }
-    std::memcpy(&value, blob.data() + offset, sizeof value);
-    return true;
-}
-
 /**
- * Reads the item set one reward's wrapper item opens into.
- * @param definition Whole item definition blob.
- * @param itemTable Item index table blob.
- * @param itemRows Located item index array.
- * @param package Receives the wrapper's items, or stays empty when the item opens into nothing.
- * @return True when the item declares no set, or declares one that reads back whole.
+ * Reads one pass row: item, rank, quantity and claim flag, then its sockets and conditions.
+ * @return False when any field is unreadable or out of range.
  */
-[[nodiscard]] bool read_package(std::span<const std::byte> definition,
-                                std::span<const std::byte> itemTable,
-                                const tables::Array& itemRows,
-                                domain::Package& package) noexcept {
-    tables::Array set{};
-    if (!tables::find_optional_array_at(definition, tables::kGearsetItemField, set)) {
+bool read_season_reward(Storage& storage,
+                        std::span<const std::byte> progressionTable,
+                        const tables::Array& itemRows,
+                        std::size_t at,
+                        domain::Reward& reward,
+                        BankFailure& failure) noexcept {
+    std::uint32_t rank = 0;
+    std::uint32_t itemIndex = 0;
+    std::uint32_t claimSlot = 0;
+    if (!tables::read(progressionTable, at + tables::kProgressionRewardRankOffset, rank)
+        || !tables::read(
+            progressionTable, at + tables::kProgressionRewardItemIndexOffset, itemIndex)
+        || !tables::read(
+            progressionTable, at + tables::kProgressionRewardQuantityOffset, reward.quantity)
+        || !tables::read(
+            progressionTable, at + tables::kProgressionRewardClaimSlotOffset, claimSlot)) {
         return false;
     }
-    if (set.count == 0) {
-        return true;
-    }
-    if (set.elementClass != tables::kGearsetItemRowClass || set.count > domain::kPackageItemCapacity
-        || set.dataOffset + static_cast<std::size_t>(set.count) * tables::kGearsetItemStride
-               > definition.size()) {
+
+    // Native fields are 32-bit, the stored row keeps the narrower native index widths.
+    tables::IndexRow entry{};
+    if (rank > (std::numeric_limits<std::uint8_t>::max)()
+        || itemIndex > (std::numeric_limits<std::uint16_t>::max)()
+        || claimSlot > (std::numeric_limits<std::uint16_t>::max)()
+        || !tables::index_row(storage.itemIndexTable, itemRows, itemIndex, entry)) {
         return false;
     }
-    for (std::uint64_t member = 0; member < set.count; ++member) {
-        std::uint16_t itemIndex = 0;
-        tables::IndexRow entry{};
-        if (!read(definition,
-                  set.dataOffset + static_cast<std::size_t>(member) * tables::kGearsetItemStride,
-                  itemIndex)
-            || !tables::index_row(itemTable, itemRows, itemIndex, entry)) {
+    reward.itemHash = entry.definitionHash;
+    reward.itemIndex = static_cast<std::uint16_t>(itemIndex);
+    reward.requiredRank = static_cast<std::uint8_t>(rank);
+
+    // A reward with no claim flag carries slot 0, any other slot must map to the account bank.
+    if (claimSlot != 0) {
+        reward.claimFlagIndex =
+            bank_index(storage.slotMaps.accountFlag, static_cast<std::int32_t>(claimSlot));
+        if (reward.claimFlagIndex == domain::kUnavailableFlagIndex) {
             return false;
         }
-        package.items[package.itemCount++] = entry.definitionHash;
     }
-    return true;
+
+    std::size_t socketCount = 0;
+    std::size_t conditionCount = 0;
+    if (!read_reward_sockets(progressionTable,
+                             at + tables::kProgressionRewardSocketsOffset,
+                             reward.sockets,
+                             socketCount)
+        || !storage.rewardBuild.conditions.read_list(
+            progressionTable,
+            at + tables::kProgressionRewardConditionsOffset,
+            reward.condition,
+            conditionCount,
+            failure)) {
+        return false;
+    }
+    reward.socketCount = static_cast<std::uint8_t>(socketCount);
+    reward.conditionCount = static_cast<std::uint8_t>(conditionCount);
+    // Checked per row so one refused row stays unavailable.
+    return state::build_data::valid_season_pass_rewards(std::span(&reward, 1))
+           && std::all_of(reward.sockets.begin(),
+                          reward.sockets.begin() + reward.socketCount,
+                          [&itemRows](const state::build_data::rewards::SocketOverride& socket) {
+                              return state::build_data::rewards::valid_socket(socket,
+                                                                              itemRows.count);
+                          });
 }
 
 } // namespace
 
 /**
- * Reads the season pass reward list and the wrapper items it grants.
- * A reward names an item index and a claim flag slot; both become the values a grant needs.
+ * Reads the season pass reward list in native order, which the opcode-2400 claim indexes.
+ * A row that fails to read is kept as an unavailable row so later claim indices stay aligned.
  * @param source Installed package source.
- * @param storage Pass storage receiving the reward rows and the wrapper packages.
+ * @param storage Pass storage receiving the reward rows.
  * @param root Investment root bytes.
  * @return True when the reward list read and produced at least one row.
  */
@@ -75,7 +96,6 @@ bool build_season_pass(const reader::Source& source,
                        Storage& storage,
                        std::span<const std::byte> root) noexcept {
     storage.seasonPassRewardCount = 0;
-    storage.seasonPassPackageCount = 0;
 
     std::uint32_t itemTableTag = 0;
     tables::Array itemRows{};
@@ -87,7 +107,6 @@ bool build_season_pass(const reader::Source& source,
         || itemRows.elementClass != tables::kItemIndexTableClass) {
         return false;
     }
-    const std::span<const std::byte> itemTable{storage.itemIndexTable};
 
     std::uint32_t tableTag = 0;
     tables::Array progressions{};
@@ -97,74 +116,49 @@ bool build_season_pass(const reader::Source& source,
                                   tables::kTableArrayDescriptor,
                                   progressions)
         || progressions.elementClass != tables::kProgressionTableClass
-        || progressions.count <= kPassProgressionIndex) {
+        || progressions.count <= state::progression::season_pass::kProgressionDefinitionIndex) {
         return false;
     }
     const std::span<const std::byte> progressionTable{storage.progressionTable};
-    const std::size_t passAt =
-        progressions.dataOffset + kPassProgressionIndex * tables::kProgressionRowStride;
+    const std::size_t passAt = progressions.dataOffset
+                               + state::progression::season_pass::kProgressionDefinitionIndex
+                                     * tables::kProgressionRowStride;
     tables::Array rewards{};
-    if (!tables::find_optional_array_at(
-            progressionTable, passAt + tables::kProgressionRewardField, rewards)
-        || rewards.count == 0 || rewards.elementClass != tables::kProgressionRewardRowClass
-        || rewards.count > domain::kRewardCapacity
-        || rewards.dataOffset
-                   + static_cast<std::size_t>(rewards.count) * tables::kProgressionRewardStride
-               > progressionTable.size()) {
+    if (!tables::read_array(progressionTable,
+                            passAt + tables::kProgressionRewardField,
+                            tables::kProgressionRewardRowClass,
+                            tables::kProgressionRewardStride,
+                            rewards)
+        || rewards.count == 0 || rewards.count > domain::kRewardCapacity) {
         return false;
     }
 
+    std::size_t skipped = 0;
     for (std::uint64_t row = 0; row < rewards.count; ++row) {
         const std::size_t at =
             rewards.dataOffset + static_cast<std::size_t>(row) * tables::kProgressionRewardStride;
-        std::uint32_t rank = 0;
-        std::uint32_t itemIndex = 0;
-        std::uint32_t claimSlot = 0;
-        domain::Reward& reward = storage.seasonPassRewards[storage.seasonPassRewardCount];
+        auto& reward = storage.seasonPassRewards[row];
         reward = {};
-        tables::IndexRow entry{};
-        if (!read(progressionTable, at + tables::kProgressionRewardRankOffset, rank)
-            || !read(progressionTable, at + tables::kProgressionRewardItemIndexOffset, itemIndex)
-            || !read(
-                progressionTable, at + tables::kProgressionRewardQuantityOffset, reward.quantity)
-            || !read(progressionTable, at + tables::kProgressionRewardClaimSlotOffset, claimSlot)
-            || rank > (std::numeric_limits<std::uint8_t>::max)()
-            || itemIndex > (std::numeric_limits<std::uint16_t>::max)()
-            || claimSlot > (std::numeric_limits<std::uint16_t>::max)()
-            || !tables::index_row(itemTable, itemRows, itemIndex, entry)) {
-            storage.seasonPassRewardCount = 0;
-            return false;
+        auto failure = BankFailure::none;
+        if (!read_season_reward(storage, progressionTable, itemRows, at, reward, failure)) {
+            // A row is left unavailable only for its data, never for failed storage.
+            if (failure == BankFailure::storage) {
+                core::log::write(core::log::Channel::client,
+                                 core::log::Level::warn,
+                                 "ev=pkg stage=season_pass result=fail reason=condition_storage");
+                return false;
+            }
+            reward = {};
+            ++skipped;
         }
-        reward.itemHash = entry.definitionHash;
-        reward.itemIndex = static_cast<std::uint16_t>(itemIndex);
-        reward.requiredRank = static_cast<std::uint8_t>(rank);
-        // A reward with no claim flag carries slot 0.
-        if (claimSlot != 0) {
-            reward.claimFlagIndex =
-                bank_index(storage.slotMaps.accountFlag, static_cast<std::int32_t>(claimSlot));
-        }
-
-        // A wrapper reward opens into a set; a plain reward declares none and keeps zero items.
-        // One wrapper can be granted at several ranks, so it is recorded once.
-        const std::span<const domain::Package> held =
-            std::span(storage.seasonPassPackages).first(storage.seasonPassPackageCount);
-        domain::Package package{};
-        package.definitionHash = entry.definitionHash;
-        if (storage.seasonPassPackageCount < domain::kPackageCapacity
-            && std::none_of(
-                held.begin(),
-                held.end(),
-                [&entry](const domain::Package& row) {
-                    return row.definitionHash == entry.definitionHash;
-                })
-            && reader::read_tag(source, storage.scratch, entry.targetTag, storage.definition)
-            && read_package(
-                std::span<const std::byte>{storage.definition}, itemTable, itemRows, package)
-            && package.itemCount != 0) {
-            storage.seasonPassPackages[storage.seasonPassPackageCount++] = package;
-        }
-        ++storage.seasonPassRewardCount;
     }
+    if (skipped != 0) {
+        core::log::writef(core::log::Channel::client,
+                          core::log::Level::warn,
+                          "ev=pkg stage=season_pass skipped=%zu",
+                          skipped);
+    }
+    storage.seasonPassRewardCount = static_cast<std::size_t>(rewards.count);
     return storage.seasonPassRewardCount != 0;
 }
 

+ 135 - 0
Sunrise/src/client/content/items/packages/package_starter_set_build.cpp

@@ -0,0 +1,135 @@
+#include <array>
+#include <cstddef>
+#include <cstring>
+
+#include "../../../../core/logging/log.h"
+#include "internal.h"
+
+namespace sunrise::client::content::items::packages {
+namespace {
+
+namespace domain = state::build_data::starter_sets;
+
+/** Package class of the new-character table blob. */
+constexpr std::uint32_t kStarterSetTableClass = 0x808075BAU;
+/** Element class of the table's per-class rows. */
+constexpr std::uint32_t kStarterSetRowClass = 0x808074E7U;
+/** Element class of an array of 16-bit item-table indices. */
+constexpr std::uint32_t kItemIndexArrayClass = 0x808087DBU;
+
+/** An array field is a 64-bit count and a 64-bit self-relative offset. */
+constexpr std::size_t kArrayFieldSize = 16;
+/** The installed class rows are 40 bytes apart. */
+constexpr std::size_t kStarterSetRowSize = 40;
+
+/** One class row: the class byte, then two arrays of item-table indices. */
+struct StarterSetRow {
+    std::uint8_t characterClass;
+    std::array<std::byte, 7> opaque01;
+    std::array<std::byte, kArrayFieldSize> firstItems;
+    std::array<std::byte, kArrayFieldSize> secondItems;
+};
+static_assert(sizeof(StarterSetRow) == kStarterSetRowSize);
+
+/**
+ * Appends one item-index array of a class row to that row's definition.
+ * @param blob Whole table blob.
+ * @param descriptorOffset Offset of the array's count and self-relative offset.
+ * @param output Row whose item list grows.
+ * @return False when the array does not read or overflows the row's storage.
+ */
+[[nodiscard]] bool append_items(std::span<const std::byte> blob,
+                                std::size_t descriptorOffset,
+                                domain::Definition& output) noexcept {
+    tables::Array items{};
+    if (!tables::find_optional_array_at(blob, descriptorOffset, items)) {
+        return false;
+    }
+    if (items.count == 0) {
+        return true;
+    }
+    const std::size_t size = static_cast<std::size_t>(items.count) * sizeof(std::uint16_t);
+    if (items.elementClass != kItemIndexArrayClass
+        || items.count > domain::kItemCapacity - output.itemCount || items.dataOffset > blob.size()
+        || size > blob.size() - items.dataOffset) {
+        return false;
+    }
+    for (std::size_t index = 0; index < items.count; ++index) {
+        std::uint16_t itemIndex = 0;
+        std::memcpy(&itemIndex,
+                    blob.data() + items.dataOffset + index * sizeof(std::uint16_t),
+                    sizeof itemIndex);
+        output.itemIndices[output.itemCount++] = itemIndex;
+    }
+    return true;
+}
+
+/**
+ * Reads every class row of the new-character table.
+ * @param blob Whole table blob.
+ * @param output Receives one definition per row.
+ * @param count Receives the row count.
+ * @return False when the rows do not match the installed layout.
+ */
+[[nodiscard]] bool read_rows(std::span<const std::byte> blob,
+                             std::array<domain::Definition, domain::kClassCount>& output,
+                             std::size_t& count) noexcept {
+    count = 0;
+    tables::Array rows{};
+    if (!tables::find_array(blob, kStarterSetRowClass, rows) || rows.count > output.size()
+        || rows.dataOffset > blob.size()
+        || static_cast<std::size_t>(rows.count) * sizeof(StarterSetRow)
+               > blob.size() - rows.dataOffset) {
+        return false;
+    }
+    for (std::size_t row = 0; row < rows.count; ++row) {
+        const std::size_t base = rows.dataOffset + row * sizeof(StarterSetRow);
+        domain::Definition& definition = output[row];
+        definition = {};
+        definition.characterClass =
+            std::to_integer<std::uint8_t>(blob[base + offsetof(StarterSetRow, characterClass)]);
+        if (!append_items(blob, base + offsetof(StarterSetRow, firstItems), definition)
+            || !append_items(blob, base + offsetof(StarterSetRow, secondItems), definition)) {
+            return false;
+        }
+    }
+    count = static_cast<std::size_t>(rows.count);
+    return true;
+}
+
+} // namespace
+
+/**
+ * Reads and publishes the new-character table: per class, the items a new character gets.
+ * A table that reads but does not match the layout publishes empty, so the pass settles.
+ * @param source Package source.
+ * @param scratch Reader scratch.
+ * @param root Investment root bytes.
+ * @param blob Scratch storage for the table blob.
+ * @return False only when the tag does not read, so a later slice retries it.
+ */
+bool build_starter_sets(const reader::Source& source,
+                        reader::Scratch& scratch,
+                        std::span<const std::byte> root,
+                        std::vector<std::byte>& blob) noexcept {
+    std::uint32_t tag = 0;
+    std::uint32_t tableClass = 0;
+    if (!tables::slot_tag(root, tables::kStarterSetTableSlot, tag) || tag == 0
+        || !reader::read_tag(source, scratch, tag, blob, tableClass)) {
+        return false;
+    }
+    std::array<domain::Definition, domain::kClassCount> rows{};
+    std::size_t count = 0;
+    if (tableClass != kStarterSetTableClass
+        || !read_rows(std::span<const std::byte>{blob}, rows, count)) {
+        count = 0;
+        core::log::writef(core::log::Channel::client,
+                          core::log::Level::warn,
+                          "ev=starter_sets stage=read result=fail tag=0x%08X class=0x%08X",
+                          tag,
+                          tableClass);
+    }
+    return state::build_data::publish_starter_sets(std::span(rows).first(count));
+}
+
+} // namespace sunrise::client::content::items::packages

+ 1 - 1
Sunrise/src/core/logging/log.cpp

@@ -22,7 +22,7 @@ namespace {
 constexpr std::array<std::string_view, static_cast<std::size_t>(Channel::count)> kChannelNames{
     "core", "client", "state", "server", "middleware"};
 /** Stable serialized severity names; Level::off is never emitted. */
-constexpr std::array<std::string_view, 4> kLevelNames{"error", "warn", "info", "debug"};
+constexpr std::array<std::string_view, 5> kLevelNames{"error", "warn", "info", "debug", "trace"};
 /** Optional logs are isolated below the shared generated-artifact directory. */
 constexpr std::wstring_view kLogDirectorySuffix = L"\\logs";
 /** The active log keeps one stable filename across process starts. */

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

@@ -26,6 +26,8 @@ enum class Level : unsigned char {
     warn,
     info,
     debug,
+    /** Per-row dumps, such as every roster Auth on every push. Off at the default threshold. */
+    trace,
     off,
 };
 

+ 10 - 0
Sunrise/src/core/settings/client/client_ui_settings_parser.cpp

@@ -46,6 +46,16 @@ bool Parser::ui_toggle_key_value(std::string_view name, UINT& output) noexcept {
     return false;
 }
 
+/** Names one Windows SDK virtual key with the setting that selects it. */
+std::string_view Parser::ui_toggle_key_name(UINT virtualKey) noexcept {
+    for (const KeyName& entry : kToggleKeys) {
+        if (entry.virtualKey == virtualKey) {
+            return entry.name;
+        }
+    }
+    return {};
+}
+
 /** Parses the in-game UI boot and input policy. */
 bool Parser::client_ui_settings(ui::runtime::Settings& output) noexcept {
     if (!consume('{')) {

+ 3 - 0
Sunrise/src/core/settings/parser.h

@@ -28,6 +28,9 @@ public:
     /** Reads only the root version; a missing version is zero. */
     [[nodiscard]] bool parse_version(std::uint32_t& output) noexcept;
 
+    /** @return The settings name of a supported menu toggle key, or empty when it is not one. */
+    [[nodiscard]] static std::string_view ui_toggle_key_name(UINT virtualKey) noexcept;
+
 private:
     /** Parses the Core settings object. */
     [[nodiscard]] bool core(Settings& output) noexcept;

+ 2 - 0
Sunrise/src/core/settings/settings_parser.cpp

@@ -270,6 +270,8 @@ bool Parser::level_value(std::string_view name, log::Level& output) noexcept {
         output = log::Level::info;
     } else if (name == "debug") {
         output = log::Level::debug;
+    } else if (name == "trace") {
+        output = log::Level::trace;
     } else if (name == "off") {
         output = log::Level::off;
     } else {

+ 2 - 0
Sunrise/src/core/ui/hud/overlay.h

@@ -8,6 +8,8 @@ namespace sunrise::core::ui::hud {
 enum class Overlay : std::uint8_t {
     /** The Sunrise name, version and animated logo. */
     logoCard,
+    /** How to open the menu, shown once beside the card shortly after start. */
+    startupHint,
     /** Where the player is: activity, bubble, slice set and closest spawn. */
     currentStatus,
     /** The instances of the session the player is in. */

+ 82 - 0
Sunrise/src/core/ui/hud/overlays/ui_hud_startup_hint_overlay.cpp

@@ -0,0 +1,82 @@
+#include "ui_hud_startup_hint_overlay.h"
+
+#include <algorithm>
+#include <array>
+#include <cctype>
+#include <cstddef>
+#include <imgui.h>
+#include <string_view>
+
+#include "../../../settings/parser.h"
+#include "../../runtime/ui_visibility_runtime.h"
+
+namespace sunrise::core::ui::hud::overlays::startup_hint {
+namespace {
+
+/** 2 seconds let the game settle before the hint asks for attention. */
+constexpr float kDelaySeconds = 2.0F;
+/** 30 seconds is long enough to notice the hint and short enough to clear itself. */
+constexpr float kVisibleSeconds = 30.0F;
+/** Half a second to fade in or out, slower than the menus so the hint stays subtle. */
+constexpr float kFadeSeconds = 0.5F;
+/** A hidden hint has finished fading out and draws nothing. */
+constexpr float kHiddenOpacity = 0.0F;
+/** A shown hint has finished fading in. */
+constexpr float kShownOpacity = 1.0F;
+/** Longest supported toggle key name, plus the null. */
+constexpr std::size_t kKeyNameCapacity = 16;
+
+/** Seconds since the first HUD frame, counted until the hint is dismissed. */
+float g_elapsedSeconds = 0.0F;
+/** Set once the surface opened or the stay ran out. The hint never returns in this run. */
+bool g_dismissed = false;
+/** The hint's own fade, so it needs no shared animation key. */
+float g_opacity = kHiddenOpacity;
+
+/**
+ * Names the configured toggle key in upper case, so it stands out from the muted text around it.
+ * The key always comes from the parser's table, because the settings reject any other one.
+ * @return Null-terminated key name.
+ */
+[[nodiscard]] std::array<char, kKeyNameCapacity> key_label() noexcept {
+    std::array<char, kKeyNameCapacity> label{};
+    const std::string_view name =
+        settings::parser::Parser::ui_toggle_key_name(runtime::snapshot().toggleVirtualKey);
+    const std::size_t length = (std::min)(name.size(), label.size() - 1);
+    for (std::size_t index = 0; index < length; ++index) {
+        label[index] = static_cast<char>(std::toupper(static_cast<unsigned char>(name[index])));
+    }
+    return label;
+}
+
+} // namespace
+
+/** Advances the hint's one-time lifetime by this frame. */
+float progress() noexcept {
+    const float deltaSeconds = ImGui::GetIO().DeltaTime;
+    if (!g_dismissed) {
+        g_elapsedSeconds += deltaSeconds;
+        // An open surface has taught the key, and a hint that came back later would nag.
+        if (runtime::snapshot().visible || g_elapsedSeconds >= kDelaySeconds + kVisibleSeconds) {
+            g_dismissed = true;
+        }
+    }
+    const bool shown = !g_dismissed && g_elapsedSeconds >= kDelaySeconds;
+    const float step = deltaSeconds / kFadeSeconds;
+    g_opacity = shown ? (std::min)(g_opacity + step, kShownOpacity)
+                      : (std::max)(g_opacity - step, kHiddenOpacity);
+    return g_opacity;
+}
+
+/** Draws the startup hint inside the overlay window the stack has already started. */
+void draw() noexcept {
+    const std::array<char, kKeyNameCapacity> key = key_label();
+    // The sentence is muted like the card's version line; the key is white like its wordmark.
+    ImGui::TextDisabled("Press");
+    ImGui::SameLine();
+    ImGui::TextUnformatted(key.data());
+    ImGui::SameLine();
+    ImGui::TextDisabled("to open the Sunrise overlay");
+}
+
+} // namespace sunrise::core::ui::hud::overlays::startup_hint

+ 15 - 0
Sunrise/src/core/ui/hud/overlays/ui_hud_startup_hint_overlay.h

@@ -0,0 +1,15 @@
+#pragma once
+
+namespace sunrise::core::ui::hud::overlays::startup_hint {
+
+/**
+ * Advances the hint's one-time lifetime by this frame: a short wait after the first HUD frame, a
+ * bounded stay, and an early end once the main surface opens. It never comes back in the same run.
+ * @return Current fade, from 0 (nothing to draw) to 1 (fully shown).
+ */
+[[nodiscard]] float progress() noexcept;
+
+/** Draws the startup hint inside the overlay window the stack has already started. */
+void draw() noexcept;
+
+} // namespace sunrise::core::ui::hud::overlays::startup_hint

+ 31 - 6
Sunrise/src/core/ui/hud/ui_hud_overlay_stack.cpp

@@ -13,6 +13,7 @@
 #include "overlays/ui_hud_mission_script_overlay.h"
 #include "overlays/ui_hud_sensor_events_overlay.h"
 #include "overlays/ui_hud_session_overlay.h"
+#include "overlays/ui_hud_startup_hint_overlay.h"
 #include "overlays/ui_hud_status_overlay.h"
 #include "store/hud_settings_store.h"
 
@@ -48,6 +49,11 @@ constexpr std::size_t kSwitchCount = kOverlayCount + kStatusLineCount;
 /** Every overlay, in Overlay order. The menu lists them and the corner stacks them in it. */
 constexpr std::array<Entry, kOverlayCount> kOverlays{
     Entry{"Sunrise Card", "sunrise_card", "##sunrise_hud_card", &overlays::logo::draw, true},
+    Entry{"Startup Hint",
+          "startup_hint",
+          "##sunrise_hud_startup_hint",
+          &overlays::startup_hint::draw,
+          true},
     // Diagnostic overlays start off because an ordinary run does not need them on screen.
     Entry{
         "Current Status", "current_status", "##sunrise_hud_status", &overlays::status::draw, false},
@@ -136,18 +142,18 @@ void save_switches() noexcept {
  * Draws one overlay at a fixed position.
  * @param entry Overlay to draw.
  * @param position Top-left corner, in final framebuffer pixels.
- * @return Height the window took, which is known only after its content is submitted.
+ * @return Size the window took, which is known only after its content is submitted.
  */
-[[nodiscard]] float draw_overlay(const Entry& entry, const ImVec2& position) noexcept {
+[[nodiscard]] ImVec2 draw_overlay(const Entry& entry, const ImVec2& position) noexcept {
     ImGui::SetNextWindowPos(position, ImGuiCond_Always);
     const bool submitContents = ImGui::Begin(entry.windowId, nullptr, kOverlayFlags);
     if (submitContents) {
         entry.draw();
     }
     // Read inside the window, because the size belongs to it and not to the caller's window.
-    const float height = ImGui::GetWindowSize().y;
+    const ImVec2 size = ImGui::GetWindowSize();
     ImGui::End();
-    return height;
+    return size;
 }
 
 } // namespace
@@ -217,14 +223,33 @@ bool draw(bool interfaceEnabled) noexcept {
     const float margin = scaling::dpi::pixels(kViewportMargin);
     const float gap = scaling::dpi::pixels(kOverlayGap);
     ImVec2 position{viewport->WorkPos.x + margin, viewport->WorkPos.y + margin};
+    // The startup hint sits beside whichever overlay owns the top row, or on the corner when
+    // none does. It is drawn after the stack, once that row's width is known, and never moves
+    // the overlays stacked below.
+    ImVec2 hintPosition = position;
     bool drawn = false;
     for (std::size_t index = 0; index < kOverlayCount; ++index) {
-        if (!g_enabled[index]) {
+        if (!g_enabled[index] || static_cast<Overlay>(index) == Overlay::startupHint) {
             continue;
         }
-        position.y += draw_overlay(kOverlays[index], position) + gap;
+        const ImVec2 size = draw_overlay(kOverlays[index], position);
+        if (!drawn) {
+            hintPosition.x += size.x + gap;
+        }
+        position.y += size.y + gap;
         drawn = true;
     }
+    constexpr auto hintIndex = static_cast<std::size_t>(Overlay::startupHint);
+    if (g_enabled[hintIndex]) {
+        const float progress = overlays::startup_hint::progress();
+        if (progress > 0.0F) {
+            // One style alpha fades the hint and everything drawn inside it together.
+            ImGui::PushStyleVar(ImGuiStyleVar_Alpha, progress);
+            (void)draw_overlay(kOverlays[hintIndex], hintPosition);
+            ImGui::PopStyleVar();
+            drawn = true;
+        }
+    }
     return drawn;
 }
 

+ 45 - 3
Sunrise/src/core/ui/layout/ui_layout_render.cpp

@@ -9,6 +9,7 @@
 #include "../components/logo/ui_logo_component.h"
 #include "../components/section/ui_section_component.h"
 #include "../modules/registry/ui_module_registry.h"
+#include "../runtime/ui_visibility_runtime.h"
 #include "../scaling/dpi/ui_dpi_scaling.h"
 #include "navigation/ui_layout_navigation.h"
 #include "ui_layout_lifecycle.h"
@@ -58,6 +59,12 @@ constexpr float kTitleTextRatio = 1.5F;
 constexpr float kHalfExtent = 2.0F;
 /** The surface names the tool with the same wordmark the HUD card carries. */
 constexpr char kTitle[] = "SUNRISE";
+/** The hidden close action label is scoped by the main window. */
+constexpr char kCloseWidgetId[] = "##close";
+/** The close cross spans this fraction of its button, so it reads as a glyph and not a frame. */
+constexpr float kCloseCrossRatio = 0.5F;
+/** One pixel keeps the close cross as thin as the body text. */
+constexpr float kCloseCrossThickness = 1.0F;
 
 /**
  * Copies one display name into null-terminated component storage.
@@ -119,8 +126,15 @@ void draw_companion_windows() noexcept {
     }
 }
 
-/** Draws the animated logo, then the name and version, on one title row. */
-void draw_title() noexcept {
+/**
+ * Draws the animated logo, then the name and version, on one title row, with a close action
+ * at its far end.
+ * @return True when the close action was pressed.
+ */
+[[nodiscard]] bool draw_title() noexcept {
+    // The row edges are taken before it opens, so the close action can sit at its far end.
+    const ImVec2 rowOrigin = ImGui::GetCursorScreenPos();
+    const float rowRight = rowOrigin.x + ImGui::GetContentRegionAvail().x;
     const float extent = scaling::dpi::pixels(kTitleLogoExtent);
     const bool logoDrawn = components::logo::draw(extent);
     if (logoDrawn) {
@@ -144,6 +158,31 @@ void draw_title() noexcept {
     ImGui::SetCursorPosY(
         titleY + ((std::max)(titleHeight - ImGui::GetTextLineHeight(), 0.0F) / kHalfExtent));
     ImGui::TextDisabled(SUNRISE_VER_STRING);
+
+    // A cross at the right edge of the title row, centered on it. It stays on the row the logo
+    // opened, like the version, so the row and the separator below keep their places.
+    ImGui::SameLine();
+    const float buttonExtent = ImGui::GetFontSize();
+    const float rowHeight = logoDrawn ? extent : titleHeight;
+    const ImVec2 buttonPosition{
+        rowRight - buttonExtent,
+        rowOrigin.y + ((std::max)(rowHeight - buttonExtent, 0.0F) / kHalfExtent)};
+    ImGui::SetCursorScreenPos(buttonPosition);
+    const bool pressed = ImGui::InvisibleButton(kCloseWidgetId, {buttonExtent, buttonExtent});
+
+    // Muted like the version until hovered, then white like the title.
+    const ImU32 color =
+        ImGui::GetColorU32(ImGui::IsItemHovered() ? ImGuiCol_Text : ImGuiCol_TextDisabled);
+    const float half = buttonExtent * kCloseCrossRatio / kHalfExtent;
+    const ImVec2 center{buttonPosition.x + (buttonExtent / kHalfExtent),
+                        buttonPosition.y + (buttonExtent / kHalfExtent)};
+    ImDrawList* drawList = ImGui::GetWindowDrawList();
+    const float thickness = scaling::dpi::pixels(kCloseCrossThickness);
+    drawList->AddLine(
+        {center.x - half, center.y - half}, {center.x + half, center.y + half}, color, thickness);
+    drawList->AddLine(
+        {center.x + half, center.y - half}, {center.x - half, center.y + half}, color, thickness);
+    return pressed;
 }
 
 } // namespace
@@ -186,7 +225,10 @@ bool render(bool visible) noexcept {
     ImGui::PushStyleVar(ImGuiStyleVar_Alpha, progress);
     const bool submitContents = ImGui::Begin("Sunrise", nullptr, kMainWindowFlags);
     if (submitContents) {
-        draw_title();
+        // The close action only ever hides, so a surface already closing stays closed.
+        if (draw_title()) {
+            runtime::hide();
+        }
         ImGui::Separator();
 
         const StateSnapshot state = snapshot();

+ 2 - 1
Sunrise/src/core/ui/modules/logs/filters/logs_filter_controls.cpp

@@ -41,12 +41,13 @@ constexpr std::array<ChannelChoice, 6> kChannelChoices{{
     {"Middleware", true, log::Channel::middleware},
 }};
 /** Ordered choices map the level combo index to one exact emitted severity. */
-constexpr std::array<LevelChoice, 5> kLevelChoices{{
+constexpr std::array<LevelChoice, 6> kLevelChoices{{
     {"All levels", false, log::Level::error},
     {"Error", true, log::Level::error},
     {"Warn", true, log::Level::warn},
     {"Info", true, log::Level::info},
     {"Debug", true, log::Level::debug},
+    {"Trace", true, log::Level::trace},
 }};
 
 struct SelectionState {

+ 7 - 0
Sunrise/src/core/ui/runtime/ui_visibility_runtime.cpp

@@ -63,4 +63,11 @@ bool toggle_for_key(UINT virtualKey) noexcept {
     return handled;
 }
 
+/** Closes the menu. A menu that is already closed or closing stays that way. */
+void hide() noexcept {
+    AcquireSRWLockExclusive(&g_visibilityLock);
+    g_state.visible = false;
+    ReleaseSRWLockExclusive(&g_visibilityLock);
+}
+
 } // namespace sunrise::core::ui::runtime

+ 3 - 0
Sunrise/src/core/ui/runtime/ui_visibility_runtime.h

@@ -35,4 +35,7 @@ void shutdown() noexcept;
  */
 [[nodiscard]] bool toggle_for_key(UINT virtualKey) noexcept;
 
+/** Closes the menu. A menu that is already closed or closing stays that way. */
+void hide() noexcept;
+
 } // namespace sunrise::core::ui::runtime

+ 43 - 0
Sunrise/src/middleware/bap/activity_message/activity_scriptable_auth_fixed_body_codec.cpp

@@ -5,6 +5,7 @@
 #include <limits>
 
 #include "scriptable_auth_internal.h"
+#include "volume_toggle_auth.h"
 
 // The fixed-width scriptable-auth bodies that drive device state: authored objects, device
 // channels, configured-action pulses, triggers and timers. Each family has one encoder and one
@@ -454,4 +455,46 @@ bool encode_type24(const Type24Body& body,
     return true;
 }
 
+/** Encodes one schema-0x8080955A body: the volume's ClientRef, then its on or off state. */
+bool encode_type32_volume(const Type32VolumeBody& body,
+                          std::span<std::byte> output,
+                          std::size_t& written) noexcept {
+    written = 0;
+    if (body.registryKey == 0 || body.registryKey == (std::numeric_limits<std::uint32_t>::max)()
+        || body.slotIndex < 0 || output.size() < kType32ByteCount) {
+        return false;
+    }
+    bits::Writer writer(output.first(kType32ByteCount));
+    return auth_fields::write_client_ref(writer,
+                                         body.registryKey,
+                                         kVolumeSlotType,
+                                         static_cast<std::uint16_t>(body.slotIndex))
+           && writer.write(body.active ? kType32StateOn : kType32StateOff, kType32StateWidth)
+           && auth_fields::finish_exact(writer, kType32BitCount, kType32ByteCount, written);
+}
+
+/** Decodes one volume toggle; the no-op state, a non-volume target and nonzero padding fail. */
+bool decode_type32_volume(std::span<const std::byte> input,
+                          std::size_t bitCount,
+                          Type32VolumeBody& body) noexcept {
+    if (bitCount != kType32BitCount || input.size() != kType32ByteCount) {
+        return false;
+    }
+    bits::Reader reader(input);
+    std::uint64_t key = 0, type = 0, index = 0, state = 0;
+    if (!reader.read(auth_fields::kClientRefKeyWidth, key) || key == 0
+        || key == (std::numeric_limits<std::uint32_t>::max)()
+        || !reader.read(kClientRefTypeWidth, type)
+        || type != kVolumeSlotType + auth_fields::kClientRefTypeBias
+        || !reader.read(kClientRefIndexWidth, index) || index < kClientRefIndexBias
+        || !reader.read(kType32StateWidth, state)
+        || (state != kType32StateOff && state != kType32StateOn) || !finish_padding(reader)) {
+        return false;
+    }
+    body = {static_cast<std::uint32_t>(key),
+            static_cast<std::int16_t>(index - kClientRefIndexBias),
+            state == kType32StateOn};
+    return true;
+}
+
 } // namespace sunrise::middleware::bap::activity_message::scriptable_auth

+ 1 - 1
Sunrise/src/middleware/bap/activity_message/activity_scriptable_auth_mission_body_codec.cpp

@@ -448,7 +448,7 @@ bool validate_type68_body(std::span<const std::byte> input, std::size_t bitCount
     if (active >= 0) {
         const std::size_t selected = static_cast<std::size_t>(active);
         if (names[selected] == 0 || names[selected] == kClientRefAbsentKey || elements[selected] < 0
-            || states[selected] < 0 || states[selected] > 2) {
+            || states[selected] < -1 || states[selected] > 2) {
             return false;
         }
     }

+ 686 - 0
Sunrise/src/middleware/bap/activity_message/activity_sense_body_decoder.cpp

@@ -0,0 +1,686 @@
+#include <bit>
+#include <limits>
+
+#include "sense_update_body.h"
+
+// The per-schema msg-6 Sense body readers. Each reads one root body inside its object budget.
+
+namespace sunrise::middleware::bap::activity_message::sense_update::body {
+namespace {
+
+/** Schema tags the sense-update objects and their nested arrays declare. */
+constexpr std::uint32_t kDevice = 0x80804F47U, kScene = 0x8080626AU;
+constexpr std::uint32_t kSquad = 0x80807ECCU, kObjective = 0x80807F04U;
+constexpr std::uint32_t kOccupancy = 0x80809531U;
+constexpr std::uint32_t kAttachment = 0x8080954AU;
+constexpr std::uint32_t kGhostLink = 0x80804D3EU;
+constexpr std::uint32_t kObject = 0x8080992EU;
+constexpr std::uint32_t kObjectSpawnMask = 0x80809E1BU, kObjectReplies = 0x80809AEAU;
+constexpr std::uint32_t kSceneEvents = 0x808094DFU, kSceneList = 0x808094E1U;
+constexpr std::uint32_t kSquadCounts = 0x80809491U, kSquadList = 0x80807ECFU;
+constexpr std::uint32_t kSquadReals = 0x80807ECDU;
+constexpr std::uint32_t kObjectiveBlock = 0x80807F07U, kObjectiveTasks = 0x80807F08U;
+constexpr std::uint32_t kCombatant = 0x80807DA2U, kCombatantAtoms = 0x80807F6EU;
+constexpr std::uint32_t kCombatantKeyed = 0x80807DA3U, kCombatantLanes = 0x80807DA4U;
+constexpr std::uint32_t kEngagement = 0x808094F0U, kEngagementList = 0x808094F8U;
+/** The engagement list: a five-bit element count that the native list caps at 16. */
+constexpr std::uint8_t kEngagementCountWidth = 5;
+constexpr std::uint64_t kEngagementCapacity = 16;
+/** Engagement ordinal 1 is a 16-bit value biased by 32768. */
+constexpr std::uint16_t kEngagementValueOrdinal = 1;
+constexpr std::uint8_t kEngagementValueWidth = 16;
+constexpr std::int32_t kEngagementValueBias = 32'768;
+/** The type-26 attachment body: three required signed 32-bit counters, then one flag. */
+constexpr std::uint16_t kAttachmentCounterCount = 3;
+constexpr std::uint8_t kAttachmentCounterWidth = 32;
+constexpr std::uint16_t kAttachmentFlagOrdinal = kAttachmentCounterCount;
+/** Type-13 participation root and its nested blocks, in body order. */
+constexpr std::uint32_t kPlayer = 0x80804F2FU, kPlayerState = 0x808094E4U;
+constexpr std::uint32_t kPlayerScalars = 0x80804F39U, kPlayerTail = 0x80804F35U;
+constexpr std::uint32_t kPlayerIdentity = 0x808094DDU, kPlayerKeys = 0x808094DFU;
+constexpr std::uint32_t kPlayerPairs = 0x808094E3U, kPlayerFlags = 0x80804F31U;
+constexpr std::uint32_t kPlayerOptional = 0x80804F34U;
+/** Type-20 damage monitor root: health, shield, echoed revision. */
+constexpr std::uint32_t kDamage = 0x80809562U;
+/** Registered object replies: the interaction latch and the ownership key. */
+constexpr std::uint32_t kInteractionReply = 0x80804FB7U, kOwnershipReply = 0x80809ACCU;
+/** Counted lists inside the participation body. */
+constexpr std::uint64_t kPlayerScalarCountMaximum = 4, kPlayerKeyCountMaximum = 32;
+/** Quantization ceilings the combatant reals carry in their own descriptors. */
+constexpr std::uint32_t kSpatialMaximumBits = 0x45000000U, kUnitMaximumBits = 0x3F800000U;
+
+/** @param width Field width in bits. @return Low-bit mask of that width. */
+[[nodiscard]] constexpr std::uint64_t mask(std::uint8_t width) noexcept {
+    return width == 64 ? (std::numeric_limits<std::uint64_t>::max)()
+                       : (std::uint64_t{1} << width) - 1;
+}
+[[nodiscard]] std::int64_t
+signed_value(std::uint64_t raw, std::int32_t bias, std::uint8_t storageWidth) noexcept {
+    const std::uint64_t decoded =
+        (raw - static_cast<std::uint64_t>(static_cast<std::int64_t>(bias))) & mask(storageWidth);
+    if (storageWidth == 64) {
+        return static_cast<std::int64_t>(decoded);
+    }
+    const std::uint64_t sign = std::uint64_t{1} << (storageWidth - 1);
+    return static_cast<std::int64_t>((decoded ^ sign) - sign);
+}
+
+/** Required fields are present; optional fields consume one presence bit within the budget. */
+[[nodiscard]] bool present(Reader& reader, bool optional, bool& output) noexcept {
+    output = true;
+    if (!optional) {
+        return true;
+    }
+    std::uint64_t raw = 0;
+    if (!reader.read(1, raw)) {
+        return false;
+    }
+    output = raw != 0;
+    return true;
+}
+/** Records an unsigned value only when its presence bit and selected value fit. */
+[[nodiscard]] bool read_unsigned(Reader& reader,
+                                 Values& values,
+                                 std::uint32_t schema,
+                                 std::uint16_t ordinal,
+                                 std::uint8_t width,
+                                 bool optional,
+                                 std::uint32_t occurrence = 0) noexcept {
+    const auto at = static_cast<std::uint32_t>(reader.position());
+    bool exists = true;
+    if (!present(reader, optional, exists)) {
+        return false;
+    }
+    std::uint64_t raw = 0;
+    if (exists && !reader.read(width, raw)) {
+        return false;
+    }
+    values.put(schema,
+               ordinal,
+               occurrence,
+               at,
+               exists ? width : 0,
+               ValueKind::unsignedInteger,
+               raw,
+               0,
+               0.0F,
+               exists);
+    return true;
+}
+/** Removes wire bias and sign-extends to the host width after reading a complete selected field. */
+[[nodiscard]] bool read_signed(Reader& reader,
+                               Values& values,
+                               std::uint32_t schema,
+                               std::uint16_t ordinal,
+                               std::uint8_t wireWidth,
+                               std::uint8_t storageWidth,
+                               std::int32_t bias,
+                               bool optional,
+                               std::uint32_t occurrence = 0) noexcept {
+    const auto at = static_cast<std::uint32_t>(reader.position());
+    bool exists = true;
+    if (!present(reader, optional, exists)) {
+        return false;
+    }
+    std::uint64_t raw = 0;
+    if (exists && !reader.read(wireWidth, raw)) {
+        return false;
+    }
+    const std::int64_t decoded = exists ? signed_value(raw, bias, storageWidth) : 0;
+    values.put(schema,
+               ordinal,
+               occurrence,
+               at,
+               exists ? wireWidth : 0,
+               ValueKind::signedInteger,
+               static_cast<std::uint64_t>(decoded),
+               decoded,
+               0.0F,
+               exists);
+    return true;
+}
+/** Reads one boolean field into the value table. */
+[[nodiscard]] bool read_bool(Reader& reader,
+                             Values& values,
+                             std::uint32_t schema,
+                             std::uint16_t ordinal,
+                             bool optional,
+                             std::uint32_t occurrence = 0) noexcept {
+    const auto at = static_cast<std::uint32_t>(reader.position());
+    bool exists = true;
+    if (!present(reader, optional, exists)) {
+        return false;
+    }
+    std::uint64_t raw = 0;
+    if (exists && !reader.read(1, raw)) {
+        return false;
+    }
+    values.put(
+        schema, ordinal, occurrence, at, exists ? 1 : 0, ValueKind::boolean, raw, 0, 0.0F, exists);
+    return true;
+}
+/** Quantized endpoints stay exact; width 32 carries raw float bits. */
+[[nodiscard]] float
+real_value(std::uint64_t raw, std::uint8_t width, std::uint32_t maximumBits) noexcept {
+    if (width == 32) {
+        return std::bit_cast<float>(static_cast<std::uint32_t>(raw));
+    }
+    const float maximum = std::bit_cast<float>(maximumBits);
+    const std::uint64_t levels = std::uint64_t{1} << width;
+    if (raw == 0) {
+        return 0.0F;
+    }
+    if (raw == levels - 1) {
+        return maximum;
+    }
+    const float step = maximum / static_cast<float>(levels - 2);
+    return static_cast<float>(raw - 1) * step + step * 0.5F;
+}
+/** Records a real only when its presence bit and selected value fit. */
+[[nodiscard]] bool read_real(Reader& reader,
+                             Values& values,
+                             std::uint32_t schema,
+                             std::uint16_t ordinal,
+                             std::uint8_t width,
+                             bool optional,
+                             std::uint32_t occurrence = 0,
+                             std::uint32_t maximumBits = 0) noexcept {
+    const auto at = static_cast<std::uint32_t>(reader.position());
+    bool exists = true;
+    if (!present(reader, optional, exists)) {
+        return false;
+    }
+    std::uint64_t raw = 0;
+    if (exists && !reader.read(width, raw)) {
+        return false;
+    }
+    values.put(schema,
+               ordinal,
+               occurrence,
+               at,
+               exists ? width : 0,
+               ValueKind::real32,
+               raw,
+               0,
+               exists ? real_value(raw, width, maximumBits) : 0.0F,
+               exists);
+    return true;
+}
+
+/** Decodes three device channels within the object's bit budget. */
+[[nodiscard]] NativeStatus decode_device(Reader& reader, Values& values) noexcept {
+    for (std::uint16_t channel = 0; channel < 3; ++channel) {
+        if (!read_real(reader, values, kDevice, channel * 2, 32, true)
+            || !read_signed(reader,
+                            values,
+                            kDevice,
+                            channel * 2 + 1,
+                            32,
+                            32,
+                            (std::numeric_limits<std::int32_t>::min)(),
+                            true)) {
+            return NativeStatus::malformed;
+        }
+    }
+    return NativeStatus::complete;
+}
+/** Decodes the occupancy flags and signed values within the object's bit budget. */
+[[nodiscard]] NativeStatus decode_occupancy(Reader& reader, Values& values) noexcept {
+    return read_bool(reader, values, kOccupancy, 0, false)
+                   && read_bool(reader, values, kOccupancy, 1, false)
+                   && read_signed(reader,
+                                  values,
+                                  kOccupancy,
+                                  2,
+                                  32,
+                                  32,
+                                  (std::numeric_limits<std::int32_t>::min)(),
+                                  false)
+                   && read_signed(reader,
+                                  values,
+                                  kOccupancy,
+                                  3,
+                                  32,
+                                  32,
+                                  (std::numeric_limits<std::int32_t>::min)(),
+                                  false)
+               ? NativeStatus::complete
+               : NativeStatus::malformed;
+}
+/** Decodes a scene and refuses event counts above the native capacity. */
+[[nodiscard]] NativeStatus decode_scene(Reader& reader, Values& values) noexcept {
+    if (!read_signed(
+            reader, values, kScene, 0, 32, 32, (std::numeric_limits<std::int32_t>::min)(), false)
+        || !read_bool(reader, values, kScene, 1, false)
+        || !read_signed(reader, values, kScene, 2, 31, 32, 0, true)
+        || !read_signed(reader, values, kScene, 3, 2, 8, 1, false)) {
+        return NativeStatus::malformed;
+    }
+    std::uint64_t count = 0;
+    const auto at = static_cast<std::uint32_t>(reader.position());
+    if (!reader.read(6, count)) {
+        return NativeStatus::malformed;
+    }
+    if (count > 32) {
+        return NativeStatus::unsafeCount;
+    }
+    values.put(kSceneList, 0, 0, at, 6, ValueKind::unsignedInteger, count, 0, 0.0F, true);
+    for (std::uint32_t index = 0; index < count; ++index) {
+        if (!read_unsigned(reader, values, kSceneEvents, 0, 32, false, index)) {
+            return NativeStatus::malformed;
+        }
+    }
+    return NativeStatus::complete;
+}
+/** Decodes squad fields and bounded member lists within the object's bit budget. */
+[[nodiscard]] NativeStatus decode_squad(Reader& reader, Values& values) noexcept {
+    if (!read_signed(reader, values, kSquad, 0, 31, 32, 0, true)
+        || !read_signed(reader, values, kSquad, 1, 31, 32, 0, true)
+        || !read_signed(reader, values, kSquad, 2, 31, 32, 0, true)
+        || !read_signed(reader, values, kSquad, 3, 6, 32, 0, true)
+        || !read_real(reader, values, kSquad, 4, 7, true, 0, 1157562368U)
+        || !read_signed(reader, values, kSquad, 5, 31, 32, 0, true)
+        || !read_signed(reader, values, kSquad, 6, 2, 8, 1, false)
+        || !read_signed(reader, values, kSquad, 7, 3, 8, 1, false)
+        || !read_bool(reader, values, kSquad, 8, false)
+        || !read_bool(reader, values, kSquad, 9, false)
+        || !read_bool(reader, values, kSquad, 10, false)) {
+        return NativeStatus::malformed;
+    }
+    bool exists = false;
+    if (!present(reader, true, exists)) {
+        return NativeStatus::malformed;
+    }
+    if (exists) {
+        std::uint64_t count = 0;
+        const auto at = static_cast<std::uint32_t>(reader.position());
+        if (!reader.read(4, count)) {
+            return NativeStatus::malformed;
+        }
+        if (count > 8) {
+            return NativeStatus::unsafeCount;
+        }
+        values.put(kSquadList, 0, 0, at, 4, ValueKind::unsignedInteger, count, 0, 0.0F, true);
+        for (std::uint32_t index = 0; index < count; ++index) {
+            if (!read_signed(reader,
+                             values,
+                             kSquadCounts,
+                             0,
+                             32,
+                             32,
+                             (std::numeric_limits<std::int32_t>::min)(),
+                             false,
+                             index)) {
+                return NativeStatus::malformed;
+            }
+        }
+    }
+    if (!present(reader, true, exists)) {
+        return NativeStatus::malformed;
+    }
+    if (exists) {
+        for (std::uint32_t index = 0; index < 24; ++index) {
+            if (!read_real(reader, values, kSquadReals, 0, 7, true, index, 1157562368U)) {
+                return NativeStatus::malformed;
+            }
+        }
+    }
+    return NativeStatus::complete;
+}
+/** Decodes the optional objective blocks and their fixed task arrays. */
+[[nodiscard]] NativeStatus decode_objective(Reader& reader, Values& values) noexcept {
+    bool exists = false;
+    if (!present(reader, true, exists)) {
+        return NativeStatus::malformed;
+    }
+    if (exists) {
+        for (std::uint32_t block = 0; block < 24; ++block) {
+            if (!read_signed(reader, values, kObjectiveBlock, 0, 7, 8, 0, true, block)
+                || !read_unsigned(reader, values, kObjectiveBlock, 1, 1, false, block)) {
+                return NativeStatus::malformed;
+            }
+            bool tasks = false;
+            if (!present(reader, true, tasks)) {
+                return NativeStatus::malformed;
+            }
+            if (!tasks) {
+                continue;
+            }
+            for (std::uint32_t task = 0; task < 24; ++task) {
+                if (!read_signed(
+                        reader, values, kObjectiveTasks, 0, 7, 8, 0, true, block * 24 + task)) {
+                    return NativeStatus::malformed;
+                }
+            }
+        }
+    }
+    return read_signed(reader, values, kObjective, 1, 31, 32, 0, true)
+                   && read_unsigned(reader, values, kObjective, 2, 32, true)
+               ? NativeStatus::complete
+               : NativeStatus::malformed;
+}
+/** Decodes an object and accepts only the registered interaction and ownership replies. */
+[[nodiscard]] NativeStatus decode_object(Reader& reader, Values& values) noexcept {
+    if (!read_signed(
+            reader, values, kObject, 0, 32, 32, (std::numeric_limits<std::int32_t>::min)(), false)
+        || !read_bool(reader, values, kObject, 1, false)
+        || !read_bool(reader, values, kObject, 2, false)
+        || !read_signed(reader,
+                        values,
+                        kObject,
+                        3,
+                        32,
+                        32,
+                        (std::numeric_limits<std::int32_t>::min)(),
+                        false)) {
+        return NativeStatus::malformed;
+    }
+    for (std::uint32_t index = 0; index < 2; ++index) {
+        if (!read_unsigned(reader, values, kObjectSpawnMask, 0, 32, false, index)) {
+            return NativeStatus::malformed;
+        }
+    }
+    std::uint64_t count = 0;
+    const auto at = static_cast<std::uint32_t>(reader.position());
+    if (!reader.read(2, count)) {
+        return NativeStatus::malformed;
+    }
+    values.put(kObjectReplies, 0, 0, at, 2, ValueKind::unsignedInteger, count, 0, 0.0F, true);
+    for (std::uint32_t index = 0; index < count; ++index) {
+        std::uint64_t exists = 0, schema = 0;
+        if (!reader.read(1, exists)) {
+            return NativeStatus::malformed;
+        }
+        if (!exists) {
+            continue;
+        }
+        if (!reader.read(32, schema)) {
+            return NativeStatus::malformed;
+        }
+        if (schema == kInteractionReply) {
+            // The interaction latch and the revision that set it.
+            if (!read_bool(reader, values, kInteractionReply, 0, false, index)
+                || !read_signed(reader,
+                                values,
+                                kInteractionReply,
+                                1,
+                                32,
+                                32,
+                                (std::numeric_limits<std::int32_t>::min)(),
+                                false,
+                                index)) {
+                return NativeStatus::malformed;
+            }
+        } else if (schema == kOwnershipReply) {
+            // The owner key is always written, zero when nobody holds the object.
+            if (!read_bool(reader, values, kOwnershipReply, 0, false, index)
+                || !read_unsigned(reader, values, kOwnershipReply, 1, 64, false, index)) {
+                return NativeStatus::malformed;
+            }
+        } else {
+            return NativeStatus::unsupported;
+        }
+    }
+    return NativeStatus::complete;
+}
+/** Decodes combatant generations, program state, keyed lanes and root flags within the budget. */
+[[nodiscard]] NativeStatus decode_combatant(Reader& reader, Values& values) noexcept {
+    if (!read_signed(reader, values, kCombatant, 0, 31, 32, 0, true)
+        || !read_real(reader, values, kCombatant, 1, 9, true, 0, kSpatialMaximumBits)
+        || !read_signed(reader, values, kCombatant, 2, 31, 32, 0, true)) {
+        return NativeStatus::malformed;
+    }
+    bool exists = false;
+    if (!present(reader, true, exists)) {
+        return NativeStatus::malformed;
+    }
+    if (exists
+        && (!read_signed(reader, values, kCombatantAtoms, 0, 6, 32, 0, true)
+            || !read_signed(reader, values, kCombatantAtoms, 1, 31, 32, 0, true)
+            || !read_signed(reader, values, kCombatantAtoms, 2, 31, 32, 0, true)
+            || !read_bool(reader, values, kCombatantAtoms, 3, false))) {
+        return NativeStatus::malformed;
+    }
+    if (!present(reader, true, exists)) {
+        return NativeStatus::malformed;
+    }
+    if (exists) {
+        bool lanes = false;
+        if (!present(reader, true, lanes)) {
+            return NativeStatus::malformed;
+        }
+        for (std::uint32_t index = 0; lanes && index < 8; ++index) {
+            if (!read_signed(reader, values, kCombatantLanes, 0, 31, 32, 0, true, index)) {
+                return NativeStatus::malformed;
+            }
+        }
+        if (!read_unsigned(reader, values, kCombatantKeyed, 1, 32, true)) {
+            return NativeStatus::malformed;
+        }
+    }
+    if (!read_signed(reader, values, kCombatant, 5, 31, 32, 0, true)
+        || !read_signed(reader, values, kCombatant, 6, 2, 8, 1, false)
+        || !read_signed(reader, values, kCombatant, 7, 31, 32, 0, true)
+        || !read_real(reader, values, kCombatant, 8, 7, true, 0, kUnitMaximumBits)
+        || !read_real(reader, values, kCombatant, 9, 7, true, 0, kUnitMaximumBits)
+        || !read_bool(reader, values, kCombatant, 10, false)
+        || !read_bool(reader, values, kCombatant, 11, false)) {
+        return NativeStatus::malformed;
+    }
+    return NativeStatus::complete;
+}
+/**
+ * Decodes the engagement body. Only an empty element list is supported: the element codec is
+ * unknown, and its storage size says nothing about its wire width.
+ */
+[[nodiscard]] NativeStatus decode_engagement(Reader& reader, Values& values) noexcept {
+    std::uint64_t count = 0;
+    const auto at = static_cast<std::uint32_t>(reader.position());
+    if (!reader.read(kEngagementCountWidth, count)) {
+        return NativeStatus::malformed;
+    }
+    if (count > kEngagementCapacity) {
+        return NativeStatus::unsafeCount;
+    }
+    if (count != 0) {
+        return NativeStatus::unsupported;
+    }
+    values.put(kEngagementList,
+               0,
+               0,
+               at,
+               kEngagementCountWidth,
+               ValueKind::unsignedInteger,
+               count,
+               0,
+               0.0F,
+               true);
+    return read_signed(reader,
+                       values,
+                       kEngagement,
+                       kEngagementValueOrdinal,
+                       kEngagementValueWidth,
+                       kEngagementValueWidth,
+                       kEngagementValueBias,
+                       false)
+               ? NativeStatus::complete
+               : NativeStatus::malformed;
+}
+
+/** Decodes the type-26 attachment body. */
+[[nodiscard]] NativeStatus decode_attachment(Reader& reader, Values& values) noexcept {
+    for (std::uint16_t ordinal = 0; ordinal < kAttachmentCounterCount; ++ordinal) {
+        if (!read_signed(reader,
+                         values,
+                         kAttachment,
+                         ordinal,
+                         kAttachmentCounterWidth,
+                         kAttachmentCounterWidth,
+                         (std::numeric_limits<std::int32_t>::min)(),
+                         false)) {
+            return NativeStatus::malformed;
+        }
+    }
+    return read_bool(reader, values, kAttachment, kAttachmentFlagOrdinal, false)
+               ? NativeStatus::complete
+               : NativeStatus::malformed;
+}
+
+/**
+ * Decodes the type-13 participation body. The whole body is read before its Ghost flag counts,
+ * because a player mid-load publishes a body with the flag but without a settled actor.
+ */
+[[nodiscard]] NativeStatus decode_player(Reader& reader, Values& values) noexcept {
+    // The participation field uses a signed 32-bit midpoint bias.
+    constexpr auto signedBias = (std::numeric_limits<std::int32_t>::min)();
+    if (!read_signed(reader, values, kPlayerState, 0, 32, 32, signedBias, true)
+        || !read_unsigned(reader, values, kPlayerState, 1, 32, true)) {
+        return NativeStatus::malformed;
+    }
+    for (std::uint16_t ordinal = 2; ordinal <= 5; ++ordinal) {
+        if (!read_bool(reader, values, kPlayerState, ordinal, false)) {
+            return NativeStatus::malformed;
+        }
+    }
+    if (!read_signed(reader, values, kPlayerState, 6, 3, 8, 1, false)
+        || !read_signed(reader, values, kPlayerState, 7, 2, 8, 1, false)) {
+        return NativeStatus::malformed;
+    }
+    std::uint64_t count = 0;
+    if (!reader.read(3, count)) {
+        return NativeStatus::malformed;
+    }
+    if (count > kPlayerScalarCountMaximum) {
+        return NativeStatus::unsafeCount;
+    }
+    for (unsigned index = 0; index < count; ++index) {
+        if (!read_unsigned(reader, values, kPlayerScalars, 0, 32, false, index)
+            || !read_real(reader, values, kPlayerScalars, 1, 32, false, index)) {
+            return NativeStatus::malformed;
+        }
+    }
+    if (!read_real(reader, values, kPlayerTail, 1, 32, false)
+        || !read_signed(reader, values, kPlayerTail, 2, 5, 32, 1, false)
+        || !read_signed(reader, values, kPlayerTail, 3, 32, 32, signedBias, true)) {
+        return NativeStatus::malformed;
+    }
+    for (std::uint16_t ordinal = 4; ordinal <= 6; ++ordinal) {
+        if (!read_bool(reader, values, kPlayerTail, ordinal, false)) {
+            return NativeStatus::malformed;
+        }
+    }
+    if (!read_unsigned(reader, values, kPlayerIdentity, 0, 64, true)
+        || !read_unsigned(reader, values, kPlayerIdentity, 1, 5, false)
+        || !read_signed(reader, values, kPlayerIdentity, 2, 6, 8, 3, false)) {
+        return NativeStatus::malformed;
+    }
+    if (!reader.read(6, count)) {
+        return NativeStatus::malformed;
+    }
+    if (count > kPlayerKeyCountMaximum) {
+        return NativeStatus::unsafeCount;
+    }
+    for (unsigned index = 0; index < count; ++index) {
+        if (!read_unsigned(reader, values, kPlayerKeys, 0, 32, false, index)) {
+            return NativeStatus::malformed;
+        }
+    }
+    if (!reader.read(6, count)) {
+        return NativeStatus::malformed;
+    }
+    if (count > kPlayerKeyCountMaximum) {
+        return NativeStatus::unsafeCount;
+    }
+    for (unsigned index = 0; index < count; ++index) {
+        if (!read_unsigned(reader, values, kPlayerPairs, 0, 32, true, index)
+            || !read_unsigned(reader, values, kPlayerPairs, 1, 64, true, index)) {
+            return NativeStatus::malformed;
+        }
+    }
+    if (!read_bool(reader, values, kPlayerFlags, 0, false)
+        || !read_unsigned(reader, values, kPlayerFlags, 1, 4, false)
+        || !read_unsigned(reader, values, kPlayerFlags, 2, 16, true)
+        || !read_unsigned(reader, values, kPlayerFlags, 3, 16, true)) {
+        return NativeStatus::malformed;
+    }
+    bool exists = false;
+    if (!present(reader, true, exists)) {
+        return NativeStatus::malformed;
+    }
+    if (exists && !read_unsigned(reader, values, kPlayerOptional, 0, 32, false)) {
+        return NativeStatus::malformed;
+    }
+    return read_bool(reader, values, kPlayerFlags, 5, false)
+                   && read_signed(reader, values, kPlayerFlags, 6, 8, 8, 128, false)
+                   && read_signed(reader, values, kPlayer, 4, 32, 32, signedBias, false)
+               ? NativeStatus::complete
+               : NativeStatus::malformed;
+}
+
+} // namespace
+
+/** Decodes one root body of the given schema; the status names how far it got. */
+[[nodiscard]] NativeStatus
+decode_body(std::uint32_t schema, Reader& reader, Values& values) noexcept {
+    std::uint64_t root = 0;
+    if (!reader.read(1, root)) {
+        return NativeStatus::malformed;
+    }
+    if (root == 0) {
+        return NativeStatus::complete;
+    }
+    switch (schema) {
+    case kPlayer:
+        return decode_player(reader, values);
+    case kDamage:
+        // Three mandatory fields: health, shield, then the echoed Auth revision.
+        return read_real(reader, values, schema, 0, 32, false)
+                       && read_real(reader, values, schema, 1, 32, false)
+                       && read_signed(reader,
+                                      values,
+                                      schema,
+                                      2,
+                                      32,
+                                      32,
+                                      (std::numeric_limits<std::int32_t>::min)(),
+                                      false)
+                   ? NativeStatus::complete
+                   : NativeStatus::malformed;
+    case kGhostLink:
+        // Three mandatory fields: active, elapsed over duration, accepted Auth generation.
+        return read_bool(reader, values, kGhostLink, 0, false)
+                       && read_real(reader, values, kGhostLink, 1, 32, false)
+                       && read_signed(reader,
+                                      values,
+                                      kGhostLink,
+                                      2,
+                                      32,
+                                      32,
+                                      (std::numeric_limits<std::int32_t>::min)(),
+                                      false)
+                   ? NativeStatus::complete
+                   : NativeStatus::malformed;
+    case kDevice:
+        return decode_device(reader, values);
+    case kScene:
+        return decode_scene(reader, values);
+    case kSquad:
+        return decode_squad(reader, values);
+    case kObjective:
+        return decode_objective(reader, values);
+    case kOccupancy:
+        return decode_occupancy(reader, values);
+    case kAttachment:
+        return decode_attachment(reader, values);
+    case kObject:
+        return decode_object(reader, values);
+    case kCombatant:
+        return decode_combatant(reader, values);
+    case kEngagement:
+        return decode_engagement(reader, values);
+    default:
+        return NativeStatus::unsupported;
+    }
+}
+
+} // namespace sunrise::middleware::bap::activity_message::sense_update::body

+ 5 - 672
Sunrise/src/middleware/bap/activity_message/activity_sense_update_decoder.cpp

@@ -1,10 +1,8 @@
-#include <bit>
-#include <limits>
-
 #include "../../encoding/bit_reader.h"
 #include "../../encoding/byte_order.h"
 #include "sense_roster_delta.h"
 #include "sense_update.h"
+#include "sense_update_body.h"
 
 namespace sunrise::middleware::bap::activity_message::sense_update {
 namespace {
@@ -14,677 +12,12 @@ namespace bits = middleware::encoding::bits;
 constexpr std::uint8_t kKeyWidth = 32, kTypeWidth = 7, kIndexWidth = 16;
 /** The slot type and index are sent unsigned, biased so their absent value is zero. */
 constexpr std::uint32_t kTypeBias = 1, kIndexBias = 32768;
-/** Schema tags the sense-update objects and their nested arrays declare. */
-constexpr std::uint32_t kDevice = 0x80804F47U, kScene = 0x8080626AU;
-constexpr std::uint32_t kSquad = 0x80807ECCU, kObjective = 0x80807F04U;
-constexpr std::uint32_t kOccupancy = 0x80809531U;
-constexpr std::uint32_t kGhostLink = 0x80804D3EU;
-constexpr std::uint32_t kObject = 0x8080992EU;
-constexpr std::uint32_t kObjectSpawnMask = 0x80809E1BU, kObjectReplies = 0x80809AEAU;
-constexpr std::uint32_t kSceneEvents = 0x808094DFU, kSceneList = 0x808094E1U;
-constexpr std::uint32_t kSquadCounts = 0x80809491U, kSquadList = 0x80807ECFU;
-constexpr std::uint32_t kSquadReals = 0x80807ECDU;
-constexpr std::uint32_t kObjectiveBlock = 0x80807F07U, kObjectiveTasks = 0x80807F08U;
-constexpr std::uint32_t kCombatant = 0x80807DA2U, kCombatantAtoms = 0x80807F6EU;
-constexpr std::uint32_t kCombatantKeyed = 0x80807DA3U, kCombatantLanes = 0x80807DA4U;
-/** Type-13 participation root and its nested blocks, in body order. */
-constexpr std::uint32_t kPlayer = 0x80804F2FU, kPlayerState = 0x808094E4U;
-constexpr std::uint32_t kPlayerScalars = 0x80804F39U, kPlayerTail = 0x80804F35U;
-constexpr std::uint32_t kPlayerIdentity = 0x808094DDU, kPlayerKeys = 0x808094DFU;
-constexpr std::uint32_t kPlayerPairs = 0x808094E3U, kPlayerFlags = 0x80804F31U;
-constexpr std::uint32_t kPlayerOptional = 0x80804F34U;
-/** Type-20 damage monitor root: health, shield, echoed revision. */
-constexpr std::uint32_t kDamage = 0x80809562U;
-/** Registered object replies: the interaction latch and the ownership key. */
-constexpr std::uint32_t kInteractionReply = 0x80804FB7U, kOwnershipReply = 0x80809ACCU;
-/** Counted lists inside the participation body. */
-constexpr std::uint64_t kPlayerScalarCountMaximum = 4, kPlayerKeyCountMaximum = 32;
-/** Quantization ceilings the combatant reals carry in their own descriptors. */
-constexpr std::uint32_t kSpatialMaximumBits = 0x45000000U, kUnitMaximumBits = 0x3F800000U;
-
-enum class NativeStatus : std::uint8_t { complete, unsupported, unsafeCount, malformed };
-
-/** Bit reader with its own budget, so one object cannot consume the whole packet. */
-class Reader final {
-public:
-    Reader(bits::Reader& source, std::size_t budget, std::size_t total) noexcept
-        : source_(source), left_(budget), total_(total) {}
-    [[nodiscard]] bool read(std::uint8_t width, std::uint64_t& value) noexcept {
-        if (width > left_ || !source_.read(width, value)) {
-            return false;
-        }
-        left_ -= width;
-        return true;
-    }
-    [[nodiscard]] bool skip(std::size_t width) noexcept {
-        if (width > left_ || !source_.skip(width)) {
-            return false;
-        }
-        left_ -= width;
-        return true;
-    }
-    [[nodiscard]] std::size_t left() const noexcept {
-        return left_;
-    }
-    [[nodiscard]] std::size_t position() const noexcept {
-        return total_ - source_.remaining_bits();
-    }
-
-private:
-    bits::Reader& source_;
-    std::size_t left_{};
-    std::size_t total_{};
-};
 
-/** @param width Field width in bits. @return Low-bit mask of that width. */
-[[nodiscard]] constexpr std::uint64_t mask(std::uint8_t width) noexcept {
-    return width == 64 ? (std::numeric_limits<std::uint64_t>::max)()
-                       : (std::uint64_t{1} << width) - 1;
-}
-[[nodiscard]] std::int64_t
-signed_value(std::uint64_t raw, std::int32_t bias, std::uint8_t storageWidth) noexcept {
-    const std::uint64_t decoded =
-        (raw - static_cast<std::uint64_t>(static_cast<std::int64_t>(bias))) & mask(storageWidth);
-    if (storageWidth == 64) {
-        return static_cast<std::int64_t>(decoded);
-    }
-    const std::uint64_t sign = std::uint64_t{1} << (storageWidth - 1);
-    return static_cast<std::int64_t>((decoded ^ sign) - sign);
-}
-
-/** Collects decoded values into the packet, dropping them once the object's room runs out. */
-class Values final {
-public:
-    Values(DecodedPacket& packet, DecodedObject* object) noexcept
-        : packet_(packet), object_(object) {}
-    /** Retains one field value, marking truncation when the packet's storage is full. */
-    void put(std::uint32_t schema,
-             std::uint16_t ordinal,
-             std::uint32_t occurrence,
-             std::uint32_t at,
-             std::uint8_t width,
-             ValueKind kind,
-             std::uint64_t raw,
-             std::int64_t signedRaw,
-             float real,
-             bool present) noexcept {
-        if (object_ == nullptr) {
-            return;
-        }
-        if (packet_.valueCount == packet_.values.size()) {
-            packet_.valuesTruncated = true;
-            return;
-        }
-        DecodedValue& value = packet_.values[packet_.valueCount++];
-        value.schemaRow = schema;
-        value.fieldRow = ordinal;
-        value.fieldOrdinal = ordinal;
-        value.occurrence = occurrence;
-        value.bitOffset = at;
-        value.width = width;
-        value.kind = kind;
-        value.unsignedValue = raw;
-        value.signedValue = signedRaw;
-        value.realValue = real;
-        value.present = present;
-    }
-
-private:
-    DecodedPacket& packet_;
-    DecodedObject* object_{};
-};
-
-/** Required fields are present; optional fields consume one presence bit within the budget. */
-[[nodiscard]] bool present(Reader& reader, bool optional, bool& output) noexcept {
-    output = true;
-    if (!optional) {
-        return true;
-    }
-    std::uint64_t raw = 0;
-    if (!reader.read(1, raw)) {
-        return false;
-    }
-    output = raw != 0;
-    return true;
-}
-/** Records an unsigned value only when its presence bit and selected value fit. */
-[[nodiscard]] bool read_unsigned(Reader& reader,
-                                 Values& values,
-                                 std::uint32_t schema,
-                                 std::uint16_t ordinal,
-                                 std::uint8_t width,
-                                 bool optional,
-                                 std::uint32_t occurrence = 0) noexcept {
-    const auto at = static_cast<std::uint32_t>(reader.position());
-    bool exists = true;
-    if (!present(reader, optional, exists)) {
-        return false;
-    }
-    std::uint64_t raw = 0;
-    if (exists && !reader.read(width, raw)) {
-        return false;
-    }
-    values.put(schema,
-               ordinal,
-               occurrence,
-               at,
-               exists ? width : 0,
-               ValueKind::unsignedInteger,
-               raw,
-               0,
-               0.0F,
-               exists);
-    return true;
-}
-/** Removes wire bias and sign-extends to the host width after reading a complete selected field. */
-[[nodiscard]] bool read_signed(Reader& reader,
-                               Values& values,
-                               std::uint32_t schema,
-                               std::uint16_t ordinal,
-                               std::uint8_t wireWidth,
-                               std::uint8_t storageWidth,
-                               std::int32_t bias,
-                               bool optional,
-                               std::uint32_t occurrence = 0) noexcept {
-    const auto at = static_cast<std::uint32_t>(reader.position());
-    bool exists = true;
-    if (!present(reader, optional, exists)) {
-        return false;
-    }
-    std::uint64_t raw = 0;
-    if (exists && !reader.read(wireWidth, raw)) {
-        return false;
-    }
-    const std::int64_t decoded = exists ? signed_value(raw, bias, storageWidth) : 0;
-    values.put(schema,
-               ordinal,
-               occurrence,
-               at,
-               exists ? wireWidth : 0,
-               ValueKind::signedInteger,
-               static_cast<std::uint64_t>(decoded),
-               decoded,
-               0.0F,
-               exists);
-    return true;
-}
-/** Reads one boolean field into the value table. */
-[[nodiscard]] bool read_bool(Reader& reader,
-                             Values& values,
-                             std::uint32_t schema,
-                             std::uint16_t ordinal,
-                             bool optional,
-                             std::uint32_t occurrence = 0) noexcept {
-    const auto at = static_cast<std::uint32_t>(reader.position());
-    bool exists = true;
-    if (!present(reader, optional, exists)) {
-        return false;
-    }
-    std::uint64_t raw = 0;
-    if (exists && !reader.read(1, raw)) {
-        return false;
-    }
-    values.put(
-        schema, ordinal, occurrence, at, exists ? 1 : 0, ValueKind::boolean, raw, 0, 0.0F, exists);
-    return true;
-}
-/** Quantized endpoints stay exact; width 32 carries raw float bits. */
-[[nodiscard]] float
-real_value(std::uint64_t raw, std::uint8_t width, std::uint32_t maximumBits) noexcept {
-    if (width == 32) {
-        return std::bit_cast<float>(static_cast<std::uint32_t>(raw));
-    }
-    const float maximum = std::bit_cast<float>(maximumBits);
-    const std::uint64_t levels = std::uint64_t{1} << width;
-    if (raw == 0) {
-        return 0.0F;
-    }
-    if (raw == levels - 1) {
-        return maximum;
-    }
-    const float step = maximum / static_cast<float>(levels - 2);
-    return static_cast<float>(raw - 1) * step + step * 0.5F;
-}
-/** Records a real only when its presence bit and selected value fit. */
-[[nodiscard]] bool read_real(Reader& reader,
-                             Values& values,
-                             std::uint32_t schema,
-                             std::uint16_t ordinal,
-                             std::uint8_t width,
-                             bool optional,
-                             std::uint32_t occurrence = 0,
-                             std::uint32_t maximumBits = 0) noexcept {
-    const auto at = static_cast<std::uint32_t>(reader.position());
-    bool exists = true;
-    if (!present(reader, optional, exists)) {
-        return false;
-    }
-    std::uint64_t raw = 0;
-    if (exists && !reader.read(width, raw)) {
-        return false;
-    }
-    values.put(schema,
-               ordinal,
-               occurrence,
-               at,
-               exists ? width : 0,
-               ValueKind::real32,
-               raw,
-               0,
-               exists ? real_value(raw, width, maximumBits) : 0.0F,
-               exists);
-    return true;
-}
+using body::decode_body;
+using body::NativeStatus;
+using body::Reader;
+using body::Values;
 
-/** Decodes three device channels within the object's bit budget. */
-[[nodiscard]] NativeStatus decode_device(Reader& reader, Values& values) noexcept {
-    for (std::uint16_t channel = 0; channel < 3; ++channel) {
-        if (!read_real(reader, values, kDevice, channel * 2, 32, true)
-            || !read_signed(reader,
-                            values,
-                            kDevice,
-                            channel * 2 + 1,
-                            32,
-                            32,
-                            (std::numeric_limits<std::int32_t>::min)(),
-                            true)) {
-            return NativeStatus::malformed;
-        }
-    }
-    return NativeStatus::complete;
-}
-/** Decodes the occupancy flags and signed values within the object's bit budget. */
-[[nodiscard]] NativeStatus decode_occupancy(Reader& reader, Values& values) noexcept {
-    return read_bool(reader, values, kOccupancy, 0, false)
-                   && read_bool(reader, values, kOccupancy, 1, false)
-                   && read_signed(reader,
-                                  values,
-                                  kOccupancy,
-                                  2,
-                                  32,
-                                  32,
-                                  (std::numeric_limits<std::int32_t>::min)(),
-                                  false)
-                   && read_signed(reader,
-                                  values,
-                                  kOccupancy,
-                                  3,
-                                  32,
-                                  32,
-                                  (std::numeric_limits<std::int32_t>::min)(),
-                                  false)
-               ? NativeStatus::complete
-               : NativeStatus::malformed;
-}
-/** Decodes a scene and refuses event counts above the native capacity. */
-[[nodiscard]] NativeStatus decode_scene(Reader& reader, Values& values) noexcept {
-    if (!read_signed(
-            reader, values, kScene, 0, 32, 32, (std::numeric_limits<std::int32_t>::min)(), false)
-        || !read_bool(reader, values, kScene, 1, false)
-        || !read_signed(reader, values, kScene, 2, 31, 32, 0, true)
-        || !read_signed(reader, values, kScene, 3, 2, 8, 1, false)) {
-        return NativeStatus::malformed;
-    }
-    std::uint64_t count = 0;
-    const auto at = static_cast<std::uint32_t>(reader.position());
-    if (!reader.read(6, count)) {
-        return NativeStatus::malformed;
-    }
-    if (count > 32) {
-        return NativeStatus::unsafeCount;
-    }
-    values.put(kSceneList, 0, 0, at, 6, ValueKind::unsignedInteger, count, 0, 0.0F, true);
-    for (std::uint32_t index = 0; index < count; ++index) {
-        if (!read_unsigned(reader, values, kSceneEvents, 0, 32, false, index)) {
-            return NativeStatus::malformed;
-        }
-    }
-    return NativeStatus::complete;
-}
-/** Decodes squad fields and bounded member lists within the object's bit budget. */
-[[nodiscard]] NativeStatus decode_squad(Reader& reader, Values& values) noexcept {
-    if (!read_signed(reader, values, kSquad, 0, 31, 32, 0, true)
-        || !read_signed(reader, values, kSquad, 1, 31, 32, 0, true)
-        || !read_signed(reader, values, kSquad, 2, 31, 32, 0, true)
-        || !read_signed(reader, values, kSquad, 3, 6, 32, 0, true)
-        || !read_real(reader, values, kSquad, 4, 7, true, 0, 1157562368U)
-        || !read_signed(reader, values, kSquad, 5, 31, 32, 0, true)
-        || !read_signed(reader, values, kSquad, 6, 2, 8, 1, false)
-        || !read_signed(reader, values, kSquad, 7, 3, 8, 1, false)
-        || !read_bool(reader, values, kSquad, 8, false)
-        || !read_bool(reader, values, kSquad, 9, false)
-        || !read_bool(reader, values, kSquad, 10, false)) {
-        return NativeStatus::malformed;
-    }
-    bool exists = false;
-    if (!present(reader, true, exists)) {
-        return NativeStatus::malformed;
-    }
-    if (exists) {
-        std::uint64_t count = 0;
-        const auto at = static_cast<std::uint32_t>(reader.position());
-        if (!reader.read(4, count)) {
-            return NativeStatus::malformed;
-        }
-        if (count > 8) {
-            return NativeStatus::unsafeCount;
-        }
-        values.put(kSquadList, 0, 0, at, 4, ValueKind::unsignedInteger, count, 0, 0.0F, true);
-        for (std::uint32_t index = 0; index < count; ++index) {
-            if (!read_signed(reader,
-                             values,
-                             kSquadCounts,
-                             0,
-                             32,
-                             32,
-                             (std::numeric_limits<std::int32_t>::min)(),
-                             false,
-                             index)) {
-                return NativeStatus::malformed;
-            }
-        }
-    }
-    if (!present(reader, true, exists)) {
-        return NativeStatus::malformed;
-    }
-    if (exists) {
-        for (std::uint32_t index = 0; index < 24; ++index) {
-            if (!read_real(reader, values, kSquadReals, 0, 7, true, index, 1157562368U)) {
-                return NativeStatus::malformed;
-            }
-        }
-    }
-    return NativeStatus::complete;
-}
-/** Decodes the optional objective blocks and their fixed task arrays. */
-[[nodiscard]] NativeStatus decode_objective(Reader& reader, Values& values) noexcept {
-    bool exists = false;
-    if (!present(reader, true, exists)) {
-        return NativeStatus::malformed;
-    }
-    if (exists) {
-        for (std::uint32_t block = 0; block < 24; ++block) {
-            if (!read_signed(reader, values, kObjectiveBlock, 0, 7, 8, 0, true, block)
-                || !read_unsigned(reader, values, kObjectiveBlock, 1, 1, false, block)) {
-                return NativeStatus::malformed;
-            }
-            bool tasks = false;
-            if (!present(reader, true, tasks)) {
-                return NativeStatus::malformed;
-            }
-            if (!tasks) {
-                continue;
-            }
-            for (std::uint32_t task = 0; task < 24; ++task) {
-                if (!read_signed(
-                        reader, values, kObjectiveTasks, 0, 7, 8, 0, true, block * 24 + task)) {
-                    return NativeStatus::malformed;
-                }
-            }
-        }
-    }
-    return read_signed(reader, values, kObjective, 1, 31, 32, 0, true)
-                   && read_unsigned(reader, values, kObjective, 2, 32, true)
-               ? NativeStatus::complete
-               : NativeStatus::malformed;
-}
-/** Decodes an object and accepts only the registered interaction and ownership replies. */
-[[nodiscard]] NativeStatus decode_object(Reader& reader, Values& values) noexcept {
-    if (!read_signed(
-            reader, values, kObject, 0, 32, 32, (std::numeric_limits<std::int32_t>::min)(), false)
-        || !read_bool(reader, values, kObject, 1, false)
-        || !read_bool(reader, values, kObject, 2, false)
-        || !read_signed(reader,
-                        values,
-                        kObject,
-                        3,
-                        32,
-                        32,
-                        (std::numeric_limits<std::int32_t>::min)(),
-                        false)) {
-        return NativeStatus::malformed;
-    }
-    for (std::uint32_t index = 0; index < 2; ++index) {
-        if (!read_unsigned(reader, values, kObjectSpawnMask, 0, 32, false, index)) {
-            return NativeStatus::malformed;
-        }
-    }
-    std::uint64_t count = 0;
-    const auto at = static_cast<std::uint32_t>(reader.position());
-    if (!reader.read(2, count)) {
-        return NativeStatus::malformed;
-    }
-    values.put(kObjectReplies, 0, 0, at, 2, ValueKind::unsignedInteger, count, 0, 0.0F, true);
-    for (std::uint32_t index = 0; index < count; ++index) {
-        std::uint64_t exists = 0, schema = 0;
-        if (!reader.read(1, exists)) {
-            return NativeStatus::malformed;
-        }
-        if (!exists) {
-            continue;
-        }
-        if (!reader.read(32, schema)) {
-            return NativeStatus::malformed;
-        }
-        if (schema == kInteractionReply) {
-            // The interaction latch and the revision that set it.
-            if (!read_bool(reader, values, kInteractionReply, 0, false, index)
-                || !read_signed(reader,
-                                values,
-                                kInteractionReply,
-                                1,
-                                32,
-                                32,
-                                (std::numeric_limits<std::int32_t>::min)(),
-                                false,
-                                index)) {
-                return NativeStatus::malformed;
-            }
-        } else if (schema == kOwnershipReply) {
-            // The owner key is always written, zero when nobody holds the object.
-            if (!read_bool(reader, values, kOwnershipReply, 0, false, index)
-                || !read_unsigned(reader, values, kOwnershipReply, 1, 64, false, index)) {
-                return NativeStatus::malformed;
-            }
-        } else {
-            return NativeStatus::unsupported;
-        }
-    }
-    return NativeStatus::complete;
-}
-/** Decodes combatant generations, program state, keyed lanes and root flags within the budget. */
-[[nodiscard]] NativeStatus decode_combatant(Reader& reader, Values& values) noexcept {
-    if (!read_signed(reader, values, kCombatant, 0, 31, 32, 0, true)
-        || !read_real(reader, values, kCombatant, 1, 9, true, 0, kSpatialMaximumBits)
-        || !read_signed(reader, values, kCombatant, 2, 31, 32, 0, true)) {
-        return NativeStatus::malformed;
-    }
-    bool exists = false;
-    if (!present(reader, true, exists)) {
-        return NativeStatus::malformed;
-    }
-    if (exists
-        && (!read_signed(reader, values, kCombatantAtoms, 0, 6, 32, 0, true)
-            || !read_signed(reader, values, kCombatantAtoms, 1, 31, 32, 0, true)
-            || !read_signed(reader, values, kCombatantAtoms, 2, 31, 32, 0, true)
-            || !read_bool(reader, values, kCombatantAtoms, 3, false))) {
-        return NativeStatus::malformed;
-    }
-    if (!present(reader, true, exists)) {
-        return NativeStatus::malformed;
-    }
-    if (exists) {
-        bool lanes = false;
-        if (!present(reader, true, lanes)) {
-            return NativeStatus::malformed;
-        }
-        for (std::uint32_t index = 0; lanes && index < 8; ++index) {
-            if (!read_signed(reader, values, kCombatantLanes, 0, 31, 32, 0, true, index)) {
-                return NativeStatus::malformed;
-            }
-        }
-        if (!read_unsigned(reader, values, kCombatantKeyed, 1, 32, true)) {
-            return NativeStatus::malformed;
-        }
-    }
-    if (!read_signed(reader, values, kCombatant, 5, 31, 32, 0, true)
-        || !read_signed(reader, values, kCombatant, 6, 2, 8, 1, false)
-        || !read_signed(reader, values, kCombatant, 7, 31, 32, 0, true)
-        || !read_real(reader, values, kCombatant, 8, 7, true, 0, kUnitMaximumBits)
-        || !read_real(reader, values, kCombatant, 9, 7, true, 0, kUnitMaximumBits)
-        || !read_bool(reader, values, kCombatant, 10, false)
-        || !read_bool(reader, values, kCombatant, 11, false)) {
-        return NativeStatus::malformed;
-    }
-    return NativeStatus::complete;
-}
-/** Validates the complete participation body before its Ghost flag becomes usable. */
-[[nodiscard]] NativeStatus decode_player(Reader& reader, Values& values) noexcept {
-    // The participation field uses a signed 32-bit midpoint bias.
-    constexpr auto signedBias = (std::numeric_limits<std::int32_t>::min)();
-    if (!read_signed(reader, values, kPlayerState, 0, 32, 32, signedBias, true)
-        || !read_unsigned(reader, values, kPlayerState, 1, 32, true)) {
-        return NativeStatus::malformed;
-    }
-    for (std::uint16_t ordinal = 2; ordinal <= 5; ++ordinal) {
-        if (!read_bool(reader, values, kPlayerState, ordinal, false)) {
-            return NativeStatus::malformed;
-        }
-    }
-    if (!read_signed(reader, values, kPlayerState, 6, 3, 8, 1, false)
-        || !read_signed(reader, values, kPlayerState, 7, 2, 8, 1, false)) {
-        return NativeStatus::malformed;
-    }
-    std::uint64_t count = 0;
-    if (!reader.read(3, count)) {
-        return NativeStatus::malformed;
-    }
-    if (count > kPlayerScalarCountMaximum) {
-        return NativeStatus::unsafeCount;
-    }
-    for (unsigned index = 0; index < count; ++index) {
-        if (!read_unsigned(reader, values, kPlayerScalars, 0, 32, false, index)
-            || !read_real(reader, values, kPlayerScalars, 1, 32, false, index)) {
-            return NativeStatus::malformed;
-        }
-    }
-    if (!read_real(reader, values, kPlayerTail, 1, 32, false)
-        || !read_signed(reader, values, kPlayerTail, 2, 5, 32, 1, false)
-        || !read_signed(reader, values, kPlayerTail, 3, 32, 32, signedBias, true)) {
-        return NativeStatus::malformed;
-    }
-    for (std::uint16_t ordinal = 4; ordinal <= 6; ++ordinal) {
-        if (!read_bool(reader, values, kPlayerTail, ordinal, false)) {
-            return NativeStatus::malformed;
-        }
-    }
-    if (!read_unsigned(reader, values, kPlayerIdentity, 0, 64, true)
-        || !read_unsigned(reader, values, kPlayerIdentity, 1, 5, false)
-        || !read_signed(reader, values, kPlayerIdentity, 2, 6, 8, 3, false)) {
-        return NativeStatus::malformed;
-    }
-    if (!reader.read(6, count)) {
-        return NativeStatus::malformed;
-    }
-    if (count > kPlayerKeyCountMaximum) {
-        return NativeStatus::unsafeCount;
-    }
-    for (unsigned index = 0; index < count; ++index) {
-        if (!read_unsigned(reader, values, kPlayerKeys, 0, 32, false, index)) {
-            return NativeStatus::malformed;
-        }
-    }
-    if (!reader.read(6, count)) {
-        return NativeStatus::malformed;
-    }
-    if (count > kPlayerKeyCountMaximum) {
-        return NativeStatus::unsafeCount;
-    }
-    for (unsigned index = 0; index < count; ++index) {
-        if (!read_unsigned(reader, values, kPlayerPairs, 0, 32, true, index)
-            || !read_unsigned(reader, values, kPlayerPairs, 1, 64, true, index)) {
-            return NativeStatus::malformed;
-        }
-    }
-    if (!read_bool(reader, values, kPlayerFlags, 0, false)
-        || !read_unsigned(reader, values, kPlayerFlags, 1, 4, false)
-        || !read_unsigned(reader, values, kPlayerFlags, 2, 16, true)
-        || !read_unsigned(reader, values, kPlayerFlags, 3, 16, true)) {
-        return NativeStatus::malformed;
-    }
-    bool exists = false;
-    if (!present(reader, true, exists)) {
-        return NativeStatus::malformed;
-    }
-    if (exists && !read_unsigned(reader, values, kPlayerOptional, 0, 32, false)) {
-        return NativeStatus::malformed;
-    }
-    return read_bool(reader, values, kPlayerFlags, 5, false)
-                   && read_signed(reader, values, kPlayerFlags, 6, 8, 8, 128, false)
-                   && read_signed(reader, values, kPlayer, 4, 32, 32, signedBias, false)
-               ? NativeStatus::complete
-               : NativeStatus::malformed;
-}
-
-/** Decodes one root body of the given schema; the status names how far it got. */
-[[nodiscard]] NativeStatus
-decode_body(std::uint32_t schema, Reader& reader, Values& values) noexcept {
-    std::uint64_t root = 0;
-    if (!reader.read(1, root)) {
-        return NativeStatus::malformed;
-    }
-    if (root == 0) {
-        return NativeStatus::complete;
-    }
-    switch (schema) {
-    case kPlayer:
-        return decode_player(reader, values);
-    case kDamage:
-        // Three mandatory fields: health, shield, then the echoed Auth revision.
-        return read_real(reader, values, schema, 0, 32, false)
-                       && read_real(reader, values, schema, 1, 32, false)
-                       && read_signed(reader,
-                                      values,
-                                      schema,
-                                      2,
-                                      32,
-                                      32,
-                                      (std::numeric_limits<std::int32_t>::min)(),
-                                      false)
-                   ? NativeStatus::complete
-                   : NativeStatus::malformed;
-    case kGhostLink:
-        // Three mandatory fields: active, elapsed over duration, accepted Auth generation.
-        return read_bool(reader, values, kGhostLink, 0, false)
-                       && read_real(reader, values, kGhostLink, 1, 32, false)
-                       && read_signed(reader,
-                                      values,
-                                      kGhostLink,
-                                      2,
-                                      32,
-                                      32,
-                                      (std::numeric_limits<std::int32_t>::min)(),
-                                      false)
-                   ? NativeStatus::complete
-                   : NativeStatus::malformed;
-    case kDevice:
-        return decode_device(reader, values);
-    case kScene:
-        return decode_scene(reader, values);
-    case kSquad:
-        return decode_squad(reader, values);
-    case kObjective:
-        return decode_objective(reader, values);
-    case kOccupancy:
-        return decode_occupancy(reader, values);
-    case kObject:
-        return decode_object(reader, values);
-    case kCombatant:
-        return decode_combatant(reader, values);
-    default:
-        return NativeStatus::unsupported;
-    }
-}
 [[nodiscard]] bool skip_group(Reader& reader) noexcept {
     if (reader.left() == 0 || !reader.skip(reader.left() - 1)) {
         return false;

+ 1 - 1
Sunrise/src/middleware/bap/activity_message/scriptable_auth_body.h

@@ -552,7 +552,7 @@ struct Type23Body final {
 struct Type68Preset final {
     std::uint32_t nameHash{};
     std::int32_t elementIndex{};
-    /** Native directive state: 0 enters, 1 completes, and 2 uses the alternate exit state. */
+    /** Native directive state: -1 disables, 0 enters, 1 completes, 2 exits. */
     std::int8_t state{};
     bool visible{true};
     /** Lane progress values, current then maximum first; the HUD reads them as a counter. */

+ 97 - 0
Sunrise/src/middleware/bap/activity_message/sense_update_body.h

@@ -0,0 +1,97 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+
+#include "../../encoding/bit_reader.h"
+#include "sense_update.h"
+
+// Shared by the msg-6 packet framing and the per-schema Sense body readers.
+
+namespace sunrise::middleware::bap::activity_message::sense_update::body {
+
+/** How far one body decode got. */
+enum class NativeStatus : std::uint8_t { complete, unsupported, unsafeCount, malformed };
+
+/** Bit reader with its own budget, so one object cannot consume the whole packet. */
+class Reader final {
+public:
+    Reader(encoding::bits::Reader& source, std::size_t budget, std::size_t total) noexcept
+        : source_(source), left_(budget), total_(total) {}
+    [[nodiscard]] bool read(std::uint8_t width, std::uint64_t& value) noexcept {
+        if (width > left_ || !source_.read(width, value)) {
+            return false;
+        }
+        left_ -= width;
+        return true;
+    }
+    [[nodiscard]] bool skip(std::size_t width) noexcept {
+        if (width > left_ || !source_.skip(width)) {
+            return false;
+        }
+        left_ -= width;
+        return true;
+    }
+    [[nodiscard]] std::size_t left() const noexcept {
+        return left_;
+    }
+    [[nodiscard]] std::size_t position() const noexcept {
+        return total_ - source_.remaining_bits();
+    }
+
+private:
+    encoding::bits::Reader& source_;
+    std::size_t left_{};
+    std::size_t total_{};
+};
+
+/** Collects decoded values into the packet, dropping them once the object's room runs out. */
+class Values final {
+public:
+    Values(DecodedPacket& packet, DecodedObject* object) noexcept
+        : packet_(packet), object_(object) {}
+    /** Retains one field value, marking truncation when the packet's storage is full. */
+    void put(std::uint32_t schema,
+             std::uint16_t ordinal,
+             std::uint32_t occurrence,
+             std::uint32_t at,
+             std::uint8_t width,
+             ValueKind kind,
+             std::uint64_t raw,
+             std::int64_t signedRaw,
+             float real,
+             bool present) noexcept {
+        if (object_ == nullptr) {
+            return;
+        }
+        if (packet_.valueCount == packet_.values.size()) {
+            packet_.valuesTruncated = true;
+            return;
+        }
+        DecodedValue& value = packet_.values[packet_.valueCount++];
+        value.schemaRow = schema;
+        value.fieldRow = ordinal;
+        value.fieldOrdinal = ordinal;
+        value.occurrence = occurrence;
+        value.bitOffset = at;
+        value.width = width;
+        value.kind = kind;
+        value.unsignedValue = raw;
+        value.signedValue = signedRaw;
+        value.realValue = real;
+        value.present = present;
+    }
+
+private:
+    DecodedPacket& packet_;
+    DecodedObject* object_{};
+};
+
+/**
+ * Decodes one root body of the given schema within the reader's budget.
+ * @return How far it got; any status but malformed leaves the group skippable.
+ */
+[[nodiscard]] NativeStatus
+decode_body(std::uint32_t schema, Reader& reader, Values& values) noexcept;
+
+} // namespace sunrise::middleware::bap::activity_message::sense_update::body

+ 35 - 0
Sunrise/src/middleware/bap/activity_message/volume_toggle_auth.h

@@ -0,0 +1,35 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+namespace sunrise::middleware::bap::activity_message::scriptable_auth {
+
+/** Type-32 toggle Auth: a volume reference followed by a signed state in {-1, 0, 1}. */
+inline constexpr std::uint8_t kType32SlotType = 32;
+inline constexpr std::uint32_t kType32ComponentClass = 0x80809556;
+inline constexpr std::uint32_t kType32Schema = 0x8080955A;
+inline constexpr std::uint8_t kVolumeSlotType = 60;
+inline constexpr std::size_t kType32BitCount = 57;
+inline constexpr std::size_t kType32ByteCount = 8;
+/** The state follows the volume ClientRef as two bits of state plus one; zero is the no-op. */
+inline constexpr std::uint8_t kType32StateWidth = 2;
+inline constexpr std::uint32_t kType32StateOff = 1;
+inline constexpr std::uint32_t kType32StateOn = 2;
+/** One volume and an explicit on/off state; the no-op state -1 is not representable. */
+struct Type32VolumeBody final {
+    std::uint32_t registryKey{};
+    std::int16_t slotIndex{-1};
+    bool active{};
+};
+/** Encodes one volume toggle into exactly kType32ByteCount bytes. */
+[[nodiscard]] bool encode_type32_volume(const Type32VolumeBody& body,
+                                        std::span<std::byte> output,
+                                        std::size_t& written) noexcept;
+/** Decodes one exact volume toggle body. */
+[[nodiscard]] bool decode_type32_volume(std::span<const std::byte> input,
+                                        std::size_t bitCount,
+                                        Type32VolumeBody& body) noexcept;
+
+} // namespace sunrise::middleware::bap::activity_message::scriptable_auth

+ 7 - 0
Sunrise/src/middleware/bap/activity_message/wire_schema/activity_wire_codec.h

@@ -21,6 +21,13 @@ namespace runtime {
 
 /** Row index meaning "no row"; the SDK never numbers a real row this high. */
 inline constexpr std::uint32_t kAbsentRuntimeRow = 0xFFFFFFFFU;
+/** Field type 1 walks one nested schema row. */
+inline constexpr std::uint8_t kNestedType = 1;
+/** Field type 34 carries its schema handle and reads through the selected-schema reader. */
+inline constexpr std::uint8_t kSelectedType = 34;
+/** Field types 12 and 35 are raw 64-bit words, read and written whole. */
+inline constexpr std::uint8_t kRaw64AliasType = 12;
+inline constexpr std::uint8_t kRaw64Type = 35;
 
 /** One runtime schema row supplied by the generated Activity SDK. */
 struct SchemaView final {

+ 13 - 4
Sunrise/src/middleware/bap/activity_message/wire_schema/activity_wire_codec_decode_runtime.h

@@ -786,6 +786,14 @@ private:
                 && resolver_.isZeroBitType(resolver_.context, field.typeCode)) {
                 return RuntimeWalkStatus::complete;
             }
+            // The native union reader dispatches through the per-type table, where a nested
+            // arm is a no-op and a type-34 arm is the ordinary selected-schema reader.
+            if (field.typeCode == runtime::kNestedType) {
+                return RuntimeWalkStatus::complete;
+            }
+            if (field.typeCode == runtime::kSelectedType) {
+                return walk_selected(owner, field, occurrence, depth);
+            }
             return walk_inline_type(owner, field, occurrence, memory, depth + 1);
         }
         if (field.typeCode == 28) {
@@ -817,10 +825,11 @@ private:
                          ValueRole::customIndex);
             return RuntimeWalkStatus::complete;
         }
-        const bool raw64 = field.typeCode == 35;
+        const bool raw64 = is_raw64(field.typeCode);
         const std::uint8_t nativeWidth = raw64 ? 64 : storage_width(field.typeCode);
         const std::uint8_t width = raw64 ? 64 : runtime_declared_width(field);
-        if (nativeWidth == 0 || width == 0) {
+        // A declared width of zero, as a union field's own parameters give, reads no bits.
+        if (nativeWidth == 0) {
             return RuntimeWalkStatus::unsupportedField;
         }
         const std::uint32_t at = static_cast<std::uint32_t>(reader_.position());
@@ -861,7 +870,7 @@ private:
                             || field.typeCode == 27 || field.typeCode == 28 || field.typeCode == 39
                             || field.typeCode == 40 || field.typeCode == 42 || field.typeCode == 43
                             || field.typeCode == 44 || field.typeCode == 45;
-        const bool raw64 = field.typeCode == 35;
+        const bool raw64 = is_raw64(field.typeCode);
         const bool zeroBit = resolver_.isZeroBitType != nullptr
                              && resolver_.isZeroBitType(resolver_.context, field.typeCode);
         // A selected field carries its schema handle on the wire, so it is structural but must
@@ -876,7 +885,7 @@ private:
         }
         const std::uint8_t nativeWidth = structural || raw64 ? 0 : storage_width(field.typeCode);
         const std::uint8_t declaredWidth = structural || raw64 ? 0 : runtime_declared_width(field);
-        if (!structural && !raw64 && (nativeWidth == 0 || declaredWidth == 0)) {
+        if (!structural && !raw64 && nativeWidth == 0) {
             return RuntimeWalkStatus::unsupportedField;
         }
         for (std::uint32_t index = 0; index < repeats; ++index) {

+ 13 - 4
Sunrise/src/middleware/bap/activity_message/wire_schema/activity_wire_codec_encode_runtime.h

@@ -58,7 +58,7 @@ struct RuntimeAuthoredValue final {
     if (field.typeCode == 11) {
         return value.kind == ValueKind::real32 && std::isfinite(value.realValue);
     }
-    if (field.typeCode == 35) {
+    if (is_raw64(field.typeCode)) {
         return value.kind == ValueKind::unsignedInteger;
     }
     const std::uint8_t width = storage_width(field.typeCode);
@@ -234,10 +234,11 @@ private:
         if (!valid_runtime_scalar(field, value)) {
             return RuntimeWalkStatus::unsupportedField;
         }
-        const bool raw64 = field.typeCode == 35;
+        const bool raw64 = is_raw64(field.typeCode);
         const std::uint8_t nativeWidth = raw64 ? 64 : storage_width(field.typeCode);
         const std::uint8_t declaredWidth = raw64 ? 64 : runtime_declared_width(field);
-        if (nativeWidth == 0 || declaredWidth == 0) {
+        // Mirrors the decoder: a zero declared width writes no bits.
+        if (nativeWidth == 0) {
             return RuntimeWalkStatus::unsupportedField;
         }
         std::uint64_t decoded = 0;
@@ -784,6 +785,14 @@ private:
                 && resolver_.isZeroBitType(resolver_.context, field.typeCode)) {
                 return RuntimeWalkStatus::complete;
             }
+            // The native union reader dispatches through the per-type table, where a nested
+            // arm is a no-op and a type-34 arm is the ordinary selected-schema reader.
+            if (field.typeCode == runtime::kNestedType) {
+                return RuntimeWalkStatus::complete;
+            }
+            if (field.typeCode == runtime::kSelectedType) {
+                return write_selected(owner, field, occurrence, depth);
+            }
             return write_inline_type(owner, field, occurrence, memory, depth + 1);
         }
         if (field.typeCode == 28) {
@@ -842,7 +851,7 @@ private:
         const bool nestedSchemaKind =
             nested || command || entityReference || schemaReference || custom;
         const bool unknownOptional = field.presence != 0 && !nestedSchemaKind && !selected
-                                     && !zeroBit && field.typeCode != 35
+                                     && !zeroBit && !is_raw64(field.typeCode)
                                      && storage_width(field.typeCode) == 0;
         const bool structural = nestedSchemaKind || selected || zeroBit || unknownOptional;
         if (!nestedSchemaKind && field.nestedSchemaRow != runtime::kAbsentRuntimeRow) {

+ 5 - 0
Sunrise/src/middleware/bap/activity_message/wire_schema/activity_wire_codec_internal.h

@@ -114,6 +114,11 @@ public:
     }
 }
 
+/** @return True for a raw 64-bit field type. */
+[[nodiscard]] constexpr bool is_raw64(std::uint8_t typeCode) noexcept {
+    return typeCode == runtime::kRaw64AliasType || typeCode == runtime::kRaw64Type;
+}
+
 /** @return A low bit mask of the given width. Width 0 masks everything away. */
 [[nodiscard]] constexpr std::uint64_t mask(std::uint8_t width) noexcept {
     return width == 64  ? (std::numeric_limits<std::uint64_t>::max)()

+ 1 - 20
Sunrise/src/middleware/content/packages/tables/activity_table.h

@@ -4,6 +4,7 @@
 
 #include "../../../../state/build_data/activities/activity_catalog.h"
 #include "definition_index_table.h"
+#include "field_reader.h"
 
 namespace sunrise::middleware::content::packages::tables::activities {
 /** Public activity index, NOT the package-definition index in root slot 29. */
@@ -32,26 +33,6 @@ template <class T>
     std::memcpy(&value, bytes.data() + offset, sizeof(T));
     return true;
 }
-/**
- * Resolves a self-relative field.
- * @param field Offset of the 8-byte signed delta.
- * @param target Receives the resolved offset.
- * @return False when the delta is zero or the target falls outside the blob.
- */
-[[nodiscard]] inline bool
-relative(std::span<const std::byte> bytes, std::size_t field, std::size_t& target) noexcept {
-    std::int64_t delta{};
-    if (!read(bytes, field, delta) || delta == 0
-        || field > static_cast<std::size_t>((std::numeric_limits<std::int64_t>::max)())) {
-        return false;
-    }
-    const auto base = static_cast<std::int64_t>(field);
-    if (delta < -base || delta > (std::numeric_limits<std::int64_t>::max)() - base) {
-        return false;
-    }
-    target = static_cast<std::size_t>(base + delta);
-    return target < bytes.size();
-}
 /**
  * Parses the dense public activity table.
  * @param bytes The table blob.

+ 13 - 1
Sunrise/src/middleware/content/packages/tables/definition_index_table.cpp

@@ -3,7 +3,7 @@
 #include <limits>
 
 #include "../reader/layout.h"
-#include "internal.h"
+#include "field_reader.h"
 
 namespace sunrise::middleware::content::packages::tables {
 namespace {
@@ -126,6 +126,18 @@ bool find_optional_array_at(std::span<const std::byte> blob,
     return resolve_descriptor(blob, descriptorOffset, true, output);
 }
 
+/** Reads an optional typed array and checks every fixed-stride row lies in its blob. */
+bool read_array(std::span<const std::byte> blob,
+                std::size_t at,
+                std::uint32_t elementClass,
+                std::size_t stride,
+                Array& rows) noexcept {
+    return stride != 0 && find_optional_array_at(blob, at, rows)
+           && (rows.count == 0
+               || (rows.elementClass == elementClass && rows.dataOffset <= blob.size()
+                   && rows.count <= (blob.size() - rows.dataOffset) / stride));
+}
+
 /** Finds the first array whose header names one element class. */
 bool find_array(std::span<const std::byte> blob,
                 std::uint32_t elementClass,

+ 19 - 4
Sunrise/src/middleware/content/packages/tables/definition_index_table.h

@@ -103,6 +103,8 @@ inline constexpr std::uint32_t kUnlockReadValueOpcode = 10;
 inline constexpr std::uint32_t kUnlockReadFlagOpcode = 1;
 /** The opcode that pushes a literal. */
 inline constexpr std::uint32_t kUnlockLiteralOpcode = 11;
+/** The opcode that expands a shared expression row. Its operand is the row index. */
+inline constexpr std::uint32_t kUnlockExpressionOpcode = 12;
 /** The opcode that tests greater than or equal. */
 inline constexpr std::uint32_t kUnlockGreaterEqualOpcode = 14;
 /** High half every installed array marker and element class carries. */
@@ -123,6 +125,11 @@ inline constexpr std::size_t kNodeExpressionFieldAlternate = 48;
 /** Records a node owns, four bytes each as a row then a gate. */
 inline constexpr std::size_t kNodeChildRecordField = 136;
 inline constexpr std::size_t kNodeChildRecordStride = 4;
+/**
+ * Array descriptor of the profile flag mapping table, between the account (+8) and character (+40)
+ * maps. Profile flags are account-wide but saved in their own bank, not the account flag bank.
+ */
+inline constexpr std::size_t kProfileFlagMapDescriptor = 24;
 /** Array descriptor of the character object's flag mapping table, sized to that bank. */
 inline constexpr std::size_t kCharacterFlagMapDescriptor = 40;
 /** Array descriptor of the character object's value mapping table, sized to that bank. */
@@ -216,10 +223,9 @@ inline constexpr std::size_t kProgressionRewardItemIndexOffset = 4;
 inline constexpr std::size_t kProgressionRewardQuantityOffset = 8;
 /** Unlock flag slot a reward's claim sets. The slots run dense in reward order. */
 inline constexpr std::size_t kProgressionRewardClaimSlotOffset = 20;
-/** Items a wrapper item opens into, two bytes each as an item-definition index. */
-inline constexpr std::size_t kGearsetItemField = 392;
-inline constexpr std::uint32_t kGearsetItemRowClass = 0x808087DBU;
-inline constexpr std::size_t kGearsetItemStride = 2;
+/** Eligibility expressions and socket overrides carried by one progression reward. */
+inline constexpr std::size_t kProgressionRewardConditionsOffset = 24;
+inline constexpr std::size_t kProgressionRewardSocketsOffset = 40;
 
 /** Expression that makes one collectible acquired, which a purchase or a grant sets. */
 inline constexpr std::size_t kCollectibleAcquiredExpressionField = 112;
@@ -277,6 +283,13 @@ using RowVisitor = bool (*)(void* context, std::uint32_t index, const IndexRow&
                                           std::size_t descriptorOffset,
                                           Array& output) noexcept;
 
+/** Reads an optional typed array and checks every fixed-stride row lies in its blob. */
+[[nodiscard]] bool read_array(std::span<const std::byte> blob,
+                              std::size_t at,
+                              std::uint32_t elementClass,
+                              std::size_t stride,
+                              Array& rows) noexcept;
+
 /**
  * Finds the first array whose header names one element class.
  * @param blob Whole definition bytes.
@@ -322,6 +335,8 @@ slot_tag(std::span<const std::byte> blob, std::size_t index, std::uint32_t& tag)
 
 /** The investment root holds the item index table at this slot. */
 inline constexpr std::size_t kItemTableSlot = 48;
+/** The investment root holds the new-character table, one row of items per class, here. */
+inline constexpr std::size_t kStarterSetTableSlot = 15;
 /** The investment root holds the shared reusable/randomized plug-set table at this slot. */
 inline constexpr std::size_t kPlugSetTableSlot = 51;
 /** The investment root holds the dense ordinary socket-type table at this slot. */

+ 49 - 0
Sunrise/src/middleware/content/packages/tables/field_reader.h

@@ -0,0 +1,49 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <cstring>
+#include <limits>
+#include <span>
+
+namespace sunrise::middleware::content::packages::tables {
+/**
+ * Reads one little-endian field that must lie inside the blob.
+ * @param blob Whole definition bytes.
+ * @param offset Field offset.
+ * @param value Receives the field.
+ * @return True when the whole field is inside the blob.
+ */
+template <typename Value>
+[[nodiscard]] inline bool
+read(std::span<const std::byte> blob, std::size_t offset, Value& value) noexcept {
+    // Subtracting rather than adding keeps a large offset from wrapping past the size.
+    if (offset > blob.size() || blob.size() - offset < sizeof value) {
+        return false;
+    }
+    std::memcpy(&value, blob.data() + offset, sizeof value);
+    return true;
+}
+
+/**
+ * Resolves a self-relative field.
+ * @param field Offset of the 8-byte signed delta.
+ * @param target Receives the resolved offset.
+ * @return False when the delta is zero or the target falls outside the blob.
+ */
+[[nodiscard]] inline bool
+relative(std::span<const std::byte> bytes, std::size_t field, std::size_t& target) noexcept {
+    std::int64_t delta{};
+    if (!read(bytes, field, delta) || delta == 0
+        || field > static_cast<std::size_t>((std::numeric_limits<std::int64_t>::max)())) {
+        return false;
+    }
+    const auto base = static_cast<std::int64_t>(field);
+    if (delta < -base || delta > (std::numeric_limits<std::int64_t>::max)() - base) {
+        return false;
+    }
+    target = static_cast<std::size_t>(base + delta);
+    return target < bytes.size();
+}
+
+} // namespace sunrise::middleware::content::packages::tables

+ 2 - 19
Sunrise/src/middleware/content/packages/tables/internal.h

@@ -2,28 +2,11 @@
 
 #include <cstddef>
 #include <cstdint>
-#include <cstring>
 #include <span>
 
-namespace sunrise::middleware::content::packages::tables {
+#include "field_reader.h"
 
-/**
- * Reads one little-endian field that must lie inside the blob.
- * @param blob Whole definition bytes.
- * @param offset Field offset.
- * @param value Receives the field.
- * @return True when the whole field is inside the blob.
- */
-template <typename Value>
-[[nodiscard]] inline bool
-read(std::span<const std::byte> blob, std::size_t offset, Value& value) noexcept {
-    // Subtracting rather than adding keeps a large offset from wrapping past the size.
-    if (offset > blob.size() || blob.size() - offset < sizeof value) {
-        return false;
-    }
-    std::memcpy(&value, blob.data() + offset, sizeof value);
-    return true;
-}
+namespace sunrise::middleware::content::packages::tables {
 
 /**
  * Finds the offset of one fixed-stride array element.

+ 3 - 3
Sunrise/src/middleware/datagen/character_record/character_record_encoder.cpp

@@ -42,9 +42,9 @@ constexpr std::size_t kCardFlagOffset = 8;
         static_cast<std::int8_t>(character.gender),
         static_cast<std::int8_t>(character.characterClass),
     };
-    std::memcpy(identity.headerBlock.data(),
-                layout::kHeaderBlockBytes.data(),
-                layout::kHeaderBlockBytes.size());
+    const auto& headerSource =
+        character.appearanceHeaderValid ? character.appearanceHeader : layout::kHeaderBlockBytes;
+    std::memcpy(identity.headerBlock.data(), headerSource.data(), headerSource.size());
 
     output = {};
     appearance::apply_sentinels(output);

+ 4 - 3
Sunrise/src/middleware/datagen/family4/character/character_encoder.cpp

@@ -270,9 +270,10 @@ bool encode(const state::CharacterState& state,
     object.identity.race = static_cast<std::int8_t>(state.race);
     object.identity.gender = static_cast<std::int8_t>(state.gender);
     object.identity.characterClass = static_cast<std::int8_t>(state.characterClass);
-    std::memcpy(object.customisationHeader.data(),
-                character_record::layout::kHeaderBlockBytes.data(),
-                character_record::layout::kHeaderBlockBytes.size());
+    const auto& headerSource = state.appearanceHeaderValid
+                                   ? state.appearanceHeader
+                                   : character_record::layout::kHeaderBlockBytes;
+    std::memcpy(object.customisationHeader.data(), headerSource.data(), headerSource.size());
     object.lastOrbitedDestination = state.lastOrbitedDestination;
     // No previous activity is tracked, and a zero here would name the orbit activity.
     object.previousActivityIndex = kAbsentActivityIndex;

+ 38 - 0
Sunrise/src/middleware/gameplay/external/composite_entity_baseline_registry.cpp

@@ -1,4 +1,5 @@
 #include <array>
+#include <bitset>
 #include <cstddef>
 #include <limits>
 #include <memory>
@@ -370,6 +371,43 @@ bool stage_entity_baseline_mutation(const CompositeEntityCodecContext& context,
         }
         return context.registry->slots[slot];
     };
+    // The client's anchor pass runs before its remove pass: an entity that receives a record
+    // detaches to root every descendant that received none in the same batch. A parent sent as a
+    // direct element therefore loses its children, and they outlive its removal as roots.
+    std::bitset<kMaximumEntitySlot + 1U> recorded{};
+    for (std::size_t index = 0; index < count; ++index) {
+        if (!candidate.ignoredRecordMask.test(index)) {
+            recorded.set(entity_record_at(batch, index).token.slot);
+        }
+    }
+    const auto& retained = context.registry->slots;
+    for (std::size_t slot = 0; slot < retained.size(); ++slot) {
+        if (recorded.test(slot) || !retained[slot].occupied || !retained[slot].anchorPresent) {
+            continue;
+        }
+        // The walk detaches such a child without descending into it, so the child's own subtree
+        // moves with it: only the direct parent's record matters.
+        const EntityToken parent = retained[slot].anchor;
+        bool detach = false;
+        if (recorded.test(parent.slot)) {
+            const auto& staged = current_slot(parent.slot);
+            detach = staged.occupied && staged.incarnation == parent.incarnation;
+        }
+        if (!detach) {
+            continue;
+        }
+        if (changes == kEntityBatchCapacity) {
+            return false;
+        }
+        auto& change = change_at(changes++);
+        change.slot = static_cast<std::uint16_t>(slot);
+        change.expected = retained[slot];
+        change.replacement = change.expected;
+        change.replacement.anchor = {};
+        change.replacement.anchorPresent = false;
+        change.replacement.anchorOrder = 0;
+        ++candidate.detachedCount;
+    }
     std::array<EntityToken, kEntityBatchCapacity> terminals{};
     std::size_t terminalCount = 0;
     const auto append_terminal = [&](EntityToken token) {

+ 85 - 30
Sunrise/src/middleware/gameplay/external/composite_entity_codec.cpp

@@ -1,5 +1,6 @@
 #include <algorithm>
 #include <array>
+#include <cmath>
 #include <cstring>
 #include <limits>
 #include <memory>
@@ -18,6 +19,21 @@ namespace bits = middleware::encoding::bits;
 constexpr std::uint32_t kMaximumByteField = 0xFFU;
 /** A schema reference the mirror retains must fit one 32-bit tag. */
 constexpr std::uint64_t kMaximumSemanticTag = 0xFFFFFFFFULL;
+/** Field type 22 is the native nullable-u32 reader: one present bit, then 32 bits. */
+constexpr std::uint8_t kNullableU32Type = 22;
+/** Field type 21 reads through the nullable-u32 reader in every family. */
+constexpr std::uint8_t kNullableU32AliasType = 21;
+/** In mode one, field type 37 is a plain nested walk of kNestedCustomSchema. */
+constexpr std::uint8_t kNestedCustomType = 37;
+/** The type-37 schema: a 19-bit real and a 5-bit int8, 24 bits in all. */
+constexpr std::uint32_t kNestedCustomSchema = 0x808092FFU;
+/** The damage component replicates each level pool as its own walk of kDamagePoolSchema. */
+constexpr std::uint32_t kDamageComponent = 0x80804BEEU;
+/** One pool level; its single field is the only value it decodes. */
+constexpr std::uint32_t kDamagePoolSchema = 0x80804C5CU;
+/** Pool 0 is health and pool 1 the shield. */
+constexpr std::uint8_t kHealthPool = 0;
+constexpr std::uint8_t kShieldPool = 1;
 
 /** Resolves one schema only when it supports the selected codec family. */
 [[nodiscard]] bool
@@ -79,6 +95,46 @@ read_schema(const void* raw, std::uint32_t rowIndex, wire::runtime::SchemaView&
            && output.row == rowIndex;
 }
 
+/** Reference types that share one native reader decode through one walker case. */
+[[nodiscard]] std::uint8_t reference_alias(format::RuntimeCodecFamily family,
+                                           std::uint8_t type) noexcept {
+    if (family != format::RuntimeCodecFamily::sobjectModeZero && type == 30) {
+        return 18;
+    }
+    if (type == kNullableU32AliasType) {
+        return kNullableU32Type;
+    }
+    if (family == format::RuntimeCodecFamily::sobjectModeOne) {
+        // Types 18, 20, 30 and 31 share one native mode-one reader.
+        if (type == 20 || type == 31) {
+            return 18;
+        }
+        if (type == 29) {
+            return 43;
+        }
+    }
+    return type;
+}
+
+/** Rewrites one field view to the type its family's native reader implements. */
+[[nodiscard]] bool canonicalize_field(const void* raw,
+                                      format::RuntimeCodecFamily family,
+                                      wire::runtime::FieldView& output) noexcept {
+    output.typeCode = reference_alias(family, output.typeCode);
+    if (family == format::RuntimeCodecFamily::sobjectModeOne
+        && output.typeCode == kNestedCustomType) {
+        wire::runtime::SchemaView nested{};
+        if (!find_schema(raw, kNestedCustomSchema, nested)) {
+            return false;
+        }
+        output.typeCode = wire::runtime::kNestedType;
+        output.nestedSchemaRow = nested.row;
+        // Its fields carry no presence bits, so it needs no slot in the component bitmap.
+        output.hasBitmapOffset = true;
+    }
+    return true;
+}
+
 /** Converts one SDK field row into the generic runtime walker view. */
 [[nodiscard]] bool
 read_field(const void* raw, std::uint32_t rowIndex, wire::runtime::FieldView& output) noexcept {
@@ -101,17 +157,7 @@ read_field(const void* raw, std::uint32_t rowIndex, wire::runtime::FieldView& ou
             }
             output.nestedSchemaRow = target.row;
         }
-        if ((context->family != format::RuntimeCodecFamily::sobjectModeZero
-             && output.typeCode == 30)
-            || (context->family == format::RuntimeCodecFamily::sobjectModeOne
-                && output.typeCode == 20)) {
-            output.typeCode = 18;
-        }
-        if (context->family == format::RuntimeCodecFamily::sobjectModeOne
-            && output.typeCode == 29) {
-            output.typeCode = 43;
-        }
-        return true;
+        return canonicalize_field(raw, context->family, output);
     }
     const format::RuntimeField& row = context->catalog->runtimeFields[rowIndex];
     if (row.schemaIndex >= context->catalog->runtimeSchemas.size()
@@ -161,13 +207,8 @@ read_field(const void* raw, std::uint32_t rowIndex, wire::runtime::FieldView& ou
                                     ? 0
                                     : static_cast<std::int32_t>(row.bits);
     output.typeCode = static_cast<std::uint8_t>(row.typeCode);
-    if ((context->family != format::RuntimeCodecFamily::sobjectModeZero && output.typeCode == 30)
-        || (context->family == format::RuntimeCodecFamily::sobjectModeOne
-            && output.typeCode == 20)) {
-        output.typeCode = 18;
-    }
-    if (context->family == format::RuntimeCodecFamily::sobjectModeOne && output.typeCode == 29) {
-        output.typeCode = 43;
+    if (!canonicalize_field(raw, context->family, output)) {
+        return false;
     }
     output.presence =
         static_cast<std::uint8_t>((row.flags & format::kRuntimeFieldPresenceBit) != 0);
@@ -229,18 +270,7 @@ static std::uint8_t canonical_type(const void* raw, std::uint8_t type) noexcept
     if (context.family == format::RuntimeCodecFamily::sobjectModeZero && type == 27) {
         return 0;
     }
-    if (context.family != format::RuntimeCodecFamily::sobjectModeZero && type == 30) {
-        return 18;
-    }
-    if (context.family == format::RuntimeCodecFamily::sobjectModeOne) {
-        if (type == 20) {
-            return 18;
-        }
-        if (type == 29) {
-            return 43;
-        }
-    }
-    return type;
+    return reference_alias(context.family, type);
 }
 
 /** The native action selector chooses one exact SDK-declared payload schema. */
@@ -532,6 +562,31 @@ runtime_schema(const CompositeEntityCodecContext& context, std::uint32_t handle)
         }
         *semanticTag = tag;
     }
+    if (resolverContext.componentTag == kDamageComponent) {
+        for (const auto& value : std::span(values).first(result.valueCount)) {
+            if (value.schemaHandle != kDamagePoolSchema || !value.present) {
+                continue;
+            }
+            const double level = value.kind == wire::ValueKind::real32
+                                     ? static_cast<double>(value.realValue)
+                                 : value.kind == wire::ValueKind::signedInteger
+                                     ? static_cast<double>(value.signedValue)
+                                     : static_cast<double>(value.unsignedValue);
+            if (!(level >= 0.0 && level <= static_cast<double>(kDamageLevelFull))) {
+                continue;
+            }
+            const auto rounded = static_cast<std::uint16_t>(std::lround(level));
+            if (mirror.damagePools == kHealthPool) {
+                mirror.damageHealth = rounded;
+                mirror.damageKnown = true;
+            } else if (mirror.damagePools == kShieldPool) {
+                mirror.damageShield = rounded;
+            }
+            if (mirror.damagePools != (std::numeric_limits<std::uint8_t>::max)()) {
+                ++mirror.damagePools;
+            }
+        }
+    }
     if (resolverContext.componentTag == 0x80C70EDCU && schemaHandle == 0x80C70EDCU) {
         wire::runtime::SchemaView component{}, source{}, reference{};
         wire::runtime::FieldView componentField{}, sourceField{};

+ 2 - 0
Sunrise/src/middleware/gameplay/external/composite_entity_codec.h

@@ -101,6 +101,8 @@ struct EntityBaselineMutation final : EntityBaselineChange {
     bool scoped{};
     std::array<EntityBaselineChange, kEntityBatchCapacity - 1> additionalChanges{};
     std::uint16_t additionalChangeCount{};
+    /** Descendants this batch detached because an ancestor received a record and they did not. */
+    std::uint16_t detachedCount{};
     std::uint64_t expectedAnchorOrder{}, replacementAnchorOrder{};
 };
 

+ 8 - 0
Sunrise/src/middleware/gameplay/external/composite_entity_codec_internal.h

@@ -45,6 +45,11 @@ struct ResolverContext final {
 class MirrorBuilder final {
 public:
     state::gameplay::entity_identity::ActorSourceReference actorSource{};
+    std::uint16_t damageHealth{};
+    std::uint16_t damageShield{};
+    bool damageKnown{};
+    /** Damage pools seen so far; each pool is its own nested walk. */
+    std::uint8_t damagePools{};
     /** Opens an empty fixed-capacity bit writer. */
     MirrorBuilder() noexcept : writer_(bytes_) {}
 
@@ -74,6 +79,9 @@ public:
         std::memcpy(candidate.state.data() + sizeof(header), bytes_.data(), byteCount);
         candidate.byteCount = static_cast<std::uint16_t>(sizeof(header) + byteCount);
         candidate.actorSource = actorSource;
+        candidate.damageHealth = damageHealth;
+        candidate.damageShield = damageShield;
+        candidate.damageKnown = damageKnown;
         output = candidate;
         return true;
     }

+ 29 - 0
Sunrise/src/middleware/gameplay/external/external_entity_codec.cpp

@@ -3,6 +3,7 @@
 #include <array>
 #include <memory>
 #include <new>
+#include <span>
 
 namespace sunrise::middleware::gameplay::external {
 namespace {
@@ -236,6 +237,33 @@ static TypePayloadCodec batch_codec(BatchCodecContext& context) noexcept {
     return (record.flags & entityUpdate) != 0 || record.update.byteCount == 0;
 }
 
+/**
+ * The client sends a child that is being removed as its own direct element, not inside its parent's
+ * implicit group. Drops those children from one resolved group; the anchor at index zero stays.
+ */
+static void drop_leaving_children(const EntityBatch& batch,
+                                  std::span<EntityToken> tokens,
+                                  std::size_t& count) noexcept {
+    const auto leaving = [&](const EntityToken& token) noexcept {
+        for (std::size_t index = 0; index < entity_record_count(batch); ++index) {
+            const auto& direct = entity_record_at(batch, index);
+            if (!direct.implicitToken && (direct.flags & entityRemove) != 0
+                && direct.token.slot == token.slot
+                && direct.token.incarnation == token.incarnation) {
+                return true;
+            }
+        }
+        return false;
+    };
+    std::size_t kept = 1;
+    for (std::size_t index = 1; index < count; ++index) {
+        if (!leaving(tokens[index])) {
+            tokens[kept++] = tokens[index];
+        }
+    }
+    count = count == 0 ? 0 : kept;
+}
+
 /** Writes one token in slot-then-incarnation order. */
 [[nodiscard]] bool write_token(encoding::bits::Writer& writer, const EntityToken& token) noexcept {
     return valid_token(token) && writer.write(token.slot, kEntitySlotWidth)
@@ -405,6 +433,7 @@ prepare_batch(const TypePayloadCodec& codec, const EntityBatch& batch, BatchPlan
                     || anchorCount == 0 || anchorCount > anchorTokens.size()) {
                     return false;
                 }
+                drop_leaving_children(batch, anchorTokens, anchorCount);
                 anchorIndex = 0;
             }
             if (anchorIndex >= anchorCount || anchorTokens[anchorIndex].slot != record.token.slot

+ 6 - 0
Sunrise/src/middleware/gameplay/external/external_entity_codec.h

@@ -27,6 +27,8 @@ inline constexpr std::uint16_t kNoEntityCell = 0xFFFF;
 inline constexpr std::size_t kMaximumTypePayloadBits = 16'384;
 /** Callback state can retain one complete bounded body plus its typed mirror header. */
 inline constexpr std::size_t kTypePayloadStateCapacity = kMaximumTypePayloadBits / 8U + 16U;
+/** A damage pool replicates a ten-bit level, so 1023 is full. */
+inline constexpr std::uint16_t kDamageLevelFull = 1023;
 
 /** Record flags transported by the five explicit envelope bits. */
 enum EntityRecordFlag : std::uint16_t {
@@ -68,6 +70,10 @@ struct TypePayload {
     std::array<std::byte, kTypePayloadStateCapacity> state{};
     std::uint16_t byteCount{};
     state::gameplay::entity_identity::ActorSourceReference actorSource{};
+    /** Damage component 0x80804BEE pools 0 and 1, ten-bit levels; valid when damageKnown. */
+    std::uint16_t damageHealth{};
+    std::uint16_t damageShield{};
+    bool damageKnown{};
 };
 
 /** One generic channel-2 record and its callback-owned type state. */

+ 2 - 4
Sunrise/src/middleware/web_service/messages/opcode501_codec.cpp

@@ -11,8 +11,9 @@ constexpr std::uint8_t kCharacterSoidWidth = 64;
 
 } // namespace
 
-/** Writes the echoed header, the shared status pair, the character id, and the trailer. */
+/** Writes the echoed header, the caller's status pair, the character id, and the trailer. */
 bool encode_response(const Message& message,
+                     const StatusResponse& status,
                      std::uint64_t characterSoid,
                      std::span<std::byte> output,
                      std::size_t& written) noexcept {
@@ -21,9 +22,6 @@ bool encode_response(const Message& message,
         return false;
     }
     encoding::bits::Writer writer = begin_response(message, output);
-    // The reply names a character the roster already holds, so no revision publishes it.
-    StatusResponse status{};
-    status.value = kNoFamily4Publication;
     // The id carries no presence bit or bias.
     bool encoded = status::write_fields(writer, ResponseShape::statusPair, status)
                    && writer.write(characterSoid, kCharacterSoidWidth)

+ 4 - 1
Sunrise/src/middleware/web_service/messages/opcode501_codec.h

@@ -13,13 +13,16 @@ inline constexpr std::uint16_t kOpcode = 501;
 
 /**
  * Encodes the create-character response: the status pair then the character object id.
+ * The client reads a refusal from the status code, not from the id.
  * @param message Parsed request whose envelope fields are echoed.
- * @param characterSoid Character object id; must be published in family 3.
+ * @param status Refusal code, and the Family-4 revision that carries the new character.
+ * @param characterSoid The new character's id; zero on a refusal.
  * @param output Caller-owned svc-11 response-body storage.
  * @param written Receives encoded response-body bytes.
  * @return True when the fixed response fits the output buffer.
  */
 [[nodiscard]] bool encode_response(const Message& message,
+                                   const StatusResponse& status,
                                    std::uint64_t characterSoid,
                                    std::span<std::byte> output,
                                    std::size_t& written) noexcept;

+ 156 - 0
Sunrise/src/middleware/web_service/messages/opcode501_request_codec.cpp

@@ -0,0 +1,156 @@
+#include "opcode501_request_codec.h"
+
+#include <array>
+#include <cstring>
+#include <type_traits>
+
+#include "../../encoding/bit_reader.h"
+
+namespace sunrise::middleware::web_service::messages::opcode501 {
+namespace {
+
+/** The identity triple and appearance header end here; the 64-byte name follows. */
+constexpr std::size_t kIdentityAndHeaderBits = 261;
+/** Each identity byte is preceded by a 1-bit presence flag. */
+constexpr std::uint8_t kPresenceBits = 1;
+/** Header fields travel as 8, 16 or 32 bits. */
+constexpr std::uint8_t kByteBits = 8;
+constexpr std::uint8_t kWordBits = 16;
+constexpr std::uint8_t kHashBits = 32;
+/** The request's last header field is 2 bits wide. */
+constexpr std::uint8_t kTrailingBits = 2;
+/** 8-bit fields travel biased by +0x80, 16-bit fields by +0x8000; 32-bit fields unbiased. */
+constexpr std::uint64_t kByteBias = 0x80U;
+constexpr std::uint64_t kWordBias = 0x8000U;
+/** Each of the header's two index runs holds 3 entries. */
+constexpr std::size_t kIndexRunCount = 3;
+/** The record requires 1 in the last field; the request always carries 0 there. */
+constexpr std::uint8_t kRecordTrailingValue = 1;
+/** Padding after the last field fills the record to its 4-byte alignment. */
+constexpr std::size_t kTailPadding = 3;
+
+/** The appearance header in record byte order. Field meanings are not decoded. */
+struct HeaderLayout {
+    std::uint16_t word00;
+    std::uint8_t byte02;
+    std::uint8_t byte03;
+    std::uint8_t byte04;
+    std::uint8_t padding05;
+    std::uint16_t word06;
+    std::uint16_t word08;
+    std::uint16_t word10;
+    std::array<std::uint16_t, kIndexRunCount> indices12;
+    std::array<std::uint16_t, kIndexRunCount> indices18;
+    std::uint16_t word24;
+    std::uint16_t padding26;
+    std::uint32_t hash28;
+    std::uint8_t trailing32;
+    std::array<std::uint8_t, kTailPadding> padding33;
+};
+static_assert(sizeof(HeaderLayout) == state::kAppearanceHeaderSize);
+static_assert(std::is_trivially_copyable_v<HeaderLayout>);
+
+/** Highest valid race, gender and class wire values, matching the state enums. */
+constexpr std::uint8_t kMaxRace = 2;
+constexpr std::uint8_t kMaxGender = 1;
+constexpr std::uint8_t kMaxClass = 2;
+
+/**
+ * Reads one presence flag and the 8-bit biased value behind it.
+ * @param reader Request bit reader.
+ * @param value Receives the unbiased value.
+ * @return False when the field is absent or short. Nothing else carries an absent value.
+ */
+[[nodiscard]] bool read_present_byte(encoding::bits::Reader& reader, std::uint8_t& value) noexcept {
+    std::uint64_t present = 0;
+    std::uint64_t raw = 0;
+    if (!reader.read(kPresenceBits, present) || present == 0 || !reader.read(kByteBits, raw)) {
+        return false;
+    }
+    value = static_cast<std::uint8_t>(raw - kByteBias);
+    return true;
+}
+
+/** Reads one 8-bit +0x80-biased header field. @return False when the payload is short. */
+[[nodiscard]] bool read_byte(encoding::bits::Reader& reader, std::uint8_t& value) noexcept {
+    std::uint64_t raw = 0;
+    if (!reader.read(kByteBits, raw)) {
+        return false;
+    }
+    value = static_cast<std::uint8_t>(raw - kByteBias);
+    return true;
+}
+
+/** Reads one 16-bit +0x8000-biased header field. @return False when the payload is short. */
+[[nodiscard]] bool read_word(encoding::bits::Reader& reader, std::uint16_t& value) noexcept {
+    std::uint64_t raw = 0;
+    if (!reader.read(kWordBits, raw)) {
+        return false;
+    }
+    value = static_cast<std::uint16_t>(raw - kWordBias);
+    return true;
+}
+
+/**
+ * Reads the appearance header fields in wire order.
+ * @param reader Request bit reader, just past the identity triple.
+ * @param header Receives the fields; padding stays zero.
+ * @return False when the payload is short.
+ */
+[[nodiscard]] bool read_header(encoding::bits::Reader& reader, HeaderLayout& header) noexcept {
+    if (!read_word(reader, header.word00) || !read_byte(reader, header.byte02)
+        || !read_byte(reader, header.byte03) || !read_byte(reader, header.byte04)
+        || !read_word(reader, header.word06) || !read_word(reader, header.word08)
+        || !read_word(reader, header.word10)) {
+        return false;
+    }
+    for (std::uint16_t& index : header.indices12) {
+        if (!read_word(reader, index)) {
+            return false;
+        }
+    }
+    for (std::uint16_t& index : header.indices18) {
+        if (!read_word(reader, index)) {
+            return false;
+        }
+    }
+    std::uint64_t hash = 0;
+    std::uint64_t trailing = 0;
+    if (!read_word(reader, header.word24) || !reader.read(kHashBits, hash)
+        || !reader.read(kTrailingBits, trailing)) {
+        return false;
+    }
+    header.hash28 = static_cast<std::uint32_t>(hash);
+    // The request's own trailing value is read past but not kept.
+    header.trailing32 = kRecordTrailingValue;
+    return true;
+}
+
+} // namespace
+
+/**
+ * Parses the create-character request body: the identity triple, then the appearance header.
+ * The trailing 64-byte name is not read.
+ * @param message Parsed Web Service envelope.
+ * @param request Receives the creation fields, the header in record byte order.
+ * @return False when the payload is short, a presence flag is clear, or a value is out of range.
+ */
+bool parse_request(const Message& message, Request& request) noexcept {
+    request = {};
+    if (message.opcode != kOpcode || message.payload.size() * kByteBits < kIdentityAndHeaderBits) {
+        return false;
+    }
+    encoding::bits::Reader reader(message.payload);
+    HeaderLayout header{};
+    if (!read_present_byte(reader, request.race) || !read_present_byte(reader, request.gender)
+        || !read_present_byte(reader, request.characterClass) || request.race > kMaxRace
+        || request.gender > kMaxGender || request.characterClass > kMaxClass
+        || !read_header(reader, header)) {
+        request = {};
+        return false;
+    }
+    std::memcpy(request.appearanceHeader.data(), &header, sizeof header);
+    return true;
+}
+
+} // namespace sunrise::middleware::web_service::messages::opcode501

+ 29 - 0
Sunrise/src/middleware/web_service/messages/opcode501_request_codec.h

@@ -0,0 +1,29 @@
+#pragma once
+
+#include <cstdint>
+
+#include "../../../state/account/account_state.h"
+#include "../web_service_envelope.h"
+#include "opcode501_codec.h"
+
+namespace sunrise::middleware::web_service::messages::opcode501 {
+
+/** The create-character request: the identity triple plus the authored appearance header. */
+struct Request {
+    std::uint8_t characterClass{};
+    std::uint8_t gender{};
+    std::uint8_t race{};
+    /** Record byte order. Its values index per-race tables, so it only fits its own `race`. */
+    state::AppearanceHeader appearanceHeader{};
+};
+
+/**
+ * Parses the create-character request body, a big-endian bitstream with no byte alignment.
+ * Race, gender and class each carry a presence bit and a +0x80 bias; the header follows.
+ * @param message Parsed Web Service envelope.
+ * @param request Receives the creation fields, the header in record byte order.
+ * @return False when the payload is short, a presence flag is clear, or a value is out of range.
+ */
+[[nodiscard]] bool parse_request(const Message& message, Request& request) noexcept;
+
+} // namespace sunrise::middleware::web_service::messages::opcode501

+ 30 - 0
Sunrise/src/middleware/web_service/messages/opcode502.cpp

@@ -0,0 +1,30 @@
+#include "opcode502.h"
+
+#include "../../encoding/bit_reader.h"
+
+namespace sunrise::middleware::web_service::messages::opcode502 {
+namespace {
+
+/** The request is one unbiased 64-bit character id with no presence bit. */
+constexpr std::uint8_t kCharacterSoidWidth = 64;
+/** Those 64 bits need 8 payload bytes. */
+constexpr std::size_t kMinimumPayloadSize = 8;
+
+} // namespace
+
+/**
+ * Parses the delete-character request: one 64-bit character id.
+ * @param message Parsed Web Service envelope.
+ * @param request Receives the targeted character id.
+ * @return False when the opcode differs or the id is short.
+ */
+bool parse_request(const Message& message, Request& request) noexcept {
+    request = {};
+    if (message.opcode != kOpcode || message.payload.size() < kMinimumPayloadSize) {
+        return false;
+    }
+    encoding::bits::Reader reader(message.payload);
+    return reader.read(kCharacterSoidWidth, request.characterSoid);
+}
+
+} // namespace sunrise::middleware::web_service::messages::opcode502

+ 25 - 0
Sunrise/src/middleware/web_service/messages/opcode502.h

@@ -0,0 +1,25 @@
+#pragma once
+
+#include <cstdint>
+
+#include "../web_service_envelope.h"
+
+namespace sunrise::middleware::web_service::messages::opcode502 {
+
+/** Web Service opcode for the delete-character request. */
+inline constexpr std::uint16_t kOpcode = 502;
+
+/** The targeted character. */
+struct Request {
+    std::uint64_t characterSoid{};
+};
+
+/**
+ * Parses the bare 64-bit targeted-character id.
+ * @param message Parsed Web Service envelope.
+ * @param request Receives the targeted character id.
+ * @return True when the whole id is present.
+ */
+[[nodiscard]] bool parse_request(const Message& message, Request& request) noexcept;
+
+} // namespace sunrise::middleware::web_service::messages::opcode502

+ 16 - 1
Sunrise/src/server/activity/activity_sdk_device_prepare.cpp

@@ -11,6 +11,7 @@
 #include "../../middleware/bap/activity_message/scene_events_auth.h"
 #include "../../middleware/bap/activity_message/sensor_auth_update.h"
 #include "../../middleware/bap/activity_message/squad_objective_auth.h"
+#include "../../middleware/bap/activity_message/volume_toggle_auth.h"
 #include "../../middleware/content/packages/tables/region_reader.h"
 #include "../../state/activity/runtime.h"
 #include "activity_sdk_device_internal.h"
@@ -503,8 +504,22 @@ prepare_slot(const sdk::BoundView& view, std::uint32_t slotRow, PreparedDevice&
         slotType == middleware::bap::activity_message::scriptable_auth::kType2SlotType
         && authSchema == middleware::bap::activity_message::scriptable_auth::kType2Schema
         && middleware::bap::activity_message::scriptable_auth::validate_type2_body(body, bitCount);
+    auth::Type32VolumeBody volumeBody{};
+    bool volumeToggle = slotType == auth::kType32SlotType && authSchema == auth::kType32Schema
+                        && slot.componentClass == auth::kType32ComponentClass
+                        && auth::decode_type32_volume(body, bitCount, volumeBody)
+                        && volumeBody.registryKey == registryKey;
+    if (volumeToggle) {
+        // Validate the inner reference again at dispatch under the same SDK/owner binding.
+        const auto matches = std::count_if(slots.begin(), slots.end(), [&](const auto& target) {
+            return target.objectIndex == slot.objectIndex
+                   && target.slotType == auth::kVolumeSlotType
+                   && target.slotIndex == static_cast<std::uint16_t>(volumeBody.slotIndex);
+        });
+        volumeToggle = matches == 1;
+    }
     if (!typedSdkBody && !occupancy && !directive && !engagement && !publicEvent && !performance
-        && !combatant) {
+        && !combatant && !volumeToggle) {
         return Status::invalidBody;
     }
     return Status::ready;

+ 8 - 4
Sunrise/src/server/activity/activity_sdk_squad_runtime.cpp

@@ -299,7 +299,7 @@ struct PreparedSquad final {
     if (squad.scenarioIndex != view.scenarioRow) {
         return Status::wrongScenario;
     }
-    if ((squad.flags & format::kSquadRunnableMask) != format::kSquadRunnableMask) {
+    if (!format::squad_runnable(squad.flags)) {
         return Status::notRunnable;
     }
     const Status members = member_status(catalog, squad, squadRow, requestedCounts);
@@ -437,11 +437,15 @@ retirement_eligibility(const sdk::BoundView& view,
                                         std::optional<std::uint32_t> spawnRuleSlotRow,
                                         std::optional<squad_auth::SpawnRule>& output) noexcept {
     output.reset();
-    if (!spawnRuleSlotRow.has_value()) {
-        return Status::ready;
-    }
     const auto squads = catalog.squads();
     const auto slots = catalog.slots();
+    if (!spawnRuleSlotRow.has_value()) {
+        // A spawner with no rule of its own cannot be placed without a selected one.
+        return squadRow < squads.size()
+                       && (squads[squadRow].flags & format::kSquadRequiresSelectedRule) != 0
+                   ? Status::notRunnable
+                   : Status::ready;
+    }
     if (squadRow >= squads.size() || *spawnRuleSlotRow >= slots.size()) {
         return Status::invalidSquad;
     }

+ 54 - 5
Sunrise/src/server/activity/host_runtime.h

@@ -148,11 +148,13 @@ enum class EventKind : std::uint8_t {
     regionChanged = 40,
     /** A watched authored scene newly reported one of its output gates; from one msg 6 body. */
     sceneOutput = 41,
+    /** Validated type-30 level or explicit continuity invalidation. */
+    triggerState = 42,
 };
 
 /** Kinds are numbered without gaps, so the last one plus one is the count. */
 inline constexpr std::size_t kEventKindCount =
-    static_cast<std::size_t>(EventKind::sceneOutput) + 1U;
+    static_cast<std::size_t>(EventKind::triggerState) + 1U;
 
 /**
  * Terminal delivery outcome of one script-requested effect.
@@ -526,6 +528,10 @@ struct Event final {
     std::int32_t triggerValue{};
     /** True when the whole watched set is inside the volume. */
     bool triggerAll{};
+    /** For triggerState events: whether a level was reported, and the volume's occupancy. */
+    bool triggerAvailable{}, triggerOccupied{};
+    /** trigger_observation::Continuity of the report, for triggerState events. */
+    std::uint8_t triggerContinuity{};
     /** Health and shield fractions, for damageState events. Negative until published. */
     float damageHealth{-1.0F};
     float damageShield{-1.0F};
@@ -569,6 +575,11 @@ struct Event final {
     bool squadObjectiveCostQualified{};
     /** Per-slot member counts the client published, for squadState events. */
     std::array<std::int32_t, kSquadSlotCapacity> squadSlotCounts{};
+    /** Spawn generation the squad Sense echoes from its placement Auth; not the record counter. */
+    std::int32_t squadSpawnGeneration{};
+    bool squadHasSpawnGeneration{};
+    /** An alive count was reported for this squad; until then the counts are zero placeholders. */
+    bool squadPopulationAvailable{};
     /** Alive members the client published. Six bits on the wire, so 0 through 63. */
     std::int32_t squadAliveCount{};
     /** Alive count this observation replaced, for entityDied events. */
@@ -583,6 +594,14 @@ struct Event final {
     std::uint8_t squadSlotOrdinal{};
     /** Client flag written on its actor death or removal path. The meaning is unproved. */
     bool squadRemovalFlag{};
+    /** Msg-6 record counter plus one; not a squad or actor lifetime identity. */
+    std::uint32_t senseGenerationPlusOne{};
+    bool hasSenseGeneration{};
+    /** First report of this squad or object generation; it never proves a death. */
+    bool initialObservation{};
+    /** Authored entry index and spawn-mask words, for objectState events. */
+    std::int32_t objectEntryIndex{};
+    std::array<std::uint32_t, 2> objectSpawnMask{};
     /** Activation token the authored scene echoed, for sceneFinished and sceneOutput events. */
     std::int32_t sceneActivationToken{};
     /** Output gate key the scene newly reported, for sceneOutput events. */
@@ -924,11 +943,41 @@ request_squad_objective(const state::activity::SessionBinding& binding,
                         std::uint64_t expectedActivityClientGeneration,
                         const ScriptableOutputReservation& reservation) noexcept;
 
-/** Queues one owned msg-6 prefix for the Activity Host service. */
-[[nodiscard]] bool submit_sense(const SenseInput& input) noexcept;
+/** Names the gate that declined one ingress submission. */
+enum class IngressRefusal : std::uint8_t {
+    /** The submission was queued. */
+    none,
+    /** The named session no longer matches a live binding. */
+    binding,
+    /** The submission carried no source generation to bind it to. */
+    generation,
+    /** The shared codec refused a bounded outer field. */
+    payload,
+    /** The pending-input ring held no free slot. */
+    queue,
+};
+
+/**
+ * Names the ingress gate that declined, for the skip diagnostic. Every name starts with
+ * `host_ingress_refused`.
+ * @param refusal Gate reported by one failed submission.
+ * @return Stable reason name.
+ */
+[[nodiscard]] const char* ingress_refusal_name(IngressRefusal refusal) noexcept;
 
-/** Queues one owned, outer-valid client msg 19 for the Activity Host service. */
-[[nodiscard]] bool submit_incident(const IncidentInput& input) noexcept;
+/**
+ * Queues one owned msg-6 prefix for the Activity Host service.
+ * @param input Owned prefix and the binding it arrived on.
+ * @return The gate that declined, or IngressRefusal::none once queued.
+ */
+[[nodiscard]] IngressRefusal submit_sense(const SenseInput& input) noexcept;
+
+/**
+ * Queues one owned, outer-valid client msg 19 for the Activity Host service.
+ * @param input Parsed incident and the binding it arrived on.
+ * @return The gate that declined, or IngressRefusal::none once queued.
+ */
+[[nodiscard]] IngressRefusal submit_incident(const IncidentInput& input) noexcept;
 
 /** Queues one committed client msg-22 numeric after-image for the Activity Host service. */
 [[nodiscard]] bool submit_client_state_change(const ClientStateChangeInput& input) noexcept;

+ 8 - 5
Sunrise/src/server/activity/host_runtime_incidents.cpp

@@ -202,9 +202,12 @@ void reset_incidents() noexcept {
 } // namespace detail
 
 /** Queues one owned, outer-valid client msg 19 for the Activity Host service. */
-bool submit_incident(const IncidentInput& input) noexcept {
-    if (!state::activity::binding_matches(input.binding) || !incident_allowed(input.incident)) {
-        return false;
+IngressRefusal submit_incident(const IncidentInput& input) noexcept {
+    if (!state::activity::binding_matches(input.binding)) {
+        return IngressRefusal::binding;
+    }
+    if (!incident_allowed(input.incident)) {
+        return IngressRefusal::payload;
     }
     AcquireSRWLockExclusive(&g_lock);
     PendingInput pending{};
@@ -214,11 +217,11 @@ bool submit_incident(const IncidentInput& input) noexcept {
         ++g_droppedIngress;
         ++g_droppedIncidents;
         ReleaseSRWLockExclusive(&g_lock);
-        return false;
+        return IngressRefusal::queue;
     }
     ++g_queuedIngress;
     ReleaseSRWLockExclusive(&g_lock);
-    return true;
+    return IngressRefusal::none;
 }
 
 /** Queues one outer-valid operator msg 19 for the exact activity generation. */

+ 19 - 0
Sunrise/src/server/activity/host_runtime_names.cpp

@@ -3,6 +3,23 @@
 
 namespace sunrise::server::activity::host {
 
+/** @return Stable reason name for one declined ingress submission. */
+const char* ingress_refusal_name(IngressRefusal refusal) noexcept {
+    switch (refusal) {
+    case IngressRefusal::binding:
+        return "host_ingress_refused_binding";
+    case IngressRefusal::generation:
+        return "host_ingress_refused_generation";
+    case IngressRefusal::payload:
+        return "host_ingress_refused_payload";
+    case IngressRefusal::queue:
+        return "host_ingress_refused_queue";
+    case IngressRefusal::none:
+        break;
+    }
+    return "host_ingress_refused";
+}
+
 /** @return Stable UI name for one event kind. */
 const char* event_name(EventKind kind) noexcept {
     switch (kind) {
@@ -42,6 +59,8 @@ const char* event_name(EventKind kind) noexcept {
         return "script effect result";
     case EventKind::phaseEntered:
         return "mission phase entered";
+    case EventKind::triggerState:
+        return "trigger volume state";
     case EventKind::triggerEntered:
         return "trigger volume entered";
     case EventKind::triggerExited:

+ 8 - 5
Sunrise/src/server/activity/host_runtime_sense.cpp

@@ -587,9 +587,12 @@ void apply_sense(const SenseInput& input, std::uint64_t now) noexcept {
 } // namespace detail
 
 /** Queues one owned msg-6 prefix for the Activity Host service. */
-bool submit_sense(const SenseInput& input) noexcept {
-    if (!state::activity::binding_matches(input.binding) || input.sourceGeneration == 0) {
-        return false;
+IngressRefusal submit_sense(const SenseInput& input) noexcept {
+    if (!state::activity::binding_matches(input.binding)) {
+        return IngressRefusal::binding;
+    }
+    if (input.sourceGeneration == 0) {
+        return IngressRefusal::generation;
     }
     AcquireSRWLockExclusive(&g_lock);
     PendingInput pending{};
@@ -598,11 +601,11 @@ bool submit_sense(const SenseInput& input) noexcept {
     if (!append_pending(pending)) {
         ++g_droppedIngress;
         ReleaseSRWLockExclusive(&g_lock);
-        return false;
+        return IngressRefusal::queue;
     }
     ++g_queuedIngress;
     ReleaseSRWLockExclusive(&g_lock);
-    return true;
+    return IngressRefusal::none;
 }
 
 /** Copies initialized recovery state for one exact ActivityClient generation. */

+ 74 - 7
Sunrise/src/server/activity/mission/mission_api_events.h

@@ -52,6 +52,9 @@ enum class FieldGroupId : std::uint8_t {
     ghostLink,
     squadProvoked,
     fireteam,
+    senseGeneration,
+    squadRemoval,
+    triggerLevel,
     count,
 };
 
@@ -116,6 +119,16 @@ inline constexpr ValueSpec kTaskGroupReturns[]{
 };
 inline constexpr ValueSpec kAppliedRequestArguments[]{
     {.name = "channel", .kind = ValueKind::enumValue, .ref = "device_channels"}};
+inline constexpr ValueSpec kTaskCosts[]{
+    {.kind = ValueKind::table,
+     .element = kNumber,
+     .note = "reported cost per task group, keyed from 1; a group without one is absent"}};
+inline constexpr ValueSpec kContinuity[]{
+    {.kind = ValueKind::string,
+     .note = "baseline, consecutive, duplicate, conflict, gap, reset, missing_counter, wrap or "
+             "invalid"}};
+inline constexpr ValueSpec kOptionalBoolean[]{{.kind = ValueKind::boolean, .optional = true}};
+inline constexpr std::string_view kUnavailableLevel = "nil when available is false";
 
 // --- Field groups ------------------------------------------------------------------------------
 // Members of each group, then the group itself.
@@ -220,8 +233,15 @@ inline constexpr MemberSpec kSquadStateFields[]{
      .arguments = kTaskGroupQueryArguments,
      .returns = kTaskGroupReturns,
      .note = "the group the squad holds for this objective"},
-    field("removal_flag", kBoolean),
     field("slot_counts", kIntegerList),
+    field("initial_observation", kBoolean, "the first report of this squad; never a death"),
+    field("population_available",
+          kBoolean,
+          "false until an alive count is reported; until then the counts are zero placeholders"),
+    field("objective_revision",
+          kInteger,
+          "objective revision the squad echoed with its costs; 0 before any"),
+    field("task_costs", kTaskCosts),
 };
 inline constexpr MemberSpec kEntitySpawnedFields[]{
     field("member_slot", kInteger),
@@ -294,6 +314,10 @@ inline constexpr MemberSpec kObjectFields[]{
     field("owner_known", kBoolean),
     field("has_owner", kBoolean),
     field("owner_key", kOptionalDecimal, "nil without an owner"),
+    field("initial_observation", kBoolean, "first report of this generation"),
+    field("entry_index", kInteger, "authored entry the object instantiated"),
+    field("spawn_mask_low", kInteger),
+    field("spawn_mask_high", kInteger),
 };
 inline constexpr MemberSpec kGhostLinkFields[]{
     field("generation", kInteger),
@@ -310,6 +334,22 @@ inline constexpr MemberSpec kFireteamFields[]{
     field("dead_count", kInteger),
     field("unknown_count", kInteger),
 };
+inline constexpr MemberSpec kSenseGenerationFields[]{
+    field("spawn_generation",
+          kOptionalInteger,
+          "spawn generation the squad Sense echoes from its placement; nil when unreported"),
+    field("sense_generation", kOptionalInteger, "msg-6 record counter plus one; nil when absent"),
+};
+inline constexpr MemberSpec kSquadRemovalFields[]{
+    field("removal_flag", kBoolean, "false when the report did not carry it")};
+inline constexpr MemberSpec kTriggerLevelFields[]{
+    field("available", kBoolean, "the report carried a complete occupancy level"),
+    field("continuity", kContinuity),
+    field("occupied", kOptionalBoolean, kUnavailableLevel),
+    field("member_count", kOptionalInteger, kUnavailableLevel),
+    field("value", kOptionalInteger, kUnavailableLevel),
+    field("all_inside", kOptionalBoolean, kUnavailableLevel),
+};
 
 /** Every field group, indexed by FieldGroupId. */
 inline constexpr std::array<FieldGroup, static_cast<std::size_t>(FieldGroupId::count)> kFieldGroups{
@@ -350,6 +390,9 @@ inline constexpr std::array<FieldGroup, static_cast<std::size_t>(FieldGroupId::c
         {"ghost_link", kGhostLinkFields},
         {"squad_provoked", kSquadProvokedFields},
         {"fireteam", kFireteamFields},
+        {"sense_generation", kSenseGenerationFields},
+        {"squad_removal", kSquadRemovalFields},
+        {"trigger_level", kTriggerLevelFields},
     }};
 
 /** @return The address of one field group, for an event's group list. */
@@ -391,15 +434,29 @@ inline constexpr const FieldGroup* kPhaseGroups[]{
     group(G::common), group(G::missionSequence), group(G::phase), group(G::stateRevision)};
 inline constexpr const FieldGroup* kTriggerGroups[]{
     group(G::common), group(G::missionSequence), group(G::slotIdentity), group(G::trigger)};
+inline constexpr const FieldGroup* kTriggerStateGroups[]{group(G::common),
+                                                         group(G::missionSequence),
+                                                         group(G::slotIdentity),
+                                                         group(G::senseGeneration),
+                                                         group(G::triggerLevel)};
 inline constexpr const FieldGroup* kSquadStateGroups[]{group(G::common),
                                                        group(G::missionSequence),
                                                        group(G::slotIdentity),
+                                                       group(G::senseGeneration),
                                                        group(G::squadAlive),
+                                                       group(G::squadRemoval),
                                                        group(G::squadState)};
-inline constexpr const FieldGroup* kEntitySpawnedGroups[]{
-    group(G::common), group(G::missionSequence), group(G::slotIdentity), group(G::entitySpawned)};
-inline constexpr const FieldGroup* kEntityDiedGroups[]{
-    group(G::common), group(G::missionSequence), group(G::slotIdentity), group(G::squadAlive)};
+inline constexpr const FieldGroup* kEntitySpawnedGroups[]{group(G::common),
+                                                          group(G::missionSequence),
+                                                          group(G::slotIdentity),
+                                                          group(G::senseGeneration),
+                                                          group(G::entitySpawned)};
+inline constexpr const FieldGroup* kEntityDiedGroups[]{group(G::common),
+                                                       group(G::missionSequence),
+                                                       group(G::slotIdentity),
+                                                       group(G::senseGeneration),
+                                                       group(G::squadAlive),
+                                                       group(G::squadRemoval)};
 inline constexpr const FieldGroup* kSceneFinishedGroups[]{
     group(G::common), group(G::missionSequence), group(G::slotIdentity), group(G::sceneFinished)};
 inline constexpr const FieldGroup* kObjectiveProgressGroups[]{group(G::common),
@@ -420,8 +477,11 @@ inline constexpr const FieldGroup* kGhostLinkGroups[]{
     group(G::common), group(G::missionSequence), group(G::slotIdentity), group(G::ghostLink)};
 inline constexpr const FieldGroup* kActorPathGroups[]{
     group(G::common), group(G::missionSequence), group(G::slotIdentity), group(G::actorPath)};
-inline constexpr const FieldGroup* kObjectGroups[]{
-    group(G::common), group(G::missionSequence), group(G::slotIdentity), group(G::object)};
+inline constexpr const FieldGroup* kObjectGroups[]{group(G::common),
+                                                   group(G::missionSequence),
+                                                   group(G::slotIdentity),
+                                                   group(G::senseGeneration),
+                                                   group(G::object)};
 inline constexpr const FieldGroup* kFireteamGroups[]{
     group(G::common), group(G::missionSequence), group(G::fireteam)};
 inline constexpr const FieldGroup* kDamageGroups[]{
@@ -597,6 +657,13 @@ inline constexpr EventSpec kEvents[]{
     {39, "DEVICE_STATE", "on_event_device_state", "SunriseDeviceStateEvent", kDeviceGroups},
     {40, "REGION_CHANGED", "on_event_region_changed", "SunriseRegionChangedEvent", kRegionGroups},
     {41, "SCENE_OUTPUT", "on_event_scene_output", "SunriseSceneOutputEvent", kSceneOutputGroups},
+    {42,
+     "TRIGGER_STATE",
+     "on_event_trigger_state",
+     "SunriseTriggerStateEvent",
+     kTriggerStateGroups,
+     "every accepted occupancy report and its counter continuity; it never changes when "
+     "entered and exited fire"},
 };
 
 // --- Program table -----------------------------------------------------------------------------

+ 33 - 1
Sunrise/src/server/activity/mission/mission_api_slot.h

@@ -10,6 +10,7 @@
 #include "../../../middleware/bap/activity_message/music_section_auth.h"
 #include "../../../middleware/bap/activity_message/scriptable_auth_body.h"
 #include "../../../middleware/bap/activity_message/squad_objective_auth.h"
+#include "../../../middleware/bap/activity_message/volume_toggle_auth.h"
 #include "../../../state/activity/mission/definition.h"
 #include "../../../state/activity_sdk/format.h"
 #include "mission_api_types.h"
@@ -58,6 +59,8 @@ inline constexpr ValueSpec kAuthType[]{{.kind = ValueKind::string,
 // --- Slot method arguments ----------------------------------------------------------------------
 inline constexpr ValueSpec kVolume[]{
     {.kind = ValueKind::handle, .ref = "slot", .slot = type_gate(slot_auth::kType60SlotType)}};
+inline constexpr ValueSpec kFilterObject[]{
+    {.kind = ValueKind::handle, .ref = "slot", .slot = type_gate(slot_format::kObjectSlotType)}};
 inline constexpr ValueSpec kObjectFilterArguments[]{
     {.name = "players",
      .kind = ValueKind::boolean,
@@ -70,6 +73,18 @@ inline constexpr ValueSpec kObjectFilterArguments[]{
      .optional = true,
      .slot = type_gate(slot_format::kObjectSlotType),
      .note = "adds a test: this one object"},
+    {.name = "targets",
+     .kind = ValueKind::list,
+     .optional = true,
+     .bound = between(1, static_cast<std::int64_t>(slot_auth::kType34PredicateCapacity)),
+     .element = kFilterObject,
+     .note = "adds one test per object"},
+    {.name = "squad",
+     .kind = ValueKind::handle,
+     .ref = "slot",
+     .optional = true,
+     .slot = type_gate(slot_format::kSquadSlotType),
+     .note = "adds a test: this squad"},
     {.name = "inside",
      .kind = ValueKind::handle,
      .ref = "slot",
@@ -83,6 +98,15 @@ inline constexpr ValueSpec kObjectFilterArguments[]{
      .element = kVolume,
      .note = "adds a test: objects inside any of these volumes"},
 };
+inline constexpr ValueSpec kVolumeActiveArguments[]{
+    {.name = "volume",
+     .kind = ValueKind::handle,
+     .ref = "slot",
+     .slot = type_gate(slot_auth::kVolumeSlotType),
+     .scope = Scope::sameObject,
+     .note = "an authored volume of the toggle's own object"},
+    {.name = "active", .kind = ValueKind::boolean, .note = "true turns it on, false off"},
+};
 inline constexpr ValueSpec kWatchDamageArguments[]{
     {.name = "target", .kind = ValueKind::handle, .ref = "slot", .slot = kExactObject},
 };
@@ -433,7 +457,15 @@ inline constexpr MemberSpec kSlotMembers[]{
      .call = CallForm::named,
      .gate = schema_gate(slot_auth::kType34SlotType),
      .arguments = kObjectFilterArguments,
-     .returns = kRequestKey},
+     .returns = kRequestKey,
+     .note = "all tests share eight predicates; a filter that needs more is refused"},
+    {.name = "set_volume_active",
+     .kind = MemberKind::request,
+     .call = CallForm::named,
+     .gate = exact_class_gate(slot_auth::kType32SlotType),
+     .arguments = kVolumeActiveArguments,
+     .returns = kRequestKey,
+     .note = "turns one authored volume on or off through its type-32 toggle"},
     {.name = "watch_damage",
      .kind = MemberKind::request,
      .call = CallForm::named,

+ 1 - 2
Sunrise/src/server/activity/mission/mission_script_actor_command_policy.cpp

@@ -31,8 +31,7 @@ dispatch_actor_command(const RuntimeInstance& instance, const lua_vm::Intent& in
     }
     const auto squads = published->squads();
     if (intent.firstRow >= squads.size()
-        || (squads[intent.firstRow].flags & format::kSquadRunnableMask)
-               != format::kSquadRunnableMask) {
+        || !format::squad_runnable(squads[intent.firstRow].flags)) {
         return ActorCommandPolicyStatus::refused;
     }
     const auto commands = published->actor_command_definitions();

+ 76 - 16
Sunrise/src/server/activity/mission/mission_script_lua_device_api.cpp

@@ -131,12 +131,22 @@ constexpr const api::MemberSpec& kApplied = api::slot_member("applied");
 constexpr std::size_t kOccupancyAuthBitCount = 87;
 constexpr std::size_t kOccupancyAuthByteCount = 11;
 
-/** Volumes one filter may test; leaves room for the players, target and inside predicates. */
+/** Volumes one filter may test. Every predicate still shares the type-34 capacity. */
 constexpr std::size_t kMaximumFilterVolumes = 5;
 /** Type-34 predicate modes: 0 tests the flag or reference as given, 1 tests inside a volume. */
 constexpr std::int8_t kFilterModeDirect = 0;
 constexpr std::int8_t kFilterModeInside = 1;
 
+/** Appends one predicate. @return False when the type-34 body is already full. */
+[[nodiscard]] bool append_predicate(scriptable_auth::Type34Body& body,
+                                    const scriptable_auth::Type34Predicate& predicate) noexcept {
+    if (body.count >= scriptable_auth::kType34PredicateCapacity) {
+        return false;
+    }
+    body.predicates[body.count++] = predicate;
+    return true;
+}
+
 } // namespace
 
 /**
@@ -210,7 +220,10 @@ constexpr std::int8_t kFilterModeInside = 1;
     return queue_slot_auth(state, slot, darkness::kSchema, darkness::kBits, body);
 }
 
-/** Native typed object filters: players, one object, and volume intersection. */
+/**
+ * Native typed object filters: players, one or several objects, a squad, and volume intersection.
+ * Every predicate shares the type-34 capacity; a filter that would exceed it is refused.
+ */
 [[nodiscard]] int slot_set_object_filter(lua_State* state) {
     namespace auth = scriptable_auth;
     const auto* const handle =
@@ -221,6 +234,7 @@ constexpr std::int8_t kFilterModeInside = 1;
         || slot.authSchema != auth::kType34Schema) {
         return luaL_error(state, "object filter requires an authored type-34 sensor");
     }
+    constexpr const char* kCapacityError = "object filter exceeds the type-34 predicate capacity";
     auth::Type34Body body{};
     lua_getfield(state, 2, optional_key(kObjectFilter, "inside_any", api::ValueKind::list));
     const bool volumes = !lua_isnil(state, -1);
@@ -239,19 +253,22 @@ constexpr std::int8_t kFilterModeInside = 1;
                 || volume.slotType != auth::kType60SlotType) {
                 return luaL_error(state, "inside_any requires authored type-60 volumes");
             }
-            body.predicates[body.count++] =
-                auth::Type34ModeFlagSlotRef{kFilterModeDirect,
-                                            true,
-                                            {volume.registryKey,
-                                             static_cast<std::int8_t>(auth::kType60SlotType),
-                                             static_cast<std::int16_t>(volume.slotIndex)}};
+            if (!append_predicate(
+                    body,
+                    auth::Type34ModeFlagSlotRef{kFilterModeDirect,
+                                                true,
+                                                {volume.registryKey,
+                                                 static_cast<std::int8_t>(auth::kType60SlotType),
+                                                 static_cast<std::int16_t>(volume.slotIndex)}})) {
+                return luaL_error(state, kCapacityError);
+            }
             lua_pop(state, 1);
         }
     }
     lua_pop(state, 1);
-    if (optional_boolean_argument(state, boolean_option(kObjectFilter, "players"))) {
-        body.predicates[body.count++] =
-            auth::Type34ModeOnlyB{static_cast<std::int8_t>(volumes ? 1 : 0)};
+    if (optional_boolean_argument(state, boolean_option(kObjectFilter, "players"))
+        && !append_predicate(body, auth::Type34ModeOnlyB{static_cast<std::int8_t>(volumes)})) {
+        return luaL_error(state, kCapacityError);
     }
     auth::Type2LaneClientRef target{};
     if (!optional_slot_reference(state,
@@ -260,8 +277,51 @@ constexpr std::int8_t kFilterModeInside = 1;
                                  target)) {
         return luaL_error(state, "filter target must be an authored type-4 object");
     }
-    if (target.slotIndex >= 0) {
-        body.predicates[body.count++] = auth::Type34ModeSlotRefC{kFilterModeDirect, target};
+    if (target.slotIndex >= 0
+        && !append_predicate(body, auth::Type34ModeSlotRefC{kFilterModeDirect, target})) {
+        return luaL_error(state, kCapacityError);
+    }
+    // Several authored objects at once (a beam hop-on names every crystal it tethers).
+    lua_getfield(state, 2, optional_key(kObjectFilter, "targets", api::ValueKind::list));
+    if (!lua_isnil(state, -1)) {
+        luaL_checktype(state, -1, LUA_TTABLE);
+        const std::size_t count = lua_rawlen(state, -1);
+        if (count == 0) {
+            return luaL_error(state, "targets must name at least one object");
+        }
+        for (std::size_t index = 1; index <= count; ++index) {
+            lua_rawgeti(state, -1, static_cast<lua_Integer>(index));
+            const auto* const targetHandle =
+                static_cast<const SlotHandle*>(luaL_checkudata(state, -1, kSlotMetatable));
+            SlotDefinition object{};
+            if (!current_slot(state, *targetHandle, object)
+                || object.slotType != auth::kType4SlotType
+                || object.slotIndex > auth_fields::kMaximumClientRefIndex) {
+                return luaL_error(state, "targets require authored type-4 objects");
+            }
+            if (!append_predicate(
+                    body,
+                    auth::Type34ModeSlotRefC{kFilterModeDirect,
+                                             {object.registryKey,
+                                              static_cast<std::int8_t>(auth::kType4SlotType),
+                                              static_cast<std::int16_t>(object.slotIndex)}})) {
+                return luaL_error(state, kCapacityError);
+            }
+            lua_pop(state, 1);
+        }
+    }
+    lua_pop(state, 1);
+    // A squad reference selects the boss a shield or tether hop-on hosts on.
+    auth::Type2LaneClientRef squad{};
+    if (!optional_slot_reference(state,
+                                 optional_key(kObjectFilter, "squad", api::ValueKind::handle),
+                                 format::kSquadSlotType,
+                                 squad)) {
+        return luaL_error(state, "filter squad must be an authored type-1 squad");
+    }
+    if (squad.slotIndex >= 0
+        && !append_predicate(body, auth::Type34ModeSlotRefC{kFilterModeDirect, squad})) {
+        return luaL_error(state, kCapacityError);
     }
     auth::Type2LaneClientRef inside{};
     if (!optional_slot_reference(state,
@@ -270,9 +330,9 @@ constexpr std::int8_t kFilterModeInside = 1;
                                  inside)) {
         return luaL_error(state, "filter inside must be an authored type-60 volume");
     }
-    if (inside.slotIndex >= 0) {
-        body.predicates[body.count++] =
-            auth::Type34ModeFlagSlotRef{kFilterModeInside, false, inside};
+    if (inside.slotIndex >= 0
+        && !append_predicate(body, auth::Type34ModeFlagSlotRef{kFilterModeInside, false, inside})) {
+        return luaL_error(state, kCapacityError);
     }
     std::array<std::byte, auth::kType34MaximumByteCount> bytes{};
     std::size_t written = 0;

+ 2 - 0
Sunrise/src/server/activity/mission/mission_script_lua_event_api.cpp

@@ -28,6 +28,8 @@ namespace sunrise::server::activity::mission::lua_vm::detail {
         return "triggerEntered";
     case host::EventKind::triggerExited:
         return "triggerExited";
+    case host::EventKind::triggerState:
+        return "triggerState";
     case host::EventKind::squadState:
         return "squadState";
     case host::EventKind::squadProvoked:

+ 63 - 1
Sunrise/src/server/activity/mission/mission_script_lua_event_fields.cpp

@@ -10,6 +10,7 @@
 #include "mission_script_lua_dispatch.h"
 #include "mission_script_lua_event_internal.h"
 #include "mission_script_lua_internal.h"
+#include "mission_trigger_observation.h"
 
 namespace sunrise::server::activity::mission::lua_vm::detail {
 namespace {
@@ -65,6 +66,16 @@ void first_object_field(lua_State* state, const host::Event& event) {
     }
 }
 
+/** Pushes one flag only when its presence flag is set. */
+template <auto Field, auto Present>
+void present_boolean(lua_State* state, const host::Event& event) {
+    if (event.*Present) {
+        lua_pushboolean(state, event.*Field ? 1 : 0);
+    } else {
+        lua_pushnil(state);
+    }
+}
+
 /** Pushes one integer field only when its presence flag is set. */
 template <auto Field, auto Present>
 void present_integer(lua_State* state, const host::Event& event) {
@@ -360,7 +371,6 @@ constexpr EventBinding kSquadAlive[]{
 constexpr EventBinding kSquadState[]{
     {"task_cost", &event_task_cost},
     {"task_group", &event_task_group},
-    {"removal_flag", nullptr, &boolean_field<&E::squadRemovalFlag>},
     {"slot_counts",
      nullptr,
      [](lua_State* state, const E& event) {
@@ -371,6 +381,20 @@ constexpr EventBinding kSquadState[]{
              lua_rawseti(state, -2, static_cast<lua_Integer>(index + 1));
          }
      }},
+    {"initial_observation", nullptr, &boolean_field<&E::initialObservation>},
+    {"population_available", nullptr, &boolean_field<&E::squadPopulationAvailable>},
+    {"objective_revision", nullptr, &integer_field<&E::squadObjectiveRevision>},
+    {"task_costs",
+     nullptr,
+     [](lua_State* state, const E& event) {
+         lua_createtable(state, static_cast<int>(host::kSquadObjectiveGroupCount), 0);
+         for (unsigned group = 0; group < host::kSquadObjectiveGroupCount; ++group) {
+             if ((event.squadObjectiveCostMask & (1U << group)) != 0) {
+                 lua_pushnumber(state, event.squadObjectiveCosts[group]);
+                 lua_rawseti(state, -2, static_cast<lua_Integer>(group) + 1);
+             }
+         }
+     }},
 };
 constexpr EventBinding kEntitySpawned[]{
     {"member_slot", nullptr, &integer_field<&E::squadSlotOrdinal>},
@@ -456,6 +480,14 @@ constexpr EventBinding kObject[]{
              lua_pushnil(state);
          }
      }},
+    {"initial_observation", nullptr, &boolean_field<&E::initialObservation>},
+    {"entry_index", nullptr, &integer_field<&E::objectEntryIndex>},
+    {"spawn_mask_low",
+     nullptr,
+     [](lua_State* state, const E& event) { lua_pushinteger(state, event.objectSpawnMask[0]); }},
+    {"spawn_mask_high",
+     nullptr,
+     [](lua_State* state, const E& event) { lua_pushinteger(state, event.objectSpawnMask[1]); }},
 };
 constexpr EventBinding kGhostLink[]{
     {"generation", nullptr, &integer_field<&E::ghostGeneration>},
@@ -473,6 +505,33 @@ constexpr EventBinding kFireteam[]{
     {"dead_count", nullptr, &integer_field<&E::fireteamDead>},
     {"unknown_count", nullptr, &integer_field<&E::fireteamUnknown>},
 };
+// Copied from the exact Sense observation, never from an actor token.
+constexpr EventBinding kSenseGeneration[]{
+    {"spawn_generation",
+     nullptr,
+     &present_integer<&E::squadSpawnGeneration, &E::squadHasSpawnGeneration>},
+    {"sense_generation",
+     nullptr,
+     &present_integer<&E::senseGenerationPlusOne, &E::hasSenseGeneration>},
+};
+constexpr EventBinding kSquadRemoval[]{
+    {"removal_flag", nullptr, &boolean_field<&E::squadRemovalFlag>},
+};
+// An invalidation carries no level, so its level fields read nil.
+constexpr EventBinding kTriggerLevel[]{
+    {"available", nullptr, &boolean_field<&E::triggerAvailable>},
+    {"continuity",
+     nullptr,
+     [](lua_State* state, const E& event) {
+         lua_pushstring(state,
+                        trigger_observation::name(
+                            static_cast<trigger_observation::Continuity>(event.triggerContinuity)));
+     }},
+    {"occupied", nullptr, &present_boolean<&E::triggerOccupied, &E::triggerAvailable>},
+    {"member_count", nullptr, &present_integer<&E::triggerCount, &E::triggerAvailable>},
+    {"value", nullptr, &present_integer<&E::triggerValue, &E::triggerAvailable>},
+    {"all_inside", nullptr, &present_boolean<&E::triggerAll, &E::triggerAvailable>},
+};
 
 /** Every group's bindings, indexed by FieldGroupId. */
 constexpr std::array<EventBindings, static_cast<std::size_t>(G::count)> kGroupBindings{{
@@ -512,6 +571,9 @@ constexpr std::array<EventBindings, static_cast<std::size_t>(G::count)> kGroupBi
     kGhostLink,
     kSquadProvoked,
     kFireteam,
+    kSenseGeneration,
+    kSquadRemoval,
+    kTriggerLevel,
 }};
 static_assert([]() consteval {
     for (std::size_t index = 0; index < kGroupBindings.size(); ++index) {

+ 52 - 0
Sunrise/src/server/activity/mission/mission_script_lua_slot_api.cpp

@@ -7,6 +7,7 @@
 
 #include "../../../middleware/bap/activity_message/auth_fields.h"
 #include "../../../middleware/bap/activity_message/auth_schema_catalog.h"
+#include "../../../middleware/bap/activity_message/volume_toggle_auth.h"
 #include "mission_script_lua_dispatch.h"
 #include "mission_script_lua_internal.h"
 
@@ -104,6 +105,56 @@ namespace auth_catalog = middleware::bap::activity_message::auth_schema_catalog;
 
 namespace {
 
+constexpr const api::MemberSpec& kVolumeActive = api::slot_member("set_volume_active");
+
+/** Reads one required boolean argument; any other type is an error. */
+[[nodiscard]] bool required_boolean(lua_State* state, const char* name) {
+    lua_getfield(state, 2, name);
+    if (!lua_isboolean(state, -1)) {
+        luaL_error(state, "%s must be a boolean", name);
+    }
+    const bool value = lua_toboolean(state, -1) != 0;
+    lua_pop(state, 1);
+    return value;
+}
+
+/** Turns one authored type-60 volume of the same object on or off through its type-32 toggle. */
+[[nodiscard]] int slot_set_volume_active(lua_State* state) {
+    const auto* const handle =
+        static_cast<const SlotHandle*>(luaL_checkudata(state, 1, kSlotMetatable));
+    refuse_unknown_arguments(state, kVolumeActive);
+    const SlotHandle target = checked_argument<SlotHandle>(
+        state, required_key(kVolumeActive, "volume", api::ValueKind::handle), kSlotMetatable);
+    const bool active =
+        required_boolean(state, required_key(kVolumeActive, "active", api::ValueKind::boolean));
+    SlotDefinition source{}, volume{};
+    if (!current_slot(state, *handle, source) || !current_slot(state, target, volume)) {
+        return luaL_error(state, "toggle or volume is stale or invalid");
+    }
+    if (source.slotType != scriptable_auth::kType32SlotType
+        || source.componentClass != scriptable_auth::kType32ComponentClass
+        || source.authSchema != scriptable_auth::kType32Schema
+        || (source.flags & format::kSlotSchemaJoinExact) == 0) {
+        return luaL_error(state, "source must be an exact type-32 toggle sensor");
+    }
+    if (volume.slotType != scriptable_auth::kVolumeSlotType || volume.objectTag != source.objectTag
+        || volume.registryKey != source.registryKey
+        || volume.slotIndex
+               > static_cast<std::uint32_t>((std::numeric_limits<std::int16_t>::max)())) {
+        return luaL_error(state, "volume must be a same-owner type-60 slot");
+    }
+    std::array<std::byte, scriptable_auth::kType32ByteCount> body{};
+    std::size_t written = 0;
+    if (!scriptable_auth::encode_type32_volume(
+            {volume.registryKey, static_cast<std::int16_t>(volume.slotIndex), active},
+            body,
+            written)) {
+        return luaL_error(state, "native toggle encoder rejected the volume");
+    }
+    return queue_slot_auth(
+        state, source, scriptable_auth::kType32Schema, scriptable_auth::kType32BitCount, body);
+}
+
 /** Pushes one property of the slot's Auth catalog entry, or nil when it has none. */
 template <typename Push> void push_auth(lua_State* state, const SlotDefinition& slot, Push push) {
     const auth_catalog::Type* const auth =
@@ -220,6 +271,7 @@ constexpr std::array<SlotBinding, std::size(api::kSlotMembers)> kSlotBindings{{
      nullptr,
      [](lua_State* state, const SlotDefinition& slot) { lua_pushinteger(state, slot.flags); }},
     {"set_object_filter", &slot_set_object_filter},
+    {"set_volume_active", &slot_set_volume_active},
     {"watch_damage", &slot_watch_damage},
     {"set_object_active", &slot_set_object_active},
     {"set_channel", &slot_set_channel},

+ 46 - 0
Sunrise/src/server/activity/mission/mission_script_lua_state_api.cpp

@@ -3,9 +3,11 @@
 #include <cmath>
 #include <cstddef>
 #include <cstdint>
+#include <cstdio>
 #include <limits>
 #include <string_view>
 
+#include "../../../core/logging/log.h"
 #include "mission_script_lua_dispatch.h"
 #include "mission_script_lua_internal.h"
 
@@ -179,6 +181,49 @@ static_assert(bound_to<Impl>(api::kStateMembers, kStateBindings));
     frame.candidate.phaseChanged = true;
     return 0;
 }
+/** Logs one staged variable write, so the gate state a controller keeps in variables is visible. */
+void log_variable_write(const StateKey& key, const VariableValue& value) noexcept {
+    std::array<char, 32> scalar{};
+    std::string_view text{};
+    int scalarWritten = 0;
+    switch (value.kind) {
+    case VariableValueKind::boolean:
+        text = value.booleanValue ? "true" : "false";
+        break;
+    case VariableValueKind::integer:
+        scalarWritten = std::snprintf(
+            scalar.data(), scalar.size(), "%lld", static_cast<long long>(value.integerValue));
+        break;
+    case VariableValueKind::real:
+        scalarWritten = std::snprintf(scalar.data(), scalar.size(), "%.6g", value.realValue);
+        break;
+    case VariableValueKind::string:
+        text = {value.stringValue.data(),
+                (std::min)(std::size_t{value.stringLength}, value.stringValue.size())};
+        break;
+    }
+    if (scalarWritten > 0) {
+        text = {scalar.data(),
+                (std::min)(static_cast<std::size_t>(scalarWritten), scalar.size() - 1)};
+    }
+    const std::string_view name = state_key_view(key);
+    std::array<char, core::log::kLineCapacity> line{};
+    const int written =
+        std::snprintf(line.data(),
+                      line.size(),
+                      "ev=mission_script stage=variable result=set name=%.*s value=%.*s",
+                      static_cast<int>(name.size()),
+                      name.data(),
+                      static_cast<int>(text.size()),
+                      text.data());
+    if (written > 0) {
+        core::log::write(
+            core::log::Channel::server,
+            core::log::Level::debug,
+            {line.data(), (std::min)(static_cast<std::size_t>(written), line.size() - 1)});
+    }
+}
+
 /** Stages one variable write that commits with the enclosing callback. */
 [[nodiscard]] int context_set_variable(lua_State* state) {
     static_cast<void>(luaL_checkudata(state, 1, kContextMetatable));
@@ -204,6 +249,7 @@ static_assert(bound_to<Impl>(api::kStateMembers, kStateBindings));
         ++frame.candidate.variableCount;
     }
     frame.candidate.variables[row] = {key, value};
+    log_variable_write(key, value);
     return 0;
 }
 /** Stages one variable removal that commits with the enclosing callback. */

+ 11 - 0
Sunrise/src/server/activity/mission/mission_script_object_sense.h

@@ -1,5 +1,6 @@
 #pragma once
 
+#include <array>
 #include <cstdint>
 #include <span>
 
@@ -13,6 +14,9 @@ inline constexpr std::uint16_t kObjectGenerationOrdinal = 0;
 /** Ordinal 1 is the property the mode-2 interaction effect clears. The meaning is assumed. */
 inline constexpr std::uint16_t kObjectInteractionOpenOrdinal = 1;
 inline constexpr std::uint16_t kObjectExistsOrdinal = 2;
+/** Authored entry the object instantiated, and its two 32-bit spawn-mask words. */
+inline constexpr std::uint16_t kObjectEntryIndexOrdinal = 3;
+inline constexpr std::uint32_t kObjectSpawnMaskSchema = 0x80809E1BU;
 /** Ownership reply: field 0 held, field 1 the owner key. */
 inline constexpr std::uint16_t kOwnerHeldOrdinal = 0;
 inline constexpr std::uint16_t kOwnerKeyOrdinal = 1;
@@ -31,6 +35,8 @@ struct ObjectInteractionLevel final {
     bool ownerKnown{};
     bool hasOwner{};
     std::uint64_t ownerKey{};
+    std::int32_t entryIndex{};
+    std::array<std::uint32_t, 2> spawnMask{};
 };
 
 /**
@@ -72,6 +78,11 @@ struct ObjectInteractionLevel final {
         } else if (value.schemaRow == root && value.fieldOrdinal == kObjectExistsOrdinal) {
             level.present = value.unsignedValue != 0;
             level.stateKnown = true;
+        } else if (value.schemaRow == root && value.fieldOrdinal == kObjectEntryIndexOrdinal) {
+            level.entryIndex = static_cast<std::int32_t>(value.signedValue);
+        } else if (value.schemaRow == kObjectSpawnMaskSchema && value.fieldOrdinal == 0
+                   && value.occurrence < level.spawnMask.size()) {
+            level.spawnMask[value.occurrence] = static_cast<std::uint32_t>(value.unsignedValue);
         } else if (value.schemaRow == object::kOwnershipReply
                    && value.fieldOrdinal == kOwnerHeldOrdinal) {
             level.ownerKnown = true;

+ 44 - 0
Sunrise/src/server/activity/mission/mission_script_runtime.cpp

@@ -18,6 +18,7 @@
 
 #include "../../../core/logging/log.h"
 #include "../../../core/settings/settings.h"
+#include "../../../middleware/gameplay/external/external_entity_codec.h"
 #include "../../../state/activity/mission/runtime.h"
 #include "../../../state/activity/runtime.h"
 #include "../../gameplay/squad_entity_retirement.h"
@@ -254,6 +255,48 @@ void report_squad_provoked(const state::activity::SessionBinding& binding,
     ReleaseSRWLockExclusive(&g_lock);
 }
 
+/**
+ * Queues one replicated damage level for a squad slot, once per change.
+ * @param binding Activity generation that accepted the entity update.
+ * @param sourceGeneration ActivityClient generation that owns the policy.
+ * @param registryKey Authored registry key.
+ * @param slotType Authored slot type: the squad's, or the combatant's own.
+ * @param slotIndex Authored slot index.
+ * @param health Pool 0 of the damage component, 0 to kDamageLevelFull.
+ * @param shield Pool 1 of the damage component, 0 to kDamageLevelFull.
+ */
+void report_squad_damage(const state::activity::SessionBinding& binding,
+                         std::uint64_t sourceGeneration,
+                         std::uint32_t registryKey,
+                         std::uint8_t slotType,
+                         std::uint16_t slotIndex,
+                         std::uint16_t health,
+                         std::uint16_t shield) noexcept {
+    constexpr std::uint16_t full = middleware::gameplay::external::kDamageLevelFull;
+    if (sourceGeneration == 0 || registryKey == 0 || health > full || shield > full
+        || slotIndex > static_cast<std::uint16_t>((std::numeric_limits<std::int16_t>::max)())) {
+        return;
+    }
+    AcquireSRWLockExclusive(&g_lock);
+    auto* instance = find_instance(binding);
+    if (instance != nullptr && instance->view.activityClientGeneration == sourceGeneration) {
+        push_entity_damage(*instance,
+                           registryKey,
+                           slotType,
+                           slotIndex,
+                           static_cast<float>(health) / static_cast<float>(full),
+                           static_cast<float>(shield) / static_cast<float>(full),
+                           GetTickCount64());
+    } else {
+        // Nothing else records a level that finds no mission.
+        log_line(core::log::Level::debug,
+                 instance,
+                 "combatant_damage",
+                 instance == nullptr ? "no_instance" : "stale_generation");
+    }
+    ReleaseSRWLockExclusive(&g_lock);
+}
+
 /** Retains the last VM stage and status shown on the panel. */
 void note_vm_status(RuntimeInstance& instance,
                     std::string_view stage,
@@ -269,6 +312,7 @@ void clear_sense_levels(RuntimeInstance& instance) noexcept {
     instance.ghostObservations = {};
     instance.damageObservations = {};
     instance.combatantDamageObservations = {};
+    instance.entityDamageObservations = {};
     instance.deviceObservations = {};
     instance.objectInteractionObservations = {};
     instance.actorPathObservations = {};

+ 12 - 0
Sunrise/src/server/activity/mission/mission_script_runtime.h

@@ -93,6 +93,18 @@ void report_squad_provoked(const state::activity::SessionBinding& binding,
                            std::uint32_t registryKey,
                            std::uint16_t slotIndex) noexcept;
 
+/**
+ * Publishes an authored actor's replicated damage levels as a damageState event when they change.
+ * Levels are ten-bit fractions of full (1023).
+ */
+void report_squad_damage(const state::activity::SessionBinding& binding,
+                         std::uint64_t sourceGeneration,
+                         std::uint32_t registryKey,
+                         std::uint8_t slotType,
+                         std::uint16_t slotIndex,
+                         std::uint16_t health,
+                         std::uint16_t shield) noexcept;
+
 /** Starts the server mission-script manager when its setting is enabled. */
 void initialize() noexcept;
 /** Writes the authored controller path, `<name>/<name>.lua`, for one 1-based SDK activity row. */

+ 86 - 34
Sunrise/src/server/activity/mission/mission_script_runtime_combatant_damage.cpp

@@ -5,6 +5,57 @@
 #include "mission_script_runtime_internal.h"
 
 namespace sunrise::server::activity::mission {
+namespace {
+
+/**
+ * Raises one damageState event for a level that just changed, and logs it.
+ * @param key Row identity; an entity-sourced row has no object tag or Sense schema to publish.
+ * @param source "sense" or "entity", for the log only.
+ */
+void push_damage_state(RuntimeInstance& instance,
+                       const host::SenseObservationKey& key,
+                       const CombatantDamageLevel& level,
+                       std::uint64_t sequence,
+                       std::uint64_t tick,
+                       const char* source) noexcept {
+    host::Event event{};
+    event.kind = host::EventKind::damageState;
+    event.binding = instance.view.binding;
+    event.sequence = sequence;
+    event.tick = tick;
+    event.sourceGeneration = instance.view.activityClientGeneration;
+    event.missionSequence = instance.lastMissionSequence;
+    event.firstRegistryKey = key.registryKey;
+    event.firstSlotIndex = key.slotIndex;
+    event.firstSlotType = key.slotType;
+    event.slotObjectTag = key.senseSchema != 0 ? key.objectTag : 0;
+    event.slotSenseSchema = key.senseSchema;
+    event.damageHealth = level.primary;
+    event.damageShield = level.secondary;
+    event.damageRevision = level.revision;
+    std::array<char, 128> details{};
+    const int written = std::snprintf(details.data(),
+                                      details.size(),
+                                      "registry=%08X type=%u slot=%u revision=%d primary=%.4f "
+                                      "secondary=%.4f source=%s",
+                                      key.registryKey,
+                                      static_cast<unsigned>(key.slotType),
+                                      static_cast<unsigned>(key.slotIndex),
+                                      level.revision,
+                                      static_cast<double>(level.primary),
+                                      static_cast<double>(level.secondary),
+                                      source);
+    if (written > 0 && static_cast<std::size_t>(written) < details.size()) {
+        log_line(core::log::Level::debug,
+                 &instance,
+                 "combatant_damage",
+                 "changed",
+                 {details.data(), static_cast<std::size_t>(written)});
+    }
+    push_script_event(instance, event);
+}
+
+} // namespace
 
 /**
  * Raises damage-state observations without inferring actor attachment or provocation.
@@ -37,41 +88,42 @@ void push_combatant_damage_edges(RuntimeInstance& instance,
         if (!update_combatant_damage(row->level, body, observation.key.schemaRow)) {
             continue;
         }
-        const auto& level = row->level;
-        host::Event event{};
-        event.kind = host::EventKind::damageState;
-        event.binding = instance.view.binding;
-        event.sequence = observation.sequence;
-        event.tick = observation.tick;
-        event.sourceGeneration = instance.view.activityClientGeneration;
-        event.missionSequence = instance.lastMissionSequence;
-        event.firstRegistryKey = observation.key.registryKey;
-        event.firstSlotIndex = observation.key.slotIndex;
-        event.firstSlotType = observation.key.slotType;
-        event.slotObjectTag = observation.key.objectTag;
-        event.slotSenseSchema = observation.key.senseSchema;
-        event.damageHealth = level.primary;
-        event.damageShield = level.secondary;
-        event.damageRevision = level.revision;
-        std::array<char, 128> details{};
-        const int written = std::snprintf(details.data(),
-                                          details.size(),
-                                          "registry=%08X slot=%u revision=%d primary=%.4f "
-                                          "secondary=%.4f",
-                                          observation.key.registryKey,
-                                          static_cast<unsigned>(observation.key.slotIndex),
-                                          level.revision,
-                                          static_cast<double>(level.primary),
-                                          static_cast<double>(level.secondary));
-        if (written > 0 && static_cast<std::size_t>(written) < details.size()) {
-            log_line(core::log::Level::debug,
-                     &instance,
-                     "combatant_damage",
-                     "changed",
-                     {details.data(), static_cast<std::size_t>(written)});
-        }
-        push_script_event(instance, event);
+        push_damage_state(
+            instance, observation.key, row->level, observation.sequence, observation.tick, "sense");
+    }
+}
+
+/**
+ * Raises a damage state from an entity's replicated damage component when its levels change.
+ * Entity rows have no object tag, so the slot type fills that key field and keeps a squad and a
+ * combatant with the same authored index apart.
+ */
+void push_entity_damage(RuntimeInstance& instance,
+                        std::uint32_t registryKey,
+                        std::uint8_t slotType,
+                        std::uint16_t slotIndex,
+                        float primary,
+                        float secondary,
+                        std::uint64_t tick) noexcept {
+    host::SenseObservationKey key{};
+    key.registryKey = registryKey;
+    key.objectTag = slotType;
+    key.senseSchema = 0;
+    key.slotIndex = slotIndex;
+    key.slotType = slotType;
+    auto* const row = find_slot_row(instance.entityDamageObservations, key);
+    if (row == nullptr) {
+        log_line(core::log::Level::warn, &instance, "combatant_damage", "watch_capacity");
+        return;
+    }
+    if (row->level.observed && row->level.primary == primary && row->level.secondary == secondary) {
+        return;
     }
+    row->level.primary = primary;
+    row->level.secondary = secondary;
+    row->level.observed = true;
+    ++row->level.revision;
+    push_damage_state(instance, key, row->level, instance.missionStateRevision, tick, "entity");
 }
 
 } // namespace sunrise::server::activity::mission

+ 84 - 26
Sunrise/src/server/activity/mission/mission_script_runtime_edges.cpp

@@ -20,14 +20,10 @@
 namespace sunrise::server::activity::mission {
 namespace {
 
-/** Values in one occupancy body: any, all, intersection count, and copied Auth value. */
-constexpr std::size_t kTriggerOccupancyValueCount = 4;
-constexpr std::uint16_t kTriggerAnyOrdinal = 0;
-constexpr std::uint16_t kTriggerAllOrdinal = 1;
-constexpr std::uint16_t kTriggerCountOrdinal = 2;
-constexpr std::uint16_t kTriggerThresholdOrdinal = 3;
 /** Root ordinal of the squad removal flag. The rest of the root is inert on this build. */
 constexpr std::uint16_t kSquadRemovalOrdinal = 8;
+/** Root ordinal 0 echoes the spawn generation the squad's placement Auth carries. */
+constexpr std::uint16_t kSquadSpawnGenerationOrdinal = 0;
 /** Root ordinals of the type-20 damage Sense body: health, shield, then the echoed revision. */
 constexpr std::uint16_t kDamageHealthOrdinal = 0;
 constexpr std::uint16_t kDamageShieldOrdinal = 1;
@@ -88,6 +84,18 @@ sense_value(std::span<const sense_values::DecodedValue> body,
     event.firstSlotType = observation.key.slotType;
     event.slotObjectTag = observation.key.objectTag;
     event.slotSenseSchema = observation.key.senseSchema;
+    event.senseGenerationPlusOne = observation.generationPlusOne;
+    event.hasSenseGeneration = observation.hasGeneration;
+    return event;
+}
+
+/** @return A squad edge carrying the squad's retained spawn generation. */
+[[nodiscard]] host::Event squad_edge_event(const RuntimeInstance& instance,
+                                           const host::SenseObservation& observation,
+                                           const SquadObservation& squad) noexcept {
+    host::Event event = sense_edge_event(instance, observation);
+    event.squadSpawnGeneration = squad.spawnGeneration;
+    event.squadHasSpawnGeneration = squad.hasSpawnGeneration;
     return event;
 }
 
@@ -205,26 +213,58 @@ void objective_task_counters(std::span<const sense_values::DecodedValue> body,
     output.blocks = static_cast<std::uint8_t>((std::min)(blocks, output.value.size()));
 }
 
+/**
+ * Raises one triggerState event for a report its tracker does not drop. The event only informs;
+ * entered and exited follow the level alone.
+ * @param event The report's level, already filled in.
+ */
+void push_trigger_state(RuntimeInstance& instance,
+                        const host::SenseObservationSnapshot& sense,
+                        const host::SenseObservation& observation,
+                        TriggerOccupancy& slot,
+                        host::Event event) noexcept {
+    trigger_observation::Snapshot level{};
+    const bool valid =
+        observation.valueCount <= sense.valueCount - observation.firstValue
+        && trigger_observation::read(std::span(sense.values.data(), sense.valueCount)
+                                         .subspan(observation.firstValue, observation.valueCount),
+                                     level);
+    const auto result = slot.tracker.observe(valid ? &level : nullptr,
+                                             instance.view.activityClientGeneration,
+                                             observation.sourceGeneration,
+                                             observation.sequence,
+                                             observation.generationPlusOne,
+                                             observation.hasGeneration);
+    if (!result.notify) {
+        return;
+    }
+    event.kind = host::EventKind::triggerState;
+    event.triggerAvailable = result.available;
+    event.triggerContinuity = static_cast<std::uint8_t>(result.continuity);
+    push_script_event(instance, event);
+}
+
 } // namespace
 
 /**
- * Raises one event per watched volume whose occupancy changed.
+ * Raises one event per watched volume whose occupancy changed, after its triggerState report.
  * The client publishes occupancy as a level, so the edge is ours to derive. A volume seen for the
  * first time only records its level, because a first observation is not an entry.
  */
 void push_trigger_edges(RuntimeInstance& instance,
                         const host::SenseObservationSnapshot& sense) noexcept {
+    namespace trigger = trigger_observation;
     for (std::size_t index = 0; index < sense.observationCount; ++index) {
         const host::SenseObservation& observation = sense.observations[index];
         if (observation.key.slotType != format::kOccupancySlotType
-            || observation.valueCount < kTriggerOccupancyValueCount
-            || observation.firstValue + kTriggerOccupancyValueCount > sense.valueCount) {
+            || observation.valueCount < trigger::kValueCount
+            || observation.firstValue + trigger::kValueCount > sense.valueCount) {
             continue;
         }
         const std::span<const sense_values::DecodedValue> body(
-            &sense.values[observation.firstValue], kTriggerOccupancyValueCount);
+            &sense.values[observation.firstValue], trigger::kValueCount);
         const std::uint32_t root = observation.key.schemaRow;
-        const bool occupied = sense_flag(body, root, kTriggerAnyOrdinal);
+        const bool occupied = sense_flag(body, root, trigger::kOccupiedOrdinal);
         TriggerOccupancy* slot = nullptr;
         TriggerOccupancy* spare = nullptr;
         for (TriggerOccupancy& retained : instance.triggerOccupancy) {
@@ -238,26 +278,29 @@ void push_trigger_edges(RuntimeInstance& instance,
                 spare = &retained;
             }
         }
-        if (slot == nullptr) {
+        const bool first = slot == nullptr;
+        if (first) {
             if (spare == nullptr) {
                 log_line(core::log::Level::warn, &instance, "trigger", "watch_capacity");
                 continue;
             }
-            spare->registryKey = observation.key.registryKey;
-            spare->objectTag = observation.key.objectTag;
-            spare->slotIndex = observation.key.slotIndex;
-            spare->occupied = occupied;
-            spare->used = true;
-            continue;
+            slot = spare;
+            slot->registryKey = observation.key.registryKey;
+            slot->objectTag = observation.key.objectTag;
+            slot->slotIndex = observation.key.slotIndex;
+            slot->occupied = occupied;
+            slot->used = true;
         }
-        if (slot->occupied == occupied) {
+        host::Event event = sense_edge_event(instance, observation);
+        event.triggerOccupied = occupied;
+        event.triggerAll = sense_flag(body, root, trigger::kAllOrdinal);
+        event.triggerCount = sense_number(body, root, trigger::kCountOrdinal);
+        event.triggerValue = sense_number(body, root, trigger::kThresholdOrdinal);
+        push_trigger_state(instance, sense, observation, *slot, event);
+        if (first || slot->occupied == occupied) {
             continue;
         }
         slot->occupied = occupied;
-        host::Event event = sense_edge_event(instance, observation);
-        event.triggerAll = sense_flag(body, root, kTriggerAllOrdinal);
-        event.triggerCount = sense_number(body, root, kTriggerCountOrdinal);
-        event.triggerValue = sense_number(body, root, kTriggerThresholdOrdinal);
         event.kind = occupied ? host::EventKind::triggerEntered : host::EventKind::triggerExited;
         push_script_event(instance, event);
     }
@@ -416,6 +459,9 @@ void push_object_interaction_edges(RuntimeInstance& instance,
             slot->level, observation_values(observation, sense), observation.key.schemaRow);
         const ObjectInteractionLevel& level = slot->level;
         host::Event event = sense_edge_event(instance, observation);
+        event.initialObservation = !before.stateKnown || before.generation != level.generation;
+        event.objectEntryIndex = level.entryIndex;
+        event.objectSpawnMask = level.spawnMask;
         event.objectGeneration = level.generation;
         event.objectPresent = level.present;
         // The alive lane carries Sense ordinal 1, which the script reads as interaction_open.
@@ -543,6 +589,15 @@ void push_squad_edges(RuntimeInstance& instance,
         }
         const bool first = !squad->used;
         squad->used = true;
+        squad->populationKnown = squad->populationKnown || hasAlive;
+        // An absent spawn generation keeps the last one reported, like the other retained levels.
+        const sense_values::DecodedValue* const spawn =
+            sense_value(body, root, kSquadSpawnGenerationOrdinal);
+        if (spawn != nullptr && spawn->present && spawn->signedValue >= 0
+            && spawn->signedValue <= kMaximumCounter) {
+            squad->spawnGeneration = static_cast<std::int32_t>(spawn->signedValue);
+            squad->hasSpawnGeneration = true;
+        }
         const std::int32_t previousAlive = first ? 0 : squad->aliveCount;
         const bool changed =
             costsChanged || first || squad->aliveCount != alive || squad->removalFlag != removal
@@ -550,16 +605,18 @@ void push_squad_edges(RuntimeInstance& instance,
         if (!changed) {
             continue;
         }
-        host::Event state = sense_edge_event(instance, observation);
+        host::Event state = squad_edge_event(instance, observation, *squad);
         state.squadObjectiveCosts = squad->objectiveCosts.values;
         state.squadObjectiveCostMask = squad->objectiveCosts.known;
         state.squadObjectiveRevision = squad->objectiveCosts.revision;
         qualify_objective_costs(instance, state);
         state.squadAliveCount = alive;
         state.squadPreviousAliveCount = previousAlive;
+        state.squadPopulationAvailable = squad->populationKnown;
         state.squadRemovalFlag = removal;
         state.squadSlotCounts = counts;
         state.squadSlotCountLength = countLength;
+        state.initialObservation = first;
         state.kind = host::EventKind::squadState;
         if (instance.programStatus == ProgramStatus::loaded) {
             push_script_event(instance, state);
@@ -571,7 +628,7 @@ void push_squad_edges(RuntimeInstance& instance,
             if (counts[slot] <= previous) {
                 continue;
             }
-            host::Event spawned = sense_edge_event(instance, observation);
+            host::Event spawned = squad_edge_event(instance, observation, *squad);
             spawned.squadSlotOrdinal = slot;
             spawned.squadSlotValue = counts[slot];
             spawned.squadPreviousSlotValue = previous;
@@ -581,9 +638,10 @@ void push_squad_edges(RuntimeInstance& instance,
             }
         }
         if (!first && alive < squad->aliveCount) {
-            host::Event died = sense_edge_event(instance, observation);
+            host::Event died = squad_edge_event(instance, observation, *squad);
             died.squadAliveCount = alive;
             died.squadPreviousAliveCount = squad->aliveCount;
+            died.squadRemovalFlag = removal;
             died.kind = host::EventKind::entityDied;
             if (instance.programStatus == ProgramStatus::loaded) {
                 push_script_event(instance, died);

Деякі файли не було показано, через те що забагато файлів було змінено