Просмотр исходного кода

Resolve which collectible an incident reports

A collectible picked up in the world arrives as activity message 19, and the
server did nothing with it: the message was validated, logged and relayed, so
picking something up changed no state at all.

The target is a 13-bit index into the SObject definition table, which is now
read during the package pass and held in its own catalog. The target names the
kind of event rather than the thing: the same target arrives for a lore pickup
and a world collectible, so the identity is in the payload.

Type 2's payload decodes against its schema. The common header is a u32
sequence, a 3-bit discriminator with a bias of one, then a 64-bit identity; the
nested block that follows opens with a type-25 tagged union holding the
collectible in its low fifteen bits, the width Collections already uses.

The union's extent was measured, not guessed: the bubble hash sits at bits 126,
158 and 190 as three consecutive u32 fields, and the character SOID at bit 35
exactly where the header schema places it. Verified in game: one collectible
yields the same index across repeated pickups, a different one yields a
different index, and both resolve to real catalog rows.

Decoding is restricted to type 2. Other types select different schemas, and
reading one against this layout produces a plausible index that means nothing.
Millie 2 недель назад
Родитель
Сommit
2e3c523adc

+ 1 - 0
.xwin-cache

@@ -0,0 +1 @@
+/home/millie/Documents/Sunrise-builds/triumph-work-code/.xwin-cache

+ 7 - 0
Sunrise/Sunrise.vcxproj

@@ -822,6 +822,7 @@
     <ClCompile Include="src\middleware\gameplay\group\migration_messages.cpp" />
     <ClCompile Include="src\middleware\gameplay\group\notice_messages.cpp" />
     <ClCompile Include="src\server\gameplay\group\group_migration_receipts.cpp" />
+    <ClCompile Include="src\state\build_data\sobjects\sobject_catalog.cpp" />
   </ItemGroup>
   <ItemGroup Condition="'$(SunriseRunClangTidy)'=='true'">
     <ClCompile Remove="vendor\detours\detours.cpp" />
@@ -1421,6 +1422,12 @@
     <ClInclude Include="src\middleware\gameplay\group\migration_messages.h" />
     <ClInclude Include="src\middleware\gameplay\group\notice_messages.h" />
     <ClInclude Include="src\server\gameplay\group\group_migration_receipts.h" />
+    <ClInclude Include="src\core\settings\settings_upgrade.h" />
+    <ClInclude Include="src\middleware\web_service\messages\opcode1820.h" />
+    <ClInclude Include="src\middleware\web_service\messages\opcode402.h" />
+    <ClInclude Include="src\middleware\web_service\messages\opcode403.h" />
+    <ClInclude Include="src\middleware\web_service\messages\opcode406.h" />
+    <ClInclude Include="src\state\build_data\sobjects\sobject_catalog.h" />
   </ItemGroup>
   <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
 </Project>

+ 37 - 0
Sunrise/src/client/content/items/packages/package_collectible_build.cpp

@@ -1,3 +1,4 @@
+#include "../../../../state/build_data/sobjects/sobject_catalog.h"
 #include <cstring>
 #include <limits>
 
@@ -12,6 +13,42 @@ bool build_collectibles(const reader::Source& source,
                         std::span<const std::byte> root,
                         std::uint64_t itemDefinitionCount) noexcept {
     namespace domain = state::build_data::collectibles;
+
+    // The definition table an incident target names. Read here because this pass already holds an
+    // open source, and because a collectible picked up in the world arrives as an incident: without
+    // this table its target is a bare number.
+    if (state::build_data::sobjects::count() == 0) {
+        namespace sobjects = state::build_data::sobjects;
+        // Geometry is documented and asserted rather than divided out: count at +112, rows at +128,
+        // forty bytes each, name hash at +0, selector group at +32, ordinal at +34, type at +36.
+        constexpr std::size_t kCountOffset = 112;
+        constexpr std::size_t kRowBase = 128;
+        constexpr std::size_t kRowStride = 40;
+        for (const std::uint32_t tag : {0x81327CD4U, 0x80B9E5BFU}) {
+            std::vector<std::byte> blob{};
+            if (!reader::read_tag(source, storage.scratch, tag, blob)
+                || blob.size() < kRowBase) {
+                continue;
+            }
+            std::uint64_t rowCount = 0;
+            std::memcpy(&rowCount, blob.data() + kCountOffset, sizeof rowCount);
+            if (rowCount == 0 || rowCount > sobjects::kDefinitionCapacity
+                || kRowBase + static_cast<std::size_t>(rowCount) * kRowStride > blob.size()) {
+                continue;
+            }
+            std::vector<sobjects::Definition> rows(static_cast<std::size_t>(rowCount));
+            for (std::size_t row = 0; row < rows.size(); ++row) {
+                const std::size_t at = kRowBase + row * kRowStride;
+                std::memcpy(&rows[row].nameHash, blob.data() + at, sizeof(std::uint32_t));
+                std::memcpy(&rows[row].selectorGroup, blob.data() + at + 32, sizeof(std::uint16_t));
+                std::memcpy(&rows[row].nodeOrdinal, blob.data() + at + 34, sizeof(std::uint16_t));
+                std::memcpy(&rows[row].typeCode, blob.data() + at + 36, sizeof(std::int32_t));
+            }
+            (void)sobjects::replace(std::span<const sobjects::Definition>{rows});
+            break;
+        }
+    }
+
     if (state::build_data::collectible_definitions_ready()) {
         return true;
     }

+ 96 - 0
Sunrise/src/server/bap/encrypted/activity_message/receipts/activity_message_receipts.cpp

@@ -4,6 +4,10 @@
  * was read so the caller can record one arrival receipt. None of them acts on what it read.
  */
 
+#include <vector>
+#include <algorithm>
+#include "../../../../../state/build_data/collectibles/collectible_catalog.h"
+#include "../../../../../state/build_data/sobjects/sobject_catalog.h"
 #include "activity_message_receipts.h"
 
 #include <array>
@@ -294,6 +298,98 @@ Framed frame_incident(const message::Request& request) noexcept {
            parsed.selectorLength,
            static_cast<unsigned>(parsed.hasOptionalBlock),
            parsed.payloadLength);
+    // Resolve the target against the definition table it indexes, so an incident says what it is
+    // rather than carrying a bare number. Nothing acts on it yet; this is what acting on it needs.
+    if (accepted) {
+        state::build_data::sobjects::Definition definition{};
+        if (state::build_data::sobjects::find(
+                static_cast<std::uint16_t>(parsed.primaryTarget), definition)) {
+            report(core::log::Level::info,
+                   "ev=activity stage=sobject target=%u hash=0x%08X type=%d group=%u ordinal=%u",
+                   parsed.primaryTarget,
+                   definition.nameHash,
+                   definition.typeCode,
+                   static_cast<unsigned>(definition.selectorGroup),
+                   static_cast<unsigned>(definition.nodeOrdinal));
+        } else {
+            report(core::log::Level::warn,
+                   "ev=activity stage=sobject target=%u result=unresolved rows=%zu",
+                   parsed.primaryTarget,
+                   state::build_data::sobjects::count());
+        }
+
+        // The type payload, which is what the schema describes. The parser separates it from
+        // the envelope; reading the whole message body instead puts every field at an offset the
+        // schema does not predict.
+        const std::span<const std::byte> body{parsed.payload.data(), parsed.payloadLength};
+
+        // The common header every type payload starts with: definition 0x80809512. A u32 sequence,
+        // then a three bit discriminator carrying a bias of one, then a 64 bit identity read only
+        // when that discriminator selects the player branch. Decoding it is the cheapest check that
+        // the payload is being read from the right place: the identity should be a SOID.
+        if (body.size() >= 13) {
+            std::uint64_t cursor = 0;
+            const auto take = [&body, &cursor](std::size_t width) noexcept -> std::uint64_t {
+                std::uint64_t value = 0;
+                for (std::size_t step = 0; step < width; ++step) {
+                    const std::size_t at = cursor + step;
+                    const auto byte = static_cast<std::uint8_t>(body[at / 8]);
+                    value = (value << 1U) | ((byte >> (7 - (at % 8))) & 1U);
+                }
+                cursor += width;
+                return value;
+            };
+            const std::uint64_t sequence = take(32);
+            const std::uint64_t kindRaw = take(3);
+            const std::uint64_t identity = take(64);
+            // The nested block follows the header. Its first field is a type-25 tagged union
+            // occupying bits 99 to 122, and the collectible sits in its low fifteen bits -- the same
+            // width Collections uses for a native row index. The bound was not guessed: the bubble
+            // hash appears at bits 126, 158 and 190, three consecutive u32 fields of the nested
+            // block, which fixes where the union has to end, and the character SOID lands at bit 35
+            // exactly where the header schema puts it.
+            // Only type 2 has this layout. Every other type code selects a different schema, and
+            // decoding one of those against this one produces a plausible looking index that means
+            // nothing: target 1121 is type 13 and yielded 21830 that way.
+            constexpr std::int32_t kPickupTypeCode = 2;
+            state::build_data::sobjects::Definition row{};
+            const bool pickupType =
+                state::build_data::sobjects::find(static_cast<std::uint16_t>(parsed.primaryTarget),
+                                                  row)
+                && row.typeCode == kPickupTypeCode;
+
+            constexpr std::size_t kUnionBits = 24;
+            constexpr std::size_t kIndexBits = 15;
+            std::uint32_t collectibleIndex = 0;
+            if (pickupType && body.size() * 8 >= 99 + kUnionBits) {
+                const std::uint64_t unionField = take(kUnionBits);
+                collectibleIndex =
+                    static_cast<std::uint32_t>(unionField & ((1U << kIndexBits) - 1U));
+                state::build_data::collectibles::Definition collectible{};
+                if (state::build_data::collectibles::find(
+                        static_cast<std::uint16_t>(collectibleIndex), collectible)) {
+                    report(core::log::Level::info,
+                           "ev=activity stage=collectible index=%u hash=0x%08X item=%u",
+                           collectibleIndex,
+                           collectible.collectibleHash,
+                           static_cast<unsigned>(collectible.itemDefinitionIndex));
+                } else {
+                    report(core::log::Level::warn,
+                           "ev=activity stage=collectible index=%u result=unknown rows=%zu",
+                           collectibleIndex, state::build_data::collectibles::count());
+                }
+            }
+
+            report(core::log::Level::info,
+                   "ev=activity stage=header target=%u sequence=%llu kind_raw=%llu "
+                   "identity=0x%016llX",
+                   parsed.primaryTarget,
+                   static_cast<unsigned long long>(sequence),
+                   static_cast<unsigned long long>(kindRaw),
+                   static_cast<unsigned long long>(identity));
+        }
+    }
+
     if (!accepted) {
         // A refused target index would index the consumer's table unbounded, so the body is kept
         // and never relayed.

+ 56 - 0
Sunrise/src/state/build_data/sobjects/sobject_catalog.cpp

@@ -0,0 +1,56 @@
+#include "sobject_catalog.h"
+
+#include "../table.h"
+
+namespace sunrise::state::build_data::sobjects {
+namespace {
+
+Lock g_lock;
+Table<Definition, kDefinitionCapacity> g_definitions;
+
+} // namespace
+
+/** Clears the table while no reader can observe a partial replacement. */
+void clear() noexcept {
+    const Lock::Exclusive guard(g_lock);
+    g_definitions.clear();
+}
+
+/**
+ * Checks the rows are dense and fit.
+ *
+ * The table has no index column: a row's position is its identity, which is what makes the wire's
+ * target index meaningful. So there is nothing to cross-check a row against, and the only thing
+ * worth asserting is the shape.
+ */
+bool valid(std::span<const Definition> definitions) noexcept {
+    return !definitions.empty() && definitions.size() <= kDefinitionCapacity;
+}
+
+/** Replaces the whole table in one step. */
+bool replace(std::span<const Definition> definitions) noexcept {
+    if (!valid(definitions)) {
+        return false;
+    }
+    const Lock::Exclusive guard(g_lock);
+    return g_definitions.replace(definitions);
+}
+
+/** Finds one row by the target index an incident carries. */
+bool find(std::uint16_t targetIndex, Definition& definition) noexcept {
+    const Lock::Shared guard(g_lock);
+    const std::span<const Definition> rows = g_definitions.rows();
+    if (targetIndex >= rows.size()) {
+        return false;
+    }
+    definition = rows[targetIndex];
+    return true;
+}
+
+/** @return Number of installed rows, read under the lock. */
+std::size_t count() noexcept {
+    const Lock::Shared guard(g_lock);
+    return g_definitions.count();
+}
+
+} // namespace sunrise::state::build_data::sobjects

+ 58 - 0
Sunrise/src/state/build_data/sobjects/sobject_catalog.h

@@ -0,0 +1,58 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+namespace sunrise::state::build_data::sobjects {
+
+/**
+ * The definition table an incident target names.
+ *
+ * An incident is the client's one general typed gameplay event, and its target is a 13-bit index
+ * into this table. Nothing acts on an incident today, so a collectible picked up in the world tells
+ * the server nothing. Resolving the target is the first step of acting on one.
+ *
+ * The table is shipped package data. Its class is `0x80807C9B` and two installed tags carry it with
+ * byte-identical hash and type-code columns, so a row index means the same thing against either.
+ */
+
+/** The wire target is thirteen bits, and 7763 rows install. */
+inline constexpr std::size_t kDefinitionCapacity = 8192;
+
+/** A row carrying this type code has no handler and cannot produce a schema. */
+inline constexpr std::int32_t kAbsentTypeCode = -1;
+
+/** One row of the table, reduced to what resolving a target needs. */
+struct Definition {
+    /** FNV-1 of the definition name. */
+    std::uint32_t nameHash{};
+    /** Selects the payload shape. Runs -1 to 48. */
+    std::int32_t typeCode{kAbsentTypeCode};
+    /** Compressed-selector group, or 0xFFFF for none. */
+    std::uint16_t selectorGroup{};
+    /** Position in that group's node table. */
+    std::uint16_t nodeOrdinal{};
+};
+
+/** Clears every generated row. */
+void clear() noexcept;
+
+/** @return True when the rows are dense and within capacity. */
+[[nodiscard]] bool valid(std::span<const Definition> definitions) noexcept;
+
+/** Replaces the whole table in one step. */
+[[nodiscard]] bool replace(std::span<const Definition> definitions) noexcept;
+
+/**
+ * Finds one row by the target index an incident carries.
+ * @param targetIndex Index from the wire, already range-checked by the incident validator.
+ * @param definition Receives the row.
+ * @return True when the index is in range.
+ */
+[[nodiscard]] bool find(std::uint16_t targetIndex, Definition& definition) noexcept;
+
+/** @return Number of installed rows. */
+[[nodiscard]] std::size_t count() noexcept;
+
+} // namespace sunrise::state::build_data::sobjects