Procházet zdrojové kódy

Chain Ember ending movies and use native cinematic UI state

Millie před 2 dny
rodič
revize
0ad2081dd3

+ 26 - 13
Sunrise/src/client/hooks/bootflow/ember_movie_ui.cpp

@@ -1,31 +1,44 @@
 #include "internal.h"
 #include "../ember_movies/ember_movies.h"
+#include "../ember_movies/playback_rules.h"
 #include "../../../core/logging/log.h"
 namespace sunrise::client::hooks::bootflow {
 namespace {
 hooking::detour::Handle handle{};
-using DrawLayer=void(__fastcall*)(void*,void*,void*,int,bool,bool*);
-void __fastcall draw_layer(void* ui,void* frame,void* commands,int layer,bool movie,bool* modal) noexcept {
-    // 132B890 submits letterbox + native video command 1B before these UI layers.
-    // Keep that renderer intact, but omit gameplay windows/HUD/fades over our movie.
-    // This is drawing only: window updates/ownership remain native for restoration.
-    if (ember_movies::presenting()) return;
-    if (auto original=reinterpret_cast<DrawLayer>(handle.original))
-        original(ui,frame,commands,layer,movie,modal);
+using SelectState=bool(__fastcall*)(void*,int);
+bool __fastcall select_state(void* ui,int requested) noexcept {
+    // E2EAE0 selects 22h when a cinematic is active and C4C070 is false.
+    // The movie component's D8 callback is DD1D10 (false), so this is its
+    // native presentation state. E1CD60 owns window/category transitions.
+    // Preserve loading/error/menu requests; substitute only normal gameplay.
+    const int selected=ember_movies::movie_ui_state(requested,ember_movies::presenting());
+    auto original=reinterpret_cast<SelectState>(handle.original);
+    if (!original) return false;
+    const bool result=original(ui,selected);
+    static bool wasCinematic{};
+    const bool cinematic=selected!=requested;
+    if (cinematic!=wasCinematic) {
+        core::log::writef(core::log::Channel::client,core::log::Level::info,
+            "ev=ember_movie result=presentation_state requested=%d selected=%d applied=%d category=%d",
+            requested,selected,*reinterpret_cast<int*>(static_cast<std::byte*>(ui)+0x204),
+            *reinterpret_cast<int*>(static_cast<std::byte*>(ui)+0x200));
+        wasCinematic=cinematic;
+    }
+    return result;
 }
 }
 StageResult stage_ember_movie_ui(hooking::detour::Spec& spec) noexcept {
     if (handle.attached) return StageResult::attached;
-    constexpr auto sig=signature<signature_length("48 8B C4 55 53 56 57 41 54 41 55 41 56 41 57 48 8D A8 B8 FA FF FF 48 81 EC 08 06 00 00 0F 29 70 A8 0F 29 78 98 44 0F 29 40 88")>(
-        "48 8B C4 55 53 56 57 41 54 41 55 41 56 41 57 48 8D A8 B8 FA FF FF 48 81 EC 08 06 00 00 0F 29 70 A8 0F 29 78 98 44 0F 29 40 88");
-    auto* target=scan_main_image_unique(sig,"ember_movie_ui_layers");
+    constexpr auto sig=signature<signature_length("40 55 53 57 41 54 41 55 41 57 48 8D AC 24 08 FD FF FF 48 81 EC F8 03 00 00 48 8B 05 ? ? ? ? 48 33 C4 48 89 85 E0 02 00 00 4C 8B E9 8B DA")>(
+        "40 55 53 57 41 54 41 55 41 57 48 8D AC 24 08 FD FF FF 48 81 EC F8 03 00 00 48 8B 05 ? ? ? ? 48 33 C4 48 89 85 E0 02 00 00 4C 8B E9 8B DA");
+    auto* target=scan_main_image_unique(sig,"ember_movie_presentation_state");
     if (!target) return StageResult::unavailable;
-    spec={target,reinterpret_cast<void*>(&draw_layer)};return StageResult::staged;
+    spec={target,reinterpret_cast<void*>(&select_state)};return StageResult::staged;
 }
 void publish_ember_movie_ui(const hooking::detour::Handle& value) noexcept {
     handle=value;ember_movies::ui_ready(value.attached);
     core::log::write(core::log::Channel::client,core::log::Level::info,
-        value.attached ? "ev=ember_movie result=ui_attached" : "ev=ember_movie result=ui_attach_failed");
+        value.attached ? "ev=ember_movie result=presentation_attached" : "ev=ember_movie result=presentation_attach_failed");
 }
 void uninstall_ember_movie_ui() noexcept {
     ember_movies::ui_ready(false);

+ 19 - 20
Sunrise/src/client/hooks/ember_movies/ember_movies.cpp

@@ -26,7 +26,7 @@ Owner firstCompleted{};
 std::uint64_t key{}, began{};
 unsigned movie{};
 Status state{};
-bool acquired{}, stopRequested{}, attempted{}, escapeHeld{}, finishing{};
+bool acquired{}, stopRequested{}, attempted{}, escapeHeld{};
 Playback playback{};
 MovieResource resource{};
 void* decoderOwner{};
@@ -75,16 +75,10 @@ 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() {
-    presentation.store(false);
     if (acquired) { api.release(api.manager()); acquired=false; }
 }
-void finish() {
-    if (!resource.release()) return;
-    state=Status::complete; watching.store(false); finishing=false; report("complete",lastDecoderState);
-    if (movie==1) firstCompleted=owner;
-    else if (firstCompleted==owner) orbit_return::arm(owner);
-}
 void fail(const char* reason) {
+    presentation.store(false);
     // Stop only our exact decoder asset, never another movie's playback.
     if (acquired) {
         auto* decoder=api.decoder();
@@ -105,10 +99,11 @@ 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 && !resource.held() && state!=Status::queued && state!=Status::preparing && state!=Status::playing) {
+    } else if (!acquired && (!resource.held() || can_chain_movies(owner,next,firstCompleted,movie,index,state))
+        && state!=Status::queued && state!=Status::preparing && state!=Status::playing) {
         if (index==1 || !(next==owner)) firstCompleted={};
         owner=next; key=nextKey; movie=index; state=Status::queued; began=GetTickCount64();
-        stopRequested=false; finishing=false; playback={}; decoderOwner=nullptr; lastDecoderState=-1;
+        stopRequested=false; playback={}; decoderOwner=nullptr; lastDecoderState=-1;
         escapeHeld=(GetAsyncKeyState(VK_ESCAPE)&0x8000)!=0;
         watching.store(true); report("queued"); accepted=true;
     }
@@ -129,22 +124,23 @@ 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());
+        // STM and CNN share textures. Waiting for those textures to disappear
+        // before reporting STM's EOF prevents Lua from ever submitting CNN.
+        const bool betweenMovies=state==Status::complete && movie==1 && region==0 && step==38;
+        if (!betweenMovies) presentation.store(false);
+        watching.store(betweenMovies || !resource.release());
         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 (!uiReady.load()) { fail("movie_ui_unavailable"); ReleaseSRWLockExclusive(&lock); return; }
-    // Native release may clear/reuse the decoder. Once EOF is observed, wait
-    // only for our resource cleanup; never reinterpret that cleared decoder.
-    if (finishing) { finish(); ReleaseSRWLockExclusive(&lock); return; }
-    if (!resolve()) { state=Status::failed; watching.store(false); ReleaseSRWLockExclusive(&lock); return; }
+    if (!uiReady.load()) { fail("movie_presentation_unavailable"); ReleaseSRWLockExclusive(&lock); return; }
+    if (!resolve()) { fail("native_api_unavailable"); 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 (!resource.held() && !resource.begin(assets[movie-1])) {
+        if (!resource.begin(assets[movie-1])) {
             fail("resource_request_failed"); ReleaseSRWLockExclusive(&lock); return;
         }
         if (!resource.advance()) {
@@ -191,9 +187,12 @@ void poll(std::int32_t region, std::int32_t step) noexcept {
     }
     if (observed==Status::complete) {
         release();
-        // Lua submits movie two as soon as completion is visible. Finish native
-        // unregistration first, so that submission never races retained resources.
-        finishing=true; finish();
+        if (movie==2) presentation.store(false);
+        state=Status::complete; watching.store(true); report("complete",decoderState);
+        if (movie==1) firstCompleted=owner;
+        else if (firstCompleted==owner) orbit_return::arm(owner);
+        // Final cleanup runs independently on subsequent frames. Native EOF,
+        // not global texture eviction, releases the mission's orbit handoff.
     }
     else if (observed==Status::failed) fail("decoder_failed_or_replaced");
     else state=observed;

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

@@ -1,6 +1,17 @@
 #pragma once
 #include "ember_movies.h"
 namespace sunrise::client::hooks::ember_movies {
+// E15D50's normal gameplay choices -> E2EAE0's native movie presentation state.
+// Both cinematic states 21h/22h map to native UI category 4 at E2E9F0.
+constexpr int movie_ui_state(int requested,bool presenting) noexcept {
+    return presenting && (requested==0x2B || requested==0x2D || requested==0x2F)
+        ? 0x22 : requested;
+}
+constexpr bool can_chain_movies(Owner current,Owner next,Owner firstCompleted,
+                                 unsigned previous,unsigned requested,Status state) noexcept {
+    return current.session && current.generation && current==next && current==firstCompleted
+        && previous==1 && requested==2 && state==Status::complete;
+}
 // A queued request and decoder preparation do not prove rendered playback.
 struct Playback {
     bool seen{};

+ 16 - 10
Sunrise/src/client/hooks/ember_movies/resources.cpp

@@ -111,21 +111,18 @@ bool sunburn_resident() noexcept {
     __except(EXCEPTION_EXECUTE_HANDLER) { return false; }
 }
 bool MovieResource::begin(std::uint32_t asset) noexcept {
-    if (held()) return asset_==asset;
+    if (held()) {
+        if (retiringSurfaces_ || (asset!=0x80BCA001U && asset!=0x80BCA003U)) return false;
+        asset_=asset;return true; // Both movies share one retained resource set.
+    }
     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;
     retiringSurfaces_=false;
-    // Retain metadata plus the compact stream mapping consumed by 41A160.
-    // Its native kind-1 load initializes file offset/patch/size without copying the movie.
-    for (const auto tag : movie_metadata(asset)) {
-        const std::uint32_t request[]{movie_resource_kind,tag};
-        add(root,request);
-    }
-    const std::uint32_t streamRequest[]{movie_resource_kind,movie_stream(asset)};
-    add(root,streamRequest);
+    // This root contains definitions only. The catalog belongs to the dependent
+    // root: retaining it while waiting for its containers to unload is circular.
     // Native registration 1204163 immediately reads the referenced definition.
     // A single flat batch cannot order that callback after the definition load.
     for (const auto tag : movie_surface_definitions) {
@@ -134,7 +131,7 @@ bool MovieResource::begin(std::uint32_t asset) noexcept {
     }
     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 kind=1 metadata=4 stream=%08X definitions=6",asset_,root_,movie_stream(asset_));
+        "ev=ember_movie result=resource_requested asset=%08X root=%08X kind=1 definitions=6",asset_,root_);
     return true;
 }
 bool MovieResource::advance() noexcept {
@@ -149,6 +146,15 @@ bool MovieResource::advance() noexcept {
         create(mgr,&surfaces_,8,2,0,"mission_ember_movie_surfaces");
         if (surfaces_==0xFFFFFFFFU) return false;
         auto* root=blob(surfaces_);if (!root) return false;
+        // Preload both streams' compact mappings and metadata. Native EOF for
+        // STM can then hand off to CNN without releasing their shared textures.
+        for (auto asset : {0x80BCA001U,0x80BCA003U}) {
+            for (auto tag : movie_metadata(asset)) {
+                if (asset==0x80BCA003U && tag==0x80BCA032U) continue;
+                const std::uint32_t request[]{movie_resource_kind,tag};add(root,request);
+            }
+            const std::uint32_t request[]{movie_resource_kind,movie_stream(asset)};add(root,request);
+        }
         // 1204581 binds each raw buffer to definition+8. Both the buffer callback
         // and the container registration require the retained definition first.
         for (auto tag : movie_surface_buffers) {

+ 1 - 6
Sunrise/src/client/hooks/graphics/renderer/graphics_renderer_frame.cpp

@@ -16,7 +16,6 @@
 #include "../../../ui/activity/authored_placement_marker.h"
 #include "../../../ui/activity/authored_spatial_overlay.h"
 #include "../../teleport/runtime.h"
-#include "../../ember_movies/ember_movies.h"
 #include "../input/input.h"
 #include "graphics_renderer_report.h"
 #include "state.h"
@@ -128,10 +127,6 @@ void render_frame_locked() noexcept {
     if (!fully_active_locked()) {
         return;
     }
-    if (ember_movies::presenting()) {
-        transition_input_visibility_locked(false);
-        return;
-    }
     if (core::ui::scaling::dpi::update(g_resources.window)) {
         // Style and text scale change together, before the backend sets up the frame.
         core::ui::theme::apply();
@@ -182,7 +177,7 @@ void render_frame_locked() noexcept {
 /** Feeds one ordinary window message into the active Dear ImGui context. */
 bool handle_window_message(HWND window, UINT message, WPARAM word, LPARAM value) noexcept {
     AcquireSRWLockExclusive(&g_rendererLock);
-    if (!fully_active_locked() || g_resources.window != window || ember_movies::presenting()) {
+    if (!fully_active_locked() || g_resources.window != window) {
         ReleaseSRWLockExclusive(&g_rendererLock);
         return false;
     }

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

@@ -1,5 +1,20 @@
 # 1AU: movie loader, surge audio and escape explosions
 
+## Visible first movie confirmed; second-movie handoff and native presentation
+
+The user confirmed visible STM playback with `15e507e`. Its decoder reached state 5 at t=281733, with all six selected Y/U/V definitions backed by non-null buffers. Native EOF arrived at t=481606. The bridge then stopped at `surfaces_retiring`: no completion was exposed to Lua, so CNN was never requested. This is the second-movie regression, not missing CNN media.
+
+Read-only captures in `build/first-encounter-audit/15e507e-live-20260906-203525/`, `15e507e-after-first-surfaces.txt`, and `15e507e-after-first-buffers.txt` show that the dependent root was freed while the catalog, registrations and buffers remained resident. Root release and global asset eviction are distinct. The old bridge incorrectly made eviction a prerequisite for reporting EOF.
+
+The correction retains one shared resource set across STM and CNN, preloading both compact media mappings. Native EOF immediately reports completion; only the same session/generation with a completed STM receipt can chain CNN onto the retained resources. The definition-only root loads first. Both movies' metadata, the shared catalog, raw buffers and registration containers load second, after validated definitions exist. Moving the catalog out of the definition root avoids retaining it during dependent cleanup. After CNN, asynchronous resource cleanup is independent of mission completion and the immediate orbit request. No asset is force-unregistered.
+
+At the user's request, the `132BD80` UI drawing suppression and Sunrise overlay suppression have been removed. Native `E2EAE0` chooses UI state `0x22` for an active pre-rendered component: its `D8` callback is `DD1D10`, which returns false. `E2E9F0` maps `0x21` and `0x22` to cinematic category 4. The bridge now passes `0x22` to the original `E1CD60` state transition when its owned playback would otherwise select normal gameplay (`0x2B/0x2D/0x2F`). Other requests, including loading/error/menu states, pass through. Presentation persists between the movies and clears after CNN or failure. Native UI drawing and window transitions run normally. Logs include requested, selected and applied state/category.
+
+`ember.hud.audience.<region>` is unrelated: it is a Lua once-only flag for initializing type-70 objective audiences. The intro starts `PF_CINEMATIC_BOOKEND_CINEMATIC` with `set_cinematic_active`; its authored region is not selected again for ending videos.
+
+The new handoff guards, all 24 portable tests, full Lua route and mapped-image/package verifier pass. The Windows release DLL also builds successfully. **Visible STM is confirmed on the prior build; CNN chaining, native cinematic UI presentation and immediate orbit arrival require a new live test.** The historical cleanup-before-completion and drawing-override descriptions below describe superseded implementations.
+
+
 ## Freeze captured on `a47adbd`: surface registration dereferenced an unloaded definition
 
 The final escape trigger queued STM at t=289137 and submitted its resource root at t=289153. No playback submission followed. All six containers were resident, but all six definitions were unloaded (`FEFE0000 / type_info 001044FB / blob 0`). Scanning the frozen process found the render thread's saved exception: **C0000005 at game+1204163, RAX=0, RBX=80BCA021, RDX=4**. That instruction reads the definition's slot byte during container registration. This establishes the fault directly; neither decoding nor orbit return had begun.

+ 10 - 2
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 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 movie, header, subtitle and shared metadata tags plus the compact video-stream mapping and six authored video-surface containers before playback. The `13f07ee` test reached decoder preparation but failed with state 7; it had omitted that stream mapping. Native `3591B0` initializes the media datum without copying the full video into RAM. See [the correction evidence and validation](mission-ember-final-corrections.md). The subsequent `6540584` test reaches native playing state with audible sound, but the user reports a permanently black picture and gameplay HUD overlay; the active trace then identified missing video surface registrations. The current correction retains and publishes those surfaces and suppresses gameplay UI layers, pending in-game presentation verification.
+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 movie, header, subtitle and shared metadata tags plus the compact video-stream mapping and six authored video-surface containers before playback. The `13f07ee` test reached decoder preparation but failed with state 7; it had omitted that stream mapping. Native `3591B0` initializes the media datum without copying the full video into RAM. See [the correction evidence and validation](mission-ember-final-corrections.md). The subsequent `6540584` test reaches native playing state with audible sound, but the user reports a permanently black picture and gameplay HUD overlay; the active trace then identified missing video surface registrations. The `15e507e` correction loaded the backing buffers and the user confirmed visible STM playback. It then withheld EOF while waiting for shared surface eviction, blocking CNN. The current handoff retains the shared resources across both movies and exposes native EOF independently of cleanup.
 
 ## Native playback bridge
 
@@ -31,7 +31,7 @@ The native CPU movie-frame routine (`41D140`) is observed after its original rou
 
 ## 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.
+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 using the same retained textures and preloaded metadata/media mappings; the handoff requires the same session and generation. Texture eviction is not a completion condition. Only confirmed CNN completion sets `ember.complete`, phase 100, and native lifetime state 6. Gameplay route callbacks remain gated while the ending is active.
 
 After both movies complete for the same owner, `orbit_return` queues the native return selection on the first ready frame with native lifetime 6 applied. There is no completion-banner delay. The request requires the same session, ActivityClient generation, Apex world arrival, and current Ember destination. Movie failure, completion of only STM, another activity, or a new mission run cannot start it.
 
@@ -46,3 +46,11 @@ This removes the failing transition from the ending route. General world teardow
 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 playback, resource, surface-publication and UI signatures and relative-call targets match the saved executable image.
 
 The user confirmed both movie audio tracks in sequence and the mission-complete presentation on `6540584`, but reported black video with the HUD visible and no automatic return to orbit. The subsequent surface/HUD correction and automatic return require a new in-game test. Diagnostics use `ev=ember_movie` for playback and `ev=ember_orbit` with `awaiting_completion`, `completion_accepted`, `selection_queued`, optional `cleanup_requested`, and `orbit_setup`, or a specific cancellation/failure reason. Offline checks verify all native return signatures/call targets; portable cases cover immediate return after completion, native readiness, duplicate frames, retirement of the old link, native cleanup already in progress, stale ownership, world replacement and timeout.
+
+## Native cinematic presentation
+
+The previous drawing-layer and Sunrise overlay suppression have been removed. Native UI selector `E2EAE0` uses state `0x22` for an active pre-rendered component: `C4C070` dispatches the component's `D8` query, whose movie implementation (`DD1D10`) returns false. Both native cinematic states (`0x21`, `0x22`) map to UI category 4 at `E2E9F0`.
+
+While the ending bridge owns playback, its state hook replaces normal gameplay choices `0x2B/0x2D/0x2F` with `0x22` and calls the original `E1CD60` transition. Other states pass through. This uses native window/category management; neither `132BD80` drawing layer is skipped. Presentation remains active across the STM-to-CNN handoff, then clears on CNN completion or failure. The mapped-image verifier checks the selector, component dispatch and category mapping; visible presentation still requires a fresh test.
+
+The Lua variable `ember.hud.audience.<region>` only records initialization of the authored objective engagement sensor. It is not a cinematic state. The intro's `set_cinematic_active` enters its authored cinematic controller; reselecting its region for these ending movies would replay/unload mission content and is not part of this change.

+ 20 - 0
tests/ending_retirement_test.cpp

@@ -24,6 +24,26 @@ static void expect(Reader& reader, unsigned width, std::uint64_t expected) {
 }
 int main() {
     namespace movies=sunrise::client::hooks::ember_movies;
+    // The first visible-video run reached native EOF, but the retained textures
+    // blocked request 2. A same-owner EOF authorizes reuse, not cache eviction.
+    const movies::Owner movieOwner{12,34};
+    assert(movies::can_chain_movies(movieOwner,movieOwner,movieOwner,1,2,movies::Status::complete));
+    assert(!movies::can_chain_movies(movieOwner,{13,34},movieOwner,1,2,movies::Status::complete));
+    assert(!movies::can_chain_movies(movieOwner,{12,35},movieOwner,1,2,movies::Status::complete));
+    assert(!movies::can_chain_movies(movieOwner,movieOwner,{},1,2,movies::Status::complete));
+    assert(!movies::can_chain_movies({}, {}, {},1,2,movies::Status::complete));
+    for (auto state : {movies::Status::queued,movies::Status::preparing,movies::Status::playing,
+                       movies::Status::failed,movies::Status::absent})
+        assert(!movies::can_chain_movies(movieOwner,movieOwner,movieOwner,1,2,state));
+    assert(!movies::can_chain_movies(movieOwner,movieOwner,movieOwner,2,1,movies::Status::complete));
+    assert(!movies::can_chain_movies(movieOwner,movieOwner,movieOwner,1,1,movies::Status::complete));
+    // Native presentation selects the cinematic category, preserving higher
+    // priority UI states. Playback completion restores the original selection.
+    for (int ui=-1;ui<=0x2F;++ui) {
+        assert(movies::movie_ui_state(ui,false)==ui);
+        const bool gameplay=ui==0x2B || ui==0x2D || ui==0x2F;
+        assert(movies::movie_ui_state(ui,true)==(gameplay ? 0x22 : ui));
+    }
     movies::OrbitReturn orbit;
     movies::OrbitObservation inApex{true,true,false,false,true,0,38,38,true};
     using OA=movies::OrbitAction;

+ 21 - 3
tests/verify_ember_movie_native.py

@@ -61,10 +61,28 @@ assert struct.unpack_from('<I',data,0x12045D0+18*4)[0] == 0x1204581
 assert data[0x12045BF:0x12045C6] == bytes.fromhex('48 2b c1 4c 89 40 08')
 assert data[0x4A6340:0x4A6345] == bytes.fromhex('48 8b 41 08 c3')
 ui = signature('Sunrise/src/client/hooks/bootflow/ember_movie_ui.cpp', 'sig')
-assert ui == 0x132BD80
+assert ui == 0xE1CD60
+# Native cinematic selection: active entry + movie's false D8 callback => state 22h.
+target(0xE2EB75,0,0xC4B760)
+target(0xE2EB8E,0,0xC4C070)
+assert data[0xE2EB97:0xE2EB9C] == bytes.fromhex('bb 22 00 00 00')
+target(0xC4C185,0,0x1871FA0)
+assert data[0x1871FAB:0x1871FB3] == bytes.fromhex('48 8b 94 02 d8 00 00 00')
+assert struct.unpack_from('<Q',data,0x1CDB4E0)[0] == 0x140DDB0F0
+assert struct.unpack_from('<Q',data,0x1CDB4E0-0x48+0xD8)[0] == 0x140DD1D10
+assert data[0xDD1D1C:0xDD1D23] == bytes.fromhex('32 c0 48 83 c4 28 c3')
+# Both authored cinematic UI choices map to category 4; E1CD60 applies it natively.
+for state in (0x21,0x22):
+    case=data[0xE2EA70+state+1]
+    assert struct.unpack_from('<I',data,0xE2EA48+4*case)[0] == 0xE2EA2A
+assert data[0xE2EA2A:0xE2EA30] == bytes.fromhex('b8 04 00 00 00 c3')
+target(ui,0x9B,0xE2E9F0)
+target(0xE1CE0F,0,0x1340F00)
+# Neither UI drawing layer is intercepted; the renderer remains native.
 target(0x132B890, 0x353, 0x1278FF0)  # native movie command is queued before either UI layer
-target(0x132B890, 0x3DF, ui)
-target(0x132B890, 0x40C, ui)
+target(0x132B890, 0x3DF, 0x132BD80)
+target(0x132B890, 0x40C, 0x132BD80)
+print('Native pre-rendered cinematic UI state 22h / category 4 verified; no draw suppression.')
 for offset, expected in [(0x96, 0x4294D0), (0xD1, 0x423EF0), (0x14C, 0x4312D0), (0x157, 0x435AA0)]:
     target(load, offset, expected)
 for offset, expected in [(0x85, 0x42C650), (0x9F, 0x425310)]: