diff --git a/Sunrise/Sunrise.vcxproj b/Sunrise/Sunrise.vcxproj
index cb8252684..ba29585aa 100644
--- a/Sunrise/Sunrise.vcxproj
+++ b/Sunrise/Sunrise.vcxproj
@@ -99,6 +99,11 @@
+
+
+
+
+
@@ -205,6 +210,8 @@
+
+
@@ -720,9 +727,12 @@
+
-
+
+
+
@@ -1339,6 +1349,11 @@
+
+
+
+
+
@@ -1626,7 +1641,10 @@
-
+
+
+
+
diff --git a/Sunrise/resources/database/investment_schema.sql b/Sunrise/resources/database/investment_schema.sql
index 662d673fd..a96d6c495 100644
--- a/Sunrise/resources/database/investment_schema.sql
+++ b/Sunrise/resources/database/investment_schema.sql
@@ -1,5 +1,5 @@
PRAGMA application_id = 1397902921;
-PRAGMA user_version = 2;
+PRAGMA user_version = 3;
CREATE TABLE account (
id INTEGER PRIMARY KEY CHECK (id = 1),
@@ -99,6 +99,13 @@ CREATE TABLE family5 (
UNIQUE (kind, slot)
) STRICT;
+CREATE TABLE character_objective_values (
+ character_soid INTEGER NOT NULL REFERENCES characters(soid) DEFERRABLE INITIALLY DEFERRED,
+ slot INTEGER NOT NULL CHECK (slot BETWEEN 0 AND 32767),
+ value INTEGER NOT NULL CHECK (value BETWEEN 0 AND 2147483647),
+ PRIMARY KEY (character_soid, slot)
+) STRICT;
+
CREATE TABLE entitlements (
position INTEGER PRIMARY KEY,
name TEXT NOT NULL,
diff --git a/Sunrise/resources/database/vendor_reputation_content.sql b/Sunrise/resources/database/vendor_reputation_content.sql
new file mode 100644
index 000000000..f344f3d70
--- /dev/null
+++ b/Sunrise/resources/database/vendor_reputation_content.sql
@@ -0,0 +1,68 @@
+-- Build 86657.20.08.23.1800-9: authored Vendor itemList and Faction tokenValues.
+-- Faction/Progression component: 83e9ee00-d5d1-453c-b454-ea08c1acefb4.
+-- Decompressed Vendor SHA256: 649c78fc84b97070c7e859d6d587f162d7264a21f403d319de5a087d6ccc05c1.
+-- Decompressed Faction SHA256: 8f6a3ec405b8c891935764e4a3c27a202029207f9d951c2054e6ae135bf2288e.
+-- Static build data is memory-only, not player state; vendor sales and faction links are rechecked.
+
+-- Sale/category indexes are vendor-local. Hash columns name manifest definitions.
+-- Each transaction awards cost_quantity * xp_per_unit; the rate is per charged item.
+CREATE TABLE reputation_sales (
+ vendor_hash INTEGER NOT NULL,
+ sale_index INTEGER NOT NULL,
+ faction_hash INTEGER NOT NULL,
+ placeholder_hash INTEGER NOT NULL,
+ cost_hash INTEGER NOT NULL,
+ category_index INTEGER NOT NULL,
+ cost_quantity INTEGER NOT NULL,
+ xp_per_unit INTEGER NOT NULL,
+ PRIMARY KEY (vendor_hash, sale_index)
+) WITHOUT ROWID;
+
+-- Zavala (69482069), Vanguard Tactical faction (611314723), turn-in category 2.
+-- Vanguard Tactician Rewards placeholder (3987308529), Vanguard Tactician Token (3899548068).
+INSERT INTO reputation_sales
+ (vendor_hash, sale_index, faction_hash, placeholder_hash, cost_hash,
+ category_index, cost_quantity, xp_per_unit) VALUES
+ (69482069, 2, 611314723, 3987308529, 3899548068, 2, 1, 100),
+ (69482069, 3, 611314723, 3987308529, 3899548068, 2, 5, 100),
+ (69482069, 4, 611314723, 3987308529, 3899548068, 2, 10, 100);
+
+-- Devrim Kay (396892126), EDZ faction (4235119312), turn-in category 4.
+-- EDZ Rewards placeholder (61430328), EDZ Token (2640973641).
+INSERT INTO reputation_sales
+ (vendor_hash, sale_index, faction_hash, placeholder_hash, cost_hash,
+ category_index, cost_quantity, xp_per_unit) VALUES
+ (396892126, 32, 4235119312, 61430328, 2640973641, 4, 1, 100),
+ (396892126, 33, 4235119312, 61430328, 2640973641, 4, 5, 100),
+ (396892126, 34, 4235119312, 61430328, 2640973641, 4, 10, 100),
+ -- Destination-material placeholder (1317670974), Dusklight Shard (950899352).
+ (396892126, 35, 4235119312, 1317670974, 950899352, 4, 1, 50),
+ (396892126, 36, 4235119312, 1317670974, 950899352, 4, 5, 50),
+ (396892126, 37, 4235119312, 1317670974, 950899352, 4, 10, 50),
+ -- The same placeholder accepts Dusklight Crystal (478751073) at a separate XP rate.
+ (396892126, 38, 4235119312, 1317670974, 478751073, 4, 1, 250),
+ (396892126, 39, 4235119312, 1317670974, 478751073, 4, 5, 250),
+ (396892126, 40, 4235119312, 1317670974, 478751073, 4, 10, 250);
+
+-- Banshee-44 (672118013), Gunsmith faction (1021210278), turn-in category 7.
+-- Gunsmith Rewards placeholder (3831705402), Gunsmith Materials (685157383).
+INSERT INTO reputation_sales
+ (vendor_hash, sale_index, faction_hash, placeholder_hash, cost_hash,
+ category_index, cost_quantity, xp_per_unit) VALUES
+ (672118013, 2, 1021210278, 3831705402, 685157383, 7, 1, 30),
+ (672118013, 3, 1021210278, 3831705402, 685157383, 7, 5, 30),
+ (672118013, 4, 1021210278, 3831705402, 685157383, 7, 10, 30),
+ (672118013, 5, 1021210278, 3831705402, 685157383, 7, 25, 30),
+ -- The same placeholder accepts Weapon Telemetry (685157381) at a separate XP rate.
+ (672118013, 6, 1021210278, 3831705402, 685157381, 7, 1, 25),
+ (672118013, 7, 1021210278, 3831705402, 685157381, 7, 5, 25),
+ (672118013, 8, 1021210278, 3831705402, 685157381, 7, 10, 25);
+
+-- Shaxx (3603221665), Crucible faction (697030790), turn-in category 8.
+-- Crucible Rewards placeholder (265113466), Crucible Token (183980811).
+INSERT INTO reputation_sales
+ (vendor_hash, sale_index, faction_hash, placeholder_hash, cost_hash,
+ category_index, cost_quantity, xp_per_unit) VALUES
+ (3603221665, 2, 697030790, 265113466, 183980811, 8, 1, 100),
+ (3603221665, 3, 697030790, 265113466, 183980811, 8, 5, 100),
+ (3603221665, 4, 697030790, 265113466, 183980811, 8, 10, 100);
diff --git a/Sunrise/resources/resource.h b/Sunrise/resources/resource.h
index 52d392ba3..06370051a 100644
--- a/Sunrise/resources/resource.h
+++ b/Sunrise/resources/resource.h
@@ -47,3 +47,5 @@
/** Account preferences have their own schema and seed data in the save. */
#define IDR_ACCOUNT_SETTINGS_SCHEMA 109
#define IDR_ACCOUNT_SETTINGS_DEFAULTS 110
+/** Build-scoped vendor turn-in definitions are static content, not player-save rows. */
+#define IDR_VENDOR_REPUTATION_CONTENT 111
diff --git a/Sunrise/resources/sunrise.rc b/Sunrise/resources/sunrise.rc
index 7becac890..3ea22d6b1 100644
--- a/Sunrise/resources/sunrise.rc
+++ b/Sunrise/resources/sunrise.rc
@@ -61,3 +61,4 @@ IDR_SQLITE_NOTICE RCDATA "../vendor/sqlite/NOTICE.txt"
IDR_ACCOUNT_SETTINGS_SCHEMA RCDATA "database/account_settings_schema.sql"
IDR_ACCOUNT_SETTINGS_DEFAULTS RCDATA "database/account_settings_defaults.sql"
+IDR_VENDOR_REPUTATION_CONTENT RCDATA "database/vendor_reputation_content.sql"
diff --git a/Sunrise/src/client/content/items/packages/internal.h b/Sunrise/src/client/content/items/packages/internal.h
index 2a9b858be..2c30be2d9 100644
--- a/Sunrise/src/client/content/items/packages/internal.h
+++ b/Sunrise/src/client/content/items/packages/internal.h
@@ -24,6 +24,7 @@
#include "../../../../state/build_data/runtime.h"
#include "../../../../state/build_data/season_pass/definition.h"
#include "../../../../state/build_data/sobjects/sobject_catalog.h"
+#include "package_reward_build.h"
namespace sunrise::client::content::items::packages {
@@ -70,9 +71,10 @@ static_assert(kUnmappedSlot
/** Bank index per unlock slot, indexed by slot. The first mapping row of a slot wins. */
using SlotMap = std::array;
-/** The four maps one root's two unlock mapping tables carry. 256 KiB together. */
+/** Saved-bank indices resolved from the root's flag and value mapping tables. */
struct SlotMaps {
SlotMap accountFlag{};
+ SlotMap profileFlag{};
SlotMap characterFlag{};
SlotMap accountValue{};
SlotMap characterValue{};
@@ -83,6 +85,8 @@ struct Storage {
reader::Scratch scratch{};
/** Read once per root. Every domain resolves its unlock slots through these. */
SlotMaps slotMaps{};
+ /** Reward pools, wrappers and condition tables read alongside the item walk. */
+ RewardBuild rewardBuild{};
/** Node rows held until the value slot and owned records are resolved. */
std::array
nodeRows{};
@@ -206,6 +210,9 @@ struct Storage {
/** @return True when the catalyst catalog is published or cannot exist on this executable. */
[[nodiscard]] bool exotic_catalysts_settled() noexcept;
+/** @return True when the reward graph is published or can never be. */
+[[nodiscard]] bool reward_definitions_settled() noexcept;
+
/** Adds one native definition index to the deduplicated request set. */
void request(std::uint16_t definitionIndex, DetailRequests& requested) noexcept;
@@ -323,7 +330,7 @@ read_investment_constants(const reader::Source& source,
/**
* Reads one root's two unlock mapping tables into the pass slot maps.
* @param source Package source.
- * @param storage Pass storage receiving the four maps.
+ * @param storage Pass storage receiving the five maps.
* @param root Investment root bytes.
* @return True when both account maps read. A character map may stay unmapped.
*/
diff --git a/Sunrise/src/client/content/items/packages/package_item_build.cpp b/Sunrise/src/client/content/items/packages/package_item_build.cpp
index e039beb82..2fe54603f 100644
--- a/Sunrise/src/client/content/items/packages/package_item_build.cpp
+++ b/Sunrise/src/client/content/items/packages/package_item_build.cpp
@@ -9,6 +9,7 @@
#include "../../../../middleware/content/packages/tables/definition_index_table.h"
#include "../../../../state/build_data/activities/activity_catalog.h"
#include "../../../../state/build_data/runtime.h"
+#include "../../../../state/build_data/vendors/vendor_gate_catalog.h"
#include "../../activity/activity_catalog_build.h"
#include "../../activity/entity_position_profile_build.h"
#include "../../hash_names/hash_name_build.h"
@@ -17,6 +18,7 @@
#include "../../vendors/vendor_build.h"
#include "build.h"
#include "internal.h"
+#include "package_reward_build.h"
#include "package_socket_plug_build.h"
namespace sunrise::client::content::items::packages {
@@ -34,7 +36,7 @@ namespace {
&& state::build_data::ability_buckets_ready()
&& state::build_data::socket_entry_buckets_ready()
&& state::build_data::progression_definitions_ready()
- && state::build_data::season_pass_ready()
+ && state::build_data::season_pass_ready() && reward_definitions_settled()
&& state::build_data::repeatable_bounties_ready()
&& state::build_data::record_definitions_ready()
&& state::build_data::node_definitions_ready()
@@ -48,7 +50,7 @@ namespace {
bool ready() noexcept {
return root_domains_ready() && state::build_data::scenario_layouts_ready()
&& state::build_data::spawn_sets_ready() && state::build_data::hash_names_ready()
- && state::build_data::vendor_catalog_ready()
+ && state::build_data::vendor_catalog_ready() && state::build_data::vendors::gates_ready()
&& content::activity::entity_position_profiles::ready()
&& (state::build_data::activities::ready()
|| state::build_data::activities::extraction_failed());
@@ -89,7 +91,7 @@ bool build() noexcept {
return true;
}
}
- if (root_domains_ready()) {
+ if (root_domains_ready() && state::build_data::vendors::gates_ready()) {
SecureZeroMemory(&keys, sizeof keys);
return ready();
}
@@ -126,7 +128,8 @@ bool build() noexcept {
if (!state::build_data::item_definitions_ready()
|| !state::build_data::record_definitions_ready()
|| !state::build_data::node_definitions_ready()
- || !state::build_data::season_pass_ready() || !exotic_catalysts_settled()) {
+ || !state::build_data::season_pass_ready() || !reward_definitions_settled()
+ || !exotic_catalysts_settled() || !state::build_data::vendors::gates_ready()) {
reason = "unlock_maps";
if (!read_unlock_slot_maps(
source, storage, std::span{storage.root})) {
@@ -184,11 +187,19 @@ bool build() noexcept {
std::span(storage.progressionSteps).first(storage.progressionStepCount));
}
}
- if (!state::build_data::season_pass_ready()
- && build_season_pass(source, storage, std::span{storage.root})) {
- (void)state::build_data::publish_season_pass(
+ // Pass rows bind through the reward condition tables, which load before the pools.
+ if (!reward_definitions_settled() || !state::build_data::season_pass_ready()) {
+ (void)storage.rewardBuild.load(
+ source, storage.scratch, storage.root, storage.slotMaps);
+ }
+ if (!state::build_data::season_pass_ready() && storage.rewardBuild.conditions.loaded()
+ && build_season_pass(source, storage, std::span{storage.root})
+ && !state::build_data::publish_season_pass(
std::span(storage.seasonPassRewards).first(storage.seasonPassRewardCount),
- std::span(storage.seasonPassPackages).first(storage.seasonPassPackageCount));
+ std::span(storage.seasonPassPackages).first(storage.seasonPassPackageCount))) {
+ core::log::write(core::log::Channel::client,
+ core::log::Level::warn,
+ "ev=pkg stage=season_pass result=fail reason=publish");
}
// Records are read before nodes: a node's lore-book flag and its parent bar come
// from the records it owns, so the record rows must already be in pass storage.
@@ -232,7 +243,9 @@ bool build() noexcept {
table)
&& table.elementClass == tables::kItemIndexTableClass;
}
- if (located && build_item_rows(source, storage, table, rowCount, reason)) {
+ const bool rowsBuilt = located && !root_domains_ready()
+ && build_item_rows(source, storage, table, rowCount, reason);
+ if (rowsBuilt) {
if (!build_material_requirements(
source, storage, std::span{storage.root}, table.count)) {
reason = "materials";
@@ -247,6 +260,15 @@ bool build() noexcept {
std::span(storage.bountyRows).first(storage.bountyCount));
}
}
+ if (root_domains_ready() && !state::build_data::vendors::gates_ready()) {
+ reason = "vendor_gates";
+ const content::vendors::GateMaps maps{storage.slotMaps.accountFlag,
+ storage.slotMaps.profileFlag,
+ storage.slotMaps.characterFlag,
+ storage.slotMaps.accountValue,
+ storage.slotMaps.characterValue};
+ (void)content::vendors::build_gates(source, storage.scratch, maps);
+ }
}
SecureZeroMemory(&keys, sizeof keys);
const bool complete = ready();
diff --git a/Sunrise/src/client/content/items/packages/package_item_rows.cpp b/Sunrise/src/client/content/items/packages/package_item_rows.cpp
index 1ffa18f8c..4c8db5eb0 100644
--- a/Sunrise/src/client/content/items/packages/package_item_rows.cpp
+++ b/Sunrise/src/client/content/items/packages/package_item_rows.cpp
@@ -54,13 +54,16 @@ bool exotic_catalysts_settled() noexcept {
* @param table Located item index array within storage.child.
* @param rowCount Starts at zero; receives the rows retained even if publication fails.
* @param reason Receives the last stage reached or its failure reason.
- * @return True when this pass's required item domains are ready; failure may retain prior results.
+ * @return True when required non-reward item domains are ready. Failure may retain prior results.
*/
bool build_item_rows(const reader::Source& source,
Storage& storage,
const tables::Array& table,
std::size_t& rowCount,
const char*& reason) noexcept {
+ const bool needRewards = !reward_definitions_settled();
+ const bool rewardStorageReady =
+ needRewards && storage.rewardBuild.begin_items(static_cast(table.count));
const bool needDefinitions = !state::build_data::item_definitions_ready();
const bool needDetails = !state::build_data::configured_item_details_ready();
const bool needSocketPlugs = !state::build_data::socket_plug_rules_ready();
@@ -71,7 +74,7 @@ bool build_item_rows(const reader::Source& source,
const bool needDetailRows = needDetails || needSocketRows;
// Bucket equipment slots are derived from this same complete item walk, so a partial retry
// must still revisit the table even when definitions and detail domains already published.
- const bool needRows = needDefinitions || needDetailRows || needBuckets;
+ const bool needRows = needDefinitions || needDetailRows || needBuckets || rewardStorageReady;
bool published = !needRows;
if (retainDetails && storage.details.size() != kDetailCapacity) {
storage.details.assign(kDetailCapacity, build_details::Definition{});
@@ -107,11 +110,23 @@ bool build_item_rows(const reader::Source& source,
item.definitionHash = row.definitionHash;
item.definitionIndex = static_cast(index);
std::uint32_t itemClass = 0;
- if (!reader::read_tag(source, storage.scratch, row.targetTag, storage.definition, itemClass)
- || !tables::items::read_definition(std::span{storage.definition},
- item)) {
+ if (!reader::read_tag(
+ source, storage.scratch, row.targetTag, storage.definition, itemClass)) {
+ // The published catalog read this row before, so its reward waits for a retry; a row
+ // the catalog lacks stays pruned from both.
+ state::build_data::items::Definition cataloged{};
+ if (rewardStorageReady
+ && state::build_data::find_item_definition_index(item.definitionIndex, cataloged)) {
+ storage.rewardBuild.item_unread(item.definitionIndex);
+ }
+ continue;
+ }
+ if (!tables::items::read_definition(std::span{storage.definition}, item)) {
continue;
}
+ if (rewardStorageReady && itemClass == tables::kItemDefinitionClass) {
+ storage.rewardBuild.item(item.definitionIndex, item.definitionHash, storage.definition);
+ }
const std::uint32_t plugCategoryHash =
corrected_plug_category(item.definitionHash, item.plugCategoryHash);
build_items::QuestInitialization quest{};
@@ -175,6 +190,13 @@ bool build_item_rows(const reader::Source& source,
published =
state::build_data::publish_item_definitions(std::span(storage.rows).first(rowCount));
}
+ // A reward refusal is reported by RewardBuild and does not block the other item domains.
+ // Rewards publish only beside the item catalog and at its row count, so both omit the same
+ // unread rows.
+ const bool rewardsPublished =
+ !needRewards
+ || (rewardStorageReady && state::build_data::item_definitions_ready()
+ && storage.rewardBuild.publish(state::build_data::item_definition_count()));
if (!published) {
reason = !detailStorageReady ? "detail_storage"
: !requestsFit ? "detail_capacity"
@@ -301,6 +323,10 @@ bool build_item_rows(const reader::Source& source,
report_ability_count(abilityCount);
}
}
+ // Named last; later stages overwrite the reason as they start.
+ if (published && !rewardsPublished) {
+ reason = "rewards";
+ }
return published && state::build_data::item_definitions_ready()
&& state::build_data::configured_item_details_ready()
&& state::build_data::socket_plug_rules_ready() && exotic_catalysts_settled()
diff --git a/Sunrise/src/client/content/items/packages/package_node_build.cpp b/Sunrise/src/client/content/items/packages/package_node_build.cpp
index a629db783..1584d0b39 100644
--- a/Sunrise/src/client/content/items/packages/package_node_build.cpp
+++ b/Sunrise/src/client/content/items/packages/package_node_build.cpp
@@ -30,9 +30,10 @@ void clear_slot_map(SlotMap& output) noexcept {
output.fill(kUnmappedSlot);
}
-/** Clears all four so an unread table cannot leave a prior pass's indexes addressable. */
+/** An unread table must not leave a prior pass's indices addressable. */
void clear_slot_maps(SlotMaps& maps) noexcept {
clear_slot_map(maps.accountFlag);
+ clear_slot_map(maps.profileFlag);
clear_slot_map(maps.characterFlag);
clear_slot_map(maps.accountValue);
clear_slot_map(maps.characterValue);
@@ -113,7 +114,7 @@ read_slot_map(std::span blob, std::size_t descriptor, SlotMap&
/**
* A gate names a slot; its saved bank index is the mapping row that names that slot.
* @param source Borrowed package source.
- * @param storage Receives four slot maps and retained value-map bytes; may be partial on failure.
+ * @param storage Receives slot maps and retained value-map bytes; may be partial on failure.
* @param root Investment root bytes naming the flag and value mapping tables.
* @return True when both account maps read; a character map may remain unmapped.
*/
@@ -131,6 +132,7 @@ bool read_unlock_slot_maps(const reader::Source& source,
const std::span flagMap{storage.child};
const bool flagMapRead =
read_slot_map(flagMap, tables::kAccountFlagMapDescriptor, maps.accountFlag);
+ (void)read_slot_map(flagMap, tables::kProfileFlagMapDescriptor, maps.profileFlag);
(void)read_slot_map(flagMap, tables::kCharacterFlagMapDescriptor, maps.characterFlag);
std::uint32_t valueMapTag = 0;
diff --git a/Sunrise/src/client/content/items/packages/package_progression_build.cpp b/Sunrise/src/client/content/items/packages/package_progression_build.cpp
index 20cb3476f..5c03588a4 100644
--- a/Sunrise/src/client/content/items/packages/package_progression_build.cpp
+++ b/Sunrise/src/client/content/items/packages/package_progression_build.cpp
@@ -36,15 +36,24 @@ bool build_progressions(const reader::Source& source,
}
const auto scope =
std::to_integer(table[at + tables::kProgressionScopeOffset]);
+ const auto repeatLastStep =
+ std::to_integer(table[at + tables::kProgressionRepeatLastStepOffset]);
+ if (repeatLastStep > 1) {
+ count = 0;
+ stepCount = 0;
+ return false;
+ }
domain::Definition& definition = output[count];
// Any scope beyond the two replicated objects belongs to an object this server does not
// push, so it claims no slot in either array.
+ const auto resolvedScope = scope <= static_cast(domain::Scope::character)
+ ? static_cast(scope)
+ : domain::Scope::unreplicated;
definition = {static_cast(row),
static_cast(stepCount),
0,
- scope <= static_cast(domain::Scope::character)
- ? static_cast(scope)
- : domain::Scope::unreplicated};
+ resolvedScope,
+ repeatLastStep != 0};
tables::Array ladder{};
if (!tables::find_optional_array_at(table, at + tables::kProgressionStepField, ladder)) {
count = 0;
diff --git a/Sunrise/src/client/content/items/packages/package_reward_build.cpp b/Sunrise/src/client/content/items/packages/package_reward_build.cpp
new file mode 100644
index 000000000..3d71ca326
--- /dev/null
+++ b/Sunrise/src/client/content/items/packages/package_reward_build.cpp
@@ -0,0 +1,811 @@
+#include "package_reward_build.h"
+
+#include
+#include
+#include
+
+#include "../../../../core/logging/log.h"
+#include "../../../../middleware/content/packages/tables/definition_index_table.h"
+#include "../../../../middleware/content/packages/tables/field_reader.h"
+#include "../../../../state/build_data/runtime.h"
+#include "../../../../state/unlocks/unlocks_expression.h"
+#include "internal.h"
+
+namespace sunrise::client::content::items::packages {
+namespace {
+
+namespace reader = middleware::content::packages::reader;
+namespace tables = middleware::content::packages::tables;
+namespace domain = state::build_data::rewards;
+namespace unlocks = state::unlocks;
+
+/** Investment-root slots identify reward pools and unlock-slot bindings. */
+constexpr std::size_t kSupplementalRewardSlot = 84;
+constexpr std::size_t kPoolSlot = 88;
+constexpr std::size_t kExpressionSlot = 109;
+constexpr std::size_t kFlagSlot = 112;
+constexpr std::size_t kValueSlot = 114;
+/** Only the omitted-bank sentinel permits a no-op; other invalid tags leave effects unresolved. */
+constexpr std::uint32_t kAbsentTableTag = 0xFFFFFFFFU;
+/** Native reward schema classes and fixed row sizes. */
+constexpr std::uint32_t kPoolClass = 0x80807553U;
+constexpr std::uint32_t kPoolRowClass = 0x8080748CU;
+constexpr std::uint32_t kEntryClass = 0x8080748EU;
+constexpr std::uint32_t kExpressionClass = 0x80807D31U;
+constexpr std::uint32_t kModifierClass = 0x80807490U;
+constexpr std::uint32_t kSocketClass = 0x80803062U;
+constexpr std::uint32_t kWrapperClass = 0x808077CCU;
+constexpr std::uint32_t kSelectionClass = 0x808077CFU;
+constexpr std::uint32_t kFlagTableClass = 0x80807D49U;
+constexpr std::uint32_t kFlagRowClass = 0x80807D4FU;
+constexpr std::uint32_t kValueTableClass = 0x80807C92U;
+constexpr std::uint32_t kValueRowClass = 0x80807C96U;
+constexpr std::uint32_t kExpressionTableClass = 0x80807C49U;
+constexpr std::uint32_t kExpressionRowClass = 0x80807C4FU;
+constexpr std::uint32_t kConditionClass = 0x80807D2FU;
+constexpr std::size_t kPoolStride = 24;
+constexpr std::size_t kEntryStride = 80;
+constexpr std::size_t kModifierStride = 24;
+constexpr std::size_t kBindingStride = 8;
+constexpr std::size_t kExpressionRowStride = 24;
+constexpr std::size_t kSocketStride = 12;
+constexpr std::size_t kSelectionStride = 12;
+/** Item headers hold a relative wrapper pointer and an acquired-unlock slot. */
+constexpr std::size_t kWrapperField = 0x58;
+constexpr std::size_t kAcquiredFlagField = 0xDA;
+
+/** Entry +10 selects a supplemental reward, not an item or nested pool. */
+constexpr std::size_t kEntryQuantityOffset = 4;
+constexpr std::size_t kEntryPoolOffset = 8;
+constexpr std::size_t kEntrySupplementalOffset = 10;
+constexpr std::size_t kEntryCategoryOffset = 20;
+constexpr std::size_t kEntryWeightOffset = 24;
+constexpr std::size_t kEntryConditionOffset = 32;
+constexpr std::size_t kEntryModifiersOffset = 48;
+constexpr std::size_t kEntrySocketsOffset = 64;
+constexpr std::size_t kModifierValueIndexOffset = 16;
+constexpr std::size_t kModifierValueOffset = 20;
+constexpr std::size_t kPoolEntriesOffset = 8;
+constexpr std::size_t kExpressionBodyOffset = 8;
+constexpr std::size_t kWrapperSelectionsOffset = 8;
+constexpr std::size_t kWrapperFlagsOffset = 24;
+constexpr std::size_t kSelectionCountOffset = 4;
+constexpr std::size_t kSocketPlugOffset = 2;
+constexpr std::size_t kSocketPlugSetOffset = 4;
+constexpr std::size_t kSocketRollSetOffset = 6;
+constexpr std::size_t kSocketSelectionOffset = 8;
+/** Relative definition bodies are preceded by their four-byte schema class. */
+constexpr std::size_t kDefinitionClassPrefixSize = sizeof(std::uint32_t);
+
+/** Class rows name a default finisher whose use gate supplies the computed class flag. */
+constexpr std::size_t kClassSlot = 12;
+constexpr std::size_t kClassStride = 32;
+constexpr std::size_t kDefaultFinisherOffset = 24;
+constexpr std::uint32_t kClassTable = 0x808075BEU;
+constexpr std::uint32_t kClassRow = 0x808074FAU;
+constexpr std::size_t kUseConditionPointer = 24;
+constexpr std::uint32_t kUseConditionClass = 0x80802980U;
+
+/** Set when the installed data refuses the reward tables or graph; no retry can change that. */
+bool g_rewardsUnsupported = false;
+
+/** Logs one refused reward load, sizing or publication. */
+void report_refusal(const char* reason) noexcept {
+ core::log::writef(core::log::Channel::client,
+ core::log::Level::warn,
+ "ev=pkg stage=rewards result=fail reason=%s",
+ reason);
+}
+
+/** Logs a refusal the installed data decides and settles the domain once. */
+void settle_unsupported(const char* reason) noexcept {
+ report_refusal(reason);
+ if (!g_rewardsUnsupported) {
+ g_rewardsUnsupported = true;
+ core::log::write(core::log::Channel::client,
+ core::log::Level::warn,
+ "ev=pkg stage=rewards result=unsupported");
+ }
+}
+
+/** An unread item row keeps a zero hash. */
+[[nodiscard]] bool item_read(std::span items, std::uint16_t index) noexcept {
+ return index < items.size() && items[index].definitionHash != 0;
+}
+
+/** Appends within a bank's capacity; an allocation failure is refused rather than thrown. */
+template bool append(std::vector& bank, T value, std::size_t capacity) noexcept {
+ if (bank.size() >= capacity) {
+ return false;
+ }
+ try {
+ bank.push_back(value);
+ return true;
+ } catch (...) {
+ return false;
+ }
+}
+
+/** Package reads can fail temporarily. Only decoded schema failures settle a domain. */
+enum class TableRead { loaded, readFailed, unsupported };
+
+/** Reads the table an investment-root slot names, checking its class when one is expected. */
+TableRead root_table(const reader::Source& source,
+ reader::Scratch& scratch,
+ std::span root,
+ std::size_t slot,
+ std::vector& blob,
+ std::uint32_t expectedClass) noexcept {
+ std::uint32_t tag = 0;
+ std::uint32_t cls = 0;
+ if (!tables::slot_tag(root, slot, tag) || tables::package_of(tag) == tables::kAbsentPackageId) {
+ return TableRead::unsupported;
+ }
+ if (!reader::read_tag(source, scratch, tag, blob, cls)) {
+ return TableRead::readFailed;
+ }
+ return expectedClass == 0 || cls == expectedClass ? TableRead::loaded : TableRead::unsupported;
+}
+
+/** Required reward tables settle only after a decoded schema failure, leaving reads retryable. */
+bool load_reward_table(const reader::Source& source,
+ reader::Scratch& scratch,
+ std::span root,
+ std::size_t slot,
+ std::vector& blob,
+ std::uint32_t expectedClass,
+ const char* reason) noexcept {
+ const auto result = root_table(source, scratch, root, slot, blob, expectedClass);
+ if (result == TableRead::unsupported) {
+ settle_unsupported(reason);
+ } else if (result == TableRead::readFailed) {
+ report_refusal(reason);
+ }
+ return result == TableRead::loaded;
+}
+
+/** Reads one entry's fixed fields; a negative or non-finite weight is refused. */
+bool read_reward_entry(std::span blob,
+ std::size_t at,
+ domain::Entry& entry) noexcept {
+ return tables::read(blob, at, entry.itemIndex)
+ && tables::read(blob, at + kEntryQuantityOffset, entry.quantity)
+ && tables::read(blob, at + kEntryPoolOffset, entry.poolIndex)
+ && tables::read(blob, at + kEntrySupplementalOffset, entry.supplementalIndex)
+ && tables::read(blob, at + kEntryCategoryOffset, entry.categoryHash)
+ && tables::read(blob, at + kEntryWeightOffset, entry.weight)
+ && std::isfinite(entry.weight) && entry.weight >= 0;
+}
+
+/** Reads the single flag that gates use of the class's default finisher. */
+bool read_class_flag(std::span blob, std::uint16_t& output) noexcept {
+ std::size_t at = 0;
+ std::uint32_t cls = 0;
+ tables::Array expression{};
+ if (!tables::relative(blob, kUseConditionPointer, at) || at < kDefinitionClassPrefixSize
+ || !tables::read(blob, at - kDefinitionClassPrefixSize, cls) || cls != kUseConditionClass
+ || !tables::read_array(
+ blob, at, kExpressionClass, tables::kUnlockInstructionStride, expression)
+ || expression.count != 1) {
+ return false;
+ }
+ std::uint32_t opcode = 0;
+ std::uint32_t flag = domain::kAbsent;
+ if (!tables::read(blob, expression.dataOffset, opcode)
+ || opcode != tables::kUnlockReadFlagOpcode
+ || !tables::read(
+ blob, expression.dataOffset + tables::kUnlockInstructionOperandOffset, flag)
+ || flag >= domain::kAbsent) {
+ return false;
+ }
+ output = static_cast(flag);
+ return true;
+}
+
+} // namespace
+
+/** @return True when the reward graph is published or can never be. */
+bool reward_definitions_settled() noexcept {
+ return state::build_data::reward_definitions_ready() || g_rewardsUnsupported;
+}
+
+bool RewardBuild::entry(std::span blob,
+ std::size_t at,
+ BankFailure& failure,
+ const char*& reason) noexcept {
+ domain::Entry out{};
+ failure = BankFailure::none;
+ if (!read_reward_entry(blob, at, out)
+ || !conditions.read(
+ blob, at + kEntryConditionOffset, instructions_, out.condition, failure)) {
+ reason = failure == BankFailure::full ? "instruction_capacity" : "instruction_storage";
+ return false;
+ }
+ out.supplementalMissing = supplementalMissing_ && out.supplementalIndex != domain::kAbsent;
+ tables::Array rows{};
+ if (!tables::read_array(
+ blob, at + kEntryModifiersOffset, kModifierClass, kModifierStride, rows)) {
+ return false;
+ }
+ out.modifiers = {static_cast(modifiers_.size()),
+ static_cast(rows.count)};
+ for (std::size_t i = 0; i < rows.count; ++i) {
+ const auto offset = rows.dataOffset + i * kModifierStride;
+ domain::Modifier modifier{};
+ if (!conditions.read(blob, offset, instructions_, modifier.condition, failure)
+ || !tables::read(blob, offset + kModifierValueIndexOffset, modifier.valueIndex)
+ || !tables::read(blob, offset + kModifierValueOffset, modifier.value)
+ || !std::isfinite(modifier.value)) {
+ reason = failure == BankFailure::full ? "instruction_capacity" : "instruction_storage";
+ return false;
+ }
+ if (modifiers_.size() >= domain::kModifierCapacity) {
+ failure = BankFailure::full;
+ reason = "modifier_capacity";
+ return false;
+ }
+ if (!append(modifiers_, modifier, domain::kModifierCapacity)) {
+ failure = BankFailure::storage;
+ reason = "modifier_storage";
+ return false;
+ }
+ }
+ std::array overrides{};
+ std::size_t count = 0;
+ if (!read_reward_sockets(blob, at + kEntrySocketsOffset, overrides, count)) {
+ return false;
+ }
+ if (count > domain::kSocketOverrideCapacity - sockets_.size()) {
+ failure = BankFailure::full;
+ reason = "socket_capacity";
+ return false;
+ }
+ out.sockets = {static_cast(sockets_.size()), static_cast(count)};
+ for (std::size_t i = 0; i < count; ++i) {
+ if (overrides[i].socketType == domain::kAbsent) {
+ return false;
+ }
+ if (!append(sockets_, overrides[i], domain::kSocketOverrideCapacity)) {
+ failure = BankFailure::storage;
+ reason = "socket_storage";
+ return false;
+ }
+ }
+ if (entries_.size() >= domain::kEntryCapacity) {
+ failure = BankFailure::full;
+ reason = "entry_capacity";
+ return false;
+ }
+ if (!append(entries_, out, domain::kEntryCapacity)) {
+ failure = BankFailure::storage;
+ reason = "entry_storage";
+ return false;
+ }
+ return true;
+}
+
+bool RewardConditions::load_class_flags(const reader::Source& source,
+ reader::Scratch& scratch,
+ std::span root) noexcept {
+ classFlags_.fill(domain::kAbsent);
+ std::vector classes;
+ std::vector index;
+ std::vector blob;
+ tables::Array classRows{};
+ tables::Array itemRows{};
+ if (!load_reward_table(source, scratch, root, kClassSlot, classes, kClassTable, "class_table")
+ || !load_reward_table(
+ source, scratch, root, tables::kItemTableSlot, index, 0, "class_item_table")) {
+ return false;
+ }
+ if (!tables::read_array(
+ classes, tables::kTableArrayDescriptor, kClassRow, kClassStride, classRows)
+ || classRows.count != classFlags_.size()
+ || !tables::read_array(index,
+ tables::kTableArrayDescriptor,
+ tables::kItemIndexTableClass,
+ tables::kItemIndexRowStride,
+ itemRows)) {
+ settle_unsupported("class_schema");
+ return false;
+ }
+ for (std::size_t i = 0; i < classFlags_.size(); ++i) {
+ std::uint16_t itemIndex = domain::kAbsent;
+ tables::IndexRow item{};
+ std::uint32_t cls = 0;
+ if (!tables::read(std::span{classes},
+ classRows.dataOffset + i * kClassStride + kDefaultFinisherOffset,
+ itemIndex)
+ || !tables::index_row(index, itemRows, itemIndex, item)) {
+ settle_unsupported("class_finisher_index");
+ return false;
+ }
+ if (!reader::read_tag(source, scratch, item.targetTag, blob, cls)) {
+ core::log::writef(core::log::Channel::client,
+ core::log::Level::warn,
+ "ev=pkg stage=class_flags class=%zu result=fail reason=finisher_read",
+ i);
+ return false;
+ }
+ if (cls != tables::kItemDefinitionClass || !read_class_flag(blob, classFlags_[i])) {
+ settle_unsupported("class_finisher_schema");
+ return false;
+ }
+ }
+ return true;
+}
+
+bool RewardConditions::load(const reader::Source& source,
+ reader::Scratch& scratch,
+ std::span root,
+ const SlotMaps& maps) noexcept {
+ loaded_ = false;
+ maps_ = &maps;
+ if (!load_class_flags(source, scratch, root)
+ || !load_reward_table(
+ source, scratch, root, kFlagSlot, flags_, kFlagTableClass, "flag_table")
+ || !load_reward_table(
+ source, scratch, root, kValueSlot, values_, kValueTableClass, "value_table")
+ || !load_reward_table(source,
+ scratch,
+ root,
+ kExpressionSlot,
+ expressions_,
+ kExpressionTableClass,
+ "expression_table")) {
+ return false;
+ }
+ // Shared expressions are expanded before the runtime evaluates reward conditions.
+ if (!tables::read_array(
+ flags_, tables::kTableArrayDescriptor, kFlagRowClass, kBindingStride, flagRows_)
+ || !tables::read_array(
+ values_, tables::kTableArrayDescriptor, kValueRowClass, kBindingStride, valueRows_)
+ || !tables::read_array(expressions_,
+ tables::kTableArrayDescriptor,
+ kExpressionRowClass,
+ kExpressionRowStride,
+ expressionRows_)) {
+ settle_unsupported("condition_schema");
+ return false;
+ }
+ loaded_ = true;
+ return true;
+}
+
+bool RewardConditions::loaded() const noexcept {
+ return loaded_;
+}
+
+bool RewardConditions::bind(std::uint32_t native,
+ std::uint32_t operand,
+ domain::Instruction& instruction) const noexcept {
+ const bool flag = native == tables::kUnlockReadFlagOpcode;
+ if (!flag && native != tables::kUnlockReadValueOpcode) {
+ instruction = {{}, unlocks::Bank::none, operand};
+ return unlocks::decode_opcode(native, instruction.opcode);
+ }
+ const auto& rows = flag ? flagRows_ : valueRows_;
+ const std::span blob = flag ? flags_ : values_;
+ std::uint32_t hash = 0;
+ if (operand >= rows.count
+ || !tables::read(blob, rows.dataOffset + operand * kBindingStride, hash)) {
+ return false;
+ }
+ instruction.opcode = flag ? unlocks::Opcode::flag : unlocks::Opcode::loadValue;
+ if (flag) {
+ for (std::size_t characterClass = 0; characterClass < classFlags_.size();
+ ++characterClass) {
+ if (classFlags_[characterClass] != domain::kAbsent
+ && operand == classFlags_[characterClass]) {
+ instruction.bank = unlocks::Bank::characterClass;
+ instruction.operand = static_cast(characterClass);
+ return true;
+ }
+ }
+ }
+ const auto slot = static_cast(operand);
+ const auto accountIndex = bank_index(flag ? maps_->accountFlag : maps_->accountValue, slot);
+ const auto profileIndex = flag ? bank_index(maps_->profileFlag, slot) : kUnmappedSlot;
+ const auto characterIndex =
+ bank_index(flag ? maps_->characterFlag : maps_->characterValue, slot);
+ if (accountIndex != kUnmappedSlot) {
+ instruction.bank = unlocks::Bank::account;
+ instruction.operand = accountIndex;
+ } else if (profileIndex != kUnmappedSlot) {
+ instruction.bank = unlocks::Bank::profile;
+ instruction.operand = profileIndex;
+ } else if (characterIndex != kUnmappedSlot) {
+ instruction.bank = unlocks::Bank::character;
+ instruction.operand = characterIndex;
+ } else {
+ instruction.bank = unlocks::Bank::external;
+ instruction.operand = hash;
+ }
+ return true;
+}
+
+bool RewardConditions::append_expression(std::span blob,
+ std::size_t at,
+ std::vector& bank,
+ std::size_t depth,
+ BankFailure& failure) const noexcept {
+ tables::Array rows{};
+ if (depth >= domain::kTraversalDepth
+ || !tables::read_array(
+ blob, at, kExpressionClass, tables::kUnlockInstructionStride, rows)) {
+ return false;
+ }
+ for (std::size_t i = 0; i < rows.count; ++i) {
+ const std::size_t row = rows.dataOffset + i * tables::kUnlockInstructionStride;
+ std::uint32_t native = 0;
+ std::uint32_t operand = 0;
+ if (!tables::read(blob, row, native)
+ || !tables::read(blob, row + tables::kUnlockInstructionOperandOffset, operand)) {
+ return false;
+ }
+ if (native == tables::kUnlockExpressionOpcode) {
+ const auto before = bank.size();
+ if (operand >= expressionRows_.count
+ || !append_expression(expressions_,
+ expressionRows_.dataOffset + operand * kExpressionRowStride
+ + kExpressionBodyOffset,
+ bank,
+ depth + 1,
+ failure)
+ || bank.size() == before) {
+ return false;
+ }
+ continue;
+ }
+ domain::Instruction instruction{};
+ if (!bind(native, operand, instruction) || !unlocks::valid(instruction)) {
+ return false;
+ }
+ if (bank.size() >= domain::kInstructionCapacity) {
+ failure = BankFailure::full;
+ return false;
+ }
+ if (!append(bank, instruction, domain::kInstructionCapacity)) {
+ failure = BankFailure::storage;
+ return false;
+ }
+ }
+ return true;
+}
+
+bool RewardConditions::read(std::span blob,
+ std::size_t at,
+ std::vector& bank,
+ domain::Range& range,
+ BankFailure& failure) const noexcept {
+ const auto first = bank.size();
+ failure = BankFailure::none;
+ if (!append_expression(blob, at, bank, 0, failure)) {
+ bank.resize(first);
+ return false;
+ }
+ range = {static_cast(first), static_cast(bank.size() - first)};
+ return true;
+}
+
+bool RewardConditions::read_list(std::span blob,
+ std::size_t at,
+ std::span output,
+ std::size_t& count,
+ BankFailure& failure) const noexcept {
+ count = 0;
+ failure = BankFailure::none;
+ tables::Array rows{};
+ std::vector instructions;
+ if (!tables::read_array(blob, at, kConditionClass, tables::kUnlockExpressionFieldSize, rows)) {
+ return false;
+ }
+ for (std::size_t i = 0; i < rows.count; ++i) {
+ domain::Range expression{};
+ if (!read(blob,
+ rows.dataOffset + i * tables::kUnlockExpressionFieldSize,
+ instructions,
+ expression,
+ failure)
+ || expression.count == 0) {
+ return false;
+ }
+ // Every expression attached to a progression reward must hold.
+ if (i != 0) {
+ if (instructions.size() >= output.size()) {
+ return false;
+ }
+ if (!append(instructions,
+ domain::Instruction{unlocks::Opcode::logicalAnd, unlocks::Bank::none, 0},
+ output.size())) {
+ failure = BankFailure::storage;
+ return false;
+ }
+ }
+ if (instructions.size() > output.size()) {
+ return false;
+ }
+ }
+ std::copy(instructions.begin(), instructions.end(), output.begin());
+ count = instructions.size();
+ return true;
+}
+
+bool read_reward_sockets(std::span blob,
+ std::size_t at,
+ std::span output,
+ std::size_t& count) noexcept {
+ count = 0;
+ tables::Array rows{};
+ if (!tables::read_array(blob, at, kSocketClass, kSocketStride, rows)
+ || rows.count > output.size()) {
+ return false;
+ }
+ for (std::size_t i = 0; i < rows.count; ++i) {
+ const std::size_t p = rows.dataOffset + i * kSocketStride;
+ auto& socket = output[i];
+ if (!tables::read(blob, p, socket.socketType)
+ || !tables::read(blob, p + kSocketPlugOffset, socket.plugItem)
+ || !tables::read(blob, p + kSocketPlugSetOffset, socket.plugSet)
+ || !tables::read(blob, p + kSocketRollSetOffset, socket.rollSet)
+ || !tables::read(blob, p + kSocketSelectionOffset, socket.selection)) {
+ return false;
+ }
+ }
+ count = static_cast(rows.count);
+ return true;
+}
+
+bool RewardBuild::load(const reader::Source& source,
+ reader::Scratch& scratch,
+ std::span root,
+ const SlotMaps& maps) noexcept {
+ loaded_ = false;
+ supplementalMissing_ = false;
+ pools_.clear();
+ entries_.clear();
+ instructions_.clear();
+ modifiers_.clear();
+ sockets_.clear();
+ std::vector blob;
+ tables::Array rows{};
+ std::uint32_t supplementalTag = 0;
+ // Conditions load first; the season pass binds through them even if the pools are refused.
+ if (!conditions.load(source, scratch, root, maps)) {
+ return false;
+ }
+ if (!tables::slot_tag(root, kSupplementalRewardSlot, supplementalTag)) {
+ settle_unsupported("supplemental_slot");
+ return false;
+ }
+ if (!load_reward_table(source, scratch, root, kPoolSlot, blob, kPoolClass, "pool_table")) {
+ return false;
+ }
+ if (!tables::read_array(
+ blob, tables::kTableArrayDescriptor, kPoolRowClass, kPoolStride, rows)) {
+ settle_unsupported("pool_schema");
+ return false;
+ }
+ if (rows.count == 0 || rows.count > domain::kPoolCapacity) {
+ settle_unsupported("pool_count");
+ return false;
+ }
+ supplementalMissing_ = supplementalTag == kAbsentTableTag;
+ std::size_t skipped = 0;
+ for (std::size_t i = 0; i < rows.count; ++i) {
+ const auto at = rows.dataOffset + i * kPoolStride;
+ domain::Pool pool{};
+ tables::Array members{};
+ if (tables::read(blob, at, pool.definitionHash) && pool.definitionHash != 0
+ && tables::read_array(
+ blob, at + kPoolEntriesOffset, kEntryClass, kEntryStride, members)) {
+ pool.entries.first = static_cast(entries_.size());
+ for (std::size_t j = 0; j < members.count; ++j) {
+ const auto beforeInstructions = instructions_.size();
+ const auto beforeModifiers = modifiers_.size();
+ const auto beforeSockets = sockets_.size();
+ auto failure = BankFailure::none;
+ const char* reason = nullptr;
+ if (!entry(blob, members.dataOffset + j * kEntryStride, failure, reason)) {
+ // Skipping a bank failure would publish a pool missing some of its entries.
+ if (failure == BankFailure::full) {
+ settle_unsupported(reason);
+ return false;
+ }
+ if (failure == BankFailure::storage) {
+ report_refusal(reason);
+ return false;
+ }
+ instructions_.resize(beforeInstructions);
+ modifiers_.resize(beforeModifiers);
+ sockets_.resize(beforeSockets);
+ ++skipped;
+ }
+ }
+ pool.entries.count = static_cast(entries_.size()) - pool.entries.first;
+ } else {
+ pool = {};
+ ++skipped;
+ }
+ if (!append(pools_, pool, domain::kPoolCapacity)) {
+ report_refusal("pool_storage");
+ return false;
+ }
+ }
+ core::log::writef(core::log::Channel::client,
+ skipped == 0 ? core::log::Level::info : core::log::Level::warn,
+ "ev=pkg stage=rewards pools=%zu entries=%zu instructions=%zu modifiers=%zu "
+ "sockets=%zu skipped=%zu",
+ pools_.size(),
+ entries_.size(),
+ instructions_.size(),
+ modifiers_.size(),
+ sockets_.size(),
+ skipped);
+ loaded_ = true;
+ return true;
+}
+
+bool RewardBuild::read_item(std::uint32_t hash,
+ std::span blob,
+ domain::Item& item) noexcept {
+ std::uint16_t acquired = domain::kAbsent;
+ std::int64_t wrapper = 0;
+ std::uint32_t cls = 0;
+ if (!tables::read(blob, kWrapperField, wrapper)
+ || !tables::read(blob, kAcquiredFlagField, acquired)) {
+ return false;
+ }
+ item.definitionHash = hash;
+ if (acquired != domain::kAbsent) {
+ domain::Instruction flag{};
+ if (!conditions.bind(tables::kUnlockReadFlagOpcode, acquired, flag)
+ || !unlocks::valid(flag)) {
+ // The grant is kept; only its acquisition flag is unknown.
+ ++unboundAcquiredFlags_;
+ } else if (flag.bank == unlocks::Bank::account) {
+ item.acquiredFlag = static_cast(flag.operand);
+ } else {
+ ++discardedAcquiredFlags_;
+ }
+ }
+ // A zero delta means the item is not a wrapper.
+ if (wrapper != 0) {
+ std::size_t at = 0;
+ tables::Array selections{};
+ if (!tables::relative(blob, kWrapperField, at) || at < kDefinitionClassPrefixSize
+ || !tables::read(blob, at - kDefinitionClassPrefixSize, cls) || cls != kWrapperClass
+ || !tables::read(blob, at, item.poolIndex)
+ || !tables::read(blob, at + kWrapperFlagsOffset, item.flags)
+ || !tables::read_array(
+ blob, at + kWrapperSelectionsOffset, kSelectionClass, kSelectionStride, selections)
+ || selections.count > item.selections.size()) {
+ return false;
+ }
+ item.selectionCount = static_cast(selections.count);
+ for (std::size_t j = 0; j < selections.count; ++j) {
+ auto& selection = item.selections[j];
+ const auto p = selections.dataOffset + j * kSelectionStride;
+ if (!tables::read(blob, p, selection.categoryHash)
+ || !tables::read(blob, p + kSelectionCountOffset, selection.count)) {
+ return false;
+ }
+ }
+ }
+ return true;
+}
+
+bool RewardBuild::begin_items(std::size_t count) noexcept {
+ unboundAcquiredFlags_ = 0;
+ discardedAcquiredFlags_ = 0;
+ unreadItems_ = 0;
+ // A refused load has already reported itself.
+ if (!loaded_) {
+ return false;
+ }
+ try {
+ if (count == 0 || count > domain::kItemCapacity) {
+ report_refusal("item_storage");
+ return false;
+ }
+ items_.assign(count, {});
+ return true;
+ } catch (...) {
+ report_refusal("item_storage");
+ return false;
+ }
+}
+
+void RewardBuild::item(std::uint16_t index,
+ std::uint32_t hash,
+ std::span blob) noexcept {
+ domain::Item parsed{};
+ if (index < items_.size() && read_item(hash, blob, parsed)) {
+ items_[index] = parsed;
+ }
+}
+
+void RewardBuild::item_unread(std::uint16_t index) noexcept {
+ if (unreadItems_++ == 0) {
+ firstUnreadItem_ = index;
+ }
+}
+
+bool RewardBuild::publish(std::size_t catalogItemCount) noexcept {
+ if (!loaded_) {
+ return false;
+ }
+ // Publishing now would prune every entry that names an unread item.
+ if (unreadItems_ != 0) {
+ core::log::writef(core::log::Channel::client,
+ core::log::Level::warn,
+ "ev=pkg stage=rewards result=fail reason=item_read unread_items=%zu "
+ "first_index=%u",
+ unreadItems_,
+ static_cast(firstUnreadItem_));
+ return false;
+ }
+ // A catalog that lost trailing rows is shorter than the table, and the cache pairs each of its
+ // items with one reward row.
+ if (catalogItemCount < items_.size()) {
+ items_.resize(catalogItemCount);
+ }
+ // Cleared first so the entry pass also drops draws of a cleared wrapper.
+ for (auto& item : items_) {
+ if (item.poolIndex != domain::kAbsent && item.poolIndex >= pools_.size()) {
+ item = {};
+ }
+ }
+ std::size_t write = 0;
+ std::size_t socketWrite = 0;
+ for (auto& pool : pools_) {
+ const auto range = pool.entries;
+ pool.entries.first = static_cast(write);
+ for (std::size_t i = range.first; i < range.first + range.count; ++i) {
+ const auto& row = entries_[i];
+ bool valid = (row.itemIndex == domain::kAbsent || item_read(items_, row.itemIndex))
+ && (row.poolIndex == domain::kAbsent || row.poolIndex < pools_.size());
+ for (const auto& socket :
+ std::span(sockets_).subspan(row.sockets.first, row.sockets.count)) {
+ valid &=
+ domain::valid_socket(socket, items_.size())
+ && (socket.plugItem == domain::kAbsent || item_read(items_, socket.plugItem));
+ }
+ if (valid) {
+ auto retained = row;
+ retained.sockets.first = static_cast(socketWrite);
+ for (const auto& socket :
+ std::span(sockets_).subspan(row.sockets.first, row.sockets.count)) {
+ sockets_[socketWrite++] = socket;
+ }
+ entries_[write++] = retained;
+ }
+ }
+ pool.entries.count = static_cast(write) - pool.entries.first;
+ }
+ const std::size_t droppedEntries = entries_.size() - write;
+ entries_.resize(write);
+ sockets_.resize(socketWrite);
+ if (unboundAcquiredFlags_ != 0 || discardedAcquiredFlags_ != 0 || droppedEntries != 0) {
+ core::log::writef(core::log::Channel::client,
+ core::log::Level::warn,
+ "ev=pkg stage=rewards result=partial unbound_acquired_flags=%zu "
+ "discarded_acquired_flags=%zu dropped_entries=%zu",
+ unboundAcquiredFlags_,
+ discardedAcquiredFlags_,
+ droppedEntries);
+ }
+ const domain::View graph{pools_, entries_, items_, instructions_, modifiers_, sockets_};
+ if (!state::build_data::valid_reward_definitions(graph)) {
+ settle_unsupported("graph");
+ return false;
+ }
+ // A cache write can still fail, so this stays retryable.
+ if (!state::build_data::publish_reward_definitions(graph)) {
+ report_refusal("publish");
+ return false;
+ }
+ return true;
+}
+
+} // namespace sunrise::client::content::items::packages
diff --git a/Sunrise/src/client/content/items/packages/package_reward_build.h b/Sunrise/src/client/content/items/packages/package_reward_build.h
new file mode 100644
index 000000000..08015f885
--- /dev/null
+++ b/Sunrise/src/client/content/items/packages/package_reward_build.h
@@ -0,0 +1,116 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+
+#include "../../../../middleware/content/packages/reader/reader.h"
+#include "../../../../middleware/content/packages/tables/definition_index_table.h"
+#include "../../../../state/account/account_state.h"
+#include "../../../../state/build_data/rewards/definition.h"
+
+namespace sunrise::client::content::items::packages {
+
+struct SlotMaps;
+
+/** A full reward bank settles the domain; failed storage is retried on a later pass. */
+enum class BankFailure : std::uint8_t { none, full, storage };
+
+/** Resolves expression references and unlock slots once for a package pass. */
+class RewardConditions {
+public:
+ /** Reads the root's flag, value, expression and class tables; false when one is missing. */
+ [[nodiscard]] bool load(const middleware::content::packages::reader::Source& source,
+ middleware::content::packages::reader::Scratch& scratch,
+ std::span root,
+ const SlotMaps& maps) noexcept;
+ /** @return True when the last load() succeeded. */
+ [[nodiscard]] bool loaded() const noexcept;
+ /** Binds one native instruction; flag and value slots resolve to their saved bank. */
+ [[nodiscard]] bool bind(std::uint32_t native,
+ std::uint32_t operand,
+ state::build_data::rewards::Instruction& instruction) const noexcept;
+ /** Appends one expanded expression; the bank is unchanged on failure. */
+ [[nodiscard]] bool read(std::span blob,
+ std::size_t at,
+ std::vector& bank,
+ state::build_data::rewards::Range& range,
+ BankFailure& failure) const noexcept;
+ /** Expands a list of expressions into one program where every expression must hold. */
+ [[nodiscard]] bool read_list(std::span blob,
+ std::size_t at,
+ std::span output,
+ std::size_t& count,
+ BankFailure& failure) const noexcept;
+
+private:
+ [[nodiscard]] bool append_expression(std::span blob,
+ std::size_t at,
+ std::vector& bank,
+ std::size_t depth,
+ BankFailure& failure) const noexcept;
+ /** Reads every class binding before any reward condition can be published. */
+ [[nodiscard]] bool load_class_flags(const middleware::content::packages::reader::Source& source,
+ middleware::content::packages::reader::Scratch& scratch,
+ std::span root) noexcept;
+ std::array classFlags_{};
+ const SlotMaps* maps_{};
+ bool loaded_{};
+ std::vector flags_;
+ std::vector values_;
+ std::vector expressions_;
+ middleware::content::packages::tables::Array flagRows_{};
+ middleware::content::packages::tables::Array valueRows_{};
+ middleware::content::packages::tables::Array expressionRows_{};
+};
+
+/** Reward metadata collected alongside the existing item-definition walk. */
+class RewardBuild {
+public:
+ RewardConditions conditions;
+ /** Reads every reward pool; unreadable entries are skipped, a full bank refuses the load. */
+ [[nodiscard]] bool load(const middleware::content::packages::reader::Source& source,
+ middleware::content::packages::reader::Scratch& scratch,
+ std::span root,
+ const SlotMaps& maps) noexcept;
+ /** Sizes the dense item rows before the item walk. */
+ [[nodiscard]] bool begin_items(std::size_t count) noexcept;
+ /** Records one item's wrapper and acquisition flag; an unreadable item stays unavailable. */
+ void item(std::uint16_t index, std::uint32_t hash, std::span blob) noexcept;
+ /** Notes an item whose definition failed to read; publication waits for a clean walk. */
+ void item_unread(std::uint16_t index) noexcept;
+ /** Trims item rows to the catalog, drops rows naming unread items or pools, then publishes. */
+ [[nodiscard]] bool publish(std::size_t catalogItemCount) noexcept;
+
+private:
+ /** Appends one entry; a bank failure also names the bank and what refused it. */
+ [[nodiscard]] bool entry(std::span blob,
+ std::size_t at,
+ BankFailure& failure,
+ const char*& reason) noexcept;
+ [[nodiscard]] bool read_item(std::uint32_t hash,
+ std::span blob,
+ state::build_data::rewards::Item& item) noexcept;
+ bool loaded_{};
+ bool supplementalMissing_{};
+ std::size_t unboundAcquiredFlags_{};
+ std::size_t discardedAcquiredFlags_{};
+ std::size_t unreadItems_{};
+ std::uint16_t firstUnreadItem_{};
+ std::vector pools_;
+ std::vector entries_;
+ std::vector items_;
+ std::vector instructions_;
+ std::vector modifiers_;
+ std::vector sockets_;
+};
+
+/** Progression rewards and pool entries use the same socket-override layout. */
+[[nodiscard]] bool read_reward_sockets(std::span blob,
+ std::size_t at,
+ std::span output,
+ std::size_t& count) noexcept;
+
+} // namespace sunrise::client::content::items::packages
diff --git a/Sunrise/src/client/content/items/packages/package_season_pass_build.cpp b/Sunrise/src/client/content/items/packages/package_season_pass_build.cpp
index 2dcc13bd9..0d207bbac 100644
--- a/Sunrise/src/client/content/items/packages/package_season_pass_build.cpp
+++ b/Sunrise/src/client/content/items/packages/package_season_pass_build.cpp
@@ -1,35 +1,24 @@
#include
-#include
#include
+#include "../../../../core/logging/log.h"
+#include "../../../../middleware/content/packages/tables/field_reader.h"
+#include "../../../../state/progression/season_pass_reward_catalog.h"
#include "internal.h"
+#include "package_reward_build.h"
namespace sunrise::client::content::items::packages {
namespace {
namespace domain = state::build_data::season_pass;
-/** Account progression the installed Season of Arrivals pass declares its rewards on. */
-constexpr std::uint16_t kPassProgressionIndex = 40;
-
-/** @param blob Source bytes. @param offset Field offset. @param value Receives the field. */
-template
-[[nodiscard]] bool
-read(std::span blob, std::size_t offset, Value& value) noexcept {
- if (offset > blob.size() || blob.size() - offset < sizeof value) {
- return false;
- }
- std::memcpy(&value, blob.data() + offset, sizeof value);
- return true;
-}
-
/**
- * Reads the item set one reward's wrapper item opens into.
+ * Reads the item set declared by one reward wrapper.
* @param definition Whole item definition blob.
* @param itemTable Item index table blob.
* @param itemRows Located item index array.
- * @param package Receives the wrapper's items, or stays empty when the item opens into nothing.
- * @return True when the item declares no set, or declares one that reads back whole.
+ * @param package Empty destination receiving resolved item hashes; may be partial on failure.
+ * @return True when the item declares no set, or its whole set resolves.
*/
[[nodiscard]] bool read_package(std::span definition,
std::span itemTable,
@@ -50,9 +39,10 @@ read(std::span blob, std::size_t offset, Value& value) noexcept
for (std::uint64_t member = 0; member < set.count; ++member) {
std::uint16_t itemIndex = 0;
tables::IndexRow entry{};
- if (!read(definition,
- set.dataOffset + static_cast(member) * tables::kGearsetItemStride,
- itemIndex)
+ if (!tables::read(definition,
+ set.dataOffset
+ + static_cast(member) * tables::kGearsetItemStride,
+ itemIndex)
|| !tables::index_row(itemTable, itemRows, itemIndex, entry)) {
return false;
}
@@ -61,13 +51,89 @@ read(std::span blob, std::size_t offset, Value& value) noexcept
return true;
}
+/**
+ * Reads one pass row: item, rank, quantity and claim flag, then its sockets and conditions.
+ * @param storage Build storage providing item rows, slot maps, and condition decoding.
+ * @param progressionTable Progression table containing the row.
+ * @param itemRows Located item index array.
+ * @param at Byte offset of the reward row.
+ * @param reward Receives the decoded row; may be partial on failure.
+ * @param failure Receives a condition-bank storage failure when one occurs.
+ * @return False when any field is unreadable or out of range.
+ */
+bool read_season_reward(Storage& storage,
+ std::span progressionTable,
+ const tables::Array& itemRows,
+ std::size_t at,
+ domain::Reward& reward,
+ BankFailure& failure) noexcept {
+ std::uint32_t rank = 0;
+ std::uint32_t itemIndex = 0;
+ std::uint32_t claimSlot = 0;
+ if (!tables::read(progressionTable, at + tables::kProgressionRewardRankOffset, rank)
+ || !tables::read(
+ progressionTable, at + tables::kProgressionRewardItemIndexOffset, itemIndex)
+ || !tables::read(
+ progressionTable, at + tables::kProgressionRewardQuantityOffset, reward.quantity)
+ || !tables::read(
+ progressionTable, at + tables::kProgressionRewardClaimSlotOffset, claimSlot)) {
+ return false;
+ }
+
+ // Native fields are 32-bit, the stored row keeps the narrower native index widths.
+ tables::IndexRow entry{};
+ if (rank > (std::numeric_limits::max)()
+ || itemIndex > (std::numeric_limits::max)()
+ || claimSlot > (std::numeric_limits::max)()
+ || !tables::index_row(storage.itemIndexTable, itemRows, itemIndex, entry)) {
+ return false;
+ }
+ reward.itemHash = entry.definitionHash;
+ reward.itemIndex = static_cast(itemIndex);
+ reward.requiredRank = static_cast(rank);
+
+ // A reward with no claim flag carries slot 0, any other slot must map to the account bank.
+ if (claimSlot != 0) {
+ reward.claimFlagIndex =
+ bank_index(storage.slotMaps.accountFlag, static_cast(claimSlot));
+ if (reward.claimFlagIndex == domain::kUnavailableFlagIndex) {
+ return false;
+ }
+ }
+
+ std::size_t socketCount = 0;
+ std::size_t conditionCount = 0;
+ if (!read_reward_sockets(progressionTable,
+ at + tables::kProgressionRewardSocketsOffset,
+ reward.sockets,
+ socketCount)
+ || !storage.rewardBuild.conditions.read_list(
+ progressionTable,
+ at + tables::kProgressionRewardConditionsOffset,
+ reward.condition,
+ conditionCount,
+ failure)) {
+ return false;
+ }
+ reward.socketCount = static_cast(socketCount);
+ reward.conditionCount = static_cast(conditionCount);
+ // Checked per row so one refused row stays unavailable.
+ return state::build_data::valid_season_pass_rewards(std::span(&reward, 1))
+ && std::all_of(reward.sockets.begin(),
+ reward.sockets.begin() + reward.socketCount,
+ [&itemRows](const state::build_data::rewards::SocketOverride& socket) {
+ return state::build_data::rewards::valid_socket(socket,
+ itemRows.count);
+ });
+}
+
} // namespace
/**
- * Reads the season pass reward list and the wrapper items it grants.
- * A reward names an item index and a claim flag slot; both become the values a grant needs.
+ * Reads the season pass reward list in native order, which the opcode-2400 claim indexes.
+ * A row that fails to read is kept as an unavailable row so later claim indices stay aligned.
* @param source Installed package source.
- * @param storage Pass storage receiving the reward rows and the wrapper packages.
+ * @param storage Pass storage receiving the reward rows.
* @param root Investment root bytes.
* @return True when the reward list read and produced at least one row.
*/
@@ -97,55 +163,47 @@ bool build_season_pass(const reader::Source& source,
tables::kTableArrayDescriptor,
progressions)
|| progressions.elementClass != tables::kProgressionTableClass
- || progressions.count <= kPassProgressionIndex) {
+ || progressions.count <= state::progression::season_pass::kProgressionDefinitionIndex) {
return false;
}
const std::span progressionTable{storage.progressionTable};
- const std::size_t passAt =
- progressions.dataOffset + kPassProgressionIndex * tables::kProgressionRowStride;
+ const std::size_t passAt = progressions.dataOffset
+ + state::progression::season_pass::kProgressionDefinitionIndex
+ * tables::kProgressionRowStride;
tables::Array rewards{};
- if (!tables::find_optional_array_at(
- progressionTable, passAt + tables::kProgressionRewardField, rewards)
- || rewards.count == 0 || rewards.elementClass != tables::kProgressionRewardRowClass
- || rewards.count > domain::kRewardCapacity
- || rewards.dataOffset
- + static_cast(rewards.count) * tables::kProgressionRewardStride
- > progressionTable.size()) {
+ if (!tables::read_array(progressionTable,
+ passAt + tables::kProgressionRewardField,
+ tables::kProgressionRewardRowClass,
+ tables::kProgressionRewardStride,
+ rewards)
+ || rewards.count == 0 || rewards.count > domain::kRewardCapacity) {
return false;
}
+ std::size_t skipped = 0;
for (std::uint64_t row = 0; row < rewards.count; ++row) {
const std::size_t at =
rewards.dataOffset + static_cast(row) * tables::kProgressionRewardStride;
- std::uint32_t rank = 0;
- std::uint32_t itemIndex = 0;
- std::uint32_t claimSlot = 0;
- domain::Reward& reward = storage.seasonPassRewards[storage.seasonPassRewardCount];
+ auto& reward = storage.seasonPassRewards[row];
reward = {};
+ auto failure = BankFailure::none;
+ if (!read_season_reward(storage, progressionTable, itemRows, at, reward, failure)) {
+ // A row is left unavailable only for its data, never for failed storage.
+ if (failure == BankFailure::storage) {
+ core::log::write(core::log::Channel::client,
+ core::log::Level::warn,
+ "ev=pkg stage=season_pass result=fail reason=condition_storage");
+ return false;
+ }
+ reward = {};
+ ++skipped;
+ continue;
+ }
+
tables::IndexRow entry{};
- if (!read(progressionTable, at + tables::kProgressionRewardRankOffset, rank)
- || !read(progressionTable, at + tables::kProgressionRewardItemIndexOffset, itemIndex)
- || !read(
- progressionTable, at + tables::kProgressionRewardQuantityOffset, reward.quantity)
- || !read(progressionTable, at + tables::kProgressionRewardClaimSlotOffset, claimSlot)
- || rank > (std::numeric_limits::max)()
- || itemIndex > (std::numeric_limits::max)()
- || claimSlot > (std::numeric_limits::max)()
- || !tables::index_row(itemTable, itemRows, itemIndex, entry)) {
- storage.seasonPassRewardCount = 0;
+ if (!tables::index_row(itemTable, itemRows, reward.itemIndex, entry)) {
return false;
}
- reward.itemHash = entry.definitionHash;
- reward.itemIndex = static_cast(itemIndex);
- reward.requiredRank = static_cast(rank);
- // A reward with no claim flag carries slot 0.
- if (claimSlot != 0) {
- reward.claimFlagIndex =
- bank_index(storage.slotMaps.accountFlag, static_cast(claimSlot));
- }
-
- // A wrapper reward opens into a set; a plain reward declares none and keeps zero items.
- // One wrapper can be granted at several ranks, so it is recorded once.
const std::span held =
std::span(storage.seasonPassPackages).first(storage.seasonPassPackageCount);
domain::Package package{};
@@ -163,8 +221,14 @@ bool build_season_pass(const reader::Source& source,
&& package.itemCount != 0) {
storage.seasonPassPackages[storage.seasonPassPackageCount++] = package;
}
- ++storage.seasonPassRewardCount;
}
+ if (skipped != 0) {
+ core::log::writef(core::log::Channel::client,
+ core::log::Level::warn,
+ "ev=pkg stage=season_pass skipped=%zu",
+ skipped);
+ }
+ storage.seasonPassRewardCount = static_cast(rewards.count);
return storage.seasonPassRewardCount != 0;
}
diff --git a/Sunrise/src/client/content/vendors/layout.h b/Sunrise/src/client/content/vendors/layout.h
index 8078dcb6d..44aced707 100644
--- a/Sunrise/src/client/content/vendors/layout.h
+++ b/Sunrise/src/client/content/vendors/layout.h
@@ -7,6 +7,20 @@ namespace sunrise::client::content::vendors {
/** Tag of the installed vendor index blob, which names every vendor definition. */
inline constexpr std::uint32_t kIndexRootTag = 0x8131931DU;
+/** Installed faction table named by the build-86657 investment content. */
+inline constexpr std::uint32_t kFactionTableTag = 0x81327CD5U;
+/** The faction table's rows begin at this array descriptor. */
+inline constexpr std::size_t kFactionArrayDescriptor = 8;
+/** One faction row holds its hash and progression index in the first eight bytes. */
+inline constexpr std::size_t kFactionRowStride = 24;
+/** The installed faction table's row class. */
+inline constexpr std::uint32_t kFactionRowClass = 0x808074CEU;
+/** Faction definition hash at the start of a faction row. */
+inline constexpr std::size_t kFactionHashOffset = 0;
+/** Progression definition index following the faction hash. */
+inline constexpr std::size_t kFactionProgressionIndexOffset = 4;
+/** Vendor definition +18 selects a faction row; out-of-range values name no faction. */
+inline constexpr std::size_t kVendorFactionIndexOffset = 18;
/** A vendor definition holds its installed array descriptor here. */
inline constexpr std::size_t kInstalledArrayDescriptor = 32;
@@ -14,6 +28,18 @@ inline constexpr std::size_t kInstalledArrayDescriptor = 32;
inline constexpr std::size_t kSaleArrayDescriptor = 48;
/** A vendor definition holds its unnamed third array descriptor here. */
inline constexpr std::size_t kThirdArrayDescriptor = 80;
+/** Element class of an interaction row in the third vendor array. */
+inline constexpr std::uint32_t kInteractionRowClass = 0x80807857U;
+/** Interaction row +8 holds its direct availability expression. */
+inline constexpr std::size_t kInteractionConditionField = 8;
+/** Interaction row +56 names the vendor category it presents. */
+inline constexpr std::size_t kInteractionCategoryOffset = 56;
+/** Interaction row +40 holds the reply array. */
+inline constexpr std::size_t kInteractionReplyField = 40;
+/** Each reply is 24 bytes, beginning with its conditional reward program. */
+inline constexpr std::size_t kReplyRowStride = 24;
+/** Element class of the installed interaction reply array. */
+inline constexpr std::uint32_t kReplyRowClass = 0x8080785BU;
/** Raw reset interval. Its unit, epoch and scope are open, so it is stored unconverted. */
inline constexpr std::size_t kResetIntervalOffset = 20;
/** Raw reset phase, paired with the interval. */
@@ -21,6 +47,8 @@ inline constexpr std::size_t kResetPhaseOffset = 24;
/** Sale row cost array descriptor, which is what the row charges. */
inline constexpr std::size_t kSaleCostArrayDescriptor = 32;
+/** Sale row +8 carries admission programs; +120 carries selection programs. */
+inline constexpr std::size_t kSaleAdmissionField = 8, kSaleSelectionField = 120;
/** Cost entry item-definition index. */
inline constexpr std::size_t kSaleCostItemIndexOffset = 0;
/** Cost entry static quantity. */
diff --git a/Sunrise/src/client/content/vendors/package_vendor_build.cpp b/Sunrise/src/client/content/vendors/package_vendor_build.cpp
index ecdd06a5a..2b2829e34 100644
--- a/Sunrise/src/client/content/vendors/package_vendor_build.cpp
+++ b/Sunrise/src/client/content/vendors/package_vendor_build.cpp
@@ -20,9 +20,18 @@ namespace reader = middleware::content::packages::reader;
namespace tables = middleware::content::packages::tables;
namespace domain = state::build_data::vendors;
+/** One array a definition or a sale row declares, reduced to what the catalog stores. */
+struct ArrayView {
+ std::uint32_t base{};
+ std::uint32_t classId{};
+ std::uint16_t count{};
+};
+
/** Every extracted row, kept off the caller stack. */
struct Storage {
std::vector blob{};
+ std::vector factionBlob{};
+ ArrayView factionRows{};
std::array index{};
std::array definitions{};
std::array saleRows{};
@@ -39,6 +48,8 @@ struct Storage {
/** Only published prefixes are visible; extraction fully assigns each new row. */
void reset() noexcept {
blob.clear();
+ factionBlob.clear();
+ factionRows = {};
indexCount = 0;
definitionCount = 0;
saleRowCount = 0;
@@ -48,13 +59,6 @@ struct Storage {
}
};
-/** One array a definition or a sale row declares, reduced to what the catalog stores. */
-struct ArrayView {
- std::uint32_t base{};
- std::uint32_t classId{};
- std::uint16_t count{};
-};
-
/** @param blob Source bytes. @param offset Field offset. @param value Receives the field. */
template
[[nodiscard]] bool
@@ -103,6 +107,17 @@ read(std::span blob, std::size_t offset, Value& value) noexcept
return true;
}
+/** Reads the native faction rows used by vendor definition +18. */
+[[nodiscard]] bool
+read_factions(const reader::Source& source, reader::Scratch& scratch, Storage& storage) noexcept {
+ return reader::read_tag(source, scratch, kFactionTableTag, storage.factionBlob)
+ && read_array(std::span{storage.factionBlob},
+ kFactionArrayDescriptor,
+ kFactionRowStride,
+ storage.factionRows)
+ && storage.factionRows.count != 0 && storage.factionRows.classId == kFactionRowClass;
+}
+
/**
* Reads one sale row's cost entries and whether they are its price.
* An entry is static only with both expression arrays empty and the plain trailing word. Any other
@@ -290,6 +305,26 @@ read_index(const reader::Source& source, reader::Scratch& scratch, Storage& stor
definition.thirdRowBase = third.base;
definition.thirdRowClass = third.classId;
definition.thirdCount = third.count;
+ if (!read(blob, kVendorFactionIndexOffset, definition.factionIndexRaw)) {
+ return false;
+ }
+ if (definition.factionIndexRaw < storage.factionRows.count) {
+ const std::size_t at =
+ storage.factionRows.base
+ + static_cast(definition.factionIndexRaw) * kFactionRowStride;
+ std::uint32_t progressionIndex = 0;
+ if (!read(std::span{storage.factionBlob},
+ at + kFactionHashOffset,
+ definition.factionHash)
+ || !read(std::span{storage.factionBlob},
+ at + kFactionProgressionIndexOffset,
+ progressionIndex)
+ || definition.factionHash == 0
+ || progressionIndex > (std::numeric_limits::max)()) {
+ return false;
+ }
+ definition.factionProgressionIndex = static_cast(progressionIndex);
+ }
definition.saleRowOffset = static_cast(storage.saleRowCount);
definition.installedRowOffset = static_cast(storage.installedRowCount);
// A skipped definition must leave both banks exactly as it found them; an orphan sale row
@@ -347,6 +382,13 @@ bool build(const reader::Source& source, reader::Scratch& scratch) noexcept {
}
static Storage storage{};
storage.reset();
+ if (!read_factions(source, scratch, storage)) {
+ // A missing faction table removes reputation links, not ordinary vendor sales.
+ storage.factionRows = {};
+ core::log::write(core::log::Channel::state,
+ core::log::Level::warn,
+ "ev=build_data stage=vendors result=factions_unavailable");
+ }
if (!read_index(source, scratch, storage)) {
report(storage, 0, "index");
return false;
diff --git a/Sunrise/src/client/content/vendors/package_vendor_gate_build.cpp b/Sunrise/src/client/content/vendors/package_vendor_gate_build.cpp
new file mode 100644
index 000000000..7028c838f
--- /dev/null
+++ b/Sunrise/src/client/content/vendors/package_vendor_gate_build.cpp
@@ -0,0 +1,352 @@
+#include
+#include
+#include
+#include
+#include
+
+#include "../../../core/logging/log.h"
+#include "../../../middleware/content/packages/tables/definition_index_table.h"
+#include "../../../middleware/content/packages/tables/field_reader.h"
+#include "../../../state/build_data/rewards/reward_catalog.h"
+#include "../../../state/build_data/runtime.h"
+#include "../../../state/build_data/vendors/vendor_catalog.h"
+#include "../../../state/build_data/vendors/vendor_gate_catalog.h"
+#include "layout.h"
+#include "vendor_build.h"
+
+namespace sunrise::client::content::vendors {
+namespace {
+
+namespace reader = middleware::content::packages::reader;
+namespace tables = middleware::content::packages::tables;
+namespace domain = state::build_data::vendors;
+namespace unlocks = state::unlocks;
+
+/** The native slot maps mark unmapped positions with the all-ones row value. */
+constexpr std::uint16_t kUnmappedRow = 0xFFFFU;
+/** Native class of an eight-byte unlock instruction in an interaction or sale gate. */
+constexpr std::uint32_t kInstructionClass = 0x80807D31U;
+/** Native class of a sale gate list's sixteen-byte expression descriptors. */
+constexpr std::uint32_t kProgramListClass = 0x80807D2FU;
+
+/** One unbound opcode and native slot or literal read from a vendor row. */
+struct NativeInstruction {
+ unlocks::Opcode opcode{};
+ std::int32_t operand{};
+};
+
+/** Native instruction storage before unlock slots are mapped to saved banks. */
+struct NativeProgram {
+ std::array instructions{};
+ std::size_t count{};
+};
+
+/**
+ * Appends one native instruction array, refusing unknown opcodes and capacity overflow.
+ * @param blob Vendor definition bytes.
+ * @param field Array descriptor offset.
+ * @param program Accumulates the decoded instructions.
+ * @return False when the array cannot be decoded within the fixed capacity.
+ */
+[[nodiscard]] bool append_program(std::span blob,
+ std::size_t field,
+ NativeProgram& program) noexcept {
+ tables::Array rows{};
+ if (!tables::read_array(blob, field, kInstructionClass, tables::kUnlockInstructionStride, rows)
+ || rows.count > program.instructions.size() - program.count) {
+ return false;
+ }
+ for (std::size_t index = 0; index < rows.count; ++index) {
+ const auto at = rows.dataOffset + index * tables::kUnlockInstructionStride;
+ std::uint32_t nativeOpcode = 0;
+ std::int32_t operand = 0;
+ unlocks::Opcode opcode{};
+ if (!tables::read(blob, at, nativeOpcode)
+ || !tables::read(blob, at + tables::kUnlockInstructionOperandOffset, operand)
+ || !unlocks::decode_opcode(nativeOpcode, opcode)) {
+ return false;
+ }
+ program.instructions[program.count++] = {opcode, operand};
+ }
+ return true;
+}
+
+/** Reads one interaction's direct native instruction array. */
+[[nodiscard]] bool
+read_program(std::span blob, std::size_t field, NativeProgram& output) noexcept {
+ output = {};
+ return append_program(blob, field, output);
+}
+
+/**
+ * Joins a sale's authored program list with postfix AND.
+ * @param blob Vendor definition bytes.
+ * @param field Program-list descriptor offset.
+ * @param output Receives the complete program, or remains empty on failure.
+ * @return False when a listed program is empty, malformed or over capacity.
+ */
+[[nodiscard]] bool read_program_list(std::span blob,
+ std::size_t field,
+ NativeProgram& output) noexcept {
+ output = {};
+ tables::Array rows{};
+ if (!tables::read_array(
+ blob, field, kProgramListClass, tables::kUnlockExpressionFieldSize, rows)) {
+ return false;
+ }
+ NativeProgram parsed{};
+ for (std::size_t index = 0; index < rows.count; ++index) {
+ const auto at = rows.dataOffset + index * tables::kUnlockExpressionFieldSize;
+ const auto before = parsed.count;
+ if (!append_program(blob, at, parsed) || parsed.count == before) {
+ return false;
+ }
+ if (index != 0) {
+ if (parsed.count == parsed.instructions.size()) {
+ return false;
+ }
+ parsed.instructions[parsed.count++] = {unlocks::Opcode::logicalAnd, 0};
+ }
+ }
+ output = parsed;
+ return true;
+}
+
+/** Binds one input to at most one saved bank; unmapped slots require Family-5 values. */
+[[nodiscard]] bool bind_input(const NativeInstruction& instruction,
+ const GateMaps& maps,
+ domain::GateInput& output) noexcept {
+ output = {};
+ const auto slot = instruction.operand;
+ if (slot < 0 || static_cast(slot) >= maps.accountFlag.size()
+ || static_cast(slot) >= maps.profileFlag.size()
+ || static_cast(slot) >= maps.characterFlag.size()
+ || static_cast(slot) >= maps.accountValue.size()
+ || static_cast(slot) >= maps.characterValue.size()) {
+ return false;
+ }
+ output.opcode = instruction.opcode;
+ output.slot = static_cast(slot);
+ output.bank = domain::GateBank::external;
+ const auto select = [&](std::span map, domain::GateBank bank) {
+ if (map[slot] == kUnmappedRow) {
+ return true;
+ }
+ if (output.bank != domain::GateBank::external) {
+ return false;
+ }
+ output.bank = bank;
+ output.row = map[slot];
+ return true;
+ };
+ if (instruction.opcode == domain::Opcode::flag) {
+ return select(maps.accountFlag, domain::GateBank::accountFlag)
+ && select(maps.profileFlag, domain::GateBank::profileFlag)
+ && select(maps.characterFlag, domain::GateBank::characterFlag);
+ }
+ return select(maps.accountValue, domain::GateBank::accountValue)
+ && select(maps.characterValue, domain::GateBank::characterValue);
+}
+
+/**
+ * Retains native slots for callback lookup and maps only inputs the expression reads.
+ * @param program Decoded native instructions.
+ * @param maps Installed unlock-slot mappings.
+ * @param gate Receives the bound program and inputs.
+ * @return False when an input is ambiguous, out of range or over capacity.
+ */
+[[nodiscard]] bool
+bind_gate(const NativeProgram& program, const GateMaps& maps, domain::Gate& gate) noexcept {
+ gate = {};
+ gate.program.count = program.count;
+ for (std::size_t row = 0; row < program.count; ++row) {
+ const auto& instruction = program.instructions[row];
+ const bool readsSlot = instruction.opcode == domain::Opcode::flag
+ || instruction.opcode == domain::Opcode::loadValue;
+ auto& bound = gate.program.instructions[row];
+ // Keep the native slot so a Family-5 override can precede its saved-bank mapping.
+ bound = {instruction.opcode,
+ readsSlot ? unlocks::Bank::external : unlocks::Bank::none,
+ static_cast(instruction.operand)};
+ if (!unlocks::valid(bound)) {
+ return false;
+ }
+ if (!readsSlot) {
+ continue;
+ }
+ const auto inputs = std::span{gate.inputs}.first(gate.inputCount);
+ const auto found = std::find_if(inputs.begin(), inputs.end(), [&](const auto& input) {
+ return input.opcode == instruction.opcode && input.slot == instruction.operand;
+ });
+ if (found != inputs.end()) {
+ continue;
+ }
+ if (gate.inputCount == gate.inputs.size()
+ || !bind_input(instruction, maps, gate.inputs[gate.inputCount])) {
+ return false;
+ }
+ ++gate.inputCount;
+ }
+ return true;
+}
+
+/** A free sale is a candidate only when its installed wrapper opens a reward pool. */
+[[nodiscard]] bool reward_package(const domain::SaleRow& sale) noexcept {
+ state::build_data::items::Definition item{};
+ state::build_data::rewards::Item reward{};
+ return sale.categoryIndex != domain::kAbsentCategoryIndex
+ && sale.priceState == domain::PriceState::plain && sale.costCount == 0
+ && state::build_data::find_item_definition_index(sale.itemIndex, item)
+ && state::build_data::find_reward_item(sale.itemIndex, reward)
+ && item.definitionHash == reward.definitionHash
+ && reward.poolIndex != state::build_data::rewards::kAbsent
+ && (reward.flags & state::build_data::rewards::kOpenOnAcquisition) != 0;
+}
+
+/**
+ * Appends one vendor's package-sale, interaction and reply gates only after complete decoding.
+ * @param source Borrowed package source; no package spans escape this call.
+ * @param scratch Reusable package-reader storage.
+ * @param maps Borrowed installed unlock-slot mappings.
+ * @param vendor Published vendor identity and expected array bounds.
+ * @param interactions Receives copied interaction and reply gates on success.
+ * @param sales Receives copied sale gates on success.
+ * @return False for unreadable or unsupported data; neither output is appended on failure.
+ */
+[[nodiscard]] bool read_vendor(const reader::Source& source,
+ reader::Scratch& scratch,
+ const GateMaps& maps,
+ const domain::Definition& vendor,
+ std::vector& interactions,
+ std::vector& sales) noexcept {
+ std::vector candidates;
+ for (std::size_t row = 0; row < vendor.saleCount; ++row) {
+ domain::SaleRow sale{};
+ if (domain::sale_row(vendor, row, sale) && reward_package(sale)) {
+ candidates.push_back(static_cast(row));
+ }
+ }
+ if (candidates.empty()) {
+ return true;
+ }
+ std::vector blob;
+ std::uint32_t classId = 0;
+ if (!reader::read_tag(source, scratch, vendor.definitionTag, blob, classId)
+ || classId != domain::kDefinitionClass || blob.size() != vendor.definitionSize) {
+ return false;
+ }
+ const std::span bytes{blob};
+ tables::Array saleRows{}, interactionRows{};
+ if (!tables::read_array(
+ bytes, kSaleArrayDescriptor, domain::kSaleRowClass, domain::kSaleRowStride, saleRows)
+ || saleRows.count != vendor.saleCount || saleRows.dataOffset != vendor.saleRowBase
+ || !tables::read_array(bytes,
+ kThirdArrayDescriptor,
+ kInteractionRowClass,
+ domain::kThirdRowStride,
+ interactionRows)
+ || interactionRows.count != vendor.thirdCount
+ || interactionRows.dataOffset != vendor.thirdRowBase) {
+ return false;
+ }
+ std::vector ownSales;
+ for (const auto row : candidates) {
+ const auto at = saleRows.dataOffset + row * domain::kSaleRowStride;
+ domain::SaleRow sale{};
+ NativeProgram admission{}, selection{};
+ domain::SaleGates gates{};
+ if (!domain::sale_row(vendor, row, sale)
+ || !read_program_list(bytes, at + kSaleAdmissionField, admission)
+ || !read_program_list(bytes, at + kSaleSelectionField, selection)
+ || !bind_gate(admission, maps, gates.admission)
+ || !bind_gate(selection, maps, gates.selection)) {
+ return false;
+ }
+ gates.vendorHash = vendor.definitionHash;
+ gates.index = row;
+ gates.categoryIndex = sale.categoryIndex;
+ ownSales.push_back(gates);
+ }
+ std::vector ownInteractions;
+ for (std::size_t row = 0; row < interactionRows.count; ++row) {
+ const auto at = interactionRows.dataOffset + row * domain::kThirdRowStride;
+ std::int32_t category = 0;
+ if (!tables::read(bytes, at + kInteractionCategoryOffset, category)) {
+ return false;
+ }
+ if (std::none_of(ownSales.begin(), ownSales.end(), [&](const auto& sale) {
+ return sale.categoryIndex == category;
+ })) {
+ continue;
+ }
+ NativeProgram condition{};
+ domain::InteractionGate gate{};
+ tables::Array replies{};
+ if (!read_program(bytes, at + kInteractionConditionField, condition)
+ || !bind_gate(condition, maps, gate.condition)
+ || !tables::read_array(
+ bytes, at + kInteractionReplyField, kReplyRowClass, kReplyRowStride, replies)) {
+ return false;
+ }
+ for (std::size_t reply = 0; reply < replies.count; ++reply) {
+ NativeProgram program{};
+ domain::Gate replyCondition{};
+ if (!read_program(bytes, replies.dataOffset + reply * kReplyRowStride, program)
+ || !bind_gate(program, maps, replyCondition)) {
+ return false;
+ }
+ gate.replyConditions.push_back(replyCondition);
+ }
+ gate.vendorHash = vendor.definitionHash;
+ gate.index = static_cast(row);
+ gate.categoryIndex = category;
+ ownInteractions.push_back(gate);
+ }
+ sales.insert(sales.end(), ownSales.begin(), ownSales.end());
+ interactions.insert(interactions.end(), ownInteractions.begin(), ownInteractions.end());
+ return true;
+}
+
+} // namespace
+
+/** Reads only faction vendors with free installed reward wrappers. */
+bool build_gates(const reader::Source& source,
+ reader::Scratch& scratch,
+ const GateMaps& maps) noexcept {
+ if (domain::gates_ready()) {
+ return true;
+ }
+ static std::array definitions{};
+ std::size_t count = 0;
+ if (!domain::snapshot_definitions(definitions, count)) {
+ return false;
+ }
+ std::vector interactions;
+ std::vector sales;
+ std::size_t skipped = 0;
+ for (std::size_t row = 0; row < count; ++row) {
+ const auto& vendor = definitions[row];
+ if (vendor.factionProgressionIndex == domain::kUnavailableFactionProgressionIndex) {
+ continue;
+ }
+ if (!read_vendor(source, scratch, maps, vendor, interactions, sales)) {
+ ++skipped;
+ core::log::writef(core::log::Channel::state,
+ core::log::Level::warn,
+ "ev=build_data stage=vendor_gates result=skip hash=0x%08X",
+ vendor.definitionHash);
+ }
+ }
+ const bool published = domain::replace_gates(interactions, sales);
+ core::log::writef(
+ core::log::Channel::state,
+ published ? core::log::Level::info : core::log::Level::warn,
+ "ev=build_data stage=vendor_gates result=%s interactions=%zu sales=%zu skipped=%zu",
+ published ? "ok" : "fail",
+ interactions.size(),
+ sales.size(),
+ skipped);
+ return published;
+}
+
+} // namespace sunrise::client::content::vendors
diff --git a/Sunrise/src/client/content/vendors/vendor_build.h b/Sunrise/src/client/content/vendors/vendor_build.h
index 5d01ea917..4e172da60 100644
--- a/Sunrise/src/client/content/vendors/vendor_build.h
+++ b/Sunrise/src/client/content/vendors/vendor_build.h
@@ -1,5 +1,8 @@
#pragma once
+#include
+#include
+
#include "../../../middleware/content/packages/reader/reader.h"
namespace sunrise::client::content::vendors {
@@ -14,4 +17,24 @@ namespace sunrise::client::content::vendors {
[[nodiscard]] bool build(const middleware::content::packages::reader::Source& source,
middleware::content::packages::reader::Scratch& scratch) noexcept;
+/** Installed unlock-slot maps used to bind native vendor expression operands to saved rows. */
+struct GateMaps {
+ std::span accountFlag;
+ std::span profileFlag;
+ std::span characterFlag;
+ std::span accountValue;
+ std::span characterValue;
+};
+
+/**
+ * Extracts faction-package gates each process, including when vendor rows came from cache.
+ * @param source Installed package directory and borrowed keys.
+ * @param scratch Caller-owned package reader storage.
+ * @param maps Native unlock-slot mappings retained from the investment root.
+ * @return True after the complete derived gate catalog is published.
+ */
+[[nodiscard]] bool build_gates(const middleware::content::packages::reader::Source& source,
+ middleware::content::packages::reader::Scratch& scratch,
+ const GateMaps& maps) noexcept;
+
} // namespace sunrise::client::content::vendors
diff --git a/Sunrise/src/middleware/content/packages/tables/activity_table.h b/Sunrise/src/middleware/content/packages/tables/activity_table.h
index c10becf27..8613c76b5 100644
--- a/Sunrise/src/middleware/content/packages/tables/activity_table.h
+++ b/Sunrise/src/middleware/content/packages/tables/activity_table.h
@@ -4,6 +4,7 @@
#include "../../../../state/build_data/activities/activity_catalog.h"
#include "definition_index_table.h"
+#include "field_reader.h"
namespace sunrise::middleware::content::packages::tables::activities {
/** Public activity index, NOT the package-definition index in root slot 29. */
@@ -32,26 +33,6 @@ template
std::memcpy(&value, bytes.data() + offset, sizeof(T));
return true;
}
-/**
- * Resolves a self-relative field.
- * @param field Offset of the 8-byte signed delta.
- * @param target Receives the resolved offset.
- * @return False when the delta is zero or the target falls outside the blob.
- */
-[[nodiscard]] inline bool
-relative(std::span bytes, std::size_t field, std::size_t& target) noexcept {
- std::int64_t delta{};
- if (!read(bytes, field, delta) || delta == 0
- || field > static_cast((std::numeric_limits::max)())) {
- return false;
- }
- const auto base = static_cast(field);
- if (delta < -base || delta > (std::numeric_limits::max)() - base) {
- return false;
- }
- target = static_cast(base + delta);
- return target < bytes.size();
-}
/**
* Parses the dense public activity table.
* @param bytes The table blob.
diff --git a/Sunrise/src/middleware/content/packages/tables/definition_index_table.cpp b/Sunrise/src/middleware/content/packages/tables/definition_index_table.cpp
index 808c396d5..1bcbfe584 100644
--- a/Sunrise/src/middleware/content/packages/tables/definition_index_table.cpp
+++ b/Sunrise/src/middleware/content/packages/tables/definition_index_table.cpp
@@ -126,6 +126,18 @@ bool find_optional_array_at(std::span blob,
return resolve_descriptor(blob, descriptorOffset, true, output);
}
+/** Reads an optional typed array and checks every fixed-stride row lies in its blob. */
+bool read_array(std::span blob,
+ std::size_t at,
+ std::uint32_t elementClass,
+ std::size_t stride,
+ Array& rows) noexcept {
+ return stride != 0 && find_optional_array_at(blob, at, rows)
+ && (rows.count == 0
+ || (rows.elementClass == elementClass && rows.dataOffset <= blob.size()
+ && rows.count <= (blob.size() - rows.dataOffset) / stride));
+}
+
/** Finds the first array whose header names one element class. */
bool find_array(std::span blob,
std::uint32_t elementClass,
diff --git a/Sunrise/src/middleware/content/packages/tables/definition_index_table.h b/Sunrise/src/middleware/content/packages/tables/definition_index_table.h
index 5d0eec5df..268589974 100644
--- a/Sunrise/src/middleware/content/packages/tables/definition_index_table.h
+++ b/Sunrise/src/middleware/content/packages/tables/definition_index_table.h
@@ -30,6 +30,8 @@ inline constexpr std::uint16_t kAbsentPackageId = 0xFFFFU;
/** Element class of the item index table inside the investment container. */
inline constexpr std::uint32_t kItemIndexTableClass = 0x80807BE8U;
+/** Serialized width of one item-index row; byte zero carries the definition hash. */
+inline constexpr std::size_t kItemIndexRowStride = 24;
/** Serialized item definition class, distinct from the item index table class. */
inline constexpr std::uint32_t kItemDefinitionClass = 0x80807BEAU;
/** Investment root slot of the records and lore table. */
@@ -103,6 +105,8 @@ inline constexpr std::uint32_t kUnlockReadValueOpcode = 10;
inline constexpr std::uint32_t kUnlockReadFlagOpcode = 1;
/** The opcode that pushes a literal. */
inline constexpr std::uint32_t kUnlockLiteralOpcode = 11;
+/** The opcode that expands a shared expression row. Its operand is the row index. */
+inline constexpr std::uint32_t kUnlockExpressionOpcode = 12;
/** The opcode that tests greater than or equal. */
inline constexpr std::uint32_t kUnlockGreaterEqualOpcode = 14;
/** High half every installed array marker and element class carries. */
@@ -123,6 +127,11 @@ inline constexpr std::size_t kNodeExpressionFieldAlternate = 48;
/** Records a node owns, four bytes each as a row then a gate. */
inline constexpr std::size_t kNodeChildRecordField = 136;
inline constexpr std::size_t kNodeChildRecordStride = 4;
+/**
+ * Array descriptor of the profile flag mapping table, between the account (+8) and character (+40)
+ * maps. Profile flags are account-wide but saved in their own bank, not the account flag bank.
+ */
+inline constexpr std::size_t kProfileFlagMapDescriptor = 24;
/** Array descriptor of the character object's flag mapping table, sized to that bank. */
inline constexpr std::size_t kCharacterFlagMapDescriptor = 40;
/** Array descriptor of the character object's value mapping table, sized to that bank. */
@@ -202,6 +211,8 @@ inline constexpr std::uint32_t kProgressionTableClass = 0x80807CDDU;
inline constexpr std::size_t kProgressionRowStride = 88;
/** A progression definition names the object array holding its record here. */
inline constexpr std::size_t kProgressionScopeOffset = 4;
+/** A progression definition repeats its final step when this byte is one. */
+inline constexpr std::size_t kProgressionRepeatLastStepOffset = 5;
/** Rank steps a progression declares: the experience each rank costs, in rank order. */
inline constexpr std::size_t kProgressionStepField = 24;
inline constexpr std::uint32_t kProgressionStepRowClass = 0x80807CEDU;
@@ -216,9 +227,14 @@ inline constexpr std::size_t kProgressionRewardItemIndexOffset = 4;
inline constexpr std::size_t kProgressionRewardQuantityOffset = 8;
/** Unlock flag slot a reward's claim sets. The slots run dense in reward order. */
inline constexpr std::size_t kProgressionRewardClaimSlotOffset = 20;
-/** Items a wrapper item opens into, two bytes each as an item-definition index. */
+/** Eligibility expressions and socket overrides carried by one progression reward. */
+inline constexpr std::size_t kProgressionRewardConditionsOffset = 24;
+inline constexpr std::size_t kProgressionRewardSocketsOffset = 40;
+/** Item-definition field containing the wrapper's gear-set array. */
inline constexpr std::size_t kGearsetItemField = 392;
+/** Element class of one gear-set item-index row. */
inline constexpr std::uint32_t kGearsetItemRowClass = 0x808087DBU;
+/** Serialized width of one gear-set item index. */
inline constexpr std::size_t kGearsetItemStride = 2;
/** Expression that makes one collectible acquired, which a purchase or a grant sets. */
@@ -277,6 +293,13 @@ using RowVisitor = bool (*)(void* context, std::uint32_t index, const IndexRow&
std::size_t descriptorOffset,
Array& output) noexcept;
+/** Reads an optional typed array and checks every fixed-stride row lies in its blob. */
+[[nodiscard]] bool read_array(std::span blob,
+ std::size_t at,
+ std::uint32_t elementClass,
+ std::size_t stride,
+ Array& rows) noexcept;
+
/**
* Finds the first array whose header names one element class.
* @param blob Whole definition bytes.
diff --git a/Sunrise/src/middleware/content/packages/tables/field_reader.h b/Sunrise/src/middleware/content/packages/tables/field_reader.h
new file mode 100644
index 000000000..55ed6d27e
--- /dev/null
+++ b/Sunrise/src/middleware/content/packages/tables/field_reader.h
@@ -0,0 +1,49 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+
+namespace sunrise::middleware::content::packages::tables {
+/**
+ * Reads one little-endian field that must lie inside the blob.
+ * @param blob Whole definition bytes.
+ * @param offset Field offset.
+ * @param value Receives the field.
+ * @return True when the whole field is inside the blob.
+ */
+template
+[[nodiscard]] inline bool
+read(std::span blob, std::size_t offset, Value& value) noexcept {
+ // Subtracting rather than adding keeps a large offset from wrapping past the size.
+ if (offset > blob.size() || blob.size() - offset < sizeof value) {
+ return false;
+ }
+ std::memcpy(&value, blob.data() + offset, sizeof value);
+ return true;
+}
+
+/**
+ * Resolves a self-relative field.
+ * @param field Offset of the 8-byte signed delta.
+ * @param target Receives the resolved offset.
+ * @return False when the delta is zero or the target falls outside the blob.
+ */
+[[nodiscard]] inline bool
+relative(std::span bytes, std::size_t field, std::size_t& target) noexcept {
+ std::int64_t delta{};
+ if (!read(bytes, field, delta) || delta == 0
+ || field > static_cast((std::numeric_limits::max)())) {
+ return false;
+ }
+ const auto base = static_cast(field);
+ if (delta < -base || delta > (std::numeric_limits::max)() - base) {
+ return false;
+ }
+ target = static_cast(base + delta);
+ return target < bytes.size();
+}
+
+} // namespace sunrise::middleware::content::packages::tables
diff --git a/Sunrise/src/middleware/content/packages/tables/internal.h b/Sunrise/src/middleware/content/packages/tables/internal.h
index 393ac00d0..2384f4996 100644
--- a/Sunrise/src/middleware/content/packages/tables/internal.h
+++ b/Sunrise/src/middleware/content/packages/tables/internal.h
@@ -2,28 +2,11 @@
#include
#include
-#include
#include
-namespace sunrise::middleware::content::packages::tables {
+#include "field_reader.h"
-/**
- * Reads one little-endian field that must lie inside the blob.
- * @param blob Whole definition bytes.
- * @param offset Field offset.
- * @param value Receives the field.
- * @return True when the whole field is inside the blob.
- */
-template
-[[nodiscard]] inline bool
-read(std::span blob, std::size_t offset, Value& value) noexcept {
- // Subtracting rather than adding keeps a large offset from wrapping past the size.
- if (offset > blob.size() || blob.size() - offset < sizeof value) {
- return false;
- }
- std::memcpy(&value, blob.data() + offset, sizeof value);
- return true;
-}
+namespace sunrise::middleware::content::packages::tables {
/**
* Finds the offset of one fixed-stride array element.
diff --git a/Sunrise/src/middleware/web_service/messages/opcode904/opcode904_codec.cpp b/Sunrise/src/middleware/web_service/messages/opcode904/opcode904_codec.cpp
index 7bdc38282..69953c965 100644
--- a/Sunrise/src/middleware/web_service/messages/opcode904/opcode904_codec.cpp
+++ b/Sunrise/src/middleware/web_service/messages/opcode904/opcode904_codec.cpp
@@ -1,6 +1,6 @@
/**
- * Opcode 904 acquires a quest or other pursuit from a vendor.
- * Three 16-bit fields biased by 0x8000 (vendor, clicked slot, one always zero), then a 32-bit
+ * Opcode 904 carries vendor acquisitions and interaction replies, including rank claims.
+ * Three 16-bit selectors biased by 0x8000, then a 32-bit
* field biased by 0x80000000 naming the sale row, then one trailing byte that is skipped.
*/
@@ -23,7 +23,7 @@ constexpr std::size_t kTrailingLimit = 8;
} // namespace
-/** Decodes one quest-acquire request body. */
+/** Decodes one vendor acquisition or interaction request body. */
bool parse_request(const Message& message, Request& output) noexcept {
if (message.opcode != kOpcode) {
return false;
diff --git a/Sunrise/src/middleware/web_service/messages/opcode904/opcode904_codec.h b/Sunrise/src/middleware/web_service/messages/opcode904/opcode904_codec.h
index 0e8df1f54..7fd412d3c 100644
--- a/Sunrise/src/middleware/web_service/messages/opcode904/opcode904_codec.h
+++ b/Sunrise/src/middleware/web_service/messages/opcode904/opcode904_codec.h
@@ -6,16 +6,18 @@
namespace sunrise::middleware::web_service::messages::opcode904 {
-/** Web Service opcode for acquiring a quest or other pursuit from a vendor. */
+/** Web Service opcode for vendor acquisitions and interaction replies, including rank claims. */
inline constexpr std::uint16_t kOpcode = 904;
+/** Logical -1 selects an interaction without a sale row. */
+inline constexpr std::int32_t kAbsentSaleIndex = -1;
-/** One decoded quest-acquire request: three biased 16-bit fields then one biased 32-bit row. */
+/** One vendor request: three biased 16-bit selectors followed by a biased 32-bit sale row. */
struct Request {
/** Index into the vendor table, the same table 901 indexes. */
std::int16_t vendorIndex{};
/** UI slot the click landed on. Not a sale row. */
std::int16_t slotIndex{};
- /** Always zero on the wire; meaning unknown. */
+ /** Reply index for rowless interactions; sale-backed semantics are unresolved. */
std::int16_t third{};
/**
* Sale row of the vendor definition, 32-bit biased by 0x80000000.
@@ -27,8 +29,7 @@ struct Request {
};
/**
- * Decodes one quest-acquire request body.
- * The four fields account for ten of the eleven body bytes; the trailing byte is skipped.
+ * Decodes three required selectors and an optional sale row, allowing one trailing byte.
* @param message Parsed Web Service envelope.
* @param output Receives the request only when the three leading fields decode.
* @return True when the opcode matches and those fields are present.
diff --git a/Sunrise/src/server/bap/bap_world_reward_queue.cpp b/Sunrise/src/server/bap/bap_world_reward_queue.cpp
index 6c27c8ea6..3cbd24b66 100644
--- a/Sunrise/src/server/bap/bap_world_reward_queue.cpp
+++ b/Sunrise/src/server/bap/bap_world_reward_queue.cpp
@@ -1,4 +1,10 @@
+#include
+#include
+#include
+#include
+
#include "../../core/logging/log.h"
+#include "../../middleware/crypto/random_bytes.h"
#include "../../state/build_data/runtime.h"
#include "../../state/investment/store_internal.h"
#include "internal.h"
@@ -6,25 +12,72 @@
namespace sunrise::server::bap {
namespace {
-/** Grants and retires one earned reward in the same database transaction. */
-bool commit_world_reward(const WorldRewardRequest& request) noexcept {
+/** Logged for a saved row whose hash is not in the item table. */
+constexpr std::uint16_t kUnresolvedItemIndex = (std::numeric_limits::max)();
+
+/** Outcome of one settlement attempt. */
+enum class WorldRewardSettlement : std::uint8_t { granted, retired, kept };
+
+/** Grants and retires one earned reward in one transaction, or retires one State rules out. */
+WorldRewardSettlement commit_world_reward(const WorldRewardRequest& request) noexcept {
state::investment::store::Transaction transaction;
if (!transaction.ready()) {
- return false;
+ return WorldRewardSettlement::kept;
}
bool committed = false;
+ bool unresolvable = false;
+ const char* reason = nullptr;
if (request.kind == WorldRewardKind::item) {
- state::PendingItemAcquisition acquisition;
- committed =
- state::prepare_item_acquisition_for_item(request.itemDefinitionIndex, acquisition)
- && state::commit_item_acquisition(acquisition);
+ if (state::item_grant_route(request.itemDefinitionIndex) == state::ItemGrantRoute::quest) {
+ if (request.quantity != 1) {
+ unresolvable = true;
+ reason = "instance_quantity";
+ } else {
+ state::PendingItemAcquisition acquisition;
+ committed = state::prepare_item_acquisition_for_item(request.itemDefinitionIndex,
+ acquisition)
+ && state::commit_item_acquisition(acquisition);
+ }
+ } else {
+ const std::unique_ptr grant(
+ new (std::nothrow) state::PendingRecordRewardGrant);
+ std::uint64_t seed = 0;
+ const bool seeded =
+ middleware::crypto::random::fill(std::as_writable_bytes(std::span(&seed, 1)));
+ reason = seeded ? "reward_allocation" : "random_source";
+ auto preparation = state::RewardPreparation::deferred;
+ if (seeded && grant) {
+ preparation =
+ state::prepare_item_reward(request.itemDefinitionIndex,
+ static_cast(request.quantity),
+ seed,
+ *grant,
+ &reason);
+ }
+ unresolvable = preparation == state::RewardPreparation::unresolvable;
+ committed = preparation == state::RewardPreparation::prepared;
+ if (committed) {
+ reason = "inventory_commit";
+ committed = state::commit_record_reward(*grant);
+ }
+ if (!committed && !unresolvable) {
+ report_reward_refusal("world_settle", request.itemDefinitionIndex, reason);
+ }
+ }
} else {
state::PendingProfileItemAcquisition acquisition;
committed = state::prepare_profile_item_acquisition_for_item(
request.itemDefinitionIndex, request.quantity, acquisition)
&& state::commit_profile_item_acquisition(acquisition);
}
- return committed && complete_world_reward(request.id) && transaction.commit();
+ if (unresolvable) {
+ return retire_world_reward(transaction, request, reason) ? WorldRewardSettlement::retired
+ : WorldRewardSettlement::kept;
+ }
+ if (!committed || !complete_world_reward(request.id) || !transaction.commit()) {
+ return WorldRewardSettlement::kept;
+ }
+ return WorldRewardSettlement::granted;
}
/** Saves the reward under its stable definition hash and earning character. */
@@ -45,6 +98,16 @@ bool enqueue_world_reward(std::uint16_t definitionIndex,
} // namespace
+/** Logs one refused reward stage with its native index and a specific reason. */
+void report_reward_refusal(const char* stage, std::uint16_t index, const char* reason) noexcept {
+ core::log::writef(core::log::Channel::server,
+ core::log::Level::warn,
+ "ev=reward stage=%s index=%u result=refused reason=%s",
+ stage,
+ static_cast(index),
+ reason != nullptr ? reason : "unknown");
+}
+
/** Saves one item reward before its pickup presentation is queued. */
bool arm_world_item_acquisition(std::uint16_t itemDefinitionIndex) noexcept {
return enqueue_world_reward(itemDefinitionIndex, 1, WorldRewardKind::item);
@@ -57,40 +120,82 @@ bool arm_world_profile_item_acquisition(std::uint16_t itemDefinitionIndex,
&& enqueue_world_reward(itemDefinitionIndex, quantity, WorldRewardKind::profileItem);
}
-/** Restores the oldest reward belonging to the selected character. */
+/** Restores the selected character's oldest reward, retiring rows that can never be granted. */
bool current_world_reward(WorldRewardRequest& request) noexcept {
- request = {};
state::investment::store::PendingReward saved;
- state::build_data::items::Definition definition;
- if (!state::investment::store::next_reward(saved)
- || !state::build_data::find_item_definition_hash(saved.definitionHash, definition)) {
- return false;
+ while (state::investment::store::next_reward(saved)) {
+ request = {};
+ request.id = saved.id;
+ request.definitionHash = saved.definitionHash;
+ request.quantity = saved.quantity;
+ request.kind = static_cast(saved.kind);
+ const bool itemsPublished = state::build_data::item_definitions_ready();
+ state::build_data::items::Definition definition;
+ const char* verdict = "reward_quantity";
+ if (state::build_data::find_item_definition_hash(saved.definitionHash, definition)) {
+ request.itemDefinitionIndex = definition.definitionIndex;
+ if (request.quantity > 0) {
+ return true;
+ }
+ } else if (!itemsPublished || !state::build_data::item_definitions_ready()) {
+ // An unpublished item table proves nothing; retry later.
+ break;
+ } else {
+ request.itemDefinitionIndex = kUnresolvedItemIndex;
+ verdict = "item_definition";
+ }
+ state::investment::store::Transaction transaction;
+ if (!transaction.ready() || !retire_world_reward(transaction, request, verdict)) {
+ break;
+ }
}
- request.id = saved.id;
- request.quantity = saved.quantity;
- request.itemDefinitionIndex = definition.definitionIndex;
- request.kind = static_cast(saved.kind);
- return true;
+ request = {};
+ return false;
}
-/** Acknowledges exactly the reward whose inventory grant is being committed. */
+/** Acknowledges exactly the reward being granted or retired. */
bool complete_world_reward(std::uint64_t id) noexcept {
return state::investment::store::complete_reward(id);
}
-/** A failed grant stays saved for a later attempt. */
+/** Deletes the row on the caller's open savepoint, commits, and logs why. */
+bool retire_world_reward(state::investment::store::Transaction& transaction,
+ const WorldRewardRequest& request,
+ const char* reason) noexcept {
+ const bool retired = complete_world_reward(request.id) && transaction.commit();
+ core::log::writef(core::log::Channel::server,
+ core::log::Level::warn,
+ "ev=reward stage=world_retire id=%llu hash=0x%08X index=%u quantity=%d "
+ "kind=%u result=%s reason=%s",
+ static_cast(request.id),
+ static_cast(request.definitionHash),
+ static_cast(request.itemDefinitionIndex),
+ static_cast(request.quantity),
+ static_cast(request.kind),
+ retired ? "retired" : "kept",
+ reason != nullptr ? reason : "unknown");
+ return retired;
+}
+
+/** A retired reward lets the next one take its turn; any other failed grant stays saved. */
void settle_world_reward() noexcept {
WorldRewardRequest request;
- if (current_world_reward(request) && commit_world_reward(request)) {
- arm_account_resync_everywhere();
+ while (current_world_reward(request)) {
+ const WorldRewardSettlement settlement = commit_world_reward(request);
+ if (settlement == WorldRewardSettlement::granted) {
+ arm_account_resync_everywhere();
+ }
+ if (settlement != WorldRewardSettlement::retired) {
+ return;
+ }
}
}
-/** Shutdown may grant ready rewards; rewards for other characters stay in the database. */
+/** Shutdown grants or retires rewards until one must wait; other characters' rewards stay saved. */
void drain_world_rewards() noexcept {
WorldRewardRequest request;
while (current_world_reward(request)) {
- if (!commit_world_reward(request)) {
+ if (commit_world_reward(request) == WorldRewardSettlement::kept) {
break;
}
}
diff --git a/Sunrise/src/server/bap/encrypted/activity_message/receipts/activity_incident_grants.cpp b/Sunrise/src/server/bap/encrypted/activity_message/receipts/activity_incident_grants.cpp
index cf76bbd84..1dcd97d71 100644
--- a/Sunrise/src/server/bap/encrypted/activity_message/receipts/activity_incident_grants.cpp
+++ b/Sunrise/src/server/bap/encrypted/activity_message/receipts/activity_incident_grants.cpp
@@ -380,10 +380,7 @@ void grant_random_moon_loot() noexcept {
grant_lost_ghost_reward();
// One found ghost pays this much seasonal experience.
constexpr std::int32_t kBaseExperienceReward = 2500;
- const bool queued = bap::arm_seasonal_experience_presentation(kBaseExperienceReward);
- if (!queued) {
- (void)state::grant_seasonal_experience(kBaseExperienceReward);
- }
+ (void)bap::arm_seasonal_experience_presentation(kBaseExperienceReward);
}
return outcome;
}
diff --git a/Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp b/Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp
index 9834c6c58..3e67cabe8 100644
--- a/Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp
+++ b/Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp
@@ -307,6 +307,26 @@ bool process(const ServiceRoute& route,
return refuse_web_action(message, output, written);
}
}
+ const auto* reputation =
+ web_service::mutation_if(webOutcome);
+ if (reputation != nullptr) {
+ if (!queuezState.family4Active || queuezState.family4RootSoid != reputation->accountSoid
+ || queuezState.family4Version == (std::numeric_limits::max)()
+ || emplace_transaction(outcome, *reputation)
+ == nullptr) {
+ return refuse_web_action(message, output, written);
+ }
+ // The next account image carries both the material debit and character progression.
+ middleware::web_service::StatusResponse status{};
+ status.value = queuezState.family4Version + 1;
+ middleware::web_service::ResponseShape shape{};
+ web_service::resolve_response_shape(message.opcode, shape);
+ if (!middleware::web_service::encode_response(
+ message, shape, status, output, written)) {
+ clear_transaction(outcome);
+ return refuse_web_action(message, output, written);
+ }
+ }
if (equipmentSwap != nullptr) {
// Promise the Family-4 revision carrying this optimistic equip.
auto* transaction = emplace_transaction(outcome);
@@ -444,14 +464,13 @@ bool process(const ServiceRoute& route,
clear_transaction(outcome);
return refuse_web_action(message, output, written);
}
+ // The response shape requires the extra bit, not a true value.
middleware::web_service::StatusResponse status{};
status.value = transaction->update.after.family4Version;
+ middleware::web_service::ResponseShape shape{};
+ web_service::resolve_response_shape(message.opcode, shape);
if (!middleware::web_service::encode_response(
- message,
- middleware::web_service::ResponseShape::statusPair,
- status,
- output,
- written)) {
+ message, shape, status, output, written)) {
core::log::write(core::log::Channel::server,
core::log::Level::warn,
"ev=acquire stage=response result=fail");
@@ -480,12 +499,10 @@ bool process(const ServiceRoute& route,
}
middleware::web_service::StatusResponse status{};
status.value = transaction->update.after.family4Version;
+ middleware::web_service::ResponseShape shape{};
+ web_service::resolve_response_shape(message.opcode, shape);
if (!middleware::web_service::encode_response(
- message,
- middleware::web_service::ResponseShape::statusPair,
- status,
- output,
- written)) {
+ message, shape, status, output, written)) {
core::log::write(core::log::Channel::server,
core::log::Level::warn,
"ev=profile_acquire stage=response result=fail");
@@ -530,22 +547,9 @@ bool process(const ServiceRoute& route,
}
if (recordRewardGrant != nullptr) {
auto* transaction = emplace_transaction(outcome);
- std::array residents{};
- std::size_t residentCount = 0;
- for (std::size_t index = 0; index < recordRewardGrant->rewardCount; ++index) {
- const auto& reward = recordRewardGrant->rewards[index];
- if (reward.kind == state::RecordRewardKind::characterInstance
- || reward.appendedProfileResident) {
- residents[residentCount++] = reward.instanceSoid;
- }
- }
- const bool staged =
- transaction != nullptr
- && queuez::stage_record_reward_grant(queuezState,
- recordRewardGrant->accountSoid,
- recordRewardGrant->characterSoid,
- std::span(residents).first(residentCount),
- transaction->update);
+ const bool staged = transaction != nullptr
+ && queuez::stage_record_reward_grant(
+ queuezState, *recordRewardGrant, transaction->update);
if (!staged) {
core::log::write(core::log::Channel::server,
core::log::Level::warn,
@@ -555,12 +559,10 @@ bool process(const ServiceRoute& route,
}
middleware::web_service::StatusResponse status{};
status.value = transaction->update.after.family4Version;
+ middleware::web_service::ResponseShape shape{};
+ web_service::resolve_response_shape(message.opcode, shape);
if (!middleware::web_service::encode_response(
- message,
- middleware::web_service::ResponseShape::statusPair,
- status,
- output,
- written)) {
+ message, shape, status, output, written)) {
core::log::write(core::log::Channel::server,
core::log::Level::warn,
"ev=ws1801 stage=reward_response result=fail");
@@ -578,43 +580,9 @@ bool process(const ServiceRoute& route,
"ev=ws2400 stage=reward_preflight result=fail");
return refuse_web_action(message, output, written);
}
- bool staged = false;
- std::int32_t stagedVersion = 0;
- if (const auto* itemGrant =
- std::get_if(&seasonPassReward->grant)) {
- auto& update = transaction->update.emplace();
- staged = queuez::stage_item_acquisition(queuezState,
- itemGrant->accountSoid,
- itemGrant->characterSoid,
- itemGrant->acquiredInstanceSoid,
- true,
- update);
- stagedVersion = update.after.family4Version;
- } else if (const auto* profileGrant = std::get_if(
- &seasonPassReward->grant)) {
- auto& update = transaction->update.emplace();
- staged = queuez::stage_profile_item_acquisition(queuezState,
- profileGrant->accountSoid,
- profileGrant->acquiredInstanceSoid,
- profileGrant->actionSource,
- profileGrant->appended,
- update);
- stagedVersion = update.after.family4Version;
- } else if (const auto* bundle =
- std::get_if(&seasonPassReward->grant)) {
- staged = queuez::stage_direct_item_bundle(queuezState,
- bundle->accountSoid,
- bundle->characterSoid,
- bundle->firstInstanceSoid,
- bundle->itemCount,
- stagedVersion);
- } else if (const auto* resources =
- std::get_if(&seasonPassReward->grant)) {
- auto& update = transaction->update.emplace();
- staged = queuez::stage_record_reward_grant(
- queuezState, resources->accountSoid, resources->characterSoid, {}, update);
- stagedVersion = update.after.family4Version;
- }
+ const bool staged = queuez::stage_record_reward_grant(
+ queuezState, seasonPassReward->grant, transaction->update);
+ const auto stagedVersion = transaction->update.after.family4Version;
if (!staged) {
core::log::write(core::log::Channel::server,
core::log::Level::warn,
diff --git a/Sunrise/src/server/bap/encrypted/encrypted_runtime.cpp b/Sunrise/src/server/bap/encrypted/encrypted_runtime.cpp
index 9889346bb..d741cc617 100644
--- a/Sunrise/src/server/bap/encrypted/encrypted_runtime.cpp
+++ b/Sunrise/src/server/bap/encrypted/encrypted_runtime.cpp
@@ -397,6 +397,8 @@ bool consume(Session& session,
}
}
const bool artifactPurchase = transaction_if(outcome) != nullptr;
+ const bool vendorReputation =
+ transaction_if(outcome) != nullptr;
const bool mutatesAccount =
outcome.hasSelectCharacter || outcome.hasRecordClaim || outcome.hasArtifactReset
|| transaction_if(outcome) != nullptr
@@ -408,7 +410,7 @@ bool consume(Session& session,
|| transaction_if(outcome) != nullptr
|| transaction_if(outcome) != nullptr
|| transaction_if(outcome) != nullptr
- || transaction_if(outcome) != nullptr;
+ || transaction_if(outcome) != nullptr || vendorReputation;
const bool presentsAcquisition =
transaction_if(outcome) != nullptr
|| transaction_if(outcome) != nullptr
@@ -534,7 +536,7 @@ bool consume(Session& session,
session.activityKeepaliveDueTick = GetTickCount64() + kActivityKeepaliveIntervalMs;
}
const bool resyncsCommittedAccount =
- hasPrecommittedAccountAction && !queuezPublication.hasState;
+ vendorReputation || (hasPrecommittedAccountAction && !queuezPublication.hasState);
if (resyncsCommittedAccount) {
bap::arm_account_resync_everywhere();
}
diff --git a/Sunrise/src/server/bap/encrypted/internal.h b/Sunrise/src/server/bap/encrypted/internal.h
index 9339a1196..5678aacf4 100644
--- a/Sunrise/src/server/bap/encrypted/internal.h
+++ b/Sunrise/src/server/bap/encrypted/internal.h
@@ -141,15 +141,7 @@ struct SeasonPassRewardTransaction {
std::unique_ptr pending{};
- std::variant
-
- update{};
+ queuez::RecordRewardGrant update{};
};
/** Optional side effect produced while decoding one authenticated service body. */
@@ -212,7 +204,8 @@ struct ServiceOutcome {
std::unique_ptr