Просмотр исходного кода

Preserve Ember landing mission and runtime fixes before upstream sync

Millie 4 дней назад
Родитель
Сommit
4a813d4081

+ 3 - 0
.gitignore

@@ -15,3 +15,6 @@
 # CMake build files
 /build/
 /out/
+
+# Local Python inspection tools and tests
+__pycache__/

+ 4 - 7
CMakeLists.txt

@@ -18,11 +18,10 @@ file(GLOB_RECURSE VENDOR_SOURCES CONFIGURE_DEPENDS
     "Sunrise/vendor/lua/*.c"
     "Sunrise/vendor/lua/*.h"
 )
-# Lua builds as C++ here, matching the MSVC project's CompileAsCpp setting.
-file(GLOB_RECURSE LUA_SOURCES CONFIGURE_DEPENDS
-    "Sunrise/vendor/lua/*.c"
-)
+file(GLOB LUA_SOURCES CONFIGURE_DEPENDS "Sunrise/vendor/lua/*.c")
+# Match the Visual Studio project: the embedded Lua runtime uses C++ linkage.
 set_source_files_properties(${LUA_SOURCES} PROPERTIES LANGUAGE CXX)
+list(APPEND VENDOR_SOURCES ${LUA_SOURCES})
 file(GLOB_RECURSE RESOURCE_FILES CONFIGURE_DEPENDS
     "Sunrise/resources/*.rc"
     "Sunrise/resources/*.h"
@@ -84,9 +83,7 @@ target_compile_definitions(steam_api64 PRIVATE
 )
 
 if(MSVC OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
-    # The vendored Lua interpreter throws, so its error path needs C++ unwinding.
-    target_compile_options(steam_api64 PRIVATE $<$<COMPILE_LANGUAGE:CXX>:/EHsc>)
-
+    target_compile_options(steam_api64 PRIVATE /EHsc)
     # Clang warnings are more strict the MSVC I guess, this only works on real clang
     if(CMAKE_CXX_COMPILER_ID MATCHES "Clang")
         list(APPEND STRICT_WARNING_FLAGS

+ 90 - 0
MISSION_EMBER_PLAN.md

@@ -0,0 +1,90 @@
+# 1AU / mission_ember implementation plan
+
+Prepared 2026-09-04. Scope: restore the complete mission through Lua using the installed generated SDK, with package-backed identifiers and authored resources.
+
+## Evidence reviewed
+
+- Reference video: https://www.youtube.com/watch?v=PqurUhqC2CE — The Videogame Library, “Destiny 2 (Xbox One) The Red War: Mission 16 - 1AU (No Commentary Longplay)”, duration 36:42. Reviewed all nine available storyboard sheets, sampling the timeline every ten seconds. This establishes broad visual order; it is not continuous playback or an audio review. Exact dialogue timing, brief interactions and damage timings remain to be verified. The recording begins in gameplay and includes subsequent story cinematics and a later mission launch; those are not all necessarily controlled by Ember.
+- Generated mission: `/home/millie/Games/Sunrise/bin/x64/Sunrise/sdk/lua/missions/mission_ember_80b3c09e.lua`.
+- Campaign activity: `mission_ember_38f926b2.lua`, display name `1AU`, activity index 281. Daily Heroic variant: `mission_ember_231dd291.lua`, index 54. Both point to activity root `0x80B3C07D` and scenario `0x80B3C09E`.
+- SDK manifest: schema `sunrise-activity-sdk-v8`, format 37, build ID `sha256:26b80ec22191c2f87d9079ef1dc90707cf26b10e5299962304aac4e7a9e8c47a`.
+- Installed packages: `/home/millie/Games/Sunrise/packages`. Read package directories and checked 154 distinct content references from the mission's slot IDs and content-tag fields: all resolved to nonempty entries across nine package families. This verifies presence, not complete semantic decoding of each payload. Runtime schema constants were excluded from the content-reference check.
+- Primary family: `w64_cabal_ship_activities_019e_{0,1,2,3,5}.pkg`, with 8,192 directory entries in the latest installed directory. Root entry: 72 bytes; scenario entry: 1,773 bytes.
+- Scenario SDK pack: `sdk/scenarios/80B3C09E-446ac6892dd45476cc8b91f8e3a654d92b14eaa6ed4d272f14e93cc43a927082.pack`.
+- Lua references: sibling `SunriseMissions`, especially `lib/mission_lib.lua` and the regional dispatch in `tangled_shore_freeroam.lua`. Its README describes the examples as WIP, so each pattern needs checking against this branch's runtime.
+
+## What the generated SDK already contains
+
+| Resource | Count / finding |
+| --- | --- |
+| State records | 8; effective region indices 0, 1, 2, 40, 48, 49, 56, 64 |
+| Named slots | 2,051 |
+| Squad sensors / richer squad definitions | 162 / 31 |
+| Device sensors | 69 |
+| Objective sensors / directive entries | 28 / 26 |
+| Player trigger sensors / trigger volumes | 127 / 211 |
+| Scenes / sequence sensors / cinematic sensors | 10 / 9 / 3 |
+| Dialogue cue selectors | 55; definition mappings exist for a subset |
+| Task targets / performance states | Both generated tables are empty |
+
+Specific assets include `sunburn_damage_object`, `security_placed_interceptor_object`, bridge machinery, east/west reactor clamshell devices and damage sensors, the coffin damage sensor, reactor shield, explosion scenes, escape ship and three cinematic bookend sensors. Directive descriptions explicitly identify the grinder, solar-exposure cover mechanic, bridge controls, energy stream, Interceptor, cooling vents, fusion cell and escape.
+
+These findings support an SDK-first implementation. They do not yet prove every required runtime operation works. In particular, the difference between squad sensors and richer squad definitions must be explained before choosing how each encounter is activated. An empty task table means we cannot assume an `advance_task` target exists for every objective.
+
+## Implementation sequence
+
+1. **Build the mission mapping and prove the SDK bindings.**
+   - Create a stage table mapping each encounter to its owning region/state, entry and exit triggers, squads, devices, objective/directive, dialogue cues, scenes and reset behavior.
+   - Use stable generated identifiers. Distinguish identical display names with different slot identities. State values are all zero here: use their region-qualified identities rather than treating the values as global phase IDs.
+   - Trace ambiguous mappings through the SDK catalog/scenario data and targeted package payloads. Determine whether squads absent from the richer table are scene-owned, activated through existing sensor state, or need an SDK export correction.
+   - Verify the generated module, branch runtime and installed runtime agree. Exercise the existing APIs for state selection, squad placement, device transitions, triggers, directives, dialogue, cinematics and lifetime state.
+   - Deliverable: an encounter map with confirmed bindings and an explicit list of unresolved operations.
+
+2. **Implement and test the opening encounter as a complete slice.**
+   - Add `scripts/mission_ember.lua` and focused modules under `scripts/mission_ember/`; include the required shared helpers in the same deployable tree.
+   - Bind the campaign activity first. Resolve the correct entry state, publish its entities, activate its encounter and show its authored directive.
+   - Prove an actual player trigger, a combat completion condition, the first gate transition and the following region load.
+   - Handle duplicate events, death/reset and region re-entry before expanding the controller.
+   - Exit criterion: launch, play the first encounter, progress, reset and replay without manual entity spawning or forced phase advancement.
+
+3. **Implement the traversal and combat sequence in order.**
+
+   | Section | Required behavior |
+   | --- | --- |
+   | Opening / mineral processing | Correct combat groups, entrances/exits, navigation, objectives and dialogue; determine any launch bookend from package ownership. |
+   | Ore tunnels and grinder | Investigation and activation interaction, defend while clearing the obstruction, completion gate and onward route. |
+   | Sunlit deck | Enable the authored solar hazard and cover behavior; activate the correct encounters and remove section-specific effects on exit/reset. |
+   | Interior chambers and machinery | Encounter waves, moving devices, doors, bridge controls and region transitions. Confirm the exact placement of each bridge against trigger ownership. |
+   | Fuel stream and Interceptor route | Energy-stream traversal, vehicle availability/boarding, enemy placement and the transition into the reactor section. |
+   | Reactor sabotage | East/west vent exposure and destruction, linked objectives and scenes, subsequent core/fusion-cell interaction, shield and route changes. Confirm authored ordering and thresholds from data. |
+   | Escape | Explosion sequence, navigation to the getaway ship, escape trigger, terminal mission state and the relevant cinematic handoff. |
+
+   - Progress from actual trigger, interaction, damage, squad and scene events. Use timers where supported by authored behavior; do not copy this player's video duration into encounter gates.
+   - Distinguish stage entry from stage resume. Only activate entities belonging to the appropriate loaded region.
+   - Exit criterion for each section: it can be reached naturally, completed and replayed after failure.
+
+4. **Complete presentation and mission lifecycle behavior.**
+   - Pair each directive and dialogue cue with its actual event; handle alternate cues and avoid repeating one-shot lines on re-entry.
+   - Restore sequences, music transitions, destruction scenes and cinematic start/termination handling using existing SDK surfaces.
+   - Establish which ending scenes belong to Ember and which belong to campaign progression. Do not launch the later mission merely because it appears at the end of the video.
+   - Verify completion is recorded by the supported activity/campaign path, with expected rewards and subsequent progression where applicable. A final Lua phase or a cinematic alone is not proof of completion.
+
+5. **Make recovery and repeated play reliable.**
+   - Implement the authored checkpoint/wipe behavior using the available runtime state and sensors; first confirm which state survives death, region transit and script reload.
+   - Reset devices, hazards, damage targets, objectives, vehicles, encounter state and timers to the checkpoint's expected state.
+   - Guard one-shot actions against duplicate/stale events. Keep regional timers and callbacks tied to the region that owns them.
+   - Test backtracking, rapid trigger crossings, repeated interaction, death during animations, vehicle loss and escape failure.
+   - Verify fireteam synchronization and late joins if supported by the mission's intended play mode. Test the Daily Heroic binding after campaign behavior is stable.
+
+6. **Validate the complete mission.**
+   - Check Lua loading and every referenced SDK symbol before deployment.
+   - Add focused event-sequence checks for progression, duplicate events, reset and terminal completion where the available harness supports them.
+   - Play from a fresh launch through the ending, with no developer intervention. Compare encounters, objectives, hazards, vehicle section and cinematics with the reference.
+   - Repeat with checkpoint deaths and a fresh second launch. Review runtime logs for unresolved references, rejected operations, stale handles and missed transitions.
+   - Record any remaining behavior differences explicitly. Completion requires functional gameplay and lifecycle behavior, not just reaching the final room.
+
+## Working approach
+
+Keep authored Lua in the `mission-ember` branch and use the installed SDK as generated input. Start with its existing capabilities. Make a targeted runtime/export fix only after a concrete required operation is shown to be unavailable or broken. Initial work should produce the encounter map and playable opening; the full mission then grows section by section using that verified approach.
+
+No gameplay code, installed game files or runtime settings were changed during this planning pass.

+ 103 - 0
MISSION_EMBER_STATUS.md

@@ -0,0 +1,103 @@
+# Ember implementation status
+
+## First test build
+
+The opening landing controller is implemented and loads with the regenerated SDK. It publishes
+the powerhouse state only when the client holds region 64, places the six landing squads and
+changes the directive after all six have been observed and cleared. It leaves bridge movement
+for the authored interaction. This is a development slice, not a complete implementation of 1AU.
+
+Build: `build/x64/Release/steam_api64.dll`.
+Regenerated SDK: `build/sdk-generated`.
+Prepared installation overlay: `build/playtest`.
+
+The overlay was installed into the game's `bin/x64` directory on 2026-09-04. It contains the
+rebuilt root DLL, generated SDK and opening scripts. The previous files are backed up in
+`build/installed-backup-20260904-224413`, with an `installation.json` listing replaced paths.
+The scripting switch `server.activation.mission_scripting` was already enabled; no settings
+were changed. DLL, SDK-pack and entry-controller copies were checked against the prepared files.
+
+## Fixed extraction problem
+
+The squad linker resolved object keys across the entire content estate. Ember and its arcade
+variant reuse object keys, so a spawn-rule slot in the arcade object could invalidate an exact
+campaign reference. The linker now restricts target descriptors to objects sharing an authored
+scenario with the source. Ambiguity inside a shared scenario remains a refusal.
+
+Package evidence: decoded all 162 Ember spawner configs using the existing package reader and
+installed Oodle codec in an isolated Wine prefix. Their 173 valid raw references all resolve to
+unique slots inside Ember; 116 collide in the global slot lookup. This census establishes the
+scope problem; it does not establish that every spawner passes every subsequent runtime gate.
+
+The production offline generator was run with the rebuilt code into an isolated output tree,
+reusing 468 matching scenario shards and publishing 1,643 Lua files. The verified output was then
+installed for the opening playtest, retaining the originals in the backup above.
+
+| Ember resource | Installed SDK | Regenerated SDK |
+| --- | ---: | ---: |
+| Squad sensors | 162 | 162 |
+| Squad definitions | 31 | 77 |
+| Definitions passing static placement checks | 29 | 64 |
+| Sensors without a squad definition | 131 | 87 |
+
+The six opening squads all have exact actor bindings, anchors and passing definition flags.
+The remaining missing definitions and refused rows require further work; the scope fix is not
+a claim that all mission combat is available. Static placement checks cover definition flags,
+member counts and the default actor-profile compatibility gate. Live lease, transport and AI
+behavior still require in-game validation.
+
+## Region map
+
+Ownership is taken from SDK state/occurrence joins. This table is not a complete event-order map.
+
+| Region | Authored name | Relevant content | Squad sensors | Passing static placement checks |
+| --- | --- | --- | ---: | ---: |
+| 64 | powerhouse | Landing, bridge, catwalk, helipad | 42 | 28 |
+| 56 | link | Processing encounter and machinery | 15 | 2 |
+| 40 | cinder | Foundry, chamber, grinder, ascent, sunlit deck | 49 | 31 |
+| 0 | apex | Interceptor/security, reactor targets, fusion-cell area and escape | 56 | 3 |
+| 1, 2 | apex alternatives | Alternate authored states | 0 | 0 |
+| 48, 49 | unnamed globals | Shared mission sensors and bookends | 0 | 0 |
+
+The generated-world shard's authored-squad context vectors are empty. Its projection helper
+`append_scenario_graph_contexts` has no caller in the current generation path. The opening
+controller uses the runtime pack's squad definitions, so wiring that separate graph projection
+was not included in the scope fix.
+
+## Verification
+
+- Full Release Windows DLL build passed using clang-cl and the existing Windows SDK.
+- CMake now includes the embedded Lua sources/headers and enables C++ exceptions, matching the
+  Visual Studio project's requirements.
+- Eight production-resolver regressions passed with address and undefined-behavior sanitizers.
+  Leak detection was disabled because the execution sandbox uses tracing.
+- Six SDK-inspector validation tests passed, including corrupted/truncated inputs and wrong
+  scenario identity.
+- Lua encounter and controller checks passed for initial empty reports, duplicate events,
+  removals, explicit reset, region transit, region ownership and controller reload.
+- The opening controller rejects the old SDK's missing squad binding and loads with the newly
+  generated SDK. No in-game playthrough has been performed.
+
+## Next checkpoint
+
+Launch campaign 1AU and check that the landing enemies appear,
+fight and allow the directive to reach the bridge controls. Check death and re-entry behavior
+without expecting complete authored checkpoint support yet. Keep the mission-script/runtime
+log from this run so spawn or lease refusals can be distinguished from missing AI behavior.
+
+Then verify the bridge's interaction event and extend the controller through the next encounter.
+The later mission, full checkpoint/wipe handling, dialogue, cinematics, rewards and terminal
+completion remain unimplemented. Do not mark the mission complete on the basis of this test build.
+
+## Live-test correction: VM arena allocation
+
+The first opening-area test reported `mission arena block could not be taken from the heap`
+before any events. An isolated Windows executable linked against the Release objects reproduced
+it: arena initialization returned false with capacity zero. LLVM IR showed that the nothrow
+byte-array new-expression had been reduced to the failure branch, with no allocation call.
+An explicit `::operator new[]` call preserves the allocation; the 64 MiB limit is unchanged.
+The rebuilt production allocator passed allocation, growth with data preservation, capacity
+refusal, free/coalescing and three release/reopen cycles under Wine. Regression source:
+`tests/mission_arena_test.cpp` (run with Release optimization; checks do not rely on assert).
+The corrected DLL is installed; backup: `build/dll-before-arena-fix-20260904-230158.dll`.
+A fresh in-game launch is still required to verify the next mission startup stage.

+ 5 - 0
Sunrise/resources/default_settings.json

@@ -105,6 +105,11 @@
         "spawn_set_hash": "0x811C9DC5"
       },
       "arrival_overrides": [
+        {
+          "package_name": "mission_ember",
+          "bubble": 8,
+          "slice_set": 64
+        },
         {
           "package_name": "raid_envy_v310",
           "bubble": 7

+ 23 - 4
Sunrise/src/client/content/activity/activity_sdk_squad_graph_edges.cpp

@@ -287,6 +287,7 @@ template <typename Index> void canonicalize_scenarios(Index& index) {
     std::uint64_t rawReference,
     const std::unordered_map<TargetKey, std::vector<std::uint32_t>, TargetKeyHash>& byTarget,
     const std::unordered_map<std::uint32_t, std::uint32_t>& rulesByConfig,
+    const std::vector<std::vector<std::uint32_t>>& scenariosByObject,
     std::map<TargetGroup, ExactTarget>& exactTargets,
     ReferenceResolutionStatus& status) {
     const ObjectReference decoded = decode_reference(rawReference);
@@ -314,8 +315,24 @@ template <typename Index> void canonicalize_scenarios(Index& index) {
     const std::vector<std::uint32_t>& candidates = found == byTarget.end() ? empty : found->second;
     std::map<TargetGroup, std::vector<std::uint32_t>> groups{};
     std::size_t authoritativeCount = 0;
+    const GraphSpawner& spawner = graph.spawners[spawnerRow];
     for (const std::uint32_t descriptorRow : candidates) {
         const GraphDescriptor& descriptor = graph.descriptors[descriptorRow];
+        // Object keys are reused by campaign and arcade scenarios. A reference can only
+        // name a target that occurs in a scenario containing its source object.
+        if (spawner.sourceDescriptorStatus == SourceDescriptorStatus::exact) {
+            const GraphDescriptor& source = graph.descriptors[spawner.sourceDescriptorRow];
+            const auto& sourceScenarios = scenariosByObject[source.objectIndex];
+            const auto& targetScenarios = scenariosByObject[descriptor.objectIndex];
+            const bool sharesScenario =
+                std::any_of(sourceScenarios.begin(), sourceScenarios.end(), [&](auto scenario) {
+                    return std::binary_search(
+                        targetScenarios.begin(), targetScenarios.end(), scenario);
+                });
+            if (!sharesScenario) {
+                continue;
+            }
+        }
         const topology::Slot& slot = topology.slots[descriptor.slotIndex];
         const auto rule = rulesByConfig.find(descriptor.configTag);
         const bool authoritative =
@@ -328,16 +345,17 @@ template <typename Index> void canonicalize_scenarios(Index& index) {
         }
         graph.referenceDescriptors.push_back({referenceRow, descriptorRow, authoritative, false});
     }
-    reference.candidateDescriptors.count = static_cast<std::uint32_t>(candidates.size());
+    reference.candidateDescriptors.count =
+        static_cast<std::uint32_t>(graph.referenceDescriptors.size())
+        - reference.candidateDescriptors.first;
 
-    const GraphSpawner& spawner = graph.spawners[spawnerRow];
     if (spawner.sourceDescriptorStatus == SourceDescriptorStatus::missing) {
         status = ReferenceResolutionStatus::sourceDescriptorMissing;
     } else if (spawner.sourceDescriptorStatus == SourceDescriptorStatus::ambiguous) {
         status = ReferenceResolutionStatus::sourceDescriptorAmbiguous;
-    } else if (candidates.empty()) {
+    } else if (reference.candidateDescriptors.count == 0) {
         status = ReferenceResolutionStatus::targetMissing;
-    } else if (authoritativeCount != candidates.size()) {
+    } else if (authoritativeCount != reference.candidateDescriptors.count) {
         status = ReferenceResolutionStatus::targetDescriptorMismatch;
     } else if (groups.size() != 1) {
         status = ReferenceResolutionStatus::targetAmbiguous;
@@ -442,6 +460,7 @@ template <typename Index> void canonicalize_scenarios(Index& index) {
                                    raw[ordinal],
                                    descriptorsByTarget,
                                    rulesByConfig,
+                                   scenariosByObject,
                                    exactTargets,
                                    statuses[ordinal])) {
                 return false;

+ 5 - 1
Sunrise/src/server/activity/mission/mission_script_arena.cpp

@@ -164,7 +164,11 @@ bool arena_initialize(Arena& arena) noexcept {
     arena.highWater = 0;
     arena.initialized = false;
     if (arena.bytes == nullptr) {
-        std::byte* const block = new (std::nothrow) std::byte[kArenaByteCapacity];
+        // Call the allocation function directly: Clang's optimized Windows build folds the
+        // nothrow byte-array new-expression into null, preventing every mission from opening.
+        // The matching byte-array deleter still releases this storage through operator delete[].
+        auto* const block =
+            static_cast<std::byte*>(::operator new[](kArenaByteCapacity, std::nothrow));
         if (block == nullptr) {
             arena.capacity = 0;
             return false;

+ 64 - 0
scripts/README.md

@@ -0,0 +1,64 @@
+# 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.
+
+Run local checks from the repository root:
+
+```sh
+lua5.4 tests/mission_ember_encounter_test.lua
+lua5.4 tests/mission_ember_controller_test.lua
+python -m unittest discover -s tests -p 'test_*.py'
+g++ -std=c++23 -O1 -g -fsanitize=address,undefined -fno-omit-frame-pointer \
+    tests/squad_reference_scope_test.cpp -lcrypto -o /tmp/ember-squad-reference-test
+ASAN_OPTIONS=detect_leaks=0 /tmp/ember-squad-reference-test
+```
+
+Leak detection is disabled in this command because it cannot run under the sandbox's tracing;
+address and undefined-behavior instrumentation remain enabled. The C++ test uses synthetic
+identities and exercises the production reference resolver. Lua tests model the context API;
+they do not substitute for a live mission run.
+
+Generate a local SDK inventory without committing package data:
+
+```sh
+python tools/inspect_mission_sdk.py /path/to/Sunrise/activity_sdk.pack \
+    --shard /path/to/Sunrise/sdk/scenarios/80B3C09E-HASH.pack \
+    --output /tmp/mission-ember-inventory.json
+```
+
+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.

+ 27 - 0
scripts/mission_ember.lua

@@ -0,0 +1,27 @@
+-- Development controller for the opening landing. The remaining mission is not wired yet.
+local missions = require("missions")
+local mission = require(assert(missions.MISSION_EMBER, "mission_ember SDK module is absent"))
+local landing = require("mission_ember.landing")(mission)
+
+return {
+    initial_state = landing.initial_state,
+    on_start = function(context, state)
+        landing.initialize(context)
+    end,
+    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)
+        if region == landing.region then
+            landing.enter(context, state)
+        end
+    end,
+    on_event_squad_state = function(context, state, event)
+        if state:variable("ember.region") == landing.region then
+            landing.on_squad_state(context, state, event)
+        end
+    end,
+}

+ 86 - 0
scripts/mission_ember/encounter.lua

@@ -0,0 +1,86 @@
+-- One encounter's progress lives in transactional mission state, never module-local tables.
+local Encounter = {}
+Encounter.__index = Encounter
+
+function Encounter.new(tag, squads, cleared)
+    assert(#squads > 0 and #squads <= 30, "encounter needs 1..30 squads")
+    return setmetatable({tag = tag, squads = squads, cleared = cleared,
+                         full = (1 << #squads) - 1}, Encounter)
+end
+
+function Encounter:phase(state)
+    return state:variable(self.tag .. ".phase") or 0
+end
+
+function Encounter:enter(context, state)
+    if self:phase(state) ~= 0 then
+        return false
+    end
+    -- Resolve everything before staging the first placement. Missing SDK rows are errors.
+    local handles = {}
+    for index, squad in ipairs(self.squads) do
+        handles[index] = context:squad(squad.id)
+        local count = 0
+        for _, value in ipairs(handles[index].default_counts) do
+            assert(value >= 0, "squad has unresolved authored counts: " .. squad.name)
+            count = count + value
+        end
+        assert(count > 0, "encounter squad has no authored members: " .. squad.name)
+    end
+    context:set_variable(self.tag .. ".seen", 0)
+    context:set_variable(self.tag .. ".alive", 0)
+    context:set_variable(self.tag .. ".phase", 1)
+    for _, handle in ipairs(handles) do
+        handle:place{mode = context.sdk.squad_modes.replace}
+    end
+    return true
+end
+
+function Encounter:on_squad_state(context, state, event)
+    if self:phase(state) ~= 1 then
+        return
+    end
+    local bit
+    for index, squad in ipairs(self.squads) do
+        local slot = context:slot(squad.sensor)
+        if event.registry_key == slot.registry_key and event.slot_type == slot.slot_type
+            and event.slot_index == slot.slot_index then
+            bit = 1 << (index - 1)
+            break
+        end
+    end
+    if bit == nil or event.alive_count == nil then
+        return
+    end
+    local seen = state:variable(self.tag .. ".seen") or 0
+    local alive = state:variable(self.tag .. ".alive") or 0
+    if event.removal_flag then
+        context:set_variable(self.tag .. ".seen", seen & ~bit)
+        context:set_variable(self.tag .. ".alive", alive & ~bit)
+        return
+    end
+    -- Initial empty Sense and an unloaded squad are not evidence of a combat clear.
+    if event.alive_count > 0 or (event.previous_alive_count or 0) > 0 then
+        seen = seen | bit
+    end
+    if event.alive_count > 0 then
+        alive = alive | bit
+    else
+        alive = alive & ~bit
+    end
+    context:set_variable(self.tag .. ".seen", seen)
+    context:set_variable(self.tag .. ".alive", alive)
+    if seen == self.full and alive == 0 then
+        context:set_variable(self.tag .. ".phase", 2)
+        self.cleared(context, state)
+    end
+end
+
+-- Called only by a confirmed checkpoint reset, not by region transit or script reload.
+function Encounter:reset(context)
+    context:clear_variable(self.tag .. ".phase")
+    context:clear_variable(self.tag .. ".seen")
+    context:clear_variable(self.tag .. ".alive")
+end
+
+return Encounter

+ 82 - 0
scripts/mission_ember/landing.lua

@@ -0,0 +1,82 @@
+-- First combat slice. Wave composition and in-game sequencing still require a live playtest.
+local Encounter = require("mission_ember.encounter")
+
+local NAMES = {
+    "LANDING_MERCURY_ANCHOR_SQUAD",
+    "LANDING_MERCURY_SUPPORT_A_SQUAD",
+    "LANDING_MERCURY_SUPPORT_B_SQUAD",
+    "LANDING_MERCURY_SUPPORT_C_SQUAD",
+    "LANDING_MERCURY_RANGED_A_SQUAD",
+    "LANDING_MERCURY_RANGED_B_SQUAD",
+    "LANDING_MERCURY_BONUS_SUPPORT_A_SQUAD",
+    "LANDING_MERCURY_BONUS_SUPPORT_B_SQUAD",
+    "LANDING_MERCURY_BONUS_SUPPORT_C_SQUAD",
+    "LANDING_MERCURY_BONUS_SUPPORT_D_SQUAD",
+}
+
+local BRIDGE_DEVICES = {
+    "POWERHOUSE_BRIDGE_ARM_MERCURY_DEVICE",
+    "POWERHOUSE_BRIDGE_GEAR_MERCURY_BOTTOM_DEVICE",
+    "POWERHOUSE_BRIDGE_GEAR_MERCURY_TOP_DEVICE",
+    "POWERHOUSE_BRIDGE_ARM_SUN_DEVICE",
+    "POWERHOUSE_BRIDGE_GEAR_SUN_BOTTOM_DEVICE",
+    "POWERHOUSE_BRIDGE_GEAR_SUN_TOP_DEVICE",
+}
+
+local function required(value, name)
+    assert(value ~= nil, "mission_ember: missing SDK binding " .. name
+           .. "; regenerate the SDK with the scenario-scoped squad linker")
+    return value
+end
+
+return function(mission)
+    local squads = {}
+    for index, name in ipairs(NAMES) do
+        squads[index] = {
+            name = name,
+            id = required(mission.Squad[name], "Squad." .. name),
+            sensor = required(mission.Slot[name], "Slot." .. name),
+        }
+    end
+    local entry = required(mission.states.STATE_80B3C09E_0008_0000_80B3C09C, "powerhouse state")
+    local bridge = {}
+    for index, name in ipairs(BRIDGE_DEVICES) do
+        bridge[index] = required(mission.Slot[name], "Slot." .. name)
+    end
+    local bridge_objective = required(mission.Slot.EMBER_POWERHOUSE_BRIDGE_OBJECTIVE,
+                                      "bridge objective")
+    local directive = required(mission.Slot.M_DIRECTIVE_SENSOR_80B3C90A, "directive sensor")
+    local clear = required(mission.Directive.FIND_AND_DISABLE_THE_ALMIGHTY_S_WEAPONS_A70DA4A6,
+                           "landing clear directive")
+    local controls = required(mission.Directive.FIND_AND_DISABLE_THE_ALMIGHTY_S_WEAPONS_2700C0C5,
+                              "bridge controls directive")
+    local encounter = Encounter.new("ember.landing", squads, function(context)
+        context:slot(directive):set_directive{directive = controls}
+        -- The bridge must wait for its authored interaction. Combat clear does not open it.
+    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
+            encounter:enter(context, state)
+            context:slot(directive):set_directive{directive = clear}
+        end,
+        on_squad_state = function(context, state, event)
+            encounter:on_squad_state(context, state, event)
+        end,
+    }
+end

+ 28 - 0
tests/mission_arena_test.cpp

@@ -0,0 +1,28 @@
+// Run against the production allocator with Release optimization enabled.
+#include <cstdio>
+#include <cstring>
+#include "server/activity/mission/mission_script_vm_internal.h"
+
+int main() {
+    using namespace sunrise::server::activity::mission::lua_vm;
+    detail::Arena arena{};
+    for (int cycle = 0; cycle < 3; ++cycle) {
+        if (!detail::arena_initialize(arena) || arena.capacity != kArenaByteCapacity) return 1;
+        void* small = detail::arena_allocate(&arena, nullptr, 0, 1024);
+        if (!small) return 2;
+        std::memset(small, 0x5A, 1024);
+        void* grown = detail::arena_allocate(&arena, small, 1024, 8192);
+        if (!grown) return 3;
+        for (int i = 0; i < 1024; ++i) {
+            if (static_cast<unsigned char*>(grown)[i] != 0x5A) return 4;
+        }
+        if (detail::arena_allocate(&arena, nullptr, 0, kArenaByteCapacity) != nullptr) return 5;
+        detail::arena_allocate(&arena, grown, 8192, 0);
+        if (arena.used != 0) return 6;
+        void* large = detail::arena_allocate(&arena, nullptr, 0, kArenaByteCapacity / 2);
+        if (!large) return 7;
+        detail::arena_release(arena);
+        if (arena.bytes || arena.capacity || arena.used || arena.initialized) return 8;
+    }
+    std::puts("production arena allocation, growth, bounds, coalescing and reopen passed");
+}

+ 95 - 0
tests/mission_ember_controller_test.lua

@@ -0,0 +1,95 @@
+package.path = "scripts/?.lua;" .. package.path
+local names = {
+    "LANDING_MERCURY_ANCHOR_SQUAD", "LANDING_MERCURY_SUPPORT_A_SQUAD",
+    "LANDING_MERCURY_SUPPORT_B_SQUAD", "LANDING_MERCURY_SUPPORT_C_SQUAD",
+    "LANDING_MERCURY_RANGED_A_SQUAD", "LANDING_MERCURY_RANGED_B_SQUAD",
+    "LANDING_MERCURY_BONUS_SUPPORT_A_SQUAD", "LANDING_MERCURY_BONUS_SUPPORT_B_SQUAD",
+    "LANDING_MERCURY_BONUS_SUPPORT_C_SQUAD", "LANDING_MERCURY_BONUS_SUPPORT_D_SQUAD",
+}
+local mission = {
+    Squad = {}, Slot = {M_DIRECTIVE_SENSOR_80B3C90A = "directive"},
+    states = {STATE_80B3C09E_0008_0000_80B3C09C = {region_index = 64}},
+    Directive = {
+        FIND_AND_DISABLE_THE_ALMIGHTY_S_WEAPONS_A70DA4A6 = "clear",
+        FIND_AND_DISABLE_THE_ALMIGHTY_S_WEAPONS_2700C0C5 = "controls",
+    },
+}
+for _, name in ipairs(names) do
+    mission.Squad[name] = name
+    mission.Slot[name] = name
+end
+local devices = {
+    "POWERHOUSE_BRIDGE_ARM_MERCURY_DEVICE", "POWERHOUSE_BRIDGE_GEAR_MERCURY_BOTTOM_DEVICE",
+    "POWERHOUSE_BRIDGE_GEAR_MERCURY_TOP_DEVICE", "POWERHOUSE_BRIDGE_ARM_SUN_DEVICE",
+    "POWERHOUSE_BRIDGE_GEAR_SUN_BOTTOM_DEVICE", "POWERHOUSE_BRIDGE_GEAR_SUN_TOP_DEVICE",
+}
+for _, name in ipairs(devices) do mission.Slot[name] = name end
+mission.Slot.EMBER_POWERHOUSE_BRIDGE_OBJECTIVE = "bridge_objective"
+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")
+local vars, directives, placements, seeds = {}, {}, 0, 0
+local state = {variable = function(_, key) return vars[key] end}
+local closed, resets = {}, 0
+local context = {sdk = {squad_modes = {replace = "replace"}, device_transitions = {close = "close"}}}
+function context:set_variable(key, value) vars[key] = value end
+function context:clear_variable(key) vars[key] = nil end
+function context:select_state(entry)
+    assert(entry.region_index == 64)
+    seeds = seeds + 1
+end
+function context:squad(name)
+    assert(mission.Squad[name])
+    return {default_counts = {1}, place = function() placements = placements + 1 end}
+end
+function context:slot(name)
+    for _, device in ipairs(devices) do
+        if name == device then
+            return {transition = function(_, args)
+                assert(args.transition == "close" and args.snap == true)
+                closed[#closed + 1] = name
+            end}
+        end
+    end
+    if name == "bridge_objective" then
+        return {reset_objectives = function() resets = resets + 1 end}
+    end
+    if name == "directive" then
+        return {set_directive = function(_, args) directives[#directives + 1] = args.directive end}
+    end
+    for index, value in ipairs(names) do
+        if name == value then return {registry_key = 7, slot_type = 1, slot_index = index} end
+    end
+    error("unknown slot " .. name)
+end
+local function region(index)
+    program.on_event_client_state_changed(context, state, {current_region_index = index})
+end
+local function cleared()
+    for index = 1, #names do
+        program.on_event_squad_state(context, state, {
+            registry_key = 7, slot_type = 1, slot_index = index,
+            alive_count = 0, previous_alive_count = 1,
+        })
+    end
+end
+program.on_start(context, state)
+assert(#closed == 6 and resets == 1 and placements == 0)
+region(40)
+assert(seeds == 0 and placements == 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)
+cleared(); cleared()
+assert(#directives == 2 and directives[2] == "controls")
+package.loaded.mission_ember = nil
+program = require("mission_ember")
+region(64); cleared()
+assert(seeds == 0 and placements == #names and #directives == 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")

+ 83 - 0
tests/mission_ember_encounter_test.lua

@@ -0,0 +1,83 @@
+package.path = "scripts/?.lua;" .. package.path
+local Encounter = require("mission_ember.encounter")
+
+local function fixture()
+    local vars, calls = {}, {placed = 0, cleared = 0}
+    local slots = {
+        a = {registry_key = 5, slot_type = 1, slot_index = 0},
+        b = {registry_key = 6, slot_type = 1, slot_index = 0},
+    }
+    local context = {sdk = {squad_modes = {replace = "replace"}}}
+    local state = {variable = function(_, key) return vars[key] end}
+    function context:set_variable(key, value) vars[key] = value end
+    function context:clear_variable(key) vars[key] = nil end
+    function context:slot(id) return assert(slots[id]) end
+    function context:squad(id)
+        assert(slots[id])
+        return {default_counts = {1}, place = function(_, args)
+            assert(args.mode == "replace")
+            calls.placed = calls.placed + 1
+        end}
+    end
+    local function make()
+        return Encounter.new("test", {{name = "a", id = "a", sensor = "a"},
+                                      {name = "b", id = "b", sensor = "b"}},
+            function() calls.cleared = calls.cleared + 1 end)
+    end
+    local encounter = make()
+    local function event(id, count, previous, removal)
+        local slot = slots[id]
+        encounter:on_squad_state(context, state, {
+            registry_key = slot.registry_key, slot_type = slot.slot_type,
+            slot_index = slot.slot_index, alive_count = count,
+            previous_alive_count = previous, removal_flag = removal,
+        })
+    end
+    return context, state, encounter, calls, event, make
+end
+
+do
+    local context, state, encounter, calls, event = fixture()
+    assert(encounter:enter(context, state))
+    assert(not encounter:enter(context, state))
+    assert(calls.placed == 2)
+    event("a", 0, 0); event("b", 0, 0)
+    assert(calls.cleared == 0)
+    event("a", 1, 0); event("b", 1, 0)
+    event("a", 0, 1); event("a", 0, 1)
+    assert(calls.cleared == 0)
+    event("b", 0, 1, true)
+    assert(calls.cleared == 0)
+    event("b", 0, 0)
+    assert(calls.cleared == 0)
+    event("b", 1, 0)
+    event("b", 0, 1)
+    assert(calls.cleared == 1 and encounter:phase(state) == 2)
+    event("b", 0, 1)
+    assert(calls.cleared == 1)
+    encounter:reset(context)
+    assert(encounter:enter(context, state) and calls.placed == 4)
+    event("a", 0, 0); event("b", 0, 0)
+    assert(calls.cleared == 1)
+end
+
+do
+    local context, state, encounter, calls, event, make = fixture()
+    encounter:enter(context, state)
+    event("a", 1, 0)
+    local reloaded = make()
+    assert(not reloaded:enter(context, state) and calls.placed == 2)
+    -- A death edge with a positive prior count proves a squad existed even if its spawn edge
+    -- was outside the callback history. Both squads still have to be accounted for.
+    event("a", 0, 1); event("b", 0, 1)
+    assert(calls.cleared == 1)
+end
+
+do
+    local context, state, encounter, calls = fixture()
+    function context:squad() error("SDK binding is not runnable") end
+    assert(not pcall(function() encounter:enter(context, state) end))
+    assert(calls.placed == 0 and encounter:phase(state) == 0)
+end
+
+print("encounter progression, replay, reload, removal and SDK refusal checks passed")

+ 119 - 0
tests/squad_reference_scope_test.cpp

@@ -0,0 +1,119 @@
+// Linux regression harness for the package-reference resolver. All identities are synthetic.
+// Include the implementation to exercise its private resolver without widening the public API.
+#include <cassert>
+#include <iostream>
+#include <openssl/sha.h>
+
+#include "../Sunrise/src/client/content/activity/activity_sdk_squad_graph_edges.cpp"
+
+namespace sunrise::middleware::crypto::sha256 {
+bool hash(std::span<const std::byte> input, Digest& output) noexcept {
+    return SHA256(reinterpret_cast<const unsigned char*>(input.data()),
+                  input.size(),
+                  reinterpret_cast<unsigned char*>(output.data()))
+           != nullptr;
+}
+} // namespace sunrise::middleware::crypto::sha256
+
+namespace squad = sunrise::client::content::activity::sdk_generation::squad_inventory;
+namespace topology = sunrise::client::content::activity::sdk_generation::topology_inventory;
+namespace detail = squad::detail;
+namespace tables = sunrise::middleware::content::packages::tables;
+
+struct Fixture {
+    topology::Snapshot topology;
+    squad::GraphSnapshot graph;
+    std::unordered_map<detail::TargetKey, std::vector<std::uint32_t>, detail::TargetKeyHash>
+        targets;
+    std::unordered_map<std::uint32_t, std::uint32_t> rules;
+    std::vector<std::vector<std::uint32_t>> scenarios{{0}, {1}};
+
+    Fixture() {
+        topology.objects.resize(2);
+        topology.slots.resize(3);
+        topology.slots[0].objectIndex = 0;
+        topology.slots[0].slotType = 1;
+        for (unsigned i = 1; i < 3; ++i) {
+            topology.slots[i].objectIndex = i - 1;
+            topology.slots[i].slotType = tables::kAuthoredSquadRuleSlotType;
+        }
+        graph.spawners.resize(1);
+        graph.spawners[0].sourceDescriptorStatus = squad::SourceDescriptorStatus::exact;
+        graph.spawners[0].sourceDescriptorRow = 0;
+        graph.descriptors.resize(3);
+        graph.descriptors[0].objectIndex = 0;
+        graph.descriptors[0].slotIndex = 0;
+        for (unsigned i = 1; i < 3; ++i) {
+            auto& descriptor = graph.descriptors[i];
+            descriptor.objectIndex = i - 1;
+            descriptor.slotIndex = i;
+            descriptor.configTag = 100 + i;
+            descriptor.componentClass = tables::kAuthoredSquadRulePrimaryClass;
+            rules.emplace(descriptor.configTag, i - 1);
+        }
+        targets[{123, tables::kAuthoredSquadRuleSlotType, 7}] = {1, 2};
+    }
+
+    squad::ReferenceResolutionStatus resolve(
+        std::uint64_t raw = 123ULL
+                            | (static_cast<std::uint64_t>(tables::kAuthoredSquadRuleSlotType) << 32)
+                            | (7ULL << 48)) {
+        std::map<detail::TargetGroup, detail::ExactTarget> exact;
+        squad::ReferenceResolutionStatus status{};
+        assert(detail::resolve_reference(
+            topology, graph, 0, 0, raw, targets, rules, scenarios, exact, status));
+        return status;
+    }
+};
+
+int main() {
+    using Status = squad::ReferenceResolutionStatus;
+    {
+        Fixture f;
+        assert(f.resolve() == Status::exact);
+        assert(f.graph.references[0].resolvedObjectRow == 0);
+        assert(f.graph.referenceDescriptors.size() == 1);
+        assert(f.graph.references[0].candidateDescriptors.count == 1);
+        assert(f.graph.referenceDescriptors[0].resolvedTarget);
+    }
+    {
+        Fixture f;
+        f.graph.descriptors[2].componentClass = 0;
+        assert(f.resolve()
+               == Status::exact); // An unrelated scenario's wrong type cannot poison it.
+    }
+    {
+        Fixture f;
+        f.scenarios[1] = {0};
+        assert(f.resolve() == Status::targetAmbiguous); // Keep real local ambiguity.
+    }
+    {
+        Fixture f;
+        f.scenarios[1] = {0};
+        f.graph.descriptors[2].componentClass = 0;
+        assert(f.resolve() == Status::targetDescriptorMismatch);
+    }
+    {
+        Fixture f;
+        f.targets.begin()->second = {2};
+        assert(f.resolve() == Status::targetMissing); // No fallback to the arcade object.
+        assert(f.graph.referenceDescriptors.empty());
+    }
+    {
+        Fixture f;
+        f.graph.spawners[0].sourceDescriptorStatus = squad::SourceDescriptorStatus::ambiguous;
+        assert(f.resolve() == Status::sourceDescriptorAmbiguous);
+    }
+    {
+        Fixture f;
+        assert(f.resolve(~0ULL) == Status::invalidEncoding);
+    }
+    {
+        Fixture f;
+        f.scenarios[0] = {0, 2};
+        f.scenarios[1] = {1, 2};
+        assert(f.resolve()
+               == Status::targetAmbiguous); // Shared objects retain overlapping contexts.
+    }
+    std::cout << "8 squad reference scope regressions passed\n";
+}

+ 84 - 0
tests/test_inspect_mission_sdk.py

@@ -0,0 +1,84 @@
+import hashlib
+from pathlib import Path
+import struct
+import sys
+import tempfile
+import unittest
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools"))
+from inspect_mission_sdk import Pack, SECTIONS, inspect_shard
+
+
+def empty_pack():
+    data = bytearray(848)
+    data[:8] = b"SRSDKP01"
+    struct.pack_into("<IIQ", data, 8, 37, 848, len(data))
+    struct.pack_into("<I", data, 152, 43)
+    data[24:56] = hashlib.sha256(b"").digest()
+    for index, stride in SECTIONS.values():
+        struct.pack_into("<QII", data, 160 + index * 16, 848, 0, stride)
+    return data
+
+
+class InspectionTests(unittest.TestCase):
+    def setUp(self):
+        self.directory = tempfile.TemporaryDirectory()
+        self.addCleanup(self.directory.cleanup)
+        self.path = Path(self.directory.name) / "fixture.pack"
+
+    def open(self, data):
+        self.path.write_bytes(data)
+        return Pack(self.path)
+
+    def test_absent_scenario_does_not_match_another(self):
+        pack = self.open(empty_pack())
+        try:
+            with self.assertRaisesRegex(ValueError, "resolve exactly once"):
+                pack.inspect(0x80800011)
+        finally:
+            pack.close()
+
+    def test_version_and_truncation(self):
+        data = empty_pack()
+        struct.pack_into("<I", data, 8, 36)
+        with self.assertRaisesRegex(ValueError, "unsupported"):
+            self.open(data)
+        with self.assertRaises(ValueError):
+            self.open(empty_pack()[:-1])
+
+    def test_payload_corruption(self):
+        data = empty_pack() + b"payload"
+        struct.pack_into("<Q", data, 16, len(data))
+        with self.assertRaisesRegex(ValueError, "checksum"):
+            self.open(data)
+
+    def test_section_outside_file(self):
+        data = empty_pack()
+        struct.pack_into("<QII", data, 160 + 2 * 16, 848, 1, 48)
+        with self.assertRaisesRegex(ValueError, "bounds"):
+            self.open(data)
+
+    def test_wrong_row_stride(self):
+        data = empty_pack()
+        struct.pack_into("<I", data, 160 + 2 * 16 + 12, 44)
+        with self.assertRaisesRegex(ValueError, "stride"):
+            self.open(data)
+
+    def test_shard_identity_and_payload(self):
+        data = bytearray(664)
+        data[:8] = b"SRGWSHRD"
+        struct.pack_into("<IIQII", data, 8, 13, 664, len(data), 0x80800011, 35)
+        data[72:104] = hashlib.sha256(b"").digest()
+        self.path.write_bytes(data)
+        result = inspect_shard(self.path, 0x80800011)
+        self.assertEqual(sum(result["authored_squad_context_counts"].values()), 0)
+        with self.assertRaisesRegex(ValueError, "mismatched"):
+            inspect_shard(self.path, 0x80800012)
+        data[72] ^= 1
+        self.path.write_bytes(data)
+        with self.assertRaisesRegex(ValueError, "checksum"):
+            inspect_shard(self.path, 0x80800011)
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 189 - 0
tools/inspect_mission_sdk.py

@@ -0,0 +1,189 @@
+#!/usr/bin/env python3
+"""Inspect installed SDK topology without copying game data into the source tree.
+
+Layouts come from Sunrise/src/state/activity_sdk/format.h (version 37).
+The JSON report is local generated evidence, not a distributable mission asset.
+"""
+
+import argparse
+import collections
+import hashlib
+import json
+import mmap
+from pathlib import Path
+import struct
+
+
+SECTIONS = {
+    "strings": (0, 1), "scenarios": (2, 48), "bubbles": (3, 40),
+    "states": (4, 64), "objects": (5, 52), "occurrences": (6, 56),
+    "slots": (7, 80), "actors": (12, 68), "squads": (16, 52), "members": (17, 44),
+}
+SQUAD_FLAGS = {
+    1: "source_descriptor_exact", 2: "spawner_rule_edge_exact",
+    4: "scenario_occurrence_exact", 8: "all_points_exact",
+    16: "member_count_valid", 32: "candidate_counts_invariant_complete",
+}
+
+
+class Pack:
+    def __init__(self, path):
+        with open(path, "rb") as source:
+            self.data = mmap.mmap(source.fileno(), 0, access=mmap.ACCESS_READ)
+        try:
+            self.validate()
+        except Exception:
+            self.data.close()
+            raise
+
+    def validate(self):
+        data = self.data
+        if len(data) < 848 or data[:8] != b"SRSDKP01":
+            raise ValueError("not a runtime activity SDK pack")
+        version, header, size = struct.unpack_from("<IIQ", data, 8)
+        if version != 37 or header != 848 or size != len(data):
+            raise ValueError("unsupported SDK version/header or truncated file")
+        if struct.unpack_from("<I", data, 152)[0] != 43:
+            raise ValueError("unexpected SDK section count")
+        payload = memoryview(data)[header:]
+        digest = hashlib.sha256(payload).digest()
+        payload.release()
+        if digest != data[24:56]:
+            raise ValueError("SDK payload checksum mismatch")
+        self.sections = [struct.unpack_from("<QII", data, 160 + i * 16)
+                         for i in range(43)]
+        for offset, count, stride in self.sections:
+            if count and (offset < header or stride == 0 or offset + count * stride > size):
+                raise ValueError("SDK section exceeds file bounds")
+        for name, (index, expected) in SECTIONS.items():
+            if self.sections[index][2] != expected:
+                raise ValueError(f"unexpected {name} stride")
+        self.build_id = "sha256:" + data[56:88].hex()
+
+    def close(self):
+        self.data.close()
+
+    def rows(self, name):
+        index, _ = SECTIONS[name]
+        offset, count, stride = self.sections[index]
+        if stride % 4:
+            raise ValueError("section is not composed of u32 fields")
+        return [struct.unpack_from("<" + "I" * (stride // 4), self.data, offset + i * stride)
+                for i in range(count)]
+
+    def text(self, row, index=0):
+        start, length = row[index:index + 2]
+        bank, count, _ = self.sections[0]
+        if start + length > count:
+            raise ValueError("string exceeds SDK string bank")
+        return self.data[bank + start:bank + start + length].decode("utf-8")
+
+    def inspect(self, tag):
+        scenarios = self.rows("scenarios")
+        matches = [i for i, row in enumerate(scenarios) if row[0] == tag]
+        if len(matches) != 1:
+            raise ValueError("scenario tag must resolve exactly once")
+        scenario_index = matches[0]
+        bubbles, states = self.rows("bubbles"), self.rows("states")
+        objects, slots = self.rows("objects"), self.rows("slots")
+        occurrences = [row for row in self.rows("occurrences") if row[8] == scenario_index]
+        squad_rows = [row for row in self.rows("squads") if row[2] == scenario_index]
+        members, actors = self.rows("members"), self.rows("actors")
+        squads_by_slot = collections.defaultdict(list)
+        for row in squad_rows:
+            selected = members[row[9]:row[9] + row[10]]
+            definition_ready = row[7] & 63 == 63 and 1 <= row[10] <= 15
+            profiles = set()
+            actors_exact = len(selected) == row[10]
+            for member in selected:
+                if member[10] >= 0x80000000:
+                    actors_exact = False
+                elif member[10] > 0:
+                    if not member[6] & 1 or member[5] >= len(actors):
+                        actors_exact = False
+                    else:
+                        profiles.add(actors[member[5]][-1])
+            profile_ready = actors_exact and len(profiles) == 1
+            squads_by_slot[row[4]].append({
+                "id": self.text(row), "flags": row[7], "member_count": row[10],
+                "anchor_count": row[12], "occurrence_index": row[8],
+                "spawner_tag": f"{row[5]:08x}", "rule_tag": f"{row[6]:08x}",
+                "definition_ready": definition_ready, "spawn_profile_ready": profile_ready,
+                "runnable": definition_ready and profile_ready,
+                "missing_flags": [name for bit, name in SQUAD_FLAGS.items() if not row[7] & bit],
+            })
+        mission_objects = {row[11] for row in occurrences}
+        mission_slots = {i: row for i, row in enumerate(slots) if row[8] in mission_objects}
+        state_reports = []
+        for index, row in enumerate(states):
+            if row[6] != scenario_index:
+                continue
+            owned = {occ[11] for occ in occurrences if occ[10] == index}
+            entries = []
+            for slot_index, slot in mission_slots.items():
+                if slot[8] not in owned:
+                    continue
+                entries.append({"id": self.text(slot), "name": self.text(slot, 2),
+                                "type": slot[10], "object_tag": f"{objects[slot[8]][2]:08x}",
+                                "squads": squads_by_slot.get(slot_index, [])})
+            state_reports.append({
+                "id": self.text(row), "bubble": self.text(bubbles[row[7]], 2),
+                "region_index": row[10] + row[8], "map_bubble_index": row[11],
+                "registry_tag": f"{row[15]:08x}", "slots": entries,
+            })
+        squad_slots = {i: row for i, row in mission_slots.items() if row[10] == 1}
+        missing = [self.text(row) for i, row in squad_slots.items() if i not in squads_by_slot]
+        runnable = sum(q["runnable"] for group in squads_by_slot.values() for q in group)
+        return {
+            "scenario": f"{tag:08x}", "name": self.text(scenarios[scenario_index], 4),
+            "sdk_build_id": self.build_id,
+            "counts": {"states": len(state_reports), "slots": len(mission_slots),
+                       "squad_sensors": len(squad_slots), "squad_definitions": len(squad_rows),
+                       "runnable_squads": runnable, "sensors_without_squad_definition": len(missing)},
+            "readiness_scope": "static definition and default actor-profile gates; live leases, placement transport and AI require an in-game test",
+            "sensors_without_squad_definition": missing, "states": state_reports,
+        }
+
+
+def main():
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument("pack", type=Path, help="installed Sunrise/activity_sdk.pack")
+    parser.add_argument("--scenario", type=lambda value: int(value, 16), default=0x80B3C09E)
+    parser.add_argument("--output", type=Path, required=True, help="local generated JSON path")
+    parser.add_argument("--shard", type=Path, help="matching generated-world scenario pack")
+    args = parser.parse_args()
+    pack = Pack(args.pack)
+    try:
+        report = pack.inspect(args.scenario)
+    finally:
+        pack.close()
+    if args.shard:
+        report["world_shard"] = inspect_shard(args.shard, args.scenario)
+    args.output.parent.mkdir(parents=True, exist_ok=True)
+    args.output.write_text(json.dumps(report, indent=2) + "\n")
+    print(json.dumps(report["counts"], indent=2))
+
+
+def inspect_shard(path, scenario):
+    """Check v13 shard identity and report whether authored squad contexts were exported."""
+    data = Path(path).read_bytes()
+    if len(data) < 664 or data[:8] != b"SRGWSHRD":
+        raise ValueError("not a generated-world scenario shard")
+    version, header, size, tag, count = struct.unpack_from("<IIQII", data, 8)
+    if (version, header, size, tag, count) != (13, 664, len(data), scenario, 35):
+        raise ValueError("unsupported or mismatched scenario shard")
+    if hashlib.sha256(data[header:]).digest() != data[72:104]:
+        raise ValueError("scenario shard payload checksum mismatch")
+    sections = [struct.unpack_from("<QII", data, 104 + i * 16) for i in range(count)]
+    for offset, rows, stride in sections:
+        if rows and (offset < header or stride == 0 or offset + rows * stride > size):
+            raise ValueError("scenario shard section exceeds file bounds")
+    names = ("config_contexts", "placement_contexts", "point_contexts",
+             "point_placement_matches", "edge_contexts")
+    return {"source_fingerprint": data[40:72].hex(),
+            "authored_squad_context_counts": {name: sections[30 + i][1]
+                                              for i, name in enumerate(names)}}
+
+
+if __name__ == "__main__":
+    main()