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

Fix Ember movie resource kind and route authored escape events

Millie 3 дней назад
Родитель
Сommit
4874b2faa1

+ 8 - 0
Sunrise/src/client/hooks/ember_movies/readiness_rules.h

@@ -1,6 +1,14 @@
 #pragma once
 #include <cstdint>
+#include <array>
 namespace sunrise::client::hooks::ember_movies {
+// Native 426920 selects 1 for ordinary tags, 2 only for type_info & F000 == 2000.
+// The four movie metadata records below are all ordinary tags (100A/103B/1019).
+constexpr std::uint32_t movie_resource_kind = 1;
+constexpr std::array<std::uint32_t,4> movie_metadata(std::uint32_t asset) noexcept {
+    if (asset!=0x80BCA001U && asset!=0x80BCA003U) return {};
+    return {asset,asset-1,asset==0x80BCA001U ? 0x80B9EB33U : 0x80B9EB34U,0x80BCA032U};
+}
 constexpr bool movie_resources_ready(int rootState,std::uint32_t asset,bool wrapperResident,
     std::uint32_t header,bool headerResident,std::uint32_t media) noexcept {
     return rootState==2 && (asset==0x80BCA001U || asset==0x80BCA003U)

+ 13 - 4
Sunrise/src/client/hooks/ember_movies/resources.cpp

@@ -23,7 +23,8 @@ void* call(std::byte* code,unsigned offset) {
     return code+offset+5+read<std::int32_t>(reinterpret_cast<std::uintptr_t>(code+offset+1));
 }
 bool resolve_native() {
-    // B46E10: the native startup loader creates a root, adds {2, TagHash}, submits it.
+    // B46E10 supplies the root lifecycle API. Its startup assets use kind 2;
+    // movies use kind 1, as selected from their package type_info by native 426920.
     constexpr auto loadSig=signature<signature_length("40 53 56 57 48 81 EC 50 01 00 00 48 8B 05 ? ? ? ? 48 33 C4 48 89 84 24 40 01 00 00 48 63 DA 48 8B F1")>(
         "40 53 56 57 48 81 EC 50 01 00 00 48 8B 05 ? ? ? ? 48 33 C4 48 89 84 24 40 01 00 00 48 63 DA 48 8B F1");
     // B44020: inspect the completed root, then release it; no global I/O drain needed.
@@ -78,10 +79,15 @@ bool MovieResource::begin(std::uint32_t asset) noexcept {
     create(mgr,&root_,8,2,0,"mission_ember_movie");
     if (!held()) return false;
     auto* root=blob(root_);if (!root) return false;
-    const std::uint32_t request[]{2,asset};
-    add(root,request);submit(mgr,root_);asset_=asset;
+    // Explicitly retain the small metadata records dereferenced by 41A810 and
+    // the subtitle reader. Native playback streams the 640MB video entry itself.
+    for (const auto tag : movie_metadata(asset)) {
+        const std::uint32_t request[]{movie_resource_kind,tag};
+        add(root,request);
+    }
+    submit(mgr,root_);asset_=asset;
     core::log::writef(core::log::Channel::client,core::log::Level::info,
-        "ev=ember_movie result=resource_requested asset=%08X root=%08X",asset_,root_);
+        "ev=ember_movie result=resource_requested asset=%08X root=%08X kind=1 metadata=4",asset_,root_);
     return true;
 }
 int MovieResource::state() const noexcept {
@@ -97,6 +103,9 @@ bool MovieResource::ready() const noexcept {
         const auto header=read<std::uint32_t>(reinterpret_cast<std::uintptr_t>(wrapper)+8);
         if (header!=asset_-1) return false;
         auto* info=blob(header,0x80808499U);
+        const auto metadata=movie_metadata(asset_);
+        if (read<std::uint32_t>(reinterpret_cast<std::uintptr_t>(wrapper)+12)!=metadata[2]
+            || !blob(metadata[2],0x80809A88U) || !blob(metadata[3],0x80806B8FU)) return false;
         return movie_resources_ready(2,asset_,true,header,info!=nullptr,
             info ? read<std::uint32_t>(reinterpret_cast<std::uintptr_t>(info)+0x18) : 0xFFFFFFFFU);
     } __except(EXCEPTION_EXECUTE_HANDLER) { return false; }

+ 51 - 0
Sunrise/src/middleware/bap/activity_message/scene_events_auth.h

@@ -0,0 +1,51 @@
+#pragma once
+#include <array>
+#include <cstdint>
+#include <span>
+#include "../../encoding/bit_reader.h"
+#include "../../encoding/bit_writer.h"
+namespace sunrise::middleware::bap::activity_message::scene_events {
+inline constexpr std::uint32_t kSchema=0x8080626B;
+inline constexpr std::size_t kMaximumEvents=32, kMaximumBytes=138;
+// Full type-43 Auth: generation, clear, dependency count, scalar, event count, keys.
+// Keep generation unchanged when appending events: changing it restarts the scene.
+inline bool encode(std::int32_t generation, std::span<const std::uint32_t> events,
+                   std::span<std::byte> output, std::size_t& bytes, std::size_t& bits) noexcept {
+    bytes=bits=0;
+    if (generation<=0 || events.size()>kMaximumEvents || output.size()<(74+32*events.size()+7)/8)
+        return false;
+    for (std::size_t i=0;i<events.size();++i) {
+        if (!events[i] || events[i]==0xFFFFFFFFU) return false;
+        for (std::size_t j=0;j<i;++j) if (events[i]==events[j]) return false;
+    }
+    encoding::bits::Writer w(output);
+    if (!w.write(static_cast<std::uint32_t>(generation)+0x80000000U,32)
+        || !w.write(0,1) || !w.write(0,4) || !w.write(0,31) || !w.write(events.size(),6)) return false;
+    for (const auto event : events) if (!w.write(event,32)) return false;
+    bits=w.bit_count(); return w.finish(bytes);
+}
+
+// Admit only the event-only subset above; dependencies, clear commands and scalar
+// inputs require their own checked API. Reject malformed lengths and nonzero padding.
+inline bool validate(std::span<const std::byte> input, std::size_t bits) noexcept {
+    if (bits<74 || bits>74+32*kMaximumEvents || input.size()!=(bits+7)/8) return false;
+    encoding::bits::Reader r(input);
+    std::uint64_t generation{}, value{}, count{};
+    if (!r.read(32,generation) || generation<=0x80000000U
+        || !r.read(1,value) || value!=0 || !r.read(4,value) || value!=0
+        || !r.read(31,value) || value!=0 || !r.read(6,count)
+        || count>kMaximumEvents || bits!=74+32*count) return false;
+    std::array<std::uint32_t,kMaximumEvents> events{};
+    for (std::size_t i=0;i<count;++i) {
+        if (!r.read(32,value)) return false;
+        events[i]=static_cast<std::uint32_t>(value);
+    }
+    std::array<std::byte,kMaximumBytes> canonical{};
+    std::size_t written{}, expectedBits{};
+    if (!encode(static_cast<std::int32_t>(generation-0x80000000U),
+                std::span(events).first(count),canonical,written,expectedBits)
+        || written!=input.size() || expectedBits!=bits) return false;
+    for (std::size_t i=0;i<written;++i) if (canonical[i]!=input[i]) return false;
+    return true;
+}
+}

+ 5 - 1
Sunrise/src/server/activity/activity_sdk_device_runtime.cpp

@@ -7,6 +7,7 @@
 #include "../../middleware/bap/activity_message/combatant_delivery_auth.h"
 #include "../../middleware/bap/activity_message/combatant_retire_auth.h"
 #include "../../middleware/bap/activity_message/ghost_link_auth.h"
+#include "../../middleware/bap/activity_message/scene_events_auth.h"
 #include "activity_sdk_device_runtime.h"
 #include "../../middleware/bap/activity_message/interactable_object_auth.h"
 
@@ -470,6 +471,9 @@ prepare_slot(const sdk::BoundView& view, std::uint32_t slotRow, PreparedDevice&
     namespace music = middleware::bap::activity_message::music_section;
     const bool musicSection = slotType == 11 && slot.componentClass == music::kClass
         && authSchema == music::kSchema && music::validate(body, bitCount);
+    namespace scene = middleware::bap::activity_message::scene_events;
+    const bool sceneEvents = slotType == 43 && slot.componentClass == 0x80806382U
+        && authSchema == scene::kSchema && scene::validate(body, bitCount);
     const bool damageMonitor = slotType == 20 && slot.componentClass == 0x80809560U
         && authSchema == 0x80809563U && bitCount == 87 && body.size() == 11;
     const bool occupancy = slotType == format::kOccupancySlotType
@@ -516,7 +520,7 @@ prepare_slot(const sdk::BoundView& view, std::uint32_t slotRow, PreparedDevice&
     const bool squadObjective = slotType == 1 && authSchema == objective::kSchema
         && slot.componentClass == format::kSquadComponentClass && objective::validate(body, bitCount);
     if (!musicSection && !objectFilter && !missionEffect && !damageMonitor && !darknessZone && !squadObjective && !occupancy && !directive && !engagement && !publicEvent && !performance && !combatant
-        && !ghostLink && !interactableObject) {
+        && !ghostLink && !interactableObject && !sceneEvents) {
         return Status::invalidBody;
     }
     return Status::ready;

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

@@ -8,6 +8,7 @@
 #include "../../../middleware/bap/activity_message/combatant_retire_auth.h"
 #include "../../../middleware/bap/activity_message/ghost_link_auth.h"
 #include "../../../middleware/bap/activity_message/interactable_object_auth.h"
+#include "../../../middleware/bap/activity_message/scene_events_auth.h"
 #include <algorithm>
 #include <array>
 #include <bit>
@@ -904,6 +905,34 @@ constexpr std::size_t kOccupancyAuthByteCount = 11;
     return queue_intent(state, frame, intent);
 }
 
+/** Publishes one scene generation and its cumulative authored event keys. */
+[[nodiscard]] int slot_set_scene_events(lua_State* state) {
+    namespace scene=middleware::bap::activity_message::scene_events;
+    const auto* handle=static_cast<const SlotHandle*>(luaL_checkudata(state,1,kSlotMetatable));
+    SlotDefinition slot{};
+    if (!current_slot(state,*handle,slot) || slot.slotType!=43 || slot.componentClass!=0x80806382U
+        || slot.authSchema!=scene::kSchema || (slot.flags&format::kSlotSchemaJoinExact)==0)
+        return luaL_error(state,"scene events require an exact type-43 scene");
+    static constexpr std::array<std::string_view,2> declared{"generation","events"};
+    refuse_unknown_arguments(state,declared);
+    lua_getfield(state,2,"generation"); const auto generation=luaL_checkinteger(state,-1);lua_pop(state,1);
+    if (generation<=0 || generation>0x7FFFFFFF) return luaL_error(state,"scene generation must be positive int32");
+    lua_getfield(state,2,"events");luaL_checktype(state,-1,LUA_TTABLE);
+    const auto count=lua_rawlen(state,-1);
+    if (count>scene::kMaximumEvents) return luaL_error(state,"scene event list exceeds 32 keys");
+    std::array<std::uint32_t,scene::kMaximumEvents> events{};
+    for (std::size_t i=0;i<count;++i) {
+        lua_rawgeti(state,-1,static_cast<lua_Integer>(i+1));const auto event=luaL_checkinteger(state,-1);lua_pop(state,1);
+        if (event<=0 || event>=0xFFFFFFFFLL) return luaL_error(state,"invalid scene event key");
+        events[i]=static_cast<std::uint32_t>(event);
+    }
+    lua_pop(state,1);
+    std::array<std::byte,scene::kMaximumBytes> body{};std::size_t bytes{},bits{};
+    if (!scene::encode(static_cast<std::int32_t>(generation),std::span(events).first(count),body,bytes,bits))
+        return luaL_error(state,"scene event keys must be unique");
+    return queue_slot_auth(state,slot,scene::kSchema,bits,std::span(body).first(bytes));
+}
+
 /** Lua `play_sequence` on a slot. Errors unless the slot is an exact type-5 sequence. */
 [[nodiscard]] int slot_play_sequence(lua_State* state) {
     const auto* const handle =
@@ -1157,6 +1186,8 @@ constexpr std::size_t kOccupancyAuthByteCount = 11;
         lua_pushcfunction(state, &slot_fire_trigger);
     } else if (key == "play_sequence") {
         lua_pushcfunction(state, &slot_play_sequence);
+    } else if (key == "set_scene_events") {
+        lua_pushcfunction(state, &slot_set_scene_events);
     } else if (key == "set_cinematic_active") {
         lua_pushcfunction(state, &slot_set_cinematic_active);
     } else if (key == "reset_objectives") {

+ 59 - 46
docs/mission-ember-final-corrections.md

@@ -1,69 +1,82 @@
-# 1AU: final playback, burn and surge correction
+# 1AU: movie loader, surge audio and escape explosions
 
-## Confirmed failure and plan
+## Current status
 
-The `4f5c706` playtest reached escape, queued STM at t=284067 and submitted it at t=284084. The decoder stayed at state 0. The deeper worker stack contains an exception record: `C0000005`, instruction `game+349D2C`, reading address `0x8`. Its callers are `41A810 -> 41CB60 -> 41D590` (movie resource lookup, prepare, manager service). This is an access violation during preparation, not an ordinary slow load and not the earlier world-retirement failure.
+The latest changes address the captured `bc3e912` movie-loader crash, couple the alarm request to the beam surge, and forward escape triggers into the authored explosion scene. Release compilation, 24 portable tests, all five Lua mission suites, native ABI/package checks, and scene schema/content checks pass. **Rendered ending playback, audible alignment and visible explosions still require a fresh game test.** Offline verification does not establish those outcomes.
 
-Read-only residency capture confirms the cause: movie tags `80BCA001/000/003/002` still contain `FEFE` free-list entries. The package is registered, but these entries were never requested. A package TagHash is already a valid runtime handle; the missing operation is **loading**, not converting the numeric hash. The package loading and handle guides under Sunrise-docs/refs document that distinction.
+## The freeze: two distinct failures
 
-Implement and verify the following:
+`4f5c706` called the movie player before requesting its assets. The decoder worker faulted at `game+349D2C`, reading address 8; movie wrappers and headers contained `FEFE` free-list entries. These TagHashes are valid runtime handles, but their contents were not resident.
 
-1. Use the native asynchronous resource-root producer to load each movie and its authored dependency graph. Require the completed root and matching resident wrapper/header before calling the player. Keep the resource request through playback, then release it without a global I/O drain.
-2. Keep STM -> CNN -> mission complete driven by real playback and completion observations. Retain Apex while the videos play, avoiding the independently broken world teardown. Failures must leave the mission incomplete and report a specific cause.
-3. Use one native sunburn effect attachment for the climb and escape. Change its filter at deposit; disable the separate damage-volume object. Preserve native damage timing, duplicate protection and detachment instead of applying an additional damage multiplier.
-4. Advance only the surge sequence request by four seconds. Keep the visual/mechanical clock at 14 seconds closed, 6 seconds surge, 10 seconds cooling.
-5. Build, run native ABI checks and mission regression suites, then install with backups and hashes while the game is closed. The user launches the game.
+`bc3e912` added asynchronous resource loading, but copied request kind **2** from startup loader `B46E10`. That kind is specific to shared/type-16 resources. The next captured worker exception was **C0000005 at `game+3374C6`, reading address 8**. Its request record at `8F267A0` contained movie `80BCA001`, unresolved shared-resource handle `FFFFFFFF`, and kind 2. The resource root was pending; acquire/play had not yet been called. This is the direct cause of this test's freeze, not cinematic authority waiting on a missing global seed.
 
-## Executed implementation
+Native `426920` derives the correct request kind from each package entry: kind 2 only when `(type_info & F000) == 2000`, otherwise kind 1. `4312D0` routes those kinds to different root lists. The installed movie records are ordinary tags:
 
-### Native movie resource lifetime
+| Asset | Class | type_info | Required kind |
+| --- | --- | --- | --- |
+| `80BCA001`, `80BCA003` movie wrappers | `80808495` | `100A` | 1 |
+| `80BCA000`, `80BCA002` movie headers | `80808499` | `103B` | 1 |
+| `80B9EB33`, `80B9EB34` subtitle metadata | `80809A88` | `1019` | 1 |
+| `80BCA032` shared movie metadata | `80806B8F` | `103B` | 1 |
 
-The bridge follows the existing startup loader `B46E10`:
+The correction creates one native asynchronous root and adds the selected wrapper, its header, its subtitle metadata and `80BCA032`, each using `{1, tag}`. It retains that root through playback. The large media entry is streamed by the native player, not loaded wholesale into the root.
 
-- `4294D0()` gets the resource manager.
-- `423EF0(manager, &root, 8, 2, 0, "mission_ember_movie")` creates an asynchronous root.
-- `4312D0(rootObject, {2, movieTag})` adds the authored resource.
-- `435AA0(manager, rootHandle)` submits the request.
-- `42C650(rootObject)` observes native root state: 1 pending, 2 completed, 3 failed.
-- The wrapper must be class `80808495`; its +8 child must be the exact corresponding class `80808499` movie header; the media reference must be present.
-- Only then run the existing acquire/play pair `41A3C0 / 41CD20`.
-- After the native movie player releases its reference, `425310` disposes the completed request. Pending requests are polled, never synchronously drained on the frame. Failed requests do not award completion.
+Playback requires native root state 2, matching resident wrapper/header classes, the expected subtitle reference and resident metadata, and a present media reference. Only then call the original acquire/play pair. Both native completion and Escape stopping must finish before releasing the root. Pending roots are polled; the frame never invokes a global I/O drain. Failed or timed-out preparation never completes the mission.
 
-All callable addresses are resolved through unique native signatures and relative call targets. No hardcoded executable address is called. The saved executable verifies the six resource calls and pool accessor. The existing decoder rules still require state 5 for the exact asset, then native completion; queued and prepared requests cannot complete a movie.
+The established sequence remains **escape trigger -> STM -> CNN -> mission complete**, using exact decoder asset/state and native busy completion. Apex stays loaded during the movies; this avoids the separately observed world-retirement failure. See [the native movie bridge](mission-ember-prerendered-ending.md) for its lifecycle and frame observer.
 
-### Single sunburn attachment
+## Beam audio follows the surge request
 
-The placed native sunburn volume's condition component (`80B82485`, `c_condition_vol_component_*`) references effect entity `80B82489`. That entity carries its own burn logic and visual components (`80F7AB1E` and `80BEB1C9`). The scripted deck heat shimmer (`80B3A2A8`) is only the visual layer; attaching it alone would not supply burn damage.
+`beam_surge(c, s, true)` now changes the beam drive and queues the appropriate surviving clamshell/coffin alarm sequences in the same callback. Its rising-edge guard prevents repeated alarms from redundant updates. Cooling changes the drive back without replaying an alarm.
 
-A read-only live capture verifies Ember slot 43's source `(80B3C0C6, 80809540, +AC8)` and its writable attachment template: `self+210+relative`, with resource `80C1D9E0` at +0. Native `9F2760 -> 56DE00` consumes that template to create an actor's tracked child attachment.
+The separate `surge_audio` timer and its speculative preroll offsets are removed. The visual/mechanical cycle stays 14 seconds closed, 6 seconds surge, 10 seconds cooling; shutters still open after the surge. This establishes shared script timing, not a measured sample-accurate native sound onset. Actual audible alignment needs confirmation in game.
 
-The narrowly scoped hook substitutes resident `80B82489` only during that one source's native attachment call, then restores the template even on exception. It does not modify package data or Foundry's shared resource. Native duplicate checks, attachment registration and removal remain in control. A missing sunburn resource refuses attachment and logs it rather than dereferencing unloaded data.
+## Escape explosions need retained scene inputs
 
-Lua uses that same slot for the five climb-pipe volumes and, after deposit, the escape rail volume `60/414`. It moves the filter with a new revision, never enables a second attachment owner, and keeps `SUNBURN_DAMAGE_OBJECT` disabled. Completion and checkpoint handling clear/reconcile the attachment through the existing hazard lifecycle. Damage rate and visual behavior still need confirmation in the game; the source/ownership fix is established, not a measured final health-loss rate.
+The failed live run already reported all four authored player triggers: registry `A3B76C64`, slots **105..108**, volumes **242..245**, at t=297197, 302773, 306890 and 312164. The missing step was forwarding them. Generic `scene:activate{}` only publishes a generation with an empty external event list; it does not automatically connect those trigger receipts to the scene.
 
-### Audio
+The native scene is `A3B76C64/43/20`, object `80B3C21C`, config `80B3C0BA+368`, resource entity `80B8248D`, graph `80BEB1CC`. Its external event-gate nodes, class `8080637D`, contain these exact FNV-1 keys:
 
-The sequence request changes from 4000 ms into the closed window to the next timer event (1 ms). This is approximately four seconds earlier. No beam pose, surge, shutter, or cooling duration changes.
+| Player trigger | Authored event name | Event key | Graph key offset |
+| --- | --- | --- | --- |
+| A / slot 105 | `explosion_set_a_trigger` | `329EB106` | `5BC0` |
+| B / slot 106 | `explosion_set_b_trigger` | `633B82E9` | `5C20` |
+| C / slot 107 | `explosion_set_c_trigger` | `15A78938` | `5C80` |
+| D / slot 108 | `explosion_set_d_trigger` | `F9D55A83` | `5CE0` |
 
-## Failure cases checked
+New Lua slot method:
 
-| Case | Required behavior |
-| --- | --- |
-| Registered but unloaded movie | Request and wait; never call native playback |
-| Partially resident or wrong movie header | Keep waiting, then fail on the preparation bound |
-| Native resource load fails | Report failure; do not complete mission |
-| Preparation timeout with pending I/O | Leave frame responsive; defer disposal until request finishes |
-| Decoder never reaches playback | Do not count it as a completed movie |
-| Wrong decoder/asset, world change | Fail rather than complete another movie's request |
-| Repeated escape, completion, or hazard callbacks | No duplicate movie completion or burn attachment |
-| Deposit | Stop beam; move the existing burn to escape bounds; no volume-object damage in parallel |
-| Movie EOF or player skip | Confirm native stop before starting the next movie / completing mission |
-| Another mission or another effect source | Sunburn substitution does not match |
+```lua
+scene:set_scene_events{generation = 1, events = {0x329EB106, 0x633B82E9}}
+```
 
-## Validation and remaining live test
+It accepts only an exact type-43 / component `80806382` / schema `8080626B` SDK binding. The packet contains signed generation, stop=false, zero dependencies, scalar=0, event count and up to 32 unique nonzero event keys: **74 + 32*n bits**. The runtime validates this restricted schema before transport, including lengths, duplicates, forbidden fields and padding. This is a full scene Auth replacement, not a squad/combatant field patch.
 
-Release build, 23 portable tests, all five Lua mission suites, and `tests/verify_ember_movie_native.py` pass. The full route stays within 239 variables, 61 intents/event and four timers. The native test validates signatures, call offsets, table accessor and attachment asset field against the mapped image. The portable cases explicitly reject unloaded/partial/wrong headers and pending disposal, and verify burn source isolation. Lua tests cover single-source damage ownership and the revised audio request timing.
+Deposit starts one generation with an empty list. Each phase-6 route trigger adds its corresponding key to the retained cumulative list without changing generation. Duplicate receipts and backtracking add nothing. Escape checkpoint reset increments generation and clears history. This matches the retained-event mechanism documented for native `B41330` in `MissionDocs/IKORA-ANIMATION-AND-ENDING.md`: unchanged generations and already committed keys do not restart or refire the graph. The native scene owns the explosion timing, placements, animation and effects. The separate core-hole explosion on deposit remains independent.
 
-The fix has **not yet been confirmed by a fresh game run**. Acceptance requires: scorch after deposit with normal damage and clean removal; surge audio aligned with the existing visual; STM begins at escape with moving frames/audio; CNN follows; mission completion occurs only after CNN; both movies can be skipped; no freeze. Expected diagnostics are `resource_requested -> resource_ready -> submitted -> playing -> complete -> resource_released` (release may log immediately before complete), repeated for movie 2. Burn attaches log `ev=ember_sunburn result=attached ... asset=80B82489`.
+## Existing scorch ownership
 
-Evidence is saved under `build/first-encounter-audit/`: `direct-movie-stall-4f5c706-20260906-1803.log`, `movie-stacks-20260906-180532/00000180.bin`, `direct-movie-20260906-180404/manager.bin`, and `reactor-runtime-20260906-181944/`. These are diagnostics, not a promise that passing offline checks proves rendered playback.
+This patch does not change damage. The prior single-owner path remains: Ember's exact slot-43 attachment source temporarily substitutes resident authored sunburn entity `80B82489`; the same source moves its filter from climb pipes to escape rails on deposit. `SUNBURN_DAMAGE_OBJECT` stays disabled to avoid a second damage owner. Native attachment registration, duplicate checks and removal remain in control. The prior test did not establish final damage-rate correctness.
+
+## Reproduction and validation
+
+Evidence lives under `build/first-encounter-audit/`: `bc3e912-resource-stall.log`, `movie-stacks-20260906-184021/`, and the resource/request captures made while that process was frozen. The prior decoder-residency failure is in `direct-movie-stall-4f5c706-20260906-1803.log` and `movie-stacks-20260906-180532/`.
+
+Checks run:
+
+```sh
+cmake --build build -j4
+cmake --build build/portable-tests -j4
+ctest --test-dir build/portable-tests --output-on-failure
+python3 tests/verify_ember_movie_native.py build/first-encounter-audit/game_image.bin /home/millie/Games/Sunrise/packages
+python3 tests/verify_ember_explosion_content.py build/sdk-corrected/activity_sdk.pack build/first-encounter-audit/tags/80BEB1CC.bin
+lua tests/mission_ember_controller_test.lua
+lua tests/mission_ember_encounter_test.lua
+lua tests/mission_ember_wipe_test.lua
+lua tests/mission_ember_combat_ai_test.lua
+lua tests/mission_ember_routes_test.lua build/sdk-mission-complete/sdk/lua/missions/mission_ember_80b3c09e.lua
+```
+
+The full route peaks at 244 durable variables, 61 intents per event, three timers, and 13,000 Lua instructions including the test mock. Packet tests check an independently assembled four-event fixture, empty/reset and maximum lists, invalid generations, malformed fields, duplicates, truncation and padding. Native verification now checks the request-kind branch and installed metadata classes/types, in addition to function addresses and call offsets. Authored-content verification checks the actual SDK wire schema and four event hashes in the extracted graph.
+
+Game acceptance: beam alarm accompanies surge without changing shutter timing; explosions A-D play as the player passes their volumes; a wipe allows a new traversal; STM has moving frames and audio, then CNN plays, and only its completion completes the mission. Test normal EOF and both Escape skips. Expected movie diagnostics: `resource_requested ... kind=1 metadata=4`, `resource_ready`, `submitted`, `playing`, `complete`, and resource release, repeated for movie 2. A request or a ready resource alone is not success.

+ 1 - 1
docs/mission-ember-prerendered-ending.md

@@ -15,7 +15,7 @@ The placement's entity reference is at +0x30. Both entities contain class 808065
 
 ## Required residency correction
 
-The installed `4f5c706` bridge froze on its first live STM request: the resource lookup read address 0x8 at `349D2C`. Both movie wrappers and headers were registered but unloaded. The bridge now requests each movie through the native asynchronous loader and validates completed residency before playback. See [the correction plan, evidence and validation](mission-ember-final-corrections.md). The original direct-play call alone was insufficient.
+The installed `4f5c706` bridge froze on its first live STM request: the resource lookup read address 0x8 at `349D2C`. Both movie wrappers and headers were registered but unloaded. The subsequent `bc3e912` loader also failed: its kind-2 request sent the ordinary movie tag down the shared-resource path, leaving a handle at `FFFFFFFF` before a worker dereferenced it at `3374C6`. The current bridge uses the package-verified kind 1 and pins the small movie, header, subtitle and shared metadata tags before playback. See [the correction evidence and validation](mission-ember-final-corrections.md). Rendered playback remains unconfirmed until a fresh game test.
 
 ## Native playback bridge
 

+ 38 - 33
scripts/mission_ember/apex.lua

@@ -18,9 +18,12 @@ return function(m, a, ending)
         "APEX_DIRECTIVE_REACTOR_RAILS_ESCAPE_PLAYER_TRIGGER"}
     -- The authored explosion prefab's four player triggers, west to east along the rails:
     -- set A x -448.75..-438.75, B -403.75..-393.75, C -368.75..-358.75, D -323.75..-313.75,
-    -- all spanning y 2967..3002.5 and z 185..212.5. They were never armed, so the sequence
-    -- had nothing to advance it and everything the scene did happened at the deposit.
+    -- all spanning y 2967..3002.5 and z 185..212.5. Their receipts must be forwarded
+    -- to the scene's external event list; arming the volumes alone cannot do that.
     local explosion_triggers = {}
+    -- FNV-1 event names in 80BEB1CC's five authored event-gate nodes.
+    local explosion_events = {0x329EB106, 0x633B82E9, 0x15A78938, 0xF9D55A83}
+    local explosion_scene = "EMBER_APEX_EXPLOSION_SEQUENCE_PREFAB_TRIGGERED_EXPLOSIONS_SCENE"
     for _, set in ipairs({"A", "B", "C", "D"}) do
         explosion_triggers[#explosion_triggers + 1] =
             "EMBER_APEX_EXPLOSION_SEQUENCE_PREFAB_EXPLOSION_SET_" .. set .. "_PLAYER_TRIGGER"
@@ -30,7 +33,6 @@ return function(m, a, ending)
     local function generation(s) return s:variable("ember.apex.generation") or 1 end
     local function dead(s, target) return s:variable("ember.apex.dead." .. target) == true end
     local function vent_timer(s) return "ember.apex.vents." .. generation(s) end
-    local function surge_audio_timer(s) return "ember.apex.surge_audio." .. generation(s) end
     local function lane(c, name, transition, snap)
         a.slot(c, name):transition{transition = c.sdk.device_transitions[transition], snap = snap or false}
     end
@@ -95,6 +97,15 @@ return function(m, a, ending)
         if s:variable("ember.apex.beam") ~= true or s:variable("ember.apex.surge") == on then return end
         c:set_variable("ember.apex.surge", on)
         beam_pose(c, on, false)
+        -- One rising edge owns both the beam drive and its authored alarm. There is no
+        -- independent pre-roll clock that can drift into the shutter cooling window.
+        if on then
+            if phase(s) == 3 then
+                for _, side in ipairs(sides) do
+                    if not dead(s, side) then a.slot(c, "REACTOR_CLAMSHELL_" .. side .. "_ALARM_SEQUENCE"):play_sequence{} end
+                end
+            elseif phase(s) == 4 then a.slot(c, "REACTOR_COFFIN_ALARM_SEQUENCE"):play_sequence{} end
+        end
     end
     -- Native slot 43 substitutes the actual sunburn attachment 80B82489 for the
     -- Foundry thermal resource. One slot owns climb/escape damage: a filter revision
@@ -127,10 +138,20 @@ return function(m, a, ending)
                 "AOD_REACTOR_RAIL_TOP_OBJECT_FILTER", rail_filter(c), mode == "escape")
         end
     end
-    -- Activate the authored explosion scene once, then arm its own four progress triggers
-    -- alongside the escape dialogue volumes so each section detonates as the player reaches it.
-    local function arm_escape(c)
-        a.scene(c, "EMBER_APEX_EXPLOSION_SEQUENCE_PREFAB_TRIGGERED_EXPLOSIONS_SCENE")
+    local function publish_explosions(c, s)
+        local events = {}
+        for i, event in ipairs(explosion_events) do
+            if s:variable("ember.apex.explosion." .. i) then events[#events + 1] = event end
+        end
+        a.slot(c, explosion_scene):set_scene_events{
+            generation = s:variable("ember.apex.explosion_generation"), events = events}
+    end
+    -- Scene activation alone supplies no event keys. Keep one generation alive and append
+    -- the authored explosion_set_a_trigger .. explosion_set_d_trigger keys as we pass them.
+    local function arm_escape(c, s)
+        c:set_variable("ember.apex.explosion_generation", (s:variable("ember.apex.explosion_generation") or 0) + 1)
+        for i in ipairs(explosion_events) do c:clear_variable("ember.apex.explosion." .. i) end
+        publish_explosions(c, s)
         for _, name in ipairs(explosion_triggers) do a.slot(c, name):fire_trigger{} end
         for _, name in ipairs(escape_triggers) do a.slot(c, name):fire_trigger{} end
     end
@@ -166,12 +187,6 @@ return function(m, a, ending)
                 if step == "open" then a.cue(c, s, 46) end
             end
         end
-        c:cancel_timer(surge_audio_timer(s))
-        if step == "closed" then
-            -- The live cue still landed four seconds late with a 4s request delay.
-            -- Queue on the next event, retaining the 14s closed / 6s surge / 10s cooling clock.
-            c:start_timer(surge_audio_timer(s), 1)
-        end
         c:start_timer(vent_timer(s), ({closed = 14000, warning = 6000, open = 10000})[step])
     end
     local function initialize_devices(c, s)
@@ -225,9 +240,8 @@ return function(m, a, ending)
             -- opened both devices here, which left it running through the whole escape.
             beam(c, s, false, true)
             c:cancel_timer(vent_timer(s))
-            c:cancel_timer(surge_audio_timer(s))
             a.scene(c, "MOTHER_BRAIN_HOLE_EXPLOSION_SCENE")
-            arm_escape(c)
+            arm_escape(c, s)
             a.cue(c, s, 51); A.guidance(c, s)
         end)
     function A.enter(c, s)
@@ -276,6 +290,12 @@ return function(m, a, ending)
         -- The explanatory cue follows the arrival line, even if its trigger was crossed in the same tick.
         if phase(s) == 5 and a.matches(c, e, "DIALOG_APEX_MOTHER_BRAIN_001_PLAYER_TRIGGER") then a.cue(c, s, 50) end
         if phase(s) == 6 then
+            for i, name in ipairs(explosion_triggers) do
+                if a.matches(c, e, name) and not s:variable("ember.apex.explosion." .. i) then
+                    c:set_variable("ember.apex.explosion." .. i, true)
+                    publish_explosions(c, s)
+                end
+            end
             if a.matches(c, e, "APEX_MOTHER_BRAIN_006_DIALOG_PLAYER_TRIGGER") then a.cue(c, s, 52) end
             if a.matches(c, e, "APEX_MOTHER_BRAIN_007_DIALOG_PLAYER_TRIGGER") then a.cue(c, s, 53) end
             if a.matches(c, e, "APEX_MOTHER_BRAIN_008_DIALOG_PLAYER_TRIGGER") then a.cue(c, s, 54) end
@@ -321,7 +341,6 @@ return function(m, a, ending)
         else
             set(c, 5)
             c:cancel_timer(vent_timer(s)); c:cancel_timer("ember.apex.explain." .. generation(s))
-            c:cancel_timer(surge_audio_timer(s))
             coffin_doors(c, true)
             -- The weapon keeps firing until the cell goes in.
             beam(c, s, true)
@@ -348,22 +367,9 @@ return function(m, a, ending)
         destroyed(c, s, which)
     end
     function A.timer(c, s, e)
-        if e.timer_name == surge_audio_timer(s) then
-            if s:variable("ember.region") == 0 and s:variable("ember.apex.vent_step") == "closed" then
-                if phase(s) == 3 then
-                    for _, side in ipairs(sides) do
-                        if not dead(s, side) then
-                            a.slot(c, "REACTOR_CLAMSHELL_" .. side .. "_ALARM_SEQUENCE"):play_sequence{}
-                        end
-                    end
-                elseif phase(s) == 4 then a.slot(c, "REACTOR_COFFIN_ALARM_SEQUENCE"):play_sequence{} end
-            end
-            return true
-        end
         if e.timer_name == "ember.apex.hazards" then
-            -- The climb pipes burn while the cell is being carried up (phase 5), well before
-            -- the deposit. Escape (phase 6) uses only the native sunburn object.
-            -- Entering escape or resetting detaches the climb burn.
+            -- One attachment owner moves from climb pipes (phase 5) to escape rails (phase 6).
+            -- Entering escape revises the filter and removes attachments from the old filter.
             if s:variable("ember.region") == 0 then
                 local p = phase(s)
                 if p == 5 then hazards(c, s, "climb")
@@ -420,11 +426,10 @@ return function(m, a, ending)
             -- The weapon is already dead at this checkpoint: restoring it must leave the
             -- beam off and re-arm the escape's own progress triggers.
             beam(c, s, false, true)
-            arm_escape(c)
+            arm_escape(c, s)
             a.darkness(c, s, true)
         else
             c:cancel_timer(vent_timer(s)); c:cancel_timer("ember.apex.core." .. generation(s))
-            c:cancel_timer(surge_audio_timer(s))
             c:cancel_timer("ember.apex.explain." .. generation(s)); c:cancel_timer("ember.apex.setup." .. generation(s))
             a.reset(c, reactor); carry.reset(c, s)
             c:set_variable("ember.apex.generation", generation(s) + 2)

+ 10 - 0
tests/CMakeLists.txt

@@ -1,6 +1,16 @@
 cmake_minimum_required(VERSION 3.20)
 project(SunrisePortableTests LANGUAGES CXX)
 enable_testing()
+add_executable(scene_events_test scene_events_test.cpp
+    ../Sunrise/src/middleware/encoding/bit_reader.cpp
+    ../Sunrise/src/middleware/encoding/bit_writer.cpp)
+target_compile_features(scene_events_test PRIVATE cxx_std_20)
+if(MSVC)
+    target_compile_options(scene_events_test PRIVATE /W4 /WX /UNDEBUG)
+else()
+    target_compile_options(scene_events_test PRIVATE -Wall -Wextra -Werror -UNDEBUG)
+endif()
+add_test(NAME scene_events COMMAND scene_events_test)
 add_executable(ending_retirement_test ending_retirement_test.cpp
     ../Sunrise/src/middleware/bap/activity_message/activity_sensor_auth_encoder.cpp
     ../Sunrise/src/middleware/bap/activity_message/activity_sensor_auth_blocks.cpp

+ 5 - 0
tests/ending_retirement_test.cpp

@@ -22,6 +22,11 @@ static void expect(Reader& reader, unsigned width, std::uint64_t expected) {
 }
 int main() {
     namespace movies=sunrise::client::hooks::ember_movies;
+    // Native kind 2 resolves shared-tag records; these ordinary movie tags require 1.
+    static_assert(movies::movie_resource_kind==1);
+    assert((movies::movie_metadata(0x80BCA001)==std::array<std::uint32_t,4>{0x80BCA001,0x80BCA000,0x80B9EB33,0x80BCA032}));
+    assert((movies::movie_metadata(0x80BCA003)==std::array<std::uint32_t,4>{0x80BCA003,0x80BCA002,0x80B9EB34,0x80BCA032}));
+    assert((movies::movie_metadata(0x80BCA034)==std::array<std::uint32_t,4>{})); // never pin raw video
     // Captured crash: registered movie tags contain free-list entries, not resident headers.
     assert(!movies::movie_resources_ready(1,0x80BCA001,false,0,false,0xFFFFFFFF));
     assert(!movies::movie_resources_ready(2,0x80BCA001,false,0x80BCA000,true,0x80BCA034));

+ 33 - 7
tests/mission_ember_routes_test.lua

@@ -33,7 +33,7 @@ end
 function c:prerendered_movie_status(index) return movieStatus[index] or 'absent' end
 c.lifetime = {set = function(_, args) record("lifetime",args.state) end}
 local types = {set_engagement_state=70,set_music_section=11,set_mission_effect=26,set_object_filter=34,transition=23,set_interactable_object=4,set_object_active=4,watch_damage=20,
-    fire_trigger=31,set_directive=68,set_darkness_zone=35,play_dialogue_cue=53,play_sequence=5,
+    set_scene_events=43,fire_trigger=31,set_directive=68,set_darkness_zone=35,play_dialogue_cue=53,play_sequence=5,
     assign_combat_objective=1,reset_objectives=3,set_cinematic_active=6}
 function c:slot(id)
     assert(id, "nil SDK slot")
@@ -271,14 +271,22 @@ for _,side in ipairs({'east','west'})do for _,wave in ipairs({'entry','reinforce
 assert(vars['ember.music.section']==22)
 timer('ember.apex.explain.');assert(vars['ember.r.cue.41'] and vars['ember.music.section']==23)
 assert(timers['ember.apex.vents.1']==14000)
-assert(timers['ember.apex.surge_audio.1']==1,'request the sound four seconds earlier without moving the visual clock')
+assert(timers['ember.apex.surge_audio.1']==nil,'audio must have no independent pre-roll timer')
 local beforeAudio=#calls
-timer('ember.apex.surge_audio.')
-assert(vars['ember.apex.vent_step']=='closed' and not vars['ember.apex.surge'],'audio pre-roll changed visual timing')
+timer('ember.apex.vents.') -- Same callback starts the visible surge and its sound.
 local alarms=0
-for i=beforeAudio+1,#calls do if calls[i][1]=='play_sequence' then alarms=alarms+1 end end
-assert(alarms==2,'both surviving clamshells must pre-roll their authored audio')
-timer('ember.apex.vents.') -- Surge precedes any target exposure.
+local beamMoved=false
+for i=beforeAudio+1,#calls do
+    local row=calls[i]
+    if row[1]=='transition' and row[2]==slotDefs[m.Slot.SPECOPS_APEX_RING_LASER_DEVICE].name then
+        beamMoved=row[3].transition=='close'
+    end
+    if row[1]=='play_sequence' then
+        assert(beamMoved,'alarm was requested before the surge state changed')
+        alarms=alarms+1
+    end
+end
+assert(alarms==2,'both surviving clamshell alarms must start on the surge rising edge')
 assert(vars['ember.apex.vent_step']=='warning' and timers['ember.apex.vents.1']==6000)
 -- The weapon fires continuously through the fight; the exposure cycle must not blink it.
 -- The surge is the authored device drive of a beam that stays present and powered.
@@ -446,9 +454,27 @@ for _,set in ipairs({'A','B','C','D'})do
     end
     assert(armed,'explosion set '..set..' was never armed')
 end
+local explosionKeys={0x329EB106,0x633B82E9,0x15A78938,0xF9D55A83}
+local function explosionState()
+    for i=#calls,1,-1 do if calls[i][1]=='set_scene_events' then return calls[i][3] end end
+end
+assert(#explosionState().events==0,'deposit must not detonate all four escape sets')
+local explosionGeneration=explosionState().generation
+for i,set in ipairs({'A','B','C','D'}) do
+    local triggerName='EMBER_APEX_EXPLOSION_SEQUENCE_PREFAB_EXPLOSION_SET_'..set..'_PLAYER_TRIGGER'
+    trigger(triggerName)
+    local state=explosionState()
+    assert(state.generation==explosionGeneration,'route events must not restart the whole scene')
+    assert(#state.events==i and state.events[i]==explosionKeys[i],'wrong authored explosion event')
+    local before=#calls
+    trigger(triggerName)
+    assert(#calls==before,'backtracking detonated an explosion twice')
+end
 reset_check('escape',function()
     assert(vars['ember.apex.phase']==6 and vars['ember.apex.dead.COFFIN'])
     assert(vars['ember.apex.beam']==false,'escape restart must leave the beam off')
+    assert(explosionState().generation==explosionGeneration+1 and #explosionState().events==0,
+        'wipe must restart the explosion scene without replaying old events')
 end)
 trigger('APEX_DIRECTIVE_REACTOR_RAILS_ESCAPE_PLAYER_TRIGGER')
 local sunburnAfterEscape

+ 49 - 0
tests/scene_events_test.cpp

@@ -0,0 +1,49 @@
+#include <array>
+#include <cassert>
+#include <string_view>
+#include "../Sunrise/src/middleware/bap/activity_message/scene_events_auth.h"
+
+namespace scene = sunrise::middleware::bap::activity_message::scene_events;
+using Bytes = std::array<std::byte,scene::kMaximumBytes>;
+
+static void field(Bytes& data, std::size_t offset, unsigned width, std::uint64_t value) {
+    for (unsigned i=0;i<width;++i) {
+        const auto bit=std::byte(1U<<(7-(offset+i)%8));
+        if ((value>>(width-i-1))&1) data[(offset+i)/8]|=bit;
+        else data[(offset+i)/8]&=~bit;
+    }
+}
+int main() {
+    // Schema-derived fixture, independent of the encoder: signed generation, no
+    // clear/dependencies/scalar, then four authored FNV-1 explosion event keys.
+    constexpr std::array<std::uint32_t,4> keys{0x329EB106,0x633B82E9,0x15A78938,0xF9D55A83};
+    Bytes body{};
+    std::size_t bytes{},bits{};
+    assert(scene::encode(1,keys,body,bytes,bits));
+    assert(bytes==26 && bits==202);
+    constexpr std::string_view fixture="8000000100000000010ca7ac4198cee0ba4569e24e3e7556a0c0";
+    const auto nibble=[](char c) { return unsigned(c<='9' ? c-'0' : c-'a'+10); };
+    for (std::size_t i=0;i<bytes;++i)
+        assert(std::to_integer<unsigned>(body[i])==((nibble(fixture[2*i])<<4)|nibble(fixture[2*i+1])));
+    assert(scene::validate(std::span(body).first(bytes),bits));
+    for (const auto& [offset,width,value] : std::array<std::array<std::uint64_t,3>,9>{{
+        {0,32,0x80000000U}, {32,1,1}, {33,4,1}, {37,31,1}, {68,6,33},
+        {74,32,0}, {74,32,0xFFFFFFFFU}, {106,32,keys[0]}, {207,1,1}}}) {
+        auto invalid=body;field(invalid,offset,static_cast<unsigned>(width),value);
+        assert(!scene::validate(std::span(invalid).first(bytes),bits));
+    }
+    assert(!scene::validate(std::span(body).first(bytes-1),bits));
+    assert(!scene::validate(std::span(body).first(bytes+1),bits));
+    assert(!scene::validate(std::span(body).first(bytes),bits-1));
+    assert(!scene::encode(0,keys,body,bytes,bits));
+    assert(!scene::encode(-1,keys,body,bytes,bits));
+    assert(!scene::encode(1,keys,std::span(body).first(25),bytes,bits));
+    assert(!scene::encode(1,std::array<std::uint32_t,2>{1,1},body,bytes,bits));
+    std::array<std::uint32_t,33> many{};
+    for (std::size_t i=0;i<many.size();++i) many[i]=static_cast<std::uint32_t>(i+1);
+    assert(!scene::encode(1,many,body,bytes,bits));
+    assert(scene::encode(0x7FFFFFFF,std::span(many).first(32),body,bytes,bits));
+    assert(bytes==138 && bits==1098 && scene::validate(std::span(body).first(bytes),bits));
+    assert(scene::encode(2,{},body,bytes,bits)); // wipe: new generation, cleared event history
+    assert(bytes==10 && bits==74 && scene::validate(std::span(body).first(bytes),bits));
+}

+ 37 - 0
tests/verify_ember_explosion_content.py

@@ -0,0 +1,37 @@
+"""Check the scene wire layout and authored event keys against extracted game data.
+Usage: python3 tests/verify_ember_explosion_content.py activity_sdk.pack 80BEB1CC.bin
+"""
+import mmap
+from pathlib import Path
+import struct
+import sys
+
+with Path(sys.argv[1]).open('rb') as stream, mmap.mmap(stream.fileno(), 0, access=mmap.ACCESS_READ) as data:
+    start, count, stride = struct.unpack_from('<QII', data, 160 + 35 * 16)
+    fields, _, field_stride = struct.unpack_from('<QII', data, 160 + 36 * 16)
+    schemas = {}
+    for index in range(count):
+        row = struct.unpack_from('<8IQII', data, start + index * stride)
+        if row[0] in (0x8080626B, 0x80807ED9, 0x808094F3, 0x808094E1, 0x808094DF):
+            schemas[row[0]] = [struct.unpack_from('<6Iq6I', data, fields + i * field_stride)
+                               for i in range(row[6], row[6] + row[7])]
+    def layout(schema):
+        return [(f[2], f[4], f[5], f[7]) for f in schemas[schema]]
+    assert layout(0x8080626B) == [(0, 5, 0xFFFFFFFF, 32), (4, 2, 0xFFFFFFFF, 1),
+                                 (8, 1, 0x80807ED9, 0xFFFFFFFF), (80, 1, 0x808094E1, 0xFFFFFFFF)]
+    assert schemas[0x8080626B][0][6] == -2147483648
+    assert layout(0x80807ED9) == [(0, 1, 0x808094F3, 0xFFFFFFFF), (68, 5, 0xFFFFFFFF, 31)]
+    assert schemas[0x808094F3][0][7] == 4
+    assert layout(0x808094E1) == [(0, 5, 0xFFFFFFFF, 6), (4, 1, 0x808094DF, 0)]
+    assert layout(0x808094DF) == [(4, 9, 0xFFFFFFFF, 32)]
+
+scene = Path(sys.argv[2]).read_bytes()
+for suffix, offset, key in [('a', 0x5BC0, 0x329EB106), ('b', 0x5C20, 0x633B82E9),
+                            ('c', 0x5C80, 0x15A78938), ('d', 0x5CE0, 0xF9D55A83)]:
+    value = 0x811C9DC5
+    for byte in f'explosion_set_{suffix}_trigger'.encode():
+        value = ((value * 0x01000193) & 0xFFFFFFFF) ^ byte
+    assert value == key
+    assert struct.unpack_from('<I', scene, offset - 12)[0] == 0x8080637D
+    assert struct.unpack_from('<I', scene, offset)[0] == key
+print('Type-43 scene schema (74 + 32*n bits) and all four authored explosion event keys verified.')

+ 46 - 2
tests/verify_ember_movie_native.py

@@ -1,5 +1,5 @@
 """Offline ABI verification against a mapped/decrypted image, never an on-disk encrypted EXE.
-Usage: python3 tests/verify_ember_movie_native.py path/to/game_image.bin
+Usage: python3 tests/verify_ember_movie_native.py path/to/game_image.bin [packages-directory]
 """
 import re
 import struct
@@ -32,10 +32,54 @@ for offset, expected in [(0x85, 0x42C650), (0x9F, 0x425310)]:
     target(end, offset, expected)
 assert data[end + 0x2E:end + 0x31] == bytes.fromhex('48 8B 05')
 assert end + 0x35 + struct.unpack_from('<i', data, end + 0x31)[0] == 0x2439C70
+# Native tag classifier, including the semantic distinction missed by the old test:
+# ordinary tag -> kind 1; shared type-16 tag (type_info & F000 == 2000) -> kind 2.
+assert data[0x42694F:0x42696F] == bytes.fromhex(
+    '8b 45 04 8b cb 48 89 7c 24 30 25 00 f0 00 00 33 ff 3d 00 20 00 00 40 0f 94 c7 45 33 c0 8d 57 01')
+target(0x426920, 0x4F, 0x433050)
+# Kind 2 is routed to root+10; ordinary metadata belongs in root+20.
+assert data[0x4313CD:0x4313E2] == bytes.fromhex(
+    '83 3f 02 b9 10 00 00 00 8b 57 04 41 b8 20 00 00 00 44 0f 44 c1')
+movie = 'Sunrise/src/client/hooks/ember_movies/ember_movies.cpp'
+start, stop, busy = (signature(movie, name) for name in ('startSig', 'stopSig', 'busySig'))
+for offset, expected in [(0x72, 0x41B040), (0x7A, 0x41A3C0), (0x8E, 0x41CD20)]:
+    target(start, offset, expected)
+for offset, expected in [(0x18, 0x41D0C0), (0x25, 0x41A980)]:
+    target(stop, offset, expected)
+assert busy == 0x41B420
+target(busy, 0x48, 0x41AB70)
+
+if len(sys.argv) > 2:
+    # Read only container metadata, without unpacking data or starting the game.
+    latest = {}
+    for path in Path(sys.argv[2]).glob('*.pkg'):
+        with path.open('rb') as stream:
+            header = stream.read(0x170)
+        package = struct.unpack_from('<H', header, 4)[0]
+        version = (struct.unpack_from('<Q', header, 0x10)[0],
+                   struct.unpack_from('<I', header, 0x1C)[0],
+                   struct.unpack_from('<H', header, 0x20)[0])
+        if package not in latest or version > latest[package][0]:
+            latest[package] = version, path, header
+    for tag, expected in [(0x80BCA001, 0x80808495), (0x80BCA003, 0x80808495),
+                          (0x80BCA000, 0x80808499), (0x80BCA002, 0x80808499),
+                          (0x80B9EB33, 0x80809A88), (0x80B9EB34, 0x80809A88),
+                          (0x80BCA032, 0x80806B8F)]:
+        # Tag package IDs include the bank: 80BCAxxx belongs to package 01E5.
+        package = (tag >> 13) & 0x3FF
+        _, path, header = latest[package]
+        table = (struct.unpack_from('<I', header, 0x110)[0] + 0x60 if header[0x1A] == 1
+                 else struct.unpack_from('<I', header, 0xB8)[0])
+        with path.open('rb') as stream:
+            stream.seek(table + (tag & 0x1FFF) * 16)
+            reference, type_info, _ = struct.unpack('<IIQ', stream.read(16))
+        assert reference == expected, (hex(tag), hex(reference), path)
+        assert type_info & 0xF000 != 0x2000, (hex(tag), hex(type_info))
+    print('Installed movie metadata classes and ordinary (kind 1) package types verified.')
 attach = signature('Sunrise/src/client/hooks/bootflow/ember_sunburn.cpp', 'sig')
 assert attach == 0x9F2760
 # Native attach dereferences the runtime relative template, then passes it to the child factory.
 target(attach, 0x68, 0x32BBD0)
 target(attach, 0x78, 0x56DE00)
 assert data[0x4AE000:0x4AE002] == bytes.fromhex('8B 09')  # factory reads resource at request+0
-print('Native resource request / submit / status / release and sunburn attachment ABI verified.')
+print('Native resource kind selection, request lifecycle, movie playback and sunburn attachment ABI verified.')