Explorar o código

Align surge audio and require native ending playback before completion

Millie hai 3 días
pai
achega
60b463b49a

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

@@ -20,3 +20,19 @@ The 15:52:37 stalled-run capture contains 491 allocated sync records and no type
 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.
 
 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.
+
+## Follow-up from IKORA-ANIMATION-AND-ENDING.md and live screenshots
+
+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.
+
+Prepared Lua changes:
+
+- 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.
+
+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.
+
+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.
+
+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.

+ 7 - 1
scripts/mission_ember.lua

@@ -20,7 +20,13 @@ end
 return {
     initial_state = opening.initial_state,
     on_event_cinematic_terminated = terminated,
-    on_event_cinematic_skip_requested = terminated,
+    on_event_cinematic_started = function(c, s, e)
+        if routes then routes.started(c, s, e) end
+    end,
+    on_event_cinematic_skip_requested = function(c, s, e)
+        opening.terminated(c, s, e)
+        if routes then routes.skip(c, s, e) end
+    end,
     on_event_client_state_changed = function(context, state, event)
         wipe.client_state(context, state, event)
         opening.client_state(context, state, event)

+ 33 - 12
scripts/mission_ember/apex.lua

@@ -30,6 +30,7 @@ return function(m, a, ending)
     local function generation(s) return s:variable("ember.apex.generation") or 1 end
     local function dead(s, target) return s:variable("ember.apex.dead." .. target) == true end
     local function vent_timer(s) return "ember.apex.vents." .. generation(s) end
+    local function surge_audio_timer(s) return "ember.apex.surge_audio." .. generation(s) end
     local function lane(c, name, transition, snap)
         a.slot(c, name):transition{transition = c.sdk.device_transitions[transition], snap = snap or false}
     end
@@ -158,11 +159,6 @@ return function(m, a, ending)
         c:set_variable("ember.apex.vents_open", step == "open")
         if step == "warning" then
             beam_surge(c, s, true)
-            if phase(s) == 3 then
-                for _, side in ipairs(sides) do
-                    if not dead(s, side) then a.slot(c, "REACTOR_CLAMSHELL_" .. side .. "_ALARM_SEQUENCE"):play_sequence{} end
-                end
-            else a.slot(c, "REACTOR_COFFIN_ALARM_SEQUENCE"):play_sequence{} end
         else
             -- Exposure is the cooling window: stop the surge before moving the shutters.
             beam_surge(c, s, false)
@@ -173,6 +169,13 @@ return function(m, a, ending)
                 if step == "open" then a.cue(c, s, 46) end
             end
         end
+        c:cancel_timer(surge_audio_timer(s))
+        if step == "closed" then
+            -- Playtest: requesting the sequence at surge start made its sound land at
+            -- cooling-door opening, one six-second window late. Pre-roll audio only;
+            -- the verified visual/exposure clock remains unchanged.
+            c:start_timer(surge_audio_timer(s), 8000)
+        end
         c:start_timer(vent_timer(s), ({closed = 14000, warning = 6000, open = 10000})[step])
     end
     local function initialize_devices(c, s)
@@ -224,6 +227,7 @@ return function(m, a, ending)
             -- opened both devices here, which left it running through the whole escape.
             beam(c, s, false, true)
             c:cancel_timer(vent_timer(s))
+            c:cancel_timer(surge_audio_timer(s))
             a.scene(c, "MOTHER_BRAIN_HOLE_EXPLOSION_SCENE")
             arm_escape(c)
             a.cue(c, s, 51); A.guidance(c, s)
@@ -319,6 +323,7 @@ return function(m, a, ending)
         else
             set(c, 5)
             c:cancel_timer(vent_timer(s)); c:cancel_timer("ember.apex.explain." .. generation(s))
+            c:cancel_timer(surge_audio_timer(s))
             coffin_doors(c, true)
             -- The weapon keeps firing until the cell goes in.
             beam(c, s, true)
@@ -345,6 +350,18 @@ return function(m, a, ending)
         destroyed(c, s, which)
     end
     function A.timer(c, s, e)
+        if e.timer_name == surge_audio_timer(s) then
+            if s:variable("ember.region") == 0 and s:variable("ember.apex.vent_step") == "closed" then
+                if phase(s) == 3 then
+                    for _, side in ipairs(sides) do
+                        if not dead(s, side) then
+                            a.slot(c, "REACTOR_CLAMSHELL_" .. side .. "_ALARM_SEQUENCE"):play_sequence{}
+                        end
+                    end
+                elseif phase(s) == 4 then a.slot(c, "REACTOR_COFFIN_ALARM_SEQUENCE"):play_sequence{} end
+            end
+            return true
+        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
@@ -409,6 +426,7 @@ return function(m, a, ending)
             a.darkness(c, s, true)
         else
             c:cancel_timer(vent_timer(s)); c:cancel_timer("ember.apex.core." .. generation(s))
+            c:cancel_timer(surge_audio_timer(s))
             c:cancel_timer("ember.apex.explain." .. generation(s)); c:cancel_timer("ember.apex.setup." .. generation(s))
             a.reset(c, reactor); carry.reset(c, s)
             c:set_variable("ember.apex.generation", generation(s) + 2)
@@ -462,13 +480,16 @@ return function(m, a, ending)
     function A.object(c, s, e)
         carry.state(c, s, e)
         if e.present and e.alive then
-            for i, name in ipairs({"SPECOPS_APEX_RING_LASER_OBJECT", "SPECOPS_APEX_RING_RING_OBJECT"}) do
-                if a.matches(c, e, name) and s:variable("ember.apex.beam") == true then
-                    -- Reapply the current pose when native creation finishes. The initial
-                    -- setup can precede creation; waiting for the first surge left it dark.
-                    unlock(c, beam_devices[i])
-                    a.device(c, beam_devices[i], s:variable("ember.apex.surge") ~= true, true)
-                end
+            if a.matches(c, e, "SPECOPS_APEX_RING_LASER_OBJECT")
+                and s:variable("ember.apex.beam") == true
+                and s:variable("ember.apex.beam_primed") ~= e.generation then
+                c:set_variable("ember.apex.beam_primed", e.generation)
+                for _, name in ipairs(beam_devices) do unlock(c, name) end
+                -- Seeking directly to the resting endpoint left the emitter dark until
+                -- its first animated cycle. Traverse the authored drive once on creation
+                -- so its effect events run, then leave normal surge timing in charge.
+                beam_pose(c, true, true)
+                if s:variable("ember.apex.surge") ~= true then beam_pose(c, false, false) end
             end
             if a.matches(c, e, "REACTOR_GETAWAY_SHIP_OBJECT") and phase(s) == 6
                 and not s:variable("ember.apex.ship_started") then

+ 25 - 3
scripts/mission_ember/ending.lua

@@ -22,7 +22,9 @@ return function(m)
     local function play(c, index)
         local row = assert(movies[index])
         c:set_variable("ember.ending", index)
-        c:set_variable("ember.ending.playing", index)
+        c:clear_variable("ember.ending.playing")
+        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
@@ -31,12 +33,32 @@ return function(m)
         music.update(c, s)
         play(c, 1)
     end
-    function E.terminated(c, s, e)
+    local function matched(c, s, e)
         local index = s:variable("ember.ending")
         local row = index and movies[index]
-        if not row or s:variable("ember.ending.playing") ~= index then return end
+        if not row then return end
         local slot = c:slot(row.slot)
         if e.registry_key ~= slot.registry_key or e.slot_type ~= slot.slot_type or e.slot_index ~= slot.slot_index then return end
+        return index, slot
+    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
+        c:set_variable("ember.ending.playing", index)
+        c:set_variable("ember.ending.runtime", e.runtime_object_id)
+    end
+    function E.skip(c, s, e)
+        local index, slot = matched(c, s, e)
+        if not index or s:variable("ember.ending.playing") ~= index
+            or e.runtime_object_id ~= s:variable("ember.ending.runtime")
+            or s:variable("ember.ending.stopping") then return end
+        c:set_variable("ember.ending.stopping", true)
+        slot:set_cinematic_active{active = false}
+    end
+    function E.terminated(c, s, e)
+        local index, slot = matched(c, s, e)
+        if not index or s:variable("ember.ending.playing") ~= index
+            or e.runtime_object_id ~= s:variable("ember.ending.runtime") then return end
         slot:set_cinematic_active{active = false}
         if movies[index + 1] then play(c, index + 1)
         else

+ 2 - 0
scripts/mission_ember/routes.lua

@@ -48,5 +48,7 @@ return function(m)
         active.reset(c, s, s:variable("ember.checkpoint.name"))
     end
     R.terminated = ending.terminated
+    R.started = ending.started
+    R.skip = ending.skip
     return R
 end

+ 32 - 8
tests/mission_ember_routes_test.lua

@@ -208,7 +208,10 @@ for _,name in ipairs({'SPECOPS_APEX_RING_LASER_OBJECT','SPECOPS_APEX_RING_RING_O
     call(R.dispatch,'object',c,s,event(name,{generation=1,present=true,alive=true}))
 end
 assert(transition('SPECOPS_APEX_RING_LASER_DEVICE',true).transition=='open')
-assert(transition('SPECOPS_APEX_RING_LASER_DEVICE',true).snap==true)
+assert(transition('SPECOPS_APEX_RING_LASER_DEVICE',true).snap==false, 'startup must run effect events instead of seeking past them')
+local primedCalls=#calls
+call(R.dispatch,'object',c,s,event('SPECOPS_APEX_RING_LASER_OBJECT',{generation=1,present=true,alive=true}))
+assert(#calls==primedCalls,'duplicate laser presence restarted its drive')
 assert(vars['ember.apex.beam'] and not vars['ember.apex.surge'],'entry must light the resting beam')
 assert(transition('CLAMSHELL_TO_COFFIN_EAST_BRIDGE_DEVICE', true).transition=='open')
 assert(transition('CLAMSHELL_TO_COFFIN_WEST_BRIDGE_DEVICE', true).transition=='open')
@@ -262,6 +265,13 @@ for _,side in ipairs({'east','west'})do for _,wave in ipairs({'entry','reinforce
 assert(vars['ember.music.section']==22)
 timer('ember.apex.explain.');assert(vars['ember.r.cue.41'] and vars['ember.music.section']==23)
 assert(timers['ember.apex.vents.1']==14000)
+assert(timers['ember.apex.surge_audio.1']==8000,'audio pre-roll must lead the unchanged visual surge by six seconds')
+local beforeAudio=#calls
+timer('ember.apex.surge_audio.')
+assert(vars['ember.apex.vent_step']=='closed' and not vars['ember.apex.surge'],'audio pre-roll changed visual timing')
+local alarms=0
+for i=beforeAudio+1,#calls do if calls[i][1]=='play_sequence' then alarms=alarms+1 end end
+assert(alarms==2,'both surviving clamshells must pre-roll their authored audio')
 timer('ember.apex.vents.') -- Surge precedes any target exposure.
 assert(vars['ember.apex.vent_step']=='warning' and timers['ember.apex.vents.1']==6000)
 -- The weapon fires continuously through the fight; the exposure cycle must not blink it.
@@ -277,6 +287,7 @@ local coolingStart=#calls
 timer('ember.apex.vents.')
 assert(timers['ember.apex.vents.1']==10000)
 assert(vars['ember.apex.surge']==false,'cooling shutters opened during the surge')
+for i=coolingStart+1,#calls do assert(calls[i][1]~='play_sequence','opening the shutters must not restart surge audio') end
 local restored={}
 for i=coolingStart+1,#calls do
     local row=calls[i]
@@ -426,16 +437,29 @@ trigger('APEX_DIRECTIVE_REACTOR_RAILS_ESCAPE_PLAYER_TRIGGER')
 -- held region. Both the selection and activation must be queued in this callback.
 -- No teleport is armed for a sibling state, so no further client report arrives. The movie
 -- must be queued with its own selection or it never starts.
-assert(vars['ember.ending']==1 and vars['ember.ending.playing']==1,'first movie never started')
-local started=false
+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
     if row[1]=='set_cinematic_active' and row[2]=='pf_cinematic_bookend_stm._cinematic'
-        and row[3].active then started=true end
+        and row[3].active then offered=true end
 end
-assert(started,'first bookend was never activated')
-call(R.terminated,c,s,event('PF_CINEMATIC_BOOKEND_STM_CINEMATIC'))
-assert(vars['ember.ending']==2 and vars['ember.ending.playing']==2,'second movie never started')
-call(R.terminated,c,s,event('PF_CINEMATIC_BOOKEND_CNN_CINEMATIC'))
+assert(offered,'first bookend was never offered')
+local first=event('PF_CINEMATIC_BOOKEND_STM_CINEMATIC',{runtime_object_id='9007199254740993'})
+local second=event('PF_CINEMATIC_BOOKEND_CNN_CINEMATIC',{runtime_object_id='9007199254740994'})
+local beforeStart=#calls
+call(R.terminated,c,s,first);call(R.skip,c,s,first)
+assert(#calls==beforeStart and vars['ember.ending']==1,'unstarted movie advanced the ending')
+call(R.started,c,s,second);assert(not vars['ember.ending.playing'],'wrong controller claimed playback')
+call(R.started,c,s,first);assert(vars['ember.ending.playing']==1)
+call(R.terminated,c,s,event('PF_CINEMATIC_BOOKEND_STM_CINEMATIC',{runtime_object_id='stale'}))
+assert(vars['ember.ending']==1,'stale runtime object advanced the movie')
+call(R.skip,c,s,first)
+assert(vars['ember.ending']==1 and not vars['ember.complete'],'skip must wait for native termination')
+local stoppingCalls=#calls;call(R.skip,c,s,first);assert(#calls==stoppingCalls,'repeated skip republished stop')
+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);call(R.terminated,c,s,second)
 assert(vars['ember.complete'])
 local objective=vars['ember.r.guidance']
 region(56);region(40);assert(vars['ember.r.guidance']==objective,'backtracking reset the forward objective')