Browse Source

Return Ember to orbit after both ending movies and completion

Millie 3 days ago
parent
commit
9538803778

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

@@ -10,6 +10,7 @@
 #include "spawn/probe.h"
 #include "../mission_retirement/mission_retirement.h"
 #include "../ember_movies/ember_movies.h"
+#include "../ember_movies/orbit_return.h"
 
 namespace sunrise::client::hooks::bootflow {
 namespace {
@@ -64,6 +65,7 @@ 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());
+    ember_movies::orbit_return::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

+ 9 - 2
Sunrise/src/client/hooks/ember_movies/ember_movies.cpp

@@ -1,6 +1,7 @@
 #include "ember_movies.h"
 #include "playback_rules.h"
 #include "resources.h"
+#include "orbit_return.h"
 #include <Windows.h>
 #include <array>
 #include <atomic>
@@ -21,6 +22,7 @@ SRWLOCK lock = SRWLOCK_INIT;
 std::atomic_bool watching{false}, frameReady{false};
 std::atomic_bool presentation{false}, uiReady{false};
 Owner owner{};
+Owner firstCompleted{};
 std::uint64_t key{}, began{};
 unsigned movie{};
 Status state{};
@@ -98,6 +100,7 @@ bool request(Owner next, std::uint64_t nextKey, unsigned index, bool stop) noexc
     } else if (next==owner && nextKey==key && index==movie) {
         accepted=state!=Status::failed;
     } else if (!acquired && !resource.held() && 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; playback={}; decoderOwner=nullptr; lastDecoderState=-1;
         escapeHeld=(GetAsyncKeyState(VK_ESCAPE)&0x8000)!=0;
@@ -110,7 +113,7 @@ Status status(Owner next, unsigned index) noexcept {
     auto value=next==owner && index==movie ? state : Status::absent;
     ReleaseSRWLockShared(&lock); return value;
 }
-bool active() noexcept { return watching.load(); }
+bool active() noexcept { return watching.load() || orbit_return::active(); }
 bool presenting() noexcept { return presentation.load(); }
 void ui_ready(bool ready) noexcept { uiReady.store(ready); }
 void frame_ready(bool ready) noexcept { frameReady.store(ready); }
@@ -174,7 +177,11 @@ void poll(std::int32_t region, std::int32_t step) noexcept {
     if (stopRequested && asset==assets[movie-1]) {
         api.stop(manager); stopRequested=false; report("stop_requested",decoderState);
     }
-    if (observed==Status::complete) { release(); state=observed; watching.store(!resource.release()); report("complete",decoderState); }
+    if (observed==Status::complete) {
+        release(); state=observed; watching.store(!resource.release()); report("complete",decoderState);
+        if (movie==1) firstCompleted=owner;
+        else if (firstCompleted==owner) orbit_return::arm(owner);
+    }
     else if (observed==Status::failed) fail("decoder_failed_or_replaced");
     else state=observed;
     if ((state==Status::preparing && now-began>30000)

+ 131 - 0
Sunrise/src/client/hooks/ember_movies/orbit_return.cpp

@@ -0,0 +1,131 @@
+#include "orbit_return.h"
+#include "orbit_rules.h"
+#include <Windows.h>
+#include <array>
+#include <atomic>
+#include <cstdio>
+#include <cstring>
+#include "../../patterns/image_scan.h"
+#include "../../patterns/signature_text.h"
+#include "../../../core/logging/log.h"
+#include "../../../server/bap/runtime.h"
+#include "../../../state/activity/runtime.h"
+
+namespace sunrise::client::hooks::ember_movies::orbit_return {
+namespace {
+using namespace patterns;
+using Accessor=void*(__fastcall*)();
+using Construct=void*(__fastcall*)(void*);
+using Clear=void(__fastcall*)();
+using Select=void(__fastcall*)(int,void*);
+using Commit=void(__fastcall*)(int);
+using Predicate=bool(__fastcall*)(int);
+using Activity=void*(__fastcall*)(std::uint16_t*);
+using Lifetime=int(__fastcall*)();
+using Step=void(__fastcall*)(void*,int,int);
+struct Api { Accessor manager{}; Construct construct{}, orbit{}; Clear clear{};
+    Select select{}; Commit commit{}; Predicate mode{}; Activity activity{};
+    Lifetime lifetime{}; Step step{}; } api;
+OrbitReturn progress{};
+Owner owner{};
+std::uint64_t arrival{};
+std::atomic_bool watching{};
+bool attempted{}, available{};
+thread_local bool inPoll{};
+template<class T> T field(const void* pointer,unsigned offset) {
+    T value{};std::memcpy(&value,static_cast<const std::byte*>(pointer)+offset,sizeof(value));return value;
+}
+void* call(std::byte* code,unsigned offset) {
+    if (!code || code[offset]!=std::byte{0xE8}) return nullptr;
+    return code+offset+5+field<std::int32_t>(code,offset+1);
+}
+void report(const char* result) {
+    std::array<char,192> line{};
+    std::snprintf(line.data(),line.size(),"ev=ember_orbit result=%s session=%llu generation=%llu",
+        result,static_cast<unsigned long long>(owner.session),static_cast<unsigned long long>(owner.generation));
+    core::log::write(core::log::Channel::client,core::log::Level::info,line.data());
+}
+bool resolve() {
+    if (attempted) return available;
+    attempted=true;
+    // E19D50's in-world return path uses these exact constructor/clear/select/commit calls.
+    constexpr auto returnSig=signature<signature_length("48 81 EC 58 01 00 00 48 8B 05 ? ? ? ? 48 33 C4 48 89 84 24 40 01 00 00 B9 03 00 00 00 E8 ? ? ? ? 84 C0 74 7E")>(
+        "48 81 EC 58 01 00 00 48 8B 05 ? ? ? ? 48 33 C4 48 89 84 24 40 01 00 00 B9 03 00 00 00 E8 ? ? ? ? 84 C0 74 7E");
+    constexpr auto activitySig=signature<signature_length("48 89 5C 24 18 55 48 8D AC 24 F0 FD FF FF 48 81 EC 10 03 00 00 48 8B 05 ? ? ? ? 48 33 C4 48 89 85 00 02 00 00 48 8B D9 E8 ? ? ? ? 4C 8B C0 33 D2 8B C2")>(
+        "48 89 5C 24 18 55 48 8D AC 24 F0 FD FF FF 48 81 EC 10 03 00 00 48 8B 05 ? ? ? ? 48 33 C4 48 89 85 00 02 00 00 48 8B D9 E8 ? ? ? ? 4C 8B C0 33 D2 8B C2");
+    constexpr auto lifetimeSig=signature<signature_length("48 83 EC 28 E8 ? ? ? ? 48 85 C0 74 0F 0F B6 00 3C FF 74 08 0F BE C0 48 83 C4 28 C3 83 C8 FF 48 83 C4 28 C3")>(
+        "48 83 EC 28 E8 ? ? ? ? 48 85 C0 74 0F 0F B6 00 3C FF 74 08 0F BE C0 48 83 C4 28 C3 83 C8 FF 48 83 C4 28 C3");
+    constexpr auto stepSig=signature<signature_length("48 89 5C 24 20 55 56 57 41 54 41 55 41 56 41 57 48 8D AC 24 D0 BC FF FF B8 30 44 00 00 E8 ? ? ? ? 48 2B E0")>(
+        "48 89 5C 24 20 55 56 57 41 54 41 55 41 56 41 57 48 8D AC 24 D0 BC FF FF B8 30 44 00 00 E8 ? ? ? ? 48 2B E0");
+    auto* native=scan_main_image_unique(returnSig,"ember_orbit_return");
+    api.mode=reinterpret_cast<Predicate>(call(native,0x1E));
+    api.manager=reinterpret_cast<Accessor>(call(native,0x2F));
+    api.construct=reinterpret_cast<Construct>(call(native,0x5B));
+    api.orbit=reinterpret_cast<Construct>(call(native,0x65));
+    api.clear=reinterpret_cast<Clear>(call(native,0x6A));
+    api.select=reinterpret_cast<Select>(call(native,0x7E));
+    api.commit=reinterpret_cast<Commit>(call(native,0x86));
+    api.activity=reinterpret_cast<Activity>(scan_main_image_unique(activitySig,"ember_orbit_activity"));
+    api.lifetime=reinterpret_cast<Lifetime>(scan_main_image_unique(lifetimeSig,"ember_orbit_lifetime"));
+    api.step=reinterpret_cast<Step>(scan_main_image_unique(stepSig,"ember_orbit_cleanup"));
+    available=api.mode && api.manager && api.construct && api.orbit && api.clear && api.select
+        && api.commit && api.activity && api.lifetime && api.step;
+    return available;
+}
+bool select_orbit() {
+    // Native selection is 0x118 bytes plus the constructor's ownership byte at +118.
+    alignas(16) std::array<std::byte,0x120> selection{};
+    api.construct(selection.data());
+    api.orbit(selection.data());
+    // Refuse a missing catalog/default or a non-orbit default before changing selection.
+    if (field<std::uint8_t>(selection.data(),0)!=2 || field<std::uint16_t>(selection.data(),2)!=0)
+        return false;
+    api.clear();
+    api.select(0,selection.data());
+    api.commit(2);
+    return true;
+}
+}
+void arm(Owner next) noexcept {
+    owner=next; arrival=state::activity::world_arrival_revision();
+    progress.arm(GetTickCount64());watching.store(true);report("awaiting_completion");
+}
+bool active() noexcept { return watching.load(); }
+void poll(int region,int step) noexcept {
+    if (!watching.load() || inPoll) return;
+    inPoll=true;
+    struct Reset { ~Reset(){inPoll=false;} } reset;
+    if (!resolve()) { watching.store(false);report("signature_failed");return; }
+    // This runs after the movie lock is released; never take the BAP lock from a movie request.
+    server::bap::CurrentActivityLinkView link{};
+    const bool sameWorld=region==0 && arrival==state::activity::world_arrival_revision();
+    const bool exact=server::bap::current_activity_link_view(region,link)
+        && link.binding.sessionId==owner.session && link.activityClientGeneration==owner.generation
+        && sameWorld;
+    std::uint16_t selected=0xFFFF;
+    auto* manager=api.manager();
+    const bool ready=manager && api.mode(3);
+    if (ready) api.activity(&selected);
+    const bool ember=exact && selected==link.binding.destination.activityIndex;
+    const bool complete=exact && api.lifetime()==6;
+    const auto before=progress.stage;
+    const auto action=progress.observe(GetTickCount64(),{exact,ember,selected==0,complete,ready,
+        region,step,manager?field<int>(manager,0x3A0):-1,sameWorld});
+    if (before!=OrbitStage::banner && progress.stage==OrbitStage::banner) report("completion_accepted");
+    switch(action) {
+    case OrbitAction::select:
+        if (select_orbit()) report("selection_queued");
+        else { progress.stage=OrbitStage::finished;report("orbit_selection_unavailable"); }
+        break;
+    case OrbitAction::cleanup:
+        // The destination must already read back as orbit. Use native deferred cleanup;
+        // it retires the world on its own tick, outside the video decoder callback.
+        api.step(manager,28,309);report("cleanup_requested");break;
+    case OrbitAction::orbitSetup: report("orbit_setup");break;
+    case OrbitAction::canceled: report("canceled_world_or_owner_changed");break;
+    case OrbitAction::timedOut: report("transition_timeout");break;
+    default: break;
+    }
+    watching.store(progress.active());
+}
+}

+ 7 - 0
Sunrise/src/client/hooks/ember_movies/orbit_return.h

@@ -0,0 +1,7 @@
+#pragma once
+#include "ember_movies.h"
+namespace sunrise::client::hooks::ember_movies::orbit_return {
+void arm(Owner owner) noexcept;
+bool active() noexcept;
+void poll(int region, int step) noexcept;
+}

+ 46 - 0
Sunrise/src/client/hooks/ember_movies/orbit_rules.h

@@ -0,0 +1,46 @@
+#pragma once
+#include <cstdint>
+namespace sunrise::client::hooks::ember_movies {
+enum class OrbitAction { none, select, cleanup, orbitSetup, canceled, timedOut };
+enum class OrbitStage { idle, completion, banner, selection, leaving, finished };
+struct OrbitObservation {
+    bool exactOwner{}, emberSelected{}, orbitSelected{}, complete{}, ready{};
+    int region{-1}, step{-1}, pendingStep{-1};
+    bool sameWorld{};
+};
+struct OrbitReturn {
+    OrbitStage stage{OrbitStage::idle};
+    std::uint64_t began{}, bannerAt{};
+    void arm(std::uint64_t now) { stage=OrbitStage::completion; began=now; bannerAt=0; }
+    bool active() const { return stage!=OrbitStage::idle && stage!=OrbitStage::finished; }
+    OrbitAction observe(std::uint64_t now, const OrbitObservation& s) {
+        if (!active()) return OrbitAction::none;
+        auto end=[this](OrbitAction a) { stage=OrbitStage::finished; return a; };
+        if (now-began>90000) return end(OrbitAction::timedOut);
+        if (stage==OrbitStage::completion || stage==OrbitStage::banner) {
+            if (!s.exactOwner || !s.emberSelected || s.region!=0 || s.step!=38)
+                return end(OrbitAction::canceled);
+            if (!s.complete) return OrbitAction::none;
+            if (stage==OrbitStage::completion) { stage=OrbitStage::banner; bannerAt=now; }
+            // Let the accepted mission-complete presentation show before leaving.
+            if (now-bannerAt<8000 || !s.ready || s.pendingStep!=38) return OrbitAction::none;
+            stage=OrbitStage::selection;
+            return OrbitAction::select;
+        }
+        if (s.step==29 && s.orbitSelected) return end(OrbitAction::orbitSetup);
+        if (stage==OrbitStage::selection) {
+            // Native selection/commit can initiate cleanup itself. Never override it.
+            if (s.step!=38) { stage=OrbitStage::leaving; return OrbitAction::none; }
+            if (!s.sameWorld || (!s.emberSelected && !s.orbitSelected))
+                return end(OrbitAction::canceled);
+            if (s.orbitSelected && s.ready && s.pendingStep==38) {
+                stage=OrbitStage::leaving;
+                return OrbitAction::cleanup;
+            }
+        } else if (s.step==38 && !s.sameWorld) return end(OrbitAction::canceled);
+        // Cleanup can temporarily have neither an ActivityClient nor a selected activity.
+        // Keep observing that native transition; absence is not an arrival or a failure.
+        return OrbitAction::none;
+    }
+};
+}

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

@@ -33,10 +33,16 @@ The native CPU movie-frame routine (`41D140`) is observed after its original rou
 
 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.
 
+After both movies complete for the same owner, `orbit_return` waits for native lifetime 6 to be applied. It allows eight seconds for the completion banner and queues the native return selection once. 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.
+
+The return operations are recovered from native `E19D50`, also used by the UI path at `157727F`: initialize the 0x120-byte local descriptor with `BF84B0`, construct the default orbit selection with `C06330`, clear selections (`BF95D0`), select at priority 0 (`BFB1F0`), and commit reason 2 (`BF97D0`). Before any selection mutation, the descriptor must have reason 2 and activity index 0. A missing or non-orbit default is refused.
+
+The frame observer waits for `C294B0` to read back activity 0. If the native commit has already started leaving, it does not interfere. If still in step 38 with no other pending step, it requests deferred cleanup step 28 through `E1B4D0`, reason 309 (the native `unavailable` reason used by the documented Omega handoff). The normal cleanup routine owns world destruction. The old ActivityClient link may disappear after selection commit; cleanup readback therefore requires the same world arrival, rather than keeping an obsolete link alive. Returning to step 29 with orbit selected reports `orbit_setup` and ends observation. This receipt proves entry into orbit setup, not that its final rendered frame has been seen. A 90-second total bound logs failure without retrying selection or overriding another transition.
+
 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 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.
+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 completion/banner ordering, duplicate frames, retirement of the old link, native cleanup already in progress, stale ownership, world replacement and timeout.

+ 2 - 0
scripts/mission_ember/ending.lua

@@ -30,6 +30,8 @@ return function(m)
                 c:cancel_timer("ember.ending.poll")
                 c:set_variable("ember.ending", 3)
                 c:set_variable("ember.complete", true)
+                -- The movie bridge observes this native lifetime receipt after both movies,
+                -- allows the completion banner, then commits a guarded return to orbit.
                 c.lifetime:set{state = c.sdk.lifetime_states:at(6)}
                 c:set_phase(100)
             end

+ 40 - 0
tests/ending_retirement_test.cpp

@@ -4,6 +4,7 @@
 #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/ember_movies/orbit_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"
@@ -23,6 +24,45 @@ static void expect(Reader& reader, unsigned width, std::uint64_t expected) {
 }
 int main() {
     namespace movies=sunrise::client::hooks::ember_movies;
+    movies::OrbitReturn orbit;
+    movies::OrbitObservation inApex{true,true,false,false,true,0,38,38,true};
+    using OA=movies::OrbitAction;
+    assert(orbit.observe(1000,inApex)==OA::none); // Playing movies cannot arm return.
+    orbit.arm(1000);
+    assert(orbit.observe(9000,inApex)==OA::none); // Host must actually publish completion.
+    inApex.complete=true;
+    assert(orbit.observe(10000,inApex)==OA::none);
+    assert(orbit.observe(17999,inApex)==OA::none); // Full banner window.
+    assert(orbit.observe(18000,inApex)==OA::select);
+    assert(orbit.observe(18001,inApex)==OA::none); // No duplicate commit.
+    auto inOrbit=inApex; inOrbit.emberSelected=false;inOrbit.orbitSelected=true;
+    inOrbit.exactOwner=false; // Committing orbit may already retire the old ActivityClient link.
+    assert(orbit.observe(18002,inOrbit)==OA::cleanup); // Requires destination readback.
+    assert(orbit.observe(18003,inOrbit)==OA::none); // No repeated cleanup request.
+    movies::OrbitObservation betweenWorlds{};betweenWorlds.step=28;
+    assert(orbit.observe(18004,betweenWorlds)==OA::none);
+    assert(orbit.active());
+    inOrbit.step=29;
+    assert(orbit.observe(18100,inOrbit)==OA::orbitSetup);
+    assert(!orbit.active() && orbit.observe(20000,inApex)==OA::none);
+    orbit.arm(30000);
+    auto stale=inApex;stale.exactOwner=false;
+    assert(orbit.observe(31000,stale)==OA::canceled);
+    orbit.arm(40000);
+    auto departed=inApex;departed.step=28;
+    assert(orbit.observe(41000,departed)==OA::canceled);
+    orbit.arm(50000);inApex.complete=false;
+    assert(orbit.observe(140001,inApex)==OA::timedOut);
+    orbit.arm(150000);inApex.complete=true;
+    assert(orbit.observe(150000,inApex)==OA::none);
+    assert(orbit.observe(158000,inApex)==OA::select);
+    assert(orbit.observe(158001,departed)==OA::none); // Native cleanup already began.
+    assert(orbit.observe(158100,inOrbit)==OA::orbitSetup);
+    orbit.arm(160000);
+    assert(orbit.observe(160000,inApex)==OA::none);
+    assert(orbit.observe(168000,inApex)==OA::select);
+    auto replaced=inOrbit;replaced.step=38;replaced.sameWorld=false;
+    assert(orbit.observe(168001,replaced)==OA::canceled);
     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

+ 18 - 0
tests/verify_ember_movie_native.py

@@ -23,6 +23,24 @@ def target(base, offset, expected):
     assert value == expected, (hex(base), hex(offset), hex(value), hex(expected))
 
 path = 'Sunrise/src/client/hooks/ember_movies/resources.cpp'
+orbit = 'Sunrise/src/client/hooks/ember_movies/orbit_return.cpp'
+return_path = signature(orbit, 'returnSig')
+assert return_path == 0xE19D50
+for offset, expected in [(0x1E,0xB48550),(0x2F,0xE35820),(0x5B,0xBF84B0),
+                         (0x65,0xC06330),(0x6A,0xBF95D0),(0x7E,0xBFB1F0),(0x86,0xBF97D0)]:
+    target(return_path,offset,expected)
+assert signature(orbit,'activitySig') == 0xC294B0
+assert signature(orbit,'lifetimeSig') == 0x4FFD10
+assert signature(orbit,'stepSig') == 0xE1B4D0
+target(0xE1D619,0,0xE35820)
+target(0xE1D619,0x15,0xE1B4D0)
+assert data[0xE1D626:0xE1D62B] == bytes.fromhex('ba 1c 00 00 00')
+# Native UI return selection: initialize, construct default orbit, clear/select/commit.
+for site, expected in [(0x157727F,0xBF84B0),(0x157728C,0xC06330),
+                       (0x1577291,0xBF95D0),(0x15772A8,0xBFB1F0),(0x15772B4,0xBF97D0)]:
+    target(site,0,expected)
+assert data[0xBF84E4:0xBF84EB] == bytes.fromhex('c6 83 18 01 00 00 00')
+print('Native orbit selection/commit, lifetime reader and deferred cleanup ABI verified.')
 load = signature(path, 'loadSig')
 end = signature(path, 'endSig')
 assert load == 0xB46E10 and end == 0xB44020