Jelajahi Sumber

Retain Ember movie video surfaces and hide gameplay UI during playback

Millie 2 hari lalu
induk
melakukan
3e28c8e7b8

+ 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_movie_ui, &publish_ember_movie_ui},
     Fix{&stage_ember_sunburn, &publish_ember_sunburn},
     Fix{&stage_owner_activity_slot, &publish_owner_activity_slot},
     Fix{&stage_region_private, &publish_region_private},
@@ -113,6 +114,7 @@ void uninstall() noexcept {
     uninstall_loading_cinematics();
     uninstall_ember_sunburn();
     uninstall_ember_movie_tick();
+    uninstall_ember_movie_ui();
     uninstall_orbit_handoff();
     uninstall_composition_check();
     uninstall_orbit_slice_set();

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

@@ -0,0 +1,34 @@
+#include "internal.h"
+#include "../ember_movies/ember_movies.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);
+}
+}
+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");
+    if (!target) return StageResult::unavailable;
+    spec={target,reinterpret_cast<void*>(&draw_layer)};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");
+}
+void uninstall_ember_movie_ui() noexcept {
+    ember_movies::ui_ready(false);
+    static_cast<void>(hooking::detour::uninstall(handle));
+}
+}

+ 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_movie_ui(hooking::detour::Spec& spec) noexcept;
+void publish_ember_movie_ui(const hooking::detour::Handle& handle) noexcept;
+void uninstall_ember_movie_ui() 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;

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

@@ -19,6 +19,7 @@ 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};
+std::atomic_bool presentation{false}, uiReady{false};
 Owner owner{};
 std::uint64_t key{}, began{};
 unsigned movie{};
@@ -72,6 +73,7 @@ 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 fail(const char* reason) {
@@ -109,6 +111,8 @@ Status status(Owner next, unsigned index) noexcept {
     ReleaseSRWLockShared(&lock); return value;
 }
 bool active() noexcept { return watching.load(); }
+bool presenting() noexcept { return presentation.load(); }
+void ui_ready(bool ready) noexcept { uiReady.store(ready); }
 void frame_ready(bool ready) noexcept { frameReady.store(ready); }
 void poll(std::int32_t region, std::int32_t step) noexcept {
     if (inPoll) return;
@@ -122,6 +126,7 @@ void poll(std::int32_t region, std::int32_t step) noexcept {
     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; }
     if (!resolve()) { state=Status::failed; watching.store(false); ReleaseSRWLockExclusive(&lock); return; }
     auto* manager=api.manager();
     auto* decoder=api.decoder();
@@ -139,11 +144,14 @@ void poll(std::int32_t region, std::int32_t step) noexcept {
         }
         if (api.busy(manager)) {
             if (now-began>30000) fail("player_busy_timeout");
+        } else if (!resource.prepare_surfaces()) {
+            if (now-began>30000) fail("surface_registration_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;
+            presentation.store(true);
             report("submitted");
         }
         ReleaseSRWLockExclusive(&lock); return;

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

@@ -7,6 +7,8 @@ enum class Status : std::uint8_t { absent, queued, preparing, playing, complete,
 bool request(Owner owner, std::uint64_t request, unsigned index, bool stop) noexcept;
 Status status(Owner owner, unsigned index) noexcept;
 bool active() noexcept;
+bool presenting() noexcept;
+void ui_ready(bool ready) noexcept;
 void frame_ready(bool ready) noexcept;
 void poll(std::int32_t region, std::int32_t step) noexcept;
 }

+ 35 - 2
Sunrise/src/client/hooks/ember_movies/resources.cpp

@@ -1,5 +1,6 @@
 #include "resources.h"
 #include "readiness_rules.h"
+#include "surface_rules.h"
 #include <Windows.h>
 #include <cstring>
 #include "../../patterns/image_scan.h"
@@ -15,6 +16,9 @@ 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{};
+using PublishSurfaces=void(__fastcall*)();
+PublishSurfaces publishSurfaces{};
+std::uintptr_t surfaceRegistrations{};
 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; }
@@ -30,17 +34,25 @@ bool resolve_native() {
     // 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");
+    // 1202B00 publishes the registered surface stack; normal world activation
+    // calls it at B5F4CE. Direct movies keep the world, so perform that step here.
+    constexpr auto surfaceSig=signature<signature_length("8B 05 ? ? ? ? 48 8D 15 ? ? ? ? C7 05 ? ? ? ? FF FF FF FF 85 C0 74 0E FF C8 48 98 8B 4C 82 04 89 0D")>(
+        "8B 05 ? ? ? ? 48 8D 15 ? ? ? ? C7 05 ? ? ? ? FF FF FF FF 85 C0 74 0E FF C8 48 98 8B 4C 82 04 89 0D");
     auto* load=scan_main_image_unique(loadSig,"ember_resource_load");
     auto* end=scan_main_image_unique(endSig,"ember_resource_release");
+    auto* surfaces=scan_main_image_unique(surfaceSig,"ember_movie_surfaces");
     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
+    if (!mgr || !create || !add || !submit || !status || !destroy || !end || !surfaces
         || 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));
+    surfaceRegistrations=reinterpret_cast<std::uintptr_t>(surfaces+13)
+        +read<std::int32_t>(reinterpret_cast<std::uintptr_t>(surfaces+9));
+    publishSurfaces=reinterpret_cast<PublishSurfaces>(surfaces);
     manager=mgr; return true;
 }
 BOOL CALLBACK initialize(PINIT_ONCE,void*,void**) { available=resolve_native();return TRUE; }
@@ -97,9 +109,13 @@ bool MovieResource::begin(std::uint32_t asset) noexcept {
     }
     const std::uint32_t streamRequest[]{movie_resource_kind,movie_stream(asset)};
     add(root,streamRequest);
+    for (const auto tag : movie_surfaces) {
+        const std::uint32_t request[]{movie_resource_kind,tag};
+        add(root,request);
+    }
     submit(mgr,root_);asset_=asset;
     core::log::writef(core::log::Channel::client,core::log::Level::info,
-        "ev=ember_movie result=resource_requested asset=%08X root=%08X kind=1 metadata=4 stream=%08X",asset_,root_,movie_stream(asset_));
+        "ev=ember_movie result=resource_requested asset=%08X root=%08X kind=1 metadata=4 stream=%08X surfaces=6",asset_,root_,movie_stream(asset_));
     return true;
 }
 int MovieResource::state() const noexcept {
@@ -123,6 +139,23 @@ bool MovieResource::ready() const noexcept {
             && media==movie_stream(asset_) && stream_ready(media);
     } __except(EXCEPTION_EXECUTE_HANDLER) { return false; }
 }
+bool MovieResource::prepare_surfaces() noexcept {
+    if (state()!=2 || !publishSurfaces || !surfaceRegistrations) return false;
+    __try {
+        for (unsigned i=0;i<movie_surfaces.size();++i) {
+            auto* container=blob(movie_surfaces[i],0x80806B91U);
+            if (!container || read<std::uint32_t>(reinterpret_cast<std::uintptr_t>(container))
+                !=movie_surface_definitions[i]) return false;
+        }
+        auto rows=read<SurfaceRegistrations>(surfaceRegistrations);
+        if (!movie_surfaces_registered(rows)) return false;
+        if (!movie_surfaces_selected(rows)) publishSurfaces();
+        if (!movie_surfaces_selected(read<SurfaceRegistrations>(surfaceRegistrations))) return false;
+        core::log::writef(core::log::Channel::client,core::log::Level::info,
+            "ev=ember_movie result=surfaces_selected asset=%08X count=6",asset_);
+        return true;
+    } __except(EXCEPTION_EXECUTE_HANDLER) { return false; }
+}
 bool MovieResource::release() noexcept {
     if (!held()) return true;
     const auto value=state();

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

@@ -9,6 +9,7 @@ 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 prepare_surfaces() noexcept; // frame-owned publication after the native player is idle
     bool release() noexcept; // false while pending: never block the frame draining I/O
     bool held() const noexcept { return root_ != 0xFFFFFFFFU; }
 };

+ 36 - 0
Sunrise/src/client/hooks/ember_movies/surface_rules.h

@@ -0,0 +1,36 @@
+#pragma once
+#include <array>
+#include <cstdint>
+namespace sunrise::client::hooks::ember_movies {
+// 80BCA032 names the six Y/U/V double-buffer surface containers. Loading that
+// list alone does not retain/activate its individual surface containers.
+constexpr std::array<std::uint32_t,6> movie_surfaces{
+    0x80BCA022U,0x80BCA025U,0x80BCA028U,0x80BCA02BU,0x80BCA02EU,0x80BCA031U};
+constexpr std::array<std::uint32_t,6> movie_surface_definitions{
+    0x80BCA021U,0x80BCA024U,0x80BCA026U,0x80BCA029U,0x80BCA02CU,0x80BCA02FU};
+struct SurfaceRegistration {
+    std::uint32_t count{}, entries[3]{}, selected{0xFFFFFFFFU};
+};
+static_assert(sizeof(SurfaceRegistration)==20);
+using SurfaceRegistrations=std::array<SurfaceRegistration,8>;
+constexpr std::uint32_t selected_surface(const SurfaceRegistration& r) noexcept {
+    return r.count>0 && r.count<=3 ? r.entries[r.count-1] : 0xFFFFFFFFU;
+}
+constexpr bool movie_surfaces_registered(const SurfaceRegistrations& rows) noexcept {
+    for (unsigned i=0;i<rows.size();++i) {
+        if (rows[i].count>3) return false;
+        const auto top=selected_surface(rows[i]);
+        // The native publisher updates all eight slots. Do not change unrelated
+        // pending slot 0/7 selections while preparing an Ember movie.
+        if (i==0 || i==7) { if (rows[i].selected!=top) return false; }
+        else if (top!=movie_surface_definitions[i-1]) return false;
+    }
+    return true;
+}
+constexpr bool movie_surfaces_selected(const SurfaceRegistrations& rows) noexcept {
+    if (!movie_surfaces_registered(rows)) return false;
+    for (unsigned i=1;i<=6;++i)
+        if (rows[i].selected!=movie_surface_definitions[i-1]) return false;
+    return true;
+}
+}

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

@@ -16,6 +16,7 @@
 #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"
@@ -127,6 +128,10 @@ 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();
@@ -177,7 +182,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) {
+    if (!fully_active_locked() || g_resources.window != window || ember_movies::presenting()) {
         ReleaseSRWLockExclusive(&g_rendererLock);
         return false;
     }

+ 22 - 1
docs/mission-ember-final-corrections.md

@@ -1,12 +1,33 @@
 # 1AU: movie loader, surge audio and escape explosions
 
+## Correction after the active black-picture trace
+
+The manual replay established the failure directly: valid 1920×800 Y/U/V frames were present in native CPU buffers, and the UI command stream included the native movie command (`1B`, with packed header flags). A decoded frame from `picture-20260906-194230/` visibly contains the cutscene. The six GPU video surface slots were null during playback. Their native registration rows had **count 0 / selected FFFFFFFF**, with only stale candidate values. The prior raw metadata request did not retain the six surface containers named by `80BCA032`.
+
+The resource root now explicitly owns those six ordinary, kind-1 surface containers alongside the metadata and compact stream mapping:
+
+| Slot | Plane | Container | Definition |
+|---|---|---|---|
+| 1 | Y0 | 80BCA022 | 80BCA021 |
+| 2 | Y1 | 80BCA025 | 80BCA024 |
+| 3 | U0 | 80BCA028 | 80BCA026 |
+| 4 | U1 | 80BCA02B | 80BCA029 |
+| 5 | V0 | 80BCA02E | 80BCA02C |
+| 6 | V1 | 80BCA031 | 80BCA02F |
+
+Each container is class `80806B91`, containing its single definition reference. Each definition's first byte identifies the matching native slot. Once the root is ready and the player is idle, the bridge verifies all six retained registrations, then invokes native `1202B00`, the publication operation normally called at world activation `B5F4CE`. Stale candidates with count zero, invalid counts, other surface owners, or unrelated pending changes to slots 0/7 cannot pass. Playback waits for matching published selections; it never writes GPU pointers or bypasses the renderer's allocation checks. The root remains held until native movie completion releases it.
+
+Native `132B890` queues the movie and letterbox before calling the two UI drawing layers at `132BD80`. The new scoped hook suppresses those gameplay UI layers only while the Ember bridge owns playback. UI state and native movie rendering remain intact, and drawing returns at completion/failure. Sunrise's own debug/interaction overlays are also suppressed during this window. Native audio, both movies' order, completion receipts, and Escape handling are unchanged.
+
+The release build, all 24 portable tests, restored full Lua route, package surface references, native publication ABI and UI call order pass offline verification. **The installed correction still needs visible-video/HUD verification in game.** The replay confirmed native EOF for STM at t=456666 and automatic CNN playback at t=457139. Capture files are under `build/first-encounter-audit/picture-20260906-194*`, `live-picture-resources.txt`, and `live-surface-rows.txt`. The capture helper was stopped; the game was never launched or stopped by the agent.
+
 ## Playback report after `6540584`
 
 The user confirms immediate ending audio, but the picture stays black with gameplay HUD visible throughout. Both native players reached decoder state 5. STM subsequently stopped and completed after Escape (t=384298–384404); CNN also reached state 5 (t=384750) and completed after Escape (t=385250–385356). This establishes decoding/playback state and skip sequencing, **not visible video**. The archived log is `build/first-encounter-audit/6540584-black-video.log`.
 
 The failed beam inversion from `6540584` has been reverted at the user's request: normal uses `open`, surge uses `close`. Audio remains tied to the surge callback; shutter mechanics are unchanged. The restored mission route and all 24 portable tests pass.
 
-The picture investigation separates CPU extraction (`41D140`), fullscreen UI command production (`132B890` → `1278FF0`, command `1B`), GPU Y/U/V upload (`41D7B0`), and the final video draw (`1159CE0`, global shader index `B4`). Shader `80B35981` is resident in the captured process. The idle UI manager at `(RIP target of 132BD5C) & ~15 = 142F4EE30` has the expected `+1E4 = -1`, `+1EC = 0`. These **post-skip** readings do not identify the during-playback failure; an active picture/command-buffer capture is pending.
+The picture investigation separates CPU extraction (`41D140`), fullscreen UI command production (`132B890` → `1278FF0`, command `1B`), GPU Y/U/V upload (`41D7B0`), and the final video draw (`1159CE0`, global shader index `B4`). Shader `80B35981` is resident in the captured process. The idle UI manager at `(RIP target of 132BD5C) & ~15 = 142F4EE30` has the expected `+1E4 = -1`, `+1EC = 0`. Those initial post-skip readings did not identify the failure; the later active trace above does.
 
 
 ## Latest playtest: metadata ready, stream missing

+ 2 - 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 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; presentation remains unresolved.
+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.
 
 ## Native playback bridge
 
@@ -37,6 +37,6 @@ This removes the failing transition from the ending route. General world teardow
 
 ## 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.
+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.
 
 Live audio and both Escape skips are confirmed by the `6540584` test, including native completion receipts for both movies. Visible video and the post-movie mission-complete presentation remain unconfirmed; the reported video is black with HUD overlaid. Diagnostics use `ev=ember_movie` with queued, submitted, decoder, playing, stop_requested and complete, or a specific failure reason.

+ 22 - 0
tests/ending_retirement_test.cpp

@@ -3,6 +3,7 @@
 #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/ember_movies/surface_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"
@@ -22,6 +23,27 @@ static void expect(Reader& reader, unsigned width, std::uint64_t expected) {
 }
 int main() {
     namespace movies=sunrise::client::hooks::ember_movies;
+    movies::SurfaceRegistrations surfaces{};
+    for (unsigned i=1;i<=6;++i) surfaces[i].entries[0]=movies::movie_surface_definitions[i-1];
+    // Live black-video capture: old candidate handles remain, but no container
+    // holds a registration and every selected surface is FFFFFFFF.
+    assert(!movies::movie_surfaces_registered(surfaces));
+    for (unsigned i=1;i<=6;++i) surfaces[i].count=1;
+    assert(movies::movie_surfaces_registered(surfaces));
+    assert(!movies::movie_surfaces_selected(surfaces));
+    for (unsigned i=1;i<=6;++i) surfaces[i].selected=surfaces[i].entries[0];
+    assert(movies::movie_surfaces_selected(surfaces));
+    surfaces[4].count=0;
+    assert(!movies::movie_surfaces_registered(surfaces));
+    surfaces[4].count=4;
+    assert(!movies::movie_surfaces_registered(surfaces));
+    surfaces[4].count=2;surfaces[4].entries[1]=123;
+    assert(!movies::movie_surfaces_registered(surfaces)); // another surface owns the top
+    surfaces[4].count=1;
+    surfaces[7].count=1;surfaces[7].entries[0]=123;
+    assert(!movies::movie_surfaces_registered(surfaces)); // do not publish an unrelated pending slot
+    surfaces[7].selected=123;
+    assert(movies::movie_surfaces_selected(surfaces));
     // Native kind 2 resolves shared-tag records; these ordinary movie tags require 1.
     static_assert(movies::movie_resource_kind==1);
     assert((movies::movie_metadata(0x80BCA001)==std::array<std::uint32_t,4>{0x80BCA001,0x80BCA000,0x80B9EB33,0x80BCA032}));

+ 24 - 1
tests/verify_ember_movie_native.py

@@ -26,6 +26,17 @@ path = 'Sunrise/src/client/hooks/ember_movies/resources.cpp'
 load = signature(path, 'loadSig')
 end = signature(path, 'endSig')
 assert load == 0xB46E10 and end == 0xB44020
+surface = signature(path, 'surfaceSig')
+assert surface == 0x1202B00
+assert surface + 13 + struct.unpack_from('<i', data, surface + 9)[0] == 0x2E800B0
+target(0xB5F4C5, 9, surface)  # native world activation publishes the surface registration stacks
+target(0x1184660, 0x37, 0x1202C20)  # renderer fetches selected surface definitions
+assert data[0x116A070:0x116A077] == bytes.fromhex('48 83 38 00 0f 95 c0')  # missing surface => skip GPU upload
+ui = signature('Sunrise/src/client/hooks/bootflow/ember_movie_ui.cpp', 'sig')
+assert ui == 0x132BD80
+target(0x132B890, 0x353, 0x1278FF0)  # native movie command is queued before either UI layer
+target(0x132B890, 0x3DF, ui)
+target(0x132B890, 0x40C, ui)
 for offset, expected in [(0x96, 0x4294D0), (0xD1, 0x423EF0), (0x14C, 0x4312D0), (0x157, 0x435AA0)]:
     target(load, offset, expected)
 for offset, expected in [(0x85, 0x42C650), (0x9F, 0x425310)]:
@@ -72,6 +83,9 @@ if len(sys.argv) > 2:
                           (0x80BCA000, 0x80808499), (0x80BCA002, 0x80808499),
                           (0x80B9EB33, 0x80809A88), (0x80B9EB34, 0x80809A88),
                           (0x80BCA032, 0x80806B8F),
+                          (0x80BCA022, 0x80806B91), (0x80BCA025, 0x80806B91),
+                          (0x80BCA028, 0x80806B91), (0x80BCA02B, 0x80806B91),
+                          (0x80BCA02E, 0x80806B91), (0x80BCA031, 0x80806B91),
                           (0x80BCA034, 0xFFFFFFFF), (0x80C7C000, 0xFFFFFFFF)]:
         # Tag package IDs include the bank: 80BCAxxx belongs to package 01E5.
         package = (tag >> 13) & 0x3FF
@@ -85,7 +99,16 @@ if len(sys.argv) > 2:
         assert type_info & 0xF000 != 0x2000, (hex(tag), hex(type_info))
         if expected == 0xFFFFFFFF:
             assert (type_info & 0x30000) == 0x10000 and (type_info >> 6) & 0x3F == 24
-    print('Installed movie metadata, compact video streams and kind-1 package types verified.')
+    print('Installed movie metadata, compact streams, six surface containers and kind-1 package types verified.')
+    tags = repo / 'build/first-encounter-audit/tags'
+    catalog = (tags / '80BCA032.bin').read_bytes()
+    for i, (container, definition) in enumerate(zip(
+        [0x80BCA022,0x80BCA025,0x80BCA028,0x80BCA02B,0x80BCA02E,0x80BCA031],
+        [0x80BCA021,0x80BCA024,0x80BCA026,0x80BCA029,0x80BCA02C,0x80BCA02F])):
+        assert struct.unpack_from('<I', catalog, 0x38 + 16*i)[0] == container
+        assert (tags / f'{container:08X}.bin').read_bytes() == struct.pack('<I', definition)
+        assert (tags / f'{definition:08X}.bin').read_bytes()[0] == i + 1
+    print('Six authored Y/U/V definitions map to the renderer slots 1..6.')
 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.