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

Suppress loading cinematics and unblock Ember ending publication

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

+ 35 - 0
MISSION_EMBER_POST_OPUS.md

@@ -0,0 +1,35 @@
+# Post-Opus follow-up — 6 September 2026
+
+Read `OpusHandoff.md` and retained Claude's latest beam polarity (normal open, surge close), thermal effect, objective and ending changes. This is an installed test candidate, not a claim that the remaining visual issues have passed gameplay validation.
+
+## Loading cinematic suppression
+
+The Omega contributor supplied `LoadingCinematics_Suppressed`, then RVA `0xC24490`. In the captured native image this is a no-argument boolean wrapper. Its travel/spaceflight callers skip cinematic setup and waiting when it returns true (D46900, D474D0, EE50A0 and EF44C0 inspected).
+
+`client.suppress_loading_cinematics` now forces that predicate true. It defaults false in source and is enabled in the installed settings. Setting it false restores the original predicate. It applies to loading cinematics generally, rather than being scoped to a particular mission. The scripted type-6 movie controller is not detoured. Verify that the erroneous Earth fly-in disappears while the authored 1AU introduction and ending movies still play.
+
+The hook resolves the unique EF44C0 caller signature, follows its call at +30 and checks the target's boolean-wrapper bytes. Offline signature validation found exactly one caller and resolved C24490. The short predicate signature alone matches two functions and is deliberately not used for lookup.
+
+## Ending publication
+
+Claude's pending fix cleared an arrival window when the old and new selected plans share a slice set. Ordinary traversal can leave the selected plan at landing (64) while the client already holds Apex (0). Selecting bookend 1 then incorrectly reopened an arrival window by comparing plans alone.
+
+Selection now reads the instantiated client region. A target world already held does not open an arrival wait, and a repeated selection repairs a stale pending flag. Tests cover landing-plan-to-bookend selection with Apex held, a sibling bookend, a real world change, and unknown client placement. A live ending test must verify publication advances and both STM/CNN movies complete; this fix addresses the observed lease deadlock and does not prove there are no subsequent native readiness gates.
+
+## Escape ship
+
+Preserved and tested Claude's uncommitted change: instantiate `REACTOR_GETAWAY_SHIP_OBJECT`, unlock and power on its paired device, then open it without snapping. A regression verifies this order after accepted cell delivery. The authored ship model is 80BFDDC2, with components 80BFDDC0, 815B8D64 and 80C70CAE. Actual flight/animation still needs visual confirmation.
+
+## Remaining surge screen effect
+
+Beam pose is user-confirmed and its corrected polarity is preserved. No additional screen-effect trigger has been established or installed. The alarm sequence resource 80BD1525 is a model with components 80C70B84 and 80F1F165; those are extracted in `build/first-encounter-audit/tags`. Inspect their effect graph before inventing another hop-on or treating a label search as proof that the effect does not exist. The existing alarm sequences remain triggered during the warning phase.
+
+## Validation and install
+
+Release DLL build passed, all 22 portable tests passed, all five Lua suites passed, and `git diff --check` passed. Full-route simulator peak: 234 variables, 61 intents/event, three timers, 13,000 instructions/event. These tests do not validate rendered effects.
+
+Installed 19 files, including the settings change, with complete prior-file backups and SHA-256 verification. No game launch, save edits or SDK changes.
+
+Backup: `/home/millie/Documents/Sunrise-builds/mission-ember/build/post-opus-install-backup-20260906-150504`.
+
+Installed DLL SHA-256: `be4ea67c9399c4f08c41eac25f9ed2f27152dff9876b449e8d0d60e7fc9a0139`.

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

@@ -28,6 +28,7 @@ constexpr std::array kFixes{
     Fix{&stage_orbit_slice_set, &publish_orbit_slice_set},
     Fix{&stage_composition_check, &publish_composition_check},
     Fix{&stage_orbit_handoff, &publish_orbit_handoff},
+    Fix{&stage_loading_cinematics, &publish_loading_cinematics},
     Fix{&stage_owner_activity_slot, &publish_owner_activity_slot},
     Fix{&stage_region_private, &publish_region_private},
 };
@@ -107,6 +108,7 @@ void uninstall() noexcept {
     uninstall_world_step();
     uninstall_region_private();
     uninstall_owner_activity_slot();
+    uninstall_loading_cinematics();
     uninstall_orbit_handoff();
     uninstall_composition_check();
     uninstall_orbit_slice_set();

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

@@ -76,6 +76,11 @@ void publish_orbit_handoff(const hooking::detour::Handle& handle) noexcept;
 /** Detaches the orbit handoff release. */
 void uninstall_orbit_handoff() 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;
+void uninstall_loading_cinematics() noexcept;
+
 /**
  * Stages the owner activity slot force. It pins the participation record to the replicated
  * snapshot at `comp + 496` instead of the local one at `comp + 1256`.

+ 57 - 0
Sunrise/src/client/hooks/bootflow/loading_cinematics.cpp

@@ -0,0 +1,57 @@
+#include <cstring>
+#include <string_view>
+
+#include "../../../core/logging/log.h"
+#include "../../../core/settings/settings.h"
+#include "internal.h"
+
+namespace sunrise::client::hooks::bootflow {
+namespace {
+// EF44C0's travel hold calls LoadingCinematics_Suppressed at +30 (C24490 in
+// the captured build). The tiny predicate alone matches two functions.
+constexpr std::string_view kCallerText =
+    "48 89 5C 24 ? 57 48 83 EC ? 48 8B D9 E8 ? ? ? ? 80 3D ? ? ? ? 00 "
+    "48 8B F8 75 ? E8 ? ? ? ? 84 C0 75 ? E8 ? ? ? ? 84 C0 75 ?";
+constexpr auto kCaller = signature<signature_length(kCallerText)>(kCallerText);
+using Predicate = bool(__fastcall*)();
+hooking::detour::Handle g_handle{};
+
+bool __fastcall loading_cinematics_suppressed() noexcept {
+    if (core::settings::get().client.suppressLoadingCinematics) {
+        return true;
+    }
+    const auto original = reinterpret_cast<Predicate>(g_handle.original);
+    return original != nullptr && original();
+}
+} // namespace
+
+StageResult stage_loading_cinematics(hooking::detour::Spec& spec) noexcept {
+    if (g_handle.attached) return StageResult::attached;
+    const auto* caller = scan_main_image_unique(kCaller, "loading_cinematics_hold");
+    if (caller == nullptr) return StageResult::unavailable;
+    auto* target = resolve_relative(caller + 31, caller + 35);
+    // Validate the no-argument boolean wrapper before staging its detour.
+    constexpr unsigned char head[]{0x48, 0x83, 0xEC, 0x28, 0xE8};
+    constexpr unsigned char tail[]{0x84, 0xC0, 0x0F, 0x95, 0xC0, 0x48, 0x83, 0xC4, 0x28, 0xC3};
+    if (target == nullptr || std::memcmp(target, head, sizeof(head)) != 0
+        || std::memcmp(target + 9, tail, sizeof(tail)) != 0) {
+        core::log::write(core::log::Channel::client, core::log::Level::warn,
+                        "ev=bootflow stage=loading_cinematics result=fail reason=predicate_signature");
+        return StageResult::unavailable;
+    }
+    spec = hooking::detour::Spec{target, reinterpret_cast<void*>(&loading_cinematics_suppressed)};
+    return StageResult::staged;
+}
+
+void publish_loading_cinematics(const hooking::detour::Handle& handle) noexcept {
+    g_handle = handle;
+    core::log::write(core::log::Channel::client,
+                    handle.attached ? core::log::Level::info : core::log::Level::warn,
+                    handle.attached ? "ev=bootflow stage=loading_cinematics result=attached"
+                                    : "ev=bootflow stage=loading_cinematics result=fail reason=attach");
+}
+
+void uninstall_loading_cinematics() noexcept {
+    if (g_handle.attached) (void)hooking::detour::uninstall(g_handle);
+}
+} // namespace sunrise::client::hooks::bootflow

+ 12 - 0
Sunrise/src/core/settings/client/client_settings_parser.cpp

@@ -15,6 +15,8 @@ bool Parser::client_settings(client::Settings& output) noexcept {
     bool hasRevealLoreBooks = false;
     bool hasRegionPrivate = false;
     bool hasSkipOrbitCinematicWait = false;
+    bool hasSuppressLoadingCinematics = false;
+    bool hasSuppressPeerRelay = false;
     bool hasPinReplicatedRecord = false;
     if (consume('}')) {
         return true;
@@ -59,6 +61,16 @@ bool Parser::client_settings(client::Settings& output) noexcept {
                 return false;
             }
             hasSkipOrbitCinematicWait = true;
+        } else if (key == "suppress_loading_cinematics") {
+            if (hasSuppressLoadingCinematics || !boolean(candidate.suppressLoadingCinematics)) {
+                return false;
+            }
+            hasSuppressLoadingCinematics = true;
+        } else if (key == "suppress_peer_relay") {
+            if (hasSuppressPeerRelay || !boolean(candidate.suppressPeerRelay)) {
+                return false;
+            }
+            hasSuppressPeerRelay = true;
         } else if (key == "pin_replicated_record") {
             if (hasPinReplicatedRecord || !boolean(candidate.pinReplicatedRecord)) {
                 return false;

+ 2 - 0
Sunrise/src/core/settings/client/definition.h

@@ -32,6 +32,8 @@ struct Settings {
      * suppresses the orbit-side entry cinematic.
      */
     bool skipOrbitCinematicWait{false};
+    /** Force LoadingCinematics_Suppressed before travel; authored mission movies stay native. */
+    bool suppressLoadingCinematics{false};
     /**
      * Pins the participation record to the replicated snapshot at `comp + 496`.
      * The msg-5 spawn hold reaches no other record.

+ 22 - 2
Sunrise/src/server/bap/bap_route.cpp

@@ -11,6 +11,7 @@
 
 #include "../../core/logging/log.h"
 #include "../../state/activity/runtime.h"
+#include "../../state/activity/membership/activity_membership_query.h"
 #include "../../state/build_data/runtime.h"
 #include "../../state/matchmaking/matchmaking_state.h"
 #include "../../state/runtime/runtime.h"
@@ -503,7 +504,15 @@ select_activity_mission_seed(const state::activity::SessionBinding& binding,
     }
     if (status == ActivityMissionSeedLeaseStatus::ready) {
         MissionSeedLease& lease = session->activityMissionSeed;
+        // Natural traversal does not select another seed plan. The retained plan can still
+        // name the landing while the client's instantiated world is already Apex.
+        const auto placement = state::activity::membership::reported_placement(binding.sessionId);
+        const auto heldRegion = state::activity::membership::instantiated_region(placement);
+        const bool targetHeld = encrypted::push::activity::mission_seed_arrival_window_closed(
+            heldRegion, plan.effectiveRegion, plan.sliceSetIndex,
+            middleware::content::packages::tables::kSliceSetIndexFactor);
         if (lease.configured && same_mission_seed_plan(lease.plan, plan)) {
+            if (targetHeld) lease.regionArrivalPending = false;
             // The script may select the plan the roster adopted by default. That is a selection.
             lease.scriptSelected = true;
             return ActivityMissionSeedLeaseStatus::ready;
@@ -540,13 +549,24 @@ select_activity_mission_seed(const state::activity::SessionBinding& binding,
             // deadlock: the roster withholds the new region's groups forever and the client never
             // finishes synchronizing. Only a real slice-set change opens the arrival window.
             if (lease.configured
-                && encrypted::push::activity::mission_seed_region_change_replaces_world(
+                && encrypted::push::activity::mission_seed_selection_needs_arrival(
                     lease.plan.sliceSetIndex,
                     lease.plan.effectiveRegion,
                     plan.sliceSetIndex,
-                    plan.effectiveRegion)) {
+                    plan.effectiveRegion, heldRegion,
+                    middleware::content::packages::tables::kSliceSetIndexFactor)) {
                 lease.previousPlan = lease.plan;
                 lease.regionArrivalPending = true;
+            } else {
+                // This selection replaces no world, so it has no arrival to wait for -- and any
+                // window still open from an earlier one must close here rather than at
+                // publication. While it is set the roster both refuses to commit a published
+                // revision and suppresses the send that would clear it, so a window that outlives
+                // its own selection can never resolve: the lease stays unpublished, every scene
+                // lease on the new state reports a pending mission seed, and the selection's own
+                // gate is skipped so nothing notices. The ending stalled exactly there, with
+                // revision 3 against published 2 and the window still open.
+                lease.regionArrivalPending = false;
             }
             lease.plan = plan;
             lease.bindingGeneration = session->activity.bindingGeneration;

+ 9 - 0
Sunrise/src/server/bap/encrypted/push/activity/mission_seed_world_change.h

@@ -63,4 +63,13 @@ mission_seed_arrival_window_closed(std::int32_t heldRegion,
            || held - (held % sliceSetFactor) == pendingSliceSetIndex;
 }
 
+/** A stale seed plan must not reopen arrival after ordinary traversal reached the target world. */
+[[nodiscard]] constexpr bool mission_seed_selection_needs_arrival(
+    std::uint32_t oldSliceSet, std::uint32_t oldRegion,
+    std::uint32_t newSliceSet, std::uint32_t newRegion,
+    std::int32_t heldRegion, std::uint32_t factor) noexcept {
+    return mission_seed_region_change_replaces_world(oldSliceSet, oldRegion, newSliceSet, newRegion)
+        && !mission_seed_arrival_window_closed(heldRegion, newRegion, newSliceSet, factor);
+}
+
 } // namespace sunrise::server::bap::encrypted::push::activity

+ 6 - 1
scripts/mission_ember/apex.lua

@@ -212,8 +212,13 @@ return function(m, a, ending)
             a.device(c, "MOTHER_BRAIN_CONSOLE_DEVICE", true)
             a.device(c, "MOTHER_BRAIN_ENGINE_LEFT_DEVICE", true)
             a.device(c, "MOTHER_BRAIN_ENGINE_RIGHT_DEVICE", true)
-            a.device(c, "REACTOR_GETAWAY_SHIP_DEVICE", true)
             a.objects(c, {"REACTOR_GETAWAY_SHIP_OBJECT", "SUNBURN_DAMAGE_OBJECT"}, true)
+            -- The escape ship has no authored flight path: the four Harvesters own the only
+            -- type-58 sequences in the mission, so this ship travels on its own device's
+            -- position lane. That device was never unlocked or powered, which is the same
+            -- omission that left the weapon beam inert, so it held its start pose.
+            unlock(c, "REACTOR_GETAWAY_SHIP_DEVICE")
+            a.device(c, "REACTOR_GETAWAY_SHIP_DEVICE", true)
             -- The weapon is dead once the cell is in: powered off but still installed, so
             -- the beam and everything built around it stay in the world. The previous code
             -- opened both devices here, which left it running through the whole escape.

+ 13 - 1
tests/mission_ember_routes_test.lua

@@ -333,7 +333,19 @@ call(R.dispatch,'object',c,s,event('MOTHER_BRAIN_CARRY_OBJECT',{generation=1,pre
 timer('ember.carry.recover.apex.')
 assert(vars['ember.carry.apex.generation']==3 and vars['ember.apex.phase']==5)
 use('MOTHER_BRAIN_CARRY_OBJECT',1);use('MOTHER_BRAIN_INTERACT_OBJECT');assert(vars['ember.apex.phase']==5)
+local depositStart=#calls
 use('MOTHER_BRAIN_CARRY_OBJECT',3);use('MOTHER_BRAIN_INTERACT_OBJECT');assert(vars['ember.apex.phase']==6)
+-- The device cannot animate an absent object or one left locked/unpowered.
+local shipSteps={}
+for i=depositStart+1,#calls do
+    local row=calls[i]
+    if row[2]==slotDefs[m.Slot.REACTOR_GETAWAY_SHIP_OBJECT].name and row[1]=='set_object_active' and row[3].active then
+        shipSteps[#shipSteps+1]='spawn'
+    elseif row[2]==slotDefs[m.Slot.REACTOR_GETAWAY_SHIP_DEVICE].name and row[1]=='transition' then
+        shipSteps[#shipSteps+1]=row[3].transition
+    end
+end
+assert(table.concat(shipSteps,',')=='spawn,unlock,power_on,open', 'escape ship activation order')
 assert(vars['ember.carry.apex.done'] and not vars['ember.carry.apex.held'])
 local before=#calls;use('MOTHER_BRAIN_INTERACT_OBJECT');assert(#calls==before)
 call(R.dispatch,'object',c,s,event('MOTHER_BRAIN_CARRY_OBJECT',{generation=3,present=false,alive=false}))
@@ -382,7 +394,7 @@ reset_check('escape',function()
 end)
 trigger('APEX_DIRECTIVE_REACTOR_RAILS_ESCAPE_PLAYER_TRIGGER')
 -- Regions 0, 1 and 2 are sibling states of one slice set, so the client never reports a new
--- held region. The bookend is activated on a later callback, never in the selecting one.
+-- held region. Both the selection and activation must be queued in this callback.
 -- No teleport is armed for a sibling state, so no further client report arrives. The movie
 -- must be queued with its own selection or it never starts.
 assert(vars['ember.ending']==1 and vars['ember.ending.playing']==1,'first movie never started')

+ 7 - 0
tests/mission_seed_world_change_test.cpp

@@ -107,6 +107,13 @@ void arrival_window_stays_open_across_a_real_slice_set_change() {
 } // namespace
 
 int main() {
+    // Real full-mission case: the last explicit seed is the landing, but ordinary
+    // traversal already brought the player to Apex before selecting its ending.
+    assert(!seed::mission_seed_selection_needs_arrival(64, 64, 0, 1, 0, 8));
+    assert(!seed::mission_seed_selection_needs_arrival(0, 1, 0, 2, 0, 8));
+    assert(seed::mission_seed_selection_needs_arrival(64, 64, 0, 1, 64, 8));
+    assert(seed::mission_seed_selection_needs_arrival(64, 64, 0, 1, -1, 8));
+
     sibling_states_keep_their_world();
     slice_set_changes_replace_the_world();
     reselecting_the_same_state_is_never_a_replacement();