Bladeren bron

Drive the ring devices alongside their objects and observe their state

The beam's two authored type-23 devices were being driven, but nothing ever
observed them: the device-sense diagnostic is scoped to registry F6FFB59E slots
0-5, the landing bridge. So a beam that did not change could not be told apart
from a device transition that never reached the device, and the earlier
conclusion that these lanes do nothing was not supported. The diagnostic now
also reports the apex ring laser and ring devices as ring_device_sense, carrying
their native position and power.

Both halves of the weapon are now driven together, the way the Mercury lever
pairs a type-23 device with the type-4 object it animates. The core and ring
objects are the structure and stay placed for the whole mission; the laser
object is the beam and its presence is the lever already shown to make the beam
appear and disappear. The devices are snap-initialized to a baseline and then
transitioned without snap, the pattern that made the lever animate: the warning
drives them open as the surge and recovery returns them, without ever removing
the beam, and the deposit powers them off and returns the drive to its baseline
so the weapon is left installed but dark.

Whether the surge is visible now depends on whether these devices move the
emitter at all. The next run's ring_device_sense lines answer that directly
instead of by inference.

22 portable tests and five Lua suites pass; route peaks at 234/512 variables,
61/63 intents per callback and 3/32 timers.
Millie 3 dagen geleden
bovenliggende
commit
19bdd2e2cb

+ 19 - 7
Sunrise/src/server/activity/mission/mission_script_runtime.cpp

@@ -979,7 +979,15 @@ void log_ember_interaction_sense(RuntimeInstance& instance,
                              && observation.key.senseSchema == 0x80804D3EU;
                              && observation.key.senseSchema == 0x80804D3EU;
         const bool bridge = observation.key.slotType == 23 && observation.key.slotIndex < 6
         const bool bridge = observation.key.slotType == 23 && observation.key.slotIndex < 6
                             && observation.key.senseSchema == 0x80804F47U;
                             && observation.key.senseSchema == 0x80804F47U;
-        if (observation.key.registryKey != 0xF6FFB59EU || (!console && !bridge)
+        // The Almighty's weapon ring: whether its two authored devices actually accept and
+        // report position and power decides whether the beam can be driven from them at all.
+        // Nothing observed these before, so a beam that did not change could not be told apart
+        // from a device transition that never landed.
+        const bool ring = observation.key.registryKey == 0xA3B76C64U
+                          && observation.key.slotType == 23
+                          && (observation.key.slotIndex == 48 || observation.key.slotIndex == 49)
+                          && observation.key.senseSchema == 0x80804F47U;
+        if ((observation.key.registryKey != 0xF6FFB59EU && !ring) || (!console && !bridge && !ring)
             || instance.emberInteractionReports >= 256
             || instance.emberInteractionReports >= 256
             || observation.firstValue > sense.valueCount
             || observation.firstValue > sense.valueCount
             || observation.valueCount > sense.valueCount - observation.firstValue) {
             || observation.valueCount > sense.valueCount - observation.firstValue) {
@@ -991,12 +999,15 @@ void log_ember_interaction_sense(RuntimeInstance& instance,
                 continue;
                 continue;
             }
             }
             if (value.fieldOrdinal >= 9) continue;
             if (value.fieldOrdinal >= 9) continue;
-            const std::size_t field = (console ? 54U : observation.key.slotIndex * 9U)
-                                      + value.fieldOrdinal;
-            const std::uint64_t bit = std::uint64_t{1} << field;
-            if ((instance.emberInteractionSeen & bit) != 0
+            const std::size_t field =
+                ring ? 64U + (observation.key.slotIndex - 48U) * 9U + value.fieldOrdinal
+                     : (console ? 54U : observation.key.slotIndex * 9U) + value.fieldOrdinal;
+            if (field >= instance.emberInteractionValues.size()) continue;
+            const std::size_t word = field / 64U;
+            const std::uint64_t bit = std::uint64_t{1} << (field % 64U);
+            if ((instance.emberInteractionSeen[word] & bit) != 0
                 && instance.emberInteractionValues[field] == value.unsignedValue) continue;
                 && instance.emberInteractionValues[field] == value.unsignedValue) continue;
-            instance.emberInteractionSeen |= bit;
+            instance.emberInteractionSeen[word] |= bit;
             instance.emberInteractionValues[field] = value.unsignedValue;
             instance.emberInteractionValues[field] = value.unsignedValue;
             if (instance.emberInteractionReports++ >= 256) return;
             if (instance.emberInteractionReports++ >= 256) return;
             std::array<char, 192> fields{};
             std::array<char, 192> fields{};
@@ -1014,7 +1025,8 @@ void log_ember_interaction_sense(RuntimeInstance& instance,
             if (written > 0) {
             if (written > 0) {
                 log_line(core::log::Level::info,
                 log_line(core::log::Level::info,
                          &instance,
                          &instance,
-                         console ? "ghost_link_sense" : "bridge_device_sense",
+                         ring ? "ring_device_sense"
+                              : (console ? "ghost_link_sense" : "bridge_device_sense"),
                          "observed",
                          "observed",
                          {fields.data(),
                          {fields.data(),
                           (std::min)(static_cast<std::size_t>(written), fields.size() - 1)});
                           (std::min)(static_cast<std::size_t>(written), fields.size() - 1)});

+ 2 - 2
Sunrise/src/server/activity/mission/mission_script_runtime_internal.h

@@ -227,8 +227,8 @@ struct RuntimeInstance final {
     std::uint64_t playerKey{};
     std::uint64_t playerKey{};
     /** Bounded diagnostic budget for Ember bridge and console Sense fields. */
     /** Bounded diagnostic budget for Ember bridge and console Sense fields. */
     std::uint16_t emberInteractionReports{};
     std::uint16_t emberInteractionReports{};
-    std::array<std::uint64_t, 64> emberInteractionValues{};
-    std::uint64_t emberInteractionSeen{};
+    std::array<std::uint64_t, 128> emberInteractionValues{};
+    std::array<std::uint64_t, 2> emberInteractionSeen{};
     bool publicTarget{};
     bool publicTarget{};
     bool missionStateBound{};
     bool missionStateBound{};
     bool missionStarted{};
     bool missionStarted{};

+ 38 - 12
scripts/mission_ember/apex.lua

@@ -50,25 +50,46 @@ return function(m, a, ending)
         end
         end
         a.device(c, "REACTOR_SHIELD_DEVICE", open, snap)
         a.device(c, "REACTOR_SHIELD_DEVICE", open, snap)
     end
     end
-    -- Both ring devices are ordinary type-23s -- their configs carry the same class refs as a
-    -- working clamshell door -- and driving their position or power changes nothing visible.
-    -- Presence of the placed objects is the only lever that moves this beam, and the three
-    -- objects split by role: the core and ring models carry solid geometry with full LOD tables,
-    -- while the laser model carries the emitter's own effect components and no geometry LODs.
-    -- So the core and ring are the structure the weapon is built from and stay for the whole
-    -- mission, and the laser is the beam itself. Firing it off leaves the weapon installed but
-    -- dark, rather than deleting it and everything around it.
+    -- The weapon has two halves and both are driven.
+    --
+    -- Objects: the core and ring models carry solid geometry with full LOD tables and are the
+    -- structure the weapon is built from, so they are placed once and never taken back out --
+    -- removing them deletes the beam and everything around it. The laser model carries the
+    -- emitter's effect components and no geometry LODs, so it is the beam itself and its
+    -- presence is what has been observed to make the beam appear and disappear.
+    --
+    -- Devices: `SPECOPS_APEX_RING_LASER_DEVICE` and `..._RING_DEVICE` are the authored lanes of
+    -- those same placed objects, exactly as the Mercury lever's type-23 device animates the
+    -- type-4 object it belongs to. Their closed vocabulary is position, power and lock. They are
+    -- snap-initialized to a baseline and then transitioned without snap, which is the pattern
+    -- that made the lever animate. Whether they actually report back is now observable:
+    -- `ring_device_sense` in the log carries their native position and power.
     local structure_objects = {"SPECOPS_APEX_RING_CORE_OBJECT", "SPECOPS_APEX_RING_RING_OBJECT"}
     local structure_objects = {"SPECOPS_APEX_RING_CORE_OBJECT", "SPECOPS_APEX_RING_RING_OBJECT"}
+    local beam_devices = {"SPECOPS_APEX_RING_LASER_DEVICE", "SPECOPS_APEX_RING_RING_DEVICE"}
+    local function beam_drive(c, driven, snap)
+        for _, name in ipairs(beam_devices) do a.device(c, name, driven, snap) end
+    end
+    -- Firing: the beam is present and both of its devices are powered.
     local function beam(c, s, firing, snap)
     local function beam(c, s, firing, snap)
         -- Idempotent: redundant publications would spend intents from the callback's budget.
         -- Idempotent: redundant publications would spend intents from the callback's budget.
         if s:variable("ember.apex.beam") == firing then return end
         if s:variable("ember.apex.beam") == firing then return end
         c:set_variable("ember.apex.beam", firing)
         c:set_variable("ember.apex.beam", firing)
         a.objects(c, {"SPECOPS_APEX_RING_LASER_OBJECT"}, firing)
         a.objects(c, {"SPECOPS_APEX_RING_LASER_OBJECT"}, firing)
-        -- The authored device lanes still follow, so the weapon's own state matches what is
-        -- drawn even though the beam is not rendered from them.
-        for _, name in ipairs({"SPECOPS_APEX_RING_LASER_DEVICE", "SPECOPS_APEX_RING_RING_DEVICE"}) do
+        for _, name in ipairs(beam_devices) do
             lane(c, name, firing and "power_on" or "power_off", snap)
             lane(c, name, firing and "power_on" or "power_off", snap)
         end
         end
+        if not firing then
+            -- Installed but dark: the drive returns to its baseline with the power.
+            c:set_variable("ember.apex.surge", false)
+            beam_drive(c, false, snap)
+        end
+    end
+    -- The surge is the authored drive of a firing beam, so it never removes it. If these
+    -- devices do move the emitter, this is the lane that shows it.
+    local function beam_surge(c, s, on)
+        if s:variable("ember.apex.beam") ~= true or s:variable("ember.apex.surge") == on then return end
+        c:set_variable("ember.apex.surge", on)
+        beam_drive(c, on, false)
     end
     end
     -- `REACTOR_COFFIN_INTERIOR_THERMAL_HOP_ON` and `FOUNDRY_THERMAL_DOT_HOP_ON` both reference
     -- `REACTOR_COFFIN_INTERIOR_THERMAL_HOP_ON` and `FOUNDRY_THERMAL_DOT_HOP_ON` both reference
     -- effect resource 80C1D9E0 -- the burn already working in the Foundry. The hot-pipe and
     -- effect resource 80C1D9E0 -- the burn already working in the Foundry. The hot-pipe and
@@ -130,12 +151,14 @@ return function(m, a, ending)
         c:set_variable("ember.apex.vent_step", step)
         c:set_variable("ember.apex.vent_step", step)
         c:set_variable("ember.apex.vents_open", step == "open")
         c:set_variable("ember.apex.vents_open", step == "open")
         if step == "warning" then
         if step == "warning" then
+            beam_surge(c, s, true)
             if phase(s) == 3 then
             if phase(s) == 3 then
                 for _, side in ipairs(sides) do
                 for _, side in ipairs(sides) do
                     if not dead(s, side) then a.slot(c, "REACTOR_CLAMSHELL_" .. side .. "_ALARM_SEQUENCE"):play_sequence{} end
                     if not dead(s, side) then a.slot(c, "REACTOR_CLAMSHELL_" .. side .. "_ALARM_SEQUENCE"):play_sequence{} end
                 end
                 end
             else a.slot(c, "REACTOR_COFFIN_ALARM_SEQUENCE"):play_sequence{} end
             else a.slot(c, "REACTOR_COFFIN_ALARM_SEQUENCE"):play_sequence{} end
         else
         else
+            if step == "closed" then beam_surge(c, s, false) end
             if phase(s) == 3 then
             if phase(s) == 3 then
                 for _, side in ipairs(sides) do if not dead(s, side) then doors(c, side, step == "open", snap) end end
                 for _, side in ipairs(sides) do if not dead(s, side) then doors(c, side, step == "open", snap) end end
             elseif phase(s) == 4 then
             elseif phase(s) == 4 then
@@ -157,8 +180,11 @@ return function(m, a, ending)
         lane(c, "MOTHER_BRAIN_DOOR_DEVICE", "lock")
         lane(c, "MOTHER_BRAIN_DOOR_DEVICE", "lock")
         unlock(c, "SPECOPS_APEX_RING_LASER_DEVICE")
         unlock(c, "SPECOPS_APEX_RING_LASER_DEVICE")
         unlock(c, "SPECOPS_APEX_RING_RING_DEVICE")
         unlock(c, "SPECOPS_APEX_RING_RING_DEVICE")
-        -- The weapon is firing at the sun from the moment the area comes up.
+        -- The weapon is firing at the sun from the moment the area comes up. Snap the drive to
+        -- its baseline here so every later surge is an animated transition, not a jump.
         beam(c, s, true, true)
         beam(c, s, true, true)
+        c:set_variable("ember.apex.surge", false)
+        beam_drive(c, false, true)
     end
     end
     local function start_reactor(c, s)
     local function start_reactor(c, s)
         if phase(s) >= 3 then return end
         if phase(s) >= 3 then return end

+ 12 - 1
tests/mission_ember_routes_test.lua

@@ -259,8 +259,11 @@ assert(timers['ember.apex.vents.1']==14000)
 timer('ember.apex.vents.') -- Surge precedes any target exposure.
 timer('ember.apex.vents.') -- Surge precedes any target exposure.
 assert(vars['ember.apex.vent_step']=='warning' and timers['ember.apex.vents.1']==6000)
 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.
 -- The weapon fires continuously through the fight; the exposure cycle must not blink it.
+-- The surge is the authored device drive of a beam that stays present and powered.
 assert(vars['ember.apex.beam']==true,'the weapon must keep firing across the cycle')
 assert(vars['ember.apex.beam']==true,'the weapon must keep firing across the cycle')
-assert(transition('SPECOPS_APEX_RING_LASER_DEVICE').transition=='power_on')
+assert(vars['ember.apex.surge']==true,'the warning must drive the beam')
+assert(transition('SPECOPS_APEX_RING_LASER_DEVICE', true).transition=='open')
+assert(transition('SPECOPS_APEX_RING_RING_DEVICE', true).transition=='open')
 assert(transition('REACTOR_CLAMSHELL_EAST_DOOR_A_DEVICE').transition=='close')
 assert(transition('REACTOR_CLAMSHELL_EAST_DOOR_A_DEVICE').transition=='close')
 timer('ember.apex.vents.')
 timer('ember.apex.vents.')
 assert(timers['ember.apex.vents.1']==10000)
 assert(timers['ember.apex.vents.1']==10000)
@@ -344,6 +347,14 @@ assert(escapeFilter=='aod_reactor_rail_top_object_filter','escape hazard must us
 -- The weapon is dead once the cell is in: powered off, but still installed. Deactivating the
 -- 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.
 -- 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')
 assert(vars['ember.apex.beam']==false,'the beam must stop firing after the deposit')
+assert(vars['ember.apex.surge']==false,'the drive must return to its baseline with the power')
+assert(transition('SPECOPS_APEX_RING_LASER_DEVICE', true).transition=='close')
+local poweredOff=false
+for _,row in ipairs(calls)do
+    if row[1]=='transition' and row[2]=='specops_apex_ring.laser_device'
+        and row[3].transition=='power_off' then poweredOff=true end
+end
+assert(poweredOff,'the beam must be powered off at the deposit')
 -- Only the laser is the beam. Removing the core or ring would take the weapon's structure,
 -- Only the laser is the beam. Removing the core or ring would take the weapon's structure,
 -- and everything built around it, out of the world.
 -- and everything built around it, out of the world.
 for _,row in ipairs(calls)do
 for _,row in ipairs(calls)do