Przeglądaj źródła

Travel to native bookend variants before offering cinematic playback

Millie 3 dni temu
rodzic
commit
fb741516b0

+ 2 - 21
Sunrise/src/server/activity/mission/mission_script_runtime_dispatch.cpp

@@ -215,27 +215,8 @@ void arm_state_region_teleport(RuntimeInstance& instance,
             instance.view.binding.sessionId, membership::kAbsentSliceSetIndex, 0));
         return;
     }
-    // A region is `sliceSetIndex + stateOrdinal`, so a sibling state sits in the slice set the
-    // client already holds and there is nothing to transition to. Arming anyway hands the client
-    // a slice-set index that is not a slice set (region 1 carrying slice set 0's name hash), and
-    // it starts a teleportation it can never finish: the client logs the transition and never
-    // reaches "Finished synchronizing". Clear the arm and let the roster publish the new state's
-    // groups into the world that is already standing.
-    //
-    // The slice set the client holds is derived from the region it reports, not from
-    // `reported_slice_set()`: that reads the newest D6 host teleport, which still named the
-    // mission's opening move to region 64 long after later areas were reached on z-legs.
-    // Slice-set indices are multiples of the factor, so a region's own slice set is that region
-    // rounded down to it.
-    const auto factor = middleware::content::packages::tables::kSliceSetIndexFactor;
-    if (reported >= 0
-        && static_cast<std::uint32_t>(reported) - (static_cast<std::uint32_t>(reported) % factor)
-               == plan.sliceSetIndex) {
-        static_cast<void>(membership::arm_host_teleport(
-            instance.view.binding.sessionId, membership::kAbsentSliceSetIndex, 0));
-        log_line(core::log::Level::info, &instance, "state_region", "teleport_not_required");
-        return;
-    }
+    // Native 4C8E40 registers alternate entries as distinct packed regions. A sibling
+    // bookend still needs travel: its cinematic controller is absent in gameplay's world.
     const std::string_view name(reinterpret_cast<const char*>(destination.packageName.data()),
                                 destination.packageNameLength);
     ::sunrise::state::build_data::scenarios::Definition layout{};

+ 3 - 10
Sunrise/src/server/bap/bap_route.cpp

@@ -538,16 +538,9 @@ select_activity_mission_seed(const state::activity::SessionBinding& binding,
                 }
                 lease.registeredRegions[lease.registeredRegionCount++] = plan.effectiveRegion;
             }
-            // A SLICE-SET change replaces the instantiated world. Publications keep answering the
-            // previous plan until the client's post-arrival solicited answer advances the region
-            // epoch, because registering the new region's groups mid-teardown races the teardown.
-            //
-            // An authored region is `sliceSetIndex + stateOrdinal`, so sibling states share one
-            // slice set: Ember's apex gameplay (0) and both ending bookends (1, 2) are all slice
-            // set 0. Moving between them instantiates nothing new, and the client's current region
-            // leg only advances on an actual slice-set switch. Waiting for an arrival there is a
-            // 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.
+            // Every distinct packed region selects a native world entry, including a
+            // cinematic variant in the same bubble. Arm travel before waiting for its
+            // exact arrival; the durable state-selection dispatcher handles that order.
             if (lease.configured
                 && encrypted::push::activity::mission_seed_selection_needs_arrival(
                     lease.plan.sliceSetIndex,

+ 2 - 7
Sunrise/src/server/bap/encrypted/push/activity/activity_mission_seed_roster.cpp

@@ -370,11 +370,7 @@ MissionSeedRosterResult append_initial_mission_seed(Session& session,
     const state::activity::membership::ClientPlacement placement =
         client_placement(session, refresh);
     const std::int32_t heldRegion = state::activity::membership::instantiated_region(placement);
-    // The window closes on arrival, and also when no arrival can ever be reported because the
-    // pending region is a sibling state of the slice set the client already holds. Leaving it open
-    // there is fatal rather than cautious: the roster refuses to commit a published revision while
-    // it is set, so the lease never publishes, every scene lease on the new state reports a
-    // pending mission seed, and the selection's own gate is skipped so nothing else notices.
+    // Close only on the exact packed-region receipt, including cinematic variants.
     if (!adopting && lease.regionArrivalPending
         && mission_seed_arrival_window_closed(
             heldRegion,
@@ -498,8 +494,7 @@ MissionSeedRosterResult append_initial_mission_seed(Session& session,
             publicRegion = isPublic;
         }
     }
-    // Arrival was already accepted for a sibling state above. Apply that same rule to
-    // the content subset, or the lease commits without ever registering its movie controller.
+    // Apply the same exact-arrival rule to the content subset and the lease.
     const bool transitionPublication = mission_seed_transition_subset_only(
         lease.fullSetPublished, lease.scriptSelected, publicRegion,
         heldRegion, selectedRegion, summary.sliceSetIndex,

+ 15 - 55
Sunrise/src/server/bap/encrypted/push/activity/mission_seed_world_change.h

@@ -5,65 +5,25 @@
 namespace sunrise::server::bap::encrypted::push::activity {
 
 /**
- * Checks whether moving to a newly selected state actually replaces the instantiated world.
- *
- * An authored region is `sliceSetIndex + stateOrdinal`, so sibling states of one slice set are
- * different regions inside the same instantiated content: Ember's apex gameplay (region 0) and
- * its two ending bookends (regions 1 and 2) all live in slice set 0. Only a slice-set change
- * tears the world down and rebuilds it.
- *
- * Treating a sibling move as a replacement deadlocks publication: the roster withholds the new
- * region's groups until the client reports holding it, while the client's current region leg
- * advances only on a real slice-set switch, so that report never arrives.
- *
- * Kept dependency-free so the decision can be exercised without the BAP session types.
- *
- * @param currentSliceSetIndex Slice set the lease's published plan belongs to.
- * @param currentEffectiveRegion That plan's authored region.
- * @param selectedSliceSetIndex Slice set the newly selected plan belongs to.
- * @param selectedEffectiveRegion The newly selected plan's authored region.
- * @return True only when the client must tear its world down and rebuild it.
+ * Native 4C8E60 registers the base entry at bubble*8; 4C8E40 registers each
+ * alternate at bubble*8+ordinal. Sharing a bubble does not instantiate its
+ * alternate cinematic world. Travel/arrival use the complete packed region.
  */
-[[nodiscard]] constexpr bool
-mission_seed_region_change_replaces_world(std::uint32_t currentSliceSetIndex,
-                                          std::uint32_t currentEffectiveRegion,
-                                          std::uint32_t selectedSliceSetIndex,
-                                          std::uint32_t selectedEffectiveRegion) noexcept {
-    return currentEffectiveRegion != selectedEffectiveRegion
-           && currentSliceSetIndex != selectedSliceSetIndex;
+[[nodiscard]] constexpr bool mission_seed_region_change_replaces_world(
+    std::uint32_t /*currentSliceSetIndex*/, std::uint32_t currentEffectiveRegion,
+    std::uint32_t /*selectedSliceSetIndex*/, std::uint32_t selectedEffectiveRegion) noexcept {
+    return currentEffectiveRegion != selectedEffectiveRegion;
 }
 
-/**
- * Checks whether a pending region arrival can still be reported by the client.
- *
- * The arrival window exists so a publication does not register the new region's groups into a
- * world the client is tearing down. It closes when the client reports holding the pending region.
- * A sibling state never produces that report: its content lives in the slice set the client
- * already holds, and the current region leg only advances on a slice-set switch. Leaving the
- * window open there is not cautious, it is fatal -- the roster refuses to commit a published
- * revision while it is set, so the lease never publishes and every scene lease on the new state
- * reports a pending mission seed forever.
- *
- * @param heldRegion Region the client reports holding, or negative when it holds none.
- * @param pendingEffectiveRegion Authored region the pending plan selects.
- * @param pendingSliceSetIndex Slice set that plan belongs to.
- * @param sliceSetFactor Regions per slice set; slice-set indices are multiples of it.
- * @return True once the window must close, either by arrival or because none can occur.
- */
-[[nodiscard]] constexpr bool
-mission_seed_arrival_window_closed(std::int32_t heldRegion,
-                                   std::uint32_t pendingEffectiveRegion,
-                                   std::uint32_t pendingSliceSetIndex,
-                                   std::uint32_t sliceSetFactor) noexcept {
-    if (heldRegion < 0 || sliceSetFactor == 0) {
-        return false;
-    }
-    const auto held = static_cast<std::uint32_t>(heldRegion);
-    return held == pendingEffectiveRegion
-           || held - (held % sliceSetFactor) == pendingSliceSetIndex;
+/** A pending world is held only after the native client reports that exact variant. */
+[[nodiscard]] constexpr bool mission_seed_arrival_window_closed(
+    std::int32_t heldRegion, std::uint32_t pendingEffectiveRegion,
+    std::uint32_t /*pendingSliceSetIndex*/, std::uint32_t sliceSetFactor) noexcept {
+    return heldRegion >= 0 && sliceSetFactor != 0
+        && static_cast<std::uint32_t>(heldRegion) == pendingEffectiveRegion;
 }
 
-/** A stale seed plan must not reopen arrival after ordinary traversal reached the target world. */
+/** Ordinary traversal may already have reached the exact destination of a stale seed plan. */
 [[nodiscard]] constexpr bool mission_seed_selection_needs_arrival(
     std::uint32_t oldSliceSet, std::uint32_t oldRegion,
     std::uint32_t newSliceSet, std::uint32_t newRegion,
@@ -72,7 +32,7 @@ mission_seed_arrival_window_closed(std::int32_t heldRegion,
         && !mission_seed_arrival_window_closed(heldRegion, newRegion, newSliceSet, factor);
 }
 
-/** State-local records may publish once the containing world is held, including sibling movies. */
+/** Keep state-local records behind actual arrival, including alternate bookend worlds. */
 [[nodiscard]] constexpr bool mission_seed_transition_subset_only(
     bool fullSetPublished, bool scriptSelected, bool publicRegion,
     std::int32_t heldRegion, std::uint32_t selectedRegion,

+ 31 - 22
docs/mission-ember-apex-cooling-and-scorch.md

@@ -1,38 +1,47 @@
-# Apex cooling, object initialization, and escape scorch
+# 1AU Apex and ending corrections — 6 September 2026
 
-Changes on 2026-09-06:
+## Current implementation
 
-- End the beam surge before publishing clamshell/coffin cooling-door opening. Keep the existing 6-second surge, 10-second exposure, and 14-second closed intervals.
-- On native laser/ring object presence, reapply power and the current pose. Initial commands can precede entity creation; this is a candidate fix for the dark beam before the first surge, pending visual confirmation. Late presence after deposit must not relight the beam.
-- Start the escape ship device only after its native object-presence receipt. Unlock, power on, snap to closed, then animate to open. Duplicate presence does not restart it. Authored flight playback remains to be verified in game; the script does not synthesize a flight path.
-- Remove the added rail-wide thermal attachment during escape. Deposit already creates SUNBURN_DAMAGE_OBJECT, while the old script additionally attached REACTOR_COFFIN_INTERIOR_THERMAL_HOP_ON to the rails. The escape-end trigger removed only that attachment. This matches the report that damage becomes normal after reaching the end. Preserve the native sunburn object and retire the climb attachment on entering escape. No global damage multiplier is changed; exact damage rate still needs playtesting.
+- 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. Audio sequences now pre-roll at closed-window second 8, six 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 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.
 
-Package evidence correction: config 80B3D494 (ship) and 80B3D497 (sunburn) both contain fallback resource 80BFDDC2 at offset 0x538. Their actual first placed entries at offset 0x580 are respectively 80B71228 and 80B82486. Do not use the fallback model to infer ship capabilities. Ship model 80B71228 includes device component 80C70C0E and animation component 80B71226. Device placement identity 42BF7017F2901B45 matches the placed ship. Model component references have 12-byte stride, not 16.
+## Escape trigger evidence
 
-Validation: all five mission Lua suites pass. Route peak: 235 variables, 61 intents per callback, three concurrent timers, 13,000 Lua instructions including the mock. Tests cover cooling command order, beam presence, deferred ship activation, duplicate ship receipts, and disabling the extra escape burn.
+The tested installed run resolved `AD062E98 / type 31 / slot 2`, volume 17, at t=451535. This is APEX_DIRECTIVE_REACTOR_RAILS_ESCAPE_PLAYER_TRIGGER. At t=451650 it selected the ending state; at t=452290 cinematic authority was staged. Therefore the escape trigger fired. The failure followed it.
 
-Still unresolved: exact native ending-readiness failure and full-screen surge distortion. The diagnostic DLL commit fe45d11 improves visibility but does not itself fix cinematic playback. Do not reinstate the reverted cinematic-region ordinal encoding: it caused Lime.
+## Ending: corrected native-world model
 
-## Ending roster correction
+The contributor's IKORA-ANIMATION-AND-ENDING.md describes retirement, native travel, exact resource/owner readiness, offered authority, revision-qualified active playback, inactive completion and post-cinematic handoff. Its Omega-specific asset/event hashes are not Ember inputs.
 
-The 15:52:37 stalled-run capture contains 491 allocated sync records and no type-6 cinematic record. All allocated records have byte 20 set to 1; its exact native readiness meaning is not established. This capture does not demonstrate Omega's 19-unseeded-record condition.
+Earlier Ember code incorrectly treated packed regions 0, 1 and 2 as one instantiated world because they share bubble 0. Direct native instruction inspection corrected this:
 
-The roster's transitionPublication predicate still compared heldRegion directly against selectedRegion. Thus held Apex region 0 / selected bookend region 1 withheld state-local groups even after the separate arrival-window check accepted the shared world. Apply the shared-world arrival rule to the roster subset too. Both bookends may then publish while the player holds Apex; genuine world changes and unknown held-world state still defer local records.
+- 4C8E60 registers the base world at bubble*8.
+- 4C8E40 registers alternates at bubble*8+(alternateIndex+1).
+- 436530 rejects global per-bubble state bytes >=1 and separately registers alternate entries. The message-1 byte is not how to select an ending variant. Leave that encoding unchanged; the reverted experiment caused Lime.
 
-This is isolated from the reverted global-message state-byte experiment. Global state encoding is unchanged. Release build and all 22 portable tests pass, including the production subset predicate. Native movie creation and playback need a live test; do not treat server cinematic_staged as proof of playback.
+The 16:20:23 stalled capture contains 479 allocated sync records and no type 6. Publishing a roster subset without actual native travel did not create the movie controller. This supersedes the same-world hypothesis and the insufficient 7ff004d roster-only fix.
 
-## Follow-up from IKORA-ANIMATION-AND-ENDING.md and live screenshots
+Current candidate:
 
-Read contributor guide `/home/millie/Documents/Sunrise-docs/MissionDocs/IKORA-ANIMATION-AND-ENDING.md`. Its current implementation distinguishes native retirement, travel arrival, exact resource/owner readiness, offered authority, revision-qualified active playback, and inactive completion. Scene VFX require the correct source bindings and retained authored external inputs; Omega event hashes are not transferable to Ember.
+1. Exact escape trigger claims the ending once.
+2. Select bookend region 1; native message-12 travel is armed even though its bubble matches Apex.
+3. The arrival lease closes only on exact packed-region arrival. Ordinary traversal can satisfy an already-held exact destination, but base Apex 0 cannot satisfy bookend 1.
+4. Lua waits for held_region_index=1 before offering type-6 playback. A pending/current-only report does not offer it. Existing host publication gates still apply.
+5. Only the exact controller's started incident records playback. Preserve its runtime-object identity as a string.
+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.
 
-Prepared Lua changes:
+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.
 
-- Beam startup: a presence receipt followed only by a snap to position 1 still reproduced the incomplete beam. The live device reported position/power 1. On the first laser presence for each generation, seek the driven endpoint, then animate to the resting endpoint so animation events have an opportunity to run. This is a candidate fix requiring visual confirmation against the user's second screenshot (thin continuous beam). Duplicate presence and post-deposit presence cannot restart it.
-- Surge audio: user confirms visuals and mechanics now align, but the sound arrives when cooling doors open. Request the authored alarm sequences at 8 seconds into the unchanged 14-second closed window, six seconds before the visual surge. This is playtest-based compensation, not a recovered native six-second delay parameter. Preserve the 6-second surge and 10-second exposure. Cancel pending audio on reset/core destruction/deposit, and guard region, phase and generation.
-- Ending lifecycle: publishing play is now an offer; only the exact controller's started incident records playing. Retain its runtime object identity as a string. Skip requests stop authority and waits for the matching terminated incident. Ignore unstarted, wrong-controller and stale-object completion. The native controller revision/resource-owner bridge described by Omega is still not implemented; these incident guards are not equivalent to that full contract.
+## Package evidence correction
 
-New stalled-ending evidence after installed 7ff004d: capture `build/first-encounter-audit/reactor-runtime-20260906-162023` has 479 allocated records and no type 6. Fixing roster subset publication was insufficient to instantiate the bookend. Investigate native travel/resource loading before adding more play revisions.
+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.
 
-Native Lime evidence: `436530` explicitly rejects a nonnegative per-bubble state byte >=1. It registers alternate entries through a separate path (`4C8E40`) in the subsequent loop. The global message-1 byte is not the route to selecting Ember bookend ordinal 1/2. Keep its encoding unchanged. Compare Ember's message-12 transit against the guide: local code labels the hash a slice-set hash and advances a world-transition token, while the guide identifies a spawn-set hash and separately echoes native transition tokens. Those differences require instruction/packet verification before changing travel behavior.
+## Validation and outstanding work
 
-All five Lua suites passed after the lifecycle/startup changes; the route suite also passes the audio follow-up (237 variables, 61 intents per event, four timers). No live verification of these follow-ups yet.
+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.
+
+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.

+ 12 - 16
scripts/mission_ember/ending.lua

@@ -1,17 +1,6 @@
--- Authored post-escape cinematic states; exact native completion/skip incidents advance once.
---
--- An authored region is `sliceSetIndex + stateOrdinal`, so apex gameplay (region 0) and both
--- ending bookends (regions 1 and 2) are sibling states of one slice set. Selecting a bookend
--- instantiates no new world, so no slice-set teleport is armed for it -- and that also means the
--- client never sends another region report. Waiting for one leaves the movie unstarted forever,
--- which is exactly what a run with the teleport removed showed: the state was selected and no
--- cinematic was ever enqueued.
---
--- So the activation is queued with the selection instead of on a later callback. Intents are
--- dispatched in order and a state selection completes only once its own roster revision has
--- published, so the cinematic Auth always follows the seed that carries its slot. Slot handles
--- resolve against the static SDK definition table, not the selected state, so naming the
--- bookend before its state is live is safe.
+-- Post-escape bookends are distinct native packed-region entries (1 and 2).
+-- Selection arms travel; offer playback only on the matching held-region receipt.
+-- Never confuse an offered command, a skip request, or a failed start with completion.
 return function(m)
     local movies = {
         {state = m.states.STATE_80B3C09E_0000_0001_80B3C091, slot = m.Slot.PF_CINEMATIC_BOOKEND_STM_CINEMATIC},
@@ -26,13 +15,20 @@ return function(m)
         c:clear_variable("ember.ending.runtime")
         c:clear_variable("ember.ending.stopping")
         c:select_state(assert(row.state))
-        c:slot(assert(row.slot)):set_cinematic_active{active = true}
     end
     function E.start(c, s)
         if s:variable("ember.ending") then return end
         music.update(c, s)
         play(c, 1)
     end
+    function E.client(c, s, e)
+        local index = s:variable("ember.ending")
+        local row = index and movies[index]
+        if not row or s:variable("ember.ending.offered") == index
+            or e.held_region_index ~= row.state.region_index then return end
+        c:set_variable("ember.ending.offered", index)
+        c:slot(row.slot):set_cinematic_active{active = true}
+    end
     local function matched(c, s, e)
         local index = s:variable("ember.ending")
         local row = index and movies[index]
@@ -43,7 +39,7 @@ return function(m)
     end
     function E.started(c, s, e)
         local index = matched(c, s, e)
-        if not index or s:variable("ember.ending.playing") or type(e.runtime_object_id) ~= "string" then return end
+        if not index or s:variable("ember.ending.offered") ~= index or s:variable("ember.ending.playing") or type(e.runtime_object_id) ~= "string" then return end
         c:set_variable("ember.ending.playing", index)
         c:set_variable("ember.ending.runtime", e.runtime_object_id)
     end

+ 6 - 4
scripts/mission_ember/routes.lua

@@ -10,9 +10,8 @@ return function(m)
     controllers[0] = require("mission_ember.apex")(m, a, ending)
     local R = {}
     function R.client(c, s, e)
-        -- Once the ending owns the mission the route stops driving anything. The bookends
-        -- need no client report: they are queued with their own state selection.
-        if s:variable("ember.ending") then return end
+        -- Native travel must reach the exact bookend before playback is offered.
+        if s:variable("ember.ending") then ending.client(c, s, e); return end
         local active = controllers[s:variable("ember.region")]
         if not active then return end
         if not s:variable("ember.later.owns_hud") then
@@ -31,6 +30,7 @@ return function(m)
         if active.guidance then active.guidance(c, s) end
     end
     function R.dispatch(method, c, s, e)
+        if s:variable("ember.ending") then return end
         if method == "timer" then
             for _, region in ipairs({56, 40, 0}) do
                 local controller = controllers[region]
@@ -41,7 +41,9 @@ return function(m)
         local active = controllers[s:variable("ember.region")]
         if active and active[method] then return active[method](c, s, e) end
     end
-    function R.squad(c, s, e) a.squad(c, s, e) end
+    function R.squad(c, s, e)
+        if not s:variable("ember.ending") then a.squad(c, s, e) end
+    end
     function R.reset(c, s)
         local active = controllers[s:variable("ember.checkpoint.region")]
         assert(active, "checkpoint has no encounter controller")

+ 10 - 4
tests/mission_ember_routes_test.lua

@@ -433,10 +433,14 @@ reset_check('escape',function()
     assert(vars['ember.apex.beam']==false,'escape restart must leave the beam off')
 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. 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.
+-- A selected bookend is not loaded merely because the player holds Apex's base world.
+local beforeArrival=#calls
+call(R.client,c,s,{held_region_index=0})
+call(R.client,c,s,{current_region_index=1})
+assert(#calls==beforeArrival and not vars['ember.ending.offered'],'pending/base world offered playback')
+region(1)
+assert(vars['ember.ending.offered']==1)
+local offeredCalls=#calls;region(1);assert(#calls==offeredCalls,'duplicate arrival replayed the movie offer')
 assert(vars['ember.ending']==1 and not vars['ember.ending.playing'],'offer is not native playback')
 local offered=false
 for _,row in ipairs(calls)do
@@ -459,6 +463,8 @@ local stoppingCalls=#calls;call(R.skip,c,s,first);assert(#calls==stoppingCalls,'
 call(R.terminated,c,s,first)
 assert(vars['ember.ending']==2 and not vars['ember.ending.playing'],'second movie is only offered')
 call(R.terminated,c,s,first);assert(vars['ember.ending']==2,'old movie completion advanced its successor')
+call(R.started,c,s,second);assert(not vars['ember.ending.playing'],'unoffered second movie claimed playback')
+region(2)
 call(R.started,c,s,second);call(R.terminated,c,s,second)
 assert(vars['ember.complete'])
 local objective=vars['ember.r.guidance']

+ 28 - 128
tests/mission_seed_world_change_test.cpp

@@ -1,133 +1,33 @@
-// Regression for the selected-state arrival window.
-//
-// An authored region is `sliceSetIndex + stateOrdinal`. Ember's apex gameplay and both ending
-// bookends are sibling states of slice set 0 (regions 0, 1 and 2), while every earlier area is
-// its own slice set. Only a slice-set change replaces the client's instantiated world.
-//
-// Treating a sibling move as a replacement is what froze the mission's ending: the roster
-// withheld region 1's groups until the client reported holding region 1, and the client's
-// current region leg only advances on a real slice-set switch, so the report never came.
-
+// Packed regions select native entries: 4C8E60 base, 4C8E40 alternates.
 #include <cassert>
-#include <cstdint>
-
 #include "../Sunrise/src/server/bap/encrypted/push/activity/mission_seed_world_change.h"
-
-namespace {
-
 namespace seed = sunrise::server::bap::encrypted::push::activity;
-
-/** Ember's authored states, as `{sliceSetIndex, effectiveRegion}`. */
-constexpr std::uint32_t kApexSliceSet = 0;
-constexpr std::uint32_t kApexRegion = 0;
-constexpr std::uint32_t kFirstMovieRegion = 1;
-constexpr std::uint32_t kSecondMovieRegion = 2;
-constexpr std::uint32_t kArrivalSliceSet = 48;
-constexpr std::uint32_t kArrivalRegion = 49;
-constexpr std::uint32_t kPowerhouseSliceSet = 64;
-constexpr std::uint32_t kPowerhouseRegion = 64;
-constexpr std::uint32_t kLinkSliceSet = 56;
-constexpr std::uint32_t kLinkRegion = 56;
-constexpr std::uint32_t kCinderSliceSet = 40;
-constexpr std::uint32_t kCinderRegion = 40;
-
-void sibling_states_keep_their_world() {
-    // Apex gameplay to the first ending bookend: same slice set, so nothing is rebuilt.
-    assert(!seed::mission_seed_region_change_replaces_world(
-        kApexSliceSet, kApexRegion, kApexSliceSet, kFirstMovieRegion));
-    // First bookend to the second: still slice set 0.
-    assert(!seed::mission_seed_region_change_replaces_world(
-        kApexSliceSet, kFirstMovieRegion, kApexSliceSet, kSecondMovieRegion));
-    // And back, so a checkpoint or reselection is treated the same way.
-    assert(!seed::mission_seed_region_change_replaces_world(
-        kApexSliceSet, kSecondMovieRegion, kApexSliceSet, kApexRegion));
-}
-
-void slice_set_changes_replace_the_world() {
-    // Every transition the mission already made is a real slice-set change and must stay one,
-    // or the roster would register the new region's groups into a world being torn down.
-    assert(seed::mission_seed_region_change_replaces_world(
-        kArrivalSliceSet, kArrivalRegion, kPowerhouseSliceSet, kPowerhouseRegion));
-    assert(seed::mission_seed_region_change_replaces_world(
-        kPowerhouseSliceSet, kPowerhouseRegion, kLinkSliceSet, kLinkRegion));
-    assert(seed::mission_seed_region_change_replaces_world(
-        kLinkSliceSet, kLinkRegion, kCinderSliceSet, kCinderRegion));
-    assert(seed::mission_seed_region_change_replaces_world(
-        kCinderSliceSet, kCinderRegion, kApexSliceSet, kApexRegion));
-    // Backtracking is a replacement in both directions.
-    assert(seed::mission_seed_region_change_replaces_world(
-        kCinderSliceSet, kCinderRegion, kLinkSliceSet, kLinkRegion));
-}
-
-void reselecting_the_same_state_is_never_a_replacement() {
-    assert(!seed::mission_seed_region_change_replaces_world(
-        kApexSliceSet, kApexRegion, kApexSliceSet, kApexRegion));
-    assert(!seed::mission_seed_region_change_replaces_world(
-        kCinderSliceSet, kCinderRegion, kCinderSliceSet, kCinderRegion));
-    // A same-region selection stays a no-op even if the slice sets disagree: the region is the
-    // publication key, and re-registering identical groups must not reopen an arrival window.
-    assert(!seed::mission_seed_region_change_replaces_world(
-        kApexSliceSet, kApexRegion, kCinderSliceSet, kApexRegion));
-}
-
-void decision_is_available_at_compile_time() {
-    // The production caller is in a lock-held path, so the predicate must fold away.
-    static_assert(!seed::mission_seed_region_change_replaces_world(0, 0, 0, 1));
-    static_assert(seed::mission_seed_region_change_replaces_world(40, 40, 0, 0));
-}
-
-void arrival_window_closes_when_no_arrival_can_be_reported() {
-    constexpr std::uint32_t factor = 8;
-    // Ember's ending: apex gameplay held, region 1 pending, both slice set 0. No slice-set switch
-    // happens, so the client never reports region 1 and the window must close on its own.
-    assert(seed::mission_seed_arrival_window_closed(kApexRegion, kFirstMovieRegion,
-                                                    kApexSliceSet, factor));
-    assert(seed::mission_seed_arrival_window_closed(kFirstMovieRegion, kSecondMovieRegion,
-                                                    kApexSliceSet, factor));
-    // The ordinary case still closes on the arrival itself.
-    assert(seed::mission_seed_arrival_window_closed(kPowerhouseRegion, kPowerhouseRegion,
-                                                    kPowerhouseSliceSet, factor));
-}
-
-void arrival_window_stays_open_across_a_real_slice_set_change() {
-    constexpr std::uint32_t factor = 8;
-    // Holding Cinder while Apex is pending is a genuine teardown: the window must stay open or
-    // the publication registers the new region's groups into the world being torn down.
-    assert(!seed::mission_seed_arrival_window_closed(kCinderRegion, kApexRegion,
-                                                     kApexSliceSet, factor));
-    assert(!seed::mission_seed_arrival_window_closed(kPowerhouseRegion, kLinkRegion,
-                                                     kLinkSliceSet, factor));
-    // Holding nothing yet is not an arrival.
-    assert(!seed::mission_seed_arrival_window_closed(-1, kApexRegion, kApexSliceSet, factor));
-    // A zero factor would make the slice-set test meaningless; it must not close the window.
-    assert(!seed::mission_seed_arrival_window_closed(kApexRegion, kFirstMovieRegion,
-                                                     kApexSliceSet, 0));
-}
-
-} // namespace
-
 int main() {
-    // Closing the arrival lease alone is insufficient: the roster must include the
-    // state-local cinematic groups while the client still reports Apex gameplay.
-    assert(!seed::mission_seed_transition_subset_only(false, true, false, 0, 1, 0, 8));
-    assert(!seed::mission_seed_transition_subset_only(false, true, false, 0, 2, 0, 8));
-    assert(seed::mission_seed_transition_subset_only(false, true, false, 40, 1, 0, 8));
-    assert(seed::mission_seed_transition_subset_only(false, true, false, -1, 1, 0, 8));
-    assert(seed::mission_seed_transition_subset_only(false, false, false, 0, 1, 0, 8));
-    assert(!seed::mission_seed_transition_subset_only(false, false, true, 0, 1, 0, 8));
-    assert(!seed::mission_seed_transition_subset_only(true, true, false, 40, 1, 0, 8));
-    // 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();
-    decision_is_available_at_compile_time();
-    arrival_window_closes_when_no_arrival_can_be_reported();
-    arrival_window_stays_open_across_a_real_slice_set_change();
-    return 0;
+    // Both movies need their own native world entry even though the bubble stays zero.
+    assert(seed::mission_seed_region_change_replaces_world(0, 0, 0, 1));
+    assert(seed::mission_seed_region_change_replaces_world(0, 1, 0, 2));
+    assert(seed::mission_seed_region_change_replaces_world(0, 2, 0, 0));
+    assert(!seed::mission_seed_region_change_replaces_world(0, 1, 0, 1));
+    // Preserve ordinary mission travel and exact same-state idempotence.
+    assert(seed::mission_seed_region_change_replaces_world(48, 49, 64, 64));
+    assert(seed::mission_seed_region_change_replaces_world(64, 64, 56, 56));
+    assert(seed::mission_seed_region_change_replaces_world(40, 40, 0, 0));
+    assert(!seed::mission_seed_region_change_replaces_world(40, 40, 40, 40));
+    // Held base gameplay is not a bookend arrival; pending/unknown is not held.
+    assert(!seed::mission_seed_arrival_window_closed(0, 1, 0, 8));
+    assert(!seed::mission_seed_arrival_window_closed(1, 2, 0, 8));
+    assert(!seed::mission_seed_arrival_window_closed(-1, 1, 0, 8));
+    assert(!seed::mission_seed_arrival_window_closed(1, 1, 0, 0));
+    assert(seed::mission_seed_arrival_window_closed(1, 1, 0, 8));
+    assert(seed::mission_seed_arrival_window_closed(2, 2, 0, 8));
+    // The last scripted seed can be landing while natural traversal reached Apex.
+    assert(seed::mission_seed_selection_needs_arrival(64, 64, 0, 1, 0, 8));
+    assert(!seed::mission_seed_selection_needs_arrival(64, 64, 0, 1, 1, 8));
+    assert(!seed::mission_seed_selection_needs_arrival(64, 64, 0, 0, 0, 8));
+    // Local objects must wait for their actual world; global-only startup stays intact.
+    assert(seed::mission_seed_transition_subset_only(false, true, false, 0, 1, 0, 8));
+    assert(!seed::mission_seed_transition_subset_only(false, true, false, 1, 1, 0, 8));
+    assert(seed::mission_seed_transition_subset_only(false, false, false, 1, 1, 0, 8));
+    assert(!seed::mission_seed_transition_subset_only(false, false, true, 1, 1, 0, 8));
+    assert(!seed::mission_seed_transition_subset_only(true, true, false, 0, 1, 0, 8));
 }