Browse Source

Retire Ember gameplay before bookend travel and restore escape scorch

Millie 3 days ago
parent
commit
0aea9a79d0

+ 10 - 1
Sunrise/src/client/hooks/bootflow/world_step.cpp

@@ -7,7 +7,8 @@
 #include "../../../core/logging/log.h"
 #include "bootflow_hook_lifecycle.h"
 #include "internal.h"
-#include "spawn/slice_set_sample.h"
+#include "spawn/probe.h"
+#include "../mission_retirement/mission_retirement.h"
 
 namespace sunrise::client::hooks::bootflow {
 namespace {
@@ -60,6 +61,14 @@ void poll_world_step() noexcept {
 /** Publishes the client's current local slice-set index. */
 void poll_current_slice_set() noexcept {
     const std::int32_t index = spawn::sample_current_slice_set();
+    mission_retirement::poll(index);
+    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
+    // the new world stays black behind the spent one-shot.
+    if (index >= 0 && previous >= 0 && index != previous) {
+        rearm_fade_release();
+    }
     g_publishedSliceSet.store(index, std::memory_order_relaxed);
     g_publishedSliceSetTick.store(GetTickCount64(), std::memory_order_release);
 }

+ 100 - 0
Sunrise/src/client/hooks/mission_retirement/mission_retirement.cpp

@@ -0,0 +1,100 @@
+#include "mission_retirement.h"
+#include <Windows.h>
+#include <array>
+#include <cstring>
+#include "../../../core/logging/log.h"
+#include "../../patterns/image_scan.h"
+#include "../../patterns/signature_text.h"
+
+namespace sunrise::client::hooks::mission_retirement {
+namespace {
+constexpr std::size_t kCapacity = 96;
+SRWLOCK g_lock = SRWLOCK_INIT;
+RequestId g_id{};
+Progress g_progress{};
+std::array<std::uint32_t, kCapacity> g_keys{};
+std::size_t g_count{};
+ULONGLONG g_started{};
+const std::uint16_t* g_registryHandle{};
+using Resolve = const std::uintptr_t*(__fastcall*)(std::uint16_t);
+Resolve g_resolve{};
+bool g_resolved{};
+
+void report(const char* line) noexcept {
+    core::log::write(core::log::Channel::client, core::log::Level::info, line);
+}
+void resolve() noexcept {
+    if (g_resolved) return;
+    g_resolved = true;
+    // 4E2580's clear routine supplies the registry handle and read-only pool accessor.
+    // Resolve those operands; do not call the clearing routine.
+    constexpr std::string_view text =
+        "48 83 EC 28 0F B7 0D ? ? ? ? 66 85 C9 74 ? E8 ? ? ? ? "
+        "48 8B 08 48 85 C9 74 ? 33 C0 33 D2 89 41 08 41 B8 24 01 00 00";
+    constexpr auto sig = patterns::signature<patterns::signature_length(text)>(text);
+    auto* p = patterns::scan_main_image_unique(sig, "mission_retirement_registry");
+    if (!p) return;
+    g_registryHandle = reinterpret_cast<const std::uint16_t*>(patterns::resolve_relative(p + 7, p + 11));
+    g_resolve = reinterpret_cast<Resolve>(patterns::resolve_relative(p + 17, p + 21));
+}
+bool sample(std::uintptr_t& owner, std::size_t& matching, std::uint32_t& count) noexcept {
+    __try {
+        if (!g_registryHandle || !g_resolve || !*g_registryHandle) return false;
+        owner = *g_resolve(*g_registryHandle);
+        if (!owner) return false;
+        count = *reinterpret_cast<const std::uint32_t*>(owner + 8);
+        if (count == 0 || count > 128) return false; // An empty/destroyed world is not retirement.
+        matching = 0;
+        for (std::uint32_t i = 0; i < count; ++i) {
+            const auto key = *reinterpret_cast<const std::uint32_t*>(owner + 20 + 24 * i);
+            for (std::size_t j = 0; j < g_count; ++j)
+                if (key == g_keys[j]) { ++matching; break; }
+        }
+        return owner == *g_resolve(*g_registryHandle)
+            && count == *reinterpret_cast<const std::uint32_t*>(owner + 8);
+    } __except (EXCEPTION_EXECUTE_HANDLER) { return false; }
+}
+}
+Status prepare(RequestId id, std::int32_t sourceRegion,
+    std::span<const std::uint32_t> keys) noexcept {
+    AcquireSRWLockExclusive(&g_lock);
+    if (!(g_id == id)) {
+        g_id = id; g_progress.owner = 0; g_count = 0; g_progress.sourceRegion = sourceRegion;
+        g_started = GetTickCount64();
+        g_progress.value = keys.empty() || keys.size() > kCapacity || sourceRegion < 0
+            ? Status::failed : Status::baselinePending;
+        if (g_progress.value != Status::failed) {
+            g_count = keys.size();
+            std::memcpy(g_keys.data(), keys.data(), keys.size_bytes());
+        }
+    }
+    const auto result = g_progress.value;
+    ReleaseSRWLockExclusive(&g_lock);
+    return result;
+}
+Status status(RequestId id) noexcept {
+    AcquireSRWLockShared(&g_lock);
+    const auto result = g_id == id ? g_progress.value : Status::absent;
+    ReleaseSRWLockShared(&g_lock);
+    return result;
+}
+void poll(std::int32_t currentRegion) noexcept {
+    AcquireSRWLockExclusive(&g_lock);
+    if (g_progress.value == Status::baselinePending || g_progress.value == Status::retiring) {
+        resolve();
+        std::uintptr_t owner{}; std::size_t matching{}; std::uint32_t count{};
+        if (GetTickCount64() - g_started > 120000) {
+            g_progress.value = g_progress.owner ? Status::failedRetiring : Status::failed;
+            report("ev=mission_retirement result=failed reason=native_cleanup_timeout");
+        } else if (sample(owner, matching, count)) {
+            const auto before = g_progress.value;
+            g_progress.observe(currentRegion, owner, count, matching);
+            if (before != g_progress.value)
+                report(g_progress.value == Status::complete
+                    ? "ev=mission_retirement result=native_cleanup_complete"
+                    : "ev=mission_retirement result=baseline_observed");
+        }
+    }
+    ReleaseSRWLockExclusive(&g_lock);
+}
+}

+ 31 - 0
Sunrise/src/client/hooks/mission_retirement/mission_retirement.h

@@ -0,0 +1,31 @@
+#pragma once
+#include <cstdint>
+#include <span>
+
+namespace sunrise::client::hooks::mission_retirement {
+struct RequestId {
+    std::uint64_t session{}, binding{}, revision{};
+    bool operator==(const RequestId&) const = default;
+};
+enum class Status { absent, baselinePending, retiring, complete, failed, failedRetiring };
+struct Progress {
+    Status value{Status::absent};
+    std::int32_t sourceRegion{-1};
+    std::uintptr_t owner{};
+    void observe(std::int32_t region, std::uintptr_t observedOwner,
+        std::uint32_t groupCount, std::size_t matchingKeys) noexcept {
+        if (region != sourceRegion || !observedOwner || !groupCount || groupCount > 128) return;
+        if (value == Status::baselinePending && matchingKeys > 0) {
+            owner = observedOwner; value = Status::retiring;
+        } else if (value == Status::retiring && owner == observedOwner && matchingKeys == 0) {
+            value = Status::complete;
+        }
+    }
+};
+// Keys remain at their wire ordinals. The client observes them before and after removal.
+Status prepare(RequestId id, std::int32_t sourceRegion,
+    std::span<const std::uint32_t> keys) noexcept;
+Status status(RequestId id) noexcept;
+// Called only from the existing native frame observation point; never changes game memory.
+void poll(std::int32_t currentRegion) noexcept;
+}

+ 5 - 3
Sunrise/src/middleware/bap/activity_message/activity_sensor_auth_blocks.cpp

@@ -1,6 +1,7 @@
 #include <algorithm>
 
 #include "sensor_auth_update.h"
+#include "roster_presence.h"
 
 namespace sunrise::middleware::bap::activity_message::sensor_auth_update {
 namespace {
@@ -141,9 +142,10 @@ group_state_sequence(const Roster& roster, std::uint32_t key, std::uint8_t fallb
     for (std::size_t index = 0; encoded && index < keyCount; ++index) {
         encoded = writer.write(block.keys[index], kKeyWidth);
     }
-    encoded = encoded && writer.write(1, kPresenceWidth)
-              && write_key_mask(writer, keyCount, kBubbleMaskWords)
-              && writer.write(1, kPresenceWidth) && writer.write(count, kBubbleCountWidth);
+    encoded = encoded && writer.write(1, kPresenceWidth);
+    for (std::size_t word = 0; encoded && word < kBubbleMaskWords; ++word)
+        encoded = writer.write(presence_word(roster, block.keys, word), kChunkWidth);
+    encoded = encoded && writer.write(1, kPresenceWidth) && writer.write(count, kBubbleCountWidth);
     for (std::size_t index = 0; encoded && index < keyCount; ++index) {
         encoded = writer.write(
             kStateByteBias + group_state_sequence(roster, block.keys[index], stateSequence), 8);

+ 8 - 7
Sunrise/src/middleware/bap/activity_message/activity_sensor_auth_encoder.cpp

@@ -71,15 +71,14 @@ constexpr std::uint32_t kMaximumRegion = 0x7FFFFFFF;
     }
     std::size_t topLevelRecords = 0;
     for (std::size_t index = 0; index < roster.topLevelGroupCount; ++index) {
-        if (!add_client_records(roster.groups[index].slotTypes.size(), topLevelRecords)) {
+        if (roster.groups[index].retired
+            || !add_client_records(roster.groups[index].slotTypes.size(), topLevelRecords)) {
             return false;
         }
     }
     std::array<bool, kPublishedGroupCapacity> referenced{};
     for (const BubbleSubBlock& block : roster.bubbleSubBlocks) {
-        if (block.keys.size() > kClientGroupCapacity - roster.topLevelGroupCount) {
-            return false;
-        }
+        std::size_t activeGroups = roster.topLevelGroupCount;
         std::size_t activeRecords = topLevelRecords;
         for (const std::uint32_t key : block.keys) {
             std::size_t matched = roster.groupCount;
@@ -94,10 +93,11 @@ constexpr std::uint32_t kMaximumRegion = 0x7FFFFFFF;
                 }
                 matched = index;
             }
-            if (matched == roster.groupCount
-                || !add_client_records(roster.groups[matched].slotTypes.size(), activeRecords)) {
+            if (matched == roster.groupCount) return false;
+            if (!roster.groups[matched].retired
+                && (++activeGroups > kClientGroupCapacity
+                    || !add_client_records(roster.groups[matched].slotTypes.size(), activeRecords)))
                 return false;
-            }
             referenced[matched] = true;
         }
     }
@@ -248,6 +248,7 @@ constexpr std::uint32_t kMaximumRegion = 0x7FFFFFFF;
     bool keyPlaced = false;
     for (std::size_t group = 0; encoded && group < snapshot.roster.groupCount; ++group) {
         const Group& row = snapshot.roster.groups[group];
+        if (row.retired) continue;
         // The filler word after the key is read and discarded.
         encoded = writer.write(1, kPresenceWidth) && writer.write(row.key, kKeyWidth)
                   && writer.write(0, kKeyWidth);

+ 31 - 0
Sunrise/src/middleware/bap/activity_message/roster_presence.h

@@ -0,0 +1,31 @@
+#pragma once
+#include "sensor_auth_update.h"
+
+namespace sunrise::middleware::bap::activity_message::sensor_auth_update {
+inline bool extend_key_order(std::span<std::uint32_t> order, std::size_t& count,
+    std::span<const std::uint32_t> incoming) noexcept {
+    if (count > order.size()) return false;
+    for (auto key : incoming) {
+        bool known = false;
+        for (std::size_t i = 0; i < count; ++i) known = known || order[i] == key;
+        if (!known) {
+            if (count == order.size()) return false;
+            order[count++] = key;
+        }
+    }
+    return true;
+}
+inline bool key_present(const Roster& roster, std::uint32_t key) noexcept {
+    for (std::size_t i = 0; i < roster.groupCount; ++i)
+        if (roster.groups[i].key == key) return !roster.groups[i].retired;
+    return false;
+}
+// The mask indexes the retained key array. A removed key must not shift its neighbours.
+inline std::uint32_t presence_word(const Roster& roster,
+    std::span<const std::uint32_t> keys, std::size_t word) noexcept {
+    std::uint32_t mask = 0;
+    for (std::size_t bit = 0; bit < 32 && word * 32 + bit < keys.size(); ++bit)
+        if (key_present(roster, keys[word * 32 + bit])) mask |= std::uint32_t{1} << bit;
+    return mask;
+}
+}

+ 2 - 0
Sunrise/src/middleware/bap/activity_message/sensor_auth_update.h

@@ -98,6 +98,8 @@ struct Group final {
     bool hasStateSequence{};
     /** True only for a generated mission group whose non-overridden slots seed empty deltas. */
     bool missionSeedOnly{};
+    /** Retain this key's ordinal but clear its presence and omit its authority bodies. */
+    bool retired{};
 };
 
 /** One exact, already-registered slot body substituted into phase 2. */

+ 25 - 1
Sunrise/src/server/activity/mission/mission_script_runtime_dispatch.cpp

@@ -17,6 +17,7 @@
 #include "../activity_sdk_squad_runtime.h"
 #include "mission_script_runtime.h"
 #include "mission_script_runtime_internal.h"
+#include "../../../client/hooks/mission_retirement/mission_retirement.h"
 
 // The intent fan-out reserves one Host output revision, then asks one typed adapter to encode it.
 
@@ -230,8 +231,12 @@ void arm_state_region_teleport(RuntimeInstance& instance,
         // arm a move it can never finish.
         return;
     }
+    const auto activities = instance.view.catalog->activities();
+    const bool emberBookend = !instance.publicTarget && instance.view.activityRow < activities.size()
+        && activities[instance.view.activityRow].definitionHash == 0x38F926B2U
+        && (plan.effectiveRegion == 1 || plan.effectiveRegion == 2);
     const bool armed = membership::arm_host_teleport(
-        instance.view.binding.sessionId, static_cast<std::int32_t>(plan.effectiveRegion), hash);
+        instance.view.binding.sessionId, static_cast<std::int32_t>(plan.effectiveRegion), hash, emberBookend);
     log_line(core::log::Level::info,
              &instance,
              "state_region",
@@ -314,6 +319,25 @@ void dispatch_intent(RuntimeInstance& instance, std::uint64_t now) noexcept {
         // A selected state names its own slice-set region. Until the client transitions there its
         // object registry comes from the loaded slice-set entry, so the new state's objects stay
         // unfindable. Arming the host teleport is the only mid-activity move.
+        const auto activities = instance.view.catalog->activities();
+        const bool emberBookend = !instance.publicTarget
+            && instance.view.activityRow < activities.size()
+            && activities[instance.view.activityRow].definitionHash == 0x38F926B2U
+            && (selected.plan.effectiveRegion == 1 || selected.plan.effectiveRegion == 2);
+        if (emberBookend && selected.regionArrivalPending) {
+            namespace retirement = client::hooks::mission_retirement;
+            const auto cleanup = retirement::status({instance.view.binding.sessionId,
+                selected.activityClientGeneration, selected.revision});
+            if (cleanup == retirement::Status::failed || cleanup == retirement::Status::failedRetiring) {
+                refuse_delivery(instance, "retirement_failed", "native roster cleanup did not complete",
+                    host::EffectOutcome::refused);
+                return;
+            }
+            if (cleanup != retirement::Status::complete) {
+                report_intent_status(instance, kIntentStatusStateTransitionPending, "native_retirement_pending");
+                return;
+            }
+        }
         arm_state_region_teleport(instance, selected.plan);
         static_cast<void>(complete_local_effect(instance, "state_selected"));
         return;

+ 85 - 1
Sunrise/src/server/bap/encrypted/push/activity/activity_mission_seed_roster.cpp

@@ -1,5 +1,8 @@
 #include "activity_mission_seed_roster.h"
+#include "../../../../../client/hooks/mission_retirement/mission_retirement.h"
+#include "../../../../../middleware/bap/activity_message/roster_presence.h"
 
+#include <algorithm>
 #include <array>
 #include <cstdio>
 #include <limits>
@@ -593,7 +596,10 @@ MissionSeedRosterResult append_initial_mission_seed(Session& session,
                 }
             }
             if (managed && !active) {
-                continue;
+                // Removal is a cleared presence bit at the old key ordinal, not omission.
+                for (std::size_t group = 0; group < snapshot.roster.groupCount; ++group)
+                    if (snapshot.roster.groups[group].key == key)
+                        snapshot.roster.groups[group].retired = true;
             }
             if (retainedCount >= scratch.rosterSubBlockKeys[blockIndex].size()) {
                 return refuse_seed("managed_key_capacity");
@@ -678,6 +684,77 @@ MissionSeedRosterResult append_initial_mission_seed(Session& session,
         return refuse_seed("scene_install");
     }
 
+    // Ember bookends replace Apex inside the same bubble. Retire its local roster while
+    // it still owns the world; the state dispatcher waits for native group removal.
+    const auto activities = view.catalog->activities();
+    const bool ember = view.activityRow < activities.size()
+        && activities[view.activityRow].definitionHash == 0x38F926B2U;
+    const bool emberBookend = ember && lease.scriptSelected
+        && (lease.plan.effectiveRegion == 1 || lease.plan.effectiveRegion == 2);
+    if (emberBookend) {
+        // A previous movie's definition can be retained while its activation is absent
+        // from the canonical roster. Restore its old ordinal before encoding removal.
+        for (std::size_t i = 0; i < lease.emberApexKeyCount; ++i) {
+            bool defined = false;
+            for (std::size_t group = 0; group < snapshot.roster.groupCount; ++group)
+                defined = defined || snapshot.roster.groups[group].key == lease.emberApexKeyOrder[i];
+            if (!defined || !append_bubble_key(0, lease.emberApexKeyOrder[i], scratch, snapshot.roster))
+                return refuse_seed("retirement_key_history");
+        }
+        for (std::size_t block = 0; block < snapshot.roster.bubbleSubBlocks.size(); ++block) {
+            const auto& source = snapshot.roster.bubbleSubBlocks[block];
+            if (source.bubble != 0) continue;
+            auto order = lease.emberApexKeyOrder;
+            std::size_t count = lease.emberApexKeyCount;
+            if (!message::extend_key_order(order, count, source.keys))
+                return refuse_seed("retirement_key_capacity");
+            std::copy_n(order.begin(), count, scratch.rosterSubBlockKeys[block].begin());
+            scratch.rosterSubBlocks[block].keys = std::span<const std::uint32_t>(
+                scratch.rosterSubBlockKeys[block].data(), count);
+        }
+    }
+    if (emberBookend) {
+        namespace retirement = client::hooks::mission_retirement;
+        std::array<std::uint32_t, message::kBubbleKeyCapacity> retiringKeys{};
+        std::size_t retiringCount = 0;
+        for (const auto& block : snapshot.roster.bubbleSubBlocks) {
+            if (block.bubble != 0) continue;
+            for (auto key : block.keys) {
+                for (std::size_t group = snapshot.roster.topLevelGroupCount;
+                     group < snapshot.roster.groupCount; ++group) {
+                    const auto& row = snapshot.roster.groups[group];
+                    if (row.key != key) continue;
+                    bool shared = false;
+                    if (!sdk::mission_seed_group_is_scenario_wide(view, row.objectTag, key, shared))
+                        return refuse_seed("retirement_group_scope");
+                    if (shared) continue;
+                    // Authored generic-controller registry for the selected movie. The
+                    // temporary materialization storage has been folded/appended by now.
+                    const auto movieTag = lease.plan.effectiveRegion == 1 ? 0x80B3C224U : 0x80B3C228U;
+                    const bool selected = !arrivalWindow && row.objectTag == movieTag;
+                    if (!selected) {
+                        if (retiringCount == retiringKeys.size()) return refuse_seed("retirement_capacity");
+                        retiringKeys[retiringCount++] = key;
+                    }
+                }
+            }
+        }
+        auto cleanup = retirement::Status::complete;
+        if (arrivalWindow) {
+            cleanup = retirement::prepare(
+                {session.activity.session.sessionId, session.activity.bindingGeneration, lease.revision},
+                heldRegion, std::span(retiringKeys).first(retiringCount));
+        }
+        if (cleanup == retirement::Status::retiring || cleanup == retirement::Status::complete
+            || cleanup == retirement::Status::failedRetiring) {
+            for (std::size_t group = snapshot.roster.topLevelGroupCount;
+                 group < snapshot.roster.groupCount; ++group)
+                for (std::size_t key = 0; key < retiringCount; ++key)
+                    if (snapshot.roster.groups[group].key == retiringKeys[key])
+                        snapshot.roster.groups[group].retired = true;
+        }
+    }
+
     if (adopting) {
         lease = {};
         lease.plan = plan;
@@ -706,6 +783,13 @@ MissionSeedRosterResult append_initial_mission_seed(Session& session,
         }
     }
     lease.fullSetPublished = lease.fullSetPublished || !transitionPublication;
+    if (ember) {
+        for (const auto& block : snapshot.roster.bubbleSubBlocks) {
+            if (block.bubble != 0) continue;
+            lease.emberApexKeyCount = static_cast<std::uint8_t>(block.keys.size());
+            std::copy(block.keys.begin(), block.keys.end(), lease.emberApexKeyOrder.begin());
+        }
+    }
     return MissionSeedRosterResult::ready;
 }
 

+ 4 - 0
Sunrise/src/server/bap/internal.h

@@ -228,6 +228,10 @@ struct MissionSeedLease {
     bool regionArrivalPending{};
     /** Set when a mission script selected the plan. An adopted default plan is not a selection. */
     bool scriptSelected{};
+    /** 1AU's same-bubble bookends must retain the applied key ordinals across both movies. */
+    std::array<std::uint32_t, middleware::bap::activity_message::sensor_auth_update::kBubbleKeyCapacity>
+        emberApexKeyOrder{};
+    std::uint8_t emberApexKeyCount{};
 };
 
 static_assert(middleware::bap::activity_message::sensor_auth_update::kAuthOverrideCapacity

+ 11 - 4
Sunrise/src/state/activity/membership/activity_membership_query.cpp

@@ -1,4 +1,5 @@
 #include "activity_membership_query.h"
+#include "teleport_rules.h"
 
 #include <Windows.h>
 
@@ -28,7 +29,8 @@ bool acknowledged(std::uint64_t sessionId) noexcept {
 /** Arms or clears the host-named teleport region for one joined session. */
 bool arm_host_teleport(std::uint64_t sessionId,
                        std::int32_t sliceSetIndex,
-                       std::uint32_t sliceSetHash) noexcept {
+                       std::uint32_t sliceSetHash,
+                       bool qualified) noexcept {
     if (sessionId == kAbsentSessionId) {
         return false;
     }
@@ -41,6 +43,7 @@ bool arm_host_teleport(std::uint64_t sessionId,
         if (sliceSetIndex == kAbsentSliceSetIndex) {
             changed = membership.hasHostTeleport;
             membership.hasHostTeleport = false;
+            membership.hostTeleportQualified = false;
             membership.hostTeleport = {};
         } else if (!membership.hasHostTeleport
                    || membership.hostTeleport.sliceSetIndex != sliceSetIndex
@@ -48,12 +51,14 @@ bool arm_host_teleport(std::uint64_t sessionId,
             // Step 0 latches the token, it does not compare it, so the increment is bookkeeping
             // for the client's own arm. The state is what gates the step, and zero is idle.
             const std::uint8_t token =
-                static_cast<std::uint8_t>(membership.hostTeleport.token + 1U);
+                qualified ? next_teleport_token(membership.teleport.token)
+                          : static_cast<std::uint8_t>(membership.hostTeleport.token + 1U);
             membership.hostTeleport.sliceSetIndex = sliceSetIndex;
             membership.hostTeleport.sliceSetHash = sliceSetHash;
             membership.hostTeleport.token = token;
             membership.hostTeleport.state = kHostTeleportArmedState;
             membership.hasHostTeleport = true;
+            membership.hostTeleportQualified = qualified;
             // The client refuses a region record whose per-member token does not equal its own
             // transition count. The initial slice-set load is count 1 and each host teleport adds
             // one, so the published token must advance or the target region never precaches.
@@ -63,8 +68,10 @@ bool arm_host_teleport(std::uint64_t sessionId,
             if (advanced == 0) {
                 advanced = kInitialTransitionToken;
             }
-            membership.transitionToken = advanced;
-            membership.hasTransitionToken = true;
+            if (!qualified) {
+                membership.transitionToken = advanced;
+                membership.hasTransitionToken = true;
+            }
             changed = true;
         }
     }

+ 3 - 2
Sunrise/src/state/activity/membership/activity_membership_query.h

@@ -96,8 +96,9 @@ struct PendingMutation final {
  * @return True when the session exists and the arm changed.
  */
 [[nodiscard]] bool arm_host_teleport(std::uint64_t sessionId,
-                                     std::int32_t sliceSetIndex,
-                                     std::uint32_t sliceSetHash) noexcept;
+                                    std::int32_t sliceSetIndex,
+                                    std::uint32_t sliceSetHash,
+                                    bool qualified = false) noexcept;
 
 /** Starts one idempotent native hard wipe on an exact session generation. */
 [[nodiscard]] bool hard_wipe_needs_publish(std::uint64_t sessionId) noexcept;

+ 2 - 0
Sunrise/src/state/activity/membership/definition.h

@@ -160,6 +160,8 @@ struct MembershipState final {
      */
     TeleportState hostTeleport{};
     bool hasHostTeleport{};
+    /** Bookend travel qualifies native arrival and echoes the separate world token. */
+    bool hostTeleportQualified{};
     HardWipeState hardWipe{};
     /** Region of the slice set the client holds; -1 while it holds none. */
     RegionState currentRegion{};

+ 24 - 0
Sunrise/src/state/activity/membership/teleport_rules.h

@@ -0,0 +1,24 @@
+#pragma once
+#include "definition.h"
+
+namespace sunrise::state::activity::membership {
+inline std::uint8_t next_teleport_token(std::uint8_t previous) noexcept {
+    const auto next = static_cast<std::uint8_t>(previous + 1U);
+    return next ? next : 1;
+}
+inline bool matching_teleport(const TeleportState& local, const TeleportState& host) noexcept {
+    return local.token == host.token && local.sliceSetIndex == host.sliceSetIndex
+        && local.sliceSetHash == host.sliceSetHash;
+}
+inline void observe_qualified_teleport(MembershipState& member) noexcept {
+    if (!member.hasHostTeleport || !matching_teleport(member.teleport, member.hostTeleport)) return;
+    if (member.teleport.state == 3 && member.currentReported
+        && member.currentRegion.index == member.hostTeleport.sliceSetIndex)
+        member.hostTeleport.state = kHostTeleportSpawnState;
+    else if (member.hostTeleport.state == kHostTeleportSpawnState && member.teleport.state == 0) {
+        member.hasHostTeleport = false;
+        member.hostTeleportQualified = false;
+        member.hostTeleport = {};
+    }
+}
+}

+ 8 - 3
Sunrise/src/state/activity/membership/transactions/internal.h

@@ -4,6 +4,7 @@
 #include <cstdint>
 
 #include "../activity_membership_query.h"
+#include "../teleport_rules.h"
 
 namespace sunrise::state::activity::membership::transactions {
 
@@ -83,7 +84,8 @@ inline MembershipState merge(const MembershipState& state,
     // client report would revert the arm's advance, and the client would then reject the target
     // region record. The client owns the token again once the teleport is spent.
     const bool hostDrivesToken =
-        state.hasHostTeleport && state.hostTeleport.state != kHostTeleportSpawnState;
+        state.hasHostTeleport && !state.hostTeleportQualified
+        && state.hostTeleport.state != kHostTeleportSpawnState;
     if (update.hasTransitionToken && !hostDrivesToken) {
         merged.transitionToken = update.transitionToken;
         merged.hasTransitionToken = true;
@@ -108,14 +110,16 @@ inline MembershipState merge(const MembershipState& state,
     // The client has reported the region the arm named, so the move is done and the same machine
     // owes the spawn. Its step 3 runs the spawn only while the host state reads 3, so the arm is
     // raised rather than dropped. Step 0 refuses to re-latch on 3, so this cannot re-arm.
-    if (merged.hasHostTeleport && merged.region.index >= 0
+    if (merged.hostTeleportQualified) {
+        observe_qualified_teleport(merged);
+    } else if (merged.hasHostTeleport && merged.region.index >= 0
         && merged.region.index == merged.hostTeleport.sliceSetIndex) {
         merged.hostTeleport.state = kHostTeleportSpawnState;
     }
     // The machine wraps its state byte to 0 at the spawn and keeps the latched token, so state 0
     // with this token is the client saying the teleport finished. Retire it here, not at the
     // commit, so the answering body carries the client's own block and its screen releases.
-    if (merged.hasHostTeleport && merged.hostTeleport.state == kHostTeleportSpawnState
+    if (!merged.hostTeleportQualified && merged.hasHostTeleport && merged.hostTeleport.state == kHostTeleportSpawnState
         && merged.teleport.state == 0 && merged.teleport.token == merged.hostTeleport.token) {
         merged.hasHostTeleport = false;
         merged.hostTeleport = {};
@@ -159,6 +163,7 @@ inline bool equal_authoritative(const MembershipState& first,
            && first.hasTransitionToken == second.hasTransitionToken
            && equal(first.spawn, second.spawn) && equal(first.teleport, second.teleport)
            && first.hasHostTeleport == second.hasHostTeleport
+           && first.hostTeleportQualified == second.hostTeleportQualified
            && first.hardWipe.active == second.hardWipe.active
            && equal(first.hardWipe.host,second.hardWipe.host)
            && equal(first.hostTeleport, second.hostTeleport)

+ 12 - 3
docs/mission-ember-apex-cooling-and-scorch.md

@@ -5,7 +5,7 @@
 - Cooling doors open after the surge stops. Visual/mechanical intervals remain 14 seconds closed, 6 seconds surging, 10 seconds exposed.
 - The user confirmed those visuals/mechanics align, but hears the surge at cooling-door opening. The next live test requested another four seconds of lead. Audio sequences now pre-roll at closed-window second 4, ten seconds before the visual surge. This is playtest-based compensation, not a recovered native delay parameter. Pending audio is phase/region/generation guarded and cancelled on reset, core destruction and deposit.
 - Initial beam: snapping to the resting endpoint still showed the incomplete beam in the first user screenshot. Native device position/power both reached 1. The new candidate seeks the driven endpoint once on laser creation, then animates to the resting endpoint so authored animation events can run. Duplicate presence and late presence after deposit cannot restart it. The target is the second user screenshot's thin, continuous beam. Needs visual confirmation.
-- Escape damage: deposit created SUNBURN_DAMAGE_OBJECT and also attached an extra rail-wide thermal effect. Reaching the end detached only that extra effect, matching the user's report that damage then became normal. The new script retires the climb attachment on deposit and leaves the native sunburn object as the escape damage source. No global damage multiplier changes.
+- Escape damage: the live test showed no scorch after the extra thermal attachment was removed. Deposit now replaces the pipe attachment with one rail-scoped instance of the proven burn (80C1D9E0), and explicitly disables SUNBURN_DAMAGE_OBJECT. Escape completion detaches it. This restores the working attachment without running both sources; visual/damage confirmation remains a live-test requirement.
 - Escape ship: wait for native object presence, unlock, power on, seek closed, animate open. Duplicate presence does not restart it. Flight remains subject to visual testing.
 
 ## Escape trigger evidence
@@ -34,7 +34,16 @@ Current candidate:
 6. Skip sends stop authority; it does not complete the movie. Only the matching native terminated incident after start advances to bookend 2, which goes through its own travel/arrival/offer.
 7. Complete native lifetime only after the second movie finishes. Ignore stale, unstarted, wrong-controller and duplicate receipts. Gameplay callbacks stop publishing encounter actions once the ending owns the route.
 
-Remaining differences from the full Omega contract: no explicit native old-roster retirement receipt, resource/owner readiness bridge, or controller-revision-qualified completion bridge has been implemented yet. Current guards use typed native incidents and exact travel arrival. Do not claim the complete guide has been ported or live playback has succeeded. Native movie creation/playback and teardown are the next live checkpoints. Compare existing message-12 hash/token handling with the contributor's spawn-set/token description if arrival stalls.
+The subsequent test froze at t=465615, just after the t=465535 escape state selection and t=465590 native teleport. The sense probe saw a cycling freed-record chain; the native frame then reported a `network_send` job stalled. The 16:49:39 capture contains zero registry groups. Evidence is retained in `build/first-encounter-audit/ending-freeze-20260906-1651.log` and the corresponding capture folder. This localizes the failure to teardown; it does not prove every cause of that stall.
+
+New candidate after this failure:
+
+- Retired per-bubble keys remain at their wire ordinals with clear presence bits; phase two omits their object bodies. Active keys and state-byte order remain intact. A bounded Apex key history preserves those positions across both bookends, appending each new movie after existing keys. A cleanup timeout cannot re-enable already-retired groups.
+- For 1AU bookends, the host identifies old local keys from the roster and retains scenario-wide services. Before sending removal, a read-only frame observer must see old keys in the native registry. After removal, the same registry owner must still exist in the same source region, with a nonempty registry and none of those keys remaining. Unknown/empty worlds, different owners, other regions and partial removal cannot release travel.
+- The selection intent waits for `ev=mission_retirement result=native_cleanup_complete` before arming travel. This observes native descriptor removal; it does not manually free records, skip readiness checks, or call cleanup from the network thread. The request is bound to session, ActivityClient generation and lease revision. Existing intent expiry bounds a stalled request.
+- Ending-only host teleport uses the prior local command token plus one (skipping zero), echoes the independent native world-transition token, and waits for local state 3 with matching command token, destination and hash plus the actual current region. A matching local state 0 retires the command. Intro travel retains its existing policy.
+
+Remaining differences from the full Omega contract: exact cinematic resource/owner readiness and controller-revision-qualified completion are still not implemented. Current movie guards use typed native incidents and exact arrival. The teleport hash still uses the existing scenario lookup; its bookend spawn-set semantics need checking if arrival fails. Do not claim full ending playback is fixed until a live test reaches both movies. The new checks specifically address the observed teardown boundary.
 
 ## Package evidence correction
 
@@ -42,6 +51,6 @@ Config 80B3D494 (ship) and 80B3D497 (sunburn) share fallback resource 80BFDDC2 a
 
 ## Validation and outstanding work
 
-Release build, all 22 portable tests and all five mission Lua suites pass. Route peak: 238 variables, 61 intents per event, four timers, 13,000 Lua instructions including mock. Coverage includes exact bookend arrival, stale native incidents, skip/finish ordering, audio pre-roll, cooling order, startup deduplication and removal of stacked escape scorch.
+Release build, all 23 portable tests and all five mission Lua suites pass. Route peak: 238 variables, 61 intents per event, four timers, 13,000 Lua instructions including mock. Coverage includes exact bookend arrival, stale native incidents, skip/finish ordering, audio pre-roll, cooling order, startup deduplication single-source escape scorch, native retirement qualification, preserved wire ordinals, absent retired authority bodies, and exact teleport tuple matching.
 
 The full-screen surge effect is still unresolved. The new guide points to authored Scene inputs/source bindings; no arbitrary Omega event hash or substitute damage effect has been added.

+ 6 - 6
scripts/mission_ember/apex.lua

@@ -120,11 +120,10 @@ return function(m, a, ending)
                     a.slot(c, "SLOT_0005_80B3C09F"), a.slot(c, "SLOT_0006_80B3C09F"),
                     a.slot(c, "SLOT_0008_80B3C09F")}}, true)
         elseif mode == "escape" then
-            -- Escape already owns SUNBURN_DAMAGE_OBJECT. Adding this rail-wide burn on top
-            -- adds damage until the escape-end trigger detaches it. Retire the
-            -- climb attachment here and let the native sunburn object own escape damage.
+            -- Replace the climb attachment with one rail-scoped burn. The sunburn prop
+            -- alone did not deliver damage in the live test; do not run both sources.
             a.effect(c, s, "REACTOR_COFFIN_INTERIOR_THERMAL_HOP_ON",
-                "AOD_REACTOR_RAIL_TOP_OBJECT_FILTER", rail_filter(c), false)
+                "AOD_REACTOR_RAIL_TOP_OBJECT_FILTER", rail_filter(c), true)
         else
             a.effect(c, s, "REACTOR_COFFIN_INTERIOR_THERMAL_HOP_ON",
                 "AOD_REACTOR_RAIL_TOP_OBJECT_FILTER", rail_filter(c), false)
@@ -218,7 +217,8 @@ 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.objects(c, {"REACTOR_GETAWAY_SHIP_OBJECT", "SUNBURN_DAMAGE_OBJECT"}, true)
+            a.objects(c, {"REACTOR_GETAWAY_SHIP_OBJECT"}, true)
+            a.objects(c, {"SUNBURN_DAMAGE_OBJECT"}, false)
             -- Object publication is not an entity-creation receipt. Start its device from
             -- A.object once the ship is present, so the initial movement is not lost.
             c:clear_variable("ember.apex.ship_started")
@@ -364,7 +364,7 @@ return function(m, a, ending)
         end
         if e.timer_name == "ember.apex.hazards" then
             -- The climb pipes burn while the cell is being carried up (phase 5), well before
-            -- the deposit. Escape (phase 6) uses its separate native sunburn object, so
+            -- the deposit. Escape (phase 6) replaces it with the rail-scoped burn, so
             -- entering it detaches the climb burn. A checkpoint reset also detaches it.
             if s:variable("ember.region") == 0 then
                 local p = phase(s)

+ 14 - 0
tests/CMakeLists.txt

@@ -1,6 +1,20 @@
 cmake_minimum_required(VERSION 3.20)
 project(SunrisePortableTests LANGUAGES CXX)
 enable_testing()
+add_executable(ending_retirement_test ending_retirement_test.cpp
+    ../Sunrise/src/middleware/bap/activity_message/activity_sensor_auth_encoder.cpp
+    ../Sunrise/src/middleware/bap/activity_message/activity_sensor_auth_blocks.cpp
+    ../Sunrise/src/middleware/bap/activity_message/activity_sensor_auth_bodies.cpp
+    ../Sunrise/src/middleware/encoding/bit_reader.cpp
+    ../Sunrise/src/middleware/encoding/bit_writer.cpp
+    ../Sunrise/src/middleware/encoding/bit_raw.cpp)
+target_compile_features(ending_retirement_test PRIVATE cxx_std_20)
+if(MSVC)
+    target_compile_options(ending_retirement_test PRIVATE /W4 /WX /UNDEBUG)
+else()
+    target_compile_options(ending_retirement_test PRIVATE -Wall -Wextra -Werror -UNDEBUG)
+endif()
+add_test(NAME ending_retirement COMMAND ending_retirement_test)
 add_executable(membership_replication_test membership_replication_test.cpp
     ../Sunrise/src/middleware/bap/activity_message/activity_replicate_membership_encoder.cpp
     ../Sunrise/src/middleware/bap/activity_message/activity_membership_member_writer.cpp

+ 96 - 0
tests/ending_retirement_test.cpp

@@ -0,0 +1,96 @@
+#include <array>
+#include <cassert>
+#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"
+#include "../Sunrise/src/state/activity/membership/teleport_rules.h"
+#include "../Sunrise/src/core/logging/log.h"
+namespace sunrise::core::log {
+bool accepts(Channel, Level) noexcept { return false; }
+void write(Channel, Level, std::string_view) noexcept {}
+}
+namespace wire = sunrise::middleware::bap::activity_message::sensor_auth_update;
+namespace retire = sunrise::client::hooks::mission_retirement;
+namespace member = sunrise::state::activity::membership;
+using sunrise::middleware::encoding::bits::Reader;
+static void expect(Reader& reader, unsigned width, std::uint64_t expected) {
+    std::uint64_t value{};
+    assert(reader.read(static_cast<std::uint8_t>(width), value) && value == expected);
+}
+int main() {
+    std::array<std::uint32_t, 5> history{102, 103, 104};
+    std::size_t historyCount = 3;
+    const std::array<std::uint32_t, 3> movieOne{105, 102, 104};
+    const std::array<std::uint32_t, 3> movieTwo{106, 105, 104};
+    assert(wire::extend_key_order(history, historyCount, movieOne));
+    assert(wire::extend_key_order(history, historyCount, movieTwo));
+    assert(historyCount == 5 && (history == std::array<std::uint32_t, 5>{102,103,104,105,106}));
+    assert(wire::extend_key_order(history, historyCount, movieOne));
+    const std::array<std::uint32_t, 1> overflow{107};
+    assert(!wire::extend_key_order(history, historyCount, overflow));
+    // Unknown/empty worlds and a different owner cannot masquerade as cleanup.
+    retire::Progress progress{retire::Status::baselinePending, 0};
+    progress.observe(0, 1234, 4, 0);
+    assert(progress.value == retire::Status::baselinePending);
+    progress.observe(0, 1234, 8, 4);
+    assert(progress.value == retire::Status::retiring);
+    progress.observe(1, 1234, 4, 0);
+    progress.observe(0, 5678, 4, 0);
+    progress.observe(0, 1234, 0, 0);
+    progress.observe(0, 1234, 5, 1);
+    assert(progress.value == retire::Status::retiring);
+    progress.observe(0, 1234, 4, 0);
+    assert(progress.value == retire::Status::complete);
+
+    // Exercise the actual encoder: old keys retain their ordinal, a clear bit removes
+    // only the retired key, and phase two contains no body for that removed group.
+    static wire::Snapshot snapshot{};
+    std::array<std::uint8_t, 1> types{1}, flags{0};
+    std::array<std::uint16_t, 1> indices{0};
+    std::array<std::uint32_t, 3> keys{102, 103, 104};
+    std::array<wire::BubbleSubBlock, 1> blocks{{{0, keys}}};
+    snapshot.roster.groupCount = 4; snapshot.roster.topLevelGroupCount = 1;
+    snapshot.roster.bubbleSubBlocks = blocks;
+    for (unsigned i = 0; i < 4; ++i) {
+        auto& group = snapshot.roster.groups[i];
+        group.key = 101 + i; group.slotTypes = types; group.slotFlags = flags; group.slotIndices = indices;
+    }
+    snapshot.roster.groups[2].retired = true;
+    std::array<std::byte, 4096> bytes{}; std::size_t size{};
+    assert(wire::encode_sensor_auth_update(snapshot, bytes, size));
+    Reader mask{std::span(bytes).first(size)};
+    assert(mask.skip(wire::kLatchBitWithoutGrant + 1 + wire::delta_bits(1, {}) - 1));
+    expect(mask, 1, 1); expect(mask, 7, 1); // Field one, one bubble.
+    expect(mask, 1, 1); expect(mask, 32, 0x80000000U);
+    expect(mask, 1, 1); expect(mask, 1, 1); expect(mask, 7, 3);
+    for (auto key : keys) expect(mask, 32, key);
+    expect(mask, 1, 1); expect(mask, 32, 5); // Keep ordinals 0 and 2.
+    expect(mask, 32, 0); expect(mask, 32, 0);
+    Reader bodies{std::span(bytes).first(size)};
+    assert(bodies.skip(wire::kLatchBitWithoutGrant + 1 + wire::delta_bits(1, blocks)));
+    for (auto key : {101U, 102U, 104U}) {
+        expect(bodies, 1, 1); expect(bodies, 32, key); expect(bodies, 32, 0);
+        expect(bodies, 1, 1); expect(bodies, 32, key); expect(bodies, 7, 2);
+        expect(bodies, 16, 32768); expect(bodies, 32, 0); expect(bodies, 1, 0);
+    }
+    expect(bodies, 1, 0); expect(bodies, 1, 0);
+    snapshot.roster.groups[0].retired = true;
+    assert(!wire::encode_sensor_auth_update(snapshot, bytes, size));
+
+    member::MembershipState state{};
+    state.hasHostTeleport = true; state.hostTeleportQualified = true;
+    state.hostTeleport = {1, 8, 1, 123}; state.teleport = {2, 8, 1, 123};
+    state.region.index = 1; state.currentReported = true; state.currentRegion.index = 0;
+    member::observe_qualified_teleport(state);
+    assert(state.hostTeleport.state == 1); // Pending-region advertisement is not arrival.
+    state.teleport.state = 3; state.currentRegion.index = 1;
+    state.teleport.sliceSetHash = 124;
+    member::observe_qualified_teleport(state); assert(state.hostTeleport.state == 1);
+    state.teleport.sliceSetHash = 123; state.teleport.token = 7;
+    member::observe_qualified_teleport(state); assert(state.hostTeleport.state == 1);
+    state.teleport.token = 8;
+    member::observe_qualified_teleport(state); assert(state.hostTeleport.state == 3);
+    state.teleport.state = 0;
+    member::observe_qualified_teleport(state); assert(!state.hasHostTeleport);
+    assert(member::next_teleport_token(255) == 1 && member::next_teleport_token(8) == 9);
+}

+ 10 - 3
tests/mission_ember_routes_test.lua

@@ -390,14 +390,21 @@ 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}))
 assert(not timers['ember.carry.recover.apex.3'],'consumed final cell respawned')
--- Escape's native sunburn object must not stack with the scripted climb/rail burn.
+-- Escape replaces the climb attachment with one scoped burn and disables the other source.
 timer('ember.apex.hazards')
 local escapeEffect
 for i=#calls,1,-1 do
     if calls[i][1]=='set_mission_effect' then escapeEffect=calls[i][3];break end
 end
-assert(escapeEffect and escapeEffect.enabled==false and escapeEffect.filter==nil,
-    'escape must detach the added burn instead of stacking it with native sunburn')
+assert(escapeEffect and escapeEffect.enabled==true and escapeEffect.filter~=nil,
+    'escape must attach the rail burn after deposit')
+local sunburnState
+for i=depositStart+1,#calls do
+    if calls[i][1]=='set_object_active' and calls[i][2]==slotDefs[m.Slot.SUNBURN_DAMAGE_OBJECT].name then
+        sunburnState=calls[i][3].active
+    end
+end
+assert(sunburnState==false,'escape must not stack the sunburn prop with the rail burn')
 -- The weapon is dead once the cell is in: powered off, but still installed. Deactivating the
 -- ring objects would take the beam and its surrounding structure out of the world entirely.
 assert(vars['ember.apex.beam']==false,'the beam must stop firing after the deposit')