소스 검색

Finalize ending retirement after roster assembly and separate scorch attachments

Millie 2 일 전
부모
커밋
1a50897b93

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

@@ -2,6 +2,7 @@
 #include <Windows.h>
 #include <array>
 #include <cstring>
+#include <cstdio>
 #include "../../../core/logging/log.h"
 #include "../../patterns/image_scan.h"
 #include "../../patterns/signature_text.h"
@@ -67,6 +68,12 @@ Status prepare(RequestId id, std::int32_t sourceRegion,
             g_count = keys.size();
             std::memcpy(g_keys.data(), keys.data(), keys.size_bytes());
         }
+        std::array<char, 192> line{};
+        std::snprintf(line.data(), line.size(),
+            "ev=mission_retirement result=requested source=%d keys=%zu status=%u revision=%llu",
+            sourceRegion, keys.size(), static_cast<unsigned>(g_progress.value),
+            static_cast<unsigned long long>(id.revision));
+        report(line.data());
     }
     const auto result = g_progress.value;
     ReleaseSRWLockExclusive(&g_lock);

+ 44 - 28
Sunrise/src/server/bap/encrypted/push/activity/activity_mission_seed_roster.cpp

@@ -684,6 +684,50 @@ MissionSeedRosterResult append_initial_mission_seed(Session& session,
         return refuse_seed("scene_install");
     }
 
+
+    if (adopting) {
+        lease = {};
+        lease.plan = plan;
+        lease.bindingGeneration = session.activity.bindingGeneration;
+        lease.revision = revision;
+        // The peer registers this state's groups now, so a later selection must keep carrying them.
+        lease.registeredRegions[0] = plan.effectiveRegion;
+        lease.registeredRegionCount = 1;
+        lease.configured = true;
+        std::array<char, core::log::kLineCapacity> line{};
+        const int written = std::snprintf(line.data(),
+                                          line.size(),
+                                          "ev=activity stage=mission_seed result=adopted region=%u "
+                                          "occurrences=%u groups=%u unreplicated=%u incomplete=%u "
+                                          "scenes=%zu",
+                                          static_cast<unsigned>(plan.effectiveRegion),
+                                          static_cast<unsigned>(summary.occurrenceCount),
+                                          static_cast<unsigned>(summary.groupCount),
+                                          static_cast<unsigned>(summary.unreplicatedObjectCount),
+                                          static_cast<unsigned>(summary.incompleteObjectCount),
+                                          sceneSeedCount);
+        if (written > 0) {
+            core::log::write(core::log::Channel::server,
+                             core::log::Level::info,
+                             {line.data(), static_cast<std::size_t>(written)});
+        }
+    }
+    lease.fullSetPublished = lease.fullSetPublished || !transitionPublication;
+    return MissionSeedRosterResult::ready;
+}
+
+/** Classify removal only after retained squads and authored Scene groups are assembled. */
+MissionSeedRosterResult finalize_mission_retirement(Session& session, Scratch& scratch,
+    message::Snapshot& snapshot, const RefreshReport* refresh) noexcept {
+    auto& lease = session.activityMissionSeed;
+    if (!lease.configured) return MissionSeedRosterResult::inactive;
+    const auto catalog = sdk::snapshot();
+    sdk::BoundView view{};
+    const sdk::Selection selection{session.activity.session, 1, session.activity.bindingGeneration};
+    if (!catalog || sdk::resolve(catalog, selection, view) != sdk::Status::ready)
+        return refuse_seed("retirement_sdk_resolve");
+    const auto heldRegion = state::activity::membership::instantiated_region(client_placement(session, refresh));
+    const bool arrivalWindow = lease.regionArrivalPending;
     // 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();
@@ -755,34 +799,6 @@ MissionSeedRosterResult append_initial_mission_seed(Session& session,
         }
     }
 
-    if (adopting) {
-        lease = {};
-        lease.plan = plan;
-        lease.bindingGeneration = session.activity.bindingGeneration;
-        lease.revision = revision;
-        // The peer registers this state's groups now, so a later selection must keep carrying them.
-        lease.registeredRegions[0] = plan.effectiveRegion;
-        lease.registeredRegionCount = 1;
-        lease.configured = true;
-        std::array<char, core::log::kLineCapacity> line{};
-        const int written = std::snprintf(line.data(),
-                                          line.size(),
-                                          "ev=activity stage=mission_seed result=adopted region=%u "
-                                          "occurrences=%u groups=%u unreplicated=%u incomplete=%u "
-                                          "scenes=%zu",
-                                          static_cast<unsigned>(plan.effectiveRegion),
-                                          static_cast<unsigned>(summary.occurrenceCount),
-                                          static_cast<unsigned>(summary.groupCount),
-                                          static_cast<unsigned>(summary.unreplicatedObjectCount),
-                                          static_cast<unsigned>(summary.incompleteObjectCount),
-                                          sceneSeedCount);
-        if (written > 0) {
-            core::log::write(core::log::Channel::server,
-                             core::log::Level::info,
-                             {line.data(), static_cast<std::size_t>(written)});
-        }
-    }
-    lease.fullSetPublished = lease.fullSetPublished || !transitionPublication;
     if (ember) {
         for (const auto& block : snapshot.roster.bubbleSubBlocks) {
             if (block.bubble != 0) continue;

+ 4 - 0
Sunrise/src/server/bap/encrypted/push/activity/activity_mission_seed_roster.h

@@ -45,4 +45,8 @@ append_initial_mission_seed(Session& session,
                             std::size_t canonicalGroupCount,
                             const RefreshReport* refresh) noexcept;
 
+/** Retire against the final roster, including runtime-added squads and Scene groups. */
+[[nodiscard]] MissionSeedRosterResult finalize_mission_retirement(Session& session,
+    Scratch& scratch, message::Snapshot& snapshot, const RefreshReport* refresh) noexcept;
+
 } // namespace sunrise::server::bap::encrypted::push::activity

+ 13 - 30
Sunrise/src/server/bap/encrypted/push/activity/activity_roster_snapshot.cpp

@@ -11,6 +11,7 @@
 #include "../../../../../state/activity/destination/activity_destination_spawn_binding.h"
 #include "../../../../../state/activity/membership/activity_membership_query.h"
 #include "../../../../../state/activity/runtime.h"
+#include "../../../../../state/activity_sdk/runtime.h"
 #include "../../../../../state/build_data/runtime.h"
 #include "../../../../../state/runtime/runtime.h"
 #include "../../../../gameplay/gameplay_advertisement.h"
@@ -96,6 +97,16 @@ bool client_region_ready(const Session& session, const RefreshReport* refresh) n
                              && lease.bindingGeneration == session.activity.bindingGeneration
                              && lease.regionArrivalPending
                              && static_cast<std::int64_t>(lease.plan.effectiveRegion) != held;
+    if (movePending && (lease.plan.effectiveRegion == 1 || lease.plan.effectiveRegion == 2)) {
+        const auto catalog = state::activity_sdk::snapshot();
+        if (catalog && lease.plan.activityRow < catalog->activities().size()
+            && catalog->activities()[lease.plan.activityRow].definitionHash == 0x38F926B2U
+            && !state::activity::membership::host_teleport_armed(session.activity.session.sessionId)) {
+            // Selecting the movie first prepares roster removal. Keep the old world's spawn
+            // gate open until the qualified cleanup receipt actually arms native travel.
+            return held >= 0;
+        }
+    }
     return !movePending && held >= 0;
 }
 
@@ -670,36 +681,8 @@ build_roster_snapshot(Session& session,
     if (pendingStateLocal && pendingGroupPosition >= snapshot.roster.groupCount) {
         return refuse_override("pending_group_position");
     }
-    std::size_t senseCount = 0;
-    for (const message::AuthOverride& auth : snapshot.authOverrides) {
-        if (auth.slotType != 1) {
-            continue;
-        }
-        server::activity::host::SenseObservationKey key{};
-        key.registryKey = auth.key;
-        key.objectTag = auth.objectTag;
-        key.senseSchema = 0x80807ECCU;
-        key.slotIndex = auth.slotIndex;
-        key.slotType = auth.slotType;
-        middleware::bap::activity_message::squad_sense::State recovered{};
-        if (!server::activity::host::snapshot_squad_sense(
-                session.activity.session, session.activity.bindingGeneration, key, recovered)) {
-            continue;
-        }
-        message::SenseOverride& sense = scratch.rosterSenseOverrides[senseCount];
-        sense = {};
-        if (!middleware::bap::activity_message::squad_sense::encode(
-                recovered, sense.body, sense.byteCount, sense.bitCount)) {
-            return refuse_override("squad_sense");
-        }
-        sense.key = auth.key;
-        sense.objectTag = auth.objectTag;
-        sense.slotIndex = auth.slotIndex;
-        sense.slotType = auth.slotType;
-        sense.counter = recovered.counter;
-        ++senseCount;
-    }
-    snapshot.senseOverrides = std::span(scratch.rosterSenseOverrides).first(senseCount);
+    if (finalize_mission_retirement(session, scratch, snapshot, refresh)
+        == MissionSeedRosterResult::refused) return refuse_override("mission_retirement");
     return RosterOutcome::published;
 }
 

+ 14 - 0
docs/mission-ember-apex-cooling-and-scorch.md

@@ -45,6 +45,16 @@ New candidate after this failure:
 
 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.
 
+## Live retirement refusal and correction (17:21 run)
+
+Installed commit 48815f9 reached the escape trigger at t=273816, queued retirement at t=273924, and refused the intent at t=275663 with `native roster cleanup did not complete`. No native travel or ending playback started. The simulation continued, matching the reported black loading screen with world audio. Captures are in `build/first-encounter-audit/bookend-live-20260906-172136.log` and `reactor-runtime-20260906-172514`.
+
+A read-only capture of the retirement request showed failed status 4, source region 0, and zero requested keys. The native registry still contained nine groups: one scenario-wide service and eight Apex-local groups. The cleanup list was assembled during initial mission seeding, before runtime squads, authored authority and Scene groups were appended. This was an empty request, not proof of a native cleanup timeout.
+
+The correction finalizes retirement after the entire roster is assembled. It also keeps the current world's loading/spawn gate open while bookend retirement is pending and native travel has not been armed. Selecting a destination alone must not black out the screen if cleanup is refused. A new request log records source region, key count, status and lease revision. The next test should demonstrate a nonempty request (eight local keys for this captured roster), baseline observation, native removal, qualified travel, and then movie startup in that order. This run's refused Lua ending request does not retry automatically; testing the correction requires a fresh run.
+
+The corrected build and the same 23 portable tests and five Lua suites pass. Native retirement and ending playback still require live confirmation.
+
 ## Package evidence correction
 
 Config 80B3D494 (ship) and 80B3D497 (sunburn) share fallback resource 80BFDDC2 at offset 0x538. Their actual placed entries at 0x580 are 80B71228 and 80B82486. Ship model 80B71228 includes device component 80C70C0E and animation component 80B71226; placement 42BF7017F2901B45 matches its device. Component references have 12-byte stride. Do not infer ship capabilities from the fallback model.
@@ -54,3 +64,7 @@ Config 80B3D494 (ship) and 80B3D497 (sunburn) share fallback resource 80BFDDC2 a
 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.
+
+## Follow-up scorch correction
+
+The user still observes doubled scorch with the rail burn enabled and the sunburn object disabled. Deposit now explicitly disables the pipe hop-on, waits 250 ms, then attaches the rail burn. Hazard mode is idempotent, so repeated callbacks do not issue additional attachment revisions. This addresses a possible overlapping attachment during an enabled-to-enabled filter change; the native cause and damage rate still require visual confirmation. The sunburn object remains disabled.

+ 9 - 2
scripts/mission_ember/apex.lua

@@ -103,6 +103,9 @@ return function(m, a, ending)
     -- removes the previous attachment (native 9EF8A0/9F1F10) before attaching the new filter.
     local function rail_filter(c) return {players = true, inside = a.slot(c, "SLOT_019E")} end
     local function hazards(c, s, mode)
+        local wanted = mode or "off"
+        if s:variable("ember.apex.hazard_mode") == wanted then return end
+        c:set_variable("ember.apex.hazard_mode", wanted)
         if mode == "climb" then
             -- The five narrow authored pipe volumes on the way up to the deposit; their heights
             -- track the climb the mother-brain dialogue volumes walk through, from z~172 at
@@ -120,7 +123,8 @@ 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
-            -- Replace the climb attachment with one rail-scoped burn. The sunburn prop
+            -- The deposit callback already detached the climb burn on an earlier tick.
+            -- Attach 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), true)
@@ -212,7 +216,10 @@ return function(m, a, ending)
         function(c, s)
             if phase(s) ~= 5 then return end
             set(c, 6)
-            c:start_timer("ember.apex.hazards", 1)
+            -- Deliver an explicit detach before publishing the rail attachment. A direct
+            -- enabled-to-enabled filter change can leave the previous burn attached.
+            hazards(c, s, nil)
+            c:start_timer("ember.apex.hazards", 250)
             a.checkpoint(c, 0, 0x45920385, "escape")
             a.device(c, "MOTHER_BRAIN_CONSOLE_DEVICE", true)
             a.device(c, "MOTHER_BRAIN_ENGINE_LEFT_DEVICE", true)

+ 9 - 0
tests/mission_ember_routes_test.lua

@@ -390,6 +390,12 @@ 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')
+-- Deposit must publish a disabled revision before the delayed rail attachment.
+local detachedBeforeRail=false
+for i=depositStart+1,#calls do
+    if calls[i][1]=='set_mission_effect' and calls[i][3].enabled==false then detachedBeforeRail=true end
+end
+assert(detachedBeforeRail,'deposit must detach the pipe burn before applying escape scorch')
 -- Escape replaces the climb attachment with one scoped burn and disables the other source.
 timer('ember.apex.hazards')
 local escapeEffect
@@ -398,6 +404,9 @@ for i=#calls,1,-1 do
 end
 assert(escapeEffect and escapeEffect.enabled==true and escapeEffect.filter~=nil,
     'escape must attach the rail burn after deposit')
+local afterRail=#calls
+call(R.dispatch,'timer',c,s,{timer_name='ember.apex.hazards'})
+assert(#calls==afterRail,'duplicate hazard callback must not reattach scorch')
 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