Sfoglia il codice sorgente

Recover Ember placement context and prepare cinematic opening playtest

Millie 4 giorni fa
parent
commit
995bd52d3f

+ 37 - 0
MISSION_EMBER_REFERENCE.md

@@ -0,0 +1,37 @@
+# 1AU reference and implementation evidence
+
+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
+cinematic. Its absence here is not evidence that the original mission lacks one.
+
+| Video time | Visible event | Script/package counterpart | Verification |
+| --- | --- | --- | --- |
+| 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 |
+| ~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 |
+| ~03:25–05:20 | Crossing/far-side combat | bridge crossing and landing-sun families | visible encounter, not a reason to spawn all later enemies at launch |
+| ~05:30 | Mineral Processing transition | link state, region 56 | video and SDK |
+| ~07:15–09:45 | Processing fight and machinery progression | processing families, fusion-cell objectives | coarse frame pass; precise triggers pending |
+| ~10:10 | Zavala/Ikora radio-silence exchange | cues 19–20 | video subtitles and SDK variants |
+| ~11:00 | Ghost identifies exterior route | cue 23 | video subtitles and SDK variants |
+| ~11:25–13:30 | Sunside traversal and combat | cinder state, sunburn families | coarse frame pass |
+| ~13:55–20:10 | Internal combat and machinery, ending with the Bruiser fight | cinder later encounters | coarse frame pass |
+| ~20:35 | Energy-stream travel | chute/tube progression | coarse frame pass |
+| ~21:00–22:40 | Dark corridors, then Interceptor and Light’s End | apex access/security encounters | coarse frame pass |
+| ~23:00–26:00 | Reactor combat and vent attacks | reactor clamshell/coffin families | coarse frame pass |
+| ~26:00–27:30 | Fusion-cell pickup, carrying and deposit | reactor carry/deposit objectives | coarse frame pass |
+| ~27:48–28:42 | Overload, escape run, cinematic transition | reactor escape and bookend state | coarse frame pass |
+| ~28:42–35:27 | City counterattack and Ghaul cinematics | two apex cinematic states are candidates; identity needs confirmation | video only for ordering |
+| ~35:54–36:42 | City arrival and Chosen gameplay | next mission | excluded from Ember scripting scope |
+
+Times are observations from this player's run, not timers to encode in Lua. Cues and waves must
+follow the corresponding spatial, interaction or combat event. The SDK contains declarations,
+identities and data; those alone do not establish every authored script condition.
+
+Live log evidence from the previous build: bridge Auth requests were staged at service ticks
+97082–97268, before mission world entry at 102262 and held region 64 at 104814. Ten squad
+requests were staged at 104915–107153. Transport staging does not establish visible state or AI.

+ 190 - 0
MISSION_EMBER_SQUADS.md

@@ -0,0 +1,190 @@
+# Ember squad coverage audit
+
+Generated from the corrected SDK on 2026-09-05. Readiness means static definition and
+actor-profile checks pass; it does not prove live AI or Lua activation.
+
+The current controller activates only the landing Mercury encounter. All other runnable
+rows still need their encounter triggers wired. This table deliberately includes every
+type-1 sensor, including rows that have no runnable definition.
+
+## Region 0 — apex
+
+| Authored squad sensor | SDK binding |
+| --- | --- |
+| `reactor_clamshell_east_anchor_a_squad` | Ready |
+| `reactor_clamshell_east_support_a_squad` | Ready |
+| `reactor_clamshell_east_support_b_squad` | Ready |
+| `reactor_clamshell_east_support_c_squad` | Ready |
+| `reactor_clamshell_east_support_d_squad` | Ready |
+| `reactor_clamshell_east_support_e_squad` | Ready |
+| `reactor_clamshell_east_support_f_squad` | Ready |
+| `reactor_clamshell_east_melee_a_squad` | Ready |
+| `reactor_clamshell_east_melee_b_squad` | Ready |
+| `reactor_clamshell_east_melee_c_squad` | Ready |
+| `reactor_clamshell_east_melee_d_squad` | Ready |
+| `reactor_clamshell_east_melee_e_squad` | Ready |
+| `reactor_clamshell_east_melee_f_squad` | Ready |
+| `reactor_clamshell_east_defense_a_squad` | Ready |
+| `reactor_clamshell_east_defense_b_squad` | Ready |
+| `reactor_clamshell_west_anchor_a_squad` | Ready |
+| `reactor_clamshell_west_support_a_squad` | Ready |
+| `reactor_clamshell_west_support_b_squad` | Ready |
+| `reactor_clamshell_west_support_c_squad` | Ready |
+| `reactor_clamshell_west_support_d_squad` | Ready |
+| `reactor_clamshell_west_support_e_squad` | Ready |
+| `reactor_clamshell_west_support_f_squad` | Ready |
+| `reactor_clamshell_west_defense_a_squad` | Ready |
+| `reactor_clamshell_west_defense_b_squad` | Ready |
+| `reactor_clamshell_west_melee_a_squad` | Ready |
+| `reactor_clamshell_west_melee_b_squad` | Ready |
+| `reactor_clamshell_west_melee_c_squad` | Ready |
+| `reactor_clamshell_west_melee_d_squad` | Ready |
+| `reactor_clamshell_west_melee_e_squad` | Ready |
+| `reactor_clamshell_west_melee_f_squad` | Ready |
+| `reactor_coffin_east_anchor_a_squad` | Ready |
+| `reactor_coffin_west_anchor_a_squad` | Ready |
+| `reactor_coffin_east_defense_a_squad` | Ready |
+| `reactor_coffin_east_defense_b_squad` | Ready |
+| `reactor_coffin_east_support_a_squad` | Ready |
+| `reactor_coffin_east_support_b_squad` | Ready |
+| `reactor_coffin_east_support_c_squad` | Ready |
+| `reactor_coffin_east_support_d_squad` | Ready |
+| `reactor_coffin_west_defense_a_squad` | Ready |
+| `reactor_coffin_west_defense_b_squad` | Ready |
+| `reactor_coffin_west_support_a_squad` | Ready |
+| `reactor_coffin_west_support_b_squad` | Ready |
+| `reactor_coffin_west_support_c_squad` | Ready |
+| `reactor_coffin_west_support_d_squad` | Ready |
+| `reactor_coffin_interior_support_squad` | Ready |
+| `access_jump_one_support_a_squad` | Missing definition |
+| `access_jump_one_support_b_squad` | Missing definition |
+| `access_jump_one_support_c_squad` | Missing definition |
+| `access_jump_two_support_a_squad` | Missing definition |
+| `access_jump_two_support_b_squad` | Missing definition |
+| `access_jump_two_support_c_squad` | Missing definition |
+| `dispenser_support_a_squad` | Missing definition |
+| `dispenser_support_b_squad` | Missing definition |
+| `dispenser_support_c_squad` | Missing definition |
+| `security_ledge_support_a_squad` | Missing definition |
+| `security_ledge_support_b_squad` | Missing definition |
+
+## Region 40 — cinder
+
+| Authored squad sensor | SDK binding |
+| --- | --- |
+| `foundry_support_a_squad` | Ready |
+| `foundry_support_b_squad` | Ready |
+| `foundry_support_c_squad` | Ready |
+| `foundry_support_d_squad` | Ready |
+| `foundry_support_e_squad` | Ready |
+| `foundry_support_f_squad` | Ready |
+| `foundry_support_g_squad` | Ready |
+| `foundry_support_h_squad` | Ready |
+| `foundry_support_i_squad` | Ready |
+| `foundry_ranged_a_squad` | Ready |
+| `foundry_ranged_b_squad` | Ready |
+| `foundry_anchor_a_squad` | Ready |
+| `ascent_squad_retreat_instance_a_prefab.retreat_01_squad` | Ready |
+| `ascent_squad_retreat_instance_b_prefab.retreat_01_squad` | Ready |
+| `ascent_squad_retreat_instance_c_prefab.retreat_01_squad` | Ready |
+| `chamber_defense_a_squad` | Ready |
+| `chamber_support_a_squad` | Ready |
+| `meat_grinder_defense_a_squad` | Ready |
+| `meat_grinder_defense_b_squad` | Ready |
+| `meat_grinder_support_a_squad` | Ready |
+| `meat_grinder_support_b_squad` | Ready |
+| `meat_grinder_melee_a_squad` | Ready |
+| `meat_grinder_melee_b_squad` | Ready |
+| `ascent_support_a_squad` | Ready |
+| `ascent_support_b_squad` | Ready |
+| `tumbler_support_a_squad` | Ready |
+| `tumbler_support_b_squad` | Ready |
+| `ready_room_01_support_a_squad` | Missing definition |
+| `ready_room_01_support_b_squad` | Ready |
+| `ready_room_01_support_c_squad` | Missing definition |
+| `ready_room_01_melee_a_squad` | Ready |
+| `ready_room_02_anchor_squad` | Ready |
+| `ready_room_02_melee_a_squad` | Ready |
+| `ready_room_02_melee_b_squad` | Ready |
+| `ready_room_02_melee_c_squad` | Ready |
+| `ready_room_02_ranged_a_squad` | Ready |
+| `ready_room_02_ranged_b_squad` | Ready |
+| `sunburn_squad_retreat_instance_a_prefab.retreat_01_squad` | Ready |
+| `sunburn_squad_retreat_instance_b_prefab.retreat_01_squad` | Ready |
+| `sunburn_squad_retreat_instance_c_prefab.retreat_01_squad` | Ready |
+| `sunburn_bridge_support_a_squad` | Ready |
+| `sunburn_bridge_support_b_squad` | Ready |
+| `sunburn_deck_east_support_a_squad` | Ready |
+| `sunburn_deck_east_support_b_squad` | Ready |
+| `sunburn_deck_west_support_a_squad` | Ready |
+| `sunburn_deck_west_support_b_squad` | Ready |
+| `sunburn_deck_west_support_c_squad` | Ready |
+| `sunburn_deck_west_support_d_squad` | Ready |
+| `sunburn_secret_support_a_squad` | Ready |
+
+## Region 56 — link
+
+| Authored squad sensor | SDK binding |
+| --- | --- |
+| `processing_entry_support_a_squad` | Ready |
+| `processing_entry_support_b_squad` | Ready |
+| `processing_entry_support_c_squad` | Ready |
+| `processing_entry_support_d_squad` | Ready |
+| `processing_defend_defense_a_squad` | Refused: two distinct spawn-rule targets |
+| `processing_defend_defense_b_squad` | Refused: two distinct spawn-rule targets |
+| `processing_defend_defense_c_squad` | Refused: two distinct spawn-rule targets |
+| `processing_defend_defense_d_squad` | Refused: two distinct spawn-rule targets |
+| `processing_defend_melee_a_squad` | Refused: two distinct spawn-rule targets |
+| `processing_defend_melee_b_squad` | Refused: two distinct spawn-rule targets |
+| `processing_defend_melee_c_squad` | Refused: two distinct spawn-rule targets |
+| `processing_defend_melee_d_squad` | Refused: two distinct spawn-rule targets |
+| `processing_defend_anchor_squad` | Refused: two distinct spawn-rule targets |
+| `processing_defend_support_a_squad` | Refused: two distinct spawn-rule targets |
+| `processing_defend_support_b_squad` | Refused: two distinct spawn-rule targets |
+
+## Region 64 — powerhouse
+
+| Authored squad sensor | SDK binding |
+| --- | --- |
+| `bridge_crossing_dropship_a_squad` | Ready |
+| `bridge_crossing_dropship_a_support_a_squad` | Ready |
+| `bridge_crossing_dropship_a_support_b_squad` | Ready |
+| `bridge_crossing_dropship_a_melee_a_squad` | Ready |
+| `bridge_crossing_dropship_a_melee_b_squad` | Ready |
+| `bridge_crossing_dropship_b_squad` | Ready |
+| `bridge_crossing_dropship_c_squad` | Ready |
+| `bridge_crossing_dropship_d_squad` | Ready |
+| `bridge_crossing_support_a_squad` | Ready |
+| `bridge_crossing_support_b_squad` | Ready |
+| `bridge_crossing_defense_a_squad` | Ready |
+| `bridge_crossing_defense_b_squad` | Ready |
+| `bridge_crossing_ranged_a_squad` | Ready |
+| `bridge_vignette_support_a_squad` | Ready |
+| `bridge_vignette_support_b_squad` | Ready |
+| `landing_mercury_anchor_squad` | Ready |
+| `landing_mercury_bonus_anchor_a_squad` | Ready |
+| `landing_mercury_bonus_support_a_squad` | Ready |
+| `landing_mercury_bonus_support_b_squad` | Ready |
+| `landing_mercury_bonus_support_c_squad` | Ready |
+| `landing_mercury_bonus_support_d_squad` | Ready |
+| `landing_mercury_support_a_squad` | Ready |
+| `landing_mercury_support_b_squad` | Ready |
+| `landing_mercury_support_c_squad` | Ready |
+| `landing_mercury_ranged_a_squad` | Ready |
+| `landing_mercury_ranged_b_squad` | Ready |
+| `landing_sun_support_a_squad` | Ready |
+| `landing_sun_support_b_squad` | Ready |
+| `pipe_crossing_support_a_squad` | Ready |
+| `pipe_crossing_support_b_squad` | Ready |
+| `catwalk_entry_ranged_a_squad` | Ready |
+| `catwalk_entry_ranged_b_squad` | Ready |
+| `catwalk_entry_support_a_squad` | Ready |
+| `catwalk_entry_support_b_squad` | Ready |
+| `catwalk_entry_support_c_squad` | Ready |
+| `catwalk_mid_support_a_squad` | Ready |
+| `catwalk_mid_support_b_squad` | Ready |
+| `catwalk_mid_support_c_squad` | Ready |
+| `catwalk_bonus_support_a_squad` | Ready |
+| `helipad_sun_anchor_a_squad` | Ready |
+| `helipad_sun_support_a_squad` | Ready |
+| `helipad_sun_support_b_squad` | Ready |

+ 41 - 0
MISSION_EMBER_STATUS.md

@@ -1,5 +1,46 @@
 # Ember implementation status
 
+## Current work — 2026-09-05
+
+The sections below this update record earlier playtest builds. Current investigation found
+that the offline SDK generator omitted the scenario-layout catalogue needed by container
+and spatial extraction. Initializing it and rebuilding the Ember shard recovered 83 additional
+squad definitions: 160 definitions, 138 runnable, across 162 squad sensors. Thirteen sensors
+still have no definition, and eleven processing sensors each have two refused alternatives.
+See [the complete squad census](MISSION_EMBER_SQUADS.md) and
+[the timestamped video comparison](MISSION_EMBER_REFERENCE.md).
+
+The production generator now prepares the missing catalogue in offline mode, waits for it
+in game mode, and rejects cached shards that lack placement context for a known map stem.
+
+The controller draft uses the separate arrival-cinematic state (49), waits for its exact
+termination event, then selects playable state 64. Bridge reset is delayed until region 64
+is held. Region-less client deltas preserve encounter ownership. Arrival dialogue waits
+for spawn settlement; the console-guidance cue follows combat clear. Regression tests pass,
+but these changes have not yet been validated visually in game.
+
+The Ghost console interaction, later encounter activation, later dialogue and full mission
+completion are still unfinished. The reference video begins after arrival and cannot verify
+the identity of the arrival movie. No claim of complete 1AU parity is made.
+
+Validation: Release build, both Lua controller/encounter suites, six Python SDK checks and
+eight sanitized production-linker regressions pass. A fresh native offline process loaded
+the scenario catalogue itself and rebuilt the old cache successfully into `build/sdk-corrected`
+(138 runnable Ember definitions). The script binds successfully to that SDK, including the
+newly recovered eleventh landing squad. The runtime logs typed Ghost-link Sense fields for
+the next console playtest without interpreting them as an interaction yet.
+
+Installation is pending: Destiny 2 was running, so the installer exited before changing any
+game file. Prepared installer: `/tmp/install-ember-corrected.py`. It backs up the previous
+runtime, SDK, scripts and settings, then verifies installed file hashes.
+
+Workspace recovery: the parent checkout was moved to Trash during the audit. The mission
+folder now has its own recovered `.git` directory, retaining branch `mission-ember` and all
+prior commits. Compiler dependencies were copied into `build/recovered-dependencies/xwin`;
+the local `.xwin-cache` points there. The trashed checkout was not modified.
+
+
+
 ## First test build
 
 The opening landing controller is implemented and loads with the regenerated SDK. It publishes

+ 2 - 2
Sunrise/resources/default_settings.json

@@ -107,8 +107,8 @@
       "arrival_overrides": [
         {
           "package_name": "mission_ember",
-          "bubble": 8,
-          "slice_set": 64
+          "bubble": 6,
+          "slice_set": 49
         },
         {
           "package_name": "raid_envy_v310",

+ 392 - 5
Sunrise/src/client/content/activity/activity_sdk_generation_worker.cpp

@@ -25,8 +25,10 @@
 #include "../../../state/activity_sdk/generation/internal.h"
 #include "../../../state/activity_sdk/identity.h"
 #include "../../../state/activity_sdk/runtime.h"
+#include "../../../state/build_data/runtime.h"
 #include "../../../state/content_manifest/content_manifest_state_runtime.h"
 #include "../items/packages/internal.h"
+#include "../scenarios/scenario_build.h"
 #include "activity_sdk_activity_inventory.h"
 #include "activity_sdk_generation_report.h"
 #include "activity_sdk_generation_worker_internal.h"
@@ -213,6 +215,378 @@ void log_stage_duration(std::string_view stage,
     return cancelled();
 }
 
+/** Counts the rows behind a refused shard, which the coverage verdict alone cannot name. */
+void report_shard_diagnostics(const Scenario& scenario,
+                              const catalog::Snapshot& snapshot) noexcept {
+    std::size_t incompleteObjects = 0;
+    std::size_t incompleteSafety = 0;
+    std::size_t leaves = 0;
+    std::size_t bareTargets = 0;
+    for (const catalog::Object& object : snapshot.objects) {
+        if (!object.complete) {
+            ++incompleteObjects;
+        }
+        if (object.safety == catalog::GroupSafety::incomplete) {
+            ++incompleteSafety;
+        }
+        leaves += object.placedLeafCount;
+        bareTargets += object.bareTargetCount;
+    }
+    std::size_t unresolvedStates = 0;
+    for (const catalog::State& state : snapshot.states) {
+        if (!state.resolved) {
+            ++unresolvedStates;
+        }
+    }
+    const catalog::ContainerPlacementDiagnostics& placements =
+        snapshot.containerPlacementDiagnostics;
+    std::array<char, 1024> line{};
+    const int written =
+        std::snprintf(line.data(),
+                      line.size(),
+                      "ev=sdk_shard_diag scenario=0x%08X objects=%zu "
+                      "obj_incomplete=%zu obj_safety_incomplete=%zu leaves=%zu "
+                      "bare=%zu states_unresolved=%zu "
+                      "unresolved_reads=%llu spatial_ctx=%u spatial_na=%u spatial_complete=%u "
+                      "spatial_unresolved=%llu spatial_dropped=%llu spatial_semantic=%llu "
+                      "spatial_tables=%zu spatial_owners=%zu spatial_instances=%zu "
+                      "cp_ctx=%u cp_na=%u cp_complete=%u cp_owner_complete=%u "
+                      "cp_unresolved=%llu cp_semantic=%llu cp_dropped=%llu/%llu/%llu/%llu/%llu "
+                      "cp_rows=%zu/%zu/%zu/%zu/%zu",
+                      static_cast<unsigned>(scenario.tag),
+                      snapshot.objects.size(),
+                      incompleteObjects,
+                      incompleteSafety,
+                      leaves,
+                      bareTargets,
+                      unresolvedStates,
+                      static_cast<unsigned long long>(snapshot.unresolvedReads),
+                      snapshot.staticSpatialContextResolved ? 1U : 0U,
+                      snapshot.staticSpatialNotApplicable ? 1U : 0U,
+                      snapshot.staticSpatialComplete ? 1U : 0U,
+                      static_cast<unsigned long long>(snapshot.staticSpatialUnresolvedReads),
+                      static_cast<unsigned long long>(snapshot.staticSpatialDropped),
+                      static_cast<unsigned long long>(snapshot.staticSpatialSemanticUnresolved),
+                      snapshot.staticSpatialTables.size(),
+                      snapshot.staticSpatialOwners.size(),
+                      snapshot.staticSpatialInstances.size(),
+                      placements.contextResolved ? 1U : 0U,
+                      placements.contextNotApplicable ? 1U : 0U,
+                      placements.complete ? 1U : 0U,
+                      placements.identityOwnerInventoryComplete ? 1U : 0U,
+                      static_cast<unsigned long long>(placements.unresolvedReads),
+                      static_cast<unsigned long long>(placements.semanticUnresolved),
+                      static_cast<unsigned long long>(placements.droppedLists),
+                      static_cast<unsigned long long>(placements.droppedOwners),
+                      static_cast<unsigned long long>(placements.droppedPlacements),
+                      static_cast<unsigned long long>(placements.droppedConfigs),
+                      static_cast<unsigned long long>(placements.droppedComponents),
+                      snapshot.containerPlacementLists.size(),
+                      snapshot.containerPlacementOwners.size(),
+                      snapshot.containerPlacements.size(),
+                      snapshot.containerPlacementConfigs.size(),
+                      snapshot.containerPlacementComponents.size());
+    if (written > 0) {
+        core::log::write(
+            core::log::Channel::client,
+            core::log::Level::error,
+            {line.data(), (std::min)(static_cast<std::size_t>(written), line.size() - 1)});
+    }
+}
+
+/** Writes one new shard under its deterministic digest name. */
+[[nodiscard]] bool build_record(const Work& work,
+                                const package_reader::Source& source,
+                                const builder::ContainerIndex& containers,
+                                package_reader::Scratch& scratch,
+                                builder::ScenarioAnalysisCache& analyses,
+                                const Scenario& scenario,
+                                manifest::Record& record,
+                                std::shared_ptr<const catalog::Snapshot>& output,
+                                std::array<char, kDetailCapacity>& detailScratch,
+                                const char*& detail) {
+    // Every arm below reported the same word, so a failure named the step it reached and nothing
+    // about which of five things went wrong. Each one now says which.
+    const std::string_view name(scenario.name.data(), scenario.nameLength);
+    const auto snapshot = builder::build_scenario_catalog(
+        source, containers, scratch, analyses, scenario.tag, name, &cancelled);
+    if (cancelled()) {
+        detail = "shard_cancelled";
+        return false;
+    }
+    if (!snapshot) {
+        detail = "shard_catalog_null";
+        return false;
+    }
+    if (snapshot->status != catalog::BuildStatus::ready) {
+        const char* stage = "shard_catalog_not_ready";
+        switch (snapshot->status) {
+        case catalog::BuildStatus::failed:
+            stage = "shard_catalog_failed";
+            break;
+        case catalog::BuildStatus::idle:
+            stage = "shard_catalog_idle";
+            break;
+        case catalog::BuildStatus::queued:
+            stage = "shard_catalog_queued";
+            break;
+        case catalog::BuildStatus::building:
+            stage = "shard_catalog_building";
+            break;
+        default:
+            break;
+        }
+        report_shard_diagnostics(scenario, *snapshot);
+        // The builder already names its own refusal. Carry that text out instead of dropping it.
+        const char* reason = snapshot->detail[0] != '\0' ? snapshot->detail.data() : "no detail";
+        detailScratch = {};
+        (void)std::snprintf(detailScratch.data(), detailScratch.size(), "%s:%s", stage, reason);
+        detail = detailScratch.data();
+        return false;
+    }
+    generated::PreparedShard prepared{};
+    if (!generated::prepare(work.sourceFingerprint, *snapshot, prepared)) {
+        detail = "shard_prepare_failed";
+        return false;
+    }
+    const generated::Digest payload = prepared.payload_sha256();
+    std::wstring finalPath;
+    generated::Digest written{};
+    if (!shard_path(work.scenarioDirectory, scenario.tag, payload, finalPath)) {
+        detail = "shard_path_failed";
+        return false;
+    }
+    if (!generated::publish(finalPath.c_str(), std::move(prepared), written)) {
+        detail = "shard_write_failed";
+        return false;
+    }
+    record = {};
+    record.scenarioTag = scenario.tag;
+    record.scenarioName = scenario.name;
+    record.scenarioNameLength = scenario.nameLength;
+    record.shardPayloadSha256 = payload;
+    output = snapshot;
+    return true;
+}
+
+/** Rewrites one loaded cache hit into an isolated output tree. */
+[[nodiscard]] bool
+materialize_cached_record(const Work& work,
+                          const Scenario& scenario,
+                          const manifest::Record& record,
+                          const std::shared_ptr<const catalog::Snapshot>& cached,
+                          std::shared_ptr<const catalog::Snapshot>& output) noexcept {
+    if (cached == nullptr) {
+        return false;
+    }
+    if (work.cacheScenarioDirectory == work.scenarioDirectory) {
+        output = cached;
+        return true;
+    }
+    std::wstring finalPath;
+    generated::Digest written{};
+    if (!shard_path(work.scenarioDirectory, scenario.tag, record.shardPayloadSha256, finalPath)
+        || !generated::write(finalPath.c_str(), work.sourceFingerprint, *cached, written)) {
+        return false;
+    }
+    output = cached;
+    return true;
+}
+
+/** One indexed result keeps parallel work deterministic when threads finish out of order. */
+struct ScenarioBuildResult final {
+    manifest::Record record{};
+    std::shared_ptr<const catalog::Snapshot> snapshot{};
+    std::array<char, kDetailCapacity> detail{};
+    bool attempted{};
+    bool ready{};
+    bool reused{};
+};
+
+/** Shared immutable inputs and atomic scheduling state for one scenario batch. */
+struct ScenarioBuildBatch final {
+    const Work* work{};
+    const package_reader::Source* source{};
+    const builder::ContainerIndex* containers{};
+    const manifest::Catalog* prior{};
+    std::vector<ScenarioBuildResult>* results{};
+    std::atomic_size_t next{};
+    std::atomic_size_t completed{};
+    SRWLOCK progressLock = SRWLOCK_INIT;
+    std::size_t progressPublished{};
+    std::size_t firstScenario{};
+    std::size_t blockCacheSlots{};
+    std::size_t tableCacheSlots{};
+    bool priorReady{};
+};
+
+/** Caps workers while leaving cores for the game and the server. */
+[[nodiscard]] std::size_t scenario_worker_count(std::size_t scenarios) noexcept {
+    SYSTEM_INFO info{};
+    GetSystemInfo(&info);
+    const std::size_t processors = static_cast<std::size_t>(info.dwNumberOfProcessors);
+    const std::size_t available = processors > 2U ? processors - 2U : 1U;
+    return (std::min)(scenarios, (std::min)(available, kMaximumScenarioWorkers));
+}
+
+/** Divides one cache budget across active workers without leaving a worker uncached. */
+[[nodiscard]] std::size_t worker_cache_slots(std::size_t budget, std::size_t workers) noexcept {
+    return (std::max)(std::size_t{1}, (budget + workers - 1U) / workers);
+}
+
+/** Publishes only increasing completion counts from out-of-order workers. */
+void publish_parallel_progress(ScenarioBuildBatch& batch, const Scenario& scenario) noexcept {
+    const std::size_t complete = batch.completed.fetch_add(1U) + 1U;
+    AcquireSRWLockExclusive(&batch.progressLock);
+    if (complete <= batch.progressPublished) {
+        ReleaseSRWLockExclusive(&batch.progressLock);
+        return;
+    }
+    batch.progressPublished = complete;
+    publish_progress(state::activity_sdk::generation::Status::building,
+                     static_cast<std::uint32_t>(complete),
+                     static_cast<std::uint32_t>(batch.work->scenarios.size()),
+                     scenario.tag,
+                     std::string_view(scenario.name.data(), scenario.nameLength));
+    ReleaseSRWLockExclusive(&batch.progressLock);
+}
+
+/** A cache made before the layout catalogue was ready omitted whole placement domains. */
+[[nodiscard]] bool placement_context_ready(const Scenario& scenario,
+                                           const catalog::Snapshot& snapshot) noexcept {
+    state::build_data::scenarios::Definition layout{};
+    const std::string_view name(scenario.name.data(), scenario.nameLength);
+    if (!state::build_data::find_scenario_layout(name, layout) || layout.spawnStemLength == 0) {
+        return true;
+    }
+    return snapshot.containerPlacementDiagnostics.contextResolved
+           && snapshot.staticSpatialContextResolved;
+}
+
+/** Builds one worker's contiguous chunks with private package and analysis caches. */
+void run_scenario_worker(ScenarioBuildBatch& batch) noexcept {
+    std::unique_ptr<package_reader::Scratch> scratch(new (std::nothrow) package_reader::Scratch());
+    if (scratch == nullptr) {
+        return;
+    }
+    if (!package_reader::prepare_blocks(*scratch, batch.blockCacheSlots)) {
+        (void)package_reader::prepare_blocks(*scratch, 0);
+    }
+    if (!package_reader::prepare_tables(*scratch, batch.tableCacheSlots)) {
+        (void)package_reader::prepare_tables(*scratch, 0);
+    }
+    builder::ScenarioAnalysisCache analyses{};
+    while (!cancelled()) {
+        const std::size_t begin = batch.next.fetch_add(kScenarioChunkSize);
+        if (begin >= batch.results->size()) {
+            break;
+        }
+        const std::size_t end = (std::min)(begin + kScenarioChunkSize, batch.results->size());
+        for (std::size_t index = begin; index < end && !cancelled(); ++index) {
+            const Scenario& scenario = batch.work->scenarios[batch.firstScenario + index];
+            ScenarioBuildResult& result = (*batch.results)[index];
+            result.attempted = true;
+            try {
+                const manifest::Record* existing =
+                    batch.priorReady ? find_record(*batch.prior, scenario.tag) : nullptr;
+                bool kept = existing != nullptr
+                            && load_full_record(batch.work->cacheScenarioDirectory,
+                                                batch.work->sourceFingerprint,
+                                                scenario,
+                                                *existing,
+                                                result.snapshot);
+                if (kept && !placement_context_ready(scenario, *result.snapshot)) {
+                    kept = false;
+                }
+                if (kept
+                    && !materialize_cached_record(
+                        *batch.work, scenario, *existing, result.snapshot, result.snapshot)) {
+                    kept = false;
+                }
+                if (kept) {
+                    result.record = *existing;
+                    result.reused = true;
+                    result.ready = true;
+                } else {
+                    const char* detail = "shard_build_failed";
+                    std::array<char, kDetailCapacity> scratchDetail{};
+                    result.ready = build_record(*batch.work,
+                                                *batch.source,
+                                                *batch.containers,
+                                                *scratch,
+                                                analyses,
+                                                scenario,
+                                                result.record,
+                                                result.snapshot,
+                                                scratchDetail,
+                                                detail);
+                    if (!result.ready) {
+                        (void)std::snprintf(
+                            result.detail.data(), result.detail.size(), "%s", detail);
+                    }
+                }
+            } catch (...) {
+                result.ready = false;
+                (void)std::snprintf(result.detail.data(),
+                                    result.detail.size(),
+                                    "%s",
+                                    "unexpected scenario build exception");
+            }
+            publish_parallel_progress(batch, scenario);
+        }
+    }
+    package_reader::close_files(*scratch);
+}
+
+/** Adapts one batch worker to the Windows thread ABI. */
+DWORD WINAPI scenario_thread_main(void* opaque) noexcept {
+    run_scenario_worker(*static_cast<ScenarioBuildBatch*>(opaque));
+    return 0;
+}
+
+/** Builds all scenario snapshots in parallel while retaining scenario-order output. */
+[[nodiscard]] bool build_scenarios(Work& work,
+                                   const package_reader::Source& source,
+                                   const builder::ContainerIndex& containers,
+                                   const manifest::Catalog& prior,
+                                   bool priorReady,
+                                   std::size_t firstScenario,
+                                   std::size_t scenarioCount,
+                                   std::vector<ScenarioBuildResult>& results) {
+    results.clear();
+    results.resize(scenarioCount);
+    ScenarioBuildBatch batch{};
+    batch.work = &work;
+    batch.source = &source;
+    batch.containers = &containers;
+    batch.prior = &prior;
+    batch.results = &results;
+    batch.completed.store(firstScenario);
+    batch.progressPublished = firstScenario;
+    batch.firstScenario = firstScenario;
+    batch.priorReady = priorReady;
+    const std::size_t workers = scenario_worker_count(results.size());
+    if (workers == 0) {
+        return false;
+    }
+    batch.blockCacheSlots = worker_cache_slots(kParallelBlockCacheBudget, workers);
+    batch.tableCacheSlots = worker_cache_slots(kParallelTableCacheBudget, workers);
+    std::vector<HANDLE> threads;
+    threads.reserve(workers - 1U);
+    for (std::size_t index = 1; index < workers; ++index) {
+        const HANDLE thread = CreateThread(nullptr, 0, &scenario_thread_main, &batch, 0, nullptr);
+        if (thread != nullptr) {
+            threads.push_back(thread);
+        }
+    }
+    run_scenario_worker(batch);
+    for (const HANDLE thread : threads) {
+        (void)WaitForSingleObject(thread, INFINITE);
+        (void)CloseHandle(thread);
+    }
+    return !cancelled();
+}
+
 /** Copies one terminal pass result before worker-owned storage is released. */
 void finish_offline(Work& work, bool complete, std::size_t built, std::size_t reused) noexcept {
     if (!work.offline || work.offlineStatus == nullptr || work.offlineResult == nullptr) {
@@ -251,9 +625,21 @@ DWORD WINAPI thread_main(void* opaque) noexcept {
                              middleware::content::packages::tables::kActivityDefinitionCount),
                          0,
                          "building installed activity inventory");
-        bool inventoried = build_inventory(*work, source);
+        // The game normally publishes these layouts during boot. An isolated generation
+        // process has no boot worker, but container and spatial extraction still need them.
+        bool layoutsReady = state::build_data::scenario_layouts_ready();
+        if (work->offline && !layoutsReady) {
+            auto scratch = std::make_unique<package_reader::Scratch>();
+            constexpr std::size_t kMaximumLayoutSteps = 10'000;
+            for (std::size_t step = 0; step < kMaximumLayoutSteps && !cancelled() && !layoutsReady;
+                 ++step) {
+                layoutsReady = scenarios::build(source, *scratch);
+            }
+            package_reader::close_files(*scratch);
+        }
+        bool inventoried = layoutsReady && build_inventory(*work, source);
         if (!inventoried) {
-            failureDetail = "inventory_incomplete";
+            failureDetail = layoutsReady ? "inventory_incomplete" : "scenario_layouts_unavailable";
         }
 
         const std::uint32_t total = static_cast<std::uint32_t>(work->scenarios.size());
@@ -649,9 +1035,10 @@ void service() noexcept {
         return;
     }
     core::path::Buffer packageDirectory;
-    const bool ready =
-        work->module != nullptr && !work->sdkDirectory.empty() && !work->packPath.empty()
-        && items::packages::package_directory(packageDirectory) && prepare_work(*work);
+    const bool ready = work->module != nullptr && !work->sdkDirectory.empty()
+                       && !work->packPath.empty() && state::build_data::scenario_layouts_ready()
+                       && items::packages::package_directory(packageDirectory)
+                       && prepare_work(*work);
     if (ready) {
         try {
             work->packageDirectory.assign(packageDirectory.chars.data(), packageDirectory.length);

+ 1009 - 1
Sunrise/src/server/activity/mission/mission_script_runtime.cpp

@@ -319,7 +319,1015 @@ bool commit_mission_state(RuntimeInstance& instance,
     return false;
 }
 
-namespace {
+/** Binds and restores the durable record before the opened program enters a callback. */
+[[nodiscard]] bool bind_mission_state(RuntimeInstance& instance, std::uint64_t now) noexcept {
+    mission_state::Snapshot snapshot{};
+    mission_state::Status status =
+        mission_state::bind(instance.view.binding, instance.programKey, snapshot);
+    ReloadAuthorization* const authorization = reload_authorization(instance.view.binding);
+    if (status == mission_state::Status::programMismatch && authorization != nullptr) {
+        status = mission_state::rebind_program(
+            instance.view.binding, authorization->program, instance.programKey, snapshot);
+    }
+    if (authorization != nullptr) {
+        *authorization = {};
+    }
+    note_vm_status(instance, "state", mission_state::status_name(status));
+    if (status != mission_state::Status::ready) {
+        log_line(
+            core::log::Level::warn, &instance, "state_bind", mission_state::status_name(status));
+        lua_vm::fault(instance.vm, "authoritative mission State binding was refused");
+        return false;
+    }
+    instance.missionStateBound = true;
+    accept_mission_state(instance, snapshot);
+    if (snapshot.state.variableCount > snapshot.state.variables.size()
+        || snapshot.state.timerCount > snapshot.state.timers.size()) {
+        fault_instance(instance, "authoritative mission State durable row count is invalid");
+        return false;
+    }
+    std::vector<lua_vm::Intent> restoredIntents{};
+    try {
+        restoredIntents.reserve(snapshot.state.pendingIntents.size());
+        for (const mission_state::PendingIntent& pending : snapshot.state.pendingIntents) {
+            restoredIntents.push_back(pending.value);
+        }
+    } catch (const std::bad_alloc&) {
+        fault_instance(instance, "authoritative mission State restore allocation failed");
+        return false;
+    }
+    if (!lua_vm::restore_state(instance.vm,
+                               snapshot.state.phase,
+                               snapshot.state.revision,
+                               {snapshot.state.variables.data(), snapshot.state.variableCount},
+                               {snapshot.state.timers.data(), snapshot.state.timerCount},
+                               snapshot.state.nextTimerSequence,
+                               snapshot.state.nextIntentKey,
+                               restoredIntents)) {
+        fault_instance(instance, "authoritative mission State restore was refused");
+        log_line(core::log::Level::warn, &instance, "state_restore", "vm_refused");
+        return false;
+    }
+    if (instance.durableHostOutputRevision != mission_state::kAbsentHostOutputRevision) {
+        instance.expectedScriptableRevision = instance.durableHostOutputRevision;
+        instance.deliveryStage = DeliveryStage::awaitingHostCommit;
+        instance.deliveryDeadline = deadline_after(now, kHostCommitTimeoutMs);
+        instance.firstIntentAttempt = now;
+        instance.intentAttempts = 1;
+        host::InstanceSnapshot hostView{};
+        if (host::instance_snapshot(instance.view.binding, hostView) && hostView.outputPending
+            && hostView.outputKind == host::OutputKind::scriptableOverride
+            && hostView.scriptableRevision == instance.expectedScriptableRevision) {
+            instance.deliveryStage = DeliveryStage::awaitingTransport;
+            instance.deliveryDeadline = deadline_after(now, kTransportTimeoutMs);
+        } else if (hostView.scriptableRevision == instance.expectedScriptableRevision
+                   && !hostView.outputPending
+                   && hostView.scriptableTransportRevision != instance.expectedScriptableRevision) {
+            instance.deliveryDeadline = now;
+        }
+    }
+    if (snapshot.state.faulted) {
+        lua_vm::fault(instance.vm, "authoritative mission State is faulted");
+        log_line(core::log::Level::warn, &instance, "state_restore", "faulted");
+        return false;
+    }
+    log_line(core::log::Level::debug,
+             &instance,
+             "state_restore",
+             snapshot.state.started ? "started" : "ready");
+    return true;
+}
+
+/**
+ * Finishes one on_load call after same-session VM reattachment.
+ * A restore is not a state transition, so an unchanged candidate must not spend a revision.
+ * @return True when the program is running.
+ */
+[[nodiscard]] bool apply_load(RuntimeInstance& instance, lua_vm::CallStatus loaded) noexcept {
+    lua_vm::Snapshot diagnostics{};
+    lua_vm::snapshot(instance.vm, diagnostics);
+    if (loaded != lua_vm::CallStatus::committed && loaded != lua_vm::CallStatus::noHandler) {
+        instance.programStatus = ProgramStatus::programError;
+        log_line(core::log::Level::warn,
+                 &instance,
+                 "load",
+                 lua_vm::status_name(loaded),
+                 {},
+                 diagnostics.lastError.data());
+        persist_mission_fault(instance);
+        return false;
+    }
+    if (diagnostics.stateRevision != instance.missionStateRevision
+        && !commit_mission_state(instance, true, instance.lastMissionSequence)) {
+        return false;
+    }
+    instance.lastLoggedRevision = instance.missionStateRevision;
+    return true;
+}
+
+enum class InitialStateGate : std::uint8_t {
+    ready,
+    pending,
+    failed,
+};
+
+/** Selects the program-declared state once, then waits for its exact roster revision to publish. */
+[[nodiscard]] InitialStateGate initial_state_gate(RuntimeInstance& instance) noexcept {
+    if (!instance.initialStateDeclared) {
+        return InitialStateGate::ready;
+    }
+    activity_sdk_mission::Snapshot seed{};
+    const activity_sdk_mission::Status status =
+        instance.initialStateSelected ? activity_sdk_mission::query(instance.view, seed)
+                                      : activity_sdk_mission::select_state(
+                                            instance.view, instance.initialStateRegion, {}, seed);
+    if (status == activity_sdk_mission::Status::outputBusy) {
+        return InitialStateGate::pending;
+    }
+    if (status != activity_sdk_mission::Status::ready || !seed.configured
+        || seed.plan.effectiveRegion != static_cast<std::uint32_t>(instance.initialStateRegion)) {
+        log_line(core::log::Level::warn,
+                 &instance,
+                 "initial_state",
+                 activity_sdk_mission::status_name(status));
+        fault_instance(instance, "program initial_state mission-seed selection was refused");
+        return InitialStateGate::failed;
+    }
+    instance.initialStateSelected = true;
+    // `plan.effectiveRegion` is the authored-state key; several authored states share one client
+    // slice-set region, so the client link cannot report it. The lease revision reaching the
+    // transport is the publication acknowledgement.
+    return seed.publicationPending || seed.revision == 0 || seed.publishedRevision != seed.revision
+               ? InitialStateGate::pending
+               : InitialStateGate::ready;
+}
+
+/** Runs and durably commits a fresh program only after its initial-state gate is open. */
+[[nodiscard]] bool start_program(RuntimeInstance& instance, std::uint64_t now) noexcept {
+    const lua_vm::CallStatus started = lua_vm::start(instance.vm, now);
+    note_vm_status(instance, "start", lua_vm::status_name(started));
+    if (started != lua_vm::CallStatus::committed && started != lua_vm::CallStatus::noHandler) {
+        instance.programStatus = ProgramStatus::programError;
+        lua_vm::Snapshot diagnostics{};
+        lua_vm::snapshot(instance.vm, diagnostics);
+        log_line(core::log::Level::warn,
+                 &instance,
+                 "start",
+                 lua_vm::status_name(started),
+                 {},
+                 diagnostics.lastError.data());
+        persist_mission_fault(instance);
+        return false;
+    }
+    if (!commit_mission_state(instance, true, instance.lastMissionSequence)) {
+        return false;
+    }
+    instance.startPending = false;
+    instance.lastLoggedRevision = instance.missionStateRevision;
+    log_line(core::log::Level::info, &instance, "open", "ready");
+    return true;
+}
+
+/** Opens the program for one slot, binds its durable record, then reattaches or starts it. */
+[[nodiscard]] AttachResult open_program(RuntimeInstance& instance, std::uint64_t now) noexcept {
+    const format::Activity* const activity = sdk::bound_activity(instance.view);
+    if (activity == nullptr
+        || !sdk_bridge::program_identity(instance.view, instance.publicTarget, instance.identity)) {
+        instance.programStatus = ProgramStatus::programError;
+        note_vm_status(instance, "open", "invalid_sdk_view");
+        log_line(core::log::Level::warn, &instance, "open", "invalid_sdk_view");
+        return AttachResult::programError;
+    }
+    instance.identity.sdkLuaSearchPath = g_sdkLuaSearchPath;
+    instance.identity.playerKey = instance.playerKey;
+    std::span<const char> source{};
+    switch (read_source(*instance.view.catalog, *activity, source)) {
+    case SourceStatus::missing:
+        instance.programStatus = ProgramStatus::missing;
+        note_vm_status(instance, "open", "no_script");
+        log_line(core::log::Level::info, &instance, "open", "no_script");
+        return AttachResult::noScript;
+    case SourceStatus::fileError:
+        instance.programStatus = ProgramStatus::fileError;
+        note_vm_status(instance, "open", "file_error");
+        log_line(core::log::Level::warn, &instance, "open", "file_error");
+        return AttachResult::scriptFileError;
+    case SourceStatus::tooLarge:
+        instance.programStatus = ProgramStatus::sourceTooLarge;
+        note_vm_status(instance, "open", "source_too_large");
+        log_line(core::log::Level::warn, &instance, "open", "source_too_large");
+        return AttachResult::sourceTooLarge;
+    case SourceStatus::ready:
+        break;
+    }
+    if (!make_program_key(instance, *activity, source, instance.programKey)) {
+        std::fill(g_source.begin(), g_source.begin() + source.size(), '\0');
+        instance.programStatus = ProgramStatus::programError;
+        note_vm_status(instance, "state", "invalid_program_key");
+        log_line(core::log::Level::warn, &instance, "open", "invalid_program_key");
+        return AttachResult::programError;
+    }
+    const lua_vm::OpenStatus opened =
+        lua_vm::open(instance.vm,
+                     instance.identity,
+                     sdk_bridge::definition_api(instance.view, instance.worldView),
+                     source);
+    std::fill(g_source.begin(), g_source.begin() + source.size(), '\0');
+    note_vm_status(instance, "open", lua_vm::status_name(opened));
+    if (opened != lua_vm::OpenStatus::ready) {
+        instance.programStatus = ProgramStatus::programError;
+        lua_vm::Snapshot diagnostics{};
+        lua_vm::snapshot(instance.vm, diagnostics);
+        log_line(core::log::Level::warn,
+                 &instance,
+                 "open",
+                 lua_vm::status_name(opened),
+                 {},
+                 diagnostics.lastError.data());
+        return AttachResult::programError;
+    }
+    instance.programStatus = ProgramStatus::loaded;
+    instance.initialStateDeclared =
+        lua_vm::initial_state_region(instance.vm, instance.initialStateRegion);
+    if (instance.initialStateDeclared) {
+        instance.activeRegion = instance.initialStateRegion;
+    }
+    if (!bind_mission_state(instance, now)) {
+        instance.programStatus = ProgramStatus::programError;
+        return AttachResult::programError;
+    }
+    if (instance.missionStarted) {
+        instance.missionReattached = true;
+        const lua_vm::CallStatus loaded = lua_vm::load(instance.vm, now);
+        note_vm_status(instance, "load", lua_vm::status_name(loaded));
+        if (!apply_load(instance, loaded)) {
+            return AttachResult::programError;
+        }
+        log_line(core::log::Level::info, &instance, "open", "ready", "reason=state_reattached");
+        return AttachResult::ready;
+    }
+    switch (initial_state_gate(instance)) {
+    case InitialStateGate::failed:
+        return AttachResult::programError;
+    case InitialStateGate::pending:
+        instance.startPending = true;
+        log_line(core::log::Level::info, &instance, "initial_state", "publication_pending");
+        return AttachResult::ready;
+    case InitialStateGate::ready:
+        break;
+    }
+    return start_program(instance, now) ? AttachResult::ready : AttachResult::programError;
+}
+
+/** Binds one host instance to a free slot once its link, SDK view and world view all resolve. */
+void attach_instance(const host::InstanceSnapshot& hostInstance,
+                     sdk::Snapshot catalog,
+                     std::uint64_t now) noexcept {
+    if (find_instance(hostInstance.binding) != nullptr) {
+        return;
+    }
+    if (catalog == nullptr) {
+        report_attach_result(
+            hostInstance.binding, AttachResult::catalogUnavailable, "catalog_unavailable");
+        return;
+    }
+    server::bap::ActivityLinkView link{};
+    if (!server::bap::activity_link_view(hostInstance.binding, link)) {
+        report_attach_result(
+            hostInstance.binding, AttachResult::noActivityLink, "no_activity_link");
+        return;
+    }
+    if (!link.joined) {
+        report_attach_result(
+            hostInstance.binding, AttachResult::noActivityLink, "activity_join_pending");
+        return;
+    }
+    sdk::BoundView view{};
+    const sdk::Selection selection{
+        .binding = hostInstance.binding,
+        .matchingLinks = link.matchingLinks,
+        .activityClientGeneration = link.activityClientGeneration,
+    };
+    const sdk::Status status = sdk::resolve(catalog, selection, view);
+    if (status != sdk::Status::ready) {
+        report_attach_result(hostInstance.binding,
+                             AttachResult::sdkStatus,
+                             sdk::status_name(status),
+                             format::kAbsentIndex,
+                             status);
+        return;
+    }
+    generated::GeneratedWorldView worldView{};
+    const generated::BindStatus worldStatus = generated::resolve(view, worldView);
+    if (worldStatus != generated::BindStatus::ready) {
+        report_attach_result(hostInstance.binding,
+                             AttachResult::generatedWorldStatus,
+                             generated::status_name(worldStatus),
+                             view.activityRow,
+                             sdk::Status::notReady,
+                             worldStatus);
+        return;
+    }
+    RuntimeInstance* const instance = free_instance();
+    if (instance == nullptr) {
+        report_attach_result(
+            hostInstance.binding, AttachResult::capacity, "capacity", view.activityRow);
+        return;
+    }
+    instance->view = std::move(view);
+    instance->worldView = std::move(worldView);
+    instance->publicTarget = link.publicTarget;
+    instance->playerKey = link.playerKey;
+    instance->occupied = true;
+    const AttachResult opened = open_program(*instance, now);
+    report_attach_result(
+        hostInstance.binding, opened, attach_result_name(opened), instance->view.activityRow);
+}
+
+/** Drops slots that no longer match, publishes the roster, and attaches active host instances. */
+void synchronize_instances(std::uint64_t now) noexcept {
+    host::DiagnosticsSnapshot diagnostics{};
+    host::snapshot(diagnostics);
+    retire_attach_diagnostics(diagnostics);
+    retire_unbound_pending_events();
+    for (RuntimeInstance& instance : g_instances) {
+        const bool bindingActive =
+            instance.occupied && is_active(diagnostics, instance.view.binding);
+        const bool bindingRetained =
+            instance.occupied && state::activity::binding_matches(instance.view.binding);
+        // A generation change only stales the view, so rebind and keep the program.
+        if (instance.occupied && bindingActive && bindingRetained && !still_exact(instance)
+            && rebind_instance(instance)) {
+            log_line(core::log::Level::debug, &instance, "rebind", "generation");
+            continue;
+        }
+        if (instance.occupied && (!bindingActive || !still_exact(instance))) {
+            log_line(core::log::Level::info, &instance, "close", "stale_generation");
+            // Accepted mission inputs belong to the exact SessionBinding, not one ActivityClient
+            // generation or one temporary link outage. Clear only after State replaces the exact
+            // session generation; otherwise reattach must finish every already-accepted row.
+            clear_instance(instance, !bindingRetained);
+        }
+    }
+    std::array<state::activity::SessionRosterRow, state::activity::kSessionCapacity> roster{};
+    std::size_t rosterCount = 0;
+    static_cast<void>(state::activity::snapshot_session_roster(roster, rosterCount));
+    for (RuntimeInstance& instance : g_instances) {
+        if (instance.occupied) {
+            push_session_roster_edges(instance, {roster.data(), rosterCount});
+        }
+    }
+    const sdk::Snapshot catalog = sdk::snapshot();
+    for (std::size_t index = 0; index < diagnostics.instanceCount; ++index) {
+        if (diagnostics.instances[index].active) {
+            attach_instance(diagnostics.instances[index], catalog, now);
+        }
+    }
+}
+
+/** Advances fresh programs only when their declared state roster has reached transport output. */
+void service_pending_starts(std::uint64_t now) noexcept {
+    for (RuntimeInstance& instance : g_instances) {
+        if (!instance.occupied || !instance.startPending
+            || instance.programStatus != ProgramStatus::loaded || instance.missionStarted) {
+            continue;
+        }
+        switch (initial_state_gate(instance)) {
+        case InitialStateGate::pending:
+            break;
+        case InitialStateGate::failed:
+            instance.startPending = false;
+            break;
+        case InitialStateGate::ready:
+            static_cast<void>(start_program(instance, now));
+            break;
+        }
+    }
+}
+
+/** @return True for the host events that report an output's progress, not an input. */
+[[nodiscard]] bool delivery_lifecycle_event(host::EventKind kind) noexcept {
+    switch (kind) {
+    case host::EventKind::authStateCommitted:
+    case host::EventKind::authStateTransportStaged:
+    case host::EventKind::authStateCanceled:
+    case host::EventKind::incidentQueued:
+    case host::EventKind::incidentTransportStaged:
+    case host::EventKind::incidentCanceled:
+    case host::EventKind::incidentRefused:
+    case host::EventKind::scriptableOverrideCommitted:
+    case host::EventKind::scriptableOverrideTransportStaged:
+    case host::EventKind::scriptableOverrideCanceled:
+    case host::EventKind::operatorRefused:
+        return true;
+    default:
+        return false;
+    }
+}
+
+/**
+ * @return True for a row that arrives on the ordered mission-input feed and owns a sequence.
+ * A host-state row must never answer true. It would consume a mission-input sequence it does not
+ * own, which faults the binding on the next real input.
+ */
+[[nodiscard]] bool host_feed_row(host::EventKind kind) noexcept {
+    switch (kind) {
+    case host::EventKind::timerElapsed:
+    case host::EventKind::effectResult:
+    case host::EventKind::phaseEntered:
+    case host::EventKind::triggerEntered:
+    case host::EventKind::triggerExited:
+    case host::EventKind::squadState:
+    case host::EventKind::entitySpawned:
+    case host::EventKind::entityDied:
+    case host::EventKind::sceneFinished:
+    case host::EventKind::objectiveProgress:
+    case host::EventKind::sessionJoined:
+    case host::EventKind::sessionLeft:
+    case host::EventKind::playerTrigger:
+    case host::EventKind::cinematicStarted:
+    case host::EventKind::cinematicTerminated:
+        return false;
+    default:
+        return true;
+    }
+}
+
+/** True when the event may reach a callback for this instance's ActivityClient generation. */
+[[nodiscard]] bool eligible_event(const RuntimeInstance& instance,
+                                  const host::Event& event) noexcept {
+    if (event.kind == host::EventKind::timerElapsed) {
+        return true;
+    }
+    if (event.sourceGeneration != instance.view.activityClientGeneration) {
+        return false;
+    }
+    return event.kind == host::EventKind::clientStateChanged
+           || event.kind == host::EventKind::incidentReceived
+           || event.kind == host::EventKind::clientMessageReceived
+           || event.kind == host::EventKind::effectResult
+           || event.kind == host::EventKind::phaseEntered
+           || event.kind == host::EventKind::triggerEntered
+           || event.kind == host::EventKind::triggerExited
+           || event.kind == host::EventKind::squadState
+           || event.kind == host::EventKind::entitySpawned
+           || event.kind == host::EventKind::entityDied
+           || event.kind == host::EventKind::sceneFinished
+           || event.kind == host::EventKind::objectiveProgress
+           || event.kind == host::EventKind::entitySlotsRequested
+           || event.kind == host::EventKind::sessionJoined
+           || event.kind == host::EventKind::sessionLeft
+           || event.kind == host::EventKind::playerTrigger
+           || event.kind == host::EventKind::cinematicStarted
+           || 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);
+}
+
+/** Faults the instance unless the ordered mission input arrives with no gap, starting at one. */
+[[nodiscard]] bool validate_mission_sequence(RuntimeInstance& instance,
+                                             const host::Event& event) noexcept {
+    if (event.kind != host::EventKind::senseUpdate
+        && event.kind != host::EventKind::incidentReceived
+        && event.kind != host::EventKind::clientStateChanged
+        && event.kind != host::EventKind::entitySlotsRequested
+        && event.kind != host::EventKind::clientMessageReceived) {
+        return true;
+    }
+    if (instance.lastMissionSequence == 0) {
+        if (event.missionSequence == 1) {
+            return true;
+        }
+        fault_instance(instance, "activity mission input did not start at sequence one");
+        log_line(core::log::Level::warn, &instance, "events", "initial_binding_gap");
+        return false;
+    }
+    const std::uint64_t expected =
+        instance.lastMissionSequence == (std::numeric_limits<std::uint64_t>::max)()
+            ? 1
+            : instance.lastMissionSequence + 1;
+    if (event.missionSequence == expected) {
+        return true;
+    }
+    fault_instance(instance, "activity mission input sequence has a gap");
+    log_line(core::log::Level::warn, &instance, "events", "binding_gap");
+    return false;
+}
+
+/** Records the typed Ghost-link fields while its interaction semantics are being verified. */
+void log_ghost_link_sense(const 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
+            || observation.firstValue > sense.valueCount
+            || observation.valueCount > sense.valueCount - observation.firstValue) {
+            continue;
+        }
+        for (std::size_t offset = 0; offset < observation.valueCount; ++offset) {
+            const auto& value = sense.values[observation.firstValue + offset];
+            if (!value.present || value.schemaRow != observation.key.schemaRow) {
+                continue;
+            }
+            std::array<char, 192> fields{};
+            const int written = std::snprintf(
+                fields.data(),
+                fields.size(),
+                "registry=%08x object=%08x slot=%u ordinal=%u bits=%u value=%llu sequence=%llu",
+                observation.key.registryKey,
+                observation.key.objectTag,
+                static_cast<unsigned>(observation.key.slotIndex),
+                static_cast<unsigned>(value.fieldOrdinal),
+                static_cast<unsigned>(value.width),
+                static_cast<unsigned long long>(value.unsignedValue),
+                static_cast<unsigned long long>(observation.sequence));
+            if (written > 0) {
+                log_line(core::log::Level::info,
+                         &instance,
+                         "ghost_link_sense",
+                         "observed",
+                         {fields.data(),
+                          (std::min)(static_cast<std::size_t>(written), fields.size() - 1)});
+            }
+        }
+    }
+}
+
+/** Runs one event through the VM, commits what it changed, and faults on a script failure. */
+[[nodiscard]] lua_vm::CallStatus dispatch_event(RuntimeInstance& instance,
+                                                const host::Event& event,
+                                                const host::SenseObservationSnapshot* sense,
+                                                const host::ClientMessageSnapshot* clientMessage,
+                                                bool firstAttempt,
+                                                std::uint64_t now) noexcept {
+    if (instance.programStatus == ProgramStatus::missing
+        && event.kind == host::EventKind::senseUpdate && sense != nullptr) {
+        push_squad_edges(instance, *sense);
+        return lua_vm::CallStatus::inactive;
+    }
+    if (instance.programStatus != ProgramStatus::loaded || !eligible_event(instance, event)) {
+        return lua_vm::CallStatus::inactive;
+    }
+    if (firstAttempt) {
+        ++instance.eventsSeen;
+        instance.lastEventSequence = event.sequence;
+    }
+    // The three region numbers the script is about to read. A report restates only the leg it
+    // moved, so `pending` and `current` read -1 on most reports and `held` is the one that says
+    // where the client is standing.
+    if (firstAttempt && event.kind == host::EventKind::clientStateChanged) {
+        std::array<char, 64> legs{};
+        const int written =
+            std::snprintf(legs.data(),
+                          legs.size(),
+                          "held=%d pending=%d current=%d",
+                          event.heldRegionIndex,
+                          event.clientStateHasRegion ? event.regionIndex : -1,
+                          event.clientStateHasCurrentRegion ? event.currentRegionIndex : -1);
+        if (written > 0) {
+            log_line(core::log::Level::debug,
+                     &instance,
+                     "client_state",
+                     "legs",
+                     {legs.data(), static_cast<std::size_t>(written)});
+        }
+    }
+    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 (firstAttempt && event.kind == host::EventKind::incidentReceived) {
+        push_player_trigger(instance, event);
+        push_cinematic(instance, event);
+    }
+    if (event.kind == host::EventKind::senseUpdate && sense != nullptr) {
+        if (firstAttempt) {
+            log_ghost_link_sense(instance, *sense);
+        }
+        push_trigger_edges(instance, *sense);
+        push_squad_edges(instance, *sense);
+        push_scene_edges(instance, *sense);
+        push_objective_edges(instance, *sense);
+    }
+    note_vm_status(instance, "event", lua_vm::status_name(status));
+    if (firstAttempt && instance.eventsSeen == 1) {
+        log_line(core::log::Level::info,
+                 &instance,
+                 "dispatch",
+                 lua_vm::status_name(status),
+                 event.kind == host::EventKind::senseUpdate            ? "sense"
+                 : event.kind == host::EventKind::clientStateChanged   ? "client_state"
+                 : event.kind == host::EventKind::incidentReceived     ? "incident"
+                 : event.kind == host::EventKind::entitySlotsRequested ? "entity_slots_requested"
+                 : event.kind == host::EventKind::timerElapsed         ? "timer"
+                 : event.kind == host::EventKind::effectResult         ? "effect_result"
+                                                                       : "client_message");
+    }
+    const std::uint64_t nextInputSequence =
+        host_feed_row(event.kind) ? event.missionSequence : instance.lastMissionSequence;
+    if (status == lua_vm::CallStatus::committed) {
+        if (!commit_mission_state(instance, true, nextInputSequence)) {
+            return lua_vm::CallStatus::scriptError;
+        }
+        ++instance.eventsCommitted;
+        lua_vm::Snapshot diagnostics{};
+        lua_vm::snapshot(instance.vm, diagnostics);
+        if (event.kind == host::EventKind::clientStateChanged
+            || diagnostics.stateRevision != instance.lastLoggedRevision) {
+            instance.lastLoggedRevision = diagnostics.stateRevision;
+            log_line(core::log::Level::debug,
+                     &instance,
+                     "event",
+                     "committed",
+                     event.kind == host::EventKind::senseUpdate          ? "sense"
+                     : event.kind == host::EventKind::clientStateChanged ? "client_state"
+                     : event.kind == host::EventKind::incidentReceived   ? "incident"
+                     : event.kind == host::EventKind::entitySlotsRequested
+                         ? "entity_slots_requested"
+                     : event.kind == host::EventKind::timerElapsed ? "timer"
+                     : event.kind == host::EventKind::effectResult ? "effect_result"
+                                                                   : "client_message");
+        }
+        return status;
+    }
+    if (status == lua_vm::CallStatus::noHandler) {
+        return commit_mission_state(instance, true, nextInputSequence)
+                   ? status
+                   : lua_vm::CallStatus::scriptError;
+    }
+    if (status == lua_vm::CallStatus::inactive) {
+        return status;
+    }
+    lua_vm::Snapshot diagnostics{};
+    lua_vm::snapshot(instance.vm, diagnostics);
+    log_line(core::log::Level::warn,
+             &instance,
+             "event",
+             lua_vm::status_name(status),
+             {},
+             diagnostics.lastError.data());
+    persist_mission_fault(instance);
+    return status;
+}
+
+/** Reads new host events, advances delivery, and queues only the lifecycle rows for scripts. */
+void consume_delivery_events(std::uint64_t now) noexcept {
+    host::EventRead events{};
+    host::read_events_after(g_eventCursor, events);
+    g_eventCursor = events.cursor;
+    if (events.reset) {
+        log_line(core::log::Level::warn, nullptr, "delivery", "event_feed_reset");
+        return;
+    }
+    if (events.gap) {
+        log_line(core::log::Level::warn, nullptr, "delivery", "event_feed_gap");
+    }
+    for (std::size_t index = 0; index < events.count; ++index) {
+        RuntimeInstance* const instance = find_instance(events.events[index].binding);
+        if (instance != nullptr) {
+            observe_delivery_event(*instance, events.events[index], now);
+            // Sense, client-state, incident and client-message rows reach the script through the
+            // ordered mission-input feed. Only the delivery lifecycle rows belong in this queue.
+            if (delivery_lifecycle_event(events.events[index].kind)) {
+                push_script_event(*instance, events.events[index]);
+            }
+        }
+    }
+}
+
+/** One free queue row, growing the queue by one when none is free; null when it cannot grow. */
+[[nodiscard]] PendingMissionEvent* free_pending_event() noexcept {
+    for (PendingMissionEvent& pending : g_pendingMissionEvents) {
+        if (!pending.occupied) {
+            return &pending;
+        }
+    }
+    if (g_pendingMissionEvents.size() == g_pendingMissionEvents.max_size()) {
+        return nullptr;
+    }
+    try {
+        g_pendingMissionEvents.emplace_back();
+    } catch (const std::bad_alloc&) {
+        return nullptr;
+    }
+    return &g_pendingMissionEvents.back();
+}
+
+/** Copies one accepted input and its values into a queue row; faults the instance when full. */
+[[nodiscard]] bool queue_mission_event(RuntimeInstance* instance,
+                                       const host::MissionInputEvent& input) noexcept {
+    PendingMissionEvent* const pending = free_pending_event();
+    if (pending == nullptr) {
+        if (instance != nullptr) {
+            fault_instance(*instance, "mission event queue allocation failed");
+            clear_pending_events(instance->view.binding);
+        }
+        log_line(core::log::Level::warn, instance, "events", "allocation_failed");
+        return false;
+    }
+    clear_pending_event(*pending);
+    if (input.event.kind == host::EventKind::senseUpdate
+        && input.event.senseDecodeStatus
+               == middleware::bap::activity_message::sense_update::DecodeStatus::complete) {
+        pending->senseAvailable =
+            host::mission_input_sense_snapshot(input.sequence, pending->sense);
+    }
+    if (input.event.kind == host::EventKind::clientMessageReceived) {
+        pending->clientMessageAvailable =
+            host::mission_input_client_message_snapshot(input.sequence, pending->clientMessage);
+    }
+    pending->nextAttempt = 0;
+    pending->event = input.event;
+    if (instance != nullptr) {
+        pending->callbackEligible = eligible_event(*instance, input.event);
+        pending->eligibilityResolved = true;
+    }
+    pending->occupied = true;
+    return true;
+}
+
+[[nodiscard]] constexpr bool mission_sequence_precedes(std::uint64_t left,
+                                                       std::uint64_t right) noexcept {
+    constexpr std::uint64_t halfRange = std::uint64_t{1} << 63U;
+    return left != right && right - left < halfRange;
+}
+
+static_assert(mission_sequence_precedes((std::numeric_limits<std::uint64_t>::max)(), 1));
+static_assert(!mission_sequence_precedes(1, (std::numeric_limits<std::uint64_t>::max)()));
+
+/** @return True when the durable cursor already committed this retained input row. */
+[[nodiscard]] bool mission_sequence_committed(std::uint64_t sequence,
+                                              std::uint64_t committed) noexcept {
+    return committed != 0
+           && (sequence == committed || mission_sequence_precedes(sequence, committed));
+}
+
+/** True when the same binding still holds a queued row with an earlier mission sequence. */
+[[nodiscard]] bool has_earlier_pending_event(const PendingMissionEvent& selected) noexcept {
+    for (const PendingMissionEvent& pending : g_pendingMissionEvents) {
+        if (pending.occupied && &pending != &selected
+            && same_binding(pending.event.binding, selected.event.binding)
+            && mission_sequence_precedes(pending.event.missionSequence,
+                                         selected.event.missionSequence)) {
+            return true;
+        }
+    }
+    return false;
+}
+
+/** TODO: no caller. Decide whether the drain gate retires on this or on `binding_matches`. */
+[[nodiscard]] bool host_binding_active(const state::activity::SessionBinding& binding) noexcept {
+    host::InstanceSnapshot snapshot{};
+    return host::instance_snapshot(binding, snapshot) && snapshot.active;
+}
+
+/** Dispatches queued rows in mission-sequence order and retires those no callback can take. */
+void drain_pending_mission_events(std::uint64_t now) noexcept {
+    bool progressed = false;
+    do {
+        progressed = false;
+        for (PendingMissionEvent& pending : g_pendingMissionEvents) {
+            if (!pending.occupied || now < pending.nextAttempt
+                || has_earlier_pending_event(pending)) {
+                continue;
+            }
+            RuntimeInstance* const instance = find_instance(pending.event.binding);
+            if (instance == nullptr) {
+                if (!state::activity::binding_matches(pending.event.binding)) {
+                    clear_pending_event(pending);
+                    progressed = true;
+                }
+                continue;
+            }
+            if (instance->programStatus != ProgramStatus::loaded) {
+                clear_pending_event(pending);
+                progressed = true;
+                continue;
+            }
+            if (instance->startPending) {
+                continue;
+            }
+            if (instance->timerPending) {
+                continue;
+            }
+            if (mission_sequence_committed(pending.event.missionSequence,
+                                           instance->lastMissionSequence)) {
+                clear_pending_event(pending);
+                progressed = true;
+                continue;
+            }
+            if (!pending.missionSequenceObserved) {
+                if (!validate_mission_sequence(*instance, pending.event)) {
+                    clear_pending_events(instance->view.binding);
+                    progressed = true;
+                    break;
+                }
+                pending.missionSequenceObserved = true;
+            }
+            if (!pending.eligibilityResolved) {
+                pending.callbackEligible = eligible_event(*instance, pending.event);
+                pending.eligibilityResolved = true;
+            }
+            if (!pending.callbackEligible) {
+                if (!commit_mission_state(
+                        *instance, instance->missionStarted, pending.event.missionSequence)) {
+                    clear_pending_events(instance->view.binding);
+                    progressed = true;
+                    break;
+                }
+                clear_pending_event(pending);
+                progressed = true;
+                continue;
+            }
+            if (pending.event.kind == host::EventKind::senseUpdate && !pending.senseAvailable) {
+                fault_instance(*instance, "accepted mission Sense values were unavailable");
+                clear_pending_events(instance->view.binding);
+                log_line(core::log::Level::warn, instance, "events", "sense_unavailable");
+                progressed = true;
+                break;
+            }
+            if (pending.event.kind == host::EventKind::clientMessageReceived
+                && !pending.clientMessageAvailable) {
+                fault_instance(*instance,
+                               "accepted mission client-message values were unavailable");
+                clear_pending_events(instance->view.binding);
+                log_line(core::log::Level::warn, instance, "events", "client_message_unavailable");
+                progressed = true;
+                break;
+            }
+            lua_vm::Intent intent{};
+            if (instance->deliveryStage != DeliveryStage::idle
+                || lua_vm::pending_intent(instance->vm, intent)) {
+                continue;
+            }
+            const bool firstAttempt = pending.attempts == 0;
+            if (firstAttempt) {
+                pending.firstAttempt = now;
+            }
+            ++pending.attempts;
+            const host::SenseObservationSnapshot* const sense =
+                pending.event.kind == host::EventKind::senseUpdate && pending.senseAvailable
+                    ? &pending.sense
+                    : nullptr;
+            const host::ClientMessageSnapshot* const clientMessage =
+                pending.event.kind == host::EventKind::clientMessageReceived
+                        && pending.clientMessageAvailable
+                    ? &pending.clientMessage
+                    : nullptr;
+            static_cast<void>(
+                dispatch_event(*instance, pending.event, sense, clientMessage, firstAttempt, now));
+            clear_pending_event(pending);
+            progressed = true;
+        }
+    } while (progressed);
+}
+
+/** @return True when one exact accepted sequence is already retained locally or in this read. */
+[[nodiscard]] bool input_sequence_retained(const state::activity::SessionBinding& binding,
+                                           std::uint64_t sequence,
+                                           const host::MissionInputRead& inputs) noexcept {
+    for (const PendingMissionEvent& pending : g_pendingMissionEvents) {
+        if (pending.occupied && same_binding(pending.event.binding, binding)
+            && pending.event.missionSequence == sequence) {
+            return true;
+        }
+    }
+    for (std::size_t index = 0; index < inputs.count; ++index) {
+        if (same_binding(inputs.events[index].event.binding, binding)
+            && inputs.events[index].event.missionSequence == sequence) {
+            return true;
+        }
+    }
+    return false;
+}
+
+/** @return True when every accepted but uncommitted sequence is present in this read or local
+ * queue. */
+[[nodiscard]] bool
+outstanding_input_interval_complete(const state::activity::SessionBinding& binding,
+                                    const mission_state::InputSequenceSnapshot& state,
+                                    const host::MissionInputRead& inputs) noexcept {
+    if (state.issued < state.committed) {
+        return false;
+    }
+    const std::uint64_t outstanding = state.issued - state.committed;
+    if (outstanding > g_pendingMissionEvents.size() + inputs.count) {
+        return false;
+    }
+    std::uint64_t sequence = state.committed;
+    for (std::uint64_t index = 0; index < outstanding; ++index) {
+        ++sequence;
+        if (!input_sequence_retained(binding, sequence, inputs)) {
+            return false;
+        }
+    }
+    return true;
+}
+
+/** Faults only retained bindings whose durable uncommitted interval is provably incomplete. */
+void reconcile_input_feed_loss(const host::MissionInputRead& inputs) noexcept {
+    host::DiagnosticsSnapshot hostState{};
+    host::snapshot(hostState);
+    for (std::size_t index = 0; index < hostState.instanceCount; ++index) {
+        const host::InstanceSnapshot& hostInstance = hostState.instances[index];
+        if (!state::activity::binding_matches(hostInstance.binding)) {
+            continue;
+        }
+        mission_state::InputSequenceSnapshot inputState{};
+        if (!mission_state::input_sequence_snapshot(hostInstance.binding, inputState)
+            || inputState.faulted
+            || outstanding_input_interval_complete(hostInstance.binding, inputState, inputs)) {
+            continue;
+        }
+        mission_state::Snapshot snapshot{};
+        const mission_state::Status status =
+            mission_state::fault_input_feed(hostInstance.binding, snapshot);
+        RuntimeInstance* const instance = find_instance(hostInstance.binding);
+        if (status != mission_state::Status::ready) {
+            log_line(core::log::Level::warn,
+                     instance,
+                     "events",
+                     mission_state::status_name(status),
+                     "reason=feed_gap_fault_refused");
+            continue;
+        }
+        if (instance != nullptr) {
+            accept_mission_state(*instance, snapshot);
+            lua_vm::fault(instance->vm, "accepted mission input feed lost a row");
+            instance->programStatus = ProgramStatus::programError;
+        }
+        log_line(core::log::Level::warn, instance, "events", "feed_gap_faulted");
+        clear_pending_events(hostInstance.binding);
+    }
+}
+
+/** Reads one page of the ordered feed and queues every row not yet committed. */
+[[nodiscard]] bool consume_mission_input_page() noexcept {
+    host::MissionInputRead inputs{};
+    host::read_mission_inputs_after(g_missionInputCursor, inputs);
+    if (inputs.reset) {
+        g_missionInputCursor = {inputs.cursor.generation, 0};
+        reconcile_input_feed_loss(inputs);
+        log_line(core::log::Level::warn, nullptr, "events", "mission_feed_reset");
+    }
+    if (inputs.gap) {
+        log_line(core::log::Level::warn, nullptr, "events", "mission_feed_gap");
+        reconcile_input_feed_loss(inputs);
+    }
+    for (std::size_t index = 0; index < inputs.count; ++index) {
+        const host::MissionInputEvent& input = inputs.events[index];
+        RuntimeInstance* const instance = find_instance(input.event.binding);
+        mission_state::InputSequenceSnapshot inputState{};
+        const bool hasInputState =
+            mission_state::input_sequence_snapshot(input.event.binding, inputState);
+        if ((instance == nullptr && !state::activity::binding_matches(input.event.binding))
+            || (instance != nullptr && instance->programStatus != ProgramStatus::loaded)
+            || (hasInputState
+                && (inputState.faulted
+                    || mission_sequence_committed(input.event.missionSequence,
+                                                  inputState.committed)))) {
+            g_missionInputCursor.generation = inputs.cursor.generation;
+            g_missionInputCursor.sequence = input.sequence;
+            continue;
+        }
+        if (instance != nullptr
+            && mission_sequence_committed(input.event.missionSequence,
+                                          instance->lastMissionSequence)) {
+            g_missionInputCursor.generation = inputs.cursor.generation;
+            g_missionInputCursor.sequence = input.sequence;
+            continue;
+        }
+        if (instance != nullptr) {
+            lua_vm::Snapshot diagnostics{};
+            lua_vm::snapshot(instance->vm, diagnostics);
+            if (diagnostics.faulted) {
+                g_missionInputCursor.generation = inputs.cursor.generation;
+                g_missionInputCursor.sequence = input.sequence;
+                continue;
+            }
+        }
+        if (!queue_mission_event(instance, input)) {
+            return false;
+        }
+        g_missionInputCursor.generation = inputs.cursor.generation;
+        g_missionInputCursor.sequence = input.sequence;
+    }
+    return inputs.count == host::kMissionInputReadPageSize;
+}
+
+/**
+ * Reads the ordered mission-input feed and drains the queue. The feed is unbounded and one read
+ * copies at most a page, so a burst is consumed in the tick it arrives instead of a page a tick.
+ */
+void consume_mission_inputs(std::uint64_t now) noexcept {
+    drain_pending_mission_events(now);
+    while (consume_mission_input_page()) {
+        drain_pending_mission_events(now);
+    }
+    drain_pending_mission_events(now);
+}
 
 /**
  * Settles the inputs of every instance whose activity has no script. No program will consume

+ 27 - 38
scripts/README.md

@@ -1,35 +1,32 @@
 # Ember development scripts
 
-`mission_ember.lua` currently implements only the opening landing combat slice. It is not a
-complete mission controller. The initial six-squad selection needs an in-game playtest. Bridge
-interaction, later encounters, authored checkpoints, dialogue and mission completion remain to
-be implemented.
-
-The original installed SDK inspected on 2026-09-04 lacks the opening squad definitions. This controller
-intentionally reports the missing binding before publishing any gameplay state. Build this branch
-and regenerate the SDK first; the scenario-scoping fix in the squad linker addresses object-key
-collisions between campaign and arcade content. Generation must still verify runnable member
-counts, actor bindings and anchors. The isolated SDK generated in `build/sdk-generated` now passes
-these checks for the initial six opening squads. Do not remove checks or supply replacement enemies.
-
-After regeneration, copy this directory's contents to `Sunrise/scripts` beside the generated
-`Sunrise/sdk/lua` directory in the game artifact tree. The runtime selects `mission_ember.lua`
-by the activity name. Enable the existing `server.activation.mission_scripting` setting.
-
-The controller declares region 64 at startup and waits for the client to hold it before spawning enemies. Enemy clear
-requires evidence that every watched squad existed and now has no live members. Duplicate clear
-events do not advance twice, and removed squads must be observed alive again. Region transit and
-script reload do not reset the encounter. `Encounter:reset` is available for a future confirmed
-checkpoint callback; this version does not infer a wipe from region transit.
-
-Fresh launches declare the opening powerhouse state through `initial_state`. The installed
-`state.activity.arrival_overrides` must also include `mission_ember` with `bubble: 8` and
-`slice_set: 64`; the arrival router otherwise chooses the first live bubble (the reactor area).
-The branch's bundled defaults include this override. Existing settings need the row added
-explicitly. Restart the game after changing arrival settings.
-
-The combat-clear callback displays the authored bridge-control directive. It does not move the
-bridge, complete the activity or silently proceed through unimplemented encounters.
+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 supplied reference video starts in gameplay;
+it cannot establish the arrival movie’s identity.
+
+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` and `slice_set: 49`; gameplay is selected by the script
+after the cinematic. 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:
 
@@ -54,11 +51,3 @@ 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
 ```
-
-The current landing test places ten SDK squads (the original six and the four available
-bonus-support squads), using their authored default counts. Bonus-wave timing remains unverified;
-the missing bonus-anchor and far-side definitions are not substituted. Startup snaps the six
-bridge devices closed and resets the bridge objective after the initial state is published.
-The region callback no longer redundantly selects that state. Bridge interaction and extension
-still need scripting; this test checks its initial state only. Server/client info logging is
-installed to expose refused spawn and device requests on the next fresh launch.

+ 9 - 10
scripts/mission_ember.lua

@@ -2,21 +2,20 @@
 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)
 
 return {
-    initial_state = landing.initial_state,
-    on_start = function(context, state)
-        landing.initialize(context)
-    end,
+    initial_state = opening.initial_state,
+    on_event_cinematic_terminated = opening.terminated,
     on_event_client_state_changed = function(context, state, event)
-        local region = event.current_region_index
-        if region == nil then
-            context:clear_variable("ember.region")
-            return
-        end
-        context:set_variable("ember.region", region)
+        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.
+        if region ~= nil then context:set_variable("ember.region", region) end
+        region = state:variable("ember.region")
         if region == landing.region then
             landing.enter(context, state)
+            landing.client_state(context, state, event)
         end
     end,
     on_event_squad_state = function(context, state, event)

+ 25 - 10
scripts/mission_ember/landing.lua

@@ -3,6 +3,7 @@ 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",
@@ -52,29 +53,43 @@ return function(mission)
                               "bridge controls directive")
     local encounter = Encounter.new("ember.landing", squads, function(context)
         context:slot(directive):set_directive{directive = controls}
+        context:slot(mission.Slot.M_DIALOG_SENSOR_80B3C90A):play_dialogue_cue{
+            cue = mission.DialogueCue.M_DIALOG_SENSOR_80B3C90A.CUE_3,
+        }
+        context:set_phase(3)
         -- The bridge must wait for its authored interaction. Combat clear does not open it.
     end)
 
+    local function initialize(context)
+        for _, device in ipairs(bridge) do
+            context:slot(device):transition{transition = context.sdk.device_transitions.close, snap = true}
+        end
+        context:slot(bridge_objective):reset_objectives{}
+    end
+
     return {
         initial_state = entry,
         region = entry.region_index,
-        initialize = function(context)
-            -- on_start runs after the initial-state roster has reached the transport.
-            -- Do not repeat this on region transit or on_load: those are not mission resets.
-            for _, device in ipairs(bridge) do
-                context:slot(device):transition{
-                    transition = context.sdk.device_transitions.close, snap = true,
-                }
-            end
-            context:slot(bridge_objective):reset_objectives{}
-        end,
         enter = function(context, state)
             if encounter:phase(state) ~= 0 then
                 return
             end
+            initialize(context)
+            context:set_phase(2)
             encounter:enter(context, state)
             context:slot(directive):set_directive{directive = clear}
         end,
+        client_state = function(context, state, event)
+            if event.teleport_state == 0 then context:set_variable("ember.spawned", true) end
+            if state:variable("ember.spawned") and not state:variable("ember.guidance")
+                and event.region_index == nil and event.current_region_index == nil
+                and event.spawn_state == nil and event.teleport_state == nil then
+                context:set_variable("ember.guidance", true)
+                context:slot(mission.Slot.M_DIALOG_SENSOR_80B3C90A):play_dialogue_cue{
+                    cue = mission.DialogueCue.M_DIALOG_SENSOR_80B3C90A.CUE_0,
+                }
+            end
+        end,
         on_squad_state = function(context, state, event)
             encounter:on_squad_state(context, state, event)
         end,

+ 32 - 0
scripts/mission_ember/opening.lua

@@ -0,0 +1,32 @@
+-- Arrival cinematic and control handoff. The cinematic owns a separate authored state.
+return function(mission, landing)
+    local entry = assert(mission.states.STATE_80B3C09E_0006_0001_80B3C09A)
+    local cinematic = assert(mission.Slot.PF_CINEMATIC_BOOKEND_CINEMATIC)
+    local function phase(state) return state:variable("ember.cinematic") or 0 end
+    local function finish(context, state)
+        if phase(state) ~= 1 then return end
+        context:set_variable("ember.cinematic", 2)
+        context:slot(cinematic):set_cinematic_active{active = false}
+        context:select_state(landing.initial_state)
+        context:set_phase(2)
+    end
+    return {
+        initial_state = entry,
+        client_state = function(context, state, event)
+            local held = event.held_region_index or event.current_region_index
+            -- Only a held-region report starts the movie; a requested destination is insufficient.
+            if held == entry.region_index and phase(state) == 0 then
+                context:set_variable("ember.cinematic", 1)
+                context:set_phase(1)
+                context:slot(cinematic):set_cinematic_active{active = true}
+            end
+        end,
+        terminated = function(context, state, event)
+            if phase(state) ~= 1 then return end
+            local slot = context:slot(cinematic)
+            if event.registry_key ~= slot.registry_key or event.slot_type ~= slot.slot_type
+                or event.slot_index ~= slot.slot_index then return end
+            finish(context, state)
+        end,
+    }
+end

+ 41 - 15
tests/mission_ember_controller_test.lua

@@ -1,6 +1,7 @@
 package.path = "scripts/?.lua;" .. package.path
 local names = {
-    "LANDING_MERCURY_ANCHOR_SQUAD", "LANDING_MERCURY_SUPPORT_A_SQUAD",
+    "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",
@@ -8,7 +9,11 @@ local names = {
 }
 local mission = {
     Squad = {}, Slot = {M_DIRECTIVE_SENSOR_80B3C90A = "directive"},
-    states = {STATE_80B3C09E_0008_0000_80B3C09C = {region_index = 64}},
+    states = {
+        STATE_80B3C09E_0008_0000_80B3C09C = {region_index = 64},
+        STATE_80B3C09E_0006_0001_80B3C09A = {region_index = 49},
+    },
+    DialogueCue = {M_DIALOG_SENSOR_80B3C90A = {CUE_0 = 0, CUE_3 = 3}},
     Directive = {
         FIND_AND_DISABLE_THE_ALMIGHTY_S_WEAPONS_A70DA4A6 = "clear",
         FIND_AND_DISABLE_THE_ALMIGHTY_S_WEAPONS_2700C0C5 = "controls",
@@ -25,15 +30,18 @@ local devices = {
 }
 for _, name in ipairs(devices) do mission.Slot[name] = name end
 mission.Slot.EMBER_POWERHOUSE_BRIDGE_OBJECTIVE = "bridge_objective"
+mission.Slot.PF_CINEMATIC_BOOKEND_CINEMATIC = "cinematic"
+mission.Slot.M_DIALOG_SENSOR_80B3C90A = "dialogue"
 package.preload.missions = function() return {MISSION_EMBER = "test_mission"} end
 package.preload.test_mission = function() return mission end
 local program = require("mission_ember")
-assert(program.initial_state == mission.states.STATE_80B3C09E_0008_0000_80B3C09C,
-       "fresh launches must declare the opening powerhouse state")
+assert(program.initial_state == mission.states.STATE_80B3C09E_0006_0001_80B3C09A,
+       "fresh launches must declare the authored cinematic state")
 local vars, directives, placements, seeds = {}, {}, 0, 0
 local state = {variable = function(_, key) return vars[key] end}
-local closed, resets = {}, 0
+local closed, resets, movies, cues = {}, 0, {}, {}
 local context = {sdk = {squad_modes = {replace = "replace"}, device_transitions = {close = "close"}}}
+function context:set_phase(value) vars.phase = value end
 function context:set_variable(key, value) vars[key] = value end
 function context:clear_variable(key) vars[key] = nil end
 function context:select_state(entry)
@@ -45,6 +53,13 @@ function context:squad(name)
     return {default_counts = {1}, place = function() placements = placements + 1 end}
 end
 function context:slot(name)
+    if name == "cinematic" then
+        return {registry_key = 9, slot_type = 6, slot_index = 0,
+            set_cinematic_active = function(_, args) movies[#movies + 1] = args.active end}
+    end
+    if name == "dialogue" then
+        return {play_dialogue_cue = function(_, args) cues[#cues + 1] = args.cue end}
+    end
     for _, device in ipairs(devices) do
         if name == device then
             return {transition = function(_, args)
@@ -75,21 +90,32 @@ local function cleared()
         })
     end
 end
-program.on_start(context, state)
-assert(#closed == 6 and resets == 1 and placements == 0)
+assert(#closed == 0 and resets == 0 and placements == 0)
+program.on_event_client_state_changed(context, state, {region_index = 49})
+assert(#movies == 0, "a requested region is not a held cinematic")
+region(49); region(49)
+assert(#movies == 1 and movies[1] == true and placements == 0)
+program.on_event_cinematic_terminated(context, state, {registry_key = 8, slot_type = 6, slot_index = 0})
+assert(seeds == 0, "unrelated movie termination cannot advance the mission")
+local termination = {registry_key = 9, slot_type = 6, slot_index = 0}
+program.on_event_cinematic_terminated(context, state, termination)
+program.on_event_cinematic_terminated(context, state, termination)
+assert(seeds == 1 and #movies == 2 and movies[2] == false)
 region(40)
-assert(seeds == 0 and placements == 0)
+assert(placements == 0 and #closed == 0)
 region(64); region(64)
-assert(seeds == 0 and placements == #names and directives[1] == "clear")
-region(nil); cleared()
-assert(#directives == 1)
-region(64)
-assert(seeds == 0 and placements == #names)
+assert(placements == #names and directives[1] == "clear")
+assert(#closed == 6 and resets == 1, "initialize devices only after playable region is held")
+program.on_event_client_state_changed(context, state, {teleport_state = 0})
+assert(#cues == 0)
+region(nil); region(nil)
+assert(#cues == 1 and cues[1] == 0, "arrival dialogue follows spawn settlement once")
 cleared(); cleared()
 assert(#directives == 2 and directives[2] == "controls")
+assert(#cues == 2 and cues[2] == 3, "settle deltas must preserve encounter ownership")
 package.loaded.mission_ember = nil
 program = require("mission_ember")
 region(64); cleared()
-assert(seeds == 0 and placements == #names and #directives == 2)
+assert(seeds == 1 and placements == #names and #directives == 2 and #cues == 2)
 assert(#closed == 6 and resets == 1, "transit and reload must not reset the bridge")
-print("controller region ownership, transit, clear and reload checks passed")
+print("cinematic handoff, held-region initialization, dialogue and reload checks passed")