Jelajahi Sumber

Retain incident payloads so cinematic completion reaches mission scripts

Millie 4 hari lalu
induk
melakukan
78364f5228

+ 23 - 0
MISSION_EMBER_STATUS.md

@@ -1,5 +1,28 @@
 # Ember implementation status
 
+## Cinematic handoff fix — 2026-09-05
+
+Playtest confirmed the arrival movie plays, but the player remained black after fade-out.
+Both native notifications arrived (targets 5239 and 1685); the mission runtime rejected each
+as `cinematic absent`. Inspection of the retained records showed zero-filled payloads and
+ClientRefs decoded as registry 0, type -1, slot -32768.
+
+Root cause: `incident::validate` advanced past selector, optional words and payload without
+retaining their contents. It now reads and stores those fields through the bit reader,
+including unaligned payloads. This restores the source identity needed to deliver the exact
+cinematic-termination event to Lua and select landing state 64. Player-trigger events used
+the same discarded payload path. Cinematic resolution now logs the source identity and
+successful start/termination for the next playtest.
+
+Validation: the new regression fails against the original parser and passes with the fix.
+It covers every payload alignment, maximum selector/payload lengths, truncated frames,
+optional fields, cinematic encode/parse/decode/catalogue resolution and player-trigger decode.
+All three portable CTest tests and the Lua handoff test pass; Release build passed.
+
+Installed and SHA-256 verified the rebuilt root DLL. Previous DLL is in
+`build/cinematic-handoff-backup-20260905-075750`. No SDK or settings regeneration was needed.
+The restored notification path still needs the next in-game spawn test.
+
 ## Current work — 2026-09-05
 
 The sections below this update record earlier playtest builds. Current investigation found

+ 27 - 7
Sunrise/src/middleware/bap/activity_message/activity_incident_parser.cpp

@@ -9,6 +9,19 @@
 namespace sunrise::middleware::bap::activity_message::incident {
 namespace {
 
+/** Retains byte fields even when their wire start is not byte-aligned. */
+[[nodiscard]] bool read_bytes(encoding::bits::Reader& reader,
+                              std::span<std::byte> output) noexcept {
+    for (std::byte& value : output) {
+        std::uint64_t field = 0;
+        if (!reader.read(encoding::kBitsPerByte, field)) {
+            return false;
+        }
+        value = static_cast<std::byte>(field);
+    }
+    return true;
+}
+
 /** @return True when one target index is safe to hand to the Client's table lookup. */
 [[nodiscard]] bool target_allowed(std::uint32_t target, Verdict& verdict) noexcept {
     if (target > kTargetMaximum) {
@@ -80,7 +93,8 @@ Verdict validate(std::span<const std::byte> payload, Incident& parsed) noexcept
     if (!reader.read(kSelectorPresenceWidth, field)) {
         return Verdict::truncated;
     }
-    if (field != 0) {
+    parsed.hasCompressedSelector = field != 0;
+    if (parsed.hasCompressedSelector) {
         if (!reader.read(kSelectorLengthWidth, field)) {
             return Verdict::truncated;
         }
@@ -88,8 +102,7 @@ Verdict validate(std::span<const std::byte> payload, Incident& parsed) noexcept
         if (parsed.selectorLength > kSelectorMaximum) {
             return Verdict::selectorTooLong;
         }
-        if (!reader.skip(static_cast<std::size_t>(parsed.selectorLength)
-                         * encoding::kBitsPerByte)) {
+        if (!read_bytes(reader, std::span(parsed.selector).first(parsed.selectorLength))) {
             return Verdict::truncated;
         }
     }
@@ -98,9 +111,15 @@ Verdict validate(std::span<const std::byte> payload, Incident& parsed) noexcept
         return Verdict::truncated;
     }
     parsed.hasOptionalBlock = field != 0;
-    if (parsed.hasOptionalBlock
-        && !reader.skip(static_cast<std::size_t>(kOptionalWordWidth) * 2U)) {
-        return Verdict::truncated;
+    if (parsed.hasOptionalBlock) {
+        if (!reader.read(kOptionalWordWidth, field)) {
+            return Verdict::truncated;
+        }
+        parsed.optionalWordA = static_cast<std::uint32_t>(field);
+        if (!reader.read(kOptionalWordWidth, field)) {
+            return Verdict::truncated;
+        }
+        parsed.optionalWordB = static_cast<std::uint32_t>(field);
     }
 
     if (!reader.read(kPayloadLengthWidth, field)) {
@@ -110,9 +129,10 @@ Verdict validate(std::span<const std::byte> payload, Incident& parsed) noexcept
     if (parsed.payloadLength > kPayloadMaximum) {
         return Verdict::payloadTooLong;
     }
-    if (!reader.skip(static_cast<std::size_t>(parsed.payloadLength) * encoding::kBitsPerByte)) {
+    if (!read_bytes(reader, std::span(parsed.payload).first(parsed.payloadLength))) {
         return Verdict::truncated;
     }
+    parsed.hasPayload = parsed.payloadLength != 0;
     parsed.consumedBits = static_cast<std::uint32_t>(payload.size() * encoding::kBitsPerByte
                                                      - reader.remaining_bits());
     return Verdict::accepted;

+ 24 - 1
Sunrise/src/server/activity/mission/mission_script_runtime_edges.cpp

@@ -2,6 +2,7 @@
 #include <array>
 #include <cstddef>
 #include <cstdint>
+#include <cstdio>
 #include <span>
 
 #include "../../../state/activity/transactions/internal.h"
@@ -301,15 +302,37 @@ void push_cinematic(RuntimeInstance& instance, const host::Event& incident) noex
     target.eventValue = incident.cinematicEventValue;
     cinematic::Source source{};
     const cinematic::ResolveStatus status = cinematic::resolve(*world, target, source);
+    std::array<char, 128> fields{};
+    const int written = std::snprintf(fields.data(),
+                                      fields.size(),
+                                      "registry=%08x slot_type=%d slot_index=%d target=%u",
+                                      target.registryKey,
+                                      static_cast<int>(target.slotType),
+                                      static_cast<int>(target.slotIndex),
+                                      incident.incidentTarget);
+    const std::string_view detail =
+        written > 0
+            ? std::string_view(fields.data(),
+                               (std::min)(static_cast<std::size_t>(written), fields.size() - 1))
+            : std::string_view{};
     if (status != cinematic::ResolveStatus::ready) {
         log_line(core::log::Level::warn,
                  &instance,
                  "cinematic",
                  status == cinematic::ResolveStatus::ambiguous        ? "ambiguous"
                  : status == cinematic::ResolveStatus::invalidCatalog ? "invalid_catalog"
-                                                                      : "absent");
+                                                                      : "absent",
+                 detail);
         return;
     }
+    log_line(core::log::Level::info,
+             &instance,
+             "cinematic",
+             incident.cinematicSignal
+                     == middleware::bap::activity_message::cinematic_incident::Signal::started
+                 ? "started"
+                 : "terminated",
+             detail);
     host::Event event = incident;
     event.kind = incident.cinematicSignal
                          == middleware::bap::activity_message::cinematic_incident::Signal::started

+ 36 - 0
tests/CMakeLists.txt

@@ -0,0 +1,36 @@
+cmake_minimum_required(VERSION 3.20)
+project(SunrisePortableTests LANGUAGES CXX)
+enable_testing()
+add_executable(socket_row_relocation_test socket_row_relocation_test.cpp)
+target_compile_features(socket_row_relocation_test PRIVATE cxx_std_20)
+if(MSVC)
+    target_compile_options(socket_row_relocation_test PRIVATE /W4 /WX /UNDEBUG)
+else()
+    target_compile_options(socket_row_relocation_test PRIVATE -Wall -Wextra -Werror -UNDEBUG)
+endif()
+add_test(NAME socket_row_relocation
+    COMMAND socket_row_relocation_test "${CMAKE_CURRENT_SOURCE_DIR}/fixtures/socket_rows.hex")
+add_executable(lore_visibility_test lore_visibility_test.cpp)
+target_compile_features(lore_visibility_test PRIVATE cxx_std_20)
+if(MSVC)
+    target_compile_options(lore_visibility_test PRIVATE /W4 /WX /UNDEBUG)
+else()
+    target_compile_options(lore_visibility_test PRIVATE -Wall -Wextra -Werror -UNDEBUG)
+endif()
+add_test(NAME lore_visibility COMMAND lore_visibility_test)
+add_executable(incident_payload_test
+    incident_payload_test.cpp
+    ../Sunrise/src/middleware/encoding/bit_reader.cpp
+    ../Sunrise/src/middleware/encoding/bit_writer.cpp
+    ../Sunrise/src/middleware/bap/activity_message/activity_incident_parser.cpp
+    ../Sunrise/src/middleware/bap/activity_message/activity_incident_encoder.cpp
+    ../Sunrise/src/middleware/bap/activity_message/activity_cinematic_incident_codec.cpp
+    ../Sunrise/src/middleware/bap/activity_message/activity_player_trigger_incident_codec.cpp
+    ../Sunrise/src/server/activity/mission/mission_script_cinematic.cpp)
+target_compile_features(incident_payload_test PRIVATE cxx_std_20)
+if(MSVC)
+    target_compile_options(incident_payload_test PRIVATE /W4 /WX /UNDEBUG)
+else()
+    target_compile_options(incident_payload_test PRIVATE -Wall -Wextra -Werror -UNDEBUG)
+endif()
+add_test(NAME incident_payload COMMAND incident_payload_test)

+ 147 - 0
tests/incident_payload_test.cpp

@@ -0,0 +1,147 @@
+#include <algorithm>
+#include <array>
+#include <bit>
+#include <cassert>
+#include <cstddef>
+#include <cstdint>
+#include <iostream>
+#include <vector>
+
+#include "../Sunrise/src/middleware/bap/activity_message/cinematic_incident.h"
+#include "../Sunrise/src/middleware/bap/activity_message/incident.h"
+#include "../Sunrise/src/middleware/bap/activity_message/player_trigger_incident.h"
+#include "../Sunrise/src/server/activity/mission/mission_script_cinematic.h"
+
+namespace incident = sunrise::middleware::bap::activity_message::incident;
+namespace cinematic = sunrise::middleware::bap::activity_message::cinematic_incident;
+namespace trigger = sunrise::middleware::bap::activity_message::player_trigger_incident;
+namespace bits = sunrise::middleware::encoding::bits;
+namespace resolver = sunrise::server::activity::mission::cinematic;
+namespace catalog = sunrise::state::build_data::scriptables;
+
+static std::vector<std::byte> encode(const incident::Incident& input) {
+    std::vector<std::byte> bytes(incident::kMaximumBodyBytes);
+    bits::Writer writer(bytes);
+    assert(incident::write(writer, input));
+    std::size_t size = 0;
+    assert(writer.finish(size));
+    bytes.resize(size);
+    return bytes;
+}
+
+static void round_trip_fields() {
+    // Extra targets move the selector/payload across every possible byte alignment.
+    for (unsigned extras = 0; extras < 8; ++extras) {
+        for (bool selector : {false, true}) {
+            for (bool optional : {false, true}) {
+                incident::Incident input{};
+                input.primaryTarget = cinematic::kTerminatedTarget;
+                input.extraTargetCount = extras;
+                for (unsigned i = 0; i < extras; ++i)
+                    input.extraTargets[i] = 100 + i;
+                input.hasCompressedSelector = selector;
+                input.selectorLength = selector ? incident::kSelectorMaximum : 0;
+                for (unsigned i = 0; i < input.selectorLength; ++i)
+                    input.selector[i] = static_cast<std::byte>((i * 19 + 7) & 255);
+                input.hasOptionalBlock = optional;
+                input.optionalWordA = optional ? 0xFEDCBA98U : 0;
+                input.optionalWordB = optional ? 0x76543210U : 0;
+                input.payloadLength = incident::kPayloadMaximum;
+                for (unsigned i = 0; i < input.payloadLength; ++i)
+                    input.payload[i] = static_cast<std::byte>((i * 37 + 13) & 255);
+                const auto encoded = encode(input);
+                incident::Incident parsed{};
+                assert(incident::validate(encoded, parsed) == incident::Verdict::accepted);
+                assert(parsed.hasCompressedSelector == selector
+                       && parsed.hasOptionalBlock == optional);
+                assert(parsed.selector == input.selector && parsed.payload == input.payload);
+                assert(parsed.optionalWordA == input.optionalWordA
+                       && parsed.optionalWordB == input.optionalWordB);
+                assert(parsed.hasPayload && parsed.payloadLength == input.payloadLength);
+                assert(encode(parsed) == encoded);
+                for (std::size_t size = 0; size < encoded.size(); ++size)
+                    assert(incident::validate(std::span(encoded).first(size), parsed)
+                           == incident::Verdict::truncated);
+            }
+        }
+    }
+    incident::Incident empty{};
+    empty.primaryTarget = 100;
+    empty.hasCompressedSelector = true; // Presence is distinct from a nonzero length.
+    incident::Incident parsed{};
+    assert(incident::validate(encode(empty), parsed) == incident::Verdict::accepted);
+    assert(parsed.hasCompressedSelector && !parsed.hasPayload && parsed.payloadLength == 0);
+}
+
+static void cinematic_handoff() {
+    constexpr std::uint32_t registry = 0x91EAC345U;
+    constexpr std::uint64_t runtime = 0x123456789ABCDEF0ULL;
+    std::array<std::byte, cinematic::kPayloadBytes> body{};
+    bits::Writer writer(body);
+    for (unsigned i = 0; i < 5; ++i)
+        assert(writer.write(0xABABABABABABABABULL, 64));
+    assert(writer.write(0x1234, 15)); // 335 bits of schema prefix.
+    assert(writer.write(registry, 32));
+    assert(writer.write(6 + 1, 7));
+    assert(writer.write(32768, 16)); // Authored slot zero.
+    assert(writer.write(runtime, 64));
+    assert(writer.write(std::bit_cast<std::uint32_t>(1.0F), 32));
+    std::size_t size = 0;
+    assert(writer.finish(size) && size == body.size());
+    catalog::Snapshot world{};
+    catalog::Object object{};
+    object.registryKey = registry;
+    object.objectTag = 0x81234567U;
+    object.stateRow = 3; // Alternate-state identity must survive the event path.
+    world.objects.push_back(object);
+    catalog::Slot slot{};
+    slot.slotType = 6;
+    world.slots.push_back(slot);
+    for (const auto target : {cinematic::kStartedTarget, cinematic::kTerminatedTarget}) {
+        incident::Incident input{};
+        input.primaryTarget = target;
+        input.payloadLength = body.size();
+        std::copy(body.begin(), body.end(), input.payload.begin());
+        incident::Incident parsed{};
+        assert(incident::validate(encode(input), parsed) == incident::Verdict::accepted);
+        cinematic::Payload movie{};
+        assert(cinematic::decode(std::span(parsed.payload).first(parsed.payloadLength), movie));
+        assert(movie.registryKey == registry && movie.slotType == 6 && movie.slotIndex == 0);
+        assert(movie.runtimeObjectId == runtime && movie.eventValue == 1.0F);
+        resolver::Source source{};
+        assert(resolver::resolve(world, movie, source) == resolver::ResolveStatus::ready);
+        assert(source.registryKey == registry && source.objectTag == object.objectTag);
+        movie.registryKey ^= 1;
+        assert(resolver::resolve(world, movie, source) == resolver::ResolveStatus::absent);
+    }
+}
+
+static void player_trigger_payload() {
+    incident::Incident input{};
+    input.primaryTarget = trigger::kPrimaryTarget;
+    input.payloadLength = trigger::kPayloadBytes;
+    bits::Writer writer(std::span(input.payload).first(input.payloadLength));
+    for (unsigned i = 0; i < 5; ++i)
+        assert(writer.write(0, 64));
+    assert(writer.write(0, 15));
+    assert(writer.write(0x87654321, 32));
+    assert(writer.write(32, 7)); // Type 31 plus bias.
+    assert(writer.write(32768 + 70, 16));
+    assert(writer.write(0x10203040, 32));
+    std::size_t size = 0;
+    assert(writer.finish(size) && size == input.payloadLength);
+    incident::Incident parsed{};
+    assert(incident::validate(encode(input), parsed) == incident::Verdict::accepted);
+    trigger::Payload decoded{};
+    assert(trigger::decode(std::span(parsed.payload).first(parsed.payloadLength), decoded));
+    assert(decoded.registryKey == 0x87654321 && decoded.slotType == 31 && decoded.slotIndex == 70);
+    assert(decoded.resolvedObjectId == 0x10203040);
+}
+
+int main() {
+    round_trip_fields();
+    cinematic_handoff();
+    player_trigger_payload();
+    std::cout << "Incident retention, truncation, cinematic resolution and player-trigger decoding "
+                 "passed\n";
+}