Parcourir la source

Implement 1AU mission scripting and native encounter support

Add the complete Lua mission route, authored AI objectives, dropship delivery,
interactions, checkpoints, HUD guidance and cinematic progression. Correct
authority cleanup and replication, recover consumed carry objects, and
bind reactor hazards and mission banners to native sensors.

Stage ending cinematics across bubbles and constrain the gate controllers.
Document remaining in-game validation for the finale changes.

Validation: Release build, 21 portable C++ tests and five Lua suites passed.
Millie il y a 1 mois
Parent
commit
2fdf2dc8f4
100 fichiers modifiés avec 10344 ajouts et 491 suppressions
  1. 79 90
      MISSION_EMBER_PLAN.md
  2. 73 0
      MISSION_EMBER_REACTOR.md
  3. 61 1
      MISSION_EMBER_REFERENCE.md
  4. 1161 3
      MISSION_EMBER_STATUS.md
  5. 1 1
      Sunrise/resources/default_settings.json
  6. 29 0
      Sunrise/src/client/content/activity/activity_sdk_lua_contract.cpp
  7. 2 0
      Sunrise/src/client/content/activity/activity_sdk_squad_graph.h
  8. 121 88
      Sunrise/src/client/content/activity/activity_sdk_squad_graph_edges.cpp
  9. 1240 0
      Sunrise/src/client/diagnostics/entity_create_probe.cpp
  10. 37 0
      Sunrise/src/client/diagnostics/entity_create_probe.h
  11. 99 0
      Sunrise/src/client/hooks/harvester_drop/harvester_drop_experiment.cpp
  12. 7 0
      Sunrise/src/client/hooks/harvester_drop/harvester_drop_experiment.h
  13. 135 0
      Sunrise/src/client/hooks/harvester_drop/harvester_exit.cpp
  14. 67 1
      Sunrise/src/client/hooks/retail_log/retail_log_enqueue_observer.cpp
  15. 3 0
      Sunrise/src/client/runtime/client_hook_activation.cpp
  16. 5 1
      Sunrise/src/middleware/bap/activity_message/activity_cinematic_incident_codec.cpp
  17. 4 1
      Sunrise/src/middleware/bap/activity_message/activity_replicate_membership_encoder.cpp
  18. 616 0
      Sunrise/src/middleware/bap/activity_message/activity_scriptable_auth_fixed_body_codec.cpp
  19. 86 2
      Sunrise/src/middleware/bap/activity_message/activity_sense_update_decoder.cpp
  20. 14 4
      Sunrise/src/middleware/bap/activity_message/activity_sensor_auth_bodies.cpp
  21. 8 7
      Sunrise/src/middleware/bap/activity_message/activity_squad_auth_body_codec.cpp
  22. 40 0
      Sunrise/src/middleware/bap/activity_message/authority_cleanup.h
  23. 4 1
      Sunrise/src/middleware/bap/activity_message/cinematic_incident.h
  24. 55 0
      Sunrise/src/middleware/bap/activity_message/combatant_action_auth.h
  25. 69 0
      Sunrise/src/middleware/bap/activity_message/combatant_delivery_auth.h
  26. 56 0
      Sunrise/src/middleware/bap/activity_message/combatant_path_auth.h
  27. 33 0
      Sunrise/src/middleware/bap/activity_message/combatant_retire_auth.h
  28. 47 0
      Sunrise/src/middleware/bap/activity_message/darkness_zone_auth.h
  29. 39 0
      Sunrise/src/middleware/bap/activity_message/ghost_link_auth.h
  30. 54 0
      Sunrise/src/middleware/bap/activity_message/interactable_object_auth.h
  31. 100 0
      Sunrise/src/middleware/bap/activity_message/mission_auth_patch.h
  32. 56 0
      Sunrise/src/middleware/bap/activity_message/mission_effect_auth.h
  33. 44 0
      Sunrise/src/middleware/bap/activity_message/music_section_auth.h
  34. 4 4
      Sunrise/src/middleware/bap/activity_message/replicate_membership.h
  35. 4 0
      Sunrise/src/middleware/bap/activity_message/scriptable_auth_body.h
  36. 26 0
      Sunrise/src/middleware/bap/activity_message/sense_observation_packet.h
  37. 3 0
      Sunrise/src/middleware/bap/activity_message/sensor_auth_update.h
  38. 8 2
      Sunrise/src/middleware/bap/activity_message/squad_auth_body.h
  39. 46 0
      Sunrise/src/middleware/bap/activity_message/squad_objective_auth.h
  40. 40 0
      Sunrise/src/server/activity/activity_sdk_behavior_scope.h
  41. 494 8
      Sunrise/src/server/activity/activity_sdk_device_runtime.cpp
  42. 625 22
      Sunrise/src/server/activity/activity_sdk_mission_runtime.cpp
  43. 5 7
      Sunrise/src/server/activity/activity_sdk_squad_runtime.cpp
  44. 788 5
      Sunrise/src/server/activity/host_runtime.cpp
  45. 34 1
      Sunrise/src/server/activity/host_runtime.h
  46. 11 0
      Sunrise/src/server/activity/host_runtime_names.cpp
  47. 796 0
      Sunrise/src/server/activity/host_runtime_scriptable.cpp
  48. 37 0
      Sunrise/src/server/activity/mission/mission_script_actor_path_sense.h
  49. 17 0
      Sunrise/src/server/activity/mission/mission_script_event_batch.h
  50. 35 0
      Sunrise/src/server/activity/mission/mission_script_ghost_sense.h
  51. 28 0
      Sunrise/src/server/activity/mission/mission_script_lua_context_api.cpp
  52. 23 0
      Sunrise/src/server/activity/mission/mission_script_lua_event_api.cpp
  53. 2 0
      Sunrise/src/server/activity/mission/mission_script_lua_event_delivery.cpp
  54. 82 0
      Sunrise/src/server/activity/mission/mission_script_lua_event_derived.cpp
  55. 3 0
      Sunrise/src/server/activity/mission/mission_script_lua_key_api.cpp
  56. 4 0
      Sunrise/src/server/activity/mission/mission_script_lua_names.h
  57. 388 2
      Sunrise/src/server/activity/mission/mission_script_lua_slot_api.cpp
  58. 8 2
      Sunrise/src/server/activity/mission/mission_script_lua_value_api.cpp
  59. 43 0
      Sunrise/src/server/activity/mission/mission_script_object_sense.h
  60. 52 0
      Sunrise/src/server/activity/mission/mission_script_player_sense.h
  61. 238 41
      Sunrise/src/server/activity/mission/mission_script_runtime.cpp
  62. 3 0
      Sunrise/src/server/activity/mission/mission_script_runtime_delivery.cpp
  63. 29 0
      Sunrise/src/server/activity/mission/mission_script_runtime_dispatch.cpp
  64. 235 47
      Sunrise/src/server/activity/mission/mission_script_runtime_edges.cpp
  65. 50 1
      Sunrise/src/server/activity/mission/mission_script_runtime_internal.h
  66. 79 0
      Sunrise/src/server/activity/mission/mission_script_squad_sense.h
  67. 9 0
      Sunrise/src/server/activity/mission/mission_script_vm.cpp
  68. 78 16
      Sunrise/src/server/bap/encrypted/activity_message/activity_message_route.cpp
  69. 5 2
      Sunrise/src/server/bap/encrypted/activity_message/definition.h
  70. 1 1
      Sunrise/src/server/bap/encrypted/activity_message/receipts/activity_message_receipts.cpp
  71. 44 0
      Sunrise/src/server/bap/encrypted/activity_transaction/activity_transaction_notifications.cpp
  72. 9 0
      Sunrise/src/server/bap/encrypted/bap_connection_publication.cpp
  73. 3 0
      Sunrise/src/server/bap/encrypted/bap_connection_publication.h
  74. 2 1
      Sunrise/src/server/bap/encrypted/push/activity/activity_keepalive_push.cpp
  75. 21 3
      Sunrise/src/server/bap/encrypted/push/activity/activity_roster_snapshot.cpp
  76. 2 5
      Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp
  77. 3 1
      Sunrise/src/server/bap/internal.h
  78. 6 0
      Sunrise/src/server/gameplay/peer/peer_established.cpp
  79. 32 0
      Sunrise/src/server/gameplay/peer/peer_external_ack.h
  80. 1 12
      Sunrise/src/server/gameplay/peer/peer_transport_internal.h
  81. 17 0
      Sunrise/src/server/transport/bap_frame_batch.h
  82. 3 3
      Sunrise/src/server/transport/bap_listener.cpp
  83. 63 0
      Sunrise/src/state/activity/membership/activity_membership_query.cpp
  84. 9 0
      Sunrise/src/state/activity/membership/activity_membership_query.h
  85. 22 0
      Sunrise/src/state/activity/membership/definition.h
  86. 4 1
      Sunrise/src/state/activity/membership/transactions/internal.h
  87. 5 2
      Sunrise/src/state/activity/mission/definition.h
  88. 34 56
      scripts/README.md
  89. 67 3
      scripts/mission_ember.lua
  90. 381 0
      scripts/mission_ember/apex.lua
  91. 91 0
      scripts/mission_ember/carry.lua
  92. 162 0
      scripts/mission_ember/cinder.lua
  93. 30 0
      scripts/mission_ember/combat_ai.lua
  94. 48 12
      scripts/mission_ember/encounter.lua
  95. 54 0
      scripts/mission_ember/ending.lua
  96. 254 31
      scripts/mission_ember/landing.lua
  97. 34 0
      scripts/mission_ember/music.lua
  98. 3 0
      scripts/mission_ember/opening.lua
  99. 117 0
      scripts/mission_ember/processing.lua
  100. 45 0
      scripts/mission_ember/roster.lua

+ 79 - 90
MISSION_EMBER_PLAN.md

@@ -1,90 +1,79 @@
-# 1AU / mission_ember implementation plan
-
-Prepared 2026-09-04. Scope: restore the complete mission through Lua using the installed generated SDK, with package-backed identifiers and authored resources.
-
-## Evidence reviewed
-
-- Reference video: https://www.youtube.com/watch?v=PqurUhqC2CE — The Videogame Library, “Destiny 2 (Xbox One) The Red War: Mission 16 - 1AU (No Commentary Longplay)”, duration 36:42. Reviewed all nine available storyboard sheets, sampling the timeline every ten seconds. This establishes broad visual order; it is not continuous playback or an audio review. Exact dialogue timing, brief interactions and damage timings remain to be verified. The recording begins in gameplay and includes subsequent story cinematics and a later mission launch; those are not all necessarily controlled by Ember.
-- Generated mission: `/home/millie/Games/Sunrise/bin/x64/Sunrise/sdk/lua/missions/mission_ember_80b3c09e.lua`.
-- Campaign activity: `mission_ember_38f926b2.lua`, display name `1AU`, activity index 281. Daily Heroic variant: `mission_ember_231dd291.lua`, index 54. Both point to activity root `0x80B3C07D` and scenario `0x80B3C09E`.
-- SDK manifest: schema `sunrise-activity-sdk-v8`, format 37, build ID `sha256:26b80ec22191c2f87d9079ef1dc90707cf26b10e5299962304aac4e7a9e8c47a`.
-- Installed packages: `/home/millie/Games/Sunrise/packages`. Read package directories and checked 154 distinct content references from the mission's slot IDs and content-tag fields: all resolved to nonempty entries across nine package families. This verifies presence, not complete semantic decoding of each payload. Runtime schema constants were excluded from the content-reference check.
-- Primary family: `w64_cabal_ship_activities_019e_{0,1,2,3,5}.pkg`, with 8,192 directory entries in the latest installed directory. Root entry: 72 bytes; scenario entry: 1,773 bytes.
-- Scenario SDK pack: `sdk/scenarios/80B3C09E-446ac6892dd45476cc8b91f8e3a654d92b14eaa6ed4d272f14e93cc43a927082.pack`.
-- Lua references: sibling `SunriseMissions`, especially `lib/mission_lib.lua` and the regional dispatch in `tangled_shore_freeroam.lua`. Its README describes the examples as WIP, so each pattern needs checking against this branch's runtime.
-
-## What the generated SDK already contains
-
-| Resource | Count / finding |
-| --- | --- |
-| State records | 8; effective region indices 0, 1, 2, 40, 48, 49, 56, 64 |
-| Named slots | 2,051 |
-| Squad sensors / richer squad definitions | 162 / 31 |
-| Device sensors | 69 |
-| Objective sensors / directive entries | 28 / 26 |
-| Player trigger sensors / trigger volumes | 127 / 211 |
-| Scenes / sequence sensors / cinematic sensors | 10 / 9 / 3 |
-| Dialogue cue selectors | 55; definition mappings exist for a subset |
-| Task targets / performance states | Both generated tables are empty |
-
-Specific assets include `sunburn_damage_object`, `security_placed_interceptor_object`, bridge machinery, east/west reactor clamshell devices and damage sensors, the coffin damage sensor, reactor shield, explosion scenes, escape ship and three cinematic bookend sensors. Directive descriptions explicitly identify the grinder, solar-exposure cover mechanic, bridge controls, energy stream, Interceptor, cooling vents, fusion cell and escape.
-
-These findings support an SDK-first implementation. They do not yet prove every required runtime operation works. In particular, the difference between squad sensors and richer squad definitions must be explained before choosing how each encounter is activated. An empty task table means we cannot assume an `advance_task` target exists for every objective.
-
-## Implementation sequence
-
-1. **Build the mission mapping and prove the SDK bindings.**
-   - Create a stage table mapping each encounter to its owning region/state, entry and exit triggers, squads, devices, objective/directive, dialogue cues, scenes and reset behavior.
-   - Use stable generated identifiers. Distinguish identical display names with different slot identities. State values are all zero here: use their region-qualified identities rather than treating the values as global phase IDs.
-   - Trace ambiguous mappings through the SDK catalog/scenario data and targeted package payloads. Determine whether squads absent from the richer table are scene-owned, activated through existing sensor state, or need an SDK export correction.
-   - Verify the generated module, branch runtime and installed runtime agree. Exercise the existing APIs for state selection, squad placement, device transitions, triggers, directives, dialogue, cinematics and lifetime state.
-   - Deliverable: an encounter map with confirmed bindings and an explicit list of unresolved operations.
-
-2. **Implement and test the opening encounter as a complete slice.**
-   - Add `scripts/mission_ember.lua` and focused modules under `scripts/mission_ember/`; include the required shared helpers in the same deployable tree.
-   - Bind the campaign activity first. Resolve the correct entry state, publish its entities, activate its encounter and show its authored directive.
-   - Prove an actual player trigger, a combat completion condition, the first gate transition and the following region load.
-   - Handle duplicate events, death/reset and region re-entry before expanding the controller.
-   - Exit criterion: launch, play the first encounter, progress, reset and replay without manual entity spawning or forced phase advancement.
-
-3. **Implement the traversal and combat sequence in order.**
-
-   | Section | Required behavior |
-   | --- | --- |
-   | Opening / mineral processing | Correct combat groups, entrances/exits, navigation, objectives and dialogue; determine any launch bookend from package ownership. |
-   | Ore tunnels and grinder | Investigation and activation interaction, defend while clearing the obstruction, completion gate and onward route. |
-   | Sunlit deck | Enable the authored solar hazard and cover behavior; activate the correct encounters and remove section-specific effects on exit/reset. |
-   | Interior chambers and machinery | Encounter waves, moving devices, doors, bridge controls and region transitions. Confirm the exact placement of each bridge against trigger ownership. |
-   | Fuel stream and Interceptor route | Energy-stream traversal, vehicle availability/boarding, enemy placement and the transition into the reactor section. |
-   | Reactor sabotage | East/west vent exposure and destruction, linked objectives and scenes, subsequent core/fusion-cell interaction, shield and route changes. Confirm authored ordering and thresholds from data. |
-   | Escape | Explosion sequence, navigation to the getaway ship, escape trigger, terminal mission state and the relevant cinematic handoff. |
-
-   - Progress from actual trigger, interaction, damage, squad and scene events. Use timers where supported by authored behavior; do not copy this player's video duration into encounter gates.
-   - Distinguish stage entry from stage resume. Only activate entities belonging to the appropriate loaded region.
-   - Exit criterion for each section: it can be reached naturally, completed and replayed after failure.
-
-4. **Complete presentation and mission lifecycle behavior.**
-   - Pair each directive and dialogue cue with its actual event; handle alternate cues and avoid repeating one-shot lines on re-entry.
-   - Restore sequences, music transitions, destruction scenes and cinematic start/termination handling using existing SDK surfaces.
-   - Establish which ending scenes belong to Ember and which belong to campaign progression. Do not launch the later mission merely because it appears at the end of the video.
-   - Verify completion is recorded by the supported activity/campaign path, with expected rewards and subsequent progression where applicable. A final Lua phase or a cinematic alone is not proof of completion.
-
-5. **Make recovery and repeated play reliable.**
-   - Implement the authored checkpoint/wipe behavior using the available runtime state and sensors; first confirm which state survives death, region transit and script reload.
-   - Reset devices, hazards, damage targets, objectives, vehicles, encounter state and timers to the checkpoint's expected state.
-   - Guard one-shot actions against duplicate/stale events. Keep regional timers and callbacks tied to the region that owns them.
-   - Test backtracking, rapid trigger crossings, repeated interaction, death during animations, vehicle loss and escape failure.
-   - Verify fireteam synchronization and late joins if supported by the mission's intended play mode. Test the Daily Heroic binding after campaign behavior is stable.
-
-6. **Validate the complete mission.**
-   - Check Lua loading and every referenced SDK symbol before deployment.
-   - Add focused event-sequence checks for progression, duplicate events, reset and terminal completion where the available harness supports them.
-   - Play from a fresh launch through the ending, with no developer intervention. Compare encounters, objectives, hazards, vehicle section and cinematics with the reference.
-   - Repeat with checkpoint deaths and a fresh second launch. Review runtime logs for unresolved references, rejected operations, stale handles and missed transitions.
-   - Record any remaining behavior differences explicitly. Completion requires functional gameplay and lifecycle behavior, not just reaching the final room.
-
-## Working approach
-
-Keep authored Lua in the `mission-ember` branch and use the installed SDK as generated input. Start with its existing capabilities. Make a targeted runtime/export fix only after a concrete required operation is shown to be unavailable or broken. Initial work should produce the encounter map and playable opening; the full mission then grows section by section using that verified approach.
-
-No gameplay code, installed game files or runtime settings were changed during this planning pass.
+# 1AU remaining mission implementation plan
+
+Authorized on 2026-09-06: finish navigation/wipes, then plan and implement the remaining
+mission without further approval. The user launches the game manually. Install completed
+candidates only while it is closed; preserve settings/save and make rollback backups.
+
+## Evidence and implementation rules
+
+Reference video: https://www.youtube.com/watch?v=PqurUhqC2CE ; saved copy
+`/tmp/ember-reference/1au.mp4`. Opening cutscene reference:
+https://www.youtube.com/watch?v=IUmUYHELJAY . Existing timestamp observations are in
+`MISSION_EMBER_REFERENCE.md`. SDK topology, compact inventories and subsequent frame
+inspection are recorded in `build/full-mission-audit/`.
+
+Use authored squad identities, spawn rules/anchors, combat objectives, devices, objects,
+navpoints, scenes, triggers and dialogue. Video timestamps describe event ordering;
+progression must follow gameplay receipts rather than the recording's elapsed time.
+Keep completed encounters complete across backtracking and streaming. A deliberate wipe
+resets only its checkpoint, with new object/ship generations and stale-event protection.
+A copied SDK declaration is not evidence that its native interaction already works.
+
+## Execution result
+
+All seven work packages reached an installed full-route candidate on2026-09-06.
+The SDK audit and controller/wire regressions passed; the complete route, checkpoint
+reset policies and ending are implemented. The native thermal/exposure and timed
+escape-failure policies remain incomplete, and actual gameplay has deliberately not been
+launched. See `MISSION_EMBER_STATUS.md` for exact limits and installation evidence.
+The work order below records the plan used to build the candidate.
+
+## Work order
+
+1. **Landing navigation and wipe foundation — candidate installed.** Native directive
+   target references, visible normal/darkness respawn policy, death evidence, three-second
+   all-dead countdown and native membership wipe. Reset at Mercury before rescanning;
+   retain completed approach and lever. Offline regressions passed. Native HUD/fade and
+   co-op presentation await the user's live test.
+2. **Complete the package audit before wiring later populations.** Resolve the SDK's
+   ambiguous Processing spawn-rule edges and missing access/ready-room definitions.
+   Extract each combat objective's task-group bounds. Map region ownership and objects
+   to their native entry/model, interaction/destruction/carried state, and paired devices.
+   Verify progression gates against closer video samples.
+3. **Mineral Processing / link, region56 (~05:30–11:00).** Entry squads and shutters;
+   obstructed grinder discovery; fusion-cell pickup, carry and receptacle; activation and
+   defend waves; clearing debris and opening the exit. Include machinery/klaxon/lighting,
+   dialogue and forward guidance. Give the defense its own retry checkpoint.
+4. **Sunside / cinder, region40 (~11:00–20:50).** Exterior solar-exposure behavior and
+   authored cover/route; Sunside squads and retreat scenes; ready rooms, tumbler,
+   chamber/ascent/meat-grinder route; Foundry/Bruiser and exit machinery. Load interactable
+   and environmental objects when their area becomes active. Preserve staged waves and
+   their objectives; checkpoint each contained encounter. Finish at the energy-stream exit.
+5. **Light's End / apex, region0 (~21:00–27:45).** Access and security squads, Interceptor
+   availability, reactor east/west/core encounters. Cycle authored vent devices and expose
+   the corresponding destructible targets; count actual target destruction. Progress from
+   two side exchangers to the core, then fusion-cell pickup and reservoir deposit. Add
+   enemy reinforcements, dialogue, objective counters, navigation and checkpoint cleanup.
+6. **Escape and ending (~27:45–35:27).** Overload scene, escape route and authored ship
+   handoff; identify and sequence the two final cinematic states, including native skip
+   incidents. Complete Ember exactly once. Chosen gameplay after~35:54 is a separate
+   mission and is outside this script.
+7. **End-to-end verification and installation.** Run the mission controller against an
+   SDK-backed simulated event stream: normal route, skipped narrow triggers, deaths and
+   retries, duplicate receipts, carried-object recovery, streaming/backtracking, and final
+   completion. Validate every referenced slot/squad/state and objective task bound. Build
+   native code, run relevant wire/decoder regressions, install and SHA-verify the complete
+   candidate with a backup. Record remaining live-only checks honestly.
+
+## Initial audit findings
+
+The generated SDK has 2,051 slot declarations, 162 squad sensors and160 squad definitions;
+138 definitions pass its existing exactness/profile checks. Processing's11 defense
+sensors each have two rule candidates with unresolved association;13 sensors in later
+access/ready-room content have no generated squad definition. These require package
+investigation before claiming every later squad is supported. One shared valid directive
+and dialogue sensor carries the mission's authored text/cues across its gameplay states.
+
+The existing landing behavior and user-confirmed Harvester doors/exit are the regression
+baseline. New code must preserve the verified initial spawn, optional lever gate, pre-bridge
+Ghost requirements, troop delivery delay, and first-section darkness completion.

+ 73 - 0
MISSION_EMBER_REACTOR.md

@@ -0,0 +1,73 @@
+> Follow-up: the latest live run reached the escape endpoint but reported missing beam effects/hazards, a stale mission banner, inaccessible Controllers, and a freeze before the ending movie. See the newest entry in MISSION_EMBER_STATUS.md for the fixes and their remaining gameplay checks. The earlier candidate below was not visually validated for those behaviors.
+
+# 1AU reactor sequence — 2026-09-06
+
+Reference: [1AU playthrough, 21:20–28:30](https://www.youtube.com/watch?v=PqurUhqC2CE&t=1280).
+Reviewed the cached video across that interval at five-second spacing, then at one-second
+spacing around 23:28–24:27, 25:32–26:39 and 27:15–28:04. Subtitles informed dialogue placement;
+this was visual inspection, not an audible validation of the music mix. Contact sheets live
+in `build/reactor-audit/`.
+
+## Reference sequence
+
+| Time | Observed behavior | Implementation |
+| --- | --- | --- |
+| 21:25–22:55 | Interceptor/security fight, then access to Light's End | Two authored single-Psion squads `DISPENSER_SUPPORT_A/B` form the Electron Controller gate; boarding and support deaths do not open the security door. |
+| 23:00–23:10 | Ghost reacts to the weapon, then identifies thermal exchangers | Cue 40 at entry, cue 41 nine seconds later; duplicate triggers do not interrupt/replay either. |
+| 23:38–23:44 | Beam/screen surge and transition, then opening clamshell shutters | Native placed laser/ring objects are active. Their device transitions and alarm sequences precede the exposure window. |
+| 23:44–23:54 | Purple target exposed for roughly ten seconds | Six-second warning, ten-second exposure, fourteen-second recovery. This thirty-second clock is reconstructed pacing, not a recovered original script. |
+| 23:51 | First target destroyed; “That's one!” | Valid health/death receipt sets one durable completion bit and activates its authored destruction scene. |
+| ~24:55–25:00 | Second target destroyed; access across bridges to central island | Both deaths extend both bridges, unlock/power central doors, instantiate the central target and activate coffin squads. |
+| 26:00–26:04, 26:25–26:30 | Central target visible, shutters close, reopen, then target destroyed | Central doors and shield inherit the same clock, including an exposure already in progress. They continue cycling until central target death. |
+| 26:30 | Ghost says temperature increase is insufficient | Cue 48; leave central housing open, unlock the fusion-cell route. |
+| 27:28–27:38 | Ghost identifies fusion cell; player picks it up | Cue 50 via approach or first ownership receipt. Native `MOTHER_BRAIN_CARRY_OBJECT`; recovery handles expiration. |
+| 27:45–28:00 | Cell carried to reservoir and inserted | Carry navpoint switches to `EMBER_DIRECTIVE_REACTOR_MOTHER_BRAIN_DELIVERY_NAV_POINT`; accepted deposit consumes the cell and begins escape once. |
+| 28:01–28:30 | Overload/explosions, escape objective, Ghost calls Zavala | Existing authored explosion scenes/escape devices, cues 51–54, escape checkpoint and two ending cinematics retained. |
+
+## Mechanisms fixed
+
+`80809562` damage Sense fields health and shield have reflected flags `0x21`, meaning
+mandatory raw float fields. Optional presence is bit `0x02`. The previous decoder consumed
+nonexistent presence bits, misaligning both health and the Auth revision. Native `A774B0`
+publishes the fixed twelve-byte health/shield/revision state. Independent complete packet
+fixtures now cover healthy, zero-health and absent-target (-1) states, plus truncation.
+Lua accepts only the current encounter generation and a previously observed living target.
+Unloading an object, initial zero, old generations and duplicate deaths cannot advance it.
+Redundant lethal-effect requests after an already acknowledged target death were removed.
+
+Sequences previously searched only the opening seed state. Scene lease validation likewise
+rejected a later local state even with a current connection/region. The resolver now prefers
+the exact live-region occurrence; the existing seed occurrence remains a fallback for shared
+mission objects. It retains scenario, object, publication, connection generation and ambiguity
+checks. Scene validation also accepts the exact live state. This addresses the recorded
+`target_unavailable` alarms and the later-area scene `wrong_state` failures without changing
+the selected mission state or republishing the opening area.
+
+Device position, power and lock are independent lanes. The central doors are initially
+closed/locked, then explicitly unlocked/powered at the second clamshell death. The reactor
+bridges use position 1 initially and position 0 to extend, consistent with the reported inverse
+pose and the landing bridge. This final-area polarity still needs visual confirmation.
+Beam VFX use the authored `SPECOPS_APEX_RING_LASER_DEVICE`/`RING_DEVICE` and their placed
+models (`80B7117C`, `80B711C6`, `80B71218`); no fabricated overlay or model replacement.
+The exact beam/screen response and animation latency require an in-game check.
+
+Native music sections now advance through access, controller defeat, weapon reveal,
+thermal-exchanger combat, first destruction, central access/exposure, cell and escape
+(19–28). Section identities beyond the recovered `tumbler_exit` are inferred from bank order;
+finale section names and audible transitions have not been independently verified.
+
+## Validation and installation
+
+- Release DLL builds; 21 portable native tests pass, including mandatory damage packets and
+  live-region behavior selection (missing area, cinematic state, foreign scenario, ambiguity).
+- Five Lua suites pass. Full route exercises both-controller gating, warning before exposure,
+  both-clamshell bridge gate, central object creation and repeating doors, stale/missing health,
+  wipe generation, cell expiration/recovery, consumption, duplicate events and ending cinematics.
+- Peak full-route usage: 228/512 variables, 61/63 recorded intents per callback, 2/32 timers,
+  13,000/100,000 instructions per mocked callback. Reset publishes cleanup before repopulating.
+- Core initialization is deferred while outside region zero and idempotent on duplicate timers.
+- Installation manifest: `build/reactor-finale-installation.json`. Existing save/settings/SDK
+  and prior replication cleanup remain intact. Game launch is manual.
+
+This is an implemented and regression-tested candidate. Native sound, screen surge, actual
+bridge pose and shutter timing remain live-test observations, not established results.

+ 61 - 1
MISSION_EMBER_REFERENCE.md

@@ -1,5 +1,8 @@
 # 1AU reference and implementation evidence
 
+Finale review and current implementation: [reactor evidence](MISSION_EMBER_REACTOR.md).
+The detailed September 6 review supersedes the earlier coarse finale rows below.
+
 Reference: https://www.youtube.com/watch?v=PqurUhqC2CE (36:42).
 A local low-resolution video copy was retrieved on 2026-09-05 for timestamped frame inspection.
 The recording starts with player control at Starboard Landing; it does not show the arrival
@@ -9,7 +12,7 @@ cinematic. Its absence here is not evidence that the original mission lacks one.
 | --- | --- | --- | --- |
 | 00:00 | Starboard Landing, initial objective | powerhouse state, region 64 | video and SDK |
 | ~00:05–00:15 | Ghost arrival line and Ikora central-core guidance | dialog sensor 80B3C90A, cue 0 | video subtitles and SDK variants |
-| ~00:25 onward | Landing enemies, including shielded Cabal and melee enemies | landing squad family | individual squad-to-actor mapping still under audit |
+| ~00:25 onward | Landing enemies, including shielded Cabal and melee enemies | catwalk entry/mid and pipe squad families | individual actor timing remains under audit |
 | ~01:35–01:45 | Weapon active / disconnect it from the sun exchange | cue 2 | video subtitles and SDK variants |
 | ~03:12–03:15 | Player approaches console and deploys Ghost | console ghost-link slot, type 65 | frame inspection at 1-second intervals |
 | ~03:16–03:22 | Bridge machinery moves; crossing becomes available | six bridge devices | direction and live Auth delivery still need confirmation |
@@ -49,3 +52,60 @@ playable state 64 only on that slot's termination event. Early region-64 reports
 landing combat or guidance before that handoff. No 21-second timer is used: completion/skip
 comes from the cinematic event. The selected package asset and the absence of a premature
 player spawn still need visual confirmation in the running game.
+
+## Opening-route correction
+
+The start of the video precedes the catwalk fight; it is not the Mercury console platform.
+Native build-data lists the `default` spawn cluster at approximately (-400, -192, -1.9).
+The generated SDK's arrival-dialogue volume surrounds that cluster, followed by catwalk
+15/35/50/80/100-percent volumes and then pipe/Mercury volumes. The previously selected
+`powerhouse_landing_mercury` set around (-444, 74, -36.5) skips that opening route.
+This spatial evidence corrects the earlier hash choice; a manual playtest must still confirm
+which registered point the client actually selects and its facing/grounding.
+
+Frame inspection was repeated for the opening contact sheet. It shows catwalk combat before
+the pipe view and weapon-active exchange around 01:35. The Lua policy now stages the early
+squad families and cue 2 on authored route events, with no video timestamps used as timers.
+
+## Bridge video recheck — 2026-09-05 14:43
+
+Half-second frame sequences3:10–3:34.5 saved in
+`build/first-encounter-audit/reference-arrival-01.jpg`, `reference-arrival-02.jpg`,
+`reference-drop-01.jpg`, and `reference-drop-02.jpg`.
+Ghost deployment is visible around3:15; machinery moves3:16–22; ships sweep into view3:24–29
+while the player crosses, followed by troop combat. The camera does not follow every ship
+or show a complete passenger manifest, so these frames cannot establish all four cargo pairings.
+
+Native curve captures prove that the earlier path packet selected the start marker. Entry/exit
+now select marker1. See `transport-curve-markers-20260905.json` for all eight endpoints and
+native parameter arrays. Values4/5 are spline coordinates, not elapsed flight seconds.
+
+
+## Full route review — 2026-09-06
+
+Inspected the saved reference video again using closer frame sequences in
+`build/full-mission-audit/processing-*.jpg`, `processing-cell-*.jpg`, `cinder-*.jpg`
+and `apex-*.jpg`. Offsets printed on those contact sheets are relative to the start
+shown after the plus sign, rather than absolute mission time.
+
+| Reference time | Observed action | Script gate |
+|---|---|---|
+| 05:30–06:40 | Processing entry fight and blocked grinder | Authored entry squads, obstruction/control volumes |
+| 06:44–07:02 | Hologram explanation and fusion-cell pickup | Control trigger, native pickup/ownership receipt |
+| 07:08–07:14 | Cell deposited; control becomes active | Accepted receptacle interaction |
+| 07:15–09:45 | Defend machinery, clear rock obstruction | Three authored defense groups, accounted squad deaths |
+| 10:18–11:18 | First Sunside ready room and window/exit | Room squads and exit devices |
+| 11:18–13:30 | Exterior bridge and shade route | Authored deck triggers and native squads |
+| 13:32–15:08 | Second ready room fight | Two groups; room checkpoint |
+| 15:20–16:44 | Red passage, machinery rooms and ascent | Chamber/meat-grinder/ascent trigger sequence |
+| 17:08–20:08 | Foundry/Bruiser, then exit opens | Three Foundry groups, hatch and ramrod devices |
+| 20:32–22:00 | Energy stream, security area and Interceptor | Chute-volume restrictions and authored vehicle object |
+| 22:50–27:20 | Light's End, exchanger/core destruction, cell | Native damage monitors followed by cell delivery |
+| 27:30–28:40 | Cell overload and upper-rail escape | Native receptacle, scenes, escape-end volume |
+| 28:42 onward | Escape exterior movie, then character cinematics | Authored STM/CNN states and matching termination incidents |
+
+The recording establishes action order, not exact native condition logic. Vent cadence
+is an explicit reconstructed18-second-open/8-second-closed policy pending live tuning.
+The recording does not prove every reinforcement trigger or optional squad timing.
+Native slot positions and combat objectives supply placement/AI. Do not describe these
+Lua files as recovered retail mission scripts.

+ 1161 - 3
MISSION_EMBER_STATUS.md

@@ -1,14 +1,1172 @@
+## Finale follow-up — 2026-09-06
+
+Changes for the latest full-run reports:
+
+- Beam warning/exposure now drives laser/ring power as well as position. The previous script only moved the devices after leaving their power continuously enabled.
+- Escape enables the authored thermal hop-on over the five narrow hot-pipe volumes and the authored rail-top effect over its authored volume. Effects are removed before ending transit and re-armed on an escape checkpoint restart. Hazard setup has its own callback.
+- HUD directives now name their type-70 engagement audience. Native `1009740 -> A76E90 -> 9F1290` checks membership; `9F2A00` includes active players with flag bit 0 clear. Earlier messages left this reference absent. The public Lua API validates this as type 70, independently of type-34 damage filters.
+- Both ending movies transit through the existing, unplayed region-49 cinematic staging state before selecting their own region. The observed region-0 -> region-1 same-bubble teleport stalled before cinematic start; staging makes both bookend transitions cross-bubble. Movie playback still waits for held-region acknowledgement and exact completion/skip incidents.
+- Electron Controllers retain individual access-objective task assignments (groups 9/10, task slots 40/41), rather than following later cost changes into other access tasks. The group-to-controller association is inferred from the authored dispenser task ordering and needs a spatial gameplay check; native combat logic remains enabled.
+- The squad observation pool reuses empty records when full, preserving records that still have population or accounted slots.
+
+Installed 18 files into the closed game at 11:24, with backup `build/finale-followup-backup-20260906-112405`. DLL SHA-256: `1ca1f37ea2735aeb0dab6d4a7522937e3cc9c66a5a09b297448253081887f97c`. Save, settings and SDK files were preserved. Manifest: `build/finale-followup-installation.json`.
+
+Validation: Release DLL built; 21 portable C++ tests and all five Lua suites pass. The full route uses 233 durable variables, at most 61 intents and 13,000 mock-instrumented Lua instructions per callback. Added coverage for engagement reference encoding, multi-volume effect filters, fixed controller assignments, and staging arrival before ending playback. No game was launched or stopped.
+
+**Gameplay verification remains required:** beam/screen visuals, exact Controller task geometry, escape damage amounts, visible banner refresh, and completion of both staged bookends. The automated checks validate script progression and wire shapes; they do not prove these visual/native behaviors. The tube/noclip issue was left alone as requested.
+
+# Latest script fix: foundry callback sandbox compatibility
+
+Installed 2026-09-06T10:46:06.446791; backup `/home/millie/Documents/Sunrise-builds/mission-ember/build/foundry-script-backup-20260906-104606`.
+Only `mission_ember/cinder.lua` changed in the game. DLL/settings/save unchanged.
+
+Live playtest fault at t617130: `cinder.lua:122: attempt to call a nil value (method 'find')`.
+The production sandbox removes string pattern functions. Replaced the prefix search with
+exact checks for the three foundry groups. The full-route harness now removes the same
+string functions during callbacks: it reproduced the original fault, then passed after the fix.
+The full route still peaks at 228 variables, 61 intents/callback, two timers and 13,000 mocked
+instructions/callback. No tube behavior changes; user identified tube deaths as a noclip issue.
+
+This run confirmed refinery cell consumption and healthy entity reclamation through the
+foundry. The script fault prevented validating the reactor finale. No game launch performed.
+
+# Latest installed: reactor finale progression and native behavior routing
+
+Installed 2026-09-06T10:29:48.150688 — DLL `bed8bd2ac98f4f39e0229c0535540c2360b6697c609360bb3aab792a0efab8c4`.
+Backup `/home/millie/Documents/Sunrise-builds/mission-ember/build/reactor-finale-backup-20260906-102948`; all 18 files hash-verified. Game closed; settings/save/SDK preserved.
+
+[Reference review, changes and validation](MISSION_EMBER_REACTOR.md).
+
+- Interceptor exit requires both authored Electron Controllers; supports/vehicle boarding do not gate it.
+- Correct mandatory-float damage Sense decoding unblocks clamshell/central target death events.
+- Retracted initial bridges, both-target extension gate, central target creation, explicit door unlocks,
+  shared warning/exposure/recovery cycle, fusion-cell route and consumed deposit to escape.
+- Authored later-area sequences/scenes resolve the live state while retaining the original seed lease.
+- Spaced finale dialogue, carry delivery navpoint, intermediate music milestones; native beam/ring controls.
+- Release DLL, 21 native tests, five Lua suites and diff checks pass. Full route peak: 228 variables,
+  61 intents/callback, two timers, 13,000 mocked instructions/callback. No game launch performed.
+
+Live confirmation still required for final-area bridge polarity, beam/screen VFX, alarm/shutter cadence,
+and audible music selection. Music section mapping and pacing are reconstructed, not original recovered Lua.
+
+# Previous installed: native entity transmission and purge sequencing
+
+Installed 2026-09-06T09:55:13.047971 — DLL `d6bc68793c009a883a420095a7c908a2a60a71336aea204aca2e6cc51559fbd4`.
+Backup `/home/millie/Documents/Sunrise-builds/mission-ember/build/replication-gate-backup-20260906-095512`; 18 files hash-verified. Settings/save/SDK preserved.
+Game was closed throughout installation; launch remains manual.
+
+09:40/09:41 read-only captures found 57 then 93 detached authority records, all waiting
+for the remote host. Native entity view +8/+9/+10 was 1/1/0, pending packet list empty,
+and server packet/ACK counters advancing. Membership field6 (mask_6) was omitted.
+Native 4DF650 reads that mask; 1702580 passes its member bit to 1703910, which calls
+16E5F30 to set parent view +178 (entity subview +10). 1712E70 refuses all transmission
+while that byte is zero. The new membership encoder explicitly publishes the remote
+host bit, or zero when no host is advertised. Sizes increased by 32 bits.
+
+Also corrected msg25 sequencing: the earlier area purges freed records but logged
+`incorrect epoch sequence` (first at t79981, then every area purge). Native 170B030
+requires the entity manager's own epoch +1. This is separate from msg44/common
+replicationEpoch. New binding-owned authorityEpoch starts at zero, increments only
+after the authenticated response is published, and refuses overflow at 255. Exact
+client-requested masks are retained; msg33 remains excluded.
+
+New 15-second `ev=entity_replication stage=census` diagnostic reports authority record
+count (capacity512), detached records, enabled views, pending deletions, packet-bearing
+views, and the native authority epoch. Reads only; native deletion/ACK handlers own frees.
+
+Validation: 20 portable native tests, five Lua suites, full cross-compiled DLL and
+`git diff --check` passed. No live validation of this candidate yet. Next check: kill
+opening enemies and verify enabled=1 and detached/pending deletions fall, then cross
+into Refinery and confirm successive purge epochs without assertions. Full mission
+Bird resolution remains unconfirmed. The prior run reached reason15 at612/1024
+simulation records despite successful area reclamation and fusion-cell consumption.
+
+# Previous installed: native authority cleanup and cell consumption
+
+Installed 2026-09-06T09:23:06.986888 — DLL `8d8772ec2c61bd60421f4f8b0600dc30697160fcf1c31040304b277a6bf8824c`.
+Backup `/home/millie/Documents/Sunrise-builds/mission-ember/build/authority-cleanup-backup-20260906-092306`; 18 files hash-verified. Settings/save/SDK preserved.
+
+Client abandoned-slot and purge requests (26/27) now receive exact-mask native purge (25)
+responses with connection/session/epoch checks. Msg33 live authority transfers are excluded.
+Replication epoch state advances when msg44 publishes. Valid transport ACKs release outgoing
+contributions independently of unsupported incoming gameplay lanes, preventing an exhausted
+packet ring from blocking further acknowledgements. Native record census logs changes every
+15 seconds, including below high pressure. Accepted cell deposit explicitly consumes the pickup
+with a newer inactive Auth generation; recovery stays disabled after completion.
+
+19 portable tests, five Lua suites and cross-compiled DLL passed. Full route:221 variables,
+60 intents/event,1 timer,12000 mocked instructions/event. Native reclamation/Bird resolution
+and carried-cell removal **still require live confirmation**. Latest failure was1024 records,
+with742 detached records in the1019-record capture. See `build/full-mission-audit/PLAYTEST-20260906.md`.
+
+# Previous installed: native squad cleanup
+
+Installed 2026-09-06T08:55:26.045462 — DLL `54c842f6d41c86e2ae675d8176ae0196c15c809bddb8e9b6059106c7fc05358b`.
+Backup `/home/millie/Documents/Sunrise-builds/mission-ember/build/native-cleanup-backup-20260906-085525`;18 files hash-verified. Settings/save preserved. No game launch/stop.
+All-zero squad requests now select native destruction (.18=0), with a fresh generation,
+instead of the previous kill-only path (.18=1). Covers existing encounter retirements and
+checkpoint resets; positive spawn requests are unchanged. Native simulation-record usage
+is logged at high pressure and on recovery.17 portable tests/build passed; Bird resolution
+requires live test. Pool remains1024. Refinery/Sunside fixes below remain installed.
+
+Main objective wording remains authored: Find and disable... until reactor; then Sabotage...,
+Deliver the final blow..., Escape... . User was informed; no speculative HUD change applied.
+
+# Latest installed correction: Refinery ownership/prompt and Sunside timing
+
+Installed 2026-09-06T08:48:06.799329 — DLL `e628a3bf733b40546b459f5c944e6589f7a7f87df756292a66d12a750e0686fd`.
+18 files hash-verified; backup `/home/millie/Documents/Sunrise-builds/mission-ember/build/refinery-fix-backup-20260906-084806`. Settings/save preserved; no game launch/stop.
+Cell ownership decoding now uses required raw64 (80809ACC flags0x21 are custom, not optional).
+Removed inverted enable_use override: F32CD0 confirms native+704=false permits unfiltered use.
+Sunside indoor checkpoint/darkness/dialogue moved beyond the far door; outdoor spawn preload remains.
+17 portable tests and5 Lua suites passed. Needs live verification. Bird remains reason14 native
+simulation records1024/1024; user asks about increasing pool allocation, investigation active.
+
 # Ember implementation status
 
-## Landing spawn selection — 2026-09-05
+## Opening regression corrected
+
+Installed 2026-09-06T08:15:31.534338;18 files hash-verified. Backup `/home/millie/Documents/Sunrise-builds/mission-ember/build/opening-regression-backup-20260906-081531`. Settings/save unchanged; game was closed and was not launched.
+
+Fixed missing-Sense-payload eligibility that faulted the mission before later opening triggers.
+Restored the working generic lever interaction; explicit use override is now receptacle-only.
+17 native tests and opening/full-route Lua regressions pass; fresh live test required.
+
+## Playtest corrections — installed 2026-09-06
+
+Installed 2026-09-06T07:37:29.613768: DLL and17 Lua files, all18 SHA-256 verified.
+Backup `build/playtest-fixes-backup-20260906-073729`. Manifest `build/playtest-fixes-installation.json`.
+DLL `8d28a8a686cc97c32dc08c510e5b640098f7f8dba729755af37de83e8623ce45`. SDK/settings/save preserved. No game launch or stop.
+
+Refinery/drill/cell recovery, end-of-drill hatch, interceptor-use door, objective mapping,
+clamshell/core progression, and native music support are implemented and built. See
+[playtest audit](build/full-mission-audit/PLAYTEST-20260906.md) for exact mechanisms and evidence.
+Valid partial Sense packets now preserve complete object/damage/life receipts. Reactor targets
+also accept guarded native present/alive-to-dead receipts. Clamshell power/lighting and alarm
+cues are wired; music uses authored section bits with inferred milestone timing.
+
+17 portable tests and all five Lua suites pass. The current candidate still requires live
+verification of insertion, boarding, shield visuals, core progression and audible music transitions.
+The earlier Bird pool-exhaustion cause remains unresolved. This is not a claim of a finished mission.
+
+## Full-mission candidate — installed 2026-09-06T01:32:12
+
+DLL SHA256 `1d33c97e9baec406afe12c614f11f2e81b87bfc1b24eef99b6bd01f540afb78e`. Backup `build/full-mission-backup-20260906-013148`.
+All 2130 DLL, SDK and script files were staged and SHA-256 verified.
+Settings/save were preserved; the game was not launched or stopped.
+Installer `/tmp/install-ember-full-mission.py`; durable manifest
+`build/full-mission-installation.json`.
+
+The implemented route now continues from the verified Powerhouse through Processing,
+Sunside, Foundry, Light's End, both reactor sides, core, cell overload, escape and the two
+ending cinematic states. The root controller dispatches native object-use/ownership and
+damage receipts. Ending completion/skip incidents advance only once; lifetime6 is selected
+after the second movie, based on the native completion/reward branch.
+
+The regenerated SDK is `build/sdk-mission-complete`. Audit:
+162 squad sensors,173 runnable definitions, zero sensors without definitions. Eleven
+Processing sensors legitimately point to two authored rule descriptors. Scoped source
+resolution now separates campaign and arcade reuse; Lua addresses every sensor once.
+The new120 later squads all retain authored combat objectives and task-group bounds.
+
+Processing includes native cell pickup/deposit, staged defense, machinery, debris, hatch
+objects and exit. Cinder includes ready rooms, exterior/retreat squads, chamber/meat-grinder/
+ascent, Foundry and beam restrictions. Apex includes access/security, Interceptor, vent
+and core objects, native damage monitoring, reinforcements, overload and escape scenes.
+Cell recovery is generation-aware and waits while its region is unloaded. Missing target
+health or an initial zero cannot advance destruction. Completed areas preserve the current
+forward objective on backtracking.
+
+Checkpoint sets: Mercury `9C58857A`/64; Processing `4B27745D`/56; ready room2
+`82328D63`/40; Foundry `782CAF4C`/40; reactor entrance `DF59C25C`/0; escape
+`45920385`/0. These are package spawn sets, not invented coordinates. Reactor reset
+publishes removals first and repopulates on the first playable post-wipe client update.
+The native countdown/3-versus30-second respawn policy is unchanged by the later route.
+
+Validation: Windows DLL built;16 portable CTest tests passed; five Lua suites passed,
+including the full route using the final generated SDK, every later checkpoint, skipped
+narrow volumes, backtracking, lost cells, duplicate cinematic receipts and stale damage.
+Full-route simulation:213 later variables (Powerhouse suite63),59/63 intents per busiest
+event,12,000/100,000 Lua instructions per event including the mock,1/32 timers peak.
+All162 authored squad sensors are mapped exactly once. Simulator population reports
+exercise native member lanes; a simulation is not evidence of actual actors appearing.
+
+### Remaining live validation and reconstruction limits
+
+This is an installed full-route implementation candidate, **not a completed live playthrough**.
+The user-confirmed landing baseline is preserved. Later placements, AI task choices,
+pickup consumption/expiry/co-op ownership, object animations and authored spawn-set
+suitability must be checked in the game. Native navpoint display, visible respawn timer,
+Darkness Consumes You presentation, total wipe timing and multi-client membership also
+await manual validation.
+
+Vent timing is reconstructed18s open/8s closed, not recovered retail timing. The native
+lethal target effect is applied only after a confirmed health-zero receipt; it is not an
+invulnerability switch. The thermal/exposure system is incomplete: deck heat shimmer and
+scoped grinder damage are wired, but sunlight-versus-cover damage, Foundry/core thermal
+hazards and the timed escape-failure damage policy are not fully reconstructed. Beam
+restrictions are wired; propulsion relies on the map's native behavior and remains unverified.
+The optional catwalk secret lever and three skybox flyby objects retain the existing baseline.
+Final STM/CNN movie ordering and lifetime6's post-movie behavior need a live check.
+Do not call the whole mission retail-equivalent or fully playable until those checks pass.
+
+Earlier status entries below are historical; the installed build above supersedes them.
+
+
+## Navpoints and checkpoint wipe — installed 2026-09-06 00:04
+
+Candidate DLL SHA256 `7e58c4d61dfb4e6996f29a59a0c340ff3bacf3ded99b55e4b5dea0c6fbb338f0`.
+Backup `build/nav-wipe-backup-20260906-000436`; installer `/tmp/install-ember-nav-wipe.py`.
+Nine runtime/script files installed and SHA-verified. Settings/save unchanged; no game launch/stop.
+
+Type68 now accepts `set_directive{directive=..., navpoint=context:slot(authored_type47)}`.
+Native100A6E0 consumes Auth80804F6B .11 /80804F70 .0 as the target ClientRef.
+Mode2 uses native route/direct destination selection; empty markers carry the proper
+811C9DC5 fallback-name sentinel. Landing Lua publishes Mercury approach, Ghost-console,
+and helipad approach targets outside active encounters. Targets retire on combat/arrival,
+streaming away and wipe cleanup; completed areas do not re-arm them by backtracking.
+This is encounter-based combat gating, not a recovered player threat-meter edge.
+
+The type13 Sense decoder now reports native death-with-Ghost evidence. Joined private
+sessions sharing the exact destination descriptor (including its selection nonce) supply
+alive/dead/unknown counts; public residents are excluded. Loading/unknown peers block
+all-dead detection. This uses Sunrise's current private cohort representation; multi-client
+fireteam membership and simultaneous wiping still require a live co-op test.
+
+All dead during darkness publishes type35's native three-second countdown, then arms
+the E51D80 membership hard-wipe machine. Host state1 starts it; client state2 triggers
+encounter reset; client4 is held until reset outputs are staged, then host4 releases it.
+The authored Mercury spawn set9C58857A replaces mission arrival for the checkpoint.
+Bridge devices retract, Ghost is re-enabled at a new generation, bridge combat resets;
+completed approach encounters and the lever remain complete. Rescanning starts a fresh
+ship lifecycle. Per-attempt timers reject old unload/departure/countdown events.
+
+Validation: Windows DLL build; native wire tests for navpoint encoding, player life,
+visible3/30-second respawn policy and wipe3/2/1/0 timer; Lua tests for revival cancellation,
+unknown-peer refusal, ordered reset/release, repeated checkpoints, old receipts and ship
+replay, plus existing encounter/AI/controller regressions. The countdown timer uses
+host-stepped native clamped values rather than an invented client-clock epoch.
+**Native HUD marker appearance, countdown presentation, Darkness Consumes You fade,
+checkpoint spawn and co-op behavior await manual gameplay validation.** Earlier entries
+below are historical build notes, not the current implementation state.
+
+## Visible respawn countdown — installed 2026-09-05 23:26
+
+User confirmed the old delay appeared as `Finding Spawn Location`, with no visible
+countdown. DC3770/DC1A20 enforce a late spawn-location hold; they do not own the HUD
+respawn timer. Removed that detour and restored participation +736's bypass.
+
+The native death-timer initializer12E9330 reads BF2480(player datum)+4, converts
+IEEE half through F32350, and stores a duration at player+64. HUD1677E60 reads
+that timer through12EE500 (rounded seconds) and12EE590 (fractional seconds).
+BF2480 -> BF4380 resolves type13 Auth+736, as established by FA00D0's binding.
+Therefore Auth80804F30 .3 ->80804F31 .3 (native+740) now explicitly carries
+half30 (`4F80`) during darkness, half3 (`4200`) outside it. This adds16 body bits
+only for a scripted darkness policy. The adjacent optional timer and independent
+awaiting-client-sync flags are preserved. Unscripted participation bodies are unchanged.
+
+Validation: Windows DLL build, darkness-zone wire regression covering both durations,
+loading suppression, release/re-entry, unscripted behavior and following-field alignment;
+Lua controller regression; diff check. Installed eight runtime/script files with SHA
+verification and a backup, preserving settings/save. No game launched or stopped.
+**The visible countdown needs the user's manual test. Checkpoint wipe is still unfinished.**
+
+DLL SHA256 `30e0b6738d9a59d467857511d95a02dbe6652ae28c9b7a4470022f3021d2d0b6`.
+Backup `build/visible-respawn-backup-20260905-232608`.
+Installer `/tmp/install-ember-visible-respawn.py`; manifest `/tmp/ember-visible-respawn-manifest.json`.
+
+Wipe investigation: F9CC60 publishes type13 Sense80804F2F .0 ->808094E4 .5
+(nativeSense+11) from player+2112==-1 AND a valid handle from352310(player+80).
+This is stronger death evidence than missing actors during loading. No player_died4053
+incident was present in the latest archived run. Existing VM peers are destination peers,
+not proven fireteam members; do not blindly use their count for a fireteam wipe.
+Hard-wipe globals still publish mode1=-1 and an inactive timer. An actual checkpoint
+reset must also rearm the relevant encounter, ship generations and timers exactly once;
+ordinary region return remains one-and-done.
+
+## Native Harvester exit action — installed 2026-09-05 22:57
+
+User confirmed ship exits work in the subsequent manual test. Archived log:
+`build/first-encounter-audit/ship-exit-respawn-test-20260905.log`.
+All four ships logged native action7B0D3643/index4 starting; live metadata uniquely
+resolved group07EBF354. The countdown correction does not change ship behavior.
+
+Recovered exact FNV-1 names in resource `80FE21CE`:
+`enter_90` 4182A2AF / index0, `enter_25` 4A82B0FF / index1,
+`enter_45` 4482A76D / index2; `exit_90` 820D414B / index3,
+`exit_25` 7B0D3643 / index4, `exit_45` 7D0D39A9 / index5.
+This replaces the earlier unmapped-action hypothesis. The shallow `exit_25` is selected
+and the user has now confirmed the departure works.
+
+Lua now sends one `play_actor_action` at second-path completion. The actor generation
+stays1; action revision advances from2 to3. It retires at revision3 completion or death,
+not revision2 completion. A separate four-bit variable prevents repeats after duplicate
+receipts or script reload. The existing6-second unload delay and4-second departure hold,
+A/B manifests, empty C/D ships, working doors and encounter progression are retained.
+
+Native wire: root80807DA1, .6 one kind9 command,254 bits. It carries group, action,
+empty additional identity, absent spatial target and mode/marker -1. Composition keeps
+the completed delivery request and actor generation. New API is documented in the Lua
+contract and accepted by the server's bounded Auth validators.
+
+The native consumer is A97800 -> AB4030 -> A054C0 -> A889E0. The group name is
+not guessed: for an empty-group exit request on actor80FE22FC only, the A054C0 hook
+searches that live actor's native metadata for exactly one group containing the authored
+variant. It passes the recovered group back through the original native lookup, retaining
+both native output indices. Missing/ambiguous tables fall back to original behavior.
+The latest live run confirmed unique group07EBF354 resolution. All unrelated
+actors/requests pass through.
+
+Hooks1023F00 and10245C0 observe native sequence start/stop. They do not force playback,
+completion, deletion, or change controller state. Logs: `harvester_exit stage=resolve`
+(unique/result), `harvester_action stage=start` (active index), and `stage=stop`.
+The stop log can also represent cancellation; compare it with revision3 Sense completion
+and the visible effect. Exact executable prefixes guard all new detours.
+
+Validation: Windows DLL build; mission Auth composition/action validation test including
+preserved cargo/generation and malformed padding; existing path packet regression;
+Lua controller (61 durable variables), encounter and AI tests; diff check. Controller
+covers duplicate/reloaded path receipts, action-running receipts and one retirement per
+completed action. No game launched or stopped. Eight installed files SHA-verified;
+settings/save preserved.
+
+DLL SHA256 `96601b6c1a71dd7f930499f4af39265ca08da0f9d8b69d4c53dc5f1aef1ab3d3`.
+Backup `build/native-exit-backup-20260905-225739`.
+Installer `/tmp/install-ember-native-exit.py`; manifest `/tmp/ember-native-exit-manifest.json`.
+All-dead checkpoint wipe remains unfinished from the earlier scope; this update focuses
+on the ship departure request. Earlier skip and3/30-second respawn changes remain installed.
+
+## Respawn timing correction — installed 2026-09-05 22:34
+
+User explicitly requires30 seconds while darkness is active and3 seconds otherwise.
+DC1A20 hook now selects30/3 via native E4C110 instead of falling back to content's
+possibly-negative delay. Type13 Auth retains the native timer in BOTH states when
+hasDarknessPolicy is set; the previous unrestricted bypass would skip the3 seconds.
+Missions without a scripted darkness policy retain their existing participation body.
+Initial spawn and loading suppression remain native. All-dead checkpoint resets and
+ship exit effects are still unfinished; native timing/HUD awaits the manual test.
+
+Build, darkness-zone wire test (activation/release/re-entry/loading/unscripted cases),
+mission controller regression and diff check passed. Eight installed files hash-verified;
+settings/save unchanged; no game launch or stop.
+DLL SHA256 `8a962d5061a3f4dd00535fe3af634ed86e360ad94efd9a32fc24919fd8314ee6`.
+Backup `build/respawn-3-30-backup-20260905-223418`.
+Installer `/tmp/install-ember-respawn-3-30.py`; manifest `/tmp/ember-respawn-3-30-manifest.json`.
+
+## Escape skip and respawn-delay test build — installed 2026-09-05 22:31
+
+Harvester doors are user-confirmed working. Ship exit visual remains unresolved;
+no entrance-effect replay has been implemented. Working ship timing/cargo and
+opening combat progression were retained.
+
+`cinematic_skip`: SObject3338, FNV-1 `7352DAFE`, type17/schema808087BF.
+Native1069BE0 checks the active/skippable cinematic and emits it through106A530,
+the same payload builder as cinematic_started5239 and cinematic_finished1685.
+Existing game logs already contained two ignored3338 requests. The host now
+resolves this exact cinematic ClientRef and emits cinematicSkipRequested. Lua
+uses the same idempotent stop/select-landing-state handoff as natural completion.
+A later finished notification or repeated Escape cannot repeat that handoff.
+
+The native DC3770 respawn gate compares elapsed time since player+1928 against
+DC1A20's delay. New guarded darkness_respawn hook returns30 seconds while the
+native E4C110 darkness flag is active, otherwise preserving the authored delay.
+It keeps the native gate/timer and initial-spawn bypass. **Neither visible HUD
+countdown nor multiplayer behavior has been verified. All-dead checkpoint reset
+is NOT implemented in this test build; the delay alone is not a team-wipe policy.**
+
+To verify player identity before writing a wipe reducer, bounded read-only
+player_lifecycle logs retain incident payload chunks, sending session, primary
+row, and lifecycle target mask. The death can be an EXTRA target of a combat
+incident; do not equate sender with victim. Known targets:1121 player_spawned,
+439 player_respawned,7117 player_resurrected (schema80806437),4053 player_died
+(schema8080645A),5896 hard_wipe (schema808087B7). No reset is triggered from an
+unverified death field. Required policy:30-second respawn while a teammate lives;
+all members dead => wipe to beginning of latest objective; retain earlier cleared
+sections except when deliberately resetting the selected checkpoint.
+
+Next manual test: Escape during intro, then a death after Ghost extends the
+bridge/activates darkness. Capture the lifecycle payload and actual respawn delay.
+No game launch or stop by the agent.
+
+DLL SHA256 `ccc0ed14ad0beec131a81056c7a70d3753f6f7e56b498ba4ac71249c7e5c049b`.
+Full DLL build, incident payload test and three Lua suites passed; diff check clean.
+Eight DLL/Lua files installed and SHA256 verified. Settings/save unchanged.
+Backup `build/skip-respawn-test-backup-20260905-223142`.
+Installer `/tmp/install-ember-skip-respawn.py`, manifest `/tmp/ember-skip-respawn-manifest.json`.
+
+## Combat and Harvester doors confirmed — installed 2026-09-05 20:06
+
+User reports all opening enemy spawns correct and darkness clears correctly. Harvester doors are now user-confirmed working. The
+remaining opening blocker is the visible exit effect. Keep the successful
+Lua spawn/timing/progression behavior stable. The user explicitly suggests replaying the
+entrance effect before deletion as a reversible exit experiment; this is authorized, but
+the actual entrance-effect trigger is NOT yet identified or implemented.
+
+The 19:32 hook installed but emitted no per-release reports. Its filter had two incorrect
+assumptions: source-root+4 is a resource-header field, not the owning actor tag; native
+resolution from the live delivery instance points to class8080670A, not8080670B. The
+new filter matches all four captured ships against the actual extracted resource.
+
+Read-only manual bridge capture: `ship-runtime-20260905-195257` through `195317`.
+Summary: `build/first-encounter-audit/animation-live-summary-20260905.json` (150 component
+samples). Delivery advances0 ->3/published1 ->5 ->0. Its four bindings+80/+160/+240/+320
+are all invalid references in live memory AND the authored80FE22FC overrides. Forcing
+these actions cannot open this model's doors. Separate80FE21CE controller has13 populated
+sequence bindings but active-action stayed-1 in our 1Hz samples. This does not rule out
+an action shorter than the sampling interval. Mapping saved in `harvester-action-map.json`.
+
+Found actual door names in model controller80FE22CB: source starts0x198, `dropship_doors`
+at file0x368 is FNV-1 D784D6F6, and `doors` at0x388 is FNV-1 80296344. Export80FE2309
+binds the former to class80803D06/feature10;80FE230A binds the latter to feature9.
+These are authored names, not invented native action IDs. Exact target semantics still
+require a visual test.
+
+Replaced the empty-action experiment with a102A5B0 delivery-stage hook. Before native
+stage1, request named scalar `doors=1`; before stage0 after delivery, request `doors=0`.
+Use native576420(entity, &nameHash, alignedFloat4), the same route used by A9BFA0's actor
+set-channel command. Source/tag checks match only80C0E5D2/class8080670A. Native stage
+arguments/result, manifest, flight timing, progression and actor retirement are retained.
+Log `harvester_doors` reports installation and requested values, NOT proof of visible motion.
+
+Installed DLL SHA256 `0667a285b63e64ded6b752c1d0c9736fdfbe976fa73e04470973b9c618cbc31a`.
+Build and three Lua suites passed; exact hook/helper prefixes and corrected source filter
+verified using extracted binary plus all four live delivery captures; diff check clean.
+Installer `/tmp/install-ember-model-doors.py`, manifest `/tmp/ember-model-doors-manifest.json`.
+All eight installed files verified. Backup `build/model-doors-experiment-backup-20260905-200649`.
+Game was confirmed closed; no launch or stop. Settings/save unchanged. The user has now
+confirmed visible door motion. Read-only capture watcher stopped after the four ships retired.
+
+## Forced Harvester pre-drop action — experimental build installed 2026-09-05 19:32
+
+User explicitly authorized trying the embedded component at troop release and reverting
+if it breaks. Added `client/hooks/harvester_drop/harvester_drop_experiment.cpp`, activated
+only with mission scripting enabled. Exact native prologues guard the hook/helpers;
+the release callback additionally checks source-root `80FE22FC` and delivery source
+`80C0E5D2`/class `8080670B`. This is a temporary native experiment, not a verified door API.
+
+Hook `102A920` immediately before native passenger release. Resolve the authored stage2
+pre-drop binding at delivery runtime+240 and invoke `C7AFA0(binding, 3D30F0)` even when
+zero native wait skipped that action. If previous published stage is2, native already
+started it and the hook does not restart it. Invalid binding logs `unbound` and is skipped.
+Keep native release arguments/result, manifest, timing and cleanup. Generic component
+activation through `44E340` already occurs in native `1029330`; this experiment forces
+the separate action rather than merely repeating that existing activation call.
+
+Log event `harvester_drop_experiment` reports installation and each matched release:
+`invoke`, `already_started`, `unbound`, or `exception`. Actual door motion is unverified.
+Build passed; all three Lua suites passed; exact guard bytes checked against extracted
+game image; diff check clean. Game was closed and was neither launched nor stopped.
+
+Installed SHA256 `14f196a2202dfc80219dfed80308201879607df621e6e7d385fa4e99750a7dfe`.
+All eight DLL/script files verified with `/tmp/ember-drop-action-manifest.json` by
+`/tmp/install-ember-drop-action.py`. Backup of previous DLL/scripts/settings:
+`build/drop-action-experiment-backup-20260905-193242`. Settings/save unchanged.
+This installation ALSO includes the previously pending near-side squad changes,
+six-second total unload timer (three additional seconds), and Bird diagnostic below.
+Revert the experiment at source by removing its activation call; the backup restores
+the previous installed runtime and scripts if the entire test build must be rolled back.
+
+## Embedded Harvester controls — investigation, not a door fix
+
+The absence of a named type23 ship-door slot does not rule out an embedded control.
+The user's model-component suggestion is valid. Offline inspection found two separate
+mechanisms in the native delivery component; neither is yet verified as the door animation:
+
+- Delivery start `1029330` calls `44E340(component, source+96, true)`; state0 clears it.
+  Its package binding runs through `80C0E5D2`, the `80FE22C9` instance override, and
+  `815B963A` (component class `80803C23`, feature6). `44E340` resolves that binding and
+  calls `590B10` with category3. The lever animation setter `DF6C70` uses sibling
+  `44E3C0`, which reaches the same helper with category0 and a different binding type.
+  `594CF0` changes component bit masks; `56FD30` updates entity category bitmaps.
+  This is evidence of shared component-activity machinery, not evidence that category3
+  or feature6 means "open doors". Do not expose a guessed door switch from these numbers.
+- Delivery stages invoke bindings at runtime+80/+160/+240/+320 through `C7AFA0`.
+  That function checks the resolved handle at binding+56, returns if it is invalid,
+  otherwise resolves the component and invokes the supplied callback. Start callback
+  `3D30F0` supplies float0 to `58E420`, which forwards to `58E260`; reset/stop callback
+  `587970` clears sequence state and releases its tracked child references via `58E630`.
+  Native stage replication `1028FB0` also switches these actions. These stage bindings
+  are stronger animation candidates, but their actual Harvester targets still need mapping.
+
+Evidence is in `build/first-encounter-audit/live-44e340.c`, `live-44e3c0.c`,
+`live-590b10.c`, `live-594d70.c`, `live-56fd30.c`, `live-1028fb0.c`, and the raw
+disassembly at the listed RVAs in `native-readable.exe`. Old slab captures contain
+source/component references; do not mistake those for the separately allocated delivery
+runtime or treat guessed "door" capture offsets as verified animation components.
+No gameplay patch or installation was made for this investigation.
+
+## Near-side pre-bridge combat, later unload and Bird diagnostic — included in 19:32 installation
+
+Candidate DLL SHA256 `d7d296ef67e8defe3d0a30c65e73a68446fe414b4480d0096d6cd078addfef0b`.
+Release linked; three Lua suites passed; diff check clean. Peak durable variables60.
+Installer `/tmp/install-ember-prebridge-bird.py`, frozen file manifest
+`/tmp/ember-prebridge-bird-manifest.json`. Game PID368720 still running at pre-install check;
+user asked to close it. This was the earlier blocked installation check; these changes
+are now included in the 19:32 experiment installation above.
+
+- Move BOTH `BRIDGE_VIGNETTE_SUPPORT_A/B_SQUAD` into required Mercury combat. Their
+  authored anchors (-420.5,79.5,-36.5) and (-420.5,73.5,-36.5) sit at the near end, not the far
+  monster closets. The eight required squads now gate Ghost. Keep their original bridge
+  AI objective14/task-group cost selection. Optional Crimson Shadow remains lever-only.
+- Actual previous log showed entry completion261710/261845 and native delivery264787/
+  264933: the existing three-second timer was running. Asked user whether to use bridge
+  completion or add three seconds; no answer received before proceeding with the stated
+  assumption of THREE ADDITIONAL seconds. Lua unload now waits3000+3000ms after entry.
+  Four-second post-unload hover and immediate second-path retirement remain intact.
+  If user clarifies the reference point, adjust this assumption; do not claim the old delay
+  was absent or describe the new total as only3000ms.
+- Bird at355562 is a native sobject creation failure: 16EE180 -> 170F190 -> 170B0F0.
+  There were116 free network indices/571 allocations. The last bridge support-A receipt
+  still had three alive at354175; no evidence that completed darkness release caused Bird.
+  Snapshot archived at `build/first-encounter-audit/bridge-clear-bird-test-20260905.log`.
+  The new diagnostic reads native failure reason at the log site before generic reason12
+  replaces it, and counts the separate1024-record simulation bitmap. Original code maps
+  reason11 to state heap,14 to simulation records,15 to authority records. Getter16CC5D0
+  is called read-only behind an exact-byte build guard and SEH; bitmapRVA030B0340 verified
+  against170B2B0 disassembly. No allocation/init hooks or speculative capacity fixes added.
+  Bird is NOT fixed yet; next reproduction must capture this specific reason.
+
+Ship-door answer: existing `transition` API accepts exact type23/device class80804F45.
+The named Harvesters are type2 actors with embedded delivery/animation components and
+have no named type23 door binding in this SDK. The lever call cannot be applied directly;
+we still need a verified native actor/component animation command. No ship-door/warp
+animation claim or guessed hash in this update.
+
+## Lever animation, Harvester hover and bridge-clear scope — installed 2026-09-05 18:55
+
+Lua changes tested against installed DLL
+`13513b002ab38633560f16e59e6dcd2984165540830816c9bba5640cf8d4161b`.
+Three Lua suites passed, diff check clean. Peak durable variables 60. No native code change.
+Installed with `/tmp/install-ember-hover-and-lever.py` after confirming Destiny closed.
+All seven installed script hashes verified against `/tmp/ember-hover-script-hashes.json`.
+Backup: `build/hover-lever-darkness-backup-20260905-185535`. Runtime, settings and save
+unchanged. No game launch or process stop. Manual validation of these changes is pending.
+
+- Use the separate authored type-23 lever device (f6ffb59e slot7) with the existing accepted
+  type-4 lever interaction. Initialize position0 with snap; move to position1 without snap
+  on use. The object's interaction and the visual animation are distinct native components.
+- Add a four-second departure hold after native unload completion and both cargo squads
+  are observed. Empty C/D also hold. Preserve the three-second unload delay and immediate
+  retirement at exit completion. Timers have durable scheduled/elapsed bits; duplicate
+  receipts and module reload cannot restart the pause or repeat departure.
+- Exclude helipad squads from bridge darkness completion. Their authored anchors are around
+  (-305,-294,-24), ~450m from the bridge fight at y140..190. Bridge/passengers/Landing Sun
+  remain required; a living passenger cannot be bypassed. Tests demonstrate last passenger
+  death clears restrictions inside region64 while helipad is still active and populated.
+
+User confirmed the previous build's lever interaction and improved despawn timing live.
+Archived test log `build/first-encounter-audit/lever-hold-darkness-test-20260905.log` shows
+bridge and Landing Sun cleared, but helipad retained 1+2+2 living actors. Passenger member
+squad f6ffb59e/type1/slot26 also last reported alive1/consumed0 (t263045); its lingering
+population is not explained by the helipad scope error. If the next test looks empty but
+remains restricted, capture that actor's actual position/state before claiming all native
+squads are clear. Do not silently remove it from completion requirements.
+
+Harvester door/warp animations remain unresolved from the prior task. This change adds
+hover time and the Mercury lever's authored animation; it does not invent ship animations.
+
+## Lever progression and packet batching — installed 2026-09-05 18:30
+
+DLL SHA256 `13513b002ab38633560f16e59e6dcd2984165540830816c9bba5640cf8d4161b`.
+Backup `build/lever-and-packet-batch-backup-20260905-183010`. Installer checked the real
+process namespace, confirmed Destiny closed and verified all eight installed file hashes.
+Save retained; SDK, declarations and mission scripting enabled. User launches manually.
+
+- Split the six required Mercury squads from five optional bonus squads (Crimson Shadow
+  anchor and four bonus war-beast groups). Optional squads have their own durable encounter
+  state. Route catch-up excludes them. Main combat alone enables Ghost; optional deaths
+  cannot replay the controls directive or block the scan.
+- The authored `LANDING_MERCURY_DOOR_BUTTON_OBJECT` now spawns at its package transform.
+  New typed `set_interactable_object` Auth carries entry zero plus registered interaction
+  child 80804FB8. Native F32220 requires that child before publishing reply 80804FB7;
+  F33A90 -> F36640 sets runtime+720 after accepted use. Auth revision zero leaves control
+  of that latch with native use, rather than forcing a false latch on every publication.
+  Exact-object `on_event_object_interacted` starts optional squads and opens the door once.
+  Removed the proximity-triggered door transition and bonus spawn.
+- Sun/helipad requests run immediately in the completed Ghost callback, ahead of the ship
+  start requests. Later bridge crossings do not respawn those waves.
+- Transport now drains up to 16 complete received frames per service pass. Previously only
+  one was consumed. Each response is flushed before another frame is consumed; partial input
+  and blocked output stop the batch. This addresses a possible packet backlog, but a live
+  timing improvement is NOT yet demonstrated. Lua still retires ships on second-path
+  completion; no extra retirement timer exists. The accepted 3000ms unload delay is retained.
+
+Evidence: archived latest log `build/first-encounter-audit/lever-delay-test-20260905.log`.
+Actor callbacks had queued_ms=0, so prior derived-event batching alone did not explain the
+remaining delays. Exit-start/retirement acknowledgements still lagged staged commands by
+roughly 12–34 seconds. These logs do not isolate client application versus reporting latency.
+
+Validation: release linked; all 12 portable CTest checks and three Lua suites passed; diff
+check clean. New fixtures cover registered object Sense, duplicate use, stale generation,
+partial packets, blocked response ordering, main combat completion without bonus combat,
+wrong lever identity, proximity rejection, and no replay after reload/return.
+
+Remaining: manual validation of the native lever and latency change. Harvester doors and
+animated warp-out are NOT fixed in this build. Current retirement is native deletion. Actor
+command kind 6 only edits a six-element behavior-tag set; kind 8 edits a transform; neither
+has been established as a door/warp action. Harvester behavior imports map to 80B3B399 and
+its component templates, extracted under the audit tags directory. No speculative parameter
+hash or damage-based retirement mode has been installed. Capture native delivery/door
+components during the user's next manually launched bridge encounter.
+
+## Event dispatch and encounter accounting — installed 2026-09-05 17:46
+
+DLL SHA256 `ee769dd010e9ab6a65b4f144e3f283ae88c823496520eba41d05919e7659b56c`.
+Backup `build/encounter-accounting-backup-20260905-174651`; installer checked Destiny closed
+in the real process namespace and verified eight installed hashes. Save retained; SDK,
+declarations and mission scripting enabled. No game launch or stop.
+
+Reviewed recording `/home/millie/2026-09-05 17-04-12.mp4`, including bridge and refinery
+transition. Respawning Restricted banner appears after the console. Archived corresponding
+log in `build/first-encounter-audit/bridge-darkness-test-20260905-1704.log`. All four
+retire-generation receipts eventually arrive. Delay from staged retirement to logged receipt:
+C/D about 33–34 seconds, A/B about 21–22 seconds. These timestamps alone do not distinguish
+client application latency from delayed host observation. Do not describe them as proven
+network latency. Native deletion still has no verified warp animation.
+
+Changes:
+- Derived script event dispatch now drains up to 64 ordered callbacks per service pass,
+  stopping on any pending output or fault. Previously one callback per pass could accumulate
+  a backlog under continuous squad-cost reports. Native input ordering and output commits
+  remain serialized. Actor callback diagnostics now include queue age and remaining count;
+  actor Sense diagnostics include the original receipt tick.
+- Pending squad Auth is installed after the retained estate, even if the squad lease already
+  installed it. Previously an older SDK objective body in the estate could overwrite the new
+  pending body in that same packet, silently deferring its effective publication.
+- Encounter zero-population completion also requires consumed counts for every authored
+  member slot. Native squad Sense .11 (+612 count, +616 values) accounts for consumed
+  requests: 4E8620 -> 4E8660 increments for unowned actor removal/death; 4E8200 increments
+  failed delivery requests. These counts are not births. A temporary zero while requests
+  remain must keep the wave outstanding instead of requesting zero counts and phase 2.
+  Accounting-only Sense deltas now reach the script. Decoder selects exact nested schemas
+  80807ECF/80809491, refusing partial or invalid lists rather than matching arbitrary widths.
+- Existing darkness trigger/release policy, three-second unload delay, manifests and paths
+  retained. No speculative door or warp opcode installed.
+
+Latest test helipad slots 12/13/14 reached positive population at 171762–172019, zero at
+174141–174701, and some repopulated afterward. Sun slots 56/57 similarly reached zero,
+then slot 56 repopulated. Old Lua immediately retired each group at the first combined zero.
+The guard fixes that premature cancellation when requests remain; the cause of initial
+actor losses is still unproven. Logs now include consumed totals for diagnosis.
+
+Validation: release linked; ten portable CTest checks and three Lua suites passed; diff check
+clean. New regression checks cover a 1000-event burst, strict output barriers and ordering,
+partial accounting rejection, temporary disappearance/repopulation and late accounting deltas.
+Peak durable variables remains 54. Manual test of this exact installed build is outstanding.
+
+Remaining: verify ship callbacks no longer lag, observe actual hangar deaths versus accounting,
+confirm darkness clears only after combat; capture live delivery/door components during the
+next user-launched run. Door animation and native animated warp-out remain unresolved.
+Additional offline RE maps command kinds 0–9 through A98E40; kind 6 (A9C060) manipulates
+actor behavior tags, kind 7 (A9BFA0) writes named scalar parameters via 576420. Neither is
+verified to be a Harvester door/warp command. Do not guess parameter hashes.
+
+## Bridge darkness policy and native ship cleanup — 2026-09-05
+
+Installed at17:02; DLL SHA256 `cbdc7a6eec5edcc41f382dc76635931c34db99966bdc5073137fde2a74bd35bc`.
+Installer confirmed game closed, backed up to `build/bridge-darkness-cleanup-backup-20260905-170239`,
+and verified all eight installed runtime/script hashes. Save preserved, SDK/declarations and
+mission scripting enabled. User launches manually; no game launch or process stop.
+Latest user instruction: enable darkness only on completed Ghost interaction starting bridge
+extension; release after bridge/passenger/Sun/helipad squads all clear. Earlier catwalk/pipe/
+Mercury combat is excluded. Release outside landing; return to cleared area cannot reactivate.
+Controller tests cover initial disable, stale/incomplete scans, duplicate scan, last remaining
+hangar squad, completed return, reload and unfinished earlier fights not blocking release.
+
+Native `set_darkness_zone` uses exact type35/class808099BD/schema808099BF. E4C110 reads
+Auth .0; E4C0A0 additionally compares lifetime native+12 against current bubble. Roster
+assembly derives policy from its final SDK Auth estate, pairs lifetime .5 with region/8 (64→8),
+and clears participation+736 delay bypass while active. Wipe countdown remains inactive:
+hardwipe .3=-1, nested .4 timer inactive. This is NOT verified full checkpoint/wipe recovery;
+manual death/restriction/HUD testing is still required. No claim that this fixes Bird.
+
+Live observation proved all four entry/exit revisions acknowledged. Each A/B had two native
+cargo refs; C/D had none. All four outgoing disabled bodies reached native Auth, but actor
+indices stayed live minutes later. Root cause: live Auth callback AB6E20 invokes AB71E0,
+which retires only on generation CHANGE; AB7350's enabled=false only inhibits creation.
+Previous AB78B0-based assumption described a different callback and was insufficient.
+Retirement now advances generation1→2 at second path completion and sends empty delivery
+manifest in the SAME update to avoid old cargo replay after the lifecycle reset. Native
+mode0 uses AB89A0 detach and 56A8F0 destruction. Visual warp animation is not established;
+this fixes the demonstrated missing cleanup, pending manual verification. Do not claim doors
+fixed: native delivery completes without the user observing door opening. Mode1 was inspected
+and routes through damage/death handling (3BD720/B43090/DDA5C0); do not assume it means warp.
+
+Sun and helipad groups now start with bridge activation (five squads), once each; seven deck
+defenders retain their crossing trigger. A/B unload delay remains3000ms. No ship timing change.
+Native header/codec tests nine CTest passed; all three Lua suites passed. Release build passed.
+Peak54 durable variables; native limit512. Generated Lua declarations now include deliver_squads.
+Capture archive: build/first-encounter-audit/harvester-two-carriers-20260905-1623.log;
+live snapshots ship-runtime-20260905-162329 through163042. Reviewed user's local recording
+2026-09-05 16-23-18.mp4; ships visibly park after flying. No causal proof for random deaths yet.
+
+
+## Three-second delay and two-carrier manifest installed — 2026-09-05 16:18
+
+Installed DLL SHA256 `d240bb7f528a3f97885e861e0ba2cdf380efad85d4fd78398a18e59a6252a3ea`.
+Backup `build/two-carriers-transport-backup-20260905-161841`; eight runtime/script hashes verified.
+Installer confirmed Destiny closed; save preserved; SDK/declarations and mission scripting on.
+User launches manually. No game launch or process stop.
+
+- Delay now3000ms per ship after its first authored path completion (user requested +1s).
+- A carries supportA+meleeA; B carries supportB+meleeB. C/D have no bridge passengers.
+  This follows the user's two-on-bridge observation and the authored entry/anchor positions;
+  exact original script pairing still reconstructed. All four retain bridge objective AI.
+- Added bounded `deliver_squads` native manifest1..8 exact distinct same-registry squads.
+  Two refs are one187-bit payload, preventing second-squad assignment replacing the first.
+  Existing single-squad API remains supported. A/B wait for native delivery completion and
+  positive population from both of their squads. C/D depart after their arrival timer without
+  waiting for a nonexistent delivery. All retire once on second path completion.
+- Native movement/doors/warp-out are NOT claimed fixed. Latest objective test still had
+  one stationary ship and no acknowledged exit revision before Bird. Log archived as
+  first-encounter-audit/harvester-objective-test-20260905-1548.log. Bird at208531 had free0,
+  allocs430 and an allocator-exhausted line; unlike the prior free116 run, this IS index
+  exhaustion. Keep both failure modes distinct.
+- Exact transported ship bodies now log `ev=ember_transport stage=auth_staged slot=... bits=... body=...`
+  for only the four Ember members. Next run compare outgoing exit revision2 with live Auth
+  +256 and Sense +396. Use /tmp/ember-capture-door-details.py while ships remain alive; the
+  linked-block base calculation now wraps subtraction to64bits (previous Python overflow).
+  Prior parked snapshots152740..153138 showed authority TRUE for all four ships; do not
+  assume missing entity authority without new evidence. Door boolean mapping previously
+  traced via44E340 actually uses generic component enable/disable through590B10/594D70;
+  it is not established as an open-door animation command.
+
+Validation: Release built, eight CTest plus three Lua suites passed. Tests cover two refs
+surviving exit-program replacement, duplicate/empty manifest rejection, 2999/3000ms boundary,
+independent empty-ship exits, both passenger squads per carrier, transit/reload and no replay.
+
+## Harvester objective membership and +2s unload installed — 2026-09-05 15:42
+
+Release built successfully. Candidate DLL SHA256
+`151a83a68a0d01b7ac66ac402cf73f782473f825b2ce4d109507395e3d986f27`.
+Installed and SHA-256 verified all eight runtime/script files after the installer confirmed
+Destiny was closed in the real process namespace. Backup:
+`build/objective-delay-transport-backup-20260905-154242`. Save/account progress preserved;
+SDK, Lua declarations and mission scripting enabled. User launches manually. No game launch.
+
+User explicitly requested restoring the previous arrival-based unload, delayed roughly2s,
+and assigning ships to objectives exactly as carried enemies are assigned. Implemented:
+- All four empty ship parent squads assign EMBER_POWERHOUSE_BRIDGE_OBJECTIVE revision1,
+  task group-1, then use the shared native task-cost selector across14 authored task groups.
+  Parent counts remain0 so adding AI cannot create duplicate stationary ships.
+- First entry revision1/cursor1 starts a durable2000ms timer per ship. Expiry requests native
+  delivery once. No bridge Sense prerequisite. Duplicate arrivals cannot restart the timer;
+  callbacks continue across region transit and script reload.
+- Second authored path completion still retires each ship immediately and once.
+- Removed the new generic device-event implementation and six-device completion gate.
+
+Live test archived as first-encounter-audit/harvester-doors-test-20260905-1533.log. Snapshots
+ship-runtime-20260905-152740 through153141 contain four live ship Auth/actor states. No delivery
+was requested in this test: the discarded device helper compared schema table index with hash,
+so it ignored native completion. All six device primary channels were0/sequence2 in capture
+152920. B/D/C entry finished t226246/227389/228420; A did not report completion until298318.
+
+User reports Bird when killing one of the bridge-start defenders, after waiting minutes for
+ships that never unloaded. At t463844 native create failed with116 free entity indices and571
+allocations. This favors initializer failure over exhausted index bitmap; do not assert it was
+caused by failed ship removal. Door animation/objective hypothesis still needs a manual test.
+
+Portable eight CTest and three Lua suites pass; controller validates actual timer_name field,
+1999ms/2000ms boundary, independent late ship, reload/transit, objective cost selection, and
+unchanged empty parent spawn requests. Peak53 variables, native capacity512.
+
+## Bridge-ready gate and region-independent cleanup installed — 2026-09-05 15:21
+
+User reports entry/drop working but a few seconds before bridge connection, doors remain shut,
+ships fail to disappear, and Bird recurs. User clarified retirement must happen immediately
+on completion of each ship's SECOND authored path. No timers or region-exit cleanup were added.
+
+Installed DLL SHA-256 `d64edd4463d8d0a1ecad6d53ed16d5438bd423304c6af4a155e1ad2bedb9630f`.
+Backup `build/bridge-ready-transport-backup-20260905-152123`; eight hashes verified.
+User confirmed game closed, installer checked real /proc, save retained, SDK/declarations and
+mission scripting enabled. No game launch or stop. User tests manually.
+
+- New device_state event reports primary native Sense position and accepted sequence for type23.
+  Sparse field updates are retained; invalid/nonfinite positions are rejected.
+- Bridge initialization uses primary sequence1/retracted1, extension uses sequence2/extended0.
+  Cargo waits for all six bridge devices to report sequence2 at position <=0.001 AND its own
+  entry-path completion. Each event order is handled; duplicate receipts cannot redeliver.
+- Actor-path and ongoing squad receipts continue after leaving region64. Previously the main
+  controller discarded them outside that region. Exit revision2/cursor1 disables that exact
+  native ship once, without waiting for player transit or another ship. Native AB8C00 routes
+  disable through entity destruction (56A8F0) when the ship has authority.
+- Bird evidence is now native network-entity creation, not proof of a ship retirement failure.
+  `transport-doors-latest.log` captures 16EE180->170F190 via B428D0/3BD440/4D7110. Creation can
+  fail in allocator1711D10 OR initializer170B0F0. Existing allocator exhaustion is now warn;
+  each bounded retail failure stack also logs current free count and allocation count. No
+  extra allocator hook, automatic retries, or pool-restock settings were introduced.
+
+Validation: Release build passed; nine CTest cases and three Lua suites passed. New tests cover
+sparse device receipts, stale generation, partially connected bridge, early and late arrivals,
+refinery transit during unload/departure, exact second-path retirement and replay suppression.
+Peak54 durable variables; native capacity512. Live visual behavior remains to be tested.
+
+**Door animation remains unresolved and unchanged in this install.** Native delivery1029330
+sets an authored boolean through44E340 and starts four effect bindings (+80/160/240/320 via
+C7AFA0 -> 3D30F0/587970); these are not established door commands. The Harvester actor80FE22FC
+binds delivery component template80C0E5D2, component instance ordinal15, to actor template
+80FE22C9 ordinal5 through the boolean ref at actor-file0xB68. Destination definition offset1018
+uses interface815B963A (80803A2B/80803A2A), implemented by actor component80803C23 feature6.
+Extracted assets are under first-encounter-audit/tags. Need capture that actor boolean/animation
+state during delivery, and entry/exit Auth plus authority while ships are alive. Do not invent
+an open-door hash or replace the existing Auth body without preserving path and delivery.
+
+## Endpoint and sequencing candidate installed — 2026-09-05 14:43
+
+User confirmed that the prior build moved ships, but too late; troops fell to their deaths,
+some squads spawned early, ships remained above the bridge, and Bird disconnected the run.
+The prior candidate is not an accepted implementation.
+
+Installed DLL SHA-256 `166c596f720094c2fa9d69e70bf29bd3495a0c1a20176c4c3ec7d87e487ed8d5`.
+Backup `build/endpoint-transport-backup-20260905-144335`; all eight DLL/script hashes verified.
+Installer checked the real process namespace and found the game closed. Save unchanged,
+SDK/Lua declarations and mission scripting enabled. User launches manually; no game launch.
+
+Concrete correction: the kind-3 path command used marker 0, the START of each authored curve.
+Native AB0C30 indexes the curve's marker array; marker 1 is the far endpoint. Both entry and
+exit now use marker 1. Read-only `sequence-runtime-20260905-143010` contains all eight source
+and curve assets. `transport-curve-markers-20260905.json` summarizes them. A entry runs from
+(-380,151,-2.5) to (-365,117,-22.5), approximately 20m lower. C entry's marker parameters are
+[0,5], others [0,4]; these are spline parameters, NOT flight durations in seconds.
+
+Cargo reservations and empty parent requests now prepare before Ghost is enabled, without
+creating ships or ground passengers. Console completion extends the bridge and requests the
+four entry flights without first queueing later ground waves. Bridge monitors arm at this
+point; early bridge receipts cannot consume Lua progression. Ground bridge/Sun/helipad groups
+enter on 25/75/100% crossings with existing forward catch-up and once-only guards. Actual
+crossing behavior still needs a fresh manual test: earlier runs sometimes lacked receipts on
+the initial traverse, and late ground placement has previously missed streaming readiness.
+
+Video rechecked at half-second intervals from 3:10 through 3:34.5. Ghost deployed around3:15,
+machinery moves during3:16–22, ships sweep into view3:24–29, and crossing combat follows.
+Four reference contact sheets saved under first-encounter-audit/reference-arrival/drop-*.
+The recording does not establish the full four-ship passenger manifest. Current one-squad-per-
+ship pairing remains reconstructed; passenger A-family support/melee anchors cluster near A/B
+entry endpoints, so original manifest deserves further verification before claiming full fidelity.
+
+Bird remains unresolved. Archived `transport-bird-20260905-1419.log` contains first-session
+failed sobject creation at t248202, after region transition240599–245393. Exit cursor receipts
+completed230157–233507; those receipts alone cannot prove visible flight and can include native
+timeout fallback. New bounded warning-level caller/stack traces preserve the native creation
+failure boundary if Bird recurs, without globally enabling debug or suppressing the error.
+
+Validation: Release build succeeded; eight CTest cases and three Lua suites passed. Packet
+regressions reject the former start-marker payload. Controller covers deferred preparation,
+scan-gated monitors/waves, arrival-only unload, independent exits, and no replay on backtracking
+or script reload. Peak52 variables; runtime capacity512. Flight, safe unloading, departure,
+full passenger composition and no Bird still require the user's fresh run.
+
+## Auth replacement fix installed — 2026-09-05 13:23
+
+User reports missing pre-bridge enemies; ships stationary; troops appeared in ships but fell
+to their deaths; some enemies appeared on the bridge; far-side closets worked as expected.
+No mission fault/capacity error in the archived `native-transport-live-test.log`.
+The log contains zero actor_path events. Do not describe the prior candidate as successful.
+
+Read-only captures establish the lost-state problem:
+- `ship-runtime-20260905-130600` and the 130638..130657 series: all four named actors
+  exist, enabled, initialized, generation1; Auth movement revision/seed/count are all zero,
+  while delivery revision1 and the correct type1 squad reference are present.
+- `encounter-runtime-20260905-130934` captures both F6FFB59E and D8CE8390, 69 rows,
+  no read errors. Active ground squads such as F6FFB59E/33,34,36,56,57 have objectives
+  and task groups but zero Auth requested-count array length and generation0.
+- The earlier 130812 encounter capture has squad Auth read errors (wrong handle offset).
+  Its component bytes exist, but use 130934 for Auth evidence. The native Auth storage
+  handle is at component+368 for both types; squad's inline Auth copy starts at384.
+- `actor-authority-20260905-130526` and 1307 captures preserve actor state; many actors
+  no longer carry squad refs. Free actor slots retain stale bytes; count live first words only.
+
+Native overrides replace complete Auth, not absent-root patches. Host now materializes
+these bounded mission API patches against the last transported same-ClientRef estate.
+Movement and delivery roots coexist; objective updates retain counts/profile/generation;
+retirement explicitly replaces requested counts with zero or actor enabled with false.
+Unsupported nested layouts are refused. Unrelated SDK APIs retain their existing behavior.
+Squad lease storage now allows the full 1313-bit schema, including objective and counts.
+
+The delivery routine begins unloading. Lua now reserves troops during flight and calls
+`deliver_squad` only after entry progress completes, then waits for native delivery completion
+and positive squad evidence before requesting exit. Packed transport state now uses FIVE bits
+per ship; this candidate requires a fresh mission, not reload into the four-bit live state.
+
+Release candidate built: `dce57b2cb894e3a08cc320f2657d6ed07bab08f41ed4c38db78fd4ea1614fcbd`.
+Installed and SHA-256 verified eight runtime/script files after the user closed the game.
+Backup: `build/auth-state-transport-backup-20260905-132343`. Save preserved, SDK and
+mission scripting enabled, no launch. Installer: `/tmp/install-ember-auth-state.py`.
+Validation: all eight portable tests and three Lua suites pass. New test covers sequential
+movement/delivery/exit/retirement, repeated AI updates preserving spawn counts and mode,
+zero-count retirement, malformed/short input, and maximum-category squad lease capacity.
+Live visual movement and correct unload location remain unverified for this new candidate.
+
+User's next correction, explicitly deferred until dropships are implemented: war-beast door
+must use `LANDING_MERCURY_DOOR_BUTTON_OBJECT` (F6FFB59E/type4/19), not proximity.
+Object activation/rendering, native interaction/destruction criteria, and object/device links
+need auditing. Nearby devices are lever7, screen8, door9. No object behavior changed yet.
+Current object Sense decoder refuses nonempty dynamic reply lists; this is part of that audit.
+
+## Installed native transport and completed-area safeguards — 2026-09-05 13:01
+
+Installed eight runtime/script files into the user's game, verified SHA-256 after copying.
+DLL: `d688af8ed3b6b738689494d34b2e5e7b15c9be97a9f6b4839a126d8474394e43`.
+Backup: `build/one-shot-transport-backup-20260905-130149`.
+Save untouched; SDK and mission scripting enabled; image capture disabled. No game launch.
+This supersedes the short-lived 12:52 candidate, which still had the host's old mode check.
+
+**Root cause corrected:** type-2 Auth `.7` is passenger delivery, not a list of flight paths.
+AB6600 dispatches its type-1 squad references to interface 80807DB4. On failure it calls
+4E8270 on those squads, so sending type-58 references there was structurally wrong.
+Movement now uses Auth `.6`, one kind-3 80807F7A action; A97BB0/4FFEC0 accepts type58
+and resolves the authored curve. Movement completion uses nested Sense `.3`/80807F6E
+progress and revision. Root Sense 5/6 now remain explicitly separate delivery fields.
+
+Harvester actor asset 80FE22FC contains component 8080670A at 0x554C. Its registered
+1029330 delivery implementation collects reserved requests (102A920), creates passengers
+in vehicle seats (1022890), and completes after attachment handles clear (102DC80).
+Passenger squads now use mode3, which 4E4580 excludes from ordinary ground placement.
+Objective assignments and cost-driven changes preserve that mode. Codec, SDK preflight,
+and host submission share the same accepted-mode predicate.
+
+Bridge completion requests bridge, landing-sun and helipad defenders before crossing volumes
+can trigger next-region streaming. It also reserves all four passenger squads and starts the
+four named ships. Each ship departs independently after movement completion, native delivery
+completion, and evidence its passenger squad existed. Current manifest is one squad per ship
+in roster order; original passenger-to-ship pairing is **not established** by the shared names.
+There is no timed ground-spawn fallback. Native completion can include cancellation/timeout;
+these receipts still require visual confirmation and actor capture in the next user-run test.
+
+Completed encounters now replace their native requested counts with zero exactly once.
+Their durable phase prevents subsequent placements and AI reassignment. Advancing route
+progress suppresses old, previously skipped triggers on backtracking; objectives/dialogue
+cannot rewind. Successful ship exit (or a dead ship) sends a disabled member body: AB78B0
+releases the actor and AB7350 prevents recreation when retained Auth is replayed.
+These rules persist through region transit and a script reload in the same mission session.
+A new Director mission starts a new session; save/checkpoint resume of all stages is not implemented.
+
+Validation: Release build completed; seven portable CTest cases and all three Lua suites passed.
+Production msg-6 fixtures distinguish delivery changes from actual action progress and reject
+truncated input. Reservation and retirement encodings are checked independently. Controller
+regressions cover independent departures, duplicates, clearing all 38 ground/passenger squads,
+zero-count retirement, ship retirement, refinery transit/reload/return, and late early triggers.
+Peak durable variables: 51 under the former 64 limit; installed runtime still supports 512.
+
+**Next manual acceptance:** fresh mission, initial landing/door/console/bridge preserved; see
+ships actually follow entry and exit curves, carry and unload every passenger, then see all
+far-side groups before refinery. Kill passengers and backtrack after clear. Capture native
+ship movement/delivery/Auth and far-side actors while powerhouse registry is still loaded.
+Visible flight, full compositions/placement, and return behavior remain unverified in-game.
+
+## Live 512-variable test — 2026-09-05
+
+User manually launched and crossed into the refinery. No script/capacity fault in the
+archived `build/first-encounter-audit/capacity-512-live-test.log`; structured squad/flight
+summary is alongside it. User reports no visible post-bridge enemies. Do not treat server
+spawn staging or positive Sense populations as proof of correct visible placement.
+
+Cutscene completed at t=112840. Catwalk actor capture `actor-authority-20260905-120413`
+confirms native objective D8CE8390/type3/index0, revision 1, assigned task groups, computed
+costs, and AI area refs. Mercury groups cleared and Ghost completion started bridge/ships.
+All four entry paths stop in 12–14 ms; exits stop in 13 ms. Read-only captures saved before
+and after ship creation: `ship-runtime-20260905-120639`, `ship-runtime-20260905-120724`; full actor snapshot
+at 120724 contains all four ships. Both 120820 and 120857 are empty after registry unload;
+they do not contain member, Auth or entity evidence.
+
+Bridge 25/75/100 triggers fired at t=369200/386691/390960 without fault; corresponding
+spawn and objective commands staged. First positive populations for bridge slots 38/39
+arrived at 399270/399470; 33/34/35 at 400133/399983/400746 (about 30 seconds late).
+Slots 36/37 reported zero only. Need explain delayed population and actual placement,
+and missing sun/helipad groups; capacity is no longer blocking callbacks. All four
+passenger groups reached zero after previously positive counts without the old disconnect
+before the user continued into refinery.
+
+Native ship capture resolves all eight type58 path refs to class 80807D9B objects, excluding
+missing registry mapping at capture time. All four ship members show generation/actor state
+consistent with spawned ships, delivery revision 2, inactive, dead=false, initialized=true.
+The later helper supports Auth/entity capture, but 120820 contains no rows. This does not
+prove readiness at first dispatch. Those old revision/state fields described delivery, not movement.
+No game files changed during this monitoring turn; no launch/shutdown or memory writes.
+
+## Variable capacity update — 2026-09-05 12:00
+
+User reports almost all enemy behavior now works, but stationary ships, missing passenger
+and post-bridge enemies, then `mission variable capacity exceeded`. Run archived as
+`build/first-encounter-audit/ai-working-flight-cancel-capacity.log`. The bridge 25% trigger
+faulted while creating encounter state, preventing later mission callbacks.
+
+Raised the shared C++ mission variable capacity from 64 to 512 at the user's request.
+All variable-count fields are size_t; VM, transactional state, and SDK capacity publication
+use the shared constant. Also packed per-squad AI assignments into 12 five-bit lanes per
+integer. Full-route regression reproduces exhaustion with the previously installed scripts;
+updated scripts peak at 57 variables under the original 64-slot test budget. A fresh mission
+is required; an already faulted run is not migrated.
+
+Release build, all seven portable tests, three Lua suites, and whitespace check passed.
+Installed and SHA-256 verified eight DLL/script files, preserving save and enabling SDK and
+mission scripting. Backup: `build/capacity-512-backup-20260905-120000`.
+DLL SHA-256: `004fcd781af52bc8eaa3fc2e56be9ab447d0ef23335902fb0f576c2ac844eaf2`.
+No game launch or shutdown.
+
+Flights remain unresolved: all four entry paths report active then inactive within 13–15 ms;
+exit paths also stop within 14–16 ms. Current transport interprets this transition as arrival,
+but it is not evidence of a completed flight. All four passenger group slots reported positive
+population, which does not establish complete composition or correct boarding/unloading.
+Need a read-only capture of stationary ships during the next manual run to inspect path
+resolution, actor readiness and component dispatch. No speculative flight change installed.
+
+## Objective and flight test candidate — 2026-09-05
+
+User confirmed bridge extension, then reported stationary ships spawning only on the return
+bridge crossing and a world disconnect after killing passengers. The installed baseline had
+no flight requests and used Bridge 25% as its spawn trigger. Candidate moves ship creation to
+Ghost completion and starts all four named members on their authored entry sequences. Native
+path-state receipts gate passenger creation, then exit sequences. Passenger attachment/unload
+is still incomplete: this candidate creates passengers at their authored ground anchors after
+arrival. Native flight/cancellation behavior requires the user's test.
+
+Actor capture `build/first-encounter-audit/actor-authority-20260905-112423` contains 10 full
+actors and five catwalk squad blocks. Actor and squad-owner authority bits are true. Squad
+Auth root 0 is unset, so they are not assigned to an authored combat objective.
+
+Derived objective assignment: 4E2A90 copies root 0 to squad +1520, root 13 to +1540,
+and root 16 to +1532. 4EC710 links the squad into the selected objective group. AB8510 /
+A99FA0 computes group costs for squads matching the objective's registry/ref; 4EB8E0 clamps
+costs at 2040, and 4E84E0 echoes the completed calculation revision into Sense root 1.
+AB83D0 / A9AD70 then selects native tasks within assigned groups; 4E2C40 applies the
+combat-area reference to the squad AI group and refreshes its actors.
+
+Candidate assigns all 38 ground/passenger squads to their corresponding same-registry
+objective, initially group -1, revision 1. Retained native costs select the lowest reachable
+authored group, preserving ties. This selection policy is reconstructed, not recovered retail
+host code. Bounds from objective source +136: catwalk 17 (80B3CA03), Mercury 11 (80B3DCC1),
+bridge 14 (80B3C8D6), pipe 3 (80B3DCC7), sun 4 (80B3DCC4), helipad 4 (80B3DDB2).
+Cost-only Sense deltas retain population counts so AI updates cannot manufacture deaths.
+
+Disconnect log archived as `build/first-encounter-audit/dropship-kill-disconnect.log`:
+t=339052 four failed sobject creations, then prerequisite failure and world cleanup. No
+verified fix yet. Existing lease-release diagnostic setting is unchanged: its applicability
+to this run has not been established.
+
+Validation: Release DLL build, all seven portable tests, three Lua suites, whitespace check.
+DLL SHA-256: `b14c56923797150227f9b1e9b1feaa4b8198dde40c669a5ab65406624407bda6`.
+Installed and SHA-256 verified at 11:44. Backup: `build/objective-flight-backup-20260905-114409`.
+Save retained; SDK and mission scripting enabled. No game launch.
+
+## Ghost completion decoder fix — 2026-09-05 10:45
+
+User confirmed the beast door opens and the Ghost console is scannable, but the bridge
+stays retracted. Archived that run as `build/first-encounter-audit/console-scannable-no-bridge.log`.
+All eleven Mercury squads cleared and console enable Auth was staged at t=278017.
+There were no decoded Ghost Sense receipts. The native msg-6 decoder lacked the
+80804D3E branch, so the new Lua completion handler could never receive its reports.
+
+Added the exact required boolean, raw float and biased signed generation fields from
+SDK schema 80804D3E and native E4A590. Independent complete packet fixtures now run
+through the production decoder and Ghost state reader: initial, active half-progress,
+and completed generation 2; a truncated packet is rejected. The test failed with the
+old decoder's partial status and passes with the fix. Both Lua suites and all six
+portable tests pass; Release DLL build and whitespace checks pass.
+
+Installed and hash-verified DLL/scripts with backup
+`build/ghost-decoder-backup-20260905-104521`. DLL SHA-256:
+`26611bdea79a39305d2fcda7a64ec06627c5a07ec9af8ee64e009d393cb42dc3`.
+No save edits or game launch. Fresh manual run still needed to confirm that completing
+the native scan triggers all six bridge animations. AI and transport work remains open.
+
+## Installed bridge, door and console test — 2026-09-05 10:34
+
+User confirmed the opening cinematic and player arrival work. Preserve that path.
+Installed Release DLL and five Lua files, with source/installed SHA-256 verification.
+Backup: `build/bridge-console-backup-20260905-103443`.
+DLL: `eed579d58c7131090bb87d6b7521b9ef8063f4c5a95f70ff68dc3a0a88bf83c9`.
+Save files were not modified. SDK and mission scripting remain enabled. Native image
+capture is now disabled because the complete image has been archived. No game launch.
+
+This candidate initializes all six bridge devices to position 1 (retracted inferred from
+native value passthrough and the user's observation that position 0 was extended), opens
+the beast door at the authored Mercury 40% trigger, and enables the native Ghost console
+only after all eleven Mercury squads report real populations and then clear. The squad
+state flag previously called removal_flag is not a reliable unload indicator: observed
+real kills set it, and it no longer suppresses death accounting. Exact type-65 Auth and
+retained partial Sense fields connect console completion to animated bridge extension.
+
+Validation: Release build current, git diff whitespace check clean, both Lua suites pass,
+and all six portable CTest tests pass. These are implementation checks; bridge direction,
+door timing and native console completion still require a fresh manual mission test.
+AI movement/idle and carrier/passenger attachment remain unresolved and are not fixed
+by this candidate. In the prior run, Mercury bonus-anchor squad slot 42 remained alive.
+
+Transport investigation checkpoint: type-2 Auth root 7 carries an authored sequence list
+and revision; native AB6600 resolves it and starts actor sequences, with Sense revision
+and active fields at offsets 448/452. This is distinct from loose squad creation or
+adopting an existing actor. No transport change has been installed. Squad Auth root 0
+is not proven to reference a dropship; its resolved target expects a controller layout,
+so do not implement a carrier link through that field on this evidence.
+
+## Phase-zero sandbox fault — 2026-09-05
+
+The manual run failed before cinematic activation. Installed log at t=52632 reports
+`landing.lua:52: attempt to call a nil value (global 'pairs')`. Sunrise deliberately removes
+unordered iteration from its mission sandbox; the standalone Lua test had left it available.
+Both controller uses now iterate an explicit route-ordered array with `ipairs`, including the
+squad-state callback. The regression disables `pairs` and `next` before loading the controller:
+it reproduced the exact old fault and passes the full route/reload test with this fix.
+No native DLL change or save reset is required. Start a fresh mission instance after installation.
+
+The user's manual run also produced a complete native image dump (zero unreadable bytes).
+It and the fault log are preserved under `build/first-encounter-audit` for offline investigation.
+
+## Powerhouse route test build — 2026-09-05
+
+Correction to the earlier spawn investigation: `powerhouse_landing_mercury` is NOT the mission
+start. It is the later console combat platform. The authored `default` set (`0x2EA8FB98`) has
+an opening three-point cluster around (-400, -192, -1.9), surrounded by the
+`powerhouse_catwalk_002_dialog_trigger_volume`; the catwalk progress volumes then lead north.
+That is the new selected set. Actual native spawn selection still needs the user's fresh run.
+
+The Lua route now binds all 42 powerhouse squads once in nine groups. The first five are the
+catwalk entry squads; 15%/35%/100% catwalk crossings stage mid-catwalk, pipe and Mercury.
+Pipe 40% starts cue 2. Bridge 25% stages bridge/dropship/passenger groups; bridge 75% stages
+landing-sun, and bridge 100% stages helipad. Later crossings catch up skipped narrow volumes.
+These choices are reconstructed policy using authored spatial identities and the reference
+video, not a claim that the packages contain recovered retail host conditions.
+
+Fixed a native Sense reader bug: a presence-gated alive-count field exists as a DecodedValue
+row even when omitted. Checking only the row pointer interpreted that omission as zero.
+The reader now requires a present root count. Added squad population receipts, exact resolved
+player-trigger identities, and bounded change-only bridge/console Sense diagnostics.
+
+Validation: two Lua suites pass (cinematic ordering, all 42 roster entries, route progression,
+exact-trigger matching, skipped volumes, removal, repeat/reload guards); all five portable
+CTest tests pass, including omitted/zero/nested/invalid alive-count cases; six SDK inspector
+tests pass. All 42 squad bindings and seven route triggers resolve against the generated SDK.
+These checks do not prove live actor creation, native AI, Ghost interaction or bridge movement.
+
+MissionDocs were reviewed. The Forest race switch is specific to Omega/Vex and is not suitable
+for Ember/Cabal. The documented member-bound actor path needs native verification before
+applying it to harvesters and other named Ember members; loose squad requests alone do not
+establish their correct creation/attachment. Ghost-console Auth semantics and bridge direction
+also remain unresolved. No automatic console completion or bridge opening was added.
+
+User constraint: apply and save to the installed game, but NEVER launch it automatically.
+The next manual boot will use Sunrise's existing native-image diagnostic to supply readable
+code for offline analysis; disable the diagnostic once captured. Existing save/settings,
+scripts and DLL are backed up before installation. SDK and mission scripting remain enabled.
+
+Installed and SHA-256 verified the Release DLL and all five Ember Lua files.
+Save/account state compared equal before and after this installation. Backup:
+`build/powerhouse-route-backup-20260905-090720`. DLL SHA-256:
+`26edd28c539beb6444c6181684154c1e43abb8940cf26c3d5eec87cfd54bcfac`.
+The installed diagnostic flag is enabled for the next MANUAL boot. No game launch was made
+after the user's manual-launch-only instruction. Next validation: fresh Director launch,
+cinematic completion, grounded starting platform, catwalk/pipe population and AI, then console.
+
+## Earlier landing spawn attempt — superseded
 
 The next playtest confirmed the movie completes and the player spawns, but in the wrong place
 under the map. Logs now resolve both cinematic notifications and arm the transition to state 64.
 The installed arrival override selected the cinematic region without an explicit spawn set.
 
 The native build-data catalogue contains a three-point landing set whose hash matches
-`powerhouse_landing_mercury` (`0x9C58857A`). Its points align with the SDK landing anchors and
-the opening area in the reference video. Settings now explicitly select that set while retaining
+`powerhouse_landing_mercury` (`0x9C58857A`). Its points align with later Mercury anchors. The claim that this was the opening area
+was incorrect; see the correction above. Settings now explicitly select that set while retaining
 bubble 6/state 49 for the movie; Lua still selects playable state 64 after termination.
 
 The spawn-binding filter previously discarded explicit sets when their activity package was

+ 1 - 1
Sunrise/resources/default_settings.json

@@ -109,7 +109,7 @@
           "package_name": "mission_ember",
           "bubble": 6,
           "slice_set": 49,
-          "spawn_set_hash": "0x9C58857A"
+          "spawn_set_hash": "0x2EA8FB98"
         },
         {
           "package_name": "raid_envy_v310",

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

@@ -87,6 +87,13 @@ local EventKind = {
     PLAYER_TRIGGER = 28,
     CINEMATIC_STARTED = 29,
     CINEMATIC_TERMINATED = 30,
+    ACTOR_PATH_STATE = 31,
+    GHOST_LINK_STATE = 32,
+    OBJECT_INTERACTED = 33,
+    CINEMATIC_SKIP_REQUESTED = 34,
+    FIRETEAM_STATE = 35,
+    OBJECT_STATE = 36,
+    DAMAGE_STATE = 37,
 }
 
 ---@class SunriseEvent
@@ -155,6 +162,13 @@ local EventKind = {
             R"lua(event: SunriseCinematicEvent)
 ---@field on_event_cinematic_terminated? fun(context: any, state: SunriseState, )lua"
             R"lua(event: SunriseCinematicEvent)
+---@field on_event_actor_path_state? SunriseEventHandler
+---@field on_event_ghost_link_state? SunriseEventHandler
+---@field on_event_object_interacted? SunriseEventHandler
+---@field on_event_fireteam_state? SunriseEventHandler
+---@field on_event_object_state? SunriseEventHandler
+---@field on_event_damage_state? SunriseEventHandler
+---@field on_event_cinematic_skip_requested? fun(context: any, state: SunriseState, event: SunriseCinematicEvent)
 
 ---@class SunriseVector3
 ---@field x number
@@ -176,6 +190,19 @@ local EventKind = {
 ---@field auth_dynamic boolean|nil
 ---@field auth_writable boolean|nil
 ---@field set_object_active fun(SunriseSlot, SunriseObjectArguments?): SunriseRequestKey
+---@field assign_combat_objective fun(self: SunriseSlot, args: {objective: SunriseSlot, revision: integer, task_group: integer, reserved: boolean?}): SunriseRequestKey
+---@field play_actor_path fun(self: SunriseSlot, args: {generation: integer, revision: integer, path: SunriseSlot}): SunriseRequestKey
+---@field deliver_squads fun(self: SunriseSlot, args: {generation: integer, revision: integer, squads: SunriseSlot[]}): SunriseRequestKey
+---@field deliver_squad fun(self: SunriseSlot, args: {generation: integer, revision: integer, squad: SunriseSlot}): SunriseRequestKey
+---@field play_actor_action fun(self: SunriseSlot, args: {generation: integer, revision: integer, group: integer, action: integer}): SunriseRequestKey
+---@field retire_actor fun(self: SunriseSlot, args: {generation: integer}): SunriseRequestKey
+---@field set_darkness_zone fun(self: SunriseSlot, args: {enabled: boolean, wipe_seconds: integer?}): SunriseRequestKey
+---@field set_interactable_object fun(self: SunriseSlot, args: {generation: integer, track_owner: boolean?, active: boolean?}): SunriseRequestKey
+---@field set_music_section fun(self: SunriseSlot, args: {section: integer, enabled: boolean?}): SunriseRequestKey
+---@field watch_damage fun(self: SunriseSlot, args: {target: SunriseSlot, revision: integer}): SunriseRequestKey
+---@field set_object_filter fun(self: SunriseSlot, args: {players: boolean?, target: SunriseSlot?, inside: SunriseSlot?, inside_any: SunriseSlot[]?}): SunriseRequestKey
+---@field set_mission_effect fun(self: SunriseSlot, args: {filter: SunriseSlot?, enabled: boolean, revision: integer}): SunriseRequestKey
+---@field set_ghost_link fun(self: SunriseSlot, args: {generation: integer, enabled: boolean}): SunriseRequestKey
 ---@field bind_combatant_to_squad fun(self: SunriseSlot): SunriseRequestKey
 ---@field transition fun(SunriseSlot, SunriseDeviceTransitionArguments): SunriseRequestKey
 ---@field fire_trigger fun(self: SunriseSlot): SunriseRequestKey
@@ -207,6 +234,8 @@ local EventKind = {
 ---@class SunriseDirectiveArguments
 ---@field directive table Generated mission directive declaration.
 ---@field state? integer Defaults to 0, the native enter state.
+---@field audience? SunriseSlot Authored type-70 engagement sensor for the mission banner.
+---@field navpoint? SunriseSlot Authored type-47 navigation marker.
 
 ---@class SunriseEngagementArguments
 ---@field flags? integer Five native flag bits; the shipped constructor default is 1.

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

@@ -158,6 +158,8 @@ struct GraphReferenceDescriptor final {
 /** One decoded raw 0x98 or 0xA0 spawner reference and its complete resolution evidence. */
 struct GraphReference final {
     std::uint32_t spawnerRow{format::kAbsentIndex};
+    /** Exact occurrence-scoped source when one config is reused by separate scenarios. */
+    std::uint32_t sourceDescriptorRow{format::kAbsentIndex};
     std::uint32_t referenceOrdinal{};
     std::uint64_t rawReference{};
     std::uint32_t targetObjectKey{};

+ 121 - 88
Sunrise/src/client/content/activity/activity_sdk_squad_graph_edges.cpp

@@ -289,10 +289,12 @@ template <typename Index> void canonicalize_scenarios(Index& index) {
     const std::unordered_map<std::uint32_t, std::uint32_t>& rulesByConfig,
     const std::vector<std::vector<std::uint32_t>>& scenariosByObject,
     std::map<TargetGroup, ExactTarget>& exactTargets,
-    ReferenceResolutionStatus& status) {
+    ReferenceResolutionStatus& status,
+    std::uint32_t scopedSource = format::kAbsentIndex) {
     const ObjectReference decoded = decode_reference(rawReference);
     GraphReference reference{};
     reference.spawnerRow = spawnerRow;
+    reference.sourceDescriptorRow = scopedSource;
     reference.referenceOrdinal = referenceOrdinal;
     reference.rawReference = rawReference;
     reference.targetObjectKey = decoded.objectKey;
@@ -316,12 +318,15 @@ template <typename Index> void canonicalize_scenarios(Index& index) {
     std::map<TargetGroup, std::vector<std::uint32_t>> groups{};
     std::size_t authoritativeCount = 0;
     const GraphSpawner& spawner = graph.spawners[spawnerRow];
+    const auto sourceStatus = scopedSource != format::kAbsentIndex
+        ? SourceDescriptorStatus::exact : spawner.sourceDescriptorStatus;
+    const auto sourceRow = scopedSource != format::kAbsentIndex ? scopedSource : spawner.sourceDescriptorRow;
     for (const std::uint32_t descriptorRow : candidates) {
         const GraphDescriptor& descriptor = graph.descriptors[descriptorRow];
         // Object keys are reused by campaign and arcade scenarios. A reference can only
         // name a target that occurs in a scenario containing its source object.
-        if (spawner.sourceDescriptorStatus == SourceDescriptorStatus::exact) {
-            const GraphDescriptor& source = graph.descriptors[spawner.sourceDescriptorRow];
+        if (sourceStatus == SourceDescriptorStatus::exact) {
+            const GraphDescriptor& source = graph.descriptors[sourceRow];
             const auto& sourceScenarios = scenariosByObject[source.objectIndex];
             const auto& targetScenarios = scenariosByObject[descriptor.objectIndex];
             const bool sharesScenario =
@@ -349,9 +354,9 @@ template <typename Index> void canonicalize_scenarios(Index& index) {
         static_cast<std::uint32_t>(graph.referenceDescriptors.size())
         - reference.candidateDescriptors.first;
 
-    if (spawner.sourceDescriptorStatus == SourceDescriptorStatus::missing) {
+    if (sourceStatus == SourceDescriptorStatus::missing) {
         status = ReferenceResolutionStatus::sourceDescriptorMissing;
-    } else if (spawner.sourceDescriptorStatus == SourceDescriptorStatus::ambiguous) {
+    } else if (sourceStatus == SourceDescriptorStatus::ambiguous) {
         status = ReferenceResolutionStatus::sourceDescriptorAmbiguous;
     } else if (reference.candidateDescriptors.count == 0) {
         status = ReferenceResolutionStatus::targetMissing;
@@ -385,6 +390,30 @@ template <typename Index> void canonicalize_scenarios(Index& index) {
     return true;
 }
 
+/** A reused config is unambiguous in disjoint scenario contexts. Never resolve two
+ * different source slots that coexist in the same scenario by arbitrary row order. */
+[[nodiscard]] std::vector<std::uint32_t> scoped_sources(const GraphSnapshot& graph,
+    const GraphSpawner& spawner, const std::vector<std::vector<std::uint32_t>>& scenarios) {
+    std::map<std::pair<std::uint32_t,std::uint32_t>,std::uint32_t> logical;
+    for (std::uint32_t i=0;i<spawner.sourceDescriptorCandidates.count;++i) {
+        const auto row=graph.sourceDescriptorCandidates[spawner.sourceDescriptorCandidates.first+i].descriptorRow;
+        const auto& d=graph.descriptors[row];logical.try_emplace({d.objectIndex,d.slotIndex},row);
+    }
+    std::vector<std::uint32_t> result;
+    for (const auto& [identity,row]:logical) {
+        const auto& owned=scenarios[identity.first];
+        const bool overlap=std::any_of(logical.begin(),logical.end(),[&](const auto& other) {
+            if (other.first==identity) return false;
+            const auto& peers=scenarios[other.first.first];
+            return std::any_of(owned.begin(),owned.end(),[&](auto scenario) {
+                return std::binary_search(peers.begin(),peers.end(),scenario);
+            });
+        });
+        if (!overlap) result.push_back(row);
+    }
+    return result;
+}
+
 /** Resolves every spawner raw reference into sorted pending authored edges. */
 [[nodiscard]] bool build_pending_edges(const topology::Snapshot& topology,
                                        GraphSnapshot& graph,
@@ -449,94 +478,98 @@ template <typename Index> void canonicalize_scenarios(Index& index) {
         }
 
         spawner.references.first = static_cast<std::uint32_t>(graph.references.size());
-        std::map<TargetGroup, ExactTarget> exactTargets{};
-        std::array<ReferenceResolutionStatus, 2> statuses{};
-        const std::array<std::uint64_t, 2> raw{spawner.rawReference98, spawner.rawReferenceA0};
-        for (std::uint32_t ordinal = 0; ordinal < raw.size(); ++ordinal) {
-            if (!resolve_reference(topology,
-                                   graph,
-                                   spawnerRow,
-                                   ordinal,
-                                   raw[ordinal],
-                                   descriptorsByTarget,
-                                   rulesByConfig,
-                                   scenariosByObject,
-                                   exactTargets,
-                                   statuses[ordinal])) {
-                return false;
+        auto sources = scoped_sources(graph,spawner,scenariosByObject);
+        if (sources.empty()) sources.push_back(format::kAbsentIndex);
+        for (const auto sourceRow : sources) {
+            std::map<TargetGroup, ExactTarget> exactTargets{};
+            std::array<ReferenceResolutionStatus, 2> statuses{};
+            const std::array<std::uint64_t, 2> raw{spawner.rawReference98, spawner.rawReferenceA0};
+            for (std::uint32_t ordinal = 0; ordinal < raw.size(); ++ordinal) {
+                if (!resolve_reference(topology,
+                                       graph,
+                                       spawnerRow,
+                                       ordinal,
+                                       raw[ordinal],
+                                       descriptorsByTarget,
+                                       rulesByConfig,
+                                       scenariosByObject,
+                                       exactTargets,
+                                       statuses[ordinal], sourceRow)) {
+                    return false;
+                }
             }
-        }
-        spawner.references.count = 2;
-        const bool hasAmbiguous =
-            std::find(statuses.begin(), statuses.end(), ReferenceResolutionStatus::targetAmbiguous)
-            != statuses.end();
-        const bool hasOther = std::any_of(statuses.begin(), statuses.end(), [](auto status) {
-            return status != ReferenceResolutionStatus::exact
-                   && status != ReferenceResolutionStatus::invalidEncoding;
-        });
-        // With both refs absent, the spawner's own point set is the target, on its own slot.
-        const bool bothAbsent = std::all_of(statuses.begin(), statuses.end(), [](auto status) {
-            return status == ReferenceResolutionStatus::invalidEncoding;
-        });
-        if (bothAbsent && spawner.hasInlinePointSet && spawner.inlineRuleRow != format::kAbsentIndex
-            && spawner.sourceDescriptorRow != format::kAbsentIndex && exactTargets.empty()) {
-            const GraphDescriptor& source = graph.descriptors[spawner.sourceDescriptorRow];
-            ExactTarget& target =
-                exactTargets[{spawner.inlineRuleRow, source.objectIndex, source.slotIndex}];
-            target.descriptors = {spawner.sourceDescriptorRow};
-        }
-        const bool associationExact =
-            spawner.sourceDescriptorStatus == SourceDescriptorStatus::exact && !hasAmbiguous
-            && !hasOther && exactTargets.size() == 1;
-        if (spawner.sourceDescriptorRow == format::kAbsentIndex) {
-            continue;
-        }
-        for (const auto& [group, target] : exactTargets) {
-            if (target.descriptors.empty()) {
-                return false;
+            const bool hasAmbiguous =
+                std::find(statuses.begin(), statuses.end(), ReferenceResolutionStatus::targetAmbiguous)
+                != statuses.end();
+            const bool hasOther = std::any_of(statuses.begin(), statuses.end(), [](auto status) {
+                return status != ReferenceResolutionStatus::exact
+                       && status != ReferenceResolutionStatus::invalidEncoding;
+            });
+            // With both refs absent, the spawner's own point set is the target, on its own slot.
+            const bool bothAbsent = std::all_of(statuses.begin(), statuses.end(), [](auto status) {
+                return status == ReferenceResolutionStatus::invalidEncoding;
+            });
+            if (bothAbsent && spawner.hasInlinePointSet && spawner.inlineRuleRow != format::kAbsentIndex
+                && sourceRow != format::kAbsentIndex && exactTargets.empty()) {
+                const GraphDescriptor& source = graph.descriptors[sourceRow];
+                ExactTarget& target =
+                    exactTargets[{spawner.inlineRuleRow, source.objectIndex, source.slotIndex}];
+                target.descriptors = {sourceRow};
             }
-            PendingEdge edge{};
-            edge.spawnerRow = spawnerRow;
-            edge.ruleRow = std::get<0>(group);
-            edge.sourceDescriptorRow = spawner.sourceDescriptorRow;
-            edge.targetObjectRow = std::get<1>(group);
-            edge.targetSlotRow = std::get<2>(group);
-            edge.referenceMask = target.referenceMask;
-            edge.targetDescriptors = target.descriptors;
-            std::sort(edge.targetDescriptors.begin(),
-                      edge.targetDescriptors.end(),
-                      [&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,
-                               spawner,
-                               graph.rules[edge.ruleRow],
-                               graph.descriptors[edge.sourceDescriptorRow],
-                               graph.descriptors[edge.targetDescriptors.front()],
-                               edge.id)) {
-                return false;
+            const bool associationExact =
+                sourceRow != format::kAbsentIndex && !hasAmbiguous && !hasOther
+                && !exactTargets.empty();
+            if (sourceRow == format::kAbsentIndex) {
+                continue;
             }
-
-            const GraphDescriptor& source = graph.descriptors[edge.sourceDescriptorRow];
-            if (source.objectIndex >= scenariosByObject.size()) {
-                return false;
+            for (const auto& [group, target] : exactTargets) {
+                if (target.descriptors.empty()) {
+                    return false;
+                }
+                PendingEdge edge{};
+                edge.spawnerRow = spawnerRow;
+                edge.ruleRow = std::get<0>(group);
+                edge.sourceDescriptorRow = sourceRow;
+                edge.targetObjectRow = std::get<1>(group);
+                edge.targetSlotRow = std::get<2>(group);
+                edge.referenceMask = target.referenceMask;
+                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;
+                          });
+                edge.associationExact = associationExact;
+                if (!edge_identity(topology,
+                                   spawner,
+                                   graph.rules[edge.ruleRow],
+                                   graph.descriptors[edge.sourceDescriptorRow],
+                                   graph.descriptors[edge.targetDescriptors.front()],
+                                   edge.id)) {
+                    return false;
+                }
+
+                const GraphDescriptor& source = graph.descriptors[edge.sourceDescriptorRow];
+                if (source.objectIndex >= scenariosByObject.size()) {
+                    return false;
+                }
+                const std::vector<std::uint32_t>& sourceScenarios =
+                    scenariosByObject[source.objectIndex];
+                const auto targetScenariosFound =
+                    exactRuleScenarios.find({edge.ruleRow, edge.targetObjectRow});
+                const std::vector<std::uint32_t> empty{};
+                const std::vector<std::uint32_t>& targetScenarios =
+                    targetScenariosFound == exactRuleScenarios.end() ? empty
+                                                                     : targetScenariosFound->second;
+                std::set_intersection(sourceScenarios.begin(),
+                                      sourceScenarios.end(),
+                                      targetScenarios.begin(),
+                                      targetScenarios.end(),
+                                      std::back_inserter(edge.scenarios));
+                output.push_back(std::move(edge));
             }
-            const std::vector<std::uint32_t>& sourceScenarios =
-                scenariosByObject[source.objectIndex];
-            const auto targetScenariosFound =
-                exactRuleScenarios.find({edge.ruleRow, edge.targetObjectRow});
-            const std::vector<std::uint32_t> empty{};
-            const std::vector<std::uint32_t>& targetScenarios =
-                targetScenariosFound == exactRuleScenarios.end() ? empty
-                                                                 : targetScenariosFound->second;
-            std::set_intersection(sourceScenarios.begin(),
-                                  sourceScenarios.end(),
-                                  targetScenarios.begin(),
-                                  targetScenarios.end(),
-                                  std::back_inserter(edge.scenarios));
-            output.push_back(std::move(edge));
         }
+        spawner.references.count = static_cast<std::uint32_t>(graph.references.size()) - spawner.references.first;
     }
     std::sort(
         output.begin(), output.end(), [](const PendingEdge& first, const PendingEdge& second) {

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

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

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

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

+ 99 - 0
Sunrise/src/client/hooks/harvester_drop/harvester_drop_experiment.cpp

@@ -0,0 +1,99 @@
+#include "harvester_drop_experiment.h"
+
+#include <Windows.h>
+#include <cstddef>
+#include <cstdint>
+#include <cstring>
+
+#include "../../../core/logging/log.h"
+#include "../../../core/settings/settings.h"
+#include "../../hooking/detour.h"
+
+namespace sunrise::client::hooks::harvester_drop {
+namespace {
+hooking::detour::Handle g_hook{};
+std::uintptr_t g_base{};
+using SetStage = std::intptr_t (__fastcall*)(void*, int);
+using SetChannel = std::intptr_t (__fastcall*)(std::uint32_t, const std::uint32_t*, const float*);
+// 80FE22CB source+0x1F0 names the model channel; FNV-1("doors").
+constexpr std::uint32_t kDoorsChannel = 0x80296344;
+
+template<class T> T read(std::uintptr_t address) noexcept {
+    T value{};
+    std::memcpy(&value, reinterpret_cast<const void*>(address), sizeof(value));
+    return value;
+}
+
+// Resolve the source exactly as 1029330 does. The runtime component is a separate
+// allocation: offsets into an entity's source slab are not runtime pointers.
+bool is_harvester_delivery(std::uintptr_t runtime) noexcept {
+    __try {
+        const auto handle = read<std::uint32_t>(runtime);
+        if (handle == 0xffffffff) return false;
+        const auto high = static_cast<std::uint32_t>(static_cast<std::int32_t>(handle) >> 13);
+        const auto pool = (high & 0xffffU) & ((static_cast<std::uint64_t>(high) | 0xffc0000ULL) >> 18);
+        const auto table = read<std::uintptr_t>(read<std::uintptr_t>(g_base + 0x2439C70));
+        const auto record = table + pool * 64;
+        const auto item = read<std::uintptr_t>(record + 8)
+            + read<std::uint32_t>(record + 48) * static_cast<std::uintptr_t>(handle & 0x1fff);
+        const auto mask = static_cast<std::uintptr_t>(static_cast<std::intptr_t>(read<std::int32_t>(record + 52)));
+        const auto slab = item - (read<std::uintptr_t>(item + 8) & mask);
+        const auto source = slab + read<std::uintptr_t>(runtime + 8);
+        // The native reference resolves the delivery RESOURCE, not the owning
+        // actor's source slab. Its root is a resource header (size/offsets), so
+        // checking slab+4 for the actor tag rejected every real delivery.
+        return read<std::uint32_t>(source) == 0x80C0E5D2
+            && read<std::uint32_t>(source + 4) == 0x8080670A;
+    } __except (EXCEPTION_EXECUTE_HANDLER) { return false; }
+}
+
+// The live capture proved all four delivery action bindings are empty. Drive the
+// separately authored model channel instead, using the native named-scalar route
+// used by actor command A9BFA0. Match only this delivery resource, never other AI.
+void set_doors(void* self, int stage) noexcept {
+    const auto runtime = reinterpret_cast<std::uintptr_t>(self);
+    if (!self || (stage != 0 && stage != 1) || !is_harvester_delivery(runtime)) return;
+    __try {
+        const auto entity = read<std::uint32_t>(runtime + 44);
+        const auto priorStage = read<std::uint8_t>(runtime + 480);
+        if (entity == 0xffffffff || priorStage == stage) return;
+        const float value = stage == 1 ? 1.0f : 0.0f;
+        alignas(16) const float values[4]{value, value, value, value};
+        reinterpret_cast<SetChannel>(g_base + 0x576420)(entity, &kDoorsChannel, values);
+        core::log::writef(core::log::Channel::client, core::log::Level::info,
+            "ev=harvester_doors stage=%d previous=%u entity=%08x channel=%08x value=%.1f result=requested",
+            stage, static_cast<unsigned>(priorStage), entity, kDoorsChannel, static_cast<double>(value));
+    } __except (EXCEPTION_EXECUTE_HANDLER) {
+        core::log::write(core::log::Channel::client, core::log::Level::error,
+            "ev=harvester_doors result=exception");
+    }
+}
+
+std::intptr_t __fastcall set_stage(void* self, int stage) noexcept {
+    set_doors(self, stage);
+    return reinterpret_cast<SetStage>(g_hook.original)(self, stage);
+}
+}
+
+bool install() noexcept {
+    if (g_hook.attached) return install_exit();
+    if (!core::settings::get().server.activation.missionScripting) return true;
+    g_base = reinterpret_cast<std::uintptr_t>(GetModuleHandleW(nullptr));
+    constexpr unsigned char stagePrefix[]{0x48,0x89,0x5c,0x24,0x08,0x57,0x48,0x83,0xec,0x20,
+        0x48,0x63,0xda,0x48,0x8b,0xf9,0x88,0x99,0xe0,0x01,0x00,0x00};
+    constexpr unsigned char channelPrefix[]{0x48,0x89,0x5c,0x24,0x08,0x48,0x89,0x74,0x24,0x10,
+        0x55,0x57,0x41,0x56,0x48,0x8d,0xac,0x24,0x70,0xf4,0xff,0xff,0x48,0x81,0xec,0x90,0x0c,0x00,0x00};
+    if (!g_base || std::memcmp(reinterpret_cast<void*>(g_base + 0x102A5B0), stagePrefix, sizeof(stagePrefix))
+        || std::memcmp(reinterpret_cast<void*>(g_base + 0x576420), channelPrefix, sizeof(channelPrefix))) {
+        core::log::write(core::log::Channel::client, core::log::Level::warn,
+            "ev=harvester_doors stage=install result=build_mismatch");
+        return false;
+    }
+    const bool ok = hooking::detour::install({reinterpret_cast<void*>(g_base + 0x102A5B0),
+        reinterpret_cast<void*>(&set_stage)}, g_hook);
+    core::log::writef(core::log::Channel::client, core::log::Level::info,
+        "ev=harvester_doors stage=install result=%s", ok ? "ok" : "failed");
+    const bool exitOk = install_exit();
+    return ok && exitOk;
+}
+}

+ 7 - 0
Sunrise/src/client/hooks/harvester_drop/harvester_drop_experiment.h

@@ -0,0 +1,7 @@
+#pragma once
+
+namespace sunrise::client::hooks::harvester_drop {
+// Experimental model doors channel: open during native delivery, close on completion.
+[[nodiscard]] bool install_exit() noexcept;
+bool install() noexcept;
+}

+ 135 - 0
Sunrise/src/client/hooks/harvester_drop/harvester_exit.cpp

@@ -0,0 +1,135 @@
+#include "harvester_drop_experiment.h"
+#include <Windows.h>
+#include <atomic>
+#include <cstdint>
+#include <cstring>
+#include "../../../core/logging/log.h"
+#include "../../hooking/detour.h"
+
+namespace sunrise::client::hooks::harvester_drop {
+namespace {
+std::uintptr_t base{};
+hooking::detour::Handle lookupHook{}, startHook{}, stopHook{};
+using Lookup = bool (__fastcall*)(std::uint32_t, const std::uint32_t*, const std::uint32_t*, int*, int*);
+using Start = std::intptr_t (__fastcall*)(void*, int);
+using Stop = bool (__fastcall*)(void*, int);
+std::atomic<unsigned> lookupReports{};
+constexpr std::uint32_t none = 0x811c9dc5;
+template<class T> T read(std::uintptr_t at) noexcept {
+    T v{}; std::memcpy(&v, reinterpret_cast<const void*>(at), sizeof(v)); return v;
+}
+std::uintptr_t resolve(std::uint32_t handle, std::uintptr_t offset = 0) noexcept {
+    if (handle == 0xffffffff) return 0;
+    const auto high = static_cast<std::uint32_t>(static_cast<std::int32_t>(handle) >> 13);
+    const auto pool = (high & 0xffffU) & ((static_cast<std::uint64_t>(high) | 0xffc0000ULL) >> 18);
+    const auto table = read<std::uintptr_t>(read<std::uintptr_t>(base + 0x2439c70));
+    const auto record = table + pool * 64;
+    const auto item = read<std::uintptr_t>(record + 8)
+        + read<std::uint32_t>(record + 48) * static_cast<std::uintptr_t>(handle & 0x1fff);
+    const auto mask = static_cast<std::uintptr_t>(static_cast<std::intptr_t>(read<std::int32_t>(record + 52)));
+    return item - (read<std::uintptr_t>(item + 8) & mask) + offset;
+}
+bool exit_name(std::uint32_t name) noexcept {
+    return name == 0x7b0d3643 || name == 0x7d0d39a9 || name == 0x820d414b;
+}
+// A889E0's exact native table: group rows are 32 bytes; variant names are u32.
+// Resolve the owner from the live actor metadata rather than inventing a group hash.
+// This extension is deliberately limited to an unnamed Harvester exit request.
+bool find_group(std::uint32_t actor, std::uint32_t variant, std::uint32_t& group) noexcept {
+    __try {
+        if (actor == 0xffffffff || !exit_name(variant)) return false;
+        const auto record = read<std::uintptr_t>(base + 0x1f9d7f8)
+            + read<std::uint32_t>(base + 0x1f9d800) * static_cast<std::uintptr_t>(actor & 0x1fff);
+        const auto entity = read<std::uint32_t>(record + 76);
+        if (entity == 0xffffffff) return false;
+        const auto entityRecord = read<std::uintptr_t>(base + 0x1f93428)
+            + read<std::uint32_t>(base + 0x1f93430) * static_cast<std::uintptr_t>(entity & 0x1fff);
+        const auto source = resolve(read<std::uint32_t>(entityRecord + 76));
+        if (!source || read<std::uint32_t>(source + 4) != 0x80fe22fc) return false;
+        const auto metadata = resolve(read<std::uint32_t>(record + 44));
+        if (!metadata) return false;
+        const auto count = read<std::int64_t>(metadata + 32);
+        if (count <= 0 || count > 256) return false;
+        const auto rows = metadata + 56 + read<std::uintptr_t>(metadata + 40);
+        unsigned matches = 0;
+        for (std::int64_t i = 0; i < count; ++i) {
+            const auto row = rows + 32 * i;
+            const auto variants = read<std::int64_t>(row + 16);
+            if (variants < 0 || variants > 256) return false;
+            const auto names = row + 40 + read<std::uintptr_t>(row + 24);
+            for (std::int64_t j = 0; j < variants; ++j) {
+                if (read<std::uint32_t>(names + 4 * j) != variant) continue;
+                group = read<std::uint32_t>(row + 12);
+                ++matches;
+            }
+        }
+        return matches == 1;
+    } __except (EXCEPTION_EXECUTE_HANDLER) { return false; }
+}
+bool __fastcall lookup(std::uint32_t actor, const std::uint32_t* group,
+    const std::uint32_t* variant, int* groupIndex, int* variantIndex) noexcept {
+    const auto original = reinterpret_cast<Lookup>(lookupHook.original);
+    if (!group || !variant || *group != none || !exit_name(*variant))
+        return original(actor, group, variant, groupIndex, variantIndex);
+    std::uint32_t resolved = none;
+    const bool found = find_group(actor, *variant, resolved);
+    const bool ok = original(actor, found ? &resolved : group, variant, groupIndex, variantIndex);
+    if (lookupReports.fetch_add(1, std::memory_order_relaxed) < 16)
+        core::log::writef(core::log::Channel::client, core::log::Level::info,
+            "ev=harvester_exit stage=resolve actor=%08x group=%08x action=%08x unique=%u result=%s",
+            actor, resolved, *variant, found ? 1U : 0U, ok ? "resolved" : "unresolved");
+    return ok;
+}
+std::uint32_t action_name(void* self, int index) noexcept {
+    __try {
+        const auto runtime = reinterpret_cast<std::uintptr_t>(self);
+        if (!runtime || index < 0 || index >= read<std::int64_t>(runtime + 1264)) return none;
+        const auto source = resolve(read<std::uint32_t>(runtime), read<std::uintptr_t>(runtime + 8));
+        if (!source || read<std::uint32_t>(source) != 0x80fe21ce
+            || read<std::uint32_t>(source + 4) != 0x808067ec) return none;
+        return read<std::uint32_t>(source + 816 + read<std::uintptr_t>(source + 784) + 80 * index);
+    } __except (EXCEPTION_EXECUTE_HANDLER) { return none; }
+}
+std::intptr_t __fastcall start(void* self, int index) noexcept {
+    const auto name = action_name(self, index);
+    const auto result = reinterpret_cast<Start>(startHook.original)(self, index);
+    if (name != none)
+        core::log::writef(core::log::Channel::client, core::log::Level::info,
+            "ev=harvester_action stage=start action=%08x index=%d active=%d",
+            name, index, read<std::int32_t>(reinterpret_cast<std::uintptr_t>(self) + 1484));
+    return result;
+}
+bool __fastcall stop(void* self, int index) noexcept {
+    const auto name = action_name(self, index);
+    const bool result = reinterpret_cast<Stop>(stopHook.original)(self, index);
+    if (name != none)
+        core::log::writef(core::log::Channel::client, core::log::Level::info,
+            "ev=harvester_action stage=stop action=%08x index=%d result=%u", name, index, result ? 1U : 0U);
+    return result;
+}
+}
+bool install_exit() noexcept {
+    if (lookupHook.attached && startHook.attached && stopHook.attached) return true;
+    base = reinterpret_cast<std::uintptr_t>(GetModuleHandleW(nullptr));
+    // Exact loaded-build prologues; no patching on an unrecognized executable.
+    constexpr unsigned char lookupPrefix[]{0x48,0x83,0xec,0x38,0x4c,0x8b,0xd2,0x81,0xe1,0xff,0x1f,0x00,0x00};
+    constexpr unsigned char startPrefix[]{0x48,0x89,0x5c,0x24,0x20,0x55,0x56,0x57,0x41,0x55,0x41,0x57,0x48,0x8d,0x6c,0x24,0xc9,0x48,0x81,0xec};
+    constexpr unsigned char stopPrefix[]{0x48,0x89,0x74,0x24,0x18,0x57,0x48,0x83,0xec,0x70,0x44,0x8b,0x11,0x48,0x8b,0xf9,0x48,0x8b,0x05,0x99};
+    if (!base || std::memcmp(reinterpret_cast<void*>(base + 0xa054c0), lookupPrefix, sizeof(lookupPrefix))
+        || std::memcmp(reinterpret_cast<void*>(base + 0x1023f00), startPrefix, sizeof(startPrefix))
+        || std::memcmp(reinterpret_cast<void*>(base + 0x10245c0), stopPrefix, sizeof(stopPrefix))) {
+        core::log::write(core::log::Channel::client, core::log::Level::warn,
+            "ev=harvester_exit stage=install result=build_mismatch");
+        return false;
+    }
+    const bool a = lookupHook.attached || hooking::detour::install(
+        {reinterpret_cast<void*>(base + 0xa054c0), reinterpret_cast<void*>(&lookup)}, lookupHook);
+    const bool b = startHook.attached || hooking::detour::install(
+        {reinterpret_cast<void*>(base + 0x1023f00), reinterpret_cast<void*>(&start)}, startHook);
+    const bool c = stopHook.attached || hooking::detour::install(
+        {reinterpret_cast<void*>(base + 0x10245c0), reinterpret_cast<void*>(&stop)}, stopHook);
+    core::log::writef(core::log::Channel::client, core::log::Level::info,
+        "ev=harvester_exit stage=install lookup=%u start=%u stop=%u", a ? 1U : 0U, b ? 1U : 0U, c ? 1U : 0U);
+    return a && b && c;
+}
+}

+ 67 - 1
Sunrise/src/client/hooks/retail_log/retail_log_enqueue_observer.cpp

@@ -8,7 +8,7 @@
 
 #include "../../../core/logging/log.h"
 #include "../../targets/game.h"
-#include "../network/investment/internal.h"
+#include "../../diagnostics/entity_create_probe.h"
 
 namespace sunrise::client::hooks::retail_log {
 namespace {
@@ -112,6 +112,72 @@ void capture_line(std::int32_t siteId, const char* text) noexcept {
     core::log::write(core::log::Channel::client, core::log::Level::info, {line.data(), length});
 }
 
+/** Text whose emitting call site is worth locating in the image. */
+constexpr std::string_view kTracedText = "failed to create";
+/** Call sites named per run, so a repeating line cannot flood the sink. */
+constexpr std::size_t kMaxCallSiteReports = 64;
+
+/** Reports already spent. */
+volatile LONG g_callSiteReports{};
+
+/**
+ * Names the image offset of the code that emitted one line.
+ * The packed executable cannot be disassembled on disk, so a dump of the mapped image is the only
+ * readable copy, and an offset from the load base is what addresses it. The retail text itself
+ * carries no address, and the site id is assigned by the game's own registration rather than by
+ * position, so nothing else here says which function produced a line. `_ReturnAddress` inside the
+ * funnel is the emitting call site, which is exactly the function to disassemble.
+ * @param returnAddress Return address captured in the funnel.
+ * @param text Already-formatted native line.
+ */
+void report_call_site(const void* returnAddress, const char* text) noexcept {
+    const bool entityFailure = std::string_view(text).find("networking:simulation:entity:")
+                               != std::string_view::npos;
+    const auto level = entityFailure ? core::log::Level::warn : core::log::Level::debug;
+    if (returnAddress == nullptr
+        || !core::log::accepts(core::log::Channel::client, level)) {
+        return;
+    }
+    const auto base = reinterpret_cast<std::uintptr_t>(GetModuleHandleW(nullptr));
+    const auto site = reinterpret_cast<std::uintptr_t>(returnAddress);
+    if (base == 0 || site < base) {
+        return;
+    }
+    if (InterlockedIncrement(&g_callSiteReports) > static_cast<LONG>(kMaxCallSiteReports)) {
+        return;
+    }
+    std::array<char, kEventCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=retail_site stage=caller rva=0x%llX va=0x%llX text=%s",
+                                      static_cast<unsigned long long>(site - base),
+                                      static_cast<unsigned long long>(site),
+                                      text);
+    if (written > 0) {
+        const auto length = static_cast<std::size_t>(written) < line.size()
+                                ? static_cast<std::size_t>(written)
+                                : line.size() - 1;
+        core::log::write(core::log::Channel::client, level,
+                         {line.data(), length});
+    }
+    if (entityFailure) {
+        diagnostics::report_entity_create_failure_pool();
+        // Bird's retail line omits the entity identity and failure boundary. Capture
+        // the callers at the failure, while they still exist, at normal log settings.
+        // This observes the failure; it does not retry or suppress native creation.
+        std::array<void*, 12> frames{};
+        const auto count = RtlCaptureStackBackTrace(1, static_cast<DWORD>(frames.size()),
+                                                    frames.data(), nullptr);
+        for (USHORT index = 0; index < count; ++index) {
+            const auto address = reinterpret_cast<std::uintptr_t>(frames[index]);
+            core::log::writef(core::log::Channel::client, level,
+                "ev=entity_create_failure stage=stack frame=%u va=0x%llX image_base=0x%llX",
+                static_cast<unsigned>(index), static_cast<unsigned long long>(address),
+                static_cast<unsigned long long>(base));
+        }
+    }
+}
+
 /**
  * Mirrors the single funnel every retail log line passes through.
  * @param siteId Registered site id.

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

@@ -24,6 +24,7 @@
 #include "../hooks/cursor/runtime.h"
 #include "../hooks/graphics/graphics_hook_lifecycle.h"
 #include "../hooks/hitch_probe/hitch_probe.h"
+#include "../hooks/harvester_drop/harvester_drop_experiment.h"
 #include "../hooks/inactivity/inactivity_override.h"
 #include "../hooks/infinite_ammo/infinite_ammo.h"
 #include "../hooks/membership_probe/membership_probe.h"
@@ -174,6 +175,8 @@ void clear_game_targets() noexcept {
                                  : "ev=activate stage=package_keys result=fail");
     // Diagnostic capture reports its own outcome and never demotes this stage.
     (void)hooks::retail_log::install();
+    (void)hooks::vendor_banner::install();
+    (void)hooks::harvester_drop::install();
     (void)hooks::assert_handler::install();
     // Read-only. At a hitch it dumps every in-flight job record from the watchdog snapshot,
     // which names the job and thread the in-world freeze blocks on.

+ 5 - 1
Sunrise/src/middleware/bap/activity_message/activity_cinematic_incident_codec.cpp

@@ -26,7 +26,7 @@ constexpr std::int32_t kSlotIndexBias = 32'768;
 /**
  * Maps one global SObject row to its cinematic signal.
  * @param target Global SObject row from the incident header.
- * @param output Set only when the row is one of the two cinematic rows.
+ * @param output Set only when the row is one of the three cinematic rows.
  * @return True when the row names a cinematic signal.
  */
 bool signal_for_target(std::uint32_t target, Signal& output) noexcept {
@@ -38,6 +38,10 @@ bool signal_for_target(std::uint32_t target, Signal& output) noexcept {
         output = Signal::terminated;
         return true;
     }
+    if (target == kSkipTarget) {
+        output = Signal::skipRequested;
+        return true;
+    }
     return false;
 }
 

+ 4 - 1
Sunrise/src/middleware/bap/activity_message/activity_replicate_membership_encoder.cpp

@@ -28,7 +28,10 @@ bool encode_replicate_membership(const MembershipSnapshot& snapshot,
                          && writer.write(1, 1) && write_region_block(writer, snapshot)
                          && writer.write(1, 1) && writer.write(occupied_member_mask(snapshot), 32)
                          && writer.write(1, 1) && writer.write(occupied_member_mask(snapshot), 32)
-                         && writer.write(1, 1) && writer.write(active_view_mask(snapshot), 32)
+                         // Field 6 drives native view +178 (entity send enabled). An absent
+                         // delta preserves zero and strands every deletion awaiting this host.
+                         && writer.write(1, 1)
+                         && writer.write(entity_replication_member_mask(snapshot), 32)
                          && writer.write(0, 1) && writer.write(0, 1);
     std::size_t encodedSize = 0;
     const std::size_t meaningfulBits = meaningful_bit_count(snapshot);

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

@@ -22,6 +22,63 @@ constexpr std::uint64_t kUnusedAuxiliary = 0;
 /** Type-31 reserves the largest generation as its unset sentinel. */
 constexpr std::uint64_t kReservedGeneration = std::numeric_limits<std::uint64_t>::max();
 
+/**
+ * Writes the 353-bit 0x808099C4 child with no timer: epoch -1, all other fields 0.
+ * Epoch 0 arms the client's goal-timer pin and the directive banner never hides.
+ */
+[[nodiscard]] bool write_neutral_timed_state(bits::Writer& writer) noexcept {
+    if (!writer.write(0, kBoolWidth)) {
+        return false;
+    }
+    for (std::size_t index = 0; index < 4; ++index) {
+        if (!writer.write(0, kWideIntegerWidth)) {
+            return false;
+        }
+    }
+    return writer.write(kNoTimerEpoch, kWideIntegerWidth) && writer.write(0, kReal32Width);
+}
+
+/** Writes one neutral 239-bit authored HUD marker subrecord. */
+[[nodiscard]] bool write_directive_marker(bits::Writer& writer, const Type2LaneClientRef& target) noexcept {
+    // 100A6E0 copies .0 into the native target; .2's first hash must be the
+    // absent FNV sentinel or the client attempts a second authored-name lookup.
+    return writer.write(target.registryKey, 32)
+           && writer.write(static_cast<std::uint8_t>(target.slotType), kClientRefTypeWidth)
+           && writer.write(static_cast<std::uint32_t>(target.slotIndex + kClientRefIndexBias), kClientRefIndexWidth)
+           && write_absent_client_ref(writer) && writer.write(kClientRefAbsentKey, 32)
+           && writer.write(0, 32) && writer.write(0, 32) && writer.write(0, 32)
+           && writer.write(0, kBoolWidth);
+}
+
+/** Writes one of the three complete type-68 state lanes. */
+[[nodiscard]] bool write_directive_entry(bits::Writer& writer,
+                                         std::uint32_t nameHash,
+                                         std::int32_t elementIndex,
+                                         std::int8_t state,
+                                         const Type2LaneClientRef& target = {}) noexcept {
+    if (!writer.write(nameHash, 32)
+        || !writer.write(std::bit_cast<std::uint32_t>(elementIndex) + kSigned32Bias, 32)
+        || !writer.write(static_cast<std::uint32_t>(state) + 1U, kDirectiveStateWidth)
+        || !write_neutral_timed_state(writer)) {
+        return false;
+    }
+    for (std::size_t index = 0; index < 4; ++index) {
+        if (!writer.write(kSigned32Bias, 32)) {
+            return false;
+        }
+    }
+    if (!writer.write(1, kDirectiveStateWidth) || !write_absent_client_ref(writer)
+        || !writer.write(target.slotIndex >= 0 ? 3U : 1U, kDirectiveAuxStateWidth)) {
+        return false;
+    }
+    for (std::size_t index = 0; index < 4; ++index) {
+        if (!write_directive_marker(writer, index == 0 ? target : Type2LaneClientRef{})) {
+            return false;
+        }
+    }
+    return true;
+}
+
 /** @return The reflected channel ordinal, or the channel count for an invalid enum value. */
 [[nodiscard]] constexpr std::size_t channel_index(Type23Channel channel) noexcept {
     const auto index = static_cast<std::size_t>(channel);
@@ -400,4 +457,563 @@ bool validate_type35_body(std::span<const std::byte> input, std::size_t bitCount
     return bitCount == kType35BitCount && decode_type35_body(input, body);
 }
 
+/** Finds the next sequence revision while reserving 0xFF as inactive. */
+bool next_type5_revision(const Type5RevisionGuard& guard, std::uint8_t& next) noexcept {
+    next = 1U;
+    if (!guard.hasLast) {
+        return true;
+    }
+    if (guard.last >= kSequenceInactiveRevision - 1U) {
+        return false;
+    }
+    next = static_cast<std::uint8_t>(guard.last + 1U);
+    return next != kSequenceInactiveRevision;
+}
+
+/** Encodes the canonical sequence body: one revision and 129 unset ClientRefs. */
+bool encode_type5(const Type5Preset& preset,
+                  const Type5RevisionGuard& guard,
+                  std::span<std::byte> output,
+                  std::size_t& written) noexcept {
+    written = 0;
+    if (preset.revision == kSequenceInactiveRevision
+        || (guard.hasLast && preset.revision <= guard.last) || output.size() < kType5ByteCount) {
+        return false;
+    }
+    bits::Writer writer(output.first(kType5ByteCount));
+    bool encoded = writer.write(0, kWideIntegerWidth) && writer.write(0, kWideIntegerWidth)
+                   && writer.write(preset.revision, 8U);
+    for (std::size_t index = 0; encoded && index < 4U; ++index) {
+        encoded = writer.write(0, kSigned32Width);
+    }
+    for (std::size_t index = 0; encoded && index < kSequenceReferenceCount; ++index) {
+        encoded = write_absent_client_ref(writer);
+    }
+    encoded = encoded && write_absent_client_ref(writer);
+    return encoded && writer.bit_count() == kType5BitCount && writer.finish(written)
+           && written == kType5ByteCount;
+}
+
+/** Accepts only the canonical closed type-5 body emitted by encode_type5. */
+bool validate_type5_body(std::span<const std::byte> input, std::size_t bitCount) noexcept {
+    if (bitCount != kType5BitCount || input.size() != kType5ByteCount) {
+        return false;
+    }
+    bits::Reader reader(input);
+    std::uint64_t first = 0;
+    std::uint64_t second = 0;
+    std::uint64_t revision = 0;
+    if (!reader.read(kWideIntegerWidth, first) || !reader.read(kWideIntegerWidth, second)
+        || !reader.read(8U, revision) || first != 0 || second != 0
+        || revision == kSequenceInactiveRevision) {
+        return false;
+    }
+    for (std::size_t index = 0; index < 4U; ++index) {
+        std::uint64_t value = 0;
+        if (!reader.read(kSigned32Width, value) || value != 0) {
+            return false;
+        }
+    }
+    for (std::size_t index = 0; index < kSequenceReferenceCount + 1U; ++index) {
+        if (!read_absent_client_ref(reader)) {
+            return false;
+        }
+    }
+    return finish_padding(reader);
+}
+
+/** Finds the next cinematic generation without wrapping. */
+bool next_type6_generation(const Type6GenerationGuard& guard, std::uint32_t& next) noexcept {
+    next = 1U;
+    if (!guard.hasLast) {
+        return true;
+    }
+    if (guard.last == (std::numeric_limits<std::uint32_t>::max)()) {
+        return false;
+    }
+    next = guard.last + 1U;
+    return next != 0;
+}
+
+/** Encodes the canonical cinematic start/stop body with empty participant arrays. */
+bool encode_type6(const Type6Preset& preset,
+                  const Type6GenerationGuard& guard,
+                  std::span<std::byte> output,
+                  std::size_t& written) noexcept {
+    written = 0;
+    if (preset.generation == 0 || (guard.hasLast && preset.generation <= guard.last)
+        || output.size() < kType6ByteCount) {
+        return false;
+    }
+    bits::Writer writer(output.first(kType6ByteCount));
+    bool encoded = writer.write(0, kWideIntegerWidth) && writer.write(0, kWideIntegerWidth)
+                   && writer.write(preset.generation, kSigned32Width)
+                   && writer.write(preset.active ? 1U : 0U, kBoolWidth)
+                   && writer.write(0, kBoolWidth)
+                   && write_absent_client_ref(writer)
+                   // Bias three stores the native wildcard value -1 as wire value two.
+                   && writer.write(2U, 6U)
+                   // Both arrays are dynamically sized. Zero counts have no element payload.
+                   && writer.write(0, 5U) && writer.write(0, 3U) && writer.write(0, kSigned32Width);
+    return encoded && writer.bit_count() == kType6BitCount && writer.finish(written)
+           && written == kType6ByteCount;
+}
+
+/** Accepts only the canonical closed type-6 body emitted by encode_type6. */
+bool validate_type6_body(std::span<const std::byte> input, std::size_t bitCount) noexcept {
+    if (bitCount != kType6BitCount || input.size() != kType6ByteCount) {
+        return false;
+    }
+    bits::Reader reader(input);
+    std::uint64_t first = 0;
+    std::uint64_t second = 0;
+    std::uint64_t generation = 0;
+    std::uint64_t active = 0;
+    std::uint64_t force = 0;
+    std::uint64_t role = 0;
+    std::uint64_t count = 0;
+    if (!reader.read(kWideIntegerWidth, first) || !reader.read(kWideIntegerWidth, second)
+        || !reader.read(kSigned32Width, generation) || !reader.read(kBoolWidth, active)
+        || !reader.read(kBoolWidth, force) || !read_absent_client_ref(reader)
+        || !reader.read(6U, role) || !reader.read(5U, count) || first != 0 || second != 0
+        || generation == 0 || force != 0 || role != 2U || count != 0) {
+        return false;
+    }
+    if (!reader.read(3U, count) || count != 0) {
+        return false;
+    }
+    std::uint64_t trailing = 0;
+    return reader.read(kSigned32Width, trailing) && trailing == 0 && finish_padding(reader);
+}
+
+/** Advances the exact signed type-3 generation without wrapping its terminal value. */
+bool next_type3_generation(const Type3GenerationGuard& guard, std::int32_t& next) noexcept {
+    return next_positive_generation(guard.hasLast, guard.last, next);
+}
+
+/** Encodes the fixed 24-objective reset body and its monotonic generation. */
+bool encode_type3(const Type3Body& body,
+                  const Type3GenerationGuard& guard,
+                  std::span<std::byte> output,
+                  std::size_t& written) noexcept {
+    written = 0;
+    if (body.generation <= 0 || (guard.hasLast && body.generation <= guard.last)
+        || output.size() < kType3ByteCount) {
+        return false;
+    }
+    bits::Writer writer(output.first(kType3ByteCount));
+    bool encoded = writer.write(1U, kBoolWidth);
+    for (const std::int8_t value : body.objectiveRevisions) {
+        if (value < 0) {
+            return false;
+        }
+        encoded = encoded && writer.write(1U, kBoolWidth)
+                  && writer.write(static_cast<std::uint8_t>(value), kType3ValueWidth);
+    }
+    encoded = encoded && writer.write(1U, kBoolWidth)
+              && writer.write(static_cast<std::uint32_t>(body.generation), kType3GenerationWidth);
+    return encoded && writer.bit_count() == kType3BitCount && writer.finish(written)
+           && written == kType3ByteCount;
+}
+
+/** Decodes one complete fixed-width type-3 objective reset body. */
+bool decode_type3_body(std::span<const std::byte> input, Type3Body& body) noexcept {
+    body = {};
+    if (input.size() != kType3ByteCount) {
+        return false;
+    }
+    bits::Reader reader(input);
+    std::uint64_t arrayPresent = 0;
+    if (!reader.read(kBoolWidth, arrayPresent) || arrayPresent == 0) {
+        return false;
+    }
+    for (std::int8_t& value : body.objectiveRevisions) {
+        std::uint64_t present = 0;
+        std::uint64_t wire = 0;
+        if (!reader.read(kBoolWidth, present)
+            || (present != 0 && !reader.read(kType3ValueWidth, wire))) {
+            return false;
+        }
+        value = present != 0 ? static_cast<std::int8_t>(wire) : 0;
+    }
+    std::uint64_t present = 0;
+    std::uint64_t generation = 0;
+    if (!reader.read(kBoolWidth, present)
+        || (present != 0 && !reader.read(kType3GenerationWidth, generation))
+        || !finish_padding(reader)) {
+        return false;
+    }
+    body.generation = present != 0 ? static_cast<std::int32_t>(generation) : 0;
+    return body.generation > 0;
+}
+
+/** Validates the exact type-3 bit count and canonical fixed-width body. */
+bool validate_type3_body(std::span<const std::byte> input, std::size_t bitCount) noexcept {
+    Type3Body body{};
+    return bitCount == kType3BitCount && decode_type3_body(input, body);
+}
+
+/** Finds the next positive task generation without wrapping. */
+bool next_type38_generation(const Type38GenerationGuard& guard, std::int32_t& next) noexcept {
+    return next_positive_generation(guard.hasLast, guard.last, next);
+}
+
+/** Encodes one exact signed-int32 task generation. */
+bool encode_type38(const Type38Preset& preset,
+                   const Type38GenerationGuard& guard,
+                   std::span<std::byte> output,
+                   std::size_t& written) noexcept {
+    written = 0;
+    if (preset.generation <= 0 || (guard.hasLast && preset.generation <= guard.last)
+        || output.size() < kType38ByteCount) {
+        return false;
+    }
+    bits::Writer writer(output.first(kType38ByteCount));
+    const std::uint32_t wire = std::bit_cast<std::uint32_t>(preset.generation) + kSigned32Bias;
+    return writer.write(wire, kSigned32Width) && writer.bit_count() == kType38BitCount
+           && writer.finish(written) && written == kType38ByteCount;
+}
+
+/** Decodes one exact signed-int32 task generation. */
+bool decode_type38_body(std::span<const std::byte> input, std::int32_t& generation) noexcept {
+    if (input.size() != kType38ByteCount) {
+        return false;
+    }
+    bits::Reader reader(input);
+    std::uint64_t wire = 0;
+    if (!reader.read(kSigned32Width, wire) || !finish_padding(reader)) {
+        return false;
+    }
+    generation = std::bit_cast<std::int32_t>(static_cast<std::uint32_t>(wire) - kSigned32Bias);
+    return generation > 0;
+}
+
+/** Checks one packed type-38 task generation. */
+bool validate_type38_body(std::span<const std::byte> input, std::size_t bitCount) noexcept {
+    std::int32_t generation = 0;
+    return bitCount == kType38BitCount && decode_type38_body(input, generation);
+}
+
+/** Finds the next positive dialogue fire sequence without wrapping. */
+bool next_type53_sequence(const Type53SequenceGuard& guard,
+                          std::uint16_t cueIndex,
+                          std::int32_t& next) noexcept {
+    next = 0;
+    if (cueIndex >= guard.last.size() || guard.last[cueIndex] < 0
+        || guard.last[cueIndex] == (std::numeric_limits<std::int32_t>::max)()) {
+        return false;
+    }
+    next = guard.last[cueIndex] + 1;
+    return next > 0;
+}
+
+/** Encodes one dialogue pulse with the schema's optional world field present only for its cue. */
+bool encode_type53(const Type53Preset& preset,
+                   const Type53SequenceGuard& guard,
+                   std::span<std::byte> output,
+                   std::size_t& written) noexcept {
+    written = 0;
+    if (preset.cueIndex >= kType53EntryCount || preset.sequence <= 0
+        || preset.sequence <= guard.last[preset.cueIndex] || output.size() < kType53ByteCount) {
+        return false;
+    }
+    bits::Writer writer(output.first(kType53ByteCount));
+    bool encoded = write_absent_client_ref(writer);
+    for (std::size_t index = 0; encoded && index < kType53EntryCount; ++index) {
+        const bool selected = index == preset.cueIndex;
+        const std::int32_t sequence = selected ? preset.sequence : guard.last[index];
+        const std::uint32_t wireSequence = std::bit_cast<std::uint32_t>(sequence) + kSigned32Bias;
+        const std::int8_t mode = selected ? kDialogueFireMode : kDialogueInactiveMode;
+        encoded =
+            writer.write(0, kWideIntegerWidth) && writer.write(selected ? 1U : 0U, kBoolWidth);
+        if (encoded && selected) {
+            encoded = writer.write(kDialogueActiveWorld, kWideIntegerWidth);
+        }
+        encoded =
+            encoded && write_absent_client_ref(writer) && writer.write(wireSequence, kSigned32Width)
+            && writer.write(static_cast<std::uint32_t>(static_cast<std::int32_t>(mode) + kModeBias),
+                            kModeWidth);
+    }
+    return encoded && writer.bit_count() == kType53BitCount && writer.finish(written)
+           && written == kType53ByteCount;
+}
+
+/** Validates the closed pulse form rather than accepting arbitrary type-53 references. */
+bool validate_type53_body(std::span<const std::byte> input, std::size_t bitCount) noexcept {
+    if (bitCount != kType53BitCount || input.size() != kType53ByteCount) {
+        return false;
+    }
+    bits::Reader reader(input);
+    if (!read_absent_client_ref(reader)) {
+        return false;
+    }
+    std::size_t activeCount = 0;
+    for (std::size_t index = 0; index < kType53EntryCount; ++index) {
+        std::uint64_t tick = 0;
+        std::uint64_t hasWorld = 0;
+        std::uint64_t world = 0;
+        std::uint64_t wireSequence = 0;
+        std::uint64_t wireMode = 0;
+        if (!reader.read(kWideIntegerWidth, tick) || !reader.read(kBoolWidth, hasWorld)
+            || (hasWorld != 0 && !reader.read(kWideIntegerWidth, world))
+            || !read_absent_client_ref(reader) || !reader.read(kSigned32Width, wireSequence)
+            || !reader.read(kModeWidth, wireMode)) {
+            return false;
+        }
+        const std::int32_t sequence =
+            std::bit_cast<std::int32_t>(static_cast<std::uint32_t>(wireSequence) - kSigned32Bias);
+        const std::int32_t mode = static_cast<std::int32_t>(wireMode) - kModeBias;
+        if (tick != 0) {
+            return false;
+        }
+        if (hasWorld != 0) {
+            if (hasWorld != 1 || world != kDialogueActiveWorld || sequence <= 0
+                || mode != kDialogueFireMode || ++activeCount != 1) {
+                return false;
+            }
+        } else if (mode != kDialogueInactiveMode) {
+            return false;
+        }
+    }
+    return activeCount == 1 && finish_padding(reader);
+}
+
+/** Encodes one complete three-lane type-68 directive state. */
+bool encode_type68(const Type68Preset& preset,
+                   std::span<std::byte> output,
+                   std::size_t& written) noexcept {
+    written = 0;
+    if ((preset.audience.slotIndex >= 0 && (preset.audience.slotType != 70
+            || preset.audience.registryKey == 0 || preset.audience.registryKey == kClientRefAbsentKey))
+        || output.size() < kType68ByteCount || preset.state < 0 || preset.state > 2
+        || (preset.visible
+            && (preset.nameHash == 0 || preset.nameHash == kClientRefAbsentKey
+                || preset.elementIndex < 0))
+        || (preset.navpoint.slotIndex >= 0 && (!preset.visible || preset.state != 0
+            || preset.navpoint.slotType != 47 || preset.navpoint.registryKey == 0
+            || preset.navpoint.registryKey == kClientRefAbsentKey))) {
+        return false;
+    }
+    bits::Writer writer(output.first(kType68ByteCount));
+    bool encoded = (writer.write(preset.audience.registryKey, 32)
+        && writer.write(static_cast<std::uint32_t>(preset.audience.slotType) + 1U, 7)
+        && writer.write(static_cast<std::uint32_t>(preset.audience.slotIndex) + kSigned16Bias, 16)) && write_absent_client_ref(writer);
+    for (std::size_t index = 0; encoded && index < kType68EntryCount; ++index) {
+        encoded =
+            index == 0 && preset.visible
+                ? write_directive_entry(writer, preset.nameHash, preset.elementIndex, preset.state, preset.navpoint)
+                : write_directive_entry(writer, kClientRefAbsentKey, 0, -1);
+    }
+    encoded = encoded && writer.write(preset.visible ? 1U : 0U, kDirectiveActiveIndexWidth);
+    return encoded && writer.bit_count() == kType68BitCount && writer.finish(written)
+           && written == kType68ByteCount;
+}
+
+/** Validates the crash-bearing selector fields and the exact type-68 body shape. */
+bool validate_type68_body(std::span<const std::byte> input, std::size_t bitCount) noexcept {
+    if (bitCount != kType68BitCount || input.size() != kType68ByteCount) {
+        return false;
+    }
+    bits::Reader reader(input);
+    auto skip = [&reader](std::size_t count) noexcept {
+        std::uint64_t discarded = 0;
+        while (count != 0) {
+            const auto width = static_cast<std::uint8_t>((std::min)(count, std::size_t{64}));
+            if (!reader.read(width, discarded)) {
+                return false;
+            }
+            count -= width;
+        }
+        return true;
+    };
+    if (!skip(110)) {
+        return false;
+    }
+    std::array<std::uint32_t, kType68EntryCount> names{};
+    std::array<std::int32_t, kType68EntryCount> elements{};
+    std::array<std::int32_t, kType68EntryCount> states{};
+    for (std::size_t index = 0; index < kType68EntryCount; ++index) {
+        std::uint64_t name = 0;
+        std::uint64_t element = 0;
+        std::uint64_t state = 0;
+        if (!reader.read(32, name) || !reader.read(32, element)
+            || !reader.read(kDirectiveStateWidth, state) || !skip(1'497)) {
+            return false;
+        }
+        names[index] = static_cast<std::uint32_t>(name);
+        elements[index] =
+            std::bit_cast<std::int32_t>(static_cast<std::uint32_t>(element) - kSigned32Bias);
+        states[index] = static_cast<std::int32_t>(state) - 1;
+    }
+    std::uint64_t activeWire = 0;
+    if (!reader.read(kDirectiveActiveIndexWidth, activeWire)) {
+        return false;
+    }
+    const std::int32_t active = static_cast<std::int32_t>(activeWire) - 1;
+    if (active < -1 || active >= static_cast<std::int32_t>(kType68EntryCount)) {
+        return false;
+    }
+    if (active >= 0) {
+        const std::size_t selected = static_cast<std::size_t>(active);
+        if (names[selected] == 0 || names[selected] == kClientRefAbsentKey || elements[selected] < 0
+            || states[selected] < 0 || states[selected] > 2) {
+            return false;
+        }
+    }
+    return finish_padding(reader);
+}
+
+/** Encodes the no-filter type-70 encounter engagement state. */
+bool encode_type70(const Type70Preset& preset,
+                   std::span<std::byte> output,
+                   std::size_t& written) noexcept {
+    written = 0;
+    if (preset.flags > 0x1FU || output.size() < kType70ByteCount) {
+        return false;
+    }
+    bits::Writer writer(output.first(kType70ByteCount));
+    const auto revision = static_cast<std::uint16_t>(static_cast<std::int32_t>(preset.revision)
+                                                     + static_cast<std::int32_t>(kSigned16Bias));
+    const bool encoded = writer.write(preset.flags, 5U)
+                         // Both dynamic collections are absent; no element payload follows.
+                         && writer.write(0, kBoolWidth) && writer.write(revision, 16U)
+                         && writer.write(0, kBoolWidth);
+    return encoded && writer.bit_count() == kType70BitCount && writer.finish(written)
+           && written == kType70ByteCount;
+}
+
+/** Validates the exact no-filter type-70 form while preserving all five flag bits. */
+bool validate_type70_body(std::span<const std::byte> input, std::size_t bitCount) noexcept {
+    if (bitCount != kType70BitCount || input.size() != kType70ByteCount) {
+        return false;
+    }
+    bits::Reader reader(input);
+    std::uint64_t flags = 0;
+    std::uint64_t playerListPresent = 0;
+    std::uint64_t revision = 0;
+    std::uint64_t definitionListPresent = 0;
+    return reader.read(5U, flags) && flags <= 0x1FU && reader.read(kBoolWidth, playerListPresent)
+           && playerListPresent == 0 && reader.read(16U, revision)
+           && reader.read(kBoolWidth, definitionListPresent) && definitionListPresent == 0
+           && finish_padding(reader);
+}
+
+bool next_type42_generation(const Type42GenerationGuard& guard, std::int32_t& next) noexcept {
+    return next_positive_generation(guard.hasLast, guard.last, next);
+}
+
+/** The client treats the FNV-1 basis as "no performance", so neither it nor zero may be sent. */
+[[nodiscard]] bool valid_type42_name(std::uint32_t nameHash) noexcept {
+    return nameHash != 0 && nameHash != kClientRefAbsentKey;
+}
+
+/** Refuses a generation that does not rise, because the client starts the state only once. */
+bool encode_type42(const Type42Preset& preset,
+                   const Type42GenerationGuard& guard,
+                   std::span<std::byte> output,
+                   std::size_t& written) noexcept {
+    written = 0;
+    if (!valid_type42_name(preset.nameHash) || preset.generation <= 0
+        || (guard.hasLast && preset.generation <= guard.last) || output.size() < kType42ByteCount) {
+        return false;
+    }
+    bits::Writer writer(output.first(kType42ByteCount));
+    const std::uint32_t generation =
+        std::bit_cast<std::uint32_t>(preset.generation) + kSigned32Bias;
+    const bool encoded = writer.write(kEnabled, kBoolWidth) && writer.write(preset.nameHash, 32U)
+                         && writer.write(preset.value, 32U)
+                         && writer.write(generation, kSigned32Width)
+                         // The row table is absent; the client keeps its own rows.
+                         && writer.write(0, kBoolWidth);
+    return encoded && writer.bit_count() == kType42BitCount && writer.finish(written)
+           && written == kType42ByteCount;
+}
+
+/** Accepts only the command group present, a real state name, and an absent row table. */
+bool validate_type42_body(std::span<const std::byte> input, std::size_t bitCount) noexcept {
+    if (bitCount != kType42BitCount || input.size() != kType42ByteCount) {
+        return false;
+    }
+    bits::Reader reader(input);
+    std::uint64_t commandPresent = 0;
+    std::uint64_t nameHash = 0;
+    std::uint64_t value = 0;
+    std::uint64_t generation = 0;
+    std::uint64_t rowsPresent = 0;
+    return reader.read(kBoolWidth, commandPresent) && commandPresent == kEnabled
+           && reader.read(32U, nameHash) && valid_type42_name(static_cast<std::uint32_t>(nameHash))
+           && reader.read(32U, value) && reader.read(kSigned32Width, generation)
+           && generation > kSigned32Bias && reader.read(kBoolWidth, rowsPresent) && rowsPresent == 0
+           && finish_padding(reader);
+}
+
+/** @return True when the area reference names a real slot the client evaluator can resolve. */
+[[nodiscard]] bool valid_type71_area(const Type71Body& body) noexcept {
+    return body.areaRegistryKey != 0 && body.areaRegistryKey != kClientRefAbsentKey
+           && body.areaSlotType < (std::uint8_t{1} << kClientRefTypeWidth) - 1U
+           && body.areaSlotIndex < kClientRefIndexBias;
+}
+
+/** Encodes the fixed 183-bit type-71 body. */
+bool encode_type71(const Type71Body& body,
+                   std::span<std::byte> output,
+                   std::size_t& written) noexcept {
+    written = 0;
+    if (!valid_type71_area(body) || !std::isfinite(body.leaveSeconds) || body.leaveSeconds < 0.0F
+        || output.size() < kType71ByteCount) {
+        return false;
+    }
+    bits::Writer writer(output.first(kType71ByteCount));
+    const std::uint32_t state = std::bit_cast<std::uint32_t>(body.state) + kSigned32Bias;
+    const bool encoded =
+        writer.write(state, kSigned32Width) && writer.write(body.playerIdentity, kWideIntegerWidth)
+        && writer.write(body.areaRegistryKey, 32)
+        && writer.write(std::uint32_t{body.areaSlotType} + 1U, kClientRefTypeWidth)
+        && writer.write(std::uint32_t{body.areaSlotIndex} + kClientRefIndexBias,
+                        kClientRefIndexWidth)
+        && writer.write(std::bit_cast<std::uint32_t>(body.leaveSeconds), kReal32Width);
+    return encoded && writer.bit_count() == kType71BitCount && writer.finish(written)
+           && written == kType71ByteCount;
+}
+
+/** Decodes one complete type-71 body. */
+bool decode_type71_body(std::span<const std::byte> input, Type71Body& body) noexcept {
+    body = {};
+    if (input.size() != kType71ByteCount) {
+        return false;
+    }
+    bits::Reader reader(input);
+    std::uint64_t state = 0;
+    std::uint64_t identity = 0;
+    std::uint64_t key = 0;
+    std::uint64_t type = 0;
+    std::uint64_t index = 0;
+    std::uint64_t seconds = 0;
+    if (!reader.read(kSigned32Width, state) || !reader.read(kWideIntegerWidth, identity)
+        || !reader.read(32, key) || !reader.read(kClientRefTypeWidth, type)
+        || !reader.read(kClientRefIndexWidth, index) || !reader.read(kReal32Width, seconds)
+        || !finish_padding(reader) || type == 0 || index < kClientRefIndexBias) {
+        return false;
+    }
+    Type71Body parsed{};
+    parsed.state = std::bit_cast<std::int32_t>(static_cast<std::uint32_t>(state) - kSigned32Bias);
+    parsed.playerIdentity = identity;
+    parsed.areaRegistryKey = static_cast<std::uint32_t>(key);
+    parsed.areaSlotType = static_cast<std::uint8_t>(type - 1U);
+    parsed.areaSlotIndex = static_cast<std::uint16_t>(index - kClientRefIndexBias);
+    parsed.leaveSeconds = std::bit_cast<float>(static_cast<std::uint32_t>(seconds));
+    if (!valid_type71_area(parsed) || !std::isfinite(parsed.leaveSeconds)
+        || parsed.leaveSeconds < 0.0F) {
+        return false;
+    }
+    body = parsed;
+    return true;
+}
+
+/** Validates the exact type-71 body shape and its present area reference. */
+bool validate_type71_body(std::span<const std::byte> input, std::size_t bitCount) noexcept {
+    Type71Body body{};
+    return bitCount == kType71BitCount && decode_type71_body(input, body);
+}
+
 } // namespace sunrise::middleware::bap::activity_message::scriptable_auth

+ 86 - 2
Sunrise/src/middleware/bap/activity_message/activity_sense_update_decoder.cpp

@@ -17,6 +17,7 @@ constexpr std::uint32_t kTypeBias = 1, kIndexBias = 32768;
 constexpr std::uint32_t kDevice = 0x80804F47U, kScene = 0x8080626AU;
 constexpr std::uint32_t kSquad = 0x80807ECCU, kObjective = 0x80807F04U;
 constexpr std::uint32_t kOccupancy = 0x80809531U;
+constexpr std::uint32_t kGhostLink = 0x80804D3EU;
 constexpr std::uint32_t kObject = 0x8080992EU;
 constexpr std::uint32_t kObjectSpawnMask = 0x80809E1BU, kObjectReplies = 0x80809AEAU;
 constexpr std::uint32_t kSceneEvents = 0x808094DFU, kSceneList = 0x808094E1U;
@@ -508,8 +509,26 @@ real_value(std::uint64_t raw, std::uint8_t width, std::uint32_t maximumBits) noe
         return NativeStatus::malformed;
     }
     values.put(kObjectReplies, 0, 0, at, 2, ValueKind::unsignedInteger, count, 0, 0.0F, true);
-    // Reply elements carry per-schema registered bodies with no authored layout here.
-    return count == 0 ? NativeStatus::complete : NativeStatus::unsupported;
+    for (std::uint32_t index = 0; index < count; ++index) {
+        std::uint64_t exists = 0, schema = 0;
+        if (!reader.read(1, exists)) return NativeStatus::malformed;
+        if (!exists) continue;
+        if (!reader.read(32, schema)) return NativeStatus::malformed;
+        // F32220 publishes the interaction latch and revision through this registered child.
+        if (schema == 0x80804FB7U) {
+            if (!read_bool(reader, values, 0x80804FB7U, 0, false, index)
+                || !read_signed(reader, values, 0x80804FB7U, 1, 32, 32,
+                    (std::numeric_limits<std::int32_t>::min)(), false, index))
+                return NativeStatus::malformed;
+        } else if (schema == 0x80809ACCU) {
+            // Runtime flags 0x21 mean exact + custom codec, not optional (0x02).
+            // Native 9F0750 always writes the packed owner key, including zero when unheld.
+            if (!read_bool(reader, values, 0x80809ACCU, 0, false, index)
+                || !read_unsigned(reader, values, 0x80809ACCU, 1, 64, false, index))
+                return NativeStatus::malformed;
+        } else return NativeStatus::unsupported;
+    }
+    return NativeStatus::complete;
 }
 /**
  * Decodes the combatant block: the accepted spawn and event generations, the atom runner's lane
@@ -564,6 +583,53 @@ real_value(std::uint64_t raw, std::uint8_t width, std::uint32_t maximumBits) noe
     }
     return NativeStatus::complete;
 }
+/** Type13 participation: authored field tables80804F2F/808094E4. In particular,
+ * F9CC60 publishes .0.5 from a dead player with a valid Ghost, not a missing actor
+ * during region loading. Decode the complete body before accepting that evidence. */
+[[nodiscard]] NativeStatus decode_player(Reader& reader, Values& values) noexcept {
+    constexpr auto signedBias = (std::numeric_limits<std::int32_t>::min)();
+    if (!read_signed(reader,values,0x808094E4,0,32,32,signedBias,true)
+        || !read_unsigned(reader,values,0x808094E4,1,32,true)) return NativeStatus::malformed;
+    for (unsigned i=2;i<=5;++i)
+        if (!read_bool(reader,values,0x808094E4,i,false)) return NativeStatus::malformed;
+    if (!read_signed(reader,values,0x808094E4,6,3,8,1,false)
+        || !read_signed(reader,values,0x808094E4,7,2,8,1,false)) return NativeStatus::malformed;
+    std::uint64_t count{};
+    if (!reader.read(3,count)) return NativeStatus::malformed;
+    if (count>4) return NativeStatus::unsafeCount;
+    for (unsigned i=0;i<count;++i)
+        if (!read_unsigned(reader,values,0x80804F39,0,32,false,i)
+            || !read_real(reader,values,0x80804F39,1,32,false,i)) return NativeStatus::malformed;
+    if (!read_real(reader,values,0x80804F35,1,32,false)
+        || !read_signed(reader,values,0x80804F35,2,5,32,1,false)
+        || !read_signed(reader,values,0x80804F35,3,32,32,signedBias,true)) return NativeStatus::malformed;
+    for (unsigned i=4;i<=6;++i)
+        if (!read_bool(reader,values,0x80804F35,i,false)) return NativeStatus::malformed;
+    if (!read_unsigned(reader,values,0x808094DD,0,64,true)
+        || !read_unsigned(reader,values,0x808094DD,1,5,false)
+        || !read_signed(reader,values,0x808094DD,2,6,8,3,false)) return NativeStatus::malformed;
+    if (!reader.read(6,count)) return NativeStatus::malformed;
+    if (count>32) return NativeStatus::unsafeCount;
+    for (unsigned i=0;i<count;++i)
+        if (!read_unsigned(reader,values,0x808094DF,0,32,false,i)) return NativeStatus::malformed;
+    if (!reader.read(6,count)) return NativeStatus::malformed;
+    if (count>32) return NativeStatus::unsafeCount;
+    for (unsigned i=0;i<count;++i)
+        if (!read_unsigned(reader,values,0x808094E3,0,32,true,i)
+            || !read_unsigned(reader,values,0x808094E3,1,64,true,i)) return NativeStatus::malformed;
+    if (!read_bool(reader,values,0x80804F31,0,false)
+        || !read_unsigned(reader,values,0x80804F31,1,4,false)
+        || !read_unsigned(reader,values,0x80804F31,2,16,true)
+        || !read_unsigned(reader,values,0x80804F31,3,16,true)) return NativeStatus::malformed;
+    bool exists{};
+    if (!present(reader,true,exists)) return NativeStatus::malformed;
+    if (exists && !read_unsigned(reader,values,0x80804F34,0,32,false)) return NativeStatus::malformed;
+    return read_bool(reader,values,0x80804F31,5,false)
+        && read_signed(reader,values,0x80804F31,6,8,8,128,false)
+        && read_signed(reader,values,0x80804F2F,4,32,32,signedBias,false)
+        ? NativeStatus::complete : NativeStatus::malformed;
+}
+
 /** Decodes one root body of the given schema; the status names how far it got. */
 [[nodiscard]] NativeStatus
 decode_body(std::uint32_t schema, Reader& reader, Values& values) noexcept {
@@ -575,6 +641,24 @@ decode_body(std::uint32_t schema, Reader& reader, Values& values) noexcept {
         return NativeStatus::complete;
     }
     switch (schema) {
+    case 0x80804F2F:
+        return decode_player(reader, values);
+    case 0x80809562U:
+        // Reflected flags 0x21: mandatory raw floats (optional is bit 0x02).
+        // Native A774B0 publishes health/shield/revision as a fixed 12-byte state.
+        return read_real(reader, values, schema, 0, 32, false)
+            && read_real(reader, values, schema, 1, 32, false)
+            && read_signed(reader, values, schema, 2, 32, 32,
+                (std::numeric_limits<std::int32_t>::min)(), false)
+            ? NativeStatus::complete : NativeStatus::malformed;
+    case kGhostLink:
+        // Native E4A590 publishes all three fields: active, elapsed/duration,
+        // and the accepted Auth generation. None has an optional presence bit.
+        return read_bool(reader, values, kGhostLink, 0, false)
+                       && read_real(reader, values, kGhostLink, 1, 32, false)
+                       && read_signed(reader, values, kGhostLink, 2, 32, 32,
+                                      (std::numeric_limits<std::int32_t>::min)(), false)
+                   ? NativeStatus::complete : NativeStatus::malformed;
     case kDevice:
         return decode_device(reader, values);
     case kScene:

+ 14 - 4
Sunrise/src/middleware/bap/activity_message/activity_sensor_auth_bodies.cpp

@@ -115,11 +115,17 @@ constexpr std::size_t kSpawnKeyCount = 32;
            && writer.write(1, kPresenceWidth) && writer.write(snapshot.playerKey, 64)
            && writer.write(0, 5) && writer.write(3, 6) && writer.write(0, 6)
            && writer.write(0, 6)
-           // Byte 736 skips the respawn delay, whose countdown never expires when the content
-           // delay is negative. Byte 737 holds the spawn while the client loads.
+           // +736 bypasses DC3770's late spawn-location hold, NOT the HUD timer.
+           // The visible countdown is initialized by 12E9330 from +740 (half)
+           // into player+64, then displayed by 1677E60 via 12EE500/12EE590.
            && writer.write(1, kPresenceWidth)
            && writer.write(snapshot.awaitClientSync ? kAwaitingClientSync : 0U, 4)
-           && writer.write(0, 3) && writer.write(0, kPresenceWidth) && writer.write(128, 8)
+           && writer.write(0, kPresenceWidth) // +738: preserve authored revive delay.
+           && writer.write(snapshot.hasDarknessPolicy, kPresenceWidth)
+           && (!snapshot.hasDarknessPolicy
+               || writer.write(snapshot.darknessEnabled ? 0x4F80U : 0x4200U, 16)) // half 30 / 3
+           && writer.write(0, kPresenceWidth) // Optional spawn override absent.
+           && writer.write(0, kPresenceWidth) && writer.write(128, 8)
            && writer.write(kSignedZero, 32);
 }
 
@@ -135,7 +141,10 @@ constexpr std::size_t kSpawnKeyCount = 32;
     bool encoded = writer.write(std::uint32_t{snapshot.lifetime} + kLifetimeBias, kLifetimeWidth)
                    && writer.write(1, 3) && writer.write(0, kPresenceWidth)
                    && writer.write(kSignedZero, 32) && writer.write(kEmptyNameHash, 32)
-                   && writer.write(kSignedZero, 32) && writer.write(1, 6)
+                   && writer.write(snapshot.hasDarknessPolicy
+                           ? (snapshot.darknessEnabled && snapshot.hasRegion
+                                  ? kSignedZero + snapshot.region / 8U : kSignedMinusOne)
+                           : kSignedZero, 32) && writer.write(1, 6)
                    && writer.write(kWaitingSwitchKey, 32) && writer.write(1, kPresenceWidth)
                    && writer.write(kWaitingSwitchClass, 32) && writer.write(kSignedZero, 32)
                    && writer.write(kSignedZero, 32);
@@ -215,6 +224,7 @@ auth_body_bits(const Snapshot& snapshot, std::uint8_t slotType, bool carriesPlay
     if (slotType == kSlotTypeParticipation) {
         return carriesPlayerKey
                    ? kParticipationBits + (snapshot.hasRegion ? kParticipationRegionBits : 0)
+                         + (snapshot.hasDarknessPolicy ? 16U : 0U)
                    : 0;
     }
     if (slotType == kSlotTypeLifetime) {

+ 8 - 7
Sunrise/src/middleware/bap/activity_message/activity_squad_auth_body_codec.cpp

@@ -31,8 +31,6 @@ constexpr std::uint8_t kModeWidth = 3;
 constexpr std::uint32_t kModeBias = 1;
 /** A present name hash is one exact unsigned 32-bit value. */
 constexpr std::uint8_t kNameHashWidth = 32;
-/** Logical active one becomes wire two after the schema bias. */
-constexpr std::uint32_t kActiveWireValue = 2;
 /** Fields zero through two precede the requested-count block. */
 constexpr std::size_t kLeadingAbsentFieldCount = 3;
 /** One unused dynamic field separates requested counts from generation. */
@@ -58,9 +56,7 @@ constexpr std::size_t kAfterSpawnReferenceAbsentFieldCount = 5;
         || (guard.hasLast && preset.generation <= guard.last)) {
         return false;
     }
-    const std::uint8_t mode = static_cast<std::uint8_t>(preset.mode);
-    if (mode != static_cast<std::uint8_t>(Mode::mode0)
-        && mode != static_cast<std::uint8_t>(Mode::mode2)) {
+    if (!valid_mode(preset.mode)) {
         return false;
     }
     if (!std::ranges::all_of(preset.requestedCounts,
@@ -127,8 +123,13 @@ constexpr std::size_t kAfterSpawnReferenceAbsentFieldCount = 5;
     }
     // A name the host does not own is the no-name value, never zero: `sub_7FF7421A9720`
     // compares this field against it and walks the member collection when it differs.
+    // Native 4E9550 consumes generation .6: .18=0 destroys owned actors through
+    // 4EC1A0 -> 56A8F0; .18=1 only kills them through 4EC410 -> 3BD720.
+    // A zero-request retirement/reset must release entities, not retain dead actors.
+    const bool hasRequests = std::any_of(preset.requestedCounts.begin(), preset.requestedCounts.end(),
+        [](std::int32_t count) { return count > 0; });
     if (!write_absent(writer, kAfterSpawnReferenceAbsentFieldCount)
-        || !writer.write(kActiveWireValue, kActiveWidth)
+        || !writer.write(hasRequests ? 2U : 1U, kActiveWidth)
         || !writer.write(static_cast<std::uint32_t>(mode) + kModeBias, kModeWidth)
         || !writer.write(1, kPresenceWidth)
         || !writer.write(preset.nameHash.value_or(kEmptyNameHash), kNameHashWidth)) {
@@ -174,7 +175,7 @@ bool encode(const Preset& preset,
     }
 
     std::array<std::byte, kMaximumByteCount> staged{};
-    bits::Writer writer(std::span(staged).first(expectedBytes));
+    bits::Writer writer{std::span(staged).first(expectedBytes)};
     std::size_t stagedBytes = 0;
     if (!write_body(writer, preset) || !writer.finish(stagedBytes)
         || stagedBytes != expectedBytes) {

+ 40 - 0
Sunrise/src/middleware/bap/activity_message/authority_cleanup.h

@@ -0,0 +1,40 @@
+#pragma once
+
+#include "activity_host_control.h"
+#include "entity_authority.h"
+
+namespace sunrise::middleware::bap::activity_message::authority_cleanup {
+
+// Only the requesting client's explicit abandoned mask is eligible. Never infer
+// an all-slots reset from elapsed time, pressure, or a region's numeric range.
+[[nodiscard]] inline bool prepare(std::uint32_t messageType,
+                                  std::span<const std::byte> payload,
+                                  std::uint8_t currentEpoch,
+                                  host_control::PurgeAuthorityBody& output) noexcept {
+    host_control::PurgeAuthorityBody candidate{};
+    // Native 170B030 compares the promoted byte with current + 1; zero
+    // cannot wrap past 255. Never send a repeated epoch or guess a reset.
+    if (currentEpoch == 255) return false;
+    candidate.epoch = static_cast<std::uint8_t>(currentEpoch + 1U);
+    if (messageType == entity_authority::kRequestPurgeMessageType) {
+        entity_authority::PurgeRequest request{};
+        if (!entity_authority::parse_request_purge(payload, request)) return false;
+        candidate.slots = request.mask;
+        candidate.reason = static_cast<std::int8_t>(request.reason);
+    } else if (messageType == entity_authority::kAbandonMessageType) {
+        entity_authority::Release release{};
+        if (!entity_authority::parse_abandon(payload, release)) return false;
+        candidate.slots = release.mask;
+        candidate.reason = static_cast<std::int8_t>(release.reason);
+    } else {
+        // Msg33 can relinquish authority over live objects inside the occupied
+        // bubble. It is not a request to delete those objects.
+        return false;
+    }
+    bool any = false;
+    for (const auto byte : candidate.slots) any |= byte != std::byte{};
+    if (!any) return false;
+    output = candidate;
+    return true;
+}
+}

+ 4 - 1
Sunrise/src/middleware/bap/activity_message/cinematic_incident.h

@@ -10,7 +10,9 @@ namespace sunrise::middleware::bap::activity_message::cinematic_incident {
 inline constexpr std::uint32_t kStartedTarget = 5'239;
 /** Global SObject row emitted for start failure or when the runtime stops owning the resource. */
 inline constexpr std::uint32_t kTerminatedTarget = 1'685;
-/** Effective type-17 incident metadata schema selected by both global SObject rows. */
+/** FNV-1(cinematic_skip), posted by native 1069BE0 through 106A530. */
+inline constexpr std::uint32_t kSkipTarget = 3'338;
+/** Effective type-17 incident metadata schema selected by the three cinematic SObject rows. */
 inline constexpr std::uint32_t kSchema = 0x808087BFU;
 /** The native schema consumes 486 meaningful bits and two zero padding bits. */
 inline constexpr std::size_t kPayloadBits = 486;
@@ -19,6 +21,7 @@ inline constexpr std::size_t kPayloadBytes = 61;
 enum class Signal : std::uint8_t {
     started,
     terminated,
+    skipRequested,
 };
 
 /** Exact Type-6 source ClientRef and runtime values retained from one cinematic incident. */

+ 55 - 0
Sunrise/src/middleware/bap/activity_message/combatant_action_auth.h

@@ -0,0 +1,55 @@
+#pragma once
+#include <array>
+#include <cstdint>
+#include <span>
+#include "../../encoding/bit_reader.h"
+#include "../../encoding/bit_writer.h"
+namespace sunrise::middleware::bap::activity_message::combatant_action {
+inline constexpr std::uint32_t kSchema = 0x80807DA1;
+inline constexpr std::size_t kBits = 254, kBytes = 32;
+struct Request {
+    std::uint32_t generation{}, revision{}, group{}, action{};
+};
+// One native kind-9 custom action, with no spatial target. Keep the spawn
+// generation while advancing the program revision; completion uses Sense .3.
+// A97800 -> AB4030 -> A054C0 resolves the authored group/action pair.
+[[nodiscard]] inline bool encode(const Request& request, std::span<std::byte> output) noexcept {
+    if (output.size() != kBytes || request.generation == 0 || request.generation > 0x7fffffff
+        || request.revision == 0 || request.revision > 0x7fffffff || request.action == 0 || request.action == 0x811c9dc5) return false;
+    std::array<std::byte, kBytes> bytes{};
+    encoding::bits::Writer w(bytes);
+    std::size_t written{};
+    const bool ok = w.write(1, 1) && w.write(request.generation, 31)
+        && w.write(1, 2) && w.write(1, 3) && w.write(1, 1)
+        && w.write(0, 2) && w.write(1, 1) // .4/.5 absent; .6 present
+        && w.write(request.revision, 31) && w.write(0, 6) && w.write(1, 6)
+        && w.write(1, 1) && w.write(10, 4) && w.write(1, 2) // kind 9, native completion
+        && w.write(request.group, 32) && w.write(request.action, 32)
+        && w.write(0x811c9dc5, 32) // no additional identity
+        && w.write(0x811c9dc5, 32) && w.write(0, 7) && w.write(32767, 16)
+        && w.write(0, 3) && w.write(127, 8) // no target, mode/marker -1
+        && w.write(0, 1) && w.bit_count() == kBits // .7 absent
+        && w.finish(written) && written == kBytes;
+    if (!ok) return false;
+    for (std::size_t i = 0; i < bytes.size(); ++i) output[i] = bytes[i];
+    return true;
+}
+[[nodiscard]] inline bool validate(std::span<const std::byte> input, std::size_t bits) noexcept {
+    if (bits != kBits || input.size() != kBytes) return false;
+    encoding::bits::Reader r(input);
+    std::uint64_t v{}, generation{}, revision{};
+    return r.read(1, v) && v == 1 && r.read(31, generation) && generation > 0
+        && r.read(2, v) && v == 1 && r.read(3, v) && v == 1
+        && r.read(1, v) && v == 1 && r.read(2, v) && v == 0
+        && r.read(1, v) && v == 1 && r.read(31, revision) && revision > 0
+        && r.read(6, v) && v == 0 && r.read(6, v) && v == 1
+        && r.read(1, v) && v == 1 && r.read(4, v) && v == 10
+        && r.read(2, v) && v == 1 && r.read(32, v)
+        && r.read(32, v) && v != 0 && v != 0x811c9dc5
+        && r.read(32, v) && v == 0x811c9dc5
+        && r.read(32, v) && v == 0x811c9dc5
+        && r.read(7, v) && v == 0 && r.read(16, v) && v == 32767
+        && r.read(3, v) && v == 0 && r.read(8, v) && v == 127
+        && r.read(1, v) && v == 0 && r.read(2, v) && v == 0;
+}
+}

+ 69 - 0
Sunrise/src/middleware/bap/activity_message/combatant_delivery_auth.h

@@ -0,0 +1,69 @@
+#pragma once
+#include <array>
+#include <cstdint>
+#include <span>
+#include "../../encoding/bit_reader.h"
+#include "../../encoding/bit_writer.h"
+namespace sunrise::middleware::bap::activity_message::combatant_delivery {
+inline constexpr std::uint32_t kSchema = 0x80807DA1;
+inline constexpr std::size_t kBits = 132, kBytes = 17;
+struct Request {
+    std::uint32_t generation{}, revision{}, registryKey{};
+    std::uint16_t squadIndex{};
+};
+// AB6600 dispatches Auth .7 to the actor's 80807DB4 interface. Harvester component
+// 8080670A implements it at 1029330: create passengers in seats, finish after detach.
+// The reference MUST be a squad (type 1); its counts must be reserved beforehand.
+// Auth .6 remains absent so delivery cannot replace the movement program.
+struct SquadReference { std::uint32_t registryKey{}; std::uint16_t squadIndex{}; };
+inline constexpr std::size_t kMaxSquads = 8;
+inline constexpr std::size_t kMaxBytes = (kBits + 55 * (kMaxSquads - 1) + 7) / 8;
+[[nodiscard]] inline bool encode_many(std::uint32_t generation, std::uint32_t revision,
+    std::span<const SquadReference> squads, std::span<std::byte> output,
+    std::size_t& written, std::size_t& bits) noexcept {
+    written = bits = 0;
+    if (!generation || generation > 0x7fffffff || !revision || revision > 0x7fffffff
+        || squads.empty() || squads.size() > kMaxSquads) return false;
+    for (std::size_t i = 0; i < squads.size(); ++i) {
+        if (!squads[i].registryKey || squads[i].squadIndex > 32767) return false;
+        for (std::size_t j = 0; j < i; ++j)
+            if (squads[i].registryKey == squads[j].registryKey && squads[i].squadIndex == squads[j].squadIndex)
+                return false;
+    }
+    std::array<std::byte, kMaxBytes> b{};
+    encoding::bits::Writer w(b);
+    if (!(w.write(1,1) && w.write(generation,31)
+        && w.write(1,2) && w.write(1,3) && w.write(1,1)
+        && w.write(0,3) && w.write(1,1) && w.write(squads.size(),4))) return false;
+    for (const auto& squad : squads)
+        if (!(w.write(squad.registryKey,32) && w.write(2,7) && w.write(squad.squadIndex + 32768U,16))) return false;
+    if (!w.write(revision,31)) return false;
+    const auto usedBits = w.bit_count(); std::size_t used{};
+    if (!w.finish(used) || output.size() < used) return false;
+    for (std::size_t i = 0; i < used; ++i) output[i] = b[i];
+    written = used; bits = usedBits; return true;
+}
+[[nodiscard]] inline bool encode(const Request& q, std::span<std::byte> output) noexcept {
+    const std::array<SquadReference,1> squads{{{q.registryKey,q.squadIndex}}};
+    std::size_t written{}, bits{};
+    return output.size() == kBytes && encode_many(q.generation,q.revision,squads,output,written,bits);
+}
+[[nodiscard]] inline bool validate(std::span<const std::byte> b, std::size_t bits) noexcept {
+    if (bits < kBits || b.size() != (bits+7)/8 || b.size() > kMaxBytes) return false;
+    encoding::bits::Reader r(b); std::uint64_t v{}, count{};
+    if (!(r.read(1,v)&&v==1 && r.read(31,v)&&v>0
+        && r.read(2,v)&&v==1 && r.read(3,v)&&v==1 && r.read(1,v)&&v==1
+        && r.read(3,v)&&v==0 && r.read(1,v)&&v==1 && r.read(4,count)&&count>0&&count<=kMaxSquads
+        && bits == kBits + 55*(count-1))) return false;
+    std::array<SquadReference,kMaxSquads> seen{};
+    for (std::size_t i=0; i<count; ++i) {
+        if (!r.read(32,v) || !v) return false;
+        seen[i].registryKey=static_cast<std::uint32_t>(v);
+        if (!(r.read(7,v)&&v==2 && r.read(16,v)&&v>=32768)) return false;
+        seen[i].squadIndex=static_cast<std::uint16_t>(v-32768);
+        for (std::size_t j=0;j<i;++j)
+            if (seen[i].registryKey==seen[j].registryKey && seen[i].squadIndex==seen[j].squadIndex) return false;
+    }
+    return r.read(31,v)&&v>0 && r.read(static_cast<std::uint8_t>(b.size()*8-bits),v)&&v==0;
+}
+}

+ 56 - 0
Sunrise/src/middleware/bap/activity_message/combatant_path_auth.h

@@ -0,0 +1,56 @@
+#pragma once
+#include <array>
+#include <cstdint>
+#include <span>
+#include "../../encoding/bit_reader.h"
+#include "../../encoding/bit_writer.h"
+namespace sunrise::middleware::bap::activity_message::combatant_path {
+inline constexpr std::uint32_t kSchema = 0x80807DA1;
+inline constexpr std::size_t kBits = 156, kBytes = 20;
+struct Request {
+    std::uint32_t generation{}, revision{}, registryKey{};
+    std::uint16_t pathIndex{};
+};
+// AB6340 creates the authored member. Auth .6 runs one kind-3 movement command:
+// A97BB0 -> 4FFEC0 resolves a type-58 authored path; native AI follows its curve.
+// Auth .7 is a squad-reference delivery request, NOT a movement path list.
+// Preserve spawn generation while advancing the action-program revision. The authored
+// entry/exit curves have two markers: marker 0 is the START, marker 1 the destination.
+// AB0C30 resolves the marker index, not an action index or a duration in seconds.
+[[nodiscard]] inline bool encode(const Request& request, std::span<std::byte> output) noexcept {
+    if (output.size() != kBytes || request.generation == 0 || request.generation > 0x7fffffff
+        || request.revision == 0 || request.revision > 0x7fffffff || request.registryKey == 0
+        || request.pathIndex > 32767) return false;
+    std::array<std::byte, kBytes> bytes{};
+    encoding::bits::Writer w(bytes);
+    std::size_t written{};
+    const bool ok = w.write(1, 1) && w.write(request.generation, 31)
+        && w.write(1, 2) && w.write(1, 3) && w.write(1, 1)
+        && w.write(0, 2) && w.write(1, 1) // .4/.5 absent; .6 present
+        && w.write(request.revision, 31) && w.write(0, 6) && w.write(1, 6)
+        && w.write(1, 1) && w.write(4, 4) && w.write(1, 2) // kind 3, completion 0
+        && w.write(request.registryKey, 32) && w.write(59, 7)
+        && w.write(request.pathIndex + 32768U, 16)
+        && w.write(1, 8) && w.write(1, 1) // destination marker; follow curve
+        && w.write(0, 1) && w.bit_count() == kBits // .7 absent
+        && w.finish(written) && written == kBytes;
+    if (!ok) return false;
+    for (std::size_t i = 0; i < bytes.size(); ++i) output[i] = bytes[i];
+    return true;
+}
+[[nodiscard]] inline bool validate(std::span<const std::byte> input, std::size_t bits) noexcept {
+    if (bits != kBits || input.size() != kBytes) return false;
+    encoding::bits::Reader r(input);
+    std::uint64_t v{}, generation{}, registry{}, index{}, revision{};
+    return r.read(1, v) && v == 1 && r.read(31, generation) && generation > 0
+        && r.read(2, v) && v == 1 && r.read(3, v) && v == 1
+        && r.read(1, v) && v == 1 && r.read(2, v) && v == 0
+        && r.read(1, v) && v == 1 && r.read(31, revision) && revision > 0
+        && r.read(6, v) && v == 0 && r.read(6, v) && v == 1
+        && r.read(1, v) && v == 1 && r.read(4, v) && v == 4
+        && r.read(2, v) && v == 1 && r.read(32, registry) && registry != 0
+        && r.read(7, v) && v == 59 && r.read(16, index) && index >= 32768
+        && r.read(8, v) && v == 1 && r.read(1, v) && v == 1
+        && r.read(1, v) && v == 0 && r.read(4, v) && v == 0;
+}
+}

+ 33 - 0
Sunrise/src/middleware/bap/activity_message/combatant_retire_auth.h

@@ -0,0 +1,33 @@
+#pragma once
+#include <array>
+#include <cstdint>
+#include <span>
+#include "../../encoding/bit_reader.h"
+#include "../../encoding/bit_writer.h"
+namespace sunrise::middleware::bap::activity_message::combatant_retire {
+inline constexpr std::uint32_t kSchema = 0x80807DA1;
+inline constexpr std::size_t kBits = 77, kBytes = 10;
+// The live Auth callback AB6E20 -> AB71E0 retires only on a NEW generation.
+// Enabled=false alone only prevents creation in AB7350; it does not remove the actor.
+// Clear delivery .7 in the same update: AB7130 must not replay old cargo after reset.
+// Root .1 mode0 takes native detach + entity destruction (AB89A0 -> 56A8F0).
+[[nodiscard]] inline bool encode(std::uint32_t generation, std::span<std::byte> output) noexcept {
+    if (output.size() != kBytes || !generation || generation > 0x7fffffff) return false;
+    std::array<std::byte, kBytes> b{};
+    encoding::bits::Writer w(b); std::size_t written{};
+    const bool ok = w.write(1,1) && w.write(generation,31)
+        && w.write(1,2) && w.write(1,3) && w.write(0,1) && w.write(0,3) && w.write(1,1)
+        && w.write(0,4) && w.write(generation,31)
+        && w.bit_count() == kBits && w.finish(written) && written == kBytes;
+    if (ok) for (std::size_t i=0;i<kBytes;++i) output[i]=b[i];
+    return ok;
+}
+[[nodiscard]] inline bool validate(std::span<const std::byte> b, std::size_t bits) noexcept {
+    if (b.size()!=kBytes || bits!=kBits) return false;
+    encoding::bits::Reader r(b); std::uint64_t v{};
+    return r.read(1,v)&&v==1 && r.read(31,v)&&v>0 && r.read(2,v)&&v==1
+        && r.read(3,v)&&v==1 && r.read(1,v)&&v==0 && r.read(3,v)&&v==0
+        && r.read(1,v)&&v==1 && r.read(4,v)&&v==0 && r.read(31,v)&&v>0
+        && r.read(3,v)&&v==0;
+}
+}

+ 47 - 0
Sunrise/src/middleware/bap/activity_message/darkness_zone_auth.h

@@ -0,0 +1,47 @@
+#pragma once
+#include <array>
+#include <cstdint>
+#include <span>
+#include "../../encoding/bit_reader.h"
+#include "../../encoding/bit_writer.h"
+namespace sunrise::middleware::bap::activity_message::darkness_zone {
+inline constexpr std::uint32_t kSchema=0x808099BF, kClass=0x808099BD;
+inline constexpr std::uint8_t kSlotType=35;
+inline constexpr std::size_t kBits=359, kBytes=45;
+// E4C110 reads .0; E4C0A0 additionally compares lifetime +12 with the current bubble.
+// .3=0 selects the native wipe countdown (5011E0/BD4860). The host publishes
+// whole-second steps with the timer clamped at the current elapsed value. Native
+// 4C8FF0/4C8E90 then expose remaining and elapsed without needing a guessed client
+// clock epoch. Native durations use673200 ticks/second (35F080/35FC40).
+[[nodiscard]] inline bool encode(bool enabled, std::span<std::byte> output,
+    int wipeSeconds=-1) noexcept {
+    if (output.size()!=kBytes || wipeSeconds < -1 || wipeSeconds>3 || (!enabled && wipeSeconds>=0)) return false;
+    const bool wipe=wipeSeconds>=0;
+    std::array<std::byte,kBytes> b{}; encoding::bits::Writer w(b); std::size_t used{};
+    if (!(w.write(enabled,1)&&w.write(0,1)&&w.write(1,2)&&w.write(wipe?1:0,2)&&w.write(wipe,1))) return false;
+    const std::uint64_t elapsed=wipe ? (3-wipeSeconds)*673200ULL : 0;
+    const std::uint64_t remaining=wipe ? wipeSeconds*673200ULL : 0;
+    for (const auto value:std::array<std::uint64_t,5>{elapsed,elapsed,elapsed,remaining,0})
+        if (!w.write(value,64)) return false;
+    if (!(w.write(wipe?0x3F800000U:0U,32)&&w.bit_count()==kBits&&w.finish(used)&&used==kBytes)) return false;
+    for (std::size_t i=0;i<kBytes;++i) output[i]=b[i];
+    return true;
+}
+[[nodiscard]] inline bool decode(std::span<const std::byte> body, std::size_t bits, bool& enabled) noexcept {
+    if (bits!=kBits||body.size()!=kBytes) return false;
+    encoding::bits::Reader r(body); std::uint64_t v{};
+    if (!r.read(1,v)) return false;
+    const bool active=v!=0;
+    if (!(r.read(1,v)&&v==0&&r.read(2,v)&&v==1&&r.read(2,v)&&v<=1)) return false;
+    const bool wipe=v==1;
+    if (!r.read(1,v) || v!=wipe || (wipe && !active)) return false;
+    std::array<std::uint64_t,5> timer{};
+    for (auto& word:timer) if (!r.read(64,word)) return false;
+    if (wipe) {
+        if (timer[0]!=timer[1] || timer[1]!=timer[2] || timer[2]>2019600
+            || timer[3]>2019600 || timer[2]+timer[3]!=2019600 || timer[3]%673200 || timer[4]) return false;
+    } else for (auto word:timer) if (word) return false;
+    if (!(r.read(32,v)&&v==(wipe?0x3F800000U:0U)&&r.read(1,v)&&v==0)) return false;
+    enabled=active;return true;
+}
+}

+ 39 - 0
Sunrise/src/middleware/bap/activity_message/ghost_link_auth.h

@@ -0,0 +1,39 @@
+#pragma once
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <span>
+#include "../../encoding/bit_reader.h"
+#include "../../encoding/bit_writer.h"
+
+namespace sunrise::middleware::bap::activity_message::ghost_link {
+inline constexpr std::uint8_t kSlotType = 65;
+inline constexpr std::uint32_t kComponentClass = 0x80804D31U;
+inline constexpr std::uint32_t kAuthSchema = 0x80804D3FU;
+inline constexpr std::uint32_t kSenseSchema = 0x80804D3EU;
+inline constexpr std::size_t kBitCount = 65;
+inline constexpr std::size_t kByteCount = 9;
+inline constexpr std::uint32_t kAuthoredInteraction = 0x811C9DC5U;
+
+// E4DFB0: +0 is a monotonic reset generation, +4 enables native interaction,
+// and +8 preserves the authored interaction hash when set to the empty FNV hash.
+[[nodiscard]] inline bool encode(std::int32_t generation, bool enabled,
+                                 std::span<std::byte> output, std::size_t& written) noexcept {
+    written = 0;
+    if (generation <= 0 || output.size() < kByteCount) return false;
+    encoding::bits::Writer writer(output.first(kByteCount));
+    return writer.write(static_cast<std::uint32_t>(generation) + 0x80000000U, 32)
+           && writer.write(enabled ? 1U : 0U, 1)
+           && writer.write(kAuthoredInteraction, 32)
+           && writer.bit_count() == kBitCount && writer.finish(written) && written == kByteCount;
+}
+[[nodiscard]] inline bool validate(std::span<const std::byte> input, std::size_t bits) noexcept {
+    if (input.size() != kByteCount || bits != kBitCount
+        || (std::to_integer<unsigned>(input.back()) & 0x7FU) != 0) return false;
+    encoding::bits::Reader reader(input);
+    std::uint64_t generation = 0, enabled = 0, interaction = 0;
+    return reader.read(32, generation) && generation > 0x80000000ULL
+           && reader.read(1, enabled) && reader.read(32, interaction)
+           && interaction == kAuthoredInteraction;
+}
+} // namespace sunrise::middleware::bap::activity_message::ghost_link

+ 54 - 0
Sunrise/src/middleware/bap/activity_message/interactable_object_auth.h

@@ -0,0 +1,54 @@
+#pragma once
+#include <algorithm>
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <span>
+#include "../../encoding/bit_reader.h"
+#include "../../encoding/bit_writer.h"
+namespace sunrise::middleware::bap::activity_message::interactable_object {
+inline constexpr std::uint32_t kSchema = 0x8080992FU;
+inline constexpr std::uint32_t kReply = 0x80804FB7U;
+inline constexpr std::size_t kBits = 375, kBytes = 47;
+inline constexpr std::size_t kOwnerBits = kBits + 33, kOwnerBytes = (kOwnerBits + 7) / 8;
+// Authored entry/transform plus one interaction subscription. F32220 emits 80804FB7
+// only when 80804FB8 exists. Revision zero preserves the native one-shot used latch;
+// F36640, reached after accepted player use, is responsible for setting it.
+[[nodiscard]] inline bool encode(std::int32_t generation, std::span<std::byte> output,
+                                 std::size_t& written, bool trackOwner = false, bool active = true) noexcept {
+    written = 0;
+    const auto bytes = trackOwner ? kOwnerBytes : kBytes;
+    const auto bits = trackOwner ? kOwnerBits : kBits;
+    if (generation <= 0 || output.size() < bytes) return false;
+    encoding::bits::Writer w(output.first(bytes));
+    const auto absent = [&]() { return w.write(0x811C9DC5U,32) && w.write(0,7) && w.write(0x7FFF,16); };
+    return w.write(static_cast<std::uint32_t>(generation)+0x80000000U,32)
+        && w.write(0x80000000U,32) && w.write(active ? 1 : 0,1) && w.write(0,1)
+        && w.write(0x80000000U,32) && absent()
+        && w.write(0,32) && w.write(0,32) && w.write(0,32) && w.write(0,1)
+        && w.write(trackOwner ? 2 : 1,2) && w.write(1,1) && w.write(0x80804FB8U,32)
+        // F33930 maps native -1 (wire 0) to +704=false. F32CD0 permits use
+        // when that flag is false and no player filter is attached. Native +1
+        // (wire 2) inverts that default and HIDES use without a matching filter.
+        // Authored item/geometry criteria are still evaluated by F32CD0.
+        && w.write(0,2) && absent() && w.write(0x80000000U,32) && w.write(0,1)
+        // Native 9F0750 accepts empty 80809ACD and returns ownership in 80809ACC.
+        && (!trackOwner || (w.write(1,1) && w.write(0x80809ACDU,32)))
+        && w.bit_count()==bits && w.finish(written) && written==bytes;
+}
+[[nodiscard]] inline bool validate(std::span<const std::byte> input, std::size_t bits) noexcept {
+    const bool owner = bits == kOwnerBits;
+    if ((!owner && bits != kBits) || input.size() != (owner ? kOwnerBytes : kBytes)) return false;
+    encoding::bits::Reader reader(input);
+    std::uint64_t generation{};
+    if (!reader.read(32,generation) || generation <= 0x80000000ULL) return false;
+    std::uint64_t active{};
+    if (!reader.skip(32) || !reader.read(1, active)) return false;
+    std::uint64_t mode{};
+    if (!reader.skip(220) || !reader.read(2, mode) || mode != 0) return false;
+    std::array<std::byte,kOwnerBytes> canonical{};
+    std::size_t written{};
+    return encode(static_cast<std::int32_t>(generation-0x80000000ULL),canonical,written,owner,active != 0)
+        && std::equal(input.begin(),input.end(),canonical.begin());
+}
+}

+ 100 - 0
Sunrise/src/middleware/bap/activity_message/mission_auth_patch.h

@@ -0,0 +1,100 @@
+#pragma once
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <span>
+#include "../../encoding/bit_reader.h"
+#include "../../encoding/bit_writer.h"
+
+namespace sunrise::middleware::bap::activity_message::mission_auth_patch {
+// Native sensor overrides replace the full Auth object. An absent root field in a
+// mission API call means preserve it, so materialize against the last staged body.
+// This deliberately supports only the squad and named-transport layouts these APIs
+// emit. Unsupported nested programs are refused rather than losing retained state.
+inline constexpr std::size_t kCapacity = 256;
+struct Field { std::size_t offset{}, bits{}; bool present{}; };
+struct Layout { std::array<Field,21> fields{}; std::size_t count{}; };
+[[nodiscard]] inline bool parse(std::uint32_t schema, std::span<const std::byte> body,
+                                std::size_t bits, Layout& out) noexcept {
+    if (body.empty() || body.size()>kCapacity || body.size()!=(bits+7)/8) return false;
+    const bool squad = schema==0x80807EC9U;
+    if (!squad && schema!=0x80807DA1U) return false;
+    encoding::bits::Reader r(body); out={}; out.count=squad?21:8;
+    auto position = [&] { return body.size()*8-r.remaining_bits(); };
+    for (std::size_t i=0;i<out.count;++i) {
+        auto& f=out.fields[i]; f.offset=position();
+        const bool optional=squad?(i!=18 && i!=19):(i==0 || i>=4);
+        std::uint64_t v=1;
+        if (optional && !r.read(1,v)) return false;
+        f.present=v!=0;
+        if (f.present) {
+            std::size_t width=0;
+            if (squad) {
+                if (i==0 || i==1 || (i>=9 && i<=12)) width=55;
+                else if (i==2) return false;
+                else if (i==3 || i==4) {
+                    if (!r.read(4,v) || v>8) return false;
+                    width=32*v;
+                } else if (i==5) width=13;
+                else if (i==6 || i==13 || i==14 || i==17) width=31;
+                else if (i==7 || i==8 || i==20) width=32;
+                else if (i==15) width=6;
+                else if (i==16) width=5;
+                else if (i==18) width=2;
+                else if (i==19) width=3;
+            } else {
+                if (i==0) width=31;
+                else if (i==1) width=2;
+                else if (i==2) width=3;
+                else if (i==3) width=1;
+                else if (i==4 || i==5) return false;
+                else if (i==6) {
+                    if (!r.skip(31+6) || !r.read(6,v) || v!=1
+                        || !r.read(1,v) || v!=1 || !r.read(4,v) || (v!=4 && v!=10)) return false;
+                    const auto kind = v;
+                    if (!r.read(2,v) || v!=1) return false;
+                    width=kind==4 ? 64 : 162; // kind-3 ClientRef, marker byte, enabled bit
+                } else if (i==7) {
+                    if (!r.read(4,v) || v>8) return false;
+                    width=55*v+31;
+                }
+            }
+            if (!r.skip(width)) return false;
+        }
+        f.bits=position()-f.offset;
+    }
+    std::uint64_t padding{};
+    return position()==bits && r.read(static_cast<std::uint8_t>(body.size()*8-bits),padding) && padding==0;
+}
+[[nodiscard]] inline bool copy_field(encoding::bits::Writer& w, std::span<const std::byte> body,
+                                     Field f) noexcept {
+    encoding::bits::Reader r(body);
+    if (!r.skip(f.offset)) return false;
+    while (f.bits) {
+        const auto n=static_cast<std::uint8_t>(f.bits>64?64:f.bits);
+        std::uint64_t v{};
+        if (!r.read(n,v) || !w.write(v,n)) return false;
+        f.bits-=n;
+    }
+    return true;
+}
+[[nodiscard]] inline bool compose(std::uint32_t schema,
+    std::span<const std::byte> previous, std::size_t previousBits,
+    std::span<const std::byte> patch, std::size_t patchBits,
+    std::span<std::byte> output, std::size_t& written, std::size_t& bits) noexcept {
+    written=bits=0;
+    Layout old{}, next{};
+    if (!parse(schema,patch,patchBits,next)
+        || (!previous.empty() && !parse(schema,previous,previousBits,old))) return false;
+    std::array<std::byte,kCapacity> staged{};
+    encoding::bits::Writer w(staged);
+    for (std::size_t i=0;i<next.count;++i) {
+        const bool keep=!previous.empty() && !next.fields[i].present && old.fields[i].present;
+        if (!copy_field(w,keep?previous:patch,keep?old.fields[i]:next.fields[i])) return false;
+    }
+    const auto usedBits=w.bit_count(); std::size_t used{};
+    if (!w.finish(used) || output.size()<used) return false;
+    for (std::size_t i=0;i<used;++i) output[i]=staged[i];
+    written=used; bits=usedBits; return true;
+}
+}

+ 56 - 0
Sunrise/src/middleware/bap/activity_message/mission_effect_auth.h

@@ -0,0 +1,56 @@
+#pragma once
+#include <array>
+#include <span>
+#include "scriptable_auth_body.h"
+#include "../../encoding/bit_writer.h"
+#include "../../encoding/bit_reader.h"
+namespace sunrise::middleware::bap::activity_message::mission_effect {
+inline constexpr std::size_t kBits=186, kBytes=24;
+// Native 9EF8A0/9F1F10 remove old attachments on a new revision, then 9EF940
+// attaches the authored hop-on to the filter's entities. An absent filter removes all.
+inline bool encode(scriptable_auth::Type2LaneClientRef filter, bool enabled,
+                   std::int32_t revision, std::span<std::byte> out, std::size_t& written) noexcept {
+    if (revision<=0 || out.size()<kBytes || (enabled && (filter.slotType!=34 || filter.slotIndex<0))) return false;
+    if (!enabled) filter={};
+    encoding::bits::Writer w(out.first(kBytes));
+    return w.write(0,1) && w.write(!enabled,1)
+        && w.write(0x80000000U,32) && w.write(0x80000000U,32) && w.write(0x80000000U,32)
+        && w.write(static_cast<std::uint32_t>(revision)+0x80000000U,32)
+        && w.write(filter.registryKey,32) && w.write(static_cast<unsigned>(filter.slotType)+1U,7)
+        && w.write(static_cast<std::int32_t>(filter.slotIndex)+32768,16)
+        && w.write(0,1) && w.bit_count()==kBits && w.finish(written) && written==kBytes;
+}
+
+inline bool validate_filter(std::span<const std::byte> input, std::size_t bits) noexcept {
+    if (bits<4 || bits>732 || input.size()!=(bits+7)/8) return false;
+    encoding::bits::Reader r(input); std::uint64_t count{},v{},schema{},mode{};
+    if (!r.read(4,count) || count>8) return false;
+    std::size_t consumed=4;
+    for (std::uint64_t i=0;i<count;++i) {
+        if (!r.read(1,v) || v!=1 || !r.read(32,schema) || !r.read(2,mode)) return false;
+        consumed+=35;
+        if (schema==0x8080957DU) { if (mode!=1 && mode!=2) return false; continue; }
+        if (schema==0x80809576U) {
+            if (!r.read(1,v) || !((mode==2 && v==0) || (mode==1 && v==1))) return false;
+            ++consumed;
+        } else if (schema!=0x80809579U || mode!=1) return false;
+        if (!r.read(32,v) || !r.read(7,v) || v!=(schema==0x80809576U ? 61U : 5U)
+            || !r.read(16,v) || v<32768U) return false;
+        consumed+=55;
+    }
+    return consumed==bits && (bits%8==0 || (std::to_integer<unsigned>(input.back()) & ((1U<<(8-bits%8))-1))==0);
+}
+inline bool validate(std::span<const std::byte> input, std::size_t bits) noexcept {
+    if (bits!=kBits || input.size()!=kBytes) return false;
+    encoding::bits::Reader r(input); std::uint64_t v{},disabled{},revision{},registry{},type{},index{};
+    if (!r.read(1,v)||v!=0||!r.read(1,disabled)) return false;
+    for (int i=0;i<3;++i) if (!r.read(32,v)||v!=0x80000000U) return false;
+    if (!r.read(32,revision)||revision<=0x80000000U||!r.read(32,registry)||!r.read(7,type)
+        ||!r.read(16,index)||!r.read(1,v)||v!=0) return false;
+    std::array<std::byte,kBytes> expected{};std::size_t written{};
+    if (!encode({static_cast<std::uint32_t>(registry),static_cast<std::int8_t>(type-1),static_cast<std::int16_t>(index-32768)},
+                !disabled,static_cast<std::int32_t>(revision-0x80000000U),expected,written)) return false;
+    for(std::size_t i=0;i<kBytes;++i)if(expected[i]!=input[i])return false;
+    return true;
+}
+}

+ 44 - 0
Sunrise/src/middleware/bap/activity_message/music_section_auth.h

@@ -0,0 +1,44 @@
+#pragma once
+#include <algorithm>
+#include <array>
+#include <cstdint>
+#include <span>
+#include "../../encoding/bit_reader.h"
+#include "../../encoding/bit_writer.h"
+
+namespace sunrise::middleware::bap::activity_message::music_section {
+inline constexpr std::uint32_t kSchema = 0x80804F58U, kClass = 0x80804E8EU;
+inline constexpr std::size_t kBits = 7223, kBytes = (kBits + 7) / 8;
+// 1069A10 applies this Auth to the native music sensor; 106AEA0 evaluates it.
+// DEF9F0 treats 80809291 as a 128-bit selection mask (NOT four cue hashes),
+// followed by 128 optional condition refs. The final ref gates the whole sensor.
+// A single enabled authored section lets the native bank perform its transition.
+[[nodiscard]] inline bool encode(std::uint8_t section, bool enabled,
+    std::span<std::byte> output, std::size_t& written) noexcept {
+    written = 0;
+    if (section >= 128 || output.size() < kBytes) return false;
+    encoding::bits::Writer w(output.first(kBytes));
+    for (std::size_t lane = 0; lane < 4; ++lane)
+        if (!w.write(enabled && section / 32 == lane ? std::uint32_t{1} << (section % 32) : 0, 32)) return false;
+    for (std::size_t i = 0; i < 129; ++i)
+        if (!w.write(0x811C9DC5U, 32) || !w.write(0, 7) || !w.write(0x7FFF, 16)) return false;
+    return w.bit_count() == kBits && w.finish(written) && written == kBytes;
+}
+[[nodiscard]] inline bool validate(std::span<const std::byte> input, std::size_t bits) noexcept {
+    if (bits != kBits || input.size() != kBytes) return false;
+    encoding::bits::Reader r(input);
+    bool enabled{}; std::uint8_t section{};
+    for (std::size_t lane = 0; lane < 4; ++lane) {
+        std::uint64_t mask{};
+        if (!r.read(32, mask)) return false;
+        if (mask != 0) {
+            if (enabled || (mask & (mask - 1)) != 0) return false;
+            enabled = true;
+            for (std::size_t bit = 0; bit < 32; ++bit)
+                if (mask & (std::uint64_t{1} << bit)) section = static_cast<std::uint8_t>(lane * 32 + bit);
+        }
+    }
+    std::array<std::byte, kBytes> canonical{}; std::size_t written{};
+    return encode(section, enabled, canonical, written) && std::equal(input.begin(), input.end(), canonical.begin());
+}
+}

+ 4 - 4
Sunrise/src/middleware/bap/activity_message/replicate_membership.h

@@ -13,9 +13,9 @@ namespace sunrise::middleware::bap::activity_message::replicate_membership {
 
 /** Membership snapshots use activity message type 12. */
 inline constexpr std::uint32_t kMessageType = 12;
-/** One local player and an explicit empty view mask use 30,077 meaningful bits. */
+/** One local player and no remote replication member is 30,077 meaningful bits. */
 inline constexpr std::size_t kMeaningfulBitCount = 30'077;
-/** The one-member snapshot has three zero padding bits. */
+/** The one-member snapshot is 3,760 bytes, the last carrying three zero padding bits. */
 inline constexpr std::size_t kEncodedSize = 3'760;
 /** A remote row adds its channel, process identity, view identity, and player snapshot. */
 inline constexpr std::size_t kRemoteMemberBitDelta = 3'044;
@@ -143,9 +143,9 @@ occupied_member_mask(const MembershipSnapshot& snapshot) noexcept {
     return kLocalMemberMask | (snapshot.remoteViewMember.present ? kRemoteMemberMask : 0U);
 }
 
-/** The native view updater skips the own member and activates the advertised remote member. */
+/** Native membership mask_6 enables entity transmission to the advertised host. */
 [[nodiscard]] constexpr std::uint32_t
-active_view_mask(const MembershipSnapshot& snapshot) noexcept {
+entity_replication_member_mask(const MembershipSnapshot& snapshot) noexcept {
     return snapshot.remoteViewMember.present ? kRemoteMemberMask : 0U;
 }
 

+ 4 - 0
Sunrise/src/middleware/bap/activity_message/scriptable_auth_body.h

@@ -464,6 +464,10 @@ struct Type68Preset final {
     /** Native directive state: 0 enters, 1 completes, and 2 uses the alternate exit state. */
     std::int8_t state{};
     bool visible{true};
+    /** Authored type-47 destination; absent removes the explicit guidance marker. */
+    Type2LaneClientRef navpoint{};
+    /** Player audience checked by native 1009740 before showing the mission banner. */
+    Type2LaneClientRef audience{};
 };
 
 /** One exact type-70 state with both optional authored filter lists absent. */

+ 26 - 0
Sunrise/src/middleware/bap/activity_message/sense_observation_packet.h

@@ -0,0 +1,26 @@
+#pragma once
+#include "sense_update.h"
+
+namespace sunrise::middleware::bap::activity_message::sense_update {
+// Partial means the complete envelope closed, but a length-delimited group had
+// an unsupported member. Other fully decoded objects still carry valid receipts.
+[[nodiscard]] inline bool observation_status(DecodeStatus status) noexcept {
+    return status == DecodeStatus::complete || status == DecodeStatus::partial;
+}
+[[nodiscard]] inline bool observation_packet(const DecodedPacket& packet) noexcept {
+    if (!observation_status(packet.status) || packet.objectsTruncated || packet.valuesTruncated
+        || packet.objectCount > packet.objects.size() || packet.valueCount > packet.values.size()) return false;
+    std::size_t end{}, decoded{};
+    for (std::size_t i=0; i<packet.objectCount; ++i) {
+        const auto& object=packet.objects[i];
+        if (object.firstValue != end || object.valueCount > packet.valueCount-end
+            || object.status == ObjectStatus::malformed) return false;
+        end += object.valueCount;
+        if (object.status == ObjectStatus::decoded) {
+            if (!object.hasGeneration) return false;
+            ++decoded;
+        } else if (packet.status == DecodeStatus::complete) return false;
+    }
+    return end == packet.valueCount && decoded == packet.objectsDecoded;
+}
+}

+ 3 - 0
Sunrise/src/middleware/bap/activity_message/sensor_auth_update.h

@@ -182,6 +182,9 @@ struct Snapshot final {
     bool hasGrant{};
     bool hasRegion{};
     bool hasSpawnOverride{};
+    /** SDK darkness policy; lifetime +12 stores a bubble, not the slice-set index. */
+    bool hasDarknessPolicy{};
+    bool darknessEnabled{};
     /** Hold the client's spawn while it loads by emitting `awaiting_client_sync`. */
     bool awaitClientSync{};
     /** Register the groups and seed no object. Separates no components from no auth state. */

+ 8 - 2
Sunrise/src/middleware/bap/activity_message/squad_auth_body.h

@@ -47,14 +47,20 @@ inline constexpr std::size_t kAuthoredProfileBitCount = 13;
 /** Buffer one squad body needs at the full requested-count length. */
 inline constexpr std::size_t kMaximumBitCount = exact_body_bit_count(kMaximumRequestedCountLength);
 inline constexpr std::size_t kMaximumByteCount = (kMaximumBitCount + 7) / 8;
+/** Full native Auth may also retain objective fields alongside the spawn preset. */
+inline constexpr std::size_t kMaximumRetainedByteCount = (1313U + 7U) / 8U;
 /** Spawn generation is an unsigned logical value stored in a 31-bit field. */
 inline constexpr std::uint32_t kMaximumGeneration = 0x7FFFFFFF;
 
-/** The schema accepts only its two observed numeric mode values. */
+/** Native 4E4580 skips ordinary placement for mode 3, retaining requests for delivery. */
 enum class Mode : std::uint8_t {
     mode0 = 0,
     mode2 = 2,
+    reserve = 3,
 };
+[[nodiscard]] constexpr bool valid_mode(Mode mode) noexcept {
+    return mode == Mode::mode0 || mode == Mode::mode2 || mode == Mode::reserve;
+}
 
 /** Last accepted positive spawn generation for one ClientRef. */
 struct GenerationGuard final {
@@ -62,7 +68,7 @@ struct GenerationGuard final {
     bool hasLast{};
 };
 
-/** One canonical activity-local squad request. Active is fixed to one on the wire. */
+/** One canonical activity-local squad request. Zero counts select native actor destruction. */
 struct Preset final {
     std::span<const std::int32_t> requestedCounts{};
     std::uint32_t generation{};

+ 46 - 0
Sunrise/src/middleware/bap/activity_message/squad_objective_auth.h

@@ -0,0 +1,46 @@
+#pragma once
+#include <array>
+#include <cstdint>
+#include <span>
+#include "../../encoding/bit_reader.h"
+#include "../../encoding/bit_writer.h"
+namespace sunrise::middleware::bap::activity_message::squad_objective {
+inline constexpr std::uint32_t kSchema = 0x80807EC9;
+inline constexpr std::size_t kBits = 153, kBytes = 20;
+struct Request {
+    std::uint32_t registry{}, revision{};
+    std::uint16_t index{};
+    std::int32_t group{-1};
+    bool reserved{};
+};
+// 4E2A90: root 0 selects the objective, root 13 invalidates its cost calculation,
+// root 16 links into an authored task group. -1 requests costs without linking a group.
+// Root 15 is explicitly unset; counts/profile/spawn generation remain absent.
+[[nodiscard]] inline bool encode(Request q, std::span<std::byte> output) noexcept {
+    if (output.size() != kBytes || !q.registry || !q.revision || q.revision > 0x7fffffff
+        || q.index > 32767 || q.group < -1 || q.group >= 24) return false;
+    std::array<std::byte, kBytes> b{};
+    encoding::bits::Writer w(b); std::size_t written{};
+    bool ok = w.write(1,1) && w.write(q.registry,32) && w.write(4,7)
+        && w.write(q.index + 32768U,16) && w.write(0,12)
+        && w.write(1,1) && w.write(q.revision,31) && w.write(0,1)
+        && w.write(1,1) && w.write(0,6) && w.write(1,1) && w.write(q.group + 1,5)
+        && w.write(0,1) && w.write(2,2) && w.write(q.reserved ? 4 : 3,3)
+        && w.write(1,1) && w.write(0x811c9dc5,32)
+        && w.bit_count() == kBits && w.finish(written) && written == kBytes;
+    if (ok) for (std::size_t i=0;i<kBytes;++i) output[i]=b[i];
+    return ok;
+}
+[[nodiscard]] inline bool validate(std::span<const std::byte> b, std::size_t bits) noexcept {
+    if (bits != kBits || b.size()!=kBytes) return false;
+    encoding::bits::Reader r(b); std::uint64_t v{}, registry{}, revision{};
+    return r.read(1,v)&&v==1 && r.read(32,registry)&&registry
+        && r.read(7,v)&&v==4 && r.read(16,v)&&v>=32768
+        && r.read(12,v)&&v==0 && r.read(1,v)&&v==1 && r.read(31,revision)&&revision
+        && r.read(1,v)&&v==0 && r.read(1,v)&&v==1 && r.read(6,v)&&v==0
+        && r.read(1,v)&&v==1 && r.read(5,v)&&v<=24
+        && r.read(1,v)&&v==0 && r.read(2,v)&&v==2 && r.read(3,v)&&(v==3 || v==4)
+        && r.read(1,v)&&v==1 && r.read(32,v)&&v==0x811c9dc5
+        && r.read(7,v)&&v==0;
+}
+}

+ 40 - 0
Sunrise/src/server/activity/activity_sdk_behavior_scope.h

@@ -0,0 +1,40 @@
+#pragma once
+#include <span>
+#include "../../state/activity_sdk/format.h"
+
+namespace sunrise::server::activity::behavior_scope {
+namespace f = state::activity_sdk::format;
+
+// A mission seed is published at the opening area. Later bubbles keep that lease,
+// but their local sequences/scenes belong to the state selected by the live region.
+[[nodiscard]] inline bool live_state(std::span<const f::State> states,
+    std::span<const f::Bubble> bubbles, std::uint32_t scenario, std::uint32_t row,
+    std::int32_t region) noexcept {
+    if (region < 0 || row >= states.size()) return false;
+    const auto& s = states[row];
+    if (s.scenarioIndex != scenario || s.bubbleIndex >= bubbles.size()
+        || bubbles[s.bubbleIndex].scenarioIndex != scenario || s.stateOrdinal >= 8) return false;
+    return std::uint64_t(s.sliceSetIndex) + s.stateOrdinal == std::uint32_t(region);
+}
+
+struct Selection { std::uint32_t row{f::kAbsentIndex}; bool ambiguous{}; };
+[[nodiscard]] inline Selection select(std::span<const f::Occurrence> occurrences,
+    std::span<const f::State> states, std::span<const f::Bubble> bubbles,
+    std::uint32_t scenario, std::uint32_t object, std::uint32_t seedState,
+    std::int32_t region) noexcept {
+    Selection result{};
+    unsigned best{};
+    for (std::uint32_t i = 0; i < occurrences.size(); ++i) {
+        const auto& o = occurrences[i];
+        if (o.scenarioIndex != scenario || o.objectIndex != object
+            || o.stateIndex >= states.size() || states[o.stateIndex].scenarioIndex != scenario
+            || o.bubbleIndex != states[o.stateIndex].bubbleIndex) continue;
+        const unsigned rank = live_state(states, bubbles, scenario, o.stateIndex, region) ? 2
+            : o.stateIndex == seedState ? 1 : 0;
+        if (!rank || rank < best) continue;
+        if (rank > best) { result = {i, false}; best = rank; }
+        else if (occurrences[result.row].stateIndex != o.stateIndex) result.ambiguous = true;
+    }
+    return result;
+}
+}

+ 494 - 8
Sunrise/src/server/activity/activity_sdk_device_runtime.cpp

@@ -1,3 +1,15 @@
+#include "../../middleware/bap/activity_message/music_section_auth.h"
+#include "../../middleware/bap/activity_message/mission_effect_auth.h"
+#include "../../middleware/bap/activity_message/darkness_zone_auth.h"
+#include "../../middleware/bap/activity_message/squad_objective_auth.h"
+#include "../../middleware/bap/activity_message/combatant_path_auth.h"
+#include "../../middleware/bap/activity_message/combatant_action_auth.h"
+#include "../../middleware/bap/activity_message/combatant_delivery_auth.h"
+#include "../../middleware/bap/activity_message/combatant_retire_auth.h"
+#include "../../middleware/bap/activity_message/ghost_link_auth.h"
+#include "activity_sdk_device_runtime.h"
+#include "../../middleware/bap/activity_message/interactable_object_auth.h"
+
 #include <algorithm>
 #include <array>
 #include <cstddef>
@@ -27,14 +39,488 @@ static_assert(format::kDeviceAuthSchema == auth::kType23Schema);
 static_assert(format::kObjectSlotType == auth::kType4SlotType);
 static_assert(format::kObjectAuthSchema == auth::kType4Schema);
 
-using detail::binding_status;
-using detail::prepare;
-using detail::prepare_combatant;
-using detail::prepare_object;
-using detail::prepare_slot;
-using detail::prepare_trigger;
-using detail::PreparedDevice;
-using detail::validate_auth;
+/** Exact private route retained between preflight and queueing. */
+struct PreparedDevice final {
+    host::ScriptableTarget target{};
+    layouts::RosterGroup generatedRosterGroup{};
+    std::uint64_t activityClientGeneration{};
+    std::uint32_t scenarioRow{format::kAbsentIndex};
+    std::uint32_t stateRow{format::kAbsentIndex};
+    std::int32_t effectiveRegion{-1};
+};
+
+/** Maps the SDK binding validator to this API's stable refusal surface. */
+[[nodiscard]] Status binding_status(const sdk::BoundView& view,
+                                    server::bap::ActivityLinkView& link) noexcept {
+    if (view.catalog == nullptr || sdk::bound_activity(view) == nullptr
+        || sdk::bound_scenario(view) == nullptr) {
+        return Status::invalidView;
+    }
+    if (!state::activity::binding_matches(view.binding)) {
+        return Status::staleBinding;
+    }
+    (void)server::bap::activity_link_view(view.binding, link);
+    switch (
+        sdk::revalidate(view, view.binding, link.matchingLinks, link.activityClientGeneration)) {
+    case sdk::Status::ready:
+        return link.effectiveRegion >= 0 ? Status::ready : Status::noActivityLink;
+    case sdk::Status::missingClient:
+    case sdk::Status::ambiguousClient:
+        return Status::noActivityLink;
+    case sdk::Status::staleSession:
+        return Status::staleBinding;
+    case sdk::Status::staleActivityClient:
+        return Status::staleActivityClient;
+    case sdk::Status::notReady:
+    case sdk::Status::missing:
+    case sdk::Status::wrongSdkBuild:
+    case sdk::Status::catalogInvalid:
+    case sdk::Status::wrongActivity:
+    case sdk::Status::activityJoinNotExact:
+    case sdk::Status::missingScenarioLink:
+        return Status::invalidView;
+    }
+    return Status::invalidView;
+}
+
+/** @return True when both complete groups have the same key and compressed wire layout. */
+[[nodiscard]] bool same_roster_layout(const layouts::RosterGroup& left,
+                                      const layouts::RosterGroup& right) noexcept {
+    if (!layouts::valid_roster_group(left) || !layouts::valid_roster_group(right)
+        || left.registryKey != right.registryKey || left.slotCount != right.slotCount) {
+        return false;
+    }
+    for (std::size_t index = 0; index < left.slotCount; ++index) {
+        if (left.slotTypes[index] != right.slotTypes[index]
+            || left.slotFlags[index] != right.slotFlags[index]
+            || left.slotIndices[index] != right.slotIndices[index]) {
+            return false;
+        }
+    }
+    return true;
+}
+
+/** Finds the selected Auth slot in one materialized group's compressed order. */
+[[nodiscard]] bool selected_slot_offset(const layouts::RosterGroup& group,
+                                        const format::Slot& slot,
+                                        std::uint16_t& output) noexcept {
+    output = 0;
+    if (!layouts::valid_roster_group(group)
+        || slot.slotIndex > (std::numeric_limits<std::uint16_t>::max)()) {
+        return false;
+    }
+    std::size_t matches = 0;
+    for (std::size_t index = 0; index < group.slotCount; ++index) {
+        if (group.slotIndices[index] != slot.slotIndex || group.slotTypes[index] != slot.slotType
+            || (group.slotFlags[index] & layouts::kSlotAuthFlag) == 0) {
+            continue;
+        }
+        output = static_cast<std::uint16_t>(index);
+        ++matches;
+    }
+    return matches == 1;
+}
+
+/** Resolves a materialized occurrence group to at most one canonical roster row. */
+[[nodiscard]] Status canonical_target(const sdk::BoundView& view,
+                                      const format::Scenario& scenario,
+                                      const format::Slot& slot,
+                                      const layouts::RosterGroup& generatedGroup,
+                                      std::int32_t effectiveRegion,
+                                      host::ScriptableTarget& output,
+                                      bool& found) noexcept {
+    output = {};
+    found = false;
+    if (view.binding.destination.packageNameLength == 0
+        || view.binding.destination.packageNameLength
+               > view.binding.destination.packageName.size()) {
+        return Status::invalidView;
+    }
+    const std::string_view destination(
+        reinterpret_cast<const char*>(view.binding.destination.packageName.data()),
+        view.binding.destination.packageNameLength);
+    layouts::Definition layout{};
+    if (!state::build_data::find_scenario_layout(destination, layout) || layout.tag != scenario.tag
+        || layout.rosterGroupCount > layout.rosterGroups.size()
+        || layout.bubbleGroupCount > layout.bubbleGroups.size()) {
+        return Status::targetUnavailable;
+    }
+    const std::uint32_t activeBubble =
+        static_cast<std::uint32_t>(effectiveRegion) / tables::kSliceSetIndexFactor;
+    std::size_t activeMatches = 0;
+    std::uint16_t selectedTable = 0;
+    layouts::RosterGroup selectedGroup{};
+    const auto consider = [&](std::uint16_t tableIndex, bool active) noexcept {
+        layouts::RosterGroup candidate{};
+        if (!state::build_data::find_roster_group(tableIndex, candidate)) {
+            return false;
+        }
+        if (!same_roster_layout(generatedGroup, candidate)) {
+            return true;
+        }
+        if (active) {
+            if (activeMatches == 0) {
+                activeMatches = 1;
+                selectedTable = tableIndex;
+                selectedGroup = candidate;
+            } else if (tableIndex != selectedTable) {
+                // The same canonical table may be published in both the always-active and bubble
+                // lanes. That is one target; only a distinct active table is ambiguous.
+                activeMatches = 2;
+            }
+        }
+        return true;
+    };
+    for (std::size_t index = 0; index < layout.rosterGroupCount; ++index) {
+        if (!consider(layout.rosterGroups[index], true)) {
+            return Status::targetUnavailable;
+        }
+    }
+    for (std::size_t index = 0; index < layout.bubbleGroupCount; ++index) {
+        const bool active =
+            activeBubble < layouts::kBubbleCapacity
+            && (layout.bubbleGroupMasks[index] & (std::uint64_t{1} << activeBubble)) != 0;
+        if (!consider(layout.bubbleGroups[index], active)) {
+            return Status::targetUnavailable;
+        }
+    }
+    if (activeMatches > 1) {
+        return Status::ambiguousTarget;
+    }
+    if (activeMatches == 0) {
+        return Status::ready;
+    }
+    std::uint16_t slotOffset = 0;
+    if (!selected_slot_offset(selectedGroup, slot, slotOffset)) {
+        return Status::invalidSlot;
+    }
+    output.objectTag = selectedGroup.objectTag;
+    output.registryKey = selectedGroup.registryKey;
+    output.authSchema = slot.authSchema;
+    output.rosterGroupIndex = selectedTable;
+    output.rosterSlotOffset = slotOffset;
+    output.slotIndex = static_cast<std::uint16_t>(slot.slotIndex);
+    output.slotType = static_cast<std::uint8_t>(slot.slotType);
+    found = true;
+    return Status::ready;
+}
+
+/** Finishes the exact canonical or state-local route resolved from the bound SDK. */
+[[nodiscard]] Status finish_prepared(const sdk::BoundView& view,
+                                     const server::bap::ActivityLinkView& link,
+                                     PreparedDevice& output) noexcept {
+    output.activityClientGeneration = link.activityClientGeneration;
+    output.scenarioRow = view.scenarioRow;
+    output.effectiveRegion = link.effectiveRegion;
+    return Status::ready;
+}
+
+/** Resolves one SDK slot through a published canonical group or its exact live occurrence. */
+[[nodiscard]] Status
+prepare_slot(const sdk::BoundView& view, std::uint32_t slotRow, PreparedDevice& output) noexcept {
+    output = {};
+    server::bap::ActivityLinkView link{};
+    const Status live = binding_status(view, link);
+    if (live != Status::ready) {
+        return live;
+    }
+    const sdk::Catalog& catalog = *view.catalog;
+    const format::Scenario* const scenario = sdk::bound_scenario(view);
+    const auto slots = catalog.slots();
+    const auto objects = catalog.objects();
+    const auto occurrences = catalog.occurrences();
+    const auto states = catalog.states();
+    const auto bubbles = catalog.bubbles();
+    if (scenario == nullptr || slotRow >= slots.size()) {
+        return Status::invalidSlot;
+    }
+    const format::Slot& slot = slots[slotRow];
+    if (slot.objectIndex >= objects.size()
+        || slot.slotIndex > middleware::bap::activity_message::sensor_auth_update::kMaximumSlotIndex
+        || slot.slotType > middleware::bap::activity_message::sensor_auth_update::kMaximumSlotType
+        || slot.authSchema == 0 || slot.authSchema == format::kAbsentIndex) {
+        return Status::invalidSlot;
+    }
+    if ((slot.flags & format::kSlotSchemaJoinExact) == 0) {
+        return Status::schemaJoinNotExact;
+    }
+
+    const format::Object& object = objects[slot.objectIndex];
+    if (object.objectTag == 0 || object.objectKey == 0
+        || !sdk::materialize_roster_group(catalog, object, output.generatedRosterGroup)) {
+        return Status::invalidSlot;
+    }
+    std::uint16_t slotOffset = 0;
+    if (!selected_slot_offset(output.generatedRosterGroup, slot, slotOffset)) {
+        return Status::invalidSlot;
+    }
+
+    bool canonical = false;
+    const Status canonicalStatus = canonical_target(view,
+                                                    *scenario,
+                                                    slot,
+                                                    output.generatedRosterGroup,
+                                                    link.effectiveRegion,
+                                                    output.target,
+                                                    canonical);
+    if (canonicalStatus != Status::ready) {
+        output = {};
+        return canonicalStatus;
+    }
+    if (canonical) {
+        return finish_prepared(view, link, output);
+    }
+
+    const format::Occurrence* selected = nullptr;
+    std::uint32_t selectedState = format::kAbsentIndex;
+    for (const format::Occurrence& occurrence : sdk::scenario_occurrences(catalog, *scenario)) {
+        if (occurrence.scenarioIndex != view.scenarioRow || &occurrence < occurrences.data()
+            || &occurrence >= occurrences.data() + occurrences.size()) {
+            return Status::invalidView;
+        }
+        if (occurrence.objectIndex != slot.objectIndex) {
+            continue;
+        }
+        if (occurrence.stateIndex >= states.size() || occurrence.bubbleIndex >= bubbles.size()) {
+            return Status::invalidView;
+        }
+        const format::State& candidateState = states[occurrence.stateIndex];
+        const format::Bubble& candidateBubble = bubbles[occurrence.bubbleIndex];
+        if (candidateState.scenarioIndex != view.scenarioRow
+            || candidateBubble.scenarioIndex != view.scenarioRow
+            || candidateState.bubbleIndex != occurrence.bubbleIndex
+            || candidateState.stateOrdinal >= tables::kSliceSetIndexFactor
+            || candidateBubble.bubbleOrdinal >= layouts::kBubbleCapacity) {
+            return Status::invalidView;
+        }
+        const std::uint64_t candidateRegion =
+            static_cast<std::uint64_t>(candidateState.sliceSetIndex) + candidateState.stateOrdinal;
+        if (candidateRegion != static_cast<std::uint64_t>(link.effectiveRegion)) {
+            continue;
+        }
+        if (selected == nullptr) {
+            selected = &occurrence;
+            selectedState = occurrence.stateIndex;
+        } else if (occurrence.stateIndex != selectedState) {
+            return Status::ambiguousTarget;
+        }
+    }
+    if (selected == nullptr) {
+        return Status::targetUnavailable;
+    }
+    if (selected->stateIndex >= states.size() || selected->bubbleIndex >= bubbles.size()
+        || selected->objectIndex >= objects.size()) {
+        return Status::invalidView;
+    }
+    const format::State& state = states[selected->stateIndex];
+    const format::Bubble& bubble = bubbles[selected->bubbleIndex];
+    if (state.scenarioIndex != view.scenarioRow || bubble.scenarioIndex != view.scenarioRow
+        || state.bubbleIndex != selected->bubbleIndex || object.objectTag == 0
+        || object.objectKey == 0 || state.stateOrdinal >= tables::kSliceSetIndexFactor
+        || bubble.bubbleOrdinal >= layouts::kBubbleCapacity) {
+        return Status::invalidView;
+    }
+    output.target.objectTag = output.generatedRosterGroup.objectTag;
+    output.target.registryKey = output.generatedRosterGroup.registryKey;
+    output.target.authSchema = slot.authSchema;
+    output.target.rosterGroupIndex = host::kGeneratedRosterGroupIndex;
+    output.target.rosterSlotOffset = slotOffset;
+    output.target.slotIndex = static_cast<std::uint16_t>(slot.slotIndex);
+    output.target.sdkObjectIndex = selected->objectIndex;
+    output.target.stateLocalRegion = link.effectiveRegion;
+    output.target.slotType = static_cast<std::uint8_t>(slot.slotType);
+    output.target.stateLocalRoster = true;
+    output.stateRow = selected->stateIndex;
+    return finish_prepared(view, link, output);
+}
+
+/** Resolves the legacy type-23 device facade through the shared typed SDK Auth slot route. */
+[[nodiscard]] Status prepare(const sdk::BoundView& view,
+                             std::uint32_t slotRow,
+                             auth::Type23Channel channel,
+                             float value,
+                             PreparedDevice& output) noexcept {
+    (void)value;
+    const auto channelIndex = static_cast<std::size_t>(channel);
+    if (channelIndex >= auth::kType23ChannelCount) {
+        return Status::invalidChannel;
+    }
+    if (view.catalog == nullptr || slotRow >= view.catalog->slots().size()) {
+        return Status::invalidSlot;
+    }
+    const format::Slot& slot = view.catalog->slots()[slotRow];
+    if (slot.slotType != format::kDeviceSlotType
+        || slot.componentClass != format::kDeviceComponentClass
+        || slot.senseSchema != format::kDeviceSenseSchema
+        || slot.authSchema != format::kDeviceAuthSchema) {
+        return Status::invalidSlot;
+    }
+    return prepare_slot(view, slotRow, output);
+}
+
+/**
+ * Resolves one exact type-31 trigger slot. Type 31 carries no caller value, so unlike the device
+ * path there is no channel or range to check first.
+ * @param view Pinned SDK view whose binding is revalidated.
+ * @param slotRow Catalog row the pulse targets.
+ * @param output Cleared, then receives the resolved target.
+ * @return ready only for an exact type-31 slot on a live owned binding.
+ */
+[[nodiscard]] Status prepare_trigger(const sdk::BoundView& view,
+                                     std::uint32_t slotRow,
+                                     PreparedDevice& output) noexcept {
+    if (view.catalog == nullptr || slotRow >= view.catalog->slots().size()) {
+        return Status::invalidSlot;
+    }
+    const format::Slot& slot = view.catalog->slots()[slotRow];
+    if (slot.slotType != auth::kType31SlotType || slot.authSchema != auth::kType31Schema) {
+        return Status::invalidSlot;
+    }
+    return prepare_slot(view, slotRow, output);
+}
+
+/** Resolves one package-owned object entry without accepting a caller transform. */
+[[nodiscard]] Status prepare_object(const sdk::BoundView& view,
+                                    std::uint32_t slotRow,
+                                    std::int32_t entryIndex,
+                                    PreparedDevice& output) noexcept {
+    if (view.catalog == nullptr || slotRow >= view.catalog->slots().size() || entryIndex < 0) {
+        return Status::invalidSlot;
+    }
+    const format::Slot& slot = view.catalog->slots()[slotRow];
+    if (slot.slotType != format::kObjectSlotType
+        || slot.componentClass != format::kObjectComponentClass
+        || slot.senseSchema != format::kObjectSenseSchema
+        || slot.authSchema != format::kObjectAuthSchema) {
+        return Status::invalidSlot;
+    }
+    return prepare_slot(view, slotRow, output);
+}
+
+/** Resolves one exact actor channel bridge. */
+[[nodiscard]] Status prepare_combatant(const sdk::BoundView& view,
+                                       std::uint32_t slotRow,
+                                       PreparedDevice& output) noexcept {
+    if (view.catalog == nullptr || slotRow >= view.catalog->slots().size()) {
+        return Status::invalidSlot;
+    }
+    const format::Slot& slot = view.catalog->slots()[slotRow];
+    if (slot.slotType != auth::kType2SlotType || slot.componentClass != auth::kType2ComponentClass
+        || slot.senseSchema != auth::kType2SenseSchema || slot.authSchema != auth::kType2Schema) {
+        return Status::invalidSlot;
+    }
+    return prepare_slot(view, slotRow, output);
+}
+
+/** Verifies exact SDK identity and schema-decodes one retained Auth body. */
+[[nodiscard]] Status validate_auth(const sdk::BoundView& view,
+                                   std::uint32_t slotRow,
+                                   std::uint32_t objectTag,
+                                   std::uint32_t registryKey,
+                                   std::uint32_t authSchema,
+                                   std::uint16_t slotIndex,
+                                   std::uint8_t slotType,
+                                   std::span<const std::byte> body,
+                                   std::uint16_t bitCount,
+                                   std::span<const std::byte> sdkBuildSha256) noexcept {
+    if (view.catalog == nullptr || sdkBuildSha256.size() != 32U
+        || view.catalog->sdk_build_sha256().size() != sdkBuildSha256.size()
+        || !std::equal(sdkBuildSha256.begin(),
+                       sdkBuildSha256.end(),
+                       view.catalog->sdk_build_sha256().begin())) {
+        return Status::wrongSdkBuild;
+    }
+    const auto slots = view.catalog->slots();
+    const auto objects = view.catalog->objects();
+    if (slotRow >= slots.size()) {
+        return Status::invalidSlot;
+    }
+    const format::Slot& slot = slots[slotRow];
+    if (slot.objectIndex >= objects.size() || objectTag == 0 || registryKey == 0 || authSchema == 0
+        || slotType > middleware::bap::activity_message::sensor_auth_update::kMaximumSlotType
+        || slotIndex > middleware::bap::activity_message::sensor_auth_update::kMaximumSlotIndex
+        || objects[slot.objectIndex].objectTag != objectTag
+        || objects[slot.objectIndex].objectKey != registryKey || slot.authSchema != authSchema
+        || slot.slotIndex != slotIndex || slot.slotType != slotType) {
+        return Status::invalidSlot;
+    }
+    if ((slot.flags & format::kSlotSchemaJoinExact) == 0) {
+        return Status::schemaJoinNotExact;
+    }
+    if (body.empty()
+        || body.size()
+               > middleware::bap::activity_message::sensor_auth_update::kAuthOverrideByteCapacity
+        || bitCount > body.size() * 8U || bitCount + 7U < body.size() * 8U) {
+        return Status::invalidBody;
+    }
+    const std::size_t trailingBits = bitCount % 8U;
+    if (trailingBits != 0) {
+        const std::uint8_t paddingMask =
+            static_cast<std::uint8_t>((std::uint16_t{1} << (8U - trailingBits)) - 1U);
+        if ((std::to_integer<std::uint8_t>(body.back()) & paddingMask) != 0) {
+            return Status::invalidBody;
+        }
+    }
+    constexpr std::size_t kOccupancyAuthBits = 87;
+    constexpr std::size_t kOccupancyAuthBytes = 11;
+    const bool objectFilter = slotType == 34 && slot.componentClass == 0x80809568U
+        && authSchema == 0x8080956AU && middleware::bap::activity_message::mission_effect::validate_filter(body,bitCount);
+    const bool missionEffect = slotType == 26 && slot.componentClass == 0x8080953FU
+        && authSchema == 0x8080954BU && middleware::bap::activity_message::mission_effect::validate(body,bitCount);
+    namespace music = middleware::bap::activity_message::music_section;
+    const bool musicSection = slotType == 11 && slot.componentClass == music::kClass
+        && authSchema == music::kSchema && music::validate(body, bitCount);
+    const bool damageMonitor = slotType == 20 && slot.componentClass == 0x80809560U
+        && authSchema == 0x80809563U && bitCount == 87 && body.size() == 11;
+    const bool occupancy = slotType == format::kOccupancySlotType
+                           && authSchema == format::kOccupancyAuthSchema
+                           && bitCount == kOccupancyAuthBits && body.size() == kOccupancyAuthBytes;
+    const bool directive =
+        slotType == middleware::bap::activity_message::scriptable_auth::kType68SlotType
+        && authSchema == middleware::bap::activity_message::scriptable_auth::kType68Schema
+        && middleware::bap::activity_message::scriptable_auth::validate_type68_body(body, bitCount);
+    const bool engagement =
+        slotType == middleware::bap::activity_message::scriptable_auth::kType70SlotType
+        && authSchema == middleware::bap::activity_message::scriptable_auth::kType70Schema
+        && middleware::bap::activity_message::scriptable_auth::validate_type70_body(body, bitCount);
+    const bool publicEvent =
+        slotType == middleware::bap::activity_message::scriptable_auth::kType71SlotType
+        && authSchema == middleware::bap::activity_message::scriptable_auth::kType71Schema
+        && middleware::bap::activity_message::scriptable_auth::validate_type71_body(body, bitCount);
+    const bool performance =
+        slotType == middleware::bap::activity_message::scriptable_auth::kType42SlotType
+        && authSchema == middleware::bap::activity_message::scriptable_auth::kType42Schema
+        && middleware::bap::activity_message::scriptable_auth::validate_type42_body(body, bitCount);
+    const bool combatant =
+        slotType == middleware::bap::activity_message::scriptable_auth::kType2SlotType
+        && authSchema == middleware::bap::activity_message::scriptable_auth::kType2Schema
+        && (middleware::bap::activity_message::scriptable_auth::validate_type2_body(body, bitCount)
+            || (slot.componentClass == auth::kType2ComponentClass
+                && (middleware::bap::activity_message::combatant_path::validate(body, bitCount)
+                    || middleware::bap::activity_message::combatant_delivery::validate(body, bitCount)
+                    || middleware::bap::activity_message::combatant_action::validate(body, bitCount)
+                    || middleware::bap::activity_message::combatant_retire::validate(body, bitCount))));
+    namespace darkness=middleware::bap::activity_message::darkness_zone;
+    bool darknessEnabled{};
+    const bool darknessZone=slotType==darkness::kSlotType&&authSchema==darkness::kSchema
+        &&slot.componentClass==darkness::kClass&&darkness::decode(body,bitCount,darknessEnabled);
+    namespace object = middleware::bap::activity_message::interactable_object;
+    const bool interactableObject = slotType == format::kObjectSlotType
+        && authSchema == object::kSchema && slot.componentClass == format::kObjectComponentClass
+        && object::validate(body,bitCount);
+    namespace ghost = middleware::bap::activity_message::ghost_link;
+    const bool ghostLink = slotType == ghost::kSlotType && authSchema == ghost::kAuthSchema
+                           && slot.componentClass == ghost::kComponentClass
+                           && ghost::validate(body, bitCount);
+    namespace objective = middleware::bap::activity_message::squad_objective;
+    const bool squadObjective = slotType == 1 && authSchema == objective::kSchema
+        && slot.componentClass == format::kSquadComponentClass && objective::validate(body, bitCount);
+    if (!musicSection && !objectFilter && !missionEffect && !damageMonitor && !darknessZone && !squadObjective && !occupancy && !directive && !engagement && !publicEvent && !performance && !combatant
+        && !ghostLink && !interactableObject) {
+        return Status::invalidBody;
+    }
+    return Status::ready;
+}
 
 } // namespace
 

+ 625 - 22
Sunrise/src/server/activity/activity_sdk_mission_runtime.cpp

@@ -8,6 +8,7 @@
 #include "../../state/activity/runtime.h"
 #include "activity_sdk_mission_internal.h"
 #include "activity_sdk_scriptable_route.h"
+#include "activity_sdk_behavior_scope.h"
 #include "host_runtime.h"
 
 namespace sunrise::server::activity::activity_sdk_mission {
@@ -18,18 +19,622 @@ namespace message = middleware::bap::activity_message::sensor_auth_update;
 namespace sdk = state::activity_sdk;
 namespace sdk_route = server::activity::activity_sdk_scriptables;
 
-using detail::binding_status;
-using detail::lease_status;
-using detail::materialize_plan;
-using detail::prepare_dialogue;
-using detail::prepare_directive;
-using detail::prepare_objective;
-using detail::prepare_scene;
-using detail::prepare_task;
-using detail::prepare_typed_behavior;
-using detail::PreparedScene;
-using detail::read_lease;
-using detail::same_plan;
+SRWLOCK g_materializeLock{SRWLOCK_INIT};
+std::array<layouts::RosterGroup, message::kPublishedGroupCapacity> g_materializedGroups{};
+
+/** Exact private route retained while one authored-scene request is staged. */
+struct PreparedScene final {
+    host::ScriptableTarget target{};
+    layouts::RosterGroup rosterGroup{};
+    std::uint64_t activityClientGeneration{};
+    std::uint32_t scenarioRow{sdk::format::kAbsentIndex};
+    std::uint32_t stateRow{sdk::format::kAbsentIndex};
+    std::int32_t effectiveRegion{-1};
+};
+
+/** Maps exact SDK binding validation to this facade's stable refusal surface. */
+[[nodiscard]] Status binding_status(const sdk::BoundView& view,
+                                    server::bap::ActivityLinkView& link) noexcept {
+    if (view.catalog == nullptr || sdk::bound_activity(view) == nullptr
+        || sdk::bound_scenario(view) == nullptr) {
+        return Status::invalidView;
+    }
+    if (!state::activity::binding_matches(view.binding)) {
+        return Status::staleBinding;
+    }
+    (void)server::bap::activity_link_view(view.binding, link);
+    switch (
+        sdk::revalidate(view, view.binding, link.matchingLinks, link.activityClientGeneration)) {
+    case sdk::Status::ready:
+        return Status::ready;
+    case sdk::Status::missingClient:
+    case sdk::Status::ambiguousClient:
+        return Status::noActivityLink;
+    case sdk::Status::staleSession:
+        return Status::staleBinding;
+    case sdk::Status::staleActivityClient:
+        return Status::staleActivityClient;
+    case sdk::Status::notReady:
+    case sdk::Status::missing:
+    case sdk::Status::wrongSdkBuild:
+    case sdk::Status::catalogInvalid:
+    case sdk::Status::wrongActivity:
+    case sdk::Status::activityJoinNotExact:
+    case sdk::Status::missingScenarioLink:
+        return Status::invalidView;
+    }
+    return Status::invalidView;
+}
+
+/** Maps the SDK materializer's closed set of semantic failures. */
+[[nodiscard]] Status mission_status(sdk::MissionSeedStatus status) noexcept {
+    switch (status) {
+    case sdk::MissionSeedStatus::ready:
+        return Status::ready;
+    case sdk::MissionSeedStatus::invalidView:
+        return Status::invalidView;
+    case sdk::MissionSeedStatus::invalidSliceSet:
+        return Status::missingLiveSliceSet;
+    case sdk::MissionSeedStatus::missingInitialState:
+        return Status::missingInitialState;
+    case sdk::MissionSeedStatus::ambiguousInitialState:
+        return Status::ambiguousInitialState;
+    case sdk::MissionSeedStatus::invalidOccurrence:
+        return Status::invalidOccurrence;
+    case sdk::MissionSeedStatus::schemaJoinNotExact:
+        return Status::schemaJoinNotExact;
+    case sdk::MissionSeedStatus::invalidRosterGroup:
+        return Status::invalidRosterGroup;
+    case sdk::MissionSeedStatus::rosterKeyConflict:
+        return Status::rosterKeyConflict;
+    case sdk::MissionSeedStatus::groupCapacityExceeded:
+        return Status::groupCapacityExceeded;
+    }
+    return Status::refused;
+}
+
+/** Maps the transport lease's closed set of connection/refusal outcomes. */
+[[nodiscard]] Status lease_status(server::bap::ActivityMissionSeedLeaseStatus status) noexcept {
+    using LeaseStatus = server::bap::ActivityMissionSeedLeaseStatus;
+    switch (status) {
+    case LeaseStatus::ready:
+        return Status::ready;
+    case LeaseStatus::noActivityLink:
+        return Status::noActivityLink;
+    case LeaseStatus::staleActivityClient:
+        return Status::staleActivityClient;
+    case LeaseStatus::wrongScenario:
+        return Status::wrongScenario;
+    case LeaseStatus::missingLiveSliceSet:
+        return Status::missingLiveSliceSet;
+    case LeaseStatus::wrongSliceSet:
+        return Status::wrongSliceSet;
+    case LeaseStatus::outputBusy:
+        return Status::outputBusy;
+    case LeaseStatus::refused:
+        return Status::refused;
+    }
+    return Status::refused;
+}
+
+/** @return True when two plans leave the same objects out of their seed, in the same order. */
+[[nodiscard]] bool same_omissions(const server::bap::ActivityMissionSeedPlan& left,
+                                  const server::bap::ActivityMissionSeedPlan& right) noexcept {
+    if (left.omissionCount != right.omissionCount) {
+        return false;
+    }
+    for (std::uint32_t index = 0; index < left.omissionCount; ++index) {
+        if (left.omissions[index].objectTag != right.omissions[index].objectTag
+            || left.omissions[index].registryKey != right.omissions[index].registryKey) {
+            return false;
+        }
+    }
+    return true;
+}
+
+/** Copies the immutable scalar materializer output into a transport-owned plan. */
+[[nodiscard]] server::bap::ActivityMissionSeedPlan
+plan_from(const sdk::MissionSeedSummary& summary) noexcept {
+    server::bap::ActivityMissionSeedPlan plan{};
+    plan.activityRow = summary.activityRow;
+    plan.scenarioRow = summary.scenarioRow;
+    plan.stateRow = summary.stateRow;
+    plan.bubbleRow = summary.bubbleRow;
+    plan.bubbleOrdinal = summary.bubbleOrdinal;
+    plan.stateOrdinal = summary.stateOrdinal;
+    plan.entryIndex = summary.entryIndex;
+    plan.sliceSetIndex = summary.sliceSetIndex;
+    plan.effectiveRegion = summary.effectiveRegion;
+    plan.omissions = summary.omissions;
+    plan.omissionCount = summary.omissionCount;
+    plan.occurrenceCount = summary.occurrenceCount;
+    plan.groupCount = summary.groupCount;
+    plan.authMappingSlots = summary.authMappingSlots;
+    plan.authResetSlots = summary.authResetSlots;
+    plan.senseSuppressedSlots = summary.senseSuppressedSlots;
+    return plan;
+}
+
+/** @return True when two plans name the same immutable generated rows and counts. */
+[[nodiscard]] bool same_plan(const server::bap::ActivityMissionSeedPlan& left,
+                             const server::bap::ActivityMissionSeedPlan& right) noexcept {
+    return left.activityRow == right.activityRow && left.scenarioRow == right.scenarioRow
+           && left.stateRow == right.stateRow && left.bubbleRow == right.bubbleRow
+           && left.bubbleOrdinal == right.bubbleOrdinal && left.stateOrdinal == right.stateOrdinal
+           && left.entryIndex == right.entryIndex && left.sliceSetIndex == right.sliceSetIndex
+           && left.effectiveRegion == right.effectiveRegion && same_omissions(left, right)
+           && left.occurrenceCount == right.occurrenceCount && left.groupCount == right.groupCount
+           && left.authMappingSlots == right.authMappingSlots
+           && left.authResetSlots == right.authResetSlots
+           && left.senseSuppressedSlots == right.senseSuppressedSlots;
+}
+
+/** Materializes into static lock-owned storage so UI stack size stays bounded. */
+[[nodiscard]] Status materialize_plan(const sdk::BoundView& view,
+                                      std::int32_t effectiveRegion,
+                                      std::span<const sdk::MissionSeedOmission> omissions,
+                                      server::bap::ActivityMissionSeedPlan& output) noexcept {
+    output = {};
+    if (effectiveRegion < 0) {
+        return Status::missingLiveSliceSet;
+    }
+    sdk::MissionSeedSummary summary{};
+    AcquireSRWLockExclusive(&g_materializeLock);
+    const sdk::MissionSeedStatus materialized = sdk::materialize_initial_mission_seed(
+        view, effectiveRegion, omissions, std::span(g_materializedGroups), summary);
+    ReleaseSRWLockExclusive(&g_materializeLock);
+    const Status status = mission_status(materialized);
+    if (status == Status::ready) {
+        output = plan_from(summary);
+    }
+    return status;
+}
+
+/** Reads the transport lease after the binding has been revalidated. */
+[[nodiscard]] Status read_lease(const sdk::BoundView& view,
+                                const server::bap::ActivityLinkView& link,
+                                server::bap::ActivityMissionSeedLeaseView& output) noexcept {
+    return lease_status(server::bap::activity_mission_seed_lease(
+        view.binding, view.scenarioRow, link.activityClientGeneration, output));
+}
+
+/** Maps the shared binding result to the authored-scene refusal surface. */
+[[nodiscard]] SceneStatus scene_binding_status(const sdk::BoundView& view,
+                                               server::bap::ActivityLinkView& link) noexcept {
+    switch (binding_status(view, link)) {
+    case Status::ready:
+        // A public-target link publishes the state-local groups of the public bubbles it hosts,
+        // so a scene, task or cue in one of them is reachable only through that link.
+        return link.effectiveRegion >= 0 ? SceneStatus::ready : SceneStatus::noActivityLink;
+    case Status::staleBinding:
+        return SceneStatus::staleBinding;
+    case Status::staleActivityClient:
+        return SceneStatus::staleActivityClient;
+    case Status::noActivityLink:
+        return SceneStatus::noActivityLink;
+    case Status::invalidView:
+    case Status::missingLiveSliceSet:
+    case Status::wrongScenario:
+    case Status::wrongSliceSet:
+    case Status::missingInitialState:
+    case Status::ambiguousInitialState:
+    case Status::invalidOccurrence:
+    case Status::schemaJoinNotExact:
+    case Status::invalidRosterGroup:
+    case Status::rosterKeyConflict:
+    case Status::groupCapacityExceeded:
+    case Status::outputBusy:
+    case Status::refused:
+        return SceneStatus::invalidView;
+    }
+    return SceneStatus::invalidView;
+}
+
+/** Checks the exact enabled and published mission-seed lease for one scene state. */
+[[nodiscard]] SceneStatus scene_lease_status(const sdk::BoundView& view,
+                                             const server::bap::ActivityLinkView& link,
+                                             std::uint32_t stateRow) noexcept {
+    server::bap::ActivityMissionSeedLeaseView lease{};
+    switch (server::bap::activity_mission_seed_lease(
+        view.binding, view.scenarioRow, link.activityClientGeneration, lease)) {
+    case server::bap::ActivityMissionSeedLeaseStatus::ready:
+        break;
+    case server::bap::ActivityMissionSeedLeaseStatus::noActivityLink:
+        return SceneStatus::noActivityLink;
+    case server::bap::ActivityMissionSeedLeaseStatus::staleActivityClient:
+        return SceneStatus::staleActivityClient;
+    case server::bap::ActivityMissionSeedLeaseStatus::outputBusy:
+        return SceneStatus::outputBusy;
+    case server::bap::ActivityMissionSeedLeaseStatus::wrongScenario:
+    case server::bap::ActivityMissionSeedLeaseStatus::missingLiveSliceSet:
+    case server::bap::ActivityMissionSeedLeaseStatus::wrongSliceSet:
+    case server::bap::ActivityMissionSeedLeaseStatus::refused:
+        return SceneStatus::missionSeedUnavailable;
+    }
+    if (lease.activityClientGeneration != link.activityClientGeneration) {
+        return SceneStatus::staleActivityClient;
+    }
+    if (!lease.configured || lease.revision == 0 || lease.plan.scenarioRow != view.scenarioRow) {
+        return SceneStatus::missionSeedUnavailable;
+    }
+    if (lease.plan.stateRow != stateRow
+        && !behavior_scope::live_state(view.catalog->states(), view.catalog->bubbles(),
+            view.scenarioRow, stateRow, link.effectiveRegion)) {
+        return SceneStatus::wrongState;
+    }
+    if (lease.publicationPending || lease.publishedRevision != lease.revision) {
+        return SceneStatus::missionSeedPending;
+    }
+    return SceneStatus::ready;
+}
+
+/** Resolves one exact generated scene without changing transport state. */
+[[nodiscard]] SceneStatus prepare_scene(const sdk::BoundView& view,
+                                        std::uint32_t occurrenceRow,
+                                        std::uint32_t slotRow,
+                                        PreparedScene& output) noexcept {
+    output = {};
+    server::bap::ActivityLinkView link{};
+    const SceneStatus live = scene_binding_status(view, link);
+    if (live != SceneStatus::ready) {
+        return live;
+    }
+
+    const sdk::Catalog& catalog = *view.catalog;
+    const sdk::format::Scenario* const scenario = sdk::bound_scenario(view);
+    const auto occurrences = catalog.occurrences();
+    const auto objects = catalog.objects();
+    const auto slots = catalog.slots();
+    const auto states = catalog.states();
+    const auto bubbles = catalog.bubbles();
+    if (scenario == nullptr || occurrenceRow >= occurrences.size()) {
+        return SceneStatus::invalidOccurrence;
+    }
+    if (slotRow >= slots.size()) {
+        return SceneStatus::invalidSlot;
+    }
+
+    const sdk::format::Occurrence& occurrence = occurrences[occurrenceRow];
+    if (occurrence.scenarioIndex != view.scenarioRow) {
+        return SceneStatus::wrongScenario;
+    }
+    if (occurrence.objectIndex >= objects.size() || occurrence.stateIndex >= states.size()
+        || occurrence.bubbleIndex >= bubbles.size()) {
+        return SceneStatus::invalidOccurrence;
+    }
+    const sdk::format::Object& object = objects[occurrence.objectIndex];
+    const sdk::format::Slot& slot = slots[slotRow];
+    const sdk::format::State& state = states[occurrence.stateIndex];
+    const sdk::format::Bubble& bubble = bubbles[occurrence.bubbleIndex];
+    if (slot.objectIndex != occurrence.objectIndex || slotRow < object.slots.first
+        || slotRow - object.slots.first >= object.slots.count) {
+        return SceneStatus::invalidSlot;
+    }
+    if (state.scenarioIndex != view.scenarioRow || bubble.scenarioIndex != view.scenarioRow
+        || state.bubbleIndex != occurrence.bubbleIndex) {
+        return SceneStatus::invalidOccurrence;
+    }
+    if (slot.slotType != sdk::format::kAuthoredSceneSlotType
+        || slot.componentClass != sdk::format::kAuthoredSceneComponentClass
+        || slot.senseSchema != sdk::format::kAuthoredSceneSenseSchema
+        || slot.authSchema != sdk::format::kAuthoredSceneAuthSchema) {
+        return SceneStatus::invalidSlot;
+    }
+    if ((slot.flags & sdk::format::kSlotSchemaJoinExact) == 0) {
+        return SceneStatus::schemaJoinNotExact;
+    }
+    if (slot.slotIndex > (std::numeric_limits<std::uint16_t>::max)()
+        || slot.slotType > (std::numeric_limits<std::uint8_t>::max)()) {
+        return SceneStatus::invalidSlot;
+    }
+
+    const auto resources = sdk::slot_authored_scene_resources(catalog, slot);
+    if (resources.empty()) {
+        return SceneStatus::missingResource;
+    }
+    if (resources.size() != 1) {
+        return SceneStatus::ambiguousResource;
+    }
+    const sdk::format::AuthoredSceneResource& resource = resources.front();
+    if (resource.slotIndex != slotRow || resource.configTag == 0
+        || resource.configTag == sdk::format::kAbsentIndex || resource.resourceTag == 0
+        || resource.resourceTag == sdk::format::kAbsentIndex
+        || resource.resourceClass != sdk::format::kAuthoredSceneResourceClass
+        || resource.flags != sdk::format::kAuthoredSceneResourceExact
+        || resource.descriptorOffset > (std::numeric_limits<std::uint32_t>::max)()
+                                           - sdk::format::kAuthoredSceneResourceRelativeOffset
+        || resource.resourceFieldOffset
+               != resource.descriptorOffset + sdk::format::kAuthoredSceneResourceRelativeOffset) {
+        return SceneStatus::missingResource;
+    }
+
+    const SceneStatus lease = scene_lease_status(view, link, occurrence.stateIndex);
+    if (lease != SceneStatus::ready) {
+        return lease;
+    }
+
+    sdk_route::SourceIdentity source{};
+    source.scenarioTag = scenario->tag;
+    source.objectTag = object.objectTag;
+    source.registryKey = object.objectKey;
+    source.authSchema = slot.authSchema;
+    source.objectRow = occurrence.objectIndex;
+    source.stateRow = occurrence.stateIndex;
+    source.slotIndex = static_cast<std::uint16_t>(slot.slotIndex);
+    source.slotType = static_cast<std::uint8_t>(slot.slotType);
+
+    sdk_route::Resolution resolved{};
+    switch (sdk_route::resolve(view, source, link.effectiveRegion, resolved)) {
+    case sdk_route::Status::ready:
+        break;
+    case sdk_route::Status::invalidView:
+        return SceneStatus::invalidView;
+    case sdk_route::Status::invalidSource:
+        return SceneStatus::invalidSlot;
+    case sdk_route::Status::missingSource:
+        return SceneStatus::targetUnavailable;
+    case sdk_route::Status::ambiguousSource:
+        return SceneStatus::ambiguousTarget;
+    }
+    if (resolved.scenarioRow != view.scenarioRow || resolved.stateRow != occurrence.stateIndex
+        || resolved.region != link.effectiveRegion || !resolved.target.stateLocalRoster
+        || resolved.target.slotType != sdk::format::kAuthoredSceneSlotType
+        || resolved.target.authSchema != sdk::format::kAuthoredSceneAuthSchema) {
+        return SceneStatus::invalidSlot;
+    }
+
+    output.target = resolved.target;
+    output.rosterGroup = resolved.rosterGroup;
+    output.activityClientGeneration = link.activityClientGeneration;
+    output.scenarioRow = resolved.scenarioRow;
+    output.stateRow = resolved.stateRow;
+    output.effectiveRegion = resolved.region;
+    return SceneStatus::ready;
+}
+
+/** Resolves one exact SDK-bounded type-53 cue without changing transport state. */
+[[nodiscard]] SceneStatus prepare_dialogue(const sdk::BoundView& view,
+                                           std::uint32_t occurrenceRow,
+                                           std::uint32_t slotRow,
+                                           std::uint16_t cueIndex,
+                                           PreparedScene& output,
+                                           std::uint16_t& authoredCueCount) noexcept {
+    output = {};
+    authoredCueCount = 0;
+    server::bap::ActivityLinkView link{};
+    const SceneStatus live = scene_binding_status(view, link);
+    if (live != SceneStatus::ready) {
+        return live;
+    }
+    const sdk::Catalog& catalog = *view.catalog;
+    const sdk::format::Scenario* const scenario = sdk::bound_scenario(view);
+    const auto occurrences = catalog.occurrences();
+    const auto objects = catalog.objects();
+    const auto slots = catalog.slots();
+    const auto states = catalog.states();
+    const auto bubbles = catalog.bubbles();
+    if (scenario == nullptr || occurrenceRow >= occurrences.size()) {
+        return SceneStatus::invalidOccurrence;
+    }
+    if (slotRow >= slots.size()) {
+        return SceneStatus::invalidSlot;
+    }
+    const sdk::format::Occurrence& occurrence = occurrences[occurrenceRow];
+    if (occurrence.scenarioIndex != view.scenarioRow || occurrence.objectIndex >= objects.size()
+        || occurrence.stateIndex >= states.size() || occurrence.bubbleIndex >= bubbles.size()) {
+        return occurrence.scenarioIndex != view.scenarioRow ? SceneStatus::wrongScenario
+                                                            : SceneStatus::invalidOccurrence;
+    }
+    const sdk::format::Object& object = objects[occurrence.objectIndex];
+    const sdk::format::Slot& slot = slots[slotRow];
+    const sdk::format::State& state = states[occurrence.stateIndex];
+    const sdk::format::Bubble& bubble = bubbles[occurrence.bubbleIndex];
+    if (slot.objectIndex != occurrence.objectIndex || slotRow < object.slots.first
+        || slotRow - object.slots.first >= object.slots.count
+        || state.scenarioIndex != view.scenarioRow || bubble.scenarioIndex != view.scenarioRow
+        || state.bubbleIndex != occurrence.bubbleIndex) {
+        return SceneStatus::invalidSlot;
+    }
+    if (slot.slotType != sdk::format::kDialogueSlotType
+        || slot.componentClass != sdk::format::kDialogueComponentClass
+        || slot.authSchema != sdk::format::kDialogueAuthSchema
+        || (slot.flags & (sdk::format::kSlotSchemaJoinExact | sdk::format::kSlotDialogueCuesExact))
+               != (sdk::format::kSlotSchemaJoinExact | sdk::format::kSlotDialogueCuesExact)
+        || slot.reserved == 0 || slot.reserved > sdk::format::kDialogueMaximumCueCount
+        || cueIndex >= slot.reserved) {
+        return SceneStatus::invalidSlot;
+    }
+    authoredCueCount = static_cast<std::uint16_t>(slot.reserved);
+    const SceneStatus lease = scene_lease_status(view, link, occurrence.stateIndex);
+    if (lease != SceneStatus::ready) {
+        return lease;
+    }
+    sdk_route::SourceIdentity source{};
+    source.scenarioTag = scenario->tag;
+    source.objectTag = object.objectTag;
+    source.registryKey = object.objectKey;
+    source.authSchema = slot.authSchema;
+    source.objectRow = occurrence.objectIndex;
+    source.stateRow = occurrence.stateIndex;
+    source.slotIndex = static_cast<std::uint16_t>(slot.slotIndex);
+    source.slotType = static_cast<std::uint8_t>(slot.slotType);
+    sdk_route::Resolution resolved{};
+    switch (sdk_route::resolve(view, source, link.effectiveRegion, resolved)) {
+    case sdk_route::Status::ready:
+        break;
+    case sdk_route::Status::invalidView:
+        return SceneStatus::invalidView;
+    case sdk_route::Status::invalidSource:
+        return SceneStatus::invalidSlot;
+    case sdk_route::Status::missingSource:
+        return SceneStatus::targetUnavailable;
+    case sdk_route::Status::ambiguousSource:
+        return SceneStatus::ambiguousTarget;
+    }
+    if (resolved.scenarioRow != view.scenarioRow || resolved.stateRow != occurrence.stateIndex
+        || resolved.region != link.effectiveRegion || !resolved.target.stateLocalRoster
+        || resolved.target.slotType != sdk::format::kDialogueSlotType
+        || resolved.target.authSchema != sdk::format::kDialogueAuthSchema) {
+        return SceneStatus::invalidSlot;
+    }
+    output.target = resolved.target;
+    output.rosterGroup = resolved.rosterGroup;
+    output.activityClientGeneration = link.activityClientGeneration;
+    output.scenarioRow = resolved.scenarioRow;
+    output.stateRow = resolved.stateRow;
+    output.effectiveRegion = resolved.region;
+    return SceneStatus::ready;
+}
+
+/** Resolves one exact fixed-schema behavior slot without changing transport state. */
+[[nodiscard]] SceneStatus prepare_typed_behavior(const sdk::BoundView& view,
+                                                 std::uint32_t occurrenceRow,
+                                                 std::uint32_t slotRow,
+                                                 std::uint32_t expectedSlotType,
+                                                 std::uint32_t expectedComponentClass,
+                                                 std::uint32_t expectedAuthSchema,
+                                                 bool requireTaskTarget,
+                                                 PreparedScene& output) noexcept {
+    output = {};
+    server::bap::ActivityLinkView link{};
+    const SceneStatus live = scene_binding_status(view, link);
+    if (live != SceneStatus::ready) {
+        return live;
+    }
+    const sdk::Catalog& catalog = *view.catalog;
+    const sdk::format::Scenario* const scenario = sdk::bound_scenario(view);
+    const auto occurrences = catalog.occurrences();
+    const auto objects = catalog.objects();
+    const auto slots = catalog.slots();
+    const auto states = catalog.states();
+    const auto bubbles = catalog.bubbles();
+    if (scenario == nullptr || occurrenceRow >= occurrences.size()) {
+        return SceneStatus::invalidOccurrence;
+    }
+    if (slotRow >= slots.size()) {
+        return SceneStatus::invalidSlot;
+    }
+    const sdk::format::Occurrence& occurrence = occurrences[occurrenceRow];
+    if (occurrence.scenarioIndex != view.scenarioRow || occurrence.objectIndex >= objects.size()
+        || occurrence.stateIndex >= states.size() || occurrence.bubbleIndex >= bubbles.size()) {
+        return occurrence.scenarioIndex != view.scenarioRow ? SceneStatus::wrongScenario
+                                                            : SceneStatus::invalidOccurrence;
+    }
+    const sdk::format::Object& object = objects[occurrence.objectIndex];
+    const sdk::format::Slot& slot = slots[slotRow];
+    const sdk::format::State& state = states[occurrence.stateIndex];
+    const sdk::format::Bubble& bubble = bubbles[occurrence.bubbleIndex];
+    if (slot.objectIndex != occurrence.objectIndex || slotRow < object.slots.first
+        || slotRow - object.slots.first >= object.slots.count
+        || state.scenarioIndex != view.scenarioRow || bubble.scenarioIndex != view.scenarioRow
+        || state.bubbleIndex != occurrence.bubbleIndex) {
+        return SceneStatus::invalidSlot;
+    }
+    if (slot.slotType != expectedSlotType || slot.componentClass != expectedComponentClass
+        || slot.authSchema != expectedAuthSchema
+        || (slot.flags & sdk::format::kSlotSchemaJoinExact) == 0
+        || (requireTaskTarget && sdk::slot_task_targets(catalog, slot).empty())
+        || slot.slotIndex > (std::numeric_limits<std::uint16_t>::max)()) {
+        return SceneStatus::invalidSlot;
+    }
+    const SceneStatus lease = scene_lease_status(view, link, occurrence.stateIndex);
+    if (lease != SceneStatus::ready) {
+        return lease;
+    }
+    sdk_route::SourceIdentity source{};
+    source.scenarioTag = scenario->tag;
+    source.objectTag = object.objectTag;
+    source.registryKey = object.objectKey;
+    source.authSchema = slot.authSchema;
+    source.objectRow = occurrence.objectIndex;
+    source.stateRow = occurrence.stateIndex;
+    source.slotIndex = static_cast<std::uint16_t>(slot.slotIndex);
+    source.slotType = static_cast<std::uint8_t>(slot.slotType);
+    sdk_route::Resolution resolved{};
+    switch (sdk_route::resolve(view, source, link.effectiveRegion, resolved)) {
+    case sdk_route::Status::ready:
+        break;
+    case sdk_route::Status::invalidView:
+        return SceneStatus::invalidView;
+    case sdk_route::Status::invalidSource:
+        return SceneStatus::invalidSlot;
+    case sdk_route::Status::missingSource:
+        return SceneStatus::targetUnavailable;
+    case sdk_route::Status::ambiguousSource:
+        return SceneStatus::ambiguousTarget;
+    }
+    if (resolved.scenarioRow != view.scenarioRow || resolved.stateRow != occurrence.stateIndex
+        || resolved.region != link.effectiveRegion || !resolved.target.stateLocalRoster
+        || resolved.target.slotType != expectedSlotType
+        || resolved.target.authSchema != expectedAuthSchema) {
+        return SceneStatus::invalidSlot;
+    }
+    output.target = resolved.target;
+    output.rosterGroup = resolved.rosterGroup;
+    output.activityClientGeneration = link.activityClientGeneration;
+    output.scenarioRow = resolved.scenarioRow;
+    output.stateRow = resolved.stateRow;
+    output.effectiveRegion = resolved.region;
+    return SceneStatus::ready;
+}
+
+/** Resolves one exact SDK-linked type-38 task without changing transport state. */
+[[nodiscard]] SceneStatus prepare_task(const sdk::BoundView& view,
+                                       std::uint32_t occurrenceRow,
+                                       std::uint32_t slotRow,
+                                       PreparedScene& output) noexcept {
+    return prepare_typed_behavior(view,
+                                  occurrenceRow,
+                                  slotRow,
+                                  sdk::format::kTaskSlotType,
+                                  sdk::format::kTaskComponentClass,
+                                  sdk::format::kTaskAuthSchema,
+                                  true,
+                                  output);
+}
+
+/** Resolves one exact type-68 HUD state without changing transport state. */
+[[nodiscard]] SceneStatus prepare_directive(const sdk::BoundView& view,
+                                            std::uint32_t occurrenceRow,
+                                            std::uint32_t slotRow,
+                                            std::uint32_t nameHash,
+                                            std::int32_t elementIndex,
+                                            PreparedScene& output) noexcept {
+    const SceneStatus status = prepare_typed_behavior(view,
+                                                      occurrenceRow,
+                                                      slotRow,
+                                                      sdk::format::kDirectiveSlotType,
+                                                      sdk::format::kDirectiveComponentClass,
+                                                      sdk::format::kDirectiveAuthSchema,
+                                                      false,
+                                                      output);
+    if (status != SceneStatus::ready) {
+        return status;
+    }
+    std::size_t matches = 0;
+    for (const sdk::format::DirectiveElement& row : view.catalog->directive_elements()) {
+        matches +=
+            row.slotIndex == slotRow && row.nameHash == nameHash && row.elementIndex == elementIndex
+                ? 1U
+                : 0U;
+    }
+    return matches == 1 ? SceneStatus::ready : SceneStatus::invalidSlot;
+}
+
+/** Resolves one exact type-3 objective sensor without changing transport state. */
+[[nodiscard]] SceneStatus prepare_objective(const sdk::BoundView& view,
+                                            std::uint32_t occurrenceRow,
+                                            std::uint32_t slotRow,
+                                            PreparedScene& output) noexcept {
+    return prepare_typed_behavior(view,
+                                  occurrenceRow,
+                                  slotRow,
+                                  sdk::format::kObjectiveSlotType,
+                                  sdk::format::kObjectiveComponentClass,
+                                  sdk::format::kObjectiveAuthSchema,
+                                  false,
+                                  output);
+}
 
 } // namespace
 
@@ -508,16 +1113,14 @@ set_cinematic_active_reserved(const sdk::BoundView& view,
     if (slotRow >= slots.size()) {
         return SceneStatus::invalidSlot;
     }
-    for (std::uint32_t index = 0; index < occurrences.size(); ++index) {
-        const sdk::format::Occurrence& occurrence = occurrences[index];
-        if (occurrence.scenarioIndex != view.scenarioRow
-            || occurrence.stateIndex != snapshot.plan.stateRow
-            || occurrence.objectIndex != slots[slotRow].objectIndex) {
-            continue;
-        }
-        occurrenceRow = index;
-        break;
-    }
+    server::bap::ActivityLinkView link{};
+    const SceneStatus live = scene_binding_status(view, link);
+    if (live != SceneStatus::ready) return live;
+    const auto selected = behavior_scope::select(occurrences, view.catalog->states(),
+        view.catalog->bubbles(), view.scenarioRow, slots[slotRow].objectIndex,
+        snapshot.plan.stateRow, link.effectiveRegion);
+    if (selected.ambiguous) return SceneStatus::ambiguousTarget;
+    occurrenceRow = selected.row;
     return occurrenceRow == sdk::format::kAbsentIndex ? SceneStatus::targetUnavailable
                                                       : SceneStatus::ready;
 }

+ 5 - 7
Sunrise/src/server/activity/activity_sdk_squad_runtime.cpp

@@ -63,11 +63,6 @@ struct PreparedSquad final {
     return Status::invalidView;
 }
 
-/** @return True only for the two numeric modes accepted by the proved type-1 schema. */
-[[nodiscard]] bool valid_mode(squad_auth::Mode mode) noexcept {
-    return mode == squad_auth::Mode::mode0 || mode == squad_auth::Mode::mode2;
-}
-
 /** Validates the exact authored member vector and every requested safe bound. */
 [[nodiscard]] Status member_status(const sdk::Catalog& catalog,
                                    const format::Squad& squad,
@@ -101,8 +96,11 @@ struct PreparedSquad final {
     const auto members = sdk::squad_members(catalog, squad);
     const auto actors = catalog.actor_classes();
     bool found = false;
+    // A named type-2 member needs its parent's authored profile but zero loose actors.
+    const bool loose = std::any_of(requestedCounts.begin(), requestedCounts.end(),
+                                    [](auto count) { return count > 0; });
     for (std::size_t index = 0; index < members.size(); ++index) {
-        if (requestedCounts[index] <= 0) {
+        if (loose && requestedCounts[index] <= 0) {
             continue;
         }
         const format::SquadMember& member = members[index];
@@ -307,7 +305,7 @@ struct PreparedSquad final {
     if (liveStatus != Status::ready) {
         return liveStatus;
     }
-    if (!valid_mode(mode)) {
+    if (!squad_auth::valid_mode(mode)) {
         return Status::invalidMode;
     }
 

+ 788 - 5
Sunrise/src/server/activity/host_runtime.cpp

@@ -1,8 +1,5 @@
-/**
- * The Activity Host instance table, event ring, reducer queue and Auth-state lane.
- * Helpers here need the Host runtime lock; the entry points take it themselves.
- */
-
+#include "../../middleware/bap/activity_message/sense_observation_packet.h"
+#include <algorithm>
 #include <array>
 #include <cstddef>
 #include <cstdio>
@@ -192,6 +189,524 @@ void cancel_output(Instance& instance, std::uint64_t now) noexcept {
     instance.view.lastEventSequence = g_sequence;
 }
 
+/** @return True when one decoded object has the exact retained observation key. */
+[[nodiscard]] bool same_sense_key(
+    const SenseObservationKey& key,
+    const middleware::bap::activity_message::sense_update::DecodedObject& object) noexcept {
+    return key.registryKey == object.registryKey && key.objectTag == object.objectTag
+           && key.slotType == object.slotType && key.slotIndex == object.slotIndex
+           && key.senseSchema == object.senseSchema && key.schemaRow == object.schemaRow;
+}
+
+/** @return True when the packet carries this key at or after the selected object. */
+[[nodiscard]] bool
+packet_has_sense_key(const middleware::bap::activity_message::sense_update::DecodedPacket& packet,
+                     const SenseObservationKey& key,
+                     std::size_t first) noexcept {
+    for (std::size_t index = first; index < packet.objectCount; ++index) {
+        if (packet.objects[index].status == middleware::bap::activity_message::sense_update::ObjectStatus::decoded
+            && packet.objects[index].hasGeneration && same_sense_key(key, packet.objects[index])) {
+            return true;
+        }
+    }
+    return false;
+}
+
+/** @return True when the envelope closed and each decoded object's storage is bounded. */
+[[nodiscard]] bool valid_sense_observation_input(const SenseInput& input) noexcept {
+    namespace sense = middleware::bap::activity_message::sense_update;
+    const auto& packet = input.decoded;
+    return input.sourceGeneration != 0 && input.clientMessageSequence != 0
+        && input.verdict == state::activity::receipts::Verdict::framed
+        && input.decodeStatus == packet.status && sense::observation_packet(packet)
+        && input.groupsSeen == packet.groupsSeen && input.groupsDecoded == packet.groupsDecoded
+        && input.groupsSkipped == packet.groupsSkipped && input.objectsSeen == packet.objectsSeen
+        && input.objectsDecoded == packet.objectsDecoded;
+}
+
+/** Retains one accepted client input independently from panel and output events. */
+void append_mission_input(
+    const Event& event,
+    const middleware::bap::activity_message::sense_update::DecodedPacket* sense,
+    const ClientMessageSnapshot* clientMessage = nullptr) noexcept {
+    if (event.missionSequence == 0) {
+        return;
+    }
+    g_missionInputSequence = next_nonzero(g_missionInputSequence);
+    MissionInputRecord record{};
+    record.view.event = event;
+    record.view.sequence = g_missionInputSequence;
+    if (record.view.event.sequence == 0) {
+        record.view.event.sequence = g_missionInputSequence;
+    }
+    if (sense != nullptr) {
+        record.sense = *sense;
+        record.hasSense = true;
+    }
+    if (clientMessage != nullptr) {
+        record.clientMessage = *clientMessage;
+        record.hasClientMessage = true;
+    }
+    // The slot was reserved when the durable sequence was issued, so this never reallocates.
+    g_missionInputs.push_back(std::move(record));
+}
+
+/** Mixes one fixed-width value into the local scene change guard. */
+void mix_scene_fingerprint(std::uint64_t& fingerprint, std::uint64_t value) noexcept {
+    constexpr std::uint64_t kPrime = 1'099'511'628'211ULL;
+    for (std::uint8_t shift = 0; shift < 64; shift += 8) {
+        fingerprint ^= (value >> shift) & 0xFFU;
+        fingerprint *= kPrime;
+    }
+}
+
+/** @return A run-local change guard over one decoded object and every retained value. */
+[[nodiscard]] std::uint64_t
+scene_fingerprint(const middleware::bap::activity_message::sense_update::DecodedObject& object,
+                  std::span<const middleware::bap::activity_message::sense_update::DecodedValue>
+                      values) noexcept {
+    constexpr std::uint64_t kOffset = 14'695'981'039'346'656'037ULL;
+    std::uint64_t fingerprint = kOffset;
+    mix_scene_fingerprint(fingerprint, object.objectRow);
+    mix_scene_fingerprint(fingerprint, object.slotRow);
+    mix_scene_fingerprint(fingerprint, object.schemaRow);
+    mix_scene_fingerprint(fingerprint, object.generationPlusOne);
+    mix_scene_fingerprint(fingerprint, object.deltaBits);
+    mix_scene_fingerprint(fingerprint, object.hasGeneration ? 1U : 0U);
+    mix_scene_fingerprint(fingerprint, values.size());
+    for (const auto& value : values) {
+        mix_scene_fingerprint(fingerprint, value.unsignedValue);
+        mix_scene_fingerprint(fingerprint, static_cast<std::uint64_t>(value.signedValue));
+        mix_scene_fingerprint(fingerprint, std::bit_cast<std::uint32_t>(value.realValue));
+        mix_scene_fingerprint(fingerprint, value.schemaRow);
+        mix_scene_fingerprint(fingerprint, value.fieldRow);
+        mix_scene_fingerprint(fingerprint, value.occurrence);
+        mix_scene_fingerprint(fingerprint, value.bitOffset);
+        mix_scene_fingerprint(fingerprint, value.fieldOrdinal);
+        mix_scene_fingerprint(fingerprint, value.width);
+        mix_scene_fingerprint(fingerprint, static_cast<std::uint8_t>(value.kind));
+        mix_scene_fingerprint(fingerprint, value.present ? 1U : 0U);
+    }
+    return fingerprint;
+}
+
+/** Writes one bounded type-43 diagnostic event. */
+void report_scene_sense(const char* format, ...) noexcept {
+    std::array<char, core::log::kLineCapacity> line{};
+    va_list arguments;
+    va_start(arguments, format);
+    const int written = std::vsnprintf(line.data(), line.size(), format, arguments);
+    va_end(arguments);
+    if (written <= 0) {
+        return;
+    }
+    const auto length = static_cast<std::size_t>(written) < line.size()
+                            ? static_cast<std::size_t>(written)
+                            : line.size() - 1;
+    core::log::write(core::log::Channel::server, core::log::Level::debug, {line.data(), length});
+}
+
+/** @return The trace row for one exact ClientRef, or null when it has not been seen. */
+[[nodiscard]] SceneSenseTraceRecord* find_scene_trace_record(
+    SceneSenseTrace& trace,
+    const middleware::bap::activity_message::sense_update::DecodedObject& object) noexcept {
+    for (SceneSenseTraceRecord& record : trace.records) {
+        if (record.occupied && same_sense_key(record.key, object)) {
+            return &record;
+        }
+    }
+    return nullptr;
+}
+
+/** @return One unused trace row, or null when the bounded table is full. */
+[[nodiscard]] SceneSenseTraceRecord* reserve_scene_trace_record(SceneSenseTrace& trace) noexcept {
+    for (SceneSenseTraceRecord& record : trace.records) {
+        if (!record.occupied) {
+            return &record;
+        }
+    }
+    return nullptr;
+}
+
+/** Reports one typed scalar with its exact reflected rows and wire position. */
+void report_scene_value(
+    const Instance& instance,
+    const SenseInput& input,
+    const middleware::bap::activity_message::sense_update::DecodedObject& object,
+    const middleware::bap::activity_message::sense_update::DecodedValue& value,
+    std::size_t valueIndex) noexcept {
+    using ValueKind = middleware::bap::activity_message::sense_update::ValueKind;
+    constexpr const char* kPrefix =
+        "ev=scene_sense kind=value activity=%d session=0x%llX binding_rev=%llu "
+        "source_gen=%llu msg_seq=%llu key=0x%08X tag=0x%08X type=%u index=%u "
+        "sense_schema=0x%08X gen_plus_one=%u value_index=%zu schema_row=%u "
+        "field_row=%u ordinal=%u occurrence=%u bit=%u width=%u present=%u";
+    std::array<char, core::log::kLineCapacity> prefix{};
+    const int written =
+        std::snprintf(prefix.data(),
+                      prefix.size(),
+                      kPrefix,
+                      static_cast<int>(instance.view.binding.destination.activityIndex),
+                      static_cast<unsigned long long>(instance.view.binding.sessionId),
+                      static_cast<unsigned long long>(instance.view.binding.createdRevision),
+                      static_cast<unsigned long long>(input.sourceGeneration),
+                      static_cast<unsigned long long>(input.clientMessageSequence),
+                      object.registryKey,
+                      object.objectTag,
+                      static_cast<unsigned>(object.slotType),
+                      static_cast<unsigned>(object.slotIndex),
+                      object.senseSchema,
+                      object.generationPlusOne,
+                      valueIndex,
+                      value.schemaRow,
+                      value.fieldRow,
+                      static_cast<unsigned>(value.fieldOrdinal),
+                      value.occurrence,
+                      value.bitOffset,
+                      static_cast<unsigned>(value.width),
+                      value.present ? 1U : 0U);
+    if (written <= 0 || static_cast<std::size_t>(written) >= prefix.size()) {
+        return;
+    }
+    if (!value.present) {
+        report_scene_sense("%s domain=%s value=absent",
+                           prefix.data(),
+                           value.kind == ValueKind::unsignedInteger ? "uint"
+                           : value.kind == ValueKind::signedInteger ? "int"
+                           : value.kind == ValueKind::boolean       ? "bool"
+                                                                    : "real32");
+        return;
+    }
+    switch (value.kind) {
+    case ValueKind::unsignedInteger:
+        report_scene_sense("%s domain=uint value=0x%llX",
+                           prefix.data(),
+                           static_cast<unsigned long long>(value.unsignedValue));
+        break;
+    case ValueKind::signedInteger:
+        report_scene_sense("%s domain=int raw=0x%llX value=%lld",
+                           prefix.data(),
+                           static_cast<unsigned long long>(value.unsignedValue),
+                           static_cast<long long>(value.signedValue));
+        break;
+    case ValueKind::boolean:
+        report_scene_sense("%s domain=bool raw=0x%llX value=%u",
+                           prefix.data(),
+                           static_cast<unsigned long long>(value.unsignedValue),
+                           value.unsignedValue != 0 ? 1U : 0U);
+        break;
+    case ValueKind::real32:
+        report_scene_sense("%s domain=real32 raw=0x%llX value_bits=0x%08X value=%.9g",
+                           prefix.data(),
+                           static_cast<unsigned long long>(value.unsignedValue),
+                           std::bit_cast<std::uint32_t>(value.realValue),
+                           static_cast<double>(value.realValue));
+        break;
+    }
+}
+
+/** Reports complete changed type-43 objects without changing retained activity state. */
+void trace_scene_sense(Instance& instance, const SenseInput& input) noexcept {
+    namespace sense = middleware::bap::activity_message::sense_update;
+    if (!core::log::accepts(core::log::Channel::server, core::log::Level::debug)) {
+        return;
+    }
+    SceneSenseTrace& trace = instance.sceneSenseTrace;
+    if (trace.sourceGeneration != input.sourceGeneration) {
+        trace = {};
+        trace.sourceGeneration = input.sourceGeneration;
+    }
+    const sense::DecodedPacket& packet = input.decoded;
+    bool hasScene = false;
+    const std::size_t retainedObjectCount = (std::min)(packet.objectCount, packet.objects.size());
+    for (std::size_t index = 0; index < retainedObjectCount; ++index) {
+        if (packet.objects[index].slotType
+            == static_cast<std::uint8_t>(state::activity_sdk::format::kAuthoredSceneSlotType)) {
+            hasScene = true;
+            break;
+        }
+    }
+    if (!valid_sense_observation_input(input)) {
+        if (!trace.incompleteReported && (hasScene || packet.objectsTruncated)) {
+            trace.incompleteReported = true;
+            report_scene_sense(
+                "ev=scene_sense kind=packet result=skip reason=incomplete activity=%d "
+                "session=0x%llX binding_rev=%llu source_gen=%llu msg_seq=%llu "
+                "status=%s objects_seen=%u objects_kept=%zu values_kept=%zu "
+                "objects_truncated=%u values_truncated=%u",
+                static_cast<int>(instance.view.binding.destination.activityIndex),
+                static_cast<unsigned long long>(instance.view.binding.sessionId),
+                static_cast<unsigned long long>(instance.view.binding.createdRevision),
+                static_cast<unsigned long long>(input.sourceGeneration),
+                static_cast<unsigned long long>(input.clientMessageSequence),
+                sense::decode_status_name(packet.status),
+                packet.objectsSeen,
+                packet.objectCount,
+                packet.valueCount,
+                packet.objectsTruncated ? 1U : 0U,
+                packet.valuesTruncated ? 1U : 0U);
+        }
+        return;
+    }
+    for (std::size_t index = 0; index < packet.objectCount; ++index) {
+        const sense::DecodedObject& object = packet.objects[index];
+        if (object.status != sense::ObjectStatus::decoded || !object.hasGeneration || object.slotType
+            != static_cast<std::uint8_t>(state::activity_sdk::format::kAuthoredSceneSlotType)
+            || packet_has_sense_key(packet,
+                                    {object.registryKey,
+                                     object.objectTag,
+                                     object.senseSchema,
+                                     object.schemaRow,
+                                     object.slotIndex,
+                                     object.slotType},
+                                    index + 1)) {
+            continue;
+        }
+        const std::span values(packet.values.data() + object.firstValue, object.valueCount);
+        const std::uint64_t fingerprint = scene_fingerprint(object, values);
+        SceneSenseTraceRecord* record = find_scene_trace_record(trace, object);
+        const bool known = record != nullptr;
+        if (known && record->fingerprint == fingerprint
+            && record->generationPlusOne == object.generationPlusOne
+            && record->valueCount == object.valueCount
+            && record->hasGeneration == object.hasGeneration) {
+            continue;
+        }
+        if (record == nullptr) {
+            record = reserve_scene_trace_record(trace);
+        }
+        if (record == nullptr) {
+            if (!trace.capacityReported) {
+                trace.capacityReported = true;
+                report_scene_sense(
+                    "ev=scene_sense kind=packet result=skip reason=trace_capacity activity=%d "
+                    "session=0x%llX binding_rev=%llu source_gen=%llu capacity=%zu",
+                    static_cast<int>(instance.view.binding.destination.activityIndex),
+                    static_cast<unsigned long long>(instance.view.binding.sessionId),
+                    static_cast<unsigned long long>(instance.view.binding.createdRevision),
+                    static_cast<unsigned long long>(input.sourceGeneration),
+                    trace.records.size());
+            }
+            continue;
+        }
+        record->key = {object.registryKey,
+                       object.objectTag,
+                       object.senseSchema,
+                       object.schemaRow,
+                       object.slotIndex,
+                       object.slotType};
+        record->fingerprint = fingerprint;
+        record->generationPlusOne = object.generationPlusOne;
+        record->valueCount = object.valueCount;
+        record->hasGeneration = object.hasGeneration;
+        record->occupied = true;
+        report_scene_sense("ev=scene_sense kind=object change=%s activity=%d session=0x%llX "
+                           "binding_rev=%llu source_gen=%llu msg_seq=%llu key=0x%08X tag=0x%08X "
+                           "object_row=%u type=%u index=%u slot_row=%u sense_schema=0x%08X "
+                           "schema_row=%u gen_plus_one=%u has_gen=%u delta_bits=%u values=%u",
+                           known ? "update" : "new",
+                           static_cast<int>(instance.view.binding.destination.activityIndex),
+                           static_cast<unsigned long long>(instance.view.binding.sessionId),
+                           static_cast<unsigned long long>(instance.view.binding.createdRevision),
+                           static_cast<unsigned long long>(input.sourceGeneration),
+                           static_cast<unsigned long long>(input.clientMessageSequence),
+                           object.registryKey,
+                           object.objectTag,
+                           object.objectRow,
+                           static_cast<unsigned>(object.slotType),
+                           static_cast<unsigned>(object.slotIndex),
+                           object.slotRow,
+                           object.senseSchema,
+                           object.schemaRow,
+                           object.generationPlusOne,
+                           object.hasGeneration ? 1U : 0U,
+                           object.deltaBits,
+                           object.valueCount);
+        for (std::size_t valueIndex = 0; valueIndex < values.size(); ++valueIndex) {
+            report_scene_value(instance, input, object, values[valueIndex], valueIndex);
+        }
+    }
+}
+
+/** Appends one observation and its complete owned value range. */
+[[nodiscard]] bool append_sense_observation(
+    SenseObservationSnapshot& output,
+    SenseObservation observation,
+    std::span<const middleware::bap::activity_message::sense_update::DecodedValue>
+        values) noexcept {
+    if (output.observationCount == output.observations.size()
+        || values.size() > output.values.size() - output.valueCount) {
+        return false;
+    }
+    observation.firstValue = static_cast<std::uint32_t>(output.valueCount);
+    observation.valueCount = static_cast<std::uint32_t>(values.size());
+    output.observations[output.observationCount++] = observation;
+    std::copy(values.begin(), values.end(), output.values.begin() + output.valueCount);
+    output.valueCount += values.size();
+    return true;
+}
+
+/** Replaces only fully decoded keys in an accepted packet; omitted or unsupported keys stay retained. */
+[[nodiscard]] bool
+retain_sense_observations(Instance& instance, const SenseInput& input, std::uint64_t now) noexcept {
+    namespace sense = middleware::bap::activity_message::sense_update;
+    if (!valid_sense_observation_input(input)) {
+        return false;
+    }
+    const sense::DecodedPacket& packet = input.decoded;
+    SenseObservationSnapshot next{};
+    next.revision = next_nonzero(instance.senseObservations.revision);
+    next.sourceGeneration = input.sourceGeneration;
+    for (std::size_t index = 0; index < packet.objectCount; ++index) {
+        const sense::DecodedObject& object = packet.objects[index];
+        if (object.status != sense::ObjectStatus::decoded || !object.hasGeneration) continue;
+        const SenseObservationKey key{object.registryKey,
+                                      object.objectTag,
+                                      object.senseSchema,
+                                      object.schemaRow,
+                                      object.slotIndex,
+                                      object.slotType};
+        if (packet_has_sense_key(packet, key, index + 1)) {
+            continue;
+        }
+        SenseObservation observation{};
+        observation.binding = input.binding;
+        observation.key = key;
+        observation.sequence = next.revision;
+        observation.tick = now;
+        observation.sourceGeneration = input.sourceGeneration;
+        observation.clientMessageSequence = input.clientMessageSequence;
+        observation.generationPlusOne = object.generationPlusOne;
+        observation.hasGeneration = object.hasGeneration;
+        const std::span values(packet.values.data() + object.firstValue, object.valueCount);
+        if (!append_sense_observation(next, observation, values)) {
+            return false;
+        }
+    }
+    if (instance.senseObservations.sourceGeneration == input.sourceGeneration) {
+        const SenseObservationSnapshot& current = instance.senseObservations;
+        for (std::size_t index = 0; index < current.observationCount; ++index) {
+            const SenseObservation& observation = current.observations[index];
+            if (packet_has_sense_key(packet, observation.key, 0)
+                || observation.firstValue > current.valueCount
+                || observation.valueCount > current.valueCount - observation.firstValue) {
+                continue;
+            }
+            const std::span values(current.values.data() + observation.firstValue,
+                                   observation.valueCount);
+            static_cast<void>(append_sense_observation(next, observation, values));
+        }
+    }
+    instance.senseObservations = next;
+    instance.view.senseObservationCount = static_cast<std::uint32_t>(next.observationCount);
+    instance.view.senseObservationValueCount = static_cast<std::uint32_t>(next.valueCount);
+    instance.view.senseObservationRevision = next.revision;
+    instance.view.senseObservationSourceGeneration = next.sourceGeneration;
+    return true;
+}
+
+/** Applies one copied msg-6 decode summary. */
+void apply_sense(const SenseInput& input, std::uint64_t now) noexcept {
+    Instance* const instance = find_instance(input.binding);
+    if (instance == nullptr || !instance->view.active) {
+        ++g_droppedIngress;
+        return;
+    }
+    touch(*instance);
+    ++instance->view.senseCount;
+    trace_scene_sense(*instance, input);
+    static_cast<void>(retain_sense_observations(*instance, input, now));
+    Event event{};
+    event.binding = input.binding;
+    event.tick = now;
+    event.kind = EventKind::senseUpdate;
+    event.epochFirst = input.epochFirst;
+    event.epochSecond = input.epochSecond;
+    event.payloadBytes = input.payloadBytes;
+    event.peerHeardMask = input.peerHeardMask;
+    event.tailBits = input.tailBits;
+    event.consumedBits = input.consumedBits;
+    event.firstGroupBits = input.firstGroupBits;
+    event.firstRegistryKey = input.firstRegistryKey;
+    event.groupsSeen = input.groupsSeen;
+    event.groupsDecoded = input.groupsDecoded;
+    event.groupsSkipped = input.groupsSkipped;
+    event.objectsSeen = input.objectsSeen;
+    event.objectsDecoded = input.objectsDecoded;
+    event.firstSlotIndex = input.firstSlotIndex;
+    event.firstSlotType = input.firstSlotType;
+    // The event names one slot only: the first decoded ClientRef of the packet.
+    if (input.decoded.objectCount != 0) {
+        event.slotObjectTag = input.decoded.objects.front().objectTag;
+        event.slotSenseSchema = input.decoded.objects.front().senseSchema;
+    }
+    event.senseDecodeStatus = input.decodeStatus;
+    event.senseSnapshotRetained = valid_sense_observation_input(input);
+    event.hasFirstObject = input.hasFirstObject;
+    event.stateRevision = instance->view.stateRevision;
+    event.sourceGeneration = input.sourceGeneration;
+    event.clientMessageSequence = input.clientMessageSequence;
+    event.lifetimeState = instance->view.lifetimeState;
+    event.verdict = input.verdict;
+    append_event(event);
+    append_mission_input(event, event.senseSnapshotRetained ? &input.decoded : nullptr);
+    instance->view.lastEventSequence = g_sequence;
+}
+
+/** Retains one outer-valid client incident for inspection and parsed-field replay. */
+void apply_incident(const IncidentInput& input, std::uint64_t now) noexcept {
+    Instance* const instance = find_instance(input.binding);
+    if (instance == nullptr || !instance->view.active) {
+        ++g_droppedIngress;
+        ++g_droppedIncidents;
+        return;
+    }
+    touch(*instance);
+    ++instance->view.incidentsReceived;
+    Event event{};
+    event.binding = input.binding;
+    event.tick = now;
+    event.kind = EventKind::incidentReceived;
+    event.sourceGeneration = input.sourceGeneration;
+    event.clientMessageSequence = input.clientMessageSequence;
+    event.payloadBytes = input.payloadBytes;
+    fill_incident_event(event, input.incident, 0);
+    event.hasPlayerTrigger = input.hasPlayerTrigger;
+    if (input.hasPlayerTrigger) {
+        event.playerTriggerRegistryKey = input.playerTrigger.registryKey;
+        event.playerTriggerSlotType = input.playerTrigger.slotType;
+        event.playerTriggerSlotIndex = input.playerTrigger.slotIndex;
+        event.playerTriggerResolvedObjectId = input.playerTrigger.resolvedObjectId;
+    }
+    event.hasCinematic = input.hasCinematic;
+    if (input.hasCinematic) {
+        event.cinematicRegistryKey = input.cinematic.registryKey;
+        event.cinematicSlotType = input.cinematic.slotType;
+        event.cinematicSlotIndex = input.cinematic.slotIndex;
+        event.cinematicRuntimeObjectId = input.cinematic.runtimeObjectId;
+        event.cinematicEventValue = input.cinematic.eventValue;
+        event.cinematicSignal = input.cinematicSignal;
+    }
+    append_event(event);
+    append_mission_input(event, nullptr);
+    instance->view.lastEventSequence = g_sequence;
+
+    IncidentRecord* const record = reserve_incident_record();
+    if (record == nullptr) {
+        ++g_droppedIncidents;
+        return;
+    }
+    *record = {};
+    record->binding = input.binding;
+    record->incident = input.incident;
+    record->sequence = g_sequence;
+    record->tick = now;
+    record->lastSourceGeneration = input.sourceGeneration;
+    record->clientMessageSequence = input.clientMessageSequence;
+    record->payloadBytes = input.payloadBytes;
+    record->status = IncidentStatus::received;
+}
+
 /** Retains one safe committed client State change in Host and ordered mission histories. */
 void apply_client_state_change(const ClientStateChangeInput& input, std::uint64_t now) noexcept {
     Instance* const instance = find_instance(input.binding);
@@ -505,6 +1020,274 @@ void read_events_after(EventCursor after, EventRead& output) noexcept {
     ReleaseSRWLockShared(&g_lock);
 }
 
+/** Reads the current accepted-mission-input position without replaying retained history. */
+MissionInputCursor current_mission_input_cursor() noexcept {
+    AcquireSRWLockShared(&g_lock);
+    const MissionInputCursor cursor{g_eventGeneration, g_missionInputSequence};
+    ReleaseSRWLockShared(&g_lock);
+    return cursor;
+}
+
+/** @return True when durable mission State still owes this retained accepted row. */
+[[nodiscard]] bool mission_input_owed(const MissionInputRecord& record) noexcept {
+    state::activity::mission::InputSequenceSnapshot cursors{};
+    // A faulted binding owes nothing. Its program is skipped, so it never commits, and its rows
+    // would otherwise be retained for the life of the process.
+    if (!state::activity::mission::input_sequence_snapshot(record.view.event.binding, cursors)
+        || cursors.faulted) {
+        return false;
+    }
+    const std::uint64_t sequence = record.view.event.missionSequence;
+    return sequence > cursors.committed && sequence <= cursors.issued;
+}
+
+/** Drops every retained row that durable mission State no longer owes. */
+void retire_settled_mission_inputs() noexcept {
+    static_cast<void>(std::erase_if(g_missionInputs, [](const MissionInputRecord& record) noexcept {
+        return !mission_input_owed(record);
+    }));
+}
+
+/** Copies accepted client mission inputs after one cursor. */
+void read_mission_inputs_after(MissionInputCursor after, MissionInputRead& output) noexcept {
+    output = {};
+    AcquireSRWLockExclusive(&g_lock);
+    output.reset = after.generation != g_eventGeneration;
+    const std::uint64_t afterSequence = output.reset ? 0 : after.sequence;
+    retire_settled_mission_inputs();
+    output.cursor = {g_eventGeneration, afterSequence};
+    if (!g_missionInputs.empty()) {
+        const std::uint64_t retainedPredecessor = g_missionInputs.front().view.sequence - 1;
+        if (afterSequence < retainedPredecessor) {
+            output.gap = true;
+            output.missed = retainedPredecessor - afterSequence;
+        }
+        for (const MissionInputRecord& record : g_missionInputs) {
+            if (record.view.sequence <= afterSequence) {
+                continue;
+            }
+            if (output.count == output.events.size()) {
+                break;
+            }
+            output.events[output.count++] = record.view;
+        }
+        if (output.count != 0) {
+            output.cursor.sequence = output.events[output.count - 1].sequence;
+        }
+    } else if (afterSequence < g_missionInputSequence) {
+        output.gap = true;
+        output.missed = g_missionInputSequence - afterSequence;
+        output.cursor.sequence = g_missionInputSequence;
+    }
+    ReleaseSRWLockExclusive(&g_lock);
+}
+
+/** Copies the exact Sense values owned by one retained accepted-input row. */
+bool mission_input_sense_snapshot(std::uint64_t sequence,
+                                  SenseObservationSnapshot& output) noexcept {
+    namespace sense = middleware::bap::activity_message::sense_update;
+    output = {};
+    if (sequence == 0) {
+        return false;
+    }
+    AcquireSRWLockShared(&g_lock);
+    const MissionInputRecord* selected = nullptr;
+    for (std::size_t offset = g_missionInputs.size(); offset != 0; --offset) {
+        const MissionInputRecord& candidate = g_missionInputs[offset - 1];
+        if (candidate.view.sequence == sequence) {
+            selected = &candidate;
+            break;
+        }
+    }
+    bool copied = selected != nullptr && selected->hasSense
+                  && selected->view.event.kind == EventKind::senseUpdate;
+    if (copied) {
+        const Event& event = selected->view.event;
+        const sense::DecodedPacket& packet = selected->sense;
+        copied = sense::observation_packet(packet);
+        if (copied) {
+            output.revision = event.sequence;
+            output.sourceGeneration = event.sourceGeneration;
+        }
+        for (std::size_t index = 0; copied && index < packet.objectCount; ++index) {
+            const sense::DecodedObject& object = packet.objects[index];
+            if (object.status != sense::ObjectStatus::decoded || !object.hasGeneration) continue;
+            if (object.firstValue > packet.valueCount
+                || object.valueCount > packet.valueCount - object.firstValue) {
+                copied = false;
+                break;
+            }
+            SenseObservation observation{};
+            observation.binding = event.binding;
+            observation.key = {object.registryKey,
+                               object.objectTag,
+                               object.senseSchema,
+                               object.schemaRow,
+                               object.slotIndex,
+                               object.slotType};
+            observation.sequence = event.sequence;
+            observation.tick = event.tick;
+            observation.sourceGeneration = event.sourceGeneration;
+            observation.clientMessageSequence = event.clientMessageSequence;
+            observation.generationPlusOne = object.generationPlusOne;
+            observation.hasGeneration = object.hasGeneration;
+            copied = append_sense_observation(
+                output, observation, {packet.values.data() + object.firstValue, object.valueCount});
+        }
+    }
+    if (!copied) {
+        output = {};
+    }
+    ReleaseSRWLockShared(&g_lock);
+    return copied;
+}
+
+/** Copies one exact generic client-message snapshot owned by the mission-input feed. */
+bool mission_input_client_message_snapshot(std::uint64_t sequence,
+                                           ClientMessageSnapshot& output) noexcept {
+    output = {};
+    if (sequence == 0) {
+        return false;
+    }
+    AcquireSRWLockShared(&g_lock);
+    const MissionInputRecord* selected = nullptr;
+    for (std::size_t offset = g_missionInputs.size(); offset != 0; --offset) {
+        const MissionInputRecord& candidate = g_missionInputs[offset - 1];
+        if (candidate.view.sequence == sequence) {
+            selected = &candidate;
+            break;
+        }
+    }
+    const bool copied = selected != nullptr && selected->hasClientMessage
+                        && selected->view.event.kind == EventKind::clientMessageReceived;
+    if (copied) {
+        output = selected->clientMessage;
+    }
+    ReleaseSRWLockShared(&g_lock);
+    return copied;
+}
+
+/** Copies the latest complete Sense observations for one exact activity generation. */
+bool snapshot_sense_observations(const state::activity::SessionBinding& binding,
+                                 SenseObservationSnapshot& output) noexcept {
+    output = {};
+    AcquireSRWLockShared(&g_lock);
+    const Instance* const instance = find_instance(binding);
+    const bool found = instance != nullptr;
+    if (found) {
+        output = instance->senseObservations;
+    }
+    ReleaseSRWLockShared(&g_lock);
+    return found;
+}
+
+/** Selects one exact reflected scalar without changing the retained snapshot. */
+SenseScalarStatus select_sense_scalar(const SenseObservationSnapshot& snapshot,
+                                      const state::activity::SessionBinding& binding,
+                                      const SenseScalarIdentity& identity,
+                                      SenseScalarSample& output) noexcept {
+    namespace sense = middleware::bap::activity_message::sense_update;
+    output = {};
+    if (binding.sessionId == state::activity::kAbsentSessionId
+        || binding.createdRevision == state::activity::kInvalidRevision
+        || identity.object.schemaRow == sense::kAbsentRuntimeRow
+        || identity.fieldSchemaRow == sense::kAbsentRuntimeRow
+        || identity.fieldRow == sense::kAbsentRuntimeRow
+        || identity.fieldSchemaRow != identity.object.schemaRow) {
+        return SenseScalarStatus::invalidIdentity;
+    }
+    if (snapshot.sourceGeneration == 0 || snapshot.observationCount > snapshot.observations.size()
+        || snapshot.valueCount > snapshot.values.size()) {
+        return SenseScalarStatus::invalidSnapshot;
+    }
+    const auto sameKey = [&identity](const SenseObservationKey& candidate) noexcept {
+        const SenseObservationKey& expected = identity.object;
+        return candidate.registryKey == expected.registryKey
+               && candidate.objectTag == expected.objectTag
+               && candidate.senseSchema == expected.senseSchema
+               && candidate.schemaRow == expected.schemaRow
+               && candidate.slotIndex == expected.slotIndex
+               && candidate.slotType == expected.slotType;
+    };
+    bool found = false;
+    for (std::size_t observationIndex = 0; observationIndex < snapshot.observationCount;
+         ++observationIndex) {
+        const SenseObservation& observation = snapshot.observations[observationIndex];
+        if (!same_binding(observation.binding, binding) || !sameKey(observation.key)) {
+            continue;
+        }
+        if (observation.sourceGeneration != snapshot.sourceGeneration
+            || observation.firstValue > snapshot.valueCount
+            || observation.valueCount > snapshot.valueCount - observation.firstValue) {
+            output = {};
+            return SenseScalarStatus::invalidSnapshot;
+        }
+        for (std::size_t valueIndex = 0; valueIndex < observation.valueCount; ++valueIndex) {
+            const sense::DecodedValue& value = snapshot.values[observation.firstValue + valueIndex];
+            if (value.schemaRow != identity.fieldSchemaRow || value.fieldRow != identity.fieldRow
+                || value.fieldOrdinal != identity.fieldOrdinal
+                || value.occurrence != identity.occurrence || value.kind != identity.kind) {
+                continue;
+            }
+            if (found) {
+                output = {};
+                return SenseScalarStatus::ambiguous;
+            }
+            output.binding = observation.binding;
+            output.identity = identity;
+            output.value = value;
+            output.observationRevision = observation.sequence;
+            output.tick = observation.tick;
+            output.sourceGeneration = observation.sourceGeneration;
+            output.clientMessageSequence = observation.clientMessageSequence;
+            output.generationPlusOne = observation.generationPlusOne;
+            output.hasGeneration = observation.hasGeneration;
+            found = true;
+        }
+    }
+    return found ? SenseScalarStatus::ready : SenseScalarStatus::notFound;
+}
+
+/** @return True when two selected scalars have the same presence and raw typed value. */
+bool same_sense_scalar_value(const SenseScalarSample& left,
+                             const SenseScalarSample& right) noexcept {
+    if (left.value.kind != right.value.kind || left.value.present != right.value.present) {
+        return false;
+    }
+    if (!left.value.present) {
+        return true;
+    }
+    return left.value.unsignedValue == right.value.unsignedValue
+           && left.value.signedValue == right.value.signedValue
+           && std::bit_cast<std::uint32_t>(left.value.realValue)
+                  == std::bit_cast<std::uint32_t>(right.value.realValue);
+}
+
+/** @return True when both samples name the same ActivityClient and reported object generations. */
+bool same_sense_scalar_generations(const SenseScalarSample& left,
+                                   const SenseScalarSample& right) noexcept {
+    return left.sourceGeneration != 0 && left.sourceGeneration == right.sourceGeneration
+           && left.hasGeneration && right.hasGeneration
+           && left.generationPlusOne == right.generationPlusOne;
+}
+
+/** @return Stable text for one exact scalar-selection result. */
+const char* sense_scalar_status_name(SenseScalarStatus status) noexcept {
+    switch (status) {
+    case SenseScalarStatus::ready:
+        return "ready";
+    case SenseScalarStatus::invalidIdentity:
+        return "invalid_identity";
+    case SenseScalarStatus::invalidSnapshot:
+        return "invalid_snapshot";
+    case SenseScalarStatus::notFound:
+        return "not_found";
+    case SenseScalarStatus::ambiguous:
+        return "ambiguous";
+    }
+    return "invalid_status";
+}
+
 /** Reads the committed Auth state for one exact activity generation. */
 bool auth_state(const state::activity::SessionBinding& binding, AuthState& output) noexcept {
     output = {};

+ 34 - 1
Sunrise/src/server/activity/host_runtime.h

@@ -120,11 +120,21 @@ enum class EventKind : std::uint8_t {
     cinematicStarted = 29,
     /** One schema-0x808087BF msg-19 reports Type-6 start failure or runtime termination. */
     cinematicTerminated = 30,
+    /** Accepted native Ghost interaction progress for one exact type-65 slot. */
+    ghostLinkState = 31,
+    actorPathState = 32,
+    objectInteracted = 33,
+    /** Native cinematic_skip incident for an exact Type-6 source. */
+    cinematicSkipRequested = 34,
+    /** Death-with-Ghost observations for the current private activity's joined party. */
+    fireteamState = 35,
+    objectState = 36,
+    damageState = 37,
 };
 
 /** Kinds are numbered without gaps, so the last one plus one is the count. */
 inline constexpr std::size_t kEventKindCount =
-    static_cast<std::size_t>(EventKind::cinematicTerminated) + 1U;
+    static_cast<std::size_t>(EventKind::damageState) + 1U;
 
 /**
  * Terminal delivery outcome of one script-requested effect.
@@ -443,6 +453,7 @@ struct Event final {
     std::uint64_t peerSessionGeneration{};
     /** Peer client key bound by its join, or zero before one. */
     std::uint64_t peerMemberKey{};
+    std::uint16_t fireteamAlive{}, fireteamDead{}, fireteamUnknown{};
     /** Committed mission phase, for phaseEntered events. */
     std::uint32_t missionPhase{};
     /** Mission phase this commit replaced. */
@@ -457,7 +468,22 @@ struct Event final {
     std::int32_t triggerValue{};
     /** True when the whole watched set is inside the volume. */
     bool triggerAll{};
+    float damageHealth{-1.0F}, damageShield{-1.0F};
+    std::int32_t damageRevision{};
+    std::int32_t objectGeneration{};
+    bool objectPresent{}, objectAlive{}, objectOwnerKnown{}, objectHasOwner{};
+    std::uint64_t objectOwnerKey{};
+    std::int32_t ghostGeneration{};
+    float ghostProgress{};
+    bool ghostActive{};
+    std::int32_t actorGeneration{}, actorPathRevision{}, actorPathState{};
+    std::int32_t actorDeliveryRevision{}, actorDeliveryState{};
+    bool actorDeliveryKnown{};
+    bool actorDead{};
     /** Per-slot member counts the client published, for squadState events. */
+    std::array<float, 24> squadObjectiveCosts{};
+    std::uint32_t squadObjectiveCostMask{};
+    std::uint32_t squadObjectiveRevision{};
     std::array<std::int32_t, kSquadSlotCapacity> squadSlotCounts{};
     /** Alive members the client published. Six bits on the wire, so 0 through 63. */
     std::int32_t squadAliveCount{};
@@ -561,6 +587,7 @@ struct Event final {
     middleware::bap::activity_message::sense_update::DecodeStatus senseDecodeStatus{
         middleware::bap::activity_message::sense_update::DecodeStatus::malformed};
     state::activity::receipts::Verdict verdict{state::activity::receipts::Verdict::absent};
+    bool senseSnapshotRetained{}; // Only bounded, fully owned observations may enter Lua.
     bool hasFirstObject{};
     bool clientStateHasRegion{};
     bool clientStateHasCurrentRegion{};
@@ -568,6 +595,12 @@ struct Event final {
     bool clientStateHasTeleport{};
     bool hasPlayerTrigger{};
     bool hasCinematic{};
+    [[nodiscard]] bool has_sense_observations() const noexcept {
+        using Status = middleware::bap::activity_message::sense_update::DecodeStatus;
+        return kind == EventKind::senseUpdate && senseSnapshotRetained
+            && (senseDecodeStatus == Status::complete || senseDecodeStatus == Status::partial);
+    }
+
 };
 
 /** Position after one event in one reset generation. */

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

@@ -66,6 +66,17 @@ const char* event_name(EventKind kind) noexcept {
         return "player trigger";
     case EventKind::cinematicStarted:
         return "cinematic started";
+    case EventKind::actorPathState:
+        return "actor_path_state";
+    case EventKind::fireteamState: return "fireteam_state";
+    case EventKind::damageState: return "damage_state";
+    case EventKind::objectState: return "object_state";
+    case EventKind::objectInteracted:
+        return "object_interacted";
+    case EventKind::ghostLinkState:
+        return "ghost_link_state";
+    case EventKind::cinematicSkipRequested:
+        return "cinematic skip requested";
     case EventKind::cinematicTerminated:
         return "cinematic terminated";
     }

+ 796 - 0
Sunrise/src/server/activity/host_runtime_scriptable.cpp

@@ -4,7 +4,14 @@
 #include <new>
 
 #include "../../middleware/bap/activity_message/sensor_auth_update.h"
+#include "../../middleware/bap/activity_message/mission_auth_patch.h"
+#include "../../middleware/bap/activity_message/squad_objective_auth.h"
+#include "../../middleware/bap/activity_message/combatant_path_auth.h"
+#include "../../middleware/bap/activity_message/combatant_action_auth.h"
+#include "../../middleware/bap/activity_message/combatant_delivery_auth.h"
+#include "../../middleware/bap/activity_message/combatant_retire_auth.h"
 #include "../../state/activity/mission/runtime.h"
+#include "../../core/logging/log.h"
 #include "../../state/activity/runtime.h"
 #include "../gameplay/squad_entity_retirement.h"
 #include "host_runtime_internal.h"
@@ -226,6 +233,22 @@ valid_state_local_group(const ScriptableTarget& target,
     if (pending.byteCount == 0 || pending.byteCount > pending.body.size()) {
         return false;
     }
+    // Record the exact transported Ember ship body, including path revision and manifest.
+    // Four fixed targets and short bodies keep this diagnostic bounded during flight.
+    if (pending.target.registryKey == 0xF6FFB59EU && pending.target.slotType == 2
+        && (pending.target.slotIndex == 22 || pending.target.slotIndex == 28
+            || pending.target.slotIndex == 30 || pending.target.slotIndex == 32)
+        && pending.byteCount <= 128) {
+        std::array<char,257> hex{};
+        constexpr char digits[] = "0123456789abcdef";
+        for (std::size_t i=0;i<pending.byteCount;++i) {
+            const auto byte=std::to_integer<unsigned>(pending.body[i]);
+            hex[i*2]=digits[byte>>4]; hex[i*2+1]=digits[byte&15];
+        }
+        core::log::writef(core::log::Channel::server,core::log::Level::info,
+            "ev=ember_transport stage=auth_staged slot=%u bits=%u body=%s",
+            static_cast<unsigned>(pending.target.slotIndex),static_cast<unsigned>(pending.bitCount),hex.data());
+    }
     PendingScriptableOverride owned = pending;
     if (owned.expectedActivityClientGeneration == 0) {
         owned.expectedActivityClientGeneration = sourceGeneration;
@@ -524,6 +547,36 @@ void apply_scriptable_control(const ScriptableRequest& request, std::uint64_t no
     } else {
         encoded = false;
     }
+    // These mission APIs emit root-field patches; native overrides are complete Auth
+    // replacements. Keep the last transported state before publishing another command.
+    namespace message = middleware::bap::activity_message;
+    const auto patch = std::span(pending.body).first(written);
+    const bool missionPatch = encoded && (request.kind == ScriptableOverrideKind::squad
+        || (request.kind == ScriptableOverrideKind::sdkAuth
+            && ((request.target.slotType == 1 && request.target.authSchema == squad::kSchema
+                 && message::squad_objective::validate(patch, pending.bitCount))
+                || (request.target.slotType == 2 && request.target.authSchema == 0x80807DA1U
+                    && (message::combatant_path::validate(patch, pending.bitCount)
+                        || message::combatant_delivery::validate(patch, pending.bitCount)
+                        || message::combatant_action::validate(patch, pending.bitCount)
+                        || message::combatant_retire::validate(patch, pending.bitCount))))));
+    if (missionPatch) {
+        std::span<const std::byte> previous{};
+        std::size_t previousBits=0;
+        // A tail cannot contain the same ClientRef as another pending body, so its
+        // predecessor is always in the transported estate, not an uncommitted row.
+        for (const auto& retained : instance->scriptableAuthEstate) {
+            if (same_client_ref(retained.target, request.target)) {
+                previous=std::span(retained.body).first(retained.byteCount);
+                previousBits=retained.bitCount;
+                break;
+            }
+        }
+        std::size_t composedBits{};
+        encoded=message::mission_auth_patch::compose(request.target.authSchema,
+            previous,previousBits,patch,pending.bitCount,pending.body,written,composedBits);
+        if (encoded) pending.bitCount=static_cast<std::uint16_t>(composedBits);
+    }
     if (!encoded || written > (std::numeric_limits<std::uint16_t>::max)()) {
         ++g_refusedControls;
     } else {
@@ -661,4 +714,747 @@ ScriptableWithdrawStatus withdraw_scriptable_output(const state::activity::Sessi
     return ScriptableWithdrawStatus::absent;
 }
 
+/** Queues one generation-bound type-23 update for an exact package-derived ClientRef. */
+bool request_type23_override(const state::activity::SessionBinding& binding,
+                             const ScriptableTarget& target,
+                             auth::Type23Channel channel,
+                             float value,
+                             bool snap,
+                             std::uint64_t expectedActivityClientGeneration,
+                             const ScriptableOutputReservation* reservation) noexcept {
+    const auto channelIndex = static_cast<std::size_t>(channel);
+    if (target.slotType != auth::kType23SlotType || target.authSchema != auth::kType23Schema
+        || channelIndex >= auth::kType23ChannelCount || expectedActivityClientGeneration == 0) {
+        return false;
+    }
+    ScriptableRequest request{};
+    request.binding = binding;
+    request.target = target;
+    request.channel = channel;
+    request.value = value;
+    request.expectedActivityClientGeneration = expectedActivityClientGeneration;
+    request.kind = ScriptableOverrideKind::type23;
+    request.snap = snap;
+    return enqueue_request(request, reservation);
+}
+
+/** Queues one package-owned type-4 entry transition. */
+bool request_state_local_type4_override(
+    const state::activity::SessionBinding& binding,
+    const ScriptableTarget& target,
+    const state::build_data::scenarios::RosterGroup& stateLocalRosterGroup,
+    std::int32_t entryIndex,
+    bool active,
+    std::uint64_t expectedActivityClientGeneration,
+    const ScriptableOutputReservation* reservation,
+    const ScriptableOutputReservation* burstHead) noexcept {
+    if (target.slotType != auth::kType4SlotType || target.authSchema != auth::kType4Schema
+        || !valid_state_local_group(target, stateLocalRosterGroup) || entryIndex < 0
+        || expectedActivityClientGeneration == 0) {
+        return false;
+    }
+    ScriptableRequest request{};
+    request.binding = binding;
+    request.target = target;
+    request.stateLocalRosterGroup = stateLocalRosterGroup;
+    request.entryIndex = entryIndex;
+    request.active = active;
+    request.expectedActivityClientGeneration = expectedActivityClientGeneration;
+    request.kind = ScriptableOverrideKind::object;
+    if (burstHead != nullptr) {
+        // Every body in one burst answers under the revision the head reserved for all of them.
+        request.burstMember = true;
+        request.expectedRevision = burstHead->revision;
+        request.expectedIntentSequence = burstHead->intentSequence;
+    }
+    return enqueue_request(request, burstHead != nullptr ? nullptr : reservation);
+}
+
+/** Queues one retained named actor-channel write. */
+bool request_state_local_type2_channel_override(
+    const state::activity::SessionBinding& binding,
+    const ScriptableTarget& target,
+    const state::build_data::scenarios::RosterGroup& stateLocalRosterGroup,
+    std::uint32_t channelHash,
+    float value,
+    std::uint64_t expectedActivityClientGeneration,
+    const ScriptableOutputReservation* reservation) noexcept {
+    if (target.slotType != auth::kType2SlotType || target.authSchema != auth::kType2Schema
+        || !valid_state_local_group(target, stateLocalRosterGroup) || !std::isfinite(value)
+        || expectedActivityClientGeneration == 0) {
+        return false;
+    }
+    ScriptableRequest request{};
+    request.binding = binding;
+    request.target = target;
+    request.stateLocalRosterGroup = stateLocalRosterGroup;
+    request.channelHash = channelHash;
+    request.value = value;
+    request.expectedActivityClientGeneration = expectedActivityClientGeneration;
+    request.kind = ScriptableOverrideKind::combatantChannel;
+    return enqueue_request(request, reservation);
+}
+
+/** Queues squad-member binding for one exact generated type-2 combatant. */
+bool request_state_local_type2_squad_binding(
+    const state::activity::SessionBinding& binding,
+    const ScriptableTarget& target,
+    const state::build_data::scenarios::RosterGroup& stateLocalRosterGroup,
+    std::uint64_t expectedActivityClientGeneration,
+    const ScriptableOutputReservation* reservation) noexcept {
+    if (target.slotType != auth::kType2SlotType || target.authSchema != auth::kType2Schema
+        || !valid_state_local_group(target, stateLocalRosterGroup)
+        || expectedActivityClientGeneration == 0) {
+        return false;
+    }
+    ScriptableRequest request{};
+    request.binding = binding;
+    request.target = target;
+    request.stateLocalRosterGroup = stateLocalRosterGroup;
+    request.expectedActivityClientGeneration = expectedActivityClientGeneration;
+    request.kind = ScriptableOverrideKind::combatantBinding;
+    return enqueue_request(request, reservation);
+}
+
+/** Queues one generation-bound type-23 update from an exact generated roster group. */
+bool request_state_local_type23_override(
+    const state::activity::SessionBinding& binding,
+    const ScriptableTarget& target,
+    const state::build_data::scenarios::RosterGroup& stateLocalRosterGroup,
+    auth::Type23Channel channel,
+    float value,
+    bool snap,
+    std::uint64_t expectedActivityClientGeneration,
+    const ScriptableOutputReservation* reservation) noexcept {
+    const auto channelIndex = static_cast<std::size_t>(channel);
+    if (target.slotType != auth::kType23SlotType || target.authSchema != auth::kType23Schema
+        || !valid_state_local_group(target, stateLocalRosterGroup)
+        || channelIndex >= auth::kType23ChannelCount || expectedActivityClientGeneration == 0) {
+        return false;
+    }
+    ScriptableRequest request{};
+    request.binding = binding;
+    request.target = target;
+    request.stateLocalRosterGroup = stateLocalRosterGroup;
+    request.expectedActivityClientGeneration = expectedActivityClientGeneration;
+    request.channel = channel;
+    request.value = value;
+    request.kind = ScriptableOverrideKind::type23;
+    request.snap = snap;
+    return enqueue_request(request, reservation);
+}
+
+/** Queues one type-31 pulse for an exact package-derived ClientRef. */
+bool request_type31_override(const state::activity::SessionBinding& binding,
+                             const ScriptableTarget& target,
+                             const ScriptableOutputReservation* reservation) noexcept {
+    if (target.slotType != auth::kType31SlotType || target.authSchema != auth::kType31Schema) {
+        return false;
+    }
+    ScriptableRequest request{};
+    request.binding = binding;
+    request.target = target;
+    request.kind = ScriptableOverrideKind::type31;
+    return enqueue_request(request, reservation);
+}
+
+/** Queues one generation-bound type-31 pulse from an exact generated roster group. */
+bool request_state_local_type31_override(
+    const state::activity::SessionBinding& binding,
+    const ScriptableTarget& target,
+    const state::build_data::scenarios::RosterGroup& stateLocalRosterGroup,
+    std::uint64_t expectedActivityClientGeneration,
+    const ScriptableOutputReservation* reservation) noexcept {
+    if (target.slotType != auth::kType31SlotType || target.authSchema != auth::kType31Schema
+        || !valid_state_local_group(target, stateLocalRosterGroup)
+        || expectedActivityClientGeneration == 0) {
+        return false;
+    }
+    ScriptableRequest request{};
+    request.binding = binding;
+    request.target = target;
+    request.stateLocalRosterGroup = stateLocalRosterGroup;
+    request.expectedActivityClientGeneration = expectedActivityClientGeneration;
+    request.kind = ScriptableOverrideKind::type31;
+    return enqueue_request(request, reservation);
+}
+
+/** Queues one state-local sequence override, but only for an exact type-5 target. */
+bool request_state_local_sequence_override(
+    const state::activity::SessionBinding& binding,
+    const ScriptableTarget& target,
+    const state::build_data::scenarios::RosterGroup& stateLocalRosterGroup,
+    std::uint64_t expectedActivityClientGeneration,
+    const ScriptableOutputReservation* reservation) noexcept {
+    if (target.slotType != auth::kType5SlotType || target.authSchema != auth::kType5Schema
+        || !valid_state_local_group(target, stateLocalRosterGroup)
+        || expectedActivityClientGeneration == 0) {
+        return false;
+    }
+    ScriptableRequest request{};
+    request.binding = binding;
+    request.target = target;
+    request.stateLocalRosterGroup = stateLocalRosterGroup;
+    request.expectedActivityClientGeneration = expectedActivityClientGeneration;
+    request.kind = ScriptableOverrideKind::sequence;
+    return enqueue_request(request, reservation);
+}
+
+/** Queues one state-local cinematic override, but only for an exact type-5 target. */
+bool request_state_local_cinematic_override(
+    const state::activity::SessionBinding& binding,
+    const ScriptableTarget& target,
+    const state::build_data::scenarios::RosterGroup& stateLocalRosterGroup,
+    bool active,
+    std::uint64_t expectedActivityClientGeneration,
+    const ScriptableOutputReservation* reservation) noexcept {
+    if (target.slotType != auth::kType6SlotType || target.authSchema != auth::kType6Schema
+        || !valid_state_local_group(target, stateLocalRosterGroup)
+        || expectedActivityClientGeneration == 0) {
+        return false;
+    }
+    ScriptableRequest request{};
+    request.binding = binding;
+    request.target = target;
+    request.stateLocalRosterGroup = stateLocalRosterGroup;
+    request.expectedActivityClientGeneration = expectedActivityClientGeneration;
+    request.kind = ScriptableOverrideKind::cinematic;
+    request.active = active;
+    return enqueue_request(request, reservation);
+}
+
+/** Queues one type-42 performance start; the encoder assigns the rising generation. */
+bool request_state_local_performance_override(
+    const state::activity::SessionBinding& binding,
+    const ScriptableTarget& target,
+    const state::build_data::scenarios::RosterGroup& stateLocalRosterGroup,
+    std::uint32_t stateNameHash,
+    std::uint64_t expectedActivityClientGeneration,
+    const ScriptableOutputReservation* reservation) noexcept {
+    if (target.slotType != auth::kType42SlotType || target.authSchema != auth::kType42Schema
+        || !valid_state_local_group(target, stateLocalRosterGroup)
+        || expectedActivityClientGeneration == 0 || stateNameHash == 0) {
+        return false;
+    }
+    ScriptableRequest request{};
+    request.binding = binding;
+    request.target = target;
+    request.stateLocalRosterGroup = stateLocalRosterGroup;
+    request.expectedActivityClientGeneration = expectedActivityClientGeneration;
+    request.kind = ScriptableOverrideKind::performance;
+    request.nameHash = stateNameHash;
+    return enqueue_request(request, reservation);
+}
+
+/** Queues one objective reset from an exact generated roster group. */
+bool request_state_local_objective_reset(
+    const state::activity::SessionBinding& binding,
+    const ScriptableTarget& target,
+    const state::build_data::scenarios::RosterGroup& stateLocalRosterGroup,
+    std::uint64_t expectedActivityClientGeneration,
+    const ScriptableOutputReservation* reservation) noexcept {
+    if (target.slotType != auth::kType3SlotType || target.authSchema != auth::kType3Schema
+        || !valid_state_local_group(target, stateLocalRosterGroup)
+        || expectedActivityClientGeneration == 0) {
+        return false;
+    }
+    ScriptableRequest request{};
+    request.binding = binding;
+    request.target = target;
+    request.stateLocalRosterGroup = stateLocalRosterGroup;
+    request.expectedActivityClientGeneration = expectedActivityClientGeneration;
+    request.kind = ScriptableOverrideKind::objectiveReset;
+    return enqueue_request(request, reservation);
+}
+
+/** Queues one generation-bound authored-task change from an exact generated roster group. */
+bool request_state_local_task_override(
+    const state::activity::SessionBinding& binding,
+    const ScriptableTarget& target,
+    const state::build_data::scenarios::RosterGroup& stateLocalRosterGroup,
+    std::uint64_t expectedActivityClientGeneration,
+    const ScriptableOutputReservation* reservation) noexcept {
+    if (target.slotType != auth::kType38SlotType || target.authSchema != auth::kType38Schema
+        || !valid_state_local_group(target, stateLocalRosterGroup)
+        || expectedActivityClientGeneration == 0) {
+        return false;
+    }
+    ScriptableRequest request{};
+    request.binding = binding;
+    request.target = target;
+    request.stateLocalRosterGroup = stateLocalRosterGroup;
+    request.expectedActivityClientGeneration = expectedActivityClientGeneration;
+    request.kind = ScriptableOverrideKind::task;
+    return enqueue_request(request, reservation);
+}
+
+/** Queues one generation-bound authored-scene activation from an exact generated roster group. */
+bool request_state_local_authored_scene_override(
+    const state::activity::SessionBinding& binding,
+    const ScriptableTarget& target,
+    const state::build_data::scenarios::RosterGroup& stateLocalRosterGroup,
+    std::uint64_t expectedActivityClientGeneration,
+    const ScriptableOutputReservation* reservation) noexcept {
+    if (target.slotType != scene::kAuthoredSceneSlotType
+        || target.authSchema != scene::kAuthoredSceneAuthSchema
+        || !valid_state_local_group(target, stateLocalRosterGroup)
+        || expectedActivityClientGeneration == 0) {
+        return false;
+    }
+    ScriptableRequest request{};
+    request.binding = binding;
+    request.target = target;
+    request.stateLocalRosterGroup = stateLocalRosterGroup;
+    request.expectedActivityClientGeneration = expectedActivityClientGeneration;
+    request.kind = ScriptableOverrideKind::authoredScene;
+    return enqueue_request(request, reservation);
+}
+
+/** Queues one SDK-bounded dialogue line pulse from an exact generated roster group. */
+bool request_state_local_dialogue_override(
+    const state::activity::SessionBinding& binding,
+    const ScriptableTarget& target,
+    const state::build_data::scenarios::RosterGroup& stateLocalRosterGroup,
+    std::uint16_t cueIndex,
+    std::uint16_t authoredCueCount,
+    std::uint64_t expectedActivityClientGeneration,
+    const ScriptableOutputReservation* reservation) noexcept {
+    if (target.slotType != auth::kType53SlotType || target.authSchema != auth::kType53Schema
+        || !valid_state_local_group(target, stateLocalRosterGroup) || authoredCueCount == 0
+        || authoredCueCount > auth::kType53EntryCount || cueIndex >= authoredCueCount
+        || expectedActivityClientGeneration == 0) {
+        return false;
+    }
+    ScriptableRequest request{};
+    request.binding = binding;
+    request.target = target;
+    request.stateLocalRosterGroup = stateLocalRosterGroup;
+    request.expectedActivityClientGeneration = expectedActivityClientGeneration;
+    request.dialogueCue = cueIndex;
+    request.kind = ScriptableOverrideKind::dialogue;
+    return enqueue_request(request, reservation);
+}
+
+/** Queues one squad placement intent for an exact package-derived ClientRef. */
+bool request_squad_override(const state::activity::SessionBinding& binding,
+                            const ScriptableTarget& target,
+                            const state::build_data::scenarios::RosterGroup* stateLocalRosterGroup,
+                            std::span<const std::int32_t> requestedCounts,
+                            squad::Mode mode,
+                            std::uint64_t expectedActivityClientGeneration,
+                            std::optional<std::uint32_t> nameHash,
+                            const ScriptableOutputReservation* reservation,
+                            std::array<std::int8_t, 4> authoredProfile) noexcept {
+    if (target.slotType != squad::kSlotType || target.authSchema != squad::kSchema
+        || (target.stateLocalRoster
+                ? stateLocalRosterGroup == nullptr
+                      || !valid_state_local_group(target, *stateLocalRosterGroup)
+                : stateLocalRosterGroup != nullptr)
+        || requestedCounts.size() < squad::kMinimumRequestedCountLength
+        || requestedCounts.size() > squad::kMaximumRequestedCountLength
+        || expectedActivityClientGeneration == 0
+        || !squad::valid_mode(mode)
+        || !std::ranges::all_of(requestedCounts, [](std::int32_t count) { return count >= 0; })) {
+        return false;
+    }
+    ScriptableRequest request{};
+    request.binding = binding;
+    request.target = target;
+    if (stateLocalRosterGroup != nullptr) {
+        request.stateLocalRosterGroup = *stateLocalRosterGroup;
+    }
+    std::ranges::copy(requestedCounts, request.requestedCounts.begin());
+    request.requestedCountLength = requestedCounts.size();
+    request.squadAuthoredProfile = authoredProfile;
+    request.nameHash = nameHash;
+    request.expectedActivityClientGeneration = expectedActivityClientGeneration;
+    request.squadMode = mode;
+    request.kind = ScriptableOverrideKind::squad;
+    return enqueue_request(request, reservation);
+}
+
+/** Queues one generation-bound activity lifetime state through the serialized output slot. */
+bool request_lifetime_override(const state::activity::SessionBinding& binding,
+                               std::uint8_t lifetimeState,
+                               std::uint64_t expectedActivityClientGeneration,
+                               const ScriptableOutputReservation* reservation) noexcept {
+    // Above the highest jump-table entry the client's spawn gate jumps out of its image.
+    if (lifetimeState > kMaximumLifetimeState || expectedActivityClientGeneration == 0) {
+        return false;
+    }
+    ScriptableRequest request{};
+    request.binding = binding;
+    request.expectedActivityClientGeneration = expectedActivityClientGeneration;
+    request.kind = ScriptableOverrideKind::lifetime;
+    request.lifetimeState = lifetimeState;
+    return enqueue_request(request, reservation);
+}
+
+/** Queues one exact SDK-compiled Auth body for a generation-bound ClientRef. */
+bool request_sdk_auth_override(
+    const state::activity::SessionBinding& binding,
+    const ScriptableTarget& target,
+    const state::build_data::scenarios::RosterGroup* stateLocalRosterGroup,
+    std::span<const std::byte> body,
+    std::uint16_t bitCount,
+    std::uint64_t expectedActivityClientGeneration,
+    const ScriptableOutputReservation* reservation) noexcept {
+    if (body.empty() || body.size() > scene::kAuthOverrideByteCapacity
+        || body.size() > (std::numeric_limits<std::uint16_t>::max)()
+        || expectedActivityClientGeneration == 0 || !valid_auth_storage(body, bitCount)
+        || (target.stateLocalRoster
+                ? stateLocalRosterGroup == nullptr
+                      || !valid_state_local_group(target, *stateLocalRosterGroup)
+                : stateLocalRosterGroup != nullptr)) {
+        return false;
+    }
+    ScriptableRequest request{};
+    request.binding = binding;
+    request.target = target;
+    if (stateLocalRosterGroup != nullptr) {
+        request.stateLocalRosterGroup = *stateLocalRosterGroup;
+    }
+    std::copy(body.begin(), body.end(), request.authBody.begin());
+    request.authBitCount = bitCount;
+    request.authByteCount = static_cast<std::uint16_t>(body.size());
+    request.expectedActivityClientGeneration = expectedActivityClientGeneration;
+    request.kind = ScriptableOverrideKind::sdkAuth;
+    return enqueue_request(request, reservation);
+}
+
+/** Reads the one pending typed ClientRef body without changing its counter. */
+bool pending_scriptable_override(const state::activity::SessionBinding& binding,
+                                 PendingScriptableOverride& output) noexcept {
+    output = {};
+    AcquireSRWLockShared(&g_lock);
+    const Instance* const instance = find_instance(binding);
+    const bool pending = instance != nullptr && instance->view.active
+                         && instance->view.outputPending
+                         && instance->view.outputKind == OutputKind::scriptableOverride
+                         && instance->pendingScriptable.revision != 0;
+    if (pending) {
+        output = instance->pendingScriptable;
+    }
+    ReleaseSRWLockShared(&g_lock);
+    return pending;
+}
+
+/** Reads one pending body only for the ActivityClient generation that authorized it. */
+bool pending_scriptable_override_for_activity_client(const state::activity::SessionBinding& binding,
+                                                     std::uint64_t activityClientGeneration,
+                                                     PendingScriptableOverride& output) noexcept {
+    output = {};
+    AcquireSRWLockExclusive(&g_lock);
+    Instance* const instance = find_instance(binding);
+    bool pending = instance != nullptr && instance->view.active && instance->view.outputPending
+                   && instance->view.outputKind == OutputKind::scriptableOverride
+                   && instance->pendingScriptable.revision != 0;
+    if (pending && instance->pendingScriptable.expectedActivityClientGeneration != 0
+        && instance->pendingScriptable.expectedActivityClientGeneration
+               != activityClientGeneration) {
+        const std::uint64_t revision = instance->pendingScriptable.revision;
+        cancel_pending(*instance, binding, revision);
+        pending = false;
+    }
+    if (pending) {
+        output = instance->pendingScriptable;
+    }
+    ReleaseSRWLockExclusive(&g_lock);
+    return pending;
+}
+
+/** Cancels one exact unstaged typed override revision without advancing its slot counter. */
+bool cancel_pending_scriptable_override(const state::activity::SessionBinding& binding,
+                                        std::uint64_t expectedRevision) noexcept {
+    if (expectedRevision == 0) {
+        return false;
+    }
+    AcquireSRWLockExclusive(&g_lock);
+    Instance* const instance = find_instance(binding);
+    const bool canceled = instance != nullptr && instance->view.active
+                          && instance->view.outputPending
+                          && instance->view.outputKind == OutputKind::scriptableOverride
+                          && instance->pendingScriptable.revision == expectedRevision;
+    if (canceled) {
+        cancel_pending(*instance, binding, expectedRevision);
+    }
+    ReleaseSRWLockExclusive(&g_lock);
+    return canceled;
+}
+
+/** Records one refused typed-body attempt without consuming its sequence or generation. */
+void note_scriptable_attempt(const state::activity::SessionBinding& binding,
+                             std::uint64_t sourceGeneration,
+                             const PendingScriptableOverride& pending,
+                             OutputStatus status) noexcept {
+    if (pending.revision == 0 || status == OutputStatus::idle || status == OutputStatus::pending
+        || status == OutputStatus::transportStaged || status == OutputStatus::canceled) {
+        return;
+    }
+    AcquireSRWLockExclusive(&g_lock);
+    Instance* const instance = find_instance(binding);
+    if (instance != nullptr && instance->view.outputPending
+        && instance->view.outputKind == OutputKind::scriptableOverride
+        && same_pending(instance->pendingScriptable, pending)) {
+        instance->view.lastOutputAttemptTick = GetTickCount64();
+        instance->view.lastOutputSourceGeneration = sourceGeneration;
+        ++instance->view.outputAttempts;
+        instance->view.outputStatus = status;
+        const bool terminal = status == OutputStatus::noLayout || status == OutputStatus::noGroups
+                              || status == OutputStatus::noOverrideTarget
+                              || status == OutputStatus::ambiguousLinks
+                              || status == OutputStatus::frameRefused;
+        if (terminal) {
+            const std::uint64_t revision = instance->pendingScriptable.revision;
+            cancel_pending(*instance, binding, revision);
+            instance->view.outputStatus = status;
+        }
+    }
+    ReleaseSRWLockExclusive(&g_lock);
+}
+
+/** @return True when this body carries the exact next counter its committed guard expects. */
+[[nodiscard]] bool staged_counter_matches(const ScriptableGuard* guard,
+                                          const PendingScriptableOverride& pending) noexcept {
+    bool nextCounter = false;
+    if (pending.kind == ScriptableOverrideKind::lifetime) {
+        nextCounter = true;
+    } else if (guard != nullptr && pending.kind == ScriptableOverrideKind::squad
+               && pending.generation <= squad::kMaximumGeneration) {
+        std::uint32_t next = 0;
+        nextCounter = squad::next_generation(guard->squad, next) && next == pending.generation;
+    } else if (guard != nullptr && pending.kind == ScriptableOverrideKind::combatantChannel) {
+        auth::Type2ChannelState candidate = guard->type2;
+        std::uint32_t revision = 0;
+        nextCounter =
+            auth::next_type2_revision(candidate, revision) && revision == pending.generation;
+        candidate.revision = revision;
+        nextCounter =
+            nextCounter
+            && auth::set_type2_channel(candidate, pending.channelHash, pending.channelValue);
+    } else if (guard != nullptr && pending.kind == ScriptableOverrideKind::combatantBinding) {
+        auth::Type2ChannelState candidate = guard->type2;
+        std::uint32_t revision = 0;
+        nextCounter =
+            auth::next_type2_revision(candidate, revision) && revision == pending.generation;
+        candidate.revision = revision;
+        candidate.actorBinding = auth::Type2ActorBinding::squadMember;
+    } else if (guard != nullptr && pending.kind == ScriptableOverrideKind::object) {
+        std::int32_t next = 0;
+        nextCounter = auth::next_type4_generation(guard->type4, next)
+                      && static_cast<std::uint64_t>(next) == pending.generation;
+    } else if (guard != nullptr && pending.kind == ScriptableOverrideKind::sequence) {
+        std::uint8_t next = 0;
+        nextCounter = auth::next_type5_revision(guard->type5, next) && next == pending.generation;
+    } else if (guard != nullptr && pending.kind == ScriptableOverrideKind::cinematic) {
+        std::uint32_t next = 0;
+        nextCounter = auth::next_type6_generation(guard->type6, next) && next == pending.generation;
+    } else if (guard != nullptr && pending.kind == ScriptableOverrideKind::performance) {
+        std::int32_t next = 0;
+        nextCounter = auth::next_type42_generation(guard->type42, next)
+                      && static_cast<std::uint64_t>(next) == pending.generation;
+    } else if (guard != nullptr && pending.kind == ScriptableOverrideKind::type23) {
+        std::int16_t next = 0;
+        nextCounter = auth::next_type23_sequence(guard->type23, pending.channel, next)
+                      && next == pending.sequence;
+    } else if (guard != nullptr && pending.kind == ScriptableOverrideKind::type31) {
+        std::uint64_t next = 0;
+        nextCounter =
+            auth::next_type31_generation(guard->type31, next) && next == pending.generation;
+    } else if (guard != nullptr && pending.kind == ScriptableOverrideKind::objectiveReset) {
+        std::int32_t next = 0;
+        nextCounter = auth::next_type3_generation(guard->type3, next)
+                      && static_cast<std::uint64_t>(next) == pending.generation;
+    } else if (guard != nullptr && pending.kind == ScriptableOverrideKind::task) {
+        std::int32_t next = 0;
+        nextCounter = auth::next_type38_generation(guard->type38, next)
+                      && static_cast<std::uint64_t>(next) == pending.generation;
+    } else if (guard != nullptr && pending.kind == ScriptableOverrideKind::authoredScene) {
+        std::uint32_t next = 0;
+        nextCounter = next_authored_scene_generation(guard->authoredSceneGeneration, next)
+                      && next == pending.generation;
+    } else if (guard != nullptr && pending.kind == ScriptableOverrideKind::dialogue) {
+        std::int32_t next = 0;
+        nextCounter = auth::next_type53_sequence(guard->type53, pending.dialogueCue, next)
+                      && next == pending.dialogueSequence;
+    } else if (guard != nullptr && pending.kind == ScriptableOverrideKind::sdkAuth
+               && pending.sdkCompiled) {
+        nextCounter = true;
+    }
+    return nextCounter;
+}
+
+/** Advances one committed guard to the body that has just reached transport. */
+void advance_staged_guard(ScriptableGuard* guard,
+                          const PendingScriptableOverride& pending) noexcept {
+    if (guard == nullptr) {
+        return;
+    }
+    if (pending.kind == ScriptableOverrideKind::squad) {
+        guard->squad.last = static_cast<std::uint32_t>(pending.generation);
+        guard->squad.hasLast = true;
+    } else if (pending.kind == ScriptableOverrideKind::combatantChannel) {
+        guard->type2.revision = static_cast<std::uint32_t>(pending.generation);
+        static_cast<void>(
+            auth::set_type2_channel(guard->type2, pending.channelHash, pending.channelValue));
+    } else if (pending.kind == ScriptableOverrideKind::combatantBinding) {
+        guard->type2.revision = static_cast<std::uint32_t>(pending.generation);
+        guard->type2.actorBinding = auth::Type2ActorBinding::squadMember;
+    } else if (pending.kind == ScriptableOverrideKind::object) {
+        guard->type4.last = static_cast<std::int32_t>(pending.generation);
+        guard->type4.hasLast = true;
+    } else if (pending.kind == ScriptableOverrideKind::sequence) {
+        guard->type5.last = static_cast<std::uint8_t>(pending.generation);
+        guard->type5.hasLast = true;
+    } else if (pending.kind == ScriptableOverrideKind::cinematic) {
+        guard->type6.last = static_cast<std::uint32_t>(pending.generation);
+        guard->type6.hasLast = true;
+    } else if (pending.kind == ScriptableOverrideKind::performance) {
+        guard->type42.last = static_cast<std::int32_t>(pending.generation);
+        guard->type42.hasLast = true;
+    } else if (pending.kind == ScriptableOverrideKind::type23) {
+        guard->type23.last[static_cast<std::size_t>(pending.channel)] = pending.sequence;
+    } else if (pending.kind == ScriptableOverrideKind::type31) {
+        guard->type31.last = pending.generation;
+        guard->type31.hasLast = true;
+    } else if (pending.kind == ScriptableOverrideKind::objectiveReset) {
+        guard->type3.last = static_cast<std::int32_t>(pending.generation);
+        guard->type3.hasLast = true;
+    } else if (pending.kind == ScriptableOverrideKind::task) {
+        guard->type38.last = static_cast<std::int32_t>(pending.generation);
+        guard->type38.hasLast = true;
+    } else if (pending.kind == ScriptableOverrideKind::authoredScene) {
+        guard->authoredSceneGeneration = static_cast<std::uint32_t>(pending.generation);
+    } else if (pending.kind == ScriptableOverrideKind::dialogue) {
+    }
+}
+
+/** Records that one pending body reached the transport, so its retained estate can advance. */
+void note_scriptable_transport_staged(const state::activity::SessionBinding& binding,
+                                      std::uint64_t sourceGeneration,
+                                      const PendingScriptableOverride& pending) noexcept {
+    if (pending.revision == 0
+        || (pending.expectedActivityClientGeneration != 0
+            && pending.expectedActivityClientGeneration != sourceGeneration)) {
+        return;
+    }
+    AcquireSRWLockExclusive(&g_lock);
+    Instance* const instance = find_instance(binding);
+    ScriptableGuard* guard = instance != nullptr ? find_guard(*instance, pending.target) : nullptr;
+    const bool nextCounter = staged_counter_matches(guard, pending);
+    if (instance != nullptr && nextCounter && instance->view.outputPending
+        && instance->view.outputKind == OutputKind::scriptableOverride
+        && same_pending(instance->pendingScriptable, pending)) {
+        const bool retained = retain_scriptable_auth(*instance, pending, sourceGeneration);
+        if (!retained) {
+            ++g_refusedControls;
+            ReleaseSRWLockExclusive(&g_lock);
+            return;
+        }
+        advance_staged_guard(guard, pending);
+        if (pending.kind == ScriptableOverrideKind::lifetime) {
+            // Latch the state so every later msg 5 keeps reporting it.
+            instance->view.lifetimeState = pending.lifetimeState;
+        }
+        instance->view.lastOutputAttemptTick = GetTickCount64();
+        Event event{};
+        event.binding = binding;
+        event.tick = instance->view.lastOutputAttemptTick;
+        event.kind = EventKind::scriptableOverrideTransportStaged;
+        event.sourceGeneration = sourceGeneration;
+        // The tail rode out on this same body, so it stages with the head or not at all.
+        for (std::size_t index = 0; index < instance->pendingScriptableTailCount; ++index) {
+            const PendingScriptableOverride& queued = instance->pendingScriptableTail[index];
+            ScriptableGuard* const queuedGuard = find_guard(*instance, queued.target);
+            if (!staged_counter_matches(queuedGuard, queued)
+                || !retain_scriptable_auth(*instance, queued, sourceGeneration)) {
+                ++g_refusedControls;
+                continue;
+            }
+            advance_staged_guard(queuedGuard, queued);
+            event.scriptableRevision = queued.revision;
+            append_event(event);
+        }
+        instance->pendingScriptableTail.fill({});
+        instance->pendingScriptableTailCount = 0;
+        instance->view.scriptableTransportRevision = instance->view.scriptableRevision;
+        instance->view.lastOutputSourceGeneration = sourceGeneration;
+        ++instance->view.outputAttempts;
+        instance->view.outputStatus = OutputStatus::transportStaged;
+        instance->view.outputPending = false;
+        instance->view.outputKind = OutputKind::none;
+        instance->pendingScriptable = {};
+        event.scriptableRevision = pending.revision;
+        append_event(event);
+        instance->view.lastEventSequence = g_sequence;
+    }
+    ReleaseSRWLockExclusive(&g_lock);
+}
+
+/** Copies the pending overrides that have no output yet. @return How many were written. */
+std::size_t pending_scriptable_tail(const state::activity::SessionBinding& binding,
+                                    std::span<PendingScriptableOverride> output) noexcept {
+    AcquireSRWLockShared(&g_lock);
+    const Instance* const instance = find_instance(binding);
+    std::size_t written = 0;
+    if (instance != nullptr && instance->view.active && instance->view.outputPending
+        && instance->view.outputKind == OutputKind::scriptableOverride) {
+        const std::size_t count = (std::min)(instance->pendingScriptableTailCount, output.size());
+        for (; written < count; ++written) {
+            output[written] = instance->pendingScriptableTail[written];
+        }
+    }
+    ReleaseSRWLockShared(&g_lock);
+    return written;
+}
+
+/** @return True when any instance still owes a Host output. */
+bool any_output_pending() noexcept {
+    AcquireSRWLockShared(&g_lock);
+    bool pending = false;
+    for (const Instance& instance : g_instances) {
+        pending =
+            pending
+            || (instance.occupied && instance.view.active
+                && (instance.view.outputPending || has_queued_control(instance.view.binding)));
+    }
+    ReleaseSRWLockShared(&g_lock);
+    return pending;
+}
+
+/** Copies the retained Auth estate for one exact ActivityClient generation. */
+bool scriptable_auth_estate(const state::activity::SessionBinding& binding,
+                            std::uint64_t activityClientGeneration,
+                            std::vector<PendingScriptableOverride>& output) noexcept {
+    output.clear();
+    if (activityClientGeneration == 0) {
+        return false;
+    }
+    AcquireSRWLockShared(&g_lock);
+    const Instance* instance = nullptr;
+    for (const Instance& candidate : g_instances) {
+        if (candidate.occupied && same_binding(candidate.view.binding, binding)) {
+            instance = &candidate;
+            break;
+        }
+    }
+    bool copied = true;
+    if (instance != nullptr && instance->view.active) {
+        try {
+            output.reserve(instance->scriptableAuthEstate.size());
+            for (const PendingScriptableOverride& retained : instance->scriptableAuthEstate) {
+                // The ActivityClient generation is a transport revision that advances on ordinary
+                // region advertisements, and the SessionBinding already owns estate lifetime.
+                // Filtering retained mission state by it erases every non-squad Auth lane.
+                output.push_back(retained);
+            }
+        } catch (const std::bad_alloc&) {
+            output.clear();
+            copied = false;
+        }
+    }
+    ReleaseSRWLockShared(&g_lock);
+    return copied;
+}
+
 } // namespace sunrise::server::activity::host

+ 37 - 0
Sunrise/src/server/activity/mission/mission_script_actor_path_sense.h

@@ -0,0 +1,37 @@
+#pragma once
+#include <cstdint>
+#include <span>
+#include "../../../middleware/bap/activity_message/sense_update.h"
+namespace sunrise::server::activity::mission {
+struct ActorPathLevel {
+    std::int32_t generation{}, revision{}, state{};
+    std::int32_t deliveryRevision{}, deliveryState{};
+    std::uint8_t present{};
+    bool dead{};
+    bool operator==(const ActorPathLevel&) const = default;
+};
+[[nodiscard]] inline bool update_actor_path_level(ActorPathLevel& level,
+    std::span<const middleware::bap::activity_message::sense_update::DecodedValue> values,
+    std::uint32_t root) noexcept {
+    auto next = level;
+    for (const auto& v : values) {
+        if (!v.present) continue;
+        // Movement is Auth .6 / Sense .3. Delivery has its own revision and state.
+        if (v.schemaRow == 0x80807F6EU) {
+            if (v.fieldOrdinal == 0) { next.state = static_cast<std::int32_t>(v.signedValue); next.present |= 4; }
+            if (v.fieldOrdinal == 1) { next.revision = static_cast<std::int32_t>(v.signedValue); next.present |= 2; }
+            continue;
+        }
+        if (v.schemaRow != root) continue;
+        switch (v.fieldOrdinal) {
+        case 0: next.generation = static_cast<std::int32_t>(v.signedValue); next.present |= 1; break;
+        case 5: next.deliveryRevision = static_cast<std::int32_t>(v.signedValue); next.present |= 16; break;
+        case 6: next.deliveryState = static_cast<std::int32_t>(v.signedValue); next.present |= 32; break;
+        case 10: next.dead = v.unsignedValue != 0; next.present |= 8; break;
+        default: break;
+        }
+    }
+    const bool changed = next != level; level = next;
+    return changed && (next.present & 15) == 15;
+}
+}

+ 17 - 0
Sunrise/src/server/activity/mission/mission_script_event_batch.h

@@ -0,0 +1,17 @@
+#pragma once
+#include <cstddef>
+
+namespace sunrise::server::activity::mission {
+// Stop at the first asynchronous output. Callbacks after it must observe its
+// committed result, but callbacks producing no output need not spend a tick each.
+template<class Ready, class Dispatch>
+std::size_t drain_script_event_batch(Ready ready, Dispatch dispatch) {
+    constexpr std::size_t limit = 64;
+    std::size_t count = 0;
+    while (count < limit && ready()) {
+        dispatch();
+        ++count;
+    }
+    return count;
+}
+}

+ 35 - 0
Sunrise/src/server/activity/mission/mission_script_ghost_sense.h

@@ -0,0 +1,35 @@
+#pragma once
+#include <cmath>
+#include <cstdint>
+#include <span>
+#include "../../../middleware/bap/activity_message/sense_update.h"
+namespace sunrise::server::activity::mission {
+struct GhostLevel final {
+    std::int32_t generation{};
+    float progress{};
+    std::uint8_t present{};
+    bool active{};
+};
+// E4A590 publishes active, elapsed/required duration, and the accepted Auth generation.
+[[nodiscard]] inline bool update_ghost_level(
+    GhostLevel& level,
+    std::span<const middleware::bap::activity_message::sense_update::DecodedValue> values,
+    std::uint32_t root) noexcept {
+    GhostLevel next = level;
+    for (const auto& v : values) {
+        if (!v.present || v.schemaRow != root) continue;
+        switch (v.fieldOrdinal) {
+        case 0: next.active = v.unsignedValue != 0; next.present |= 1; break;
+        case 1:
+            if (!std::isfinite(v.realValue) || v.realValue < 0) return false;
+            next.progress = v.realValue; next.present |= 2; break;
+        case 2: next.generation = static_cast<std::int32_t>(v.signedValue); next.present |= 4; break;
+        default: break;
+        }
+    }
+    const bool changed = next.present != level.present || next.active != level.active
+                         || next.progress != level.progress || next.generation != level.generation;
+    level = next;
+    return changed && next.present == 7;
+}
+} // namespace sunrise::server::activity::mission

+ 28 - 0
Sunrise/src/server/activity/mission/mission_script_lua_context_api.cpp

@@ -1,4 +1,5 @@
 #include <cstddef>
+#include <charconv>
 #include <cstdint>
 #include <limits>
 #include <string_view>
@@ -23,6 +24,31 @@ namespace {
     return 1;
 }
 
+[[nodiscard]] int context_restart_checkpoint(lua_State* state) {
+    static_cast<void>(luaL_checkudata(state,1,kContextMetatable));
+    static constexpr std::array<std::string_view,3> fields{"region","spawn_set_hash","release_request"};
+    refuse_unknown_arguments(state,fields);
+    const auto region=optional_integer_argument(state,"region",-1);
+    const auto hash=optional_integer_argument(state,"spawn_set_hash",0);
+    if (region<0 || region>511 || hash<=0 || hash>=UINT32_MAX)
+        return luaL_error(state,"checkpoint requires an authored region and spawn-set hash");
+    std::uint64_t release{};
+    lua_getfield(state,2,"release_request");
+    if (!lua_isnil(state,-1)) {
+        std::size_t length{};
+        const char* value=luaL_checklstring(state,-1,&length);
+        const auto parsed=std::from_chars(value,value+length,release);
+        if (parsed.ec!=std::errc{} || parsed.ptr!=value+length || release==0)
+            return luaL_error(state,"checkpoint release requires the original RequestKey.value");
+    }
+    lua_pop(state,1);
+    Intent intent{};intent.kind=IntentKind::restartCheckpoint;
+    intent.checkpointReleaseRequest=static_cast<std::uint64_t>(release);
+    intent.effectiveRegion=static_cast<std::int32_t>(region);
+    intent.checkpointSpawnHash=static_cast<std::uint32_t>(hash);
+    return queue_intent(state,active_frame(state),intent);
+}
+
 [[nodiscard]] int context_scene(lua_State* state) {
     static_cast<void>(luaL_checkudata(state, 1, kContextMetatable));
     SceneDefinition definition{};
@@ -197,6 +223,8 @@ resolve_message_name(lua_State* state, std::string_view name, ActivityMessageDef
         lua_pushcfunction(state, &context_slot);
     } else if (key == "select_state") {
         lua_pushcfunction(state, &context_select_state);
+    } else if (key == "restart_checkpoint") {
+        lua_pushcfunction(state, &context_restart_checkpoint);
     } else if (key == "set_phase") {
         lua_pushcfunction(state, &context_set_phase);
     } else if (key == "set_variable") {

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

@@ -9,6 +9,8 @@ namespace sunrise::server::activity::mission::lua_vm::detail {
 [[nodiscard]] std::string_view event_kind_name(lua_State* state, host::EventKind kind) noexcept {
     (void)state;
     switch (kind) {
+    case host::EventKind::fireteamState:
+        return "fireteamState";
     case host::EventKind::senseUpdate:
         return "sensorSenseUpdated";
     case host::EventKind::clientStateChanged:
@@ -45,8 +47,18 @@ namespace sunrise::server::activity::mission::lua_vm::detail {
         return "playerTrigger";
     case host::EventKind::cinematicStarted:
         return "cinematicStarted";
+    case host::EventKind::cinematicSkipRequested:
+        return "cinematicSkipRequested";
     case host::EventKind::cinematicTerminated:
         return "cinematicTerminated";
+    case host::EventKind::actorPathState:
+        return "actorPathState";
+    case host::EventKind::objectInteracted:
+        return "objectInteracted";
+    case host::EventKind::damageState: return "damageState";
+    case host::EventKind::objectState: return "objectState";
+    case host::EventKind::ghostLinkState:
+        return "ghostLinkState";
     case host::EventKind::clientMessageReceived:
         return "clientMessageReceived";
     case host::EventKind::authStateCommitted:
@@ -82,6 +94,8 @@ namespace sunrise::server::activity::mission::lua_vm::detail {
 /** @return The view metatable one kind is pushed with. */
 [[nodiscard]] const char* event_metatable(host::EventKind kind) noexcept {
     switch (kind) {
+    case host::EventKind::fireteamState:
+        return kFireteamStateEventMetatable;
     case host::EventKind::senseUpdate:
         return kSenseUpdateEventMetatable;
     case host::EventKind::clientStateChanged:
@@ -142,8 +156,17 @@ namespace sunrise::server::activity::mission::lua_vm::detail {
         return kPlayerTriggerEventMetatable;
     case host::EventKind::cinematicStarted:
         return kCinematicStartedEventMetatable;
+    case host::EventKind::cinematicSkipRequested:
     case host::EventKind::cinematicTerminated:
         return kCinematicTerminatedEventMetatable;
+    case host::EventKind::actorPathState:
+        return kActorPathEventMetatable;
+    case host::EventKind::objectInteracted:
+        return kObjectInteractionEventMetatable;
+    case host::EventKind::damageState:
+    case host::EventKind::objectState: return kObjectInteractionEventMetatable;
+    case host::EventKind::ghostLinkState:
+        return kGhostLinkEventMetatable;
     }
     return kSenseUpdateEventMetatable;
 }

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

@@ -46,6 +46,8 @@ push_incident_revision_member(lua_State* state, const host::Event& event, std::s
         return {};
     case ActionKind::setLifetime:
         return "lifetime.set";
+    case ActionKind::restartCheckpoint:
+        return "mission.restart_checkpoint";
     case ActionKind::fireTrigger:
         return "slot.fire_trigger";
     case ActionKind::playSequence:

+ 82 - 0
Sunrise/src/server/activity/mission/mission_script_lua_event_derived.cpp

@@ -61,6 +61,15 @@ push_trigger_member(lua_State* state, const host::Event& event, std::string_view
 push_squad_state_member(lua_State* state, const host::Event& event, std::string_view key) {
     if (key == "alive_count") {
         lua_pushinteger(state, event.squadAliveCount);
+    } else if (key == "objective_revision") {
+        lua_pushinteger(state, event.squadObjectiveRevision);
+    } else if (key == "task_costs") {
+        lua_createtable(state, 24, 0);
+        for (unsigned i=0; i<24; ++i) {
+            if (!(event.squadObjectiveCostMask & (1U << i))) continue;
+            lua_pushnumber(state, event.squadObjectiveCosts[i]);
+            lua_rawseti(state, -2, i+1);
+        }
     } else if (key == "previous_alive_count") {
         lua_pushinteger(state, event.squadPreviousAliveCount);
     } else if (key == "removal_flag") {
@@ -255,6 +264,64 @@ push_joined_revision_member(lua_State* state, const host::Event& event, std::str
     return cinematic_started_index(state);
 }
 
+/** Reports the accepted member generation and native movement-path state. */
+[[nodiscard]] int actor_path_index(lua_State* state) {
+    const auto& event = check_event(state, 1);
+    const auto key = lua_string_view(state, 2);
+    if (push_common_member(state, event, key) || push_mission_sequence_member(state, event, key)) return 1;
+    if (event_surface_visible(state, event.kind)) {
+        if (push_slot_identity_member(state, event, key)) return 1;
+        if (key == "generation") { lua_pushinteger(state, event.actorGeneration); return 1; }
+        if (key == "revision") { lua_pushinteger(state, event.actorPathRevision); return 1; }
+        if (key == "path_state") { lua_pushinteger(state, event.actorPathState); return 1; }
+        if (key == "delivery_revision" && event.actorDeliveryKnown) {
+            lua_pushinteger(state, event.actorDeliveryRevision); return 1;
+        }
+        if (key == "delivery_state" && event.actorDeliveryKnown) {
+            lua_pushinteger(state, event.actorDeliveryState); return 1;
+        }
+        if (key == "dead") { lua_pushboolean(state, event.actorDead); return 1; }
+    }
+    lua_pushnil(state); return 1;
+}
+
+[[nodiscard]] int object_interaction_index(lua_State* state) {
+    const host::Event& event = check_event(state,1);
+    const auto key = lua_string_view(state,2);
+    if (push_common_member(state,event,key) || push_mission_sequence_member(state,event,key)
+        || (event_surface_visible(state,event.kind) && push_slot_identity_member(state,event,key))) return 1;
+    if (event.kind == host::EventKind::damageState) {
+        if (key == "health") lua_pushnumber(state,event.damageHealth);
+        else if (key == "shield") lua_pushnumber(state,event.damageShield);
+        else if (key == "revision") lua_pushinteger(state,event.damageRevision);
+        else lua_pushnil(state);
+        return 1;
+    }
+    if (key == "generation") lua_pushinteger(state,event.objectGeneration);
+    else if (key == "present") lua_pushboolean(state,event.objectPresent);
+    else if (key == "alive") lua_pushboolean(state,event.objectAlive);
+    else if (key == "owner_known") lua_pushboolean(state,event.objectOwnerKnown);
+    else if (key == "has_owner") lua_pushboolean(state,event.objectHasOwner);
+    else if (key == "owner_key" && event.objectOwnerKey != 0) push_u64_string(state,event.objectOwnerKey);
+    else lua_pushnil(state);
+    return 1;
+}
+
+/** Reports native interaction progress without exposing raw Sense storage. */
+[[nodiscard]] int ghost_link_index(lua_State* state) {
+    const host::Event& event = check_event(state, 1);
+    const std::string_view key = lua_string_view(state, 2);
+    if (push_common_member(state, event, key) || push_mission_sequence_member(state, event, key)) return 1;
+    if (event_surface_visible(state, event.kind)) {
+        if (push_slot_identity_member(state, event, key)) return 1;
+        if (key == "generation") { lua_pushinteger(state, event.ghostGeneration); return 1; }
+        if (key == "progress") { lua_pushnumber(state, event.ghostProgress); return 1; }
+        if (key == "active") { lua_pushboolean(state, event.ghostActive); return 1; }
+    }
+    lua_pushnil(state);
+    return 1;
+}
+
 /** Reads one member of a squad occupancy edge. */
 [[nodiscard]] int squad_state_index(lua_State* state) {
     const host::Event& event = check_event(state, 1);
@@ -410,10 +477,22 @@ push_joined_revision_member(lua_State* state, const host::Event& event, std::str
     return 1;
 }
 
+[[nodiscard]] int fireteam_state_index(lua_State* state) {
+    const auto& event=check_event(state,1);
+    const auto key=lua_string_view(state,2);
+    if (push_common_member(state,event,key) || push_mission_sequence_member(state,event,key)) return 1;
+    if (key=="alive_count") lua_pushinteger(state,event.fireteamAlive);
+    else if (key=="dead_count") lua_pushinteger(state,event.fireteamDead);
+    else if (key=="unknown_count") lua_pushinteger(state,event.fireteamUnknown);
+    else lua_pushnil(state);
+    return 1;
+}
+
 } // namespace
 
 /** Installs the derived and internal view metatables. */
 void register_derived_event_metatables(lua_State* state) {
+    register_metatable(state,kFireteamStateEventMetatable,&fireteam_state_index);
     register_metatable(state, kTriggerEnteredEventMetatable, &trigger_entered_index);
     register_metatable(state, kTriggerExitedEventMetatable, &trigger_exited_index);
     register_metatable(state, kSquadStateEventMetatable, &squad_state_index);
@@ -429,6 +508,9 @@ void register_derived_event_metatables(lua_State* state) {
     register_metatable(state, kPlayerTriggerEventMetatable, &player_trigger_index);
     register_metatable(state, kCinematicStartedEventMetatable, &cinematic_started_index);
     register_metatable(state, kCinematicTerminatedEventMetatable, &cinematic_terminated_index);
+    register_metatable(state, kGhostLinkEventMetatable, &ghost_link_index);
+    register_metatable(state, kObjectInteractionEventMetatable, &object_interaction_index);
+    register_metatable(state, kActorPathEventMetatable, &actor_path_index);
 }
 
 } // namespace sunrise::server::activity::mission::lua_vm::detail

+ 3 - 0
Sunrise/src/server/activity/mission/mission_script_lua_key_api.cpp

@@ -24,6 +24,9 @@ namespace {
     const std::string_view key = lua_string_view(state, 2);
     if (key == "matches") {
         lua_pushcfunction(state, &request_key_matches);
+    } else if (key == "value") {
+        const auto* handle = static_cast<const RequestKeyHandle*>(luaL_checkudata(state, 1, kRequestKeyMetatable));
+        push_u64_string(state, handle->key);
     } else {
         lua_pushnil(state);
     }

+ 4 - 0
Sunrise/src/server/activity/mission/mission_script_lua_names.h

@@ -74,6 +74,10 @@ inline constexpr char kEntitySlotsRequestedEventMetatable[] =
 // Session membership, player trigger, and cinematic transitions.
 inline constexpr char kSessionJoinedEventMetatable[] = "sunrise.mission.event.session_joined";
 inline constexpr char kSessionLeftEventMetatable[] = "sunrise.mission.event.session_left";
+inline constexpr char kActorPathEventMetatable[] = "sunrise.mission.event.actor_path";
+inline constexpr char kObjectInteractionEventMetatable[] = "sunrise.mission.event.object_interaction";
+inline constexpr char kGhostLinkEventMetatable[] = "sunrise.mission.event.ghost_link";
+inline constexpr char kFireteamStateEventMetatable[] = "sunrise.mission.event.fireteam_state";
 inline constexpr char kPlayerTriggerEventMetatable[] = "sunrise.mission.event.player_trigger";
 inline constexpr char kCinematicStartedEventMetatable[] = "sunrise.mission.event.cinematic_started";
 inline constexpr char kCinematicTerminatedEventMetatable[] =

+ 388 - 2
Sunrise/src/server/activity/mission/mission_script_lua_slot_api.cpp

@@ -1,3 +1,13 @@
+#include "../../../middleware/bap/activity_message/music_section_auth.h"
+#include "../../../middleware/bap/activity_message/mission_effect_auth.h"
+#include "../../../middleware/bap/activity_message/darkness_zone_auth.h"
+#include "../../../middleware/bap/activity_message/squad_objective_auth.h"
+#include "../../../middleware/bap/activity_message/combatant_path_auth.h"
+#include "../../../middleware/bap/activity_message/combatant_action_auth.h"
+#include "../../../middleware/bap/activity_message/combatant_delivery_auth.h"
+#include "../../../middleware/bap/activity_message/combatant_retire_auth.h"
+#include "../../../middleware/bap/activity_message/ghost_link_auth.h"
+#include "../../../middleware/bap/activity_message/interactable_object_auth.h"
 #include <algorithm>
 #include <array>
 #include <bit>
@@ -218,8 +228,7 @@ constexpr std::size_t kOccupancyAuthByteCount = 11;
 [[nodiscard]] int slot_set_directive(lua_State* state) {
     const auto* const handle =
         static_cast<const SlotHandle*>(luaL_checkudata(state, 1, kSlotMetatable));
-    // Named arguments this call accepts. Any other key is refused.
-    static constexpr std::array<std::string_view, 2> kDeclared{"directive", "state"};
+    static constexpr std::array<std::string_view, 4> kDeclared{"directive", "state", "navpoint", "audience"};
     refuse_unknown_arguments(state, kDeclared);
     SlotDefinition slot{};
     if (!current_slot(state, *handle, slot)) {
@@ -256,6 +265,26 @@ constexpr std::size_t kOccupancyAuthByteCount = 11;
                                          .elementIndex = resolved.elementIndex,
                                          .state = static_cast<std::int8_t>(directiveState),
                                          .visible = true};
+    lua_getfield(state, 2, "audience");
+    if (!lua_isnil(state, -1)) {
+        const auto* ref = static_cast<const SlotHandle*>(luaL_checkudata(state, -1, kSlotMetatable));
+        SlotDefinition filter{};
+        if (!current_slot(state, *ref, filter) || filter.slotType != 70 || filter.slotIndex < 0
+            || filter.slotIndex > INT16_MAX)
+            return luaL_error(state, "directive audience requires an authored type-70 engagement sensor");
+        preset.audience = {filter.registryKey, 70, static_cast<std::int16_t>(filter.slotIndex)};
+    }
+    lua_pop(state, 1);
+    lua_getfield(state, 2, "navpoint");
+    if (!lua_isnil(state, -1)) {
+        const auto* targetHandle = static_cast<const SlotHandle*>(luaL_checkudata(state, -1, kSlotMetatable));
+        SlotDefinition target{};
+        if (!current_slot(state, *targetHandle, target) || target.slotType != 47
+            || target.slotIndex < 0 || target.slotIndex > INT16_MAX)
+            return luaL_error(state, "directive navpoint requires a current authored type-47 slot");
+        preset.navpoint = {target.registryKey, 47, static_cast<std::int16_t>(target.slotIndex)};
+    }
+    lua_pop(state, 1);
     std::array<std::byte, scriptable_auth::kType68ByteCount> body{};
     std::size_t written = 0;
     if (!scriptable_auth::encode_type68(preset, body, written) || written != body.size()) {
@@ -317,6 +346,187 @@ constexpr std::size_t kOccupancyAuthByteCount = 11;
         state, slot, scriptable_auth::kType70Schema, scriptable_auth::kType70BitCount, body);
 }
 
+/** Enables the native restriction; roster assembly supplies its matching bubble. */
+[[nodiscard]] int slot_set_darkness_zone(lua_State* state) {
+    namespace darkness=middleware::bap::activity_message::darkness_zone;
+    const auto* handle=static_cast<const SlotHandle*>(luaL_checkudata(state,1,kSlotMetatable));
+    static constexpr std::array<std::string_view,2> declared{"enabled","wipe_seconds"};
+    refuse_unknown_arguments(state,declared);
+    SlotDefinition slot{};
+    if (!current_slot(state,*handle,slot)||slot.slotType!=darkness::kSlotType
+        ||slot.componentClass!=darkness::kClass||slot.authSchema!=darkness::kSchema
+        ||(slot.flags&format::kSlotSchemaJoinExact)==0)
+        return luaL_error(state,"darkness zone requires the exact hard-wipe globals sensor");
+    std::array<std::byte,darkness::kBytes> body{};
+    const auto wipe=optional_integer_argument(state,"wipe_seconds",-1);
+    if (wipe < -1 || wipe>3
+        || !darkness::encode(optional_boolean_argument(state,"enabled",false),body,static_cast<int>(wipe)))
+        return luaL_error(state,"darkness zone encoder failed");
+    return queue_slot_auth(state,slot,darkness::kSchema,darkness::kBits,body);
+}
+
+/** Native typed object filters: players, one object, and volume intersection. */
+[[nodiscard]] int slot_set_object_filter(lua_State* state) {
+    namespace auth = middleware::bap::activity_message::scriptable_auth;
+    const auto* handle=static_cast<const SlotHandle*>(luaL_checkudata(state,1,kSlotMetatable));
+    static constexpr std::array<std::string_view,4> fields{"players","target","inside","inside_any"};
+    refuse_unknown_arguments(state,fields);
+    SlotDefinition slot{};
+    if (!current_slot(state,*handle,slot) || slot.slotType!=34 || slot.authSchema!=auth::kType34Schema)
+        return luaL_error(state,"object filter requires an authored type-34 sensor");
+    auth::Type34Body body{};
+    lua_getfield(state, 2, "inside_any");
+    const bool volumes = !lua_isnil(state, -1);
+    if (volumes) {
+        luaL_checktype(state, -1, LUA_TTABLE);
+        const auto count = lua_rawlen(state, -1);
+        if (count == 0 || count > 5) return luaL_error(state, "inside_any requires 1..5 authored volumes");
+        for (std::size_t index = 1; index <= count; ++index) {
+            lua_rawgeti(state, -1, static_cast<lua_Integer>(index));
+            const auto* ref = static_cast<const SlotHandle*>(luaL_checkudata(state, -1, kSlotMetatable));
+            SlotDefinition volume{};
+            if (!current_slot(state, *ref, volume) || volume.slotType != 60)
+                return luaL_error(state, "inside_any requires authored type-60 volumes");
+            body.predicates[body.count++] = auth::Type34ModeFlagSlotRef{0, true,
+                {volume.registryKey, 60, static_cast<std::int16_t>(volume.slotIndex)}};
+            lua_pop(state, 1);
+        }
+    }
+    lua_pop(state, 1);
+    if (optional_boolean_argument(state,"players",false))
+        body.predicates[body.count++]=auth::Type34ModeOnlyB{static_cast<std::int8_t>(volumes ? 1 : 0)};
+    for (const auto name : {"target", "inside"}) {
+        lua_getfield(state,2,name);
+        if (!lua_isnil(state,-1)) {
+            const auto* ref=static_cast<const SlotHandle*>(luaL_checkudata(state,-1,kSlotMetatable));
+            SlotDefinition target{};
+            if (!current_slot(state,*ref,target) || target.slotType != (std::string_view(name)=="target" ? 4 : 60))
+                return luaL_error(state,"filter target must be type 4; inside must be type 60");
+            const auth::Type2LaneClientRef client{target.registryKey,static_cast<std::int8_t>(target.slotType),static_cast<std::int16_t>(target.slotIndex)};
+            if (std::string_view(name)=="target") body.predicates[body.count++]=auth::Type34ModeSlotRefC{0,client};
+            else body.predicates[body.count++]=auth::Type34ModeFlagSlotRef{1,false,client};
+        }
+        lua_pop(state,1);
+    }
+    std::array<std::byte,auth::kType34MaximumByteCount> bytes{};std::size_t written{},bits{};
+    if (!auth::encode_type34(body,bytes,written,bits)) return luaL_error(state,"object filter encoder failed");
+    return queue_slot_auth(state,slot,auth::kType34Schema,bits,std::span(bytes).first(written));
+}
+[[nodiscard]] int slot_set_mission_effect(lua_State* state) {
+    namespace effect=middleware::bap::activity_message::mission_effect;
+    namespace auth=middleware::bap::activity_message::scriptable_auth;
+    const auto* handle=static_cast<const SlotHandle*>(luaL_checkudata(state,1,kSlotMetatable));
+    static constexpr std::array<std::string_view,3> fields{"filter","enabled","revision"};
+    refuse_unknown_arguments(state,fields);
+    SlotDefinition slot{};
+    if (!current_slot(state,*handle,slot) || slot.slotType!=26 || slot.authSchema!=0x8080954BU)
+        return luaL_error(state,"mission effect requires an authored type-26 hop-on");
+    const bool enabled=optional_boolean_argument(state,"enabled",true);
+    auth::Type2LaneClientRef client{};
+    if (enabled) {
+        lua_getfield(state,2,"filter");
+        const auto* ref=static_cast<const SlotHandle*>(luaL_checkudata(state,-1,kSlotMetatable));
+        SlotDefinition filter{};
+        if (!current_slot(state,*ref,filter) || filter.slotType!=34) return luaL_error(state,"mission effect requires a type-34 filter");
+        client={filter.registryKey,34,static_cast<std::int16_t>(filter.slotIndex)};lua_pop(state,1);
+    }
+    const auto revision=optional_integer_argument(state,"revision",1);
+    if (revision<=0 || revision>INT32_MAX) return luaL_error(state,"effect revision must be positive");
+    std::array<std::byte,effect::kBytes> body{};std::size_t written{};
+    if (!effect::encode(client,enabled,static_cast<std::int32_t>(revision),body,written)) return luaL_error(state,"mission effect encoder failed");
+    return queue_slot_auth(state,slot,0x8080954BU,effect::kBits,body);
+}
+
+/** Binds an authored damage monitor to its exact object, with a retry revision. */
+[[nodiscard]] int slot_watch_damage(lua_State* state) {
+    const auto* handle = static_cast<const SlotHandle*>(luaL_checkudata(state,1,kSlotMetatable));
+    static constexpr std::array<std::string_view,2> fields{"target", "revision"};
+    refuse_unknown_arguments(state,fields);
+    SlotDefinition slot{}, target{};
+    if (!current_slot(state,*handle,slot) || slot.slotType != 20 || slot.authSchema != 0x80809563U)
+        return luaL_error(state,"damage watch requires an authored type-20 monitor");
+    lua_getfield(state,2,"target");
+    const auto* ref = static_cast<const SlotHandle*>(luaL_checkudata(state,-1,kSlotMetatable));
+    if (!current_slot(state,*ref,target) || !exact_object_slot(target))
+        return luaL_error(state,"damage target must be an authored type-4 object");
+    lua_pop(state,1);
+    const auto revision = optional_integer_argument(state,"revision",1);
+    if (revision <= 0 || revision > INT32_MAX) return luaL_error(state,"damage revision must be positive");
+    std::array<std::byte,11> body{}; std::size_t written{};
+    middleware::encoding::bits::Writer writer(body);
+    if (!writer.write(target.registryKey,32) || !writer.write(5,7)
+        || !writer.write(static_cast<std::uint16_t>(target.slotIndex) + 32768U,16)
+        || !writer.write(static_cast<std::uint32_t>(revision) + 0x80000000U,32)
+        || !writer.finish(written)) return luaL_error(state,"damage monitor encoder failed");
+    return queue_slot_auth(state,slot,0x80809563U,87,body);
+}
+
+/** Selects one authored section in a native music sensor's 128-bit mask. */
+[[nodiscard]] int slot_set_music_section(lua_State* state) {
+    namespace music = middleware::bap::activity_message::music_section;
+    const auto* handle = static_cast<const SlotHandle*>(luaL_checkudata(state, 1, kSlotMetatable));
+    static constexpr std::array<std::string_view, 2> declared{"section", "enabled"};
+    refuse_unknown_arguments(state, declared);
+    SlotDefinition slot{};
+    const auto section = checked_integer_argument(state, "section");
+    if (!current_slot(state, *handle, slot) || slot.slotType != 11 || slot.componentClass != music::kClass
+        || slot.authSchema != music::kSchema || !(slot.flags & format::kSlotSchemaJoinExact)
+        || section < 0 || section >= 128)
+        return luaL_error(state, "music requires an exact type-11 sensor and section index 0..127");
+    std::array<std::byte, music::kBytes> body{};
+    std::size_t written{};
+    if (!music::encode(static_cast<std::uint8_t>(section), optional_boolean_argument(state, "enabled", true), body, written))
+        return luaL_error(state, "music section encoder failed");
+    return queue_slot_auth(state, slot, music::kSchema, music::kBits, body);
+}
+
+/** Spawns authored entry zero and subscribes to accepted native player use. */
+[[nodiscard]] int slot_set_interactable_object(lua_State* state) {
+    namespace object = middleware::bap::activity_message::interactable_object;
+    const auto* handle = static_cast<const SlotHandle*>(luaL_checkudata(state,1,kSlotMetatable));
+    static constexpr std::array<std::string_view,3> declared{"generation", "track_owner", "active"};
+    refuse_unknown_arguments(state,declared);
+    SlotDefinition slot{};
+    if (!current_slot(state,*handle,slot) || !exact_object_slot(slot))
+        return luaL_error(state,"interaction requires an exact authored object");
+    const auto generation = optional_integer_argument(state,"generation",1);
+    if (generation <= 0 || generation > 0x7FFFFFFF)
+        return luaL_error(state,"object generation must be a positive int32");
+    const bool trackOwner = optional_boolean_argument(state,"track_owner",false);
+    std::array<std::byte,object::kOwnerBytes> body{};
+    std::size_t written{};
+    if (!object::encode(static_cast<std::int32_t>(generation),body,written,trackOwner,
+        optional_boolean_argument(state,"active",true)))
+        return luaL_error(state,"interactable object encoder failed");
+    return queue_slot_auth(state,slot,object::kSchema,trackOwner ? object::kOwnerBits : object::kBits,
+        std::span(body).first(written));
+}
+
+/** Enables the authored Ghost interaction without replacing its action hash. */
+[[nodiscard]] int slot_set_ghost_link(lua_State* state) {
+    namespace ghost = middleware::bap::activity_message::ghost_link;
+    const auto* handle = static_cast<const SlotHandle*>(luaL_checkudata(state, 1, kSlotMetatable));
+    static constexpr std::array<std::string_view, 2> declared{"generation", "enabled"};
+    refuse_unknown_arguments(state, declared);
+    SlotDefinition slot{};
+    if (!current_slot(state, *handle, slot) || slot.slotType != ghost::kSlotType
+        || slot.componentClass != ghost::kComponentClass || slot.authSchema != ghost::kAuthSchema
+        || (slot.flags & format::kSlotSchemaJoinExact) == 0) {
+        return luaL_error(state, "activity slot is not an exact Ghost-link sensor");
+    }
+    const lua_Integer generation = checked_integer_argument(state, "generation");
+    if (generation <= 0 || generation > 0x7FFFFFFF) {
+        return luaL_error(state, "Ghost-link generation must be a positive int32");
+    }
+    const bool enabled = optional_boolean_argument(state, "enabled", true);
+    std::array<std::byte, ghost::kByteCount> body{};
+    std::size_t written = 0;
+    if (!ghost::encode(static_cast<std::int32_t>(generation), enabled, body, written)) {
+        return luaL_error(state, "Ghost-link native encoder failed");
+    }
+    return queue_slot_auth(state, slot, ghost::kAuthSchema, ghost::kBitCount, body);
+}
+
 /** Names the player, the event area and the leave timeout one public-event sensor watches. */
 [[nodiscard]] int slot_set_public_event_state(lua_State* state) {
     const auto* const handle =
@@ -379,6 +589,156 @@ constexpr std::size_t kOccupancyAuthByteCount = 11;
         state, slot, scriptable_auth::kType71Schema, scriptable_auth::kType71BitCount, bytes);
 }
 
+/** Assigns a squad to a native combat objective and its selected task group. */
+[[nodiscard]] int slot_assign_combat_objective(lua_State* state) {
+    namespace objective = middleware::bap::activity_message::squad_objective;
+    const auto* handle = static_cast<const SlotHandle*>(luaL_checkudata(state, 1, kSlotMetatable));
+    static constexpr std::array<std::string_view, 4> declared{"objective", "revision", "task_group", "reserved"};
+    refuse_unknown_arguments(state, declared);
+    const auto reference = checked_argument<SlotHandle>(state, "objective", kSlotMetatable);
+    const auto revision = checked_integer_argument(state, "revision");
+    const auto group = checked_integer_argument(state, "task_group");
+    const bool reserved = optional_boolean_argument(state, "reserved", false);
+    SlotDefinition squad{}, target{};
+    if (!current_slot(state, *handle, squad) || squad.slotType != 1
+        || squad.componentClass != format::kSquadComponentClass
+        || squad.authSchema != objective::kSchema || !(squad.flags & format::kSlotSchemaJoinExact)
+        || !current_slot(state, reference, target) || !exact_objective_reset_slot(target)
+        || target.componentClass != 0x80808348 || squad.registryKey != target.registryKey)
+        return luaL_error(state, "combat objective requires exact squad/objective slots in the same registry");
+    if (revision <= 0 || revision > 0x7fffffff || group < -1 || group >= 24 || target.slotIndex > 32767)
+        return luaL_error(state, "combat objective revision, group or index is outside its native range");
+    std::array<std::byte, objective::kBytes> body{};
+    if (!objective::encode({target.registryKey, static_cast<std::uint32_t>(revision),
+                           static_cast<std::uint16_t>(target.slotIndex), static_cast<std::int32_t>(group), reserved}, body))
+        return luaL_error(state, "combat objective encoder failed");
+    return queue_slot_auth(state, squad, objective::kSchema, objective::kBits, body);
+}
+
+/** Creates a named actor and starts one package-authored movement path. */
+[[nodiscard]] int slot_play_actor_path(lua_State* state) {
+    namespace path = middleware::bap::activity_message::combatant_path;
+    const auto* handle = static_cast<const SlotHandle*>(luaL_checkudata(state, 1, kSlotMetatable));
+    static constexpr std::array<std::string_view, 3> declared{"generation", "revision", "path"};
+    refuse_unknown_arguments(state, declared);
+    const auto generation = checked_integer_argument(state, "generation");
+    const auto revision = checked_integer_argument(state, "revision");
+    const auto reference = checked_argument<SlotHandle>(state, "path", kSlotMetatable);
+    SlotDefinition actor{}, sequence{};
+    if (!current_slot(state, *handle, actor) || !exact_combatant_slot(actor)
+        || !current_slot(state, reference, sequence) || sequence.slotType != 58
+        || sequence.componentClass != 0x80807D9B || actor.objectTag != sequence.objectTag
+        || actor.registryKey != sequence.registryKey)
+        return luaL_error(state, "actor path requires an exact member and same-registry type-58 path");
+    if (generation <= 0 || generation > 0x7fffffff || revision <= 0 || revision > 0x7fffffff
+        || sequence.slotIndex > 32767)
+        return luaL_error(state, "actor path generation, revision or index is outside its native range");
+    std::array<std::byte, path::kBytes> body{};
+    if (!path::encode({static_cast<std::uint32_t>(generation), static_cast<std::uint32_t>(revision),
+                      sequence.registryKey, static_cast<std::uint16_t>(sequence.slotIndex)}, body))
+        return luaL_error(state, "actor path encoder failed");
+    return queue_slot_auth(state, actor, path::kSchema, path::kBits, body);
+}
+
+/** Runs an authored native custom action without recreating its actor. */
+[[nodiscard]] int slot_play_actor_action(lua_State* state) {
+    namespace action = middleware::bap::activity_message::combatant_action;
+    const auto* handle = static_cast<const SlotHandle*>(luaL_checkudata(state, 1, kSlotMetatable));
+    static constexpr std::array<std::string_view, 4> declared{"generation", "revision", "group", "action"};
+    refuse_unknown_arguments(state, declared);
+    const auto generation = checked_integer_argument(state, "generation");
+    const auto revision = checked_integer_argument(state, "revision");
+    const auto group = checked_integer_argument(state, "group");
+    const auto name = checked_integer_argument(state, "action");
+    SlotDefinition actor{};
+    if (!current_slot(state, *handle, actor) || !exact_combatant_slot(actor)
+        || generation <= 0 || generation > 0x7fffffff || revision <= 0 || revision > 0x7fffffff
+        || group < 0 || group > 0xffffffffLL || name <= 0 || name > 0xffffffffLL)
+        return luaL_error(state, "actor action requires an exact member and valid native identities");
+    std::array<std::byte, action::kBytes> body{};
+    if (!action::encode({static_cast<std::uint32_t>(generation), static_cast<std::uint32_t>(revision),
+                        static_cast<std::uint32_t>(group), static_cast<std::uint32_t>(name)}, body))
+        return luaL_error(state, "actor action encoder failed");
+    return queue_slot_auth(state, actor, action::kSchema, action::kBits, body);
+}
+
+/** Gives a reserved squad to the named actor's native passenger-delivery component. */
+[[nodiscard]] int slot_deliver_squad(lua_State* state) {
+    namespace delivery = middleware::bap::activity_message::combatant_delivery;
+    const auto* handle = static_cast<const SlotHandle*>(luaL_checkudata(state, 1, kSlotMetatable));
+    static constexpr std::array<std::string_view, 3> declared{"generation", "revision", "squad"};
+    refuse_unknown_arguments(state, declared);
+    const auto generation = checked_integer_argument(state, "generation");
+    const auto revision = checked_integer_argument(state, "revision");
+    const auto reference = checked_argument<SlotHandle>(state, "squad", kSlotMetatable);
+    SlotDefinition actor{}, squad{};
+    if (!current_slot(state, *handle, actor) || !exact_combatant_slot(actor)
+        || !current_slot(state, reference, squad) || squad.slotType != 1
+        || squad.componentClass != format::kSquadComponentClass
+        || !(squad.flags & format::kSlotSchemaJoinExact)
+        || actor.objectTag != squad.objectTag || actor.registryKey != squad.registryKey)
+        return luaL_error(state, "delivery requires an exact member and same-registry squad");
+    if (generation <= 0 || generation > 0x7fffffff || revision <= 0 || revision > 0x7fffffff
+        || squad.slotIndex > 32767)
+        return luaL_error(state, "delivery generation, revision or index is outside its native range");
+    std::array<std::byte, delivery::kBytes> body{};
+    if (!delivery::encode({static_cast<std::uint32_t>(generation), static_cast<std::uint32_t>(revision),
+                          squad.registryKey, static_cast<std::uint16_t>(squad.slotIndex)}, body))
+        return luaL_error(state, "delivery encoder failed");
+    return queue_slot_auth(state, actor, delivery::kSchema, delivery::kBits, body);
+}
+
+/** Sends one native manifest so multiple reserved squads share the same ship and unload. */
+[[nodiscard]] int slot_deliver_squads(lua_State* state) {
+    namespace delivery = middleware::bap::activity_message::combatant_delivery;
+    const auto* handle = static_cast<const SlotHandle*>(luaL_checkudata(state, 1, kSlotMetatable));
+    static constexpr std::array<std::string_view,3> declared{"generation","revision","squads"};
+    refuse_unknown_arguments(state, declared);
+    const auto generation = checked_integer_argument(state, "generation");
+    const auto revision = checked_integer_argument(state, "revision");
+    SlotDefinition actor{};
+    if (!current_slot(state,*handle,actor) || !exact_combatant_slot(actor)
+        || generation <= 0 || generation > 0x7fffffff || revision <= 0 || revision > 0x7fffffff)
+        return luaL_error(state,"delivery requires an exact actor and positive generation/revision");
+    lua_getfield(state,2,"squads"); luaL_checktype(state,-1,LUA_TTABLE);
+    const int list=lua_gettop(state); const auto count=lua_rawlen(state,list);
+    if (count==0 || count>delivery::kMaxSquads) return luaL_error(state,"delivery needs 1..8 reserved squads");
+    std::array<delivery::SquadReference,delivery::kMaxSquads> refs{};
+    for (std::size_t i=0;i<count;++i) {
+        lua_rawgeti(state,list,static_cast<lua_Integer>(i+1));
+        const auto* squadHandle=static_cast<const SlotHandle*>(luaL_checkudata(state,-1,kSlotMetatable));
+        SlotDefinition squad{};
+        if (!current_slot(state,*squadHandle,squad) || squad.slotType!=1
+            || squad.componentClass!=format::kSquadComponentClass || !(squad.flags & format::kSlotSchemaJoinExact)
+            || squad.registryKey!=actor.registryKey || squad.objectTag!=actor.objectTag || squad.slotIndex>32767)
+            return luaL_error(state,"delivery squads must be exact and in the actor's registry");
+        refs[i]={squad.registryKey,static_cast<std::uint16_t>(squad.slotIndex)};
+        lua_pop(state,1);
+    }
+    lua_pop(state,1);
+    std::array<std::byte,delivery::kMaxBytes> body{}; std::size_t written{},bits{};
+    if (!delivery::encode_many(static_cast<std::uint32_t>(generation),static_cast<std::uint32_t>(revision),
+        std::span(refs).first(count),body,written,bits)) return luaL_error(state,"invalid delivery manifest");
+    return queue_slot_auth(state,actor,delivery::kSchema,bits,std::span(body).first(written));
+}
+
+/** Disables the named actor after completion, including retained Auth on region reload. */
+[[nodiscard]] int slot_retire_actor(lua_State* state) {
+    namespace retire = middleware::bap::activity_message::combatant_retire;
+    const auto* handle = static_cast<const SlotHandle*>(luaL_checkudata(state, 1, kSlotMetatable));
+    static constexpr std::array<std::string_view, 1> declared{"generation"};
+    refuse_unknown_arguments(state, declared);
+    const auto generation = checked_integer_argument(state, "generation");
+    SlotDefinition actor{};
+    if (!current_slot(state, *handle, actor) || !exact_combatant_slot(actor)
+        || generation <= 0 || generation > 0x7fffffff)
+        return luaL_error(state, "actor retirement requires an exact member and positive generation");
+    std::array<std::byte, retire::kBytes> body{};
+    if (!retire::encode(static_cast<std::uint32_t>(generation), body))
+        return luaL_error(state, "actor retirement encoder failed");
+    return queue_slot_auth(state, actor, retire::kSchema, retire::kBits, body);
+}
+
 /** Binds one exact combatant to its package-authored squad member. */
 [[nodiscard]] int slot_bind_combatant_to_squad(lua_State* state) {
     const auto* const handle =
@@ -747,6 +1107,12 @@ constexpr std::size_t kOccupancyAuthByteCount = 11;
         }
     } else if (key == "flags") {
         lua_pushinteger(state, definition.flags);
+    } else if (key == "set_object_filter") {
+        lua_pushcfunction(state, &slot_set_object_filter);
+    } else if (key == "set_mission_effect") {
+        lua_pushcfunction(state, &slot_set_mission_effect);
+    } else if (key == "watch_damage") {
+        lua_pushcfunction(state, &slot_watch_damage);
     } else if (key == "set_object_active") {
         lua_pushcfunction(state, &slot_set_object_active);
     } else if (key == "set_channel") {
@@ -759,10 +1125,30 @@ constexpr std::size_t kOccupancyAuthByteCount = 11;
         lua_pushcfunction(state, &slot_set_directive);
     } else if (key == "clear_directives") {
         lua_pushcfunction(state, &slot_clear_directives);
+    } else if (key == "set_darkness_zone") {
+        lua_pushcfunction(state, &slot_set_darkness_zone);
+    } else if (key == "set_music_section") {
+        lua_pushcfunction(state, &slot_set_music_section);
+    } else if (key == "set_interactable_object") {
+        lua_pushcfunction(state, &slot_set_interactable_object);
+    } else if (key == "set_ghost_link") {
+        lua_pushcfunction(state, &slot_set_ghost_link);
     } else if (key == "set_engagement_state") {
         lua_pushcfunction(state, &slot_set_engagement_state);
     } else if (key == "set_public_event_state") {
         lua_pushcfunction(state, &slot_set_public_event_state);
+    } else if (key == "assign_combat_objective") {
+        lua_pushcfunction(state, &slot_assign_combat_objective);
+    } else if (key == "play_actor_path") {
+        lua_pushcfunction(state, &slot_play_actor_path);
+    } else if (key == "deliver_squad") {
+        lua_pushcfunction(state, &slot_deliver_squad);
+    } else if (key == "deliver_squads") {
+        lua_pushcfunction(state, &slot_deliver_squads);
+    } else if (key == "play_actor_action") {
+        lua_pushcfunction(state, &slot_play_actor_action);
+    } else if (key == "retire_actor") {
+        lua_pushcfunction(state, &slot_retire_actor);
     } else if (key == "bind_combatant_to_squad") {
         lua_pushcfunction(state, &slot_bind_combatant_to_squad);
     } else if (key == "run_atoms") {

+ 8 - 2
Sunrise/src/server/activity/mission/mission_script_lua_value_api.cpp

@@ -11,9 +11,10 @@
 namespace sunrise::server::activity::mission::lua_vm::detail {
 namespace {
 
-/** The two placement modes the squad Auth lane accepts. Every other encoding has no name. */
+/** Ordinary placement and native transport reservation; other encodings have no name. */
 constexpr std::uint8_t kSquadModeReinforce = 0;
 constexpr std::uint8_t kSquadModeReplace = 2;
+constexpr std::uint8_t kSquadModeReserve = 3;
 
 /** Lua index for a unit scalar: its value and the bounds it was clamped to. */
 [[nodiscard]] int unit_scalar_index(lua_State* state) {
@@ -66,8 +67,10 @@ constexpr std::uint8_t kSquadModeReplace = 2;
         push_squad_mode(state, kSquadModeReinforce);
     } else if (key == "replace") {
         push_squad_mode(state, kSquadModeReplace);
+    } else if (key == "reserve") {
+        push_squad_mode(state, kSquadModeReserve);
     } else if (key == "count") {
-        lua_pushinteger(state, 2);
+        lua_pushinteger(state, 3);
     } else {
         lua_pushnil(state);
     }
@@ -141,6 +144,9 @@ std::string_view squad_mode_name(std::uint8_t mode) noexcept {
     if (mode == kSquadModeReplace) {
         return "replace";
     }
+    if (mode == kSquadModeReserve) {
+        return "reserve";
+    }
     return "unknown";
 }
 

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

@@ -0,0 +1,43 @@
+#pragma once
+#include <cstdint>
+#include <span>
+#include "../../../middleware/bap/activity_message/sense_update.h"
+namespace sunrise::server::activity::mission {
+struct ObjectInteractionLevel final {
+    std::int32_t generation{};
+    bool generationKnown{}, interacted{}, interactionKnown{};
+    bool present{}, alive{}, stateKnown{}, ownerKnown{}, hasOwner{};
+    std::uint64_t ownerKey{};
+};
+// Do not combine a new object generation with a stale interaction latch. Replay of
+// an accepted true level is silent; the Lua encounter also retains its one-shot receipt.
+[[nodiscard]] inline bool update_object_interaction(ObjectInteractionLevel& level,
+    std::span<const middleware::bap::activity_message::sense_update::DecodedValue> values,
+    std::uint32_t root) noexcept {
+    const auto before = level;
+    for (const auto& v : values) {
+        if (!v.present || v.schemaRow != root || v.fieldOrdinal != 0) continue;
+        const auto generation = static_cast<std::int32_t>(v.signedValue);
+        if (level.generationKnown && generation < level.generation) return false;
+        if (!level.generationKnown || generation != level.generation) level = {};
+        level.generation = generation; level.generationKnown = true;
+    }
+    // Complete native object bodies clear an absent owner subscription; they are not deltas.
+    level.ownerKnown = false; level.hasOwner = false; level.ownerKey = 0;
+    for (const auto& v : values) {
+        if (v.present && v.schemaRow == root && v.fieldOrdinal == 1) level.alive = v.unsignedValue != 0;
+        if (v.present && v.schemaRow == root && v.fieldOrdinal == 2) {
+            level.present = v.unsignedValue != 0; level.stateKnown = true;
+        }
+        if (v.present && v.schemaRow == 0x80809ACCU && v.fieldOrdinal == 0) {
+            level.ownerKnown = true; level.hasOwner = v.unsignedValue != 0;
+        }
+        if (v.present && v.schemaRow == 0x80809ACCU && v.fieldOrdinal == 1) level.ownerKey = v.unsignedValue;
+        if (v.present && v.schemaRow == 0x80804FB7U && v.fieldOrdinal == 0) {
+            level.interacted = v.unsignedValue != 0; level.interactionKnown = true;
+        }
+    }
+    return level.generationKnown && level.generation > 0 && level.interactionKnown && level.interacted
+        && (!before.interactionKnown || !before.interacted || before.generation != level.generation);
+}
+}

+ 52 - 0
Sunrise/src/server/activity/mission/mission_script_player_sense.h

@@ -0,0 +1,52 @@
+#pragma once
+#include <cstdint>
+#include <span>
+#include "../../../middleware/bap/activity_message/sense_update.h"
+
+namespace sunrise::server::activity::mission {
+enum class PlayerLife : std::uint8_t { unknown, alive, dead };
+struct PlayerLifeObservation {
+    std::uint64_t playerKey{};
+    std::int32_t region{-1};
+    std::uint8_t known{};
+    bool loaded{}, settled{}, ghost{};
+    [[nodiscard]] PlayerLife life() const noexcept {
+        if (known!=31 || playerKey==0 || playerKey==UINT64_MAX
+            || region<0 || region>INT16_MAX || !loaded || !settled) return PlayerLife::unknown;
+        return ghost ? PlayerLife::dead : PlayerLife::alive;
+    }
+};
+// A decoded type13 body is a full native snapshot. An absent optional identity
+// or region invalidates old life evidence. Clear records on client generation changes.
+inline void update_player_life(PlayerLifeObservation& level,
+    std::span<const middleware::bap::activity_message::sense_update::DecodedValue> values) noexcept {
+    for (const auto& v:values) {
+        if (!v.present) {
+            if (v.schemaRow==0x808094DD && v.fieldOrdinal==0) { level.playerKey=0;level.known&=~1U; }
+            if (v.schemaRow==0x808094E4 && v.fieldOrdinal==0) { level.region=-1;level.known&=~2U; }
+            continue;
+        }
+        if (v.schemaRow==0x808094DD && v.fieldOrdinal==0) {
+            level.playerKey=v.unsignedValue; level.known|=1;
+        } else if (v.schemaRow==0x808094E4) {
+            switch (v.fieldOrdinal) {
+            case 0: level.region=static_cast<std::int32_t>(v.signedValue);level.known|=2;break;
+            case 2: level.loaded=v.unsignedValue!=0;level.known|=4;break;
+            case 4: level.settled=v.unsignedValue!=0;level.known|=8;break;
+            case 5: level.ghost=v.unsignedValue!=0;level.known|=16;break;
+            default: break;
+            }
+        }
+    }
+}
+struct FireteamLife {
+    std::uint16_t alive{},dead{},unknown{};
+    [[nodiscard]] bool all_dead() const noexcept { return dead!=0 && alive==0 && unknown==0; }
+    void add(PlayerLife life) noexcept {
+        if (life==PlayerLife::alive) ++alive;
+        else if (life==PlayerLife::dead) ++dead;
+        else ++unknown;
+    }
+    bool operator==(const FireteamLife&) const = default;
+};
+}

+ 238 - 41
Sunrise/src/server/activity/mission/mission_script_runtime.cpp

@@ -1,8 +1,5 @@
-/**
- * The mission-program instance table, the durable state commit, the timers and the service slice.
- * The service slice and every lifecycle entry point take the mission runtime lock.
- */
-
+#include "../../../middleware/bap/activity_message/sense_observation_packet.h"
+#include "mission_script_event_batch.h"
 #include "mission_script_runtime.h"
 
 #include <Windows.h>
@@ -120,7 +117,147 @@ void push_script_event(RuntimeInstance& instance, const host::Event& event) noex
     }
 }
 
-/** Retains the last VM stage and status shown on the panel. */
+namespace {
+
+enum class SourceStatus : std::uint8_t {
+    ready,
+    missing,
+    fileError,
+    tooLarge,
+};
+
+/** Stable result classes bound repeated attach logs. */
+enum class AttachResult : std::uint8_t {
+    none,
+    catalogUnavailable,
+    noActivityLink,
+    sdkStatus,
+    generatedWorldStatus,
+    capacity,
+    noScript,
+    scriptFileError,
+    sourceTooLarge,
+    programError,
+    ready,
+};
+
+/** One extra slot retains a capacity refusal beyond all runtime slots. */
+constexpr std::size_t kAttachDiagnosticCapacity = host::kInstanceCapacity + 1;
+
+/** One binding's last attach result limits repeated gate logs. */
+struct AttachDiagnostic final {
+    state::activity::SessionBinding binding{};
+    sdk::Status sdkStatus{sdk::Status::notReady};
+    generated::BindStatus generatedWorldStatus{generated::BindStatus::invalidBoundView};
+    std::uint32_t activityRow{format::kAbsentIndex};
+    AttachResult result{AttachResult::none};
+    bool occupied{};
+};
+
+/** One queued mission event and the retry bookkeeping the drain uses. */
+struct PendingMissionEvent final {
+    host::Event event{};
+    host::SenseObservationSnapshot sense{};
+    host::ClientMessageSnapshot clientMessage{};
+    std::uint64_t firstAttempt{};
+    std::uint64_t nextAttempt{};
+    std::uint32_t attempts{};
+    bool missionSequenceObserved{};
+    bool senseAvailable{};
+    bool clientMessageAvailable{};
+    bool callbackEligible{};
+    bool eligibilityResolved{};
+    bool occupied{};
+};
+
+/** One explicit reload may replace the source hash for its exact binding. */
+struct ReloadAuthorization final {
+    state::activity::SessionBinding binding{};
+    mission_state::ProgramKey program{};
+    bool occupied{};
+};
+
+std::array<RuntimeInstance, host::kInstanceCapacity> g_instances{};
+
+[[nodiscard]] PlayerLife own_player_life(const RuntimeInstance& instance) noexcept {
+    if (!instance.occupied || instance.playerLifeGeneration!=instance.view.activityClientGeneration)
+        return PlayerLife::unknown;
+    for (const auto& life:instance.playerLife)
+        if (life.playerKey==instance.playerKey && instance.playerKey!=0) return life.life();
+    return PlayerLife::unknown;
+}
+
+// A private mission's committed destination peers form its joined party. Public
+// activity co-residents must never be treated as a fireteam. Missing or loading
+// party members count as unknown and prevent the all-dead edge.
+void publish_fireteam_life(std::uint64_t now) noexcept {
+    for (auto& instance:g_instances) {
+        if (!instance.occupied || instance.publicTarget || !instance.sessionRosterObserved
+            || instance.programStatus!=ProgramStatus::loaded) continue;
+        FireteamLife counts{};
+        counts.add(own_player_life(instance));
+        for (const auto& peer:instance.sessionRoster) {
+            if (!peer.used) continue;
+            PlayerLife life=PlayerLife::unknown;
+            for (const auto& candidate:g_instances) {
+                if (candidate.occupied && !candidate.publicTarget
+                    && candidate.view.binding.sessionId==peer.sessionId
+                    && candidate.view.binding.createdRevision==peer.createdRevision) {
+                    life=own_player_life(candidate);break;
+                }
+            }
+            counts.add(life);
+        }
+        if (instance.fireteamLifePublished && counts==instance.lastFireteamLife) continue;
+        instance.lastFireteamLife=counts;instance.fireteamLifePublished=true;
+        host::Event event{};
+        event.kind=host::EventKind::fireteamState;event.binding=instance.view.binding;
+        event.sequence=instance.missionStateRevision;
+        event.sourceGeneration=instance.view.activityClientGeneration;
+        event.missionSequence=instance.lastMissionSequence;event.tick=now;
+        event.fireteamAlive=counts.alive;event.fireteamDead=counts.dead;event.fireteamUnknown=counts.unknown;
+        push_script_event(instance,event);
+        std::array<char,96> fields{};
+        const int length=std::snprintf(fields.data(),fields.size(),"alive=%u dead=%u unknown=%u",
+            unsigned(counts.alive),unsigned(counts.dead),unsigned(counts.unknown));
+        if (length>0) log_line(core::log::Level::info,&instance,"fireteam_life","changed",
+            {fields.data(),static_cast<std::size_t>(length)});
+    }
+}
+
+void observe_player_life(RuntimeInstance& instance, const host::SenseObservationSnapshot& sense) noexcept {
+    if (instance.playerLifeGeneration!=sense.sourceGeneration) {
+        instance.playerLife={};instance.playerLifeGeneration=sense.sourceGeneration;
+    }
+    for (std::size_t i=0;i<sense.observationCount;++i) {
+        const auto& observation=sense.observations[i];
+        if (observation.key.slotType!=13 || observation.key.senseSchema!=0x80804F2F
+            || observation.key.objectTag!=0x80FEB3DC || observation.key.slotIndex<5
+            || observation.key.slotIndex>=21 || observation.firstValue>sense.valueCount
+            || observation.valueCount>sense.valueCount-observation.firstValue) continue;
+        update_player_life(instance.playerLife[observation.key.slotIndex-5],
+            std::span(sense.values).subspan(observation.firstValue,observation.valueCount));
+    }
+}
+std::array<AttachDiagnostic, kAttachDiagnosticCapacity> g_attachDiagnostics{};
+std::vector<PendingMissionEvent> g_pendingMissionEvents{};
+std::array<ReloadAuthorization, host::kInstanceCapacity> g_reloadAuthorizations{};
+std::array<char, lua_vm::kSourceByteCapacity> g_source{};
+core::path::Buffer g_scriptRoot{};
+std::array<char, 1024> g_sdkLuaSearchPath{};
+host::EventCursor g_eventCursor{};
+host::MissionInputCursor g_missionInputCursor{};
+bool g_enabled{};
+bool g_pathReady{};
+SRWLOCK g_lock{SRWLOCK_INIT};
+
+template <std::size_t Capacity>
+void copy_text(std::array<char, Capacity>& output, std::string_view value) noexcept {
+    output = {};
+    const std::size_t length = (std::min)(value.size(), output.size() - 1);
+    std::copy_n(value.data(), length, output.data());
+}
+
 void note_vm_status(RuntimeInstance& instance,
                     std::string_view stage,
                     std::string_view status) noexcept {
@@ -182,11 +319,15 @@ void clear_instance(RuntimeInstance& instance, bool clearPending) noexcept {
     instance.startPending = false;
     instance.timerPending = false;
     instance.triggerOccupancy = {};
+    instance.ghostObservations = {};
+    instance.actorPathObservations = {};
     instance.squadObservations = {};
     instance.sceneObservations = {};
     instance.objectiveObservations = {};
     instance.sessionRoster = {};
     instance.sessionRosterObserved = false;
+    instance.playerLife={};instance.playerLifeGeneration=0;
+    instance.lastFireteamLife={};instance.fireteamLifePublished=false;
     std::vector<host::Event>{}.swap(instance.scriptEvents);
     instance.firstScriptEventAttempt = 0;
     instance.nextScriptEventAttempt = 0;
@@ -678,6 +819,7 @@ void synchronize_instances(std::uint64_t now) noexcept {
         }
     }
     const sdk::Snapshot catalog = sdk::snapshot();
+    publish_fireteam_life(now);
     for (std::size_t index = 0; index < diagnostics.instanceCount; ++index) {
         if (diagnostics.instances[index].active) {
             attach_instance(diagnostics.instances[index], catalog, now);
@@ -746,7 +888,14 @@ void service_pending_starts(std::uint64_t now) noexcept {
     case host::EventKind::sessionLeft:
     case host::EventKind::playerTrigger:
     case host::EventKind::cinematicStarted:
+    case host::EventKind::cinematicSkipRequested:
     case host::EventKind::cinematicTerminated:
+    case host::EventKind::actorPathState:
+    case host::EventKind::damageState:
+    case host::EventKind::objectState:
+    case host::EventKind::fireteamState:
+    case host::EventKind::objectInteracted:
+    case host::EventKind::ghostLinkState:
         return false;
     default:
         return true;
@@ -779,11 +928,16 @@ void service_pending_starts(std::uint64_t now) noexcept {
            || event.kind == host::EventKind::sessionLeft
            || event.kind == host::EventKind::playerTrigger
            || event.kind == host::EventKind::cinematicStarted
+           || event.kind == host::EventKind::actorPathState
+           || event.kind == host::EventKind::damageState
+           || event.kind == host::EventKind::objectState
+           || event.kind == host::EventKind::fireteamState
+           || event.kind == host::EventKind::objectInteracted
+           || event.kind == host::EventKind::ghostLinkState
+           || event.kind == host::EventKind::cinematicSkipRequested
            || event.kind == host::EventKind::cinematicTerminated
            || delivery_lifecycle_event(event.kind)
-           || (event.kind == host::EventKind::senseUpdate
-               && event.senseDecodeStatus
-                      == middleware::bap::activity_message::sense_update::DecodeStatus::complete);
+           || event.has_sense_observations();
 }
 
 /** Faults the instance unless the ordered mission input arrives with no gap, starting at one. */
@@ -816,12 +970,17 @@ void service_pending_starts(std::uint64_t now) noexcept {
     return false;
 }
 
-/** Records the typed Ghost-link fields while its interaction semantics are being verified. */
-void log_ghost_link_sense(const RuntimeInstance& instance,
+/** Records bounded Ember bridge/console fields while interaction semantics are verified. */
+void log_ember_interaction_sense(RuntimeInstance& instance,
                           const host::SenseObservationSnapshot& sense) noexcept {
     for (std::size_t index = 0; index < sense.observationCount; ++index) {
         const auto& observation = sense.observations[index];
-        if (observation.key.slotType != 65 || observation.key.senseSchema != 0x80804D3EU
+        const bool console = observation.key.slotType == 65
+                             && observation.key.senseSchema == 0x80804D3EU;
+        const bool bridge = observation.key.slotType == 23 && observation.key.slotIndex < 6
+                            && observation.key.senseSchema == 0x80804F47U;
+        if (observation.key.registryKey != 0xF6FFB59EU || (!console && !bridge)
+            || instance.emberInteractionReports >= 256
             || observation.firstValue > sense.valueCount
             || observation.valueCount > sense.valueCount - observation.firstValue) {
             continue;
@@ -831,6 +990,15 @@ void log_ghost_link_sense(const RuntimeInstance& instance,
             if (!value.present || value.schemaRow != observation.key.schemaRow) {
                 continue;
             }
+            if (value.fieldOrdinal >= 9) continue;
+            const std::size_t field = (console ? 54U : observation.key.slotIndex * 9U)
+                                      + value.fieldOrdinal;
+            const std::uint64_t bit = std::uint64_t{1} << field;
+            if ((instance.emberInteractionSeen & bit) != 0
+                && instance.emberInteractionValues[field] == value.unsignedValue) continue;
+            instance.emberInteractionSeen |= bit;
+            instance.emberInteractionValues[field] = value.unsignedValue;
+            if (instance.emberInteractionReports++ >= 256) return;
             std::array<char, 192> fields{};
             const int written = std::snprintf(
                 fields.data(),
@@ -846,7 +1014,7 @@ void log_ghost_link_sense(const RuntimeInstance& instance,
             if (written > 0) {
                 log_line(core::log::Level::info,
                          &instance,
-                         "ghost_link_sense",
+                         console ? "ghost_link_sense" : "bridge_device_sense",
                          "observed",
                          {fields.data(),
                           (std::min)(static_cast<std::size_t>(written), fields.size() - 1)});
@@ -895,8 +1063,11 @@ void log_ghost_link_sense(const RuntimeInstance& instance,
         }
     }
     const lua_vm::CallStatus status = lua_vm::dispatch(instance.vm, event, clientMessage, now);
-    if (event.kind == host::EventKind::clientStateChanged && event.clientStateHasRegion) {
-        instance.activeRegion = event.regionIndex;
+    if (event.kind == host::EventKind::clientStateChanged) {
+        // Pending-region reports can name the next slice while the player still
+        // holds the old one. Match Lua's region choice and the committed after-image.
+        if (event.heldRegionIndex >= 0) instance.activeRegion = event.heldRegionIndex;
+        else if (event.currentRegionIndex >= 0) instance.activeRegion = event.currentRegionIndex;
     }
     if (firstAttempt && event.kind == host::EventKind::incidentReceived) {
         push_player_trigger(instance, event);
@@ -904,9 +1075,15 @@ void log_ghost_link_sense(const RuntimeInstance& instance,
     }
     if (event.kind == host::EventKind::senseUpdate && sense != nullptr) {
         if (firstAttempt) {
-            log_ghost_link_sense(instance, *sense);
+            log_ember_interaction_sense(instance, *sense);
+            observe_player_life(instance,*sense);
+            publish_fireteam_life(now);
         }
         push_trigger_edges(instance, *sense);
+        push_ghost_edges(instance, *sense);
+        push_object_interaction_edges(instance, *sense);
+        push_damage_edges(instance, *sense);
+        push_actor_path_edges(instance, *sense);
         push_squad_edges(instance, *sense);
         push_scene_edges(instance, *sense);
         push_objective_edges(instance, *sense);
@@ -1028,9 +1205,7 @@ void consume_delivery_events(std::uint64_t now) noexcept {
         return false;
     }
     clear_pending_event(*pending);
-    if (input.event.kind == host::EventKind::senseUpdate
-        && input.event.senseDecodeStatus
-               == middleware::bap::activity_message::sense_update::DecodeStatus::complete) {
+    if (input.event.has_sense_observations()) {
         pending->senseAvailable =
             host::mission_input_sense_snapshot(input.sequence, pending->sense);
     }
@@ -1355,30 +1530,52 @@ void retire_scriptless_inputs() noexcept {
     }
 }
 
-/**
- * Delivers at most one queued host-state event per instance, in arrival order.
- * These events carry no durable state, so each head is delivered once and then retired.
- */
+[[nodiscard]] bool has_pending_host_input(const RuntimeInstance& instance) noexcept {
+    return std::any_of(g_pendingMissionEvents.begin(),
+                       g_pendingMissionEvents.end(),
+                       [&instance](const PendingMissionEvent& pending) noexcept {
+                           return pending.occupied
+                                  && same_binding(pending.event.binding, instance.view.binding);
+                       });
+}
+
+[[nodiscard]] bool earlier_timer(const lua_vm::MissionTimer& left,
+                                 const lua_vm::MissionTimer& right) noexcept {
+    return left.deadlineTick < right.deadlineTick
+           || (left.deadlineTick == right.deadlineTick && left.sequence < right.sequence);
+}
+
+/** Drain ordered derived events until output must commit or the batch budget is spent. */
 void service_script_events(std::uint64_t now) noexcept {
     for (RuntimeInstance& instance : g_instances) {
-        if (!instance.occupied || instance.scriptEventRead >= instance.scriptEvents.size()
-            || instance.programStatus != ProgramStatus::loaded || instance.startPending) {
-            continue;
-        }
-        lua_vm::Intent pendingIntent{};
-        if (instance.deliveryStage != DeliveryStage::idle
-            || lua_vm::pending_intent(instance.vm, pendingIntent)) {
-            continue;
-        }
-        const bool firstAttempt = instance.scriptEventAttempts == 0;
-        if (firstAttempt) {
-            instance.firstScriptEventAttempt = now;
-        }
-        ++instance.scriptEventAttempts;
-        host::Event& head = instance.scriptEvents[instance.scriptEventRead];
-        head.tick = now;
-        static_cast<void>(dispatch_event(instance, head, nullptr, nullptr, firstAttempt, now));
-        retire_script_event(instance);
+        drain_script_event_batch([&] {
+            if (!instance.occupied || instance.scriptEventRead >= instance.scriptEvents.size()
+                || instance.programStatus != ProgramStatus::loaded || instance.startPending) {
+                return false;
+            }
+            lua_vm::Intent pendingIntent{};
+            return instance.deliveryStage == DeliveryStage::idle
+                   && !lua_vm::pending_intent(instance.vm, pendingIntent);
+        }, [&] {
+            const bool firstAttempt = instance.scriptEventAttempts == 0;
+            if (firstAttempt) instance.firstScriptEventAttempt = now;
+            ++instance.scriptEventAttempts;
+            host::Event& head = instance.scriptEvents[instance.scriptEventRead];
+            if (head.kind == host::EventKind::actorPathState) {
+                std::array<char, 128> fields{};
+                const int length = std::snprintf(fields.data(), fields.size(),
+                    "slot=%u queued_ms=%llu remaining=%zu",
+                    static_cast<unsigned>(head.firstSlotIndex),
+                    static_cast<unsigned long long>(now >= head.tick ? now - head.tick : 0),
+                    instance.scriptEvents.size() - instance.scriptEventRead);
+                if (length > 0 && static_cast<std::size_t>(length) < fields.size())
+                    log_line(core::log::Level::info, &instance, "actor_callback", "dispatch",
+                             {fields.data(), static_cast<std::size_t>(length)});
+            }
+            head.tick = now;
+            static_cast<void>(dispatch_event(instance, head, nullptr, nullptr, firstAttempt, now));
+            retire_script_event(instance);
+        });
     }
 }
 

+ 3 - 0
Sunrise/src/server/activity/mission/mission_script_runtime_delivery.cpp

@@ -235,6 +235,9 @@ void complete_delivery(RuntimeInstance& instance) noexcept {
     case lua_vm::IntentKind::setLifetime:
         result = "lifetime_staged";
         break;
+    case lua_vm::IntentKind::restartCheckpoint:
+        result = "checkpoint_staged";
+        break;
     case lua_vm::IntentKind::fireTrigger:
         result = "trigger_staged";
         break;

+ 29 - 0
Sunrise/src/server/activity/mission/mission_script_runtime_dispatch.cpp

@@ -7,6 +7,7 @@
 
 #include "../../../state/activity/membership/activity_membership_query.h"
 #include "../../../state/build_data/runtime.h"
+#include "../../../state/build_data/spawn_sets/spawn_set_catalog.h"
 #include "../activity_sdk_device_runtime.h"
 #include "../activity_sdk_lifetime_runtime.h"
 #include "../activity_sdk_mission_runtime.h"
@@ -312,6 +313,34 @@ void dispatch_intent(RuntimeInstance& instance, std::uint64_t now) noexcept {
         static_cast<void>(complete_local_effect(instance, "state_selected"));
         return;
     }
+    case lua_vm::IntentKind::restartCheckpoint: {
+        if (intent.checkpointReleaseRequest) {
+            if (::sunrise::state::activity::membership::release_hard_wipe(instance.view.binding,
+                    intent.checkpointReleaseRequest))
+                static_cast<void>(complete_local_effect(instance,"checkpoint_reset_ready"));
+            else refuse_delivery(instance,"checkpoint_refused","wipe_not_active",host::EffectOutcome::refused);
+            return;
+        }
+        namespace data=::sunrise::state::build_data;
+        const auto& destination=instance.view.binding.destination;
+        const std::string_view package(reinterpret_cast<const char*>(destination.packageName.data()),
+            destination.packageNameLength);
+        data::scenarios::Definition layout{};
+        data::spawn_sets::NameHash spawn{};
+        if (instance.publicTarget || !instance.lastFireteamLife.all_dead()
+            || instance.activeRegion!=intent.effectiveRegion
+            || !data::find_scenario_layout(package,layout)
+            || !data::spawn_sets::find_hash({layout.spawnStem.data(),layout.spawnStemLength},
+                intent.checkpointSpawnHash,spawn) || !spawn.pointCount) {
+            refuse_delivery(instance,"checkpoint_refused","party_or_spawn_unavailable",host::EffectOutcome::refused);
+            return;
+        }
+        if (::sunrise::state::activity::membership::arm_hard_wipe(instance.view.binding,
+                intent.requestKey,intent.effectiveRegion,intent.checkpointSpawnHash))
+            static_cast<void>(complete_local_effect(instance,"checkpoint_wipe_armed"));
+        else refuse_delivery(instance,"checkpoint_refused","membership_busy",host::EffectOutcome::refused);
+        return;
+    }
     case lua_vm::IntentKind::placeSquad: {
         const std::span<const std::int32_t> counts(intent.squadCounts.data(), intent.squadCount);
         const auto mode = static_cast<squad_auth::Mode>(intent.squadMode);

+ 235 - 47
Sunrise/src/server/activity/mission/mission_script_runtime_edges.cpp

@@ -1,6 +1,7 @@
 #include <algorithm>
 #include <array>
 #include <cstddef>
+#include <cmath>
 #include <cstdint>
 #include <cstdio>
 #include <span>
@@ -8,6 +9,7 @@
 #include "../../../state/activity/transactions/internal.h"
 #include "../activity_sdk_mission_runtime.h"
 #include "mission_script_cinematic.h"
+#include "mission_script_squad_sense.h"
 #include "mission_script_player_trigger.h"
 #include "mission_script_runtime_internal.h"
 
@@ -27,11 +29,7 @@ constexpr std::uint16_t kTriggerAllOrdinal = 1;
 constexpr std::uint16_t kTriggerCountOrdinal = 2;
 constexpr std::uint16_t kTriggerThresholdOrdinal = 3;
 /** Root ordinals of the two live squad fields. The rest of the root is inert on this build. */
-constexpr std::uint16_t kSquadAliveOrdinal = 3;
 constexpr std::uint16_t kSquadRemovalOrdinal = 8;
-/** The per-slot counts are nested: a four-bit length, then the counts themselves. */
-constexpr std::uint8_t kSquadSlotLengthWidth = 4;
-constexpr std::uint8_t kSquadSlotCountWidth = 32;
 /** Root ordinal of the authored scene's activation token. Signed, bias -2^31. */
 constexpr std::uint16_t kSceneTokenOrdinal = 0;
 /** Root ordinal of the authored scene's completion latch. A zero-width boolean. */
@@ -95,32 +93,6 @@ sense_value(std::span<const sense_values::DecodedValue> body,
     return event;
 }
 
-/**
- * Copies the squad's per-slot counts out of its nested length-prefixed list.
- * The length and the counts sit in nested schemas, so they are the values the root does not own.
- * Their declared widths tell the length apart from the counts.
- * @return Live entries written into output.
- */
-[[nodiscard]] std::uint8_t
-squad_slot_counts(std::span<const sense_values::DecodedValue> body,
-                  std::uint32_t rootSchemaRow,
-                  std::array<std::int32_t, host::kSquadSlotCapacity>& output) noexcept {
-    output = {};
-    std::size_t length = 0;
-    std::size_t written = 0;
-    for (const sense_values::DecodedValue& value : body) {
-        if (value.schemaRow == rootSchemaRow || !value.present) {
-            continue;
-        }
-        if (value.width == kSquadSlotLengthWidth) {
-            length = static_cast<std::size_t>(value.unsignedValue);
-        } else if (value.width == kSquadSlotCountWidth && written < output.size()) {
-            output[written++] = static_cast<std::int32_t>(value.signedValue);
-        }
-    }
-    return static_cast<std::uint8_t>((std::min)(written, length));
-}
-
 /** @return The retained record for one squad, allocating on a first observation. */
 [[nodiscard]] SquadObservation* find_squad(RuntimeInstance& instance,
                                            const host::SenseObservationKey& key) noexcept {
@@ -134,6 +106,13 @@ squad_slot_counts(std::span<const sense_values::DecodedValue> body,
             spare = &retained;
         }
     }
+    if (spare == nullptr) {
+        for (SquadObservation& retained : instance.squadObservations) {
+            bool empty = retained.aliveCount == 0;
+            for (const auto count : retained.slotCounts) empty = empty && count == 0;
+            if (empty) { retained = {}; spare = &retained; break; }
+        }
+    }
     if (spare == nullptr) {
         log_line(core::log::Level::warn, &instance, "squad", "watch_capacity");
         return nullptr;
@@ -280,8 +259,18 @@ void push_player_trigger(RuntimeInstance& instance, const host::Event& incident)
     event.firstSlotType = source.slotType;
     event.slotSenseSchema = 0;
     event.playerTriggerRegistryKey = source.volumeRegistryKey;
-    event.playerTriggerSlotType = static_cast<std::int8_t>(source.volumeSlotType);
-    event.playerTriggerSlotIndex = static_cast<std::int16_t>(source.volumeSlotIndex);
+    event.playerTriggerSlotType = source.volumeSlotType;
+    std::array<char, 96> fields{};
+    const int written = std::snprintf(fields.data(), fields.size(),
+        "registry=%08x slot=%u volume_registry=%08x volume_slot=%u",
+        source.registryKey, static_cast<unsigned>(source.slotIndex), source.volumeRegistryKey,
+        static_cast<unsigned>(source.volumeSlotIndex));
+    if (written > 0) {
+        log_line(core::log::Level::info, &instance, "player_trigger", "resolved",
+            {fields.data(), (std::min)(static_cast<std::size_t>(written), fields.size() - 1)});
+    }
+
+    event.playerTriggerSlotIndex = source.volumeSlotIndex;
     push_script_event(instance, event);
 }
 
@@ -331,13 +320,15 @@ void push_cinematic(RuntimeInstance& instance, const host::Event& incident) noex
              incident.cinematicSignal
                      == middleware::bap::activity_message::cinematic_incident::Signal::started
                  ? "started"
-                 : "terminated",
+                 : incident.cinematicSignal == middleware::bap::activity_message::cinematic_incident::Signal::skipRequested
+                     ? "skip_requested" : "terminated",
              detail);
     host::Event event = incident;
     event.kind = incident.cinematicSignal
                          == middleware::bap::activity_message::cinematic_incident::Signal::started
                      ? host::EventKind::cinematicStarted
-                     : host::EventKind::cinematicTerminated;
+                     : incident.cinematicSignal == middleware::bap::activity_message::cinematic_incident::Signal::skipRequested
+                         ? host::EventKind::cinematicSkipRequested : host::EventKind::cinematicTerminated;
     event.firstRegistryKey = source.registryKey;
     event.slotObjectTag = source.objectTag;
     event.firstSlotIndex = source.slotIndex;
@@ -402,6 +393,185 @@ void push_trigger_edges(RuntimeInstance& instance,
     }
 }
 
+/** Retains the native movement program revision and completed-command count. */
+void push_actor_path_edges(RuntimeInstance& instance,
+                      const host::SenseObservationSnapshot& sense) noexcept {
+    for (std::size_t i = 0; i < sense.observationCount; ++i) {
+        const auto& observation = sense.observations[i];
+        if (observation.key.slotType != 2 || observation.key.senseSchema != 0x80807DA2U
+            || observation.valueCount == 0 || observation.firstValue > sense.valueCount
+            || observation.valueCount > sense.valueCount - observation.firstValue) continue;
+        ActorPathObservation* slot = nullptr;
+        ActorPathObservation* spare = nullptr;
+        for (auto& retained : instance.actorPathObservations) {
+            if (retained.used && retained.registryKey == observation.key.registryKey
+                && retained.objectTag == observation.key.objectTag
+                && retained.slotIndex == observation.key.slotIndex) { slot = &retained; break; }
+            if (!retained.used && spare == nullptr) spare = &retained;
+        }
+        if (slot == nullptr) {
+            if (spare == nullptr) continue;
+            slot = spare;
+            slot->used = true;
+            slot->registryKey = observation.key.registryKey;
+            slot->objectTag = observation.key.objectTag;
+            slot->slotIndex = observation.key.slotIndex;
+        }
+        if (!update_actor_path_level(slot->level,
+            std::span(&sense.values[observation.firstValue], observation.valueCount),
+            observation.key.schemaRow)) continue;
+        auto event = sense_edge_event(instance, observation);
+        event.kind = host::EventKind::actorPathState;
+        event.actorGeneration = slot->level.generation;
+        event.actorPathRevision = slot->level.revision;
+        event.actorPathState = slot->level.state;
+        event.actorDeliveryRevision = slot->level.deliveryRevision;
+        event.actorDeliveryState = slot->level.deliveryState;
+        event.actorDeliveryKnown = (slot->level.present & 48) == 48;
+        event.actorDead = slot->level.dead;
+        std::array<char, 192> details{};
+        const int count = std::snprintf(details.data(), details.size(),
+            "registry=%08x slot=%u generation=%d revision=%d state=%d dead=%u delivery_revision=%d delivery_state=%d received_tick=%llu",
+            slot->registryKey, slot->slotIndex, slot->level.generation,
+            slot->level.revision, slot->level.state, slot->level.dead ? 1U : 0U,
+            event.actorDeliveryKnown ? slot->level.deliveryRevision : -1,
+            event.actorDeliveryKnown ? slot->level.deliveryState : -1,
+            static_cast<unsigned long long>(observation.tick));
+        if (count > 0 && static_cast<std::size_t>(count) < details.size())
+            log_line(core::log::Level::info, &instance, "actor_path", "changed",
+                     {details.data(), static_cast<std::size_t>(count)});
+        push_script_event(instance, event);
+    }
+}
+
+// Native A774B0 publishes the bound object's health/shield fractions and the Auth revision.
+void push_damage_edges(RuntimeInstance& instance, const host::SenseObservationSnapshot& sense) noexcept {
+    for (std::size_t i = 0; i < sense.observationCount; ++i) {
+        const auto& o = sense.observations[i];
+        if (o.key.slotType != 20 || o.key.senseSchema != 0x80809562U
+            || o.firstValue > sense.valueCount || o.valueCount > sense.valueCount - o.firstValue) continue;
+        float health = -1.0F, shield = -1.0F;
+        std::int32_t revision{}; bool revisionKnown{};
+        for (const auto& v : std::span(&sense.values[o.firstValue], o.valueCount)) {
+            if (!v.present || v.schemaRow != o.key.schemaRow) continue;
+            if (v.fieldOrdinal == 0) health = v.realValue;
+            else if (v.fieldOrdinal == 1) shield = v.realValue;
+            else if (v.fieldOrdinal == 2) { revision = static_cast<std::int32_t>(v.signedValue); revisionKnown = true; }
+        }
+        if (!revisionKnown || revision <= 0 || !std::isfinite(health) || !std::isfinite(shield)) continue;
+        DamageObservation* found{}; DamageObservation* spare{};
+        for (auto& row : instance.damageObservations) {
+            if (row.used && row.registryKey == o.key.registryKey && row.objectTag == o.key.objectTag
+                && row.slotIndex == o.key.slotIndex) { found = &row; break; }
+            if (!row.used && spare == nullptr) spare = &row;
+        }
+        if (found == nullptr) found = spare;
+        if (found == nullptr || (found->used && revision < found->revision)) continue;
+        if (found->used && found->revision == revision && found->health == health && found->shield == shield) continue;
+        *found = {o.key.registryKey, o.key.objectTag, o.key.slotIndex, revision, health, shield, true};
+        auto event = sense_edge_event(instance, o);
+        event.kind = host::EventKind::damageState;
+        event.damageHealth = health; event.damageShield = shield; event.damageRevision = revision;
+        std::array<char, 192> details{};
+        const int size = std::snprintf(details.data(), details.size(),
+            "registry=%08X slot=%u revision=%d health=%.4f shield=%.4f",
+            o.key.registryKey, o.key.slotIndex, revision, health, shield);
+        if (size > 0 && static_cast<std::size_t>(size) < details.size())
+            log_line(core::log::Level::info, &instance, "damage", "changed", {details.data(), static_cast<std::size_t>(size)});
+        push_script_event(instance, event);
+    }
+}
+
+void push_object_interaction_edges(RuntimeInstance& instance,
+                      const host::SenseObservationSnapshot& sense) noexcept {
+    for (std::size_t i = 0; i < sense.observationCount; ++i) {
+        const auto& observation = sense.observations[i];
+        if (observation.key.slotType != 4 || observation.key.senseSchema != 0x8080992EU
+            || observation.valueCount == 0 || observation.firstValue > sense.valueCount
+            || observation.valueCount > sense.valueCount - observation.firstValue) continue;
+        ObjectInteractionObservation* slot = nullptr;
+        ObjectInteractionObservation* spare = nullptr;
+        for (auto& retained : instance.objectInteractionObservations) {
+            if (retained.used && retained.registryKey == observation.key.registryKey
+                && retained.objectTag == observation.key.objectTag
+                && retained.slotIndex == observation.key.slotIndex) { slot = &retained; break; }
+            if (!retained.used && spare == nullptr) spare = &retained;
+        }
+        if (slot == nullptr) {
+            if (spare == nullptr) continue;
+            slot = spare;
+            slot->used = true;
+            slot->registryKey = observation.key.registryKey;
+            slot->objectTag = observation.key.objectTag;
+            slot->slotIndex = observation.key.slotIndex;
+        }
+        const auto before = slot->level;
+        const bool interacted = update_object_interaction(slot->level,
+            std::span(&sense.values[observation.firstValue], observation.valueCount),
+            observation.key.schemaRow);
+        const auto& level = slot->level;
+        auto event = sense_edge_event(instance, observation);
+        event.objectGeneration = level.generation;
+        event.objectPresent = level.present; event.objectAlive = level.alive;
+        event.objectOwnerKnown = level.ownerKnown; event.objectHasOwner = level.hasOwner;
+        event.objectOwnerKey = level.ownerKey;
+        if (level.generationKnown && level.generation > 0 && level.stateKnown
+            && (!before.stateKnown || before.generation != level.generation
+                || before.present != level.present || before.alive != level.alive
+                || before.ownerKnown != level.ownerKnown || before.hasOwner != level.hasOwner
+                || before.ownerKey != level.ownerKey)) {
+            event.kind = host::EventKind::objectState;
+            std::array<char, 192> details{};
+            const int size = std::snprintf(details.data(), details.size(),
+                "registry=%08X slot=%u generation=%d present=%u alive=%u owner_known=%u has_owner=%u",
+                observation.key.registryKey, observation.key.slotIndex, level.generation,
+                level.present, level.alive, level.ownerKnown, level.hasOwner);
+            if (size > 0 && static_cast<std::size_t>(size) < details.size())
+                log_line(core::log::Level::info, &instance, "object", "changed", {details.data(), static_cast<std::size_t>(size)});
+            push_script_event(instance, event);
+        }
+        if (interacted) {
+            event.kind = host::EventKind::objectInteracted;
+            push_script_event(instance, event);
+        }
+    }
+}
+
+void push_ghost_edges(RuntimeInstance& instance,
+                      const host::SenseObservationSnapshot& sense) noexcept {
+    for (std::size_t i = 0; i < sense.observationCount; ++i) {
+        const auto& observation = sense.observations[i];
+        if (observation.key.slotType != 65 || observation.key.senseSchema != 0x80804D3EU
+            || observation.valueCount == 0 || observation.firstValue > sense.valueCount
+            || observation.valueCount > sense.valueCount - observation.firstValue) continue;
+        GhostObservation* slot = nullptr;
+        GhostObservation* spare = nullptr;
+        for (auto& retained : instance.ghostObservations) {
+            if (retained.used && retained.registryKey == observation.key.registryKey
+                && retained.objectTag == observation.key.objectTag
+                && retained.slotIndex == observation.key.slotIndex) { slot = &retained; break; }
+            if (!retained.used && spare == nullptr) spare = &retained;
+        }
+        if (slot == nullptr) {
+            if (spare == nullptr) continue;
+            slot = spare;
+            slot->used = true;
+            slot->registryKey = observation.key.registryKey;
+            slot->objectTag = observation.key.objectTag;
+            slot->slotIndex = observation.key.slotIndex;
+        }
+        if (!update_ghost_level(slot->level,
+            std::span(&sense.values[observation.firstValue], observation.valueCount),
+            observation.key.schemaRow)) continue;
+        auto event = sense_edge_event(instance, observation);
+        event.kind = host::EventKind::ghostLinkState;
+        event.ghostGeneration = slot->level.generation;
+        event.ghostProgress = slot->level.progress;
+        event.ghostActive = slot->level.active;
+        push_script_event(instance, event);
+    }
+}
+
 /**
  * Raises the squad events derived from one msg 6 body.
  * The client publishes levels, so a first sighting only records them. A slot count that rose is the
@@ -418,29 +588,47 @@ void push_squad_edges(RuntimeInstance& instance,
         const std::span<const sense_values::DecodedValue> body(
             &sense.values[observation.firstValue], observation.valueCount);
         const std::uint32_t root = observation.key.schemaRow;
-        // An unchanged squad sends an empty delta, which must not read as a squad of zero.
-        if (sense_value(body, root, kSquadAliveOrdinal) == nullptr) {
-            continue;
-        }
-        const std::int32_t alive = sense_number(body, root, kSquadAliveOrdinal);
-        const bool removal = sense_flag(body, root, kSquadRemovalOrdinal);
-        std::array<std::int32_t, host::kSquadSlotCapacity> counts{};
-        const std::uint8_t countLength = squad_slot_counts(body, root, counts);
-
         SquadObservation* const squad = find_squad(instance, observation.key);
-        if (squad == nullptr) {
-            continue;
-        }
+        if (squad == nullptr) continue;
+        const bool costsChanged = update_squad_objective_costs(squad->objectiveCosts, body, root);
+        // Cost-only deltas retain combat counts; they must never manufacture a death.
+        std::int32_t alive = squad->aliveCount;
+        const bool hasAlive = read_squad_alive(body, root, alive);
+        const bool removal = sense_flag(body, root, kSquadRemovalOrdinal);
+        auto counts = squad->slotCounts;
+        auto countLength = squad->slotCountLength;
+        std::array<std::int32_t, host::kSquadSlotCapacity> incomingCounts{};
+        const auto incomingLength = read_squad_consumed_counts(body, incomingCounts);
+        if (incomingLength != 0) { counts = incomingCounts; countLength = incomingLength; }
+        if (!hasAlive && !costsChanged && incomingLength == 0) continue;
         const bool first = !squad->used;
         squad->used = true;
         const std::int32_t previousAlive = first ? 0 : squad->aliveCount;
-        const bool changed = first || squad->aliveCount != alive || squad->removalFlag != removal
+        const bool changed = costsChanged || first || squad->aliveCount != alive || squad->removalFlag != removal
                              || squad->slotCountLength != countLength
                              || squad->slotCounts != counts;
         if (!changed) {
             continue;
         }
+        if (instance.programStatus == ProgramStatus::loaded) {
+            std::int64_t consumed = 0;
+            for (std::size_t slot = 0; slot < countLength; ++slot) consumed += counts[slot];
+            std::array<char, 192> fields{};
+            const int written = std::snprintf(fields.data(), fields.size(),
+                "registry=%08x object=%08x slot=%u alive=%d previous=%d removal=%u first=%u consumed=%lld received_tick=%llu",
+                observation.key.registryKey, observation.key.objectTag,
+                static_cast<unsigned>(observation.key.slotIndex), alive, previousAlive,
+                static_cast<unsigned>(removal), static_cast<unsigned>(first),
+                static_cast<long long>(consumed), static_cast<unsigned long long>(observation.tick));
+            if (written > 0) {
+                log_line(core::log::Level::info, &instance, "squad_sense", "changed",
+                    {fields.data(), (std::min)(static_cast<std::size_t>(written), fields.size() - 1)});
+            }
+        }
         host::Event state = sense_edge_event(instance, observation);
+        state.squadObjectiveCosts = squad->objectiveCosts.values;
+        state.squadObjectiveCostMask = squad->objectiveCosts.known;
+        state.squadObjectiveRevision = squad->objectiveCosts.revision;
         state.squadAliveCount = alive;
         state.squadPreviousAliveCount = previousAlive;
         state.squadRemovalFlag = removal;

+ 50 - 1
Sunrise/src/server/activity/mission/mission_script_runtime_internal.h

@@ -1,3 +1,5 @@
+#include "mission_script_squad_sense.h"
+#include "mission_script_actor_path_sense.h"
 #pragma once
 
 #include <algorithm>
@@ -17,6 +19,9 @@
 #include "../host_runtime.h"
 #include "mission_script_runtime.h"
 #include "mission_script_vm.h"
+#include "mission_script_ghost_sense.h"
+#include "mission_script_object_sense.h"
+#include "mission_script_player_sense.h"
 
 // What the seven mission-runtime translation units share: the instance table and service slice,
 // the attach pipeline, the two Host feeds and the VM callback, the panel rows, the delivery state
@@ -75,7 +80,7 @@ constexpr std::size_t kSquadObservationCapacity = 160;
 /** Watched authored scenes retained per instance. */
 constexpr std::size_t kSceneObservationCapacity = 32;
 /** Watched objective sensors retained per instance. */
-constexpr std::size_t kObjectiveObservationCapacity = 8;
+constexpr std::size_t kObjectiveObservationCapacity = 32;
 /** One objective sensor carries this many objective blocks. */
 constexpr std::size_t kObjectiveCapacity = 24;
 /** One objective block carries this many task counters. */
@@ -95,7 +100,34 @@ struct TriggerOccupancy final {
 };
 
 /** Last squad counters seen for one watched object, so only a change raises an event. */
+struct ActorPathObservation final {
+    ActorPathLevel level{};
+    std::uint32_t registryKey{}, objectTag{};
+    std::uint16_t slotIndex{};
+    bool used{};
+};
+struct DamageObservation final {
+    std::uint32_t registryKey{}, objectTag{};
+    std::uint16_t slotIndex{};
+    std::int32_t revision{};
+    float health{-1.0F}, shield{-1.0F};
+    bool used{};
+};
+struct ObjectInteractionObservation final {
+    ObjectInteractionLevel level{};
+    std::uint32_t registryKey{}, objectTag{};
+    std::uint16_t slotIndex{};
+    bool used{};
+};
+struct GhostObservation final {
+    GhostLevel level{};
+    std::uint32_t registryKey{};
+    std::uint32_t objectTag{};
+    std::uint16_t slotIndex{};
+    bool used{};
+};
 struct SquadObservation final {
+    SquadObjectiveCosts objectiveCosts{};
     std::array<std::int32_t, host::kSquadSlotCapacity> slotCounts{};
     std::uint32_t registryKey{};
     std::uint32_t objectTag{};
@@ -163,10 +195,18 @@ struct RuntimeInstance final {
     std::uint64_t nextTimerAttempt{};
     std::array<TriggerOccupancy, kTriggerOccupancyCapacity> triggerOccupancy{};
     std::array<SquadObservation, kSquadObservationCapacity> squadObservations{};
+    std::array<GhostObservation, 8> ghostObservations{};
+    std::array<DamageObservation, 8> damageObservations{};
+    std::array<ObjectInteractionObservation, 64> objectInteractionObservations{};
+    std::array<ActorPathObservation, 64> actorPathObservations{};
     std::array<SceneObservation, kSceneObservationCapacity> sceneObservations{};
     std::array<ObjectiveObservation, kObjectiveObservationCapacity> objectiveObservations{};
     // The table is exactly as large as the session table, so it can never overflow.
     std::array<SessionRosterWatch, state::activity::kSessionCapacity> sessionRoster{};
+    std::array<PlayerLifeObservation, 16> playerLife{};
+    std::uint64_t playerLifeGeneration{};
+    FireteamLife lastFireteamLife{};
+    bool fireteamLifePublished{};
     /** Dynamically sized host-state reports waiting for this script, in arrival order. */
     std::vector<host::Event> scriptEvents{};
     std::uint64_t firstScriptEventAttempt{};
@@ -185,6 +225,10 @@ struct RuntimeInstance final {
     DeliveryStage deliveryStage{DeliveryStage::idle};
     /** The player key the bound link's message 5 binds, read at attach. */
     std::uint64_t playerKey{};
+    /** Bounded diagnostic budget for Ember bridge and console Sense fields. */
+    std::uint16_t emberInteractionReports{};
+    std::array<std::uint64_t, 64> emberInteractionValues{};
+    std::uint64_t emberInteractionSeen{};
     bool publicTarget{};
     bool missionStateBound{};
     bool missionStarted{};
@@ -217,6 +261,11 @@ void push_trigger_edges(RuntimeInstance& instance,
 void push_player_trigger(RuntimeInstance& instance, const host::Event& incident) noexcept;
 /** Raises one exact Type-6 start/finish edge from a decoded schema-0x808087BF msg-19 payload. */
 void push_cinematic(RuntimeInstance& instance, const host::Event& incident) noexcept;
+void push_actor_path_edges(RuntimeInstance& instance, const host::SenseObservationSnapshot& sense) noexcept;
+void push_damage_edges(RuntimeInstance& instance, const host::SenseObservationSnapshot& sense) noexcept;
+void push_object_interaction_edges(RuntimeInstance& instance, const host::SenseObservationSnapshot& sense) noexcept;
+void push_ghost_edges(RuntimeInstance& instance,
+                      const host::SenseObservationSnapshot& sense) noexcept;
 /** Raises the squad state, spawn and death events derived from one msg 6 body. */
 void push_squad_edges(RuntimeInstance& instance,
                       const host::SenseObservationSnapshot& sense) noexcept;

+ 79 - 0
Sunrise/src/server/activity/mission/mission_script_squad_sense.h

@@ -0,0 +1,79 @@
+#pragma once
+
+#include <cstdint>
+#include <array>
+#include <cmath>
+#include <span>
+#include "../../../middleware/bap/activity_message/sense_update.h"
+
+namespace sunrise::server::activity::mission {
+
+// Sense .11 is the consumed-request list at native squad +612. Deaths and
+// failed/cancelled placements advance it; a rise is not an actor spawn event.
+[[nodiscard]] inline std::uint8_t read_squad_consumed_counts(
+    std::span<const middleware::bap::activity_message::sense_update::DecodedValue> values,
+    std::span<std::int32_t> output) noexcept {
+    std::uint32_t length = 0, known = 0;
+    std::array<std::int32_t, 8> counts{};
+    for (const auto& value : values) {
+        if (!value.present) continue;
+        if (value.schemaRow == 0x80807ECFU && value.fieldOrdinal == 0) {
+            if (value.unsignedValue > counts.size()) return 0;
+            length = static_cast<std::uint32_t>(value.unsignedValue);
+        } else if (value.schemaRow == 0x80809491U && value.fieldOrdinal == 0
+                   && value.occurrence < counts.size()) {
+            if (value.signedValue < 0 || value.signedValue > 0x7fffffff) return 0;
+            counts[value.occurrence] = static_cast<std::int32_t>(value.signedValue);
+            known |= 1U << value.occurrence;
+        }
+    }
+    if (!length || length > output.size() || known != (1U << length) - 1) return 0;
+    for (std::size_t i = 0; i < output.size(); ++i) output[i] = i < length ? counts[i] : 0;
+    return static_cast<std::uint8_t>(length);
+}
+
+// Native 4EB8E0 publishes one saturated distance cost per authored objective group.
+// 4E84E0 publishes root 1 only after the cost pass, echoing Auth root 13.
+struct SquadObjectiveCosts {
+    std::array<float, 24> values{};
+    std::uint32_t known{};
+    std::uint32_t revision{};
+};
+[[nodiscard]] inline bool update_squad_objective_costs(SquadObjectiveCosts& retained,
+    std::span<const middleware::bap::activity_message::sense_update::DecodedValue> values,
+    std::uint32_t root) noexcept {
+    bool changed = false;
+    for (const auto& v : values) {
+        if (!v.present) continue;
+        if (v.schemaRow == root && v.fieldOrdinal == 1 && v.signedValue >= 0
+            && v.signedValue <= 0x7fffffff) {
+            const auto revision = static_cast<std::uint32_t>(v.signedValue);
+            changed |= retained.revision != revision;
+            retained.revision = revision;
+        } else if (v.schemaRow == 0x80807ECD && v.fieldOrdinal == 0 && v.occurrence < 24
+                   && std::isfinite(v.realValue) && v.realValue >= 0 && v.realValue <= 2040) {
+            const auto bit = 1U << v.occurrence;
+            changed |= !(retained.known & bit) || retained.values[v.occurrence] != v.realValue;
+            retained.known |= bit;
+            retained.values[v.occurrence] = v.realValue;
+        }
+    }
+    return changed;
+}
+
+// Optional schema fields still produce DecodedValue rows when their presence bit is false.
+// Only a present root alive count is evidence of a changed population; zero is a real value.
+[[nodiscard]] inline bool read_squad_alive(
+    std::span<const middleware::bap::activity_message::sense_update::DecodedValue> values,
+    std::uint32_t rootSchema, std::int32_t& alive) noexcept {
+    for (const auto& value : values) {
+        if (value.schemaRow == rootSchema && value.fieldOrdinal == 3 && value.present) {
+            if (value.signedValue < 0 || value.signedValue > 63) return false;
+            alive = static_cast<std::int32_t>(value.signedValue);
+            return true;
+        }
+    }
+    return false;
+}
+
+} // namespace sunrise::server::activity::mission

+ 9 - 0
Sunrise/src/server/activity/mission/mission_script_vm.cpp

@@ -138,8 +138,17 @@ inline constexpr std::array<const char*, host::kEventKindCount> kEventHandlerNam
     "on_event_player_trigger",
     "on_event_cinematic_started",
     "on_event_cinematic_terminated",
+    "on_event_ghost_link_state",
+    "on_event_actor_path_state",
+    "on_event_object_interacted",
+    "on_event_cinematic_skip_requested",
+    "on_event_fireteam_state",
+    "on_event_object_state",
+    "on_event_damage_state",
 }};
 
+static_assert([] { for (const auto* name : kEventHandlerNames) if (name == nullptr) return false; return true; }());
+
 /** Captures immutable callback references while table access remains inside lua_pcall. */
 [[nodiscard]] int capture_program(lua_State* state) {
     Impl* const impl = impl_from_state(state);

+ 78 - 16
Sunrise/src/server/bap/encrypted/activity_message/activity_message_route.cpp

@@ -1,6 +1,7 @@
 #include "activity_message_route.h"
 
 #include <algorithm>
+#include <atomic>
 #include <array>
 #include <cstddef>
 #include <cstdint>
@@ -11,6 +12,11 @@
 #include "../../../../core/settings/settings.h"
 #include "../../../../middleware/bap/activity_message/activity_join_request_parser.h"
 #include "../../../../middleware/bap/activity_message/activity_message_request_parser.h"
+#include "../../../../middleware/bap/activity_message/activity_state_refresh_parser.h"
+#include "../../../../middleware/bap/activity_message/cinematic_incident.h"
+#include "../../../../middleware/bap/activity_message/client_authoritative_data.h"
+#include "../../../../middleware/bap/activity_message/entity_authority.h"
+#include "../../../../middleware/bap/activity_message/authority_cleanup.h"
 #include "../../../../middleware/bap/activity_message/entity_slots.h"
 #include "../../../../middleware/bap/activity_message/wire_schema/activity_communication_route.h"
 #include "../../../../middleware/crypto/hmac.h"
@@ -35,6 +41,42 @@ namespace {
 
 namespace activity_sdk = state::activity_sdk;
 
+// Read-only evidence for the pending checkpoint/wipe policy. A death can be an
+// extra target of a combat incident; never infer its victim from the sending session.
+void report_player_lifecycle(const service::incident::Incident& incident,
+                             std::uint64_t sessionId, std::uint64_t sequence) noexcept {
+    if (!core::settings::get().server.activation.missionScripting) return;
+    const auto signal = [](std::uint32_t target) -> unsigned {
+        switch (target) {
+        case 1121: return 1; // player_spawned, schema 80806437
+        case 439: return 2;  // player_respawned
+        case 7117: return 4; // player_resurrected
+        case 4053: return 8; // player_died, schema 8080645A
+        case 5896: return 16; // hard_wipe, schema 808087B7
+        default: return 0;
+        }
+    };
+    unsigned signals = signal(incident.primaryTarget);
+    for (unsigned i = 0; i < incident.extraTargetCount; ++i) signals |= signal(incident.extraTargets[i]);
+    if (!signals) return;
+    static std::atomic<unsigned> reports{};
+    if (reports.fetch_add(1, std::memory_order_relaxed) >= 128) return;
+    constexpr char hex[] = "0123456789abcdef";
+    for (std::size_t offset = 0; offset < incident.payloadLength; offset += 256) {
+        std::array<char, 513> bytes{};
+        const auto count = (std::min)(std::size_t{256}, incident.payloadLength - offset);
+        for (std::size_t i = 0; i < count; ++i) {
+            const auto value = std::to_integer<unsigned>(incident.payload[offset + i]);
+            bytes[2*i] = hex[value >> 4]; bytes[2*i + 1] = hex[value & 15];
+        }
+        core::log::writef(core::log::Channel::server, core::log::Level::info,
+            "ev=player_lifecycle session=%llu sequence=%llu target=%u signals=%u bytes=%u offset=%zu payload=%s",
+            static_cast<unsigned long long>(sessionId), static_cast<unsigned long long>(sequence),
+            incident.primaryTarget, signals, incident.payloadLength, offset, bytes.data());
+    }
+}
+
+
 /** Asks for the membership snapshot as it stands, naming no bubble. */
 constexpr std::uint32_t kCurrentRevision = 0;
 constexpr std::int32_t kNoBubble = -1;
@@ -336,17 +378,29 @@ std::uint64_t record(const service::Request& request,
             input.authoritative = *diagnostic.authoritative;
             input.hasAuthoritative = true;
         }
-        communication::ActivityCommunicationRoute route{};
-        const bool executableRoute =
-            activity_sdk::executable_communication_route(request.messageType, route);
-        // Message handlers parse their own authored native body. Routing retains no parallel
-        // schema-driven scalar decode.
-        sequence = server::activity::host::record_client_message(input, diagnostic.sense);
-        if (sequence != 0 && request.messageType != service::entity_slot_request::kMessageType
-            && executableRoute
-            && route.ingressDelivery == communication::IngressDeliveryPolicy::protocolHostInput
-            && !server::activity::host::submit_client_message(input, sequence)) {
-            report_message(request.messageType, request.sessionId, "mission_ingress_refused");
+    } else if (isIncident && framed.verdict == store::Verdict::framed) {
+        report_player_lifecycle(parsedIncident, request.sessionId, clientMessageSequence);
+        server::activity::host::IncidentInput input{};
+        input.binding = binding.session;
+        input.incident = parsedIncident;
+        input.sourceGeneration = binding.bindingGeneration;
+        input.clientMessageSequence = clientMessageSequence;
+        input.payloadBytes = static_cast<std::uint32_t>(request.payload.size());
+        if (parsedIncident.primaryTarget == player_trigger_incident::kPrimaryTarget
+            && parsedIncident.payloadLength == player_trigger_incident::kPayloadBytes) {
+            input.hasPlayerTrigger = player_trigger_incident::decode(
+                std::span(parsedIncident.payload).first(parsedIncident.payloadLength),
+                input.playerTrigger);
+        }
+        if (parsedIncident.payloadLength == cinematic_incident::kPayloadBytes
+            && cinematic_incident::signal_for_target(parsedIncident.primaryTarget,
+                                                     input.cinematicSignal)) {
+            input.hasCinematic = cinematic_incident::decode(
+                std::span(parsedIncident.payload).first(parsedIncident.payloadLength),
+                input.cinematic);
+        }
+        if (!server::activity::host::submit_incident(input)) {
+            report_message(request.messageType, request.sessionId, "host_ingress_refused");
         }
     }
     store::Arrival arrival{};
@@ -439,12 +493,20 @@ bool process(const ActivityClientBinding& binding,
     case IngressAdapter::authorityResetAcknowledgement:
         return prepare_authority_reset_acknowledgement(
             binding, rosterDecode, adapter, request, plan, hasTransaction);
-    case IngressAdapter::authorityAbdicate:
-        return prepare_authority_abdication(
-            binding, rosterDecode, adapter, request, plan, hasTransaction);
+    case IngressAdapter::authorityAbandon:
     case IngressAdapter::authorityRequestPurge:
-        return prepare_authority_purge(
-            binding, rosterDecode, adapter, request, plan, hasTransaction);
+        if (!frame_only(binding, rosterDecode, adapter, request)) return false;
+        if (!service::authority_cleanup::prepare(request.messageType, request.payload,
+                                                 binding.authorityEpoch, plan.authorityPurge)) {
+            return true;
+        }
+        plan.sessionId = request.sessionId;
+        plan.targetBinding = binding.session;
+        plan.authorityPurgeGeneration = binding.bindingGeneration;
+        plan.delivery = Delivery::authorityPurgeNotification;
+        plan.mutationDomain = MutationDomain::authorityPurge;
+        hasTransaction = true;
+        return true;
     case IngressAdapter::authorityQueryAnswer:
         return prepare_authority_query_answer(
             binding, rosterDecode, adapter, request, plan, hasTransaction);

+ 5 - 2
Sunrise/src/server/bap/encrypted/activity_message/definition.h

@@ -5,6 +5,7 @@
 #include "../../../../middleware/bap/activity_message/activity_host_control.h"
 #include "../../../../middleware/bap/activity_message/activity_patch_epoch_parser.h"
 #include "../../../../middleware/bap/activity_message/entity_authority.h"
+#include "../../../../middleware/bap/activity_message/activity_host_control.h"
 #include "../../../../state/activity/membership/activity_membership_query.h"
 #include "../../../../state/activity/runtime.h"
 #include "../../../gameplay/group/group_host_sessions.h"
@@ -14,6 +15,7 @@ namespace sunrise::server::bap::encrypted::activity_message {
 /** Outbound delivery staged for one activity State transaction. */
 enum class Delivery : std::uint8_t {
     none,
+    authorityPurgeNotification,
     joinNotifications,
     entitySlotNotification,
     membershipNotification,
@@ -37,6 +39,7 @@ enum class Delivery : std::uint8_t {
 /** State transaction family staged by one activity service request. */
 enum class MutationDomain : std::uint8_t {
     none,
+    authorityPurge,
     entitySlots,
     membership,
     /** The patch epoch is kept on the connection and changes no State. */
@@ -126,8 +129,8 @@ struct ActivityPlan final {
     EntitySlotsRequestedIngress entitySlotsRequested{};
     AuthorityQueryIngress authorityQuery{};
     AuthorityResetIngress authorityReset{};
-    AuthorityAbdicationIngress authorityAbdication{};
-    AuthorityPurgeIngress authorityPurge{};
+    middleware::bap::activity_message::host_control::PurgeAuthorityBody authorityPurge{};
+    std::uint64_t authorityPurgeGeneration{};
     middleware::bap::activity_message::patch_epoch::PatchEpoch patchEpoch{};
     /** Exact target generation whose destination the staged msg1 must encode. */
     state::activity::SessionBinding targetBinding{};

+ 1 - 1
Sunrise/src/server/bap/encrypted/activity_message/receipts/activity_message_receipts.cpp

@@ -527,7 +527,7 @@ Framed frame_authority_release(const message::Request& request, bool expectReaso
     return {Verdict::framed, payload_bits(request)};
 }
 
-/** Frames one purge request before its transactional message-25 answer. */
+/** Frames one purge request; the activity route stages its exact msg-25 answer. */
 Framed frame_request_purge(const message::Request& request) noexcept {
     authority::PurgeRequest decoded{};
     if (!authority::parse_request_purge(request.payload, decoded)) {

+ 44 - 0
Sunrise/src/server/bap/encrypted/activity_transaction/activity_transaction_notifications.cpp

@@ -1,6 +1,10 @@
 #include "activity_transaction_notifications.h"
 
+#include <bit>
+#include <algorithm>
+#include "../../../../core/logging/log.h"
 #include "../../../../middleware/secure_channel/runtime.h"
+
 #include "../../../gameplay/gameplay_advertisement.h"
 #include "../push/activity/activity_arrival.h"
 #include "../push/activity/activity_global_state_push.h"
@@ -14,6 +18,43 @@
 namespace sunrise::server::bap::encrypted::activity_transaction {
 namespace {
 
+/** Publishes the exact abandoned mask with the next entity-slot epoch. */
+[[nodiscard]] bool stage_purge(Session& session, Scratch& scratch,
+                               const activity_message::ActivityPlan& activity,
+                               std::span<const std::byte, state::kAesKeySize> key,
+                               std::array<std::byte, state::kBapNonceSize>& nonce,
+                               std::span<std::byte> response, std::size_t& written) noexcept {
+    namespace control = middleware::bap::activity_message::host_control;
+    if (activity.authorityPurgeGeneration != session.activity.bindingGeneration
+        || activity.sessionId != session.activity.session.sessionId
+        || activity.authorityPurge.epoch != unsigned(session.activity.authorityEpoch) + 1U
+        || written > response.size()) return false;
+    std::array<std::byte, control::kPurgeAuthorityByteCount> body{};
+    std::size_t bodySize{};
+    if (!control::encode_purge_authority(activity.authorityPurge, body, bodySize)) return false;
+    const auto initialWritten = written;
+    const auto initialNonce = nonce;
+    if (!push::activity::append_notification_frame(scratch, activity.sessionId,
+            control::kPurgeAuthorityMessageType, std::span(body).first(bodySize),
+            key, nonce, response, written)) {
+        if (written > initialWritten)
+            std::fill(response.begin() + initialWritten, response.begin() + written, std::byte{});
+        written = initialWritten;
+        nonce = initialNonce;
+        return false;
+    }
+    middleware::secure_channel::advance_nonce(nonce);
+    unsigned slots = 0;
+    for (const auto byte : activity.authorityPurge.slots)
+        slots += std::popcount(std::to_integer<unsigned>(byte));
+    core::log::writef(core::log::Channel::server, core::log::Level::info,
+        "ev=authority_cleanup stage=purge_staged session=%llu epoch=%u reason=%d slots=%u",
+        static_cast<unsigned long long>(activity.sessionId),
+        static_cast<unsigned>(activity.authorityPurge.epoch),
+        static_cast<int>(activity.authorityPurge.reason), slots);
+    return true;
+}
+
 /**
  * Reports whether the citizen advertisement this membership body would carry is still coming.
  * One membership update lands per revision, so a body sent before the region's host session exists
@@ -185,6 +226,9 @@ bool stage_notifications(Session& session,
                          bool allowEntityRetirement) noexcept {
     // Each encoder refuses an absent session itself, so a plan that delivers nothing needs no
     // session at all. Message type 52 is the one that arrives on an unallocated link.
+    if (activity.delivery == activity_message::Delivery::authorityPurgeNotification) {
+        return stage_purge(session, scratch, activity, key, nonce, response, written);
+    }
     if (activity.delivery == activity_message::Delivery::joinNotifications) {
         return push::activity::append_join_notifications(
             scratch, session, activity, key, nonce, response, written);

+ 9 - 0
Sunrise/src/server/bap/encrypted/bap_connection_publication.cpp

@@ -114,6 +114,10 @@ ConnectionFields connection_fields(const ServiceOutcome& outcome) noexcept {
         fields.patchEpoch = plan->patchEpoch;
         fields.retainsPatchEpoch = true;
     }
+    if (plan->delivery == activity_message::Delivery::authorityPurgeNotification) {
+        fields.authorityPurgeGeneration = plan->authorityPurgeGeneration;
+        fields.authorityPurgeEpoch = plan->authorityPurge.epoch;
+    }
     fields.receivesClientIdentity =
         plan->mutationDomain == activity_message::MutationDomain::membership
         && plan->membershipMutation.kind == state::activity::membership::MutationKind::identity;
@@ -155,6 +159,11 @@ void publish_connection_fields(Session& session,
         session.activityJoinGeneration = session.activity.bindingGeneration;
         session.activityCharacterSoid = fields.joinCharacterSoid;
     }
+    if (fields.authorityPurgeGeneration != 0
+        && fields.authorityPurgeGeneration == session.activity.bindingGeneration
+        && fields.authorityPurgeEpoch == unsigned(session.activity.authorityEpoch) + 1U) {
+        session.activity.authorityEpoch = fields.authorityPurgeEpoch;
+    }
     if (fields.retainsPatchEpoch) {
         session.activityPatchEpoch.value = fields.patchEpoch;
         session.activityPatchEpoch.bindingGeneration = session.activity.bindingGeneration;

+ 3 - 0
Sunrise/src/server/bap/encrypted/bap_connection_publication.h

@@ -17,6 +17,9 @@ struct ConnectionFields {
     std::uint64_t joinMemberKey{};
     /** The join also names the character the player signed in on. */
     std::uint64_t joinCharacterSoid{};
+    /** Msg25 advances only after its complete authenticated response is published. */
+    std::uint64_t authorityPurgeGeneration{};
+    std::uint8_t authorityPurgeEpoch{};
     bool retainsPatchEpoch{};
     /** Set when this transaction commits one client-authored type-23 identity. */
     bool receivesClientIdentity{};

+ 2 - 1
Sunrise/src/server/bap/encrypted/push/activity/activity_keepalive_push.cpp

@@ -440,7 +440,8 @@ bool consume_activity_keepalive(Session& session,
     // arm stops counting as armed once the client reports the region, which ends this.
     const bool needsRepublish =
         (advertisedRegionReady
-         || state::activity::membership::host_teleport_armed(session.activity.session.sessionId))
+         || state::activity::membership::host_teleport_armed(session.activity.session.sessionId)
+         || state::activity::membership::hard_wipe_needs_publish(session.activity.session.sessionId))
         && state::activity::membership::acknowledged(session.activity.session.sessionId);
     bool preparedRepublish = needsRepublish
                              && state::activity::membership::prepare_republish(

+ 21 - 3
Sunrise/src/server/bap/encrypted/push/activity/activity_roster_snapshot.cpp

@@ -1,3 +1,4 @@
+#include "../../../../../middleware/bap/activity_message/darkness_zone_auth.h"
 #include <Windows.h>
 
 #include <algorithm>
@@ -462,7 +463,6 @@ build_roster_snapshot(Session& session,
     if (!pendingAddsGroup && pendingStateLocal && pendingGroupIndex < lease.groupCount) {
         pendingGroupPosition = retainedGroupPositions[pendingGroupIndex];
     }
-    bool pendingInstalled = false;
     for (std::size_t index = 0; retainedSquad && index < lease.authCount; ++index) {
         message::AuthOverride retainedAuth{};
         if (!retained_squad_auth(lease, index, retainedAuth)) {
@@ -488,7 +488,6 @@ build_roster_snapshot(Session& session,
                                    effectiveStateLocal)) {
             return refuse_override("retained_auth_install");
         }
-        pendingInstalled = pendingInstalled || replace;
     }
     // Message 5 resets every registered Auth slot before applying its bodies, so the complete
     // latest-per-ClientRef estate is re-emitted and a later action cannot erase an earlier one.
@@ -575,7 +574,10 @@ build_roster_snapshot(Session& session,
             }
         }
     }
-    if (authOverride != nullptr && !pendingInstalled
+    // The estate may contain an older SDK objective update for a squad that was
+    // also installed through its lease above. Apply the pending body last even
+    // for that target; otherwise the older body silently wins this packet.
+    if (authOverride != nullptr
         && !install_auth_override(layout,
                                   region,
                                   scratch,
@@ -599,6 +601,17 @@ build_roster_snapshot(Session& session,
             return refuse_override("tail_auth_apply");
         }
     }
+    // Use the final merged estate, so a pending disable wins over retained enable.
+    namespace darkness=middleware::bap::activity_message::darkness_zone;
+    for (const auto& value : snapshot.authOverrides) {
+        bool enabled{};
+        if (value.sdkCompiled&&value.present&&value.slotType==darkness::kSlotType
+            &&value.authSchema==darkness::kSchema&&value.byteCount<=value.body.size()
+            &&darkness::decode(std::span(value.body).first(value.byteCount),value.bitCount,enabled)) {
+            snapshot.hasDarknessPolicy=true;
+            snapshot.darknessEnabled=enabled;
+        }
+    }
     // Staging runs before the connection field is published, so a body answering message 52 has to
     // take the epoch from that message instead of from the connection.
     snapshot.patchEpoch = epoch != nullptr ? *epoch : session.activityPatchEpoch.value;
@@ -624,6 +637,11 @@ build_roster_snapshot(Session& session,
         region.index >= 0 ? static_cast<std::uint32_t>(region.index) : region.arrival;
     snapshot.spawnSetHash =
         state::activity::destination::attachable_spawn_set_hash(selection, fallback.spawnSetHash);
+    // A genuine wipe selects the authored darkness checkpoint, not the mission's
+    // initial arrival override. Keep it in this region while the player respawns.
+    if (const auto checkpoint=state::activity::membership::checkpoint_spawn_hash(
+            session.activity.source.sessionId,region.index); checkpoint!=0)
+        snapshot.spawnSetHash=checkpoint;
     snapshot.hasSpawnOverride =
         snapshot.spawnSetHash != 0 && snapshot.spawnSetHash != message::kAbsentSpawnSetHash;
     advance_region_epoch(session, refresh);

+ 2 - 5
Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp

@@ -215,13 +215,10 @@ bool commit(ServiceOutcome& outcome, Publication& publication, const char*& reas
             reason = "authority_reset";
             return plan->authorityReset.pending;
         }
-        if (plan->mutationDomain == activity_message::MutationDomain::authorityAbdication) {
-            reason = "authority_abdication";
-            return plan->authorityAbdication.pending;
-        }
         if (plan->mutationDomain == activity_message::MutationDomain::authorityPurge) {
             reason = "authority_purge";
-            return plan->authorityPurge.pending;
+            return plan->authorityPurgeGeneration != 0
+                   && state::activity::binding_matches(plan->targetBinding);
         }
         // The retained patch epoch is connection state, so it commits nothing here.
         reason = "mutation_domain";

+ 3 - 1
Sunrise/src/server/bap/internal.h

@@ -159,7 +159,7 @@ struct RosterPublication {
 
 /** Compact retained squad body; shared target fields and the generated group live on its group. */
 struct RetainedSquadAuth {
-    std::array<std::byte, middleware::bap::activity_message::squad_auth::kMaximumByteCount> body{};
+    std::array<std::byte, middleware::bap::activity_message::squad_auth::kMaximumRetainedByteCount> body{};
     std::uint32_t generation{};
     std::uint16_t rosterSlotOffset{};
     std::uint16_t slotIndex{};
@@ -252,6 +252,8 @@ struct ActivityClientBinding {
     std::uint64_t bindingGeneration{};
     /** Epoch this host authored in the accepted join result. */
     std::uint8_t replicationEpoch{};
+    /** Msg24/25 entity-slot sequence; native manager starts at zero independently of msg44. */
+    std::uint8_t authorityEpoch{};
     /** Private: last citizen region. Public: immutable region captured by the host binding. */
     std::int32_t advertisedRegion{-1};
     /** True when the last advertisement named a private region's own Bubble Host. */

+ 6 - 0
Sunrise/src/server/gameplay/peer/peer_established.cpp

@@ -489,6 +489,12 @@ void consume_established(const gp::Endpoint& from,
         guardAccepted = packet.connectionSequenceLow2 == expectedGuard;
     }
     if (guardAccepted) {
+        // Reliable queues drain even when a later external component rejects its body.
+        // ACKs belong to the validated transport header, independently of this
+        // packet's incoming gameplay lanes. An unsupported lane must not pin
+        // every outgoing contribution: after 128 packets that prevents sending
+        // ACKs at all, leaving native deletion recipients permanently pending.
+        completedCount = acknowledge_external(*peer, packet.ack, completed);
         if (packet.ack.outboundHeadPresent) {
             record_sequence(*peer, packet.ack.outboundHead);
         }

+ 32 - 0
Sunrise/src/server/gameplay/peer/peer_external_ack.h

@@ -0,0 +1,32 @@
+#pragma once
+#include "../../../middleware/gameplay/peer/established_packet.h"
+
+namespace sunrise::server::gameplay::peer {
+struct CompletedExternal {
+    std::uint64_t groupSessionId{};
+    std::uint64_t transmissionId{};
+    middleware::gameplay::peer::AckOutcome outcome{
+        middleware::gameplay::peer::AckOutcome::unresolved};
+};
+using DisplacedExternals =
+    std::array<CompletedExternal, state::gameplay::external::kExternalContributionCapacity>;
+
+// Called after transport ownership/guard validation, independently of lane decoding.
+[[nodiscard]] inline std::size_t acknowledge_external(
+    state::gameplay::PeerLink& peer,
+    const middleware::gameplay::peer::AckState& ack,
+    DisplacedExternals& completed) noexcept {
+    namespace wire = middleware::gameplay::peer;
+    std::size_t count = 0;
+    for (auto& contribution : peer.externalContributions) {
+        if (!contribution.occupied) continue;
+        const auto outcome = wire::acknowledgement_outcome(ack, contribution.packetSequence);
+        if (outcome == wire::AckOutcome::unresolved) continue;
+        completed[count++] = {contribution.groupSessionId, contribution.transmissionId, outcome};
+        if (outcome == wire::AckOutcome::received && contribution.commonPresent
+            && contribution.viewGeneration == peer.viewGeneration) peer.commonCommitted = true;
+        contribution = {};
+    }
+    return count;
+}
+}

+ 1 - 12
Sunrise/src/server/gameplay/peer/peer_transport_internal.h

@@ -8,6 +8,7 @@
 #include <span>
 
 #include "peer_transport.h"
+#include "peer_external_ack.h"
 
 namespace sunrise::server::gameplay::peer {
 
@@ -33,18 +34,6 @@ extern EntityTransport g_entityTransport;
 /** Channel ids this host hands out. The peer refuses one that does not increase. */
 extern std::uint32_t g_channelId;
 
-/** One external outcome copied out before its callback runs. */
-struct CompletedExternal {
-    std::uint64_t groupSessionId{};
-    std::uint64_t transmissionId{};
-    middleware::gameplay::peer::AckOutcome outcome{
-        middleware::gameplay::peer::AckOutcome::unresolved};
-};
-
-/** Every occupied external contribution one link is about to lose. */
-using DisplacedExternals =
-    std::array<CompletedExternal, state::gameplay::external::kExternalContributionCapacity>;
-
 /**
  * Sends one payload over whichever transport the peer arrived on.
  * Records go first. The engine association answers only when no record association exists.

+ 17 - 0
Sunrise/src/server/transport/bap_frame_batch.h

@@ -0,0 +1,17 @@
+#pragma once
+#include <cstddef>
+
+namespace sunrise::server::transport {
+// Drain coalesced TCP frames without overwriting an unsent response. The budget
+// keeps one busy connection from monopolizing the server thread.
+template<class Peer, class Drain, class Flush>
+bool drain_frame_batch(Peer& peer, Drain drain, Flush flush) {
+    for (std::size_t count = 0; count < 16 && peer.outputSize == 0; ++count) {
+        const auto before = peer.streamSize;
+        if (!drain(peer)) return false;
+        if (before == peer.streamSize) break; // Incomplete frame: wait for more bytes.
+        if (peer.outputSize != 0 && !flush(peer)) return false;
+    }
+    return true;
+}
+}

+ 3 - 3
Sunrise/src/server/transport/bap_listener.cpp

@@ -1,4 +1,5 @@
 #include "bap_listener.h"
+#include "bap_frame_batch.h"
 
 #include <WS2tcpip.h>
 #include <WinSock2.h>
@@ -111,8 +112,7 @@ void receive_peer(Peer& peer) noexcept {
 }
 
 /**
- * Services one peer with one read, frame, write and due-poll budget.
- * @param peer Live peer.
+ * Services one peer with one read, a bounded frame batch and one due poll.
  * @param readable Ready-read set from select.
  * @param writable Ready-write set from select.
  * @param wasPending True when select saw output.
@@ -134,7 +134,7 @@ void service_peer(
     if (peer.socket == INVALID_SOCKET) {
         return;
     }
-    if (peer.outputSize == 0 && !drain_stream(peer)) {
+    if (peer.outputSize == 0 && !drain_frame_batch(peer, drain_stream, flush_peer)) {
         close_peer(peer);
         return;
     }

+ 63 - 0
Sunrise/src/state/activity/membership/activity_membership_query.cpp

@@ -72,6 +72,69 @@ bool arm_host_teleport(std::uint64_t sessionId,
     return changed;
 }
 
+bool arm_hard_wipe(const SessionBinding& binding, std::uint64_t requestKey,
+    std::int32_t region, std::uint32_t spawnSetHash) noexcept {
+    if (!requestKey || region<0 || !spawnSetHash || spawnSetHash==UINT32_MAX) return false;
+    bool accepted=false;
+    AcquireSRWLockExclusive(&runtime::storage::g_stateLock);
+    ActivityState& state=runtime::storage::g_state.activity;
+    const auto target=activity::transactions::find_session(state,binding.sessionId);
+    if (target!=kInvalidSessionSlot && state.sessions[target].createdRevision==binding.createdRevision) {
+        auto& membership=state.sessions[target].membership;
+        auto& wipe=membership.hardWipe;
+        if (wipe.requestKey==requestKey) accepted=true;
+        else if (!wipe.active && !membership.hasHostTeleport) {
+            wipe.requestKey=requestKey;wipe.region=region;wipe.spawnSetHash=spawnSetHash;
+            wipe.host={};wipe.host.state=1;
+            wipe.host.opaqueByte=static_cast<std::uint8_t>(membership.spawn.opaqueByte+1U);
+            wipe.active=true;wipe.resetReady=false;wipe.clientWaiting=false;accepted=true;
+        }
+    }
+    ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
+    return accepted;
+}
+
+bool hard_wipe_needs_publish(std::uint64_t sessionId) noexcept {
+    bool pending = false;
+    AcquireSRWLockShared(&runtime::storage::g_stateLock);
+    const auto& state = runtime::storage::g_state.activity;
+    const auto target = activity::transactions::find_session(state, sessionId);
+    if (target != kInvalidSessionSlot) {
+        const auto& member = state.sessions[target].membership;
+        const auto& wipe = member.hardWipe;
+        pending = wipe.active && (wipe.host.state == 4
+            || member.spawn.opaqueByte != wipe.host.opaqueByte || member.spawn.state < 1);
+    }
+    ReleaseSRWLockShared(&runtime::storage::g_stateLock);
+    return pending;
+}
+
+bool release_hard_wipe(const SessionBinding& binding, std::uint64_t requestKey) noexcept {
+    bool accepted = false;
+    AcquireSRWLockExclusive(&runtime::storage::g_stateLock);
+    ActivityState& state = runtime::storage::g_state.activity;
+    const auto target = activity::transactions::find_session(state, binding.sessionId);
+    if (target != kInvalidSessionSlot && state.sessions[target].createdRevision == binding.createdRevision) {
+        auto& wipe = state.sessions[target].membership.hardWipe;
+        if (wipe.active && wipe.requestKey == requestKey) { wipe.release(); accepted = true; }
+    }
+    ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
+    return accepted;
+}
+
+std::uint32_t checkpoint_spawn_hash(std::uint64_t sessionId, std::int32_t region) noexcept {
+    std::uint32_t result{};
+    AcquireSRWLockShared(&runtime::storage::g_stateLock);
+    const ActivityState& state=runtime::storage::g_state.activity;
+    const auto target=activity::transactions::find_session(state,sessionId);
+    if (target!=kInvalidSessionSlot) {
+        const auto& wipe=state.sessions[target].membership.hardWipe;
+        if (wipe.requestKey && wipe.region==region) result=wipe.spawnSetHash;
+    }
+    ReleaseSRWLockShared(&runtime::storage::g_stateLock);
+    return result;
+}
+
 /** Reports whether a host-named teleport is still waiting for the client to move. */
 bool host_teleport_armed(std::uint64_t sessionId) noexcept {
     if (sessionId == kAbsentSessionId) {

+ 9 - 0
Sunrise/src/state/activity/membership/activity_membership_query.h

@@ -99,6 +99,15 @@ struct PendingMutation final {
                                      std::int32_t sliceSetIndex,
                                      std::uint32_t sliceSetHash) noexcept;
 
+/** Starts one idempotent native hard wipe on an exact session generation. */
+[[nodiscard]] bool hard_wipe_needs_publish(std::uint64_t sessionId) noexcept;
+
+[[nodiscard]] bool release_hard_wipe(const SessionBinding& binding, std::uint64_t requestKey) noexcept;
+
+[[nodiscard]] bool arm_hard_wipe(const SessionBinding& binding, std::uint64_t requestKey,
+    std::int32_t region, std::uint32_t spawnSetHash) noexcept;
+[[nodiscard]] std::uint32_t checkpoint_spawn_hash(std::uint64_t sessionId, std::int32_t region) noexcept;
+
 /**
  * @param sessionId Joined activity session.
  * @return True while a host-named teleport is armed and unspent.

+ 22 - 0
Sunrise/src/state/activity/membership/definition.h

@@ -69,6 +69,27 @@ struct SpawnState final {
     std::uint64_t opaqueValue{};
 };
 
+// Native E51D80: host1 starts the hard-wipe machine; client4 awaits host4
+// before restoring play. Client0 with the latched token acknowledges completion.
+struct HardWipeState final {
+    SpawnState host{};
+    std::uint64_t requestKey{};
+    std::uint32_t spawnSetHash{};
+    std::int32_t region{-1};
+    bool active{};
+    bool resetReady{};
+    bool clientWaiting{};
+    void release() noexcept {
+        resetReady = true;
+        if (active && clientWaiting) host.state = 4;
+    }
+    void observe(const SpawnState& client) noexcept {
+        if (!active || client.opaqueByte!=host.opaqueByte) return;
+        if (client.state==4) { clientWaiting = true; if (resetReady) host.state=4; }
+        else if (host.state==4 && client.state==0) active=false;
+    }
+};
+
 /** Teleport state kept for the current activity host, in no wire form. */
 struct TeleportState final {
     std::int8_t state{};
@@ -139,6 +160,7 @@ struct MembershipState final {
      */
     TeleportState hostTeleport{};
     bool hasHostTeleport{};
+    HardWipeState hardWipe{};
     /** Region of the slice set the client holds; -1 while it holds none. */
     RegionState currentRegion{};
     /** Pending region leg as last reported; -1 once a transition has completed. */

+ 4 - 1
Sunrise/src/state/activity/membership/transactions/internal.h

@@ -90,6 +90,7 @@ inline MembershipState merge(const MembershipState& state,
     }
     if (update.hasSpawn) {
         merged.spawn = update.spawn;
+        merged.hardWipe.observe(update.spawn);
     }
     if (update.hasTeleport) {
         merged.teleport = update.teleport;
@@ -158,6 +159,8 @@ inline bool equal_authoritative(const MembershipState& first,
            && first.hasTransitionToken == second.hasTransitionToken
            && equal(first.spawn, second.spawn) && equal(first.teleport, second.teleport)
            && first.hasHostTeleport == second.hasHostTeleport
+           && first.hardWipe.active == second.hardWipe.active
+           && equal(first.hardWipe.host,second.hardWipe.host)
            && equal(first.hostTeleport, second.hostTeleport)
            && equal(first.currentRegion, second.currentRegion) && equal(first.region, second.region)
            && first.currentReported == second.currentReported
@@ -188,7 +191,7 @@ inline Snapshot make_snapshot(const MembershipState& state,
                               std::uint32_t revision) noexcept {
     Snapshot snapshot{};
     snapshot.identity = identity;
-    snapshot.spawn = state.spawn;
+    snapshot.spawn = state.hardWipe.active ? state.hardWipe.host : state.spawn;
     // An armed host teleport replaces the mirror, because these are the fields the client's
     // teleport arm reads. Everything else about the block stays the client's own report.
     snapshot.teleport = state.hasHostTeleport ? state.hostTeleport : state.teleport;

+ 5 - 2
Sunrise/src/state/activity/mission/definition.h

@@ -7,8 +7,8 @@
 
 namespace sunrise::state::activity::mission {
 
-/** One activity generation retains at most sixty-four script variables. */
-inline constexpr std::size_t kVariableCapacity = 64;
+/** One activity generation retains at most 512 script variables. */
+inline constexpr std::size_t kVariableCapacity = 512;
 /** One activity generation retains at most thirty-two authoritative timers. */
 inline constexpr std::size_t kTimerCapacity = 32;
 /** Durable variable and timer names contain at most sixty-three bytes. */
@@ -75,6 +75,7 @@ enum class IntentKind : std::uint8_t {
     bindCombatantToSquad,
     actorCommand,
     playPerformance,
+    restartCheckpoint,
 };
 
 /** One object a mission omits, named the way a roster group is: its tag and its registry key. */
@@ -105,6 +106,8 @@ struct TypedIntent final {
     std::uint32_t authSchema{};
     /** SDK-selected actor-command selector, never a wire constant owned by Mission State. */
     std::uint32_t actorCommandSelector{};
+    std::uint32_t checkpointSpawnHash{};
+    std::uint64_t checkpointReleaseRequest{};
     /** Authored effective region selected by the generated mission-state table. */
     std::int32_t effectiveRegion{-1};
     std::int32_t entryIndex{};

+ 34 - 56
scripts/README.md

@@ -1,56 +1,34 @@
-# Ember development scripts
-
-This controller is a development playtest slice, not complete 1AU. It selects the authored
-arrival-cinematic state (49), activates its bookend when held, and hands off to powerhouse
-state 64 on the matching termination event. The separate arrival reference establishes the
-movie sequence, and the latest playtest confirmed playback and the termination handoff.
-
-The landing encounter uses eleven authored Mercury squads with their default counts.
-Bridge devices reset only when the playable region is held. Arrival dialogue waits for spawn
-settlement; combat clear sets the console directive and plays the console-guidance cue.
-Actual Ghost-console activation/bridge extension, later encounters, later dialogue, wipes and
-mission completion still need implementation and in-game verification. See
-[the full squad audit](../MISSION_EMBER_SQUADS.md) and
-[video observations](../MISSION_EMBER_REFERENCE.md).
-
-Build this branch and regenerate the SDK before installing these scripts. The corrected
-native generator initializes the scenario catalogue before offline extraction and rejects
-cached shards missing container/spatial context. `build/sdk-corrected` has 138 statically
-runnable Ember definitions; 13 sensors have no definition and 11 processing sensors have
-refused alternate spawn-rule bindings. Never substitute arbitrary enemies or coordinates.
-
-Copy these scripts to `Sunrise/scripts` beside `Sunrise/sdk/lua`. Enable
-`server.activation.mission_scripting`. For cinematic arrival, the settings override for
-`mission_ember` must use `bubble: 6`, `slice_set: 49` and
-`spawn_set_hash: "0x9C58857A"` (the authored Mercury landing set). Gameplay is selected by
-the script after the cinematic. Use the current native build so the explicit spawn override
-is preserved even when its package is absent from the direct scenario package list.
-Restart after updating arrival settings.
-
-Region-less client deltas preserve encounter ownership. Duplicate events and script reload
-do not repeat the movie, spawn requests, dialogue or bridge reset. Mock-context tests verify
-those conditions; they do not establish visible AI, dialogue or cinematic playback.
-
-Run local checks from the repository root:
-
-```sh
-lua5.4 tests/mission_ember_encounter_test.lua
-lua5.4 tests/mission_ember_controller_test.lua
-python -m unittest discover -s tests -p 'test_*.py'
-g++ -std=c++23 -O1 -g -fsanitize=address,undefined -fno-omit-frame-pointer \
-    tests/squad_reference_scope_test.cpp -lcrypto -o /tmp/ember-squad-reference-test
-ASAN_OPTIONS=detect_leaks=0 /tmp/ember-squad-reference-test
-```
-
-Leak detection is disabled in this command because it cannot run under the sandbox's tracing;
-address and undefined-behavior instrumentation remain enabled. The C++ test uses synthetic
-identities and exercises the production reference resolver. Lua tests model the context API;
-they do not substitute for a live mission run.
-
-Generate a local SDK inventory without committing package data:
-
-```sh
-python tools/inspect_mission_sdk.py /path/to/Sunrise/activity_sdk.pack \
-    --shard /path/to/Sunrise/sdk/scenarios/80B3C09E-HASH.pack \
-    --output /tmp/mission-ember-inventory.json
-```
+# Ember mission scripts
+
+The controller now covers arrival, Powerhouse, Mineral Processing, Sunside, Light's End,
+reactor destruction, fusion-cell overload, escape and both authored ending cinematics.
+This is a reconstructed full-mission candidate. The user has verified the Powerhouse
+combat, bridge, Harvester doors and exit; the later encounters are tested offline and need
+an in-game playthrough.
+
+Use the matching native DLL and generated SDK together. The final SDK in
+`build/sdk-mission-complete` resolves all162 squad sensors to173 runnable definitions;
+11 Processing sensors have two distinct valid authored rule bindings. The Lua roster
+chooses each sensor once and uses native member counts, placements, combat objectives
+and task costs. No alternate enemy types or hand-entered spawn positions replace them.
+
+`mission_ember.lua` dispatches the opening/landing and later controllers. Later modules
+are `processing.lua`, `cinder.lua` and `apex.lua`, with shared `route_roster.lua`,
+`route_support.lua`, `carry.lua`, `routes.lua` and `ending.lua`. Progress is transactional
+mission state, and completed encounters retain zero native spawn requests on backtracking.
+
+Navigation uses authored type47 marker references in type68 directives, hidden while the
+relevant encounter is active. Darkness uses the native3/30-second respawn policy and
+all-dead wipe handshake. Checkpoint reset dispatches to the active encounter; actor,
+interaction and damage generations reject stale receipts. Escape and native cinematic
+skip incidents advance the ending only once.
+
+Enable `server.activation.mission_scripting` and SDK generation/Lua declarations in
+settings. Install all modules under `Sunrise/scripts`, beside `Sunrise/sdk/lua`, with the
+matching `Sunrise/activity_sdk.pack`. The installed settings already select cinematic
+arrival; preserve them. The user launches the game manually.
+
+See [implementation plan](../MISSION_EMBER_PLAN.md),
+[status and validation](../MISSION_EMBER_STATUS.md), and
+[reference observations](../MISSION_EMBER_REFERENCE.md) for the candidate's limits.
+The build is not a claim of verified retail-equivalent timing or complete hazard behavior.

+ 67 - 3
scripts/mission_ember.lua

@@ -1,13 +1,28 @@
--- Development controller for the opening landing. The remaining mission is not wired yet.
+-- Full mission controller: arrival, Powerhouse, Processing, Sunside and Light's End.
 local missions = require("missions")
 local mission = require(assert(missions.MISSION_EMBER, "mission_ember SDK module is absent"))
 local landing = require("mission_ember.landing")(mission)
 local opening = require("mission_ember.opening")(mission, landing)
+-- Lazy construction keeps the opening independent of later region initialization.
+local routes
+local function later() if not routes then routes = require("mission_ember.routes")(mission) end; return routes end
+local wipe = require("mission_ember.wipe")(mission, landing, function(c, s) later().reset(c, s) end)
+local function dispatch(method, c, s, e)
+    if not wipe.active(s) and opening.playable(s) and s:variable("ember.later.owns_hud") then
+        return later().dispatch(method, c, s, e)
+    end
+end
+local function terminated(c, s, e)
+    opening.terminated(c, s, e)
+    if routes then routes.terminated(c, s, e) end
+end
 
 return {
     initial_state = opening.initial_state,
-    on_event_cinematic_terminated = opening.terminated,
+    on_event_cinematic_terminated = terminated,
+    on_event_cinematic_skip_requested = terminated,
     on_event_client_state_changed = function(context, state, event)
+        wipe.client_state(context, state, event)
         opening.client_state(context, state, event)
         local region = event.held_region_index or event.current_region_index
         -- A settle-only delta omits region fields; it does not mean that the player left.
@@ -17,10 +32,59 @@ return {
             landing.enter(context, state)
             landing.client_state(context, state, event)
         end
+        if opening.playable(state) and not wipe.active(state) and (region == 56 or region == 40 or region == 0 or routes) then
+            later().client(context, state, event)
+        end
+        if opening.playable(state) then
+            landing.update_darkness(context, state)
+            landing.update_guidance(context, state)
+        end
+    end,
+    on_event_player_trigger = function(context, state, event)
+        dispatch("trigger", context, state, event)
+        if wipe.active(state) then return end
+        if state:variable("ember.region") == landing.region and opening.playable(state) then
+            landing.on_player_trigger(context, state, event)
+            landing.update_guidance(context, state)
+        end
+    end,
+    on_event_object_interacted = function(context, state, event)
+        dispatch("interaction", context, state, event)
+        if wipe.active(state) then return end
+        if state:variable("ember.region") == landing.region and opening.playable(state) then
+            landing.on_object_interacted(context, state, event)
+            landing.update_guidance(context, state)
+        end
+    end,
+    on_event_ghost_link_state = function(context, state, event)
+        if wipe.active(state) then return end
+        if state:variable("ember.region") == landing.region and opening.playable(state) then
+            landing.on_ghost_link_state(context, state, event)
+            landing.update_guidance(context, state)
+        end
+    end,
+    on_event_timer_elapsed = function(context, state, event)
+        if wipe.timer(context, state, event) or wipe.active(state) then return end
+        if dispatch("timer", context, state, event) then return end
+        if opening.playable(state) then landing.on_timer(context, state, event) end
+    end,
+    on_event_actor_path_state = function(context, state, event)
+        if wipe.active(state) then return end
+        -- Existing flights finish and retire even when the player has left this region.
+        if opening.playable(state) then
+            landing.on_actor_path_state(context, state, event)
+        end
     end,
     on_event_squad_state = function(context, state, event)
-        if state:variable("ember.region") == landing.region then
+        if wipe.active(state) then return end
+        if opening.playable(state) then
+            if routes then routes.squad(context, state, event) end
             landing.on_squad_state(context, state, event)
+            landing.update_guidance(context, state)
         end
     end,
+    on_event_object_state = function(c, s, e) dispatch("object", c, s, e) end,
+    on_event_damage_state = function(c, s, e) dispatch("damage", c, s, e) end,
+    on_event_fireteam_state = wipe.fireteam,
+    on_event_effect_result = wipe.effect,
 }

+ 381 - 0
scripts/mission_ember/apex.lua

@@ -0,0 +1,381 @@
+-- Light's End: authored access squads, damage-gated reactor, cell delivery and escape.
+return function(m, a, ending)
+    local A = {region = 0}
+    local sides = {"EAST", "WEST"}
+    local access = {"access1", "access2", "electron_controllers", "dispenser", "security"}
+    local reactor = {"reactor_east_entry", "reactor_west_entry", "reactor_east_reinforce", "reactor_west_reinforce",
+        "reactor_east_final", "reactor_west_final", "coffin_east", "coffin_west", "coffin_interior"}
+    local triggers = {"ACCESS_DOOR_INNER_PLAYER_TRIGGER", "ACCESS_JUMP_ONE_PLAYER_TRIGGER",
+        "ACCESS_JUMP_TWO_PLAYER_TRIGGER", "SECURITY_CENTER_PLAYER_TRIGGER", "SECURITY_DOOR_PLAYER_TRIGGER",
+        "SECURITY_LEDGE_PLAYER_TRIGGER", "APEX_DIRECTIVE_REACTOR_GOTO_PLAYER_TRIGGER",
+        "APEX_REACTOR_CLAMSHELL_001_DIALOG_START_PLAYER_TRIGGER", "REACTOR_ARM_EAST_ENTRANCE_PLAYER_TRIGGER",
+        "REACTOR_MOTHER_BRAIN_ENTRY_PLAYER_TRIGGGER", "DIALOG_APEX_SECURITY_001_TEST_PLAYER_TRIGGER",
+        "DIALOG_APEX_SECURITY_002_PLAYER_TRIGGER", "DIALOG_APEX_MOTHER_BRAIN_001_PLAYER_TRIGGER",
+        "DIALOG_APEX_MOTHER_BRAIN_002_PLAYER_TRIGGER", "DIALOG_APEX_MOTHER_BRAIN_003_PLAYER_TRIGGER",
+        "DIALOG_APEX_MOTHER_BRAIN_004_PLAYER_TRIGGER"}
+    local escape_triggers = {"APEX_MOTHER_BRAIN_005_DIALOG_PLAYER_TRIGGER", "APEX_MOTHER_BRAIN_006_DIALOG_PLAYER_TRIGGER",
+        "APEX_MOTHER_BRAIN_007_DIALOG_PLAYER_TRIGGER", "APEX_MOTHER_BRAIN_008_DIALOG_PLAYER_TRIGGER",
+        "APEX_DIRECTIVE_REACTOR_RAILS_ESCAPE_PLAYER_TRIGGER"}
+    local function phase(s) return s:variable("ember.apex.phase") or 0 end
+    local function set(c, p) c:set_variable("ember.apex.phase", p) end
+    local function generation(s) return s:variable("ember.apex.generation") or 1 end
+    local function dead(s, target) return s:variable("ember.apex.dead." .. target) == true end
+    local function vent_timer(s) return "ember.apex.vents." .. generation(s) end
+    local function lane(c, name, transition, snap)
+        a.slot(c, name):transition{transition = c.sdk.device_transitions[transition], snap = snap or false}
+    end
+    local function unlock(c, name)
+        lane(c, name, "unlock"); lane(c, name, "power_on")
+    end
+    local function bridges(c, extended, snap)
+        -- As with the landing bridge, the authored position-0 pose is extended.
+        for _, side in ipairs(sides) do
+            a.device(c, "CLAMSHELL_TO_COFFIN_" .. side .. "_BRIDGE_DEVICE", not extended, snap)
+        end
+    end
+    local function coffin_doors(c, open, snap)
+        for _, side in ipairs(sides) do
+            a.device(c, "REACTOR_COFFIN_DOOR_" .. side .. "_DEVICE", open, snap)
+            a.device(c, "REACTOR_COFFIN_LIGHT_" .. side .. "_DEVICE", open, snap)
+            lane(c, "REACTOR_COFFIN_LIGHT_" .. side .. "_DEVICE", open and "power_on" or "power_off", snap)
+        end
+        a.device(c, "REACTOR_SHIELD_DEVICE", open, snap)
+    end
+    local function beam(c, active, snap)
+        -- Both devices address the placed ring/laser objects and their native VFX graphs.
+        a.device(c, "SPECOPS_APEX_RING_LASER_DEVICE", active, snap)
+        a.device(c, "SPECOPS_APEX_RING_RING_DEVICE", active, snap)
+        -- FX power is independent of mechanical position; pulse both authored graphs.
+        lane(c, "SPECOPS_APEX_RING_LASER_DEVICE", active and "power_on" or "power_off", snap)
+        lane(c, "SPECOPS_APEX_RING_RING_DEVICE", active and "power_on" or "power_off", snap)
+    end
+    local function hazards(c, s, enabled)
+        a.effect(c, s, "REACTOR_MOTHER_BRAIN_HOT_PIPES_THERMAL_HOP_ON",
+            "REACTOR_MOTHER_BRAIN_HOT_PIPES_OBJECT_FILTER_80B3C09F",
+            {players = true, inside_any = {
+                a.slot(c, "REACTOR_MOTHER_BRAIN_HOT_PIPES_02_TRIGGER_VOLUME"),
+                a.slot(c, "REACTOR_MOTHER_BRAIN_HOT_PIPES_03_TRIGGER_VOLUME"),
+                a.slot(c, "SLOT_0005_80B3C09F"), a.slot(c, "SLOT_0006_80B3C09F"),
+                a.slot(c, "SLOT_0008_80B3C09F")}}, enabled)
+        a.effect(c, s, "AOD_REACTOR_RAIL_TOP_HOP_ON", "AOD_REACTOR_RAIL_TOP_OBJECT_FILTER",
+            {players = true, inside = a.slot(c, "SLOT_019E")}, enabled)
+    end
+    local function doors(c, side, open, snap)
+        for _, part in ipairs({"DOOR_A", "DOOR_B", "LIGHT_A", "LIGHT_B", "TARGET"}) do
+            local name = "REACTOR_CLAMSHELL_" .. side .. "_" .. part .. "_DEVICE"
+            a.device(c, name, open, snap)
+            if part == "LIGHT_A" or part == "LIGHT_B" or part == "TARGET" then
+                a.slot(c, name):transition{transition = open and c.sdk.device_transitions.power_on
+                    or c.sdk.device_transitions.power_off, snap = snap or false}
+            end
+        end
+    end
+    local function target(c, s, name)
+        a.slot(c, name .. "_TARGET_OBJECT"):set_interactable_object{generation = generation(s)}
+        -- Bind on the object's presence receipt: Auth delivery and entity creation
+        -- happen on different ticks. A missing/unloaded object is not a kill.
+    end
+    -- One encounter clock survives the clamshell -> coffin transition. Durations are
+    -- reconstructed from the reference's warning, exposure and recovery windows.
+    local function cycle(c, s, step, snap)
+        c:set_variable("ember.apex.vent_step", step)
+        c:set_variable("ember.apex.vents_open", step == "open")
+        if step == "warning" then
+            beam(c, true)
+            if phase(s) == 3 then
+                for _, side in ipairs(sides) do
+                    if not dead(s, side) then a.slot(c, "REACTOR_CLAMSHELL_" .. side .. "_ALARM_SEQUENCE"):play_sequence{} end
+                end
+            else a.slot(c, "REACTOR_COFFIN_ALARM_SEQUENCE"):play_sequence{} end
+        else
+            if step == "closed" then beam(c, false, snap) end
+            if phase(s) == 3 then
+                for _, side in ipairs(sides) do if not dead(s, side) then doors(c, side, step == "open", snap) end end
+            elseif phase(s) == 4 then
+                coffin_doors(c, step == "open", snap)
+                if step == "open" then a.cue(c, s, 46) end
+            end
+        end
+        c:start_timer(vent_timer(s), ({closed = 14000, warning = 6000, open = 10000})[step])
+    end
+    local function initialize_devices(c, s)
+        if s:variable("ember.apex.devices_ready") then return end
+        c:set_variable("ember.apex.devices_ready", true)
+        unlock(c, "SECURITY_DOOR_DEVICE")
+        for _, side in ipairs(sides) do
+            for _, part in ipairs({"DOOR_A", "DOOR_B"}) do unlock(c, "REACTOR_CLAMSHELL_" .. side .. "_" .. part .. "_DEVICE") end
+            unlock(c, "CLAMSHELL_TO_COFFIN_" .. side .. "_BRIDGE_DEVICE")
+            lane(c, "REACTOR_COFFIN_DOOR_" .. side .. "_DEVICE", "lock")
+        end
+        lane(c, "MOTHER_BRAIN_DOOR_DEVICE", "lock")
+        unlock(c, "SPECOPS_APEX_RING_LASER_DEVICE")
+        unlock(c, "SPECOPS_APEX_RING_RING_DEVICE")
+        beam(c, false, true)
+    end
+    local function start_reactor(c, s)
+        if phase(s) >= 3 then return end
+        initialize_devices(c, s)
+        set(c, 3)
+        a.checkpoint(c, 0, 0xDF59C25C, "reactor")
+        a.darkness(c, s, true)
+        a.device(c, "SECURITY_DOOR_DEVICE", true)
+        for _, side in ipairs(sides) do target(c, s, "REACTOR_CLAMSHELL_" .. side) end
+        a.spawn(c, s, {"reactor_east_entry", "reactor_west_entry"})
+        a.cue(c, s, 40); cycle(c, s, "closed", true)
+        c:start_timer("ember.apex.explain." .. generation(s), 9000)
+    end
+    local carry = require("mission_ember.carry")(a, "apex", "MOTHER_BRAIN_CARRY_OBJECT", "MOTHER_BRAIN_INTERACT_OBJECT",
+        function(c, s) a.cue(c, s, 50); A.guidance(c, s) end,
+        function(c, s)
+            if phase(s) ~= 5 then return end
+            set(c, 6)
+            c:start_timer("ember.apex.hazards", 1)
+            a.checkpoint(c, 0, 0x45920385, "escape")
+            a.device(c, "MOTHER_BRAIN_CONSOLE_DEVICE", true)
+            a.device(c, "MOTHER_BRAIN_ENGINE_LEFT_DEVICE", true)
+            a.device(c, "MOTHER_BRAIN_ENGINE_RIGHT_DEVICE", true)
+            a.device(c, "REACTOR_GETAWAY_SHIP_DEVICE", true)
+            a.objects(c, {"REACTOR_GETAWAY_SHIP_OBJECT", "SUNBURN_DAMAGE_OBJECT"}, true)
+            a.device(c, "SPECOPS_APEX_RING_LASER_DEVICE", true)
+            a.device(c, "SPECOPS_APEX_RING_RING_DEVICE", true)
+            a.scene(c, "MOTHER_BRAIN_HOLE_EXPLOSION_SCENE")
+            a.scene(c, "EMBER_APEX_EXPLOSION_SEQUENCE_PREFAB_TRIGGERED_EXPLOSIONS_SCENE")
+            for _, name in ipairs(escape_triggers) do a.slot(c, name):fire_trigger{} end
+            a.cue(c, s, 51); A.guidance(c, s)
+        end)
+    function A.enter(c, s)
+        if s:variable("ember.apex.restart_pending") then
+            c:clear_variable("ember.apex.restart_pending")
+            start_reactor(c, s); A.guidance(c, s); return
+        end
+        if phase(s) > 0 then return end
+        set(c, 1)
+        c:start_timer("ember.apex.setup." .. generation(s), 1)
+        a.device(c, "ACCESS_DOOR_INNER_DEVICE", true)
+        a.device(c, "ACCESS_DOOR_OUTER_DEVICE", true)
+        a.device(c, "SECURITY_DOOR_DEVICE", false, true)
+        a.slot(c, "SECURITY_PLACED_INTERCEPTOR_OBJECT"):set_interactable_object{generation = 1}
+        a.objects(c, {"SPECOPS_APEX_RING_LASER_OBJECT",
+            "SPECOPS_APEX_RING_CORE_OBJECT", "SPECOPS_APEX_RING_RING_OBJECT"}, true)
+        for _, side in ipairs(sides) do
+            doors(c, side, false, true)
+            a.device(c, "CLAMSHELL_PIPES_" .. side .. "_DEVICE", false, true)
+            a.device(c, "CLAMSHELL_TO_COFFIN_" .. side .. "_BRIDGE_DEVICE", true, true)
+            a.device(c, "REACTOR_COFFIN_DOOR_" .. side .. "_DEVICE", false, true)
+        end
+        a.device(c, "REACTOR_SHIELD_DEVICE", false, true)
+        a.device(c, "COFFIN_BUNKER_DOOR_SOUTH_DEVICE", false, true)
+        a.device(c, "MOTHER_BRAIN_DOOR_DEVICE", false, true)
+        for _, name in ipairs(triggers) do a.slot(c, name):fire_trigger{} end
+        a.spawn(c, s, {"access1"}); A.guidance(c, s)
+    end
+    function A.trigger(c, s, e)
+        if phase(s) <= 2 then
+            if a.matches(c, e, "ACCESS_JUMP_TWO_PLAYER_TRIGGER") then a.spawn(c, s, {"access2"}) end
+            if a.matches(c, e, "SECURITY_CENTER_PLAYER_TRIGGER") or a.matches(c, e, "SECURITY_LEDGE_PLAYER_TRIGGER")
+                or a.matches(c, e, "SECURITY_DOOR_PLAYER_TRIGGER") then
+                set(c, 2); a.spawn(c, s, access)
+                a.device(c, "DISPENSER_MONSTER_CLOSET_LIGHT_DEVICE", true)
+                if not s:variable("ember.apex.dispenser") then
+                    c:set_variable("ember.apex.dispenser", true)
+                    a.slot(c, "DISPENSER_MONSTER_CLOSET_SEQUENCE"):play_sequence{}
+                end
+                a.cue(c, s, 38)
+            end
+        end
+        if a.matches(c, e, "APEX_DIRECTIVE_REACTOR_GOTO_PLAYER_TRIGGER")
+            or a.matches(c, e, "APEX_REACTOR_CLAMSHELL_001_DIALOG_START_PLAYER_TRIGGER")
+            or a.matches(c, e, "REACTOR_ARM_EAST_ENTRANCE_PLAYER_TRIGGER") then start_reactor(c, s) end
+        -- The explanatory cue follows the arrival line, even if its trigger was crossed in the same tick.
+        if phase(s) == 5 and a.matches(c, e, "DIALOG_APEX_MOTHER_BRAIN_001_PLAYER_TRIGGER") then a.cue(c, s, 50) end
+        if phase(s) == 6 then
+            if a.matches(c, e, "APEX_MOTHER_BRAIN_006_DIALOG_PLAYER_TRIGGER") then a.cue(c, s, 52) end
+            if a.matches(c, e, "APEX_MOTHER_BRAIN_007_DIALOG_PLAYER_TRIGGER") then a.cue(c, s, 53) end
+            if a.matches(c, e, "APEX_MOTHER_BRAIN_008_DIALOG_PLAYER_TRIGGER") then a.cue(c, s, 54) end
+            if a.matches(c, e, "APEX_DIRECTIVE_REACTOR_RAILS_ESCAPE_PLAYER_TRIGGER") then
+                set(c, 7); a.darkness(c, s, false)
+                hazards(c, s, false)
+                c:cancel_timer("ember.apex.hazards")
+                c:cancel_timer(vent_timer(s))
+                ending.start(c, s)
+            end
+        end
+        A.guidance(c, s)
+    end
+    function A.cleared(c, s, name)
+        if name == "access1" and phase(s) <= 2 then a.spawn(c, s, {"access2"}) end
+        if name == "electron_controllers" and phase(s) <= 2 then
+            unlock(c, "SECURITY_DOOR_DEVICE")
+            a.device(c, "SECURITY_DOOR_DEVICE", true)
+        end
+        if phase(s) == 3 or phase(s) == 4 then
+            for _, side in ipairs({"east", "west"}) do
+                if name == "reactor_" .. side .. "_entry" then a.spawn(c, s, {"reactor_" .. side .. "_reinforce"}) end
+                if name == "reactor_" .. side .. "_reinforce" then a.spawn(c, s, {"reactor_" .. side .. "_final"}) end
+            end
+        end
+        A.guidance(c, s)
+    end
+    local function destroyed(c, s, which)
+        if dead(s, which) or (which == "COFFIN" and phase(s) ~= 4)
+            or (which ~= "COFFIN" and phase(s) ~= 3) then return end
+        c:set_variable("ember.apex.dead." .. which, true)
+        if which ~= "COFFIN" then
+            doors(c, which, true)
+            a.scene(c, "APEX_REACTOR_CLAMSHELL_" .. which .. "_DESTROYED_SCENE")
+            a.device(c, "CLAMSHELL_PIPES_" .. which .. "_DEVICE", true)
+            a.cue(c, s, dead(s, "EAST") and dead(s, "WEST") and 44 or 43)
+            if dead(s, "EAST") and dead(s, "WEST") then
+                set(c, 4) -- Keep the current warning/exposure deadline for the central reactor.
+                -- Remaining authored waves stay alive and retain their objectives.
+                a.spawn(c, s, {"reactor_east_reinforce", "reactor_west_reinforce", "reactor_east_final", "reactor_west_final"})
+                c:start_timer("ember.apex.core." .. generation(s), 1)
+            end
+        else
+            set(c, 5)
+            c:cancel_timer(vent_timer(s)); c:cancel_timer("ember.apex.explain." .. generation(s))
+            coffin_doors(c, true)
+            beam(c, true)
+            unlock(c, "MOTHER_BRAIN_DOOR_DEVICE")
+            unlock(c, "COFFIN_BUNKER_DOOR_SOUTH_DEVICE")
+            a.device(c, "MOTHER_BRAIN_DOOR_DEVICE", true)
+            a.device(c, "COFFIN_BUNKER_DOOR_SOUTH_DEVICE", true)
+            carry.start(c, s); a.cue(c, s, 48)
+        end
+        A.guidance(c, s)
+    end
+    function A.damage(c, s, e)
+        if e.revision ~= generation(s) or e.health == nil or e.health ~= e.health or e.health < 0 then return end
+        local which
+        for _, side in ipairs(sides) do if a.matches(c, e, "REACTOR_CLAMSHELL_" .. side .. "_TARGET_DAMAGE") then which = side end end
+        if a.matches(c, e, "REACTOR_COFFIN_TARGET_DAMAGE") then which = "COFFIN" end
+        if not which or dead(s, which) or (which == "COFFIN" and phase(s) ~= 4)
+            or (which ~= "COFFIN" and phase(s) ~= 3) then return end
+        local seen = "ember.apex.healthy." .. which
+        if e.health > 0 then c:set_variable(seen, true); return end
+        if not s:variable(seen) then return end -- Absent/unloaded targets are never destruction receipts.
+        destroyed(c, s, which)
+    end
+    function A.timer(c, s, e)
+        if e.timer_name == "ember.apex.hazards" then
+            if phase(s) == 6 and s:variable("ember.region") == 0 then hazards(c, s, true) end
+            return true
+        end
+        if carry.timer(c, s, e) then return true end
+        if e.timer_name == "ember.apex.setup." .. generation(s) then
+            if s:variable("ember.region") == 0 then initialize_devices(c, s) end
+            return true
+        end
+        if e.timer_name == "ember.apex.explain." .. generation(s) then
+            if phase(s) == 3 and s:variable("ember.region") == 0 then a.cue(c, s, 41); A.guidance(c, s) end
+            return true
+        end
+        if e.timer_name == vent_timer(s) then
+            if (phase(s) == 3 or phase(s) == 4) and s:variable("ember.region") == 0 then
+                cycle(c, s, ({closed = "warning", warning = "open", open = "closed"})[s:variable("ember.apex.vent_step")] or "closed")
+                A.guidance(c, s)
+            end
+            return true
+        end
+        if e.timer_name == "ember.apex.core." .. generation(s) and phase(s) == 4 then
+            if s:variable("ember.region") ~= 0 or s:variable("ember.apex.core_ready") then return true end
+            c:set_variable("ember.apex.core_ready", true)
+            bridges(c, true)
+            for _, side in ipairs(sides) do unlock(c, "REACTOR_COFFIN_DOOR_" .. side .. "_DEVICE") end
+            unlock(c, "REACTOR_SHIELD_DEVICE")
+            coffin_doors(c, s:variable("ember.apex.vents_open") == true)
+            target(c, s, "REACTOR_COFFIN")
+            a.spawn(c, s, {"coffin_east", "coffin_west", "coffin_interior"})
+            A.guidance(c, s); return true
+        end
+        return false
+    end
+    function A.guidance(c, s)
+        if s:variable("ember.region") ~= 0 then return end
+        local p = phase(s)
+        if p == 1 then a.directive(c, s, "4E4862BB", "SECURITY_INTERCEPTOR_NAV_POINT", a.combat(s, access))
+        elseif p == 2 then a.directive(c, s, s:variable("ember.apex.interceptor_boarded") and "4E4862BB" or "3CBFC90B",
+            s:variable("ember.apex.interceptor_boarded") and "APEX_DIRECTIVE_REACTOR_CLAMSHELL_GOTO_NAV_POINT" or "SECURITY_INTERCEPTOR_NAV_POINT", a.combat(s, access))
+        elseif p == 3 then a.directive(c, s, "26C3C19D", "APEX_DIRECTIVE_REACTOR_CLAMSHELL_GOTO_NAV_POINT", a.combat(s, reactor))
+        elseif p == 4 then a.directive(c, s, "61D2B286", "APEX_DIRECTIVE_REACTOR_COFFIN_TARGET_NAV_POINT", a.combat(s, reactor))
+        elseif p == 5 then a.directive(c, s, "4746660F",
+            carry.held(s) and "EMBER_DIRECTIVE_REACTOR_MOTHER_BRAIN_DELIVERY_NAV_POINT" or "APEX_WEAPON_NAV_POINT", a.combat(s, reactor))
+        elseif p == 6 then a.directive(c, s, "40FC40AD", "APEX_DIRECTIVE_REACTOR_RAILS_ESCAPE_NAV_POINT", false) end
+    end
+    function A.reset(c, s, name)
+        if name == "escape" then
+            c:start_timer("ember.apex.hazards", 1)
+            set(c, 6)
+            for _, t in ipairs(escape_triggers) do a.slot(c, t):fire_trigger{} end
+            a.darkness(c, s, true)
+        else
+            c:cancel_timer(vent_timer(s)); c:cancel_timer("ember.apex.core." .. generation(s))
+            c:cancel_timer("ember.apex.explain." .. generation(s)); c:cancel_timer("ember.apex.setup." .. generation(s))
+            a.reset(c, reactor); carry.reset(c, s)
+            c:set_variable("ember.apex.generation", generation(s) + 2)
+            c:clear_variable("ember.apex.core_ready")
+            for _, side in ipairs({"EAST", "WEST", "COFFIN"}) do
+                c:clear_variable("ember.apex.dead." .. side); c:clear_variable("ember.apex.healthy." .. side)
+            end
+            for _, name in ipairs({"EMBER_APEX_REACTOR_CLAMSHELL_EAST_OBJECTIVE", "EMBER_APEX_REACTOR_CLAMSHELL_WEST_OBJECTIVE",
+                "EMBER_APEX_REACTOR_COFFIN_OBJECTIVE"}) do a.slot(c, name):reset_objectives{} end
+            a.objects(c, {"REACTOR_COFFIN_TARGET_OBJECT"}, false)
+            for _, side in ipairs(sides) do
+                a.device(c, "CLAMSHELL_TO_COFFIN_" .. side .. "_BRIDGE_DEVICE", true, true)
+                a.device(c, "REACTOR_COFFIN_DOOR_" .. side .. "_DEVICE", false, true)
+                lane(c, "REACTOR_COFFIN_DOOR_" .. side .. "_DEVICE", "lock")
+            end
+            lane(c, "MOTHER_BRAIN_DOOR_DEVICE", "lock")
+            a.device(c, "MOTHER_BRAIN_DOOR_DEVICE", false, true)
+            a.device(c, "COFFIN_BUNKER_DOOR_SOUTH_DEVICE", false, true)
+            a.device(c, "REACTOR_SHIELD_DEVICE", false, true)
+            set(c, 2)
+            -- Publish the reset now; repopulate on the first playable client report
+            -- after the wipe handshake. A one-millisecond timer can expire during the fade.
+            c:set_variable("ember.apex.restart_pending", true)
+        end
+        c:clear_variable("ember.r.guidance")
+    end
+    function A.resume(c, s)
+        carry.resume(c, s)
+        if phase(s) == 4 and not s:variable("ember.apex.core_ready") then
+            c:start_timer("ember.apex.core." .. generation(s), 1)
+        end
+        a.darkness(c, s, phase(s) >= 3 and phase(s) <= 6)
+        if phase(s) == 3 or phase(s) == 4 then
+            cycle(c, s, "closed", true)
+            if phase(s) == 3 and not s:variable("ember.r.cue.41") then c:start_timer("ember.apex.explain." .. generation(s), 9000) end
+        end
+    end
+    function A.interaction(c, s, e)
+        if a.matches(c, e, "SECURITY_PLACED_INTERCEPTOR_OBJECT") and e.generation == 1
+            and not s:variable("ember.apex.interceptor_boarded") then
+            c:set_variable("ember.apex.interceptor_boarded", true)
+        end
+        carry.interaction(c, s, e)
+        A.guidance(c, s)
+    end
+    function A.object(c, s, e)
+        carry.state(c, s, e)
+        if e.generation == generation(s) then
+            for _, which in ipairs({"EAST", "WEST", "COFFIN"}) do
+                local prefix = which == "COFFIN" and "REACTOR_COFFIN" or ("REACTOR_CLAMSHELL_" .. which)
+                if a.matches(c, e, prefix .. "_TARGET_OBJECT") and not dead(s, which)
+                    and ((which == "COFFIN" and phase(s) == 4) or (which ~= "COFFIN" and phase(s) == 3)) then
+                    local seen = "ember.apex.healthy." .. which
+                    if e.present and e.alive then
+                        if not s:variable(seen) then
+                            c:set_variable(seen, true)
+                            a.slot(c, prefix .. "_TARGET_DAMAGE"):watch_damage{
+                                target = a.slot(c, prefix .. "_TARGET_OBJECT"), revision = generation(s)}
+                        end
+                    elseif e.present and e.alive == false and s:variable(seen) then
+                        destroyed(c, s, which)
+                    end
+                end
+            end
+        end
+        A.guidance(c, s)
+    end
+    return A
+end

+ 91 - 0
scripts/mission_ember/carry.lua

@@ -0,0 +1,91 @@
+-- The native pickup grants the cell and the receptacle owns its usability/item criteria.
+-- Ownership Sense distinguishes held, dropped and missing cells. Only accepted receptacle
+-- interaction completes delivery; proximity and the recording's timing cannot complete it.
+return function(api, tag, pickup, receptacle, on_pickup, on_deposit)
+    local function key(suffix) return "ember.carry." .. tag .. "." .. suffix end
+    local function generation(s) return s:variable(key("generation")) or 1 end
+    local function recovery(s) return "ember.carry.recover." .. tag .. "." .. generation(s) end
+    local region = tag == "processing" and 56 or 0
+    local obj = {}
+    function obj.start(c, s)
+        if s:variable(key("started")) then return end
+        c:set_variable(key("started"), true)
+        c:set_variable(key("receptacle_generation"), generation(s))
+        api.slot(c, pickup):set_interactable_object{generation = generation(s), track_owner = true}
+        api.slot(c, receptacle):set_interactable_object{generation = generation(s)}
+    end
+    function obj.interaction(c, s, e)
+        if not s:variable(key("started")) or s:variable(key("done")) then return end
+        if api.matches(c, e, receptacle) and e.generation == s:variable(key("receptacle_generation")) then
+            -- Require a pickup receipt as well as native accepted use of the receptacle.
+            if not s:variable(key("notified")) then return end
+            c:set_variable(key("done"), true)
+            c:cancel_timer(recovery(s))
+            c:clear_variable(key("recover_armed"))
+            c:clear_variable(key("recover_pending"))
+            c:set_variable(key("held"), false)
+            c:set_variable(key("present"), false)
+            -- Native 9F19F0 destroys the old entity on a newer generation and
+            -- active=false prevents replacement. Includes cells attached to a player.
+            -- Keep the pickup generation for stale-event guards; a reset advances by two.
+            api.slot(c, pickup):set_interactable_object{
+                generation = generation(s) + 1, active = false, track_owner = true}
+            on_deposit(c, s)
+        elseif api.matches(c, e, pickup) and e.generation == generation(s) and not s:variable(key("notified")) then
+            c:set_variable(key("notified"), true)
+            c:set_variable(key("seen"), true)
+            c:set_variable(key("held"), true)
+            on_pickup(c, s)
+        end
+    end
+    function obj.state(c, s, e)
+        if not s:variable(key("started")) or s:variable(key("done"))
+            or e.generation ~= generation(s) or not api.matches(c, e, pickup) then return end
+        -- Expired cells may retain an entity for cleanup after they stop being alive.
+        -- That entity cannot be carried or inserted and must not suppress recovery.
+        local usable = e.present and e.alive ~= false
+        c:set_variable(key("present"), usable)
+        if usable then
+            c:set_variable(key("seen"), true)
+            c:cancel_timer(recovery(s))
+            c:clear_variable(key("recover_armed"))
+            if e.owner_known then c:set_variable(key("held"), e.has_owner) end
+            if e.has_owner and not s:variable(key("notified")) then
+                c:set_variable(key("notified"), true); on_pickup(c, s)
+            end
+        else
+            c:set_variable(key("held"), false)
+            -- Also recover when the first acknowledged generation is already missing;
+            -- a late subscription must not require an earlier present/held event.
+            if not s:variable(key("recover_armed")) then
+                c:set_variable(key("recover_armed"), true)
+                c:start_timer(recovery(s), 2000)
+            end
+        end
+    end
+    function obj.timer(c, s, e)
+        if e.timer_name ~= recovery(s) then return false end
+        c:clear_variable(key("recover_armed"))
+        if s:variable(key("done")) or s:variable(key("present")) then return true end
+        if s:variable("ember.region") ~= region then c:set_variable(key("recover_pending"), true); return true end
+        c:set_variable(key("generation"), generation(s) + 2)
+        c:clear_variable(key("seen")); c:clear_variable(key("notified"))
+        api.slot(c, pickup):set_interactable_object{generation = generation(s), track_owner = true}
+        -- Preserve the receptacle's current generation. A cell's expiry is not a new objective.
+        return true
+    end
+    function obj.resume(c, s)
+        if s:variable(key("recover_pending")) then
+            c:clear_variable(key("recover_pending")); c:start_timer(recovery(s), 2000)
+        end
+    end
+    function obj.reset(c, s)
+        c:cancel_timer(recovery(s))
+        api.objects(c, {pickup, receptacle}, false)
+        c:set_variable(key("generation"), generation(s) + 2)
+        for _, suffix in ipairs({"started", "done", "seen", "held", "present", "notified", "recover_pending", "recover_armed"}) do c:clear_variable(key(suffix)) end
+    end
+    function obj.held(s) return s:variable(key("held")) == true end
+    function obj.done(s) return s:variable(key("done")) == true end
+    return obj
+end

+ 162 - 0
scripts/mission_ember/cinder.lua

@@ -0,0 +1,162 @@
+-- Route policy reconstructed from the video and authored volume positions.
+return function(m, a)
+    local C = {region = 40}
+    local steps = {
+        {trigger = "READY_ROOM_01_SPAWN_SET_02_PLAYER_TRIGGER_80B3C618", groups = {"ready1_reinforce"}, phase = 1},
+        {trigger = "READY_ROOM_01_SPAWN_SET_03_PLAYER_TRIGGER", groups = {"ready1_reinforce"}, phase = 1},
+        {trigger = "TUMBLER_ENTRY_PLAYER_TRIGGER", groups = {"tumbler"}, phase = 2},
+        {trigger = "DECK_EAST_ENTRY_PLAYER_TRIGGER_80B3C6E8", groups = {"sunburn_east"}, phase = 3, cue = 26},
+        {trigger = "DECK_BRIDGE_025_PERCENT_PLAYER_TRIGGER_80B3C6E8", groups = {"sunburn_bridge"}, phase = 4, cue = 27},
+        {trigger = "DECK_BRIDGE_100_PERCENT_PLAYER_TRIGGER", groups = {"sunburn_west"}, phase = 5},
+        -- The deck exit volume is still outdoors. Preload defenders there, but
+        -- begin the indoor checkpoint/dialogue only beyond the far Sunside door.
+        {trigger = "DECK_WEST_EXIT_PLAYER_TRIGGER_80B3C6E8", groups = {"ready2_entry"}, phase = 5},
+        {trigger = "DIALOG_CINDER_READY_ROOM_02_001_PLAYER_TRIGGER", groups = {"ready2_entry"}, phase = 6, cue = 28},
+        {trigger = "READY_ROOM_02_SPAWN_SET_01_PLAYER_TRIGGER", groups = {"ready2_entry"}, phase = 6},
+        {trigger = "READY_ROOM_02_SPAWN_SET_02_PLAYER_TRIGGER", groups = {"ready2_reinforce"}, phase = 6},
+        {trigger = "READY_ROOM_02_SPAWN_SET_03_PLAYER_TRIGGER", groups = {"ready2_reinforce"}, phase = 6},
+        {trigger = "READY_ROOM_02_TO_CHAMBER_PLAYER_TRIGGER_80B3C67D", groups = {"chamber"}, phase = 7, cue = 30},
+        {trigger = "CHAMBER_SPAWN_SET_01_PLAYER_TRIGGER", groups = {"chamber"}, phase = 7},
+        {trigger = "MEAT_GRINDER_SPAWN_SET_01_PLAYER_TRIGGER", groups = {"meat1"}, phase = 8},
+        {trigger = "MEAT_GRINDER_SPAWN_SET_02_PLAYER_TRIGGER", groups = {"meat2"}, phase = 9},
+        {trigger = "ASCENT_SPAWN_SET_01_PLAYER_TRIGGER", groups = {"ascent"}, phase = 10, cue = 31},
+        {trigger = "OVERLOOK_TO_FOUNDRY_PLAYER_TRIGGER", groups = {"foundry_entry"}, phase = 11, cue = 33},
+        {trigger = "FOUNDRY_SPAWN_SET_02_PLAYER_TRIGGER", groups = {"foundry_mid"}, phase = 12},
+        {trigger = "FOUNDRY_SPAWN_SET_03_PLAYER_TRIGGER", groups = {"foundry_final"}, phase = 13},
+    }
+    local extras = {"DECK_EAST_SECRET_PLAYER_TRIGGER", "ASCENT_SPAWN_SET_02_PLAYER_TRIGGER",
+        "ASCENT_SPAWN_SET_03_PLAYER_TRIGGER", "ASCENT_SPAWN_SET_04_PLAYER_TRIGGER",
+        "CHUTE_FIRST_WINDOW_PLAYER_TRIGGER", "APEX_GOTO_001_DIALOG_PLAYER_TRIGGER",
+        "DIALOG_CINDER_SUNBURN_001_PLAYER_TRIGGER", "DIALOG_CINDER_READY_ROOM_02_002_PLAYER_TRIGGER",
+        "CINDER_FOUNDRY_001_DIALOG_PLAYER_TRIGGER"}
+    local current_groups = {{"ready1_entry", "ready1_reinforce"}, {"tumbler"}, {"sunburn_east"},
+        {"sunburn_bridge"}, {"sunburn_west"}, {"ready2_entry", "ready2_reinforce"}, {"chamber"},
+        {"meat1"}, {"meat2"}, {"ascent"}, {"foundry_entry", "foundry_mid", "foundry_final"}}
+    local function phase(s) return s:variable("ember.cinder.phase") or 0 end
+    local function set(c, p) c:set_variable("ember.cinder.phase", p) end
+    local function retreat(c, s, area, letter)
+        local group = area:lower() .. "_retreat_" .. letter:lower()
+        if a.groups[group]:enter(c, s) then a.scene(c, area .. "_SQUAD_RETREAT_INSTANCE_" .. letter .. "_PREFAB_RETREAT_SCENE") end
+    end
+    local function arm(c, minimum)
+        for _, row in ipairs(steps) do if row.phase >= (minimum or 0) then a.slot(c, row.trigger):fire_trigger{} end end
+        for _, name in ipairs(extras) do a.slot(c, name):fire_trigger{} end
+    end
+    local function foundry_doors(c, open, snap)
+        for _, name in ipairs({"FOUNDRY_HATCH_DOOR_LEFT_DEVICE", "FOUNDRY_HATCH_DOOR_RIGHT_DEVICE",
+            "FOUNDRY_RAMROD_LEFT_DEVICE", "FOUNDRY_RAMROD_RIGHT_DEVICE", "FOUNDRY_WARNING_LIGHT_DEVICE"}) do
+            a.device(c, name, open, snap)
+        end
+    end
+    function C.enter(c, s)
+        if phase(s) > 0 then return end
+        set(c, 1)
+        a.objects(c, {"CORE_WEAPON_LASER_BEAM_OBJECT_80B3C22D"}, true)
+        -- This is the entrance to Ready Room 1; its defenders are beyond the hatch.
+        a.device(c, "TUMBLER_HATCH_DOOR_DEVICE", true)
+        a.slot(c, "TUMBLER_HATCH_DOOR_KLAXON_AUDIO_SEQUENCE"):play_sequence{}
+        a.device(c, "READY_ROOM_01_WINDOW_CONSOLE_DEVICE", false, true)
+        foundry_doors(c, false, true)
+        arm(c)
+        -- Exact 60/118 volume backing DECK_WHOLE_PLAYER_TRIGGER in 80B3C6E8.
+        a.effect(c, s, "DECK_HEAT_SHIMMER_HOP_ON", "FOUNDRY_THERMAL_DOT_OBJECT_FILTER",
+            {players = true, inside = a.slot(c, "SLOT_0076_80B3C6E8")}, true)
+        for _, name in ipairs({"WEAPON_DOWN", "NO_COMBAT_ABILITIES"}) do
+            a.effect(c, s, name .. "_HOP_ON", name .. "_OBJECT_FILTER",
+                {players = true, inside = a.slot(c, "TV_CHUTE_MOTION_BLUR_TRIGGER_VOLUME")}, true)
+        end
+        a.spawn(c, s, {"ready1_entry"})
+        a.cue(c, s, 19)
+        C.guidance(c, s)
+    end
+    local function advance(c, s, row)
+        local previous = phase(s)
+        if row.phase < previous then return end
+        -- Spatial fallback catches a skipped narrow volume within this room, never a completed room.
+        if row.phase > previous then
+            set(c, row.phase)
+            if row.phase == 6 then a.checkpoint(c, 40, 0x82328D63, "ready2") end
+            if row.phase >= 11 and previous < 11 then a.checkpoint(c, 40, 0x782CAF4C, "foundry") end
+        end
+        if row.phase == 6 then a.spawn(c, s, {"ready2_entry"}) end
+        if row.phase >= 11 and row.phase <= 13 then
+            a.spawn(c, s, {"foundry_entry"})
+            if row.phase >= 12 then a.spawn(c, s, {"foundry_mid"}) end
+        end
+        a.spawn(c, s, row.groups)
+        if row.phase == 6 then a.darkness(c, s, not a.clear(s, current_groups[6]))
+        elseif row.phase >= 11 and row.phase <= 13 then a.darkness(c, s, not a.clear(s, current_groups[11])) end
+        if row.cue then a.cue(c, s, row.cue) end
+        if row.phase == 3 then
+            retreat(c, s, "SUNBURN", "A"); retreat(c, s, "SUNBURN", "B"); retreat(c, s, "SUNBURN", "C")
+        end
+    end
+    function C.trigger(c, s, e)
+        for _, row in ipairs(steps) do
+            if a.matches(c, e, row.trigger) then
+                advance(c, s, row); C.guidance(c, s); return
+            end
+        end
+        if a.matches(c, e, "DECK_EAST_SECRET_PLAYER_TRIGGER") and phase(s) <= 5 then a.spawn(c, s, {"sunburn_secret"}) end
+        for index, letter in ipairs({"A", "B", "C"}) do
+            if a.matches(c, e, "ASCENT_SPAWN_SET_0" .. (index + 1) .. "_PLAYER_TRIGGER") and phase(s) <= 10 then
+                retreat(c, s, "ASCENT", letter)
+            end
+        end
+        if a.matches(c, e, "DIALOG_CINDER_SUNBURN_001_PLAYER_TRIGGER") then a.cue(c, s, 23) end
+        if a.matches(c, e, "DIALOG_CINDER_READY_ROOM_02_002_PLAYER_TRIGGER") and phase(s) == 6 then a.cue(c, s, 20) end
+        if a.matches(c, e, "CHUTE_FIRST_WINDOW_PLAYER_TRIGGER") and phase(s) >= 14 then a.cue(c, s, 35) end
+        if a.matches(c, e, "APEX_GOTO_001_DIALOG_PLAYER_TRIGGER") and phase(s) >= 14 then a.cue(c, s, 36) end
+        C.guidance(c, s)
+    end
+    function C.cleared(c, s, name)
+        if name == "ready1_entry" then a.spawn(c, s, {"ready1_reinforce"}) end
+        if (name == "ready1_entry" or name == "ready1_reinforce") and a.clear(s, current_groups[1]) then
+            a.device(c, "READY_ROOM_01_WINDOW_CONSOLE_DEVICE", true)
+            a.spawn(c, s, {"tumbler"})
+        end
+        if name == "ready2_entry" then a.spawn(c, s, {"ready2_reinforce"}) end
+        if (name == "ready2_entry" or name == "ready2_reinforce") and phase(s) == 6 and a.clear(s, current_groups[6]) then a.darkness(c, s, false) end
+        if name == "foundry_entry" then a.spawn(c, s, {"foundry_mid"}) end
+        if name == "foundry_mid" then a.spawn(c, s, {"foundry_final"}) end
+        if phase(s) >= 11 and phase(s) <= 13
+            and (name == "foundry_entry" or name == "foundry_mid" or name == "foundry_final")
+            and a.clear(s, current_groups[11]) then
+            set(c, 14)
+            foundry_doors(c, true)
+            a.slot(c, "FOUNDRY_HATCH_DOOR_CENTER_KLAXON_AUDIO_SEQUENCE"):play_sequence{}
+            a.darkness(c, s, false)
+            a.cue(c, s, 34)
+        end
+        C.guidance(c, s)
+    end
+    function C.guidance(c, s)
+        if s:variable("ember.region") ~= 40 then return end
+        local p = phase(s)
+        local combat = a.combat(s, current_groups[math.min(p, 11)] or {})
+        if p <= 2 then a.directive(c, s, "ECECF63D", "CINDER_DIRECTIVE_SUNBURN_GOTO_NAV_POINT", combat)
+        elseif p <= 5 then a.directive(c, s, "D496059B", "CINDER_DIRECTIVE_READY_ROOM_02_GOTO_NAV_POINT", combat)
+        elseif p == 6 then a.directive(c, s, combat and "7FF69D75" or "DF93A91C", "CINDER_DIRECTIVE_MEAT_GRINDER_GOTO_NAV_POINT", combat)
+        elseif p == 7 then a.directive(c, s, "62E3AEFB", "CINDER_DIRECTIVE_MEAT_GRINDER_GOTO_NAV_POINT", combat)
+        elseif p <= 10 then a.directive(c, s, "BAD7D583", "CINDER_DIRECTIVE_FOUNDRY_GOTO_NAV_POINT", combat)
+        elseif p <= 13 then a.directive(c, s, "7FF69D75", "FOUNDRY_HATCH_DOOR_CENTER_NAV_POINT", combat)
+        else a.directive(c, s, "FF7AB219", "CINDER_DIRECTIVE_CHUTE_GOTO_NAV_POINT", false) end
+    end
+    function C.resume(c, s)
+        if (s:variable("ember.r.furthest") or 0) > 2 then return end
+        local p = phase(s)
+        a.darkness(c, s, (p == 6 and not a.clear(s, current_groups[6]))
+            or (p >= 11 and p <= 13 and not a.clear(s, current_groups[11])))
+    end
+    function C.reset(c, s, name)
+        local first = name == "ready2" and 6 or 11
+        a.reset(c, current_groups[first])
+        if first == 11 then foundry_doors(c, false, true) end
+        a.slot(c, first == 6 and "EMBER_CINDER_READY_ROOM_02_OBJECTIVE" or "EMBER_CINDER_FOUNDRY_OBJECTIVE"):reset_objectives{}
+        set(c, first); arm(c, first)
+        a.spawn(c, s, {first == 6 and "ready2_entry" or "foundry_entry"})
+        a.darkness(c, s, true)
+        c:clear_variable("ember.r.guidance"); C.guidance(c, s)
+    end
+    return C
+end

+ 30 - 0
scripts/mission_ember/combat_ai.lua

@@ -0,0 +1,30 @@
+-- Native objective costs select the group; the objective owns areas and task execution.
+local AI = {}
+function AI.assign(context, sensor, objective, group, reserved)
+    context:slot(sensor):assign_combat_objective{
+        objective = context:slot(objective), revision = 1, task_group = group, reserved = reserved or false,
+    }
+end
+function AI.update(context, state, key, sensor, objective, group_count, event, ordinal, reserved)
+    if event.objective_revision ~= 1 or not event.task_costs then return end
+    -- Twelve five-bit group choices fit in one signed 64-bit durable Lua integer.
+    -- Zero encodes unassigned; 1..24 encode native task groups 0..23.
+    local shift = ((ordinal or 1) - 1) * 5
+    assert(shift >= 0 and shift <= 55, "AI state lane is outside its packed word")
+    local packed = state:variable(key) or 0
+    local current = ((packed >> shift) & 31) - 1
+    local best, cost = -1, 2040 -- native 4EB8E0's saturated/unreachable value
+    for index = 1, group_count do
+        local candidate = event.task_costs[index]
+        if candidate and candidate >= 0 and candidate < cost then
+            best, cost = index - 1, candidate
+        end
+    end
+    -- Keep the current group on equal quantized costs to avoid needless relinking.
+    if best >= 0 and current and current >= 0 and event.task_costs[current + 1] == cost then best = current end
+    if current ~= best then
+        AI.assign(context, sensor, objective, best, reserved)
+        context:set_variable(key, (packed & ~(31 << shift)) | ((best + 1) << shift))
+    end
+end
+return AI

+ 48 - 12
scripts/mission_ember/encounter.lua

@@ -1,11 +1,12 @@
 -- One encounter's progress lives in transactional mission state, never module-local tables.
+local AI = require("mission_ember.combat_ai")
 local Encounter = {}
 Encounter.__index = Encounter
 
-function Encounter.new(tag, squads, cleared)
+function Encounter.new(tag, squads, cleared, reserved)
     assert(#squads > 0 and #squads <= 30, "encounter needs 1..30 squads")
     return setmetatable({tag = tag, squads = squads, cleared = cleared,
-                         full = (1 << #squads) - 1}, Encounter)
+                         full = (1 << #squads) - 1, reserved = reserved or false}, Encounter)
 end
 
 function Encounter:phase(state)
@@ -30,8 +31,14 @@ function Encounter:enter(context, state)
     context:set_variable(self.tag .. ".seen", 0)
     context:set_variable(self.tag .. ".alive", 0)
     context:set_variable(self.tag .. ".phase", 1)
-    for _, handle in ipairs(handles) do
-        handle:place{mode = context.sdk.squad_modes.replace}
+    for index, handle in ipairs(handles) do
+        handle:place{mode = self.reserved and context.sdk.squad_modes.reserve or context.sdk.squad_modes.replace}
+        local squad = self.squads[index]
+        if squad.objective then
+            AI.assign(context, squad.sensor, squad.objective, squad.fixed_task or -1, self.reserved)
+            local word = (index - 1) // 12
+            if (index - 1) % 12 == 0 then context:set_variable(self.tag .. ".ai." .. word, 0) end
+        end
     end
     return true
 end
@@ -40,12 +47,17 @@ function Encounter:on_squad_state(context, state, event)
     if self:phase(state) ~= 1 then
         return
     end
-    local bit
+    local bit, matched
     for index, squad in ipairs(self.squads) do
         local slot = context:slot(squad.sensor)
         if event.registry_key == slot.registry_key and event.slot_type == slot.slot_type
             and event.slot_index == slot.slot_index then
             bit = 1 << (index - 1)
+            matched = squad
+            if squad.objective and squad.fixed_task == nil then
+                AI.update(context, state, self.tag .. ".ai." .. ((index - 1) // 12),
+                          squad.sensor, squad.objective, squad.task_groups, event, (index - 1) % 12 + 1, self.reserved)
+            end
             break
         end
     end
@@ -54,16 +66,23 @@ function Encounter:on_squad_state(context, state, event)
     end
     local seen = state:variable(self.tag .. ".seen") or 0
     local alive = state:variable(self.tag .. ".alive") or 0
-    if event.removal_flag then
-        context:set_variable(self.tag .. ".seen", seen & ~bit)
-        context:set_variable(self.tag .. ".alive", alive & ~bit)
-        return
-    end
-    -- Initial empty Sense and an unloaded squad are not evidence of a combat clear.
+    -- Native A9CAA0/4E5C80 publish root ordinal 8 from the squad's state flags.
+    -- It is also set on real deaths; it does not identify a squad unload.
+    -- Require evidence of positive population before accepting a zero count.
     if event.alive_count > 0 or (event.previous_alive_count or 0) > 0 then
         seen = seen | bit
     end
-    if event.alive_count > 0 then
+    -- Sense .11 counts consumed requests (native 4E8660/4E8200), not births.
+    -- Zero living actors while requests remain is an incomplete placement, not
+    -- a cleared wave. Keep that squad outstanding until every authored slot is
+    -- accounted for, including waves whose members arrive over several frames.
+    local accounted = event.slot_counts ~= nil
+    for index, requested in ipairs(context:squad(matched.id).default_counts) do
+        if not event.slot_counts or (event.slot_counts[index] or -1) < requested then
+            accounted = false
+        end
+    end
+    if event.alive_count > 0 or not accounted then
         alive = alive | bit
     else
         alive = alive & ~bit
@@ -72,12 +91,29 @@ function Encounter:on_squad_state(context, state, event)
     context:set_variable(self.tag .. ".alive", alive)
     if seen == self.full and alive == 0 then
         context:set_variable(self.tag .. ".phase", 2)
+        -- Retire the native requested counts as well as the Lua encounter. The host
+        -- republishes retained Auth when regions return; a completed wave must retain
+        -- zero requests, not its original spawn composition.
+        for _, squad in ipairs(self.squads) do
+            local handle = context:squad(squad.id)
+            local counts = handle:counts()
+            for index = 1, #handle.default_counts do counts:set(index, 0) end
+            handle:place{counts = counts,
+                mode = self.reserved and context.sdk.squad_modes.reserve or context.sdk.squad_modes.replace}
+        end
         self.cleared(context, state)
     end
 end
 
 -- Called only by a confirmed checkpoint reset, not by region transit or script reload.
 function Encounter:reset(context)
+    for _, squad in ipairs(self.squads) do
+        local handle = context:squad(squad.id)
+        local counts = handle:counts()
+        for index = 1, #handle.default_counts do counts:set(index, 0) end
+        handle:place{counts = counts,
+            mode = self.reserved and context.sdk.squad_modes.reserve or context.sdk.squad_modes.replace}
+    end
     context:clear_variable(self.tag .. ".phase")
     context:clear_variable(self.tag .. ".seen")
     context:clear_variable(self.tag .. ".alive")

+ 54 - 0
scripts/mission_ember/ending.lua

@@ -0,0 +1,54 @@
+-- Authored post-escape cinematic regions; exact native completion/skip incidents advance once.
+return function(m)
+    local movies = {
+        {state = m.states.STATE_80B3C09E_0000_0001_80B3C091, slot = m.Slot.PF_CINEMATIC_BOOKEND_STM_CINEMATIC},
+        {state = m.states.STATE_80B3C09E_0000_0002_80B3C093, slot = m.Slot.PF_CINEMATIC_BOOKEND_CNN_CINEMATIC},
+    }
+    local E = {}
+    local staging = assert(m.states.STATE_80B3C09E_0006_0001_80B3C09A)
+    local function transit(c)
+        c:set_variable("ember.ending.transit", true)
+        c:select_state(staging)
+    end
+    local music = require("mission_ember.music")(m)
+    function E.start(c, s)
+        if s:variable("ember.ending") then return end
+        c:set_variable("ember.ending", 1)
+        music.update(c, s)
+        transit(c)
+    end
+    function E.client(c, s, e)
+        local index = s:variable("ember.ending")
+        local row = index and movies[index]
+        if not row or s:variable("ember.ending.playing") == index then return end
+        local held = e.held_region_index or e.current_region_index
+        if s:variable("ember.ending.transit") then
+            if held == staging.region_index then
+                c:clear_variable("ember.ending.transit")
+                c:select_state(row.state)
+            end
+            return
+        end
+        if held == row.state.region_index then
+            c:set_variable("ember.ending.playing", index)
+            c:slot(assert(row.slot)):set_cinematic_active{active = true}
+        end
+    end
+    function E.terminated(c, s, e)
+        local index = s:variable("ember.ending")
+        local row = index and movies[index]
+        if not row or s:variable("ember.ending.playing") ~= index then return end
+        local slot = c:slot(row.slot)
+        if e.registry_key ~= slot.registry_key or e.slot_type ~= slot.slot_type or e.slot_index ~= slot.slot_index then return end
+        c:set_variable("ember.ending", index + 1)
+        slot:set_cinematic_active{active = false}
+        if movies[index + 1] then transit(c)
+        else
+            c:set_variable("ember.complete", true)
+            -- Native lifetime 6 enters the completion/reward branch (BEA9D0/B37100).
+            c.lifetime:set{state = c.sdk.lifetime_states:at(6)}
+            c:set_phase(100)
+        end
+    end
+    return E
+end

+ 254 - 31
scripts/mission_ember/landing.lua

@@ -1,18 +1,32 @@
 -- First combat slice. Wave composition and in-game sequencing still require a live playtest.
 local Encounter = require("mission_ember.encounter")
 
-local NAMES = {
-    "LANDING_MERCURY_ANCHOR_SQUAD",
-    "LANDING_MERCURY_BONUS_ANCHOR_A_SQUAD",
-    "LANDING_MERCURY_SUPPORT_A_SQUAD",
-    "LANDING_MERCURY_SUPPORT_B_SQUAD",
-    "LANDING_MERCURY_SUPPORT_C_SQUAD",
-    "LANDING_MERCURY_RANGED_A_SQUAD",
-    "LANDING_MERCURY_RANGED_B_SQUAD",
-    "LANDING_MERCURY_BONUS_SUPPORT_A_SQUAD",
-    "LANDING_MERCURY_BONUS_SUPPORT_B_SQUAD",
-    "LANDING_MERCURY_BONUS_SUPPORT_C_SQUAD",
-    "LANDING_MERCURY_BONUS_SUPPORT_D_SQUAD",
+local ROSTER = require("mission_ember.roster")
+local GROUP_ORDER = {"catwalk_entry", "catwalk_mid", "pipe", "mercury",
+                     "bridge", "sun", "helipad", "passengers", "mercury_bonus"}
+-- These are authored volume crossings. Timing/group choices are reconstructed route policy.
+local ROUTE = {
+    {trigger = "CATWALK_015_PERCENT_PLAYER_TRIGGER", groups = {"catwalk_mid"}},
+    {trigger = "CATWALK_035_PERCENT_PLAYER_TRIGGER_80B3CC33", groups = {"pipe"}},
+    {trigger = "CATWALK_100_PERCENT_PLAYER_TRIGGER", groups = {"mercury"}},
+    {trigger = "PIPE_CROSSING_040_PERCENT_PLAYER_TRIGGER", groups = {"mercury"}, cue = 2},
+    {trigger = "LANDING_MERCURY_040_PERCENT_PLAYER_TRIGGER", groups = {"mercury"}},
+    {trigger = "BRIDGE_025_PERCENT_PLAYER_TRIGGER", groups = {"bridge"}},
+    {trigger = "BRIDGE_075_PERCENT_PLAYER_TRIGGER", groups = {"sun"}},
+    {trigger = "BRIDGE_100_PERCENT_PLAYER_TRIGGER", groups = {"helipad"}},
+}
+
+-- Counts are read from each objective's authored task-group array (source +136).
+local OBJECTIVES = {
+    catwalk_entry = {"EMBER_POWERHOUSE_CATWALK_OBJECTIVE", 17}, -- 80B3CA03
+    catwalk_mid = {"EMBER_POWERHOUSE_CATWALK_OBJECTIVE", 17},
+    pipe = {"EMBER_POWERHOUSE_PIPE_CROSSING_OBJECTIVE", 3}, -- 80B3DCC7
+    mercury = {"EMBER_POWERHOUSE_LANDING_MERCURY_OBJECTIVE", 11}, -- 80B3DCC1
+    mercury_bonus = {"EMBER_POWERHOUSE_LANDING_MERCURY_OBJECTIVE", 11},
+    bridge = {"EMBER_POWERHOUSE_BRIDGE_OBJECTIVE", 14}, -- 80B3C8D6
+    passengers = {"EMBER_POWERHOUSE_BRIDGE_OBJECTIVE", 14},
+    sun = {"EMBER_POWERHOUSE_LANDING_SUN_OBJECTIVE", 4}, -- 80B3DCC4
+    helipad = {"EMBER_POWERHOUSE_HELIPAD_SUN_OBJECTIVE", 4}, -- 80B3DDB2
 }
 
 local BRIDGE_DEVICES = {
@@ -31,14 +45,7 @@ local function required(value, name)
 end
 
 return function(mission)
-    local squads = {}
-    for index, name in ipairs(NAMES) do
-        squads[index] = {
-            name = name,
-            id = required(mission.Squad[name], "Squad." .. name),
-            sensor = required(mission.Slot[name], "Slot." .. name),
-        }
-    end
+    local music = require("mission_ember.music")(mission)
     local entry = required(mission.states.STATE_80B3C09E_0008_0000_80B3C09C, "powerhouse state")
     local bridge = {}
     for index, name in ipairs(BRIDGE_DEVICES) do
@@ -47,37 +54,153 @@ return function(mission)
     local bridge_objective = required(mission.Slot.EMBER_POWERHOUSE_BRIDGE_OBJECTIVE,
                                       "bridge objective")
     local directive = required(mission.Slot.M_DIRECTIVE_SENSOR_80B3C90A, "directive sensor")
+    local audience = required(mission.Slot.M_ENGAGEMENT_SENSOR_80B3CB5E, "landing engagement")
     local clear = required(mission.Directive.FIND_AND_DISABLE_THE_ALMIGHTY_S_WEAPONS_A70DA4A6,
                            "landing clear directive")
     local controls = required(mission.Directive.FIND_AND_DISABLE_THE_ALMIGHTY_S_WEAPONS_2700C0C5,
                               "bridge controls directive")
-    local encounter = Encounter.new("ember.landing", squads, function(context)
-        context:slot(directive):set_directive{directive = controls}
+    local follow = required(mission.Directive.FIND_AND_DISABLE_THE_ALMIGHTY_S_WEAPONS_4EA80049,
+                            "fuel stream directive")
+    local console = required(mission.Slot.LANDING_MERCURY_CONSOLE_BUTTON_GHOST_LINK, "Ghost console")
+    local lever = required(mission.Slot.LANDING_MERCURY_DOOR_BUTTON_OBJECT, "Mercury door lever")
+    local lever_device = required(mission.Slot.POWERHOUSE_LANDING_MERCURY_DOOR_LEVER_DEVICE,
+                                   "Mercury lever animation")
+    local door = required(mission.Slot.POWERHOUSE_LANDING_MERCURY_DOOR_DEVICE, "war-beast door")
+    local cross = required(mission.Directive.FIND_AND_DISABLE_THE_ALMIGHTY_S_WEAPONS_AFBF882A,
+                           "cross bridge directive")
+    local directives = {follow, clear, controls, cross}
+    local navpoints = {
+        required(mission.Slot.POWERHOUSE_DIRECTIVE_LANDING_MERCURY_CLEAR_NAV_POINT, "Mercury approach navpoint"),
+        required(mission.Slot.POWERHOUSE_DIRECTIVE_LANDING_MERCURY_INTERACT_NAV_POINT, "console navpoint"),
+        required(mission.Slot.HELIPAD_SUN_SQUAD_FORMATION_NAV_POINT, "helipad navpoint"),
+    }
+    local function show_directive(context, id)
+        context:set_variable("ember.directive", id)
+        context:set_variable("ember.navpoint", 0)
+        context:slot(directive):set_directive{directive = directives[id], audience = context:slot(audience)}
+    end
+    local function dialogue(context, cue)
         context:slot(mission.Slot.M_DIALOG_SENSOR_80B3C90A):play_dialogue_cue{
-            cue = mission.DialogueCue.M_DIALOG_SENSOR_80B3C90A.CUE_3,
+            cue = required(mission.DialogueCue.M_DIALOG_SENSOR_80B3C90A["CUE_" .. cue], "dialogue cue"),
+        }
+    end
+    local encounters, transport = {}, nil
+    for _, group in ipairs(GROUP_ORDER) do
+        local names = ROSTER[group]
+        local squads = {}
+        for index, name in ipairs(names) do
+            -- Progression membership changes; their authored AI objective stays intact.
+            local objective = (name == "BRIDGE_VIGNETTE_SUPPORT_A_SQUAD"
+                or name == "BRIDGE_VIGNETTE_SUPPORT_B_SQUAD") and OBJECTIVES.bridge or OBJECTIVES[group]
+            squads[index] = {
+                name = name,
+                id = required(mission.Squad[name], "Squad." .. name),
+                sensor = required(mission.Slot[name], "Slot." .. name),
+                objective = required(mission.Slot[objective[1]], "combat objective"),
+                task_groups = objective[2],
+            }
+        end
+        -- Retain the existing Mercury state key across a development-script reload.
+        local key = group == "mercury" and "ember.landing" or "ember." .. group
+        encounters[group] = Encounter.new(key, squads, function(context, state)
+            if group == "mercury" then
+                -- Earlier skipped volumes may first arrive while backtracking. They
+                -- cannot replay the landing instruction after this combat is complete.
+                context:set_variable("ember.route.progress", math.max(state:variable("ember.route.progress") or 0, 5))
+                show_directive(context, 3)
+                dialogue(context, 3)
+                transport.prepare(context, state)
+                context:slot(console):set_ghost_link{generation = 2, enabled = true}
+                context:set_variable("ember.console.enabled", true)
+                context:set_phase(3)
+                -- Console completion, not combat clear, must extend the bridge.
+            end
+        end, group == "passengers")
+    end
+    transport = require("mission_ember.transport")(mission, encounters.passengers)
+    for _, step in ipairs(ROUTE) do
+        required(mission.Slot[step.trigger], "Slot." .. step.trigger)
+    end
+
+    local function update_darkness(context, state)
+        if state:variable("ember.later.owns_hud") then return end
+        if not state:variable("ember.route.initialized") then return end
+        -- Helipad squads belong to the later section, ~450m south of this fight.
+        -- Their preloaded population cannot hold the bridge's restriction open.
+        local complete = true
+        for _, group in ipairs({"bridge", "passengers", "sun"}) do
+            if encounters[group]:phase(state) ~= 2 then complete = false; break end
+        end
+        local enabled = state:variable("ember.bridge.extended") == true
+            and state:variable("ember.region") == entry.region_index and not complete
+        if state:variable("ember.darkness.enabled") == enabled then return end
+        context:slot(required(mission.Slot.HARD_WIPE_GLOBALS, "hard-wipe globals"))
+            :set_darkness_zone{enabled = enabled}
+        context:set_variable("ember.darkness.enabled", enabled)
+    end
+
+    local function update_guidance(context, state)
+        if state:variable("ember.later.owns_hud") then return end
+        if not state:variable("ember.route.initialized") then return end
+        music.update(context, state)
+        local target = 0
+        if state:variable("ember.region") == entry.region_index
+            and (state:variable("ember.wipe.stage") or 0) < 2 then
+            local combat = false
+            for _, group in ipairs({"catwalk_entry", "catwalk_mid", "pipe", "mercury", "mercury_bonus"}) do
+                if encounters[group]:phase(state) == 1 then combat = true end
+            end
+            if state:variable("ember.bridge.extended") then
+                for _, group in ipairs({"bridge", "passengers", "sun"}) do
+                    if encounters[group]:phase(state) ~= 2 then combat = true end
+                end
+                -- The distant helipad population is preloaded with the bridge;
+                -- it suppresses guidance only after the route reaches that fight.
+                local at_helipad = (state:variable("ember.route.progress") or 0) >= 8
+                if at_helipad then combat = true end
+                if not combat then target = 3 end
+            elseif not combat then
+                target = state:variable("ember.console.enabled") and 2 or 1
+            end
+        end
+        if state:variable("ember.navpoint") == target then return end
+        context:set_variable("ember.navpoint", target)
+        context:slot(directive):set_directive{
+            directive = directives[state:variable("ember.directive") or 1],
+            audience = context:slot(audience),
+            navpoint = target > 0 and context:slot(navpoints[target]) or nil,
         }
-        context:set_phase(3)
-        -- The bridge must wait for its authored interaction. Combat clear does not open it.
-    end)
+    end
 
     local function initialize(context)
+        context:slot(audience):set_engagement_state{flags = 0, revision = 1}
         for _, device in ipairs(bridge) do
-            context:slot(device):transition{transition = context.sdk.device_transitions.close, snap = true}
+            -- These bridge animations use position 1 for retracted, 0 for extended.
+            -- DF6C70 passes the value through; generic open/close names describe the value.
+            context:slot(device):transition{transition = context.sdk.device_transitions.open, snap = true}
         end
         context:slot(bridge_objective):reset_objectives{}
+        context:slot(console):set_ghost_link{generation = 1, enabled = false}
+        context:slot(door):transition{transition = context.sdk.device_transitions.close, snap = true}
+        context:slot(lever_device):transition{transition = context.sdk.device_transitions.close, snap = true}
+        context:slot(lever):set_interactable_object{generation = 1}
     end
 
     return {
         initial_state = entry,
         region = entry.region_index,
         enter = function(context, state)
-            if encounter:phase(state) ~= 0 then
+            if state:variable("ember.route.initialized") then
                 return
             end
             initialize(context)
+            context:set_variable("ember.route.initialized", true)
+            for index, step in ipairs(ROUTE) do
+                if index < 6 then context:slot(mission.Slot[step.trigger]):fire_trigger{} end
+            end
             context:set_phase(2)
-            encounter:enter(context, state)
-            context:slot(directive):set_directive{directive = clear}
+            encounters.catwalk_entry:enter(context, state)
+            show_directive(context, 1)
         end,
         client_state = function(context, state, event)
             if event.teleport_state == 0 then context:set_variable("ember.spawned", true) end
@@ -90,8 +213,108 @@ return function(mission)
                 }
             end
         end,
+        on_player_trigger = function(context, state, event)
+            if not state:variable("ember.route.initialized") then return end
+            for index, step in ipairs(ROUTE) do
+                local slot = context:slot(mission.Slot[step.trigger])
+                local key = "ember.route." .. step.trigger
+                if event.registry_key == slot.registry_key and event.slot_type == slot.slot_type
+                    and event.slot_index == slot.slot_index and not state:variable(key) then
+                    -- A bridge volume can overlap the near landing. Do not consume its
+                    -- one-shot receipt or start later waves before the bridge is usable.
+                    if index >= 6 and not state:variable("ember.bridge.extended") then return end
+                    context:set_variable(key, true)
+                    if index <= (state:variable("ember.route.progress") or 0) then return end
+                    context:set_variable("ember.route.progress", index)
+                    -- Catch up earlier combat if a player jumped over a narrow volume.
+                    if step.groups then
+                        local last = step.groups[#step.groups]
+                        for _, group in ipairs(GROUP_ORDER) do
+                            encounters[group]:enter(context, state)
+                            if group == last then break end
+                        end
+                    end
+                    if index >= 3 and encounters.mercury:phase(state) == 1
+                        and not state:variable("ember.clear.instruction") then
+                        context:set_variable("ember.clear.instruction", true)
+                        show_directive(context, 2)
+                    end
+                    if step.cue then dialogue(context, step.cue) end
+                    return
+                end
+            end
+        end,
+        on_object_interacted = function(context, state, event)
+            if not state:variable("ember.route.initialized") or state:variable("ember.lever.used") then return end
+            local slot = context:slot(lever)
+            if event.registry_key ~= slot.registry_key or event.slot_type ~= slot.slot_type
+                or event.slot_index ~= slot.slot_index then return end
+            context:set_variable("ember.lever.used", true)
+            context:slot(lever_device):transition{transition = context.sdk.device_transitions.open}
+            encounters.mercury_bonus:enter(context, state)
+            context:slot(door):transition{transition = context.sdk.device_transitions.open}
+        end,
+        on_ghost_link_state = function(context, state, event)
+            if not state:variable("ember.console.enabled") or state:variable("ember.bridge.extended") then return end
+            local slot = context:slot(console)
+            if event.registry_key ~= slot.registry_key or event.slot_type ~= slot.slot_type
+                or event.slot_index ~= slot.slot_index or event.generation ~= (state:variable("ember.console.generation") or 2)
+                or event.active or event.progress == nil or event.progress < 1 then return end
+            for _, device in ipairs(bridge) do
+                context:slot(device):transition{transition = context.sdk.device_transitions.close}
+            end
+            context:set_variable("ember.bridge.extended", true)
+            update_darkness(context, state)
+            -- Ship parents/cargo were prepared before Ghost became usable.
+            -- Far-side hangar reinforcements start with bridge activation.
+            -- Encounter state keeps later crossings and backtracking from respawning them.
+            encounters.sun:enter(context, state)
+            encounters.helipad:enter(context, state)
+            transport.start(context, state)
+            -- Arm crossing monitors when their route opens, so the approach to the
+            -- console cannot consume a crossing before its encounter is eligible.
+            for index = 6, #ROUTE do
+                context:slot(mission.Slot[ROUTE[index].trigger]):fire_trigger{}
+            end
+            show_directive(context, 4)
+            context:set_phase(4)
+        end,
+        update_darkness = update_darkness,
+        update_guidance = update_guidance,
+        reset_checkpoint = function(context, state)
+            -- The user chose the checkpoint before scanning the console again.
+            -- Completed catwalk/pipe/Mercury encounters and the bonus lever stay done.
+            transport.reset(context, state)
+            for _, group in ipairs({"bridge", "passengers", "sun", "helipad"}) do
+                encounters[group]:reset(context)
+            end
+            for index = 6, #ROUTE do
+                context:clear_variable("ember.route." .. ROUTE[index].trigger)
+            end
+            context:set_variable("ember.route.progress", 5)
+            for _, device in ipairs(bridge) do
+                context:slot(device):transition{transition = context.sdk.device_transitions.open, snap = true}
+            end
+            context:slot(bridge_objective):reset_objectives{}
+            context:set_variable("ember.bridge.extended", false)
+            context:set_variable("ember.darkness.enabled", false)
+            context:slot(mission.Slot.HARD_WIPE_GLOBALS):set_darkness_zone{enabled = false}
+            local revision = (state:variable("ember.console.generation") or 2) + 2
+            context:set_variable("ember.console.generation", revision)
+            transport.prepare(context, state)
+            context:slot(console):set_ghost_link{generation = revision, enabled = true}
+            context:set_variable("ember.console.enabled", true)
+            show_directive(context, 3)
+            context:set_phase(3)
+        end,
+        on_timer = transport.on_timer,
+        on_actor_path_state = transport.on_path,
         on_squad_state = function(context, state, event)
-            encounter:on_squad_state(context, state, event)
+            for _, group in ipairs(GROUP_ORDER) do
+                encounters[group]:on_squad_state(context, state, event)
+            end
+            transport.on_squad(context, state, event)
+            update_darkness(context, state)
         end,
     }
 end

+ 34 - 0
scripts/mission_ember/music.lua

@@ -0,0 +1,34 @@
+-- Native music sections from 80B3C904: one choice group, 29 ordered sections.
+-- The bank owns musical transitions; scripts only select a section at milestones.
+-- Section 5's authored name resolves to tumbler_exit. Later section-to-encounter
+-- pacing is reconstructed from package order and needs an audible full-route test.
+return function(m)
+    local M = {}
+    function M.update(c, s)
+        local section
+        local region = s:variable("ember.region")
+        if s:variable("ember.ending") then section = -1
+        elseif region == 0 then
+            local p = s:variable("ember.apex.phase") or 0
+            section = ({[0]=19,[1]=19,[2]=20,[3]=22,[4]=25,[5]=27,[6]=28,[7]=-1})[p]
+            if p == 2 and s:variable("ember.r.electron_controllers.phase") == 2 then section = 21 end
+            if p == 3 and s:variable("ember.r.cue.41") then section = 23 end
+            if p == 3 and (s:variable("ember.apex.dead.EAST") or s:variable("ember.apex.dead.WEST")) then section = 24 end
+            if p == 4 and s:variable("ember.r.cue.46") then section = 26 end
+        elseif region == 40 then
+            local p = s:variable("ember.cinder.phase") or 0
+            section = ({[0]=5,[1]=6,[2]=6,[3]=7,[4]=7,[5]=8,[6]=9,[7]=10,[8]=11,
+                [9]=12,[10]=13,[11]=15,[12]=16,[13]=16,[14]=17})[p]
+        elseif region == 56 then
+            section = ({[0]=3,[1]=3,[2]=3,[3]=4,[4]=5})[s:variable("ember.processing.phase") or 0]
+        elseif region == 64 then
+            section = s:variable("ember.bridge.extended") and 2
+                or ((s:variable("ember.route.progress") or 0) >= 5 and 1 or 0)
+        end
+        if section == nil or s:variable("ember.music.section") == section then return end
+        c:slot(assert(m.Slot.M_MUSIC_SENSOR_80B3C90A)):set_music_section{
+            section = math.max(section, 0), enabled = section >= 0}
+        c:set_variable("ember.music.section", section)
+    end
+    return M
+end

+ 3 - 0
scripts/mission_ember/opening.lua

@@ -22,6 +22,9 @@ return function(mission, landing)
                 context:slot(cinematic):set_cinematic_active{active = true}
             end
         end,
+        -- Native completion and cinematic_skip use the same exact source identity.
+        -- finish() commits phase 2 before requesting stop, so the ensuing finished
+        -- incident (or a repeated Escape) cannot start the spawn handoff twice.
         terminated = function(context, state, event)
             if phase(state) ~= 1 then return end
             local slot = context:slot(cinematic)

+ 117 - 0
scripts/mission_ember/processing.lua

@@ -0,0 +1,117 @@
+-- Mineral Processing: video ~05:30–10:15. Gameplay receipts, not video timestamps, advance it.
+return function(m, a)
+    local P = {region = 56}
+    local debris = {"TUMBLER_OBSTRUCTION_OBJECT"}
+    for _, letter in ipairs({"A", "B", "C", "D", "E", "F", "G", "H", "I"}) do
+        debris[#debris + 1] = "TUMBLER_CHAMBER_PRE_PURGE_ROCK_" .. letter .. "_OBJECT"
+    end
+    local waves = {"processing_wave1", "processing_wave2", "processing_wave3"}
+    local triggers = {"PROCESSING_000_PERCENT_PLAYER_TRIGGER", "PROCESSING_015_PERCENT_PLAYER_TRIGGER",
+        "PROCESSING_075_PERCENT_PLAYER_TRIGGER", "LINK_ENTRY_PLAYER_TRIGGER",
+        "LINK_DIRECTIVE_TUMBLER_OBSTRUCTION_PLAYER_TRIGGER", "LINK_DIRECTIVE_CONTROL_PLAYER_TRIGGER",
+        "LINK_CARRY_OBJECT_PLAYER_TRIGGER", "LINK_DIRECTIVE_TUMBLER_EXIT_PLAYER_TRIGGER",
+        "DIALOG_LINK_TUMBLER_001_PLAYER_TRIGGER"}
+    local function phase(s) return s:variable("ember.processing.phase") or 0 end
+    local function set(c, value) c:set_variable("ember.processing.phase", value) end
+    local function hatch(c, number, open, snap)
+        a.device(c, "DOOR_HATCH_" .. number .. "_DEVICE", open, snap)
+    end
+    local carry = require("mission_ember.carry")(a, "processing", "CARRY_OBJECT", "CARRY_RECEPTACLE_INTERACT_OBJECT",
+        function(c, s) a.cue(c, s, 13); P.guidance(c, s) end,
+        function(c, s)
+            if phase(s) >= 3 then return end
+            set(c, 3)
+            a.checkpoint(c, 56, 0x4B27745D, "processing")
+            a.darkness(c, s, true)
+            a.effect(c, s, "TUMBLER_DAMAGE_HOP_ON", "TUMBLER_DAMAGE_OBJECT_FILTER",
+                {players = true, inside = a.slot(c, "TUMBLER_INTERIOR_SAFETY_TRIGGER_VOLUME")}, true)
+            a.cue(c, s, 14)
+            a.device(c, "TUMBLER_DOOR_DEVICE", false)
+            a.device(c, "CONTROL_CONSOLE_SCREEN_DEVICE", true)
+            a.device(c, "LINK_TUMBLER_WARNING_LIGHTING_STATE_DEVICE", true)
+            a.slot(c, "PROCESSING_AUDIO_KLAXON_SEQUENCE"):play_sequence{}
+            hatch(c, "ONE", true); hatch(c, "TWO", true)
+            a.spawn(c, s, {waves[1]})
+            P.guidance(c, s)
+        end)
+    function P.enter(c, s)
+        if phase(s) > 0 then return end
+        set(c, 1)
+        a.objects(c, debris, true)
+        a.objects(c, {"MERCURY_HOLOGRAM_OBJECT", "DEVICE_DOOR_HATCH_ONE_OBJECT", "DEVICE_DOOR_HATCH_TWO_OBJECT",
+            "DEVICE_DOOR_HATCH_THREE_OBJECT", "DEVICE_DOOR_HATCH_FOUR_OBJECT",
+            "CORE_WEAPON_LASER_BEAM_OBJECT_80B3C8F7"}, true)
+        a.device(c, "LINK_ENTRY_DOOR_DEVICE", true)
+        a.device(c, "TUMBLER_DOOR_DEVICE", true, true)
+        a.device(c, "CONTROL_CONSOLE_SCREEN_DEVICE", false, true)
+        a.device(c, "LINK_HOLOGRAM_LIGHTING_STATE_DEVICE", true, true)
+        for _, n in ipairs({"ONE", "TWO", "THREE", "FOUR"}) do hatch(c, n, false, true) end
+        for _, t in ipairs(triggers) do a.slot(c, t):fire_trigger{} end
+        a.spawn(c, s, {"processing_entry"})
+        a.cue(c, s, 9)
+        P.guidance(c, s)
+    end
+    local function discover(c, s)
+        if phase(s) >= 2 then return end
+        set(c, 2)
+        carry.start(c, s)
+        a.cue(c, s, 10)
+    end
+    function P.trigger(c, s, e)
+        if a.matches(c, e, "LINK_DIRECTIVE_TUMBLER_OBSTRUCTION_PLAYER_TRIGGER")
+            or a.matches(c, e, "LINK_DIRECTIVE_CONTROL_PLAYER_TRIGGER")
+            or a.matches(c, e, "LINK_CARRY_OBJECT_PLAYER_TRIGGER")
+            or a.matches(c, e, "PROCESSING_075_PERCENT_PLAYER_TRIGGER") then discover(c, s) end
+        if a.matches(c, e, "LINK_DIRECTIVE_CONTROL_PLAYER_TRIGGER") then a.cue(c, s, 11) end
+        if phase(s) == 4 and a.matches(c, e, "LINK_DIRECTIVE_TUMBLER_EXIT_PLAYER_TRIGGER") then a.cue(c, s, 18) end
+        P.guidance(c, s)
+    end
+    function P.cleared(c, s, name)
+        if name == "processing_entry" then discover(c, s)
+        elseif phase(s) == 3 and name == waves[1] then
+            hatch(c, "THREE", true); a.spawn(c, s, {waves[2]})
+        elseif phase(s) == 3 and name == waves[2] then
+            hatch(c, "FOUR", true); a.spawn(c, s, {waves[3]})
+        elseif phase(s) == 3 and name == waves[3] and a.clear(s, waves) then
+            set(c, 4)
+            a.device(c, "TUMBLER_CHAMBER_LEFT_DEVICE", true)
+            a.device(c, "TUMBLER_CHAMBER_RIGHT_DEVICE", true)
+            a.objects(c, debris, false)
+            a.device(c, "TUMBLER_DOOR_DEVICE", true)
+            a.device(c, "LINK_TUMBLER_WARNING_LIGHTING_STATE_DEVICE", false)
+            a.darkness(c, s, false)
+            a.effect(c, s, "TUMBLER_DAMAGE_HOP_ON", nil, nil, false)
+            a.cue(c, s, 17)
+        end
+        P.guidance(c, s)
+    end
+    function P.guidance(c, s)
+        if s:variable("ember.region") ~= 56 then return end
+        local p = phase(s)
+        if p == 1 then a.directive(c, s, "883C188D", "LINK_DIRECTIVE_CONTROL_INTERACT_NAV_POINT", a.combat(s, {"processing_entry"}))
+        elseif p == 2 then a.directive(c, s, s:variable("ember.carry.processing.notified") and "57050F62" or "591B1D88", carry.held(s) and "LINK_DIRECTIVE_CONTROL_INTERACT_NAV_POINT" or "LINK_CARRY_OBJECT_NAV_POINT", a.combat(s, {"processing_entry"}))
+        elseif p == 3 then a.directive(c, s, "03285502", nil, true)
+        elseif p == 4 then a.directive(c, s, "65D5979D", "CINDER_DIRECTIVE_CINDER_GOTO_BUBBLE_NAV_POINT", false) end
+    end
+    function P.reset(c, s)
+        a.effect(c, s, "TUMBLER_DAMAGE_HOP_ON", nil, nil, false)
+        a.reset(c, waves)
+        carry.reset(c, s)
+        a.slot(c, "EMBER_LINK_PROCESSING_DEFEND_OBJECTIVE"):reset_objectives{}
+        a.objects(c, debris, true)
+        for _, n in ipairs({"ONE", "TWO", "THREE", "FOUR"}) do hatch(c, n, false, true) end
+        a.device(c, "TUMBLER_DOOR_DEVICE", true, true)
+        for _, n in ipairs({"TUMBLER_CHAMBER_LEFT_DEVICE", "TUMBLER_CHAMBER_RIGHT_DEVICE",
+            "CONTROL_CONSOLE_SCREEN_DEVICE", "LINK_TUMBLER_WARNING_LIGHTING_STATE_DEVICE"}) do a.device(c, n, false, true) end
+        set(c, 2); a.darkness(c, s, false); carry.start(c, s)
+        c:clear_variable("ember.r.guidance"); P.guidance(c, s)
+    end
+    P.interaction = carry.interaction
+    function P.object(c, s, e) carry.state(c, s, e); P.guidance(c, s) end
+    P.timer = carry.timer
+    function P.resume(c, s)
+        carry.resume(c, s)
+        if (s:variable("ember.r.furthest") or 0) <= 1 then a.darkness(c, s, phase(s) == 3) end
+    end
+    return P
+end

+ 45 - 0
scripts/mission_ember/roster.lua

@@ -0,0 +1,45 @@
+-- Every authored squad in powerhouse. Groups follow the route, not package enumeration order.
+return {
+    catwalk_entry = {
+        "CATWALK_ENTRY_RANGED_A_SQUAD", "CATWALK_ENTRY_RANGED_B_SQUAD",
+        "CATWALK_ENTRY_SUPPORT_A_SQUAD", "CATWALK_ENTRY_SUPPORT_B_SQUAD",
+        "CATWALK_ENTRY_SUPPORT_C_SQUAD",
+    },
+    catwalk_mid = {
+        "CATWALK_MID_SUPPORT_A_SQUAD", "CATWALK_MID_SUPPORT_B_SQUAD",
+        "CATWALK_MID_SUPPORT_C_SQUAD", "CATWALK_BONUS_SUPPORT_A_SQUAD",
+    },
+    pipe = {"PIPE_CROSSING_SUPPORT_A_SQUAD", "PIPE_CROSSING_SUPPORT_B_SQUAD"},
+    mercury = {
+        "LANDING_MERCURY_ANCHOR_SQUAD",
+        "LANDING_MERCURY_SUPPORT_A_SQUAD", "LANDING_MERCURY_SUPPORT_B_SQUAD",
+        "LANDING_MERCURY_SUPPORT_C_SQUAD", "LANDING_MERCURY_RANGED_A_SQUAD",
+        "LANDING_MERCURY_RANGED_B_SQUAD",
+        -- Both authored vignette groups occupy the Mercury end of the bridge.
+        "BRIDGE_VIGNETTE_SUPPORT_A_SQUAD", "BRIDGE_VIGNETTE_SUPPORT_B_SQUAD",
+    },
+    mercury_bonus = {
+        "LANDING_MERCURY_BONUS_ANCHOR_A_SQUAD", "LANDING_MERCURY_BONUS_SUPPORT_A_SQUAD",
+        "LANDING_MERCURY_BONUS_SUPPORT_B_SQUAD", "LANDING_MERCURY_BONUS_SUPPORT_C_SQUAD",
+        "LANDING_MERCURY_BONUS_SUPPORT_D_SQUAD",
+    },
+    bridge = {
+        "BRIDGE_CROSSING_SUPPORT_A_SQUAD", "BRIDGE_CROSSING_SUPPORT_B_SQUAD",
+        "BRIDGE_CROSSING_DEFENSE_A_SQUAD", "BRIDGE_CROSSING_DEFENSE_B_SQUAD",
+        "BRIDGE_CROSSING_RANGED_A_SQUAD",
+    },
+    dropships = {
+        "BRIDGE_CROSSING_DROPSHIP_A_SQUAD", "BRIDGE_CROSSING_DROPSHIP_B_SQUAD",
+        "BRIDGE_CROSSING_DROPSHIP_C_SQUAD", "BRIDGE_CROSSING_DROPSHIP_D_SQUAD",
+    },
+    passengers = {
+        "BRIDGE_CROSSING_DROPSHIP_A_SUPPORT_A_SQUAD",
+        "BRIDGE_CROSSING_DROPSHIP_A_SUPPORT_B_SQUAD",
+        "BRIDGE_CROSSING_DROPSHIP_A_MELEE_A_SQUAD",
+        "BRIDGE_CROSSING_DROPSHIP_A_MELEE_B_SQUAD",
+    },
+    sun = {"LANDING_SUN_SUPPORT_A_SQUAD", "LANDING_SUN_SUPPORT_B_SQUAD"},
+    helipad = {
+        "HELIPAD_SUN_ANCHOR_A_SQUAD", "HELIPAD_SUN_SUPPORT_A_SQUAD", "HELIPAD_SUN_SUPPORT_B_SQUAD",
+    },
+}

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff