Bladeren bron

Close the arrival window for sibling states; report unresolved type-23 links

Ending. The seed lease for the ending state never published, so every scene
lease on it reported mission_seed_pending and the queued set_cinematic_active
expired after sixty seconds. The cause is the region-arrival window: the roster
refuses to commit a published revision while regionArrivalPending is set
(`hasMissionSeedRevision` requires it clear), and the window only closed when the
client reported holding the pending region. A sibling state never produces that
report -- its content is in the slice set the client already holds, and the
current region leg advances only on a slice-set switch -- so the window stayed
open forever. This also explains why the selection itself completed instantly
rather than waiting: its gate is skipped entirely while the window is open, so
nothing else noticed the lease was stuck.

The window now also closes when the pending region is a sibling of the slice set
the client already holds, which is the same reasoning that removed the bogus
teleport for those states. Extracted as mission_seed_arrival_window_closed and
covered by the portable regression, including that a real slice-set change still
holds the window open so a publication cannot race a teardown.

Beam. The apex ring devices resolve to no exact placement, which is why they are
never drawn and why driving them does nothing. Nothing surfaced that: the browser
shows such a row as "context only" whenever its owning object has context
positions, hiding the failed join behind the object's own count. The builder
already counts every way the identifier match can fail, so the link pass now
reports its counters and names each unresolved type-23 row with its identity
match, candidate and active counts. That says whether the identifier was unread,
matched nothing, or matched ambiguously -- which decides whether this is a
recoverable generator defect or genuinely unplaced authored content.

22 portable tests and five Lua suites pass.
Millie 3 dagen geleden
bovenliggende
commit
6500f1d60c

+ 59 - 0
Sunrise/src/client/content/activity/scriptable_catalog_type23_placement_links.cpp

@@ -6,8 +6,11 @@
 #include <limits>
 #include <span>
 #include <utility>
+#include <array>
+#include <cstdio>
 #include <vector>
 
+#include "../../../core/logging/log.h"
 #include "../../../middleware/content/packages/tables/type23_placement_identifier_reader.h"
 #include "../../../state/build_data/scriptables/scriptable_catalog.h"
 
@@ -113,6 +116,61 @@ descriptor_object(const catalog::Snapshot& source, const catalog::Descriptor& de
     return count;
 }
 
+/**
+ * Records every type-23 device whose placement link did not resolve to one exact placement.
+ *
+ * A device that resolves to no exact placement is never drawn and has nothing in the world to
+ * drive, so its Auth is accepted and does nothing. Nothing surfaced that before: the browser
+ * shows such a row as "context only" whenever its owning object has context positions, which
+ * hides the failed join behind the object's own count. The builder already counts every way the
+ * match can fail, so this reports the counters once and then names the unresolved rows.
+ */
+void report_unresolved_links(const catalog::Snapshot& output) noexcept {
+    const auto& diagnostics = output.type23PlacementDiagnostics;
+    std::array<char, 224> line{};
+    int written = std::snprintf(
+        line.data(),
+        line.size(),
+        "ev=type23_placement stage=summary links=%zu complete=%d unread=%llu zero_identity=%llu "
+        "multi_identity=%llu zero_active=%llu multi_active=%llu scenario_resolved=%llu",
+        output.type23PlacementLinks.size(),
+        diagnostics.complete ? 1 : 0,
+        static_cast<unsigned long long>(diagnostics.unreadIdentifiers),
+        static_cast<unsigned long long>(diagnostics.zeroIdentityMatches),
+        static_cast<unsigned long long>(diagnostics.multipleIdentityMatches),
+        static_cast<unsigned long long>(diagnostics.zeroActiveCandidates),
+        static_cast<unsigned long long>(diagnostics.multipleActiveCandidates),
+        static_cast<unsigned long long>(diagnostics.scenarioResolvedCandidates));
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::info,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+    std::size_t reported = 0;
+    for (const catalog::Type23PlacementLink& link : output.type23PlacementLinks) {
+        if (link.join == catalog::ReferenceJoin::exact || reported >= 32) {
+            continue;
+        }
+        ++reported;
+        written = std::snprintf(line.data(),
+                                line.size(),
+                                "ev=type23_placement stage=unresolved slot=%u identifier=%llu "
+                                "identity_matches=%u candidates=%u active=%u complete=%d join=%d",
+                                link.slotRow,
+                                static_cast<unsigned long long>(link.placementIdentifier),
+                                link.identityMatchCount,
+                                link.candidateCount,
+                                link.activeCandidateCount,
+                                link.complete ? 1 : 0,
+                                static_cast<int>(link.join));
+        if (written > 0) {
+            core::log::write(core::log::Channel::client,
+                             core::log::Level::info,
+                             {line.data(), static_cast<std::size_t>(written)});
+        }
+    }
+}
+
 /** Appends one descriptor link and every retained exact identifier match. */
 [[nodiscard]] bool append_link(catalog::Snapshot& output,
                                std::uint32_t descriptorRow,
@@ -282,6 +340,7 @@ bool append_type23_placement_links(catalog::Snapshot& output,
                 return false;
             }
         }
+        report_unresolved_links(output);
         return !cancelled(cancel);
     } catch (...) {
         output.type23PlacementDiagnostics.complete = false;

+ 11 - 3
Sunrise/src/server/bap/encrypted/push/activity/activity_mission_seed_roster.cpp

@@ -370,9 +370,17 @@ 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);
-    const bool pendingRegionHeld =
-        heldRegion >= 0 && static_cast<std::uint32_t>(heldRegion) == lease.plan.effectiveRegion;
-    if (!adopting && lease.regionArrivalPending && pendingRegionHeld) {
+    // 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.
+    if (!adopting && lease.regionArrivalPending
+        && mission_seed_arrival_window_closed(
+            heldRegion,
+            lease.plan.effectiveRegion,
+            lease.plan.sliceSetIndex,
+            middleware::content::packages::tables::kSliceSetIndexFactor)) {
         lease.regionArrivalPending = false;
     }
     const bool arrivalWindow = !adopting && lease.regionArrivalPending;

+ 30 - 0
Sunrise/src/server/bap/encrypted/push/activity/mission_seed_world_change.h

@@ -33,4 +33,34 @@ mission_seed_region_change_replaces_world(std::uint32_t currentSliceSetIndex,
            && currentSliceSetIndex != selectedSliceSetIndex;
 }
 
+/**
+ * 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;
+}
+
 } // namespace sunrise::server::bap::encrypted::push::activity

+ 30 - 0
tests/mission_seed_world_change_test.cpp

@@ -76,6 +76,34 @@ void decision_is_available_at_compile_time() {
     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() {
@@ -83,5 +111,7 @@ int main() {
     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;
 }