Browse Source

Play Ember pre-rendered bookends directly before mission completion

Millie 2 ngày trước cách đây
mục cha
commit
d699e5cda5

+ 9 - 1
Sunrise/Sunrise.vcxproj

@@ -280,7 +280,13 @@
     <ClCompile Include="src\client\hooks\bootflow\orbit_slice_set.cpp" />
     <ClCompile Include="src\client\hooks\bootflow\region_private.cpp" />
     <ClCompile Include="src\client\hooks\bootflow\world_step.cpp" />
-    <ClCompile Include="src\client\hooks\bootflow\spawn\slice_set_sample.cpp" />
+    <ClCompile Include="src\client\hooks\ember_movies\ember_movies.cpp" />
+    <ClCompile Include="src\client\hooks\bootflow\ember_movie_tick.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" />
+    <ClCompile Include="src\client\hooks\bootflow\spawn\spawn_gate_targets.cpp" />
+    <ClCompile Include="src\client\hooks\bootflow\spawn\spawn_gate_record_dump.cpp" />
     <ClCompile Include="src\server\bap\encrypted\push\activity\activity_arrival.cpp" />
     <ClCompile Include="src\middleware\bap\activity_host_manager\request\selection\activity_manager_selection_bits.cpp" />
     <ClCompile Include="src\middleware\bap\activity_host_manager\request\selection\activity_manager_selection_snapshot.cpp" />
@@ -1241,6 +1247,8 @@
     <ClCompile Remove="vendor\lua\lzio.c" />
   </ItemGroup>
   <ItemGroup>
+    <ClInclude Include="src\client\hooks\ember_movies\ember_movies.h" />
+    <ClInclude Include="src\client\hooks\ember_movies\playback_rules.h" />
     <ClInclude Include="resources\resource.h" />
     <ClInclude Include="src\core\logging\log.h" />
     <ClInclude Include="src\core\logging\snapshot\snapshot.h" />

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

@@ -29,6 +29,7 @@ constexpr std::array kFixes{
     Fix{&stage_composition_check, &publish_composition_check},
     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_owner_activity_slot, &publish_owner_activity_slot},
     Fix{&stage_region_private, &publish_region_private},
 };
@@ -109,6 +110,7 @@ void uninstall() noexcept {
     uninstall_region_private();
     uninstall_owner_activity_slot();
     uninstall_loading_cinematics();
+    uninstall_ember_movie_tick();
     uninstall_orbit_handoff();
     uninstall_composition_check();
     uninstall_orbit_slice_set();

+ 34 - 0
Sunrise/src/client/hooks/bootflow/ember_movie_tick.cpp

@@ -0,0 +1,34 @@
+#include "internal.h"
+#include "bootflow_hook_lifecycle.h"
+#include "../ember_movies/ember_movies.h"
+#include "../../../core/logging/log.h"
+namespace sunrise::client::hooks::bootflow {
+namespace {
+hooking::detour::Handle handle{};
+using Tick = void(__fastcall*)(void*);
+void __fastcall movie_tick(void* decoder) noexcept {
+    if (auto original=reinterpret_cast<Tick>(handle.original)) original(decoder);
+    // Video presentation can suspend the player-camera callback. Observe completion
+    // from the movie player's own frame as well, including its final stopped frame.
+    if (ember_movies::active()) poll_current_slice_set();
+}
+}
+StageResult stage_ember_movie_tick(hooking::detour::Spec& spec) noexcept {
+    if (handle.attached) return StageResult::attached;
+    constexpr auto sig=signature<signature_length("4C 8B DC 55 57 49 8D AB 58 FE FF FF 48 81 EC 98 02 00 00 48 8B 05 ? ? ? ? 48 33 C4 48 89 85 50 01 00 00 48 8B F9 48 8B 49 08")>(
+        "4C 8B DC 55 57 49 8D AB 58 FE FF FF 48 81 EC 98 02 00 00 48 8B 05 ? ? ? ? 48 33 C4 48 89 85 50 01 00 00 48 8B F9 48 8B 49 08");
+    auto* target=scan_main_image_unique(sig,"ember_movie_frame");
+    if (!target) return StageResult::unavailable;
+    spec={target,reinterpret_cast<void*>(&movie_tick)};return StageResult::staged;
+}
+void publish_ember_movie_tick(const hooking::detour::Handle& value) noexcept {
+    handle=value;
+    ember_movies::frame_ready(value.attached);
+    core::log::write(core::log::Channel::client,core::log::Level::info,
+        value.attached ? "ev=ember_movie result=frame_attached" : "ev=ember_movie result=frame_attach_failed");
+}
+void uninstall_ember_movie_tick() noexcept {
+    ember_movies::frame_ready(false);
+    static_cast<void>(hooking::detour::uninstall(handle));
+}
+}

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

@@ -76,6 +76,10 @@ void publish_orbit_handoff(const hooking::detour::Handle& handle) noexcept;
 /** Detaches the orbit handoff release. */
 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;
+
 /** LoadingCinematics_Suppressed: travel-only suppression, separate from movie Auth. */
 [[nodiscard]] StageResult stage_loading_cinematics(hooking::detour::Spec& spec) noexcept;
 void publish_loading_cinematics(const hooking::detour::Handle& handle) noexcept;

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

@@ -9,6 +9,7 @@
 #include "internal.h"
 #include "spawn/probe.h"
 #include "../mission_retirement/mission_retirement.h"
+#include "../ember_movies/ember_movies.h"
 
 namespace sunrise::client::hooks::bootflow {
 namespace {
@@ -62,6 +63,7 @@ void poll_world_step() noexcept {
 void poll_current_slice_set() noexcept {
     const std::int32_t index = spawn::sample_current_slice_set();
     mission_retirement::poll(index);
+    ember_movies::poll(index, read_step());
     const std::int32_t previous = g_publishedSliceSet.load(std::memory_order_relaxed);
     // A slice-set change is a world replacement whose transition arms a fresh fade, and a
     // teleport never passes the off-destination step that re-arms the release. Re-arm here or

+ 162 - 0
Sunrise/src/client/hooks/ember_movies/ember_movies.cpp

@@ -0,0 +1,162 @@
+#include "ember_movies.h"
+#include "playback_rules.h"
+#include <Windows.h>
+#include <array>
+#include <atomic>
+#include <cstdio>
+#include <cstring>
+#include "../../../core/logging/log.h"
+#include "../../patterns/image_scan.h"
+#include "../../patterns/signature_text.h"
+namespace sunrise::client::hooks::ember_movies {
+namespace {
+using namespace patterns;
+using Accessor = void*(__fastcall*)();
+using Operation = void(__fastcall*)(void*);
+using Play = void(__fastcall*)(void*, std::uint32_t, std::uint32_t);
+using Busy = bool(__fastcall*)(void*);
+struct Api { Accessor manager{}, decoder{}; Operation acquire{}, release{}, stop{}; Play play{}; Busy busy{}; } api;
+SRWLOCK lock = SRWLOCK_INIT;
+std::atomic_bool watching{false}, frameReady{false};
+Owner owner{};
+std::uint64_t key{}, began{};
+unsigned movie{};
+Status state{};
+bool acquired{}, stopRequested{}, attempted{}, escapeHeld{};
+Playback playback{};
+void* decoderOwner{};
+int lastDecoderState{-1};
+thread_local bool inPoll{};
+constexpr std::array<std::uint32_t, 2> assets{0x80BCA001U, 0x80BCA003U};
+void report(const char* result, int decoderState = -1) {
+    std::array<char, 240> text{};
+    std::snprintf(text.data(), text.size(),
+        "ev=ember_movie result=%s movie=%u asset=%08X request=%llu decoder_state=%d",
+        result, movie, movie >= 1 && movie <= 2 ? assets[movie-1] : 0,
+        static_cast<unsigned long long>(key), decoderState);
+    core::log::write(core::log::Channel::client, core::log::Level::info, text.data());
+}
+void* call_target(std::byte* code, std::size_t offset) {
+    if (!code || code[offset] != std::byte{0xE8}) return nullptr;
+    std::int32_t relative{}; std::memcpy(&relative, code + offset + 1, 4);
+    return code + offset + 5 + relative;
+}
+bool resolve() {
+    if (attempted) return api.play != nullptr;
+    attempted = true;
+    // Native pre-rendered component start DDB0F0: acquire manager then play config+4C.
+    constexpr auto startSig = signature<signature_length("40 56 48 83 EC 20 48 83 79 30 FF 48 8B F1 0F 85 ? ? ? ? E8 ? ? ? ? 84 C0 0F 85")>(
+        "40 56 48 83 EC 20 48 83 79 30 FF 48 8B F1 0F 85 ? ? ? ? E8 ? ? ? ? 84 C0 0F 85");
+    constexpr auto stopSig = signature<signature_length("40 53 48 83 EC 20 48 83 79 30 FF 48 8B D9 74 22 E8 ? ? ? ? 48 8B C8 E8 ? ? ? ? E8")>(
+        "40 53 48 83 EC 20 48 83 79 30 FF 48 8B D9 74 22 E8 ? ? ? ? 48 8B C8 E8 ? ? ? ? E8");
+    constexpr auto busySig = signature<signature_length("48 83 EC 28 83 79 58 FF 75 ? 8B 0D ? ? ? ? 33 D2 48 89 5C 24 20 32 DB")>(
+        "48 83 EC 28 83 79 58 FF 75 ? 8B 0D ? ? ? ? 33 D2 48 89 5C 24 20 32 DB");
+    auto* start = scan_main_image_unique(startSig, "ember_movie_start");
+    auto* stop = scan_main_image_unique(stopSig, "ember_movie_stop");
+    auto* busy = scan_main_image_unique(busySig, "ember_movie_busy");
+    Api candidate{};
+    candidate.manager = reinterpret_cast<Accessor>(call_target(start, 0x72));
+    candidate.acquire = reinterpret_cast<Operation>(call_target(start, 0x7A));
+    candidate.play = reinterpret_cast<Play>(call_target(start, 0x8E));
+    candidate.stop = reinterpret_cast<Operation>(call_target(stop, 0x18));
+    candidate.release = reinterpret_cast<Operation>(call_target(stop, 0x25));
+    candidate.busy = reinterpret_cast<Busy>(busy);
+    candidate.decoder = reinterpret_cast<Accessor>(call_target(busy, 0x48));
+    if (!candidate.manager || !candidate.acquire || !candidate.play || !candidate.stop
+        || !candidate.release || !candidate.busy || !candidate.decoder) { report("signature_failed"); return false; }
+    api = candidate; return true;
+}
+template<class T> T field(void* pointer, unsigned offset) {
+    T value{}; std::memcpy(&value, static_cast<std::byte*>(pointer)+offset, sizeof(value)); return value;
+}
+void release() {
+    if (acquired) { api.release(api.manager()); acquired=false; }
+}
+void fail(const char* reason) {
+    // Stop only our exact decoder asset, never another movie's playback.
+    if (acquired) {
+        auto* decoder=api.decoder();
+        if (decoder && decoder==decoderOwner && field<std::uint32_t>(decoder,0x1B4)==assets[movie-1])
+            api.stop(api.manager());
+        release();
+    }
+    state=Status::failed; watching.store(false); report(reason);
+}
+}
+bool request(Owner next, std::uint64_t nextKey, unsigned index, bool stop) noexcept {
+    if (!next.session || !next.generation || !nextKey || index<1 || index>2) return false;
+    AcquireSRWLockExclusive(&lock);
+    bool accepted=false;
+    if (stop) {
+        if (next==owner && index==movie && (state==Status::preparing || state==Status::playing)) {
+            stopRequested=true; accepted=true;
+        }
+    } else if (next==owner && nextKey==key && index==movie) {
+        accepted=state!=Status::failed;
+    } else if (!acquired && 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;
+        watching.store(true); report("queued"); accepted=true;
+    }
+    ReleaseSRWLockExclusive(&lock); return accepted;
+}
+Status status(Owner next, unsigned index) noexcept {
+    AcquireSRWLockShared(&lock);
+    auto value=next==owner && index==movie ? state : Status::absent;
+    ReleaseSRWLockShared(&lock); return value;
+}
+bool active() noexcept { return watching.load(); }
+void frame_ready(bool ready) noexcept { frameReady.store(ready); }
+void poll(std::int32_t region, std::int32_t step) noexcept {
+    if (inPoll) return;
+    inPoll=true;
+    struct Reset { ~Reset() { inPoll=false; } } reset;
+    AcquireSRWLockExclusive(&lock);
+    if (state!=Status::queued && state!=Status::preparing && state!=Status::playing) {
+        ReleaseSRWLockExclusive(&lock); return;
+    }
+    const auto now=GetTickCount64();
+    if (region!=0 || step!=38) { fail("world_changed"); ReleaseSRWLockExclusive(&lock); return; }
+    if (!frameReady.load()) { fail("frame_observer_unavailable"); ReleaseSRWLockExclusive(&lock); return; }
+    if (!resolve()) { state=Status::failed; watching.store(false); ReleaseSRWLockExclusive(&lock); return; }
+    auto* manager=api.manager();
+    auto* decoder=api.decoder();
+    if (!manager || !decoder) { fail("player_unavailable"); ReleaseSRWLockExclusive(&lock); return; }
+    if (state==Status::queued) {
+        if (api.busy(manager)) {
+            if (now-began>30000) fail("player_busy_timeout");
+        } else {
+            // Same acquire/play pairing as the authored pre-rendered component.
+            decoderOwner=decoder; api.acquire(manager); acquired=true;
+            api.play(manager,assets[movie-1],0); state=Status::preparing; began=now;
+            report("submitted");
+        }
+        ReleaseSRWLockExclusive(&lock); return;
+    }
+    if (decoder!=decoderOwner) { fail("decoder_owner_changed"); ReleaseSRWLockExclusive(&lock); return; }
+    const auto asset=field<std::uint32_t>(decoder,0x1B4);
+    const int decoderState=field<int>(decoder,0x1B0);
+    if (decoderState!=lastDecoderState) { report("decoder",decoderState); lastDecoderState=decoderState; }
+    const auto observed=playback.observe(asset==assets[movie-1],decoderState,api.busy(manager));
+    if (observed==Status::playing && state!=Status::playing) report("playing",decoderState);
+    // The direct video path has no type-6 source to emit a cinematic-skip incident.
+    // A foreground Escape press asks the original native movie player to stop; completion
+    // still requires the decoder's subsequent stopped/end receipt for this asset.
+    DWORD foregroundProcess{};
+    GetWindowThreadProcessId(GetForegroundWindow(),&foregroundProcess);
+    const bool escapeDown=(GetAsyncKeyState(VK_ESCAPE)&0x8000)!=0;
+    if (observed==Status::playing && foregroundProcess==GetCurrentProcessId()
+        && escapeDown && !escapeHeld) stopRequested=true;
+    escapeHeld=escapeDown;
+    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); }
+    else if (observed==Status::failed) fail("decoder_failed_or_replaced");
+    else state=observed;
+    if ((state==Status::preparing && now-began>30000)
+        || (state==Status::playing && now-began>600000)) fail("playback_timeout");
+    ReleaseSRWLockExclusive(&lock);
+}
+}

+ 12 - 0
Sunrise/src/client/hooks/ember_movies/ember_movies.h

@@ -0,0 +1,12 @@
+#pragma once
+#include <cstdint>
+namespace sunrise::client::hooks::ember_movies {
+// Only Ember's two packaged pre-rendered bookends are admitted by this bridge.
+struct Owner { std::uint64_t session{}, generation{}; bool operator==(const Owner&) const = default; };
+enum class Status : std::uint8_t { absent, queued, preparing, playing, complete, failed };
+bool request(Owner owner, std::uint64_t request, unsigned index, bool stop) noexcept;
+Status status(Owner owner, unsigned index) noexcept;
+bool active() noexcept;
+void frame_ready(bool ready) noexcept;
+void poll(std::int32_t region, std::int32_t step) noexcept;
+}

+ 17 - 0
Sunrise/src/client/hooks/ember_movies/playback_rules.h

@@ -0,0 +1,17 @@
+#pragma once
+#include "ember_movies.h"
+namespace sunrise::client::hooks::ember_movies {
+// A queued request and decoder preparation do not prove rendered playback.
+struct Playback {
+    bool seen{};
+    Status observe(bool exactAsset, int decoderState, bool busy) noexcept {
+        if (!exactAsset) return seen ? Status::failed : Status::preparing;
+        if (decoderState==5) seen=true;
+        if (!busy) {
+            if (decoderState!=0 && decoderState!=6) return Status::failed;
+            return seen ? Status::complete : Status::preparing;
+        }
+        return seen ? Status::playing : Status::preparing;
+    }
+};
+}

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

@@ -5,6 +5,7 @@
 #include <string_view>
 
 #include "mission_script_lua_internal.h"
+#include "../../../client/hooks/ember_movies/ember_movies.h"
 #include "mission_script_lua_names.h"
 #include "mission_script_lua_peer_internal.h"
 #include "mission_script_lua_resolve.h"
@@ -192,6 +193,35 @@ resolve_message_name(lua_State* state, std::string_view name, ActivityMessageDef
     return queue_intent(state, frame, intent);
 }
 
+/** Ember's exact packaged movie pair; work is committed as an ordinary mission intent. */
+int context_play_prerendered_movie(lua_State* state) {
+    static_cast<void>(luaL_checkudata(state,1,kContextMetatable));
+    const auto* impl=impl_from_state(state);
+    if (impl->identity.publicTarget || impl->identity.definitionHash!=0x38F926B2U)
+        return luaL_error(state,"pre-rendered movie bridge is scoped to mission_ember");
+    static constexpr std::array<std::string_view,2> fields{"index","stop"};
+    refuse_unknown_arguments(state,fields);
+    const auto index=optional_integer_argument(state,"index",0);
+    if (index<1 || index>2) return luaL_error(state,"unknown Ember bookend index");
+    lua_getfield(state,2,"stop"); const bool stop=lua_toboolean(state,-1); lua_pop(state,1);
+    Intent intent{};intent.kind=IntentKind::playPrerenderedMovie;
+    intent.firstRow=static_cast<std::uint32_t>(index);intent.active=!stop;
+    return queue_intent(state,active_frame(state),intent);
+}
+int context_prerendered_movie_status(lua_State* state) {
+    static_cast<void>(luaL_checkudata(state,1,kContextMetatable));
+    const auto index=luaL_checkinteger(state,2);
+    auto& frame=active_frame(state);
+    const auto* impl=impl_from_state(state);
+    if (impl->identity.definitionHash!=0x38F926B2U || impl->identity.publicTarget
+        || !frame.event || index<1 || index>2) { lua_pushliteral(state,"absent");return 1; }
+    namespace movies=client::hooks::ember_movies;
+    const auto result=movies::status({frame.event->binding.sessionId,frame.event->sourceGeneration},
+        static_cast<unsigned>(index));
+    constexpr std::array<const char*,6> names{"absent","queued","preparing","playing","complete","failed"};
+    lua_pushstring(state,names[static_cast<unsigned>(result)]);return 1;
+}
+
 /** Lua index for the mission context: its collections, phase, variables and timers. */
 [[nodiscard]] int context_index(lua_State* state) {
     static_cast<void>(luaL_checkudata(state, 1, kContextMetatable));
@@ -221,6 +251,10 @@ resolve_message_name(lua_State* state, std::string_view name, ActivityMessageDef
         lua_pushcfunction(state, &context_scene);
     } else if (key == "slot") {
         lua_pushcfunction(state, &context_slot);
+    } else if (key == "play_prerendered_movie") {
+        lua_pushcfunction(state, &context_play_prerendered_movie);
+    } else if (key == "prerendered_movie_status") {
+        lua_pushcfunction(state, &context_prerendered_movie_status);
     } else if (key == "select_state") {
         lua_pushcfunction(state, &context_select_state);
     } else if (key == "restart_checkpoint") {

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

@@ -62,6 +62,8 @@ push_incident_revision_member(lua_State* state, const host::Event& event, std::s
         return "slot.advance_task";
     case ActionKind::playDialogueCue:
         return "slot.play_dialogue_cue";
+    case ActionKind::playPrerenderedMovie:
+        return "mission.play_prerendered_movie";
     case ActionKind::selectMissionState:
         return "mission.select_state";
     }

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

@@ -263,6 +263,9 @@ void complete_delivery(RuntimeInstance& instance) noexcept {
     case lua_vm::IntentKind::playDialogueCue:
         result = "dialogue_staged";
         break;
+    case lua_vm::IntentKind::playPrerenderedMovie:
+        result = "movie_queued";
+        break;
     case lua_vm::IntentKind::selectMissionState:
         result = "state_selected";
         break;

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

@@ -18,6 +18,7 @@
 #include "mission_script_runtime.h"
 #include "mission_script_runtime_internal.h"
 #include "../../../client/hooks/mission_retirement/mission_retirement.h"
+#include "../../../client/hooks/ember_movies/ember_movies.h"
 
 // The intent fan-out reserves one Host output revision, then asks one typed adapter to encode it.
 
@@ -285,6 +286,17 @@ void dispatch_intent(RuntimeInstance& instance, std::uint64_t now) noexcept {
     }
     begin_intent_attempt(instance, now);
     switch (intent.kind) {
+    case lua_vm::IntentKind::playPrerenderedMovie: {
+        const auto activities=instance.view.catalog->activities();
+        if (instance.publicTarget || instance.view.activityRow>=activities.size()
+            || activities[instance.view.activityRow].definitionHash!=0x38F926B2U
+            || instance.activeRegion!=0
+            || !client::hooks::ember_movies::request({instance.view.binding.sessionId,
+                instance.view.activityClientGeneration},intent.requestKey,intent.firstRow,!intent.active)) {
+            refuse_delivery(instance,"movie_refused","native movie request unavailable",host::EffectOutcome::refused);
+        } else static_cast<void>(complete_local_effect(instance,"movie_queued"));
+        return;
+    }
     case lua_vm::IntentKind::selectMissionState: {
         scenes::Snapshot selected{};
         const scenes::Status status =

+ 1 - 0
Sunrise/src/state/activity/mission/definition.h

@@ -76,6 +76,7 @@ enum class IntentKind : std::uint8_t {
     actorCommand,
     playPerformance,
     restartCheckpoint,
+    playPrerenderedMovie,
 };
 
 /** One object a mission omits, named the way a roster group is: its tag and its registry key. */

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

@@ -2,6 +2,8 @@
 
 ## Current implementation
 
+The ending route now uses the native pre-rendered player directly; see [the current ending mechanism](mission-ember-prerendered-ending.md). The world-transfer experiments below are retained as historical failure evidence. Direct playback still needs live confirmation.
+
 - Cooling doors open after the surge stops. Visual/mechanical intervals remain 14 seconds closed, 6 seconds surging, 10 seconds exposed.
 - The user confirmed those visuals/mechanics align, but hears the surge at cooling-door opening. The next live test requested another four seconds of lead. Audio sequences now pre-roll at closed-window second 4, ten seconds before the visual surge. This is playtest-based compensation, not a recovered native delay parameter. Pending audio is phase/region/generation guarded and cancelled on reset, core destruction and deposit.
 - Initial beam: snapping to the resting endpoint still showed the incomplete beam in the first user screenshot. Native device position/power both reached 1. The new candidate seeks the driven endpoint once on laser creation, then animates to the resting endpoint so authored animation events can run. Duplicate presence and late presence after deposit cannot restart it. The target is the second user screenshot's thin, continuous beam. Needs visual confirmation.

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

@@ -0,0 +1,38 @@
+# Ember's pre-rendered ending
+
+## Why the world transition was wrong for this path
+
+The a8d3064 live test removed all eight Apex groups, then froze in `network_send` immediately after teleporting to bookend region 1. No movie controller appeared. This is a remaining teardown defect, not a movie playback failure. The direct path below avoids that transition; it does not repair the general teardown defect.
+
+The two Ember bookends differ from the Omega in-engine cinematic:
+
+| Bookend | Placement | Entity | Component | Movie asset |
+| --- | --- | --- | --- | --- |
+| STM (first) | 80B3C222 | 80B38179 | 80BDDC62 | 80BCA001 |
+| CNN (second) | 80B3C226 | 80B3817B | 80BDDC67 | 80BCA003 |
+
+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.
+
+## 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.
+
+The API is resolved through unique executable signatures on the component operations, and verified relative call targets; no fixed address is used for executable calls. Offline matching against the saved game image resolved start DDB0F0, stop DDB830, busy predicate 41B420, manager accessor 41B040, decoder accessor 41AB70 and movie frame 41D140.
+
+Movie requests are ordinary committed mission intents, limited to private mission_ember in Apex. Lua uses `context:play_prerendered_movie{index=1|2}` and reads `context:prerendered_movie_status(index)`. Each native request carries session, ActivityClient generation and request identity. Startup waits for an idle native player; another movie is never replaced. Calls execute on the game frame, not the network delivery thread.
+
+The decoder's current asset is +0x1B4 and native state is +0x1B0. Rendering in 41D140 is gated on state 5. The bridge requires state 5 for its exact asset before reporting playback, then requires the native busy predicate to clear with stopped/end state 0 or 6 before completion. An error, replaced decoder, changed asset after playback, or changed world fails without completing the mission. Queue/preparation have 30-second bounds; playback has a 600-second bound. These deadlines fail, never award completion.
+
+The movie frame is observed after its original routine, as well as the player-camera frame. This is necessary because movie presentation may stop the player-camera callback. The extra frame poll is active only while this bridge has a request. Foreground Escape invokes the same native stop operation, edge-triggered only during confirmed playback. Direct videos have no type-6 source to emit the existing cinematic-skip incident, so unrelated/stale type-6 incidents are ignored. Completion still waits for the native stopped receipt.
+
+## Mission ordering
+
+Escape disables scorch and starts STM without selecting another mission state. Apex remains loaded while the native player owns video presentation. A bounded Lua timer reads playback status every 250 ms, so advancing the movies does not depend on a new client-state delta. Confirmed STM completion queues CNN. Only confirmed CNN completion sets `ember.complete`, phase 100, and native lifetime state 6. Gameplay route callbacks remain gated while the ending is active.
+
+This removes the failing transition from the ending route. General world teardown, the old type-6 bookend transfer code, and Omega's in-engine resource readiness rules are not claimed fixed by this change.
+
+## Validation
+
+All 23 portable tests and five Lua mission suites pass. Coverage rejects queue/preparation as completion, wrong assets, decoder errors, stale type-6 incidents, duplicate completion, and mission completion after only the first movie. It also checks that the ending requests no world selection and that native completion following a skip follows the same sequence. All four native signatures and six relative-call targets match the saved executable image.
+
+Live playback, video/audio presentation, both Escape skips and the post-movie mission-complete presentation still require a game test. Diagnostics use `ev=ember_movie` with queued, submitted, decoder, playing, stop_requested and complete, or a specific failure reason.

+ 39 - 48
scripts/mission_ember/ending.lua

@@ -1,20 +1,15 @@
--- Post-escape bookends are distinct native packed-region entries (1 and 2).
--- Selection arms travel; offer playback only on the matching held-region receipt.
--- Never confuse an offered command, a skip request, or a failed start with completion.
+-- Ember bookends are pre-rendered movies. Keep Apex loaded while the native movie
+-- player owns presentation; never tear down the gameplay world to create type-6 actors.
 return function(m)
-    local movies = {
-        {state = m.states.STATE_80B3C09E_0000_0001_80B3C091, slot = m.Slot.PF_CINEMATIC_BOOKEND_STM_CINEMATIC},
-        {state = m.states.STATE_80B3C09E_0000_0002_80B3C093, slot = m.Slot.PF_CINEMATIC_BOOKEND_CNN_CINEMATIC},
-    }
     local E = {}
     local music = require("mission_ember.music")(m)
     local function play(c, index)
-        local row = assert(movies[index])
         c:set_variable("ember.ending", index)
         c:clear_variable("ember.ending.playing")
-        c:clear_variable("ember.ending.runtime")
         c:clear_variable("ember.ending.stopping")
-        c:select_state(assert(row.state))
+        c:play_prerendered_movie{index = index}
+        c:set_variable("ember.ending.polls", 0)
+        c:start_timer("ember.ending.poll", 250)
     end
     function E.start(c, s)
         if s:variable("ember.ending") then return end
@@ -23,47 +18,43 @@ return function(m)
     end
     function E.client(c, s, e)
         local index = s:variable("ember.ending")
-        local row = index and movies[index]
-        if not row or s:variable("ember.ending.offered") == index
-            or e.held_region_index ~= row.state.region_index then return end
-        c:set_variable("ember.ending.offered", index)
-        c:slot(row.slot):set_cinematic_active{active = true}
+        if not index or index > 2 then return end
+        local status = c:prerendered_movie_status(index)
+        if status == "playing" then
+            if s:variable("ember.ending.playing") ~= index then c:set_variable("ember.ending.playing", index) end
+        elseif status == "complete" then
+            -- The native bridge requires real playback before it can report completion,
+            -- including when a short/skip transition happens between script callbacks.
+            if index == 1 then play(c, 2)
+            else
+                c:cancel_timer("ember.ending.poll")
+                c:set_variable("ember.ending", 3)
+                c:set_variable("ember.complete", true)
+                c.lifetime:set{state = c.sdk.lifetime_states:at(6)}
+                c:set_phase(100)
+            end
+        elseif status == "failed" then
+            c:set_variable("ember.ending.failed", true)
+        end
     end
-    local function matched(c, s, e)
+    function E.timer(c, s, e)
+        if e.timer_name ~= "ember.ending.poll" then return true end
+        E.client(c, s, e)
         local index = s:variable("ember.ending")
-        local row = index and movies[index]
-        if not row then return end
-        local slot = c:slot(row.slot)
-        if e.registry_key ~= slot.registry_key or e.slot_type ~= slot.slot_type or e.slot_index ~= slot.slot_index then return end
-        return index, slot
-    end
-    function E.started(c, s, e)
-        local index = matched(c, s, e)
-        if not index or s:variable("ember.ending.offered") ~= index or s:variable("ember.ending.playing") or type(e.runtime_object_id) ~= "string" then return end
-        c:set_variable("ember.ending.playing", index)
-        c:set_variable("ember.ending.runtime", e.runtime_object_id)
-    end
-    function E.skip(c, s, e)
-        local index, slot = matched(c, s, e)
-        if not index or s:variable("ember.ending.playing") ~= index
-            or e.runtime_object_id ~= s:variable("ember.ending.runtime")
-            or s:variable("ember.ending.stopping") then return end
-        c:set_variable("ember.ending.stopping", true)
-        slot:set_cinematic_active{active = false}
-    end
-    function E.terminated(c, s, e)
-        local index, slot = matched(c, s, e)
-        if not index or s:variable("ember.ending.playing") ~= index
-            or e.runtime_object_id ~= s:variable("ember.ending.runtime") then return end
-        slot:set_cinematic_active{active = false}
-        if movies[index + 1] then play(c, index + 1)
-        else
-            c:set_variable("ember.ending", index + 1)
-            c:set_variable("ember.complete", true)
-            -- Native lifetime 6 enters the completion/reward branch (BEA9D0/B37100).
-            c.lifetime:set{state = c.sdk.lifetime_states:at(6)}
-            c:set_phase(100)
+        if index and index <= 2 and not s:variable("ember.ending.failed") then
+            local polls = (s:variable("ember.ending.polls") or 0) + 1
+            c:set_variable("ember.ending.polls", polls)
+            -- Also bound a refused delivery that never reached the native bridge.
+            if polls >= 2600 then c:set_variable("ember.ending.failed", true)
+            else c:start_timer("ember.ending.poll", 250) end
         end
+        return true
     end
+    -- Direct movies have no type-6 source: the native bridge handles local Escape,
+    -- invokes the native stop routine, and waits for the decoder to stop.
+    function E.skip(c, s, e) end
+    -- A type-6 incident cannot complete a pre-rendered movie request.
+    function E.started(c, s, e) end
+    function E.terminated(c, s, e) end
     return E
 end

+ 5 - 2
scripts/mission_ember/routes.lua

@@ -10,7 +10,7 @@ return function(m)
     controllers[0] = require("mission_ember.apex")(m, a, ending)
     local R = {}
     function R.client(c, s, e)
-        -- Native travel must reach the exact bookend before playback is offered.
+        -- Pre-rendered playback is observed without replacing the gameplay world.
         if s:variable("ember.ending") then ending.client(c, s, e); return end
         local active = controllers[s:variable("ember.region")]
         if not active then return end
@@ -30,7 +30,10 @@ return function(m)
         if active.guidance then active.guidance(c, s) end
     end
     function R.dispatch(method, c, s, e)
-        if s:variable("ember.ending") then return end
+        if s:variable("ember.ending") then
+            if method == "timer" then return ending.timer(c, s, e) end
+            return
+        end
         if method == "timer" then
             for _, region in ipairs({56, 40, 0}) do
                 local controller = controllers[region]

+ 16 - 0
tests/ending_retirement_test.cpp

@@ -1,5 +1,6 @@
 #include <array>
 #include <cassert>
+#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"
 #include "../Sunrise/src/middleware/encoding/bit_reader.h"
@@ -18,6 +19,21 @@ static void expect(Reader& reader, unsigned width, std::uint64_t expected) {
     assert(reader.read(static_cast<std::uint8_t>(width), value) && value == expected);
 }
 int main() {
+    namespace movies=sunrise::client::hooks::ember_movies;
+    movies::Playback playback;
+    assert(playback.observe(false,5,true)==movies::Status::preparing);
+    assert(playback.observe(true,0,false)==movies::Status::preparing);
+    assert(playback.observe(true,6,false)==movies::Status::preparing);
+    assert(playback.observe(true,3,true)==movies::Status::preparing);
+    assert(playback.observe(true,5,true)==movies::Status::playing);
+    assert(playback.observe(true,6,true)==movies::Status::playing);
+    assert(playback.observe(true,6,false)==movies::Status::complete);
+    assert(playback.observe(false,6,false)==movies::Status::failed);
+    assert(playback.observe(true,7,false)==movies::Status::failed);
+    movies::Playback skipped;
+    assert(skipped.observe(true,5,true)==movies::Status::playing);
+    assert(skipped.observe(true,0,false)==movies::Status::complete);
+
     std::array<std::uint32_t, 5> history{102, 103, 104};
     std::size_t historyCount = 3;
     const std::array<std::uint32_t, 3> movieOne{105, 102, 104};

+ 29 - 31
tests/mission_ember_routes_test.lua

@@ -25,6 +25,12 @@ end
 function c:cancel_timer(k) timers[k] = nil end
 function c:set_phase(p) record("phase",p) end
 function c:select_state(state) assert(state.region_index); record("select",state.region_index) end
+local movieStatus={}
+function c:play_prerendered_movie(args)
+    record('movie',args.index,args)
+    if not args.stop then movieStatus[args.index]='queued' end
+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,
@@ -460,40 +466,32 @@ for i=#calls,1,-1 do
     end
 end
 assert(sunburnAfterEscape==false,'escape completion must disable native sunburn')
--- A selected bookend is not loaded merely because the player holds Apex's base world.
-local beforeArrival=#calls
+-- Direct playback leaves Apex loaded and never selects a bookend world.
+assert(vars['ember.ending']==1 and movieStatus[1]=='queued')
+for i=depositStart+1,#calls do assert(calls[i][1]~='select','ending must not request world teardown') end
+local beforeStart=#calls
 call(R.client,c,s,{held_region_index=0})
-call(R.client,c,s,{current_region_index=1})
-assert(#calls==beforeArrival and not vars['ember.ending.offered'],'pending/base world offered playback')
-region(1)
-assert(vars['ember.ending.offered']==1)
-local offeredCalls=#calls;region(1);assert(#calls==offeredCalls,'duplicate arrival replayed the movie offer')
-assert(vars['ember.ending']==1 and not vars['ember.ending.playing'],'offer is not native playback')
-local offered=false
-for _,row in ipairs(calls)do
-    if row[1]=='set_cinematic_active' and row[2]=='pf_cinematic_bookend_stm._cinematic'
-        and row[3].active then offered=true end
-end
-assert(offered,'first bookend was never offered')
+assert(#calls==beforeStart and not vars['ember.complete'],'queue receipt must not complete a movie')
 local first=event('PF_CINEMATIC_BOOKEND_STM_CINEMATIC',{runtime_object_id='9007199254740993'})
-local second=event('PF_CINEMATIC_BOOKEND_CNN_CINEMATIC',{runtime_object_id='9007199254740994'})
-local beforeStart=#calls
-call(R.terminated,c,s,first);call(R.skip,c,s,first)
-assert(#calls==beforeStart and vars['ember.ending']==1,'unstarted movie advanced the ending')
-call(R.started,c,s,second);assert(not vars['ember.ending.playing'],'wrong controller claimed playback')
-call(R.started,c,s,first);assert(vars['ember.ending.playing']==1)
-call(R.terminated,c,s,event('PF_CINEMATIC_BOOKEND_STM_CINEMATIC',{runtime_object_id='stale'}))
-assert(vars['ember.ending']==1,'stale runtime object advanced the movie')
+call(R.terminated,c,s,first);call(R.started,c,s,first);call(R.skip,c,s,first)
+assert(vars['ember.ending']==1 and #calls==beforeStart,'type-6 receipts must not advance direct playback')
+movieStatus[1]='preparing';call(R.client,c,s,{held_region_index=0})
+assert(not vars['ember.ending.playing'])
+movieStatus[1]='playing';call(R.client,c,s,{held_region_index=0})
+assert(vars['ember.ending.playing']==1)
 call(R.skip,c,s,first)
-assert(vars['ember.ending']==1 and not vars['ember.complete'],'skip must wait for native termination')
-local stoppingCalls=#calls;call(R.skip,c,s,first);assert(#calls==stoppingCalls,'repeated skip republished stop')
-call(R.terminated,c,s,first)
-assert(vars['ember.ending']==2 and not vars['ember.ending.playing'],'second movie is only offered')
-call(R.terminated,c,s,first);assert(vars['ember.ending']==2,'old movie completion advanced its successor')
-call(R.started,c,s,second);assert(not vars['ember.ending.playing'],'unoffered second movie claimed playback')
-region(2)
-call(R.started,c,s,second);call(R.terminated,c,s,second)
-assert(vars['ember.complete'])
+assert(vars['ember.ending']==1 and not vars['ember.complete'],'skip must wait for native completion')
+local stoppedCalls=#calls;call(R.skip,c,s,first);assert(#calls==stoppedCalls)
+movieStatus[1]='complete';timer('ember.ending.poll')
+assert(vars['ember.ending']==2 and movieStatus[2]=='queued')
+call(R.client,c,s,{held_region_index=0});call(R.terminated,c,s,first)
+assert(not vars['ember.complete'],'first movie completion must not complete the mission')
+movieStatus[2]='failed';call(R.client,c,s,{held_region_index=0})
+assert(vars['ember.ending.failed'] and not vars['ember.complete'],'playback failure must not award completion')
+movieStatus[2]='playing';call(R.client,c,s,{held_region_index=0})
+movieStatus[2]='complete';timer('ember.ending.poll')
+assert(vars['ember.complete'] and vars['ember.ending']==3)
+local completedCalls=#calls;call(R.client,c,s,{held_region_index=0});assert(#calls==completedCalls)
 local objective=vars['ember.r.guidance']
 region(56);region(40);assert(vars['ember.r.guidance']==objective,'backtracking reset the forward objective')
 local n=#calls;call(R.terminated,c,s,event('PF_CINEMATIC_BOOKEND_CNN_CINEMATIC'));assert(#calls==n)