stan hai 3 días
pai
achega
f84356c14b
Modificáronse 100 ficheiros con 7906 adicións e 6717 borrados
  1. 1 1
      .clang-tidy
  2. 0 2
      .gitignore
  3. 2 0
      CMakeLists.txt
  4. 2 2
      README.md
  5. 91 24
      Sunrise/Sunrise.vcxproj
  6. 10 10
      Sunrise/resources/default_settings.json
  7. 0 21
      Sunrise/resources/vendor_rules/README.md
  8. 0 61
      Sunrise/resources/vendor_rules/vendor_bounty_roll.txt
  9. 0 24
      Sunrise/resources/vendor_rules/vendor_catalog.txt
  10. 0 298
      Sunrise/resources/vendor_rules/vendor_exchange.txt
  11. 0 16
      Sunrise/resources/vendor_rules/vendor_item_substitute.txt
  12. 2 1
      Sunrise/src/client/content/activity/activity_sdk_activity_enrichment_inventory.cpp
  13. 6 4
      Sunrise/src/client/content/activity/activity_sdk_activity_inventory.cpp
  14. 2 1148
      Sunrise/src/client/content/activity/activity_sdk_actor_rsat_inventory.cpp
  15. 204 0
      Sunrise/src/client/content/activity/activity_sdk_actor_rsat_inventory_internal.h
  16. 393 0
      Sunrise/src/client/content/activity/activity_sdk_actor_rsat_read.cpp
  17. 637 0
      Sunrise/src/client/content/activity/activity_sdk_actor_rsat_validation.cpp
  18. 3 3
      Sunrise/src/client/content/activity/activity_sdk_authored_scene_facts.cpp
  19. 189 0
      Sunrise/src/client/content/activity/activity_sdk_authored_scene_internal.h
  20. 109 541
      Sunrise/src/client/content/activity/activity_sdk_authored_scene_inventory.cpp
  21. 307 0
      Sunrise/src/client/content/activity/activity_sdk_authored_scene_validation.cpp
  22. 20 15
      Sunrise/src/client/content/activity/activity_sdk_behavior_inventory.cpp
  23. 6 4
      Sunrise/src/client/content/activity/activity_sdk_dialogue_group_index.cpp
  24. 1 0
      Sunrise/src/client/content/activity/activity_sdk_estate_validation.cpp
  25. 6 6
      Sunrise/src/client/content/activity/activity_sdk_external_placements.cpp
  26. 378 0
      Sunrise/src/client/content/activity/activity_sdk_generation_scenario_shards.cpp
  27. 2 0
      Sunrise/src/client/content/activity/activity_sdk_generation_shard.cpp
  28. 23 593
      Sunrise/src/client/content/activity/activity_sdk_generation_worker.cpp
  29. 38 0
      Sunrise/src/client/content/activity/activity_sdk_generation_worker_internal.h
  30. 233 0
      Sunrise/src/client/content/activity/activity_sdk_generation_world_source.cpp
  31. 3 0
      Sunrise/src/client/content/activity/activity_sdk_ir_family_coverage.cpp
  32. 1 0
      Sunrise/src/client/content/activity/activity_sdk_ir_family_coverage.h
  33. 27 14
      Sunrise/src/client/content/activity/activity_sdk_live_publication.cpp
  34. 27 764
      Sunrise/src/client/content/activity/activity_sdk_lua_activities.cpp
  35. 15 0
      Sunrise/src/client/content/activity/activity_sdk_lua_artifacts_internal.h
  36. 2 2
      Sunrise/src/client/content/activity/activity_sdk_lua_catalog_contract.cpp
  37. 749 0
      Sunrise/src/client/content/activity/activity_sdk_lua_missions.cpp
  38. 37 0
      Sunrise/src/client/content/activity/activity_sdk_lua_missions_internal.h
  39. 2 0
      Sunrise/src/client/content/activity/activity_sdk_lua_publish.cpp
  40. 9 7
      Sunrise/src/client/content/activity/activity_sdk_lua_value.cpp
  41. 2 2
      Sunrise/src/client/content/activity/activity_sdk_lua_world_contract.cpp
  42. 3 2
      Sunrise/src/client/content/activity/activity_sdk_lua_world_core_views.cpp
  43. 2 2
      Sunrise/src/client/content/activity/activity_sdk_lua_world_spatial_views.cpp
  44. 60 0
      Sunrise/src/client/content/activity/activity_sdk_native_pack_internal.h
  45. 29 485
      Sunrise/src/client/content/activity/activity_sdk_native_pack_pipeline.cpp
  46. 466 0
      Sunrise/src/client/content/activity/activity_sdk_native_pack_text.cpp
  47. 2 0
      Sunrise/src/client/content/activity/activity_sdk_one_shot.cpp
  48. 2 0
      Sunrise/src/client/content/activity/activity_sdk_one_shot_abi.h
  49. 1 0
      Sunrise/src/client/content/activity/activity_sdk_one_shot_export.cpp
  50. 1 0
      Sunrise/src/client/content/activity/activity_sdk_pack_composer_rows.cpp
  51. 5 3
      Sunrise/src/client/content/activity/activity_sdk_policy_input_validation.cpp
  52. 550 723
      Sunrise/src/client/content/activity/activity_sdk_policy_inventory.cpp
  53. 143 0
      Sunrise/src/client/content/activity/activity_sdk_policy_inventory_capabilities.cpp
  54. 99 0
      Sunrise/src/client/content/activity/activity_sdk_policy_inventory_internal.h
  55. 5 4
      Sunrise/src/client/content/activity/activity_sdk_shard_cleanup.cpp
  56. 246 0
      Sunrise/src/client/content/activity/activity_sdk_squad_config_parse.cpp
  57. 17 7
      Sunrise/src/client/content/activity/activity_sdk_squad_graph_edges.cpp
  58. 15 245
      Sunrise/src/client/content/activity/activity_sdk_squad_inventory.cpp
  59. 16 0
      Sunrise/src/client/content/activity/activity_sdk_squad_inventory_internal.h
  60. 13 10
      Sunrise/src/client/content/activity/activity_sdk_squad_inventory_link.cpp
  61. 1 2
      Sunrise/src/client/content/activity/activity_sdk_topology_enrichment.cpp
  62. 22 20
      Sunrise/src/client/content/activity/activity_sdk_topology_inventory_finish.cpp
  63. 4 0
      Sunrise/src/client/content/activity/activity_sdk_tree_publication.cpp
  64. 12 5
      Sunrise/src/client/content/activity/entity_object_type_build.cpp
  65. 305 75
      Sunrise/src/client/content/activity/entity_position_profile_build.cpp
  66. 279 0
      Sunrise/src/client/content/activity/scriptable_catalog_container_placement_cache.cpp
  67. 3 395
      Sunrise/src/client/content/activity/scriptable_catalog_container_placements.cpp
  68. 173 0
      Sunrise/src/client/content/activity/scriptable_catalog_container_placements_internal.h
  69. 6 4
      Sunrise/src/client/content/activity/scriptable_catalog_embedded_placements.cpp
  70. 5 2
      Sunrise/src/client/content/activity/scriptable_catalog_inline_names.cpp
  71. 22 20
      Sunrise/src/client/content/activity/scriptable_catalog_names.cpp
  72. 497 0
      Sunrise/src/client/content/activity/scriptable_catalog_object_analysis.cpp
  73. 1 0
      Sunrise/src/client/content/activity/scriptable_catalog_reference_scan.cpp
  74. 2 0
      Sunrise/src/client/content/activity/scriptable_catalog_spatial_graph.cpp
  75. 4 2
      Sunrise/src/client/content/activity/scriptable_catalog_trigger_volume_links.cpp
  76. 13 11
      Sunrise/src/client/content/activity/scriptable_catalog_trigger_volumes.cpp
  77. 6 5
      Sunrise/src/client/content/activity/scriptable_catalog_type23_placement_links.cpp
  78. 29 564
      Sunrise/src/client/content/activity/scriptable_catalog_worker.cpp
  79. 119 0
      Sunrise/src/client/content/activity/scriptable_catalog_worker_internal.h
  80. 2 4
      Sunrise/src/client/content/handles/handle_resolver.cpp
  81. 5 22
      Sunrise/src/client/content/investment/investment_refresh.cpp
  82. 9 11
      Sunrise/src/client/content/investment/investment_source.cpp
  83. 1 2
      Sunrise/src/client/content/investment/worker/investment_refresh_worker.cpp
  84. 94 14
      Sunrise/src/client/content/items/packages/internal.h
  85. 81 0
      Sunrise/src/client/content/items/packages/package_bounty_build.cpp
  86. 21 22
      Sunrise/src/client/content/items/packages/package_catalyst_condition_reader.cpp
  87. 98 20
      Sunrise/src/client/content/items/packages/package_collectible_build.cpp
  88. 1 1
      Sunrise/src/client/content/items/packages/package_detail_build.cpp
  89. 42 84
      Sunrise/src/client/content/items/packages/package_item_build.cpp
  90. 20 4
      Sunrise/src/client/content/items/packages/package_item_rows.cpp
  91. 166 214
      Sunrise/src/client/content/items/packages/package_node_build.cpp
  92. 45 11
      Sunrise/src/client/content/items/packages/package_progression_build.cpp
  93. 332 103
      Sunrise/src/client/content/items/packages/package_record_build.cpp
  94. 1 3
      Sunrise/src/client/content/items/packages/package_root_tables.cpp
  95. 219 0
      Sunrise/src/client/content/items/packages/package_season_pass_build.cpp
  96. 36 25
      Sunrise/src/client/content/items/packages/package_socket_plug_build.cpp
  97. 9 7
      Sunrise/src/client/content/items/packages/package_socket_plug_build.h
  98. 1 4
      Sunrise/src/client/content/scenarios/internal.h
  99. 2 4
      Sunrise/src/client/content/scenarios/scenario_build.cpp
  100. 4 13
      Sunrise/src/client/content/scenarios/scenario_roster_groups.cpp

+ 1 - 1
.clang-tidy

@@ -74,5 +74,5 @@ ExtraArgsBefore:
   - -Wdocumentation
 FormatStyle: file
 CheckOptions:
-  portability-restrict-system-includes.Includes: '-*,Windows.h,WinSock2.h,WS2tcpip.h,MSWSock.h,WinDNS.h,TlHelp32.h,Shellapi.h,bcrypt.h,d3d11.h,detours.h,dxgi.h,wincodec.h,imgui.h,imgui_impl_dx11.h,imgui_impl_win32.h,intrin.h,algorithm,array,atomic,bit,bitset,cctype,charconv,chrono,climits,cmath,concepts,cstdarg,cstddef,cstdint,cstdio,cstdlib,cstring,cwchar,limits,memory,mutex,new,optional,shared_mutex,span,string_view,type_traits,utility,variant,vector'
+  portability-restrict-system-includes.Includes: '-*,Windows.h,WinSock2.h,WS2tcpip.h,MSWSock.h,WinDNS.h,TlHelp32.h,Shellapi.h,bcrypt.h,d3d11.h,d3d11_1.h,d3dcompiler.h,detours.h,dxgi.h,wincodec.h,imgui.h,imgui_impl_dx11.h,imgui_impl_win32.h,intrin.h,algorithm,array,atomic,bit,bitset,cctype,cerrno,charconv,chrono,climits,cmath,compare,concepts,cstdarg,cstddef,cstdint,cstdio,cstdlib,cstring,cwchar,deque,functional,initializer_list,iterator,limits,map,memory,mutex,new,numeric,optional,queue,set,shared_mutex,span,sstream,string,string_view,tuple,type_traits,unordered_map,unordered_set,utility,variant,vector'
 ...

+ 0 - 2
.gitignore

@@ -14,6 +14,4 @@
 
 # CMake build files
 /build/
-/build-merge/
-/build-resolved/
 /out/

+ 2 - 0
CMakeLists.txt

@@ -18,6 +18,7 @@ file(GLOB_RECURSE VENDOR_SOURCES CONFIGURE_DEPENDS
     "Sunrise/vendor/lua/*.c"
     "Sunrise/vendor/lua/*.h"
 )
+# Lua builds as C++ here, matching the MSVC project's CompileAsCpp setting.
 file(GLOB_RECURSE LUA_SOURCES CONFIGURE_DEPENDS
     "Sunrise/vendor/lua/*.c"
 )
@@ -83,6 +84,7 @@ target_compile_definitions(steam_api64 PRIVATE
 )
 
 if(MSVC OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
+    # The vendored Lua interpreter throws, so its error path needs C++ unwinding.
     target_compile_options(steam_api64 PRIVATE $<$<COMPILE_LANGUAGE:CXX>:/EHsc>)
 
     # Clang warnings are more strict the MSVC I guess, this only works on real clang

+ 2 - 2
README.md

@@ -19,8 +19,8 @@ Destiny 2 Offline Exploration Mod
 
 ## WIP
 
-This mod is a work in progress. Things might break or work in unexpected ways. There is also currently
-a lack of documentation. This will improve over the coming weeks.
+This mod is a work in progress. Things might break or work in unexpected ways. There is also
+currently a lack of documentation. This will improve over the coming weeks.
 
 ## Support Me
 

+ 91 - 24
Sunrise/Sunrise.vcxproj

@@ -148,6 +148,7 @@
     <ClCompile Include="src\middleware\web_service\messages\opcode1820_codec.cpp" />
     <ClCompile Include="src\state\runtime\state_account_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" />
     <ClCompile Include="src\state\runtime\state_account_dismantle_staging.cpp" />
     <ClCompile Include="src\state\runtime\state_account_equipment_runtime.cpp" />
@@ -249,10 +250,12 @@
     <ClCompile Include="src\client\hooks\infinite_ammo\infinite_ammo.cpp" />
     <ClCompile Include="src\client\input\window_focus.cpp" />
     <ClCompile Include="src\client\hooks\teleport\teleport_lifecycle.cpp" />
-    <ClCompile Include="src\client\hooks\vendor_banner\vendor_banner_retire.cpp" />
     <ClCompile Include="src\client\hooks\teleport\teleport_move.cpp" />
     <ClCompile Include="src\client\hooks\teleport\teleport_action_key.cpp" />
     <ClCompile Include="src\client\hooks\world_objects\world_object_registry.cpp" />
+    <ClCompile Include="src\client\hooks\world_objects\world_object_identity_report.cpp" />
+    <ClCompile Include="src\client\hooks\world_objects\world_object_purge_policy.cpp" />
+    <ClCompile Include="src\client\hooks\world_objects\world_object_rebind_observer.cpp" />
     <ClCompile Include="src\client\hooking\detour.cpp" />
     <ClCompile Include="src\client\hooking\detour\protected_detour_uninstall.cpp" />
     <ClCompile Include="src\client\hooking\detour\transaction\detour_thread_transaction.cpp" />
@@ -278,7 +281,6 @@
     <ClCompile Include="src\client\hooks\bootflow\owner_activity_slot.cpp" />
     <ClCompile Include="src\client\hooks\bootflow\orbit_slice_set.cpp" />
     <ClCompile Include="src\client\hooks\bootflow\region_private.cpp" />
-    <ClCompile Include="src\client\hooks\bootflow\profile_setup_skip.cpp" />
     <ClCompile Include="src\client\hooks\bootflow\world_step.cpp" />
     <ClCompile Include="src\client\hooks\bootflow\spawn_hold.cpp" />
     <ClCompile Include="src\client\hooks\bootflow\fade_release.cpp" />
@@ -582,6 +584,7 @@
     <ClCompile Include="src\steam\interfaces\interface_tables.cpp" />
     <ClCompile Include="src\steam\interfaces\steam_interface_factory.cpp" />
     <ClCompile Include="src\state\runtime\state_runtime.cpp" />
+    <ClCompile Include="src\state\runtime\state_progression_runtime.cpp" />
     <ClCompile Include="src\state\gameplay\external\replication_common_reconciler.cpp" />
     <ClCompile Include="src\state\gameplay\external\replication_view_receptor.cpp" />
     <ClCompile Include="src\state\gameplay\external\replication_world_state.cpp" />
@@ -589,8 +592,10 @@
     <ClCompile Include="src\state\activity\activity_world_arrival.cpp" />
     <ClCompile Include="src\state\activity\mission\activity_mission_state.cpp" />
     <ClCompile Include="src\state\activity\mission\activity_mission_publication.cpp" />
+    <ClCompile Include="src\state\activity\mission\activity_mission_intents.cpp" />
     <ClCompile Include="src\state\activity\membership\activity_membership_query.cpp" />
     <ClCompile Include="src\state\unlocks\unlocks_runtime.cpp" />
+    <ClCompile Include="src\state\unlocks\unlocks_records.cpp" />
     <ClCompile Include="src\state\entitlements\entitlement_defaults.cpp" />
     <ClCompile Include="src\state\entitlements\entitlement_runtime.cpp" />
     <ClCompile Include="src\state\entitlements\entitlement_validation.cpp" />
@@ -616,7 +621,6 @@
     <ClCompile Include="src\state\matchmaking\transactions\matchmaking_commit.cpp" />
     <ClCompile Include="src\state\runtime\equipment\configured_equipment_identity.cpp" />
     <ClCompile Include="src\state\account\pursuit_hold.cpp" />
-    <ClCompile Include="src\state\vendors\answered_interactions.cpp" />
     <ClCompile Include="src\state\account\account_state.cpp" />
     <ClCompile Include="src\state\account\inventory\inventory_state.cpp" />
     <ClCompile Include="src\state\account\settings\settings_state.cpp" />
@@ -638,6 +642,7 @@
     <ClCompile Include="src\state\activity_sdk\activity_sdk_relation_validation.cpp" />
     <ClCompile Include="src\state\activity_sdk\activity_sdk_resolver.cpp" />
     <ClCompile Include="src\state\activity_sdk\activity_sdk_structure_validation.cpp" />
+    <ClCompile Include="src\state\activity_sdk\activity_sdk_seeds.cpp" />
     <ClCompile Include="src\state\activity_sdk\generation\activity_sdk_generation_state.cpp" />
     <ClCompile Include="src\state\activity_sdk\generation\activity_sdk_pack_writer.cpp" />
     <ClCompile Include="src\state\activity_sdk\generated_world\catalog_manifest.cpp" />
@@ -660,6 +665,7 @@
     <ClCompile Include="src\state\build_data\cache\records\cache_spawn_set_records.cpp" />
     <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\client\content\vendors\package_vendor_build.cpp" />
     <ClCompile Include="src\state\build_data\cache\records\cache_hash_name_records.cpp" />
@@ -700,6 +706,10 @@
     <ClCompile Include="src\state\build_data\constants\investment_constant_catalog.cpp" />
     <ClCompile Include="src\state\build_data\abilities\ability_bucket_catalog.cpp" />
     <ClCompile Include="src\state\build_data\progressions\progression_catalog.cpp" />
+    <ClCompile Include="src\state\build_data\season_pass\season_pass_catalog.cpp" />
+    <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\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" />
@@ -778,6 +788,7 @@
     <ClCompile Include="src\client\ui\activity\package_embedded_placement_marker_source.cpp" />
     <ClCompile Include="src\client\ui\activity\package_trigger_volume_geometry.cpp" />
     <ClCompile Include="src\client\ui\activity\package_trigger_volume_marker_source.cpp" />
+    <ClCompile Include="src\client\ui\activity\authored_placement_marker_render.cpp" />
     <ClCompile Include="src\core\ui\world_marker\projection.cpp" />
     <ClCompile Include="src\middleware\content\packages\tables\authored_placement_reader.cpp" />
     <ClCompile Include="src\middleware\content\packages\tables\authored_squad_reader.cpp" />
@@ -843,10 +854,22 @@
     <ClCompile Include="src\server\activity\mission\mission_script_world_sdk_fields.cpp" />
     <ClCompile Include="src\server\activity\mission\mission_script_world_sdk_values.cpp" />
     <ClCompile Include="src\server\activity\mission\mission_script_vm.cpp" />
+    <ClCompile Include="src\server\activity\mission\mission_script_lua_slot_atoms.cpp" />
+    <ClCompile Include="src\server\activity\mission\mission_script_runtime_attach.cpp" />
+    <ClCompile Include="src\server\activity\mission\mission_script_runtime_diagnostics.cpp" />
+    <ClCompile Include="src\server\activity\mission\mission_script_runtime_feed.cpp" />
     <ClCompile Include="src\server\activity\host_runtime.cpp" />
     <ClCompile Include="src\server\activity\host_runtime_names.cpp" />
     <ClCompile Include="src\server\activity\host_runtime_scriptable.cpp" />
     <ClCompile Include="src\server\activity\scriptable_override_route.cpp" />
+    <ClCompile Include="src\server\activity\activity_sdk_device_prepare.cpp" />
+    <ClCompile Include="src\server\activity\activity_sdk_mission_prepare.cpp" />
+    <ClCompile Include="src\server\activity\host_runtime_scriptable_requests.cpp" />
+    <ClCompile Include="src\server\activity\host_runtime_scriptable_staging.cpp" />
+    <ClCompile Include="src\server\activity\host_runtime_sense.cpp" />
+    <ClCompile Include="src\server\activity\host_runtime_incidents.cpp" />
+    <ClCompile Include="src\server\activity\host_runtime_client_messages.cpp" />
+    <ClCompile Include="src\server\activity\host_runtime_mission_inputs.cpp" />
     <ClCompile Include="src\server\bap\encrypted\push\activity\activity_incident_push.cpp" />
     <ClCompile Include="src\server\ui\runtime\server_ui_module_runtime.cpp" />
     <ClCompile Include="src\server\ui\activity_host\activity_host_panel.cpp" />
@@ -865,10 +888,22 @@
     <ClCompile Include="src\server\ui\activity_host\activity_host_scriptable_details.cpp" />
     <ClCompile Include="src\server\ui\activity_host\activity_host_scriptable_labels.cpp" />
     <ClCompile Include="src\server\ui\activity_host\activity_host_scriptable_browser.cpp" />
+    <ClCompile Include="src\server\ui\activity_host\activity_host_sdk_view_text.cpp" />
+    <ClCompile Include="src\server\ui\activity_host\activity_host_sdk_symbol_pages.cpp" />
+    <ClCompile Include="src\server\ui\activity_host\activity_host_sdk_behavior_view.cpp" />
+    <ClCompile Include="src\server\ui\activity_host\activity_host_scriptable_world_links.cpp" />
+    <ClCompile Include="src\server\ui\activity_host\activity_host_sdk_squad_actions.cpp" />
     <ClCompile Include="src\server\ui\activity_override\activity_override_panel.cpp" />
     <ClCompile Include="src\server\ui\activity_override\activity_override_lists.cpp" />
     <ClCompile Include="src\server\transport\bap_listener.cpp" />
     <ClCompile Include="src\server\bap\bap_route.cpp" />
+    <ClCompile Include="src\server\bap\bap_activity_link_view.cpp" />
+    <ClCompile Include="src\server\bap\bap_scriptable_override_route.cpp" />
+    <ClCompile Include="src\server\bap\bap_squad_override_route.cpp" />
+    <ClCompile Include="src\server\bap\bap_world_reward_queue.cpp" />
+    <ClCompile Include="src\server\bap\encrypted\queuez\queuez_reward_staging.cpp" />
+    <ClCompile Include="src\server\bap\encrypted\push\snapshot\family4_reward_updates.cpp" />
+    <ClCompile Include="src\server\bap\encrypted\push\snapshot\family4_socket_updates.cpp" />
     <ClCompile Include="src\server\bap\activity_mission_seed_lease.cpp" />
     <ClCompile Include="src\server\bap\squad_override_capacity.cpp" />
     <ClCompile Include="src\server\bap\plaintext.cpp" />
@@ -880,6 +915,7 @@
     <ClCompile Include="src\server\bap\encrypted\reply\bap_encrypted_reply.cpp" />
     <ClCompile Include="src\server\bap\encrypted\activity_host_manager\activity_host_manager_route.cpp" />
     <ClCompile Include="src\server\bap\encrypted\activity_message\activity_message_route.cpp" />
+    <ClCompile Include="src\server\bap\encrypted\activity_message\activity_message_framing.cpp" />
     <ClCompile Include="src\server\bap\encrypted\activity_message\patch_epoch\activity_patch_epoch_route.cpp" />
     <ClCompile Include="src\server\bap\encrypted\activity_message\membership\activity_membership_route.cpp" />
     <ClCompile Include="src\server\bap\encrypted\matchmaking\matchmaking_route.cpp" />
@@ -910,6 +946,7 @@
     <ClCompile Include="src\server\bap\encrypted\push\activity\activity_authority_query_push.cpp" />
     <ClCompile Include="src\server\bap\activity_authority_query_owner.cpp" />
     <ClCompile Include="src\server\bap\encrypted\push\activity\activity_authority_reset_push.cpp" />
+    <ClCompile Include="src\server\bap\encrypted\push\activity\activity_roster_groups.cpp" />
     <ClCompile Include="src\server\bap\activity_authority_reset_owner.cpp" />
     <ClCompile Include="src\server\bap\encrypted\push\snapshot\snapshot_object_compressor.cpp" />
     <ClCompile Include="src\server\bap\encrypted\push\snapshot\family4_selection_resolver.cpp" />
@@ -924,6 +961,8 @@
     <ClCompile Include="src\server\web_service\opcode_routes.cpp" />
     <ClCompile Include="src\server\web_service\web_service_runtime.cpp" />
     <ClCompile Include="src\server\web_service\web_service_actions.cpp" />
+    <ClCompile Include="src\server\web_service\web_service_vendor_actions.cpp" />
+    <ClCompile Include="src\server\web_service\web_service_record_actions.cpp" />
     <ClCompile Include="src\server\http\server_http.cpp" />
     <ClCompile Include="src\middleware\runtime\middleware_runtime.cpp" />
     <ClCompile Include="src\middleware\protobuf\protobuf_measure.cpp" />
@@ -1037,6 +1076,8 @@
     <ClCompile Include="src\client\content\items\packages\package_ability_build.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_subclass_build.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_progression_build.cpp" />
+    <ClCompile Include="src\client\content\items\packages\package_season_pass_build.cpp" />
+    <ClCompile Include="src\client\content\items\packages\package_bounty_build.cpp" />
     <ClCompile Include="src\middleware\content\packages\reader\package_file_locator.cpp" />
     <ClCompile Include="src\middleware\crypto\aes_gcm_decrypt.cpp" />
     <ClCompile Include="src\middleware\content\manifest\content_manifest_encoder.cpp" />
@@ -1071,12 +1112,11 @@
     <ClCompile Include="src\core\settings\state\account_rows_parser.cpp" />
     <ClCompile Include="src\server\bap\encrypted\push\queuez\queuez_banner_push.cpp" />
     <ClCompile Include="src\server\bap\encrypted\push\queuez\queuez_push_reporting.cpp" />
+    <ClCompile Include="src\server\bap\encrypted\push\queuez\queuez_family5_push.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_container_location.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_build_report.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_root_tables.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_item_rows.cpp" />
-    <ClCompile Include="src\client\diagnostics\entity_create_probe.cpp" />
-    <ClCompile Include="src\client\diagnostics\image_dump.cpp" />
     <ClCompile Include="src\client\diagnostics\module_range.cpp" />
     <ClCompile Include="src\client\process\freeze\client_process_freeze.cpp" />
     <ClCompile Include="src\core\settings\address_text.cpp" />
@@ -1152,6 +1192,7 @@
     <ClCompile Include="src\middleware\bap\activity_message\activity_sense_update_parser.cpp" />
     <ClCompile Include="src\middleware\bap\activity_message\activity_sense_update_decoder.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" />
     <ClCompile Include="src\state\activity\receipts\activity_receipts.cpp" />
     <ClCompile Include="src\core\settings\server\activation\activation_settings_parser.cpp" />
     <ClCompile Include="src\middleware\gameplay\group\migration_messages.cpp" />
@@ -1166,13 +1207,8 @@
     <ClCompile Include="src\state\build_data\nodes\node_catalog.cpp" />
     <ClCompile Include="src\state\build_data\records\record_build_data_runtime.cpp" />
     <ClCompile Include="src\state\build_data\records\record_catalog.cpp" />
-    <ClCompile Include="src\state\build_data\records\rewards\reward_build_data_runtime.cpp" />
-    <ClCompile Include="src\state\build_data\records\rewards\reward_catalog.cpp" />
-    <ClCompile Include="src\state\build_data\records\rewards\reward_persistence.cpp" />
-    <ClCompile Include="src\state\record_claims\record_claims.cpp" />
-    <ClCompile Include="src\state\progression\seasonal_experience.cpp" />
+    <ClCompile Include="src\state\build_data\sobjects\sobject_build_data_runtime.cpp" />
     <ClCompile Include="src\state\build_data\sobjects\sobject_catalog.cpp" />
-    <ClCompile Include="src\state\lore\lore_grant.cpp" />
   </ItemGroup>
   <ItemGroup Condition="'$(SunriseRunClangTidy)'=='true'">
     <ClCompile Remove="vendor\detours\detours.cpp" />
@@ -1218,7 +1254,9 @@
     <ClInclude Include="src\core\logging\snapshot\internal.h" />
     <ClInclude Include="src\core\logging\view\log_snapshot_view.h" />
     <ClInclude Include="src\core\filesystem\path.h" />
-    <ClInclude Include="src\core\settings\rule_text.h" />
+    <ClInclude Include="src\core\threading\srw_lock.h" />
+    <ClInclude Include="src\core\threading\data_mutex.h" />
+    <ClInclude Include="src\core\threading\sendable.h" />
     <ClInclude Include="src\core\filesystem\temporary_sibling.h" />
     <ClInclude Include="src\core\settings\settings.h" />
     <ClInclude Include="src\core\settings\parser.h" />
@@ -1298,10 +1336,10 @@
     <ClInclude Include="src\client\hooks\infinite_ammo\infinite_ammo.h" />
     <ClInclude Include="src\client\input\window_focus.h" />
     <ClInclude Include="src\client\hooks\teleport\internal.h" />
-    <ClInclude Include="src\client\hooks\vendor_banner\vendor_banner_retire.h" />
     <ClInclude Include="src\client\hooks\teleport\runtime.h" />
     <ClInclude Include="src\client\hooks\world_objects\world_object_registry.h" />
     <ClInclude Include="src\client\hooks\world_objects\world_object_rebind_trace.h" />
+    <ClInclude Include="src\client\hooks\world_objects\internal.h" />
     <ClInclude Include="src\client\ui\movement\movement_panel.h" />
     <ClInclude Include="src\client\runtime\internal.h" />
     <ClInclude Include="src\client\runtime\host\game_host_classification.h" />
@@ -1392,6 +1430,7 @@
     <ClInclude Include="src\steam\interfaces\internal.h" />
     <ClInclude Include="src\state\unlocks\definition.h" />
     <ClInclude Include="src\state\unlocks\unlocks_runtime.h" />
+    <ClInclude Include="src\state\unlocks\unlocks_records.h" />
     <ClInclude Include="src\state\runtime\runtime.h" />
     <ClInclude Include="src\state\runtime\state_account_transaction_helpers.h" />
     <ClInclude Include="src\state\runtime\state_rolled_socket_plugs.h" />
@@ -1400,6 +1439,7 @@
     <ClInclude Include="src\state\activity\definition.h" />
     <ClInclude Include="src\state\activity\mission\definition.h" />
     <ClInclude Include="src\state\activity\mission\runtime.h" />
+    <ClInclude Include="src\state\activity\mission\internal.h" />
     <ClInclude Include="src\state\activity\defaults\definition.h" />
     <ClInclude Include="src\state\activity\defaults\activity_defaults_snapshot.h" />
     <ClInclude Include="src\state\activity\defaults\activity_defaults_validation.h" />
@@ -1427,7 +1467,6 @@
     <ClInclude Include="src\state\matchmaking\transactions\internal.h" />
     <ClInclude Include="src\state\runtime\equipment\configured_equipment_identity.h" />
     <ClInclude Include="src\state\account\pursuit_hold.h" />
-    <ClInclude Include="src\state\vendors\answered_interactions.h" />
     <ClInclude Include="src\state\account\account_state.h" />
     <ClInclude Include="src\state\account\inventory\inventory_state.h" />
     <ClInclude Include="src\state\account\settings\settings_state.h" />
@@ -1554,6 +1593,8 @@
     <ClInclude Include="src\server\activity\host_runtime.h" />
     <ClInclude Include="src\server\activity\host_runtime_internal.h" />
     <ClInclude Include="src\server\activity\scriptable_override_route.h" />
+    <ClInclude Include="src\server\activity\activity_sdk_device_internal.h" />
+    <ClInclude Include="src\server\activity\activity_sdk_mission_internal.h" />
     <ClInclude Include="src\server\bap\encrypted\push\activity\activity_incident_push.h" />
     <ClInclude Include="src\server\ui\runtime\server_ui_module_runtime.h" />
     <ClInclude Include="src\server\ui\activity_host\activity_host_panel.h" />
@@ -1574,6 +1615,12 @@
     <ClInclude Include="src\server\ui\activity_host\activity_host_scriptable_browser.h" />
     <ClInclude Include="src\server\ui\activity_host\activity_host_table_layout.h" />
     <ClInclude Include="src\server\ui\activity_host\activity_host_tool_window.h" />
+    <ClInclude Include="src\server\ui\activity_host\activity_host_sdk_view_text.h" />
+    <ClInclude Include="src\server\ui\activity_host\activity_host_sdk_symbol_pages.h" />
+    <ClInclude Include="src\server\ui\activity_host\activity_host_sdk_mission_text.h" />
+    <ClInclude Include="src\server\ui\activity_host\activity_host_sdk_behavior_view.h" />
+    <ClInclude Include="src\server\ui\activity_host\activity_host_scriptable_world_links.h" />
+    <ClInclude Include="src\server\ui\activity_host\activity_host_sdk_squad_actions.h" />
     <ClInclude Include="src\server\ui\activity_override\activity_override_panel.h" />
     <ClInclude Include="src\server\ui\activity_override\activity_override_lists.h" />
     <ClInclude Include="src\server\bap\runtime.h" />
@@ -1585,6 +1632,7 @@
     <ClInclude Include="src\server\bap\encrypted\activity_host_manager\activity_host_manager_route.h" />
     <ClInclude Include="src\server\bap\encrypted\activity_message\definition.h" />
     <ClInclude Include="src\server\bap\encrypted\activity_message\activity_message_route.h" />
+    <ClInclude Include="src\server\bap\encrypted\activity_message\activity_message_route_internal.h" />
     <ClInclude Include="src\server\bap\encrypted\activity_message\patch_epoch\activity_patch_epoch_route.h" />
     <ClInclude Include="src\server\bap\encrypted\activity_message\membership\activity_membership_route.h" />
     <ClInclude Include="src\server\bap\encrypted\matchmaking\matchmaking_route.h" />
@@ -1616,6 +1664,7 @@
     <ClInclude Include="src\server\bap\encrypted\push\snapshot\snapshot.h" />
     <ClInclude Include="src\server\bap\encrypted\push\snapshot\snapshot_storage.h" />
     <ClInclude Include="src\server\web_service\internal.h" />
+    <ClInclude Include="src\server\web_service\internal_actions.h" />
     <ClInclude Include="src\server\web_service\opcode_routes.h" />
     <ClInclude Include="src\server\web_service\web_service_runtime.h" />
     <ClInclude Include="src\server\web_service\web_service_actions.h" />
@@ -1885,6 +1934,12 @@
     <ClInclude Include="src\client\content\activity\activity_sdk_one_shot_abi.h" />
     <ClInclude Include="src\client\content\activity\activity_sdk_tree_publication.h" />
     <ClInclude Include="src\client\content\activity\scriptable_catalog_worker.h" />
+    <ClInclude Include="src\client\content\activity\activity_sdk_actor_rsat_inventory_internal.h" />
+    <ClInclude Include="src\client\content\activity\activity_sdk_authored_scene_internal.h" />
+    <ClInclude Include="src\client\content\activity\activity_sdk_lua_missions_internal.h" />
+    <ClInclude Include="src\client\content\activity\activity_sdk_native_pack_internal.h" />
+    <ClInclude Include="src\client\content\activity\scriptable_catalog_container_placements_internal.h" />
+    <ClInclude Include="src\client\content\activity\scriptable_catalog_worker_internal.h" />
     <ClInclude Include="src\client\ui\activity\authored_placement_marker.h" />
     <ClInclude Include="src\client\ui\activity\authored_placement_marker_draw.h" />
     <ClInclude Include="src\client\ui\activity\authored_placement_marker_settings_store.h" />
@@ -1894,6 +1949,7 @@
     <ClInclude Include="src\client\ui\activity\package_embedded_placement_marker_source.h" />
     <ClInclude Include="src\client\ui\activity\package_trigger_volume_geometry.h" />
     <ClInclude Include="src\client\ui\activity\package_trigger_volume_marker_source.h" />
+    <ClInclude Include="src\client\ui\activity\authored_placement_marker_internal.h" />
     <ClInclude Include="src\core\ui\world_marker\projection.h" />
     <ClInclude Include="src\middleware\crypto\aes_gcm_decrypt.h" />
     <ClInclude Include="src\middleware\datagen\character_record\appearance\internal.h" />
@@ -1908,6 +1964,11 @@
     <ClInclude Include="src\state\build_data\constants\investment_constant_catalog.h" />
     <ClInclude Include="src\state\build_data\constants\definition.h" />
     <ClInclude Include="src\state\build_data\progressions\progression_catalog.h" />
+    <ClInclude Include="src\state\build_data\season_pass\definition.h" />
+    <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\vendors\repeatable_triggers.h" />
     <ClInclude Include="src\state\build_data\progressions\definition.h" />
     <ClInclude Include="src\state\entitlements\definition.h" />
     <ClInclude Include="src\state\entitlements\entitlement_runtime.h" />
@@ -1915,8 +1976,6 @@
     <ClInclude Include="src\server\transport\internal.h" />
     <ClInclude Include="src\middleware\content\packages\reader\locator_cache.h" />
     <ClInclude Include="src\server\bap\encrypted\push\queuez\queuez_push_reporting.h" />
-    <ClInclude Include="src\client\diagnostics\entity_create_probe.h" />
-    <ClInclude Include="src\client\diagnostics\image_dump.h" />
     <ClInclude Include="src\client\diagnostics\module_range.h" />
     <ClInclude Include="src\client\process\freeze\client_process_freeze.h" />
     <ClInclude Include="src\core\settings\address_text.h" />
@@ -1949,6 +2008,7 @@
     <ClInclude Include="src\middleware\gameplay\external\actor_entity_registry.h" />
     <ClInclude Include="src\middleware\gameplay\external\composite_entity_codec.h" />
     <ClInclude Include="src\middleware\gameplay\external\simulation_event_runtime_codec.h" />
+    <ClInclude Include="src\middleware\gameplay\external\composite_entity_codec_internal.h" />
     <ClInclude Include="src\state\gameplay\definition.h" />
     <ClInclude Include="src\state\gameplay\external\definition.h" />
     <ClInclude Include="src\state\gameplay\external\replication_common_reconciler.h" />
@@ -2000,6 +2060,7 @@
     <ClInclude Include="src\middleware\bap\activity_message\start_activity.h" />
     <ClInclude Include="src\middleware\bap\activity_message\sense_update.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" />
     <ClInclude Include="src\state\activity\receipts\activity_receipts.h" />
     <ClInclude Include="src\core\settings\server\activation\definition.h" />
@@ -2011,12 +2072,15 @@
   </ItemGroup>
   <ItemGroup>
     <ClCompile Include="src\middleware\bap\activity_message\squad_sense_state.cpp" />
+    <ClCompile Include="src\middleware\bap\activity_message\activity_scriptable_auth_mission_body_codec.cpp" />
     <ClInclude Include="src\middleware\bap\activity_message\squad_sense_state.h" />
   </ItemGroup>
   <ItemGroup>
     <ClCompile Include="src\state\gameplay\external\entity_identity.cpp" />
     <ClCompile Include="src\server\gameplay\entity_identities.cpp" />
     <ClCompile Include="src\middleware\gameplay\external\entity_identity_metadata.cpp" />
+    <ClCompile Include="src\middleware\gameplay\external\composite_entity_batch_grammar.cpp" />
+    <ClCompile Include="src\middleware\gameplay\external\composite_entity_baseline_registry.cpp" />
   </ItemGroup>
   <ItemGroup>
     <ClCompile Include="src\state\gameplay\external\entity_position_profiles.cpp" />
@@ -2026,6 +2090,17 @@
     <ClCompile Include="src\middleware\content\packages\tables\entity_position_profile_extractor.cpp" />
     <ClCompile Include="src\client\content\activity\entity_position_profile_build.cpp" />
     <ClCompile Include="src\client\content\activity\entity_object_type_build.cpp" />
+    <ClCompile Include="src\client\content\activity\activity_sdk_squad_config_parse.cpp" />
+    <ClCompile Include="src\client\content\activity\activity_sdk_actor_rsat_read.cpp" />
+    <ClCompile Include="src\client\content\activity\activity_sdk_actor_rsat_validation.cpp" />
+    <ClCompile Include="src\client\content\activity\activity_sdk_generation_world_source.cpp" />
+    <ClCompile Include="src\client\content\activity\activity_sdk_generation_scenario_shards.cpp" />
+    <ClCompile Include="src\client\content\activity\scriptable_catalog_object_analysis.cpp" />
+    <ClCompile Include="src\client\content\activity\activity_sdk_authored_scene_validation.cpp" />
+    <ClCompile Include="src\client\content\activity\activity_sdk_lua_missions.cpp" />
+    <ClCompile Include="src\client\content\activity\scriptable_catalog_container_placement_cache.cpp" />
+    <ClCompile Include="src\client\content\activity\activity_sdk_native_pack_text.cpp" />
+    <ClCompile Include="src\client\content\activity\activity_sdk_policy_inventory_capabilities.cpp" />
     <ClCompile Include="src\server\gameplay\entity_position_profile_provider.cpp" />
     <ClInclude Include="src\state\gameplay\external\entity_position_profiles.h" />
     <ClInclude Include="src\server\gameplay\entity_position_profile_provider.h" />
@@ -2041,18 +2116,10 @@
     <ClInclude Include="src\state\build_data\nodes\node_catalog.h" />
     <ClInclude Include="src\state\build_data\records\definition.h" />
     <ClInclude Include="src\state\build_data\records\record_catalog.h" />
-    <ClInclude Include="src\state\build_data\records\rewards\definition.h" />
-    <ClInclude Include="src\state\build_data\records\rewards\reward_catalog.h" />
-    <ClInclude Include="src\state\build_data\records\rewards\reward_persistence.h" />
     <ClInclude Include="src\state\build_data\sobjects\sobject_catalog.h" />
-    <ClInclude Include="src\state\lore\lore_grant.h" />
     <ClInclude Include="src\state\progression\season_pass_reward_catalog.h" />
-    <ClInclude Include="src\state\progression\seasonal_experience.h" />
-    <ClInclude Include="src\state\record_claims\objective_slot_table.h" />
-    <ClInclude Include="src\state\record_claims\parent_bar_table.h" />
     <ClInclude Include="src\client\hooks\network\investment\lore_visibility_patch.h" />
     <ClCompile Include="src\client\hooks\network\investment\investment_lore_visibility.cpp" />
-    <ClInclude Include="src\state\record_claims\record_claims.h" />
   </ItemGroup>
   <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
 </Project>

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 10 - 10
Sunrise/resources/default_settings.json


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

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

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

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

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

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

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

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

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

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

+ 2 - 1
Sunrise/src/client/content/activity/activity_sdk_activity_enrichment_inventory.cpp

@@ -232,7 +232,7 @@ bool build(const reader::Source& source,
             if (!copy_text(name, row.displayName)) {
                 return false;
             }
-            pending.rows.push_back(std::move(row));
+            pending.rows.push_back(row);
         }
         if (!validate(pending) || !validate_source(activities, pending)) {
             return false;
@@ -261,6 +261,7 @@ bool validate_source(const activity_inventory::Snapshot& source,
 
 /** Adds display names and the exact content-join flag to an otherwise closed native topology. */
 bool apply(const Snapshot& enrichment, topology_inventory::Snapshot& topology) noexcept {
+    // The exact-join bits a topology row must already carry before names are added.
     constexpr std::uint32_t beforeContent = format::kActivityRootExact
                                             | format::kActivityScenarioExact
                                             | format::kActivityExtractionPresent;

+ 6 - 4
Sunrise/src/client/content/activity/activity_sdk_activity_inventory.cpp

@@ -203,8 +203,8 @@ template <typename Root>
 
 /** Sorts live roots by tag and refuses zero or duplicate tags. */
 [[nodiscard]] bool unique_sorted_live(std::vector<LiveRoot>& rows) noexcept {
-    std::sort(rows.begin(), rows.end(), [](const LiveRoot& left, const LiveRoot& right) {
-        return left.tag < right.tag;
+    std::sort(rows.begin(), rows.end(), [](const LiveRoot& first, const LiveRoot& second) {
+        return first.tag < second.tag;
     });
     for (std::size_t index = 0; index < rows.size(); ++index) {
         if (rows[index].tag == 0 || (index != 0 && rows[index - 1U].tag == rows[index].tag)) {
@@ -223,8 +223,9 @@ void canonicalize(std::vector<std::uint32_t>& rows) {
 /** Sorts and deduplicates package locators by their serialized identity. */
 void canonicalize(std::vector<PackageLocator>& rows) {
     std::sort(
-        rows.begin(), rows.end(), [](const PackageLocator& left, const PackageLocator& right) {
-            return left.tag < right.tag || (left.tag == right.tag && left.offset < right.offset);
+        rows.begin(), rows.end(), [](const PackageLocator& first, const PackageLocator& second) {
+            return first.tag < second.tag
+                   || (first.tag == second.tag && first.offset < second.offset);
         });
     rows.erase(std::unique(rows.begin(), rows.end()), rows.end());
 }
@@ -376,6 +377,7 @@ void measure(const ActivityVariant& row,
                               : BindingCompletenessStatus::blockedUnresolvedRunnable;
 }
 
+/** @return True when both variants agree on every joined binding field. */
 [[nodiscard]] bool same_binding(const ActivityVariant& left,
                                 const ActivityVariant& right) noexcept {
     return left.activityRootTag == right.activityRootTag && left.scenarioTag == right.scenarioTag

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 2 - 1148
Sunrise/src/client/content/activity/activity_sdk_actor_rsat_inventory.cpp


+ 204 - 0
Sunrise/src/client/content/activity/activity_sdk_actor_rsat_inventory_internal.h

@@ -0,0 +1,204 @@
+#pragma once
+
+#include <algorithm>
+#include <cstddef>
+#include <cstdint>
+#include <cstdio>
+#include <cstring>
+#include <limits>
+#include <span>
+#include <string_view>
+#include <unordered_map>
+#include <unordered_set>
+#include <vector>
+
+#include "../../../middleware/content/packages/reader/reader.h"
+#include "activity_sdk_actor_rsat_inventory.h"
+
+namespace sunrise::client::content::activity::sdk_generation::actor_rsat_inventory {
+
+namespace format = state::activity_sdk::format;
+namespace reader = middleware::content::packages::reader;
+
+/** Zero and the all-ones sentinel do not name a package row. */
+inline constexpr std::uint32_t kAbsentTag = 0xFFFFFFFFU;
+
+/** One validated typed-array descriptor and its exact stored metadata. */
+struct TypedArray final {
+    std::uint32_t count{};
+    std::int64_t relative{};
+    std::uint32_t headerOffset{format::kAbsentIndex};
+    std::uint32_t dataOffset{format::kAbsentIndex};
+    std::uint32_t elementClass{format::kAbsentIndex};
+    bool typed{};
+};
+
+/** One schema and its raw fields before global schema ordering is known. */
+struct SchemaSource final {
+    RsatSchema row{};
+    std::vector<RsatField> fields{};
+};
+
+/** State owned by one transactional build. */
+struct BuildState final {
+    ReadTag readTag{};
+    void* readContext{};
+    CancelProbe cancel{};
+    void* cancelContext{};
+    Snapshot snapshot{};
+    std::vector<SchemaSource> schemaSources{};
+    std::unordered_map<std::uint32_t, std::size_t> schemaIndexes{};
+    std::unordered_set<std::uint32_t> rsatTags{};
+};
+
+/** Actual reader callback context. */
+struct PrefetchedTag final {
+    std::vector<std::byte> bytes{};
+    std::uint32_t classId{};
+};
+
+/** Package rows already held in memory, plus the reader used for every other tag. */
+struct PackageReadContext final {
+    const reader::Source* source{};
+    reader::Scratch* scratch{};
+    std::unordered_map<std::uint32_t, PrefetchedTag> prefetched{};
+};
+
+/** @return True when the caller asked the build to stop. */
+[[nodiscard]] inline bool is_cancelled(CancelProbe probe, void* context) noexcept {
+    return probe != nullptr && probe(context);
+}
+
+/** @return True when the tag names no package row. */
+[[nodiscard]] inline bool is_absent_tag(std::uint32_t tag) noexcept {
+    return tag == 0 || tag == kAbsentTag;
+}
+
+/** @return True when one complete range lies in the package blob. */
+[[nodiscard]] inline bool
+contains(std::span<const std::byte> blob, std::size_t offset, std::size_t size) noexcept {
+    return offset <= blob.size() && size <= blob.size() - offset;
+}
+
+/**
+ * Copies one fixed-size value out of a package blob.
+ * @param blob Package row bytes.
+ * @param offset Byte offset of the value.
+ * @param output Receives the value; zeroed when the range does not fit.
+ * @return False when the range does not fit the blob.
+ */
+template <typename Value>
+[[nodiscard]] bool
+read_value(std::span<const std::byte> blob, std::size_t offset, Value& output) noexcept {
+    output = {};
+    if (!contains(blob, offset, sizeof output)) {
+        return false;
+    }
+    std::memcpy(&output, blob.data() + offset, sizeof output);
+    return true;
+}
+
+/** Narrows one host size to the 32-bit width every stored offset uses. */
+[[nodiscard]] inline bool to_u32(std::size_t value, std::uint32_t& output) noexcept {
+    output = 0;
+    if (value > (std::numeric_limits<std::uint32_t>::max)()) {
+        return false;
+    }
+    output = static_cast<std::uint32_t>(value);
+    return true;
+}
+
+/** Adds one signed self-relative field without wrapping host arithmetic. */
+[[nodiscard]] inline bool
+relative_offset(std::size_t base, std::int64_t relative, std::size_t& output) noexcept {
+    // The blob stores self-relative offsets as int64, so its bounds are the limit.
+    constexpr std::int64_t kMaximum = (std::numeric_limits<std::int64_t>::max)();
+    constexpr std::int64_t kMinimum = (std::numeric_limits<std::int64_t>::min)();
+    output = 0;
+    if (base > static_cast<std::size_t>(kMaximum)) {
+        return false;
+    }
+    const auto signedBase = static_cast<std::int64_t>(base);
+    if ((relative > 0 && signedBase > kMaximum - relative)
+        || (relative < 0 && signedBase < kMinimum - relative)) {
+        return false;
+    }
+    const std::int64_t target = signedBase + relative;
+    if (target < 0) {
+        return false;
+    }
+    output = static_cast<std::size_t>(target);
+    return true;
+}
+
+/** Formats the stable actor-class ID used by the generated pack. */
+[[nodiscard]] inline bool format_actor_id(std::uint32_t tag, Text& output) noexcept {
+    output = {};
+    const int written = std::snprintf(
+        output.value.data(), output.value.size(), "actor-class/%08x", static_cast<unsigned>(tag));
+    if (written <= 0 || static_cast<std::size_t>(written) >= output.value.size()) {
+        output = {};
+        return false;
+    }
+    output.length = static_cast<std::uint16_t>(written);
+    return true;
+}
+
+/** Formats the stable RSAT schema ID used by the generated pack. */
+[[nodiscard]] inline bool format_schema_id(std::uint32_t tag, Text& output) noexcept {
+    output = {};
+    const int written = std::snprintf(
+        output.value.data(), output.value.size(), "rsat-schema/%08x", static_cast<unsigned>(tag));
+    if (written <= 0 || static_cast<std::size_t>(written) >= output.value.size()) {
+        output = {};
+        return false;
+    }
+    output.length = static_cast<std::uint16_t>(written);
+    return true;
+}
+
+/** Formats the stable tag-and-ordinal ID for one RSAT descriptor. */
+[[nodiscard]] inline bool
+format_descriptor_id(std::uint32_t tag, std::uint32_t ordinal, Text& output) noexcept {
+    output = {};
+    const int written = std::snprintf(output.value.data(),
+                                      output.value.size(),
+                                      "rsat-descriptor/%08x/%06x",
+                                      static_cast<unsigned>(tag),
+                                      static_cast<unsigned>(ordinal));
+    if (written <= 0 || static_cast<std::size_t>(written) >= output.value.size()) {
+        output = {};
+        return false;
+    }
+    output.length = static_cast<std::uint16_t>(written);
+    return true;
+}
+
+/** Reads the exact `{i64 count, i64 self-relative header}` package array. */
+[[nodiscard]] bool typed_array(std::span<const std::byte> blob,
+                               std::size_t field,
+                               std::uint32_t expectedClass,
+                               std::size_t stride,
+                               TypedArray& output) noexcept;
+
+/** Appends the state names one actor class can be told to enter, in authored order. */
+[[nodiscard]] bool collect_state_names(BuildState& state,
+                                       std::uint32_t actorIndex,
+                                       std::span<const std::byte> actorBlob);
+
+/** Reads and retains one schema exactly once by tag. */
+[[nodiscard]] bool schema(BuildState& state, std::uint32_t tag, std::size_t& sourceIndex) noexcept;
+
+/** Reads one installed SObject RSAT and retains its complete ordered component layout. */
+[[nodiscard]] bool sobject_rsat(BuildState& state, std::uint32_t tag) noexcept;
+
+/** Reads one package row and confirms its physical class. */
+[[nodiscard]] bool package_read(void* opaque,
+                                std::uint32_t tag,
+                                std::uint32_t expectedClass,
+                                std::vector<std::byte>& output) noexcept;
+
+/** Adds the pinned actor command semantics shared by every installed actor. */
+[[nodiscard]] bool add_engine_semantics(Snapshot& snapshot);
+
+} // namespace sunrise::client::content::activity::sdk_generation::actor_rsat_inventory

+ 393 - 0
Sunrise/src/client/content/activity/activity_sdk_actor_rsat_read.cpp

@@ -0,0 +1,393 @@
+#include <algorithm>
+#include <cstring>
+#include <limits>
+#include <utility>
+
+#include "activity_sdk_actor_rsat_inventory_internal.h"
+
+namespace sunrise::client::content::activity::sdk_generation::actor_rsat_inventory {
+namespace {
+
+/** Exact Tiger typed-array marker and the canonical extraction count ceiling. */
+constexpr std::uint32_t kArrayMarker = 0x80809FBDU;
+constexpr std::int64_t kMaximumArrayCount = 1'000'000;
+/** An actor definition reaches its state-machine definition through a placed object. */
+constexpr std::uint32_t kPlacedObjectClass = 0x80809C36U;
+
+/** Reads one package array whose element class is not checked, only its marker and count. */
+[[nodiscard]] bool untyped_array(std::span<const std::byte> blob,
+                                 std::size_t field,
+                                 std::size_t stride,
+                                 TypedArray& output) noexcept {
+    output = {};
+    std::int64_t count = 0;
+    std::int64_t relative = 0;
+    if (!read_value(blob, field, count) || !read_value(blob, field + 8U, relative) || count < 0
+        || count > kMaximumArrayCount) {
+        return false;
+    }
+    output.count = static_cast<std::uint32_t>(count);
+    output.relative = relative;
+    if (count == 0 && relative == 0) {
+        return true;
+    }
+    std::size_t header = 0;
+    std::uint32_t marker = 0;
+    std::int64_t repeatedCount = 0;
+    if (!relative_offset(field + 8U, relative, header) || header < 4U
+        || !contains(blob, header - 4U, 20U) || !read_value(blob, header - 4U, marker)
+        || marker != kArrayMarker || !read_value(blob, header, repeatedCount)
+        || repeatedCount != count || !read_value(blob, header + 8U, output.elementClass)
+        || header > (std::numeric_limits<std::size_t>::max)() - 16U) {
+        return false;
+    }
+    const std::size_t data = header + 16U;
+    const auto unsignedCount = static_cast<std::uint64_t>(count);
+    if (data > blob.size()
+        || (unsignedCount != 0
+            && unsignedCount > static_cast<std::uint64_t>((blob.size() - data) / stride))
+        || !to_u32(header, output.headerOffset) || !to_u32(data, output.dataOffset)) {
+        return false;
+    }
+    output.typed = true;
+    return true;
+}
+
+/** Every aligned word in the package tag range; a reference field holds its tag verbatim. */
+void tag_shaped_words(std::span<const std::byte> blob, std::vector<std::uint32_t>& output) {
+    // Package tags occupy this half-open range.
+    constexpr std::uint32_t kFirstTag = 0x80800000U;
+    constexpr std::uint32_t kLastTag = 0x81C00000U;
+    output.clear();
+    for (std::size_t offset = 0; offset + 4U <= blob.size(); offset += 4U) {
+        std::uint32_t word = 0;
+        std::memcpy(&word, blob.data() + offset, sizeof word);
+        if (word >= kFirstTag && word < kLastTag
+            && std::find(output.begin(), output.end(), word) == output.end()) {
+            output.push_back(word);
+        }
+    }
+}
+
+/** The state-machine definition lists 32-byte groups at +32; group +12 is the group name hash. */
+constexpr std::size_t kStateMachineGroupArrayOffset = 32;
+constexpr std::size_t kStateMachineGroupStride = 32;
+constexpr std::size_t kStateMachineGroupHashOffset = 12;
+constexpr std::size_t kStateMachineGroupNamesOffset = 16;
+
+/** Appends the `state_machine` group's names from one definition, in authored order. */
+[[nodiscard]] bool state_machine_names(std::span<const std::byte> definition,
+                                       std::vector<std::uint32_t>& output) noexcept {
+    output.clear();
+    TypedArray groups{};
+    if (!untyped_array(
+            definition, kStateMachineGroupArrayOffset, kStateMachineGroupStride, groups)) {
+        return false;
+    }
+    bool found = false;
+    for (std::uint32_t index = 0; index < groups.count; ++index) {
+        const std::size_t element = static_cast<std::size_t>(groups.dataOffset)
+                                    + static_cast<std::size_t>(index) * kStateMachineGroupStride;
+        std::uint32_t groupHash = 0;
+        if (!read_value(definition, element + kStateMachineGroupHashOffset, groupHash)) {
+            return false;
+        }
+        if (groupHash != format::kActorStateMachineGroupHash) {
+            continue;
+        }
+        if (found) {
+            return false;
+        }
+        found = true;
+        TypedArray names{};
+        if (!untyped_array(definition, element + kStateMachineGroupNamesOffset, 4U, names)) {
+            return false;
+        }
+        for (std::uint32_t ordinal = 0; ordinal < names.count; ++ordinal) {
+            std::uint32_t nameHash = 0;
+            if (!read_value(definition,
+                            static_cast<std::size_t>(names.dataOffset)
+                                + static_cast<std::size_t>(ordinal) * 4U,
+                            nameHash)) {
+                return false;
+            }
+            output.push_back(nameHash);
+        }
+    }
+    return found;
+}
+
+} // namespace
+
+/** Reads the exact `{i64 count, i64 self-relative header}` package array. */
+bool typed_array(std::span<const std::byte> blob,
+                 std::size_t field,
+                 std::uint32_t expectedClass,
+                 std::size_t stride,
+                 TypedArray& output) noexcept {
+    output = {};
+    std::int64_t count = 0;
+    std::int64_t relative = 0;
+    if (!read_value(blob, field, count) || !read_value(blob, field + 8U, relative) || count < 0
+        || count > kMaximumArrayCount) {
+        return false;
+    }
+    output.count = static_cast<std::uint32_t>(count);
+    output.relative = relative;
+    if (count == 0 && relative == 0) {
+        return true;
+    }
+
+    std::size_t header = 0;
+    if (!relative_offset(field + 8U, relative, header) || header < 4U
+        || !contains(blob, header - 4U, 20U)) {
+        return false;
+    }
+    std::uint32_t marker = 0;
+    std::int64_t repeatedCount = 0;
+    std::uint32_t elementClass = 0;
+    std::uint32_t padding = 0;
+    if (!read_value(blob, header - 4U, marker) || marker != kArrayMarker
+        || !read_value(blob, header, repeatedCount) || repeatedCount != count
+        || !read_value(blob, header + 8U, elementClass) || elementClass != expectedClass
+        || !read_value(blob, header + 12U, padding) || padding != 0
+        || header > (std::numeric_limits<std::size_t>::max)() - 16U) {
+        return false;
+    }
+    const std::size_t data = header + 16U;
+    const auto unsignedCount = static_cast<std::uint64_t>(count);
+    if (data > blob.size()
+        || (unsignedCount != 0
+            && unsignedCount > static_cast<std::uint64_t>((blob.size() - data) / stride))
+        || !to_u32(header, output.headerOffset) || !to_u32(data, output.dataOffset)) {
+        return false;
+    }
+    output.elementClass = elementClass;
+    output.typed = true;
+    return true;
+}
+
+/**
+ * Collects the state names one actor class can be told to enter. The actor definition refers to
+ * placed objects, and exactly one of those refers to one state-machine definition. Any other
+ * shape emits no rows for the class.
+ */
+bool collect_state_names(BuildState& state,
+                         std::uint32_t actorIndex,
+                         std::span<const std::byte> actorBlob) {
+    std::vector<std::uint32_t> words{};
+    std::vector<std::uint32_t> placedWords{};
+    std::vector<std::byte> placed{};
+    std::vector<std::byte> definition{};
+    std::vector<std::uint32_t> definitions{};
+    tag_shaped_words(actorBlob, words);
+    for (const std::uint32_t placedTag : words) {
+        if (!state.readTag(state.readContext, placedTag, kPlacedObjectClass, placed)) {
+            continue;
+        }
+        tag_shaped_words(placed, placedWords);
+        for (const std::uint32_t definitionTag : placedWords) {
+            if (std::find(definitions.begin(), definitions.end(), definitionTag)
+                    != definitions.end()
+                || !state.readTag(state.readContext,
+                                  definitionTag,
+                                  format::kActorStateMachineDefinitionClass,
+                                  definition)) {
+                continue;
+            }
+            definitions.push_back(definitionTag);
+        }
+    }
+    if (definitions.size() != 1
+        || !state.readTag(state.readContext,
+                          definitions.front(),
+                          format::kActorStateMachineDefinitionClass,
+                          definition)) {
+        return true;
+    }
+    std::vector<std::uint32_t> names{};
+    if (!state_machine_names(definition, names)) {
+        return true;
+    }
+    // The ordinal counts emitted rows, not authored positions. A skipped name would otherwise
+    // leave a gap, and the catalog requires one contiguous run per actor class.
+    std::uint32_t ordinal = 0;
+    for (const std::uint32_t nameHash : names) {
+        if (nameHash == 0 || nameHash == kAbsentTag) {
+            continue;
+        }
+        ActorStateName row{};
+        row.actorClassIndex = actorIndex;
+        row.definitionTag = definitions.front();
+        row.groupHash = format::kActorStateMachineGroupHash;
+        row.nameHash = nameHash;
+        row.ordinal = ordinal++;
+        row.flags = format::kActorStateNameExact;
+        state.snapshot.actorStateNames.push_back(row);
+    }
+    return true;
+}
+
+/** Reads and retains one schema exactly once by tag. */
+bool schema(BuildState& state, std::uint32_t tag, std::size_t& sourceIndex) noexcept {
+    sourceIndex = 0;
+    if (is_absent_tag(tag) || is_cancelled(state.cancel, state.cancelContext)) {
+        return false;
+    }
+    const auto found = state.schemaIndexes.find(tag);
+    if (found != state.schemaIndexes.end()) {
+        sourceIndex = found->second;
+        return true;
+    }
+
+    std::vector<std::byte> blob{};
+    if (!state.readTag(state.readContext, tag, format::kActorRsatSchemaClass, blob)) {
+        return false;
+    }
+    TypedArray array{};
+    if (!typed_array(blob,
+                     format::kActorRsatSchemaFieldArrayOffset,
+                     format::kActorRsatSchemaFieldClass,
+                     kSchemaFieldStride,
+                     array)) {
+        return false;
+    }
+
+    SchemaSource source{};
+    if (!format_schema_id(tag, source.row.id)) {
+        return false;
+    }
+    source.row.schemaTag = tag;
+    source.row.schemaClass = format::kActorRsatSchemaClass;
+    source.row.fieldCount = array.count;
+    source.row.fieldArrayOffset = format::kActorRsatSchemaFieldArrayOffset;
+    source.row.fieldArrayRelative = array.relative;
+    source.row.fieldArrayHeaderOffset = array.headerOffset;
+    source.row.fieldArrayDataOffset = array.dataOffset;
+    source.row.fieldElementClass = array.elementClass;
+    if (array.typed) {
+        source.row.flags |= format::kRsatSchemaTypedFieldArray;
+    }
+    if (array.count != 0) {
+        if (!read_value(blob, array.dataOffset, source.row.firstFieldRuntimeGate)
+            || !read_value(blob, array.dataOffset + 0x10U, source.row.firstFieldRawU32At10)) {
+            return false;
+        }
+        if (source.row.firstFieldRuntimeGate != format::kAbsentIndex) {
+            source.row.flags |= format::kRsatSchemaDynamicPresenceEligible;
+        }
+    }
+    try {
+        source.fields.reserve(array.count);
+        for (std::uint32_t ordinal = 0; ordinal < array.count; ++ordinal) {
+            const std::size_t offset = static_cast<std::size_t>(array.dataOffset)
+                                       + static_cast<std::size_t>(ordinal) * kSchemaFieldStride;
+            RsatField field{};
+            std::copy_n(blob.data() + offset, field.rawRow.size(), field.rawRow.begin());
+            source.fields.push_back(field);
+        }
+        sourceIndex = state.schemaSources.size();
+        state.schemaSources.push_back(std::move(source));
+        state.schemaIndexes.emplace(tag, sourceIndex);
+        return true;
+    } catch (...) {
+        return false;
+    }
+}
+
+/** Reads one installed SObject RSAT and retains its complete ordered component layout. */
+bool sobject_rsat(BuildState& state, std::uint32_t tag) noexcept {
+    if (is_absent_tag(tag) || is_cancelled(state.cancel, state.cancelContext)
+        || state.snapshot.sobjectRsats.size() > (std::numeric_limits<std::uint32_t>::max)()) {
+        return false;
+    }
+    std::vector<std::byte> blob{};
+    TypedArray array{};
+    SobjectRsat row{};
+    if (!state.readTag(state.readContext, tag, kActorRsatClass, blob)
+        || !read_value(blob, kActorRsatReverseDefinitionOffset, row.reverseDefinitionTag)
+        || !typed_array(blob,
+                        format::kActorRsatDescriptorArrayOffset,
+                        format::kActorRsatDescriptorClass,
+                        kDescriptorStride,
+                        array)
+        || state.snapshot.sobjectRsatDescriptors.size()
+               > (std::numeric_limits<std::uint32_t>::max)() - array.count) {
+        return false;
+    }
+    row.rsatTag = tag;
+    row.descriptorArrayOffset = format::kActorRsatDescriptorArrayOffset;
+    row.descriptorArrayRelative = array.relative;
+    row.descriptorArrayHeaderOffset = array.headerOffset;
+    row.descriptorArrayDataOffset = array.dataOffset;
+    row.descriptorElementClass = array.elementClass;
+    row.provenance = format::ActorSemanticProvenance::packageField;
+    row.descriptors.first =
+        static_cast<std::uint32_t>(state.snapshot.sobjectRsatDescriptors.size());
+    row.descriptors.count = array.count;
+    row.flags = format::kSobjectRsatExact;
+    const std::uint32_t rsatIndex = static_cast<std::uint32_t>(state.snapshot.sobjectRsats.size());
+    std::uint32_t tailOrdinal = 0;
+    for (std::uint32_t ordinal = 0; ordinal < array.count; ++ordinal) {
+        const std::size_t offset = static_cast<std::size_t>(array.dataOffset)
+                                   + static_cast<std::size_t>(ordinal) * kDescriptorStride;
+        SobjectRsatDescriptor descriptor{};
+        descriptor.rsatIndex = rsatIndex;
+        descriptor.descriptorOrdinal = ordinal;
+        if (!to_u32(offset, descriptor.descriptorOffset)) {
+            return false;
+        }
+        std::copy_n(blob.data() + offset, descriptor.rawRow.size(), descriptor.rawRow.begin());
+        std::memcpy(
+            &descriptor.componentTag, descriptor.rawRow.data(), sizeof descriptor.componentTag);
+        std::memcpy(
+            &descriptor.schemaTag, descriptor.rawRow.data() + 4U, sizeof descriptor.schemaTag);
+        std::size_t schemaSourceIndex = 0;
+        if (!schema(state, descriptor.schemaTag, schemaSourceIndex)) {
+            return false;
+        }
+        const RsatSchema& schemaRow = state.schemaSources[schemaSourceIndex].row;
+        descriptor.schemaFieldCount = schemaRow.fieldCount;
+        descriptor.schemaFirstFieldRuntimeGate = schemaRow.firstFieldRuntimeGate;
+        if ((schemaRow.flags & format::kRsatSchemaDynamicPresenceEligible) != 0) {
+            descriptor.flags |= format::kSobjectRsatDescriptorDynamicPresenceEligible;
+            descriptor.dynamicPresenceTailOrdinal = tailOrdinal++;
+        }
+        state.snapshot.sobjectRsatDescriptors.push_back(descriptor);
+    }
+    row.dynamicPresenceTailCount = tailOrdinal;
+    state.snapshot.sobjectRsats.push_back(row);
+    return true;
+}
+
+/** Reads one package row and confirms its physical class. */
+bool package_read(void* opaque,
+                  std::uint32_t tag,
+                  std::uint32_t expectedClass,
+                  std::vector<std::byte>& output) noexcept {
+    output.clear();
+    if (opaque == nullptr) {
+        return false;
+    }
+    auto& context = *static_cast<PackageReadContext*>(opaque);
+    const auto prefetched = context.prefetched.find(tag);
+    if (prefetched != context.prefetched.end()) {
+        if (prefetched->second.classId != expectedClass) {
+            return false;
+        }
+        try {
+            output = prefetched->second.bytes;
+            return true;
+        } catch (...) {
+            output.clear();
+            return false;
+        }
+    }
+    std::uint32_t actualClass = 0;
+    return context.source != nullptr && context.scratch != nullptr
+           && reader::read_tag_class(*context.source, *context.scratch, tag, actualClass)
+           && actualClass == expectedClass
+           && reader::read_tag(*context.source, *context.scratch, tag, output, actualClass);
+}
+
+} // namespace sunrise::client::content::activity::sdk_generation::actor_rsat_inventory

+ 637 - 0
Sunrise/src/client/content/activity/activity_sdk_actor_rsat_validation.cpp

@@ -0,0 +1,637 @@
+// Owns the pinned engine-semantics table, the copy that installs it, and the snapshot validator.
+// The table is large, so exactly one translation unit may include it.
+#include <algorithm>
+#include <array>
+#include <cstring>
+#include <limits>
+#include <string_view>
+#include <vector>
+
+#include "activity_sdk_actor_rsat_inventory_internal.h"
+
+namespace sunrise::client::content::activity::sdk_generation::actor_rsat_inventory {
+namespace {
+
+#include "activity_sdk_actor_engine_semantics.inc"
+
+/** Copies one bounded catalog name into fixed inventory storage. */
+[[nodiscard]] bool text_literal(std::string_view value, Text& output) noexcept {
+    output = {};
+    if (value.empty() || value.size() >= output.value.size()) {
+        return false;
+    }
+    std::copy(value.begin(), value.end(), output.value.begin());
+    output.length = static_cast<std::uint16_t>(value.size());
+    return true;
+}
+
+/** Accepts only one bounded string followed by zero-filled storage. */
+[[nodiscard]] bool valid_text(const Text& text) noexcept {
+    if (text.length >= text.value.size() || text.value[text.length] != '\0') {
+        return false;
+    }
+    for (std::size_t index = 0; index < text.length; ++index) {
+        if (text.value[index] == '\0') {
+            return false;
+        }
+    }
+    return std::all_of(text.value.begin() + text.length + 1U, text.value.end(), [](char value) {
+        return value == '\0';
+    });
+}
+
+[[nodiscard]] bool same_text(const Text& left, const Text& right) noexcept {
+    return valid_text(left) && valid_text(right) && left.length == right.length
+           && left.value == right.value;
+}
+
+[[nodiscard]] bool range_inside(format::Range range, std::size_t size) noexcept {
+    return range.first <= size && range.count <= size - range.first;
+}
+
+/** Field-5 lanes 1..4 are bias-one values with widths 2, 3, 2, and 3. */
+[[nodiscard]] bool valid_authored_spawn_profile(const ActorClass& actor) noexcept {
+    // Logical maxima of the four lanes after the bias-one decode.
+    constexpr std::array<std::int8_t, 4> kMaximumLogical{2, 6, 2, 6};
+    for (std::size_t index = 0; index < actor.authoredSpawnProfile.size(); ++index) {
+        if (actor.authoredSpawnProfile[index] < 0
+            || actor.authoredSpawnProfile[index] > kMaximumLogical[index]) {
+            return false;
+        }
+    }
+    return true;
+}
+
+/** Checks an exact stored self-relative header and data pair. */
+[[nodiscard]] bool stored_typed_array(std::uint32_t field,
+                                      std::int64_t relative,
+                                      std::uint32_t header,
+                                      std::uint32_t data) noexcept {
+    std::size_t expected = 0;
+    return relative != 0
+           && relative_offset(static_cast<std::size_t>(field) + 8U, relative, expected)
+           && expected <= (std::numeric_limits<std::uint32_t>::max)()
+           && header == static_cast<std::uint32_t>(expected)
+           && header <= (std::numeric_limits<std::uint32_t>::max)() - 16U && data == header + 16U;
+}
+
+} // namespace
+
+/** Adds the pinned actor command semantics shared by every installed actor. */
+bool add_engine_semantics(Snapshot& snapshot) {
+    const ExtractedActorEngineSemantics& extracted = kExtractedActorEngineSemantics;
+    const bool hasIdentity = std::any_of(extracted.executableIdentity.begin(),
+                                         extracted.executableIdentity.end(),
+                                         [](std::byte value) { return value != std::byte{}; });
+    if (extracted.version == 0 || !hasIdentity || extracted.message.evidenceAddress == 0) {
+        return false;
+    }
+    ActorMessageSchema message{};
+    if (!text_literal(extracted.message.name, message.name)) {
+        return false;
+    }
+    message.definitionHandle = extracted.message.definitionHandle;
+    message.durableKey = extracted.message.durableKey;
+    message.ownerClass = extracted.message.ownerClass;
+    message.handlerSlot = extracted.message.handlerSlot;
+    message.bodyType = extracted.message.bodyType;
+    message.commands = {0, static_cast<std::uint32_t>(extracted.commands.size())};
+    message.flags = format::kActorMessageSchemaExact;
+    snapshot.messageSchemas.push_back(message);
+    for (const ExtractedActorCommandDefinition& input : extracted.commands) {
+        ActorCommandDefinition command{};
+        if (input.metadataEvidenceAddress == 0 || !text_literal(input.name, command.name)) {
+            return false;
+        }
+        command.selector = input.selector;
+        command.payloadHandle = input.payloadHandle;
+        command.effect = input.setFaction ? format::ActorCommandEffect::setFaction
+                                          : format::ActorCommandEffect::opaque;
+        if (input.setFaction) {
+            if (input.effectEvidenceAddress == 0
+                || !text_literal(input.factionNoneName, command.factionNoneName)
+                || !text_literal(input.factionRemovedName, command.factionRemovedName)
+                || !text_literal(input.factionHostileToAllName, command.factionHostileToAllName)) {
+                return false;
+            }
+            command.factionNone = input.factionNone;
+            command.factionRemoved = input.factionRemoved;
+            command.factionHostileToAll = input.factionHostileToAll;
+        }
+        command.flags = format::kActorCommandDefinitionExact;
+        snapshot.commandDefinitions.push_back(command);
+    }
+    for (const ExtractedSimulationEventDefinition& input : extracted.simulationEvents) {
+        SimulationEventDefinition event{};
+        if (!text_literal(input.name, event.name)) {
+            return false;
+        }
+        event.eventType = input.eventType;
+        event.primarySchema = input.primarySchema;
+        event.secondarySchema = input.secondarySchema;
+        event.descriptorEvidenceAddress = input.descriptorEvidenceAddress;
+        event.primaryEvidenceAddress = input.primaryEvidenceAddress;
+        event.secondaryEvidenceAddress = input.secondaryEvidenceAddress;
+        event.flags = format::kSimulationEventDefinitionExact;
+        if (input.primarySchema == format::kAbsentIndex) {
+            event.flags |= format::kSimulationEventPrimaryAbsent;
+        }
+        if (input.secondarySchema == format::kAbsentIndex) {
+            event.flags |= format::kSimulationEventSecondaryAbsent;
+        }
+        snapshot.simulationEvents.push_back(event);
+    }
+    for (const ExtractedRuntimeSchema& input : extracted.runtimeSchemas) {
+        RuntimeSchema schema{};
+        schema.handle = input.handle;
+        schema.decodedSize = input.decodedSize;
+        schema.definitionHash = input.definitionHash;
+        schema.definitionClass = input.definitionClass;
+        schema.codecFamilies = input.codecFamilies;
+        schema.fields = {input.firstField, input.fieldCount};
+        schema.evidenceAddress = input.evidenceAddress;
+        schema.flags = input.flags;
+        schema.arrayElementCount = input.arrayElementCount;
+        snapshot.runtimeSchemas.push_back(schema);
+    }
+    for (const ExtractedRuntimeField& input : extracted.runtimeFields) {
+        RuntimeField field{};
+        field.schemaIndex = input.schemaIndex;
+        field.ordinal = input.ordinal;
+        field.structOffset = input.structOffset;
+        field.alternateOffset = input.alternateOffset;
+        field.typeCode = input.typeCode;
+        field.nestedHandle = input.nestedHandle;
+        field.bias = input.bias;
+        field.bits = input.bits;
+        field.codecParameters = input.codecParameters;
+        field.flags = input.flags;
+        snapshot.runtimeFields.push_back(field);
+    }
+    for (const ExtractedRuntimeTypeDefinition& input : extracted.runtimeTypes) {
+        RuntimeTypeDefinition type{};
+        if (!text_literal(input.name, type.name)) {
+            return false;
+        }
+        type.codecFamilies = input.codecFamily;
+        type.typeCode = input.typeCode;
+        type.decodedSize = input.decodedSize;
+        type.fixedBits = input.fixedBits;
+        type.minimumBits = input.minimumBits;
+        type.maximumBits = input.maximumBits;
+        type.writerEvidenceAddress = input.writerEvidenceAddress;
+        type.readerEvidenceAddress = input.readerEvidenceAddress;
+        type.flags = input.flags;
+        snapshot.runtimeTypes.push_back(type);
+    }
+    for (const ExtractedEntityTypeDefinition& input : extracted.entityTypes) {
+        EntityTypeDefinition entity{};
+        if (!text_literal(input.name, entity.name)) {
+            return false;
+        }
+        entity.entityType = input.entityType;
+        entity.baselineSchema = input.baselineSchema;
+        entity.updateSchema = input.updateSchema;
+        entity.vtableEvidenceAddress = input.vtableEvidenceAddress;
+        entity.baselineEvidenceAddress = input.baselineEvidenceAddress;
+        entity.updateEvidenceAddress = input.updateEvidenceAddress;
+        entity.flags = input.flags;
+        snapshot.entityTypes.push_back(entity);
+    }
+    return true;
+}
+
+/** Validates every id, scalar, raw row, owner, range, order, join, and flag. */
+bool validate(const Snapshot& snapshot) noexcept {
+    if (!snapshot.complete || snapshot.actorClasses.empty()) {
+        return false;
+    }
+
+    Text messageName{};
+    const ExtractedActorEngineSemantics& extracted = kExtractedActorEngineSemantics;
+    if (snapshot.messageSchemas.size() != 1
+        || snapshot.commandDefinitions.size() != extracted.commands.size()
+        || snapshot.behaviorProfiles.size() != snapshot.actorClasses.size()
+        || !text_literal(extracted.message.name, messageName)) {
+        return false;
+    }
+    const ActorMessageSchema& message = snapshot.messageSchemas.front();
+    if (!same_text(message.name, messageName)
+        || message.definitionHandle != extracted.message.definitionHandle
+        || message.durableKey != extracted.message.durableKey
+        || message.ownerClass != extracted.message.ownerClass
+        || message.handlerSlot != extracted.message.handlerSlot
+        || message.bodyType != extracted.message.bodyType
+        || message.provenance != format::ActorSemanticProvenance::executableStatic
+        || message.commands.first != 0
+        || message.commands.count != snapshot.commandDefinitions.size()
+        || message.flags != format::kActorMessageSchemaExact) {
+        return false;
+    }
+    for (std::size_t index = 0; index < snapshot.commandDefinitions.size(); ++index) {
+        const ActorCommandDefinition& command = snapshot.commandDefinitions[index];
+        const ExtractedActorCommandDefinition& expected = extracted.commands[index];
+        Text commandName{};
+        Text factionNoneName{};
+        Text factionRemovedName{};
+        Text factionHostileName{};
+        const auto expectedEffect = expected.setFaction ? format::ActorCommandEffect::setFaction
+                                                        : format::ActorCommandEffect::opaque;
+        if (!text_literal(expected.name, commandName)
+            || (expected.setFaction
+                && (!text_literal(expected.factionNoneName, factionNoneName)
+                    || !text_literal(expected.factionRemovedName, factionRemovedName)
+                    || !text_literal(expected.factionHostileToAllName, factionHostileName)))
+            || !same_text(command.name, commandName)
+            || !same_text(command.factionNoneName, factionNoneName)
+            || !same_text(command.factionRemovedName, factionRemovedName)
+            || !same_text(command.factionHostileToAllName, factionHostileName)
+            || command.selector != expected.selector
+            || command.payloadHandle != expected.payloadHandle || command.effect != expectedEffect
+            || command.provenance != format::ActorSemanticProvenance::executableStatic
+            || command.factionNone != expected.factionNone
+            || command.factionRemoved != expected.factionRemoved
+            || command.factionHostileToAll != expected.factionHostileToAll
+            || command.flags != format::kActorCommandDefinitionExact) {
+            return false;
+        }
+    }
+    if (snapshot.simulationEvents.size() != extracted.simulationEvents.size()
+        || snapshot.runtimeSchemas.size() != extracted.runtimeSchemas.size()
+        || snapshot.runtimeFields.size() != extracted.runtimeFields.size()
+        || snapshot.runtimeTypes.size() != extracted.runtimeTypes.size()
+        || snapshot.entityTypes.size() != extracted.entityTypes.size()) {
+        return false;
+    }
+    for (std::size_t index = 0; index < snapshot.simulationEvents.size(); ++index) {
+        const SimulationEventDefinition& row = snapshot.simulationEvents[index];
+        const ExtractedSimulationEventDefinition& expected = extracted.simulationEvents[index];
+        Text expectedName{};
+        const std::uint32_t absentFlags =
+            (expected.primarySchema == format::kAbsentIndex ? format::kSimulationEventPrimaryAbsent
+                                                            : 0U)
+            | (expected.secondarySchema == format::kAbsentIndex
+                   ? format::kSimulationEventSecondaryAbsent
+                   : 0U);
+        if (!text_literal(expected.name, expectedName) || !same_text(row.name, expectedName)
+            || row.eventType != expected.eventType || row.primarySchema != expected.primarySchema
+            || row.secondarySchema != expected.secondarySchema
+            || row.provenance != format::ActorSemanticProvenance::executableStatic
+            || row.descriptorEvidenceAddress != expected.descriptorEvidenceAddress
+            || row.primaryEvidenceAddress != expected.primaryEvidenceAddress
+            || row.secondaryEvidenceAddress != expected.secondaryEvidenceAddress
+            || row.flags != (format::kSimulationEventDefinitionExact | absentFlags)) {
+            return false;
+        }
+    }
+    for (std::size_t index = 0; index < snapshot.runtimeSchemas.size(); ++index) {
+        const RuntimeSchema& row = snapshot.runtimeSchemas[index];
+        const ExtractedRuntimeSchema& expected = extracted.runtimeSchemas[index];
+        if (row.handle != expected.handle || row.decodedSize != expected.decodedSize
+            || row.definitionHash != expected.definitionHash
+            || row.definitionClass != expected.definitionClass
+            || row.codecFamilies != expected.codecFamilies
+            || row.provenance != format::ActorSemanticProvenance::executableStatic
+            || row.fields.first != expected.firstField || row.fields.count != expected.fieldCount
+            || !range_inside(row.fields, snapshot.runtimeFields.size())
+            || row.evidenceAddress != expected.evidenceAddress || row.flags != expected.flags
+            || row.arrayElementCount != expected.arrayElementCount) {
+            return false;
+        }
+    }
+    for (std::size_t index = 0; index < snapshot.runtimeFields.size(); ++index) {
+        const RuntimeField& row = snapshot.runtimeFields[index];
+        const ExtractedRuntimeField& expected = extracted.runtimeFields[index];
+        if (row.schemaIndex != expected.schemaIndex || row.ordinal != expected.ordinal
+            || row.structOffset != expected.structOffset
+            || row.alternateOffset != expected.alternateOffset || row.typeCode != expected.typeCode
+            || row.nestedHandle != expected.nestedHandle || row.bias != expected.bias
+            || row.bits != expected.bits || row.codecParameters != expected.codecParameters
+            || row.flags != expected.flags || row.schemaIndex >= snapshot.runtimeSchemas.size()) {
+            return false;
+        }
+    }
+    for (std::size_t index = 0; index < snapshot.runtimeTypes.size(); ++index) {
+        const RuntimeTypeDefinition& row = snapshot.runtimeTypes[index];
+        const ExtractedRuntimeTypeDefinition& expected = extracted.runtimeTypes[index];
+        Text expectedName{};
+        if (!text_literal(expected.name, expectedName) || !same_text(row.name, expectedName)
+            || row.codecFamilies != expected.codecFamily || row.typeCode != expected.typeCode
+            || row.decodedSize != expected.decodedSize || row.fixedBits != expected.fixedBits
+            || row.minimumBits != expected.minimumBits || row.maximumBits != expected.maximumBits
+            || row.writerEvidenceAddress != expected.writerEvidenceAddress
+            || row.readerEvidenceAddress != expected.readerEvidenceAddress
+            || row.flags != expected.flags) {
+            return false;
+        }
+    }
+    for (std::size_t index = 0; index < snapshot.entityTypes.size(); ++index) {
+        const EntityTypeDefinition& row = snapshot.entityTypes[index];
+        const ExtractedEntityTypeDefinition& expected = extracted.entityTypes[index];
+        Text expectedName{};
+        if (!text_literal(expected.name, expectedName) || !same_text(row.name, expectedName)
+            || row.entityType != expected.entityType
+            || row.baselineSchema != expected.baselineSchema
+            || row.updateSchema != expected.updateSchema
+            || row.provenance != format::ActorSemanticProvenance::executableStatic
+            || row.vtableEvidenceAddress != expected.vtableEvidenceAddress
+            || row.baselineEvidenceAddress != expected.baselineEvidenceAddress
+            || row.updateEvidenceAddress != expected.updateEvidenceAddress
+            || row.flags != expected.flags) {
+            return false;
+        }
+    }
+    for (std::size_t index = 0; index < snapshot.actorStateNames.size(); ++index) {
+        const ActorStateName& row = snapshot.actorStateNames[index];
+        const bool ordered =
+            index == 0 || snapshot.actorStateNames[index - 1U].actorClassIndex < row.actorClassIndex
+            || (snapshot.actorStateNames[index - 1U].actorClassIndex == row.actorClassIndex
+                && snapshot.actorStateNames[index - 1U].ordinal + 1U == row.ordinal);
+        const bool first =
+            index == 0
+            || snapshot.actorStateNames[index - 1U].actorClassIndex != row.actorClassIndex;
+        if (!ordered || (first && row.ordinal != 0)
+            || row.actorClassIndex >= snapshot.actorClasses.size()
+            || is_absent_tag(row.definitionTag)
+            || row.groupHash != format::kActorStateMachineGroupHash || row.nameHash == 0
+            || row.nameHash == kAbsentTag || row.flags != format::kActorStateNameExact) {
+            return false;
+        }
+    }
+    if (snapshot.sobjectRsatFieldBindings.size() != snapshot.fields.size()) {
+        return false;
+    }
+    for (std::size_t index = 0; index < snapshot.sobjectRsatFieldBindings.size(); ++index) {
+        const SobjectRsatFieldBinding& binding = snapshot.sobjectRsatFieldBindings[index];
+        const RsatField& field = snapshot.fields[index];
+        std::uint32_t handle = 0;
+        std::uint32_t parameter14 = 0;
+        std::uint32_t parameter18 = 0;
+        std::uint64_t decodedOffset = 0;
+        std::memcpy(&handle, field.rawRow.data() + 0x10U, sizeof handle);
+        std::memcpy(&parameter14, field.rawRow.data() + 0x14U, sizeof parameter14);
+        std::memcpy(&parameter18, field.rawRow.data() + 0x18U, sizeof parameter18);
+        std::memcpy(&decodedOffset, field.rawRow.data() + 0x20U, sizeof decodedOffset);
+        const std::uint32_t expectedFlags =
+            format::kSobjectRsatFieldBindingExact
+            | (handle != format::kAbsentIndex ? format::kSobjectRsatFieldBindingHasRuntimeSchema
+                                              : 0U);
+        const RuntimeSchema* runtime = nullptr;
+        if (handle != format::kAbsentIndex) {
+            const auto found =
+                std::find_if(snapshot.runtimeSchemas.begin(),
+                             snapshot.runtimeSchemas.end(),
+                             [handle](const RuntimeSchema& row) { return row.handle == handle; });
+            if (found == snapshot.runtimeSchemas.end()) {
+                return false;
+            }
+            runtime = &*found;
+        }
+        if (binding.rsatFieldIndex != index || binding.runtimeSchemaHandle != handle
+            || binding.parameter14 != parameter14 || binding.parameter18 != parameter18
+            || binding.decodedOffset != decodedOffset
+            || (runtime != nullptr
+                && (binding.definitionClass != runtime->definitionClass
+                    || binding.codecFamilies != runtime->codecFamilies))
+            || (runtime == nullptr && (binding.definitionClass != 0 || binding.codecFamilies != 0))
+            || binding.provenance != format::ActorSemanticProvenance::packageField
+            || binding.flags != expectedFlags) {
+            return false;
+        }
+    }
+
+    std::vector<std::uint32_t> schemaReferences{};
+    std::size_t descriptorCursor = 0;
+    try {
+        schemaReferences.resize(snapshot.schemas.size());
+    } catch (...) {
+        return false;
+    }
+    for (std::size_t actorIndex = 0; actorIndex < snapshot.actorClasses.size(); ++actorIndex) {
+        const ActorClass& actor = snapshot.actorClasses[actorIndex];
+        const ActorBehaviorProfile& profile = snapshot.behaviorProfiles[actorIndex];
+        Text expectedId{};
+        const bool absentRsat = is_absent_tag(actor.rsatTag);
+        const bool nullArray = !absentRsat && actor.descriptorArrayRelative == 0;
+        const bool typedArray =
+            !absentRsat
+            && stored_typed_array(actor.descriptorArrayOffset,
+                                  actor.descriptorArrayRelative,
+                                  actor.descriptorArrayHeaderOffset,
+                                  actor.descriptorArrayDataOffset)
+            && actor.descriptorElementClass == format::kActorRsatDescriptorClass;
+        if (!format_actor_id(actor.definitionTag, expectedId) || !same_text(actor.id, expectedId)
+            || is_absent_tag(actor.definitionTag)
+            || (actorIndex != 0
+                && snapshot.actorClasses[actorIndex - 1U].definitionTag >= actor.definitionTag)
+            || actor.objectType > 0xFFU || !valid_authored_spawn_profile(actor)
+            || profile.actorClassIndex != actorIndex
+            || (is_absent_tag(profile.behaviorConfigTag)
+                && (profile.behaviorConfigClass != format::kAbsentIndex
+                    || profile.behaviorProvenance != format::ActorSemanticProvenance::notPresent))
+            || (!is_absent_tag(profile.behaviorConfigTag)
+                && (profile.behaviorConfigClass != format::kActorBehaviorConfigClass
+                    || profile.behaviorProvenance != format::ActorSemanticProvenance::packageField))
+            || profile.behaviorConfigOffset != kActorBehaviorConfigOffset
+            || profile.defaultFaction != 0
+            || profile.factionProvenance != format::ActorSemanticProvenance::engineZeroDefault
+            || profile.flags != format::kActorBehaviorProfileExact
+            || actor.descriptors.first != descriptorCursor
+            || !range_inside(actor.descriptors, snapshot.descriptors.size())
+            || actor.dynamicPresenceTailCount > actor.descriptors.count
+            || (absentRsat
+                && (actor.rsatReverseDefinitionTag != format::kAbsentIndex
+                    || actor.descriptorArrayOffset != format::kAbsentIndex
+                    || actor.descriptorArrayRelative != format::kAbsentRelativeOffset
+                    || actor.descriptorArrayHeaderOffset != format::kAbsentIndex
+                    || actor.descriptorArrayDataOffset != format::kAbsentIndex
+                    || actor.descriptorElementClass != format::kAbsentIndex
+                    || actor.descriptors.count != 0 || actor.dynamicPresenceTailCount != 0))
+            || (!absentRsat
+                && (actor.rsatReverseDefinitionTag != actor.definitionTag
+                    || actor.descriptorArrayOffset != format::kActorRsatDescriptorArrayOffset
+                    || (!nullArray && !typedArray)
+                    || (nullArray
+                        && (actor.descriptorArrayHeaderOffset != format::kAbsentIndex
+                            || actor.descriptorArrayDataOffset != format::kAbsentIndex
+                            || actor.descriptorElementClass != format::kAbsentIndex
+                            || actor.descriptors.count != 0
+                            || actor.dynamicPresenceTailCount != 0))))) {
+            return false;
+        }
+        if (!absentRsat) {
+            for (std::size_t priorActorIndex = 0; priorActorIndex < actorIndex; ++priorActorIndex) {
+                if (snapshot.actorClasses[priorActorIndex].rsatTag == actor.rsatTag) {
+                    return false;
+                }
+            }
+        }
+
+        std::uint32_t tailOrdinal = 0;
+        for (std::uint32_t ordinal = 0; ordinal < actor.descriptors.count; ++ordinal) {
+            const RsatDescriptor& descriptor = snapshot.descriptors[descriptorCursor + ordinal];
+            Text expectedDescriptorId{};
+            const bool eligible =
+                (descriptor.flags & format::kRsatDescriptorDynamicPresenceEligible) != 0;
+            const std::uint64_t offset =
+                static_cast<std::uint64_t>(actor.descriptorArrayDataOffset)
+                + static_cast<std::uint64_t>(ordinal) * format::kRsatDescriptorRawRowSize;
+            std::uint32_t rawComponent = 0;
+            std::uint32_t rawSchema = 0;
+            std::memcpy(&rawComponent, descriptor.rawRow.data(), sizeof rawComponent);
+            std::memcpy(&rawSchema, descriptor.rawRow.data() + 4U, sizeof rawSchema);
+            if (!format_descriptor_id(actor.rsatTag, ordinal, expectedDescriptorId)
+                || !same_text(descriptor.id, expectedDescriptorId)
+                || descriptor.actorClassIndex != actorIndex || descriptor.rsatTag != actor.rsatTag
+                || descriptor.descriptorOrdinal != ordinal
+                || offset > (std::numeric_limits<std::uint32_t>::max)()
+                || descriptor.descriptorOffset != static_cast<std::uint32_t>(offset)
+                || descriptor.descriptorElementClass != format::kActorRsatDescriptorClass
+                || descriptor.schemaIndex >= snapshot.schemas.size()
+                || (descriptor.flags & ~format::kRsatDescriptorFlagMask) != 0
+                || descriptor.componentTag != rawComponent || descriptor.schemaTag != rawSchema
+                || (eligible && descriptor.dynamicPresenceTailOrdinal != tailOrdinal)
+                || (!eligible && descriptor.dynamicPresenceTailOrdinal != format::kAbsentIndex)) {
+                return false;
+            }
+            const RsatSchema& schema = snapshot.schemas[descriptor.schemaIndex];
+            const bool schemaEligible =
+                (schema.flags & format::kRsatSchemaDynamicPresenceEligible) != 0;
+            if (descriptor.schemaTag != schema.schemaTag
+                || descriptor.schemaFieldCount != schema.fieldCount
+                || descriptor.schemaFirstFieldRuntimeGate != schema.firstFieldRuntimeGate
+                || descriptor.schemaFirstFieldRawU32At10 != schema.firstFieldRawU32At10
+                || eligible != schemaEligible) {
+                return false;
+            }
+            ++schemaReferences[descriptor.schemaIndex];
+            tailOrdinal += eligible ? 1U : 0U;
+        }
+        if (tailOrdinal != actor.dynamicPresenceTailCount) {
+            return false;
+        }
+        descriptorCursor += actor.descriptors.count;
+    }
+    if (descriptorCursor != snapshot.descriptors.size()) {
+        return false;
+    }
+
+    std::size_t sobjectDescriptorCursor = 0;
+    for (std::size_t rsatIndex = 0; rsatIndex < snapshot.sobjectRsats.size(); ++rsatIndex) {
+        const SobjectRsat& rsat = snapshot.sobjectRsats[rsatIndex];
+        const bool nullArray = rsat.descriptorArrayRelative == 0;
+        const bool typedArray = stored_typed_array(rsat.descriptorArrayOffset,
+                                                   rsat.descriptorArrayRelative,
+                                                   rsat.descriptorArrayHeaderOffset,
+                                                   rsat.descriptorArrayDataOffset)
+                                && rsat.descriptorElementClass == format::kActorRsatDescriptorClass;
+        if (is_absent_tag(rsat.rsatTag)
+            || (rsatIndex != 0 && snapshot.sobjectRsats[rsatIndex - 1U].rsatTag >= rsat.rsatTag)
+            || is_absent_tag(rsat.reverseDefinitionTag)
+            || rsat.descriptorArrayOffset != format::kActorRsatDescriptorArrayOffset
+            || (!nullArray && !typedArray)
+            || (nullArray
+                && (rsat.descriptorArrayHeaderOffset != format::kAbsentIndex
+                    || rsat.descriptorArrayDataOffset != format::kAbsentIndex
+                    || rsat.descriptorElementClass != format::kAbsentIndex
+                    || rsat.descriptors.count != 0 || rsat.dynamicPresenceTailCount != 0))
+            || rsat.provenance != format::ActorSemanticProvenance::packageField
+            || rsat.descriptors.first != sobjectDescriptorCursor
+            || !range_inside(rsat.descriptors, snapshot.sobjectRsatDescriptors.size())
+            || rsat.dynamicPresenceTailCount > rsat.descriptors.count
+            || rsat.flags != format::kSobjectRsatExact || rsat.reserved != 0) {
+            return false;
+        }
+        std::uint32_t tailOrdinal = 0;
+        for (std::uint32_t ordinal = 0; ordinal < rsat.descriptors.count; ++ordinal) {
+            const SobjectRsatDescriptor& descriptor =
+                snapshot.sobjectRsatDescriptors[sobjectDescriptorCursor + ordinal];
+            const std::uint64_t expectedOffset =
+                static_cast<std::uint64_t>(rsat.descriptorArrayDataOffset)
+                + static_cast<std::uint64_t>(ordinal) * format::kRsatDescriptorRawRowSize;
+            std::uint32_t rawComponent = 0;
+            std::uint32_t rawSchema = 0;
+            std::memcpy(&rawComponent, descriptor.rawRow.data(), sizeof rawComponent);
+            std::memcpy(&rawSchema, descriptor.rawRow.data() + 4U, sizeof rawSchema);
+            const bool eligible =
+                (descriptor.flags & format::kSobjectRsatDescriptorDynamicPresenceEligible) != 0;
+            if (descriptor.rsatIndex != rsatIndex || descriptor.descriptorOrdinal != ordinal
+                || expectedOffset > (std::numeric_limits<std::uint32_t>::max)()
+                || descriptor.descriptorOffset != static_cast<std::uint32_t>(expectedOffset)
+                || descriptor.componentTag != rawComponent || descriptor.schemaTag != rawSchema
+                || descriptor.schemaIndex >= snapshot.schemas.size()
+                || descriptor.schemaTag != snapshot.schemas[descriptor.schemaIndex].schemaTag
+                || descriptor.schemaFieldCount
+                       != snapshot.schemas[descriptor.schemaIndex].fieldCount
+                || descriptor.schemaFirstFieldRuntimeGate
+                       != snapshot.schemas[descriptor.schemaIndex].firstFieldRuntimeGate
+                || (eligible && descriptor.dynamicPresenceTailOrdinal != tailOrdinal)
+                || (!eligible && descriptor.dynamicPresenceTailOrdinal != format::kAbsentIndex)
+                || (descriptor.flags & ~format::kSobjectRsatDescriptorDynamicPresenceEligible)
+                       != 0) {
+                return false;
+            }
+            ++schemaReferences[descriptor.schemaIndex];
+            tailOrdinal += eligible ? 1U : 0U;
+        }
+        if (tailOrdinal != rsat.dynamicPresenceTailCount) {
+            return false;
+        }
+        sobjectDescriptorCursor += rsat.descriptors.count;
+    }
+    if (sobjectDescriptorCursor != snapshot.sobjectRsatDescriptors.size()
+        || snapshot.sobjectRsats.empty()) {
+        return false;
+    }
+
+    std::size_t fieldCursor = 0;
+    for (std::size_t schemaIndex = 0; schemaIndex < snapshot.schemas.size(); ++schemaIndex) {
+        const RsatSchema& schema = snapshot.schemas[schemaIndex];
+        Text expectedId{};
+        const bool typed = (schema.flags & format::kRsatSchemaTypedFieldArray) != 0;
+        const bool eligible = (schema.flags & format::kRsatSchemaDynamicPresenceEligible) != 0;
+        const bool typedShape = stored_typed_array(schema.fieldArrayOffset,
+                                                   schema.fieldArrayRelative,
+                                                   schema.fieldArrayHeaderOffset,
+                                                   schema.fieldArrayDataOffset)
+                                && schema.fieldElementClass == format::kActorRsatSchemaFieldClass;
+        if (!format_schema_id(schema.schemaTag, expectedId) || !same_text(schema.id, expectedId)
+            || is_absent_tag(schema.schemaTag)
+            || (schemaIndex != 0
+                && snapshot.schemas[schemaIndex - 1U].schemaTag >= schema.schemaTag)
+            || schema.schemaClass != format::kActorRsatSchemaClass
+            || schema.fieldCount != schema.fields.count || schema.fields.first != fieldCursor
+            || !range_inside(schema.fields, snapshot.fields.size())
+            || schema.fieldArrayOffset != format::kActorRsatSchemaFieldArrayOffset
+            || (schema.flags & ~format::kRsatSchemaFlagMask) != 0
+            || eligible
+                   != (schema.fieldCount != 0
+                       && schema.firstFieldRuntimeGate != format::kAbsentIndex)
+            || (schema.fieldCount == 0
+                && (schema.firstFieldRuntimeGate != format::kAbsentIndex
+                    || schema.firstFieldRawU32At10 != format::kAbsentIndex))
+            || (!typed
+                && (schema.fieldCount != 0 || schema.fieldArrayRelative != 0
+                    || schema.fieldArrayHeaderOffset != format::kAbsentIndex
+                    || schema.fieldArrayDataOffset != format::kAbsentIndex
+                    || schema.fieldElementClass != format::kAbsentIndex))
+            || (typed && !typedShape) || schemaReferences[schemaIndex] == 0) {
+            return false;
+        }
+        if (schema.fieldCount != 0) {
+            std::uint32_t firstGate = 0;
+            std::uint32_t firstRaw = 0;
+            const RsatField& first = snapshot.fields[fieldCursor];
+            std::memcpy(&firstGate, first.rawRow.data(), sizeof firstGate);
+            std::memcpy(&firstRaw, first.rawRow.data() + 0x10U, sizeof firstRaw);
+            if (firstGate != schema.firstFieldRuntimeGate
+                || firstRaw != schema.firstFieldRawU32At10) {
+                return false;
+            }
+        }
+        fieldCursor += schema.fieldCount;
+    }
+    return fieldCursor == snapshot.fields.size();
+}
+
+} // namespace sunrise::client::content::activity::sdk_generation::actor_rsat_inventory

+ 3 - 3
Sunrise/src/client/content/activity/activity_sdk_authored_scene_facts.cpp

@@ -310,7 +310,7 @@ descriptor_config(void* opaque, std::uint32_t configTag, std::span<const std::by
     }
     std::sort(walk.descriptors.begin(),
               walk.descriptors.end(),
-              [](const auto& left, const auto& right) { return left.id < right.id; });
+              [](const auto& first, const auto& second) { return first.id < second.id; });
     std::vector<squad::DescriptorFact> unique{};
     unique.reserve(walk.descriptors.size());
     for (squad::DescriptorFact& descriptor : walk.descriptors) {
@@ -359,11 +359,11 @@ bool collect_facts(const topology::Snapshot& topology,
         }
         std::sort(pending.descriptors.begin(),
                   pending.descriptors.end(),
-                  [](const auto& left, const auto& right) { return left.id < right.id; });
+                  [](const auto& first, const auto& second) { return first.id < second.id; });
         if (std::adjacent_find(
                 pending.descriptors.begin(),
                 pending.descriptors.end(),
-                [](const auto& left, const auto& right) { return left.id == right.id; })
+                [](const auto& first, const auto& second) { return first.id == second.id; })
             != pending.descriptors.end()) {
             return false;
         }

+ 189 - 0
Sunrise/src/client/content/activity/activity_sdk_authored_scene_internal.h

@@ -0,0 +1,189 @@
+#pragma once
+
+#include <algorithm>
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <cstdio>
+#include <string_view>
+#include <tuple>
+#include <unordered_map>
+
+#include "activity_sdk_authored_scene_inventory.h"
+
+namespace sunrise::client::content::activity::sdk_generation::authored_scene_inventory {
+
+/** One exact slot schema indexed by the canonical global slot row. */
+using SchemaIndex = std::unordered_map<std::uint32_t, const squad::SlotSchemaFact*>;
+
+/** Formats one structural ID without truncation. */
+template <typename... Values>
+[[nodiscard]] inline bool format_text(Text& output, const char* spec, Values... values) noexcept {
+    output = {};
+    const int written = std::snprintf(output.value.data(), output.value.size(), spec, values...);
+    if (written <= 0 || static_cast<std::size_t>(written) >= output.value.size()) {
+        output = {};
+        return false;
+    }
+    output.length = static_cast<std::uint16_t>(written);
+    return true;
+}
+
+/** @return True when all fixed string storage follows the deferred-text contract. */
+[[nodiscard]] inline bool valid_text(const Text& text) noexcept {
+    if (text.length >= text.value.size() || text.value[text.length] != '\0') {
+        return false;
+    }
+    for (std::size_t index = 0; index < text.length; ++index) {
+        if (text.value[index] == '\0') {
+            return false;
+        }
+    }
+    return std::all_of(text.value.begin() + text.length + 1U, text.value.end(), [](char value) {
+        return value == '\0';
+    });
+}
+
+/** Returns one deferred string after validating its fixed storage. */
+[[nodiscard]] inline bool text_view(const Text& text, std::string_view& output) noexcept {
+    output = {};
+    if (!valid_text(text)) {
+        return false;
+    }
+    output = std::string_view(text.value.data(), text.length);
+    return true;
+}
+
+/** Tests the exact type-43 descriptor shape before package projection. */
+[[nodiscard]] inline bool is_scene_descriptor(const topology::Snapshot& topology,
+                                              const squad::DescriptorFact& descriptor) noexcept {
+    return descriptor.slotIndex < topology.slots.size()
+           && topology.slots[descriptor.slotIndex].slotType == format::kAuthoredSceneSlotType
+           && descriptor.componentClass == format::kAuthoredSceneComponentClass
+           && descriptor.senseSchema == format::kAuthoredSceneSenseSchema
+           && descriptor.authSchema == format::kAuthoredSceneAuthSchema;
+}
+
+/** Tests the exact type-42 descriptor shape before package projection. */
+[[nodiscard]] inline bool
+is_performance_descriptor(const topology::Snapshot& topology,
+                          const squad::DescriptorFact& descriptor) noexcept {
+    return descriptor.slotIndex < topology.slots.size()
+           && topology.slots[descriptor.slotIndex].slotType == format::kPerformanceSlotType
+           && descriptor.componentClass == format::kPerformanceComponentClass
+           && descriptor.senseSchema == format::kAbsentIndex
+           && descriptor.authSchema == format::kPerformanceAuthSchema;
+}
+
+/** Tests the exact type-38 descriptor shape before package projection. */
+[[nodiscard]] inline bool is_task_descriptor(const topology::Snapshot& topology,
+                                             const squad::DescriptorFact& descriptor) noexcept {
+    return descriptor.slotIndex < topology.slots.size()
+           && topology.slots[descriptor.slotIndex].slotType == format::kTaskSlotType
+           && descriptor.componentClass == format::kTaskComponentClass
+           && descriptor.senseSchema == format::kAbsentIndex
+           && descriptor.authSchema == format::kTaskAuthSchema;
+}
+
+/** Sort key matching the final section-19 pack order. */
+[[nodiscard]] inline auto resource_natural(const Resource& row) noexcept {
+    return std::tie(row.slotIndex, row.configTag, row.descriptorOffset);
+}
+
+/** Sort key matching the final section-22 pack order. */
+[[nodiscard]] inline auto edge_natural(const SquadEdge& row) noexcept {
+    return std::tie(row.sceneSlotIndex, row.configTag, row.descriptorOffset);
+}
+
+/** Sort key matching the final task-target pack order. */
+[[nodiscard]] inline auto task_natural(const TaskTarget& row) noexcept {
+    return std::tie(row.taskSlotIndex, row.configTag, row.descriptorOffset);
+}
+
+/** Compares resource rows in final pack order. */
+[[nodiscard]] inline bool resource_less(const Resource& left, const Resource& right) noexcept {
+    std::string_view leftId{};
+    std::string_view rightId{};
+    if (!text_view(left.id, leftId) || !text_view(right.id, rightId)) {
+        return false;
+    }
+    return std::tie(left.slotIndex, left.configTag, left.descriptorOffset, left.resourceTag, leftId)
+           < std::tie(right.slotIndex,
+                      right.configTag,
+                      right.descriptorOffset,
+                      right.resourceTag,
+                      rightId);
+}
+
+/** Compares edge rows in final pack order. */
+[[nodiscard]] inline bool edge_less(const SquadEdge& left, const SquadEdge& right) noexcept {
+    std::string_view leftId{};
+    std::string_view rightId{};
+    if (!text_view(left.id, leftId) || !text_view(right.id, rightId)) {
+        return false;
+    }
+    return std::tie(left.sceneSlotIndex,
+                    left.configTag,
+                    left.descriptorOffset,
+                    left.squadSlotIndex,
+                    leftId)
+           < std::tie(right.sceneSlotIndex,
+                      right.configTag,
+                      right.descriptorOffset,
+                      right.squadSlotIndex,
+                      rightId);
+}
+
+/** Compares task-target rows in final pack order. */
+[[nodiscard]] inline bool task_less(const TaskTarget& left, const TaskTarget& right) noexcept {
+    std::string_view leftId{};
+    std::string_view rightId{};
+    if (!text_view(left.id, leftId) || !text_view(right.id, rightId)) {
+        return false;
+    }
+    return std::tie(left.taskSlotIndex,
+                    left.configTag,
+                    left.descriptorOffset,
+                    left.objectiveSlotIndex,
+                    left.bitIndex,
+                    leftId)
+           < std::tie(right.taskSlotIndex,
+                      right.configTag,
+                      right.descriptorOffset,
+                      right.objectiveSlotIndex,
+                      right.bitIndex,
+                      rightId);
+}
+
+/** Builds and validates the unique global schema lookup. */
+[[nodiscard]] bool
+schema_index(const topology::Snapshot& topology, const Facts& facts, SchemaIndex& output);
+
+/** Checks the topology fields consumed by this bounded projection. */
+[[nodiscard]] bool valid_topology(const topology::Snapshot& topology) noexcept;
+
+/** Tests one exact final slot shape supplied by the separate schema join. */
+[[nodiscard]] bool slot_shape(const topology::Snapshot& topology,
+                              const SchemaIndex& schemas,
+                              std::uint32_t slotIndex,
+                              std::uint32_t slotType,
+                              std::uint32_t componentClass,
+                              std::uint32_t senseSchema,
+                              std::uint32_t authSchema) noexcept;
+
+/** Formats one resource ID from its exact descriptor tuple. */
+[[nodiscard]] bool resource_id(const topology::Snapshot& topology,
+                               const squad::DescriptorFact& descriptor,
+                               Text& output) noexcept;
+
+/** Formats one scene-to-squad edge ID from its exact descriptor tuple. */
+[[nodiscard]] bool edge_id(const topology::Snapshot& topology,
+                           const squad::DescriptorFact& descriptor,
+                           Text& output) noexcept;
+
+/** Formats one task-to-objective target ID from its exact descriptor tuple. */
+[[nodiscard]] bool task_target_id(const topology::Snapshot& topology,
+                                  const squad::DescriptorFact& descriptor,
+                                  Text& output) noexcept;
+
+} // namespace sunrise::client::content::activity::sdk_generation::authored_scene_inventory

+ 109 - 541
Sunrise/src/client/content/activity/activity_sdk_authored_scene_inventory.cpp

@@ -4,128 +4,19 @@
 #include <array>
 #include <cstdio>
 #include <cstring>
-#include <string>
-#include <string_view>
-#include <tuple>
 #include <unordered_map>
-#include <unordered_set>
 #include <utility>
 
 #include "../../../core/logging/log.h"
 #include "../../../middleware/content/packages/tables/slot_descriptor_reader.h"
+#include "activity_sdk_authored_scene_internal.h"
 
 namespace sunrise::client::content::activity::sdk_generation::authored_scene_inventory {
-namespace {
-
-namespace tables = middleware::content::packages::tables;
-
-/** Hash combining uses the 32-bit golden-ratio increment. */
-constexpr std::size_t kHashCombineConstant = 0x9E3779B9U;
-/** The target slot type follows the object key in a scene reference. */
-constexpr std::size_t kTargetSlotTypeRelativeOffset = 4U;
-/** The target slot index follows the target slot type. */
-constexpr std::size_t kTargetSlotIndexRelativeOffset = 6U;
-
-/** One cached package entry and its physical class. */
-struct PackageRow final {
-    std::vector<std::byte> bytes{};
-    std::uint32_t classId{};
-};
-
-/** One exact slot schema indexed by the canonical global slot row. */
-using SchemaIndex = std::unordered_map<std::uint32_t, const squad::SlotSchemaFact*>;
-/** Package reads are cached by tag for one transaction. */
-using PackageCache = std::unordered_map<std::uint32_t, PackageRow>;
-
-/** @return True when one complete range lies in the package blob. */
-[[nodiscard]] bool
-contains(std::span<const std::byte> blob, std::size_t offset, std::size_t size) noexcept {
-    return offset <= blob.size() && size <= blob.size() - offset;
-}
-
-template <typename Value>
-[[nodiscard]] bool
-read_value(std::span<const std::byte> blob, std::size_t offset, Value& output) noexcept {
-    output = {};
-    if (!contains(blob, offset, sizeof output)) {
-        return false;
-    }
-    std::memcpy(&output, blob.data() + offset, sizeof output);
-    return true;
-}
-
-/** Formats one structural ID without truncation. */
-template <typename... Values>
-[[nodiscard]] bool format_text(Text& output, const char* spec, Values... values) noexcept {
-    output = {};
-    const int written = std::snprintf(output.value.data(), output.value.size(), spec, values...);
-    if (written <= 0 || static_cast<std::size_t>(written) >= output.value.size()) {
-        output = {};
-        return false;
-    }
-    output.length = static_cast<std::uint16_t>(written);
-    return true;
-}
-
-/** @return True when all fixed string storage follows the deferred-text contract. */
-[[nodiscard]] bool valid_text(const Text& text) noexcept {
-    if (text.length >= text.value.size() || text.value[text.length] != '\0') {
-        return false;
-    }
-    for (std::size_t index = 0; index < text.length; ++index) {
-        if (text.value[index] == '\0') {
-            return false;
-        }
-    }
-    return std::all_of(text.value.begin() + text.length + 1U, text.value.end(), [](char value) {
-        return value == '\0';
-    });
-}
-
-/** Returns one deferred string after validating its fixed storage. */
-[[nodiscard]] bool text_view(const Text& text, std::string_view& output) noexcept {
-    output = {};
-    if (!valid_text(text)) {
-        return false;
-    }
-    output = std::string_view(text.value.data(), text.length);
-    return true;
-}
-
-/** Derives the descriptor identity used by every authored-scene child row. */
-[[nodiscard]] bool descriptor_id(const topology::Snapshot& topology,
-                                 const squad::DescriptorFact& descriptor,
-                                 std::string& output) {
-    output.clear();
-    if (descriptor.objectIndex >= topology.objects.size()
-        || descriptor.slotIndex >= topology.slots.size()) {
-        return false;
-    }
-    const topology::Object& object = topology.objects[descriptor.objectIndex];
-    const topology::Slot& slot = topology.slots[descriptor.slotIndex];
-    if (slot.objectIndex != descriptor.objectIndex) {
-        return false;
-    }
-    std::array<char, 80> buffer{};
-    const int written = std::snprintf(buffer.data(),
-                                      buffer.size(),
-                                      "descriptor/%08x/%08x/%08x/%04x/%04x",
-                                      static_cast<unsigned>(descriptor.configTag),
-                                      static_cast<unsigned>(object.objectTag),
-                                      static_cast<unsigned>(descriptor.descriptorOffset),
-                                      static_cast<unsigned>(slot.slotIndex),
-                                      static_cast<unsigned>(slot.slotType));
-    if (written <= 0 || static_cast<std::size_t>(written) >= buffer.size()) {
-        return false;
-    }
-    output.assign(buffer.data(), static_cast<std::size_t>(written));
-    return true;
-}
 
 /** Formats one resource ID from its exact descriptor tuple. */
-[[nodiscard]] bool resource_id(const topology::Snapshot& topology,
-                               const squad::DescriptorFact& descriptor,
-                               Text& output) noexcept {
+bool resource_id(const topology::Snapshot& topology,
+                 const squad::DescriptorFact& descriptor,
+                 Text& output) noexcept {
     if (descriptor.objectIndex >= topology.objects.size()
         || descriptor.slotIndex >= topology.slots.size()) {
         return false;
@@ -142,9 +33,9 @@ template <typename... Values>
 }
 
 /** Formats one scene-to-squad edge ID from its exact descriptor tuple. */
-[[nodiscard]] bool edge_id(const topology::Snapshot& topology,
-                           const squad::DescriptorFact& descriptor,
-                           Text& output) noexcept {
+bool edge_id(const topology::Snapshot& topology,
+             const squad::DescriptorFact& descriptor,
+             Text& output) noexcept {
     if (descriptor.objectIndex >= topology.objects.size()
         || descriptor.slotIndex >= topology.slots.size()) {
         return false;
@@ -161,9 +52,9 @@ template <typename... Values>
 }
 
 /** Formats one task-to-objective target ID from its exact descriptor tuple. */
-[[nodiscard]] bool task_target_id(const topology::Snapshot& topology,
-                                  const squad::DescriptorFact& descriptor,
-                                  Text& output) noexcept {
+bool task_target_id(const topology::Snapshot& topology,
+                    const squad::DescriptorFact& descriptor,
+                    Text& output) noexcept {
     if (descriptor.objectIndex >= topology.objects.size()
         || descriptor.slotIndex >= topology.slots.size()) {
         return false;
@@ -179,14 +70,105 @@ template <typename... Values>
                        static_cast<unsigned>(slot.slotType));
 }
 
-/** Tests the exact type-43 descriptor shape before package projection. */
-[[nodiscard]] bool is_scene_descriptor(const topology::Snapshot& topology,
-                                       const squad::DescriptorFact& descriptor) noexcept {
-    return descriptor.slotIndex < topology.slots.size()
-           && topology.slots[descriptor.slotIndex].slotType == format::kAuthoredSceneSlotType
-           && descriptor.componentClass == format::kAuthoredSceneComponentClass
-           && descriptor.senseSchema == format::kAuthoredSceneSenseSchema
-           && descriptor.authSchema == format::kAuthoredSceneAuthSchema;
+/** Tests one exact final slot shape supplied by the separate schema join. */
+bool slot_shape(const topology::Snapshot& topology,
+                const SchemaIndex& schemas,
+                std::uint32_t slotIndex,
+                std::uint32_t slotType,
+                std::uint32_t componentClass,
+                std::uint32_t senseSchema,
+                std::uint32_t authSchema) noexcept {
+    if (slotIndex >= topology.slots.size()) {
+        return false;
+    }
+    const auto found = schemas.find(slotIndex);
+    if (found == schemas.end() || found->second == nullptr || !found->second->exact) {
+        return false;
+    }
+    const topology::Slot& slot = topology.slots[slotIndex];
+    const squad::SlotSchemaFact& schema = *found->second;
+    return slot.slotType == slotType && schema.slotIndex == slotIndex
+           && schema.componentClass == componentClass && schema.senseSchema == senseSchema
+           && schema.authSchema == authSchema;
+}
+
+/** Builds and validates the unique global schema lookup. */
+bool schema_index(const topology::Snapshot& topology, const Facts& facts, SchemaIndex& output) {
+    output.clear();
+    try {
+        output.reserve(facts.slotSchemas.size());
+        for (const squad::SlotSchemaFact& schema : facts.slotSchemas) {
+            if (schema.slotIndex >= topology.slots.size()
+                || !output.emplace(schema.slotIndex, &schema).second) {
+                return false;
+            }
+        }
+        return true;
+    } catch (...) {
+        output.clear();
+        return false;
+    }
+}
+
+/** Checks the topology fields consumed by this bounded projection. */
+bool valid_topology(const topology::Snapshot& topology) noexcept {
+    if (!topology.ready || topology.objects.empty() || topology.slots.empty()) {
+        return false;
+    }
+    std::size_t nextSlot = 0;
+    for (std::size_t objectIndex = 0; objectIndex < topology.objects.size(); ++objectIndex) {
+        const topology::Object& object = topology.objects[objectIndex];
+        if (object.objectTag == 0 || object.objectTag == format::kAbsentIndex
+            || object.objectKey == 0 || object.objectKey == format::kAbsentIndex
+            || object.firstSlot != nextSlot || object.firstSlot > topology.slots.size()
+            || object.slotCount > topology.slots.size() - object.firstSlot) {
+            return false;
+        }
+        for (std::uint32_t index = object.firstSlot; index < object.firstSlot + object.slotCount;
+             ++index) {
+            const topology::Slot& slot = topology.slots[index];
+            if (slot.objectIndex != objectIndex || slot.slotIndex != index - object.firstSlot) {
+                return false;
+            }
+        }
+        nextSlot += object.slotCount;
+    }
+    return nextSlot == topology.slots.size();
+}
+
+namespace {
+
+namespace tables = middleware::content::packages::tables;
+
+/** The target slot type follows the object key in a scene reference. */
+constexpr std::size_t kTargetSlotTypeRelativeOffset = 4U;
+/** The target slot index follows the target slot type. */
+constexpr std::size_t kTargetSlotIndexRelativeOffset = 6U;
+
+/** One cached package entry and its physical class. */
+struct PackageRow final {
+    std::vector<std::byte> bytes{};
+    std::uint32_t classId{};
+};
+
+/** Package reads are cached by tag for one transaction. */
+using PackageCache = std::unordered_map<std::uint32_t, PackageRow>;
+
+/** @return True when one complete range lies in the package blob. */
+[[nodiscard]] bool
+contains(std::span<const std::byte> blob, std::size_t offset, std::size_t size) noexcept {
+    return offset <= blob.size() && size <= blob.size() - offset;
+}
+
+template <typename Value>
+[[nodiscard]] bool
+read_value(std::span<const std::byte> blob, std::size_t offset, Value& output) noexcept {
+    output = {};
+    if (!contains(blob, offset, sizeof output)) {
+        return false;
+    }
+    std::memcpy(&output, blob.data() + offset, sizeof output);
+    return true;
 }
 
 /** Names the guard that refused one type-42 sensor, so one run can say why an idle has no state. */
@@ -209,48 +191,6 @@ void log_performance_edge(const squad::DescriptorFact& descriptor, const char* r
     }
 }
 
-/** Tests the exact type-42 descriptor shape before package projection. */
-[[nodiscard]] bool is_performance_descriptor(const topology::Snapshot& topology,
-                                             const squad::DescriptorFact& descriptor) noexcept {
-    return descriptor.slotIndex < topology.slots.size()
-           && topology.slots[descriptor.slotIndex].slotType == format::kPerformanceSlotType
-           && descriptor.componentClass == format::kPerformanceComponentClass
-           && descriptor.senseSchema == format::kAbsentIndex
-           && descriptor.authSchema == format::kPerformanceAuthSchema;
-}
-
-/** Tests the exact type-38 descriptor shape before package projection. */
-[[nodiscard]] bool is_task_descriptor(const topology::Snapshot& topology,
-                                      const squad::DescriptorFact& descriptor) noexcept {
-    return descriptor.slotIndex < topology.slots.size()
-           && topology.slots[descriptor.slotIndex].slotType == format::kTaskSlotType
-           && descriptor.componentClass == format::kTaskComponentClass
-           && descriptor.senseSchema == format::kAbsentIndex
-           && descriptor.authSchema == format::kTaskAuthSchema;
-}
-
-/** Tests one exact final slot shape supplied by the separate schema join. */
-[[nodiscard]] bool slot_shape(const topology::Snapshot& topology,
-                              const SchemaIndex& schemas,
-                              std::uint32_t slotIndex,
-                              std::uint32_t slotType,
-                              std::uint32_t componentClass,
-                              std::uint32_t senseSchema,
-                              std::uint32_t authSchema) noexcept {
-    if (slotIndex >= topology.slots.size()) {
-        return false;
-    }
-    const auto found = schemas.find(slotIndex);
-    if (found == schemas.end() || found->second == nullptr || !found->second->exact) {
-        return false;
-    }
-    const topology::Slot& slot = topology.slots[slotIndex];
-    const squad::SlotSchemaFact& schema = *found->second;
-    return slot.slotType == slotType && schema.slotIndex == slotIndex
-           && schema.componentClass == componentClass && schema.senseSchema == senseSchema
-           && schema.authSchema == authSchema;
-}
-
 /** Reads one tag once and retains the physical class beside its bytes. */
 [[nodiscard]] bool package_row(squad::TagReader reader,
                                void* readerContext,
@@ -344,380 +284,8 @@ void log_performance_edge(const squad::DescriptorFact& descriptor, const char* r
     return true;
 }
 
-/** Builds and validates the unique global schema lookup. */
-[[nodiscard]] bool
-schema_index(const topology::Snapshot& topology, const Facts& facts, SchemaIndex& output) {
-    output.clear();
-    try {
-        output.reserve(facts.slotSchemas.size());
-        for (const squad::SlotSchemaFact& schema : facts.slotSchemas) {
-            if (schema.slotIndex >= topology.slots.size()
-                || !output.emplace(schema.slotIndex, &schema).second) {
-                return false;
-            }
-        }
-        return true;
-    } catch (...) {
-        output.clear();
-        return false;
-    }
-}
-
-/** Checks the topology fields consumed by this bounded projection. */
-[[nodiscard]] bool valid_topology(const topology::Snapshot& topology) noexcept {
-    if (!topology.ready || topology.objects.empty() || topology.slots.empty()) {
-        return false;
-    }
-    std::size_t nextSlot = 0;
-    for (std::size_t objectIndex = 0; objectIndex < topology.objects.size(); ++objectIndex) {
-        const topology::Object& object = topology.objects[objectIndex];
-        if (object.objectTag == 0 || object.objectTag == format::kAbsentIndex
-            || object.objectKey == 0 || object.objectKey == format::kAbsentIndex
-            || object.firstSlot != nextSlot || object.firstSlot > topology.slots.size()
-            || object.slotCount > topology.slots.size() - object.firstSlot) {
-            return false;
-        }
-        for (std::uint32_t index = object.firstSlot; index < object.firstSlot + object.slotCount;
-             ++index) {
-            const topology::Slot& slot = topology.slots[index];
-            if (slot.objectIndex != objectIndex || slot.slotIndex != index - object.firstSlot) {
-                return false;
-            }
-        }
-        nextSlot += object.slotCount;
-    }
-    return nextSlot == topology.slots.size();
-}
-
-/** Checks the complete descriptor set before package reads begin. */
-[[nodiscard]] bool valid_facts(const topology::Snapshot& topology, const Facts& facts) {
-    if (!facts.complete || facts.slotSchemas.size() != topology.slots.size()) {
-        return false;
-    }
-    std::unordered_set<std::string> ids{};
-    std::unordered_set<std::uint32_t> schemaSlots{};
-    try {
-        ids.reserve(facts.descriptors.size());
-        schemaSlots.reserve(facts.slotSchemas.size());
-        std::vector<std::size_t> descriptorCounts(topology.objects.size());
-        std::vector<bool> projectedOwners(topology.objects.size());
-        for (const squad::SlotSchemaFact& schema : facts.slotSchemas) {
-            if (schema.slotIndex >= topology.slots.size()
-                || !schemaSlots.emplace(schema.slotIndex).second) {
-                return false;
-            }
-            const topology::Slot& slot = topology.slots[schema.slotIndex];
-            if (slot.objectIndex >= topology.objects.size()) {
-                return false;
-            }
-            if (slot.slotType == format::kAuthoredSceneSlotType && schema.exact
-                && schema.componentClass == format::kAuthoredSceneComponentClass
-                && schema.senseSchema == format::kAuthoredSceneSenseSchema
-                && schema.authSchema == format::kAuthoredSceneAuthSchema) {
-                projectedOwners[slot.objectIndex] = true;
-            }
-            if (slot.slotType == format::kTaskSlotType && schema.exact
-                && schema.componentClass == format::kTaskComponentClass
-                && schema.senseSchema == format::kAbsentIndex
-                && schema.authSchema == format::kTaskAuthSchema) {
-                projectedOwners[slot.objectIndex] = true;
-            }
-        }
-        for (const squad::DescriptorFact& descriptor : facts.descriptors) {
-            std::string expected{};
-            if (!descriptor.complete || descriptor.configTag == 0
-                || descriptor.configTag == format::kAbsentIndex
-                || descriptor.objectIndex >= topology.objects.size()
-                || descriptor.slotIndex >= topology.slots.size()
-                || descriptor.descriptorOffset
-                       > format::kAbsentIndex - format::kAuthoredSceneSquadReferenceRelativeOffset
-                || !descriptor_id(topology, descriptor, expected) || descriptor.id != expected
-                || !ids.emplace(descriptor.id).second) {
-                return false;
-            }
-            ++descriptorCounts[descriptor.objectIndex];
-        }
-        for (std::size_t index = 0; index < topology.objects.size(); ++index) {
-            const topology::Object& object = topology.objects[index];
-            if (projectedOwners[index]
-                && (!object.descriptorEvidenceComplete
-                    || object.descriptorCount == format::kAbsentIndex
-                    || descriptorCounts[index] != object.descriptorCount)) {
-                return false;
-            }
-        }
-        return true;
-    } catch (...) {
-        return false;
-    }
-}
-
-/** Sort key matching the final section-19 pack order. */
-[[nodiscard]] auto resource_natural(const Resource& row) noexcept {
-    return std::tie(row.slotIndex, row.configTag, row.descriptorOffset);
-}
-
-/** Sort key matching the final section-22 pack order. */
-[[nodiscard]] auto edge_natural(const SquadEdge& row) noexcept {
-    return std::tie(row.sceneSlotIndex, row.configTag, row.descriptorOffset);
-}
-
-/** Sort key matching the final task-target pack order. */
-[[nodiscard]] auto task_natural(const TaskTarget& row) noexcept {
-    return std::tie(row.taskSlotIndex, row.configTag, row.descriptorOffset);
-}
-
-/** Compares resource rows in final pack order. */
-[[nodiscard]] bool resource_less(const Resource& left, const Resource& right) noexcept {
-    std::string_view leftId{};
-    std::string_view rightId{};
-    if (!text_view(left.id, leftId) || !text_view(right.id, rightId)) {
-        return false;
-    }
-    return std::tie(left.slotIndex, left.configTag, left.descriptorOffset, left.resourceTag, leftId)
-           < std::tie(right.slotIndex,
-                      right.configTag,
-                      right.descriptorOffset,
-                      right.resourceTag,
-                      rightId);
-}
-
-/** Compares edge rows in final pack order. */
-[[nodiscard]] bool edge_less(const SquadEdge& left, const SquadEdge& right) noexcept {
-    std::string_view leftId{};
-    std::string_view rightId{};
-    if (!text_view(left.id, leftId) || !text_view(right.id, rightId)) {
-        return false;
-    }
-    return std::tie(left.sceneSlotIndex,
-                    left.configTag,
-                    left.descriptorOffset,
-                    left.squadSlotIndex,
-                    leftId)
-           < std::tie(right.sceneSlotIndex,
-                      right.configTag,
-                      right.descriptorOffset,
-                      right.squadSlotIndex,
-                      rightId);
-}
-
-/** Compares task-target rows in final pack order. */
-[[nodiscard]] bool task_less(const TaskTarget& left, const TaskTarget& right) noexcept {
-    std::string_view leftId{};
-    std::string_view rightId{};
-    if (!text_view(left.id, leftId) || !text_view(right.id, rightId)) {
-        return false;
-    }
-    return std::tie(left.taskSlotIndex,
-                    left.configTag,
-                    left.descriptorOffset,
-                    left.objectiveSlotIndex,
-                    left.bitIndex,
-                    leftId)
-           < std::tie(right.taskSlotIndex,
-                      right.configTag,
-                      right.descriptorOffset,
-                      right.objectiveSlotIndex,
-                      right.bitIndex,
-                      rightId);
-}
-
-/** One descriptor lookup key matches the final natural identity. */
-struct DescriptorKey final {
-    std::uint32_t slotIndex{};
-    std::uint32_t configTag{};
-    std::uint32_t descriptorOffset{};
-
-    bool operator==(const DescriptorKey&) const = default;
-};
-
-/** Mixes the three exact u32 identity lanes without narrowing them. */
-struct DescriptorKeyHash final {
-    [[nodiscard]] std::size_t operator()(const DescriptorKey& value) const noexcept {
-        std::size_t result = value.slotIndex;
-        result ^= static_cast<std::size_t>(value.configTag) + kHashCombineConstant + (result << 6U)
-                  + (result >> 2U);
-        result ^= static_cast<std::size_t>(value.descriptorOffset) + kHashCombineConstant
-                  + (result << 6U) + (result >> 2U);
-        return result;
-    }
-};
-
-using DescriptorIndex =
-    std::unordered_map<DescriptorKey, const squad::DescriptorFact*, DescriptorKeyHash>;
-
-/** Builds the exact descriptor lookup consumed by output validation. */
-[[nodiscard]] bool descriptor_index(const Facts& facts, DescriptorIndex& output) {
-    output.clear();
-    try {
-        output.reserve(facts.descriptors.size());
-        for (const squad::DescriptorFact& descriptor : facts.descriptors) {
-            const DescriptorKey key{
-                descriptor.slotIndex, descriptor.configTag, descriptor.descriptorOffset};
-            if (!output.emplace(key, &descriptor).second) {
-                return false;
-            }
-        }
-        return true;
-    } catch (...) {
-        output.clear();
-        return false;
-    }
-}
-
-/** Finds one descriptor fact by its natural package identity. */
-[[nodiscard]] const squad::DescriptorFact*
-find_descriptor(const DescriptorIndex& descriptors,
-                std::uint32_t slotIndex,
-                std::uint32_t configTag,
-                std::uint32_t descriptorOffset) noexcept {
-    const auto found = descriptors.find({slotIndex, configTag, descriptorOffset});
-    return found == descriptors.end() ? nullptr : found->second;
-}
-
 } // namespace
 
-/** Validates every structural ID, scalar domain, slot join, flag, and row order. */
-bool validate(const topology::Snapshot& topology,
-              const Facts& facts,
-              const Snapshot& snapshot) noexcept {
-    if (!snapshot.complete || !valid_topology(topology) || !valid_facts(topology, facts)) {
-        return false;
-    }
-    SchemaIndex schemas{};
-    DescriptorIndex descriptors{};
-    if (!schema_index(topology, facts, schemas) || !descriptor_index(facts, descriptors)) {
-        return false;
-    }
-    for (std::size_t index = 0; index < snapshot.resources.size(); ++index) {
-        const Resource& row = snapshot.resources[index];
-        const squad::DescriptorFact* descriptor =
-            find_descriptor(descriptors, row.slotIndex, row.configTag, row.descriptorOffset);
-        Text expectedId{};
-        if (descriptor == nullptr || !is_scene_descriptor(topology, *descriptor)
-            || !resource_id(topology, *descriptor, expectedId) || expectedId.value != row.id.value
-            || expectedId.length != row.id.length
-            || !slot_shape(topology,
-                           schemas,
-                           row.slotIndex,
-                           format::kAuthoredSceneSlotType,
-                           format::kAuthoredSceneComponentClass,
-                           format::kAuthoredSceneSenseSchema,
-                           format::kAuthoredSceneAuthSchema)
-            || row.descriptorOffset
-                   > format::kAbsentIndex - format::kAuthoredSceneResourceRelativeOffset
-            || row.resourceFieldOffset
-                   != row.descriptorOffset + format::kAuthoredSceneResourceRelativeOffset
-            || row.resourceTag == 0 || row.resourceTag == format::kAbsentIndex
-            || row.resourceClass != format::kAuthoredSceneResourceClass
-            || row.flags != format::kAuthoredSceneResourceExact || row.reserved != 0
-            || (index != 0 && !resource_less(snapshot.resources[index - 1], row))) {
-            return false;
-        }
-    }
-    for (std::size_t index = 1; index < snapshot.resources.size(); ++index) {
-        if (resource_natural(snapshot.resources[index - 1])
-            == resource_natural(snapshot.resources[index])) {
-            return false;
-        }
-    }
-    for (std::size_t index = 0; index < snapshot.squadEdges.size(); ++index) {
-        const SquadEdge& row = snapshot.squadEdges[index];
-        const squad::DescriptorFact* descriptor =
-            find_descriptor(descriptors, row.sceneSlotIndex, row.configTag, row.descriptorOffset);
-        Text expectedId{};
-        // A row is a type-43 scene edge or a type-42 performance edge; the flag says which.
-        const bool performance = row.flags == format::kAuthoredSceneSquadPerformanceTargetExact;
-        const std::uint32_t referenceOffset =
-            performance ? format::kPerformanceSquadReferenceRelativeOffset
-                        : format::kAuthoredSceneSquadReferenceRelativeOffset;
-        const bool sourceShape = performance ? slot_shape(topology,
-                                                          schemas,
-                                                          row.sceneSlotIndex,
-                                                          format::kPerformanceSlotType,
-                                                          format::kPerformanceComponentClass,
-                                                          format::kAbsentIndex,
-                                                          format::kPerformanceAuthSchema)
-                                             : slot_shape(topology,
-                                                          schemas,
-                                                          row.sceneSlotIndex,
-                                                          format::kAuthoredSceneSlotType,
-                                                          format::kAuthoredSceneComponentClass,
-                                                          format::kAuthoredSceneSenseSchema,
-                                                          format::kAuthoredSceneAuthSchema);
-        if (descriptor == nullptr || descriptor->objectIndex >= topology.objects.size()
-            || row.squadSlotIndex >= topology.slots.size()
-            || topology.slots[row.squadSlotIndex].objectIndex != descriptor->objectIndex
-            || !(performance ? is_performance_descriptor(topology, *descriptor)
-                             : is_scene_descriptor(topology, *descriptor))
-            || !edge_id(topology, *descriptor, expectedId) || expectedId.value != row.id.value
-            || expectedId.length != row.id.length || !sourceShape
-            || !slot_shape(topology,
-                           schemas,
-                           row.squadSlotIndex,
-                           format::kSquadSlotType,
-                           format::kSquadComponentClass,
-                           format::kSquadSenseSchema,
-                           format::kSquadAuthSchema)
-            || row.descriptorOffset > format::kAbsentIndex - referenceOffset
-            || row.referenceFieldOffset != row.descriptorOffset + referenceOffset
-            || row.targetObjectKey != topology.objects[descriptor->objectIndex].objectKey
-            || (row.flags != format::kAuthoredSceneSquadSameObjectExact && !performance)
-            || row.reserved != 0
-            || (index != 0 && !edge_less(snapshot.squadEdges[index - 1], row))) {
-            return false;
-        }
-    }
-    for (std::size_t index = 1; index < snapshot.squadEdges.size(); ++index) {
-        if (edge_natural(snapshot.squadEdges[index - 1])
-            == edge_natural(snapshot.squadEdges[index])) {
-            return false;
-        }
-    }
-    for (std::size_t index = 0; index < snapshot.taskTargets.size(); ++index) {
-        const TaskTarget& row = snapshot.taskTargets[index];
-        const squad::DescriptorFact* descriptor =
-            find_descriptor(descriptors, row.taskSlotIndex, row.configTag, row.descriptorOffset);
-        Text expectedId{};
-        if (descriptor == nullptr || descriptor->objectIndex >= topology.objects.size()
-            || row.objectiveSlotIndex >= topology.slots.size()
-            || !is_task_descriptor(topology, *descriptor)
-            || !task_target_id(topology, *descriptor, expectedId)
-            || expectedId.value != row.id.value || expectedId.length != row.id.length
-            || !slot_shape(topology,
-                           schemas,
-                           row.taskSlotIndex,
-                           format::kTaskSlotType,
-                           format::kTaskComponentClass,
-                           format::kAbsentIndex,
-                           format::kTaskAuthSchema)
-            || !slot_shape(topology,
-                           schemas,
-                           row.objectiveSlotIndex,
-                           format::kObjectiveSlotType,
-                           format::kObjectiveComponentClass,
-                           format::kObjectiveSenseSchema,
-                           format::kObjectiveAuthSchema)
-            || row.descriptorOffset > format::kAbsentIndex - format::kTaskBitIndexRelativeOffset
-            || row.referenceFieldOffset
-                   != row.descriptorOffset + format::kTaskReferenceRelativeOffset
-            || topology.slots[row.objectiveSlotIndex].objectIndex >= topology.objects.size()
-            || row.targetObjectKey
-                   != topology.objects[topology.slots[row.objectiveSlotIndex].objectIndex].objectKey
-            || row.bitIndex >= 24U || row.flags != format::kTaskTargetExact || row.reserved != 0
-            || (index != 0 && !task_less(snapshot.taskTargets[index - 1], row))) {
-            return false;
-        }
-    }
-    for (std::size_t index = 1; index < snapshot.taskTargets.size(); ++index) {
-        if (task_natural(snapshot.taskTargets[index - 1])
-            == task_natural(snapshot.taskTargets[index])) {
-            return false;
-        }
-    }
-    return true;
-}
-
 /** Builds both authored-scene sections from complete topology and package facts. */
 bool build(const topology::Snapshot& topology,
            const Facts& facts,

+ 307 - 0
Sunrise/src/client/content/activity/activity_sdk_authored_scene_validation.cpp

@@ -0,0 +1,307 @@
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <cstdio>
+#include <string>
+#include <unordered_map>
+#include <unordered_set>
+#include <vector>
+
+#include "activity_sdk_authored_scene_internal.h"
+
+namespace sunrise::client::content::activity::sdk_generation::authored_scene_inventory {
+namespace {
+
+/** Hash combining uses the 32-bit golden-ratio increment. */
+constexpr std::size_t kHashCombineConstant = 0x9E3779B9U;
+
+/** Derives the descriptor identity used by every authored-scene child row. */
+[[nodiscard]] bool descriptor_id(const topology::Snapshot& topology,
+                                 const squad::DescriptorFact& descriptor,
+                                 std::string& output) {
+    output.clear();
+    if (descriptor.objectIndex >= topology.objects.size()
+        || descriptor.slotIndex >= topology.slots.size()) {
+        return false;
+    }
+    const topology::Object& object = topology.objects[descriptor.objectIndex];
+    const topology::Slot& slot = topology.slots[descriptor.slotIndex];
+    if (slot.objectIndex != descriptor.objectIndex) {
+        return false;
+    }
+    std::array<char, 80> buffer{};
+    const int written = std::snprintf(buffer.data(),
+                                      buffer.size(),
+                                      "descriptor/%08x/%08x/%08x/%04x/%04x",
+                                      static_cast<unsigned>(descriptor.configTag),
+                                      static_cast<unsigned>(object.objectTag),
+                                      static_cast<unsigned>(descriptor.descriptorOffset),
+                                      static_cast<unsigned>(slot.slotIndex),
+                                      static_cast<unsigned>(slot.slotType));
+    if (written <= 0 || static_cast<std::size_t>(written) >= buffer.size()) {
+        return false;
+    }
+    output.assign(buffer.data(), static_cast<std::size_t>(written));
+    return true;
+}
+
+/** Checks the complete descriptor set before package reads begin. */
+[[nodiscard]] bool valid_facts(const topology::Snapshot& topology, const Facts& facts) {
+    if (!facts.complete || facts.slotSchemas.size() != topology.slots.size()) {
+        return false;
+    }
+    std::unordered_set<std::string> ids{};
+    std::unordered_set<std::uint32_t> schemaSlots{};
+    try {
+        ids.reserve(facts.descriptors.size());
+        schemaSlots.reserve(facts.slotSchemas.size());
+        std::vector<std::size_t> descriptorCounts(topology.objects.size());
+        std::vector<bool> projectedOwners(topology.objects.size());
+        for (const squad::SlotSchemaFact& schema : facts.slotSchemas) {
+            if (schema.slotIndex >= topology.slots.size()
+                || !schemaSlots.emplace(schema.slotIndex).second) {
+                return false;
+            }
+            const topology::Slot& slot = topology.slots[schema.slotIndex];
+            if (slot.objectIndex >= topology.objects.size()) {
+                return false;
+            }
+            if (slot.slotType == format::kAuthoredSceneSlotType && schema.exact
+                && schema.componentClass == format::kAuthoredSceneComponentClass
+                && schema.senseSchema == format::kAuthoredSceneSenseSchema
+                && schema.authSchema == format::kAuthoredSceneAuthSchema) {
+                projectedOwners[slot.objectIndex] = true;
+            }
+            if (slot.slotType == format::kTaskSlotType && schema.exact
+                && schema.componentClass == format::kTaskComponentClass
+                && schema.senseSchema == format::kAbsentIndex
+                && schema.authSchema == format::kTaskAuthSchema) {
+                projectedOwners[slot.objectIndex] = true;
+            }
+        }
+        for (const squad::DescriptorFact& descriptor : facts.descriptors) {
+            std::string expected{};
+            if (!descriptor.complete || descriptor.configTag == 0
+                || descriptor.configTag == format::kAbsentIndex
+                || descriptor.objectIndex >= topology.objects.size()
+                || descriptor.slotIndex >= topology.slots.size()
+                || descriptor.descriptorOffset
+                       > format::kAbsentIndex - format::kAuthoredSceneSquadReferenceRelativeOffset
+                || !descriptor_id(topology, descriptor, expected) || descriptor.id != expected
+                || !ids.emplace(descriptor.id).second) {
+                return false;
+            }
+            ++descriptorCounts[descriptor.objectIndex];
+        }
+        for (std::size_t index = 0; index < topology.objects.size(); ++index) {
+            const topology::Object& object = topology.objects[index];
+            if (projectedOwners[index]
+                && (!object.descriptorEvidenceComplete
+                    || object.descriptorCount == format::kAbsentIndex
+                    || descriptorCounts[index] != object.descriptorCount)) {
+                return false;
+            }
+        }
+        return true;
+    } catch (...) {
+        return false;
+    }
+}
+
+/** One descriptor lookup key matches the final natural identity. */
+struct DescriptorKey final {
+    std::uint32_t slotIndex{};
+    std::uint32_t configTag{};
+    std::uint32_t descriptorOffset{};
+
+    bool operator==(const DescriptorKey&) const = default;
+};
+
+/** Mixes the three exact u32 identity lanes without narrowing them. */
+struct DescriptorKeyHash final {
+    [[nodiscard]] std::size_t operator()(const DescriptorKey& value) const noexcept {
+        std::size_t result = value.slotIndex;
+        result ^= static_cast<std::size_t>(value.configTag) + kHashCombineConstant + (result << 6U)
+                  + (result >> 2U);
+        result ^= static_cast<std::size_t>(value.descriptorOffset) + kHashCombineConstant
+                  + (result << 6U) + (result >> 2U);
+        return result;
+    }
+};
+
+using DescriptorIndex =
+    std::unordered_map<DescriptorKey, const squad::DescriptorFact*, DescriptorKeyHash>;
+
+/** Builds the exact descriptor lookup consumed by output validation. */
+[[nodiscard]] bool descriptor_index(const Facts& facts, DescriptorIndex& output) {
+    output.clear();
+    try {
+        output.reserve(facts.descriptors.size());
+        for (const squad::DescriptorFact& descriptor : facts.descriptors) {
+            const DescriptorKey key{
+                descriptor.slotIndex, descriptor.configTag, descriptor.descriptorOffset};
+            if (!output.emplace(key, &descriptor).second) {
+                return false;
+            }
+        }
+        return true;
+    } catch (...) {
+        output.clear();
+        return false;
+    }
+}
+
+/** Finds one descriptor fact by its natural package identity. */
+[[nodiscard]] const squad::DescriptorFact*
+find_descriptor(const DescriptorIndex& descriptors,
+                std::uint32_t slotIndex,
+                std::uint32_t configTag,
+                std::uint32_t descriptorOffset) noexcept {
+    const auto found = descriptors.find({slotIndex, configTag, descriptorOffset});
+    return found == descriptors.end() ? nullptr : found->second;
+}
+
+} // namespace
+
+/** Validates every structural ID, scalar domain, slot join, flag, and row order. */
+bool validate(const topology::Snapshot& topology,
+              const Facts& facts,
+              const Snapshot& snapshot) noexcept {
+    if (!snapshot.complete || !valid_topology(topology) || !valid_facts(topology, facts)) {
+        return false;
+    }
+    SchemaIndex schemas{};
+    DescriptorIndex descriptors{};
+    if (!schema_index(topology, facts, schemas) || !descriptor_index(facts, descriptors)) {
+        return false;
+    }
+    for (std::size_t index = 0; index < snapshot.resources.size(); ++index) {
+        const Resource& row = snapshot.resources[index];
+        const squad::DescriptorFact* descriptor =
+            find_descriptor(descriptors, row.slotIndex, row.configTag, row.descriptorOffset);
+        Text expectedId{};
+        if (descriptor == nullptr || !is_scene_descriptor(topology, *descriptor)
+            || !resource_id(topology, *descriptor, expectedId) || expectedId.value != row.id.value
+            || expectedId.length != row.id.length
+            || !slot_shape(topology,
+                           schemas,
+                           row.slotIndex,
+                           format::kAuthoredSceneSlotType,
+                           format::kAuthoredSceneComponentClass,
+                           format::kAuthoredSceneSenseSchema,
+                           format::kAuthoredSceneAuthSchema)
+            || row.descriptorOffset
+                   > format::kAbsentIndex - format::kAuthoredSceneResourceRelativeOffset
+            || row.resourceFieldOffset
+                   != row.descriptorOffset + format::kAuthoredSceneResourceRelativeOffset
+            || row.resourceTag == 0 || row.resourceTag == format::kAbsentIndex
+            || row.resourceClass != format::kAuthoredSceneResourceClass
+            || row.flags != format::kAuthoredSceneResourceExact || row.reserved != 0
+            || (index != 0 && !resource_less(snapshot.resources[index - 1], row))) {
+            return false;
+        }
+    }
+    for (std::size_t index = 1; index < snapshot.resources.size(); ++index) {
+        if (resource_natural(snapshot.resources[index - 1])
+            == resource_natural(snapshot.resources[index])) {
+            return false;
+        }
+    }
+    for (std::size_t index = 0; index < snapshot.squadEdges.size(); ++index) {
+        const SquadEdge& row = snapshot.squadEdges[index];
+        const squad::DescriptorFact* descriptor =
+            find_descriptor(descriptors, row.sceneSlotIndex, row.configTag, row.descriptorOffset);
+        Text expectedId{};
+        // A row is a type-43 scene edge or a type-42 performance edge; the flag says which.
+        const bool performance = row.flags == format::kAuthoredSceneSquadPerformanceTargetExact;
+        const std::uint32_t referenceOffset =
+            performance ? format::kPerformanceSquadReferenceRelativeOffset
+                        : format::kAuthoredSceneSquadReferenceRelativeOffset;
+        const bool sourceShape = performance ? slot_shape(topology,
+                                                          schemas,
+                                                          row.sceneSlotIndex,
+                                                          format::kPerformanceSlotType,
+                                                          format::kPerformanceComponentClass,
+                                                          format::kAbsentIndex,
+                                                          format::kPerformanceAuthSchema)
+                                             : slot_shape(topology,
+                                                          schemas,
+                                                          row.sceneSlotIndex,
+                                                          format::kAuthoredSceneSlotType,
+                                                          format::kAuthoredSceneComponentClass,
+                                                          format::kAuthoredSceneSenseSchema,
+                                                          format::kAuthoredSceneAuthSchema);
+        if (descriptor == nullptr || descriptor->objectIndex >= topology.objects.size()
+            || row.squadSlotIndex >= topology.slots.size()
+            || topology.slots[row.squadSlotIndex].objectIndex != descriptor->objectIndex
+            || !(performance ? is_performance_descriptor(topology, *descriptor)
+                             : is_scene_descriptor(topology, *descriptor))
+            || !edge_id(topology, *descriptor, expectedId) || expectedId.value != row.id.value
+            || expectedId.length != row.id.length || !sourceShape
+            || !slot_shape(topology,
+                           schemas,
+                           row.squadSlotIndex,
+                           format::kSquadSlotType,
+                           format::kSquadComponentClass,
+                           format::kSquadSenseSchema,
+                           format::kSquadAuthSchema)
+            || row.descriptorOffset > format::kAbsentIndex - referenceOffset
+            || row.referenceFieldOffset != row.descriptorOffset + referenceOffset
+            || row.targetObjectKey != topology.objects[descriptor->objectIndex].objectKey
+            || (row.flags != format::kAuthoredSceneSquadSameObjectExact && !performance)
+            || row.reserved != 0
+            || (index != 0 && !edge_less(snapshot.squadEdges[index - 1], row))) {
+            return false;
+        }
+    }
+    for (std::size_t index = 1; index < snapshot.squadEdges.size(); ++index) {
+        if (edge_natural(snapshot.squadEdges[index - 1])
+            == edge_natural(snapshot.squadEdges[index])) {
+            return false;
+        }
+    }
+    for (std::size_t index = 0; index < snapshot.taskTargets.size(); ++index) {
+        const TaskTarget& row = snapshot.taskTargets[index];
+        const squad::DescriptorFact* descriptor =
+            find_descriptor(descriptors, row.taskSlotIndex, row.configTag, row.descriptorOffset);
+        Text expectedId{};
+        if (descriptor == nullptr || descriptor->objectIndex >= topology.objects.size()
+            || row.objectiveSlotIndex >= topology.slots.size()
+            || !is_task_descriptor(topology, *descriptor)
+            || !task_target_id(topology, *descriptor, expectedId)
+            || expectedId.value != row.id.value || expectedId.length != row.id.length
+            || !slot_shape(topology,
+                           schemas,
+                           row.taskSlotIndex,
+                           format::kTaskSlotType,
+                           format::kTaskComponentClass,
+                           format::kAbsentIndex,
+                           format::kTaskAuthSchema)
+            || !slot_shape(topology,
+                           schemas,
+                           row.objectiveSlotIndex,
+                           format::kObjectiveSlotType,
+                           format::kObjectiveComponentClass,
+                           format::kObjectiveSenseSchema,
+                           format::kObjectiveAuthSchema)
+            || row.descriptorOffset > format::kAbsentIndex - format::kTaskBitIndexRelativeOffset
+            || row.referenceFieldOffset
+                   != row.descriptorOffset + format::kTaskReferenceRelativeOffset
+            || topology.slots[row.objectiveSlotIndex].objectIndex >= topology.objects.size()
+            || row.targetObjectKey
+                   != topology.objects[topology.slots[row.objectiveSlotIndex].objectIndex].objectKey
+            || row.bitIndex >= 24U || row.flags != format::kTaskTargetExact || row.reserved != 0
+            || (index != 0 && !task_less(snapshot.taskTargets[index - 1], row))) {
+            return false;
+        }
+    }
+    for (std::size_t index = 1; index < snapshot.taskTargets.size(); ++index) {
+        if (task_natural(snapshot.taskTargets[index - 1])
+            == task_natural(snapshot.taskTargets[index])) {
+            return false;
+        }
+    }
+    return true;
+}
+
+} // namespace sunrise::client::content::activity::sdk_generation::authored_scene_inventory

+ 20 - 15
Sunrise/src/client/content/activity/activity_sdk_behavior_inventory.cpp

@@ -20,18 +20,22 @@ namespace {
 namespace reader = middleware::content::packages::reader;
 namespace parallel = middleware::content::packages::reader::parallel;
 
+// Package class ids of the behavior graph nodes this pass reads.
 constexpr std::uint32_t kRootClass = 0x8080941EU;
 constexpr std::uint32_t kStorageChannelClass = 0x80802A6FU;
 constexpr std::uint32_t kConditionClass = 0x80804D7DU;
 constexpr std::uint32_t kFilterClass = 0x80804E3DU;
 constexpr std::uint32_t kTargetClass = 0x80804E10U;
 constexpr std::uint32_t kConditionGroupClass = 0x80809310U;
+// Extraction bounds; one malformed blob must not exhaust memory.
 constexpr std::uint32_t kMaximumRows = 1U << 20U;
 constexpr std::uint32_t kMaximumDepth = 256U;
+// Array header marker and the element classes it may carry.
 constexpr std::uint32_t kArrayMarker = 0x80809FBDU;
 constexpr std::uint32_t kBuildElementClass = 0x80809C04U;
 constexpr std::uint32_t kDescriptorElementClass = 0x80809C22U;
 constexpr std::uint32_t kMetadataElementClass = 0x80809C20U;
+// All-ones marks an absent row index in every extracted table.
 constexpr std::uint32_t kAbsentIndex = (std::numeric_limits<std::uint32_t>::max)();
 
 enum class InputRole : std::uint8_t {
@@ -48,6 +52,7 @@ struct NodeShape final {
     std::uint8_t expressionOffset{};
 };
 
+// Class id, blob size, root field offsets and expression offset of each node.
 constexpr std::array kNodeShapes{
     NodeShape{0x80804E0FU, 0x58, {}, 0, 0x10},
     NodeShape{0x80804E10U, 0x10},
@@ -589,19 +594,19 @@ struct BuildOwner final {
     }
     parallel::release();
     std::sort(
-        output.owners.begin(), output.owners.end(), [](const Owner& left, const Owner& right) {
-            return std::tie(left.programRow,
-                            left.actorClassIndex,
-                            left.configTag,
-                            left.configFieldOffset,
-                            left.buildOrdinal,
-                            left.descriptorOrdinal)
-                   < std::tie(right.programRow,
-                              right.actorClassIndex,
-                              right.configTag,
-                              right.configFieldOffset,
-                              right.buildOrdinal,
-                              right.descriptorOrdinal);
+        output.owners.begin(), output.owners.end(), [](const Owner& first, const Owner& second) {
+            return std::tie(first.programRow,
+                            first.actorClassIndex,
+                            first.configTag,
+                            first.configFieldOffset,
+                            first.buildOrdinal,
+                            first.descriptorOrdinal)
+                   < std::tie(second.programRow,
+                              second.actorClassIndex,
+                              second.configTag,
+                              second.configFieldOffset,
+                              second.buildOrdinal,
+                              second.descriptorOrdinal);
         });
     return true;
 }
@@ -722,8 +727,8 @@ bool build(const reader::Source& source,
                 parsed.push_back(std::move(row));
             }
         }
-        std::sort(parsed.begin(), parsed.end(), [](const auto& left, const auto& right) {
-            return left.program.rootTag < right.program.rootTag;
+        std::sort(parsed.begin(), parsed.end(), [](const auto& first, const auto& second) {
+            return first.program.rootTag < second.program.rootTag;
         });
         std::size_t parsedIndex = 0;
         for (const std::uint32_t tag : tags) {

+ 6 - 4
Sunrise/src/client/content/activity/activity_sdk_dialogue_group_index.cpp

@@ -7,6 +7,7 @@
 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;
 
 template <typename Value>
@@ -20,6 +21,7 @@ read_value(std::span<const std::byte> bytes, std::size_t offset, Value& output)
     return true;
 }
 
+/** Applies one signed blob-relative offset. @return False when the result leaves the blob. */
 [[nodiscard]] bool
 add_relative(std::size_t member, std::int64_t relative, std::size_t& target) noexcept {
     if (relative >= 0) {
@@ -71,13 +73,13 @@ bool build(std::span<const std::byte> bytes,
                 }
             }
         }
-        std::sort(output.begin(), output.end(), [](const Span& left, const Span& right) {
-            return left.definitionHash < right.definitionHash;
+        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& left, const Span& right) {
-                                   return left.definitionHash == right.definitionHash;
+                               [](const Span& first, const Span& second) {
+                                   return first.definitionHash == second.definitionHash;
                                })
             != output.end()) {
             output.clear();

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

@@ -106,6 +106,7 @@ const char* status_name(Status value) noexcept {
     return "not_generated";
 }
 
+/** Records one validation result for an artifact tree. @return False when an input is bad. */
 bool remember(std::wstring_view artifactDirectory,
               const Digest& sourceFingerprint,
               const Result& result) noexcept {

+ 6 - 6
Sunrise/src/client/content/activity/activity_sdk_external_placements.cpp

@@ -132,15 +132,15 @@ void finalize(Index& output) noexcept {
     output.lookup.clear();
     std::sort(output.scenarioRows.begin(),
               output.scenarioRows.end(),
-              [](const ScenarioRow& left, const ScenarioRow& right) {
-                  return std::tie(left.scenarioTag, left.rowIndex)
-                         < std::tie(right.scenarioTag, right.rowIndex);
+              [](const ScenarioRow& first, const ScenarioRow& second) {
+                  return std::tie(first.scenarioTag, first.rowIndex)
+                         < std::tie(second.scenarioTag, second.rowIndex);
               });
     output.scenarioRows.erase(std::unique(output.scenarioRows.begin(),
                                           output.scenarioRows.end(),
-                                          [](const ScenarioRow& left, const ScenarioRow& right) {
-                                              return left.scenarioTag == right.scenarioTag
-                                                     && left.rowIndex == right.rowIndex;
+                                          [](const ScenarioRow& first, const ScenarioRow& second) {
+                                              return first.scenarioTag == second.scenarioTag
+                                                     && first.rowIndex == second.rowIndex;
                                           }),
                               output.scenarioRows.end());
 }

+ 378 - 0
Sunrise/src/client/content/activity/activity_sdk_generation_scenario_shards.cpp

@@ -0,0 +1,378 @@
+#include <Windows.h>
+
+#include <algorithm>
+#include <array>
+#include <atomic>
+#include <cstddef>
+#include <cstdint>
+#include <cstdio>
+#include <memory>
+#include <new>
+#include <string>
+#include <string_view>
+#include <vector>
+
+#include "../../../core/logging/log.h"
+#include "../../../state/activity_sdk/generated_world/codec.h"
+#include "../../../state/activity_sdk/generation/definition.h"
+#include "activity_sdk_generation_worker_internal.h"
+
+namespace sunrise::client::content::activity::sdk_generation::worker_internal {
+namespace {
+
+/** Parallel readers divide one pass-wide cache budget instead of multiplying it per core. */
+constexpr std::size_t kParallelBlockCacheBudget = 256;
+constexpr std::size_t kParallelTableCacheBudget = 16;
+constexpr std::size_t kMaximumScenarioWorkers = 12;
+constexpr std::size_t kScenarioChunkSize = 1;
+
+/** Counts the rows behind a refused shard, which the coverage verdict alone cannot name. */
+void report_shard_diagnostics(const Scenario& scenario,
+                              const catalog::Snapshot& snapshot) noexcept {
+    std::size_t incompleteObjects = 0;
+    std::size_t incompleteSafety = 0;
+    std::size_t leaves = 0;
+    std::size_t bareTargets = 0;
+    for (const catalog::Object& object : snapshot.objects) {
+        if (!object.complete) {
+            ++incompleteObjects;
+        }
+        if (object.safety == catalog::GroupSafety::incomplete) {
+            ++incompleteSafety;
+        }
+        leaves += object.placedLeafCount;
+        bareTargets += object.bareTargetCount;
+    }
+    std::size_t unresolvedStates = 0;
+    for (const catalog::State& state : snapshot.states) {
+        if (!state.resolved) {
+            ++unresolvedStates;
+        }
+    }
+    const catalog::ContainerPlacementDiagnostics& placements =
+        snapshot.containerPlacementDiagnostics;
+    std::array<char, 1024> line{};
+    const int written =
+        std::snprintf(line.data(),
+                      line.size(),
+                      "ev=sdk_shard_diag scenario=0x%08X objects=%zu "
+                      "obj_incomplete=%zu obj_safety_incomplete=%zu leaves=%zu "
+                      "bare=%zu states_unresolved=%zu "
+                      "unresolved_reads=%llu spatial_ctx=%u spatial_na=%u spatial_complete=%u "
+                      "spatial_unresolved=%llu spatial_dropped=%llu spatial_semantic=%llu "
+                      "spatial_tables=%zu spatial_owners=%zu spatial_instances=%zu "
+                      "cp_ctx=%u cp_na=%u cp_complete=%u cp_owner_complete=%u "
+                      "cp_unresolved=%llu cp_semantic=%llu cp_dropped=%llu/%llu/%llu/%llu/%llu "
+                      "cp_rows=%zu/%zu/%zu/%zu/%zu",
+                      static_cast<unsigned>(scenario.tag),
+                      snapshot.objects.size(),
+                      incompleteObjects,
+                      incompleteSafety,
+                      leaves,
+                      bareTargets,
+                      unresolvedStates,
+                      static_cast<unsigned long long>(snapshot.unresolvedReads),
+                      snapshot.staticSpatialContextResolved ? 1U : 0U,
+                      snapshot.staticSpatialNotApplicable ? 1U : 0U,
+                      snapshot.staticSpatialComplete ? 1U : 0U,
+                      static_cast<unsigned long long>(snapshot.staticSpatialUnresolvedReads),
+                      static_cast<unsigned long long>(snapshot.staticSpatialDropped),
+                      static_cast<unsigned long long>(snapshot.staticSpatialSemanticUnresolved),
+                      snapshot.staticSpatialTables.size(),
+                      snapshot.staticSpatialOwners.size(),
+                      snapshot.staticSpatialInstances.size(),
+                      placements.contextResolved ? 1U : 0U,
+                      placements.contextNotApplicable ? 1U : 0U,
+                      placements.complete ? 1U : 0U,
+                      placements.identityOwnerInventoryComplete ? 1U : 0U,
+                      static_cast<unsigned long long>(placements.unresolvedReads),
+                      static_cast<unsigned long long>(placements.semanticUnresolved),
+                      static_cast<unsigned long long>(placements.droppedLists),
+                      static_cast<unsigned long long>(placements.droppedOwners),
+                      static_cast<unsigned long long>(placements.droppedPlacements),
+                      static_cast<unsigned long long>(placements.droppedConfigs),
+                      static_cast<unsigned long long>(placements.droppedComponents),
+                      snapshot.containerPlacementLists.size(),
+                      snapshot.containerPlacementOwners.size(),
+                      snapshot.containerPlacements.size(),
+                      snapshot.containerPlacementConfigs.size(),
+                      snapshot.containerPlacementComponents.size());
+    if (written > 0) {
+        core::log::write(
+            core::log::Channel::client,
+            core::log::Level::error,
+            {line.data(), (std::min)(static_cast<std::size_t>(written), line.size() - 1)});
+    }
+}
+
+/** Writes one new shard under its deterministic digest name. */
+[[nodiscard]] bool build_record(const Work& work,
+                                const package_reader::Source& source,
+                                const builder::ContainerIndex& containers,
+                                package_reader::Scratch& scratch,
+                                builder::ScenarioAnalysisCache& analyses,
+                                const Scenario& scenario,
+                                manifest::Record& record,
+                                std::shared_ptr<const catalog::Snapshot>& output,
+                                std::array<char, kDetailCapacity>& detailScratch,
+                                const char*& detail) {
+    // Every arm below reported the same word, so a failure named the step it reached and nothing
+    // about which of five things went wrong. Each one now says which.
+    const std::string_view name(scenario.name.data(), scenario.nameLength);
+    const auto snapshot = builder::build_scenario_catalog(
+        source, containers, scratch, analyses, scenario.tag, name, &cancel_requested);
+    if (cancel_requested()) {
+        detail = "shard_cancelled";
+        return false;
+    }
+    if (!snapshot) {
+        detail = "shard_catalog_null";
+        return false;
+    }
+    if (snapshot->status != catalog::BuildStatus::ready) {
+        const char* stage = "shard_catalog_not_ready";
+        switch (snapshot->status) {
+        case catalog::BuildStatus::failed:
+            stage = "shard_catalog_failed";
+            break;
+        case catalog::BuildStatus::idle:
+            stage = "shard_catalog_idle";
+            break;
+        case catalog::BuildStatus::queued:
+            stage = "shard_catalog_queued";
+            break;
+        case catalog::BuildStatus::building:
+            stage = "shard_catalog_building";
+            break;
+        default:
+            break;
+        }
+        report_shard_diagnostics(scenario, *snapshot);
+        // The builder already names its own refusal. Carry that text out instead of dropping it.
+        const char* reason = snapshot->detail[0] != '\0' ? snapshot->detail.data() : "no detail";
+        detailScratch = {};
+        (void)std::snprintf(detailScratch.data(), detailScratch.size(), "%s:%s", stage, reason);
+        detail = detailScratch.data();
+        return false;
+    }
+    generated::PreparedShard prepared{};
+    if (!generated::prepare(work.sourceFingerprint, *snapshot, prepared)) {
+        detail = "shard_prepare_failed";
+        return false;
+    }
+    const generated::Digest payload = prepared.payload_sha256();
+    std::wstring finalPath;
+    generated::Digest written{};
+    if (!shard_path(work.scenarioDirectory, scenario.tag, payload, finalPath)) {
+        detail = "shard_path_failed";
+        return false;
+    }
+    if (!generated::publish(finalPath.c_str(), std::move(prepared), written)) {
+        detail = "shard_write_failed";
+        return false;
+    }
+    record = {};
+    record.scenarioTag = scenario.tag;
+    record.scenarioName = scenario.name;
+    record.scenarioNameLength = scenario.nameLength;
+    record.shardPayloadSha256 = payload;
+    output = snapshot;
+    return true;
+}
+
+/** Rewrites one loaded cache hit into an isolated output tree. */
+[[nodiscard]] bool
+materialize_cached_record(const Work& work,
+                          const Scenario& scenario,
+                          const manifest::Record& record,
+                          const std::shared_ptr<const catalog::Snapshot>& cached,
+                          std::shared_ptr<const catalog::Snapshot>& output) noexcept {
+    if (cached == nullptr) {
+        return false;
+    }
+    if (work.cacheScenarioDirectory == work.scenarioDirectory) {
+        output = cached;
+        return true;
+    }
+    std::wstring finalPath;
+    generated::Digest written{};
+    if (!shard_path(work.scenarioDirectory, scenario.tag, record.shardPayloadSha256, finalPath)
+        || !generated::write(finalPath.c_str(), work.sourceFingerprint, *cached, written)) {
+        return false;
+    }
+    output = cached;
+    return true;
+}
+
+/** Shared immutable inputs and atomic scheduling state for one scenario batch. */
+struct ScenarioBuildBatch final {
+    const Work* work{};
+    const package_reader::Source* source{};
+    const builder::ContainerIndex* containers{};
+    const manifest::Catalog* prior{};
+    std::vector<ScenarioBuildResult>* results{};
+    std::atomic_size_t next{};
+    std::atomic_size_t completed{};
+    SRWLOCK progressLock = SRWLOCK_INIT;
+    std::size_t progressPublished{};
+    std::size_t firstScenario{};
+    std::size_t blockCacheSlots{};
+    std::size_t tableCacheSlots{};
+    bool priorReady{};
+};
+
+/** Caps workers while leaving cores for the game and the server. */
+[[nodiscard]] std::size_t scenario_worker_count(std::size_t scenarios) noexcept {
+    SYSTEM_INFO info{};
+    GetSystemInfo(&info);
+    const std::size_t processors = static_cast<std::size_t>(info.dwNumberOfProcessors);
+    const std::size_t available = processors > 2U ? processors - 2U : 1U;
+    return (std::min)(scenarios, (std::min)(available, kMaximumScenarioWorkers));
+}
+
+/** Divides one cache budget across active workers without leaving a worker uncached. */
+[[nodiscard]] std::size_t worker_cache_slots(std::size_t budget, std::size_t workers) noexcept {
+    return (std::max)(std::size_t{1}, (budget + workers - 1U) / workers);
+}
+
+/** Publishes only increasing completion counts from out-of-order workers. */
+void publish_parallel_progress(ScenarioBuildBatch& batch, const Scenario& scenario) noexcept {
+    const std::size_t complete = batch.completed.fetch_add(1U) + 1U;
+    AcquireSRWLockExclusive(&batch.progressLock);
+    if (complete <= batch.progressPublished) {
+        ReleaseSRWLockExclusive(&batch.progressLock);
+        return;
+    }
+    batch.progressPublished = complete;
+    publish_progress(state::activity_sdk::generation::Status::building,
+                     static_cast<std::uint32_t>(complete),
+                     static_cast<std::uint32_t>(batch.work->scenarios.size()),
+                     scenario.tag,
+                     std::string_view(scenario.name.data(), scenario.nameLength));
+    ReleaseSRWLockExclusive(&batch.progressLock);
+}
+
+/** Builds one worker's contiguous chunks with private package and analysis caches. */
+void run_scenario_worker(ScenarioBuildBatch& batch) noexcept {
+    std::unique_ptr<package_reader::Scratch> scratch(new (std::nothrow) package_reader::Scratch());
+    if (scratch == nullptr) {
+        return;
+    }
+    if (!package_reader::prepare_blocks(*scratch, batch.blockCacheSlots)) {
+        (void)package_reader::prepare_blocks(*scratch, 0);
+    }
+    if (!package_reader::prepare_tables(*scratch, batch.tableCacheSlots)) {
+        (void)package_reader::prepare_tables(*scratch, 0);
+    }
+    builder::ScenarioAnalysisCache analyses{};
+    while (!cancel_requested()) {
+        const std::size_t begin = batch.next.fetch_add(kScenarioChunkSize);
+        if (begin >= batch.results->size()) {
+            break;
+        }
+        const std::size_t end = (std::min)(begin + kScenarioChunkSize, batch.results->size());
+        for (std::size_t index = begin; index < end && !cancel_requested(); ++index) {
+            const Scenario& scenario = batch.work->scenarios[batch.firstScenario + index];
+            ScenarioBuildResult& result = (*batch.results)[index];
+            result.attempted = true;
+            try {
+                const manifest::Record* existing =
+                    batch.priorReady ? find_record(*batch.prior, scenario.tag) : nullptr;
+                bool kept = existing != nullptr
+                            && load_full_record(batch.work->cacheScenarioDirectory,
+                                                batch.work->sourceFingerprint,
+                                                scenario,
+                                                *existing,
+                                                result.snapshot);
+                if (kept
+                    && !materialize_cached_record(
+                        *batch.work, scenario, *existing, result.snapshot, result.snapshot)) {
+                    kept = false;
+                }
+                if (kept) {
+                    result.record = *existing;
+                    result.reused = true;
+                    result.ready = true;
+                } else {
+                    const char* detail = "shard_build_failed";
+                    std::array<char, kDetailCapacity> scratchDetail{};
+                    result.ready = build_record(*batch.work,
+                                                *batch.source,
+                                                *batch.containers,
+                                                *scratch,
+                                                analyses,
+                                                scenario,
+                                                result.record,
+                                                result.snapshot,
+                                                scratchDetail,
+                                                detail);
+                    if (!result.ready) {
+                        (void)std::snprintf(
+                            result.detail.data(), result.detail.size(), "%s", detail);
+                    }
+                }
+            } catch (...) {
+                result.ready = false;
+                (void)std::snprintf(result.detail.data(),
+                                    result.detail.size(),
+                                    "%s",
+                                    "unexpected scenario build exception");
+            }
+            publish_parallel_progress(batch, scenario);
+        }
+    }
+    package_reader::close_files(*scratch);
+}
+
+/** Adapts one batch worker to the Windows thread ABI. */
+DWORD WINAPI scenario_thread_main(void* opaque) noexcept {
+    run_scenario_worker(*static_cast<ScenarioBuildBatch*>(opaque));
+    return 0;
+}
+
+} // namespace
+
+/** Builds all scenario snapshots in parallel while retaining scenario-order output. */
+bool build_scenarios(Work& work,
+                     const package_reader::Source& source,
+                     const builder::ContainerIndex& containers,
+                     const manifest::Catalog& prior,
+                     bool priorReady,
+                     std::size_t firstScenario,
+                     std::size_t scenarioCount,
+                     std::vector<ScenarioBuildResult>& results) {
+    results.clear();
+    results.resize(scenarioCount);
+    ScenarioBuildBatch batch{};
+    batch.work = &work;
+    batch.source = &source;
+    batch.containers = &containers;
+    batch.prior = &prior;
+    batch.results = &results;
+    batch.completed.store(firstScenario);
+    batch.progressPublished = firstScenario;
+    batch.firstScenario = firstScenario;
+    batch.priorReady = priorReady;
+    const std::size_t workers = scenario_worker_count(results.size());
+    if (workers == 0) {
+        return false;
+    }
+    batch.blockCacheSlots = worker_cache_slots(kParallelBlockCacheBudget, workers);
+    batch.tableCacheSlots = worker_cache_slots(kParallelTableCacheBudget, workers);
+    std::vector<HANDLE> threads;
+    threads.reserve(workers - 1U);
+    for (std::size_t index = 1; index < workers; ++index) {
+        const HANDLE thread = CreateThread(nullptr, 0, &scenario_thread_main, &batch, 0, nullptr);
+        if (thread != nullptr) {
+            threads.push_back(thread);
+        }
+    }
+    run_scenario_worker(batch);
+    for (const HANDLE thread : threads) {
+        (void)WaitForSingleObject(thread, INFINITE);
+        (void)CloseHandle(thread);
+    }
+    return !cancel_requested();
+}
+
+} // namespace sunrise::client::content::activity::sdk_generation::worker_internal

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

@@ -11,6 +11,7 @@ namespace sunrise::client::content::activity::sdk_generation::worker_internal {
 
 namespace shard_store = state::activity_sdk::generated_world::store;
 
+// Shards are published with the pack extension.
 constexpr std::wstring_view kShardExtension = L".pack";
 
 /** Formats an immutable shard path from its scenario and payload identities. */
@@ -18,6 +19,7 @@ constexpr std::wstring_view kShardExtension = L".pack";
                               std::uint32_t scenarioTag,
                               const generated::Digest& digest,
                               std::wstring& output) noexcept {
+    // A shard leaf spells the digest in lowercase hex.
     static constexpr wchar_t kDigits[] = L"0123456789abcdef";
     std::array<wchar_t, 10> tag{};
     const int length =

+ 23 - 593
Sunrise/src/client/content/activity/activity_sdk_generation_worker.cpp

@@ -5,7 +5,6 @@
 #include <algorithm>
 #include <array>
 #include <atomic>
-#include <cctype>
 #include <cstddef>
 #include <cstdio>
 #include <cstring>
@@ -50,243 +49,31 @@ namespace topology = ::sunrise::client::content::activity::sdk_generation::topol
 namespace lua = ::sunrise::client::content::activity::sdk_generation::lua_artifacts;
 namespace worker = worker_internal;
 
+// Generated artifact paths below the scenario root.
 constexpr std::wstring_view kSdkDirectorySuffix = L"\\sdk";
 constexpr std::wstring_view kScenarioDirectorySuffix = L"\\sdk\\scenarios";
 constexpr std::wstring_view kCatalogFileSuffix = L"\\sdk\\catalog.bin";
 constexpr std::wstring_view kPackFileSuffix = L"\\activity_sdk.pack";
 constexpr std::wstring_view kShardExtension = L".pack";
+// Wait this long before probing package data again.
 constexpr ULONGLONG kPackageWaitMs = 1'000;
-/** Parallel readers divide one pass-wide cache budget instead of multiplying it per core. */
-constexpr std::size_t kParallelBlockCacheBudget = 256;
-constexpr std::size_t kParallelTableCacheBudget = 16;
-constexpr std::size_t kMaximumScenarioWorkers = 12;
-constexpr std::size_t kScenarioChunkSize = 1;
 /** A small batch bounds complete snapshots retained while parallel work finishes. */
 constexpr std::size_t kScenarioBatchSize = 24;
-/** Holds one step token plus the builder's own 96-byte refusal text. */
-constexpr std::size_t kDetailCapacity = 160;
-
-/** @return One selected hash name, or an empty view when the name is unresolved. */
-[[nodiscard]] std::string_view selected_name(const catalog::Snapshot& snapshot,
-                                             std::uint32_t row) noexcept {
-    if (row >= snapshot.names.size()) {
-        return {};
-    }
-    const catalog::Name& name = snapshot.names[row];
-    if (name.selectedCandidate >= snapshot.nameCandidates.size()) {
-        return {};
-    }
-    const catalog::NameCandidate& candidate = snapshot.nameCandidates[name.selectedCandidate];
-    return {candidate.value.data(), candidate.length};
-}
-
-/** @return One selected package tag name, or an empty view when it is unresolved. */
-[[nodiscard]] std::string_view selected_tag_name(const catalog::Snapshot& snapshot,
-                                                 std::uint32_t row) noexcept {
-    if (row >= snapshot.tagNames.size()) {
-        return {};
-    }
-    const catalog::TagName& name = snapshot.tagNames[row];
-    if (name.selectedCandidate >= snapshot.nameCandidates.size()) {
-        return {};
-    }
-    const catalog::NameCandidate& candidate = snapshot.nameCandidates[name.selectedCandidate];
-    return {candidate.value.data(), candidate.length};
-}
-
-/** Appends a deterministic Lua string literal. */
-void append_lua_string(std::string& output, std::string_view value) {
-    output.push_back('"');
-    constexpr char digits[] = "0123456789ABCDEF";
-    for (const unsigned char byte : value) {
-        if (byte == '\\' || byte == '"') {
-            output.push_back('\\');
-            output.push_back(static_cast<char>(byte));
-        } else if (byte == '\n') {
-            output.append("\\n");
-        } else if (byte < 0x20U || byte == 0x7FU) {
-            output.append("\\x");
-            output.push_back(digits[byte >> 4U]);
-            output.push_back(digits[byte & 0xFU]);
-        } else {
-            output.push_back(static_cast<char>(byte));
-        }
-    }
-    output.push_back('"');
-}
-
-void append_format(std::string& output, const char* format, auto... values) {
-    std::array<char, 512> buffer{};
-    const int length = std::snprintf(buffer.data(), buffer.size(), format, values...);
-    if (length > 0) {
-        output.append(buffer.data(),
-                      (std::min)(static_cast<std::size_t>(length), buffer.size() - 1));
-    }
-}
-
-/** Converts an extracted trigger name into a declaration key. */
-[[nodiscard]] std::string lua_constant(std::string_view value) {
-    std::string output;
-    bool separator = false;
-    for (const unsigned char byte : value) {
-        if (std::isalnum(byte) != 0) {
-            if (separator && !output.empty()) {
-                output.push_back('_');
-            }
-            output.push_back(static_cast<char>(std::toupper(byte)));
-            separator = false;
-        } else {
-            separator = true;
-        }
-    }
-    if (output.empty()) {
-        output = "UNNAMED";
-    }
-    if (std::isdigit(static_cast<unsigned char>(output.front())) != 0) {
-        output.insert(output.begin(), '_');
-    }
-    return output;
-}
-
-/** Emits transparent positional trigger declarations for one mission module. */
-[[nodiscard]] bool build_world_source(const catalog::Snapshot& snapshot,
-                                      lua::ScenarioWorldSource& result) {
-    result = {};
-    result.scenarioTag = snapshot.scenarioTag;
-    std::string source = "\n---@type SunriseTriggerVolume[]\n"
-                         "mission.trigger_volumes = {\n";
-    std::vector<std::pair<std::string, std::uint32_t>> constants;
-    std::vector<std::string_view> names;
-    std::uint32_t emitted = 0;
-    for (const catalog::TriggerVolumeOwner& owner : snapshot.triggerVolumeOwners) {
-        if (owner.tableRow >= snapshot.triggerVolumeTables.size()
-            || owner.objectRow >= snapshot.objects.size()) {
-            continue;
-        }
-        const catalog::TriggerVolumeTable& table = snapshot.triggerVolumeTables[owner.tableRow];
-        const std::size_t first = table.firstInstance;
-        const std::size_t count = table.instanceCount;
-        if (first > snapshot.triggerVolumeInstances.size()
-            || count > snapshot.triggerVolumeInstances.size() - first) {
-            continue;
-        }
-        names.clear();
-        const std::size_t incomingFirst = owner.firstIncomingReference;
-        const std::size_t incomingCount = owner.incomingReferenceCount;
-        if (incomingFirst <= snapshot.triggerVolumeIncomingReferences.size()
-            && incomingCount <= snapshot.triggerVolumeIncomingReferences.size() - incomingFirst) {
-            for (std::size_t offset = 0; offset < incomingCount; ++offset) {
-                const auto& incoming =
-                    snapshot.triggerVolumeIncomingReferences[incomingFirst + offset];
-                if (incoming.sourceSlotRow < snapshot.slots.size()) {
-                    const std::string_view name =
-                        selected_name(snapshot, snapshot.slots[incoming.sourceSlotRow].nameRow);
-                    if (!name.empty()
-                        && std::find(names.begin(), names.end(), name) == names.end()) {
-                        names.push_back(name);
-                    }
-                }
-            }
-        }
-        if (names.empty() && owner.slotRow < snapshot.slots.size()) {
-            const std::string_view name =
-                selected_name(snapshot, snapshot.slots[owner.slotRow].nameRow);
-            if (!name.empty()) {
-                names.push_back(name);
-            }
-        }
-        if (names.empty()) {
-            const catalog::Object& object = snapshot.objects[owner.objectRow];
-            constexpr std::size_t kFallbackCount = 3;
-            const std::array<std::uint32_t, kFallbackCount> fallbackRows{
-                table.configNameRow, object.objectNameRow, object.registryNameRow};
-            for (const std::uint32_t row : fallbackRows) {
-                const std::string_view name = selected_tag_name(snapshot, row);
-                if (!name.empty() && std::find(names.begin(), names.end(), name) == names.end()) {
-                    names.push_back(name);
-                }
-            }
-        }
-        for (std::size_t offset = 0; offset < count; ++offset) {
-            const catalog::TriggerVolumeInstance& instance =
-                snapshot.triggerVolumeInstances[first + offset];
-            if (!instance.complete) {
-                continue;
-            }
-            std::vector<std::string_view> instanceNames = names;
-            if (instanceNames.empty()) {
-                const std::array<std::uint32_t, 2> fallbackRows{instance.classDefinitionNameRow,
-                                                                instance.shapeResourceNameRow};
-                for (const std::uint32_t row : fallbackRows) {
-                    const std::string_view name = selected_tag_name(snapshot, row);
-                    if (!name.empty()
-                        && std::find(instanceNames.begin(), instanceNames.end(), name)
-                               == instanceNames.end()) {
-                        instanceNames.push_back(name);
-                    }
-                }
-            }
-            ++emitted;
-            source.append("    { names = {");
-            for (const std::string_view name : instanceNames) {
-                append_lua_string(source, name);
-                source.append(", ");
-            }
-            append_format(source,
-                          "}, registry_key = 0x%08X, slot_type = %u, slot_index = %u, "
-                          "position = { x = %.9g, y = %.9g, z = %.9g }, "
-                          "minimum = { x = %.9g, y = %.9g, z = %.9g }, "
-                          "maximum = { x = %.9g, y = %.9g, z = %.9g }, "
-                          "shape_tag = 0x%08X, shape_index = %u, active = %u },\n",
-                          table.registryKey,
-                          static_cast<unsigned>(table.slotType),
-                          static_cast<unsigned>(table.slotIndex),
-                          static_cast<double>(instance.position[0]),
-                          static_cast<double>(instance.position[1]),
-                          static_cast<double>(instance.position[2]),
-                          static_cast<double>(instance.minimum[0]),
-                          static_cast<double>(instance.minimum[1]),
-                          static_cast<double>(instance.minimum[2]),
-                          static_cast<double>(instance.maximum[0]),
-                          static_cast<double>(instance.maximum[1]),
-                          static_cast<double>(instance.maximum[2]),
-                          instance.shapeResourceTag,
-                          instance.shapeIndex,
-                          static_cast<unsigned>(instance.active));
-            for (const std::string_view name : instanceNames) {
-                std::string key = lua_constant(name);
-                const std::string base = key;
-                std::uint32_t suffix = 1;
-                while (std::any_of(constants.begin(), constants.end(), [&key](const auto& entry) {
-                    return entry.first == key;
-                })) {
-                    key = base;
-                    append_format(key, "_%u", suffix++);
-                }
-                constants.emplace_back(std::move(key), emitted);
-            }
-        }
-    }
-    source.append("}\nmission.TriggerVolume = {\n");
-    for (const auto& [key, index] : constants) {
-        source.append("    ");
-        source.append(key);
-        append_format(source, " = mission.trigger_volumes[%u],\n", index);
-    }
-    source.append("}\n");
-    result.source = std::move(source);
-    return true;
-}
 
 using worker::binding_ready;
 using worker::build_inventory;
+using worker::build_scenarios;
+using worker::build_world_source;
 using worker::convert_binding_completeness;
 using worker::find_record;
+using worker::kDetailCapacity;
 using worker::load_full_record;
 using worker::load_record;
 using worker::publish_estate;
+using worker::publish_progress;
 using worker::record_name_matches;
 using worker::Scenario;
+using worker::ScenarioBuildResult;
 using worker::shard_path;
 using worker::Work;
 
@@ -325,22 +112,6 @@ void* g_offlineProgressContext{};
     return g_cancel.load(std::memory_order_relaxed);
 }
 
-/** Sends one worker event to the selected live or offline observer. */
-void publish_progress(state::activity_sdk::generation::Status status,
-                      std::uint32_t current,
-                      std::uint32_t total,
-                      std::uint32_t scenarioTag,
-                      std::string_view detail,
-                      bool determinate = true) noexcept {
-    if (g_offlineProgress != nullptr) {
-        g_offlineProgress(g_offlineProgressContext, {status, current, total, scenarioTag, detail});
-        return;
-    }
-    core::ui::busy::set_progress(
-        core::ui::busy::Task::sdkGeneration, current, total, detail, determinate);
-    state::activity_sdk::generation::internal::publish(status, current, total, scenarioTag, detail);
-}
-
 /** Emits one timing row for the two user-visible generation stages. */
 void log_stage_duration(std::string_view stage,
                         ULONGLONG started,
@@ -442,363 +213,6 @@ void log_stage_duration(std::string_view stage,
     return cancelled();
 }
 
-/** Counts the rows behind a refused shard, which the coverage verdict alone cannot name. */
-void report_shard_diagnostics(const Scenario& scenario,
-                              const catalog::Snapshot& snapshot) noexcept {
-    std::size_t incompleteObjects = 0;
-    std::size_t incompleteSafety = 0;
-    std::size_t leaves = 0;
-    std::size_t bareTargets = 0;
-    for (const catalog::Object& object : snapshot.objects) {
-        if (!object.complete) {
-            ++incompleteObjects;
-        }
-        if (object.safety == catalog::GroupSafety::incomplete) {
-            ++incompleteSafety;
-        }
-        leaves += object.placedLeafCount;
-        bareTargets += object.bareTargetCount;
-    }
-    std::size_t unresolvedStates = 0;
-    for (const catalog::State& state : snapshot.states) {
-        if (!state.resolved) {
-            ++unresolvedStates;
-        }
-    }
-    const catalog::ContainerPlacementDiagnostics& placements =
-        snapshot.containerPlacementDiagnostics;
-    std::array<char, 1024> line{};
-    const int written =
-        std::snprintf(line.data(),
-                      line.size(),
-                      "ev=sdk_shard_diag scenario=0x%08X objects=%zu "
-                      "obj_incomplete=%zu obj_safety_incomplete=%zu leaves=%zu "
-                      "bare=%zu states_unresolved=%zu "
-                      "unresolved_reads=%llu spatial_ctx=%u spatial_na=%u spatial_complete=%u "
-                      "spatial_unresolved=%llu spatial_dropped=%llu spatial_semantic=%llu "
-                      "spatial_tables=%zu spatial_owners=%zu spatial_instances=%zu "
-                      "cp_ctx=%u cp_na=%u cp_complete=%u cp_owner_complete=%u "
-                      "cp_unresolved=%llu cp_semantic=%llu cp_dropped=%llu/%llu/%llu/%llu/%llu "
-                      "cp_rows=%zu/%zu/%zu/%zu/%zu",
-                      static_cast<unsigned>(scenario.tag),
-                      snapshot.objects.size(),
-                      incompleteObjects,
-                      incompleteSafety,
-                      leaves,
-                      bareTargets,
-                      unresolvedStates,
-                      static_cast<unsigned long long>(snapshot.unresolvedReads),
-                      snapshot.staticSpatialContextResolved ? 1U : 0U,
-                      snapshot.staticSpatialNotApplicable ? 1U : 0U,
-                      snapshot.staticSpatialComplete ? 1U : 0U,
-                      static_cast<unsigned long long>(snapshot.staticSpatialUnresolvedReads),
-                      static_cast<unsigned long long>(snapshot.staticSpatialDropped),
-                      static_cast<unsigned long long>(snapshot.staticSpatialSemanticUnresolved),
-                      snapshot.staticSpatialTables.size(),
-                      snapshot.staticSpatialOwners.size(),
-                      snapshot.staticSpatialInstances.size(),
-                      placements.contextResolved ? 1U : 0U,
-                      placements.contextNotApplicable ? 1U : 0U,
-                      placements.complete ? 1U : 0U,
-                      placements.identityOwnerInventoryComplete ? 1U : 0U,
-                      static_cast<unsigned long long>(placements.unresolvedReads),
-                      static_cast<unsigned long long>(placements.semanticUnresolved),
-                      static_cast<unsigned long long>(placements.droppedLists),
-                      static_cast<unsigned long long>(placements.droppedOwners),
-                      static_cast<unsigned long long>(placements.droppedPlacements),
-                      static_cast<unsigned long long>(placements.droppedConfigs),
-                      static_cast<unsigned long long>(placements.droppedComponents),
-                      snapshot.containerPlacementLists.size(),
-                      snapshot.containerPlacementOwners.size(),
-                      snapshot.containerPlacements.size(),
-                      snapshot.containerPlacementConfigs.size(),
-                      snapshot.containerPlacementComponents.size());
-    if (written > 0) {
-        core::log::write(
-            core::log::Channel::client,
-            core::log::Level::error,
-            {line.data(), (std::min)(static_cast<std::size_t>(written), line.size() - 1)});
-    }
-}
-
-/** Writes one new shard under its deterministic digest name. */
-[[nodiscard]] bool build_record(const Work& work,
-                                const package_reader::Source& source,
-                                const builder::ContainerIndex& containers,
-                                package_reader::Scratch& scratch,
-                                builder::ScenarioAnalysisCache& analyses,
-                                const Scenario& scenario,
-                                manifest::Record& record,
-                                std::shared_ptr<const catalog::Snapshot>& output,
-                                std::array<char, kDetailCapacity>& detailScratch,
-                                const char*& detail) {
-    // Every arm below reported the same word, so a failure named the step it reached and nothing
-    // about which of five things went wrong. Each one now says which.
-    const std::string_view name(scenario.name.data(), scenario.nameLength);
-    const auto snapshot = builder::build_scenario_catalog(
-        source, containers, scratch, analyses, scenario.tag, name, &cancelled);
-    if (cancelled()) {
-        detail = "shard_cancelled";
-        return false;
-    }
-    if (!snapshot) {
-        detail = "shard_catalog_null";
-        return false;
-    }
-    if (snapshot->status != catalog::BuildStatus::ready) {
-        const char* stage = "shard_catalog_not_ready";
-        switch (snapshot->status) {
-        case catalog::BuildStatus::failed:
-            stage = "shard_catalog_failed";
-            break;
-        case catalog::BuildStatus::idle:
-            stage = "shard_catalog_idle";
-            break;
-        case catalog::BuildStatus::queued:
-            stage = "shard_catalog_queued";
-            break;
-        case catalog::BuildStatus::building:
-            stage = "shard_catalog_building";
-            break;
-        default:
-            break;
-        }
-        report_shard_diagnostics(scenario, *snapshot);
-        // The builder already names its own refusal. Carry that text out instead of dropping it.
-        const char* reason = snapshot->detail[0] != '\0' ? snapshot->detail.data() : "no detail";
-        detailScratch = {};
-        (void)std::snprintf(detailScratch.data(), detailScratch.size(), "%s:%s", stage, reason);
-        detail = detailScratch.data();
-        return false;
-    }
-    generated::PreparedShard prepared{};
-    if (!generated::prepare(work.sourceFingerprint, *snapshot, prepared)) {
-        detail = "shard_prepare_failed";
-        return false;
-    }
-    const generated::Digest payload = prepared.payload_sha256();
-    std::wstring finalPath;
-    generated::Digest written{};
-    if (!shard_path(work.scenarioDirectory, scenario.tag, payload, finalPath)) {
-        detail = "shard_path_failed";
-        return false;
-    }
-    if (!generated::publish(finalPath.c_str(), std::move(prepared), written)) {
-        detail = "shard_write_failed";
-        return false;
-    }
-    record = {};
-    record.scenarioTag = scenario.tag;
-    record.scenarioName = scenario.name;
-    record.scenarioNameLength = scenario.nameLength;
-    record.shardPayloadSha256 = payload;
-    output = snapshot;
-    return true;
-}
-
-/** Rewrites one loaded cache hit into an isolated output tree. */
-[[nodiscard]] bool
-materialize_cached_record(const Work& work,
-                          const Scenario& scenario,
-                          const manifest::Record& record,
-                          const std::shared_ptr<const catalog::Snapshot>& cached,
-                          std::shared_ptr<const catalog::Snapshot>& output) noexcept {
-    if (cached == nullptr) {
-        return false;
-    }
-    if (work.cacheScenarioDirectory == work.scenarioDirectory) {
-        output = cached;
-        return true;
-    }
-    std::wstring finalPath;
-    generated::Digest written{};
-    if (!shard_path(work.scenarioDirectory, scenario.tag, record.shardPayloadSha256, finalPath)
-        || !generated::write(finalPath.c_str(), work.sourceFingerprint, *cached, written)) {
-        return false;
-    }
-    output = cached;
-    return true;
-}
-
-/** One indexed result keeps parallel work deterministic when threads finish out of order. */
-struct ScenarioBuildResult final {
-    manifest::Record record{};
-    std::shared_ptr<const catalog::Snapshot> snapshot{};
-    std::array<char, kDetailCapacity> detail{};
-    bool attempted{};
-    bool ready{};
-    bool reused{};
-};
-
-/** Shared immutable inputs and atomic scheduling state for one scenario batch. */
-struct ScenarioBuildBatch final {
-    const Work* work{};
-    const package_reader::Source* source{};
-    const builder::ContainerIndex* containers{};
-    const manifest::Catalog* prior{};
-    std::vector<ScenarioBuildResult>* results{};
-    std::atomic_size_t next{};
-    std::atomic_size_t completed{};
-    SRWLOCK progressLock = SRWLOCK_INIT;
-    std::size_t progressPublished{};
-    std::size_t firstScenario{};
-    std::size_t blockCacheSlots{};
-    std::size_t tableCacheSlots{};
-    bool priorReady{};
-};
-
-/** Caps workers while leaving cores for the game and the server. */
-[[nodiscard]] std::size_t scenario_worker_count(std::size_t scenarios) noexcept {
-    SYSTEM_INFO info{};
-    GetSystemInfo(&info);
-    const std::size_t processors = static_cast<std::size_t>(info.dwNumberOfProcessors);
-    const std::size_t available = processors > 2U ? processors - 2U : 1U;
-    return (std::min)(scenarios, (std::min)(available, kMaximumScenarioWorkers));
-}
-
-/** Divides one cache budget across active workers without leaving a worker uncached. */
-[[nodiscard]] std::size_t worker_cache_slots(std::size_t budget, std::size_t workers) noexcept {
-    return (std::max)(std::size_t{1}, (budget + workers - 1U) / workers);
-}
-
-/** Publishes only increasing completion counts from out-of-order workers. */
-void publish_parallel_progress(ScenarioBuildBatch& batch, const Scenario& scenario) noexcept {
-    const std::size_t complete = batch.completed.fetch_add(1U) + 1U;
-    AcquireSRWLockExclusive(&batch.progressLock);
-    if (complete <= batch.progressPublished) {
-        ReleaseSRWLockExclusive(&batch.progressLock);
-        return;
-    }
-    batch.progressPublished = complete;
-    publish_progress(state::activity_sdk::generation::Status::building,
-                     static_cast<std::uint32_t>(complete),
-                     static_cast<std::uint32_t>(batch.work->scenarios.size()),
-                     scenario.tag,
-                     std::string_view(scenario.name.data(), scenario.nameLength));
-    ReleaseSRWLockExclusive(&batch.progressLock);
-}
-
-/** Builds one worker's contiguous chunks with private package and analysis caches. */
-void run_scenario_worker(ScenarioBuildBatch& batch) noexcept {
-    std::unique_ptr<package_reader::Scratch> scratch(new (std::nothrow) package_reader::Scratch());
-    if (scratch == nullptr) {
-        return;
-    }
-    if (!package_reader::prepare_blocks(*scratch, batch.blockCacheSlots)) {
-        (void)package_reader::prepare_blocks(*scratch, 0);
-    }
-    if (!package_reader::prepare_tables(*scratch, batch.tableCacheSlots)) {
-        (void)package_reader::prepare_tables(*scratch, 0);
-    }
-    builder::ScenarioAnalysisCache analyses{};
-    while (!cancelled()) {
-        const std::size_t begin = batch.next.fetch_add(kScenarioChunkSize);
-        if (begin >= batch.results->size()) {
-            break;
-        }
-        const std::size_t end = (std::min)(begin + kScenarioChunkSize, batch.results->size());
-        for (std::size_t index = begin; index < end && !cancelled(); ++index) {
-            const Scenario& scenario = batch.work->scenarios[batch.firstScenario + index];
-            ScenarioBuildResult& result = (*batch.results)[index];
-            result.attempted = true;
-            try {
-                const manifest::Record* existing =
-                    batch.priorReady ? find_record(*batch.prior, scenario.tag) : nullptr;
-                bool kept = existing != nullptr
-                            && load_full_record(batch.work->cacheScenarioDirectory,
-                                                batch.work->sourceFingerprint,
-                                                scenario,
-                                                *existing,
-                                                result.snapshot);
-                if (kept
-                    && !materialize_cached_record(
-                        *batch.work, scenario, *existing, result.snapshot, result.snapshot)) {
-                    kept = false;
-                }
-                if (kept) {
-                    result.record = *existing;
-                    result.reused = true;
-                    result.ready = true;
-                } else {
-                    const char* detail = "shard_build_failed";
-                    std::array<char, kDetailCapacity> scratchDetail{};
-                    result.ready = build_record(*batch.work,
-                                                *batch.source,
-                                                *batch.containers,
-                                                *scratch,
-                                                analyses,
-                                                scenario,
-                                                result.record,
-                                                result.snapshot,
-                                                scratchDetail,
-                                                detail);
-                    if (!result.ready) {
-                        (void)std::snprintf(
-                            result.detail.data(), result.detail.size(), "%s", detail);
-                    }
-                }
-            } catch (...) {
-                result.ready = false;
-                (void)std::snprintf(result.detail.data(),
-                                    result.detail.size(),
-                                    "%s",
-                                    "unexpected scenario build exception");
-            }
-            publish_parallel_progress(batch, scenario);
-        }
-    }
-    package_reader::close_files(*scratch);
-}
-
-/** Adapts one batch worker to the Windows thread ABI. */
-DWORD WINAPI scenario_thread_main(void* opaque) noexcept {
-    run_scenario_worker(*static_cast<ScenarioBuildBatch*>(opaque));
-    return 0;
-}
-
-/** Builds all scenario snapshots in parallel while retaining scenario-order output. */
-[[nodiscard]] bool build_scenarios(Work& work,
-                                   const package_reader::Source& source,
-                                   const builder::ContainerIndex& containers,
-                                   const manifest::Catalog& prior,
-                                   bool priorReady,
-                                   std::size_t firstScenario,
-                                   std::size_t scenarioCount,
-                                   std::vector<ScenarioBuildResult>& results) {
-    results.clear();
-    results.resize(scenarioCount);
-    ScenarioBuildBatch batch{};
-    batch.work = &work;
-    batch.source = &source;
-    batch.containers = &containers;
-    batch.prior = &prior;
-    batch.results = &results;
-    batch.completed.store(firstScenario);
-    batch.progressPublished = firstScenario;
-    batch.firstScenario = firstScenario;
-    batch.priorReady = priorReady;
-    const std::size_t workers = scenario_worker_count(results.size());
-    if (workers == 0) {
-        return false;
-    }
-    batch.blockCacheSlots = worker_cache_slots(kParallelBlockCacheBudget, workers);
-    batch.tableCacheSlots = worker_cache_slots(kParallelTableCacheBudget, workers);
-    std::vector<HANDLE> threads;
-    threads.reserve(workers - 1U);
-    for (std::size_t index = 1; index < workers; ++index) {
-        const HANDLE thread = CreateThread(nullptr, 0, &scenario_thread_main, &batch, 0, nullptr);
-        if (thread != nullptr) {
-            threads.push_back(thread);
-        }
-    }
-    run_scenario_worker(batch);
-    for (const HANDLE thread : threads) {
-        (void)WaitForSingleObject(thread, INFINITE);
-        (void)CloseHandle(thread);
-    }
-    return !cancelled();
-}
-
 /** Copies one terminal pass result before worker-owned storage is released. */
 void finish_offline(Work& work, bool complete, std::size_t built, std::size_t reused) noexcept {
     if (!work.offline || work.offlineStatus == nullptr || work.offlineResult == nullptr) {
@@ -1019,6 +433,22 @@ bool worker_internal::cancel_requested() noexcept {
     return cancelled();
 }
 
+/** Sends one worker event to the selected live or offline observer. */
+void worker_internal::publish_progress(state::activity_sdk::generation::Status status,
+                                       std::uint32_t current,
+                                       std::uint32_t total,
+                                       std::uint32_t scenarioTag,
+                                       std::string_view detail,
+                                       bool determinate) noexcept {
+    if (g_offlineProgress != nullptr) {
+        g_offlineProgress(g_offlineProgressContext, {status, current, total, scenarioTag, detail});
+        return;
+    }
+    core::ui::busy::set_progress(
+        core::ui::busy::Task::sdkGeneration, current, total, detail, determinate);
+    state::activity_sdk::generation::internal::publish(status, current, total, scenarioTag, detail);
+}
+
 /** Runs one synchronous package pass into an isolated artifact tree. */
 OfflineBuildStatus build_offline(const OfflineBuildRequest& request,
                                  OfflineCancelProbe cancel,

+ 38 - 0
Sunrise/src/client/content/activity/activity_sdk_generation_worker_internal.h

@@ -11,12 +11,14 @@
 #include "../../../middleware/content/packages/reader/reader.h"
 #include "../../../state/activity_sdk/generated_world/catalog_manifest.h"
 #include "../../../state/activity_sdk/generated_world/format.h"
+#include "../../../state/activity_sdk/generation/definition.h"
 #include "../../../state/build_data/scriptables/definition.h"
 #include "activity_sdk_activity_inventory.h"
 #include "activity_sdk_external_placements.h"
 #include "activity_sdk_generation_worker.h"
 #include "activity_sdk_lua_artifacts.h"
 #include "activity_sdk_topology_inventory.h"
+#include "scriptable_catalog_builder.h"
 
 namespace sunrise::client::content::activity::sdk_generation::worker_internal {
 
@@ -24,9 +26,13 @@ namespace generated = state::activity_sdk::generated_world;
 namespace manifest = state::activity_sdk::generated_world::manifest;
 namespace package_reader = middleware::content::packages::reader;
 namespace catalog = state::build_data::scriptables;
+namespace builder = ::sunrise::client::content::activity::scriptables::internal;
 namespace inventory = activity_inventory;
 namespace topology = topology_inventory;
 
+/** Holds one step token plus the builder's own 96-byte refusal text. */
+inline constexpr std::size_t kDetailCapacity = 160;
+
 /** One stable scenario identity copied out of the package inventory. */
 struct Scenario final {
     std::uint32_t tag{};
@@ -64,12 +70,44 @@ struct Work final {
     bool luaDeclarations{};
 };
 
+/** One indexed result keeps parallel work deterministic when threads finish out of order. */
+struct ScenarioBuildResult final {
+    manifest::Record record{};
+    std::shared_ptr<const catalog::Snapshot> snapshot{};
+    std::array<char, kDetailCapacity> detail{};
+    bool attempted{};
+    bool ready{};
+    bool reused{};
+};
+
 /** @return True when the selected live or offline caller asked the pass to stop. */
 [[nodiscard]] bool cancel_requested() noexcept;
 
+/** Sends one worker event to the selected live or offline observer. */
+void publish_progress(state::activity_sdk::generation::Status status,
+                      std::uint32_t current,
+                      std::uint32_t total,
+                      std::uint32_t scenarioTag,
+                      std::string_view detail,
+                      bool determinate = true) noexcept;
+
+/** Builds all scenario snapshots in parallel while retaining scenario-order output. */
+[[nodiscard]] bool build_scenarios(Work& work,
+                                   const package_reader::Source& source,
+                                   const builder::ContainerIndex& containers,
+                                   const manifest::Catalog& prior,
+                                   bool priorReady,
+                                   std::size_t firstScenario,
+                                   std::size_t scenarioCount,
+                                   std::vector<ScenarioBuildResult>& results);
+
 /** Converts and checks the package-backed activity inventory used by every later stage. */
 [[nodiscard]] bool build_inventory(Work& work, const package_reader::Source& source) noexcept;
 
+/** Emits transparent positional trigger declarations for one mission module. */
+[[nodiscard]] bool build_world_source(const catalog::Snapshot& snapshot,
+                                      lua_artifacts::ScenarioWorldSource& result);
+
 /** @return True when the native binding partition is safe to publish. */
 [[nodiscard]] bool binding_ready(const inventory::BindingCompleteness& completeness,
                                  std::size_t activityCount) noexcept;

+ 233 - 0
Sunrise/src/client/content/activity/activity_sdk_generation_world_source.cpp

@@ -0,0 +1,233 @@
+#include <algorithm>
+#include <array>
+#include <cctype>
+#include <cstddef>
+#include <cstdint>
+#include <cstdio>
+#include <string>
+#include <string_view>
+#include <utility>
+#include <vector>
+
+#include "activity_sdk_generation_worker_internal.h"
+
+namespace sunrise::client::content::activity::sdk_generation::worker_internal {
+namespace {
+
+/** @return One selected hash name, or an empty view when the name is unresolved. */
+[[nodiscard]] std::string_view selected_name(const catalog::Snapshot& snapshot,
+                                             std::uint32_t row) noexcept {
+    if (row >= snapshot.names.size()) {
+        return {};
+    }
+    const catalog::Name& name = snapshot.names[row];
+    if (name.selectedCandidate >= snapshot.nameCandidates.size()) {
+        return {};
+    }
+    const catalog::NameCandidate& candidate = snapshot.nameCandidates[name.selectedCandidate];
+    return {candidate.value.data(), candidate.length};
+}
+
+/** @return One selected package tag name, or an empty view when it is unresolved. */
+[[nodiscard]] std::string_view selected_tag_name(const catalog::Snapshot& snapshot,
+                                                 std::uint32_t row) noexcept {
+    if (row >= snapshot.tagNames.size()) {
+        return {};
+    }
+    const catalog::TagName& name = snapshot.tagNames[row];
+    if (name.selectedCandidate >= snapshot.nameCandidates.size()) {
+        return {};
+    }
+    const catalog::NameCandidate& candidate = snapshot.nameCandidates[name.selectedCandidate];
+    return {candidate.value.data(), candidate.length};
+}
+
+/** Appends a deterministic Lua string literal. */
+void append_lua_string(std::string& output, std::string_view value) {
+    output.push_back('"');
+    // Escaped bytes are emitted as uppercase hex.
+    constexpr char digits[] = "0123456789ABCDEF";
+    for (const unsigned char byte : value) {
+        if (byte == '\\' || byte == '"') {
+            output.push_back('\\');
+            output.push_back(static_cast<char>(byte));
+        } else if (byte == '\n') {
+            output.append("\\n");
+        } else if (byte < 0x20U || byte == 0x7FU) {
+            output.append("\\x");
+            output.push_back(digits[byte >> 4U]);
+            output.push_back(digits[byte & 0xFU]);
+        } else {
+            output.push_back(static_cast<char>(byte));
+        }
+    }
+    output.push_back('"');
+}
+
+void append_format(std::string& output, const char* format, auto... values) {
+    std::array<char, 512> buffer{};
+    const int length = std::snprintf(buffer.data(), buffer.size(), format, values...);
+    if (length > 0) {
+        output.append(buffer.data(),
+                      (std::min)(static_cast<std::size_t>(length), buffer.size() - 1));
+    }
+}
+
+/** Converts an extracted trigger name into a declaration key. */
+[[nodiscard]] std::string lua_constant(std::string_view value) {
+    std::string output;
+    bool separator = false;
+    for (const unsigned char byte : value) {
+        if (std::isalnum(byte) != 0) {
+            if (separator && !output.empty()) {
+                output.push_back('_');
+            }
+            output.push_back(static_cast<char>(std::toupper(byte)));
+            separator = false;
+        } else {
+            separator = true;
+        }
+    }
+    if (output.empty()) {
+        output = "UNNAMED";
+    }
+    if (std::isdigit(static_cast<unsigned char>(output.front())) != 0) {
+        output.insert(output.begin(), '_');
+    }
+    return output;
+}
+
+} // namespace
+
+/** Emits transparent positional trigger declarations for one mission module. */
+bool build_world_source(const catalog::Snapshot& snapshot,
+                        lua_artifacts::ScenarioWorldSource& result) {
+    result = {};
+    result.scenarioTag = snapshot.scenarioTag;
+    std::string source = "\n---@type SunriseTriggerVolume[]\n"
+                         "mission.trigger_volumes = {\n";
+    std::vector<std::pair<std::string, std::uint32_t>> constants;
+    std::vector<std::string_view> names;
+    std::uint32_t emitted = 0;
+    for (const catalog::TriggerVolumeOwner& owner : snapshot.triggerVolumeOwners) {
+        if (owner.tableRow >= snapshot.triggerVolumeTables.size()
+            || owner.objectRow >= snapshot.objects.size()) {
+            continue;
+        }
+        const catalog::TriggerVolumeTable& table = snapshot.triggerVolumeTables[owner.tableRow];
+        const std::size_t first = table.firstInstance;
+        const std::size_t count = table.instanceCount;
+        if (first > snapshot.triggerVolumeInstances.size()
+            || count > snapshot.triggerVolumeInstances.size() - first) {
+            continue;
+        }
+        names.clear();
+        const std::size_t incomingFirst = owner.firstIncomingReference;
+        const std::size_t incomingCount = owner.incomingReferenceCount;
+        if (incomingFirst <= snapshot.triggerVolumeIncomingReferences.size()
+            && incomingCount <= snapshot.triggerVolumeIncomingReferences.size() - incomingFirst) {
+            for (std::size_t offset = 0; offset < incomingCount; ++offset) {
+                const auto& incoming =
+                    snapshot.triggerVolumeIncomingReferences[incomingFirst + offset];
+                if (incoming.sourceSlotRow < snapshot.slots.size()) {
+                    const std::string_view name =
+                        selected_name(snapshot, snapshot.slots[incoming.sourceSlotRow].nameRow);
+                    if (!name.empty()
+                        && std::find(names.begin(), names.end(), name) == names.end()) {
+                        names.push_back(name);
+                    }
+                }
+            }
+        }
+        if (names.empty() && owner.slotRow < snapshot.slots.size()) {
+            const std::string_view name =
+                selected_name(snapshot, snapshot.slots[owner.slotRow].nameRow);
+            if (!name.empty()) {
+                names.push_back(name);
+            }
+        }
+        if (names.empty()) {
+            const catalog::Object& object = snapshot.objects[owner.objectRow];
+            // Three fallback name rows are tried in order.
+            constexpr std::size_t kFallbackCount = 3;
+            const std::array<std::uint32_t, kFallbackCount> fallbackRows{
+                table.configNameRow, object.objectNameRow, object.registryNameRow};
+            for (const std::uint32_t row : fallbackRows) {
+                const std::string_view name = selected_tag_name(snapshot, row);
+                if (!name.empty() && std::find(names.begin(), names.end(), name) == names.end()) {
+                    names.push_back(name);
+                }
+            }
+        }
+        for (std::size_t offset = 0; offset < count; ++offset) {
+            const catalog::TriggerVolumeInstance& instance =
+                snapshot.triggerVolumeInstances[first + offset];
+            if (!instance.complete) {
+                continue;
+            }
+            std::vector<std::string_view> instanceNames = names;
+            if (instanceNames.empty()) {
+                const std::array<std::uint32_t, 2> fallbackRows{instance.classDefinitionNameRow,
+                                                                instance.shapeResourceNameRow};
+                for (const std::uint32_t row : fallbackRows) {
+                    const std::string_view name = selected_tag_name(snapshot, row);
+                    if (!name.empty()
+                        && std::find(instanceNames.begin(), instanceNames.end(), name)
+                               == instanceNames.end()) {
+                        instanceNames.push_back(name);
+                    }
+                }
+            }
+            ++emitted;
+            source.append("    { names = {");
+            for (const std::string_view name : instanceNames) {
+                append_lua_string(source, name);
+                source.append(", ");
+            }
+            append_format(source,
+                          "}, registry_key = 0x%08X, slot_type = %u, slot_index = %u, "
+                          "position = { x = %.9g, y = %.9g, z = %.9g }, "
+                          "minimum = { x = %.9g, y = %.9g, z = %.9g }, "
+                          "maximum = { x = %.9g, y = %.9g, z = %.9g }, "
+                          "shape_tag = 0x%08X, shape_index = %u, active = %u },\n",
+                          table.registryKey,
+                          static_cast<unsigned>(table.slotType),
+                          static_cast<unsigned>(table.slotIndex),
+                          static_cast<double>(instance.position[0]),
+                          static_cast<double>(instance.position[1]),
+                          static_cast<double>(instance.position[2]),
+                          static_cast<double>(instance.minimum[0]),
+                          static_cast<double>(instance.minimum[1]),
+                          static_cast<double>(instance.minimum[2]),
+                          static_cast<double>(instance.maximum[0]),
+                          static_cast<double>(instance.maximum[1]),
+                          static_cast<double>(instance.maximum[2]),
+                          instance.shapeResourceTag,
+                          instance.shapeIndex,
+                          static_cast<unsigned>(instance.active));
+            for (const std::string_view name : instanceNames) {
+                std::string key = lua_constant(name);
+                const std::string base = key;
+                std::uint32_t suffix = 1;
+                while (std::any_of(constants.begin(), constants.end(), [&key](const auto& entry) {
+                    return entry.first == key;
+                })) {
+                    key = base;
+                    append_format(key, "_%u", suffix++);
+                }
+                constants.emplace_back(std::move(key), emitted);
+            }
+        }
+    }
+    source.append("}\nmission.TriggerVolume = {\n");
+    for (const auto& [key, index] : constants) {
+        source.append("    ");
+        source.append(key);
+        append_format(source, " = mission.trigger_volumes[%u],\n", index);
+    }
+    source.append("}\n");
+    result.source = std::move(source);
+    return true;
+}
+
+} // namespace sunrise::client::content::activity::sdk_generation::worker_internal

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

@@ -7,6 +7,7 @@ namespace {
 
 using enum Family;
 
+// Short domain aliases keep the descriptor rows below the column limit.
 constexpr Domain kSourceIdentity = Domain::sourceIdentity;
 constexpr Domain kActivityGraph = Domain::activityGraph;
 constexpr Domain kWorldGraph = Domain::worldGraph;
@@ -16,6 +17,7 @@ constexpr Domain kBehaviorGraph = Domain::behaviorGraph;
 constexpr Domain kApiPolicy = Domain::apiPolicy;
 constexpr Domain kValidation = Domain::validation;
 
+// One row per family: the family, its stable report name, and its domain.
 constexpr std::array<Descriptor, kFamilyCount> kDescriptors{{
     {sources, "sources", kSourceIdentity},
     {buildProfile, "build_profile", kSourceIdentity},
@@ -279,6 +281,7 @@ std::string_view stable_name(Projection projection) noexcept {
     return {};
 }
 
+/** @return The stable report name of one coverage status. */
 std::string_view stable_name(Status status) noexcept {
     switch (status) {
     case Status::unresolved:

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

@@ -68,6 +68,7 @@ enum class Family : std::uint8_t {
     count,
 };
 
+// A coverage report carries one row per family.
 inline constexpr std::size_t kFamilyCount = static_cast<std::size_t>(Family::count);
 static_assert(kFamilyCount == 55);
 

+ 27 - 14
Sunrise/src/client/content/activity/activity_sdk_live_publication.cpp

@@ -14,11 +14,14 @@
 namespace sunrise::client::content::activity::sdk_generation::live_publication {
 namespace {
 
+/** Names of the staging, backup and marker paths; recovery scans for exactly these. */
 constexpr std::wstring_view kStageSuffix = L"\\.activity-sdk-stage.%08lX.%08lX.%08lX";
 constexpr std::wstring_view kBackupSuffix = L".activity-sdk-backup";
 constexpr std::wstring_view kPendingMarkerSuffix = L"\\.activity-sdk-publication.pending";
 constexpr std::wstring_view kCommittedMarkerSuffix = L"\\.activity-sdk-publication.committed";
+/** Distinct stage directories tried before giving up on a name collision. */
 constexpr std::size_t kAllocationAttempts = 16;
+/** Marker file magic, bytes "ASP1"; a file without it is not ours. */
 constexpr std::uint32_t kMarkerMagic = 0x31505341U;
 
 volatile LONG g_sequence{};
@@ -69,34 +72,45 @@ struct Marker final {
     }
 }
 
-/** Names the first drive-path component which is not an ordinary directory. */
-[[nodiscard]] const char* ordinary_ancestry_reason(const std::wstring& directory) noexcept {
-    if (directory.size() < 3U || directory[1] != L':' || directory[2] != L'\\') {
-        return "path_shape";
+/** Which drive-path component, if any, is not an ordinary directory. */
+enum class Ancestry : std::uint8_t {
+    ready,
+    pathShape,
+    driveRoot,
+    ancestor,
+};
+
+/** A drive path is at least a letter, a colon and a separator. */
+constexpr std::size_t kDrivePrefixLength = 3;
+
+/** Reports the first drive-path component which is not an ordinary directory. */
+[[nodiscard]] Ancestry ordinary_ancestry(const std::wstring& directory) noexcept {
+    if (directory.size() < kDrivePrefixLength || directory[1] != L':' || directory[2] != L'\\') {
+        return Ancestry::pathShape;
     }
-    const bool rootOrdinary = ordinary_directory(directory.substr(0, 3U).c_str());
-    std::size_t cursor = 3U;
+    const bool rootOrdinary = ordinary_directory(directory.substr(0, kDrivePrefixLength).c_str());
+    std::size_t cursor = kDrivePrefixLength;
     while (cursor < directory.size()) {
         const std::size_t separator = directory.find(L'\\', cursor);
         const std::size_t end = separator == std::wstring::npos ? directory.size() : separator;
         const std::wstring prefix = directory.substr(0, end);
         if (!ordinary_directory(prefix.c_str())) {
-            return "ancestor";
+            return Ancestry::ancestor;
         }
         if (separator == std::wstring::npos) {
             break;
         }
         cursor = separator + 1U;
     }
-    return rootOrdinary ? "ready" : "drive_root";
+    return rootOrdinary ? Ancestry::ready : Ancestry::driveRoot;
 }
 
-/** Wine maps its synthetic drive root as a reparse point; real path components remain checked. */
-[[nodiscard]] bool ordinary_ancestry_allowed(const char* reason) noexcept {
-    if (std::string_view(reason) == "ready") {
+/** Wine maps its synthetic drive root as a reparse point; real path components stay checked. */
+[[nodiscard]] bool ordinary_ancestry_allowed(Ancestry ancestry) noexcept {
+    if (ancestry == Ancestry::ready) {
         return true;
     }
-    if (std::string_view(reason) != "drive_root") {
+    if (ancestry != Ancestry::driveRoot) {
         return false;
     }
     const HMODULE ntdll = GetModuleHandleW(L"ntdll.dll");
@@ -105,8 +119,7 @@ struct Marker final {
 
 /** Resolves one existing ordinary directory and rejects reparse points in every ancestor. */
 [[nodiscard]] bool canonical_directory(const wchar_t* input, std::wstring& output) noexcept {
-    return full_path(input, output)
-           && ordinary_ancestry_allowed(ordinary_ancestry_reason(output));
+    return full_path(input, output) && ordinary_ancestry_allowed(ordinary_ancestry(output));
 }
 
 /** Compares two complete Windows path components without locale-sensitive folding. */

+ 27 - 764
Sunrise/src/client/content/activity/activity_sdk_lua_activities.cpp

@@ -3,27 +3,21 @@
 #include <algorithm>
 #include <array>
 #include <atomic>
-#include <bit>
 #include <cctype>
 #include <cstdio>
-#include <limits>
 #include <string>
 #include <string_view>
-#include <unordered_map>
 #include <unordered_set>
 #include <vector>
 
-#include "../../../middleware/bap/activity_message/auth_schema_catalog.h"
-#include "activity_sdk_lua_artifacts_internal.h"
+#include "activity_sdk_lua_missions_internal.h"
 
 namespace sunrise::client::content::activity::sdk_generation::lua_artifacts::internal {
-namespace {
-
-namespace auth_catalog = middleware::bap::activity_message::auth_schema_catalog;
 
 /** Appends one Lua string literal without depending on a runtime serializer. */
 void append_string(std::string& output, std::string_view value) {
     output.push_back('"');
+    // Escaped bytes are emitted as uppercase hex.
     constexpr char digits[] = "0123456789ABCDEF";
     for (const unsigned char byte : value) {
         switch (byte) {
@@ -57,7 +51,7 @@ void append_string(std::string& output, std::string_view value) {
 }
 
 /** Converts arbitrary extracted names to stable Lua identifiers or safe file stems. */
-[[nodiscard]] std::string identifier(std::string_view value, bool upper) {
+std::string identifier(std::string_view value, bool upper) {
     std::string output;
     output.reserve(value.size() + 1U);
     bool separator = false;
@@ -96,52 +90,37 @@ void append_uint(std::string& output, std::uint32_t value) {
     output.append(buffer.data(), static_cast<std::size_t>(length));
 }
 
-void append_int(std::string& output, std::int32_t value) {
-    std::array<char, 16> buffer{};
-    const int length = std::snprintf(buffer.data(), buffer.size(), "%d", value);
-    output.append(buffer.data(), static_cast<std::size_t>(length));
+std::string stem(std::string_view name, std::uint32_t identity) {
+    std::string output = identifier(name, false);
+    std::array<char, 16> suffix{};
+    const int length = std::snprintf(suffix.data(), suffix.size(), "_%08x", identity);
+    output.append(suffix.data(), static_cast<std::size_t>(length));
+    return output;
 }
 
-void append_float(std::string& output, std::uint32_t bits) {
-    std::array<char, 32> buffer{};
-    const int length = std::snprintf(
-        buffer.data(), buffer.size(), "%.9g", static_cast<double>(std::bit_cast<float>(bits)));
-    output.append(buffer.data(), static_cast<std::size_t>(length));
+bool range_inside(format::Range range, std::size_t size) noexcept {
+    return range.first <= size && range.count <= size - range.first;
 }
 
-/** Builds the exact occurrence-bound identity consumed by the mission scene API. */
-[[nodiscard]] bool scene_symbol_id(const Source& source,
-                                   const format::Occurrence& occurrence,
-                                   const format::Slot& slot,
-                                   std::string& output) {
-    constexpr std::string_view kOccurrencePrefix = "object-occurrence/";
-    const std::string_view occurrenceId = text(source, occurrence.id);
-    const std::string_view suffix = occurrenceId.starts_with(kOccurrencePrefix)
-                                        ? occurrenceId.substr(kOccurrencePrefix.size())
-                                        : occurrenceId;
-    if (suffix.empty() || slot.slotIndex > (std::numeric_limits<std::uint16_t>::max)()
-        || slot.slotType > (std::numeric_limits<std::uint16_t>::max)()) {
-        return false;
-    }
-    std::array<char, 32> tail{};
-    const int length =
-        std::snprintf(tail.data(), tail.size(), "/%04x/%04x", slot.slotIndex, slot.slotType);
-    if (length <= 0 || static_cast<std::size_t>(length) >= tail.size()) {
-        return false;
+/** Keeps the extracted name unchanged unless another row already owns that Lua key. */
+std::string append_unique_key(std::string& output,
+                              std::unordered_set<std::string>& used,
+                              std::string_view name,
+                              std::uint32_t fallback) {
+    std::string key = identifier(name, true);
+    if (!used.insert(key).second) {
+        std::array<char, 16> suffix{};
+        const int length = std::snprintf(suffix.data(), suffix.size(), "_%08X", fallback);
+        key.append(suffix.data(), static_cast<std::size_t>(length));
+        (void)used.insert(key);
     }
-    output.assign("symbol/");
-    output.append(suffix);
-    output.append(tail.data(), static_cast<std::size_t>(length));
-    return true;
+    output.append("    ");
+    output.append(key);
+    output.append(" = ");
+    return key;
 }
 
-[[nodiscard]] std::string stem(std::string_view name, std::uint32_t identity) {
-    std::string output = identifier(name, false);
-    std::array<char, 16> suffix{};
-    const int length = std::snprintf(suffix.data(), suffix.size(), "_%08x", identity);
-    output.append(suffix.data(), static_cast<std::size_t>(length));
-    return output;
-}
+namespace {
 
 [[nodiscard]] std::string_view activity_name(const Source& source,
                                              const format::Activity& activity) noexcept {
@@ -149,20 +128,6 @@ void append_float(std::string& output, std::uint32_t bits) {
     return internal.empty() ? text(source, activity.displayName) : internal;
 }
 
-[[nodiscard]] bool range_inside(format::Range range, std::size_t size) noexcept {
-    return range.first <= size && range.count <= size - range.first;
-}
-
-/** Direct owner indexes replace full global scans in every mission renderer. */
-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{};
-    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::uint32_t> worldsByScenarioTag{};
-};
-
 /** Builds immutable lookup rows once for the whole Lua pass. */
 [[nodiscard]] bool build_render_index(const Source& source, RenderIndex& output) {
     output = {};
@@ -199,708 +164,6 @@ struct RenderIndex final {
     return true;
 }
 
-/** Collects each global slot row used by one scenario exactly once. */
-[[nodiscard]] bool scenario_slots(const Source& source,
-                                  const format::Scenario& scenario,
-                                  std::vector<std::uint32_t>& output) {
-    output.clear();
-    if (!range_inside(scenario.occurrences, source.occurrences.size())) {
-        return false;
-    }
-    for (std::size_t offset = 0; offset < scenario.occurrences.count; ++offset) {
-        const format::Occurrence& occurrence =
-            source.occurrences[scenario.occurrences.first + offset];
-        if (occurrence.objectIndex >= source.objects.size()) {
-            return false;
-        }
-        const format::Object& object = source.objects[occurrence.objectIndex];
-        if (!range_inside(object.slots, source.slots.size())) {
-            return false;
-        }
-        for (std::size_t slotOffset = 0; slotOffset < object.slots.count; ++slotOffset) {
-            output.push_back(object.slots.first + static_cast<std::uint32_t>(slotOffset));
-        }
-    }
-    std::sort(output.begin(), output.end());
-    output.erase(std::unique(output.begin(), output.end()), output.end());
-    return true;
-}
-
-/** Keeps the extracted name unchanged unless another row already owns that Lua key. */
-[[nodiscard]] std::string append_unique_key(std::string& output,
-                                            std::unordered_set<std::string>& used,
-                                            std::string_view name,
-                                            std::uint32_t fallback) {
-    std::string key = identifier(name, true);
-    if (!used.insert(key).second) {
-        std::array<char, 16> suffix{};
-        const int length = std::snprintf(suffix.data(), suffix.size(), "_%08X", fallback);
-        key.append(suffix.data(), static_cast<std::size_t>(length));
-        (void)used.insert(key);
-    }
-    output.append("    ");
-    output.append(key);
-    output.append(" = ");
-    return key;
-}
-
-/** Folds slot names into keys. A repeated name takes its object tag, then its slot index. */
-[[nodiscard]] std::vector<std::string> unique_slot_keys(const Source& source,
-                                                        const std::vector<std::uint32_t>& rows,
-                                                        const std::vector<std::string>& names) {
-    std::vector<std::string> keys(rows.size());
-    std::unordered_map<std::string, std::size_t> counts;
-    for (std::size_t index = 0; index < rows.size(); ++index) {
-        keys[index] = identifier(names[index], true);
-        ++counts[keys[index]];
-    }
-    // Every member of a repeated group is suffixed, so a key never depends on row order.
-    std::unordered_map<std::string, std::size_t> taggedCounts;
-    for (std::size_t index = 0; index < rows.size(); ++index) {
-        if (counts[keys[index]] > 1) {
-            const format::Slot& slot = source.slots[rows[index]];
-            const std::uint32_t tag = slot.objectIndex < source.objects.size()
-                                          ? source.objects[slot.objectIndex].objectTag
-                                          : 0U;
-            std::array<char, 16> suffix{};
-            const int length = std::snprintf(suffix.data(), suffix.size(), "_%08X", tag);
-            keys[index].append(suffix.data(), static_cast<std::size_t>(length));
-        }
-        ++taggedCounts[keys[index]];
-    }
-    for (std::size_t index = 0; index < rows.size(); ++index) {
-        if (taggedCounts[keys[index]] > 1) {
-            std::array<char, 16> suffix{};
-            const int length =
-                std::snprintf(suffix.data(),
-                              suffix.size(),
-                              "_%04X",
-                              static_cast<unsigned>(source.slots[rows[index]].slotIndex));
-            keys[index].append(suffix.data(), static_cast<std::size_t>(length));
-        }
-    }
-    return keys;
-}
-
-/** Emits one concrete mission module with no pack or resolver access. */
-[[nodiscard]] bool render_mission(const Source& source,
-                                  const RenderIndex& index,
-                                  std::uint32_t scenarioIndex,
-                                  const format::Scenario& scenario,
-                                  SourceModule& module) {
-    const std::string_view name = text(source, scenario.name);
-    module.stem = stem(name, scenario.tag);
-    std::string output = "-- Generated from installed mission data. Do not edit.\n"
-                         "local sdk = require(\"sunrise.activity_sdk\")\n\n"
-                         "---@type SunriseMission\n"
-                         "local mission = {\n    name = ";
-    append_string(output, name);
-    output.append(",\n    id = ");
-    append_string(output, text(source, scenario.id));
-    output.append(",\n    tag = ");
-    append_hex(output, scenario.tag);
-    output.append(",\n    states = {\n");
-    std::unordered_set<std::string> stateKeys;
-    std::string stateConstants = "mission.State = {\n";
-    if (!range_inside(scenario.states, source.states.size())) {
-        return false;
-    }
-    for (std::size_t offset = 0; offset < scenario.states.count; ++offset) {
-        const format::State& state = source.states[scenario.states.first + offset];
-        const std::string_view id = text(source, state.id);
-        const std::string key = append_unique_key(output, stateKeys, id, state.stateHash);
-        output.append("{ id = ");
-        append_string(output, id);
-        output.append(", ordinal = ");
-        append_uint(output, state.stateOrdinal);
-        output.append(", slice_set_index = ");
-        append_uint(output, state.sliceSetIndex);
-        output.append(", region_index = ");
-        append_uint(output, state.sliceSetIndex + state.stateOrdinal);
-        output.append(", hash = ");
-        append_hex(output, state.stateHash);
-        output.append(", value = ");
-        append_uint(output, state.publicValue);
-        output.append(" },\n");
-        stateConstants.append("    ");
-        stateConstants.append(key);
-        stateConstants.append(" = ");
-        append_uint(stateConstants, state.publicValue);
-        stateConstants.append(",\n");
-    }
-    output.append("    },\n    slots = {\n");
-    std::unordered_map<std::uint32_t, std::string> slotKeyByRow;
-    std::string slotConstants = "mission.Slot = {\n";
-    std::array<std::string, auth_catalog::kTypes.size()> authConstants{};
-    std::vector<std::uint32_t> slots;
-    if (!scenario_slots(source, scenario, slots)) {
-        return false;
-    }
-    std::vector<std::string> slotNames;
-    slotNames.reserve(slots.size());
-    for (const std::uint32_t row : slots) {
-        const format::Slot& slot = source.slots[row];
-        const auth_catalog::Type* const auth =
-            auth_catalog::find(static_cast<std::uint8_t>(slot.slotType), slot.authSchema);
-        std::string nameValue(text(source, slot.name));
-        if (nameValue.empty()) {
-            std::array<char, 24> suffix{};
-            const int length = std::snprintf(
-                suffix.data(), suffix.size(), "_%04X", static_cast<unsigned>(slot.slotIndex));
-            nameValue.assign(auth == nullptr ? "slot" : auth->name);
-            nameValue.append(suffix.data(), static_cast<std::size_t>(length));
-        }
-        slotNames.push_back(std::move(nameValue));
-    }
-    const std::vector<std::string> slotKeys = unique_slot_keys(source, slots, slotNames);
-    slotKeyByRow.reserve(slots.size());
-    for (std::size_t slotOrdinal = 0; slotOrdinal < slots.size(); ++slotOrdinal) {
-        const std::uint32_t row = slots[slotOrdinal];
-        const format::Slot& slot = source.slots[row];
-        const auth_catalog::Type* const auth =
-            auth_catalog::find(static_cast<std::uint8_t>(slot.slotType), slot.authSchema);
-        const std::string_view nameValue = slotNames[slotOrdinal];
-        const std::string& key = slotKeys[slotOrdinal];
-        slotKeyByRow.emplace(row, key);
-        output.append("    ");
-        output.append(key);
-        output.append(" = ");
-        output.append("{ id = ");
-        append_string(output, text(source, slot.id));
-        output.append(", name = ");
-        append_string(output, nameValue);
-        output.append(", index = ");
-        append_uint(output, slot.slotIndex);
-        output.append(", type = ");
-        append_uint(output, slot.slotType);
-        output.append(", component_class = ");
-        append_hex(output, slot.componentClass);
-        output.append(", sense_schema = ");
-        append_uint(output, slot.senseSchema);
-        output.append(", auth_schema = ");
-        append_uint(output, slot.authSchema);
-        if (auth != nullptr) {
-            output.append(", auth_type = ");
-            append_string(output, auth->name);
-            output.append(", auth_min_bits = ");
-            append_uint(output, auth->minimumBits);
-            output.append(", auth_max_bits = ");
-            append_uint(output, auth->maximumBits);
-            output.append(", auth_dynamic = ");
-            output.append(auth->hasDynamicBody ? "true" : "false");
-            output.append(", auth_writable = ");
-            output.append(auth->writable ? "true" : "false");
-            if (auth->hasContiguousMirror) {
-                output.append(", auth_component_offset = ");
-                append_uint(output, auth->componentOffset);
-            }
-        }
-        output.append(" },\n");
-        slotConstants.append("    ");
-        slotConstants.append(key);
-        slotConstants.append(" = ");
-        append_string(slotConstants, text(source, slot.id));
-        slotConstants.append(",\n");
-        if (auth != nullptr) {
-            const std::size_t authRow =
-                static_cast<std::size_t>(auth - auth_catalog::kTypes.data());
-            authConstants[authRow].append("        ");
-            authConstants[authRow].append(key);
-            authConstants[authRow].append(" = mission.Slot.");
-            authConstants[authRow].append(key);
-            authConstants[authRow].append(",\n");
-        }
-    }
-    output.append("    },\n    squads = {\n");
-    std::unordered_set<std::string> squadKeys;
-    std::string squadConstants = "mission.Squad = {\n";
-    if (scenarioIndex >= index.squadsByScenario.size()) {
-        return false;
-    }
-    for (const std::uint32_t squadRow : index.squadsByScenario[scenarioIndex]) {
-        const format::Squad& squad = source.squads[squadRow];
-        const std::string_view id = text(source, squad.id);
-        const auto slotKey = slotKeyByRow.find(squad.slotIndex);
-        const std::string_view squadName =
-            slotKey == slotKeyByRow.end() ? id : std::string_view{slotKey->second};
-        const std::string key = append_unique_key(output, squadKeys, squadName, squad.slotIndex);
-        output.append("{ id = ");
-        append_string(output, id);
-        output.append(", slot = ");
-        append_uint(output, squad.slotIndex);
-        output.append(", spawner_config = ");
-        append_hex(output, squad.spawnerConfigTag);
-        output.append(", spawn_rule_config = ");
-        append_hex(output, squad.spawnRuleConfigTag);
-        output.append(", members = {\n");
-        if (!range_inside(squad.members, source.squadMembers.size())) {
-            return false;
-        }
-        for (std::size_t memberOffset = 0; memberOffset < squad.members.count; ++memberOffset) {
-            const format::SquadMember& member =
-                source.squadMembers[squad.members.first + memberOffset];
-            const bool exactActor = (member.flags & format::kSquadMemberActorClassExact) != 0;
-            if (!exactActor) {
-                output.append("            { actor_class = nil, behavior_config = nil, "
-                              "default_faction = nil },\n");
-                continue;
-            }
-            if (member.actorClassIndex == format::kAbsentIndex
-                || member.actorClassIndex >= source.actorClasses.size()
-                || member.actorClassIndex >= source.actorBehaviorProfiles.size()) {
-                return false;
-            }
-            const format::ActorClass& actor = source.actorClasses[member.actorClassIndex];
-            const format::ActorBehaviorProfile& profile =
-                source.actorBehaviorProfiles[member.actorClassIndex];
-            if (profile.actorClassIndex != member.actorClassIndex) {
-                return false;
-            }
-            output.append("            { actor_class = ");
-            append_hex(output, actor.definitionTag);
-            output.append(", behavior_config = ");
-            append_hex(output, profile.behaviorConfigTag);
-            output.append(", default_faction = ");
-            append_int(output, profile.defaultFaction);
-            output.append(" },\n");
-        }
-        output.append("        }, anchors = {\n");
-        if (!range_inside(squad.anchors, source.squadAnchors.size())) {
-            return false;
-        }
-        for (std::size_t anchorOffset = 0; anchorOffset < squad.anchors.count; ++anchorOffset) {
-            const format::SquadAnchor& anchor =
-                source.squadAnchors[squad.anchors.first + anchorOffset];
-            output.append("            { x = ");
-            append_float(output, anchor.positionBits[0]);
-            output.append(", y = ");
-            append_float(output, anchor.positionBits[1]);
-            output.append(", z = ");
-            append_float(output, anchor.positionBits[2]);
-            output.append(", point = ");
-            append_uint(output, anchor.pointOrdinal);
-            output.append(" },\n");
-        }
-        output.append("        } },\n");
-        squadConstants.append("    ");
-        squadConstants.append(key);
-        squadConstants.append(" = ");
-        append_string(squadConstants, id);
-        squadConstants.append(",\n");
-    }
-    output.append("    },\n    scenes = {\n");
-    std::unordered_set<std::string> sceneKeys;
-    std::string sceneConstants = "mission.Scene = {\n";
-    if (!range_inside(scenario.occurrences, source.occurrences.size())) {
-        return false;
-    }
-    for (std::size_t occurrenceOffset = 0; occurrenceOffset < scenario.occurrences.count;
-         ++occurrenceOffset) {
-        const std::uint32_t occurrenceRow =
-            scenario.occurrences.first + static_cast<std::uint32_t>(occurrenceOffset);
-        const format::Occurrence& occurrence = source.occurrences[occurrenceRow];
-        if (occurrence.objectIndex >= source.objects.size()) {
-            return false;
-        }
-        const format::Object& object = source.objects[occurrence.objectIndex];
-        if (!range_inside(object.slots, source.slots.size())) {
-            return false;
-        }
-        for (std::size_t slotOffset = 0; slotOffset < object.slots.count; ++slotOffset) {
-            const std::uint32_t slotRow =
-                object.slots.first + static_cast<std::uint32_t>(slotOffset);
-            const auto found = index.scenesBySlot.find(slotRow);
-            const auto slotKey = slotKeyByRow.find(slotRow);
-            if (found == index.scenesBySlot.end() || found->second.size() != 1
-                || slotKey == slotKeyByRow.end()) {
-                continue;
-            }
-            const format::Slot& slot = source.slots[slotRow];
-            const format::AuthoredSceneResource& scene =
-                source.authoredSceneResources[found->second.front()];
-            std::string id;
-            if (!scene_symbol_id(source, occurrence, slot, id)) {
-                return false;
-            }
-            std::string key = slotKey->second;
-            if (!sceneKeys.insert(key).second) {
-                key.append("__");
-                key.append(identifier(text(source, occurrence.id), true));
-                if (!sceneKeys.insert(key).second) {
-                    std::array<char, 16> suffix{};
-                    const int length =
-                        std::snprintf(suffix.data(), suffix.size(), "_%08X", occurrenceRow);
-                    key.append(suffix.data(), static_cast<std::size_t>(length));
-                    if (!sceneKeys.insert(key).second) {
-                        return false;
-                    }
-                }
-            }
-            output.append("    ");
-            output.append(key);
-            output.append(" = ");
-            output.append("{ id = ");
-            append_string(output, id);
-            output.append(", slot = ");
-            append_uint(output, slotRow);
-            output.append(", config_tag = ");
-            append_hex(output, scene.configTag);
-            output.append(", resource_tag = ");
-            append_hex(output, scene.resourceTag);
-            output.append(" },\n");
-            sceneConstants.append("    ");
-            sceneConstants.append(key);
-            sceneConstants.append(" = ");
-            append_string(sceneConstants, id);
-            sceneConstants.append(",\n");
-        }
-    }
-    output.append("    },\n    tasks = {\n");
-    std::unordered_set<std::string> taskKeys;
-    std::string taskConstants = "mission.Task = {\n";
-    std::vector<std::uint32_t> tasks;
-    for (const std::uint32_t slot : slots) {
-        const auto found = index.tasksBySlot.find(slot);
-        if (found != index.tasksBySlot.end()) {
-            tasks.insert(tasks.end(), found->second.begin(), found->second.end());
-        }
-    }
-    std::sort(tasks.begin(), tasks.end());
-    tasks.erase(std::unique(tasks.begin(), tasks.end()), tasks.end());
-    for (const std::uint32_t taskRow : tasks) {
-        const format::TaskTarget& task = source.taskTargets[taskRow];
-        const std::string_view id = text(source, task.id);
-        const std::string key = append_unique_key(output, taskKeys, id, task.taskSlotIndex);
-        output.append("{ id = ");
-        append_string(output, id);
-        output.append(", task_slot = ");
-        append_uint(output, task.taskSlotIndex);
-        output.append(", objective_slot = ");
-        append_uint(output, task.objectiveSlotIndex);
-        output.append(", objective_bit = ");
-        append_uint(output, task.bitIndex);
-        output.append(", config_tag = ");
-        append_hex(output, task.configTag);
-        output.append(" },\n");
-        taskConstants.append("    ");
-        taskConstants.append(key);
-        taskConstants.append(" = ");
-        append_string(taskConstants, id);
-        taskConstants.append(",\n");
-    }
-    output.append("    },\n}\n\n");
-    stateConstants.append("}\n");
-    slotConstants.append("}\n");
-    squadConstants.append("}\n");
-    sceneConstants.append("}\n");
-    taskConstants.append("}\n");
-    std::string dialogueCueConstants = "mission.DialogueCue = {\n";
-    std::string dialogueCueTextConstants = "mission.DialogueCueVariants = {\n";
-    std::string dialogueDefinitionConstants = "mission.DialogueDefinition = {\n";
-    for (const std::uint32_t slot : slots) {
-        const auto found = index.dialogueBySlot.find(slot);
-        const auto slotKey = slotKeyByRow.find(slot);
-        if (slotKey == slotKeyByRow.end() || slot >= source.slots.size()) {
-            continue;
-        }
-        const format::Slot& definition = source.slots[slot];
-        if ((definition.flags & format::kSlotDialogueCuesExact) == 0 || definition.reserved == 0) {
-            continue;
-        }
-        dialogueCueConstants.append("    ");
-        dialogueCueConstants.append(slotKey->second);
-        dialogueCueConstants.append(" = {\n");
-        dialogueCueTextConstants.append("    ");
-        dialogueCueTextConstants.append(slotKey->second);
-        dialogueCueTextConstants.append(" = {\n");
-        dialogueDefinitionConstants.append("    ");
-        dialogueDefinitionConstants.append(slotKey->second);
-        dialogueDefinitionConstants.append(" = {\n");
-        for (std::uint32_t cue = 0; cue < definition.reserved; ++cue) {
-            dialogueCueConstants.append("        CUE_");
-            append_uint(dialogueCueConstants, cue);
-            dialogueCueConstants.append(" = ");
-            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;
-                    }
-                }
-            }
-
-            dialogueCueTextConstants.append("        [");
-            append_uint(dialogueCueTextConstants, cue);
-            dialogueCueTextConstants.append("] = {");
-            std::unordered_set<std::string_view> emittedTexts;
-            if (found != index.dialogueBySlot.end()) {
-                for (const std::uint32_t rowIndex : found->second) {
-                    const format::DialogueCueText& row = source.dialogueCueTexts[rowIndex];
-                    const std::string_view candidate = text(source, row.text);
-                    if (row.cueIndex != cue || candidate.empty()
-                        || !emittedTexts.emplace(candidate).second) {
-                        continue;
-                    }
-                    dialogueCueTextConstants.push_back('\n');
-                    dialogueCueTextConstants.append("            ");
-                    append_string(dialogueCueTextConstants, candidate);
-                    dialogueCueTextConstants.push_back(',');
-                }
-            }
-            if (!emittedTexts.empty()) {
-                dialogueCueTextConstants.append("\n        ");
-            }
-            dialogueCueTextConstants.append("},\n");
-        }
-        dialogueCueConstants.append("    },\n");
-        dialogueCueTextConstants.append("    },\n");
-        dialogueDefinitionConstants.append("    },\n");
-    }
-    dialogueCueConstants.append("}\n");
-    dialogueCueTextConstants.append("}\n");
-    dialogueDefinitionConstants.append("}\n");
-    std::unordered_set<std::string> directiveKeys;
-    std::string directiveConstants = "mission.Directive = {\n";
-    for (const std::uint32_t slot : slots) {
-        const auto found = index.directivesBySlot.find(slot);
-        if (found == index.directivesBySlot.end()) {
-            continue;
-        }
-        for (const std::uint32_t rowIndex : found->second) {
-            const format::DirectiveElement& row = source.directiveElements[rowIndex];
-            (void)append_unique_key(
-                directiveConstants, directiveKeys, text(source, row.title), row.nameHash);
-            directiveConstants.append("{ id = ");
-            append_string(directiveConstants, text(source, row.id));
-            directiveConstants.append(", slot_row = ");
-            append_uint(directiveConstants, row.slotIndex);
-            directiveConstants.append(", name_hash = ");
-            append_hex(directiveConstants, row.nameHash);
-            directiveConstants.append(", element = ");
-            append_uint(directiveConstants, static_cast<std::uint32_t>(row.elementIndex));
-            directiveConstants.append(", title = ");
-            append_string(directiveConstants, text(source, row.title));
-            directiveConstants.append(", description = ");
-            append_string(directiveConstants, text(source, row.description));
-            directiveConstants.append(" },\n");
-        }
-    }
-    directiveConstants.append("}\n");
-    // One entry per type-42 sensor: the distinct state names its target squad's members declare.
-    std::string performanceConstants = "mission.PerformanceState = {\n";
-    for (const std::uint32_t slotRow : slots) {
-        const format::Slot& slot = source.slots[slotRow];
-        if (slot.slotType != format::kPerformanceSlotType
-            || slot.authSchema != format::kPerformanceAuthSchema) {
-            continue;
-        }
-        std::uint32_t squadSlot = format::kAbsentIndex;
-        std::size_t edges = 0;
-        for (const format::AuthoredSceneSquadEdge& edge : source.authoredSceneSquadEdges) {
-            if (edge.sceneSlotIndex == slotRow
-                && (edge.flags & format::kAuthoredSceneSquadPerformanceTargetExact) != 0) {
-                squadSlot = edge.squadSlotIndex;
-                ++edges;
-            }
-        }
-        if (edges != 1) {
-            continue;
-        }
-        std::vector<std::uint32_t> names;
-        for (const format::Squad& squad : source.squads) {
-            if (squad.scenarioIndex != scenarioIndex || squad.slotIndex != squadSlot
-                || !range_inside(squad.members, source.squadMembers.size())) {
-                continue;
-            }
-            for (std::size_t offset = 0; offset < squad.members.count; ++offset) {
-                const std::uint32_t actorClass =
-                    source.squadMembers[squad.members.first + offset].actorClassIndex;
-                for (const format::ActorStateName& row : source.actorStateNames) {
-                    if (row.actorClassIndex == actorClass
-                        && std::find(names.begin(), names.end(), row.nameHash) == names.end()) {
-                        names.push_back(row.nameHash);
-                    }
-                }
-            }
-        }
-        if (names.empty()) {
-            continue;
-        }
-        performanceConstants.append("    ");
-        performanceConstants.append(slotKeyByRow.at(slotRow));
-        performanceConstants.append(" = {\n");
-        for (std::size_t ordinal = 0; ordinal < names.size(); ++ordinal) {
-            std::array<char, 24> key{};
-            const int length = std::snprintf(
-                key.data(), key.size(), "STATE_%08X", static_cast<unsigned>(names[ordinal]));
-            performanceConstants.append("        ");
-            performanceConstants.append(key.data(), static_cast<std::size_t>(length));
-            performanceConstants.append(" = { slot_row = ");
-            append_uint(performanceConstants, slotRow);
-            performanceConstants.append(", name_hash = ");
-            append_hex(performanceConstants, names[ordinal]);
-            performanceConstants.append(", ordinal = ");
-            append_uint(performanceConstants, static_cast<std::uint32_t>(ordinal + 1));
-            performanceConstants.append(" },\n");
-        }
-        performanceConstants.append("    },\n");
-    }
-    performanceConstants.append("}\n");
-    std::string authSlotConstants = "mission.Auth = {\n";
-    for (std::size_t authRow = 0; authRow < authConstants.size(); ++authRow) {
-        if (authConstants[authRow].empty()) {
-            continue;
-        }
-        authSlotConstants.append("    ");
-        authSlotConstants.append(auth_catalog::kTypes[authRow].name);
-        authSlotConstants.append(" = {\n");
-        authSlotConstants.append(authConstants[authRow]);
-        authSlotConstants.append("    },\n");
-    }
-    authSlotConstants.append("}\n");
-    std::string actorMessageConstants = "mission.ActorMessage = {\n";
-    for (const format::ActorMessageSchema& row : source.actorMessageSchemas) {
-        actorMessageConstants.append("    ");
-        actorMessageConstants.append(identifier(text(source, row.name), true));
-        actorMessageConstants.append(" = { definition = ");
-        append_hex(actorMessageConstants, row.definitionHandle);
-        actorMessageConstants.append(", durable_key = ");
-        append_hex(actorMessageConstants, row.durableKey);
-        actorMessageConstants.append(", owner_class = ");
-        append_hex(actorMessageConstants, row.ownerClass);
-        actorMessageConstants.append(", handler_slot = ");
-        append_uint(actorMessageConstants, row.handlerSlot);
-        actorMessageConstants.append(", body_type = ");
-        append_uint(actorMessageConstants, row.bodyType);
-        actorMessageConstants.append(" },\n");
-    }
-    actorMessageConstants.append("}\n");
-    std::string actorCommandConstants = "mission.ActorCommand = {\n";
-    std::string actorCommandDefinitions = "mission.ActorCommandDefinition = {\n";
-    std::string factionConstants = "mission.Faction = {\n";
-    bool factionValuesWritten = false;
-    for (const format::ActorCommandDefinition& row : source.actorCommandDefinitions) {
-        const std::string commandName = identifier(text(source, row.name), true);
-        actorCommandConstants.append("    ");
-        actorCommandConstants.append(commandName);
-        actorCommandConstants.append(" = ");
-        append_uint(actorCommandConstants, row.selector);
-        actorCommandConstants.append(",\n");
-        actorCommandDefinitions.append("    ");
-        actorCommandDefinitions.append(commandName);
-        actorCommandDefinitions.append(" = { selector = mission.ActorCommand.");
-        actorCommandDefinitions.append(commandName);
-        actorCommandDefinitions.append(", payload = ");
-        append_hex(actorCommandDefinitions, row.payloadHandle);
-        actorCommandDefinitions.append(" },\n");
-        if (row.effect == format::ActorCommandEffect::setFaction && !factionValuesWritten) {
-            factionConstants.append("    ");
-            factionConstants.append(identifier(text(source, row.factionNoneName), true));
-            factionConstants.append(" = ");
-            append_int(factionConstants, row.factionNone);
-            factionConstants.append(",\n    ");
-            factionConstants.append(identifier(text(source, row.factionRemovedName), true));
-            factionConstants.append(" = ");
-            append_int(factionConstants, row.factionRemoved);
-            factionConstants.append(",\n    ");
-            factionConstants.append(identifier(text(source, row.factionHostileToAllName), true));
-            factionConstants.append(" = ");
-            append_int(factionConstants, row.factionHostileToAll);
-            factionConstants.append(",\n");
-            factionValuesWritten = true;
-        }
-    }
-    actorCommandConstants.append("}\n");
-    actorCommandDefinitions.append("}\n");
-    factionConstants.append("}\n");
-    std::string simulationEventConstants = "mission.SimulationEvent = {\n";
-    std::string simulationEventDefinitions = "mission.SimulationEventDefinition = {\n";
-    for (const format::SimulationEventDefinition& row : source.simulationEventDefinitions) {
-        const std::string eventName = identifier(text(source, row.name), true);
-        simulationEventConstants.append("    ");
-        simulationEventConstants.append(eventName);
-        simulationEventConstants.append(" = ");
-        append_uint(simulationEventConstants, row.eventType);
-        simulationEventConstants.append(",\n");
-        simulationEventDefinitions.append("    ");
-        simulationEventDefinitions.append(eventName);
-        simulationEventDefinitions.append(" = { event_type = mission.SimulationEvent.");
-        simulationEventDefinitions.append(eventName);
-        simulationEventDefinitions.append(", primary_schema = ");
-        if (row.primarySchema == format::kAbsentIndex) {
-            simulationEventDefinitions.append("nil");
-        } else {
-            append_hex(simulationEventDefinitions, row.primarySchema);
-        }
-        simulationEventDefinitions.append(", secondary_schema = ");
-        if (row.secondarySchema == format::kAbsentIndex) {
-            simulationEventDefinitions.append("nil");
-        } else {
-            append_hex(simulationEventDefinitions, row.secondarySchema);
-        }
-        simulationEventDefinitions.append(" },\n");
-    }
-    simulationEventConstants.append("}\n");
-    simulationEventDefinitions.append("}\n");
-    std::string runtimeFieldTypes = "mission.RuntimeFieldType = {\n";
-    constexpr std::array<std::pair<format::RuntimeCodecFamily, std::string_view>, 3> kFamilies{{
-        {format::RuntimeCodecFamily::activity, "ACTIVITY"},
-        {format::RuntimeCodecFamily::sobjectModeZero, "SOBJECT_MODE_ZERO"},
-        {format::RuntimeCodecFamily::sobjectModeOne, "SOBJECT_MODE_ONE"},
-    }};
-    for (const auto& [family, familyName] : kFamilies) {
-        runtimeFieldTypes.append("    ");
-        runtimeFieldTypes.append(familyName);
-        runtimeFieldTypes.append(" = {\n");
-        for (const format::RuntimeTypeDefinition& row : source.runtimeTypeDefinitions) {
-            if ((row.codecFamilies & static_cast<std::uint32_t>(family)) == 0) {
-                continue;
-            }
-            runtimeFieldTypes.append("        ");
-            runtimeFieldTypes.append(identifier(text(source, row.name), true));
-            runtimeFieldTypes.append(" = ");
-            append_uint(runtimeFieldTypes, row.typeCode);
-            runtimeFieldTypes.append(",\n");
-        }
-        runtimeFieldTypes.append("    },\n");
-    }
-    runtimeFieldTypes.append("}\n");
-    output.append(stateConstants);
-    output.append(slotConstants);
-    output.append(authSlotConstants);
-    output.append(actorMessageConstants);
-    output.append(actorCommandConstants);
-    output.append(actorCommandDefinitions);
-    output.append(factionConstants);
-    output.append(simulationEventConstants);
-    output.append(simulationEventDefinitions);
-    output.append(runtimeFieldTypes);
-    output.append(squadConstants);
-    output.append(sceneConstants);
-    output.append(taskConstants);
-    output.append(dialogueCueConstants);
-    output.append(dialogueCueTextConstants);
-    output.append(dialogueDefinitionConstants);
-    output.append(directiveConstants);
-    output.append(performanceConstants);
-    const auto world = index.worldsByScenarioTag.find(scenario.tag);
-    if (world != index.worldsByScenarioTag.end()
-        && world->second < source.scenarioWorldSources.size()) {
-        output.append(source.scenarioWorldSources[world->second].source);
-    }
-    output.append("\nreturn mission\n");
-    module.source = std::move(output);
-    return true;
-}
-
 /** Shared read-only state for deterministic indexed mission rendering. */
 struct MissionRenderBatch final {
     const Source* source{};

+ 15 - 0
Sunrise/src/client/content/activity/activity_sdk_lua_artifacts_internal.h

@@ -1,5 +1,6 @@
 #pragma once
 
+#include <cstddef>
 #include <cstdint>
 #include <initializer_list>
 #include <string>
@@ -48,4 +49,18 @@ void append_world_spatial_views(Value::Object& output);
 [[nodiscard]] std::string_view text(const Source& source, format::StringRef reference) noexcept;
 [[nodiscard]] std::string digest_hex(const std::array<std::byte, 32>& digest, bool prefix);
 
+/** Appends one Lua string literal without depending on a runtime serializer. */
+void append_string(std::string& output, std::string_view value);
+void append_hex(std::string& output, std::uint32_t value);
+void append_uint(std::string& output, std::uint32_t value);
+
+/** Converts arbitrary extracted names to stable Lua identifiers or safe file stems. */
+[[nodiscard]] std::string identifier(std::string_view value, bool upper);
+
+/** Names one generated module file from its extracted name and stable identity. */
+[[nodiscard]] std::string stem(std::string_view name, std::uint32_t identity);
+
+/** @return True when one complete row range lies inside a bank. */
+[[nodiscard]] bool range_inside(format::Range range, std::size_t size) noexcept;
+
 } // namespace sunrise::client::content::activity::sdk_generation::lua_artifacts::internal

+ 2 - 2
Sunrise/src/client/content/activity/activity_sdk_lua_catalog_contract.cpp

@@ -290,8 +290,8 @@ constexpr std::array<RowSpec, 40> kRows{{
 }};
 
 void sort_keys(Value::Object& value) {
-    std::sort(value.begin(), value.end(), [](const auto& left, const auto& right) {
-        return left.first < right.first;
+    std::sort(value.begin(), value.end(), [](const auto& first, const auto& second) {
+        return first.first < second.first;
     });
 }
 

+ 749 - 0
Sunrise/src/client/content/activity/activity_sdk_lua_missions.cpp

@@ -0,0 +1,749 @@
+#include <algorithm>
+#include <array>
+#include <bit>
+#include <cstddef>
+#include <cstdint>
+#include <cstdio>
+#include <limits>
+#include <string>
+#include <string_view>
+#include <unordered_map>
+#include <unordered_set>
+#include <vector>
+
+#include "../../../middleware/bap/activity_message/auth_schema_catalog.h"
+#include "activity_sdk_lua_missions_internal.h"
+
+namespace sunrise::client::content::activity::sdk_generation::lua_artifacts::internal {
+namespace {
+
+namespace auth_catalog = middleware::bap::activity_message::auth_schema_catalog;
+
+void append_int(std::string& output, std::int32_t value) {
+    std::array<char, 16> buffer{};
+    const int length = std::snprintf(buffer.data(), buffer.size(), "%d", value);
+    output.append(buffer.data(), static_cast<std::size_t>(length));
+}
+
+void append_float(std::string& output, std::uint32_t bits) {
+    std::array<char, 32> buffer{};
+    const int length = std::snprintf(
+        buffer.data(), buffer.size(), "%.9g", static_cast<double>(std::bit_cast<float>(bits)));
+    output.append(buffer.data(), static_cast<std::size_t>(length));
+}
+
+/** Builds the exact occurrence-bound identity consumed by the mission scene API. */
+[[nodiscard]] bool scene_symbol_id(const Source& source,
+                                   const format::Occurrence& occurrence,
+                                   const format::Slot& slot,
+                                   std::string& output) {
+    // Occurrence ids carry this prefix; the scene symbol keeps the remainder.
+    constexpr std::string_view kOccurrencePrefix = "object-occurrence/";
+    const std::string_view occurrenceId = text(source, occurrence.id);
+    const std::string_view suffix = occurrenceId.starts_with(kOccurrencePrefix)
+                                        ? occurrenceId.substr(kOccurrencePrefix.size())
+                                        : occurrenceId;
+    if (suffix.empty() || slot.slotIndex > (std::numeric_limits<std::uint16_t>::max)()
+        || slot.slotType > (std::numeric_limits<std::uint16_t>::max)()) {
+        return false;
+    }
+    std::array<char, 32> tail{};
+    const int length =
+        std::snprintf(tail.data(), tail.size(), "/%04x/%04x", slot.slotIndex, slot.slotType);
+    if (length <= 0 || static_cast<std::size_t>(length) >= tail.size()) {
+        return false;
+    }
+    output.assign("symbol/");
+    output.append(suffix);
+    output.append(tail.data(), static_cast<std::size_t>(length));
+    return true;
+}
+
+/** Collects each global slot row used by one scenario exactly once. */
+[[nodiscard]] bool scenario_slots(const Source& source,
+                                  const format::Scenario& scenario,
+                                  std::vector<std::uint32_t>& output) {
+    output.clear();
+    if (!range_inside(scenario.occurrences, source.occurrences.size())) {
+        return false;
+    }
+    for (std::size_t offset = 0; offset < scenario.occurrences.count; ++offset) {
+        const format::Occurrence& occurrence =
+            source.occurrences[scenario.occurrences.first + offset];
+        if (occurrence.objectIndex >= source.objects.size()) {
+            return false;
+        }
+        const format::Object& object = source.objects[occurrence.objectIndex];
+        if (!range_inside(object.slots, source.slots.size())) {
+            return false;
+        }
+        for (std::size_t slotOffset = 0; slotOffset < object.slots.count; ++slotOffset) {
+            output.push_back(object.slots.first + static_cast<std::uint32_t>(slotOffset));
+        }
+    }
+    std::sort(output.begin(), output.end());
+    output.erase(std::unique(output.begin(), output.end()), output.end());
+    return true;
+}
+
+/** Folds slot names into keys. A repeated name takes its object tag, then its slot index. */
+[[nodiscard]] std::vector<std::string> unique_slot_keys(const Source& source,
+                                                        const std::vector<std::uint32_t>& rows,
+                                                        const std::vector<std::string>& names) {
+    std::vector<std::string> keys(rows.size());
+    std::unordered_map<std::string, std::size_t> counts;
+    for (std::size_t index = 0; index < rows.size(); ++index) {
+        keys[index] = identifier(names[index], true);
+        ++counts[keys[index]];
+    }
+    // Every member of a repeated group is suffixed, so a key never depends on row order.
+    std::unordered_map<std::string, std::size_t> taggedCounts;
+    for (std::size_t index = 0; index < rows.size(); ++index) {
+        if (counts[keys[index]] > 1) {
+            const format::Slot& slot = source.slots[rows[index]];
+            const std::uint32_t tag = slot.objectIndex < source.objects.size()
+                                          ? source.objects[slot.objectIndex].objectTag
+                                          : 0U;
+            std::array<char, 16> suffix{};
+            const int length = std::snprintf(suffix.data(), suffix.size(), "_%08X", tag);
+            keys[index].append(suffix.data(), static_cast<std::size_t>(length));
+        }
+        ++taggedCounts[keys[index]];
+    }
+    for (std::size_t index = 0; index < rows.size(); ++index) {
+        if (taggedCounts[keys[index]] > 1) {
+            std::array<char, 16> suffix{};
+            const int length =
+                std::snprintf(suffix.data(),
+                              suffix.size(),
+                              "_%04X",
+                              static_cast<unsigned>(source.slots[rows[index]].slotIndex));
+            keys[index].append(suffix.data(), static_cast<std::size_t>(length));
+        }
+    }
+    return keys;
+}
+
+} // namespace
+
+/** Emits one concrete mission module with no pack or resolver access. */
+bool render_mission(const Source& source,
+                    const RenderIndex& index,
+                    std::uint32_t scenarioIndex,
+                    const format::Scenario& scenario,
+                    SourceModule& module) {
+    const std::string_view name = text(source, scenario.name);
+    module.stem = stem(name, scenario.tag);
+    std::string output = "-- Generated from installed mission data. Do not edit.\n"
+                         "local sdk = require(\"sunrise.activity_sdk\")\n\n"
+                         "---@type SunriseMission\n"
+                         "local mission = {\n    name = ";
+    append_string(output, name);
+    output.append(",\n    id = ");
+    append_string(output, text(source, scenario.id));
+    output.append(",\n    tag = ");
+    append_hex(output, scenario.tag);
+    output.append(",\n    states = {\n");
+    std::unordered_set<std::string> stateKeys;
+    std::string stateConstants = "mission.State = {\n";
+    if (!range_inside(scenario.states, source.states.size())) {
+        return false;
+    }
+    for (std::size_t offset = 0; offset < scenario.states.count; ++offset) {
+        const format::State& state = source.states[scenario.states.first + offset];
+        const std::string_view id = text(source, state.id);
+        const std::string key = append_unique_key(output, stateKeys, id, state.stateHash);
+        output.append("{ id = ");
+        append_string(output, id);
+        output.append(", ordinal = ");
+        append_uint(output, state.stateOrdinal);
+        output.append(", slice_set_index = ");
+        append_uint(output, state.sliceSetIndex);
+        output.append(", region_index = ");
+        append_uint(output, state.sliceSetIndex + state.stateOrdinal);
+        output.append(", hash = ");
+        append_hex(output, state.stateHash);
+        output.append(", value = ");
+        append_uint(output, state.publicValue);
+        output.append(" },\n");
+        stateConstants.append("    ");
+        stateConstants.append(key);
+        stateConstants.append(" = ");
+        append_uint(stateConstants, state.publicValue);
+        stateConstants.append(",\n");
+    }
+    output.append("    },\n    slots = {\n");
+    std::unordered_map<std::uint32_t, std::string> slotKeyByRow;
+    std::string slotConstants = "mission.Slot = {\n";
+    std::array<std::string, auth_catalog::kTypes.size()> authConstants{};
+    std::vector<std::uint32_t> slots;
+    if (!scenario_slots(source, scenario, slots)) {
+        return false;
+    }
+    std::vector<std::string> slotNames;
+    slotNames.reserve(slots.size());
+    for (const std::uint32_t row : slots) {
+        const format::Slot& slot = source.slots[row];
+        const auth_catalog::Type* const auth =
+            auth_catalog::find(static_cast<std::uint8_t>(slot.slotType), slot.authSchema);
+        std::string nameValue(text(source, slot.name));
+        if (nameValue.empty()) {
+            std::array<char, 24> suffix{};
+            const int length = std::snprintf(
+                suffix.data(), suffix.size(), "_%04X", static_cast<unsigned>(slot.slotIndex));
+            nameValue.assign(auth == nullptr ? "slot" : auth->name);
+            nameValue.append(suffix.data(), static_cast<std::size_t>(length));
+        }
+        slotNames.push_back(std::move(nameValue));
+    }
+    const std::vector<std::string> slotKeys = unique_slot_keys(source, slots, slotNames);
+    slotKeyByRow.reserve(slots.size());
+    for (std::size_t slotOrdinal = 0; slotOrdinal < slots.size(); ++slotOrdinal) {
+        const std::uint32_t row = slots[slotOrdinal];
+        const format::Slot& slot = source.slots[row];
+        const auth_catalog::Type* const auth =
+            auth_catalog::find(static_cast<std::uint8_t>(slot.slotType), slot.authSchema);
+        const std::string_view nameValue = slotNames[slotOrdinal];
+        const std::string& key = slotKeys[slotOrdinal];
+        slotKeyByRow.emplace(row, key);
+        output.append("    ");
+        output.append(key);
+        output.append(" = ");
+        output.append("{ id = ");
+        append_string(output, text(source, slot.id));
+        output.append(", name = ");
+        append_string(output, nameValue);
+        output.append(", index = ");
+        append_uint(output, slot.slotIndex);
+        output.append(", type = ");
+        append_uint(output, slot.slotType);
+        output.append(", component_class = ");
+        append_hex(output, slot.componentClass);
+        output.append(", sense_schema = ");
+        append_uint(output, slot.senseSchema);
+        output.append(", auth_schema = ");
+        append_uint(output, slot.authSchema);
+        if (auth != nullptr) {
+            output.append(", auth_type = ");
+            append_string(output, auth->name);
+            output.append(", auth_min_bits = ");
+            append_uint(output, auth->minimumBits);
+            output.append(", auth_max_bits = ");
+            append_uint(output, auth->maximumBits);
+            output.append(", auth_dynamic = ");
+            output.append(auth->hasDynamicBody ? "true" : "false");
+            output.append(", auth_writable = ");
+            output.append(auth->writable ? "true" : "false");
+            if (auth->hasContiguousMirror) {
+                output.append(", auth_component_offset = ");
+                append_uint(output, auth->componentOffset);
+            }
+        }
+        output.append(" },\n");
+        slotConstants.append("    ");
+        slotConstants.append(key);
+        slotConstants.append(" = ");
+        append_string(slotConstants, text(source, slot.id));
+        slotConstants.append(",\n");
+        if (auth != nullptr) {
+            const std::size_t authRow =
+                static_cast<std::size_t>(auth - auth_catalog::kTypes.data());
+            authConstants[authRow].append("        ");
+            authConstants[authRow].append(key);
+            authConstants[authRow].append(" = mission.Slot.");
+            authConstants[authRow].append(key);
+            authConstants[authRow].append(",\n");
+        }
+    }
+    output.append("    },\n    squads = {\n");
+    std::unordered_set<std::string> squadKeys;
+    std::string squadConstants = "mission.Squad = {\n";
+    if (scenarioIndex >= index.squadsByScenario.size()) {
+        return false;
+    }
+    for (const std::uint32_t squadRow : index.squadsByScenario[scenarioIndex]) {
+        const format::Squad& squad = source.squads[squadRow];
+        const std::string_view id = text(source, squad.id);
+        const auto slotKey = slotKeyByRow.find(squad.slotIndex);
+        const std::string_view squadName =
+            slotKey == slotKeyByRow.end() ? id : std::string_view{slotKey->second};
+        const std::string key = append_unique_key(output, squadKeys, squadName, squad.slotIndex);
+        output.append("{ id = ");
+        append_string(output, id);
+        output.append(", slot = ");
+        append_uint(output, squad.slotIndex);
+        output.append(", spawner_config = ");
+        append_hex(output, squad.spawnerConfigTag);
+        output.append(", spawn_rule_config = ");
+        append_hex(output, squad.spawnRuleConfigTag);
+        output.append(", members = {\n");
+        if (!range_inside(squad.members, source.squadMembers.size())) {
+            return false;
+        }
+        for (std::size_t memberOffset = 0; memberOffset < squad.members.count; ++memberOffset) {
+            const format::SquadMember& member =
+                source.squadMembers[squad.members.first + memberOffset];
+            const bool exactActor = (member.flags & format::kSquadMemberActorClassExact) != 0;
+            if (!exactActor) {
+                output.append("            { actor_class = nil, behavior_config = nil, "
+                              "default_faction = nil },\n");
+                continue;
+            }
+            if (member.actorClassIndex == format::kAbsentIndex
+                || member.actorClassIndex >= source.actorClasses.size()
+                || member.actorClassIndex >= source.actorBehaviorProfiles.size()) {
+                return false;
+            }
+            const format::ActorClass& actor = source.actorClasses[member.actorClassIndex];
+            const format::ActorBehaviorProfile& profile =
+                source.actorBehaviorProfiles[member.actorClassIndex];
+            if (profile.actorClassIndex != member.actorClassIndex) {
+                return false;
+            }
+            output.append("            { actor_class = ");
+            append_hex(output, actor.definitionTag);
+            output.append(", behavior_config = ");
+            append_hex(output, profile.behaviorConfigTag);
+            output.append(", default_faction = ");
+            append_int(output, profile.defaultFaction);
+            output.append(" },\n");
+        }
+        output.append("        }, anchors = {\n");
+        if (!range_inside(squad.anchors, source.squadAnchors.size())) {
+            return false;
+        }
+        for (std::size_t anchorOffset = 0; anchorOffset < squad.anchors.count; ++anchorOffset) {
+            const format::SquadAnchor& anchor =
+                source.squadAnchors[squad.anchors.first + anchorOffset];
+            output.append("            { x = ");
+            append_float(output, anchor.positionBits[0]);
+            output.append(", y = ");
+            append_float(output, anchor.positionBits[1]);
+            output.append(", z = ");
+            append_float(output, anchor.positionBits[2]);
+            output.append(", point = ");
+            append_uint(output, anchor.pointOrdinal);
+            output.append(" },\n");
+        }
+        output.append("        } },\n");
+        squadConstants.append("    ");
+        squadConstants.append(key);
+        squadConstants.append(" = ");
+        append_string(squadConstants, id);
+        squadConstants.append(",\n");
+    }
+    output.append("    },\n    scenes = {\n");
+    std::unordered_set<std::string> sceneKeys;
+    std::string sceneConstants = "mission.Scene = {\n";
+    if (!range_inside(scenario.occurrences, source.occurrences.size())) {
+        return false;
+    }
+    for (std::size_t occurrenceOffset = 0; occurrenceOffset < scenario.occurrences.count;
+         ++occurrenceOffset) {
+        const std::uint32_t occurrenceRow =
+            scenario.occurrences.first + static_cast<std::uint32_t>(occurrenceOffset);
+        const format::Occurrence& occurrence = source.occurrences[occurrenceRow];
+        if (occurrence.objectIndex >= source.objects.size()) {
+            return false;
+        }
+        const format::Object& object = source.objects[occurrence.objectIndex];
+        if (!range_inside(object.slots, source.slots.size())) {
+            return false;
+        }
+        for (std::size_t slotOffset = 0; slotOffset < object.slots.count; ++slotOffset) {
+            const std::uint32_t slotRow =
+                object.slots.first + static_cast<std::uint32_t>(slotOffset);
+            const auto found = index.scenesBySlot.find(slotRow);
+            const auto slotKey = slotKeyByRow.find(slotRow);
+            if (found == index.scenesBySlot.end() || found->second.size() != 1
+                || slotKey == slotKeyByRow.end()) {
+                continue;
+            }
+            const format::Slot& slot = source.slots[slotRow];
+            const format::AuthoredSceneResource& scene =
+                source.authoredSceneResources[found->second.front()];
+            std::string id;
+            if (!scene_symbol_id(source, occurrence, slot, id)) {
+                return false;
+            }
+            std::string key = slotKey->second;
+            if (!sceneKeys.insert(key).second) {
+                key.append("__");
+                key.append(identifier(text(source, occurrence.id), true));
+                if (!sceneKeys.insert(key).second) {
+                    std::array<char, 16> suffix{};
+                    const int length =
+                        std::snprintf(suffix.data(), suffix.size(), "_%08X", occurrenceRow);
+                    key.append(suffix.data(), static_cast<std::size_t>(length));
+                    if (!sceneKeys.insert(key).second) {
+                        return false;
+                    }
+                }
+            }
+            output.append("    ");
+            output.append(key);
+            output.append(" = ");
+            output.append("{ id = ");
+            append_string(output, id);
+            output.append(", slot = ");
+            append_uint(output, slotRow);
+            output.append(", config_tag = ");
+            append_hex(output, scene.configTag);
+            output.append(", resource_tag = ");
+            append_hex(output, scene.resourceTag);
+            output.append(" },\n");
+            sceneConstants.append("    ");
+            sceneConstants.append(key);
+            sceneConstants.append(" = ");
+            append_string(sceneConstants, id);
+            sceneConstants.append(",\n");
+        }
+    }
+    output.append("    },\n    tasks = {\n");
+    std::unordered_set<std::string> taskKeys;
+    std::string taskConstants = "mission.Task = {\n";
+    std::vector<std::uint32_t> tasks;
+    for (const std::uint32_t slot : slots) {
+        const auto found = index.tasksBySlot.find(slot);
+        if (found != index.tasksBySlot.end()) {
+            tasks.insert(tasks.end(), found->second.begin(), found->second.end());
+        }
+    }
+    std::sort(tasks.begin(), tasks.end());
+    tasks.erase(std::unique(tasks.begin(), tasks.end()), tasks.end());
+    for (const std::uint32_t taskRow : tasks) {
+        const format::TaskTarget& task = source.taskTargets[taskRow];
+        const std::string_view id = text(source, task.id);
+        const std::string key = append_unique_key(output, taskKeys, id, task.taskSlotIndex);
+        output.append("{ id = ");
+        append_string(output, id);
+        output.append(", task_slot = ");
+        append_uint(output, task.taskSlotIndex);
+        output.append(", objective_slot = ");
+        append_uint(output, task.objectiveSlotIndex);
+        output.append(", objective_bit = ");
+        append_uint(output, task.bitIndex);
+        output.append(", config_tag = ");
+        append_hex(output, task.configTag);
+        output.append(" },\n");
+        taskConstants.append("    ");
+        taskConstants.append(key);
+        taskConstants.append(" = ");
+        append_string(taskConstants, id);
+        taskConstants.append(",\n");
+    }
+    output.append("    },\n}\n\n");
+    stateConstants.append("}\n");
+    slotConstants.append("}\n");
+    squadConstants.append("}\n");
+    sceneConstants.append("}\n");
+    taskConstants.append("}\n");
+    std::string dialogueCueConstants = "mission.DialogueCue = {\n";
+    std::string dialogueCueTextConstants = "mission.DialogueCueVariants = {\n";
+    std::string dialogueDefinitionConstants = "mission.DialogueDefinition = {\n";
+    for (const std::uint32_t slot : slots) {
+        const auto found = index.dialogueBySlot.find(slot);
+        const auto slotKey = slotKeyByRow.find(slot);
+        if (slotKey == slotKeyByRow.end() || slot >= source.slots.size()) {
+            continue;
+        }
+        const format::Slot& definition = source.slots[slot];
+        if ((definition.flags & format::kSlotDialogueCuesExact) == 0 || definition.reserved == 0) {
+            continue;
+        }
+        dialogueCueConstants.append("    ");
+        dialogueCueConstants.append(slotKey->second);
+        dialogueCueConstants.append(" = {\n");
+        dialogueCueTextConstants.append("    ");
+        dialogueCueTextConstants.append(slotKey->second);
+        dialogueCueTextConstants.append(" = {\n");
+        dialogueDefinitionConstants.append("    ");
+        dialogueDefinitionConstants.append(slotKey->second);
+        dialogueDefinitionConstants.append(" = {\n");
+        for (std::uint32_t cue = 0; cue < definition.reserved; ++cue) {
+            dialogueCueConstants.append("        CUE_");
+            append_uint(dialogueCueConstants, cue);
+            dialogueCueConstants.append(" = ");
+            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;
+                    }
+                }
+            }
+
+            dialogueCueTextConstants.append("        [");
+            append_uint(dialogueCueTextConstants, cue);
+            dialogueCueTextConstants.append("] = {");
+            std::unordered_set<std::string_view> emittedTexts;
+            if (found != index.dialogueBySlot.end()) {
+                for (const std::uint32_t rowIndex : found->second) {
+                    const format::DialogueCueText& row = source.dialogueCueTexts[rowIndex];
+                    const std::string_view candidate = text(source, row.text);
+                    if (row.cueIndex != cue || candidate.empty()
+                        || !emittedTexts.emplace(candidate).second) {
+                        continue;
+                    }
+                    dialogueCueTextConstants.push_back('\n');
+                    dialogueCueTextConstants.append("            ");
+                    append_string(dialogueCueTextConstants, candidate);
+                    dialogueCueTextConstants.push_back(',');
+                }
+            }
+            if (!emittedTexts.empty()) {
+                dialogueCueTextConstants.append("\n        ");
+            }
+            dialogueCueTextConstants.append("},\n");
+        }
+        dialogueCueConstants.append("    },\n");
+        dialogueCueTextConstants.append("    },\n");
+        dialogueDefinitionConstants.append("    },\n");
+    }
+    dialogueCueConstants.append("}\n");
+    dialogueCueTextConstants.append("}\n");
+    dialogueDefinitionConstants.append("}\n");
+    std::unordered_set<std::string> directiveKeys;
+    std::string directiveConstants = "mission.Directive = {\n";
+    for (const std::uint32_t slot : slots) {
+        const auto found = index.directivesBySlot.find(slot);
+        if (found == index.directivesBySlot.end()) {
+            continue;
+        }
+        for (const std::uint32_t rowIndex : found->second) {
+            const format::DirectiveElement& row = source.directiveElements[rowIndex];
+            (void)append_unique_key(
+                directiveConstants, directiveKeys, text(source, row.title), row.nameHash);
+            directiveConstants.append("{ id = ");
+            append_string(directiveConstants, text(source, row.id));
+            directiveConstants.append(", slot_row = ");
+            append_uint(directiveConstants, row.slotIndex);
+            directiveConstants.append(", name_hash = ");
+            append_hex(directiveConstants, row.nameHash);
+            directiveConstants.append(", element = ");
+            append_uint(directiveConstants, static_cast<std::uint32_t>(row.elementIndex));
+            directiveConstants.append(", title = ");
+            append_string(directiveConstants, text(source, row.title));
+            directiveConstants.append(", description = ");
+            append_string(directiveConstants, text(source, row.description));
+            directiveConstants.append(" },\n");
+        }
+    }
+    directiveConstants.append("}\n");
+    // One entry per type-42 sensor: the distinct state names its target squad's members declare.
+    std::string performanceConstants = "mission.PerformanceState = {\n";
+    for (const std::uint32_t slotRow : slots) {
+        const format::Slot& slot = source.slots[slotRow];
+        if (slot.slotType != format::kPerformanceSlotType
+            || slot.authSchema != format::kPerformanceAuthSchema) {
+            continue;
+        }
+        std::uint32_t squadSlot = format::kAbsentIndex;
+        std::size_t edges = 0;
+        for (const format::AuthoredSceneSquadEdge& edge : source.authoredSceneSquadEdges) {
+            if (edge.sceneSlotIndex == slotRow
+                && (edge.flags & format::kAuthoredSceneSquadPerformanceTargetExact) != 0) {
+                squadSlot = edge.squadSlotIndex;
+                ++edges;
+            }
+        }
+        if (edges != 1) {
+            continue;
+        }
+        std::vector<std::uint32_t> names;
+        for (const format::Squad& squad : source.squads) {
+            if (squad.scenarioIndex != scenarioIndex || squad.slotIndex != squadSlot
+                || !range_inside(squad.members, source.squadMembers.size())) {
+                continue;
+            }
+            for (std::size_t offset = 0; offset < squad.members.count; ++offset) {
+                const std::uint32_t actorClass =
+                    source.squadMembers[squad.members.first + offset].actorClassIndex;
+                for (const format::ActorStateName& row : source.actorStateNames) {
+                    if (row.actorClassIndex == actorClass
+                        && std::find(names.begin(), names.end(), row.nameHash) == names.end()) {
+                        names.push_back(row.nameHash);
+                    }
+                }
+            }
+        }
+        if (names.empty()) {
+            continue;
+        }
+        performanceConstants.append("    ");
+        performanceConstants.append(slotKeyByRow.at(slotRow));
+        performanceConstants.append(" = {\n");
+        for (std::size_t ordinal = 0; ordinal < names.size(); ++ordinal) {
+            std::array<char, 24> key{};
+            const int length = std::snprintf(
+                key.data(), key.size(), "STATE_%08X", static_cast<unsigned>(names[ordinal]));
+            performanceConstants.append("        ");
+            performanceConstants.append(key.data(), static_cast<std::size_t>(length));
+            performanceConstants.append(" = { slot_row = ");
+            append_uint(performanceConstants, slotRow);
+            performanceConstants.append(", name_hash = ");
+            append_hex(performanceConstants, names[ordinal]);
+            performanceConstants.append(", ordinal = ");
+            append_uint(performanceConstants, static_cast<std::uint32_t>(ordinal + 1));
+            performanceConstants.append(" },\n");
+        }
+        performanceConstants.append("    },\n");
+    }
+    performanceConstants.append("}\n");
+    std::string authSlotConstants = "mission.Auth = {\n";
+    for (std::size_t authRow = 0; authRow < authConstants.size(); ++authRow) {
+        if (authConstants[authRow].empty()) {
+            continue;
+        }
+        authSlotConstants.append("    ");
+        authSlotConstants.append(auth_catalog::kTypes[authRow].name);
+        authSlotConstants.append(" = {\n");
+        authSlotConstants.append(authConstants[authRow]);
+        authSlotConstants.append("    },\n");
+    }
+    authSlotConstants.append("}\n");
+    std::string actorMessageConstants = "mission.ActorMessage = {\n";
+    for (const format::ActorMessageSchema& row : source.actorMessageSchemas) {
+        actorMessageConstants.append("    ");
+        actorMessageConstants.append(identifier(text(source, row.name), true));
+        actorMessageConstants.append(" = { definition = ");
+        append_hex(actorMessageConstants, row.definitionHandle);
+        actorMessageConstants.append(", durable_key = ");
+        append_hex(actorMessageConstants, row.durableKey);
+        actorMessageConstants.append(", owner_class = ");
+        append_hex(actorMessageConstants, row.ownerClass);
+        actorMessageConstants.append(", handler_slot = ");
+        append_uint(actorMessageConstants, row.handlerSlot);
+        actorMessageConstants.append(", body_type = ");
+        append_uint(actorMessageConstants, row.bodyType);
+        actorMessageConstants.append(" },\n");
+    }
+    actorMessageConstants.append("}\n");
+    std::string actorCommandConstants = "mission.ActorCommand = {\n";
+    std::string actorCommandDefinitions = "mission.ActorCommandDefinition = {\n";
+    std::string factionConstants = "mission.Faction = {\n";
+    bool factionValuesWritten = false;
+    for (const format::ActorCommandDefinition& row : source.actorCommandDefinitions) {
+        const std::string commandName = identifier(text(source, row.name), true);
+        actorCommandConstants.append("    ");
+        actorCommandConstants.append(commandName);
+        actorCommandConstants.append(" = ");
+        append_uint(actorCommandConstants, row.selector);
+        actorCommandConstants.append(",\n");
+        actorCommandDefinitions.append("    ");
+        actorCommandDefinitions.append(commandName);
+        actorCommandDefinitions.append(" = { selector = mission.ActorCommand.");
+        actorCommandDefinitions.append(commandName);
+        actorCommandDefinitions.append(", payload = ");
+        append_hex(actorCommandDefinitions, row.payloadHandle);
+        actorCommandDefinitions.append(" },\n");
+        if (row.effect == format::ActorCommandEffect::setFaction && !factionValuesWritten) {
+            factionConstants.append("    ");
+            factionConstants.append(identifier(text(source, row.factionNoneName), true));
+            factionConstants.append(" = ");
+            append_int(factionConstants, row.factionNone);
+            factionConstants.append(",\n    ");
+            factionConstants.append(identifier(text(source, row.factionRemovedName), true));
+            factionConstants.append(" = ");
+            append_int(factionConstants, row.factionRemoved);
+            factionConstants.append(",\n    ");
+            factionConstants.append(identifier(text(source, row.factionHostileToAllName), true));
+            factionConstants.append(" = ");
+            append_int(factionConstants, row.factionHostileToAll);
+            factionConstants.append(",\n");
+            factionValuesWritten = true;
+        }
+    }
+    actorCommandConstants.append("}\n");
+    actorCommandDefinitions.append("}\n");
+    factionConstants.append("}\n");
+    std::string simulationEventConstants = "mission.SimulationEvent = {\n";
+    std::string simulationEventDefinitions = "mission.SimulationEventDefinition = {\n";
+    for (const format::SimulationEventDefinition& row : source.simulationEventDefinitions) {
+        const std::string eventName = identifier(text(source, row.name), true);
+        simulationEventConstants.append("    ");
+        simulationEventConstants.append(eventName);
+        simulationEventConstants.append(" = ");
+        append_uint(simulationEventConstants, row.eventType);
+        simulationEventConstants.append(",\n");
+        simulationEventDefinitions.append("    ");
+        simulationEventDefinitions.append(eventName);
+        simulationEventDefinitions.append(" = { event_type = mission.SimulationEvent.");
+        simulationEventDefinitions.append(eventName);
+        simulationEventDefinitions.append(", primary_schema = ");
+        if (row.primarySchema == format::kAbsentIndex) {
+            simulationEventDefinitions.append("nil");
+        } else {
+            append_hex(simulationEventDefinitions, row.primarySchema);
+        }
+        simulationEventDefinitions.append(", secondary_schema = ");
+        if (row.secondarySchema == format::kAbsentIndex) {
+            simulationEventDefinitions.append("nil");
+        } else {
+            append_hex(simulationEventDefinitions, row.secondarySchema);
+        }
+        simulationEventDefinitions.append(" },\n");
+    }
+    simulationEventConstants.append("}\n");
+    simulationEventDefinitions.append("}\n");
+    std::string runtimeFieldTypes = "mission.RuntimeFieldType = {\n";
+    // Lua name published for each runtime codec family.
+    constexpr std::array<std::pair<format::RuntimeCodecFamily, std::string_view>, 3> kFamilies{{
+        {format::RuntimeCodecFamily::activity, "ACTIVITY"},
+        {format::RuntimeCodecFamily::sobjectModeZero, "SOBJECT_MODE_ZERO"},
+        {format::RuntimeCodecFamily::sobjectModeOne, "SOBJECT_MODE_ONE"},
+    }};
+    for (const auto& [family, familyName] : kFamilies) {
+        runtimeFieldTypes.append("    ");
+        runtimeFieldTypes.append(familyName);
+        runtimeFieldTypes.append(" = {\n");
+        for (const format::RuntimeTypeDefinition& row : source.runtimeTypeDefinitions) {
+            if ((row.codecFamilies & static_cast<std::uint32_t>(family)) == 0) {
+                continue;
+            }
+            runtimeFieldTypes.append("        ");
+            runtimeFieldTypes.append(identifier(text(source, row.name), true));
+            runtimeFieldTypes.append(" = ");
+            append_uint(runtimeFieldTypes, row.typeCode);
+            runtimeFieldTypes.append(",\n");
+        }
+        runtimeFieldTypes.append("    },\n");
+    }
+    runtimeFieldTypes.append("}\n");
+    output.append(stateConstants);
+    output.append(slotConstants);
+    output.append(authSlotConstants);
+    output.append(actorMessageConstants);
+    output.append(actorCommandConstants);
+    output.append(actorCommandDefinitions);
+    output.append(factionConstants);
+    output.append(simulationEventConstants);
+    output.append(simulationEventDefinitions);
+    output.append(runtimeFieldTypes);
+    output.append(squadConstants);
+    output.append(sceneConstants);
+    output.append(taskConstants);
+    output.append(dialogueCueConstants);
+    output.append(dialogueCueTextConstants);
+    output.append(dialogueDefinitionConstants);
+    output.append(directiveConstants);
+    output.append(performanceConstants);
+    const auto world = index.worldsByScenarioTag.find(scenario.tag);
+    if (world != index.worldsByScenarioTag.end()
+        && world->second < source.scenarioWorldSources.size()) {
+        output.append(source.scenarioWorldSources[world->second].source);
+    }
+    output.append("\nreturn mission\n");
+    module.source = std::move(output);
+    return true;
+}
+
+} // namespace sunrise::client::content::activity::sdk_generation::lua_artifacts::internal

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

@@ -0,0 +1,37 @@
+#pragma once
+
+#include <cstdint>
+#include <string>
+#include <string_view>
+#include <unordered_map>
+#include <unordered_set>
+#include <vector>
+
+#include "activity_sdk_lua_artifacts_internal.h"
+
+namespace sunrise::client::content::activity::sdk_generation::lua_artifacts::internal {
+
+/** Direct owner indexes replace full global scans in every mission renderer. */
+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{};
+    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::uint32_t> worldsByScenarioTag{};
+};
+
+/** Keeps the extracted name unchanged unless another row already owns that Lua key. */
+[[nodiscard]] std::string append_unique_key(std::string& output,
+                                            std::unordered_set<std::string>& used,
+                                            std::string_view name,
+                                            std::uint32_t fallback);
+
+/** Emits one concrete mission module with no pack or resolver access. */
+[[nodiscard]] bool render_mission(const Source& source,
+                                  const RenderIndex& index,
+                                  std::uint32_t scenarioIndex,
+                                  const format::Scenario& scenario,
+                                  SourceModule& module);
+
+} // namespace sunrise::client::content::activity::sdk_generation::lua_artifacts::internal

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

@@ -20,11 +20,13 @@
 namespace sunrise::client::content::activity::sdk_generation::lua_artifacts {
 namespace {
 
+// Published Lua tree layout below the artifact root.
 constexpr std::wstring_view kLuaSuffix = L"\\lua";
 constexpr std::wstring_view kActivitiesSuffix = L"\\activities";
 constexpr std::wstring_view kMissionsSuffix = L"\\missions";
 constexpr std::wstring_view kSunriseSuffix = L"\\sunrise";
 constexpr std::wstring_view kTemporarySuffix = L".%08lX.%08lX.%08lX.tmp";
+// Distinct temporary names tried before publication fails.
 constexpr std::size_t kTemporaryAttempts = 16;
 
 volatile LONG g_temporarySequence{};

+ 9 - 7
Sunrise/src/client/content/activity/activity_sdk_lua_value.cpp

@@ -24,6 +24,7 @@ enum class Dialect : std::uint8_t {
 
 /** Appends JSON/Lua-safe ASCII and refuses non-ASCII bytes without rollback. */
 [[nodiscard]] bool append_quoted(std::string_view value, std::string& output) {
+    // Escaped bytes are emitted as lowercase hex.
     static constexpr char kHex[] = "0123456789abcdef";
     output.push_back('"');
     for (const unsigned char byte : value) {
@@ -138,18 +139,18 @@ render_value(const Value& value, Dialect dialect, std::uint32_t depth, std::stri
     return std::visit(
         [&](const auto& item) -> bool {
             using Item = std::remove_cvref_t<decltype(item)>;
-            if constexpr (std::is_same_v<Item, std::monostate>) {
+            if constexpr (std::is_same_v<Item, std::monostate>) { // Empty spells differently.
                 output.append(dialect == Dialect::json ? "null" : "nil");
                 return true;
-            } else if constexpr (std::is_same_v<Item, bool>) {
+            } else if constexpr (std::is_same_v<Item, bool>) { // Both dialects share the literals.
                 output.append(item ? "true" : "false");
                 return true;
-            } else if constexpr (std::is_same_v<Item, std::uint64_t>) {
+            } else if constexpr (std::is_same_v<Item, std::uint64_t>) { // No float form is emitted.
                 output.append(std::to_string(item));
                 return true;
-            } else if constexpr (std::is_same_v<Item, std::string>) {
+            } else if constexpr (std::is_same_v<Item, std::string>) { // Non-ASCII is refused.
                 return append_quoted(item, output);
-            } else if constexpr (std::is_same_v<Item, Value::Array>) {
+            } else if constexpr (std::is_same_v<Item, Value::Array>) { // Objects take the last arm.
                 return render_array(item, dialect, depth, output);
             } else {
                 return render_object(item, dialect, depth, output);
@@ -181,8 +182,8 @@ Value array(Value::Array values) {
 }
 
 Value object(Value::Object values) {
-    std::sort(values.begin(), values.end(), [](const auto& left, const auto& right) {
-        return left.first < right.first;
+    std::sort(values.begin(), values.end(), [](const auto& first, const auto& second) {
+        return first.first < second.first;
     });
     return Value{std::move(values)};
 }
@@ -227,6 +228,7 @@ std::string_view text(const Source& source, format::StringRef reference) noexcep
 
 /** Encodes the 32-byte digest in lowercase with an optional sha256 prefix. */
 std::string digest_hex(const std::array<std::byte, 32>& digest, bool prefix) {
+    // The digest is published in lowercase hex.
     static constexpr char kHex[] = "0123456789abcdef";
     std::string output;
     output.reserve((prefix ? 7U : 0U) + digest.size() * 2U);

+ 2 - 2
Sunrise/src/client/content/activity/activity_sdk_lua_world_contract.cpp

@@ -12,8 +12,8 @@ namespace {
 using Field = std::pair<std::string_view, std::string_view>;
 
 void sort_keys(Value::Object& value) {
-    std::sort(value.begin(), value.end(), [](const auto& left, const auto& right) {
-        return left.first < right.first;
+    std::sort(value.begin(), value.end(), [](const auto& first, const auto& second) {
+        return first.first < second.first;
     });
 }
 

+ 3 - 2
Sunrise/src/client/content/activity/activity_sdk_lua_world_core_views.cpp

@@ -12,8 +12,8 @@ namespace {
 using Field = std::pair<std::string_view, std::string_view>;
 
 void sort_keys(Value::Object& value) {
-    std::sort(value.begin(), value.end(), [](const auto& left, const auto& right) {
-        return left.first < right.first;
+    std::sort(value.begin(), value.end(), [](const auto& first, const auto& second) {
+        return first.first < second.first;
     });
 }
 
@@ -75,6 +75,7 @@ void append_name_fields(Value::Object& output, std::string_view prefix) {
 
 } // namespace
 
+/** Appends the core world view declarations to the contract object. */
 void append_world_core_views(Value::Object& output) {
     output.push_back(
         {"WorldView",

+ 2 - 2
Sunrise/src/client/content/activity/activity_sdk_lua_world_spatial_views.cpp

@@ -12,8 +12,8 @@ namespace {
 using Field = std::pair<std::string_view, std::string_view>;
 
 void sort_keys(Value::Object& value) {
-    std::sort(value.begin(), value.end(), [](const auto& left, const auto& right) {
-        return left.first < right.first;
+    std::sort(value.begin(), value.end(), [](const auto& first, const auto& second) {
+        return first.first < second.first;
     });
 }
 

+ 60 - 0
Sunrise/src/client/content/activity/activity_sdk_native_pack_internal.h

@@ -0,0 +1,60 @@
+#pragma once
+
+#include <cstdint>
+#include <memory>
+#include <vector>
+
+#include "activity_sdk_authored_scene_inventory.h"
+#include "activity_sdk_native_pack_pipeline.h"
+#include "activity_sdk_squad_inventory.h"
+#include "activity_sdk_topology_enrichment.h"
+
+namespace sunrise::client::content::activity::sdk_generation::native_pack_pipeline {
+
+namespace reader = middleware::content::packages::reader;
+namespace authored_scene = authored_scene_inventory;
+namespace squads = squad_inventory;
+namespace topology_enrichment = sdk_generation::topology_enrichment;
+
+/** One cache-owning package reader shared by squad and authored-scene extraction. */
+struct PackageContext final {
+    const reader::Source* source{};
+    std::unique_ptr<reader::Scratch> scratch{};
+    CancelProbe cancel{};
+    void* cancelContext{};
+
+    ~PackageContext() noexcept {
+        if (scratch != nullptr) {
+            reader::close_files(*scratch);
+        }
+    }
+};
+
+/** @return True when the caller asked this pass to stop. */
+[[nodiscard]] bool cancelled(CancelProbe probe, void* context) noexcept;
+
+/** Adapts the checked package reader to the squad and authored-scene boundaries. */
+[[nodiscard]] bool read_tag(void* opaque,
+                            std::uint32_t tag,
+                            std::vector<std::byte>& bytes,
+                            std::uint32_t& classId) noexcept;
+
+/** Reads one tag only when its physical class matches the typed edge being followed. */
+[[nodiscard]] bool read_localized_tag(void* opaque,
+                                      std::uint32_t tag,
+                                      std::uint32_t expectedClass,
+                                      std::vector<std::byte>& bytes) noexcept;
+
+/** Extracts localized dialogue aliases and safe authored directive elements. */
+[[nodiscard]] bool attach_authored_text(const topology_inventory::Snapshot& topology,
+                                        const squads::Facts& facts,
+                                        PackageContext& packageContext,
+                                        authored_scene::Snapshot& output);
+
+/** Resolves the native type-53 authored list and attaches its exact bound to the SDK slot row. */
+[[nodiscard]] bool attach_dialogue_cue_counts(const topology_inventory::Snapshot& topology,
+                                              const squads::Facts& facts,
+                                              PackageContext& packageContext,
+                                              topology_enrichment::Snapshot& enrichment);
+
+} // namespace sunrise::client::content::activity::sdk_generation::native_pack_pipeline

+ 29 - 485
Sunrise/src/client/content/activity/activity_sdk_native_pack_pipeline.cpp

@@ -15,14 +15,13 @@
 #include <utility>
 #include <vector>
 
-#include "../../../middleware/content/packages/tables/activity_display_name_reader.h"
 #include "../../../state/activity_sdk/runtime.h"
 #include "activity_sdk_activity_enrichment_inventory.h"
 #include "activity_sdk_actor_rsat_inventory.h"
 #include "activity_sdk_authored_scene_inventory.h"
 #include "activity_sdk_behavior_inventory.h"
-#include "activity_sdk_dialogue_group_index.h"
 #include "activity_sdk_lua_artifacts.h"
+#include "activity_sdk_native_pack_internal.h"
 #include "activity_sdk_pack_composer.h"
 #include "activity_sdk_policy_input_adapter.h"
 #include "activity_sdk_policy_inventory.h"
@@ -30,43 +29,50 @@
 #include "activity_sdk_topology_enrichment.h"
 
 namespace sunrise::client::content::activity::sdk_generation::native_pack_pipeline {
+
+bool cancelled(CancelProbe probe, void* context) noexcept {
+    return probe != nullptr && probe(context);
+}
+
+/** Adapts the checked package reader to the squad and authored-scene boundaries. */
+bool read_tag(void* opaque,
+              std::uint32_t tag,
+              std::vector<std::byte>& bytes,
+              std::uint32_t& classId) noexcept {
+    bytes.clear();
+    if (opaque == nullptr) {
+        return false;
+    }
+    auto& context = *static_cast<PackageContext*>(opaque);
+    return context.source != nullptr && context.scratch != nullptr
+           && !cancelled(context.cancel, context.cancelContext)
+           && reader::read_tag(*context.source, *context.scratch, tag, bytes, classId);
+}
+
+bool read_localized_tag(void* opaque,
+                        std::uint32_t tag,
+                        std::uint32_t expectedClass,
+                        std::vector<std::byte>& bytes) noexcept {
+    std::uint32_t classId = 0;
+    return read_tag(opaque, tag, bytes, classId) && classId == expectedClass;
+}
 namespace {
 
-namespace reader = middleware::content::packages::reader;
 namespace format = state::activity_sdk::format;
 namespace activity_enrichment = activity_enrichment_inventory;
 namespace actor_rsat = actor_rsat_inventory;
 namespace authored_scene = authored_scene_inventory;
 namespace behaviors = behavior_inventory;
-namespace dialogue_groups = dialogue_group_index;
 namespace lua = lua_artifacts;
 namespace composer = pack_composer;
 namespace policy_adapter = policy_input_adapter;
 namespace policy = policy_inventory;
 namespace squads = squad_inventory;
 namespace topology_enrichment = sdk_generation::topology_enrichment;
-namespace display = middleware::content::packages::tables::activity_display_names;
 
 static_assert(sizeof(std::size_t) <= sizeof(std::uint64_t));
 
-/** One cache-owning package reader shared by squad and authored-scene extraction. */
-struct PackageContext final {
-    const reader::Source* source{};
-    std::unique_ptr<reader::Scratch> scratch{};
-    CancelProbe cancel{};
-    void* cancelContext{};
-
-    ~PackageContext() noexcept {
-        if (scratch != nullptr) {
-            reader::close_files(*scratch);
-        }
-    }
-};
-
-[[nodiscard]] bool cancelled(CancelProbe probe, void* context) noexcept {
-    return probe != nullptr && probe(context);
-}
-
+/** Appends one live tag to the caller's vector. @return False when the tag or sink is unusable. */
 [[nodiscard]] bool collect_tag(void* opaque, std::uint32_t tag) noexcept {
     if (opaque == nullptr || tag == 0 || tag == format::kAbsentIndex) {
         return false;
@@ -103,468 +109,6 @@ void report(ProgressProbe probe, void* context, Phase phase) noexcept {
     const DWORD attributes = GetFileAttributesW(path.c_str());
     return attributes != INVALID_FILE_ATTRIBUTES && (attributes & FILE_ATTRIBUTE_DIRECTORY) != 0;
 }
-
-/** Adapts the checked package reader to the squad and authored-scene boundaries. */
-[[nodiscard]] bool read_tag(void* opaque,
-                            std::uint32_t tag,
-                            std::vector<std::byte>& bytes,
-                            std::uint32_t& classId) noexcept {
-    bytes.clear();
-    if (opaque == nullptr) {
-        return false;
-    }
-    auto& context = *static_cast<PackageContext*>(opaque);
-    return context.source != nullptr && context.scratch != nullptr
-           && !cancelled(context.cancel, context.cancelContext)
-           && reader::read_tag(*context.source, *context.scratch, tag, bytes, classId);
-}
-
-[[nodiscard]] bool read_localized_tag(void* opaque,
-                                      std::uint32_t tag,
-                                      std::uint32_t expectedClass,
-                                      std::vector<std::byte>& bytes) noexcept {
-    std::uint32_t classId = 0;
-    return read_tag(opaque, tag, bytes, classId) && classId == expectedClass;
-}
-
-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;
-}
-
-[[nodiscard]] bool
-add_relative(std::size_t member, std::int64_t relative, std::size_t& target) noexcept {
-    if (relative >= 0) {
-        const auto distance = static_cast<std::uint64_t>(relative);
-        if (distance > (std::numeric_limits<std::size_t>::max)() - 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 declared class and stride. */
-[[nodiscard]] bool read_array(std::span<const std::byte> bytes,
-                              std::size_t field,
-                              std::size_t stride,
-                              std::uint32_t expectedClass,
-                              std::size_t& data,
-                              std::size_t& count) noexcept {
-    std::uint64_t rawCount = 0;
-    std::int64_t relative = 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 > format::kAbsentIndex
-        || !read_value(bytes, field + 8U, relative) || !add_relative(field + 8U, relative, header)
-        || !read_value(bytes, header, repeated) || repeated != rawCount
-        || !read_value(bytes, header + 8U, classId) || classId != expectedClass
-        || !read_value(bytes, header + 12U, padding) || padding != 0
-        || rawCount > (std::numeric_limits<std::size_t>::max)() / stride) {
-        return false;
-    }
-    data = header + 16U;
-    count = static_cast<std::size_t>(rawCount);
-    return data <= bytes.size() && count * stride <= bytes.size() - data;
-}
-
-[[nodiscard]] bool copy_text(std::string_view value, authored_scene::Text& output) noexcept {
-    output = {};
-    if (value.empty() || value.size() >= output.value.size()
-        || value.size() > (std::numeric_limits<std::uint16_t>::max)()) {
-        return false;
-    }
-    std::copy(value.begin(), value.end(), output.value.begin());
-    output.length = static_cast<std::uint16_t>(value.size());
-    return true;
-}
-
-struct AuthoredTextCandidate final {
-    display::Reference reference{};
-    std::uint32_t slotIndex{};
-    std::uint32_t cueIndex{format::kAbsentIndex};
-    std::uint32_t definitionHash{};
-};
-
-/** The two localized fields are one directive element, not two selectable directives. */
-struct AuthoredDirectiveCandidate final {
-    display::Reference title{};
-    display::Reference description{};
-    std::uint32_t slotIndex{};
-    std::uint32_t nameHash{};
-    std::int32_t elementIndex{-1};
-    std::uint32_t elementCount{};
-};
-
-/** Extracts localized dialogue aliases and safe authored directive elements. */
-[[nodiscard]] bool attach_authored_text(const topology_inventory::Snapshot& topology,
-                                        const squads::Facts& facts,
-                                        PackageContext& packageContext,
-                                        authored_scene::Snapshot& output) {
-    struct CachedTag final {
-        std::vector<std::byte> bytes{};
-        std::uint32_t classId{};
-    };
-    std::unordered_map<std::uint32_t, CachedTag> cache{};
-    auto package = [&](std::uint32_t tag, const CachedTag*& result) -> bool {
-        const auto found = cache.find(tag);
-        if (found != cache.end()) {
-            result = &found->second;
-            return true;
-        }
-        CachedTag row{};
-        if (!read_tag(&packageContext, tag, row.bytes, row.classId)) {
-            return false;
-        }
-        result = &cache.emplace(tag, std::move(row)).first->second;
-        return true;
-    };
-    try {
-        std::vector<AuthoredTextCandidate> dialogueCandidates{};
-        std::vector<AuthoredDirectiveCandidate> directiveCandidates{};
-        for (const squads::DescriptorFact& descriptor : facts.descriptors) {
-            if (descriptor.slotIndex >= topology.slots.size()) {
-                continue;
-            }
-            const std::uint32_t slotType = topology.slots[descriptor.slotIndex].slotType;
-            if (slotType != format::kDialogueSlotType && slotType != 68U) {
-                continue;
-            }
-            const CachedTag* config = nullptr;
-            if (!package(descriptor.configTag, config) || config == nullptr) {
-                continue;
-            }
-            const std::size_t field = static_cast<std::size_t>(descriptor.descriptorOffset)
-                                      + (slotType == format::kDialogueSlotType ? 0x58U : 0x5CU);
-            std::uint32_t resourceTag = 0;
-            const CachedTag* resource = nullptr;
-            if (!read_value(std::span(config->bytes), field, resourceTag)
-                || !package(resourceTag, resource) || resource == nullptr) {
-                continue;
-            }
-            const auto bytes = std::span(resource->bytes);
-            if (slotType == format::kDialogueSlotType) {
-                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)) {
-                    continue;
-                }
-                std::size_t definitions = 0;
-                std::size_t definitionCount = 0;
-                if (!read_array(bytes,
-                                8U,
-                                8U,
-                                format::kDialogueDefinitionArrayClass,
-                                definitions,
-                                definitionCount)) {
-                    continue;
-                }
-                std::vector<dialogue_groups::Span> groupIndex{};
-                if (!dialogue_groups::build(bytes, groups, groupCount, groupIndex)) {
-                    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;
-                    }
-                    dialogue_groups::Span group{};
-                    if (!dialogue_groups::find(groupIndex, definitionHash, group)) {
-                        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;
-                        }
-                        dialogueCandidates.push_back({{containerTag, stringHash},
-                                                      descriptor.slotIndex,
-                                                      static_cast<std::uint32_t>(cue),
-                                                      definitionHash});
-                    }
-                }
-            } else {
-                if (resource->classId != 0x80804F72U) {
-                    continue;
-                }
-                std::size_t entries = 0;
-                std::size_t entryCount = 0;
-                if (!read_array(bytes, 8U, 40U, 0x80804F74U, entries, entryCount)) {
-                    continue;
-                }
-                for (std::size_t entry = 0; entry < entryCount; ++entry) {
-                    const std::size_t row = entries + entry * 40U;
-                    std::uint32_t nameHash = 0;
-                    std::int64_t relative = 0;
-                    std::size_t elements = 0;
-                    std::uint64_t elementCount = 0;
-                    std::uint32_t elementClass = 0;
-                    if (!read_value(bytes, row, nameHash) || !read_value(bytes, row + 24U, relative)
-                        || !add_relative(row + 24U, relative, elements)
-                        || !read_value(bytes, elements, elementCount) || elementCount == 0
-                        || elementCount > format::kAbsentIndex
-                        || !read_value(bytes, elements + 8U, elementClass)
-                        || elementClass != 0x80804F76U) {
-                        continue;
-                    }
-                    const std::size_t data = elements + 16U;
-                    if (data > bytes.size() || elementCount > (bytes.size() - data) / 36U) {
-                        continue;
-                    }
-                    for (std::size_t element = 0; element < elementCount; ++element) {
-                        const std::size_t elementRow = data + element * 36U;
-                        AuthoredDirectiveCandidate candidate{};
-                        candidate.slotIndex = descriptor.slotIndex;
-                        candidate.nameHash = nameHash;
-                        candidate.elementIndex = static_cast<std::int32_t>(element);
-                        candidate.elementCount = static_cast<std::uint32_t>(elementCount);
-                        if (!read_value(bytes, elementRow, candidate.title.containerTag)
-                            || !read_value(bytes, elementRow + 4U, candidate.title.stringHash)
-                            || !read_value(
-                                bytes, elementRow + 8U, candidate.description.containerTag)
-                            || !read_value(
-                                bytes, elementRow + 12U, candidate.description.stringHash)) {
-                            continue;
-                        }
-                        const CachedTag* title = nullptr;
-                        const CachedTag* description = nullptr;
-                        if (!package(candidate.title.containerTag, title) || title == nullptr
-                            || title->classId != display::kStringContainerClass
-                            || !package(candidate.description.containerTag, description)
-                            || description == nullptr
-                            || description->classId != display::kStringContainerClass) {
-                            continue;
-                        }
-                        directiveCandidates.push_back(candidate);
-                    }
-                }
-            }
-        }
-        std::vector<display::Reference> references{};
-        references.reserve(dialogueCandidates.size() + directiveCandidates.size() * 2U);
-        for (const AuthoredTextCandidate& row : dialogueCandidates) {
-            references.push_back(row.reference);
-        }
-        for (const AuthoredDirectiveCandidate& row : directiveCandidates) {
-            references.push_back(row.title);
-            references.push_back(row.description);
-        }
-        if (references.empty()) {
-            return true;
-        }
-        display::Snapshot names{};
-        if (!display::resolve({&packageContext, &read_localized_tag, 0, 0}, references, names)
-            || names.names.size() != references.size()) {
-            return true;
-        }
-        for (std::size_t index = 0; index < dialogueCandidates.size(); ++index) {
-            const display::Name& name = names.names[index];
-            if (name.authoredEmpty || name.length == 0) {
-                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",
-                                             candidate.slotIndex,
-                                             candidate.cueIndex,
-                                             candidate.definitionHash,
-                                             candidate.reference.stringHash);
-            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)
-                || !copy_text(text, row.text)) {
-                continue;
-            }
-            row.slotIndex = candidate.slotIndex;
-            row.cueIndex = candidate.cueIndex;
-            row.definitionHash = candidate.definitionHash;
-            row.containerTag = candidate.reference.containerTag;
-            row.stringHash = candidate.reference.stringHash;
-            output.dialogueCueTexts.push_back(row);
-        }
-        std::size_t resolved = dialogueCandidates.size();
-        for (const AuthoredDirectiveCandidate& candidate : directiveCandidates) {
-            const display::Name& title = names.names[resolved++];
-            const display::Name& description = names.names[resolved++];
-            if (title.authoredEmpty || title.length == 0 || description.authoredEmpty
-                || description.length == 0) {
-                continue;
-            }
-            char id[96]{};
-            const int length = std::snprintf(id,
-                                             sizeof id,
-                                             "directive/%08x/%08x/%d",
-                                             candidate.slotIndex,
-                                             candidate.nameHash,
-                                             candidate.elementIndex);
-            authored_scene::DirectiveElement 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)
-                || !copy_text(std::string_view(title.value.data(), title.length), row.title)
-                || !copy_text(std::string_view(description.value.data(), description.length),
-                              row.description)) {
-                continue;
-            }
-            row.slotIndex = candidate.slotIndex;
-            row.nameHash = candidate.nameHash;
-            row.elementIndex = candidate.elementIndex;
-            row.elementCount = candidate.elementCount;
-            row.titleContainerTag = candidate.title.containerTag;
-            row.titleStringHash = candidate.title.stringHash;
-            row.descriptionContainerTag = candidate.description.containerTag;
-            row.descriptionStringHash = candidate.description.stringHash;
-            output.directiveElements.push_back(row);
-        }
-        auto dialogueLess = [](const auto& left, const auto& right) {
-            return std::tie(left.slotIndex, left.cueIndex, left.definitionHash, left.stringHash)
-                   < std::tie(
-                       right.slotIndex, right.cueIndex, right.definitionHash, right.stringHash);
-        };
-        auto directiveLess = [](const auto& left, const auto& right) {
-            return std::tie(left.slotIndex, left.nameHash, left.elementIndex)
-                   < std::tie(right.slotIndex, right.nameHash, right.elementIndex);
-        };
-        std::sort(output.dialogueCueTexts.begin(), output.dialogueCueTexts.end(), dialogueLess);
-        std::sort(output.directiveElements.begin(), output.directiveElements.end(), directiveLess);
-        output.dialogueCueTexts.erase(std::unique(output.dialogueCueTexts.begin(),
-                                                  output.dialogueCueTexts.end(),
-                                                  [&](const auto& left, const auto& right) {
-                                                      return !dialogueLess(left, right)
-                                                             && !dialogueLess(right, left);
-                                                  }),
-                                      output.dialogueCueTexts.end());
-        output.directiveElements.erase(std::unique(output.directiveElements.begin(),
-                                                   output.directiveElements.end(),
-                                                   [&](const auto& left, const auto& right) {
-                                                       return !directiveLess(left, right)
-                                                              && !directiveLess(right, left);
-                                                   }),
-                                       output.directiveElements.end());
-        return true;
-    } catch (...) {
-        return false;
-    }
-}
-
-/** Resolves the native type-53 authored list and attaches its exact bound to the SDK slot row. */
-[[nodiscard]] bool attach_dialogue_cue_counts(const topology_inventory::Snapshot& topology,
-                                              const squads::Facts& facts,
-                                              PackageContext& packageContext,
-                                              topology_enrichment::Snapshot& enrichment) {
-    if (topology.slots.size() != enrichment.slots.size()) {
-        return false;
-    }
-    struct CachedTag final {
-        std::vector<std::byte> bytes{};
-        std::uint32_t classId{};
-    };
-    std::unordered_map<std::uint32_t, CachedTag> cache{};
-    auto package = [&](std::uint32_t tag, const CachedTag*& output) -> bool {
-        output = nullptr;
-        const auto found = cache.find(tag);
-        if (found != cache.end()) {
-            output = &found->second;
-            return true;
-        }
-        CachedTag row{};
-        if (tag == 0 || tag == format::kAbsentIndex
-            || !read_tag(&packageContext, tag, row.bytes, row.classId)) {
-            return false;
-        }
-        const auto [inserted, accepted] = cache.emplace(tag, std::move(row));
-        if (!accepted) {
-            return false;
-        }
-        output = &inserted->second;
-        return true;
-    };
-
-    try {
-        cache.reserve(facts.descriptors.size());
-        for (std::uint32_t slotRow = 0; slotRow < topology.slots.size(); ++slotRow) {
-            if (topology.slots[slotRow].slotType != format::kDialogueSlotType) {
-                continue;
-            }
-            topology_enrichment::Slot& enriched = enrichment.slots[slotRow];
-            if (enriched.componentClass != format::kDialogueComponentClass
-                || enriched.authSchema != format::kDialogueAuthSchema
-                || (enriched.flags & format::kSlotSchemaJoinExact) == 0) {
-                continue;
-            }
-            std::uint64_t agreedCount = 0;
-            bool sawDescriptor = false;
-            bool resolved = true;
-            for (const squads::DescriptorFact& descriptor : facts.descriptors) {
-                if (descriptor.slotIndex != slotRow) {
-                    continue;
-                }
-                sawDescriptor = true;
-                const CachedTag* config = nullptr;
-                if (!package(descriptor.configTag, config) || config == nullptr) {
-                    resolved = false;
-                    break;
-                }
-                const std::size_t listField = static_cast<std::size_t>(descriptor.descriptorOffset)
-                                              + format::kDialogueAuthoredListRelativeOffset;
-                std::uint32_t listTag = 0;
-                if (!read_value(std::span(config->bytes), listField, listTag) || listTag == 0
-                    || listTag == format::kAbsentIndex) {
-                    resolved = false;
-                    break;
-                }
-                const CachedTag* authored = nullptr;
-                std::uint64_t count = 0;
-                if (!package(listTag, authored) || authored == nullptr
-                    || !read_value(std::span(authored->bytes), 8U, count) || count == 0
-                    || count > format::kDialogueMaximumCueCount
-                    || (agreedCount != 0 && agreedCount != count)) {
-                    resolved = false;
-                    break;
-                }
-                agreedCount = count;
-            }
-            if (cancelled(packageContext.cancel, packageContext.cancelContext)) {
-                return false;
-            }
-            if (resolved && sawDescriptor && agreedCount != 0) {
-                enriched.dialogueCueCount = static_cast<std::uint32_t>(agreedCount);
-                enriched.flags |= format::kSlotDialogueCuesExact;
-            }
-        }
-        return true;
-    } catch (...) {
-        return false;
-    }
-}
-
 /** Builds one schema fact for every final catalog-global slot row. */
 [[nodiscard]] bool build_slot_schemas(const topology_inventory::Snapshot& topology,
                                       const topology_enrichment::Snapshot& enrichment,

+ 466 - 0
Sunrise/src/client/content/activity/activity_sdk_native_pack_text.cpp

@@ -0,0 +1,466 @@
+#include <algorithm>
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <cstring>
+#include <limits>
+#include <span>
+#include <string>
+#include <string_view>
+#include <unordered_map>
+#include <utility>
+#include <vector>
+
+#include "../../../middleware/content/packages/tables/activity_display_name_reader.h"
+#include "activity_sdk_dialogue_group_index.h"
+#include "activity_sdk_native_pack_internal.h"
+
+namespace sunrise::client::content::activity::sdk_generation::native_pack_pipeline {
+namespace {
+
+namespace format = state::activity_sdk::format;
+namespace dialogue_groups = dialogue_group_index;
+namespace display = middleware::content::packages::tables::activity_display_names;
+
+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;
+}
+
+/** Applies one signed blob-relative offset. @return False when the result leaves the blob. */
+[[nodiscard]] bool
+add_relative(std::size_t member, std::int64_t relative, std::size_t& target) noexcept {
+    if (relative >= 0) {
+        const auto distance = static_cast<std::uint64_t>(relative);
+        if (distance > (std::numeric_limits<std::size_t>::max)() - 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 declared class and stride. */
+[[nodiscard]] bool read_array(std::span<const std::byte> bytes,
+                              std::size_t field,
+                              std::size_t stride,
+                              std::uint32_t expectedClass,
+                              std::size_t& data,
+                              std::size_t& count) noexcept {
+    std::uint64_t rawCount = 0;
+    std::int64_t relative = 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 > format::kAbsentIndex
+        || !read_value(bytes, field + 8U, relative) || !add_relative(field + 8U, relative, header)
+        || !read_value(bytes, header, repeated) || repeated != rawCount
+        || !read_value(bytes, header + 8U, classId) || classId != expectedClass
+        || !read_value(bytes, header + 12U, padding) || padding != 0
+        || rawCount > (std::numeric_limits<std::size_t>::max)() / stride) {
+        return false;
+    }
+    data = header + 16U;
+    count = static_cast<std::size_t>(rawCount);
+    return data <= bytes.size() && count * stride <= bytes.size() - data;
+}
+
+[[nodiscard]] bool copy_text(std::string_view value, authored_scene::Text& output) noexcept {
+    output = {};
+    if (value.empty() || value.size() >= output.value.size()
+        || value.size() > (std::numeric_limits<std::uint16_t>::max)()) {
+        return false;
+    }
+    std::copy(value.begin(), value.end(), output.value.begin());
+    output.length = static_cast<std::uint16_t>(value.size());
+    return true;
+}
+
+struct AuthoredTextCandidate final {
+    display::Reference reference{};
+    std::uint32_t slotIndex{};
+    std::uint32_t cueIndex{format::kAbsentIndex};
+    std::uint32_t definitionHash{};
+};
+
+/** The two localized fields are one directive element, not two selectable directives. */
+struct AuthoredDirectiveCandidate final {
+    display::Reference title{};
+    display::Reference description{};
+    std::uint32_t slotIndex{};
+    std::uint32_t nameHash{};
+    std::int32_t elementIndex{-1};
+    std::uint32_t elementCount{};
+};
+
+} // namespace
+
+/** Extracts localized dialogue aliases and safe authored directive elements. */
+bool attach_authored_text(const topology_inventory::Snapshot& topology,
+                          const squads::Facts& facts,
+                          PackageContext& packageContext,
+                          authored_scene::Snapshot& output) {
+    struct CachedTag final {
+        std::vector<std::byte> bytes{};
+        std::uint32_t classId{};
+    };
+    std::unordered_map<std::uint32_t, CachedTag> cache{};
+    auto package = [&](std::uint32_t tag, const CachedTag*& result) -> bool {
+        const auto found = cache.find(tag);
+        if (found != cache.end()) {
+            result = &found->second;
+            return true;
+        }
+        CachedTag row{};
+        if (!read_tag(&packageContext, tag, row.bytes, row.classId)) {
+            return false;
+        }
+        result = &cache.emplace(tag, std::move(row)).first->second;
+        return true;
+    };
+    try {
+        std::vector<AuthoredTextCandidate> dialogueCandidates{};
+        std::vector<AuthoredDirectiveCandidate> directiveCandidates{};
+        for (const squads::DescriptorFact& descriptor : facts.descriptors) {
+            if (descriptor.slotIndex >= topology.slots.size()) {
+                continue;
+            }
+            const std::uint32_t slotType = topology.slots[descriptor.slotIndex].slotType;
+            if (slotType != format::kDialogueSlotType && slotType != 68U) {
+                continue;
+            }
+            const CachedTag* config = nullptr;
+            if (!package(descriptor.configTag, config) || config == nullptr) {
+                continue;
+            }
+            const std::size_t field = static_cast<std::size_t>(descriptor.descriptorOffset)
+                                      + (slotType == format::kDialogueSlotType ? 0x58U : 0x5CU);
+            std::uint32_t resourceTag = 0;
+            const CachedTag* resource = nullptr;
+            if (!read_value(std::span(config->bytes), field, resourceTag)
+                || !package(resourceTag, resource) || resource == nullptr) {
+                continue;
+            }
+            const auto bytes = std::span(resource->bytes);
+            if (slotType == format::kDialogueSlotType) {
+                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)) {
+                    continue;
+                }
+                std::size_t definitions = 0;
+                std::size_t definitionCount = 0;
+                if (!read_array(bytes,
+                                8U,
+                                8U,
+                                format::kDialogueDefinitionArrayClass,
+                                definitions,
+                                definitionCount)) {
+                    continue;
+                }
+                std::vector<dialogue_groups::Span> groupIndex{};
+                if (!dialogue_groups::build(bytes, groups, groupCount, groupIndex)) {
+                    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;
+                    }
+                    dialogue_groups::Span group{};
+                    if (!dialogue_groups::find(groupIndex, definitionHash, group)) {
+                        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;
+                        }
+                        dialogueCandidates.push_back({{containerTag, stringHash},
+                                                      descriptor.slotIndex,
+                                                      static_cast<std::uint32_t>(cue),
+                                                      definitionHash});
+                    }
+                }
+            } else {
+                if (resource->classId != 0x80804F72U) {
+                    continue;
+                }
+                std::size_t entries = 0;
+                std::size_t entryCount = 0;
+                if (!read_array(bytes, 8U, 40U, 0x80804F74U, entries, entryCount)) {
+                    continue;
+                }
+                for (std::size_t entry = 0; entry < entryCount; ++entry) {
+                    const std::size_t row = entries + entry * 40U;
+                    std::uint32_t nameHash = 0;
+                    std::int64_t relative = 0;
+                    std::size_t elements = 0;
+                    std::uint64_t elementCount = 0;
+                    std::uint32_t elementClass = 0;
+                    if (!read_value(bytes, row, nameHash) || !read_value(bytes, row + 24U, relative)
+                        || !add_relative(row + 24U, relative, elements)
+                        || !read_value(bytes, elements, elementCount) || elementCount == 0
+                        || elementCount > format::kAbsentIndex
+                        || !read_value(bytes, elements + 8U, elementClass)
+                        || elementClass != 0x80804F76U) {
+                        continue;
+                    }
+                    const std::size_t data = elements + 16U;
+                    if (data > bytes.size() || elementCount > (bytes.size() - data) / 36U) {
+                        continue;
+                    }
+                    for (std::size_t element = 0; element < elementCount; ++element) {
+                        const std::size_t elementRow = data + element * 36U;
+                        AuthoredDirectiveCandidate candidate{};
+                        candidate.slotIndex = descriptor.slotIndex;
+                        candidate.nameHash = nameHash;
+                        candidate.elementIndex = static_cast<std::int32_t>(element);
+                        candidate.elementCount = static_cast<std::uint32_t>(elementCount);
+                        if (!read_value(bytes, elementRow, candidate.title.containerTag)
+                            || !read_value(bytes, elementRow + 4U, candidate.title.stringHash)
+                            || !read_value(
+                                bytes, elementRow + 8U, candidate.description.containerTag)
+                            || !read_value(
+                                bytes, elementRow + 12U, candidate.description.stringHash)) {
+                            continue;
+                        }
+                        const CachedTag* title = nullptr;
+                        const CachedTag* description = nullptr;
+                        if (!package(candidate.title.containerTag, title) || title == nullptr
+                            || title->classId != display::kStringContainerClass
+                            || !package(candidate.description.containerTag, description)
+                            || description == nullptr
+                            || description->classId != display::kStringContainerClass) {
+                            continue;
+                        }
+                        directiveCandidates.push_back(candidate);
+                    }
+                }
+            }
+        }
+        std::vector<display::Reference> references{};
+        references.reserve(dialogueCandidates.size() + directiveCandidates.size() * 2U);
+        for (const AuthoredTextCandidate& row : dialogueCandidates) {
+            references.push_back(row.reference);
+        }
+        for (const AuthoredDirectiveCandidate& row : directiveCandidates) {
+            references.push_back(row.title);
+            references.push_back(row.description);
+        }
+        if (references.empty()) {
+            return true;
+        }
+        display::Snapshot names{};
+        if (!display::resolve({&packageContext, &read_localized_tag, 0, 0}, references, names)
+            || names.names.size() != references.size()) {
+            return true;
+        }
+        for (std::size_t index = 0; index < dialogueCandidates.size(); ++index) {
+            const display::Name& name = names.names[index];
+            if (name.authoredEmpty || name.length == 0) {
+                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",
+                                             candidate.slotIndex,
+                                             candidate.cueIndex,
+                                             candidate.definitionHash,
+                                             candidate.reference.stringHash);
+            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)
+                || !copy_text(text, row.text)) {
+                continue;
+            }
+            row.slotIndex = candidate.slotIndex;
+            row.cueIndex = candidate.cueIndex;
+            row.definitionHash = candidate.definitionHash;
+            row.containerTag = candidate.reference.containerTag;
+            row.stringHash = candidate.reference.stringHash;
+            output.dialogueCueTexts.push_back(row);
+        }
+        std::size_t resolved = dialogueCandidates.size();
+        for (const AuthoredDirectiveCandidate& candidate : directiveCandidates) {
+            const display::Name& title = names.names[resolved++];
+            const display::Name& description = names.names[resolved++];
+            if (title.authoredEmpty || title.length == 0 || description.authoredEmpty
+                || description.length == 0) {
+                continue;
+            }
+            char id[96]{};
+            const int length = std::snprintf(id,
+                                             sizeof id,
+                                             "directive/%08x/%08x/%d",
+                                             candidate.slotIndex,
+                                             candidate.nameHash,
+                                             candidate.elementIndex);
+            authored_scene::DirectiveElement 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)
+                || !copy_text(std::string_view(title.value.data(), title.length), row.title)
+                || !copy_text(std::string_view(description.value.data(), description.length),
+                              row.description)) {
+                continue;
+            }
+            row.slotIndex = candidate.slotIndex;
+            row.nameHash = candidate.nameHash;
+            row.elementIndex = candidate.elementIndex;
+            row.elementCount = candidate.elementCount;
+            row.titleContainerTag = candidate.title.containerTag;
+            row.titleStringHash = candidate.title.stringHash;
+            row.descriptionContainerTag = candidate.description.containerTag;
+            row.descriptionStringHash = candidate.description.stringHash;
+            output.directiveElements.push_back(row);
+        }
+        auto dialogueLess = [](const auto& first, const auto& second) {
+            return std::tie(first.slotIndex, first.cueIndex, first.definitionHash, first.stringHash)
+                   < std::tie(
+                       second.slotIndex, second.cueIndex, second.definitionHash, second.stringHash);
+        };
+        auto directiveLess = [](const auto& first, const auto& second) {
+            return std::tie(first.slotIndex, first.nameHash, first.elementIndex)
+                   < std::tie(second.slotIndex, second.nameHash, second.elementIndex);
+        };
+        std::sort(output.dialogueCueTexts.begin(), output.dialogueCueTexts.end(), dialogueLess);
+        std::sort(output.directiveElements.begin(), output.directiveElements.end(), directiveLess);
+        output.dialogueCueTexts.erase(std::unique(output.dialogueCueTexts.begin(),
+                                                  output.dialogueCueTexts.end(),
+                                                  [&](const auto& left, const auto& right) {
+                                                      return !dialogueLess(left, right)
+                                                             && !dialogueLess(right, left);
+                                                  }),
+                                      output.dialogueCueTexts.end());
+        output.directiveElements.erase(std::unique(output.directiveElements.begin(),
+                                                   output.directiveElements.end(),
+                                                   [&](const auto& left, const auto& right) {
+                                                       return !directiveLess(left, right)
+                                                              && !directiveLess(right, left);
+                                                   }),
+                                       output.directiveElements.end());
+        return true;
+    } catch (...) {
+        return false;
+    }
+}
+
+/** Resolves the native type-53 authored list and attaches its exact bound to the SDK slot row. */
+bool attach_dialogue_cue_counts(const topology_inventory::Snapshot& topology,
+                                const squads::Facts& facts,
+                                PackageContext& packageContext,
+                                topology_enrichment::Snapshot& enrichment) {
+    if (topology.slots.size() != enrichment.slots.size()) {
+        return false;
+    }
+    struct CachedTag final {
+        std::vector<std::byte> bytes{};
+        std::uint32_t classId{};
+    };
+    std::unordered_map<std::uint32_t, CachedTag> cache{};
+    auto package = [&](std::uint32_t tag, const CachedTag*& output) -> bool {
+        output = nullptr;
+        const auto found = cache.find(tag);
+        if (found != cache.end()) {
+            output = &found->second;
+            return true;
+        }
+        CachedTag row{};
+        if (tag == 0 || tag == format::kAbsentIndex
+            || !read_tag(&packageContext, tag, row.bytes, row.classId)) {
+            return false;
+        }
+        const auto [inserted, accepted] = cache.emplace(tag, std::move(row));
+        if (!accepted) {
+            return false;
+        }
+        output = &inserted->second;
+        return true;
+    };
+
+    try {
+        cache.reserve(facts.descriptors.size());
+        for (std::uint32_t slotRow = 0; slotRow < topology.slots.size(); ++slotRow) {
+            if (topology.slots[slotRow].slotType != format::kDialogueSlotType) {
+                continue;
+            }
+            topology_enrichment::Slot& enriched = enrichment.slots[slotRow];
+            if (enriched.componentClass != format::kDialogueComponentClass
+                || enriched.authSchema != format::kDialogueAuthSchema
+                || (enriched.flags & format::kSlotSchemaJoinExact) == 0) {
+                continue;
+            }
+            std::uint64_t agreedCount = 0;
+            bool sawDescriptor = false;
+            bool resolved = true;
+            for (const squads::DescriptorFact& descriptor : facts.descriptors) {
+                if (descriptor.slotIndex != slotRow) {
+                    continue;
+                }
+                sawDescriptor = true;
+                const CachedTag* config = nullptr;
+                if (!package(descriptor.configTag, config) || config == nullptr) {
+                    resolved = false;
+                    break;
+                }
+                const std::size_t listField = static_cast<std::size_t>(descriptor.descriptorOffset)
+                                              + format::kDialogueAuthoredListRelativeOffset;
+                std::uint32_t listTag = 0;
+                if (!read_value(std::span(config->bytes), listField, listTag) || listTag == 0
+                    || listTag == format::kAbsentIndex) {
+                    resolved = false;
+                    break;
+                }
+                const CachedTag* authored = nullptr;
+                std::uint64_t count = 0;
+                if (!package(listTag, authored) || authored == nullptr
+                    || !read_value(std::span(authored->bytes), 8U, count) || count == 0
+                    || count > format::kDialogueMaximumCueCount
+                    || (agreedCount != 0 && agreedCount != count)) {
+                    resolved = false;
+                    break;
+                }
+                agreedCount = count;
+            }
+            if (cancelled(packageContext.cancel, packageContext.cancelContext)) {
+                return false;
+            }
+            if (resolved && sawDescriptor && agreedCount != 0) {
+                enriched.dialogueCueCount = static_cast<std::uint32_t>(agreedCount);
+                enriched.flags |= format::kSlotDialogueCuesExact;
+            }
+        }
+        return true;
+    } catch (...) {
+        return false;
+    }
+}
+
+} // namespace sunrise::client::content::activity::sdk_generation::native_pack_pipeline

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

@@ -22,6 +22,7 @@ namespace generated = state::activity_sdk::generated_world;
 namespace publication = tree_publication;
 namespace estate = estate_validation;
 
+// A stage name carries three sequence words so two passes never collide.
 constexpr std::wstring_view kStageSuffix = L".stage.%08lX.%08lX.%08lX";
 
 volatile LONG g_stageSequence{};
@@ -162,6 +163,7 @@ void report(OfflineProgressSink sink,
                 directory, buffer.data(), needed, FILE_NAME_NORMALIZED | VOLUME_NAME_DOS);
             if (written != 0 && written < needed) {
                 buffer.resize(written);
+                // Windows returns the extended prefix; callers store the plain path.
                 constexpr std::wstring_view kExtended = L"\\\\?\\";
                 if (buffer.starts_with(kExtended)) {
                     buffer.erase(0, kExtended.size());

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

@@ -5,7 +5,9 @@
 
 namespace sunrise::client::content::activity::sdk_generation::one_shot::abi {
 
+// Progress and result records carry this ABI version.
 inline constexpr std::uint32_t kVersion = 1;
+// Digests are SHA-256, so 32 bytes.
 inline constexpr std::size_t kDigestSize = 32;
 
 struct Progress final {

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

@@ -55,6 +55,7 @@ void copy_result(Status status, const Result& source, public_abi::Result& output
     std::memcpy(output.payloadSha256, source.payloadSha256.data(), source.payloadSha256.size());
 }
 
+/** Copies one fingerprint. @return False when the size is wrong or every byte is zero. */
 [[nodiscard]] bool read_fingerprint(const std::uint8_t* bytes,
                                     std::uint32_t size,
                                     generated::Digest& output) noexcept {

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

@@ -756,6 +756,7 @@ translate_native_rows(const Inputs& inputs, const detail::StringResolver& linker
 
 } // namespace
 
+/** Translates topology, policy and native rows into pack storage. @return False on failure. */
 bool detail::translate_rows(const Inputs& inputs,
                             const PreparedRanges& ranges,
                             const StringResolver& strings,

+ 5 - 3
Sunrise/src/client/content/activity/activity_sdk_policy_input_validation.cpp

@@ -1,7 +1,8 @@
 #include <algorithm>
-#include <functional>
+#include <cstddef>
 #include <limits>
 #include <string>
+#include <string_view>
 #include <unordered_set>
 
 #include "../../../state/build_data/scriptables/inline_name_evidence.h"
@@ -89,8 +90,9 @@ bool FailureReasons::add(std::string_view value) noexcept {
 }
 
 void FailureReasons::canonicalize() noexcept {
-    std::sort(values.begin(), values.begin() + count, byte_less);
-    count = static_cast<std::size_t>(std::unique(values.begin(), values.begin() + count)
+    const auto used = static_cast<std::ptrdiff_t>(count);
+    std::sort(values.begin(), values.begin() + used, byte_less);
+    count = static_cast<std::size_t>(std::unique(values.begin(), values.begin() + used)
                                      - values.begin());
 }
 

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 550 - 723
Sunrise/src/client/content/activity/activity_sdk_policy_inventory.cpp


+ 143 - 0
Sunrise/src/client/content/activity/activity_sdk_policy_inventory_capabilities.cpp

@@ -0,0 +1,143 @@
+#include <algorithm>
+#include <cstddef>
+#include <cstdint>
+#include <string>
+#include <string_view>
+#include <vector>
+
+#include "activity_sdk_policy_inventory_internal.h"
+
+namespace sunrise::client::content::activity::sdk_generation::policy_inventory::internal {
+
+/**
+ * Emits one compiled host surface using its implemented adapter gate set.
+ * @param model The host declaration and sorted subject index.
+ * @return True when the capability, gates, and optional refusal fit their bounds.
+ */
+bool Builder::build_host_capability(const HostModel& model) {
+    const HostSurface& input = *model.surface;
+    const bool squad = input.operation == "squad.place";
+    const bool routed = input.operation == "scene.activate" || input.operation == "slot.apply_auth"
+                        || input.operation == "slot.set_channel";
+    const bool implemented = squad || routed;
+    Capability capability{};
+    std::string capabilityId{};
+    std::uint32_t capabilityIndex = 0;
+    if (!begin_capability(input.id,
+                          input.operation,
+                          {},
+                          format::SubjectKind::hostApi,
+                          model.subjectIndex,
+                          implemented ? format::kExposureMask : format::kInspectExposure,
+                          0,
+                          capability,
+                          capabilityId,
+                          capabilityIndex)) {
+        return false;
+    }
+    const std::size_t firstGate = output_.gates.size();
+    FailureReasons failures{};
+    if (squad) {
+        if (!add_gate("authored_mapping", true, {}, {}, failures)
+            || !add_gate("host_role", true, {}, {}, failures)
+            || !add_gate("route", true, {}, {}, failures)
+            || !add_gate("runtime_adapter", true, {}, {}, failures)) {
+            return false;
+        }
+    } else if (routed) {
+        if (!add_gate("authored_mapping", true, {}, {}, failures)
+            || !add_gate("meaning", true, {}, {}, failures)
+            || !add_gate("panel_adapter", true, {}, {}, failures)
+            || !add_gate("reader", true, {}, {}, failures)
+            || !add_gate("route_contract", true, {}, {}, failures)
+            || !add_gate("runtime_adapter", true, {}, {}, failures)
+            || !add_gate("schema_encode", true, {}, {}, failures)) {
+            return false;
+        }
+    } else if (!add_gate("host_role",
+                         false,
+                         "unsupported_host_role",
+                         "An authoritative activity host",
+                         failures)
+               || !add_gate("live_effect",
+                            false,
+                            "live_effect_unverified",
+                            "One visible end-to-end effect",
+                            failures)
+               || !add_gate("meaning",
+                            input.operation == "sleep_until",
+                            "meaning_unverified",
+                            "A verified retail action mapping",
+                            failures)
+               || !add_gate("runtime_adapter",
+                            false,
+                            "runtime_adapter_missing",
+                            "The script VM adapter",
+                            failures)) {
+        return false;
+    }
+    const std::size_t firstRefusal = output_.refusals.size();
+    if (!implemented && !add_refusal(capabilityId, kScript, kRefused, failures, capabilityIndex)) {
+        return false;
+    }
+    return finish_capability(capability, firstGate, firstRefusal);
+}
+
+/** @return True after host subjects and operations are emitted in byte-sorted order. */
+bool Builder::build_host_capabilities() {
+    std::vector<std::string_view> subjects{};
+    subjects.reserve(inputs_.hostSurfaces.size());
+    for (const HostSurface& row : inputs_.hostSurfaces) {
+        subjects.push_back(row.id);
+    }
+    std::sort(subjects.begin(), subjects.end(), byte_less);
+    subjects.erase(std::unique(subjects.begin(), subjects.end()), subjects.end());
+    if (subjects.size() > kMaximumHostSurfaceCount) {
+        return false;
+    }
+    output_.hostSubjects.resize(subjects.size());
+    for (std::size_t index = 0; index < subjects.size(); ++index) {
+        if (!intern(subjects[index], output_.hostSubjects[index].id)) {
+            return false;
+        }
+    }
+
+    std::vector<HostModel> models{};
+    models.reserve(inputs_.hostSurfaces.size());
+    for (const HostSurface& row : inputs_.hostSurfaces) {
+        const auto found = std::lower_bound(subjects.begin(), subjects.end(), row.id, byte_less);
+        if (found == subjects.end() || *found != row.id) {
+            return false;
+        }
+        models.push_back({&row, static_cast<std::uint32_t>(found - subjects.begin())});
+    }
+    std::sort(
+        models.begin(), models.end(), [](const HostModel& first, const HostModel& second) noexcept {
+            if (first.subjectIndex != second.subjectIndex) {
+                return first.subjectIndex < second.subjectIndex;
+            }
+            if (first.surface->operation != second.surface->operation) {
+                return byte_less(first.surface->operation, second.surface->operation);
+            }
+            return byte_less(first.surface->id, second.surface->id);
+        });
+
+    std::size_t nextModel = 0;
+    for (std::size_t subjectIndex = 0; subjectIndex < subjects.size(); ++subjectIndex) {
+        const std::size_t firstCapability = output_.capabilities.size();
+        while (nextModel < models.size() && models[nextModel].subjectIndex == subjectIndex) {
+            if (!build_host_capability(models[nextModel])) {
+                return false;
+            }
+            ++nextModel;
+        }
+        if (!make_range(firstCapability,
+                        output_.capabilities.size() - firstCapability,
+                        output_.hostSubjects[subjectIndex].capabilities)) {
+            return false;
+        }
+    }
+    return nextModel == models.size();
+}
+
+} // namespace sunrise::client::content::activity::sdk_generation::policy_inventory::internal

+ 99 - 0
Sunrise/src/client/content/activity/activity_sdk_policy_inventory_internal.h

@@ -1,10 +1,12 @@
 #pragma once
 
 #include <array>
+#include <cstddef>
 #include <cstdint>
 #include <span>
 #include <string>
 #include <string_view>
+#include <unordered_map>
 #include <vector>
 
 #include "activity_sdk_policy_inventory.h"
@@ -14,6 +16,23 @@ namespace sunrise::client::content::activity::sdk_generation::policy_inventory::
 /** One capability's fixed gate sets produce at most this many unique refusal reasons. */
 inline constexpr std::size_t kMaximumFailureReasonCount = 16;
 
+/** These stable strings are scalar values in canonical format-v12 policy rows. */
+inline constexpr std::string_view kExact = "exact";
+inline constexpr std::string_view kPassed = "passed";
+inline constexpr std::string_view kFailed = "failed";
+inline constexpr std::string_view kUnresolved = "unresolved";
+inline constexpr std::string_view kInspect = "inspect";
+inline constexpr std::string_view kPanelTest = "panel_test";
+inline constexpr std::string_view kScript = "script";
+inline constexpr std::string_view kCandidate = "candidate";
+inline constexpr std::string_view kRefused = "refused";
+
+/** One host surface paired with the sorted subject row that owns it. */
+struct HostModel final {
+    const HostSurface* surface{};
+    std::uint32_t subjectIndex{};
+};
+
 /** Supports transparent lookup in the snapshot's owned deferred string index. */
 struct StringHash final {
     using is_transparent = void;
@@ -66,4 +85,84 @@ struct RouteEvidence final {
 /** Builds complete object route evidence without retaining occurrence strings. */
 [[nodiscard]] bool build_routes(const Inputs& inputs, std::vector<RouteEvidence>& output);
 
+/** Owns one pending policy projection and its non-final string pool. */
+class Builder final {
+public:
+    Builder(const Inputs& inputs, std::span<const RouteEvidence> routes);
+
+    /** Builds every policy section in canonical ownership order. */
+    [[nodiscard]] bool run();
+
+    /** @return The finished snapshot, moved out of the builder. */
+    [[nodiscard]] Snapshot take() noexcept;
+
+private:
+    /** Reserves input-derived estimates without imposing one captured estate size. */
+    void reserve();
+
+    /** Interns one borrowed value into the snapshot-owned deferred string pool. */
+    [[nodiscard]] bool intern(std::string_view value, Text& output);
+
+    /** Appends one deferred text row for one interned value. */
+    [[nodiscard]] bool add_text(std::string_view value, format::TextKind kind);
+
+    /** @return True after activity and sorted slot aliases are emitted in parent order. */
+    [[nodiscard]] bool build_aliases();
+
+    /** Appends one canonical gate and records a failed reason for later refusal rows. */
+    [[nodiscard]] bool add_gate(std::string_view name,
+                                bool passed,
+                                std::string_view reason,
+                                std::string_view wouldConfirm,
+                                FailureReasons& failures);
+
+    /** Appends one refusal and its sorted unique Text-owned reason rows. */
+    [[nodiscard]] bool add_refusal(std::string_view capabilityId,
+                                   std::string_view exposure,
+                                   std::string_view status,
+                                   FailureReasons& failures,
+                                   std::uint32_t capabilityIndex);
+
+    /** Initializes one capability and assigns its future canonical row index. */
+    [[nodiscard]] bool begin_capability(std::string_view subjectId,
+                                        std::string_view operation,
+                                        std::string_view valueSchemaId,
+                                        format::SubjectKind subjectKind,
+                                        std::uint32_t subjectIndex,
+                                        std::uint32_t exposureFlags,
+                                        std::uint32_t candidateExposureFlags,
+                                        Capability& capability,
+                                        std::string& capabilityId,
+                                        std::uint32_t& capabilityIndex);
+
+    /** Closes and appends one capability after all owned child rows are present. */
+    [[nodiscard]] bool
+    finish_capability(Capability& capability, std::size_t firstGate, std::size_t firstRefusal);
+
+    /** @return True after every activity mission-binding capability is emitted in row order. */
+    [[nodiscard]] bool build_activity_capabilities();
+
+    /** Emits the always-visible inspection capability for one final slot. */
+    [[nodiscard]] bool build_inspect_capability(const SlotInput& input, std::uint32_t slotIndex);
+
+    /** Emits one device or trigger operation from final slot and route evidence. */
+    [[nodiscard]] bool build_adapter_capability(const SlotInput& input,
+                                                std::uint32_t slotIndex,
+                                                std::string_view operation);
+
+    /** @return True after every slot capability group is emitted in canonical operation order. */
+    [[nodiscard]] bool build_slot_capabilities();
+
+    /** Emits one compiled host surface using its implemented adapter gate set. */
+    [[nodiscard]] bool build_host_capability(const HostModel& model);
+
+    /** @return True after host subjects and operations are emitted in byte-sorted order. */
+    [[nodiscard]] bool build_host_capabilities();
+
+    const Inputs& inputs_;
+    std::span<const RouteEvidence> routes_{};
+    Snapshot output_{};
+    std::unordered_map<std::string, std::uint32_t, StringHash, StringEqual> stringIndexes_{};
+};
+
 } // namespace sunrise::client::content::activity::sdk_generation::policy_inventory::internal

+ 5 - 4
Sunrise/src/client/content/activity/activity_sdk_shard_cleanup.cpp

@@ -77,6 +77,7 @@ namespace manifest = state::activity_sdk::generated_world::manifest;
 [[nodiscard]] bool parse_shard_leaf(std::wstring_view leaf,
                                     std::uint32_t& scenarioTag,
                                     generated::Digest& digest) noexcept {
+    // A shard leaf is an 8-hex tag, a dash, the hex digest, then the pack suffix.
     constexpr std::size_t kTagLength = 8;
     constexpr std::size_t kDigestLength = generated::Digest{}.size() * 2;
     constexpr std::wstring_view kSuffix = L".pack";
@@ -109,14 +110,14 @@ namespace manifest = state::activity_sdk::generated_world::manifest;
 /** Deletes only owned shards missing from the active manifest or carrying a stale digest. */
 bool clean_stale_shards(const std::wstring& directory,
                         std::span<const manifest::Record> active) noexcept {
-    const auto ordered = [](const manifest::Record& left, const manifest::Record& right) {
-        return left.scenarioTag < right.scenarioTag;
+    const auto ordered = [](const manifest::Record& first, const manifest::Record& second) {
+        return first.scenarioTag < second.scenarioTag;
     };
     if (!std::is_sorted(active.begin(), active.end(), ordered)
         || std::adjacent_find(active.begin(),
                               active.end(),
-                              [](const manifest::Record& left, const manifest::Record& right) {
-                                  return left.scenarioTag == right.scenarioTag;
+                              [](const manifest::Record& first, const manifest::Record& second) {
+                                  return first.scenarioTag == second.scenarioTag;
                               })
                != active.end()) {
         return false;

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

@@ -0,0 +1,246 @@
+/** Parses authored type-1 spawner and type-66 rule configs into normalized squad facts. */
+
+#include <limits>
+#include <utility>
+
+#include "../../../middleware/content/packages/tables/authored_squad_reader.h"
+#include "activity_sdk_squad_inventory_internal.h"
+
+namespace sunrise::client::content::activity::sdk_generation::squad_inventory::detail {
+namespace {
+
+namespace tables = middleware::content::packages::tables;
+
+/** Reads one complete candidate row without trusting unbounded offset arithmetic. */
+[[nodiscard]] bool candidate_placement(std::span<const std::byte> blob,
+                                       const tables::AuthoredSquadMember& member,
+                                       std::size_t variant,
+                                       std::uint64_t index,
+                                       CandidateFact& output) noexcept {
+    output = {};
+    tables::AuthoredSquadCandidate source{};
+    if (!tables::authored_squad_candidate_record_at(blob, member, variant, index, source)) {
+        return false;
+    }
+    output.candidateDescriptorOffset = source.descriptorOffset;
+    output.placementRelative = source.placementRelative;
+    output.placementOffset = source.placementOffset;
+    output.candidateTail = source.descriptorTail;
+    if (!source.hasPlacement) {
+        return true;
+    }
+    output.placedEntryClass = source.placementClass;
+    output.actorDefinitionTag = source.classDefinitionTag;
+    output.quaternionBits = source.rotationBits;
+    output.positionBits = source.positionBits;
+    output.uniformScaleBits = source.uniformScaleBits;
+    output.nameHash = source.nameHash;
+    output.placementFlagsRaw = source.placementFlagsRaw;
+    output.placedEntryIdentity = source.placementIdentity;
+    output.state = CandidateState::exactPlacement;
+    return true;
+}
+
+/** Compares two parsed candidate lanes exactly. */
+[[nodiscard]] bool same_member(const MemberFact& left, const MemberFact& right) noexcept {
+    if (left.memberKey != right.memberKey || left.reservedU32 != right.reservedU32) {
+        return false;
+    }
+    for (std::size_t lane = 0; lane < left.candidates.size(); ++lane) {
+        if (left.candidates[lane].size() != right.candidates[lane].size()) {
+            return false;
+        }
+        for (std::size_t row = 0; row < left.candidates[lane].size(); ++row) {
+            if (left.candidates[lane][row] != right.candidates[lane][row]) {
+                return false;
+            }
+        }
+    }
+    return true;
+}
+
+} // namespace
+
+/** Compares repeated spawner definitions from different placed paths. */
+[[nodiscard]] bool same_spawner(const SpawnerFact& left, const SpawnerFact& right) noexcept {
+    if (left.configTag != right.configTag || left.rawReference98 != right.rawReference98
+        || left.rawReferenceA0 != right.rawReferenceA0 || left.complete != right.complete
+        || left.primaryComponentOffset != right.primaryComponentOffset
+        || left.secondaryComponentOffset != right.secondaryComponentOffset
+        || left.primaryComponentClass != right.primaryComponentClass
+        || left.secondaryComponentClass != right.secondaryComponentClass
+        || left.members.size() != right.members.size()
+        || left.hasInlinePointSet != right.hasInlinePointSet
+        || left.inlinePointSetOffset != right.inlinePointSetOffset
+        || left.inlinePlacementComponentOffset != right.inlinePlacementComponentOffset
+        || left.inlineInitialPointIndex != right.inlineInitialPointIndex
+        || left.inlinePoints != right.inlinePoints
+        || left.inlinePlacement != right.inlinePlacement) {
+        return false;
+    }
+    for (std::size_t index = 0; index < left.members.size(); ++index) {
+        if (!same_member(left.members[index], right.members[index])) {
+            return false;
+        }
+    }
+    return true;
+}
+
+/** Compares repeated spawn-rule definitions from different placed paths. */
+[[nodiscard]] bool same_rule(const RuleFact& left, const RuleFact& right) noexcept {
+    if (left.configTag != right.configTag || left.complete != right.complete
+        || left.primaryComponentOffset != right.primaryComponentOffset
+        || left.secondaryComponentOffset != right.secondaryComponentOffset
+        || left.primaryComponentClass != right.primaryComponentClass
+        || left.secondaryComponentClass != right.secondaryComponentClass
+        || left.inlineForm != right.inlineForm || left.points.size() != right.points.size()) {
+        return false;
+    }
+    for (std::size_t index = 0; index < left.points.size(); ++index) {
+        if (left.points[index] != right.points[index]) {
+            return false;
+        }
+    }
+    return true;
+}
+
+/** Parses one exact authored type-1 config. */
+[[nodiscard]] bool
+parse_spawner(std::uint32_t tag, std::span<const std::byte> blob, SpawnerFact& output) {
+    output = {};
+    tables::AuthoredSquadSpawner source{};
+    if (!tables::authored_squad_spawner(blob, tag, source)
+        || source.members.count >= format::kAbsentIndex) {
+        return false;
+    }
+    try {
+        output.configTag = tag;
+        output.rawReference98 = source.rawReference98;
+        output.rawReferenceA0 = source.rawReferenceA0;
+        output.primaryComponentOffset = source.components.primaryOffset;
+        output.secondaryComponentOffset = source.components.secondaryOffset;
+        output.primaryComponentClass = source.components.primaryClass;
+        output.secondaryComponentClass = source.components.secondaryClass;
+        output.members.reserve(static_cast<std::size_t>(source.members.count));
+        for (std::uint64_t memberIndex = 0; memberIndex < source.members.count; ++memberIndex) {
+            tables::AuthoredSquadMember member{};
+            if (!tables::authored_squad_member_at(blob, source, memberIndex, member)) {
+                return false;
+            }
+            MemberFact row{};
+            row.memberKey = member.key;
+            row.reservedU32 = member.reserved;
+            for (std::size_t lane = 0; lane < member.candidates.size(); ++lane) {
+                const tables::Array& candidates = member.candidates[lane];
+                if (candidates.count > (std::numeric_limits<std::uint16_t>::max)()) {
+                    return false;
+                }
+                row.candidates[lane].reserve(static_cast<std::size_t>(candidates.count));
+                for (std::uint64_t candidateIndex = 0; candidateIndex < candidates.count;
+                     ++candidateIndex) {
+                    CandidateFact candidate{};
+                    if (!candidate_placement(blob, member, lane, candidateIndex, candidate)) {
+                        return false;
+                    }
+                    row.candidates[lane].push_back(candidate);
+                }
+            }
+            output.members.push_back(std::move(row));
+        }
+        bool inlinePresent = false;
+        tables::AuthoredSquadInlinePointSet inlineSet{};
+        if (!tables::authored_squad_inline_point_set(blob, tag, source, inlinePresent, inlineSet)) {
+            return false;
+        }
+        if (inlinePresent) {
+            if (inlineSet.points.count >= format::kAbsentIndex) {
+                return false;
+            }
+            output.hasInlinePointSet = true;
+            output.inlinePointSetOffset = inlineSet.pointSetOffset;
+            output.inlinePlacementComponentOffset = inlineSet.placementComponentOffset;
+            output.inlineInitialPointIndex = inlineSet.initialPointIndex;
+            tables::AuthoredSquadRule pointOwner{};
+            pointOwner.points = inlineSet.points;
+            output.inlinePoints.reserve(static_cast<std::size_t>(inlineSet.points.count));
+            for (std::uint64_t index = 0; index < inlineSet.points.count; ++index) {
+                tables::AuthoredSquadRulePoint point{};
+                if (!tables::authored_squad_rule_point_at(blob, pointOwner, index, point)) {
+                    return false;
+                }
+                RulePointFact row{};
+                row.placedEntryIdentity = point.placementIdentity;
+                row.rowOffset = point.rowOffset;
+                row.rawTail = point.rawTail;
+                output.inlinePoints.push_back(row);
+            }
+            const tables::AuthoredSquadCandidate& placement = inlineSet.placement;
+            CandidateFact& row = output.inlinePlacement;
+            row.placementOffset = placement.placementOffset;
+            row.placedEntryClass = placement.placementClass;
+            row.actorDefinitionTag = placement.classDefinitionTag;
+            row.quaternionBits = placement.rotationBits;
+            row.positionBits = placement.positionBits;
+            row.uniformScaleBits = placement.uniformScaleBits;
+            row.nameHash = placement.nameHash;
+            row.placementFlagsRaw = placement.placementFlagsRaw;
+            row.placedEntryIdentity = placement.placementIdentity;
+            row.state = CandidateState::exactPlacement;
+        }
+        output.complete = true;
+        return true;
+    } catch (...) {
+        output = {};
+        return false;
+    }
+}
+
+/** Builds the rule row a spawner's own point set stands for. */
+[[nodiscard]] RuleFact inline_rule(const SpawnerFact& spawner) {
+    RuleFact rule{};
+    rule.configTag = spawner.configTag;
+    rule.points = spawner.inlinePoints;
+    rule.complete = true;
+    rule.primaryComponentOffset = spawner.inlinePointSetOffset;
+    rule.secondaryComponentOffset = spawner.inlinePlacementComponentOffset;
+    rule.primaryComponentClass = tables::kAuthoredSquadInlinePointSetClass;
+    rule.secondaryComponentClass = tables::kAuthoredSquadInlinePlacementClass;
+    rule.inlineForm = true;
+    return rule;
+}
+
+/** Parses one exact authored type-66 config. */
+[[nodiscard]] bool
+parse_rule(std::uint32_t tag, std::span<const std::byte> blob, RuleFact& output) {
+    output = {};
+    tables::AuthoredSquadRule source{};
+    if (!tables::authored_squad_rule(blob, tag, source)
+        || source.points.count >= format::kAbsentIndex) {
+        return false;
+    }
+    try {
+        output.configTag = tag;
+        output.primaryComponentOffset = source.components.primaryOffset;
+        output.secondaryComponentOffset = source.components.secondaryOffset;
+        output.primaryComponentClass = source.components.primaryClass;
+        output.secondaryComponentClass = source.components.secondaryClass;
+        output.points.reserve(static_cast<std::size_t>(source.points.count));
+        for (std::uint64_t index = 0; index < source.points.count; ++index) {
+            tables::AuthoredSquadRulePoint point{};
+            if (!tables::authored_squad_rule_point_at(blob, source, index, point)) {
+                return false;
+            }
+            RulePointFact row{};
+            row.placedEntryIdentity = point.placementIdentity;
+            row.rowOffset = point.rowOffset;
+            row.rawTail = point.rawTail;
+            output.points.push_back(row);
+        }
+        output.complete = true;
+        return true;
+    } catch (...) {
+        output = {};
+        return false;
+    }
+}
+} // namespace sunrise::client::content::activity::sdk_generation::squad_inventory::detail

+ 17 - 7
Sunrise/src/client/content/activity/activity_sdk_squad_graph_edges.cpp

@@ -101,6 +101,11 @@ template <typename... Values>
     return true;
 }
 
+/**
+ * Appends one unsigned value in little-endian order.
+ * @tparam Value Unsigned type whose whole width is appended.
+ * @return False when the buffer cannot grow.
+ */
 template <typename Value>
 [[nodiscard]] bool append_little(std::vector<std::byte>& output, Value value) {
     static_assert(std::is_unsigned_v<Value>);
@@ -113,6 +118,7 @@ template <typename Value>
     return true;
 }
 
+/** Appends one length-prefixed hash input part. @return False when the buffer cannot grow. */
 [[nodiscard]] bool append_hash_part(std::vector<std::byte>& output, std::string_view value) {
     if (!append_little(output, static_cast<std::uint64_t>(value.size()))
         || value.size() > (std::numeric_limits<std::size_t>::max)() - output.size()) {
@@ -125,6 +131,7 @@ template <typename Value>
     return true;
 }
 
+/** Hashes one domain and its parts to lowercase hex. @return False when hashing fails. */
 [[nodiscard]] bool
 domain_hash(std::string_view domain, std::span<const std::string_view> parts, std::string& output) {
     output.clear();
@@ -145,6 +152,7 @@ domain_hash(std::string_view domain, std::span<const std::string_view> parts, st
     if (!crypto::hash(input, digest)) {
         return false;
     }
+    // Edge ids are published in lowercase hex.
     static constexpr std::array<char, 16> kHex{
         '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'};
     output.resize(digest.size() * 2U);
@@ -156,6 +164,7 @@ domain_hash(std::string_view domain, std::span<const std::string_view> parts, st
     return true;
 }
 
+/** Decodes one packed object reference; the result is invalid when any field is out of range. */
 [[nodiscard]] ObjectReference decode_reference(std::uint64_t raw) noexcept {
     ObjectReference output{};
     output.objectKey = static_cast<std::uint32_t>(raw);
@@ -477,8 +486,8 @@ template <typename Index> void canonicalize_scenarios(Index& index) {
             edge.targetDescriptors = target.descriptors;
             std::sort(edge.targetDescriptors.begin(),
                       edge.targetDescriptors.end(),
-                      [&graph](std::uint32_t left, std::uint32_t right) {
-                          return graph.descriptors[left].id < graph.descriptors[right].id;
+                      [&graph](std::uint32_t first, std::uint32_t second) {
+                          return graph.descriptors[first].id < graph.descriptors[second].id;
                       });
             edge.associationExact = associationExact;
             if (!edge_identity(topology,
@@ -510,13 +519,14 @@ template <typename Index> void canonicalize_scenarios(Index& index) {
             output.push_back(std::move(edge));
         }
     }
-    std::sort(output.begin(), output.end(), [](const PendingEdge& left, const PendingEdge& right) {
-        return left.id < right.id;
-    });
+    std::sort(
+        output.begin(), output.end(), [](const PendingEdge& first, const PendingEdge& second) {
+            return first.id < second.id;
+        });
     return std::adjacent_find(output.begin(),
                               output.end(),
-                              [](const PendingEdge& left, const PendingEdge& right) {
-                                  return left.id == right.id;
+                              [](const PendingEdge& first, const PendingEdge& second) {
+                                  return first.id == second.id;
                               })
            == output.end();
 }

+ 15 - 245
Sunrise/src/client/content/activity/activity_sdk_squad_inventory.cpp

@@ -162,237 +162,6 @@ template <typename... Values>
                        static_cast<unsigned>(slot.slotType));
 }
 
-/** Reads one complete candidate row without trusting unbounded offset arithmetic. */
-[[nodiscard]] bool candidate_placement(std::span<const std::byte> blob,
-                                       const tables::AuthoredSquadMember& member,
-                                       std::size_t variant,
-                                       std::uint64_t index,
-                                       CandidateFact& output) noexcept {
-    output = {};
-    tables::AuthoredSquadCandidate source{};
-    if (!tables::authored_squad_candidate_record_at(blob, member, variant, index, source)) {
-        return false;
-    }
-    output.candidateDescriptorOffset = source.descriptorOffset;
-    output.placementRelative = source.placementRelative;
-    output.placementOffset = source.placementOffset;
-    output.candidateTail = source.descriptorTail;
-    if (!source.hasPlacement) {
-        return true;
-    }
-    output.placedEntryClass = source.placementClass;
-    output.actorDefinitionTag = source.classDefinitionTag;
-    output.quaternionBits = source.rotationBits;
-    output.positionBits = source.positionBits;
-    output.uniformScaleBits = source.uniformScaleBits;
-    output.nameHash = source.nameHash;
-    output.placementFlagsRaw = source.placementFlagsRaw;
-    output.placedEntryIdentity = source.placementIdentity;
-    output.state = CandidateState::exactPlacement;
-    return true;
-}
-
-/** Compares two parsed candidate lanes exactly. */
-[[nodiscard]] bool same_member(const MemberFact& left, const MemberFact& right) noexcept {
-    if (left.memberKey != right.memberKey || left.reservedU32 != right.reservedU32) {
-        return false;
-    }
-    for (std::size_t lane = 0; lane < left.candidates.size(); ++lane) {
-        if (left.candidates[lane].size() != right.candidates[lane].size()) {
-            return false;
-        }
-        for (std::size_t row = 0; row < left.candidates[lane].size(); ++row) {
-            if (left.candidates[lane][row] != right.candidates[lane][row]) {
-                return false;
-            }
-        }
-    }
-    return true;
-}
-
-/** Compares repeated spawner definitions from different placed paths. */
-[[nodiscard]] bool same_spawner(const SpawnerFact& left, const SpawnerFact& right) noexcept {
-    if (left.configTag != right.configTag || left.rawReference98 != right.rawReference98
-        || left.rawReferenceA0 != right.rawReferenceA0 || left.complete != right.complete
-        || left.primaryComponentOffset != right.primaryComponentOffset
-        || left.secondaryComponentOffset != right.secondaryComponentOffset
-        || left.primaryComponentClass != right.primaryComponentClass
-        || left.secondaryComponentClass != right.secondaryComponentClass
-        || left.members.size() != right.members.size()
-        || left.hasInlinePointSet != right.hasInlinePointSet
-        || left.inlinePointSetOffset != right.inlinePointSetOffset
-        || left.inlinePlacementComponentOffset != right.inlinePlacementComponentOffset
-        || left.inlineInitialPointIndex != right.inlineInitialPointIndex
-        || left.inlinePoints != right.inlinePoints
-        || left.inlinePlacement != right.inlinePlacement) {
-        return false;
-    }
-    for (std::size_t index = 0; index < left.members.size(); ++index) {
-        if (!same_member(left.members[index], right.members[index])) {
-            return false;
-        }
-    }
-    return true;
-}
-
-/** Compares repeated spawn-rule definitions from different placed paths. */
-[[nodiscard]] bool same_rule(const RuleFact& left, const RuleFact& right) noexcept {
-    if (left.configTag != right.configTag || left.complete != right.complete
-        || left.primaryComponentOffset != right.primaryComponentOffset
-        || left.secondaryComponentOffset != right.secondaryComponentOffset
-        || left.primaryComponentClass != right.primaryComponentClass
-        || left.secondaryComponentClass != right.secondaryComponentClass
-        || left.inlineForm != right.inlineForm || left.points.size() != right.points.size()) {
-        return false;
-    }
-    for (std::size_t index = 0; index < left.points.size(); ++index) {
-        if (left.points[index] != right.points[index]) {
-            return false;
-        }
-    }
-    return true;
-}
-
-/** Parses one exact authored type-1 config. */
-[[nodiscard]] bool
-parse_spawner(std::uint32_t tag, std::span<const std::byte> blob, SpawnerFact& output) {
-    output = {};
-    tables::AuthoredSquadSpawner source{};
-    if (!tables::authored_squad_spawner(blob, tag, source)
-        || source.members.count >= format::kAbsentIndex) {
-        return false;
-    }
-    try {
-        output.configTag = tag;
-        output.rawReference98 = source.rawReference98;
-        output.rawReferenceA0 = source.rawReferenceA0;
-        output.primaryComponentOffset = source.components.primaryOffset;
-        output.secondaryComponentOffset = source.components.secondaryOffset;
-        output.primaryComponentClass = source.components.primaryClass;
-        output.secondaryComponentClass = source.components.secondaryClass;
-        output.members.reserve(static_cast<std::size_t>(source.members.count));
-        for (std::uint64_t memberIndex = 0; memberIndex < source.members.count; ++memberIndex) {
-            tables::AuthoredSquadMember member{};
-            if (!tables::authored_squad_member_at(blob, source, memberIndex, member)) {
-                return false;
-            }
-            MemberFact row{};
-            row.memberKey = member.key;
-            row.reservedU32 = member.reserved;
-            for (std::size_t lane = 0; lane < member.candidates.size(); ++lane) {
-                const tables::Array& candidates = member.candidates[lane];
-                if (candidates.count > (std::numeric_limits<std::uint16_t>::max)()) {
-                    return false;
-                }
-                row.candidates[lane].reserve(static_cast<std::size_t>(candidates.count));
-                for (std::uint64_t candidateIndex = 0; candidateIndex < candidates.count;
-                     ++candidateIndex) {
-                    CandidateFact candidate{};
-                    if (!candidate_placement(blob, member, lane, candidateIndex, candidate)) {
-                        return false;
-                    }
-                    row.candidates[lane].push_back(candidate);
-                }
-            }
-            output.members.push_back(std::move(row));
-        }
-        bool inlinePresent = false;
-        tables::AuthoredSquadInlinePointSet inlineSet{};
-        if (!tables::authored_squad_inline_point_set(blob, tag, source, inlinePresent, inlineSet)) {
-            return false;
-        }
-        if (inlinePresent) {
-            if (inlineSet.points.count >= format::kAbsentIndex) {
-                return false;
-            }
-            output.hasInlinePointSet = true;
-            output.inlinePointSetOffset = inlineSet.pointSetOffset;
-            output.inlinePlacementComponentOffset = inlineSet.placementComponentOffset;
-            output.inlineInitialPointIndex = inlineSet.initialPointIndex;
-            tables::AuthoredSquadRule pointOwner{};
-            pointOwner.points = inlineSet.points;
-            output.inlinePoints.reserve(static_cast<std::size_t>(inlineSet.points.count));
-            for (std::uint64_t index = 0; index < inlineSet.points.count; ++index) {
-                tables::AuthoredSquadRulePoint point{};
-                if (!tables::authored_squad_rule_point_at(blob, pointOwner, index, point)) {
-                    return false;
-                }
-                RulePointFact row{};
-                row.placedEntryIdentity = point.placementIdentity;
-                row.rowOffset = point.rowOffset;
-                row.rawTail = point.rawTail;
-                output.inlinePoints.push_back(row);
-            }
-            const tables::AuthoredSquadCandidate& placement = inlineSet.placement;
-            CandidateFact& row = output.inlinePlacement;
-            row.placementOffset = placement.placementOffset;
-            row.placedEntryClass = placement.placementClass;
-            row.actorDefinitionTag = placement.classDefinitionTag;
-            row.quaternionBits = placement.rotationBits;
-            row.positionBits = placement.positionBits;
-            row.uniformScaleBits = placement.uniformScaleBits;
-            row.nameHash = placement.nameHash;
-            row.placementFlagsRaw = placement.placementFlagsRaw;
-            row.placedEntryIdentity = placement.placementIdentity;
-            row.state = CandidateState::exactPlacement;
-        }
-        output.complete = true;
-        return true;
-    } catch (...) {
-        output = {};
-        return false;
-    }
-}
-
-/** Builds the rule row a spawner's own point set stands for. */
-[[nodiscard]] RuleFact inline_rule(const SpawnerFact& spawner) {
-    RuleFact rule{};
-    rule.configTag = spawner.configTag;
-    rule.points = spawner.inlinePoints;
-    rule.complete = true;
-    rule.primaryComponentOffset = spawner.inlinePointSetOffset;
-    rule.secondaryComponentOffset = spawner.inlinePlacementComponentOffset;
-    rule.primaryComponentClass = tables::kAuthoredSquadInlinePointSetClass;
-    rule.secondaryComponentClass = tables::kAuthoredSquadInlinePlacementClass;
-    rule.inlineForm = true;
-    return rule;
-}
-
-/** Parses one exact authored type-66 config. */
-[[nodiscard]] bool
-parse_rule(std::uint32_t tag, std::span<const std::byte> blob, RuleFact& output) {
-    output = {};
-    tables::AuthoredSquadRule source{};
-    if (!tables::authored_squad_rule(blob, tag, source)
-        || source.points.count >= format::kAbsentIndex) {
-        return false;
-    }
-    try {
-        output.configTag = tag;
-        output.primaryComponentOffset = source.components.primaryOffset;
-        output.secondaryComponentOffset = source.components.secondaryOffset;
-        output.primaryComponentClass = source.components.primaryClass;
-        output.secondaryComponentClass = source.components.secondaryClass;
-        output.points.reserve(static_cast<std::size_t>(source.points.count));
-        for (std::uint64_t index = 0; index < source.points.count; ++index) {
-            tables::AuthoredSquadRulePoint point{};
-            if (!tables::authored_squad_rule_point_at(blob, source, index, point)) {
-                return false;
-            }
-            RulePointFact row{};
-            row.placedEntryIdentity = point.placementIdentity;
-            row.rowOffset = point.rowOffset;
-            row.rawTail = point.rawTail;
-            output.points.push_back(row);
-        }
-        output.complete = true;
-        return true;
-    } catch (...) {
-        output = {};
-        return false;
-    }
-}
-
 /** Inserts one exact parsed spawner or rejects a repeated mismatch. */
 [[nodiscard]] bool merge_spawner(Collector& collector, SpawnerFact value) {
     const auto found = collector.spawners.find(value.configTag);
@@ -403,7 +172,7 @@ parse_rule(std::uint32_t tag, std::span<const std::byte> blob, RuleFact& output)
         return true;
     }
     return found->second < collector.facts->spawners.size()
-           && same_spawner(collector.facts->spawners[found->second], value);
+           && detail::same_spawner(collector.facts->spawners[found->second], value);
 }
 
 /** Inserts one exact parsed rule or rejects a repeated mismatch. */
@@ -416,7 +185,7 @@ parse_rule(std::uint32_t tag, std::span<const std::byte> blob, RuleFact& output)
         return true;
     }
     return found->second < collector.facts->rules.size()
-           && same_rule(collector.facts->rules[found->second], value);
+           && detail::same_rule(collector.facts->rules[found->second], value);
 }
 
 /** Inserts one exact descriptor or rejects a repeated mismatch. */
@@ -556,11 +325,11 @@ bool collect_config(void* opaque,
         if (pair.primaryClass == tables::kAuthoredSquadSpawnerPrimaryClass
             && pair.secondaryClass == tables::kAuthoredSquadSpawnerSecondaryClass) {
             SpawnerFact spawner{};
-            if (!parse_spawner(configTag, blob, spawner)) {
+            if (!detail::parse_spawner(configTag, blob, spawner)) {
                 return false;
             }
             const bool hasInline = spawner.hasInlinePointSet;
-            RuleFact rule = hasInline ? inline_rule(spawner) : RuleFact{};
+            RuleFact rule = hasInline ? detail::inline_rule(spawner) : RuleFact{};
             if (!merge_spawner(*context.collector, std::move(spawner))
                 || (hasInline && !merge_rule(*context.collector, std::move(rule)))) {
                 return false;
@@ -568,7 +337,7 @@ bool collect_config(void* opaque,
         } else if (pair.primaryClass == tables::kAuthoredSquadRulePrimaryClass
                    && pair.secondaryClass == tables::kAuthoredSquadRuleSecondaryClass) {
             RuleFact rule{};
-            if (!parse_rule(configTag, blob, rule)
+            if (!detail::parse_rule(configTag, blob, rule)
                 || !merge_rule(*context.collector, std::move(rule))) {
                 return false;
             }
@@ -809,23 +578,24 @@ bool collect_facts(const topology::Snapshot& topology,
                 }
             }
         }
+        std::sort(pending.spawners.begin(),
+                  pending.spawners.end(),
+                  [](const auto& first, const auto& second) {
+                      return first.configTag < second.configTag;
+                  });
         std::sort(
-            pending.spawners.begin(),
-            pending.spawners.end(),
-            [](const auto& left, const auto& right) { return left.configTag < right.configTag; });
-        std::sort(
-            pending.rules.begin(), pending.rules.end(), [](const auto& left, const auto& right) {
-                return left.configTag < right.configTag;
+            pending.rules.begin(), pending.rules.end(), [](const auto& first, const auto& second) {
+                return first.configTag < second.configTag;
             });
         std::sort(pending.descriptors.begin(),
                   pending.descriptors.end(),
-                  [](const auto& left, const auto& right) { return left.id < right.id; });
+                  [](const auto& first, const auto& second) { return first.id < second.id; });
         std::sort(pending.configOccurrences.begin(),
                   pending.configOccurrences.end(),
-                  [](const auto& left, const auto& right) { return left.id < right.id; });
+                  [](const auto& first, const auto& second) { return first.id < second.id; });
         std::sort(pending.placementOccurrences.begin(),
                   pending.placementOccurrences.end(),
-                  [](const auto& left, const auto& right) { return left.id < right.id; });
+                  [](const auto& first, const auto& second) { return first.id < second.id; });
         for (const SpawnerFact& spawner : pending.spawners) {
             for (const MemberFact& member : spawner.members) {
                 for (const auto& lane : member.candidates) {

+ 16 - 0
Sunrise/src/client/content/activity/activity_sdk_squad_inventory_internal.h

@@ -10,6 +10,22 @@ namespace sunrise::client::content::activity::sdk_generation::squad_inventory::d
 /** Validates all normalized facts before linking begins. */
 [[nodiscard]] bool validate_facts(const topology_inventory::Snapshot& topology, const Facts& facts);
 
+/** @return True when two parsed spawner definitions from different paths agree exactly. */
+[[nodiscard]] bool same_spawner(const SpawnerFact& left, const SpawnerFact& right) noexcept;
+
+/** @return True when two parsed spawn-rule definitions from different paths agree exactly. */
+[[nodiscard]] bool same_rule(const RuleFact& left, const RuleFact& right) noexcept;
+
+/** Parses one exact authored type-1 config. */
+[[nodiscard]] bool
+parse_spawner(std::uint32_t tag, std::span<const std::byte> blob, SpawnerFact& output);
+
+/** Builds the rule row a spawner's own point set stands for. */
+[[nodiscard]] RuleFact inline_rule(const SpawnerFact& spawner);
+
+/** Parses one exact authored type-66 config. */
+[[nodiscard]] bool parse_rule(std::uint32_t tag, std::span<const std::byte> blob, RuleFact& output);
+
 /** Builds one member row and retains an unresolved actor link when needed. */
 [[nodiscard]] bool build_member(const MemberFact& source,
                                 std::string_view squadDigest,

+ 13 - 10
Sunrise/src/client/content/activity/activity_sdk_squad_inventory_link.cpp

@@ -25,6 +25,7 @@ namespace topology = topology_inventory;
 /** One reusable CNG hash operation for the hundreds of thousands of canonical squad ids. */
 class SquadIdHasher final {
 public:
+    /** Opens the reusable CNG hash; the object stays not ready when CNG refuses. */
     SquadIdHasher() noexcept {
         if (BCryptOpenAlgorithmProvider(&algorithm_, BCRYPT_SHA256_ALGORITHM, nullptr, 0) < 0
             || BCryptCreateHash(
@@ -167,6 +168,7 @@ template <typename Value>
     if (!hasher.hash(input, digest)) {
         return false;
     }
+    // Squad ids are published in lowercase hex.
     static constexpr std::array<char, 16> kHex{
         '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'};
     output.resize(digest.size() * 2U);
@@ -303,11 +305,12 @@ bool link(const topology::Snapshot& topology,
         }
         for (auto& [key, occurrences] : occurrencesByConfigObjectScenario) {
             (void)key;
-            std::sort(occurrences.begin(), occurrences.end(), [&topology](auto left, auto right) {
-                std::string_view leftId{};
-                std::string_view rightId{};
-                return text_view(topology.occurrences[left].id, leftId)
-                       && text_view(topology.occurrences[right].id, rightId) && leftId < rightId;
+            std::sort(occurrences.begin(), occurrences.end(), [&topology](auto first, auto second) {
+                std::string_view firstId{};
+                std::string_view secondId{};
+                return text_view(topology.occurrences[first].id, firstId)
+                       && text_view(topology.occurrences[second].id, secondId)
+                       && firstId < secondId;
             });
             occurrences.erase(std::unique(occurrences.begin(), occurrences.end()),
                               occurrences.end());
@@ -563,11 +566,11 @@ bool link(const topology::Snapshot& topology,
 
         std::sort(pending.begin(),
                   pending.end(),
-                  [](const PendingSquad& left, const PendingSquad& right) {
-                      const auto leftNatural = squad_natural(left);
-                      const auto rightNatural = squad_natural(right);
-                      return leftNatural != rightNatural ? leftNatural < rightNatural
-                                                         : left.row.id < right.row.id;
+                  [](const PendingSquad& first, const PendingSquad& second) {
+                      const auto firstNatural = squad_natural(first);
+                      const auto secondNatural = squad_natural(second);
+                      return firstNatural != secondNatural ? firstNatural < secondNatural
+                                                           : first.row.id < second.row.id;
                   });
         for (std::size_t index = 1; index < pending.size(); ++index) {
             if (squad_natural(pending[index - 1]) == squad_natural(pending[index])) {

+ 1 - 2
Sunrise/src/client/content/activity/activity_sdk_topology_enrichment.cpp

@@ -6,7 +6,6 @@
 #include <limits>
 #include <span>
 #include <string_view>
-#include <tuple>
 #include <utility>
 #include <vector>
 
@@ -355,7 +354,7 @@ bool build_impl(const topology_inventory::Snapshot& topology,
             if (!resolve_slot(topology, source, pending.slotAliases, slot)) {
                 return false;
             }
-            pending.slots.push_back(std::move(slot));
+            pending.slots.push_back(slot);
         }
         if (validateOutput && !validate(topology, pending)) {
             return false;

+ 22 - 20
Sunrise/src/client/content/activity/activity_sdk_topology_inventory_finish.cpp

@@ -98,10 +98,11 @@ struct InlineValue final {
     for (const std::string& value : source.accumulator.inlineNames) {
         values.push_back({content_hash(value), &value});
     }
-    std::sort(values.begin(), values.end(), [](const InlineValue& left, const InlineValue& right) {
-        return left.hash != right.hash ? left.hash < right.hash
-                                       : byte_less(*left.value, *right.value);
-    });
+    std::sort(
+        values.begin(), values.end(), [](const InlineValue& first, const InlineValue& second) {
+            return first.hash != second.hash ? first.hash < second.hash
+                                             : byte_less(*first.value, *second.value);
+        });
     rows.clear();
     bytes.clear();
     rows.reserve(values.size());
@@ -206,22 +207,22 @@ struct InlineValue final {
 }
 
 /** Orders occurrences exactly as the v9 builder does after global indices are linked. */
-[[nodiscard]] bool occurrence_less(const Occurrence& left, const Occurrence& right) noexcept {
-    if (left.scenarioIndex != right.scenarioIndex) {
-        return left.scenarioIndex < right.scenarioIndex;
+[[nodiscard]] bool occurrence_less(const Occurrence& first, const Occurrence& second) noexcept {
+    if (first.scenarioIndex != second.scenarioIndex) {
+        return first.scenarioIndex < second.scenarioIndex;
     }
-    if (left.bubbleIndex != right.bubbleIndex) {
-        return left.bubbleIndex < right.bubbleIndex;
+    if (first.bubbleIndex != second.bubbleIndex) {
+        return first.bubbleIndex < second.bubbleIndex;
     }
-    if (left.stateIndex != right.stateIndex) {
-        return left.stateIndex < right.stateIndex;
+    if (first.stateIndex != second.stateIndex) {
+        return first.stateIndex < second.stateIndex;
     }
-    std::string_view leftId{};
-    std::string_view rightId{};
-    if (!text_view(left.id, leftId) || !text_view(right.id, rightId)) {
+    std::string_view firstId{};
+    std::string_view secondId{};
+    if (!text_view(first.id, firstId) || !text_view(second.id, secondId)) {
         return false;
     }
-    return byte_less(leftId, rightId);
+    return byte_less(firstId, secondId);
 }
 
 /** Appends observed values for one hash without publishing v9 aliases. */
@@ -334,9 +335,10 @@ struct InlineValue final {
             return false;
         }
     }
-    std::sort(output.begin(), output.end(), [](const std::string& left, const std::string& right) {
-        return byte_less(left, right);
-    });
+    std::sort(
+        output.begin(), output.end(), [](const std::string& first, const std::string& second) {
+            return byte_less(first, second);
+        });
     output.erase(std::unique(output.begin(), output.end()), output.end());
     if (output.size() >= format::kAbsentIndex) {
         return false;
@@ -372,8 +374,8 @@ bool finish(Snapshot& output) noexcept {
     }
     try {
         std::vector<Object> objects = output.objects;
-        std::sort(objects.begin(), objects.end(), [](const Object& left, const Object& right) {
-            return left.objectTag < right.objectTag;
+        std::sort(objects.begin(), objects.end(), [](const Object& first, const Object& second) {
+            return first.objectTag < second.objectTag;
         });
         if (!valid_objects(objects)) {
             return false;

+ 4 - 0
Sunrise/src/client/content/activity/activity_sdk_tree_publication.cpp

@@ -10,10 +10,13 @@
 namespace sunrise::client::content::activity::sdk_generation::tree_publication {
 namespace {
 
+// Both separators end a path component.
 constexpr std::wstring_view kSeparators = L"\\/";
+// Suffixes of the sibling names one publication owns.
 constexpr std::wstring_view kBackupSuffix = L".activity-sdk-backup";
 constexpr std::wstring_view kPendingSuffix = L".activity-sdk-publication.pending";
 constexpr std::wstring_view kCommittedSuffix = L".activity-sdk-publication.committed";
+// Marker magic; the bytes spell AST1.
 constexpr std::uint32_t kMarkerMagic = 0x31545341U;
 
 struct Marker final {
@@ -95,6 +98,7 @@ struct Marker final {
                   == CSTR_EQUAL;
 }
 
+/** Splits one path at its last separator. @return False when there is no interior separator. */
 [[nodiscard]] bool
 split(std::wstring_view path, std::wstring_view& parent, std::wstring_view& leaf) noexcept {
     const std::size_t separator = path.find_last_of(kSeparators);

+ 12 - 5
Sunrise/src/client/content/activity/entity_object_type_build.cpp

@@ -18,20 +18,26 @@ struct Context {
 bool collect(void* opaque, std::uint32_t rsat) noexcept {
     auto& context = *static_cast<Context*>(opaque);
     try {
-        if (context.rows.size() >= types::kMaximumRows) return false;
+        if (context.rows.size() >= types::kMaximumRows) {
+            return false;
+        }
         std::vector<std::byte> resource, definition;
         std::uint32_t cls{}, backlink{}, forward{};
         if (!reader::read_tag(context.source, context.scratch, rsat, resource, cls)
-            || cls != kRsatClass || resource.size() < kReverseOffset + sizeof(std::uint32_t))
+            || cls != kRsatClass || resource.size() < kReverseOffset + sizeof(std::uint32_t)) {
             return false;
+        }
         std::memcpy(&backlink, resource.data() + kReverseOffset, sizeof backlink);
         if (!backlink || backlink == 0xFFFFFFFFU
             || !reader::read_tag(context.source, context.scratch, backlink, definition, cls)
-            || cls != kDefinitionClass || definition.size() <= kObjectTypeOffset)
+            || cls != kDefinitionClass || definition.size() <= kObjectTypeOffset) {
             return false;
+        }
         std::memcpy(&forward, definition.data() + kForwardOffset, sizeof forward);
         const auto objectType = std::to_integer<std::uint8_t>(definition[kObjectTypeOffset]);
-        if (forward != rsat || objectType > types::kMaximumObjectType) return false;
+        if (forward != rsat || objectType > types::kMaximumObjectType) {
+            return false;
+        }
         context.rows.push_back({rsat, backlink, objectType});
         return true;
     } catch (...) {
@@ -46,8 +52,9 @@ bool build(const reader::Source& source,
     try {
         Context context{source, scratch, {}};
         reader::ScanResult result{};
-        if (!reader::scan_class(source.directory, kRsatClass, &collect, &context, result))
+        if (!reader::scan_class(source.directory, kRsatClass, &collect, &context, result)) {
             return false;
+        }
         std::sort(context.rows.begin(), context.rows.end(), [](const auto& a, const auto& b) {
             return a.rsatTag < b.rsatTag;
         });

+ 305 - 75
Sunrise/src/client/content/activity/entity_position_profile_build.cpp

@@ -1,13 +1,18 @@
 #include "entity_position_profile_build.h"
 
+#include <Windows.h>
+
 #include <algorithm>
+#include <array>
+#include <cstddef>
+#include <cstdint>
 #include <cstdio>
 #include <cstring>
-#include <filesystem>
-#include <fstream>
-#include <map>
-#include <set>
+#include <span>
+#include <string_view>
+#include <vector>
 
+#include "../../../core/filesystem/path.h"
 #include "../../../core/logging/log.h"
 #include "../../../middleware/content/packages/named_tags.h"
 #include "../../../middleware/content/packages/tables/entity_position_profile_extractor.h"
@@ -23,13 +28,22 @@ namespace extractor = middleware::content::packages::position_profiles;
 namespace named = middleware::content::packages::named_tags;
 namespace reader = middleware::content::packages::reader;
 using Blob = std::vector<std::byte>;
+/** One installed package directory never holds more `.pkg` files than this. */
+constexpr std::size_t kMaximumPackageFileCount = 65536;
+/** Package files carry the header prefix this pass reads before any metadata. */
+constexpr std::size_t kHeaderBytes = 0x180;
+/** The hash64 reference directory is refused above this size. */
+constexpr std::uint64_t kMaximumMetadataBytes = 64ULL * 1024ULL * 1024ULL;
+/** Package file names carry this extension and nothing else is read. */
+constexpr std::wstring_view kPackageExtension = L".pkg";
 struct Name final {
     extractor::NamedTag value;
     std::uint32_t patch{};
     bool conflict{};
 };
 struct Names final {
-    std::map<std::string, Name> rows;
+    /** Sorted by name so the merge order matches the emitted row order. */
+    std::vector<Name> rows;
     bool base{};
     std::uint32_t patch{};
 };
@@ -37,6 +51,99 @@ struct Context final {
     const reader::Source& source;
     reader::Scratch& scratch;
 };
+/** One installed package file. The family is the leading part of the name before its patch. */
+struct File final {
+    std::array<wchar_t, MAX_PATH> name{};
+    std::size_t familyLength{};
+    std::uint32_t patch{};
+};
+/** One hash64 metadata reference and the package row it names. */
+struct Reference final {
+    std::uint64_t key{};
+    std::uint32_t tag{};
+    std::uint32_t classId{};
+};
+/** One merged reference. A key that two families disagree on is not emitted. */
+struct MergedReference final {
+    Reference row{};
+    bool unique{true};
+};
+/** Closes one package handle on every exit path. */
+class FileHandle final {
+public:
+    explicit FileHandle(const wchar_t* path) noexcept
+        : handle_(CreateFileW(path,
+                              GENERIC_READ,
+                              FILE_SHARE_READ,
+                              nullptr,
+                              OPEN_EXISTING,
+                              FILE_ATTRIBUTE_NORMAL | FILE_FLAG_SEQUENTIAL_SCAN,
+                              nullptr)) {}
+
+    ~FileHandle() noexcept {
+        if (handle_ != INVALID_HANDLE_VALUE) {
+            (void)CloseHandle(handle_);
+        }
+    }
+
+    FileHandle(const FileHandle&) = delete;
+    FileHandle& operator=(const FileHandle&) = delete;
+
+    [[nodiscard]] bool valid() const noexcept {
+        return handle_ != INVALID_HANDLE_VALUE;
+    }
+
+    [[nodiscard]] HANDLE get() const noexcept {
+        return handle_;
+    }
+
+private:
+    HANDLE handle_{INVALID_HANDLE_VALUE};
+};
+/** @return The family part of one package file name. */
+[[nodiscard]] std::wstring_view family_of(const File& file) noexcept {
+    return {file.name.data(), file.familyLength};
+}
+/** Joins one directory and child without doubling a separator an extended path would keep. */
+[[nodiscard]] bool join_path(std::wstring_view directory,
+                             std::wstring_view child,
+                             core::path::Buffer& output) noexcept {
+    if (!core::path::assign(output, directory)) {
+        return false;
+    }
+    if (!directory.empty() && directory.back() != L'\\' && directory.back() != L'/'
+        && !core::path::append(output, L"\\")) {
+        return false;
+    }
+    return core::path::append(output, child);
+}
+/**
+ * Reads one exact byte range. A short read is a failure.
+ * @param file Open package handle.
+ * @param offset Absolute byte offset.
+ * @param output Receives the range; its size is the amount required.
+ * @return True when the whole range was read.
+ */
+[[nodiscard]] bool
+read_at(HANDLE file, std::uint64_t offset, std::span<std::byte> output) noexcept {
+    LARGE_INTEGER position{};
+    position.QuadPart = static_cast<LONGLONG>(offset);
+    if (SetFilePointerEx(file, position, nullptr, FILE_BEGIN) == FALSE) {
+        return false;
+    }
+    std::size_t done = 0;
+    while (done < output.size()) {
+        // One ReadFile call is bounded by its DWORD length.
+        constexpr std::size_t kMaximumChunk = 0x10000000;
+        const auto chunk = static_cast<DWORD>((std::min)(output.size() - done, kMaximumChunk));
+        DWORD read = 0;
+        if (ReadFile(file, output.data() + done, chunk, &read, nullptr) == FALSE || read == 0) {
+            return false;
+        }
+        done += read;
+    }
+    return true;
+}
 /** The manifest identity includes the installed package builds. */
 bool fingerprint(void* opaque, const state::content_manifest::View& view) noexcept {
     std::copy(view.buildFingerprint.begin(),
@@ -48,97 +155,209 @@ bool fingerprint(void* opaque, const state::content_manifest::View& view) noexce
 bool collect_name(void* opaque, const named::Entry& entry) noexcept {
     try {
         auto& names = *static_cast<Names*>(opaque);
-        if (entry.classId != 0x808091DE && entry.classId != 0x80809994) return true;
-        const std::string name(entry.name.data(), entry.nameLength);
-        auto found = names.rows.find(name);
-        if (found == names.rows.end() || found->second.patch < names.patch)
-            names.rows[name] = {{name, entry.tag, entry.classId, names.base}, names.patch, false};
-        else if (found->second.patch == names.patch
-                 && (found->second.value.tag != entry.tag
-                     || found->second.value.classId != entry.classId))
-            found->second.conflict = true;
+        if (entry.classId != 0x808091DE && entry.classId != 0x80809994) {
+            return true;
+        }
+        const std::string_view name(entry.name.data(), entry.nameLength);
+        const auto found = std::lower_bound(
+            names.rows.begin(), names.rows.end(), name, [](const Name& row, std::string_view key) {
+                return row.value.text() < key;
+            });
+        const Name replacement{{name, entry.tag, entry.classId, names.base}, names.patch, false};
+        if (found == names.rows.end() || found->value.text() != name) {
+            names.rows.insert(found, replacement);
+        } else if (found->patch < names.patch) {
+            *found = replacement;
+        } else if (found->patch == names.patch
+                   && (found->value.tag != entry.tag || found->value.classId != entry.classId)) {
+            found->conflict = true;
+        }
         return true;
     } catch (...) {
         return false;
     }
 }
 template <class T> T value(const Blob& bytes, std::size_t offset) {
-    if (offset > bytes.size() || sizeof(T) > bytes.size() - offset) throw 0;
+    if (offset > bytes.size() || sizeof(T) > bytes.size() - offset) {
+        throw 0;
+    }
     T result{};
     std::memcpy(&result, bytes.data() + offset, sizeof result);
     return result;
 }
+/** Replaces one family reference, keeping the bank sorted by key. */
+void set_reference(std::vector<Reference>& rows, const Reference& row) {
+    const auto found = std::lower_bound(
+        rows.begin(), rows.end(), row.key, [](const Reference& left, std::uint64_t key) {
+            return left.key < key;
+        });
+    if (found != rows.end() && found->key == row.key) {
+        *found = row;
+        return;
+    }
+    rows.insert(found, row);
+}
+/** Folds one family's references in, marking any key two families disagree on. */
+void merge_references(const std::vector<Reference>& family, std::vector<MergedReference>& merged) {
+    for (const Reference& row : family) {
+        const auto found = std::lower_bound(
+            merged.begin(),
+            merged.end(),
+            row.key,
+            [](const MergedReference& left, std::uint64_t key) { return left.row.key < key; });
+        if (found == merged.end() || found->row.key != row.key) {
+            merged.insert(found, MergedReference{row, true});
+        } else if (found->row.tag != row.tag || found->row.classId != row.classId) {
+            found->unique = false;
+        }
+    }
+}
+/** Parses one `<family>_<patch>.pkg` file name. @return False when the name has no patch suffix. */
+[[nodiscard]] bool parse_file_name(std::wstring_view name, File& output) noexcept {
+    output = {};
+    if (name.size() <= kPackageExtension.size() || name.size() >= output.name.size()
+        || name.substr(name.size() - kPackageExtension.size()) != kPackageExtension) {
+        return false;
+    }
+    const std::wstring_view stem = name.substr(0, name.size() - kPackageExtension.size());
+    const std::size_t separator = stem.rfind(L'_');
+    if (separator == std::wstring_view::npos || separator + 1 == stem.size()) {
+        return false;
+    }
+    std::uint64_t patch = 0;
+    for (const wchar_t character : stem.substr(separator + 1)) {
+        if (character < L'0' || character > L'9') {
+            return false;
+        }
+        patch = patch * 10U + static_cast<std::uint64_t>(character - L'0');
+        if (patch > UINT32_MAX) {
+            return false;
+        }
+    }
+    std::copy(name.begin(), name.end(), output.name.begin());
+    output.familyLength = separator;
+    output.patch = static_cast<std::uint32_t>(patch);
+    return true;
+}
+/** Collects every installed package file in family and patch order. */
+[[nodiscard]] bool collect_files(std::wstring_view directory, std::vector<File>& files) {
+    core::path::Buffer search{};
+    if (!join_path(directory, L"*", search)) {
+        return false;
+    }
+    WIN32_FIND_DATAW entry{};
+    const HANDLE find = FindFirstFileW(search.chars.data(), &entry);
+    if (find == INVALID_HANDLE_VALUE) {
+        return false;
+    }
+    bool complete = true;
+    do {
+        if ((entry.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0) {
+            continue;
+        }
+        File file{};
+        const std::wstring_view name(entry.cFileName);
+        if (name.size() <= kPackageExtension.size()
+            || name.substr(name.size() - kPackageExtension.size()) != kPackageExtension) {
+            continue;
+        }
+        if (files.size() >= kMaximumPackageFileCount || !parse_file_name(name, file)) {
+            complete = false;
+            break;
+        }
+        files.push_back(file);
+    } while (FindNextFileW(find, &entry) != FALSE);
+    (void)FindClose(find);
+    if (!complete || files.empty()) {
+        return false;
+    }
+    std::sort(files.begin(), files.end(), [](const File& a, const File& b) {
+        return family_of(a) < family_of(b) || (family_of(a) == family_of(b) && a.patch < b.patch);
+    });
+    return true;
+}
 /** Metadata references merge patches within a family before cross-family conflict checks. */
 bool inventory(std::wstring_view directory,
                std::vector<extractor::NamedTag>& names,
                std::vector<extractor::KeyTag>& keys) {
-    struct File final {
-        std::filesystem::path path;
-        std::wstring family;
-        std::uint32_t patch{};
-    };
     std::vector<File> files;
-    for (const auto& file : std::filesystem::directory_iterator(directory)) {
-        if (!file.is_regular_file() || file.path().extension() != L".pkg") continue;
-        const auto stem = file.path().stem().wstring();
-        const auto separator = stem.rfind(L'_');
-        if (separator == std::wstring::npos) return false;
-        std::size_t consumed{};
-        const auto patch = std::stoul(stem.substr(separator + 1), &consumed);
-        if (consumed != stem.size() - separator - 1 || patch > UINT32_MAX) return false;
-        files.push_back(
-            {file.path(), stem.substr(0, separator), static_cast<std::uint32_t>(patch)});
-    }
-    if (files.empty()) return false;
-    std::sort(files.begin(), files.end(), [](const File& a, const File& b) {
-        return a.family < b.family || (a.family == b.family && a.patch < b.patch);
-    });
+    if (!collect_files(directory, files)) {
+        return false;
+    }
     Names collected;
-    std::map<std::wstring, std::map<std::uint64_t, std::pair<std::uint32_t, std::uint32_t>>>
-        families;
+    std::vector<Reference> family;
+    std::vector<MergedReference> merged;
+    std::wstring_view currentFamily{};
+    core::path::Buffer path{};
     for (const auto& file : files) {
-        collected.base = file.family.find(L"_activities_") == std::wstring::npos;
+        if (family_of(file) != currentFamily) {
+            merge_references(family, merged);
+            family.clear();
+            currentFamily = family_of(file);
+        }
+        collected.base = currentFamily.find(L"_activities_") == std::wstring_view::npos;
         collected.patch = file.patch;
         named::Result result{};
-        if (!named::extract_file(file.path.c_str(), &collect_name, &collected, result))
+        if (!join_path(directory, std::wstring_view(file.name.data()), path)
+            || !named::extract_file(path.chars.data(), &collect_name, &collected, result)) {
+            return false;
+        }
+        const FileHandle handle(path.chars.data());
+        LARGE_INTEGER length{};
+        if (!handle.valid() || GetFileSizeEx(handle.get(), &length) == FALSE
+            || length.QuadPart <= 0) {
             return false;
-        std::ifstream stream(file.path, std::ios::binary | std::ios::ate);
-        const auto length = stream.tellg();
-        Blob header(0x180);
-        stream.seekg(0);
-        if (!stream.read(reinterpret_cast<char*>(header.data()), header.size())
-            || value<std::uint16_t>(header, 0) != 38)
+        }
+        Blob header(kHeaderBytes);
+        if (!read_at(handle.get(), 0, header) || value<std::uint16_t>(header, 0) != 38) {
             return false;
+        }
         /** Beta metadata has no hash64 reference directory at offset 48. */
-        if (value<std::uint8_t>(header, 0x1A) == 0) continue;
-        if (value<std::uint8_t>(header, 0x1A) != 1) return false;
+        if (value<std::uint8_t>(header, 0x1A) == 0) {
+            continue;
+        }
+        if (value<std::uint8_t>(header, 0x1A) != 1) {
+            return false;
+        }
         const auto offset = value<std::uint32_t>(header, 0xF0),
                    size = value<std::uint32_t>(header, 0xF4);
-        if (size == 0) continue;
-        if (size > 64 * 1024 * 1024
-            || static_cast<std::uint64_t>(offset) + size > static_cast<std::uint64_t>(length))
+        if (size == 0) {
+            continue;
+        }
+        if (size > kMaximumMetadataBytes
+            || static_cast<std::uint64_t>(offset) + size
+                   > static_cast<std::uint64_t>(length.QuadPart)) {
             return false;
+        }
         Blob metadata(size);
-        stream.seekg(offset);
-        if (!stream.read(reinterpret_cast<char*>(metadata.data()), size)) return false;
-        if (metadata.size() < 64) continue;
+        if (!read_at(handle.get(), offset, metadata)) {
+            return false;
+        }
+        if (metadata.size() < 64) {
+            continue;
+        }
         std::vector<std::size_t> offsets;
-        if (!extractor::array(metadata, 48, 16, 0x80809D02, offsets)) return false;
-        auto& rows = families[file.family];
-        for (auto member : offsets)
-            rows[value<std::uint64_t>(metadata, member)] = {
-                value<std::uint32_t>(metadata, member + 8),
-                value<std::uint32_t>(metadata, member + 12)};
-    }
-    std::map<std::uint64_t, std::set<std::pair<std::uint32_t, std::uint32_t>>> merged;
-    for (const auto& family : families)
-        for (const auto& row : family.second)
-            merged[row.first].insert(row.second);
-    for (const auto& row : merged)
-        if (row.second.size() == 1)
-            keys.push_back({row.first, row.second.begin()->first, row.second.begin()->second});
-    for (const auto& row : collected.rows)
-        if (!row.second.conflict) names.push_back(row.second.value);
+        if (!extractor::array(metadata, 48, 16, 0x80809D02, offsets)) {
+            return false;
+        }
+        for (auto member : offsets) {
+            set_reference(family,
+                          {value<std::uint64_t>(metadata, member),
+                           value<std::uint32_t>(metadata, member + 8),
+                           value<std::uint32_t>(metadata, member + 12)});
+        }
+    }
+    merge_references(family, merged);
+    for (const MergedReference& row : merged) {
+        if (row.unique) {
+            keys.push_back({row.row.key, row.row.tag, row.row.classId});
+        }
+    }
+    for (const Name& row : collected.rows) {
+        if (!row.conflict) {
+            names.push_back(row.value);
+        }
+    }
     return true;
 }
 /** Class checks apply to every live tag reached by the extraction. */
@@ -151,7 +370,9 @@ bool read(void* opaque, std::uint32_t tag, std::uint32_t expected, Blob& bytes)
 } // namespace
 bool ready() noexcept {
     profiles::Fingerprint identity{};
-    if (!state::content_manifest::visit_snapshot(&fingerprint, &identity)) return false;
+    if (!state::content_manifest::visit_snapshot(&fingerprint, &identity)) {
+        return false;
+    }
     const bool positions = profiles::confirm(identity);
     const bool objects = state::gameplay::entity_object_types::confirm(identity);
     return positions && objects;
@@ -160,11 +381,17 @@ bool ready() noexcept {
 bool build(const reader::Source& source, reader::Scratch& scratch) noexcept {
     try {
         profiles::Fingerprint identity{};
-        if (!state::content_manifest::visit_snapshot(&fingerprint, &identity)) return false;
+        if (!state::content_manifest::visit_snapshot(&fingerprint, &identity)) {
+            return false;
+        }
         const bool positions = profiles::confirm(identity);
         const bool objects = state::gameplay::entity_object_types::confirm(identity);
-        if (positions && objects) return true;
-        if (!objects && !entity_object_types::build(source, scratch, identity)) return false;
+        if (positions && objects) {
+            return true;
+        }
+        if (!objects && !entity_object_types::build(source, scratch, identity)) {
+            return false;
+        }
         if (positions) {
             state::build_data::invalidate_cache();
             return true;
@@ -175,11 +402,14 @@ bool build(const reader::Source& source, reader::Scratch& scratch) noexcept {
         profiles::Rows rows;
         Context context{source, scratch};
         if (!inventory(source.directory, names, keys)
-            || !extractor::extract(names, keys, &read, &context, rows))
+            || !extractor::extract(names, keys, &read, &context, rows)) {
             return false;
+        }
         const auto count = rows.size();
         const bool published = profiles::publish(std::move(rows), identity);
-        if (published) state::build_data::invalidate_cache();
+        if (published) {
+            state::build_data::invalidate_cache();
+        }
         char line[160]{};
         (void)std::snprintf(
             line, sizeof line, "entity_position_profiles source=packages rows=%zu", count);

+ 279 - 0
Sunrise/src/client/content/activity/scriptable_catalog_container_placement_cache.cpp

@@ -0,0 +1,279 @@
+#include <algorithm>
+#include <cstddef>
+#include <cstdint>
+#include <cstring>
+#include <limits>
+#include <vector>
+
+#include "scriptable_catalog_container_placements_internal.h"
+
+namespace sunrise::client::content::activity::scriptables::internal::container_placements {
+namespace {
+
+/** Copies every placement member onto deterministic zeroed storage. */
+void copy_placement_row(const catalog::ContainerPlacement& source,
+                        catalog::ContainerPlacement& output) noexcept {
+    zero_row_storage(output);
+    output.listRow = source.listRow;
+    output.objectListTag = source.objectListTag;
+    output.entryIndex = source.entryIndex;
+    output.classListTag = source.classListTag;
+    output.classListNameRow = source.classListNameRow;
+    output.firstConfig = source.firstConfig;
+    output.configCount = source.configCount;
+    output.rotation = source.rotation;
+    output.position = source.position;
+    output.uniformScale = source.uniformScale;
+    output.placementIdentifier = source.placementIdentifier;
+    output.objectType = source.objectType;
+    output.placementIdentifierRead = source.placementIdentifierRead;
+    output.complete = source.complete;
+}
+
+/** Copies every config member onto deterministic zeroed storage. */
+void copy_config_row(const catalog::ContainerPlacementConfig& source,
+                     catalog::ContainerPlacementConfig& output) noexcept {
+    zero_row_storage(output);
+    output.placementRow = source.placementRow;
+    output.configTag = source.configTag;
+    output.configNameRow = source.configNameRow;
+    output.firstComponent = source.firstComponent;
+    output.componentCount = source.componentCount;
+    output.buildOrdinal = source.buildOrdinal;
+    output.secondWord = source.secondWord;
+    output.thirdWord = source.thirdWord;
+    output.complete = source.complete;
+}
+
+/** Captures one exact evidence slice with every byte offset local to that slice. */
+[[nodiscard]] bool capture_evidence(const catalog::Snapshot& output,
+                                    std::size_t firstInlineName,
+                                    std::size_t firstInlineByte,
+                                    ContainerPlacementCacheAccess::Evidence& cached) noexcept {
+    if (firstInlineName > output.inlineNameCandidates.size()
+        || firstInlineByte > output.inlineNameBytes.size()
+        || firstInlineByte > (std::numeric_limits<std::uint32_t>::max)()) {
+        return false;
+    }
+    try {
+        cached = {};
+        cached.bytes.assign(output.inlineNameBytes.begin()
+                                + static_cast<std::ptrdiff_t>(firstInlineByte),
+                            output.inlineNameBytes.end());
+        cached.rows.reserve(output.inlineNameCandidates.size() - firstInlineName);
+        for (std::size_t index = firstInlineName; index < output.inlineNameCandidates.size();
+             ++index) {
+            catalog::InlineNameCandidate row = output.inlineNameCandidates[index];
+            if (row.firstByte < firstInlineByte) {
+                return false;
+            }
+            const std::size_t relative = row.firstByte - firstInlineByte;
+            if (relative > cached.bytes.size() || row.byteCount > cached.bytes.size() - relative) {
+                return false;
+            }
+            row.firstByte = static_cast<std::uint32_t>(relative);
+            cached.rows.push_back(row);
+        }
+        return true;
+    } catch (...) {
+        return false;
+    }
+}
+
+/** Replays one exact evidence slice or leaves both destination banks unchanged. */
+[[nodiscard]] CacheReplay
+replay_evidence(BuildContext& context,
+                const ContainerPlacementCacheAccess::Evidence& cached) noexcept {
+    if (cancelled(context)) {
+        return CacheReplay::cancelled;
+    }
+    catalog::Snapshot& output = *context.output;
+    // Snapshot rows are published as u32, so a bank stops at that maximum.
+    constexpr std::size_t maximum = (std::numeric_limits<std::uint32_t>::max)();
+    const std::size_t firstInlineName = output.inlineNameCandidates.size();
+    const std::size_t firstInlineByte = output.inlineNameBytes.size();
+    if (firstInlineName > maximum || cached.rows.size() > maximum - firstInlineName
+        || firstInlineByte > maximum || cached.bytes.size() > maximum - firstInlineByte) {
+        return CacheReplay::unavailable;
+    }
+    try {
+        output.inlineNameCandidates.reserve(firstInlineName + cached.rows.size());
+        output.inlineNameBytes.reserve(firstInlineByte + cached.bytes.size());
+    } catch (...) {
+        return CacheReplay::unavailable;
+    }
+    output.inlineNameBytes.insert(
+        output.inlineNameBytes.end(), cached.bytes.begin(), cached.bytes.end());
+    for (catalog::InlineNameCandidate row : cached.rows) {
+        if (cancelled(context)) {
+            output.inlineNameCandidates.resize(firstInlineName);
+            output.inlineNameBytes.resize(firstInlineByte);
+            return CacheReplay::cancelled;
+        }
+        row.firstByte += static_cast<std::uint32_t>(firstInlineByte);
+        output.inlineNameCandidates.push_back(row);
+    }
+    if (cancelled(context)) {
+        output.inlineNameCandidates.resize(firstInlineName);
+        output.inlineNameBytes.resize(firstInlineByte);
+        return CacheReplay::cancelled;
+    }
+    return CacheReplay::complete;
+}
+
+/** @return True when a cached complete graph fits every remaining scenario bank. */
+[[nodiscard]] bool graph_fits(const catalog::Snapshot& output,
+                              const ContainerPlacementCacheAccess::Value& cached) noexcept {
+    return output.containerPlacements.size() <= kPlacementCapacity
+           && cached.placements.size() <= kPlacementCapacity - output.containerPlacements.size()
+           && output.containerPlacementConfigs.size() <= kConfigCapacity
+           && cached.configs.size() <= kConfigCapacity - output.containerPlacementConfigs.size()
+           && output.containerPlacementComponents.size() <= kComponentCapacity
+           && cached.components.size()
+                  <= kComponentCapacity - output.containerPlacementComponents.size();
+}
+
+} // namespace
+
+/** Captures one complete list graph with every row index local to that graph. */
+bool capture_cached_graph(const catalog::Snapshot& output,
+                          std::uint32_t listRow,
+                          std::size_t firstPlacement,
+                          std::size_t firstConfig,
+                          std::size_t firstComponent,
+                          std::size_t firstInlineName,
+                          std::size_t firstInlineByte,
+                          ContainerPlacementCacheAccess::Value& cached) noexcept {
+    try {
+        cached = {};
+        cached.placements.assign(output.containerPlacements.begin()
+                                     + static_cast<std::ptrdiff_t>(firstPlacement),
+                                 output.containerPlacements.end());
+        cached.configs.assign(output.containerPlacementConfigs.begin()
+                                  + static_cast<std::ptrdiff_t>(firstConfig),
+                              output.containerPlacementConfigs.end());
+        cached.components.assign(output.containerPlacementComponents.begin()
+                                     + static_cast<std::ptrdiff_t>(firstComponent),
+                                 output.containerPlacementComponents.end());
+        for (catalog::ContainerPlacement& placement : cached.placements) {
+            if (placement.listRow != listRow || placement.firstConfig < firstConfig
+                || placement.firstConfig - firstConfig > cached.configs.size()) {
+                return false;
+            }
+            placement.listRow = 0;
+            placement.firstConfig -= static_cast<std::uint32_t>(firstConfig);
+        }
+        for (catalog::ContainerPlacementConfig& config : cached.configs) {
+            if (config.placementRow < firstPlacement
+                || config.placementRow - firstPlacement >= cached.placements.size()
+                || config.firstComponent < firstComponent
+                || config.firstComponent - firstComponent > cached.components.size()) {
+                return false;
+            }
+            config.placementRow -= static_cast<std::uint32_t>(firstPlacement);
+            config.firstComponent -= static_cast<std::uint32_t>(firstComponent);
+        }
+        for (catalog::ContainerPlacementComponent& component : cached.components) {
+            if (component.configRow < firstConfig
+                || component.configRow - firstConfig >= cached.configs.size()) {
+                return false;
+            }
+            component.configRow -= static_cast<std::uint32_t>(firstConfig);
+        }
+        return capture_evidence(output, firstInlineName, firstInlineByte, cached.evidence);
+    } catch (...) {
+        return false;
+    }
+}
+
+/** Replays one complete graph or leaves every destination bank unchanged. */
+CacheReplay replay_cached_graph(BuildContext& context,
+                                std::uint32_t listRow,
+                                const ContainerPlacementCacheAccess::Value& cached) noexcept {
+    if (cancelled(context)) {
+        return CacheReplay::cancelled;
+    }
+    catalog::Snapshot& output = *context.output;
+    if (!graph_fits(output, cached)) {
+        return CacheReplay::unavailable;
+    }
+    // Snapshot rows are published as u32, so a bank stops at that maximum.
+    constexpr std::size_t maximum = (std::numeric_limits<std::uint32_t>::max)();
+    const std::size_t firstPlacement = output.containerPlacements.size();
+    const std::size_t firstConfig = output.containerPlacementConfigs.size();
+    const std::size_t firstComponent = output.containerPlacementComponents.size();
+    const std::size_t firstInlineName = output.inlineNameCandidates.size();
+    const std::size_t firstInlineByte = output.inlineNameBytes.size();
+    if (firstInlineName > maximum || cached.evidence.rows.size() > maximum - firstInlineName
+        || firstInlineByte > maximum || cached.evidence.bytes.size() > maximum - firstInlineByte) {
+        return CacheReplay::unavailable;
+    }
+    try {
+        output.containerPlacements.reserve(firstPlacement + cached.placements.size());
+        output.containerPlacementConfigs.reserve(firstConfig + cached.configs.size());
+        output.containerPlacementComponents.reserve(firstComponent + cached.components.size());
+        output.inlineNameCandidates.reserve(firstInlineName + cached.evidence.rows.size());
+        output.inlineNameBytes.reserve(firstInlineByte + cached.evidence.bytes.size());
+    } catch (...) {
+        return CacheReplay::unavailable;
+    }
+    const auto rollback = [&output,
+                           firstPlacement,
+                           firstConfig,
+                           firstComponent,
+                           firstInlineName,
+                           firstInlineByte]() noexcept {
+        output.containerPlacements.resize(firstPlacement);
+        output.containerPlacementConfigs.resize(firstConfig);
+        output.containerPlacementComponents.resize(firstComponent);
+        output.inlineNameCandidates.resize(firstInlineName);
+        output.inlineNameBytes.resize(firstInlineByte);
+    };
+    output.inlineNameBytes.insert(
+        output.inlineNameBytes.end(), cached.evidence.bytes.begin(), cached.evidence.bytes.end());
+    for (catalog::InlineNameCandidate row : cached.evidence.rows) {
+        if (cancelled(context)) {
+            rollback();
+            return CacheReplay::cancelled;
+        }
+        row.firstByte += static_cast<std::uint32_t>(firstInlineByte);
+        output.inlineNameCandidates.push_back(row);
+    }
+    for (const catalog::ContainerPlacement& source : cached.placements) {
+        if (cancelled(context)) {
+            rollback();
+            return CacheReplay::cancelled;
+        }
+        output.containerPlacements.emplace_back();
+        catalog::ContainerPlacement& row = output.containerPlacements.back();
+        copy_placement_row(source, row);
+        row.listRow = listRow;
+        row.firstConfig += static_cast<std::uint32_t>(firstConfig);
+    }
+    for (const catalog::ContainerPlacementConfig& source : cached.configs) {
+        if (cancelled(context)) {
+            rollback();
+            return CacheReplay::cancelled;
+        }
+        output.containerPlacementConfigs.emplace_back();
+        catalog::ContainerPlacementConfig& row = output.containerPlacementConfigs.back();
+        copy_config_row(source, row);
+        row.placementRow += static_cast<std::uint32_t>(firstPlacement);
+        row.firstComponent += static_cast<std::uint32_t>(firstComponent);
+    }
+    for (catalog::ContainerPlacementComponent row : cached.components) {
+        if (cancelled(context)) {
+            rollback();
+            return CacheReplay::cancelled;
+        }
+        row.configRow += static_cast<std::uint32_t>(firstConfig);
+        output.containerPlacementComponents.push_back(row);
+    }
+    if (cancelled(context)) {
+        rollback();
+        return CacheReplay::cancelled;
+    }
+    return CacheReplay::complete;
+}
+
+} // namespace sunrise::client::content::activity::scriptables::internal::container_placements

+ 3 - 395
Sunrise/src/client/content/activity/scriptable_catalog_container_placements.cpp

@@ -20,177 +20,19 @@
 #include "../../../middleware/content/packages/tables/container_placement_reader.h"
 #include "../../../state/build_data/runtime.h"
 #include "../spawn_sets/spawn_set_catalog_builder.h"
+#include "scriptable_catalog_container_placements_internal.h"
 #include "scriptable_catalog_inline_names.h"
 
 namespace sunrise::client::content::activity::scriptables::internal {
 
-/** Fixed-capacity per-tag store of decoded container placements, shared across scenarios. */
-struct ContainerPlacementCache::Impl final {
-    static constexpr std::size_t kEntryCapacity = 16'384;
-    static constexpr std::size_t kPlacementCapacity = 200'000;
-    static constexpr std::size_t kConfigCapacity = 500'000;
-    static constexpr std::size_t kComponentCapacity = 600'000;
-    static constexpr std::size_t kInlineNameCapacity = 1'048'576;
-    static constexpr std::size_t kInlineByteCapacity = 128ULL * 1024ULL * 1024ULL;
-
-    struct Evidence final {
-        std::vector<state::build_data::scriptables::InlineNameCandidate> rows{};
-        std::vector<std::byte> bytes{};
-    };
-
-    struct Value final {
-        std::vector<state::build_data::scriptables::ContainerPlacement> placements{};
-        std::vector<state::build_data::scriptables::ContainerPlacementConfig> configs{};
-        std::vector<state::build_data::scriptables::ContainerPlacementComponent> components{};
-        Evidence evidence{};
-    };
-
-    Impl() {
-        entries.reserve(kEntryCapacity);
-    }
-
-    std::unordered_map<std::uint32_t, std::shared_ptr<const Value>> entries{};
-    std::size_t placementRows{};
-    std::size_t configRows{};
-    std::size_t componentRows{};
-    std::size_t inlineNameRows{};
-    std::size_t inlineBytes{};
-};
-
-/** Gives this translation unit bounded access to the cache's private storage. */
-struct ContainerPlacementCacheAccess final {
-    using Evidence = ContainerPlacementCache::Impl::Evidence;
-    using Value = ContainerPlacementCache::Impl::Value;
-
-    [[nodiscard]] static std::shared_ptr<const Value> find(const ContainerPlacementCache& cache,
-                                                           std::uint32_t tag) noexcept {
-        if (cache.impl_ == nullptr) {
-            return {};
-        }
-        const auto found = cache.impl_->entries.find(tag);
-        return found == cache.impl_->entries.end() ? std::shared_ptr<const Value>{} : found->second;
-    }
-
-    /** Stores one tag's decoded placements, dropping the insert when a capacity is reached. */
-    static void remember(ContainerPlacementCache& cache, std::uint32_t tag, Value value) noexcept {
-        if (cache.impl_ == nullptr || cache.impl_->entries.contains(tag)) {
-            return;
-        }
-        ContainerPlacementCache::Impl& impl = *cache.impl_;
-        if (impl.entries.size() >= ContainerPlacementCache::Impl::kEntryCapacity
-            || impl.placementRows > ContainerPlacementCache::Impl::kPlacementCapacity
-            || value.placements.size()
-                   > ContainerPlacementCache::Impl::kPlacementCapacity - impl.placementRows
-            || impl.configRows > ContainerPlacementCache::Impl::kConfigCapacity
-            || value.configs.size()
-                   > ContainerPlacementCache::Impl::kConfigCapacity - impl.configRows
-            || impl.componentRows > ContainerPlacementCache::Impl::kComponentCapacity
-            || value.components.size()
-                   > ContainerPlacementCache::Impl::kComponentCapacity - impl.componentRows
-            || impl.inlineNameRows > ContainerPlacementCache::Impl::kInlineNameCapacity
-            || value.evidence.rows.size()
-                   > ContainerPlacementCache::Impl::kInlineNameCapacity - impl.inlineNameRows
-            || impl.inlineBytes > ContainerPlacementCache::Impl::kInlineByteCapacity
-            || value.evidence.bytes.size()
-                   > ContainerPlacementCache::Impl::kInlineByteCapacity - impl.inlineBytes) {
-            return;
-        }
-        try {
-            const std::size_t placements = value.placements.size();
-            const std::size_t configs = value.configs.size();
-            const std::size_t components = value.components.size();
-            const std::size_t names = value.evidence.rows.size();
-            const std::size_t bytes = value.evidence.bytes.size();
-            auto shared = std::make_shared<Value>(std::move(value));
-            const auto result = impl.entries.emplace(tag, std::move(shared));
-            if (result.second) {
-                impl.placementRows += placements;
-                impl.configRows += configs;
-                impl.componentRows += components;
-                impl.inlineNameRows += names;
-                impl.inlineBytes += bytes;
-            }
-        } catch (...) {
-            // A complete result stays valid when the optional pass cache cannot grow.
-        }
-    }
-};
-
 namespace {
 
+using namespace container_placements;
+
 namespace catalog = state::build_data::scriptables;
 namespace package_reader = middleware::content::packages::reader;
 namespace tables = middleware::content::packages::tables;
 
-/** Hard limits bound one scenario's process-only package graph. */
-constexpr std::size_t kListCapacity = 8'192;
-constexpr std::size_t kOwnerCapacity = 32'768;
-constexpr std::size_t kPlacementCapacity = 262'144;
-constexpr std::size_t kConfigCapacity = 1'048'576;
-constexpr std::size_t kComponentCapacity = 1'048'576;
-
-/** Inputs and outputs of one container placement pass. */
-struct BuildContext final {
-    const package_reader::Source* source{};
-    package_reader::Scratch* scratch{};
-    catalog::Snapshot* output{};
-    ContainerPlacementCache* cache{};
-    ContainerPlacementCancelCheck cancel{};
-    std::string_view stem{};
-    std::unordered_map<std::uint32_t, std::uint32_t> listRows{};
-    std::vector<std::byte> containerBytes{};
-    std::vector<std::byte> listBytes{};
-    std::vector<std::byte> classBytes{};
-    std::vector<std::byte> configBytes{};
-    std::vector<std::byte> resourceBytes{};
-    bool failed{};
-};
-
-[[nodiscard]] bool cancelled(const BuildContext& context) noexcept {
-    return context.cancel != nullptr && context.cancel();
-}
-
-/** Clears padding as well as members before a raw row can reach the serialized snapshot. */
-template <typename Value> void zero_row_storage(Value& output) noexcept {
-    static_assert(std::is_trivially_copyable_v<Value>);
-    std::memset(&output, 0, sizeof output);
-}
-
-/** Copies every placement member onto deterministic zeroed storage. */
-void copy_placement_row(const catalog::ContainerPlacement& source,
-                        catalog::ContainerPlacement& output) noexcept {
-    zero_row_storage(output);
-    output.listRow = source.listRow;
-    output.objectListTag = source.objectListTag;
-    output.entryIndex = source.entryIndex;
-    output.classListTag = source.classListTag;
-    output.classListNameRow = source.classListNameRow;
-    output.firstConfig = source.firstConfig;
-    output.configCount = source.configCount;
-    output.rotation = source.rotation;
-    output.position = source.position;
-    output.uniformScale = source.uniformScale;
-    output.placementIdentifier = source.placementIdentifier;
-    output.objectType = source.objectType;
-    output.placementIdentifierRead = source.placementIdentifierRead;
-    output.complete = source.complete;
-}
-
-/** Copies every config member onto deterministic zeroed storage. */
-void copy_config_row(const catalog::ContainerPlacementConfig& source,
-                     catalog::ContainerPlacementConfig& output) noexcept {
-    zero_row_storage(output);
-    output.placementRow = source.placementRow;
-    output.configTag = source.configTag;
-    output.configNameRow = source.configNameRow;
-    output.firstComponent = source.firstComponent;
-    output.componentCount = source.componentCount;
-    output.buildOrdinal = source.buildOrdinal;
-    output.secondWord = source.secondWord;
-    output.thirdWord = source.thirdWord;
-    output.complete = source.complete;
-}
-
 /** Reads one reached tag and keeps all valid inline-name evidence in its blob. */
 [[nodiscard]] bool read_tag(BuildContext& context,
                             std::uint32_t tag,
@@ -261,240 +103,6 @@ template <std::size_t Size>
     return std::all_of(value.begin(), value.end(), [](float lane) { return std::isfinite(lane); });
 }
 
-enum class CacheReplay : std::uint8_t {
-    unavailable,
-    complete,
-    cancelled,
-};
-
-/** Captures one exact evidence slice with every byte offset local to that slice. */
-[[nodiscard]] bool capture_evidence(const catalog::Snapshot& output,
-                                    std::size_t firstInlineName,
-                                    std::size_t firstInlineByte,
-                                    ContainerPlacementCacheAccess::Evidence& cached) noexcept {
-    if (firstInlineName > output.inlineNameCandidates.size()
-        || firstInlineByte > output.inlineNameBytes.size()
-        || firstInlineByte > (std::numeric_limits<std::uint32_t>::max)()) {
-        return false;
-    }
-    try {
-        cached = {};
-        cached.bytes.assign(output.inlineNameBytes.begin()
-                                + static_cast<std::ptrdiff_t>(firstInlineByte),
-                            output.inlineNameBytes.end());
-        cached.rows.reserve(output.inlineNameCandidates.size() - firstInlineName);
-        for (std::size_t index = firstInlineName; index < output.inlineNameCandidates.size();
-             ++index) {
-            catalog::InlineNameCandidate row = output.inlineNameCandidates[index];
-            if (row.firstByte < firstInlineByte) {
-                return false;
-            }
-            const std::size_t relative = row.firstByte - firstInlineByte;
-            if (relative > cached.bytes.size() || row.byteCount > cached.bytes.size() - relative) {
-                return false;
-            }
-            row.firstByte = static_cast<std::uint32_t>(relative);
-            cached.rows.push_back(row);
-        }
-        return true;
-    } catch (...) {
-        return false;
-    }
-}
-
-/** Replays one exact evidence slice or leaves both destination banks unchanged. */
-[[nodiscard]] CacheReplay
-replay_evidence(BuildContext& context,
-                const ContainerPlacementCacheAccess::Evidence& cached) noexcept {
-    if (cancelled(context)) {
-        return CacheReplay::cancelled;
-    }
-    catalog::Snapshot& output = *context.output;
-    constexpr std::size_t maximum = (std::numeric_limits<std::uint32_t>::max)();
-    const std::size_t firstInlineName = output.inlineNameCandidates.size();
-    const std::size_t firstInlineByte = output.inlineNameBytes.size();
-    if (firstInlineName > maximum || cached.rows.size() > maximum - firstInlineName
-        || firstInlineByte > maximum || cached.bytes.size() > maximum - firstInlineByte) {
-        return CacheReplay::unavailable;
-    }
-    try {
-        output.inlineNameCandidates.reserve(firstInlineName + cached.rows.size());
-        output.inlineNameBytes.reserve(firstInlineByte + cached.bytes.size());
-    } catch (...) {
-        return CacheReplay::unavailable;
-    }
-    output.inlineNameBytes.insert(
-        output.inlineNameBytes.end(), cached.bytes.begin(), cached.bytes.end());
-    for (catalog::InlineNameCandidate row : cached.rows) {
-        if (cancelled(context)) {
-            output.inlineNameCandidates.resize(firstInlineName);
-            output.inlineNameBytes.resize(firstInlineByte);
-            return CacheReplay::cancelled;
-        }
-        row.firstByte += static_cast<std::uint32_t>(firstInlineByte);
-        output.inlineNameCandidates.push_back(row);
-    }
-    if (cancelled(context)) {
-        output.inlineNameCandidates.resize(firstInlineName);
-        output.inlineNameBytes.resize(firstInlineByte);
-        return CacheReplay::cancelled;
-    }
-    return CacheReplay::complete;
-}
-
-/** Captures one complete list graph with every row index local to that graph. */
-[[nodiscard]] bool capture_cached_graph(const catalog::Snapshot& output,
-                                        std::uint32_t listRow,
-                                        std::size_t firstPlacement,
-                                        std::size_t firstConfig,
-                                        std::size_t firstComponent,
-                                        std::size_t firstInlineName,
-                                        std::size_t firstInlineByte,
-                                        ContainerPlacementCacheAccess::Value& cached) noexcept {
-    try {
-        cached = {};
-        cached.placements.assign(output.containerPlacements.begin()
-                                     + static_cast<std::ptrdiff_t>(firstPlacement),
-                                 output.containerPlacements.end());
-        cached.configs.assign(output.containerPlacementConfigs.begin()
-                                  + static_cast<std::ptrdiff_t>(firstConfig),
-                              output.containerPlacementConfigs.end());
-        cached.components.assign(output.containerPlacementComponents.begin()
-                                     + static_cast<std::ptrdiff_t>(firstComponent),
-                                 output.containerPlacementComponents.end());
-        for (catalog::ContainerPlacement& placement : cached.placements) {
-            if (placement.listRow != listRow || placement.firstConfig < firstConfig
-                || placement.firstConfig - firstConfig > cached.configs.size()) {
-                return false;
-            }
-            placement.listRow = 0;
-            placement.firstConfig -= static_cast<std::uint32_t>(firstConfig);
-        }
-        for (catalog::ContainerPlacementConfig& config : cached.configs) {
-            if (config.placementRow < firstPlacement
-                || config.placementRow - firstPlacement >= cached.placements.size()
-                || config.firstComponent < firstComponent
-                || config.firstComponent - firstComponent > cached.components.size()) {
-                return false;
-            }
-            config.placementRow -= static_cast<std::uint32_t>(firstPlacement);
-            config.firstComponent -= static_cast<std::uint32_t>(firstComponent);
-        }
-        for (catalog::ContainerPlacementComponent& component : cached.components) {
-            if (component.configRow < firstConfig
-                || component.configRow - firstConfig >= cached.configs.size()) {
-                return false;
-            }
-            component.configRow -= static_cast<std::uint32_t>(firstConfig);
-        }
-        return capture_evidence(output, firstInlineName, firstInlineByte, cached.evidence);
-    } catch (...) {
-        return false;
-    }
-}
-
-/** @return True when a cached complete graph fits every remaining scenario bank. */
-[[nodiscard]] bool graph_fits(const catalog::Snapshot& output,
-                              const ContainerPlacementCacheAccess::Value& cached) noexcept {
-    return output.containerPlacements.size() <= kPlacementCapacity
-           && cached.placements.size() <= kPlacementCapacity - output.containerPlacements.size()
-           && output.containerPlacementConfigs.size() <= kConfigCapacity
-           && cached.configs.size() <= kConfigCapacity - output.containerPlacementConfigs.size()
-           && output.containerPlacementComponents.size() <= kComponentCapacity
-           && cached.components.size()
-                  <= kComponentCapacity - output.containerPlacementComponents.size();
-}
-
-/** Replays one complete graph or leaves every destination bank unchanged. */
-[[nodiscard]] CacheReplay
-replay_cached_graph(BuildContext& context,
-                    std::uint32_t listRow,
-                    const ContainerPlacementCacheAccess::Value& cached) noexcept {
-    if (cancelled(context)) {
-        return CacheReplay::cancelled;
-    }
-    catalog::Snapshot& output = *context.output;
-    if (!graph_fits(output, cached)) {
-        return CacheReplay::unavailable;
-    }
-    constexpr std::size_t maximum = (std::numeric_limits<std::uint32_t>::max)();
-    const std::size_t firstPlacement = output.containerPlacements.size();
-    const std::size_t firstConfig = output.containerPlacementConfigs.size();
-    const std::size_t firstComponent = output.containerPlacementComponents.size();
-    const std::size_t firstInlineName = output.inlineNameCandidates.size();
-    const std::size_t firstInlineByte = output.inlineNameBytes.size();
-    if (firstInlineName > maximum || cached.evidence.rows.size() > maximum - firstInlineName
-        || firstInlineByte > maximum || cached.evidence.bytes.size() > maximum - firstInlineByte) {
-        return CacheReplay::unavailable;
-    }
-    try {
-        output.containerPlacements.reserve(firstPlacement + cached.placements.size());
-        output.containerPlacementConfigs.reserve(firstConfig + cached.configs.size());
-        output.containerPlacementComponents.reserve(firstComponent + cached.components.size());
-        output.inlineNameCandidates.reserve(firstInlineName + cached.evidence.rows.size());
-        output.inlineNameBytes.reserve(firstInlineByte + cached.evidence.bytes.size());
-    } catch (...) {
-        return CacheReplay::unavailable;
-    }
-    const auto rollback = [&output,
-                           firstPlacement,
-                           firstConfig,
-                           firstComponent,
-                           firstInlineName,
-                           firstInlineByte]() noexcept {
-        output.containerPlacements.resize(firstPlacement);
-        output.containerPlacementConfigs.resize(firstConfig);
-        output.containerPlacementComponents.resize(firstComponent);
-        output.inlineNameCandidates.resize(firstInlineName);
-        output.inlineNameBytes.resize(firstInlineByte);
-    };
-    output.inlineNameBytes.insert(
-        output.inlineNameBytes.end(), cached.evidence.bytes.begin(), cached.evidence.bytes.end());
-    for (catalog::InlineNameCandidate row : cached.evidence.rows) {
-        if (cancelled(context)) {
-            rollback();
-            return CacheReplay::cancelled;
-        }
-        row.firstByte += static_cast<std::uint32_t>(firstInlineByte);
-        output.inlineNameCandidates.push_back(row);
-    }
-    for (const catalog::ContainerPlacement& source : cached.placements) {
-        if (cancelled(context)) {
-            rollback();
-            return CacheReplay::cancelled;
-        }
-        output.containerPlacements.emplace_back();
-        catalog::ContainerPlacement& row = output.containerPlacements.back();
-        copy_placement_row(source, row);
-        row.listRow = listRow;
-        row.firstConfig += static_cast<std::uint32_t>(firstConfig);
-    }
-    for (const catalog::ContainerPlacementConfig& source : cached.configs) {
-        if (cancelled(context)) {
-            rollback();
-            return CacheReplay::cancelled;
-        }
-        output.containerPlacementConfigs.emplace_back();
-        catalog::ContainerPlacementConfig& row = output.containerPlacementConfigs.back();
-        copy_config_row(source, row);
-        row.placementRow += static_cast<std::uint32_t>(firstPlacement);
-        row.firstComponent += static_cast<std::uint32_t>(firstComponent);
-    }
-    for (catalog::ContainerPlacementComponent row : cached.components) {
-        if (cancelled(context)) {
-            rollback();
-            return CacheReplay::cancelled;
-        }
-        row.configRow += static_cast<std::uint32_t>(firstConfig);
-        output.containerPlacementComponents.push_back(row);
-    }
-    if (cancelled(context)) {
-        rollback();
-        return CacheReplay::cancelled;
-    }
-    return CacheReplay::complete;
-}
-
 /** Reads one tag only when its class matches the typed edge being followed. */
 [[nodiscard]] bool read_exact(BuildContext& context,
                               std::uint32_t tag,

+ 173 - 0
Sunrise/src/client/content/activity/scriptable_catalog_container_placements_internal.h

@@ -0,0 +1,173 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <cstring>
+#include <memory>
+#include <string_view>
+#include <type_traits>
+#include <unordered_map>
+#include <vector>
+
+#include "scriptable_catalog_container_placements.h"
+
+namespace sunrise::client::content::activity::scriptables::internal {
+
+/** Fixed-capacity per-tag store of decoded container placements, shared across scenarios. */
+struct ContainerPlacementCache::Impl final {
+    // Fixed capacities bound one cached tag's decoded rows and inline bytes.
+    static constexpr std::size_t kEntryCapacity = 16'384;
+    static constexpr std::size_t kPlacementCapacity = 200'000;
+    static constexpr std::size_t kConfigCapacity = 500'000;
+    static constexpr std::size_t kComponentCapacity = 600'000;
+    static constexpr std::size_t kInlineNameCapacity = 1'048'576;
+    static constexpr std::size_t kInlineByteCapacity = 128ULL * 1024ULL * 1024ULL;
+
+    struct Evidence final {
+        std::vector<state::build_data::scriptables::InlineNameCandidate> rows{};
+        std::vector<std::byte> bytes{};
+    };
+
+    struct Value final {
+        std::vector<state::build_data::scriptables::ContainerPlacement> placements{};
+        std::vector<state::build_data::scriptables::ContainerPlacementConfig> configs{};
+        std::vector<state::build_data::scriptables::ContainerPlacementComponent> components{};
+        Evidence evidence{};
+    };
+
+    Impl() {
+        entries.reserve(kEntryCapacity);
+    }
+
+    std::unordered_map<std::uint32_t, std::shared_ptr<const Value>> entries{};
+    std::size_t placementRows{};
+    std::size_t configRows{};
+    std::size_t componentRows{};
+    std::size_t inlineNameRows{};
+    std::size_t inlineBytes{};
+};
+
+/** Gives this translation unit bounded access to the cache's private storage. */
+struct ContainerPlacementCacheAccess final {
+    using Evidence = ContainerPlacementCache::Impl::Evidence;
+    using Value = ContainerPlacementCache::Impl::Value;
+
+    [[nodiscard]] static std::shared_ptr<const Value> find(const ContainerPlacementCache& cache,
+                                                           std::uint32_t tag) noexcept {
+        if (cache.impl_ == nullptr) {
+            return {};
+        }
+        const auto found = cache.impl_->entries.find(tag);
+        return found == cache.impl_->entries.end() ? std::shared_ptr<const Value>{} : found->second;
+    }
+
+    /** Stores one tag's decoded placements, dropping the insert when a capacity is reached. */
+    static void remember(ContainerPlacementCache& cache, std::uint32_t tag, Value value) noexcept {
+        if (cache.impl_ == nullptr || cache.impl_->entries.contains(tag)) {
+            return;
+        }
+        ContainerPlacementCache::Impl& impl = *cache.impl_;
+        if (impl.entries.size() >= ContainerPlacementCache::Impl::kEntryCapacity
+            || impl.placementRows > ContainerPlacementCache::Impl::kPlacementCapacity
+            || value.placements.size()
+                   > ContainerPlacementCache::Impl::kPlacementCapacity - impl.placementRows
+            || impl.configRows > ContainerPlacementCache::Impl::kConfigCapacity
+            || value.configs.size()
+                   > ContainerPlacementCache::Impl::kConfigCapacity - impl.configRows
+            || impl.componentRows > ContainerPlacementCache::Impl::kComponentCapacity
+            || value.components.size()
+                   > ContainerPlacementCache::Impl::kComponentCapacity - impl.componentRows
+            || impl.inlineNameRows > ContainerPlacementCache::Impl::kInlineNameCapacity
+            || value.evidence.rows.size()
+                   > ContainerPlacementCache::Impl::kInlineNameCapacity - impl.inlineNameRows
+            || impl.inlineBytes > ContainerPlacementCache::Impl::kInlineByteCapacity
+            || value.evidence.bytes.size()
+                   > ContainerPlacementCache::Impl::kInlineByteCapacity - impl.inlineBytes) {
+            return;
+        }
+        try {
+            const std::size_t placements = value.placements.size();
+            const std::size_t configs = value.configs.size();
+            const std::size_t components = value.components.size();
+            const std::size_t names = value.evidence.rows.size();
+            const std::size_t bytes = value.evidence.bytes.size();
+            auto shared = std::make_shared<Value>(std::move(value));
+            const auto result = impl.entries.emplace(tag, std::move(shared));
+            if (result.second) {
+                impl.placementRows += placements;
+                impl.configRows += configs;
+                impl.componentRows += components;
+                impl.inlineNameRows += names;
+                impl.inlineBytes += bytes;
+            }
+        } catch (...) {
+            // A complete result stays valid when the optional pass cache cannot grow.
+        }
+    }
+};
+
+namespace container_placements {
+
+namespace catalog = state::build_data::scriptables;
+namespace package_reader = middleware::content::packages::reader;
+
+/** Hard limits bound one scenario's process-only package graph. */
+constexpr std::size_t kListCapacity = 8'192;
+constexpr std::size_t kOwnerCapacity = 32'768;
+constexpr std::size_t kPlacementCapacity = 262'144;
+constexpr std::size_t kConfigCapacity = 1'048'576;
+constexpr std::size_t kComponentCapacity = 1'048'576;
+
+/** Inputs and outputs of one container placement pass. */
+struct BuildContext final {
+    const package_reader::Source* source{};
+    package_reader::Scratch* scratch{};
+    catalog::Snapshot* output{};
+    ContainerPlacementCache* cache{};
+    ContainerPlacementCancelCheck cancel{};
+    std::string_view stem{};
+    std::unordered_map<std::uint32_t, std::uint32_t> listRows{};
+    std::vector<std::byte> containerBytes{};
+    std::vector<std::byte> listBytes{};
+    std::vector<std::byte> classBytes{};
+    std::vector<std::byte> configBytes{};
+    std::vector<std::byte> resourceBytes{};
+    bool failed{};
+};
+
+/** @return True when the caller asked this pass to stop. */
+[[nodiscard]] inline bool cancelled(const BuildContext& context) noexcept {
+    return context.cancel != nullptr && context.cancel();
+}
+
+/** Clears padding as well as members before a raw row can reach the serialized snapshot. */
+template <typename Value> void zero_row_storage(Value& output) noexcept {
+    static_assert(std::is_trivially_copyable_v<Value>);
+    std::memset(&output, 0, sizeof output);
+}
+
+enum class CacheReplay : std::uint8_t {
+    unavailable,
+    complete,
+    cancelled,
+};
+
+/** Captures one complete list graph with every row index local to that graph. */
+[[nodiscard]] bool capture_cached_graph(const catalog::Snapshot& output,
+                                        std::uint32_t listRow,
+                                        std::size_t firstPlacement,
+                                        std::size_t firstConfig,
+                                        std::size_t firstComponent,
+                                        std::size_t firstInlineName,
+                                        std::size_t firstInlineByte,
+                                        ContainerPlacementCacheAccess::Value& cached) noexcept;
+
+/** Replays one complete graph or leaves every destination bank unchanged. */
+[[nodiscard]] CacheReplay
+replay_cached_graph(BuildContext& context,
+                    std::uint32_t listRow,
+                    const ContainerPlacementCacheAccess::Value& cached) noexcept;
+
+} // namespace container_placements
+
+} // namespace sunrise::client::content::activity::scriptables::internal

+ 6 - 4
Sunrise/src/client/content/activity/scriptable_catalog_embedded_placements.cpp

@@ -279,10 +279,12 @@ bool append_embedded_placements(const package_reader::Source& source,
             ++output.embeddedPlacementDiagnostics.applicableDescriptors;
             requests.push_back({descriptor.configTag, static_cast<std::uint32_t>(row)});
         }
-        std::sort(requests.begin(), requests.end(), [](const Request& left, const Request& right) {
-            return left.configTag != right.configTag ? left.configTag < right.configTag
-                                                     : left.descriptorRow < right.descriptorRow;
-        });
+        std::sort(
+            requests.begin(), requests.end(), [](const Request& first, const Request& second) {
+                return first.configTag != second.configTag
+                           ? first.configTag < second.configTag
+                           : first.descriptorRow < second.descriptorRow;
+            });
         output.embeddedPlacementLinks.reserve((std::min)(requests.size(), kLinkCapacity));
         output.embeddedPlacements.reserve((std::min)(requests.size(), kPlacementCapacity));
         BuildContext context{&source, &scratch, &output, cancel};

+ 5 - 2
Sunrise/src/client/content/activity/scriptable_catalog_inline_names.cpp

@@ -3,7 +3,6 @@
 #include <algorithm>
 #include <cstring>
 #include <limits>
-#include <numeric>
 #include <utility>
 #include <vector>
 
@@ -15,6 +14,7 @@ namespace {
 namespace catalog = state::build_data::scriptables;
 namespace evidence = state::build_data::scriptables::inline_name_evidence;
 
+// Inline strings: the package class id and the header bytes before the text.
 constexpr std::uint32_t kInlineStringClass = 0x80800065U;
 constexpr std::size_t kInlineStringHeaderBytes = 12;
 
@@ -50,6 +50,7 @@ read_value(std::span<const std::byte> blob, std::size_t offset, Value& output) n
 /** Appends one encountered tuple for one linear sort and dedupe after all package reads. */
 [[nodiscard]] bool
 append_candidate(catalog::Snapshot& output, std::uint32_t hash, std::span<const std::byte> bytes) {
+    // Candidate rows and byte offsets are published as u32.
     constexpr std::size_t kMaximumOffset = (std::numeric_limits<std::uint32_t>::max)();
     if (output.inlineNameCandidates.size() >= kMaximumOffset
         || output.inlineNameBytes.size() > kMaximumOffset
@@ -124,7 +125,9 @@ bool collect_inline_name_evidence(catalog::Snapshot& output,
 bool canonicalize_inline_name_evidence(catalog::Snapshot& output) noexcept {
     try {
         std::vector<std::size_t> order(output.inlineNameCandidates.size());
-        std::iota(order.begin(), order.end(), 0);
+        for (std::size_t index = 0; index < order.size(); ++index) {
+            order[index] = index;
+        }
         for (const catalog::InlineNameCandidate& row : output.inlineNameCandidates) {
             std::span<const std::byte> bytes{};
             if (!candidate_bytes(output, row, bytes)) {

+ 22 - 20
Sunrise/src/client/content/activity/scriptable_catalog_names.cpp

@@ -297,16 +297,16 @@ bool resolve_names(catalog::Snapshot& output,
         definitions.resize(definitionCount);
         std::sort(definitions.begin(),
                   definitions.end(),
-                  [](const state::content::Definition& left,
-                     const state::content::Definition& right) noexcept {
-                      if (left.tag != right.tag) {
-                          return left.tag < right.tag;
+                  [](const state::content::Definition& first,
+                     const state::content::Definition& second) noexcept {
+                      if (first.tag != second.tag) {
+                          return first.tag < second.tag;
                       }
-                      if (left.classId != right.classId) {
-                          return left.classId < right.classId;
+                      if (first.classId != second.classId) {
+                          return first.classId < second.classId;
                       }
-                      return std::string_view(left.name.data(), left.nameLength)
-                             < std::string_view(right.name.data(), right.nameLength);
+                      return std::string_view(first.name.data(), first.nameLength)
+                             < std::string_view(second.name.data(), second.nameLength);
                   });
         if (cancelled(cancel)) {
             return false;
@@ -322,21 +322,23 @@ bool resolve_names(catalog::Snapshot& output,
         std::sort(
             hashDefinitions.begin(),
             hashDefinitions.end(),
-            [&definitions](const HashDefinition& left, const HashDefinition& right) noexcept {
-                if (left.hash != right.hash) {
-                    return left.hash < right.hash;
+            [&definitions](const HashDefinition& first, const HashDefinition& second) noexcept {
+                if (first.hash != second.hash) {
+                    return first.hash < second.hash;
                 }
-                const state::content::Definition& leftDefinition = definitions[left.definitionRow];
-                const state::content::Definition& rightDefinition =
-                    definitions[right.definitionRow];
-                if (leftDefinition.tag != rightDefinition.tag) {
-                    return leftDefinition.tag < rightDefinition.tag;
+                const state::content::Definition& firstDefinition =
+                    definitions[first.definitionRow];
+                const state::content::Definition& secondDefinition =
+                    definitions[second.definitionRow];
+                if (firstDefinition.tag != secondDefinition.tag) {
+                    return firstDefinition.tag < secondDefinition.tag;
                 }
-                if (leftDefinition.classId != rightDefinition.classId) {
-                    return leftDefinition.classId < rightDefinition.classId;
+                if (firstDefinition.classId != secondDefinition.classId) {
+                    return firstDefinition.classId < secondDefinition.classId;
                 }
-                return std::string_view(leftDefinition.name.data(), leftDefinition.nameLength)
-                       < std::string_view(rightDefinition.name.data(), rightDefinition.nameLength);
+                return std::string_view(firstDefinition.name.data(), firstDefinition.nameLength)
+                       < std::string_view(secondDefinition.name.data(),
+                                          secondDefinition.nameLength);
             });
         if (cancelled(cancel)) {
             return false;

+ 497 - 0
Sunrise/src/client/content/activity/scriptable_catalog_object_analysis.cpp

@@ -0,0 +1,497 @@
+#include <algorithm>
+#include <cstddef>
+#include <cstdint>
+#include <limits>
+#include <span>
+#include <vector>
+
+#include "../../../middleware/content/packages/tables/authored_placement_reader.h"
+#include "../../../middleware/content/packages/tables/container_placement_reader.h"
+#include "../../../middleware/content/packages/tables/type23_placement_identifier_reader.h"
+#include "../../../middleware/crypto/sha256.h"
+#include "../../../state/build_data/scenarios/definition.h"
+#include "scriptable_catalog_worker_internal.h"
+
+namespace sunrise::client::content::activity::scriptables::worker_internal {
+namespace {
+
+namespace sha256 = middleware::crypto::sha256;
+
+struct DescriptorContext final {
+    Analysis* analysis{};
+    const std::vector<AnalysisSlot>* slots{};
+    std::span<const std::byte> config{};
+};
+
+/** Per-chain state carried through one placed-object walk. */
+struct PlacedChainContext final {
+    BuildContext* build{};
+    Analysis* analysis{};
+    std::uint32_t registryKey{};
+    std::int32_t declaredBubbleIndex{};
+    std::uint32_t subblockRow{};
+    std::uint32_t leafRow{};
+    std::uint32_t subblockOrdinal{};
+    std::uint32_t leafOrdinal{};
+    bool leafComplete{true};
+    bool fatal{};
+};
+
+/** Retains descriptors that match the owning object's exact declared slot. */
+[[nodiscard]] bool collect_descriptor(void* opaque,
+                                      const tables::SlotDescriptor& descriptor) noexcept {
+    auto& context = *static_cast<DescriptorContext*>(opaque);
+    if (context.analysis == nullptr || context.slots == nullptr
+        || descriptor.slotIndex >= context.slots->size()
+        || (*context.slots)[descriptor.slotIndex].type != descriptor.slotType) {
+        return true;
+    }
+    try {
+        AnalysisDescriptor row{};
+        row.descriptor = descriptor;
+        row.placementIdentifierRead = tables::type23_placement_identifier(
+            context.config, descriptor, row.placementIdentifier);
+        context.analysis->descriptors.push_back(row);
+    } catch (...) {
+        return false;
+    }
+    return true;
+}
+
+/** Supplies one tag to the path-aware placed-chain observer. */
+[[nodiscard]] bool placed_chain_reader(void* opaque,
+                                       std::uint32_t tag,
+                                       std::span<const std::byte>& blob,
+                                       std::uint32_t& classId) noexcept {
+    if (opaque == nullptr) {
+        return false;
+    }
+    auto& context = *static_cast<PlacedChainContext*>(opaque);
+    if (context.build == nullptr || !read_tag(*context.build, tag, context.build->chain, classId)) {
+        blob = {};
+        return false;
+    }
+    blob = context.build->chain;
+    return true;
+}
+
+/** Retains one unique config and rolls back its rows when parsing fails. */
+[[nodiscard]] bool
+collect_placed_config(void* opaque, std::uint32_t tag, std::span<const std::byte> blob) noexcept {
+    if (opaque == nullptr) {
+        return false;
+    }
+    auto& context = *static_cast<PlacedChainContext*>(opaque);
+    if (context.analysis == nullptr) {
+        return false;
+    }
+    Analysis& analysis = *context.analysis;
+    try {
+        if (std::find(analysis.observedConfigs.begin(), analysis.observedConfigs.end(), tag)
+            == analysis.observedConfigs.end()) {
+            analysis.observedConfigs.push_back(tag);
+        }
+    } catch (...) {
+        return false;
+    }
+    if (std::find(analysis.resolvedConfigs.begin(), analysis.resolvedConfigs.end(), tag)
+        != analysis.resolvedConfigs.end()) {
+        return true;
+    }
+    const std::size_t firstDescriptor = analysis.descriptors.size();
+    const std::size_t firstReference = analysis.references.size();
+    DescriptorContext descriptorContext{&analysis, &analysis.slots, blob};
+    if (!tables::visit_slot_descriptors(
+            blob, tag, context.registryKey, &collect_descriptor, &descriptorContext)) {
+        analysis.descriptors.resize(firstDescriptor);
+        return false;
+    }
+    try {
+        internal::collect_typed_references(blob, tag, analysis.references);
+        analysis.resolvedConfigs.push_back(tag);
+        return true;
+    } catch (...) {
+        analysis.descriptors.resize(firstDescriptor);
+        analysis.references.resize(firstReference);
+        return false;
+    }
+}
+
+/** Converts one retained vector index to the SDK's exact u32 row domain. */
+[[nodiscard]] bool row_index(std::size_t value, std::uint32_t& output) noexcept {
+    if (value > (std::numeric_limits<std::uint32_t>::max)()) {
+        output = catalog::kNoRow;
+        return false;
+    }
+    output = static_cast<std::uint32_t>(value);
+    return true;
+}
+
+/** Maps the validated package-reader shape without assigning new semantics. */
+[[nodiscard]] constexpr catalog::PlacedHopShape
+placed_hop_shape(tables::PlacedChainShape value) noexcept {
+    switch (value) {
+    case tables::PlacedChainShape::config:
+        return catalog::PlacedHopShape::config;
+    case tables::PlacedChainShape::redirect:
+        return catalog::PlacedHopShape::redirect;
+    case tables::PlacedChainShape::descriptorRedirectArray:
+        return catalog::PlacedHopShape::descriptorRedirectArray;
+    case tables::PlacedChainShape::bareObjectList:
+        return catalog::PlacedHopShape::bareObjectList;
+    }
+    return catalog::PlacedHopShape::config;
+}
+
+/** Retains one exact path-specific hop and any terminal config or object-list edge. */
+[[nodiscard]] bool collect_placed_chain_record(void* opaque,
+                                               const tables::PlacedChainRecord& source,
+                                               std::span<const std::byte> blob) noexcept {
+    if (opaque == nullptr) {
+        return false;
+    }
+    auto& context = *static_cast<PlacedChainContext*>(opaque);
+    if (context.build == nullptr || context.analysis == nullptr
+        || context.subblockRow >= context.analysis->placedSubblocks.size()
+        || context.leafRow >= context.analysis->placedLeaves.size()
+        || source.branchPathCount > catalog::kPlacedBranchPathCapacity) {
+        context.fatal = true;
+        return false;
+    }
+    Analysis& analysis = *context.analysis;
+    std::uint32_t hopRow = 0;
+    if (!row_index(analysis.placedHops.size(), hopRow)) {
+        context.fatal = true;
+        return false;
+    }
+    catalog::PlacedHop hop{};
+    hop.subblockRow = context.subblockRow;
+    hop.leafRow = context.leafRow;
+    hop.subblockOrdinal = context.subblockOrdinal;
+    hop.leafOrdinal = context.leafOrdinal;
+    hop.declaredBubbleIndex = context.declaredBubbleIndex;
+    hop.tag = source.tag;
+    hop.classId = source.classId;
+    hop.branchPath = source.branchPath;
+    hop.childCount = source.childCount;
+    hop.directTargetTag = source.directTargetTag;
+    hop.branchPathCount = source.branchPathCount;
+    hop.depth = source.depth;
+    hop.shape = placed_hop_shape(source.shape);
+    hop.complete = true;
+    if (!sha256::hash(blob, hop.payloadSha256)) {
+        context.fatal = true;
+        return false;
+    }
+    try {
+        analysis.placedHops.push_back(hop);
+    } catch (...) {
+        context.fatal = true;
+        return false;
+    }
+
+    if (source.shape == tables::PlacedChainShape::config) {
+        std::uint32_t occurrenceRow = 0;
+        if (!row_index(analysis.placedConfigOccurrences.size(), occurrenceRow)) {
+            analysis.placedHops.pop_back();
+            context.fatal = true;
+            return false;
+        }
+        catalog::PlacedConfigOccurrence occurrence{};
+        occurrence.subblockRow = context.subblockRow;
+        occurrence.leafRow = context.leafRow;
+        occurrence.terminalHopRow = hopRow;
+        occurrence.configTag = source.tag;
+        occurrence.declaredBubbleIndex = context.declaredBubbleIndex;
+        occurrence.branchPath = source.branchPath;
+        occurrence.branchPathCount = source.branchPathCount;
+        occurrence.complete = true;
+        try {
+            analysis.placedConfigOccurrences.push_back(occurrence);
+        } catch (...) {
+            analysis.placedHops.pop_back();
+            context.fatal = true;
+            return false;
+        }
+        analysis.placedHops[hopRow].configOccurrenceRow = occurrenceRow;
+        if (!collect_placed_config(&context, source.tag, blob)) {
+            context.leafComplete = false;
+            return false;
+        }
+        return true;
+    }
+
+    if (source.shape != tables::PlacedChainShape::bareObjectList) {
+        return true;
+    }
+
+    std::uint32_t targetRow = 0;
+    if (!row_index(analysis.placedBareTargets.size(), targetRow)) {
+        analysis.placedHops.pop_back();
+        context.fatal = true;
+        return false;
+    }
+    catalog::PlacedBareTarget target{};
+    target.subblockRow = context.subblockRow;
+    target.leafRow = context.leafRow;
+    target.sourceHopRow = hopRow;
+    target.declaredBubbleIndex = context.declaredBubbleIndex;
+    target.targetTag = source.directTargetTag;
+    target.expectedTargetClass = tables::kAuthoredPlacementListClass;
+    std::uint32_t targetClass = 0;
+    if (!read_tag(*context.build, source.directTargetTag, context.build->chain, targetClass)) {
+        target.status = catalog::PlacedBareTargetStatus::unreadableTarget;
+        context.leafComplete = false;
+        analysis.readComplete = false;
+        if (context.build->failed || cancelled(context.build->cancel)) {
+            analysis.placedHops.pop_back();
+            context.fatal = true;
+            return false;
+        }
+    } else {
+        target.targetClass = targetClass;
+        target.targetLogicalSize = context.build->chain.size();
+        if (!sha256::hash(context.build->chain, target.targetPayloadSha256)) {
+            analysis.placedHops.pop_back();
+            context.fatal = true;
+            return false;
+        }
+        if (targetClass != tables::kAuthoredPlacementListClass) {
+            target.status = catalog::PlacedBareTargetStatus::targetClassMismatch;
+            context.leafComplete = false;
+            analysis.readComplete = false;
+        } else {
+            target.status = catalog::PlacedBareTargetStatus::completeStructuralEdge;
+            if (!internal::collect_authored_placements(analysis.authored,
+                                                       context.build->chain,
+                                                       source.directTargetTag,
+                                                       context.declaredBubbleIndex)) {
+                context.leafComplete = false;
+                analysis.readComplete = false;
+            }
+        }
+    }
+    try {
+        analysis.placedBareTargets.push_back(target);
+    } catch (...) {
+        analysis.placedHops.pop_back();
+        context.fatal = true;
+        return false;
+    }
+    analysis.placedHops[hopRow].bareTargetRow = targetRow;
+    return true;
+}
+
+/** Follows every authored branch and retains every exact path-specific row. */
+[[nodiscard]] bool follow_handle(BuildContext& context,
+                                 Analysis& analysis,
+                                 std::uint32_t handle,
+                                 std::uint32_t registryKey,
+                                 std::int32_t declaredBubbleIndex,
+                                 std::uint32_t subblockRow,
+                                 std::uint32_t leafRow,
+                                 std::uint32_t subblockOrdinal,
+                                 std::uint32_t leafOrdinal) noexcept {
+    if (leafRow >= analysis.placedLeaves.size()) {
+        return false;
+    }
+    PlacedChainContext walkContext{&context,
+                                   &analysis,
+                                   registryKey,
+                                   declaredBubbleIndex,
+                                   subblockRow,
+                                   leafRow,
+                                   subblockOrdinal,
+                                   leafOrdinal};
+    tables::PlacedChainObservation observation{};
+    const bool walked = tables::visit_placed_chain_records(handle,
+                                                           &placed_chain_reader,
+                                                           &walkContext,
+                                                           &collect_placed_chain_record,
+                                                           &walkContext,
+                                                           observation);
+    catalog::PlacedLeaf& leaf = analysis.placedLeaves[leafRow];
+    const std::size_t hopCount = analysis.placedHops.size() - leaf.firstHop;
+    const std::size_t configCount =
+        analysis.placedConfigOccurrences.size() - leaf.firstConfigOccurrence;
+    const std::size_t bareCount = analysis.placedBareTargets.size() - leaf.firstBareTarget;
+    if (!row_index(hopCount, leaf.hopCount) || !row_index(configCount, leaf.configOccurrenceCount)
+        || !row_index(bareCount, leaf.bareTargetCount) || walkContext.fatal) {
+        return false;
+    }
+    leaf.complete = walked && walkContext.leafComplete && observation.hopCount == leaf.hopCount
+                    && observation.bareTargetCount == leaf.bareTargetCount;
+    if (!leaf.complete) {
+        analysis.readComplete = false;
+    }
+    return true;
+}
+
+/** @return True when the placed class definition marks its objects as network replicated. */
+[[nodiscard]] bool class_replicates(BuildContext& context, std::uint32_t classTag) noexcept {
+    const auto cached = context.classReplication.find(classTag);
+    if (cached != context.classReplication.end()) {
+        return cached->second;
+    }
+    std::uint32_t classId = 0;
+    tables::PlacedClassDefinition definition{};
+    const bool replicated = read_tag(context, classTag, context.classBytes, classId)
+                            && classId == tables::kPlacedClassDefinitionClass
+                            && tables::placed_class_definition(context.classBytes, definition)
+                            && definition.networkReplicated;
+    try {
+        context.classReplication.emplace(classTag, replicated);
+    } catch (...) {
+        // A missed cache entry costs one more read, nothing else.
+    }
+    return replicated;
+}
+
+/** Counts the authored placements the game replicates: entry flag bit 0 clear, class bit set. */
+[[nodiscard]] std::uint32_t count_replicated_placements(BuildContext& context,
+                                                        const Analysis& analysis) noexcept {
+    std::uint32_t count = 0;
+    for (const internal::RawAuthoredPlacement& placement : analysis.authored.placements) {
+        if ((placement.placementFlagsRaw & tables::kAuthoredPlacementNoReplicationBit) == 0
+            && class_replicates(context, placement.classListTag)) {
+            ++count;
+        }
+    }
+    return count;
+}
+
+} // namespace
+
+/** Reads one object layout and its reachable descriptor/config records. */
+bool analyze_object(BuildContext& context,
+                    const tables::Placement& placement,
+                    Analysis& output) noexcept {
+    output = {};
+    tables::Array slots{};
+    if (!tables::object_slots(placement.objectBytes, slots) || slots.count > kSlotCapacity) {
+        return false;
+    }
+    try {
+        output.slots.reserve(static_cast<std::size_t>(slots.count));
+        for (std::uint64_t index = 0; index < slots.count; ++index) {
+            tables::Slot slot{};
+            if (!tables::object_slot_at(placement.objectBytes, slots, index, slot) || slot.type == 0
+                || slot.type > state::build_data::scenarios::kMaximumSlotType) {
+                return false;
+            }
+            output.slots.push_back({slot.nameHash, static_cast<std::uint16_t>(slot.type)});
+        }
+    } catch (...) {
+        return false;
+    }
+
+    tables::Array bubbles{};
+    if (!tables::object_bubbles(placement.objectBytes, bubbles)
+        || bubbles.count > (std::numeric_limits<std::uint32_t>::max)()) {
+        return false;
+    }
+    try {
+        output.placedSubblocks.reserve(static_cast<std::size_t>(bubbles.count));
+    } catch (...) {
+        return false;
+    }
+    for (std::uint64_t bubbleIndex = 0; bubbleIndex < bubbles.count; ++bubbleIndex) {
+        tables::ObjectBubble bubble{};
+        std::uint32_t subblockRow = 0;
+        std::uint32_t firstLeaf = 0;
+        if (!tables::object_bubble_at(placement.objectBytes, bubbles, bubbleIndex, bubble)
+            || bubbleIndex > (std::numeric_limits<std::uint32_t>::max)()
+            || !row_index(output.placedSubblocks.size(), subblockRow)
+            || !row_index(output.placedLeaves.size(), firstLeaf)) {
+            return false;
+        }
+        catalog::PlacedSubblock subblock{};
+        subblock.subblockOrdinal = static_cast<std::uint32_t>(bubbleIndex);
+        subblock.declaredBubbleIndex = bubble.bubbleIndex;
+        subblock.firstLeaf = firstLeaf;
+        subblock.sourceOffset = bubble.sourceOffset;
+        try {
+            output.placedSubblocks.push_back(subblock);
+            if (bubble.handleCount
+                > (std::numeric_limits<std::uint32_t>::max)() - output.placedLeaves.size()) {
+                return false;
+            }
+            output.placedLeaves.reserve(output.placedLeaves.size()
+                                        + static_cast<std::size_t>(bubble.handleCount));
+        } catch (...) {
+            return false;
+        }
+        bool subblockComplete = true;
+        for (std::uint64_t leafOrdinal = 0; leafOrdinal < bubble.handleCount; ++leafOrdinal) {
+            std::uint32_t handle = 0;
+            std::uint32_t leafRow = 0;
+            catalog::PlacedLeaf leaf{};
+            if (!tables::object_placed_handle_at(placement.objectBytes, bubble, leafOrdinal, handle)
+                || leafOrdinal > (std::numeric_limits<std::uint32_t>::max)()
+                || !row_index(output.placedLeaves.size(), leafRow)
+                || !row_index(output.placedHops.size(), leaf.firstHop)
+                || !row_index(output.placedConfigOccurrences.size(), leaf.firstConfigOccurrence)
+                || !row_index(output.placedBareTargets.size(), leaf.firstBareTarget)) {
+                return false;
+            }
+            leaf.subblockRow = subblockRow;
+            leaf.subblockOrdinal = static_cast<std::uint32_t>(bubbleIndex);
+            leaf.leafOrdinal = static_cast<std::uint32_t>(leafOrdinal);
+            leaf.declaredBubbleIndex = bubble.bubbleIndex;
+            leaf.rootTag = handle;
+            leaf.sourceOffset = static_cast<std::uint64_t>(bubble.handleDataOffset)
+                                + leafOrdinal * tables::kObjectPlacedHandleStride;
+            try {
+                output.placedLeaves.push_back(leaf);
+            } catch (...) {
+                return false;
+            }
+            if (!follow_handle(context,
+                               output,
+                               handle,
+                               placement.objectKey,
+                               bubble.bubbleIndex,
+                               subblockRow,
+                               leafRow,
+                               static_cast<std::uint32_t>(bubbleIndex),
+                               static_cast<std::uint32_t>(leafOrdinal))) {
+                return false;
+            }
+            subblockComplete = subblockComplete && output.placedLeaves[leafRow].complete;
+        }
+        const std::size_t leafCount = output.placedLeaves.size() - firstLeaf;
+        if (!row_index(leafCount, output.placedSubblocks[subblockRow].leafCount)) {
+            return false;
+        }
+        output.placedSubblocks[subblockRow].complete = subblockComplete;
+    }
+    if (!row_index(output.observedConfigs.size(), output.configCount)) {
+        return false;
+    }
+    output.replicatedPlacementCount = count_replicated_placements(context, output);
+    std::sort(output.descriptors.begin(),
+              output.descriptors.end(),
+              [](const AnalysisDescriptor& firstRow, const AnalysisDescriptor& secondRow) noexcept {
+                  const tables::SlotDescriptor& first = firstRow.descriptor;
+                  const tables::SlotDescriptor& second = secondRow.descriptor;
+                  if (first.slotIndex != second.slotIndex) {
+                      return first.slotIndex < second.slotIndex;
+                  }
+                  if (first.componentClass != second.componentClass) {
+                      return first.componentClass < second.componentClass;
+                  }
+                  if (first.senseSchema != second.senseSchema) {
+                      return first.senseSchema < second.senseSchema;
+                  }
+                  if (first.authSchema != second.authSchema) {
+                      return first.authSchema < second.authSchema;
+                  }
+                  if (first.configTag != second.configTag) {
+                      return first.configTag < second.configTag;
+                  }
+                  return first.descriptorOffset < second.descriptorOffset;
+              });
+    return true;
+}
+
+} // namespace sunrise::client::content::activity::scriptables::worker_internal

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

@@ -7,6 +7,7 @@
 namespace sunrise::client::content::activity::scriptables::internal {
 namespace {
 
+// Client references: the package class id and the fixed row stride.
 constexpr std::uint32_t kClientReferenceClass = 0x80809C42U;
 constexpr std::size_t kClientReferenceSize = 16;
 

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

@@ -27,6 +27,7 @@ namespace sunrise::client::content::activity::scriptables::internal {
 
 namespace catalog = state::build_data::scriptables;
 
+// Fixed capacities bound one cached table's rows and inline bytes.
 constexpr std::size_t kCachedTableCapacity = 2'048;
 constexpr std::size_t kCachedInstanceCapacity = 2'000'000;
 constexpr std::size_t kCachedInlineNameCapacity = 1'048'576;
@@ -357,6 +358,7 @@ void materialize_table(BuildContext& context, std::uint32_t row) {
 /** Adds cached table and bounds inline strings at the skipped read boundary. */
 [[nodiscard]] bool append_cached_evidence(const CachedStaticSpatialTable& cached,
                                           catalog::Snapshot& output) noexcept {
+    // Snapshot rows are published as u32, so a bank stops at that maximum.
     constexpr std::size_t maximum = (std::numeric_limits<std::uint32_t>::max)();
     const std::size_t firstRow = output.inlineNameCandidates.size();
     const std::size_t firstByte = output.inlineNameBytes.size();

+ 4 - 2
Sunrise/src/client/content/activity/scriptable_catalog_trigger_volume_links.cpp

@@ -11,8 +11,10 @@ namespace {
 
 namespace catalog = state::build_data::scriptables;
 
+// Package slot types of an incoming trigger reference and of its target.
 constexpr std::uint16_t kIncomingTriggerSlotType = 31;
 constexpr std::uint8_t kTargetTriggerSlotType = 60;
+// Fixed capacity for one pass's incoming trigger references.
 constexpr std::size_t kIncomingReferenceCapacity = 262'144;
 
 struct Candidate final {
@@ -75,8 +77,8 @@ bool append_trigger_volume_incoming_references(catalog::Snapshot& output,
         }
         std::sort(candidates.begin(),
                   candidates.end(),
-                  [](const Candidate& left, const Candidate& right) noexcept {
-                      return identity(left) < identity(right);
+                  [](const Candidate& first, const Candidate& second) noexcept {
+                      return identity(first) < identity(second);
                   });
         output.triggerVolumeIncomingReferences.reserve(
             (std::min)(candidates.size(), kIncomingReferenceCapacity));

+ 13 - 11
Sunrise/src/client/content/activity/scriptable_catalog_trigger_volumes.cpp

@@ -22,12 +22,14 @@ namespace catalog = state::build_data::scriptables;
 namespace package_reader = middleware::content::packages::reader;
 namespace tables = middleware::content::packages::tables;
 
+// Fixed capacities bound one scenario's trigger tables, prisms and components.
 constexpr std::size_t kTableCapacity = 65'536;
 constexpr std::size_t kOwnerCapacity = 262'144;
 constexpr std::size_t kInstanceCapacity = 262'144;
 constexpr std::size_t kVertexCapacity = 1'048'576;
 constexpr std::size_t kTriangleCapacity = 1'048'576;
 constexpr std::size_t kComponentCapacity = 4'096;
+// Authored bounds must agree within this absolute tolerance.
 constexpr float kBoundsTolerance = 0.0001F;
 
 struct BuildContext final {
@@ -278,9 +280,9 @@ void append_root(BuildContext& context,
         }
         rows.emplace_back(identity, static_cast<std::uint32_t>(rowIndex));
     }
-    std::sort(rows.begin(), rows.end(), [](const auto& left, const auto& right) noexcept {
-        return std::tie(left.first.key, left.first.type, left.first.index, left.second)
-               < std::tie(right.first.key, right.first.type, right.first.index, right.second);
+    std::sort(rows.begin(), rows.end(), [](const auto& first, const auto& second) noexcept {
+        return std::tie(first.first.key, first.first.type, first.first.index, first.second)
+               < std::tie(second.first.key, second.first.type, second.first.index, second.second);
     });
     for (std::size_t first = 0; first < rows.size();) {
         std::size_t last = first + 1;
@@ -398,18 +400,18 @@ bool append_trigger_volumes(const package_reader::Source& source,
         std::vector<TriggerVolumeInput> ordered(inputs.begin(), inputs.end());
         std::sort(ordered.begin(),
                   ordered.end(),
-                  [](const TriggerVolumeInput& left, const TriggerVolumeInput& right) noexcept {
-                      if (left.configTag != right.configTag) {
-                          return left.configTag < right.configTag;
+                  [](const TriggerVolumeInput& first, const TriggerVolumeInput& second) noexcept {
+                      if (first.configTag != second.configTag) {
+                          return first.configTag < second.configTag;
                       }
-                      return left.objectRow < right.objectRow;
+                      return first.objectRow < second.objectRow;
                   });
         ordered.erase(std::unique(ordered.begin(),
                                   ordered.end(),
-                                  [](const TriggerVolumeInput& left,
-                                     const TriggerVolumeInput& right) noexcept {
-                                      return left.configTag == right.configTag
-                                             && left.objectRow == right.objectRow;
+                                  [](const TriggerVolumeInput& first,
+                                     const TriggerVolumeInput& second) noexcept {
+                                      return first.configTag == second.configTag
+                                             && first.objectRow == second.objectRow;
                                   }),
                       ordered.end());
         output.triggerVolumeTables.reserve(256);

+ 6 - 5
Sunrise/src/client/content/activity/scriptable_catalog_type23_placement_links.cpp

@@ -17,6 +17,7 @@ namespace {
 namespace catalog = state::build_data::scriptables;
 namespace tables = middleware::content::packages::tables;
 
+// Fixed capacities for one scenario's type-23 links and their candidates.
 constexpr std::size_t kLinkCapacity = 262'144;
 constexpr std::size_t kCandidateCapacity = 1'048'576;
 
@@ -168,7 +169,7 @@ descriptor_object(const catalog::Snapshot& source, const catalog::Descriptor& de
     }
     std::uint64_t active = 0;
     for (std::size_t offset = 0; offset < retained; ++offset) {
-        const PlacementIndex& indexed = first[offset];
+        const PlacementIndex& indexed = first[static_cast<std::ptrdiff_t>(offset)];
         catalog::Type23PlacementCandidate candidate{};
         candidate.linkRow = linkRow;
         candidate.placementRow = indexed.placementRow;
@@ -254,10 +255,10 @@ bool append_type23_placement_links(catalog::Snapshot& output,
         }
         std::sort(index.begin(),
                   index.end(),
-                  [](const PlacementIndex& left, const PlacementIndex& right) noexcept {
-                      return left.identifier != right.identifier
-                                 ? left.identifier < right.identifier
-                                 : left.placementRow < right.placementRow;
+                  [](const PlacementIndex& first, const PlacementIndex& second) noexcept {
+                      return first.identifier != second.identifier
+                                 ? first.identifier < second.identifier
+                                 : first.placementRow < second.placementRow;
                   });
         output.type23PlacementLinks.reserve((std::min)(output.descriptors.size(), kLinkCapacity));
         output.type23PlacementCandidates.reserve(

+ 29 - 564
Sunrise/src/client/content/activity/scriptable_catalog_worker.cpp

@@ -21,7 +21,6 @@
 #include "../../../middleware/content/packages/tables/scenario_reader.h"
 #include "../../../middleware/content/packages/tables/slot_descriptor_reader.h"
 #include "../../../middleware/content/packages/tables/type23_placement_identifier_reader.h"
-#include "../../../middleware/crypto/sha256.h"
 #include "../../../state/build_data/scenarios/definition.h"
 #include "../../../state/build_data/scriptables/coverage.h"
 #include "../../../state/build_data/scriptables/scriptable_catalog.h"
@@ -36,101 +35,30 @@
 #include "scriptable_catalog_spatial_graph.h"
 #include "scriptable_catalog_trigger_volumes.h"
 #include "scriptable_catalog_type23_placement_links.h"
+#include "scriptable_catalog_worker_internal.h"
 #include "source.h"
 
 namespace sunrise::client::content::activity::scriptables {
-namespace {
 
-namespace catalog = state::build_data::scriptables;
-namespace package_reader = middleware::content::packages::reader;
-namespace tables = middleware::content::packages::tables;
-namespace sha256 = middleware::crypto::sha256;
+using namespace worker_internal;
+
+namespace worker_internal {
+
+/** @return True when the caller asked this build to stop. */
+bool cancelled(internal::BuilderCancelCheck check) noexcept {
+    return check != nullptr && check();
+}
+
+} // namespace worker_internal
+
+namespace {
 
 /** Fixed package ids and bounds keep extraction inside validated layouts and owned storage. */
 constexpr std::size_t kObjectCapacity = 65'536;
-constexpr std::size_t kSlotCapacity = 262'144;
 constexpr std::size_t kDescriptorCapacity = 262'144;
 constexpr std::size_t kReferenceCapacity = 262'144;
 constexpr std::size_t kTriggerVolumeInputCapacity = 262'144;
 
-struct AnalysisSlot final {
-    std::uint32_t nameHash{};
-    std::uint16_t type{};
-};
-
-/** One validated descriptor plus its class-specific optional placement identifier. */
-struct AnalysisDescriptor final {
-    tables::SlotDescriptor descriptor{};
-    std::uint64_t placementIdentifier{};
-    bool placementIdentifierRead{};
-};
-
-/** One exact inline string found in a source tag used by an object analysis. */
-struct AnalysisInlineName final {
-    std::uint32_t hash{};
-    std::string value{};
-};
-
-/** Inline strings from one first-seen source tag, kept in package encounter order. */
-struct AnalysisTagEvidence final {
-    std::uint32_t tag{};
-    std::vector<AnalysisInlineName> names{};
-};
-
-/** Everything one scenario analysis produced, keyed and shared between build passes. */
-struct Analysis final {
-    std::vector<AnalysisSlot> slots{};
-    std::vector<AnalysisDescriptor> descriptors{};
-    std::vector<internal::RawReference> references{};
-    internal::AuthoredPlacementAnalysis authored{};
-    std::vector<std::uint32_t> observedConfigs{};
-    std::vector<std::uint32_t> resolvedConfigs{};
-    std::vector<catalog::PlacedSubblock> placedSubblocks{};
-    std::vector<catalog::PlacedLeaf> placedLeaves{};
-    std::vector<catalog::PlacedHop> placedHops{};
-    std::vector<catalog::PlacedConfigOccurrence> placedConfigOccurrences{};
-    std::vector<catalog::PlacedBareTarget> placedBareTargets{};
-    std::vector<AnalysisTagEvidence> tagEvidence{};
-    std::uint32_t configCount{};
-    /** Authored placements whose flags and class definition let the game replicate them. */
-    std::uint32_t replicatedPlacementCount{};
-    bool readComplete{true};
-};
-
-using AnalysisMap = std::unordered_map<std::uint64_t, std::shared_ptr<const Analysis>>;
-
-/** Inputs and owned scratch of one scenario build pass. */
-struct BuildContext final {
-    const package_reader::Source* source{};
-    internal::BuilderCancelCheck cancel{};
-    ScenarioSource scenarioSource{};
-    std::vector<std::byte> scenario{};
-    std::vector<std::byte> chain{};
-    std::vector<std::byte> classBytes{};
-    /** Replication bit per placed class definition, read once per tag. */
-    std::unordered_map<std::uint32_t, bool> classReplication{};
-    std::vector<internal::InlineName> inlineNames{};
-    std::vector<internal::TriggerVolumeInput> triggerVolumeInputs{};
-    AnalysisMap analyses{};
-    AnalysisMap* sharedAnalyses{};
-    Analysis* recordingAnalysis{};
-    std::unordered_set<std::uint32_t> recordedAnalysisTags{};
-    bool recordingCacheable{};
-    /** Tags whose inline strings were already banked, so no blob is scanned twice. */
-    std::unordered_set<std::uint32_t> scannedTags{};
-    /** Total tag reads this scenario asked for, including every revisit. */
-    std::size_t tagReads{};
-    std::size_t analysisHits{};
-    std::size_t analysisMisses{};
-    std::shared_ptr<catalog::Snapshot> output{};
-    tables::WalkResult walk{};
-    bool failed{};
-};
-
-[[nodiscard]] bool cancelled(internal::BuilderCancelCheck check) noexcept {
-    return check != nullptr && check();
-}
-
 /** @return True when this tag's inline strings have not been banked yet. */
 [[nodiscard]] bool inline_names_pending(BuildContext& context, std::uint32_t tag) noexcept {
     try {
@@ -199,6 +127,7 @@ capture_inline_name(void* opaque, std::uint32_t hash, std::span<const std::byte>
         return false;
     }
     catalog::Snapshot& output = *context.output;
+    // Snapshot rows are published as u32, so a bank stops at that maximum.
     constexpr std::size_t maximum = (std::numeric_limits<std::uint32_t>::max)();
     for (const AnalysisInlineName& candidate : evidence.names) {
         if (output.inlineNameCandidates.size() >= maximum || output.inlineNameBytes.size() > maximum
@@ -275,11 +204,15 @@ capture_inline_name(void* opaque, std::uint32_t hash, std::span<const std::byte>
     return true;
 }
 
+} // namespace
+
+namespace worker_internal {
+
 /** Reads one tag and offers its inline names to the current build. */
-[[nodiscard]] bool read_tag(BuildContext& context,
-                            std::uint32_t tag,
-                            std::vector<std::byte>& bytes,
-                            std::uint32_t& classId) noexcept {
+bool read_tag(BuildContext& context,
+              std::uint32_t tag,
+              std::vector<std::byte>& bytes,
+              std::uint32_t& classId) noexcept {
     ++context.tagReads;
     if (cancelled(context.cancel) || context.source == nullptr
         || !package_reader::read_tag(
@@ -297,6 +230,10 @@ capture_inline_name(void* opaque, std::uint32_t hash, std::span<const std::byte>
     return true;
 }
 
+} // namespace worker_internal
+
+namespace {
+
 [[nodiscard]] bool read_exact(BuildContext& context,
                               std::uint32_t tag,
                               std::uint32_t expectedClass,
@@ -315,6 +252,7 @@ capture_inline_name(void* opaque, std::uint32_t hash, std::span<const std::byte>
     if (index >= context.scenarioSource.slots.size()) {
         return false;
     }
+    // Expected package class of each read slot, in ReadSlot order.
     constexpr std::array<std::uint32_t, static_cast<std::size_t>(tables::ReadSlot::count)> expected{
         tables::kSliceEntryClass, tables::kObjectRegistryClass, tables::kObjectClass};
     std::vector<std::byte>& bytes = context.scenarioSource.slots[index];
@@ -325,481 +263,6 @@ capture_inline_name(void* opaque, std::uint32_t hash, std::span<const std::byte>
     return true;
 }
 
-struct DescriptorContext final {
-    Analysis* analysis{};
-    const std::vector<AnalysisSlot>* slots{};
-    std::span<const std::byte> config{};
-};
-
-/** Per-chain state carried through one placed-object walk. */
-struct PlacedChainContext final {
-    BuildContext* build{};
-    Analysis* analysis{};
-    std::uint32_t registryKey{};
-    std::int32_t declaredBubbleIndex{};
-    std::uint32_t subblockRow{};
-    std::uint32_t leafRow{};
-    std::uint32_t subblockOrdinal{};
-    std::uint32_t leafOrdinal{};
-    bool leafComplete{true};
-    bool fatal{};
-};
-
-/** Retains descriptors that match the owning object's exact declared slot. */
-[[nodiscard]] bool collect_descriptor(void* opaque,
-                                      const tables::SlotDescriptor& descriptor) noexcept {
-    auto& context = *static_cast<DescriptorContext*>(opaque);
-    if (context.analysis == nullptr || context.slots == nullptr
-        || descriptor.slotIndex >= context.slots->size()
-        || (*context.slots)[descriptor.slotIndex].type != descriptor.slotType) {
-        return true;
-    }
-    try {
-        AnalysisDescriptor row{};
-        row.descriptor = descriptor;
-        row.placementIdentifierRead = tables::type23_placement_identifier(
-            context.config, descriptor, row.placementIdentifier);
-        context.analysis->descriptors.push_back(row);
-    } catch (...) {
-        return false;
-    }
-    return true;
-}
-
-/** Supplies one tag to the path-aware placed-chain observer. */
-[[nodiscard]] bool placed_chain_reader(void* opaque,
-                                       std::uint32_t tag,
-                                       std::span<const std::byte>& blob,
-                                       std::uint32_t& classId) noexcept {
-    if (opaque == nullptr) {
-        return false;
-    }
-    auto& context = *static_cast<PlacedChainContext*>(opaque);
-    if (context.build == nullptr || !read_tag(*context.build, tag, context.build->chain, classId)) {
-        blob = {};
-        return false;
-    }
-    blob = context.build->chain;
-    return true;
-}
-
-/** Retains one unique config and rolls back its rows when parsing fails. */
-[[nodiscard]] bool
-collect_placed_config(void* opaque, std::uint32_t tag, std::span<const std::byte> blob) noexcept {
-    if (opaque == nullptr) {
-        return false;
-    }
-    auto& context = *static_cast<PlacedChainContext*>(opaque);
-    if (context.analysis == nullptr) {
-        return false;
-    }
-    Analysis& analysis = *context.analysis;
-    try {
-        if (std::find(analysis.observedConfigs.begin(), analysis.observedConfigs.end(), tag)
-            == analysis.observedConfigs.end()) {
-            analysis.observedConfigs.push_back(tag);
-        }
-    } catch (...) {
-        return false;
-    }
-    if (std::find(analysis.resolvedConfigs.begin(), analysis.resolvedConfigs.end(), tag)
-        != analysis.resolvedConfigs.end()) {
-        return true;
-    }
-    const std::size_t firstDescriptor = analysis.descriptors.size();
-    const std::size_t firstReference = analysis.references.size();
-    DescriptorContext descriptorContext{&analysis, &analysis.slots, blob};
-    if (!tables::visit_slot_descriptors(
-            blob, tag, context.registryKey, &collect_descriptor, &descriptorContext)) {
-        analysis.descriptors.resize(firstDescriptor);
-        return false;
-    }
-    try {
-        internal::collect_typed_references(blob, tag, analysis.references);
-        analysis.resolvedConfigs.push_back(tag);
-        return true;
-    } catch (...) {
-        analysis.descriptors.resize(firstDescriptor);
-        analysis.references.resize(firstReference);
-        return false;
-    }
-}
-
-/** Converts one retained vector index to the SDK's exact u32 row domain. */
-[[nodiscard]] bool row_index(std::size_t value, std::uint32_t& output) noexcept {
-    if (value > (std::numeric_limits<std::uint32_t>::max)()) {
-        output = catalog::kNoRow;
-        return false;
-    }
-    output = static_cast<std::uint32_t>(value);
-    return true;
-}
-
-/** Maps the validated package-reader shape without assigning new semantics. */
-[[nodiscard]] constexpr catalog::PlacedHopShape
-placed_hop_shape(tables::PlacedChainShape value) noexcept {
-    switch (value) {
-    case tables::PlacedChainShape::config:
-        return catalog::PlacedHopShape::config;
-    case tables::PlacedChainShape::redirect:
-        return catalog::PlacedHopShape::redirect;
-    case tables::PlacedChainShape::descriptorRedirectArray:
-        return catalog::PlacedHopShape::descriptorRedirectArray;
-    case tables::PlacedChainShape::bareObjectList:
-        return catalog::PlacedHopShape::bareObjectList;
-    }
-    return catalog::PlacedHopShape::config;
-}
-
-/** Retains one exact path-specific hop and any terminal config or object-list edge. */
-[[nodiscard]] bool collect_placed_chain_record(void* opaque,
-                                               const tables::PlacedChainRecord& source,
-                                               std::span<const std::byte> blob) noexcept {
-    if (opaque == nullptr) {
-        return false;
-    }
-    auto& context = *static_cast<PlacedChainContext*>(opaque);
-    if (context.build == nullptr || context.analysis == nullptr
-        || context.subblockRow >= context.analysis->placedSubblocks.size()
-        || context.leafRow >= context.analysis->placedLeaves.size()
-        || source.branchPathCount > catalog::kPlacedBranchPathCapacity) {
-        context.fatal = true;
-        return false;
-    }
-    Analysis& analysis = *context.analysis;
-    std::uint32_t hopRow = 0;
-    if (!row_index(analysis.placedHops.size(), hopRow)) {
-        context.fatal = true;
-        return false;
-    }
-    catalog::PlacedHop hop{};
-    hop.subblockRow = context.subblockRow;
-    hop.leafRow = context.leafRow;
-    hop.subblockOrdinal = context.subblockOrdinal;
-    hop.leafOrdinal = context.leafOrdinal;
-    hop.declaredBubbleIndex = context.declaredBubbleIndex;
-    hop.tag = source.tag;
-    hop.classId = source.classId;
-    hop.branchPath = source.branchPath;
-    hop.childCount = source.childCount;
-    hop.directTargetTag = source.directTargetTag;
-    hop.branchPathCount = source.branchPathCount;
-    hop.depth = source.depth;
-    hop.shape = placed_hop_shape(source.shape);
-    hop.complete = true;
-    if (!sha256::hash(blob, hop.payloadSha256)) {
-        context.fatal = true;
-        return false;
-    }
-    try {
-        analysis.placedHops.push_back(hop);
-    } catch (...) {
-        context.fatal = true;
-        return false;
-    }
-
-    if (source.shape == tables::PlacedChainShape::config) {
-        std::uint32_t occurrenceRow = 0;
-        if (!row_index(analysis.placedConfigOccurrences.size(), occurrenceRow)) {
-            analysis.placedHops.pop_back();
-            context.fatal = true;
-            return false;
-        }
-        catalog::PlacedConfigOccurrence occurrence{};
-        occurrence.subblockRow = context.subblockRow;
-        occurrence.leafRow = context.leafRow;
-        occurrence.terminalHopRow = hopRow;
-        occurrence.configTag = source.tag;
-        occurrence.declaredBubbleIndex = context.declaredBubbleIndex;
-        occurrence.branchPath = source.branchPath;
-        occurrence.branchPathCount = source.branchPathCount;
-        occurrence.complete = true;
-        try {
-            analysis.placedConfigOccurrences.push_back(occurrence);
-        } catch (...) {
-            analysis.placedHops.pop_back();
-            context.fatal = true;
-            return false;
-        }
-        analysis.placedHops[hopRow].configOccurrenceRow = occurrenceRow;
-        if (!collect_placed_config(&context, source.tag, blob)) {
-            context.leafComplete = false;
-            return false;
-        }
-        return true;
-    }
-
-    if (source.shape != tables::PlacedChainShape::bareObjectList) {
-        return true;
-    }
-
-    std::uint32_t targetRow = 0;
-    if (!row_index(analysis.placedBareTargets.size(), targetRow)) {
-        analysis.placedHops.pop_back();
-        context.fatal = true;
-        return false;
-    }
-    catalog::PlacedBareTarget target{};
-    target.subblockRow = context.subblockRow;
-    target.leafRow = context.leafRow;
-    target.sourceHopRow = hopRow;
-    target.declaredBubbleIndex = context.declaredBubbleIndex;
-    target.targetTag = source.directTargetTag;
-    target.expectedTargetClass = tables::kAuthoredPlacementListClass;
-    std::uint32_t targetClass = 0;
-    if (!read_tag(*context.build, source.directTargetTag, context.build->chain, targetClass)) {
-        target.status = catalog::PlacedBareTargetStatus::unreadableTarget;
-        context.leafComplete = false;
-        analysis.readComplete = false;
-        if (context.build->failed || cancelled(context.build->cancel)) {
-            analysis.placedHops.pop_back();
-            context.fatal = true;
-            return false;
-        }
-    } else {
-        target.targetClass = targetClass;
-        target.targetLogicalSize = context.build->chain.size();
-        if (!sha256::hash(context.build->chain, target.targetPayloadSha256)) {
-            analysis.placedHops.pop_back();
-            context.fatal = true;
-            return false;
-        }
-        if (targetClass != tables::kAuthoredPlacementListClass) {
-            target.status = catalog::PlacedBareTargetStatus::targetClassMismatch;
-            context.leafComplete = false;
-            analysis.readComplete = false;
-        } else {
-            target.status = catalog::PlacedBareTargetStatus::completeStructuralEdge;
-            if (!internal::collect_authored_placements(analysis.authored,
-                                                       context.build->chain,
-                                                       source.directTargetTag,
-                                                       context.declaredBubbleIndex)) {
-                context.leafComplete = false;
-                analysis.readComplete = false;
-            }
-        }
-    }
-    try {
-        analysis.placedBareTargets.push_back(target);
-    } catch (...) {
-        analysis.placedHops.pop_back();
-        context.fatal = true;
-        return false;
-    }
-    analysis.placedHops[hopRow].bareTargetRow = targetRow;
-    return true;
-}
-
-/** Follows every authored branch and retains every exact path-specific row. */
-[[nodiscard]] bool follow_handle(BuildContext& context,
-                                 Analysis& analysis,
-                                 std::uint32_t handle,
-                                 std::uint32_t registryKey,
-                                 std::int32_t declaredBubbleIndex,
-                                 std::uint32_t subblockRow,
-                                 std::uint32_t leafRow,
-                                 std::uint32_t subblockOrdinal,
-                                 std::uint32_t leafOrdinal) noexcept {
-    if (leafRow >= analysis.placedLeaves.size()) {
-        return false;
-    }
-    PlacedChainContext walkContext{&context,
-                                   &analysis,
-                                   registryKey,
-                                   declaredBubbleIndex,
-                                   subblockRow,
-                                   leafRow,
-                                   subblockOrdinal,
-                                   leafOrdinal};
-    tables::PlacedChainObservation observation{};
-    const bool walked = tables::visit_placed_chain_records(handle,
-                                                           &placed_chain_reader,
-                                                           &walkContext,
-                                                           &collect_placed_chain_record,
-                                                           &walkContext,
-                                                           observation);
-    catalog::PlacedLeaf& leaf = analysis.placedLeaves[leafRow];
-    const std::size_t hopCount = analysis.placedHops.size() - leaf.firstHop;
-    const std::size_t configCount =
-        analysis.placedConfigOccurrences.size() - leaf.firstConfigOccurrence;
-    const std::size_t bareCount = analysis.placedBareTargets.size() - leaf.firstBareTarget;
-    if (!row_index(hopCount, leaf.hopCount) || !row_index(configCount, leaf.configOccurrenceCount)
-        || !row_index(bareCount, leaf.bareTargetCount) || walkContext.fatal) {
-        return false;
-    }
-    leaf.complete = walked && walkContext.leafComplete && observation.hopCount == leaf.hopCount
-                    && observation.bareTargetCount == leaf.bareTargetCount;
-    if (!leaf.complete) {
-        analysis.readComplete = false;
-    }
-    return true;
-}
-
-/** @return True when the placed class definition marks its objects as network replicated. */
-[[nodiscard]] bool class_replicates(BuildContext& context, std::uint32_t classTag) noexcept {
-    const auto cached = context.classReplication.find(classTag);
-    if (cached != context.classReplication.end()) {
-        return cached->second;
-    }
-    std::uint32_t classId = 0;
-    tables::PlacedClassDefinition definition{};
-    const bool replicated = read_tag(context, classTag, context.classBytes, classId)
-                            && classId == tables::kPlacedClassDefinitionClass
-                            && tables::placed_class_definition(context.classBytes, definition)
-                            && definition.networkReplicated;
-    try {
-        context.classReplication.emplace(classTag, replicated);
-    } catch (...) {
-        // A missed cache entry costs one more read, nothing else.
-    }
-    return replicated;
-}
-
-/** Counts the authored placements the game replicates: entry flag bit 0 clear, class bit set. */
-[[nodiscard]] std::uint32_t count_replicated_placements(BuildContext& context,
-                                                        const Analysis& analysis) noexcept {
-    std::uint32_t count = 0;
-    for (const internal::RawAuthoredPlacement& placement : analysis.authored.placements) {
-        if ((placement.placementFlagsRaw & tables::kAuthoredPlacementNoReplicationBit) == 0
-            && class_replicates(context, placement.classListTag)) {
-            ++count;
-        }
-    }
-    return count;
-}
-
-/** Reads one object layout and its reachable descriptor/config records. */
-[[nodiscard]] bool analyze_object(BuildContext& context,
-                                  const tables::Placement& placement,
-                                  Analysis& output) noexcept {
-    output = {};
-    tables::Array slots{};
-    if (!tables::object_slots(placement.objectBytes, slots) || slots.count > kSlotCapacity) {
-        return false;
-    }
-    try {
-        output.slots.reserve(static_cast<std::size_t>(slots.count));
-        for (std::uint64_t index = 0; index < slots.count; ++index) {
-            tables::Slot slot{};
-            if (!tables::object_slot_at(placement.objectBytes, slots, index, slot) || slot.type == 0
-                || slot.type > state::build_data::scenarios::kMaximumSlotType) {
-                return false;
-            }
-            output.slots.push_back({slot.nameHash, static_cast<std::uint16_t>(slot.type)});
-        }
-    } catch (...) {
-        return false;
-    }
-
-    tables::Array bubbles{};
-    if (!tables::object_bubbles(placement.objectBytes, bubbles)
-        || bubbles.count > (std::numeric_limits<std::uint32_t>::max)()) {
-        return false;
-    }
-    try {
-        output.placedSubblocks.reserve(static_cast<std::size_t>(bubbles.count));
-    } catch (...) {
-        return false;
-    }
-    for (std::uint64_t bubbleIndex = 0; bubbleIndex < bubbles.count; ++bubbleIndex) {
-        tables::ObjectBubble bubble{};
-        std::uint32_t subblockRow = 0;
-        std::uint32_t firstLeaf = 0;
-        if (!tables::object_bubble_at(placement.objectBytes, bubbles, bubbleIndex, bubble)
-            || bubbleIndex > (std::numeric_limits<std::uint32_t>::max)()
-            || !row_index(output.placedSubblocks.size(), subblockRow)
-            || !row_index(output.placedLeaves.size(), firstLeaf)) {
-            return false;
-        }
-        catalog::PlacedSubblock subblock{};
-        subblock.subblockOrdinal = static_cast<std::uint32_t>(bubbleIndex);
-        subblock.declaredBubbleIndex = bubble.bubbleIndex;
-        subblock.firstLeaf = firstLeaf;
-        subblock.sourceOffset = bubble.sourceOffset;
-        try {
-            output.placedSubblocks.push_back(subblock);
-            if (bubble.handleCount
-                > (std::numeric_limits<std::uint32_t>::max)() - output.placedLeaves.size()) {
-                return false;
-            }
-            output.placedLeaves.reserve(output.placedLeaves.size()
-                                        + static_cast<std::size_t>(bubble.handleCount));
-        } catch (...) {
-            return false;
-        }
-        bool subblockComplete = true;
-        for (std::uint64_t leafOrdinal = 0; leafOrdinal < bubble.handleCount; ++leafOrdinal) {
-            std::uint32_t handle = 0;
-            std::uint32_t leafRow = 0;
-            catalog::PlacedLeaf leaf{};
-            if (!tables::object_placed_handle_at(placement.objectBytes, bubble, leafOrdinal, handle)
-                || leafOrdinal > (std::numeric_limits<std::uint32_t>::max)()
-                || !row_index(output.placedLeaves.size(), leafRow)
-                || !row_index(output.placedHops.size(), leaf.firstHop)
-                || !row_index(output.placedConfigOccurrences.size(), leaf.firstConfigOccurrence)
-                || !row_index(output.placedBareTargets.size(), leaf.firstBareTarget)) {
-                return false;
-            }
-            leaf.subblockRow = subblockRow;
-            leaf.subblockOrdinal = static_cast<std::uint32_t>(bubbleIndex);
-            leaf.leafOrdinal = static_cast<std::uint32_t>(leafOrdinal);
-            leaf.declaredBubbleIndex = bubble.bubbleIndex;
-            leaf.rootTag = handle;
-            leaf.sourceOffset = static_cast<std::uint64_t>(bubble.handleDataOffset)
-                                + leafOrdinal * tables::kObjectPlacedHandleStride;
-            try {
-                output.placedLeaves.push_back(leaf);
-            } catch (...) {
-                return false;
-            }
-            if (!follow_handle(context,
-                               output,
-                               handle,
-                               placement.objectKey,
-                               bubble.bubbleIndex,
-                               subblockRow,
-                               leafRow,
-                               static_cast<std::uint32_t>(bubbleIndex),
-                               static_cast<std::uint32_t>(leafOrdinal))) {
-                return false;
-            }
-            subblockComplete = subblockComplete && output.placedLeaves[leafRow].complete;
-        }
-        const std::size_t leafCount = output.placedLeaves.size() - firstLeaf;
-        if (!row_index(leafCount, output.placedSubblocks[subblockRow].leafCount)) {
-            return false;
-        }
-        output.placedSubblocks[subblockRow].complete = subblockComplete;
-    }
-    if (!row_index(output.observedConfigs.size(), output.configCount)) {
-        return false;
-    }
-    output.replicatedPlacementCount = count_replicated_placements(context, output);
-    std::sort(output.descriptors.begin(),
-              output.descriptors.end(),
-              [](const AnalysisDescriptor& leftRow, const AnalysisDescriptor& rightRow) noexcept {
-                  const tables::SlotDescriptor& left = leftRow.descriptor;
-                  const tables::SlotDescriptor& right = rightRow.descriptor;
-                  if (left.slotIndex != right.slotIndex) {
-                      return left.slotIndex < right.slotIndex;
-                  }
-                  if (left.componentClass != right.componentClass) {
-                      return left.componentClass < right.componentClass;
-                  }
-                  if (left.senseSchema != right.senseSchema) {
-                      return left.senseSchema < right.senseSchema;
-                  }
-                  if (left.authSchema != right.authSchema) {
-                      return left.authSchema < right.authSchema;
-                  }
-                  if (left.configTag != right.configTag) {
-                      return left.configTag < right.configTag;
-                  }
-                  return left.descriptorOffset < right.descriptorOffset;
-              });
-    return true;
-}
-
 /** Counts descriptors from one config and returns their shared slot index. */
 [[nodiscard]] std::size_t descriptors_for_config(const Analysis& analysis,
                                                  std::uint32_t configTag,
@@ -818,6 +281,7 @@ placed_hop_shape(tables::PlacedChainShape value) noexcept {
 
 /** @return True when one local row bank fits after an existing u32-indexed bank. */
 [[nodiscard]] bool can_append_rows(std::size_t existing, std::size_t incoming) noexcept {
+    // Snapshot rows are published as u32, so a bank stops at that maximum.
     constexpr std::size_t maximum = (std::numeric_limits<std::uint32_t>::max)();
     return existing <= maximum && incoming <= maximum - existing;
 }
@@ -1251,6 +715,7 @@ build_scenario_catalog(const middleware::content::packages::reader::Source& sour
         source, containers, scratch, nullptr, nullptr, nullptr, scenarioTag, scenarioName, cancel);
 }
 
+/** Builds one scenario catalog, reusing the caller's analysis caches when they exist. */
 [[nodiscard]] std::shared_ptr<state::build_data::scriptables::Snapshot>
 build_scenario_catalog(const middleware::content::packages::reader::Source& source,
                        const ContainerIndex& containers,

+ 119 - 0
Sunrise/src/client/content/activity/scriptable_catalog_worker_internal.h

@@ -0,0 +1,119 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <memory>
+#include <span>
+#include <string>
+#include <unordered_map>
+#include <unordered_set>
+#include <vector>
+
+#include "../../../middleware/content/packages/tables/scenario_reader.h"
+#include "../../../middleware/content/packages/tables/slot_descriptor_reader.h"
+#include "../../../state/build_data/scriptables/scriptable_catalog.h"
+#include "scriptable_catalog_authored_placements.h"
+#include "scriptable_catalog_builder.h"
+#include "scriptable_catalog_names.h"
+#include "scriptable_catalog_reference_scan.h"
+#include "scriptable_catalog_trigger_volumes.h"
+#include "source.h"
+
+namespace sunrise::client::content::activity::scriptables::worker_internal {
+
+namespace catalog = state::build_data::scriptables;
+namespace package_reader = middleware::content::packages::reader;
+namespace tables = middleware::content::packages::tables;
+
+/** One object declares at most this many slots; a larger layout is refused. */
+inline constexpr std::size_t kSlotCapacity = 262'144;
+
+struct AnalysisSlot final {
+    std::uint32_t nameHash{};
+    std::uint16_t type{};
+};
+
+/** One validated descriptor plus its class-specific optional placement identifier. */
+struct AnalysisDescriptor final {
+    tables::SlotDescriptor descriptor{};
+    std::uint64_t placementIdentifier{};
+    bool placementIdentifierRead{};
+};
+
+/** One exact inline string found in a source tag used by an object analysis. */
+struct AnalysisInlineName final {
+    std::uint32_t hash{};
+    std::string value{};
+};
+
+/** Inline strings from one first-seen source tag, kept in package encounter order. */
+struct AnalysisTagEvidence final {
+    std::uint32_t tag{};
+    std::vector<AnalysisInlineName> names{};
+};
+
+/** Everything one scenario analysis produced, keyed and shared between build passes. */
+struct Analysis final {
+    std::vector<AnalysisSlot> slots{};
+    std::vector<AnalysisDescriptor> descriptors{};
+    std::vector<internal::RawReference> references{};
+    internal::AuthoredPlacementAnalysis authored{};
+    std::vector<std::uint32_t> observedConfigs{};
+    std::vector<std::uint32_t> resolvedConfigs{};
+    std::vector<catalog::PlacedSubblock> placedSubblocks{};
+    std::vector<catalog::PlacedLeaf> placedLeaves{};
+    std::vector<catalog::PlacedHop> placedHops{};
+    std::vector<catalog::PlacedConfigOccurrence> placedConfigOccurrences{};
+    std::vector<catalog::PlacedBareTarget> placedBareTargets{};
+    std::vector<AnalysisTagEvidence> tagEvidence{};
+    std::uint32_t configCount{};
+    /** Authored placements whose flags and class definition let the game replicate them. */
+    std::uint32_t replicatedPlacementCount{};
+    bool readComplete{true};
+};
+
+using AnalysisMap = std::unordered_map<std::uint64_t, std::shared_ptr<const Analysis>>;
+
+/** Inputs and owned scratch of one scenario build pass. */
+struct BuildContext final {
+    const package_reader::Source* source{};
+    internal::BuilderCancelCheck cancel{};
+    ScenarioSource scenarioSource{};
+    std::vector<std::byte> scenario{};
+    std::vector<std::byte> chain{};
+    std::vector<std::byte> classBytes{};
+    /** Replication bit per placed class definition, read once per tag. */
+    std::unordered_map<std::uint32_t, bool> classReplication{};
+    std::vector<internal::InlineName> inlineNames{};
+    std::vector<internal::TriggerVolumeInput> triggerVolumeInputs{};
+    AnalysisMap analyses{};
+    AnalysisMap* sharedAnalyses{};
+    Analysis* recordingAnalysis{};
+    std::unordered_set<std::uint32_t> recordedAnalysisTags{};
+    bool recordingCacheable{};
+    /** Tags whose inline strings were already banked, so no blob is scanned twice. */
+    std::unordered_set<std::uint32_t> scannedTags{};
+    /** Total tag reads this scenario asked for, including every revisit. */
+    std::size_t tagReads{};
+    std::size_t analysisHits{};
+    std::size_t analysisMisses{};
+    std::shared_ptr<catalog::Snapshot> output{};
+    tables::WalkResult walk{};
+    bool failed{};
+};
+
+/** @return True when the caller asked this build to stop. */
+[[nodiscard]] bool cancelled(internal::BuilderCancelCheck check) noexcept;
+
+/** Reads one tag and offers its inline names to the current build. */
+[[nodiscard]] bool read_tag(BuildContext& context,
+                            std::uint32_t tag,
+                            std::vector<std::byte>& bytes,
+                            std::uint32_t& classId) noexcept;
+
+/** Reads one object layout and its reachable descriptor/config records. */
+[[nodiscard]] bool analyze_object(BuildContext& context,
+                                  const tables::Placement& placement,
+                                  Analysis& output) noexcept;
+
+} // namespace sunrise::client::content::activity::scriptables::worker_internal

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

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

+ 5 - 22
Sunrise/src/client/content/investment/investment_refresh.cpp

@@ -21,12 +21,9 @@ core::threading::SrwLock g_refreshLock{};
 }
 
 /**
- * Runs the emote-collection canonicalization on the extraction path, where it is an opportunistic
- * head start rather than a precondition: the snapshot path runs the same step behind its own
- * preflight, so nothing here is the last chance to apply it.
- * @return False only when the account itself could not be updated, which is the one outcome that
- * says something is wrong rather than merely unfinished. A build that cannot carry the item, and
- * one whose data is still being extracted, both leave the cache worth writing.
+ * Runs the emote-collection canonicalization early; the snapshot path runs it again behind its
+ * own preflight.
+ * @return False only when the account itself could not be updated.
  */
 [[nodiscard]] bool emote_collection_settled() noexcept {
     return state::ensure_character_emote_collection() != state::EmoteCollectionOutcome::failed;
@@ -45,21 +42,8 @@ bool refresh() noexcept {
         // The same lock as the extraction path. A cache write holds its own lock across file
         // calls, so a held thread stopped inside one would deadlock the freeze below.
         const std::lock_guard lock(g_refreshLock);
-        // The vendor catalog is deliberately not part of `ready()` - a boot without vendors is
-        // still a boot - but a restored cache can carry every mapping domain and no catalog,
-        // because the boot that wrote it lost the vendor pass. Every domain in `ready()` retries
-        // through the pass below until it publishes; this is the one domain that gate skips, so
-        // it gets one retry here. Once per session, because a pass that failed against these
-        // packages will keep failing against them, and its own log lines already say why.
-        static bool vendorRetryDone = false;
-        if (!vendorRetryDone && !state::build_data::vendor_catalog_ready()
-            && items::packages::readable()) {
-            vendorRetryDone = true;
-            (void)items::packages::build();
-        }
         const bool persisted = state::ensure_profile_item_identities()
-                               && state::ensure_character_subclasses()
-                               && emote_collection_settled()
+                               && state::ensure_character_subclasses() && emote_collection_settled()
                                && state::build_data::persist();
         // Nothing reads a package again until the next boot, so the open files and the held
         // tables go back now rather than at process exit.
@@ -77,8 +61,7 @@ bool refresh() noexcept {
     (void)items::packages::build();
     const bool domainsReady = ready();
     const bool complete = domainsReady && state::ensure_profile_item_identities()
-                          && state::ensure_character_subclasses()
-                          && emote_collection_settled()
+                          && state::ensure_character_subclasses() && emote_collection_settled()
                           && state::build_data::persist();
     // The overlay ends with the work, not with the slice, so it spans every retry the pass needs.
     if (complete) {

+ 9 - 11
Sunrise/src/client/content/investment/investment_source.cpp

@@ -18,10 +18,10 @@ namespace {
 
 /** FNV-1 hash of the investment-globals bootstrap name, so the name itself is not shipped. */
 constexpr std::uint32_t kInvestmentGlobalsNameHash = 0x6F7125CBU;
-/** The bootstrap name is not unique, so every match is collected. */
-// Keep this identical to the package extractor. The installed catalogue currently has more than
-// eight entries with this shared name; treating a truncated lookup as total failure made live
-// socket routing permanently defer even though the correct candidate was present.
+/**
+ * The bootstrap name is not unique, so every match is collected.
+ * A truncated lookup is not a failure: every copied candidate is still worth validating.
+ */
 constexpr std::size_t kBootstrapMatchCapacity = 64;
 /** One native resolver prefix is enough to recover all descriptor field offsets. */
 constexpr std::size_t kResolverDiagnosticBytes = 128;
@@ -65,7 +65,7 @@ void report_bytes(const char* stage,
     }
     std::size_t length = (std::min)(static_cast<std::size_t>(prefix), line.size() - 1U);
     static_cast<void>(core::log::append_hex(line, length, bytes));
-    core::log::write(core::log::Channel::client, core::log::Level::warn, {line.data(), length});
+    core::log::write(core::log::Channel::client, core::log::Level::debug, {line.data(), length});
 }
 
 /** Writes one candidate descriptor and its package identity as a diagnostic line. */
@@ -87,13 +87,14 @@ void report_descriptor(unsigned depth,
     }
     std::size_t length = (std::min)(static_cast<std::size_t>(prefix), line.size() - 1U);
     static_cast<void>(core::log::append_hex(line, length, bytes));
-    core::log::write(core::log::Channel::client, core::log::Level::warn, {line.data(), length});
+    core::log::write(core::log::Channel::client, core::log::Level::debug, {line.data(), length});
 }
 
 /** Captures the native resolver and every plausible descriptor base once, without mutation. */
 void report_layout_diagnostics(const targets::game::content::Targets& targets,
                                std::span<const state::content::Definition> candidates) noexcept {
-    if (g_diagnosticsReported.exchange(true, std::memory_order_relaxed)) {
+    if (!core::log::accepts(core::log::Channel::client, core::log::Level::debug)
+        || g_diagnosticsReported.exchange(true, std::memory_order_relaxed)) {
         return;
     }
     std::array<std::byte, kResolverDiagnosticBytes> resolver{};
@@ -125,7 +126,7 @@ void report_layout_diagnostics(const targets::game::content::Targets& targets,
     if (written > 0) {
         core::log::write(
             core::log::Channel::client,
-            core::log::Level::warn,
+            core::log::Level::debug,
             {line.data(), (std::min)(static_cast<std::size_t>(written), line.size() - 1U)});
     }
 
@@ -163,9 +164,6 @@ bool resolve_source(Source& source) noexcept {
 
     std::array<state::content::Definition, kBootstrapMatchCapacity> candidates{};
     std::size_t count = 0;
-    // A shared bootstrap name may have more installed matches than this bounded scratch array.
-    // Truncation is not a lookup failure: every copied candidate is still safe to validate, and
-    // rejecting the whole set is what kept socket-category routing permanently deferred.
     if (!state::content::lookup_hash(kInvestmentGlobalsNameHash, candidates, count) && count == 0) {
         return false;
     }

+ 1 - 2
Sunrise/src/client/content/investment/worker/investment_refresh_worker.cpp

@@ -13,8 +13,7 @@ namespace {
 
 /**
  * Delay between bounded refresh slices.
- * A slice runs on every pump. The extraction is hundreds of slices and each bounds its own length,
- * so a delay on top only added waiting: at 50 ms it was most of what the boot spent extracting.
+ * Each slice bounds its own length, so a delay on top of that is pure boot latency.
  */
 constexpr std::uint64_t kRefreshIntervalMilliseconds = 0;
 

+ 94 - 14
Sunrise/src/client/content/items/packages/internal.h

@@ -12,6 +12,7 @@
 #include "../../../../middleware/content/packages/tables/definition_index_table.h"
 #include "../../../../middleware/content/packages/tables/items.h"
 #include "../../../../state/build_data/abilities/definition.h"
+#include "../../../../state/build_data/bounties/definition.h"
 #include "../../../../state/build_data/collectibles/collectible_catalog.h"
 #include "../../../../state/build_data/constants/definition.h"
 #include "../../../../state/build_data/inventory/buckets/definition.h"
@@ -21,6 +22,8 @@
 #include "../../../../state/build_data/material_requirements/material_requirement_catalog.h"
 #include "../../../../state/build_data/progressions/definition.h"
 #include "../../../../state/build_data/runtime.h"
+#include "../../../../state/build_data/season_pass/definition.h"
+#include "../../../../state/build_data/sobjects/sobject_catalog.h"
 
 namespace sunrise::client::content::items::packages {
 
@@ -63,6 +66,27 @@ struct Storage {
     std::array<state::build_data::records::Definition,
                state::build_data::records::kDefinitionCapacity>
         recordRows{};
+    /** Flat objective, interval and reward banks the record rows name by range. */
+    std::array<state::build_data::records::Objective,
+               state::build_data::records::kObjectiveCapacity>
+        recordObjectives{};
+    std::array<state::build_data::records::Interval, state::build_data::records::kIntervalCapacity>
+        recordIntervals{};
+    std::array<state::build_data::records::Reward, state::build_data::records::kRewardCapacity>
+        recordRewards{};
+    std::size_t nodeCount{};
+    std::size_t recordCount{};
+    std::size_t recordObjectiveCount{};
+    std::size_t recordIntervalCount{};
+    std::size_t recordRewardCount{};
+    /** Objective and record display tables, held while the record pass joins them. */
+    std::vector<std::byte> objectiveTable{};
+    std::vector<std::byte> displayTable{};
+    /** Progression, item index, per-item strings and unlock slot tables, held across one join. */
+    std::vector<std::byte> progressionTable{};
+    std::vector<std::byte> itemIndexTable{};
+    std::vector<std::byte> itemStringsTable{};
+    std::vector<std::byte> unlockSlotTable{};
     std::vector<std::byte> container{};
     std::vector<std::byte> child{};
     std::vector<std::byte> root{};
@@ -100,6 +124,26 @@ struct Storage {
     std::array<state::build_data::progressions::Definition,
                state::build_data::progressions::kDefinitionCapacity>
         progressionRows{};
+    std::array<state::build_data::progressions::Step,
+               state::build_data::progressions::kStepCapacity>
+        progressionSteps{};
+    std::size_t progressionStepCount{};
+    /** Season pass reward rows, their raw claim slots, and the wrapper items they grant. */
+    std::array<state::build_data::season_pass::Reward,
+               state::build_data::season_pass::kRewardCapacity>
+        seasonPassRewards{};
+    std::array<std::uint16_t, state::build_data::season_pass::kRewardCapacity>
+        seasonPassClaimSlots{};
+    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>
+        bountyRows{};
+    std::size_t bountyCount{};
     std::array<state::build_data::collectibles::Definition,
                state::build_data::collectibles::kDefinitionCapacity>
         collectibleRows{};
@@ -108,6 +152,10 @@ struct Storage {
         materialRequirementRows{};
     std::array<state::build_data::items::Definition, state::build_data::items::kDefinitionCapacity>
         rows{};
+    /** Incident-target rows held until the sobject table is published. */
+    std::array<state::build_data::sobjects::Definition,
+               state::build_data::sobjects::kDefinitionCapacity>
+        sobjectRows{};
 };
 
 /**
@@ -132,6 +180,9 @@ struct Storage {
 /** Publishes parsed inventory buckets after applying the extracted item-slot relation. */
 [[nodiscard]] bool publish_buckets(Storage& storage) noexcept;
 
+/** @return True when the catalyst catalog is published or cannot exist on this executable. */
+[[nodiscard]] bool exotic_catalysts_settled() noexcept;
+
 /** Adds one native definition index to the deduplicated request set. */
 void request(std::uint16_t definitionIndex, DetailRequests& requested) noexcept;
 
@@ -246,29 +297,58 @@ read_investment_constants(const reader::Source& source,
     std::array<std::uint8_t, state::build_data::socket_entry_lists::kEntryCapacity>&
         output) noexcept;
 
-/** Reads nodes and resolves their value slots and owned records. */
+/**
+ * Reads nodes and resolves their value slots, owned records and lore parent bars.
+ * The record rows must already be built: a node's lore-book flag and parent bar come from them.
+ * @param source Package source.
+ * @param storage Pass storage holding the record rows and the objective bank.
+ * @param root Investment root bytes.
+ * @return True when the node table read and produced at least one row.
+ */
 [[nodiscard]] bool build_nodes(const reader::Source& source,
-                               reader::Scratch& scratch,
-                               std::span<const std::byte> root,
-                               std::vector<std::byte>& blob,
-                               std::span<state::build_data::nodes::Definition> output,
-                               std::size_t& count) noexcept;
+                               Storage& storage,
+                               std::span<const std::byte> root) noexcept;
 
-/** Reads records and resolves their completion-flag indices. */
+/**
+ * Reads records and resolves their flags, objectives, intervals and rewards.
+ * @param source Package source.
+ * @param storage Pass storage receiving the record rows and all three flat banks.
+ * @param root Investment root bytes.
+ * @return True when the record table read and produced at least one row.
+ */
 [[nodiscard]] bool build_records(const reader::Source& source,
-                                 reader::Scratch& scratch,
-                                 std::span<const std::byte> root,
-                                 std::vector<std::byte>& blob,
-                                 std::span<state::build_data::records::Definition> output,
-                                 std::size_t& count) noexcept;
+                                 Storage& storage,
+                                 std::span<const std::byte> root) noexcept;
 
-/** Reads progression definitions and resolves their replicated-object slots. */
+/**
+ * Reads progression definitions, their rank steps, and their replicated-object slots.
+ * @param source Package source.
+ * @param scratch Reader scratch.
+ * @param root Investment root bytes.
+ * @param blob Scratch storage for the progression table.
+ * @param output Definition storage.
+ * @param count Receives the definitions read.
+ * @param steps Flat step bank storage the definitions name by range.
+ * @param stepCount Receives the steps read.
+ * @return True when the table read and produced at least one row.
+ */
 [[nodiscard]] bool build_progressions(const reader::Source& source,
                                       reader::Scratch& scratch,
                                       std::span<const std::byte> root,
                                       std::vector<std::byte>& blob,
                                       std::span<state::build_data::progressions::Definition> output,
-                                      std::size_t& count) noexcept;
+                                      std::size_t& count,
+                                      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. */
+[[nodiscard]] bool build_season_pass(const reader::Source& source,
+                                     Storage& storage,
+                                     std::span<const std::byte> root) noexcept;
+
+/** Reads the item-type of every repeatable bounty, which is what groups a vendor pool. */
+[[nodiscard]] bool
+build_bounties(const reader::Source& source, Storage& storage, std::size_t itemCount) noexcept;
 
 /**
  * Copies the block key material this pass borrows.

+ 81 - 0
Sunrise/src/client/content/items/packages/package_bounty_build.cpp

@@ -0,0 +1,81 @@
+#include <cstring>
+
+#include "internal.h"
+
+namespace sunrise::client::content::items::packages {
+namespace {
+
+namespace domain = state::build_data::bounties;
+
+/** @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;
+}
+
+} // namespace
+
+/**
+ * Reads the item-type of every repeatable bounty, which is the key a vendor pool is grouped by.
+ * No sale row and no vendor field names a pool, so the shared item-type pair is what joins one.
+ * @param source Installed package source.
+ * @param storage Pass storage holding the item rows and receiving the bounty rows.
+ * @param itemCount Item rows this pass built.
+ * @return True when the strings table read and produced at least one row.
+ */
+bool build_bounties(const reader::Source& source,
+                    Storage& storage,
+                    std::size_t itemCount) noexcept {
+    storage.bountyCount = 0;
+    std::uint32_t tableClass = 0;
+    tables::Array rows{};
+    if (!reader::read_tag(source,
+                          storage.scratch,
+                          tables::kItemStringsIndexTag,
+                          storage.itemStringsTable,
+                          tableClass)
+        || !tables::find_array_at(std::span<const std::byte>{storage.itemStringsTable},
+                                  tables::kTableArrayDescriptor,
+                                  rows)
+        || rows.elementClass != tables::kItemStringsIndexRowClass || rows.count < itemCount) {
+        return false;
+    }
+    const std::span<const std::byte> table{storage.itemStringsTable};
+    for (std::size_t item = 0; item < itemCount; ++item) {
+        const state::build_data::items::Definition& definition = storage.rows[item];
+        if (definition.bucketId != domain::kBountyBucketId
+            || definition.tier != domain::kBountyTier) {
+            continue;
+        }
+        if (storage.bountyCount >= domain::kDefinitionCapacity) {
+            storage.bountyCount = 0;
+            return false;
+        }
+        tables::IndexRow entry{};
+        domain::Definition& row = storage.bountyRows[storage.bountyCount];
+        row = {};
+        row.itemIndex = definition.definitionIndex;
+        // A bounty whose strings blob will not read carries no pool key, so it is dropped rather
+        // than published with a zero pair that would merge it into every other unnamed row.
+        if (!tables::index_row(table, rows, definition.definitionIndex, entry)
+            || !reader::read_tag(source, storage.scratch, entry.targetTag, storage.definition)
+            || !read(std::span<const std::byte>{storage.definition},
+                     tables::kItemStringsTypePairOffset,
+                     row.itemType.bank)
+            || !read(std::span<const std::byte>{storage.definition},
+                     tables::kItemStringsTypeHashOffset,
+                     row.itemType.hash)
+            || row.itemType.hash == 0) {
+            continue;
+        }
+        ++storage.bountyCount;
+    }
+    return storage.bountyCount != 0;
+}
+
+} // namespace sunrise::client::content::items::packages

+ 21 - 22
Sunrise/src/client/content/items/packages/package_catalyst_condition_reader.cpp

@@ -9,13 +9,7 @@
 namespace sunrise::client::content::items::packages {
 namespace {
 
-/** Native postfix opcodes for flag, value, literal, and greater-than-or-equal. */
-constexpr std::uint32_t kFlagOpcode = 1;
-constexpr std::uint32_t kValueOpcode = 10;
-constexpr std::uint32_t kLiteralOpcode = 11;
-constexpr std::uint32_t kGreaterEqualOpcode = 14;
-/** Each postfix token is an opcode and one 32-bit operand. */
-constexpr std::size_t kExpressionTokenSize = 8;
+/** One value term is read-value, push-literal, then greater-or-equal. */
 constexpr std::size_t kValueExpressionTokenCount = 3;
 
 /** Adds one unique positive flag term to a bounded condition. */
@@ -58,6 +52,10 @@ void mark_ambiguous(catalysts::CompletionCondition& output) noexcept {
 
 } // namespace
 
+/**
+ * Reads one catalyst's completion flags, value minimums and objective reference.
+ * @param output Receives the unique condition, or its absent or ambiguous state.
+ */
 void read_catalyst_completion_condition(std::span<const std::byte> definition,
                                         std::uint16_t itemDefinitionIndex,
                                         catalysts::CompletionCondition& output) noexcept {
@@ -70,13 +68,15 @@ void read_catalyst_completion_condition(std::span<const std::byte> definition,
         if (!tables::find_array_at(definition, descriptor, expression)) {
             continue;
         }
+        // An objective reference row is one 16-bit objective index; an array of several is skipped.
+        // TODO: read the whole array and pick the objective a multi-objective catalyst completes
+        // on.
         if (expression.elementClass == tables::kObjectiveReferenceArrayClass
             && expression.count == 1 && expression.dataOffset <= definition.size()
             && definition.size() - expression.dataOffset >= 2 * sizeof(std::uint32_t)) {
             std::array<std::uint32_t, 2> reference{};
-            std::memcpy(reference.data(),
-                        definition.data() + expression.dataOffset,
-                        sizeof reference);
+            std::memcpy(
+                reference.data(), definition.data() + expression.dataOffset, sizeof reference);
             if (reference[0] < catalysts::kUnavailableObjectiveIndex
                 && reference[1] == tables::kObjectiveReferenceRowClass) {
                 const auto objective = static_cast<std::uint16_t>(reference[0]);
@@ -91,37 +91,36 @@ void read_catalyst_completion_condition(std::span<const std::byte> definition,
         }
         if (expression.elementClass != tables::kInvestmentExpressionRowClass
             || expression.dataOffset > definition.size()
-            || expression.count
-                   > (definition.size() - expression.dataOffset) / kExpressionTokenSize) {
+            || expression.count > (definition.size() - expression.dataOffset)
+                                      / tables::kUnlockInstructionStride) {
             continue;
         }
         for (std::size_t token = 0; token < expression.count; ++token) {
             std::array<std::uint32_t, 2> current{};
             std::memcpy(current.data(),
                         definition.data() + expression.dataOffset
-                            + token * kExpressionTokenSize,
+                            + token * tables::kUnlockInstructionStride,
                         sizeof current);
-            if (current[0] == kFlagOpcode
+            if (current[0] == tables::kUnlockReadFlagOpcode
                 && current[1] < state::build_data::items::kDefinitionCapacity
                 && !append_flag(output.completion, static_cast<std::uint16_t>(current[1]))) {
                 mark_ambiguous(output);
                 return;
             }
-            if (current[0] != kValueOpcode
+            if (current[0] != tables::kUnlockReadValueOpcode
                 || token + kValueExpressionTokenCount > expression.count) {
                 continue;
             }
             std::array<std::uint32_t, kValueExpressionTokenCount * 2> tokens{};
             std::memcpy(tokens.data(),
                         definition.data() + expression.dataOffset
-                            + token * kExpressionTokenSize,
+                            + token * tables::kUnlockInstructionStride,
                         sizeof tokens);
-            if (tokens[2] != kLiteralOpcode || tokens[4] != kGreaterEqualOpcode
-                || tokens[5] != UINT32_MAX
+            if (tokens[2] != tables::kUnlockLiteralOpcode
+                || tokens[4] != tables::kUnlockGreaterEqualOpcode || tokens[5] != UINT32_MAX
                 || tokens[1] >= catalysts::kUnavailableCompletionValueIndex || tokens[3] == 0
                 || tokens[3]
-                       > static_cast<std::uint32_t>(
-                           (std::numeric_limits<std::int32_t>::max)())) {
+                       > static_cast<std::uint32_t>((std::numeric_limits<std::int32_t>::max)())) {
                 continue;
             }
             if (!upsert_value(output.completion,
@@ -136,8 +135,8 @@ void read_catalyst_completion_condition(std::span<const std::byte> definition,
               output.completion.flags.begin() + output.completion.flagCount);
     std::sort(output.completion.values.begin(),
               output.completion.values.begin() + output.completion.valueCount,
-              [](const catalysts::CompletionValue& left,
-                 const catalysts::CompletionValue& right) { return left.index < right.index; });
+              [](const catalysts::CompletionValue& first,
+                 const catalysts::CompletionValue& second) { return first.index < second.index; });
     if (output.completion.flagCount != 0 || output.completion.valueCount != 0
         || output.objectiveDefinitionIndex != catalysts::kUnavailableObjectiveIndex) {
         output.state = catalysts::CompletionConditionState::present;

+ 98 - 20
Sunrise/src/client/content/items/packages/package_collectible_build.cpp

@@ -1,3 +1,4 @@
+#include <array>
 #include <cstring>
 #include <limits>
 
@@ -6,6 +7,58 @@
 #include "internal.h"
 
 namespace sunrise::client::content::items::packages {
+namespace {
+
+/**
+ * Reads the one flag slot a collectible's acquired-state expression tests.
+ * A collectible gated by anything other than one plain flag test keeps the unavailable slot.
+ * @param table Collectible table bytes.
+ * @param at Collectible row offset.
+ * @param slot Receives the tested slot, or the unavailable slot.
+ */
+void read_acquired_flag_slot(std::span<const std::byte> table,
+                             std::size_t at,
+                             std::uint16_t& slot) noexcept {
+    namespace domain = state::build_data::collectibles;
+    slot = domain::kUnavailableFlagSlot;
+    tables::Array expression{};
+    std::array<std::uint32_t, 2> instruction{};
+    if (!tables::find_optional_array_at(
+            table, at + tables::kCollectibleAcquiredExpressionField, expression)
+        || expression.count != 1 || expression.elementClass != tables::kInvestmentExpressionRowClass
+        || expression.dataOffset + sizeof instruction > table.size()) {
+        return;
+    }
+    std::memcpy(instruction.data(), table.data() + expression.dataOffset, sizeof instruction);
+    if (instruction[0] == tables::kUnlockReadFlagOpcode
+        && instruction[1] < domain::kUnavailableFlagSlot) {
+        slot = static_cast<std::uint16_t>(instruction[1]);
+    }
+}
+
+/**
+ * Resolves one unlock flag slot to the bank row it feeds.
+ * @param table Unlock flag slot table bytes.
+ * @param rows Located slot array, which is keyed by slot.
+ * @param slot Slot to resolve.
+ * @return The bank row, or the unavailable row when the slot is not in the table.
+ */
+[[nodiscard]] std::uint16_t flag_bank_index(std::span<const std::byte> table,
+                                            const tables::Array& rows,
+                                            std::uint16_t slot) noexcept {
+    namespace domain = state::build_data::collectibles;
+    const std::size_t at = rows.dataOffset
+                           + static_cast<std::size_t>(slot) * tables::kUnlockSlotRowStride
+                           + tables::kUnlockSlotBankIndexOffset;
+    std::uint16_t index = domain::kUnavailableFlagIndex;
+    if (slot >= rows.count || at + sizeof index > table.size()) {
+        return domain::kUnavailableFlagIndex;
+    }
+    std::memcpy(&index, table.data() + at, sizeof index);
+    return index;
+}
+
+} // namespace
 
 /** Reads the fixed collectible table and publishes its native-index item links. */
 bool build_collectibles(const reader::Source& source,
@@ -14,34 +67,41 @@ bool build_collectibles(const reader::Source& source,
                         std::uint64_t itemDefinitionCount) noexcept {
     namespace domain = state::build_data::collectibles;
 
-    // The definition table an incident target names. Read here because this pass already holds an
-    // open source, and because a collectible picked up in the world arrives as an incident: without
-    // this table its target is a bare number.
-    if (state::build_data::sobjects::count() == 0) {
+    // A collectible picked up in the world arrives as an incident, and its target is a row of the
+    // sobject table. Without that table the target is a bare number.
+    if (!state::build_data::sobject_definitions_ready()) {
         namespace sobjects = state::build_data::sobjects;
-        // Count at +112; 40-byte rows at +128 hold the name hash, packed lane, and type.
-        constexpr std::size_t kCountOffset = 112;
-        constexpr std::size_t kRowBase = 128;
-        constexpr std::size_t kRowStride = 40;
-        for (const std::uint32_t tag : {0x81327CD4U, 0x80B9E5BFU}) {
-            std::vector<std::byte> blob{};
-            if (!reader::read_tag(source, storage.scratch, tag, blob) || blob.size() < kRowBase) {
+        for (const std::uint32_t tag :
+             {tables::kSobjectTablePrimaryTag, tables::kSobjectTableAlternateTag}) {
+            std::vector<std::byte>& blob = storage.definition;
+            if (!reader::read_tag(source, storage.scratch, tag, blob)
+                || blob.size() < tables::kSobjectFirstRow) {
                 continue;
             }
             std::uint64_t rowCount = 0;
-            std::memcpy(&rowCount, blob.data() + kCountOffset, sizeof rowCount);
+            std::memcpy(&rowCount, blob.data() + tables::kSobjectCountOffset, sizeof rowCount);
             if (rowCount == 0 || rowCount > sobjects::kDefinitionCapacity
-                || kRowBase + static_cast<std::size_t>(rowCount) * kRowStride > blob.size()) {
+                || tables::kSobjectFirstRow
+                           + static_cast<std::size_t>(rowCount) * tables::kSobjectRowStride
+                       > blob.size()) {
                 continue;
             }
-            std::vector<sobjects::Definition> rows(static_cast<std::size_t>(rowCount));
-            for (std::size_t row = 0; row < rows.size(); ++row) {
-                const std::size_t at = kRowBase + row * kRowStride;
-                std::memcpy(&rows[row].nameHash, blob.data() + at, sizeof rows[row].nameHash);
-                std::memcpy(&rows[row].lane4, blob.data() + at + 16, sizeof rows[row].lane4);
-                std::memcpy(&rows[row].typeCode, blob.data() + at + 36, sizeof rows[row].typeCode);
+            const auto count = static_cast<std::size_t>(rowCount);
+            for (std::size_t row = 0; row < count; ++row) {
+                const std::size_t at = tables::kSobjectFirstRow + row * tables::kSobjectRowStride;
+                sobjects::Definition& definition = storage.sobjectRows[row];
+                std::memcpy(&definition.nameHash,
+                            blob.data() + at + tables::kSobjectNameHashOffset,
+                            sizeof definition.nameHash);
+                std::memcpy(&definition.lane4,
+                            blob.data() + at + tables::kSobjectLaneOffset,
+                            sizeof definition.lane4);
+                std::memcpy(&definition.typeCode,
+                            blob.data() + at + tables::kSobjectTypeCodeOffset,
+                            sizeof definition.typeCode);
             }
-            (void)sobjects::replace(std::span<const sobjects::Definition>{rows});
+            (void)state::build_data::publish_sobject_definitions(
+                std::span(storage.sobjectRows).first(count));
             break;
         }
     }
@@ -78,6 +138,19 @@ bool build_collectibles(const reader::Source& source,
         return false;
     }
 
+    // The slot table is keyed by slot, so it resolves each collectible's acquired flag to the
+    // bank row the object its kind names carries.
+    std::uint32_t slotTableTag = 0;
+    tables::Array slotRows{};
+    if (!tables::slot_tag(root, tables::kUnlockFlagSlotTableSlot, slotTableTag) || slotTableTag == 0
+        || !reader::read_tag(source, storage.scratch, slotTableTag, storage.unlockSlotTable)
+        || !tables::find_array_at(std::span<const std::byte>{storage.unlockSlotTable},
+                                  tables::kTableArrayDescriptor,
+                                  slotRows)) {
+        return false;
+    }
+    const std::span<const std::byte> slotTable{storage.unlockSlotTable};
+
     std::uint32_t tableTag = 0;
     std::uint32_t tableClass = 0;
     tables::Array rows{};
@@ -121,6 +194,11 @@ bool build_collectibles(const reader::Source& source,
         output.collectibleHash = collectibleHash;
         output.collectibleIndex = static_cast<std::uint16_t>(row);
         output.itemDefinitionIndex = itemDefinitionIndex;
+        read_acquired_flag_slot(table, at, output.acquiredFlagSlot);
+        if (output.acquiredFlagSlot != domain::kUnavailableFlagSlot) {
+            output.acquiredFlagIndex =
+                flag_bank_index(slotTable, slotRows, output.acquiredFlagSlot);
+        }
         if (requirementSetIndex == domain::kUnavailableMaterialRequirementSetIndex) {
             continue;
         }

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

@@ -76,7 +76,7 @@ namespace domain = state::build_data::items::details;
 constexpr std::size_t kConstantsPrefix = 8;
 /** Client offset of the stat row the banner's power number is searched by. */
 constexpr std::size_t kLightStatRowOffset = 592;
-/** Build-86657 sub_140553ED0 reads the weapon Power stat row at this client offset. */
+/** Client offset of the stat row a weapon's power number is read from. */
 constexpr std::size_t kWeaponPowerStatRowOffset = 606;
 /**
  * Client offsets of the 6 character stat rows, in the two runs the blob stores them in.

+ 42 - 84
Sunrise/src/client/content/items/packages/package_item_build.cpp

@@ -5,12 +5,9 @@
 
 #include "../../../../core/filesystem/path.h"
 #include "../../../../core/logging/log.h"
-#include "../../../../core/settings/rule_text.h"
 #include "../../../../middleware/content/packages/reader/reader.h"
 #include "../../../../middleware/content/packages/tables/definition_index_table.h"
 #include "../../../../state/build_data/runtime.h"
-#include "../../../../state/build_data/sobjects/sobject_catalog.h"
-#include "../../../../state/build_data/vendors/vendor_catalog.h"
 #include "../../activity/entity_position_profile_build.h"
 #include "../../hash_names/hash_name_build.h"
 #include "../../scenarios/scenario_build.h"
@@ -23,54 +20,6 @@
 namespace sunrise::client::content::items::packages {
 namespace {
 
-/**
- * Reads the vendors to publish definitions for, by definition hash, from `vendor_catalog.txt`.
- *
- * A row position is not a stable name for a vendor and the useful ones are not all at the head of
- * the index, so the list is authored by hash. An absent or empty file leaves the caller with the
- * leading window it used before.
- *
- * @param hashes Receives the requested definition hashes.
- * @return How many were read.
- */
-[[nodiscard]] std::size_t read_vendor_hashes(std::span<std::uint32_t> hashes) noexcept {
-    static std::array<char, core::rule_text::kRuleTextCapacity> text{};
-    if (!core::path::read_artifact_text(L"vendor_catalog.txt", text)) {
-        return 0;
-    }
-    std::size_t count = 0;
-    core::rule_text::Cursor rules{text.data()};
-    while (count < hashes.size() && rules.seek_field()) {
-        const std::uint32_t parsed = rules.read_hex();
-        if (parsed != 0) {
-            hashes[count++] = parsed;
-        }
-    }
-    return count;
-}
-
-/**
- * Publishes the vendor catalog, index and definitions both.
- *
- * `vendors::build` reads the whole index and a definition for each vendor named by hash, filling
- * any room left from the head of the index. The names come from `vendor_catalog.txt`: a row
- * position is not a stable name for a vendor and the useful ones are not all at the head - the
- * Drifter is row 195, so every request against him once failed to resolve a definition that had
- * never been read.
- *
- * @param source Package directory and borrowed block keys.
- * @param scratch Block storage shared with the other content passes.
- */
-void build_vendor_catalog(const reader::Source& source, reader::Scratch& scratch) noexcept {
-    namespace vendor_domain = state::build_data::vendors;
-    if (state::build_data::vendor_catalog_ready()) {
-        return;
-    }
-    static std::array<std::uint32_t, vendor_domain::kDefinitionCapacity> named{};
-    const std::size_t namedCount = read_vendor_hashes(named);
-    (void)content::vendors::build(source, scratch, std::span(named).first(namedCount));
-}
-
 /** @return True when every item and investment-root domain is published. */
 [[nodiscard]] bool root_domains_ready() noexcept {
     return state::build_data::item_definitions_ready()
@@ -83,11 +32,12 @@ void build_vendor_catalog(const reader::Source& source, reader::Scratch& scratch
            && 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::repeatable_bounties_ready()
            && state::build_data::record_definitions_ready()
            && state::build_data::node_definitions_ready()
-           && state::build_data::sobjects::count() != 0
-           && state::build_data::investment_constants_ready()
-           && state::build_data::exotic_catalysts_ready();
+           && state::build_data::sobject_definitions_ready()
+           && state::build_data::investment_constants_ready() && exotic_catalysts_settled();
 }
 
 } // namespace
@@ -96,6 +46,7 @@ void build_vendor_catalog(const reader::Source& source, reader::Scratch& scratch
 bool ready() noexcept {
     return root_domains_ready() && state::build_data::scenario_layouts_ready()
            && state::build_data::spawn_sets_ready() && state::build_data::hash_names_ready()
+           && state::build_data::vendor_catalog_ready()
            && content::activity::entity_position_profiles::ready();
 }
 
@@ -123,7 +74,7 @@ bool build() noexcept {
         (void)content::scenarios::build(packageSource, storage.scratch);
         (void)content::spawn_sets::build(packageSource, storage.scratch);
         (void)content::hash_names::build(packageSource, storage.scratch);
-        build_vendor_catalog(packageSource, storage.scratch);
+        (void)content::vendors::build(packageSource, storage.scratch);
         if (ready()) {
             SecureZeroMemory(&keys, sizeof keys);
             return true;
@@ -177,19 +128,18 @@ bool build() noexcept {
                     continue;
                 }
             }
-            if (!state::build_data::exotic_catalysts_ready()) {
+            if (!exotic_catalysts_settled()) {
                 reason = "catalyst_gates";
                 if (!read_catalyst_acquisition_gates(source,
                                                      storage.scratch,
                                                      std::span<const std::byte>{storage.root},
                                                      storage.child,
                                                      storage.catalystAcquisitionGates)
-                    || !read_catalyst_objective_values(
-                        source,
-                        storage.scratch,
-                        std::span<const std::byte>{storage.root},
-                        storage.child,
-                        storage.catalystObjectiveValues)) {
+                    || !read_catalyst_objective_values(source,
+                                                       storage.scratch,
+                                                       std::span<const std::byte>{storage.root},
+                                                       storage.child,
+                                                       storage.catalystObjectiveValues)) {
                     continue;
                 }
             }
@@ -206,33 +156,37 @@ bool build() noexcept {
                                        std::span<const std::byte>{storage.root},
                                        storage.child,
                                        storage.progressionRows,
-                                       progressionCount)) {
+                                       progressionCount,
+                                       storage.progressionSteps,
+                                       storage.progressionStepCount)) {
                     (void)state::build_data::publish_progression_definitions(
-                        std::span(storage.progressionRows).first(progressionCount));
+                        std::span(storage.progressionRows).first(progressionCount),
+                        std::span(storage.progressionSteps).first(storage.progressionStepCount));
                 }
             }
-            if (!state::build_data::node_definitions_ready()) {
-                std::size_t nodeCount = 0;
-                if (build_nodes(source,
-                                storage.scratch,
-                                std::span<const std::byte>{storage.root},
-                                storage.child,
-                                storage.nodeRows,
-                                nodeCount)) {
-                    (void)state::build_data::publish_node_definitions(
-                        std::span(storage.nodeRows).first(nodeCount));
-                }
+            if (!state::build_data::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));
             }
-            if (!state::build_data::record_definitions_ready()) {
-                std::size_t recordCount = 0;
-                if (build_records(source,
-                                  storage.scratch,
-                                  std::span<const std::byte>{storage.root},
-                                  storage.child,
-                                  storage.recordRows,
-                                  recordCount)) {
+            // 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.
+            if (!state::build_data::record_definitions_ready()
+                || !state::build_data::node_definitions_ready()) {
+                const bool builtRecords =
+                    build_records(source, storage, std::span<const std::byte>{storage.root});
+                if (builtRecords && !state::build_data::record_definitions_ready()) {
                     (void)state::build_data::publish_record_definitions(
-                        std::span(storage.recordRows).first(recordCount));
+                        std::span(storage.recordRows).first(storage.recordCount),
+                        std::span(storage.recordObjectives).first(storage.recordObjectiveCount),
+                        std::span(storage.recordIntervals).first(storage.recordIntervalCount),
+                        std::span(storage.recordRewards).first(storage.recordRewardCount));
+                }
+                if (builtRecords && !state::build_data::node_definitions_ready()
+                    && build_nodes(source, storage, std::span<const std::byte>{storage.root})) {
+                    (void)state::build_data::publish_node_definitions(
+                        std::span(storage.nodeRows).first(storage.nodeCount));
                 }
             }
             if (!state::build_data::investment_constants_ready()) {
@@ -267,6 +221,10 @@ bool build() noexcept {
                                            std::span<const std::byte>{storage.root},
                                            table.count)) {
                 reason = "collectibles";
+            } else if (!state::build_data::repeatable_bounties_ready()
+                       && build_bounties(source, storage, rowCount)) {
+                (void)state::build_data::publish_repeatable_bounties(
+                    std::span(storage.bountyRows).first(storage.bountyCount));
             }
         }
     }

+ 20 - 4
Sunrise/src/client/content/items/packages/package_item_rows.cpp

@@ -13,6 +13,13 @@ namespace {
 namespace build_details = state::build_data::items::details;
 namespace build_items = state::build_data::items;
 
+/**
+ * Set when a derivation reports the installed executable is not the one the catalyst facts pin.
+ * A build identity cannot change while the process runs, so the catalog will never derive here.
+ * TODO: replace with a build_data predicate for catalyst build support; nothing exports one yet.
+ */
+bool g_catalystsUnsupported = false;
+
 /** @return True when one extracted detail can join the currently published numeric domains. */
 [[nodiscard]] bool publishable_detail(const build_details::Definition& detail) noexcept {
     const std::size_t itemCount = state::build_data::item_definition_count();
@@ -34,6 +41,11 @@ namespace build_items = state::build_data::items;
 
 } // namespace
 
+/** @return True when the catalyst catalog is published or cannot exist on this executable. */
+bool exotic_catalysts_settled() noexcept {
+    return state::build_data::exotic_catalysts_ready() || g_catalystsUnsupported;
+}
+
 /** Walks the located item index table, then publishes every domain that depends on it. */
 bool build_item_rows(const reader::Source& source,
                      Storage& storage,
@@ -43,7 +55,7 @@ bool build_item_rows(const reader::Source& source,
     const bool needDefinitions = !state::build_data::item_definitions_ready();
     const bool needDetails = !state::build_data::configured_item_details_ready();
     const bool needSocketPlugs = !state::build_data::socket_plug_rules_ready();
-    const bool needCatalysts = !state::build_data::exotic_catalysts_ready();
+    const bool needCatalysts = !exotic_catalysts_settled();
     const bool needBuckets = !state::build_data::inventory_bucket_descriptors_ready();
     const bool retainDetails = needDetails || needCatalysts;
     const bool needSocketRows = needSocketPlugs || needCatalysts;
@@ -199,7 +211,12 @@ bool build_item_rows(const reader::Source& source,
             catalystBuilt = state::build_data::derive_exotic_catalysts(
                 catalystSource, catalystRows, catalystCount, catalystReport);
             report_catalyst_catalog(catalystReport, catalystBuilt);
-            published = catalystBuilt;
+            // An unsupported executable settles the domain: retrying repeats the whole item walk
+            // on every refresh slice and reaches the same verdict.
+            g_catalystsUnsupported =
+                catalystReport.error
+                == state::build_data::items::catalysts::Error::unsupportedBuild;
+            published = catalystBuilt || g_catalystsUnsupported;
         }
         if (published && needSocketPlugs) {
             const std::size_t rules = socketPlugBuild.rule_count();
@@ -248,8 +265,7 @@ bool build_item_rows(const reader::Source& source,
     }
     return published && state::build_data::item_definitions_ready()
            && state::build_data::configured_item_details_ready()
-           && state::build_data::socket_plug_rules_ready()
-           && state::build_data::exotic_catalysts_ready()
+           && state::build_data::socket_plug_rules_ready() && exotic_catalysts_settled()
            && state::build_data::ability_buckets_ready();
 }
 

+ 166 - 214
Sunrise/src/client/content/items/packages/package_node_build.cpp

@@ -1,17 +1,18 @@
-#include <algorithm>
 #include <array>
 #include <cstdio>
-#include <unordered_map>
-#include <vector>
+#include <cstring>
 
 #include "../../../../core/logging/log.h"
-#include "../../../../state/build_data/runtime.h"
 #include "../../../../middleware/content/packages/tables/unlock_expression.h"
+#include "../../../../state/build_data/runtime.h"
 #include "internal.h"
 
 namespace sunrise::client::content::items::packages {
 namespace {
 
+namespace domain = state::build_data::nodes;
+namespace record_domain = state::build_data::records;
+
 /** Reports where the node pass stopped, so a silent miss cannot look like a stuck progress bar. */
 void report(const char* stage, unsigned long long detail) noexcept {
     std::array<char, 128> line{};
@@ -24,143 +25,171 @@ void report(const char* stage, unsigned long long detail) noexcept {
     }
 }
 
-} // namespace
+/** A slot is a signed 16-bit value, so the widest addressable slot space is this. */
+constexpr std::size_t kSlotSpace = 32768;
+
+/** Unlock slot to bank mapping row, indexed by slot. The first mapping row of a slot wins. */
+using SlotMap = std::array<std::uint16_t, kSlotSpace>;
+
+/** The four maps one node pass reads. 256 KiB together, so they never sit on the caller stack. */
+struct SlotMaps {
+    SlotMap accountFlag{};
+    SlotMap characterFlag{};
+    SlotMap accountValue{};
+    SlotMap characterValue{};
+};
+
+/** Held here because the pass owns the build-data lock and runs once per process. */
+SlotMaps g_slotMaps{};
+
+/** Clears one map so every slot reads as unavailable. */
+void clear_slot_map(SlotMap& output) noexcept {
+    output.fill(domain::kUnavailableValueIndex);
+}
+
+/** Clears all four so an unread table cannot leave a prior pass's indexes addressable. */
+void clear_slot_maps(SlotMaps& maps) noexcept {
+    clear_slot_map(maps.accountFlag);
+    clear_slot_map(maps.characterFlag);
+    clear_slot_map(maps.accountValue);
+    clear_slot_map(maps.characterValue);
+}
 
 /**
- * Reads the presentation node table and resolves each node's value slot and owned records.
- *
- * A node's progress bar shows a value slot named by its own expression, and the records it owns sit
- * at row `+136` as a row and a gate. Both are read here so a claim never has to walk the node table.
+ * Reads one unlock mapping table into a slot-to-index map.
+ * @param blob Blob holding the mapping table.
+ * @param descriptor Array descriptor of the mapping table inside that blob.
+ * @param output Receives one bank index per addressable slot, or the unavailable index.
+ * @return True when the table resolves and every row fits the blob.
  */
-bool build_nodes(const reader::Source& source,
-                 reader::Scratch& scratch,
-                 std::span<const std::byte> root,
-                 std::vector<std::byte>& blob,
-                 std::span<state::build_data::nodes::Definition> output,
-                 std::size_t& count) noexcept {
-    namespace domain = state::build_data::nodes;
-    count = 0;
-
-    // The account flag mapping table, read first. A category's gate names a flag slot, and a slot
-    // is not an index: the byte that feeds it sits at the row whose destination is that slot.
-    std::uint32_t flagMapTag = 0;
-    tables::Array flagMapRows{};
-    std::unordered_map<std::int16_t, std::uint16_t> flagIndexBySlot{};
-    if (tables::slot_tag(root, tables::kUnlockFlagMapTableSlot, flagMapTag) && flagMapTag != 0
-        && tables::package_of(flagMapTag) != tables::kAbsentPackageId
-        && reader::read_tag(source, scratch, flagMapTag, blob)
-        && tables::find_array_at(
-            std::span<const std::byte>{blob}, tables::kAccountFlagMapDescriptor, flagMapRows)
-        && flagMapRows.count != 0
-        && flagMapRows.dataOffset
-                   + static_cast<std::size_t>(flagMapRows.count) * tables::kUnlockMapRowStride
-               <= blob.size()) {
-        for (std::uint64_t row = 0; row < flagMapRows.count && row <= domain::kUnavailableFlagIndex;
-             ++row) {
-            std::int16_t slot = 0;
-            std::memcpy(&slot,
-                        blob.data() + flagMapRows.dataOffset
-                            + static_cast<std::size_t>(row) * tables::kUnlockMapRowStride
-                            + tables::kUnlockMapDestinationSlotOffset,
-                        sizeof slot);
-            flagIndexBySlot.emplace(slot, static_cast<std::uint16_t>(row));
+[[nodiscard]] bool
+read_slot_map(std::span<const std::byte> blob, std::size_t descriptor, SlotMap& output) noexcept {
+    clear_slot_map(output);
+    tables::Array rows{};
+    if (!tables::find_array_at(blob, descriptor, rows) || rows.count == 0
+        || rows.dataOffset + static_cast<std::size_t>(rows.count) * tables::kUnlockMapRowStride
+               > blob.size()) {
+        return false;
+    }
+    for (std::uint64_t row = 0; row < rows.count && row <= domain::kUnavailableValueIndex; ++row) {
+        std::int16_t slot = 0;
+        std::memcpy(&slot,
+                    blob.data() + rows.dataOffset
+                        + static_cast<std::size_t>(row) * tables::kUnlockMapRowStride
+                        + tables::kUnlockMapDestinationSlotOffset,
+                    sizeof slot);
+        // An expression operand is never negative, so a negative destination addresses nothing.
+        if (slot < 0) {
+            continue;
+        }
+        std::uint16_t& existing = output[static_cast<std::size_t>(slot)];
+        if (existing == domain::kUnavailableValueIndex) {
+            existing = static_cast<std::uint16_t>(row);
         }
     }
+    return true;
+}
 
+/** @param map Slot map. @param slot Raw unlock slot. @return Bank index, or the unavailable one. */
+[[nodiscard]] std::uint16_t bank_index(const SlotMap& map, std::int16_t slot) noexcept {
+    if (slot < 0) {
+        return domain::kUnavailableValueIndex;
+    }
+    return map[static_cast<std::size_t>(slot)];
+}
 
-    tables::Array characterFlagMapRows{};
-    std::unordered_map<std::int16_t, std::uint16_t> characterFlagIndexBySlot{};
-    if (flagMapTag != 0
-        && tables::find_array_at(std::span<const std::byte>{blob},
-                                 tables::kCharacterFlagMapDescriptor,
-                                 characterFlagMapRows)
-        && characterFlagMapRows.count != 0
-        && characterFlagMapRows.dataOffset
-                   + static_cast<std::size_t>(characterFlagMapRows.count)
-                         * tables::kUnlockMapRowStride
-               <= blob.size()) {
-        for (std::uint64_t row = 0;
-             row < characterFlagMapRows.count && row <= domain::kUnavailableFlagIndex; ++row) {
-            std::int16_t slot = 0;
-            std::memcpy(&slot,
-                        blob.data() + characterFlagMapRows.dataOffset
-                            + static_cast<std::size_t>(row) * tables::kUnlockMapRowStride
-                            + tables::kUnlockMapDestinationSlotOffset,
-                        sizeof slot);
-            characterFlagIndexBySlot.emplace(slot, static_cast<std::uint16_t>(row));
+/**
+ * Resolves one node's lore-book flag and the value slot its parent bar reads.
+ * A book's parent triumph is the one child record that displays no lore. Its first objective
+ * names the slot the book's collected-chapter bar counts in.
+ * @param storage Pass storage holding the record rows and the objective bank.
+ * @param node Node whose children were already read.
+ * @param parentSlot Receives the parent bar's raw value slot, or -1 when the book has none.
+ * @return True when at least one child record displays lore, which is what makes a book.
+ */
+[[nodiscard]] bool resolve_book(const Storage& storage,
+                                const domain::Definition& node,
+                                std::int16_t& parentSlot) noexcept {
+    parentSlot = -1;
+    bool book = false;
+    for (std::size_t child = 0; child < node.childCount; ++child) {
+        const std::size_t row = node.children[child];
+        if (row >= storage.recordCount) {
+            continue;
+        }
+        const record_domain::Definition& record = storage.recordRows[row];
+        if (record.loreRow != record_domain::kUnavailableLoreRow) {
+            book = true;
+            continue;
+        }
+        for (std::size_t entry = 0; entry < record.objectiveCount; ++entry) {
+            const std::size_t at = static_cast<std::size_t>(record.objectiveOffset) + entry;
+            if (at < storage.recordObjectiveCount
+                && storage.recordObjectives[at].sourceValueSlot >= 0) {
+                parentSlot = storage.recordObjectives[at].sourceValueSlot;
+            }
         }
     }
+    return book;
+}
+
+} // namespace
+
+/** Reads nodes and resolves their value slots, owned records and lore parent bars. */
+bool build_nodes(const reader::Source& source,
+                 Storage& storage,
+                 std::span<const std::byte> root) noexcept {
+    storage.nodeCount = 0;
+    clear_slot_maps(g_slotMaps);
+
+    // A gate names a flag slot, not an index. The index is the mapping row whose destination is
+    // that slot.
+    std::uint32_t flagMapTag = 0;
+    if (tables::slot_tag(root, tables::kUnlockFlagMapTableSlot, flagMapTag) && flagMapTag != 0
+        && tables::package_of(flagMapTag) != tables::kAbsentPackageId
+        && reader::read_tag(source, storage.scratch, flagMapTag, storage.child)) {
+        const std::span<const std::byte> blob{storage.child};
+        (void)read_slot_map(blob, tables::kAccountFlagMapDescriptor, g_slotMaps.accountFlag);
+        (void)read_slot_map(blob, tables::kCharacterFlagMapDescriptor, g_slotMaps.characterFlag);
+    }
 
     std::uint32_t mapTag = 0;
-    tables::Array mapRows{};
     if (!tables::slot_tag(root, tables::kUnlockValueMapTableSlot, mapTag) || mapTag == 0
         || tables::package_of(mapTag) == tables::kAbsentPackageId
-        || !reader::read_tag(source, scratch, mapTag, blob)
-        || !tables::find_array_at(std::span<const std::byte>{blob},
-                                  tables::kAccountValueMapDescriptor,
-                                  mapRows)
-        || mapRows.count == 0
-        || mapRows.dataOffset
-                   + static_cast<std::size_t>(mapRows.count) * tables::kUnlockMapRowStride
-               > blob.size()) {
+        || !reader::read_tag(source, storage.scratch, mapTag, storage.child)) {
         report("value_map_fail", mapTag);
         return false;
     }
-    std::unordered_map<std::int16_t, std::uint16_t> indexBySlot{};
-    for (std::uint64_t row = 0; row < mapRows.count && row <= domain::kUnavailableValueIndex;
-         ++row) {
-        const std::size_t at =
-            mapRows.dataOffset + static_cast<std::size_t>(row) * tables::kUnlockMapRowStride;
-        std::int16_t slot = 0;
-        std::memcpy(&slot, blob.data() + at + tables::kUnlockMapDestinationSlotOffset, sizeof slot);
-        indexBySlot.emplace(slot, static_cast<std::uint16_t>(row));
-    }
-
-
-    tables::Array characterValueMapRows{};
-    std::unordered_map<std::int16_t, std::uint16_t> characterValueIndexBySlot{};
-    if (tables::find_array_at(std::span<const std::byte>{blob},
-                              tables::kCharacterValueMapDescriptor,
-                              characterValueMapRows)
-        && characterValueMapRows.count != 0
-        && characterValueMapRows.dataOffset
-                   + static_cast<std::size_t>(characterValueMapRows.count)
-                         * tables::kUnlockMapRowStride
-               <= blob.size()) {
-        for (std::uint64_t row = 0;
-             row < characterValueMapRows.count && row <= domain::kUnavailableValueIndex; ++row) {
-            std::int16_t slot = 0;
-            std::memcpy(&slot,
-                        blob.data() + characterValueMapRows.dataOffset
-                            + static_cast<std::size_t>(row) * tables::kUnlockMapRowStride
-                            + tables::kUnlockMapDestinationSlotOffset,
-                        sizeof slot);
-            characterValueIndexBySlot.emplace(slot, static_cast<std::uint16_t>(row));
-        }
+    const std::span<const std::byte> valueMap{storage.child};
+    const bool valueMapRead =
+        read_slot_map(valueMap, tables::kAccountValueMapDescriptor, g_slotMaps.accountValue);
+    (void)read_slot_map(valueMap, tables::kCharacterValueMapDescriptor, g_slotMaps.characterValue);
+    if (!valueMapRead) {
+        report("value_map_fail", mapTag);
+        return false;
     }
 
     std::uint32_t tableTag = 0;
     tables::Array rows{};
     if (!tables::slot_tag(root, tables::kPresentationNodeTableSlot, tableTag) || tableTag == 0
         || tables::package_of(tableTag) == tables::kAbsentPackageId
-        || !reader::read_tag(source, scratch, tableTag, blob)
+        || !reader::read_tag(source, storage.scratch, tableTag, storage.child)
         || !tables::find_array_at(
-            std::span<const std::byte>{blob}, tables::kTableArrayDescriptor, rows)
-        || rows.count == 0 || rows.count > output.size()
+            std::span<const std::byte>{storage.child}, tables::kTableArrayDescriptor, rows)
+        || rows.count == 0 || rows.count > storage.nodeRows.size()
         || rows.dataOffset + static_cast<std::size_t>(rows.count) * tables::kNodeRowStride
-               > blob.size()) {
+               > storage.child.size()) {
         report("node_table_fail", tableTag);
         return false;
     }
 
-    const std::span<const std::byte> table{blob};
-    std::size_t driving = 0;
+    const std::span<const std::byte> table{storage.child};
+    std::size_t books = 0;
 
-    // Pass 1: resolve every node's own value slot and children.
     for (std::uint64_t row = 0; row < rows.count; ++row) {
         const std::size_t at =
             rows.dataOffset + static_cast<std::size_t>(row) * tables::kNodeRowStride;
-        domain::Definition& definition = output[static_cast<std::size_t>(row)];
+        domain::Definition& definition = storage.nodeRows[static_cast<std::size_t>(row)];
         definition = {};
         definition.definitionIndex = static_cast<std::uint16_t>(row);
 
@@ -168,49 +197,35 @@ bool build_nodes(const reader::Source& source,
         std::int16_t slot = 0;
         const bool named =
             tables::expression_value_slot(table, at, tables::kNodeExpressionFieldPrimary, slot)
-            || tables::expression_value_slot(table, at, tables::kNodeExpressionFieldAlternate, slot);
+            || tables::expression_value_slot(
+                table, at, tables::kNodeExpressionFieldAlternate, slot);
         if (named) {
             definition.valueSlot = slot;
-            const auto found = indexBySlot.find(slot);
-            if (found != indexBySlot.end()) {
-                definition.valueIndex = found->second;
-            }
-            // The same expression may resolve in the character scope: one lore book's bar reads a
-            // slot only the character table carries, and its parent has to be fed there too.
+            definition.valueIndex = bank_index(g_slotMaps.accountValue, slot);
+            // One book's bar reads a slot only the character table carries, so both scopes resolve.
             definition.characterValueSlot = slot;
-            const auto character_resolved = characterValueIndexBySlot.find(slot);
-            if (character_resolved != characterValueIndexBySlot.end()) {
-                definition.characterValueIndex = character_resolved->second;
-            }
+            definition.characterValueIndex = bank_index(g_slotMaps.characterValue, slot);
         }
 
-        // A category gated on a flag rather than on its own progress cannot reveal itself by being
-        // played: with no title shown there is nothing inside to claim, and nothing to claim leaves
-        // the gate shut. Resolve that flag so the gate can be satisfied.
+        // A category gated on a flag never opens from progress alone, so resolve that flag.
         std::int16_t gateSlot = 0;
         if (tables::expression_flag_slot(table, at, tables::kNodeExpressionFieldPrimary, gateSlot)
-            || tables::expression_flag_slot(table, at, tables::kNodeExpressionFieldAlternate, gateSlot)) {
-            const auto gate = flagIndexBySlot.find(gateSlot);
-            if (gate != flagIndexBySlot.end()) {
-                definition.visibilityFlagIndex = gate->second;
-            }
-            const auto characterGate = characterFlagIndexBySlot.find(gateSlot);
-            if (characterGate != characterFlagIndexBySlot.end()) {
-                definition.visibilityCharacterFlagIndex = characterGate->second;
-            }
+            || tables::expression_flag_slot(
+                table, at, tables::kNodeExpressionFieldAlternate, gateSlot)) {
+            definition.visibilityFlagIndex = bank_index(g_slotMaps.accountFlag, gateSlot);
+            definition.visibilityCharacterFlagIndex =
+                bank_index(g_slotMaps.characterFlag, gateSlot);
         }
 
-
-
         // Records the node owns, four bytes each as a row and a gate.
         std::int64_t childCount = 0;
         std::int64_t childRelative = 0;
-        std::memcpy(&childCount, table.data() + at + tables::kNodeChildRecordField,
-                    sizeof childCount);
-        std::memcpy(&childRelative, table.data() + at + tables::kNodeChildRecordField + 8,
-                    sizeof childRelative);
+        const std::size_t pointerAt =
+            at + tables::kNodeChildRecordField + tables::kUnlockExpressionPointerOffset;
+        std::memcpy(
+            &childCount, table.data() + at + tables::kNodeChildRecordField, sizeof childCount);
+        std::memcpy(&childRelative, table.data() + pointerAt, sizeof childRelative);
         if (childCount >= 1 && childCount <= static_cast<std::int64_t>(domain::kChildCapacity)) {
-            const std::size_t pointerAt = at + tables::kNodeChildRecordField + 8;
             const std::int64_t target = static_cast<std::int64_t>(pointerAt) + childRelative
                                         + static_cast<std::int64_t>(tables::kHeaderSkip);
             if (target >= 0
@@ -230,85 +245,22 @@ bool build_nodes(const reader::Source& source,
                 }
             }
         }
-        if (definition.childCount != 0 && definition.valueIndex != domain::kUnavailableValueIndex) {
-            ++driving;
-        }
-        ++count;
-    }
 
-    // Pass 2: assign each lore book's parent-record bar slot from the shipped allocation.
-    //
-    // The naive rule (parent = category slot + 1) holds only when the slot above a category was
-    // free at allocation time. Categories were handed out in contiguous runs, and a run's parent
-    // slots were deferred to immediately after the run, assigned in reverse category order: the
-    // run's first book takes the last parent slot, its last book takes the first. Verified against
-    // four independent in-game marker readings; see kNodeParentSlotStep in definition_index_table.h.
-    //
-    // Both scopes are walked. Most books are account-scoped; one reads its category from the
-    // character table, and its parent sits in that scope too.
-    {
-        struct BookSlot {
-            std::int32_t slot;
-            std::size_t row;
-        };
-        std::vector<BookSlot> accountBooks;
-        std::vector<BookSlot> characterBooks;
-        for (std::size_t row = 0; row < count; ++row) {
-            const auto& d = output[row];
-            if (!domain::lore_category(d.definitionIndex)) {
-                continue;
-            }
-            if (d.valueSlot >= 0) {
-                accountBooks.push_back({d.valueSlot, row});
-            }
-            if (d.characterValueSlot >= 0) {
-                characterBooks.push_back({d.characterValueSlot, row});
-            }
+        // A lore book and its parent bar are read from the records the node owns, never from the
+        // order the value bank happens to allocate its slots in.
+        std::int16_t parentSlot = -1;
+        definition.loreBook = resolve_book(storage, definition, parentSlot);
+        if (definition.loreBook && parentSlot >= 0) {
+            definition.parentValueIndex = bank_index(g_slotMaps.accountValue, parentSlot);
+            definition.parentCharacterValueIndex =
+                bank_index(g_slotMaps.characterValue, parentSlot);
         }
-
-        auto assignRuns = [&](std::vector<BookSlot>& books,
-                              const std::unordered_map<std::int16_t, std::uint16_t>& indexBySlot,
-                              auto memberSetter) {
-            std::sort(books.begin(), books.end(),
-                      [](const BookSlot& a, const BookSlot& b) { return a.slot < b.slot; });
-            std::size_t i = 0;
-            while (i < books.size()) {
-                std::size_t j = i;
-                while (j + 1 < books.size() && books[j + 1].slot == books[j].slot + 1) {
-                    ++j;
-                }
-                const std::size_t runLength = j - i + 1;
-                const std::int32_t runEnd = books[j].slot;
-                for (std::size_t k = 0; k < runLength; ++k) {
-                    // Reverse order within the run: book k gets the slot after the run counting
-                    // back from its end.
-                    const std::int32_t parentSlot =
-                        runEnd + static_cast<std::int32_t>(runLength - k);
-                    const auto found = indexBySlot.find(static_cast<std::int16_t>(parentSlot));
-                    if (found != indexBySlot.end()) {
-                        memberSetter(output[books[i + k].row], found->second);
-                    }
-                }
-                i = j + 1;
-            }
-        };
-
-        assignRuns(accountBooks,
-                   indexBySlot,
-                   [](domain::Definition& d, std::uint16_t index) {
-                       d.parentValueIndex = index;
-                   });
-        assignRuns(characterBooks,
-                   characterValueIndexBySlot,
-                   [](domain::Definition& d, std::uint16_t index) {
-                       d.parentCharacterValueIndex = index;
-                   });
+        books += definition.loreBook ? 1U : 0U;
+        ++storage.nodeCount;
     }
 
-    report("ok", static_cast<unsigned long long>(driving));
-
-
-    return count != 0;
+    report("ok", static_cast<unsigned long long>(books));
+    return storage.nodeCount != 0;
 }
 
 } // namespace sunrise::client::content::items::packages

+ 45 - 11
Sunrise/src/client/content/items/packages/package_progression_build.cpp

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

+ 332 - 103
Sunrise/src/client/content/items/packages/package_record_build.cpp

@@ -1,16 +1,18 @@
 #include <array>
 #include <cstdio>
 #include <cstring>
+#include <limits>
 
 #include "../../../../core/logging/log.h"
-
-#include "../../../../state/build_data/runtime.h"
 #include "../../../../middleware/content/packages/tables/unlock_expression.h"
+#include "../../../../state/build_data/runtime.h"
 #include "internal.h"
 
 namespace sunrise::client::content::items::packages {
 namespace {
 
+namespace domain = state::build_data::records;
+
 /** Reports where the record pass stopped, so a silent miss cannot look like a working claim. */
 void report(const char* stage, unsigned long long detail) noexcept {
     std::array<char, 128> line{};
@@ -28,150 +30,377 @@ void report(const char* stage, unsigned long long detail) noexcept {
     return slot > 0;
 }
 
+/** A slot is a signed 16-bit value, so the widest addressable slot space is this. */
+constexpr std::size_t kSlotSpace = 32768;
+
+/** A record score is held as 16 bits. */
+constexpr std::uint32_t kScoreCeiling = 0xFFFFU;
+
+/** Unlock slot to bank mapping row, indexed by slot. The first mapping row of a slot wins. */
+using SlotMap = std::vector<std::uint16_t>;
+
+/**
+ * Builds one slot-to-bank-index map from an unlock mapping table.
+ * @param blob Blob holding the mapping table.
+ * @param descriptor Array descriptor of the mapping table inside that blob.
+ * @param output Receives one bank index per addressable slot, or the unavailable index.
+ * @return True when the table resolves and every row fits the blob.
+ */
+[[nodiscard]] bool
+build_slot_map(std::span<const std::byte> blob, std::size_t descriptor, SlotMap& output) noexcept {
+    output.clear();
+    tables::Array rows{};
+    if (!tables::find_array_at(blob, descriptor, rows) || rows.count == 0
+        || rows.dataOffset + static_cast<std::size_t>(rows.count) * tables::kUnlockMapRowStride
+               > blob.size()) {
+        return false;
+    }
+    output.assign(kSlotSpace, domain::kUnavailableValueIndex);
+    for (std::uint64_t row = 0; row < rows.count && row <= domain::kUnavailableValueIndex; ++row) {
+        std::int16_t slot = 0;
+        std::memcpy(&slot,
+                    blob.data() + rows.dataOffset
+                        + static_cast<std::size_t>(row) * tables::kUnlockMapRowStride
+                        + tables::kUnlockMapDestinationSlotOffset,
+                    sizeof slot);
+        if (!addressable_slot(slot)) {
+            continue;
+        }
+        std::uint16_t& existing = output[static_cast<std::size_t>(slot)];
+        if (existing == domain::kUnavailableValueIndex) {
+            existing = static_cast<std::uint16_t>(row);
+        }
+    }
+    return true;
+}
+
+/** @param map Slot map. @param slot Raw unlock slot. @return Bank index, or the unavailable one. */
+[[nodiscard]] std::uint16_t bank_index(const SlotMap& map, std::int32_t slot) noexcept {
+    if (map.empty() || slot <= 0 || static_cast<std::size_t>(slot) >= kSlotSpace) {
+        return domain::kUnavailableValueIndex;
+    }
+    return map[static_cast<std::size_t>(slot)];
+}
+
+/** One located inline array of a definition row. */
+struct RowArray {
+    std::size_t dataOffset{};
+    std::uint64_t count{};
+};
+
+/**
+ * Locates one inline array a row declares, and rejects a class or a length the row cannot own.
+ * @param blob Blob holding the row.
+ * @param at Row offset.
+ * @param field Array descriptor offset inside the row.
+ * @param elementClass Element class the array header must carry.
+ * @param stride One element's size.
+ * @param output Receives the located array; count zero when the row declares none.
+ * @return True when the array is absent or ends inside the blob with that class.
+ */
+[[nodiscard]] bool row_array(std::span<const std::byte> blob,
+                             std::size_t at,
+                             std::size_t field,
+                             std::uint32_t elementClass,
+                             std::size_t stride,
+                             RowArray& output) noexcept {
+    output = {};
+    tables::Array array{};
+    if (!tables::find_optional_array_at(blob, at + field, array)) {
+        return false;
+    }
+    if (array.count == 0) {
+        return true;
+    }
+    if (array.elementClass != elementClass
+        || array.dataOffset + static_cast<std::size_t>(array.count) * stride > blob.size()) {
+        return false;
+    }
+    output = {array.dataOffset, array.count};
+    return true;
+}
+
+/** Reads the completion threshold of one objective row. */
+[[nodiscard]] bool objective_threshold(std::span<const std::byte> blob,
+                                       const tables::Array& objectiveRows,
+                                       std::uint64_t objectiveRow,
+                                       std::int32_t& threshold) noexcept {
+    threshold = 0;
+    if (objectiveRow >= objectiveRows.count) {
+        return false;
+    }
+    const std::size_t at = objectiveRows.dataOffset
+                           + static_cast<std::size_t>(objectiveRow) * tables::kObjectiveRowStride;
+    std::memcpy(
+        &threshold, blob.data() + at + tables::kObjectiveCompletionValueOffset, sizeof threshold);
+    return true;
+}
+
+/** Reads the unlock value slot one objective reads its progress from, or -1 when it names none. */
+[[nodiscard]] std::int16_t objective_source_slot(std::span<const std::byte> blob,
+                                                 const tables::Array& objectiveRows,
+                                                 std::uint64_t objectiveRow) noexcept {
+    if (objectiveRow >= objectiveRows.count) {
+        return -1;
+    }
+    const std::size_t at = objectiveRows.dataOffset
+                           + static_cast<std::size_t>(objectiveRow) * tables::kObjectiveRowStride;
+    std::int16_t slot = 0;
+    return tables::expression_value_slot(blob, at, tables::kObjectiveSourceExpressionField, slot)
+               ? slot
+               : static_cast<std::int16_t>(-1);
+}
 
 } // namespace
 
 /**
- * Reads the records table and resolves each record's completion flag to a bank index.
- *
- * A record row carries the unlock slot of its completion flag, and a slot is not an array index:
- * the byte that feeds a slot sits at the row number of the mapping table whose destination is that
- * slot. Both tables are walked here so a claim can go straight from a record row to the index it
- * has to set.
+ * Reads the records table and resolves each record's flags, objectives, intervals and rewards.
+ * A row names an unlock slot, not an index; the index is the mapping row that feeds that slot.
+ * @param source Installed package source.
+ * @param storage Pass storage receiving the record rows and all three flat banks.
+ * @param root Investment root bytes.
+ * @return True when the record table read and produced at least one row.
  */
 bool build_records(const reader::Source& source,
-                   reader::Scratch& scratch,
-                   std::span<const std::byte> root,
-                   std::vector<std::byte>& blob,
-                   std::span<state::build_data::records::Definition> output,
-                   std::size_t& count) noexcept {
-    namespace domain = state::build_data::records;
-    count = 0;
+                   Storage& storage,
+                   std::span<const std::byte> root) noexcept {
+    storage.recordCount = 0;
+    storage.recordObjectiveCount = 0;
+    storage.recordIntervalCount = 0;
+    storage.recordRewardCount = 0;
 
     // The account flag mapping table, read first because the record rows are matched against it.
     std::uint32_t mapTag = 0;
-    tables::Array mapRows{};
+    SlotMap flagIndexBySlot{};
     if (!tables::slot_tag(root, tables::kUnlockFlagMapTableSlot, mapTag) || mapTag == 0
         || tables::package_of(mapTag) == tables::kAbsentPackageId
-        || !reader::read_tag(source, scratch, mapTag, blob)
-        || !tables::find_array_at(std::span<const std::byte>{blob},
-                                  tables::kAccountFlagMapDescriptor,
-                                  mapRows)
-        || mapRows.count == 0
-        || mapRows.dataOffset
-                   + static_cast<std::size_t>(mapRows.count) * tables::kUnlockMapRowStride
-               > blob.size()) {
+        || !reader::read_tag(source, storage.scratch, mapTag, storage.child)
+        || !build_slot_map(std::span<const std::byte>{storage.child},
+                           tables::kAccountFlagMapDescriptor,
+                           flagIndexBySlot)) {
         report("flag_map_fail", mapTag);
         return false;
     }
 
-    // Destination slot to mapping row. The first row wins, matching how a bank is addressed.
-    constexpr std::size_t kSlotSpace = 32768;
-    static_assert(domain::kUnavailableFlagIndex == 0xFFFFU);
-    std::vector<std::uint16_t> indexBySlot{};
-    indexBySlot.assign(kSlotSpace, domain::kUnavailableFlagIndex);
-    for (std::uint64_t row = 0; row < mapRows.count; ++row) {
-        const std::size_t at =
-            mapRows.dataOffset + static_cast<std::size_t>(row) * tables::kUnlockMapRowStride;
-        std::int16_t slot = 0;
-        std::memcpy(&slot, blob.data() + at + tables::kUnlockMapDestinationSlotOffset, sizeof slot);
-        if (!addressable_slot(slot) || static_cast<std::size_t>(slot) >= kSlotSpace
-            || row > domain::kUnavailableFlagIndex) {
-            continue;
-        }
-        std::uint16_t& existing = indexBySlot[static_cast<std::size_t>(slot)];
-        if (existing == domain::kUnavailableFlagIndex) {
-            existing = static_cast<std::uint16_t>(row);
-        }
+    // The account value mapping table. A record names value slots for its category, its
+    // redeemed-interval count and each objective's progress source; all become bank indices here.
+    std::uint32_t valueMapTag = 0;
+    SlotMap valueIndexBySlot{};
+    if (!tables::slot_tag(root, tables::kUnlockValueMapTableSlot, valueMapTag) || valueMapTag == 0
+        || tables::package_of(valueMapTag) == tables::kAbsentPackageId
+        || !reader::read_tag(source, storage.scratch, valueMapTag, storage.child)
+        || !build_slot_map(std::span<const std::byte>{storage.child},
+                           tables::kAccountValueMapDescriptor,
+                           valueIndexBySlot)) {
+        report("value_map_fail", valueMapTag);
+        return false;
     }
 
-    // The account value mapping table, read while the blob is still free. A record names its
-    // category's value slot, and that slot has to become an index the same way a flag slot does.
-    std::uint32_t valueMapTag = 0;
-    tables::Array valueMapRows{};
-    std::vector<std::uint16_t> valueIndexBySlot{};
-    if (tables::slot_tag(root, tables::kUnlockValueMapTableSlot, valueMapTag) && valueMapTag != 0
-        && tables::package_of(valueMapTag) != tables::kAbsentPackageId
-        && reader::read_tag(source, scratch, valueMapTag, blob)
-        && tables::find_array_at(std::span<const std::byte>{blob},
-                                 tables::kAccountValueMapDescriptor,
-                                 valueMapRows)
-        && valueMapRows.count != 0
-        && valueMapRows.dataOffset
-                   + static_cast<std::size_t>(valueMapRows.count) * tables::kUnlockMapRowStride
-               <= blob.size()) {
-        valueIndexBySlot.assign(kSlotSpace, domain::kUnavailableValueIndex);
-        for (std::uint64_t row = 0; row < valueMapRows.count; ++row) {
-            std::int16_t slot = 0;
-            std::memcpy(&slot,
-                        blob.data() + valueMapRows.dataOffset
-                            + static_cast<std::size_t>(row) * tables::kUnlockMapRowStride
-                            + tables::kUnlockMapDestinationSlotOffset,
-                        sizeof slot);
-            if (!addressable_slot(slot) || static_cast<std::size_t>(slot) >= kSlotSpace
-                || row > domain::kUnavailableValueIndex) {
-                continue;
-            }
-            std::uint16_t& existing = valueIndexBySlot[static_cast<std::size_t>(slot)];
-            if (existing == domain::kUnavailableValueIndex) {
-                existing = static_cast<std::uint16_t>(row);
-            }
-        }
+    // The objective table carries every threshold and progress source a record names.
+    std::uint32_t objectiveTag = 0;
+    tables::Array objectiveRows{};
+    if (!tables::slot_tag(root, tables::kObjectiveTableSlot, objectiveTag) || objectiveTag == 0
+        || tables::package_of(objectiveTag) == tables::kAbsentPackageId
+        || !reader::read_tag(source, storage.scratch, objectiveTag, storage.objectiveTable)
+        || !tables::find_array_at(std::span<const std::byte>{storage.objectiveTable},
+                                  tables::kTableArrayDescriptor,
+                                  objectiveRows)
+        || objectiveRows.elementClass != tables::kObjectiveRowClass
+        || objectiveRows.dataOffset
+                   + static_cast<std::size_t>(objectiveRows.count) * tables::kObjectiveRowStride
+               > storage.objectiveTable.size()) {
+        report("objective_table_fail", objectiveTag);
+        return false;
     }
+    const std::span<const std::byte> objectiveTable{storage.objectiveTable};
+
+    // Reward rows sit on the display half, whose rows are in record order.
+    std::uint32_t displayClass = 0;
+    tables::Array displayRows{};
+    if (!reader::read_tag(source,
+                          storage.scratch,
+                          tables::kRecordDisplayTableTag,
+                          storage.displayTable,
+                          displayClass)
+        || !tables::find_array_at(std::span<const std::byte>{storage.displayTable},
+                                  tables::kTableArrayDescriptor,
+                                  displayRows)
+        || displayRows.elementClass != tables::kRecordDisplayRowClass
+        || displayRows.dataOffset
+                   + static_cast<std::size_t>(displayRows.count) * tables::kRecordDisplayRowStride
+               > storage.displayTable.size()) {
+        report("display_table_fail", tables::kRecordDisplayTableTag);
+        return false;
+    }
+    const std::span<const std::byte> displayTable{storage.displayTable};
 
     std::uint32_t tableTag = 0;
     tables::Array rows{};
     if (!tables::slot_tag(root, tables::kRecordTableSlot, tableTag) || tableTag == 0
         || tables::package_of(tableTag) == tables::kAbsentPackageId
-        || !reader::read_tag(source, scratch, tableTag, blob)
+        || !reader::read_tag(source, storage.scratch, tableTag, storage.child)
         || !tables::find_array_at(
-            std::span<const std::byte>{blob}, tables::kTableArrayDescriptor, rows)
-        || rows.count == 0 || rows.count > output.size()
+            std::span<const std::byte>{storage.child}, tables::kTableArrayDescriptor, rows)
+        || rows.count == 0 || rows.count > storage.recordRows.size()
+        || rows.count > displayRows.count
         || rows.dataOffset + static_cast<std::size_t>(rows.count) * tables::kRecordRowStride
-               > blob.size()) {
+               > storage.child.size()) {
         report("record_table_fail", tableTag);
         return false;
     }
+    const std::span<const std::byte> table{storage.child};
 
+    // The client places record objective values in one run, in record row order, so the base
+    // advances by every record's reserved count whether or not the record names objective rows.
+    std::uint32_t valueIndex = domain::kObjectiveValueIndexBase;
     for (std::uint64_t row = 0; row < rows.count; ++row) {
         const std::size_t at =
             rows.dataOffset + static_cast<std::size_t>(row) * tables::kRecordRowStride;
-        std::int16_t slot = 0;
-        std::memcpy(&slot,
-                    blob.data() + at + tables::kRecordCompletionFlagOffset,
-                    sizeof slot);
-        std::uint32_t score = 0;
-        std::memcpy(&score, blob.data() + at + tables::kRecordScoreOffset, sizeof score);
-        domain::Definition& definition = output[static_cast<std::size_t>(row)];
+        const std::size_t displayAt =
+            displayRows.dataOffset
+            + static_cast<std::size_t>(row) * tables::kRecordDisplayRowStride;
+        domain::Definition& definition = storage.recordRows[static_cast<std::size_t>(row)];
         definition = {};
         definition.definitionIndex = static_cast<std::uint16_t>(row);
-        std::memcpy(&definition.definitionHash, blob.data() + at + tables::kRecordHashOffset,
+        definition.objectiveValueIndex = static_cast<std::uint16_t>(valueIndex);
+        definition.objectiveOffset = static_cast<std::uint16_t>(storage.recordObjectiveCount);
+        definition.intervalOffset = static_cast<std::uint16_t>(storage.recordIntervalCount);
+        definition.rewardOffset = static_cast<std::uint16_t>(storage.recordRewardCount);
+        std::memcpy(&definition.definitionHash,
+                    table.data() + at + tables::kRecordHashOffset,
                     sizeof definition.definitionHash);
         // The lore row this record displays, or 0xFFFF for a book's parent triumph.
-        std::memcpy(&definition.loreRow, blob.data() + at + tables::kLoreRowOffset,
+        std::memcpy(&definition.loreRow,
+                    table.data() + at + tables::kLoreRowOffset,
                     sizeof definition.loreRow);
-        // The shipped table tops out at 500, so anything wider is not a score and is dropped.
-        definition.scoreValue = score <= 0xFFFFU ? static_cast<std::uint16_t>(score) : 0U;
+        std::uint32_t score = 0;
+        std::memcpy(&score, table.data() + at + tables::kRecordScoreOffset, sizeof score);
+        // The score is held as 16 bits, so a wider field is not a score and is dropped.
+        definition.scoreValue = score <= kScoreCeiling ? static_cast<std::uint16_t>(score) : 0U;
         std::uint32_t hasTitle = 0;
-        std::memcpy(&hasTitle, blob.data() + at + tables::kRecordHasTitleOffset, sizeof hasTitle);
+        std::memcpy(&hasTitle, table.data() + at + tables::kRecordHasTitleOffset, sizeof hasTitle);
         definition.hasTitle = hasTitle != 0;
+        std::int16_t flagSlot = 0;
+        std::memcpy(
+            &flagSlot, table.data() + at + tables::kRecordCompletionFlagOffset, sizeof flagSlot);
+        definition.completionFlagIndex = bank_index(flagIndexBySlot, flagSlot);
         std::int16_t categorySlot = 0;
-        if (!valueIndexBySlot.empty()
-            && tables::expression_value_slot(std::span<const std::byte>{blob},
-                                     at,
-                                     tables::kRecordCategoryExpressionField,
-                                     categorySlot)
-            && addressable_slot(categorySlot)
-            && static_cast<std::size_t>(categorySlot) < kSlotSpace) {
-            definition.categoryValueIndex = valueIndexBySlot[static_cast<std::size_t>(categorySlot)];
+        if (tables::expression_value_slot(
+                table, at, tables::kRecordCategoryExpressionField, categorySlot)) {
+            definition.categoryValueIndex = bank_index(valueIndexBySlot, categorySlot);
         }
-        if (addressable_slot(slot) && static_cast<std::size_t>(slot) < kSlotSpace) {
-            definition.completionFlagIndex = indexBySlot[static_cast<std::size_t>(slot)];
+        std::uint16_t redeemedSlot = 0;
+        std::memcpy(&redeemedSlot,
+                    table.data() + at + tables::kRecordRedeemedCountSlotOffset,
+                    sizeof redeemedSlot);
+        definition.redeemedCountValueIndex = bank_index(valueIndexBySlot, redeemedSlot);
+
+        RowArray objectives{};
+        RowArray intervals{};
+        RowArray rewards{};
+        if (!row_array(table,
+                       at,
+                       tables::kRecordObjectiveField,
+                       tables::kRecordObjectiveRowClass,
+                       tables::kRecordObjectiveStride,
+                       objectives)
+            || !row_array(table,
+                          at,
+                          tables::kRecordIntervalField,
+                          tables::kRecordIntervalRowClass,
+                          tables::kRecordIntervalStride,
+                          intervals)
+            || !row_array(displayTable,
+                          displayAt,
+                          tables::kRecordRewardField,
+                          tables::kRecordRewardRowClass,
+                          tables::kRecordRewardStride,
+                          rewards)
+            || objectives.count > (std::numeric_limits<std::uint8_t>::max)()
+            || intervals.count > domain::kIntervalPerRecordCapacity
+            || rewards.count > domain::kRewardPerRecordCapacity
+            || objectives.count > domain::kObjectiveCapacity - storage.recordObjectiveCount
+            || intervals.count > domain::kIntervalCapacity - storage.recordIntervalCount
+            || rewards.count > domain::kRewardCapacity - storage.recordRewardCount) {
+            report("record_row_fail", row);
+            return false;
         }
-        ++count;
+
+        for (std::uint64_t entry = 0; entry < objectives.count; ++entry) {
+            std::uint16_t objectiveRow = 0;
+            std::memcpy(&objectiveRow,
+                        table.data() + objectives.dataOffset
+                            + static_cast<std::size_t>(entry) * tables::kRecordObjectiveStride,
+                        sizeof objectiveRow);
+            domain::Objective& objective = storage.recordObjectives[storage.recordObjectiveCount];
+            objective = {};
+            if (!objective_threshold(
+                    objectiveTable, objectiveRows, objectiveRow, objective.completionValue)) {
+                report("objective_row_fail", objectiveRow);
+                return false;
+            }
+            objective.valueIndex = static_cast<std::uint16_t>(valueIndex + entry);
+            objective.sourceValueSlot =
+                objective_source_slot(objectiveTable, objectiveRows, objectiveRow);
+            objective.sourceValueIndex = bank_index(valueIndexBySlot, objective.sourceValueSlot);
+            ++storage.recordObjectiveCount;
+        }
+
+        for (std::uint64_t entry = 0; entry < intervals.count; ++entry) {
+            const std::size_t stepAt =
+                intervals.dataOffset
+                + static_cast<std::size_t>(entry) * tables::kRecordIntervalStride;
+            std::uint32_t objectiveRow = 0;
+            std::memcpy(&objectiveRow,
+                        table.data() + stepAt + tables::kRecordIntervalObjectiveRowOffset,
+                        sizeof objectiveRow);
+            domain::Interval& interval = storage.recordIntervals[storage.recordIntervalCount];
+            interval = {};
+            if (!objective_threshold(
+                    objectiveTable, objectiveRows, objectiveRow, interval.completionValue)) {
+                report("interval_row_fail", objectiveRow);
+                return false;
+            }
+            std::memcpy(&interval.score,
+                        table.data() + stepAt + tables::kRecordIntervalScoreOffset,
+                        sizeof interval.score);
+            std::memcpy(&interval.itemIndex,
+                        table.data() + stepAt + tables::kRecordIntervalItemIndexOffset,
+                        sizeof interval.itemIndex);
+            ++storage.recordIntervalCount;
+        }
+
+        for (std::uint64_t entry = 0; entry < rewards.count; ++entry) {
+            const std::size_t rewardAt =
+                rewards.dataOffset + static_cast<std::size_t>(entry) * tables::kRecordRewardStride;
+            std::uint32_t quantity = 0;
+            std::memcpy(&quantity,
+                        displayTable.data() + rewardAt + tables::kRecordRewardQuantityOffset,
+                        sizeof quantity);
+            std::uint32_t itemIndex = 0;
+            std::memcpy(&itemIndex,
+                        displayTable.data() + rewardAt + tables::kRecordRewardItemIndexOffset,
+                        sizeof itemIndex);
+            // A row the grant path cannot honour is dropped, not published as a zero grant.
+            if (quantity == 0
+                || quantity > static_cast<std::uint32_t>((std::numeric_limits<std::int32_t>::max)())
+                || itemIndex > domain::kUnavailableItemIndex) {
+                continue;
+            }
+            storage.recordRewards[storage.recordRewardCount] = {
+                static_cast<std::int32_t>(quantity), static_cast<std::uint16_t>(itemIndex)};
+            ++storage.recordRewardCount;
+            ++definition.rewardCount;
+        }
+
+        definition.objectiveCount = static_cast<std::uint8_t>(objectives.count);
+        definition.intervalCount = static_cast<std::uint8_t>(intervals.count);
+        valueIndex += definition.objectiveCount != 0 ? definition.objectiveCount
+                                                     : domain::kReservedObjectiveValueCount;
+        ++storage.recordCount;
     }
-    report("ok", static_cast<unsigned long long>(count));
-    return count != 0;
+    report("ok", static_cast<unsigned long long>(storage.recordCount));
+    return storage.recordCount != 0;
 }
 
 } // namespace sunrise::client::content::items::packages

+ 1 - 3
Sunrise/src/client/content/items/packages/package_root_tables.cpp

@@ -58,9 +58,7 @@ bool collect_bucket_definition_tag(void* context, std::uint32_t tag) noexcept {
 
 /**
  * Locates the bucket-definition table by the class its entry record carries.
- * A tag is a package-local handle that a repack moves, so naming one pins the extraction to a
- * single install. The class belongs to the definition ABI, so it names the table wherever it was
- * packed. Entry tables are plain file data, so the sweep needs no block keys.
+ * A repack moves the tag but not the class, so search by class. Entry tables need no block keys.
  * @param source Package source.
  * @param tag Receives the located tag.
  * @return True when exactly one installed entry carries the class.

+ 219 - 0
Sunrise/src/client/content/items/packages/package_season_pass_build.cpp

@@ -0,0 +1,219 @@
+#include <algorithm>
+#include <cstring>
+#include <limits>
+
+#include "internal.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;
+}
+
+/**
+ * Resolves each reward's claim flag slot to its account flag bank row.
+ * The map is walked once: a per-slot table would cost more storage than the pass has rewards.
+ * @param blob Blob holding the account flag mapping table.
+ * @param rewards Reward rows whose claim slots are already read.
+ * @param count Rows in use.
+ * @param claimSlots Raw claim slot of each reward row, in the same order.
+ * @return True when the mapping table resolves.
+ */
+[[nodiscard]] bool resolve_claim_flags(std::span<const std::byte> blob,
+                                       std::span<domain::Reward> rewards,
+                                       std::size_t count,
+                                       std::span<std::uint16_t> claimSlots) noexcept {
+    tables::Array rows{};
+    if (!tables::find_array_at(blob, tables::kAccountFlagMapDescriptor, rows) || rows.count == 0
+        || rows.count > (std::numeric_limits<std::uint16_t>::max)()
+        || rows.dataOffset + static_cast<std::size_t>(rows.count) * tables::kUnlockMapRowStride
+               > blob.size()) {
+        return false;
+    }
+    for (std::uint64_t row = 0; row < rows.count; ++row) {
+        std::int16_t slot = 0;
+        if (!read(blob,
+                  rows.dataOffset + static_cast<std::size_t>(row) * tables::kUnlockMapRowStride
+                      + tables::kUnlockMapDestinationSlotOffset,
+                  slot)
+            || slot <= 0) {
+            continue;
+        }
+        for (std::size_t reward = 0; reward < count; ++reward) {
+            if (claimSlots[reward] == static_cast<std::uint16_t>(slot)
+                && rewards[reward].claimFlagIndex == domain::kUnavailableFlagIndex) {
+                rewards[reward].claimFlagIndex = static_cast<std::uint16_t>(row);
+            }
+        }
+    }
+    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.
+ */
+[[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)) {
+        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()) {
+        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)) {
+            return false;
+        }
+        package.items[package.itemCount++] = entry.definitionHash;
+    }
+    return true;
+}
+
+} // 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.
+ * @param source Installed package source.
+ * @param storage Pass storage receiving the reward rows and the wrapper packages.
+ * @param root Investment root bytes.
+ * @return True when the reward list read and produced at least one row.
+ */
+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{};
+    if (!tables::slot_tag(root, tables::kItemTableSlot, itemTableTag) || itemTableTag == 0
+        || !reader::read_tag(source, storage.scratch, itemTableTag, storage.itemIndexTable)
+        || !tables::find_array_at(std::span<const std::byte>{storage.itemIndexTable},
+                                  tables::kTableArrayDescriptor,
+                                  itemRows)
+        || itemRows.elementClass != tables::kItemIndexTableClass) {
+        return false;
+    }
+    const std::span<const std::byte> itemTable{storage.itemIndexTable};
+
+    std::uint32_t tableTag = 0;
+    tables::Array progressions{};
+    if (!tables::slot_tag(root, tables::kProgressionTableSlot, tableTag) || tableTag == 0
+        || !reader::read_tag(source, storage.scratch, tableTag, storage.progressionTable)
+        || !tables::find_array_at(std::span<const std::byte>{storage.progressionTable},
+                                  tables::kTableArrayDescriptor,
+                                  progressions)
+        || progressions.elementClass != tables::kProgressionTableClass
+        || progressions.count <= kPassProgressionIndex) {
+        return false;
+    }
+    const std::span<const std::byte> progressionTable{storage.progressionTable};
+    const std::size_t passAt =
+        progressions.dataOffset + kPassProgressionIndex * 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()) {
+        return false;
+    }
+
+    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];
+        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;
+        }
+        reward.itemHash = entry.definitionHash;
+        reward.itemIndex = static_cast<std::uint16_t>(itemIndex);
+        reward.requiredRank = static_cast<std::uint8_t>(rank);
+        storage.seasonPassClaimSlots[storage.seasonPassRewardCount] =
+            static_cast<std::uint16_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;
+    }
+
+    std::uint32_t mapTag = 0;
+    if (!tables::slot_tag(root, tables::kUnlockFlagMapTableSlot, mapTag) || mapTag == 0
+        || !reader::read_tag(source, storage.scratch, mapTag, storage.child)
+        || !resolve_claim_flags(std::span<const std::byte>{storage.child},
+                                storage.seasonPassRewards,
+                                storage.seasonPassRewardCount,
+                                storage.seasonPassClaimSlots)) {
+        storage.seasonPassRewardCount = 0;
+        storage.seasonPassPackageCount = 0;
+        return false;
+    }
+    return storage.seasonPassRewardCount != 0;
+}
+
+} // namespace sunrise::client::content::items::packages

+ 36 - 25
Sunrise/src/client/content/items/packages/package_socket_plug_build.cpp

@@ -22,17 +22,10 @@ constexpr std::array<std::uint32_t, 3> kTrackerPlugHashes{
     2'302'094'943U,
     38'912'240U,
 };
-/** Enhanced Sword Scavenger already carries the correct Arrivals leg-armour socket relation. */
-constexpr std::uint32_t kArrivalsLegReferenceHash = 3'213'968'579U;
-/** Plug category declared by Enhanced Sword Scavenger and required by leg-armour sockets. */
-constexpr std::uint32_t kArrivalsLegCategoryHash = 0x7DDE0206U;
-/** Arrivals artifact records whose leg-armour label conflicts with their shipped general pool. */
-constexpr std::array<std::uint32_t, 4> kArrivalsLegModHashes{
-    3'465'659'109U, // Flourishing Blade
-    3'465'659'111U, // Automatic Prize
-    3'465'659'104U, // Dimensional Tithes
-    3'465'659'105U, // Ascendant Bounty
-};
+using socket_plugs::kArrivalsLegCategoryHash;
+using socket_plugs::kArrivalsLegModHashes;
+using socket_plugs::kArrivalsLegReferenceHash;
+
 /** Native ordinary socket type whose choices are the synthetic tracker set. */
 constexpr std::uint16_t kTrackerSocketType = 518;
 /** FNV-1a constants make pool fingerprints stable and cheap. */
@@ -72,6 +65,12 @@ struct VisitorContext {
 
 } // namespace
 
+/**
+ * Reads one acquisition gate per native socket type from the socket-type table.
+ * @param blob Scratch storage for the socket-type table.
+ * @param output Receives one gate per socket type; cleared first.
+ * @return False when the table shape or a row extent is invalid.
+ */
 bool read_catalyst_acquisition_gates(const reader::Source& source,
                                      reader::Scratch& scratch,
                                      std::span<const std::byte> root,
@@ -123,6 +122,12 @@ bool read_catalyst_acquisition_gates(const reader::Source& source,
     return true;
 }
 
+/**
+ * Reads the build-defined completion value of every native objective.
+ * @param blob Scratch storage for the objective table.
+ * @param output Receives one value per objective index; cleared on failure.
+ * @return False when the table class, count or a row extent is invalid.
+ */
 bool read_catalyst_objective_values(const reader::Source& source,
                                     reader::Scratch& scratch,
                                     std::span<const std::byte> root,
@@ -220,11 +225,13 @@ bool SocketPlugBuild::prepare(
             }
         }
     }
+    // The correction needs the reference item and all four mods. A package without them keeps its
+    // own pools; the rest of the domain is unaffected.
     if (arrivalsLegReference_ == UINT16_MAX
         || std::find(arrivalsLegMembers_.begin(), arrivalsLegMembers_.end(), UINT16_MAX)
                != arrivalsLegMembers_.end()) {
-        release();
-        return false;
+        arrivalsLegReference_ = UINT16_MAX;
+        arrivalsLegMembers_.fill(UINT16_MAX);
     }
     return true;
 }
@@ -243,18 +250,18 @@ bool SocketPlugBuild::add(std::uint32_t itemDefinitionIndex,
 
 /** Mirrors Enhanced Sword Scavenger's exact lane admission onto the four reclassified mods. */
 bool SocketPlugBuild::route_arrivals_leg_mods() noexcept {
-    const bool legLane = std::find(candidates_.data(),
-                                   candidates_.data() + candidateCount_,
-                                   arrivalsLegReference_)
-                         != candidates_.data() + candidateCount_;
+    if (arrivalsLegReference_ == UINT16_MAX) {
+        return true;
+    }
+    const bool legLane =
+        std::find(candidates_.data(), candidates_.data() + candidateCount_, arrivalsLegReference_)
+        != candidates_.data() + candidateCount_;
     const auto isReclassified = [this](socket_plugs::Member member) noexcept {
         return std::find(arrivalsLegMembers_.begin(), arrivalsLegMembers_.end(), member)
                != arrivalsLegMembers_.end();
     };
     candidateCount_ = static_cast<std::size_t>(
-        std::remove_if(candidates_.data(),
-                       candidates_.data() + candidateCount_,
-                       isReclassified)
+        std::remove_if(candidates_.data(), candidates_.data() + candidateCount_, isReclassified)
         - candidates_.data());
     if (!legLane) {
         return true;
@@ -390,11 +397,15 @@ bool SocketPlugBuild::append(const tables::items::Row& item,
 
 /** Publishes the bounded relation and retains its rows for dependent package builders. */
 bool SocketPlugBuild::publish() noexcept {
-    return !rules_.empty() && !pools_.empty() && !members_.empty()
-           && state::build_data::publish_socket_plug_rules(
-               std::span(rules_.data(), ruleCount_),
-               std::span(pools_.data(), poolCount_),
-               std::span(members_.data(), memberCount_));
+    const bool published =
+        !rules_.empty() && !pools_.empty() && !members_.empty()
+        && state::build_data::publish_socket_plug_rules(std::span(rules_.data(), ruleCount_),
+                                                        std::span(pools_.data(), poolCount_),
+                                                        std::span(members_.data(), memberCount_));
+    // Interning is over, so the 8 MiB pool-fingerprint table goes back before the ability pass.
+    lookup_.clear();
+    lookup_.shrink_to_fit();
+    return published;
 }
 
 /** Reports how many lanes failed closed during extraction. */

+ 9 - 7
Sunrise/src/client/content/items/packages/package_socket_plug_build.h

@@ -44,12 +44,11 @@ read_catalyst_acquisition_gates(const reader::Source& source,
  * @param output Receives one completion value per native objective index.
  * @return True when the table class, row class, count, and fixed rows are valid.
  */
-[[nodiscard]] bool
-read_catalyst_objective_values(const reader::Source& source,
-                               reader::Scratch& scratch,
-                               std::span<const std::byte> root,
-                               std::vector<std::byte>& blob,
-                               std::vector<std::int32_t>& output) noexcept;
+[[nodiscard]] bool read_catalyst_objective_values(const reader::Source& source,
+                                                  reader::Scratch& scratch,
+                                                  std::span<const std::byte> root,
+                                                  std::vector<std::byte>& blob,
+                                                  std::vector<std::int32_t>& output) noexcept;
 
 /**
  * Finds every positive flag and `value >= literal` term plus one objective reference.
@@ -139,7 +138,10 @@ private:
 
     /** Expands native category families, canonicalizes, and interns the current candidate. */
     [[nodiscard]] bool intern(std::uint32_t& poolIndex) noexcept;
-    /** Replaces the four Worthy-era general memberships with the reference Arrivals leg set. */
+    /**
+     * Replaces the four Worthy-era general memberships with the reference Arrivals leg set.
+     * Does nothing when the package names no reference item or is missing one of the four mods.
+     */
     [[nodiscard]] bool route_arrivals_leg_mods() noexcept;
     /** Releases every transient allocation and count. */
     void release() noexcept;

+ 1 - 4
Sunrise/src/client/content/scenarios/internal.h

@@ -66,8 +66,6 @@ struct RosterStorage {
     std::uint32_t destinationTag{};
     /**
      * Why the descriptor walk of the object being resolved fell short, counted per exit.
-     * A group is refused when its found slots miss its declared ones, and the summary says only
-     * how many were refused. These say which step lost them, which is what picks the fix.
      * Cleared with the slot list, so every count belongs to one object.
      */
     struct WalkExits {
@@ -96,8 +94,7 @@ inline constexpr std::size_t kRosterReadBudget = 150;
 inline constexpr std::size_t kLiveTagCapacity = 1'024;
 /**
  * How long the collection keeps retrying the destinations that have not read yet.
- * Packages register during the boot, so an early attempt reads fewer of them. One run latched at
- * 417 of 466 and the destination it dropped was the Tower.
+ * Packages register during the boot, so an early attempt reads fewer of them.
  */
 inline constexpr std::uint64_t kResolveWindowMs = 15'000;
 /** Tag reads one collection call may spend, for the same reason the roster walk is bounded. */

+ 2 - 4
Sunrise/src/client/content/scenarios/scenario_build.cpp

@@ -94,10 +94,8 @@ bool build(const packages::reader::Source& source, packages::reader::Scratch& sc
             return false;
         }
         compact_rows(storage);
-        // An empty result is never a finished pass. Every blob read needs the block keys, and
-        // those arrive during the boot, so a window that closes first reads nothing. Latching it
-        // would publish a domain with no destinations for the whole run. Only the first empty
-        // round reports, because the retry runs on every worker slice.
+        // An empty result is never a finished pass: a read before the block keys arrive returns
+        // nothing. Report only the first empty round, since the retry runs on every worker slice.
         if (storage.keptCount == 0) {
             if (storage.resolveRounds == 0) {
                 report(storage, 0, 0, "empty");

+ 4 - 13
Sunrise/src/client/content/scenarios/scenario_roster_groups.cpp

@@ -108,10 +108,7 @@ struct ChainReadContext {
     return true;
 }
 
-/**
- * Group objects reported per run. The measured drop count is 59, so this shows every one and
- * still bounds a content tree that drops far more.
- */
+/** Unresolved group objects reported per walk. Bounds the sink on a tree that drops many. */
 constexpr std::size_t kMaxUnresolvedReports = 128;
 /** Size of one line, set by its tag, key and the per-exit counts that follow them. */
 constexpr std::size_t kUnresolvedLineCapacity = 256;
@@ -121,9 +118,7 @@ std::atomic_size_t g_unresolvedReports{0};
 
 /**
  * Names one group object the descriptor walk could not fill.
- * The domain summary counts these but names none, so a walk that drops most of what it finds
- * reads the same as one that found little. The gap between declared and found is what says
- * whether the chain stopped early or the classification refused what it reached.
+ * The declared-to-found gap says whether the chain stopped early or the classification refused it.
  * @param objectTag Tag of the object being resolved.
  * @param registryKey Registry key the object declares.
  * @param declaredSlotCount Slots the object's own slot array declares.
@@ -178,12 +173,8 @@ std::atomic_size_t g_placementReports{0};
 
 /**
  * Names one placed object and every slot type it declares, before any filter has judged it.
- * `carries_roster_slot` admits an object only when it declares one of `kRosterSlotTypes`, and an
- * object it refuses leaves no trace anywhere: it is not counted, not published, and not reported.
- * So a bubble whose objects all declare some other type is indistinguishable from a bubble with no
- * objects at all, which is exactly the ambiguity that has to be settled before that list is
- * widened. Widening it blind is not safe — `kRosterKeyCapacity` overflow costs a destination every
- * group it publishes today, so this reports what the filter sees rather than changing it.
+ * An object `carries_roster_slot` refuses leaves no other trace, so the two empty cases read alike.
+ * @param destinationTag Destination whose scenario named this object.
  * @param sliceSetIndex Slice set whose registry named this object.
  * @param objectTag Tag of the placed object.
  * @param object Whole placed-object bytes.

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio