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

Load Ember ending movie resources before playback and use authored sunburn

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

+ 5 - 0
Sunrise/Sunrise.vcxproj

@@ -281,7 +281,9 @@
     <ClCompile Include="src\client\hooks\bootflow\region_private.cpp" />
     <ClCompile Include="src\client\hooks\bootflow\world_step.cpp" />
     <ClCompile Include="src\client\hooks\ember_movies\ember_movies.cpp" />
+    <ClCompile Include="src\client\hooks\ember_movies\resources.cpp" />
     <ClCompile Include="src\client\hooks\bootflow\ember_movie_tick.cpp" />
+    <ClCompile Include="src\client\hooks\bootflow\ember_sunburn.cpp" />
     <ClCompile Include="src\client\hooks\bootflow\spawn_hold.cpp" />
     <ClCompile Include="src\client\hooks\bootflow\fade_release.cpp" />
     <ClCompile Include="src\client\hooks\bootflow\spawn\spawn_gate_probe.cpp" />
@@ -1248,6 +1250,9 @@
   </ItemGroup>
   <ItemGroup>
     <ClInclude Include="src\client\hooks\ember_movies\ember_movies.h" />
+    <ClInclude Include="src\client\hooks\ember_movies\resources.h" />
+    <ClInclude Include="src\client\hooks\ember_movies\readiness_rules.h" />
+    <ClInclude Include="src\client\hooks\ember_movies\sunburn_rules.h" />
     <ClInclude Include="src\client\hooks\ember_movies\playback_rules.h" />
     <ClInclude Include="resources\resource.h" />
     <ClInclude Include="src\core\logging\log.h" />

+ 2 - 0
Sunrise/src/client/hooks/bootflow/bootflow_hook_lifecycle.cpp

@@ -30,6 +30,7 @@ constexpr std::array kFixes{
     Fix{&stage_orbit_handoff, &publish_orbit_handoff},
     Fix{&stage_loading_cinematics, &publish_loading_cinematics},
     Fix{&stage_ember_movie_tick, &publish_ember_movie_tick},
+    Fix{&stage_ember_sunburn, &publish_ember_sunburn},
     Fix{&stage_owner_activity_slot, &publish_owner_activity_slot},
     Fix{&stage_region_private, &publish_region_private},
 };
@@ -110,6 +111,7 @@ void uninstall() noexcept {
     uninstall_region_private();
     uninstall_owner_activity_slot();
     uninstall_loading_cinematics();
+    uninstall_ember_sunburn();
     uninstall_ember_movie_tick();
     uninstall_orbit_handoff();
     uninstall_composition_check();

+ 62 - 0
Sunrise/src/client/hooks/bootflow/ember_sunburn.cpp

@@ -0,0 +1,62 @@
+#include <Windows.h>
+#include "internal.h"
+#include "../ember_movies/resources.h"
+#include "../ember_movies/sunburn_rules.h"
+#include "../../../core/logging/log.h"
+#include <atomic>
+#include <cstring>
+namespace sunrise::client::hooks::bootflow {
+namespace {
+hooking::detour::Handle hook{};
+using Attach=bool(__fastcall*)(void*,std::uint32_t);
+std::atomic<unsigned> reports{};
+template<class T> T read(const void* pointer) { T value{};std::memcpy(&value,pointer,sizeof(value));return value; }
+std::uint32_t* ember_template(void* component) noexcept {
+    __try {
+        auto* self=static_cast<std::byte*>(component);
+        // Only Ember's type-26 slot 43, whose source and runtime spawn template were captured live.
+        if (read<std::uint32_t>(self)!=0x80B3C0C6U) return nullptr;
+        const auto relative=read<std::int64_t>(self+0x210);
+        if (relative<=0 || relative>0x1000) return nullptr;
+        auto* request=reinterpret_cast<std::uint32_t*>(self+0x210+relative);
+        return ember_movies::ember_burn_source(read<std::uint32_t>(self),read<std::uint32_t>(self+4),
+            read<std::uint64_t>(self+8),*request) ? request : nullptr;
+    } __except(EXCEPTION_EXECUTE_HANDLER) { return nullptr; }
+}
+bool __fastcall attach(void* component,std::uint32_t actor) noexcept {
+    const auto original=reinterpret_cast<Attach>(hook.original);
+    auto* request=ember_template(component);
+    if (!request) return original(component,actor);
+    if (!ember_movies::sunburn_resident()) {
+        if (reports.fetch_add(1)<8) core::log::write(core::log::Channel::client,core::log::Level::warn,
+            "ev=ember_sunburn result=resource_unavailable asset=80B82489");
+        return false;
+    }
+    const auto previous=*request;
+    bool result{};
+    // This is the writable, per-component spawn request, NOT shared package data.
+    // 9F2760 -> 56DE00 consumes the asset at request+0 and creates a tracked child of this actor.
+    // Preserve native duplicate checks, attachment registration and detach behavior.
+    *request=0x80B82489U;
+    __try { result=original(component,actor); }
+    __finally { *request=previous; }
+    if (result && reports.fetch_add(1)<16) core::log::writef(core::log::Channel::client,core::log::Level::info,
+        "ev=ember_sunburn result=attached actor=%08X asset=80B82489",actor);
+    return result;
+}
+}
+StageResult stage_ember_sunburn(hooking::detour::Spec& spec) noexcept {
+    if (hook.attached) return StageResult::attached;
+    constexpr auto sig=signature<signature_length("48 89 5C 24 18 57 48 83 EC 20 8B C2 8B DA 25 FF 1F 00 00 48 8B F9 44 8B C0 8B D0 49 C1 E8 05")>(
+        "48 89 5C 24 18 57 48 83 EC 20 8B C2 8B DA 25 FF 1F 00 00 48 8B F9 44 8B C0 8B D0 49 C1 E8 05");
+    auto* target=scan_main_image_unique(sig,"ember_sunburn_attach");
+    if (!target) return StageResult::unavailable;
+    spec={target,reinterpret_cast<void*>(&attach)};return StageResult::staged;
+}
+void publish_ember_sunburn(const hooking::detour::Handle& value) noexcept {
+    hook=value;
+    core::log::write(core::log::Channel::client,core::log::Level::info,
+        value.attached ? "ev=ember_sunburn result=hook_attached" : "ev=ember_sunburn result=hook_failed");
+}
+void uninstall_ember_sunburn() noexcept { static_cast<void>(hooking::detour::uninstall(hook)); }
+}

+ 3 - 0
Sunrise/src/client/hooks/bootflow/internal.h

@@ -79,6 +79,9 @@ void uninstall_orbit_handoff() noexcept;
 [[nodiscard]] StageResult stage_ember_movie_tick(hooking::detour::Spec& spec) noexcept;
 void publish_ember_movie_tick(const hooking::detour::Handle& handle) noexcept;
 void uninstall_ember_movie_tick() noexcept;
+[[nodiscard]] StageResult stage_ember_sunburn(hooking::detour::Spec& spec) noexcept;
+void publish_ember_sunburn(const hooking::detour::Handle& handle) noexcept;
+void uninstall_ember_sunburn() noexcept;
 
 /** LoadingCinematics_Suppressed: travel-only suppression, separate from movie Auth. */
 [[nodiscard]] StageResult stage_loading_cinematics(hooking::detour::Spec& spec) noexcept;

+ 17 - 3
Sunrise/src/client/hooks/ember_movies/ember_movies.cpp

@@ -1,5 +1,6 @@
 #include "ember_movies.h"
 #include "playback_rules.h"
+#include "resources.h"
 #include <Windows.h>
 #include <array>
 #include <atomic>
@@ -24,6 +25,7 @@ unsigned movie{};
 Status state{};
 bool acquired{}, stopRequested{}, attempted{}, escapeHeld{};
 Playback playback{};
+MovieResource resource{};
 void* decoderOwner{};
 int lastDecoderState{-1};
 thread_local bool inPoll{};
@@ -80,7 +82,7 @@ void fail(const char* reason) {
             api.stop(api.manager());
         release();
     }
-    state=Status::failed; watching.store(false); report(reason);
+    state=Status::failed; watching.store(!resource.release()); report(reason);
 }
 }
 bool request(Owner next, std::uint64_t nextKey, unsigned index, bool stop) noexcept {
@@ -93,7 +95,7 @@ bool request(Owner next, std::uint64_t nextKey, unsigned index, bool stop) noexc
         }
     } else if (next==owner && nextKey==key && index==movie) {
         accepted=state!=Status::failed;
-    } else if (!acquired && state!=Status::queued && state!=Status::preparing && state!=Status::playing) {
+    } else if (!acquired && !resource.held() && state!=Status::queued && state!=Status::preparing && state!=Status::playing) {
         owner=next; key=nextKey; movie=index; state=Status::queued; began=GetTickCount64();
         stopRequested=false; playback={}; decoderOwner=nullptr; lastDecoderState=-1;
         escapeHeld=(GetAsyncKeyState(VK_ESCAPE)&0x8000)!=0;
@@ -114,6 +116,7 @@ void poll(std::int32_t region, std::int32_t step) noexcept {
     struct Reset { ~Reset() { inPoll=false; } } reset;
     AcquireSRWLockExclusive(&lock);
     if (state!=Status::queued && state!=Status::preparing && state!=Status::playing) {
+        watching.store(!resource.release());
         ReleaseSRWLockExclusive(&lock); return;
     }
     const auto now=GetTickCount64();
@@ -124,10 +127,21 @@ void poll(std::int32_t region, std::int32_t step) noexcept {
     auto* decoder=api.decoder();
     if (!manager || !decoder) { fail("player_unavailable"); ReleaseSRWLockExclusive(&lock); return; }
     if (state==Status::queued) {
+        if (!resource.held() && !resource.begin(assets[movie-1])) {
+            fail("resource_request_failed"); ReleaseSRWLockExclusive(&lock); return;
+        }
+        if (resource.state()==3 || resource.state()<0) {
+            fail("resource_load_failed"); ReleaseSRWLockExclusive(&lock); return;
+        }
+        if (!resource.ready()) {
+            if (now-began>30000) fail("resource_ready_timeout");
+            ReleaseSRWLockExclusive(&lock); return;
+        }
         if (api.busy(manager)) {
             if (now-began>30000) fail("player_busy_timeout");
         } else {
             // Same acquire/play pairing as the authored pre-rendered component.
+            report("resource_ready");
             decoderOwner=decoder; api.acquire(manager); acquired=true;
             api.play(manager,assets[movie-1],0); state=Status::preparing; began=now;
             report("submitted");
@@ -152,7 +166,7 @@ void poll(std::int32_t region, std::int32_t step) noexcept {
     if (stopRequested && asset==assets[movie-1]) {
         api.stop(manager); stopRequested=false; report("stop_requested",decoderState);
     }
-    if (observed==Status::complete) { release(); state=observed; watching.store(false); report("complete",decoderState); }
+    if (observed==Status::complete) { release(); state=observed; watching.store(!resource.release()); report("complete",decoderState); }
     else if (observed==Status::failed) fail("decoder_failed_or_replaced");
     else state=observed;
     if ((state==Status::preparing && now-began>30000)

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

@@ -0,0 +1,10 @@
+#pragma once
+#include <cstdint>
+namespace sunrise::client::hooks::ember_movies {
+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)
+        && wrapperResident && header==asset-1 && headerResident && media!=0xFFFFFFFFU;
+}
+constexpr bool resource_can_release(int state) noexcept { return state==2 || state==3; }
+}

+ 113 - 0
Sunrise/src/client/hooks/ember_movies/resources.cpp

@@ -0,0 +1,113 @@
+#include "resources.h"
+#include "readiness_rules.h"
+#include <Windows.h>
+#include <cstring>
+#include "../../patterns/image_scan.h"
+#include "../../patterns/signature_text.h"
+#include "../../../core/logging/log.h"
+namespace sunrise::client::hooks::ember_movies {
+namespace {
+using namespace patterns;
+using Manager=void*(__fastcall*)();
+using Create=std::uint32_t*(__fastcall*)(void*,std::uint32_t*,int,int,int,const char*);
+using Add=void(__fastcall*)(void*,const std::uint32_t*);
+using Submit=void(__fastcall*)(void*,std::uint32_t);
+using State=int(__fastcall*)(void*);
+Manager manager{}; Create create{}; Add add{}; Submit submit{},destroy{}; State status{};
+std::uintptr_t tableGlobal{};
+INIT_ONCE init=INIT_ONCE_STATIC_INIT;
+bool available{};
+template<class T> T read(std::uintptr_t at) { T value{};std::memcpy(&value,reinterpret_cast<void*>(at),sizeof(value));return value; }
+void* call(std::byte* code,unsigned offset) {
+    if (!code || code[offset]!=std::byte{0xE8}) return nullptr;
+    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.
+    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.
+    constexpr auto endSig=signature<signature_length("48 89 5C 24 08 48 89 6C 24 10 48 89 74 24 18 57 41 56 41 57 48 83 EC 20 48 63 EA 4C 8B F1 E8")>(
+        "48 89 5C 24 08 48 89 6C 24 10 48 89 74 24 18 57 41 56 41 57 48 83 EC 20 48 63 EA 4C 8B F1 E8");
+    auto* load=scan_main_image_unique(loadSig,"ember_resource_load");
+    auto* end=scan_main_image_unique(endSig,"ember_resource_release");
+    auto mgr=reinterpret_cast<Manager>(call(load,0x96));
+    create=reinterpret_cast<Create>(call(load,0xD1));
+    add=reinterpret_cast<Add>(call(load,0x14C));
+    submit=reinterpret_cast<Submit>(call(load,0x157));
+    status=reinterpret_cast<State>(call(end,0x85));
+    destroy=reinterpret_cast<Submit>(call(end,0x9F));
+    if (!mgr || !create || !add || !submit || !status || !destroy || !end
+        || end[0x2E]!=std::byte{0x48} || end[0x2F]!=std::byte{0x8B} || end[0x30]!=std::byte{0x05}) return false;
+    tableGlobal=reinterpret_cast<std::uintptr_t>(end+0x35)+read<std::int32_t>(reinterpret_cast<std::uintptr_t>(end+0x31));
+    manager=mgr; return true;
+}
+BOOL CALLBACK initialize(PINIT_ONCE,void*,void**) { available=resolve_native();return TRUE; }
+bool resolve() { InitOnceExecuteOnce(&init,initialize,nullptr,nullptr);return available; }
+std::uintptr_t row(std::uint32_t handle) {
+    if (handle==0xFFFFFFFFU || !tableGlobal) return 0;
+    const auto high=static_cast<std::uint32_t>(static_cast<std::int32_t>(handle)>>13);
+    const auto pool=(high&0xFFFFU)&((static_cast<std::uint64_t>(high)|0xFFC0000ULL)>>18);
+    const auto head=read<std::uintptr_t>(tableGlobal);
+    if (!head) return 0;
+    const auto table=read<std::uintptr_t>(head);
+    return table ? table+64*pool : 0;
+}
+void* blob(std::uint32_t handle,std::uint32_t expectedClass=0) {
+    const auto pool=row(handle);if (!pool) return nullptr;
+    const auto storage=read<std::uintptr_t>(pool+8);
+    const auto stride=read<std::uint32_t>(pool+48);
+    if (!storage || !stride) return nullptr;
+    const auto item=storage+stride*static_cast<std::uintptr_t>(handle&0x1FFFU);
+    // Unloaded package entries contain FEFE free-list markers, not a definition.
+    if (expectedClass && read<std::uint32_t>(item)!=expectedClass) return nullptr;
+    const auto mask=static_cast<std::uintptr_t>(static_cast<std::intptr_t>(read<std::int32_t>(pool+52)));
+    const auto at=item-(read<std::uintptr_t>(item+8)&mask);
+    return reinterpret_cast<void*>(at);
+}
+}
+bool sunburn_resident() noexcept {
+    if (!resolve()) return false;
+    __try { return blob(0x80B82489U,0x80809C0FU)!=nullptr; }
+    __except(EXCEPTION_EXECUTE_HANDLER) { return false; }
+}
+bool MovieResource::begin(std::uint32_t asset) noexcept {
+    if (held()) return asset_==asset;
+    if ((asset!=0x80BCA001U && asset!=0x80BCA003U) || !resolve()) return false;
+    auto* mgr=manager(); if (!mgr) return false;
+    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;
+    core::log::writef(core::log::Channel::client,core::log::Level::info,
+        "ev=ember_movie result=resource_requested asset=%08X root=%08X",asset_,root_);
+    return true;
+}
+int MovieResource::state() const noexcept {
+    if (!held() || !status) return -1;
+    __try { auto* root=blob(root_);return root ? status(root) : -1; }
+    __except(EXCEPTION_EXECUTE_HANDLER) { return -1; }
+}
+bool MovieResource::ready() const noexcept {
+    if (state()!=2) return false;
+    __try {
+        auto* wrapper=blob(asset_,0x80808495U);
+        if (!wrapper) return false;
+        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);
+        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; }
+}
+bool MovieResource::release() noexcept {
+    if (!held()) return true;
+    const auto value=state();
+    if (!resource_can_release(value)) return false;
+    destroy(manager(),root_);
+    core::log::writef(core::log::Channel::client,core::log::Level::info,
+        "ev=ember_movie result=resource_released asset=%08X root=%08X",asset_,root_);
+    root_=0xFFFFFFFFU;asset_=0;return true;
+}
+}

+ 15 - 0
Sunrise/src/client/hooks/ember_movies/resources.h

@@ -0,0 +1,15 @@
+#pragma once
+#include <cstdint>
+namespace sunrise::client::hooks::ember_movies {
+bool sunburn_resident() noexcept;
+// Native root owns the complete dependency graph until playback has released it.
+class MovieResource {
+    std::uint32_t root_{0xFFFFFFFFU}, asset_{};
+public:
+    bool begin(std::uint32_t asset) noexcept;
+    int state() const noexcept; // native root: 1 pending, 2 ready, 3 failed
+    bool ready() const noexcept;
+    bool release() noexcept; // false while pending: never block the frame draining I/O
+    bool held() const noexcept { return root_ != 0xFFFFFFFFU; }
+};
+}

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

@@ -0,0 +1,8 @@
+#pragma once
+#include <cstdint>
+namespace sunrise::client::hooks::ember_movies {
+constexpr bool ember_burn_source(std::uint32_t tag,std::uint32_t schema,
+    std::uint64_t offset,std::uint32_t asset) noexcept {
+    return tag==0x80B3C0C6U && schema==0x80809540U && offset==0xAC8U && asset==0x80C1D9E0U;
+}
+}

+ 2 - 0
docs/mission-ember-apex-cooling-and-scorch.md

@@ -1,3 +1,5 @@
+> Current correction: the native damage-object-only escape test did not burn the player. The new implementation uses one player attachment of the authored sunburn effect and keeps that object disabled. See [final corrections](mission-ember-final-corrections.md) for evidence, scope and current validation; earlier experiments below are historical.
+
 # 1AU Apex and ending corrections — 6 September 2026
 
 ## Current implementation

+ 69 - 0
docs/mission-ember-final-corrections.md

@@ -0,0 +1,69 @@
+# 1AU: final playback, burn and surge correction
+
+## Confirmed failure and plan
+
+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.
+
+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.
+
+Implement and verify the following:
+
+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.
+
+## Executed implementation
+
+### Native movie resource lifetime
+
+The bridge follows the existing startup loader `B46E10`:
+
+- `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.
+
+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.
+
+### Single sunburn attachment
+
+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.
+
+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 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.
+
+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.
+
+### Audio
+
+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.
+
+## Failure cases checked
+
+| 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 |
+
+## Validation and remaining live test
+
+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.
+
+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`.
+
+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.

+ 4 - 0
docs/mission-ember-prerendered-ending.md

@@ -13,6 +13,10 @@ The two Ember bookends differ from the Omega in-engine cinematic:
 
 The placement's entity reference is at +0x30. Both entities contain class 808065EB, configured by 808065EC. The component config begins at +0xC8; its movie identifier is at config+0x4C (file+0x114). The mapped native class table at 1CDB480 identifies the corresponding component operations. These are package/native-code observations, not inferred from the slot names.
 
+## 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.
+
 ## Native playback bridge
 
 Native pre-rendered component start DDB0F0 acquires the movie manager with 41B040 / 41A3C0, then calls 41CD20(manager, movieAsset, 0). Stop DDB830 calls 41D0C0 and balances its acquire with 41A980. The new bridge follows that acquire/play/stop/release contract for exactly the two assets above. It does not instantiate a dummy cinematic controller or manufacture type-6 incidents.

+ 9 - 13
scripts/mission_ember/apex.lua

@@ -96,18 +96,15 @@ return function(m, a, ending)
         c:set_variable("ember.apex.surge", on)
         beam_pose(c, on, false)
     end
-    -- `REACTOR_COFFIN_INTERIOR_THERMAL_HOP_ON` and `FOUNDRY_THERMAL_DOT_HOP_ON` both reference
-    -- effect resource 80C1D9E0 -- the burn already working in the Foundry. The hot-pipe and
-    -- rail-top hop-ons carry 80B82484 and 80C1D389 instead, which is why contact read as a
-    -- shock rather than a scorch. Use the authored scorch for the climb pipes. A new revision
-    -- removes the previous attachment (native 9EF8A0/9F1F10) before attaching the new filter.
+    -- Native slot 43 substitutes the actual sunburn attachment 80B82489 for the
+    -- Foundry thermal resource. One slot owns climb/escape damage: a filter revision
+    -- detaches the old child before attaching the new one. Never combine it with the volume object.
     local function rail_filter(c) return {players = true, inside = a.slot(c, "SLOT_019E")} end
     local function hazards(c, s, mode)
         local wanted = mode or "off"
         if s:variable("ember.apex.hazard_mode") == wanted then return end
         c:set_variable("ember.apex.hazard_mode", wanted)
-        -- The placed native sunburn object owns escape damage and its authored bounds.
-        a.objects(c, {"SUNBURN_DAMAGE_OBJECT"}, mode == "escape")
+        a.objects(c, {"SUNBURN_DAMAGE_OBJECT"}, false)
         if mode == "climb" then
             -- The five narrow authored pipe volumes on the way up to the deposit; their heights
             -- track the climb the mother-brain dialogue volumes walk through, from z~172 at
@@ -125,9 +122,9 @@ return function(m, a, ending)
                     a.slot(c, "SLOT_0005_80B3C09F"), a.slot(c, "SLOT_0006_80B3C09F"),
                     a.slot(c, "SLOT_0008_80B3C09F")}}, true)
         else
-            -- End the climb attachment; never add a second rail-wide damage effect.
+            -- Reuse the same attachment owner on the authored escape rail volume.
             a.effect(c, s, "REACTOR_COFFIN_INTERIOR_THERMAL_HOP_ON",
-                "AOD_REACTOR_RAIL_TOP_OBJECT_FILTER", rail_filter(c), false)
+                "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
@@ -171,10 +168,9 @@ return function(m, a, ending)
         end
         c:cancel_timer(surge_audio_timer(s))
         if step == "closed" then
-            -- Playtest: requesting the sequence at surge start made its sound land at
-            -- cooling-door opening. The next playtest requested four more seconds
-            -- of lead: pre-roll ten seconds before the unchanged visual surge.
-            c:start_timer(surge_audio_timer(s), 4000)
+            -- 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

+ 19 - 0
tests/ending_retirement_test.cpp

@@ -1,5 +1,7 @@
 #include <array>
 #include <cassert>
+#include "../Sunrise/src/client/hooks/ember_movies/readiness_rules.h"
+#include "../Sunrise/src/client/hooks/ember_movies/sunburn_rules.h"
 #include "../Sunrise/src/client/hooks/ember_movies/playback_rules.h"
 #include "../Sunrise/src/client/hooks/mission_retirement/mission_retirement.h"
 #include "../Sunrise/src/middleware/bap/activity_message/roster_presence.h"
@@ -20,6 +22,23 @@ static void expect(Reader& reader, unsigned width, std::uint64_t expected) {
 }
 int main() {
     namespace movies=sunrise::client::hooks::ember_movies;
+    // 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));
+    assert(!movies::movie_resources_ready(2,0x80BCA001,true,0x80BCA000,false,0x80BCA034));
+    assert(!movies::movie_resources_ready(2,0x80BCA001,true,0x80BCA002,true,0x80BCA034));
+    assert(!movies::movie_resources_ready(3,0x80BCA001,true,0x80BCA000,true,0x80BCA034));
+    assert(!movies::movie_resources_ready(2,0x80BCA001,true,0x80BCA000,true,0xFFFFFFFF));
+    assert(movies::movie_resources_ready(2,0x80BCA001,true,0x80BCA000,true,0x80BCA034));
+    assert(movies::movie_resources_ready(2,0x80BCA003,true,0x80BCA002,true,0x80C7C000));
+    assert(!movies::resource_can_release(0) && !movies::resource_can_release(1));
+    assert(movies::resource_can_release(2) && movies::resource_can_release(3));
+    // Only Ember slot 43 may borrow sunburn. The global Foundry effect and rail shock stay distinct.
+    assert(movies::ember_burn_source(0x80B3C0C6,0x80809540,0xAC8,0x80C1D9E0));
+    assert(!movies::ember_burn_source(0x80BEB26F,0x80809540,0xAC8,0x80C1D9E0));
+    assert(!movies::ember_burn_source(0x80B3C0C6,0x8080953F,0xAC8,0x80C1D9E0));
+    assert(!movies::ember_burn_source(0x80B3C0C6,0x80809540,0xAC0,0x80C1D9E0));
+    assert(!movies::ember_burn_source(0x80B3C0C6,0x80809540,0xAC8,0x80C1D389));
     movies::Playback playback;
     assert(playback.observe(false,5,true)==movies::Status::preparing);
     assert(playback.observe(true,0,false)==movies::Status::preparing);

+ 16 - 24
tests/mission_ember_routes_test.lua

@@ -271,7 +271,7 @@ 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']==4000,'audio pre-roll must lead the unchanged visual surge by ten seconds')
+assert(timers['ember.apex.surge_audio.1']==1,'request the sound four seconds earlier without moving the visual clock')
 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')
@@ -396,34 +396,26 @@ assert(vars['ember.carry.apex.done'] and not vars['ember.carry.apex.held'])
 local before=#calls;use('MOTHER_BRAIN_INTERACT_OBJECT');assert(#calls==before)
 call(R.dispatch,'object',c,s,event('MOTHER_BRAIN_CARRY_OBJECT',{generation=3,present=false,alive=false}))
 assert(not timers['ember.carry.recover.apex.3'],'consumed final cell respawned')
--- Deposit disables the scripted climb burn; native sunburn alone owns escape damage.
-local detachedBeforeRail=false
-for i=depositStart+1,#calls do
-    if calls[i][1]=='set_mission_effect' and calls[i][3].enabled==false then detachedBeforeRail=true end
-end
-assert(detachedBeforeRail,'deposit must detach the pipe burn before escape')
--- No scripted rail burn is attached during escape.
-call(R.dispatch,'timer',c,s,{timer_name='ember.apex.hazards'})
-local escapeEffect
-for i=#calls,1,-1 do
-    if calls[i][1]=='set_mission_effect' then escapeEffect=calls[i][3];break end
-end
-assert(escapeEffect and escapeEffect.enabled==false,
-    'escape must leave the additional scripted burn disabled')
+-- Deposit moves the ONE burn attachment from pipes to the escape rail.
+local burnSlot=slotDefs[m.Slot.REACTOR_COFFIN_INTERIOR_THERMAL_HOP_ON].name
+local escapeAttachments=0
+local sunburnState
 for i=depositStart+1,#calls do
-    assert(not (calls[i][1]=='set_mission_effect' and calls[i][3].enabled),
-        'deposit must not add a second damage attachment')
+    local row=calls[i]
+    if row[1]=='set_mission_effect' and row[3].enabled then
+        assert(row[2]==burnSlot,'escape created a second independent damage source')
+        assert(row[3].filter==c:slot(m.Slot.AOD_REACTOR_RAIL_TOP_OBJECT_FILTER))
+        escapeAttachments=escapeAttachments+1
+    end
+    if row[1]=='set_object_active' and row[2]==slotDefs[m.Slot.SUNBURN_DAMAGE_OBJECT].name then
+        sunburnState=row[3].active
+    end
 end
+assert(escapeAttachments==1,'deposit must move the existing burn to the escape filter exactly once')
+assert(sunburnState==false,'native damage volume would stack with the player attachment')
 local afterRail=#calls
 call(R.dispatch,'timer',c,s,{timer_name='ember.apex.hazards'})
 assert(#calls==afterRail,'duplicate hazard callback must not reattach scorch')
-local sunburnState
-for i=depositStart+1,#calls do
-    if calls[i][1]=='set_object_active' and calls[i][2]==slotDefs[m.Slot.SUNBURN_DAMAGE_OBJECT].name then
-        sunburnState=calls[i][3].active
-    end
-end
-assert(sunburnState==true,'escape must activate its authored sunburn object')
 -- The weapon is dead once the cell is in: powered off, but still installed. Deactivating the
 -- ring objects would take the beam and its surrounding structure out of the world entirely.
 assert(vars['ember.apex.beam']==false,'the beam must stop firing after the deposit')

+ 41 - 0
tests/verify_ember_movie_native.py

@@ -0,0 +1,41 @@
+"""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
+"""
+import re
+import struct
+import sys
+from pathlib import Path
+
+repo = Path(__file__).resolve().parents[1]
+data = Path(sys.argv[1]).read_bytes()
+
+def signature(file, name):
+    source = (repo / file).read_text()
+    pattern = re.search(r'constexpr auto ' + name + r'\s*=\s*signature<signature_length\("([^"]+)"\)', source)[1]
+    regex = b''.join(b'.' if token == '?' else re.escape(bytes([int(token, 16)])) for token in pattern.split())
+    matches = [m.start() for m in re.finditer(regex, data, re.S)]
+    assert len(matches) == 1, (name, matches)
+    return matches[0]
+
+def target(base, offset, expected):
+    assert data[base + offset] == 0xE8
+    value = base + offset + 5 + struct.unpack_from('<i', data, base + offset + 1)[0]
+    assert value == expected, (hex(base), hex(offset), hex(value), hex(expected))
+
+path = 'Sunrise/src/client/hooks/ember_movies/resources.cpp'
+load = signature(path, 'loadSig')
+end = signature(path, 'endSig')
+assert load == 0xB46E10 and end == 0xB44020
+for offset, expected in [(0x96, 0x4294D0), (0xD1, 0x423EF0), (0x14C, 0x4312D0), (0x157, 0x435AA0)]:
+    target(load, offset, expected)
+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
+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.')