From 09fac1413eaa2f3a3ec3ef843728b270b3dfd47b Mon Sep 17 00:00:00 2001 From: Zach Date: Tue, 22 Sep 2026 22:46:25 -0500 Subject: [PATCH 01/12] Extract runtime reward definitions and resolve server grants Read installed reward pools, item wrappers, conditions, and Season Pass rows alongside item definitions. Resolve existing server grants through State and publish inventory and acquisition flags in the same transaction. Replace authored engram and material reward tables and Season Pass armor rolls. Share the extracted class-package path, preserve native claim indices, and apply earned progression flags as seasonal experience changes. --- Sunrise/Sunrise.vcxproj | 9 + .../client/content/items/packages/internal.h | 9 +- .../items/packages/package_item_build.cpp | 16 +- .../items/packages/package_item_rows.cpp | 12 +- .../items/packages/package_node_build.cpp | 6 +- .../items/packages/package_reward_build.cpp | 567 ++++++++++++++++++ .../items/packages/package_reward_build.h | 79 +++ .../packages/package_season_pass_build.cpp | 188 +++--- .../tables/definition_index_table.cpp | 25 +- .../packages/tables/definition_index_table.h | 69 ++- .../content/packages/tables/field_reader.h | 26 + .../content/packages/tables/internal.h | 21 +- .../tables/quest_initialization_reader.cpp | 5 +- .../packages/tables/unlock_expression.h | 5 +- .../src/server/bap/bap_world_reward_queue.cpp | 38 +- .../receipts/activity_incident_grants.cpp | 5 +- .../bap/encrypted/body/bap_service_body.cpp | 59 +- Sunrise/src/server/bap/encrypted/internal.h | 34 +- .../push/queuez/queuez_select_character.cpp | 53 -- .../push/snapshot/family4_reward_updates.cpp | 193 +----- .../bap/encrypted/push/snapshot/internal.h | 9 - .../encrypted/queuez/queuez_deferred_push.cpp | 69 +++ .../queuez/queuez_reward_staging.cpp | 96 +-- .../queuez/queuez_state_validation.h | 16 +- .../staging/queuez_character_staging.cpp | 62 +- Sunrise/src/server/bap/internal.h | 3 + .../web_service_record_actions.cpp | 315 +--------- Sunrise/src/state/account/account_state.h | 4 + .../state/build_data/build_data_runtime.cpp | 10 +- .../cache/read/cache_file_reader.cpp | 16 +- .../cache/read/cache_payload_reader.cpp | 47 +- .../cache/records/cache_domain_validation.cpp | 42 +- .../records/cache_investment_records.cpp | 50 +- .../cache/records/cache_reward_records.cpp | 123 ++++ .../state/build_data/cache/records/codec.h | 22 +- .../state/build_data/cache/records/domains.h | 22 +- .../state/build_data/cache/records/format.h | 110 +++- .../cache/write/cache_file_writer.cpp | 9 +- .../cache/write/cache_payload_writer.cpp | 18 +- .../write/temporary/temporary_cache_file.cpp | 7 +- .../build_data/inventory/buckets/definition.h | 2 + .../src/state/build_data/rewards/definition.h | 114 ++++ .../build_data/rewards/reward_catalog.cpp | 231 +++++++ .../state/build_data/rewards/reward_catalog.h | 30 + Sunrise/src/state/build_data/runtime.h | 21 +- .../runtime/build_data_catalog_runtime.cpp | 8 + .../persistence/build_data_persistence.cpp | 46 +- .../persistence/build_data_persistence.h | 8 +- .../state/build_data/season_pass/definition.h | 26 +- .../season_pass_build_data_runtime.cpp | 18 +- .../season_pass/season_pass_catalog.cpp | 85 +-- .../season_pass/season_pass_catalog.h | 64 +- .../progression/season_pass_reward_catalog.h | 188 +----- Sunrise/src/state/rewards/reward_resolver.cpp | 418 +++++++++++++ Sunrise/src/state/rewards/reward_resolver.h | 44 ++ Sunrise/src/state/runtime/runtime.h | 59 +- .../state_account_acquisition_runtime.cpp | 175 ------ .../state_account_reward_grant_runtime.cpp | 513 +++++++++++++--- .../state_account_transaction_helpers.h | 7 - .../runtime/state_progression_runtime.cpp | 114 +++- 60 files changed, 2971 insertions(+), 1669 deletions(-) create mode 100644 Sunrise/src/client/content/items/packages/package_reward_build.cpp create mode 100644 Sunrise/src/client/content/items/packages/package_reward_build.h create mode 100644 Sunrise/src/middleware/content/packages/tables/field_reader.h create mode 100644 Sunrise/src/state/build_data/cache/records/cache_reward_records.cpp create mode 100644 Sunrise/src/state/build_data/rewards/definition.h create mode 100644 Sunrise/src/state/build_data/rewards/reward_catalog.cpp create mode 100644 Sunrise/src/state/build_data/rewards/reward_catalog.h create mode 100644 Sunrise/src/state/rewards/reward_resolver.cpp create mode 100644 Sunrise/src/state/rewards/reward_resolver.h diff --git a/Sunrise/Sunrise.vcxproj b/Sunrise/Sunrise.vcxproj index 6cd0414ae..1a9537ecd 100644 --- a/Sunrise/Sunrise.vcxproj +++ b/Sunrise/Sunrise.vcxproj @@ -99,6 +99,10 @@ + + + + @@ -1254,6 +1258,11 @@ + + + + + diff --git a/Sunrise/src/client/content/items/packages/internal.h b/Sunrise/src/client/content/items/packages/internal.h index 33aff9e42..76f23e8ff 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,7 @@ struct Storage { reader::Scratch scratch{}; /** Read once per root. Every domain resolves its unlock slots through these. */ SlotMaps slotMaps{}; + RewardBuild rewardBuild{}; /** Node rows held until the value slot and owned records are resolved. */ std::array nodeRows{}; @@ -158,11 +161,7 @@ struct Storage { std::array seasonPassRewards{}; - std::array - seasonPassPackages{}; std::size_t seasonPassRewardCount{}; - std::size_t seasonPassPackageCount{}; /** Repeatable bounty rows, keyed by the item-type pair their pool shares. */ std::array 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..f90dd1a2d 100644 --- a/Sunrise/src/client/content/items/packages/package_item_build.cpp +++ b/Sunrise/src/client/content/items/packages/package_item_build.cpp @@ -8,6 +8,7 @@ #include "../../../../middleware/content/packages/reader/reader.h" #include "../../../../middleware/content/packages/tables/definition_index_table.h" #include "../../../../state/build_data/activities/activity_catalog.h" +#include "../../../../state/build_data/rewards/reward_catalog.h" #include "../../../../state/build_data/runtime.h" #include "../../activity/activity_catalog_build.h" #include "../../activity/entity_position_profile_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() && state::build_data::rewards::ready() && state::build_data::repeatable_bounties_ready() && state::build_data::record_definitions_ready() && state::build_data::node_definitions_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() || !state::build_data::rewards::ready() + || !exotic_catalysts_settled()) { reason = "unlock_maps"; if (!read_unlock_slot_maps( source, storage, std::span{storage.root})) { @@ -184,11 +187,14 @@ bool build() noexcept { std::span(storage.progressionSteps).first(storage.progressionStepCount)); } } - if (!state::build_data::season_pass_ready() + const bool rewardTables = + (!state::build_data::rewards::ready() || !state::build_data::season_pass_ready()) + && storage.rewardBuild.load( + source, storage.scratch, storage.root, storage.slotMaps); + if (rewardTables && !state::build_data::season_pass_ready() && build_season_pass(source, storage, std::span{storage.root})) { (void)state::build_data::publish_season_pass( - std::span(storage.seasonPassRewards).first(storage.seasonPassRewardCount), - std::span(storage.seasonPassPackages).first(storage.seasonPassPackageCount)); + std::span(storage.seasonPassRewards).first(storage.seasonPassRewardCount)); } // 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. 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..f6f9f842e 100644 --- a/Sunrise/src/client/content/items/packages/package_item_rows.cpp +++ b/Sunrise/src/client/content/items/packages/package_item_rows.cpp @@ -4,6 +4,7 @@ #include "../../../../middleware/content/packages/tables/quest_initialization_reader.h" #include "../../../../state/build_data/items/catalysts/exotic_catalyst_builder.h" #include "../../../../state/build_data/items/details/item_detail_catalog.h" +#include "../../../../state/build_data/rewards/reward_catalog.h" #include "../../../../state/build_data/runtime.h" #include "internal.h" #include "package_socket_plug_build.h" @@ -61,6 +62,9 @@ bool build_item_rows(const reader::Source& source, const tables::Array& table, std::size_t& rowCount, const char*& reason) noexcept { + const bool needRewards = !state::build_data::rewards::ready(); + 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 +75,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 || needRewards; bool published = !needRows; if (retainDetails && storage.details.size() != kDetailCapacity) { storage.details.assign(kDetailCapacity, build_details::Definition{}); @@ -112,6 +116,9 @@ bool build_item_rows(const reader::Source& source, item)) { continue; } + if (needRewards && 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{}; @@ -160,6 +167,9 @@ bool build_item_rows(const reader::Source& source, append_initial_plugs(item, table.count, storage.detailRequests); } } + if (needRewards && rewardStorageReady) { + (void)storage.rewardBuild.publish(); + } bool requestsFit = true; if (needRows) { // Every walked entry either published a row or was skipped, so the count of one 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_reward_build.cpp b/Sunrise/src/client/content/items/packages/package_reward_build.cpp new file mode 100644 index 000000000..f943983c6 --- /dev/null +++ b/Sunrise/src/client/content/items/packages/package_reward_build.cpp @@ -0,0 +1,567 @@ +#include "package_reward_build.h" + +#include +#include +#include + +#include "../../../../middleware/content/packages/tables/definition_index_table.h" +#include "../../../../middleware/content/packages/tables/internal.h" +#include "../../../../state/build_data/rewards/reward_catalog.h" +#include "../../../../state/build_data/runtime.h" +#include "core/logging/log.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; + +/** Investment-root slots identify reward pools and unlock-slot bindings. */ +constexpr std::size_t kPoolSlot = 88; +constexpr std::size_t kExpressionSlot = 109; +constexpr std::size_t kFlagSlot = 112; +constexpr std::size_t kValueSlot = 114; +/** 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, kFlagRowClass = 0x80807D4FU; +constexpr std::uint32_t kValueTableClass = 0x80807C92U, kValueRowClass = 0x80807C96U; +constexpr std::uint32_t kExpressionTableClass = 0x80807C49U, kExpressionRowClass = 0x80807C4FU; +constexpr std::uint32_t kConditionClass = 0x80807D2FU; +constexpr std::size_t kPoolStride = 24, kEntryStride = 80, kModifierStride = 24; +constexpr std::size_t kBindingStride = 8, kExpressionRowStride = 24; +constexpr std::size_t kSocketStride = 12, 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; + +/** Byte offsets inside the serialized reward rows. */ +constexpr std::size_t kEntryQuantityOffset = 4, kEntryPoolOffset = 8; +constexpr std::size_t kEntryCategoryOffset = 20, kEntryWeightOffset = 24; +constexpr std::size_t kEntryBucketOffset = 28, kEntryConditionOffset = 32; +constexpr std::size_t kEntryModifiersOffset = 48, kEntrySocketsOffset = 64; +constexpr std::size_t kModifierValueIndexOffset = 16, kModifierValueOffset = 20; +constexpr std::size_t kPoolEntriesOffset = 8, kExpressionBodyOffset = 8; +constexpr std::size_t kWrapperSelectionsOffset = 8, kWrapperFlagsOffset = 24; +constexpr std::size_t kSelectionCountOffset = 4; +constexpr std::size_t kSocketPlugOffset = 2, kSocketPlugSetOffset = 4; +constexpr std::size_t kSocketRollSetOffset = 6, kSocketSelectionOffset = 8; +/** Relative definition bodies are preceded by their four-byte schema class. */ +constexpr std::size_t kDefinitionClassPrefixSize = sizeof(std::uint32_t); + +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; + } +} + +bool root_table(const reader::Source& source, + reader::Scratch& scratch, + std::span root, + std::size_t slot, + std::vector& blob, + std::uint32_t expectedClass = 0) noexcept { + std::uint32_t tag = 0; + std::uint32_t cls = 0; + return tables::slot_tag(root, slot, tag) && tables::package_of(tag) != tables::kAbsentPackageId + && reader::read_tag(source, scratch, tag, blob, cls) + && (expectedClass == 0 || cls == expectedClass); +} + +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 + kEntryCategoryOffset, entry.categoryHash) + && tables::read(blob, at + kEntryWeightOffset, entry.weight) + && tables::read(blob, at + kEntryBucketOffset, entry.bucketHash) + && std::isfinite(entry.weight) && entry.weight >= 0; +} + +} // namespace + +bool RewardBuild::entry(std::span blob, std::size_t at) noexcept { + domain::Entry out{}; + if (!read_reward_entry(blob, at, out) + || !conditions.read(blob, at + kEntryConditionOffset, instructions, out.condition)) { + return false; + } + 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) + || !tables::read(blob, offset + kModifierValueIndexOffset, modifier.valueIndex) + || !tables::read(blob, offset + kModifierValueOffset, modifier.value) + || !std::isfinite(modifier.value) + || !append(modifiers, modifier, domain::kModifierCapacity)) { + return false; + } + } + std::array overrides{}; + std::size_t count = 0; + if (!read_reward_sockets(blob, at + kEntrySocketsOffset, overrides, count) + || count > domain::kSocketOverrideCapacity - sockets.size()) { + 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 + || !append(sockets, overrides[i], domain::kSocketOverrideCapacity)) { + return false; + } + } + return append(entries, out, domain::kEntryCapacity); +} + +void RewardConditions::load_class_flags(const reader::Source& source, + reader::Scratch& scratch, + std::span root) noexcept { + classFlags_.fill(domain::kAbsent); + // Class rows name a default finisher whose use gate supplies the computed class flag. + constexpr std::size_t kClassSlot = 12, kClassStride = 32, kDefaultFinisherOffset = 24; + constexpr std::uint32_t kClassTable = 0x808075BEU, kClassRow = 0x808074FAU; + constexpr std::size_t kUseConditionPointer = 24; + constexpr std::uint32_t kUseConditionClass = 0x80802980U; + std::vector classes, index, blob; + tables::Array classRows{}, itemRows{}; + if (!root_table(source, scratch, root, kClassSlot, classes, kClassTable) + || !tables::read_array( + classes, tables::kTableArrayDescriptor, kClassRow, kClassStride, classRows) + || classRows.count != classFlags_.size() + || !root_table(source, scratch, root, tables::kItemTableSlot, index) + || !tables::read_array(index, + tables::kTableArrayDescriptor, + tables::kItemIndexTableClass, + tables::kItemIndexRowStride, + itemRows)) { + core::log::write(core::log::Channel::client, + core::log::Level::warn, + "ev=pkg stage=class_flags result=skip reason=class_tables"); + return; + } + for (std::size_t i = 0; i < classFlags_.size(); ++i) { + std::uint16_t itemIndex = domain::kAbsent; + tables::IndexRow item{}; + tables::Array condition{}; + std::uint32_t cls = 0, opcode = 0, flag = domain::kAbsent; + std::int64_t relative = 0; + if (!tables::read(std::span{classes}, + classRows.dataOffset + i * kClassStride + kDefaultFinisherOffset, + itemIndex) + || !tables::index_row(index, itemRows, itemIndex, item) + || !reader::read_tag(source, scratch, item.targetTag, blob, cls) + || cls != tables::kItemDefinitionClass + || !tables::read(std::span{blob}, kUseConditionPointer, relative) + || relative <= 0 || static_cast(relative) > blob.size() + || kUseConditionPointer > blob.size() - static_cast(relative)) { + core::log::writef( + core::log::Channel::client, + core::log::Level::warn, + "ev=pkg stage=class_flags class=%zu result=skip reason=default_finisher", + i); + continue; + } + const auto at = kUseConditionPointer + static_cast(relative); + if (tables::read(std::span{blob}, at - kDefinitionClassPrefixSize, cls) + && cls == kUseConditionClass + && tables::read_array( + blob, at, kExpressionClass, tables::kUnlockInstructionStride, condition) + && condition.count == 1 + && tables::read(std::span{blob}, condition.dataOffset, opcode) + && opcode == static_cast(tables::UnlockOpcode::flag) + && tables::read(std::span{blob}, + condition.dataOffset + tables::kUnlockInstructionOperandOffset, + flag) + && flag < domain::kAbsent) { + classFlags_[i] = static_cast(flag); + } else { + core::log::writef(core::log::Channel::client, + core::log::Level::warn, + "ev=pkg stage=class_flags class=%zu result=skip reason=use_condition", + i); + } + } +} + +bool RewardConditions::load(const reader::Source& source, + reader::Scratch& scratch, + std::span root, + const SlotMaps& maps) noexcept { + maps_ = &maps; + load_class_flags(source, scratch, root); + // Shared expressions are expanded before the runtime evaluates reward conditions. + return root_table(source, scratch, root, kFlagSlot, flags_, kFlagTableClass) + && tables::read_array( + flags_, tables::kTableArrayDescriptor, kFlagRowClass, kBindingStride, flagRows_) + && root_table(source, scratch, root, kValueSlot, values_, kValueTableClass) + && tables::read_array( + values_, tables::kTableArrayDescriptor, kValueRowClass, kBindingStride, valueRows_) + && root_table( + source, scratch, root, kExpressionSlot, expressions_, kExpressionTableClass) + && tables::read_array(expressions_, + tables::kTableArrayDescriptor, + kExpressionRowClass, + kExpressionRowStride, + expressionRows_); +} + +bool RewardConditions::bind(domain::Instruction& instruction) const noexcept { + const auto opcode = + static_cast(instruction.opcode); + const bool flag = opcode == middleware::content::packages::tables::UnlockOpcode::flag; + if (!flag && opcode != middleware::content::packages::tables::UnlockOpcode::loadValue) { + return true; + } + const auto& rows = flag ? flagRows_ : valueRows_; + const std::span blob = flag ? flags_ : values_; + if (instruction.operand >= rows.count) { + return false; + } + const std::size_t at = rows.dataOffset + instruction.operand * kBindingStride; + std::uint32_t hash = 0; + if (!tables::read(blob, at, hash)) { + return false; + } + using B = domain::BankRead; + if (flag) { + for (std::size_t characterClass = 0; characterClass < classFlags_.size(); + ++characterClass) { + if (classFlags_[characterClass] != domain::kAbsent + && instruction.operand == classFlags_[characterClass]) { + instruction = {static_cast(B::characterClass), + static_cast(characterClass)}; + return true; + } + } + } + const auto& account = flag ? maps_->accountFlag : maps_->accountValue; + const auto& character = flag ? maps_->characterFlag : maps_->characterValue; + const auto accountIndex = bank_index(account, static_cast(instruction.operand)); + const auto characterIndex = + bank_index(character, static_cast(instruction.operand)); + if (accountIndex != kUnmappedSlot) { + instruction = {static_cast(flag ? B::accountFlag : B::accountValue), + accountIndex}; + } else if (flag + && bank_index(maps_->profileFlag, static_cast(instruction.operand)) + != kUnmappedSlot) { + instruction = { + static_cast(B::profileFlag), + bank_index(maps_->profileFlag, static_cast(instruction.operand))}; + } else if (characterIndex != kUnmappedSlot) { + instruction = {static_cast(flag ? B::characterFlag : B::characterValue), + characterIndex}; + } else { + instruction = {static_cast(flag ? B::externalFlag : B::externalValue), hash}; + } + return true; +} + +bool RewardConditions::append_expression(std::span blob, + std::size_t at, + std::vector& bank, + std::size_t depth) 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) { + domain::Instruction instruction{}; + if (!tables::read( + blob, rows.dataOffset + i * tables::kUnlockInstructionStride, instruction.opcode) + || !tables::read(blob, + rows.dataOffset + i * tables::kUnlockInstructionStride + + tables::kUnlockInstructionOperandOffset, + instruction.operand)) { + return false; + } + if (static_cast(instruction.opcode) + == middleware::content::packages::tables::UnlockOpcode::expression) { + const auto before = bank.size(); + if (instruction.operand >= expressionRows_.count + || !append_expression(expressions_, + expressionRows_.dataOffset + + instruction.operand * kExpressionRowStride + + kExpressionBodyOffset, + bank, + depth + 1) + || bank.size() == before) { + return false; + } + } else if (!bind(instruction) || !domain::valid_instruction(instruction) + || !append(bank, instruction, domain::kInstructionCapacity)) { + return false; + } + } + return true; +} + +bool RewardConditions::read(std::span blob, + std::size_t at, + std::vector& bank, + domain::Range& range) const noexcept { + const auto first = bank.size(); + if (!append_expression(blob, at, bank, 0)) { + 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) const noexcept { + count = 0; + 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) + || expression.count == 0) { + return false; + } + // Every expression attached to a progression reward must hold. + if (i != 0 + && !append(instructions, + domain::Instruction{ + static_cast( + middleware::content::packages::tables::UnlockOpcode::logicalAnd), + 0}, + output.size())) { + 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; + pools.clear(); + entries.clear(); + instructions.clear(); + modifiers.clear(); + sockets.clear(); + std::vector blob; + tables::Array rows{}; + if (!conditions.load(source, scratch, root, maps) + || !root_table(source, scratch, root, kPoolSlot, blob, kPoolClass) + || !tables::read_array( + blob, tables::kTableArrayDescriptor, kPoolRowClass, kPoolStride, rows) + || rows.count == 0 || rows.count > domain::kPoolCapacity) { + return false; + } + 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(), + beforeModifiers = modifiers.size(), beforeSockets = sockets.size(); + if (!entry(blob, members.dataOffset + j * kEntryStride)) { + 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)) { + return false; + } + } + if (skipped != 0) { + core::log::writef(core::log::Channel::client, + core::log::Level::warn, + "ev=pkg stage=rewards skipped=%zu", + 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 relative = 0; + std::uint32_t cls = 0; + if (!tables::read(blob, kWrapperField, relative) + || !tables::read(blob, kAcquiredFlagField, acquired)) { + return false; + } + item.definitionHash = hash; + if (acquired != domain::kAbsent) { + domain::Instruction flag{ + static_cast(middleware::content::packages::tables::UnlockOpcode::flag), + acquired}; + if (!conditions.bind(flag) || !domain::valid_instruction(flag)) { + return false; + } + if (flag.opcode == static_cast(domain::BankRead::accountFlag)) { + item.acquiredFlag = static_cast(flag.operand); + } + } + if (relative != 0) { + if (relative < 0 || static_cast(relative) > blob.size() + || kWrapperField > blob.size() - static_cast(relative)) { + return false; + } + const auto at = kWrapperField + static_cast(relative); + tables::Array selections{}; + if (!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 { + try { + if (!loaded_ || count == 0 || count > domain::kItemCapacity) { + return false; + } + items.assign(count, {}); + return true; + } catch (...) { + 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; + } +} + +bool RewardBuild::publish() noexcept { + if (!loaded_) { + return false; + } + std::size_t write = 0, 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 || row.itemIndex < items.size()) + && (row.poolIndex == domain::kAbsent || row.poolIndex < pools.size()); + for (const auto& socket : + std::span(sockets).subspan(row.sockets.first, row.sockets.count)) { + valid &= socket.plugItem == domain::kAbsent || socket.plugItem < items.size(); + } + 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; + } + entries.resize(write); + sockets.resize(socketWrite); + for (auto& item : items) { + if (item.poolIndex != domain::kAbsent && item.poolIndex >= pools.size()) { + item = {}; + } + } + return state::build_data::publish_reward_definitions( + {pools, entries, items, instructions, modifiers, sockets}); +} + +} // 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..5ac927368 --- /dev/null +++ b/Sunrise/src/client/content/items/packages/package_reward_build.h @@ -0,0 +1,79 @@ +#pragma once + +#include "../../../../middleware/content/packages/reader/reader.h" +#include "../../../../middleware/content/packages/tables/definition_index_table.h" +#include "../../../../state/build_data/rewards/definition.h" +#include "state/account/account_state.h" + +namespace sunrise::client::content::items::packages { + +struct SlotMaps; + +/** Resolves expression references and unlock slots once for a package pass. */ +class RewardConditions { +public: + [[nodiscard]] bool load(const middleware::content::packages::reader::Source& source, + middleware::content::packages::reader::Scratch& scratch, + std::span root, + const SlotMaps& maps) noexcept; + [[nodiscard]] bool bind(state::build_data::rewards::Instruction& instruction) const noexcept; + [[nodiscard]] bool read(std::span blob, + std::size_t at, + std::vector& bank, + state::build_data::rewards::Range& range) const noexcept; + [[nodiscard]] bool read_list(std::span blob, + std::size_t at, + std::span output, + std::size_t& count) const noexcept; + +private: + [[nodiscard]] bool append_expression(std::span blob, + std::size_t at, + std::vector& bank, + std::size_t depth) const noexcept; + void 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_{}; + 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; + [[nodiscard]] bool load(const middleware::content::packages::reader::Source& source, + middleware::content::packages::reader::Scratch& scratch, + std::span root, + const SlotMaps& maps) noexcept; + [[nodiscard]] bool begin_items(std::size_t count) noexcept; + void item(std::uint16_t index, std::uint32_t hash, std::span blob) noexcept; + [[nodiscard]] bool publish() noexcept; + +private: + [[nodiscard]] bool entry(std::span blob, std::size_t at) noexcept; + [[nodiscard]] bool read_item(std::uint32_t hash, + std::span blob, + state::build_data::rewards::Item& item) noexcept; + bool loaded_{}; + 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..0d48675b0 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,81 +1,87 @@ #include -#include #include +#include "../../../../middleware/content/packages/tables/internal.h" +#include "../../../../state/build_data/season_pass/season_pass_catalog.h" +#include "../../../../state/progression/season_pass_reward_catalog.h" +#include "core/logging/log.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) { +bool read_season_reward(Storage& storage, + std::span progressionTable, + const tables::Array& itemRows, + std::size_t at, + domain::Reward& reward) noexcept { + std::uint32_t rank = 0; + std::uint32_t itemIndex = 0; + std::uint32_t claimSlot = 0; + tables::IndexRow entry{}; + 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) + || reward.quantity == 0 || reward.quantity > INT32_MAX + || 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; } - std::memcpy(&value, blob.data() + offset, sizeof value); - return true; -} - -/** - * Reads the item set one reward's wrapper item opens into. - * @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. - */ -[[nodiscard]] bool read_package(std::span definition, - std::span itemTable, - const tables::Array& itemRows, - domain::Package& package) noexcept { - tables::Array set{}; - if (!tables::find_optional_array_at(definition, tables::kGearsetItemField, set)) { + reward.itemHash = entry.definitionHash; + reward.itemIndex = static_cast(itemIndex); + reward.requiredRank = static_cast(rank); + std::size_t socketCount = 0; + if (!read_reward_sockets(progressionTable, + at + tables::kProgressionRewardSocketsOffset, + reward.sockets, + socketCount)) { return false; } - if (set.count == 0) { - return true; - } - if (set.elementClass != tables::kGearsetItemRowClass || set.count > domain::kPackageItemCapacity - || set.dataOffset + static_cast(set.count) * tables::kGearsetItemStride - > definition.size()) { + reward.socketCount = static_cast(socketCount); + std::size_t conditionCount = 0; + if (!storage.rewardBuild.conditions.read_list(progressionTable, + at + tables::kProgressionRewardConditionsOffset, + reward.condition, + conditionCount)) { return false; } - 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) - || !tables::index_row(itemTable, itemRows, itemIndex, entry)) { - return false; - } - package.items[package.itemCount++] = entry.definitionHash; + reward.conditionCount = static_cast(conditionCount); + // A reward with no claim flag carries slot 0. + if (claimSlot != 0) { + reward.claimFlagIndex = + bank_index(storage.slotMaps.accountFlag, static_cast(claimSlot)); + } + + if (!domain::valid(std::span(&reward, 1)) + || (claimSlot != 0 && reward.claimFlagIndex == domain::kUnavailableFlagIndex) + || !std::all_of(reward.sockets.begin(), + reward.sockets.begin() + reward.socketCount, + [&](const auto& socket) { + return socket.socketType != state::build_data::rewards::kAbsent + && (socket.plugItem == state::build_data::rewards::kAbsent + || socket.plugItem < itemRows.count); + })) { + return false; } return true; } } // 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. - * @param source Installed package source. - * @param storage Pass storage receiving the reward rows and the wrapper packages. - * @param root Investment root bytes. - * @return True when the reward list read and produced at least one row. - */ +/** Preserves native reward order for opcode-2400 claim indices. */ bool build_season_pass(const reader::Source& source, Storage& storage, std::span root) noexcept { storage.seasonPassRewardCount = 0; - storage.seasonPassPackageCount = 0; std::uint32_t itemTableTag = 0; tables::Array itemRows{}; @@ -87,7 +93,6 @@ bool build_season_pass(const reader::Source& source, || itemRows.elementClass != tables::kItemIndexTableClass) { return false; } - const std::span itemTable{storage.itemIndexTable}; std::uint32_t tableTag = 0; tables::Array progressions{}; @@ -97,74 +102,41 @@ 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 = {}; - 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; - 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)); + if (!read_season_reward(storage, progressionTable, itemRows, at, reward)) { + reward = {}; + ++skipped; } - - // 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{}; - package.definitionHash = entry.definitionHash; - if (storage.seasonPassPackageCount < domain::kPackageCapacity - && std::none_of( - held.begin(), - held.end(), - [&entry](const domain::Package& row) { - return row.definitionHash == entry.definitionHash; - }) - && reader::read_tag(source, storage.scratch, entry.targetTag, storage.definition) - && read_package( - std::span{storage.definition}, itemTable, itemRows, package) - && 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/middleware/content/packages/tables/definition_index_table.cpp b/Sunrise/src/middleware/content/packages/tables/definition_index_table.cpp index e2e83ab68..68b58d85e 100644 --- a/Sunrise/src/middleware/content/packages/tables/definition_index_table.cpp +++ b/Sunrise/src/middleware/content/packages/tables/definition_index_table.cpp @@ -1,8 +1,9 @@ #include "definition_index_table.h" -#include #include +#include "field_reader.h" + namespace sunrise::middleware::content::packages::tables { namespace { @@ -31,17 +32,6 @@ constexpr std::size_t kIndexRowTagOffset = 16; /** Arrays larger than this are not definition tables. */ constexpr std::uint64_t kMaximumArrayCount = 300000; -/** @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; -} - /** * Checks one array header reached from a descriptor. * @param blob Whole definition bytes. @@ -137,6 +127,17 @@ bool find_optional_array_at(std::span blob, return resolve_descriptor(blob, descriptorOffset, true, output); } +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 a1aa1502e..b581f8b86 100644 --- a/Sunrise/src/middleware/content/packages/tables/definition_index_table.h +++ b/Sunrise/src/middleware/content/packages/tables/definition_index_table.h @@ -28,6 +28,9 @@ inline constexpr std::uint16_t kAbsentPackageId = 0xFFFFU; return static_cast((tag - kTagLowerBound) >> kTagPackageShift); } +/** Hidden plugs, perks and quest roots use this bucket instead of held inventory. */ +inline constexpr std::uint8_t kNonInventoryBucketId = 37; + /** Element class of the item index table inside the investment container. */ inline constexpr std::uint32_t kItemIndexTableClass = 0x80807BE8U; /** Serialized item definition class, distinct from the item index table class. */ @@ -96,22 +99,58 @@ inline constexpr std::size_t kUnlockInstructionOperandOffset = 4; /** An expression field is a 64-bit count then a 64-bit self-relative offset. */ inline constexpr std::size_t kUnlockExpressionFieldSize = 16; inline constexpr std::size_t kUnlockExpressionPointerOffset = 8; -/** Opcodes run to fifteen; anything wider means the field is not an expression. */ -inline constexpr std::uint32_t kUnlockOpcodeCeiling = 20; +/** Native instruction values before unlock references are bound. */ +enum class UnlockOpcode : std::uint32_t { + flag = 1, + logicalNot = 2, + logicalOr = 3, + logicalAnd = 4, + logicalNor = 5, + notEqualAlternate = 6, + logicalNand = 7, + equal = 8, + notEqual = 9, + loadValue = 10, + constant = 11, + expression = 12, + greaterThan = 13, + greaterOrEqual = 14, + lessThan = 15, + lessOrEqual = 16, + add = 17, + subtract = 18, + multiply = 19, + divide = 20, + remainder = 21, + negate = 22, + hash = 23, + hashCombine = 24, + bitwiseAnd = 25, + bitwiseOr = 26, + bitwiseXor = 27, + bitwiseNot = 28, +}; + /** The opcode that reads a value slot. */ -inline constexpr std::uint32_t kUnlockReadValueOpcode = 10; +inline constexpr std::uint32_t kUnlockReadValueOpcode = + static_cast(UnlockOpcode::loadValue); /** The opcode that tests a flag. */ -inline constexpr std::uint32_t kUnlockReadFlagOpcode = 1; +inline constexpr std::uint32_t kUnlockReadFlagOpcode = + static_cast(UnlockOpcode::flag); /** The opcode that pushes a literal. */ -inline constexpr std::uint32_t kUnlockLiteralOpcode = 11; +inline constexpr std::uint32_t kUnlockLiteralOpcode = + static_cast(UnlockOpcode::constant); /** The opcode that tests greater than or equal. */ -inline constexpr std::uint32_t kUnlockGreaterEqualOpcode = 14; +inline constexpr std::uint32_t kUnlockGreaterEqualOpcode = + static_cast(UnlockOpcode::greaterOrEqual); /** The opcode that inverts the value on top of the stack. Its operand is unused. */ -inline constexpr std::uint32_t kUnlockNotOpcode = 2; +inline constexpr std::uint32_t kUnlockNotOpcode = + static_cast(UnlockOpcode::logicalNot); /** The opcode that folds the top two values with logical and. */ -inline constexpr std::uint32_t kUnlockAndOpcode = 4; +inline constexpr std::uint32_t kUnlockAndOpcode = + static_cast(UnlockOpcode::logicalAnd); /** The opcode that tests the top two values for equality. */ -inline constexpr std::uint32_t kUnlockEqualOpcode = 8; +inline constexpr std::uint32_t kUnlockEqualOpcode = static_cast(UnlockOpcode::equal); /** High half every installed array marker and element class carries. */ inline constexpr std::uint32_t kDefinitionClassHigh = 0x8080U; /** Shift that leaves `kDefinitionClassHigh` from a marker or element class. */ @@ -130,6 +169,8 @@ 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 account object's profile unlock flag mapping table. */ +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. */ @@ -223,6 +264,9 @@ 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; +/** Eligibility expressions and socket overrides carried by one progression reward. */ +inline constexpr std::size_t kProgressionRewardConditionsOffset = 24; +inline constexpr std::size_t kProgressionRewardSocketsOffset = 40; /** Items a wrapper item opens into, two bytes each as an item-definition index. */ inline constexpr std::size_t kGearsetItemField = 392; inline constexpr std::uint32_t kGearsetItemRowClass = 0x808087DBU; @@ -283,6 +327,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..018d4169b --- /dev/null +++ b/Sunrise/src/middleware/content/packages/tables/field_reader.h @@ -0,0 +1,26 @@ +#pragma once + +#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; +} + +} // 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/content/packages/tables/quest_initialization_reader.cpp b/Sunrise/src/middleware/content/packages/tables/quest_initialization_reader.cpp index 70230840e..0181daa99 100644 --- a/Sunrise/src/middleware/content/packages/tables/quest_initialization_reader.cpp +++ b/Sunrise/src/middleware/content/packages/tables/quest_initialization_reader.cpp @@ -57,9 +57,6 @@ constexpr std::uint32_t kItemPresenceFlagClass = 0x80807D4BU; constexpr std::size_t kItemPresenceFlagStride = sizeof(std::uint16_t); /** Authored value/flag slots must fit the nonnegative range of a signed 16-bit mapping. */ constexpr std::uint16_t kUnlockSlotLimit = 0x8000U; -/** Policy: pursuits without presence flags need a bucket-37 root with no objective block. */ -constexpr std::uint8_t kSeparateQuestRootBucketId = 37; - /** Map +40 has no supported save bank; a matching slot makes initialization unsafe. */ constexpr std::size_t kThirdValueMapDescriptor = 40; /** Map +56 is also checked for duplicate slots but has no supported save bank. */ @@ -199,7 +196,7 @@ Quest read_quest_initialization(std::span definition, std::uint8_t parentBucket = 0; std::int64_t parentObjective = 0; if (parentIndex == itemIndex || !read(parent, kBucketIdOffset, parentBucket) - || parentBucket != kSeparateQuestRootBucketId + || parentBucket != kNonInventoryBucketId || !read(parent, kItemObjectiveBlockOffset, parentObjective) || parentObjective != 0) { return {}; } diff --git a/Sunrise/src/middleware/content/packages/tables/unlock_expression.h b/Sunrise/src/middleware/content/packages/tables/unlock_expression.h index 541116cb9..235c94b41 100644 --- a/Sunrise/src/middleware/content/packages/tables/unlock_expression.h +++ b/Sunrise/src/middleware/content/packages/tables/unlock_expression.h @@ -9,6 +9,9 @@ namespace sunrise::middleware::content::packages::tables { +/** Record and node field detection rejects values above 20 as non-expression data. */ +inline constexpr std::uint32_t kExpressionDetectionOpcodeCeiling = 20; + /** * Reads the first operand of a given opcode out of one expression field. * The same field holds a value read on one row and a flag test on another, so the opcode is asked @@ -51,7 +54,7 @@ namespace sunrise::middleware::content::packages::tables { const std::size_t at = base + static_cast(index) * kUnlockInstructionStride; std::memcpy(&instruction, table.data() + at, sizeof instruction); std::memcpy(&operand, table.data() + at + kUnlockInstructionOperandOffset, sizeof operand); - if (instruction > kUnlockOpcodeCeiling) { + if (instruction > kExpressionDetectionOpcodeCeiling) { return false; } if (instruction == opcode && operand <= static_cast(INT16_MAX)) { diff --git a/Sunrise/src/server/bap/bap_world_reward_queue.cpp b/Sunrise/src/server/bap/bap_world_reward_queue.cpp index 6c27c8ea6..5b19d3a71 100644 --- a/Sunrise/src/server/bap/bap_world_reward_queue.cpp +++ b/Sunrise/src/server/bap/bap_world_reward_queue.cpp @@ -1,6 +1,10 @@ -#include "../../core/logging/log.h" +#include +#include +#include + #include "../../state/build_data/runtime.h" #include "../../state/investment/store_internal.h" +#include "core/logging/log.h" #include "internal.h" namespace sunrise::server::bap { @@ -14,10 +18,25 @@ bool commit_world_reward(const WorldRewardRequest& request) noexcept { } bool committed = false; if (request.kind == WorldRewardKind::item) { - state::PendingItemAcquisition acquisition; - committed = - state::prepare_item_acquisition_for_item(request.itemDefinitionIndex, acquisition) - && state::commit_item_acquisition(acquisition); + state::build_data::items::Definition item{}; + if (!state::build_data::find_item_definition_index(request.itemDefinitionIndex, item)) { + return false; + } + if (state::item_grant_route(request.itemDefinitionIndex) == state::ItemGrantRoute::quest) { + state::PendingItemAcquisition acquisition; + committed = request.quantity == 1 + && 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); + committed = grant && request.quantity > 0 + && state::prepare_item_reward(request.itemDefinitionIndex, + static_cast(request.quantity), + *grant) + && state::commit_record_reward(*grant); + } } else { state::PendingProfileItemAcquisition acquisition; committed = state::prepare_profile_item_acquisition_for_item( @@ -45,6 +64,15 @@ bool enqueue_world_reward(std::uint16_t definitionIndex, } // namespace +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); 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 148327cea..5b8873d6a 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 80baf576e..4901881ef 100644 --- a/Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp +++ b/Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp @@ -529,22 +529,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, @@ -577,43 +564,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/internal.h b/Sunrise/src/server/bap/encrypted/internal.h index 197bde99d..5ba1a0301 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. */ @@ -481,30 +473,6 @@ void append_queuez_notification(Scratch& scratch, queuez::SessionState& after) noexcept; -/** Publishes one Season package as new residents plus their acquisition descriptors. */ - -[[nodiscard]] bool append_season_pass_package_notification( - - Scratch& scratch, - - const queuez::SessionState& before, - - const state::PendingDirectItemBundle& mutation, - - std::uint16_t rewardIndex, - - std::span acquisitionPresentationRows, - - std::span key, - - std::span nonce, - - std::span response, - - std::size_t& written, - - queuez::SessionState& after) noexcept; - /** Publishes every item and the claimed record in one Family-4 revision. */ [[nodiscard]] bool append_record_reward_notification( diff --git a/Sunrise/src/server/bap/encrypted/push/queuez/queuez_select_character.cpp b/Sunrise/src/server/bap/encrypted/push/queuez/queuez_select_character.cpp index 5ab320f8d..3a0e59e8d 100644 --- a/Sunrise/src/server/bap/encrypted/push/queuez/queuez_select_character.cpp +++ b/Sunrise/src/server/bap/encrypted/push/queuez/queuez_select_character.cpp @@ -178,59 +178,6 @@ bool append_seasonal_experience_notification( return true; } -/** Publishes every item in one rank-one class package as an ordinary acquisition. */ -bool append_season_pass_package_notification( - Scratch& scratch, - const queuez::SessionState& before, - const state::PendingDirectItemBundle& mutation, - std::uint16_t rewardIndex, - std::span acquisitionPresentationRows, - std::span key, - std::span nonce, - std::span response, - std::size_t& written, - queuez::SessionState& after) noexcept { - after = before; - snapshot::Prepared prepared{}; - const std::size_t itemCount = mutation.itemCount; - // The item objects are indexed below, and every new resident must fit the peer's manifest. - if (!snapshot::prepare_season_pass_package( - scratch, before, mutation, rewardIndex, acquisitionPresentationRows, prepared) - || itemCount == 0 || prepared.family.objects.size() != itemCount + 2U - || before.family4ResidentCount + itemCount > before.family4Residents.size()) { - return false; - } - for (std::size_t index = 0; index < itemCount; ++index) { - const middleware::queuez::Object& object = prepared.family.objects[index]; - for (std::size_t residentIndex = 0; residentIndex < before.family4ResidentCount; - ++residentIndex) { - if (before.family4Residents[residentIndex].objectSoid == object.version) { - return false; - } - } - after.family4Residents[after.family4ResidentCount++] = - queuez::ResidentObject{object.version, object.id}; - } - const middleware::queuez::Object& characterObject = prepared.family.objects[itemCount]; - std::size_t characterMatches = 0; - for (std::size_t index = 0; index < before.family4ResidentCount; ++index) { - const queuez::ResidentObject& resident = before.family4Residents[index]; - characterMatches += - static_cast(resident.objectSoid == characterObject.version - && resident.definitionId == characterObject.id); - } - if (characterMatches != 1) { - return false; - } - after.family4Version = prepared.family.version; - if (!queuez::valid(after) - || !queuez_frame::append_prepared(scratch, prepared, key, nonce, response, written)) { - after = before; - return false; - } - return true; -} - /** Publishes one prepared record-reward batch. */ bool append_record_reward_notification( Scratch& scratch, diff --git a/Sunrise/src/server/bap/encrypted/push/snapshot/family4_reward_updates.cpp b/Sunrise/src/server/bap/encrypted/push/snapshot/family4_reward_updates.cpp index f517e2753..9ca41d5b4 100644 --- a/Sunrise/src/server/bap/encrypted/push/snapshot/family4_reward_updates.cpp +++ b/Sunrise/src/server/bap/encrypted/push/snapshot/family4_reward_updates.cpp @@ -21,192 +21,6 @@ namespace sunrise::server::bap::encrypted::push::snapshot { namespace family4_datagen = middleware::datagen::family4; -/** Builds one atomic Season package update with one acquisition record per granted item. */ -bool prepare_season_pass_package( - Scratch& scratch, - const queuez::SessionState& before, - const state::PendingDirectItemBundle& mutation, - std::uint16_t rewardIndex, - std::span acquisitionPresentationRows, - Prepared& prepared) noexcept { - namespace character_layout = family4_datagen::character::layout; - const std::size_t itemCount = mutation.itemCount; - state::build_data::season_pass::Reward reward{}; - state::build_data::season_pass::Package package{}; - if (itemCount == 0 || itemCount > character_layout::kInventoryChangeRecordCapacity - || !mutation.prepared || !state::build_data::find_season_pass_reward(rewardIndex, reward) - || reward.quantity != 1 || reward.itemHash != mutation.sourceDefinitionHash - || !state::build_data::find_season_pass_package(reward.itemHash, package) - || !queuez::valid(before) || !before.family4Active || before.family4ResidentCount == 0 - || before.family4Version == (std::numeric_limits::max)() - || before.family4ResidentCount + itemCount > before.family4Residents.size()) { - return report_failure("season_package_session"); - } - - state::AccountState account{}; - if (!state::preview_direct_item_bundle(mutation, account)) { - return report_failure("season_package_preview"); - } - const std::optional selectedIndex = find_character_index(account); - Resolved selected{}; - if (!state::account::valid(account) || account.primarySoid != before.family4RootSoid - || account.primarySoid != mutation.accountSoid || !selectedIndex.has_value() - || *selectedIndex != mutation.characterIndex - || !resolve(account, *selectedIndex, selected)) { - return report_failure("season_package_account"); - } - const state::CharacterState& character = account.characters[*selectedIndex]; - if (character.soid != mutation.characterSoid - || mutation.expectedInventoryCount > character.inventory.count - || character.inventory.count != mutation.expectedInventoryCount + itemCount) { - return report_failure("season_package_count"); - } - - std::uint32_t itemInstanceObjectId = 0; - if (!middleware::datagen::object_id(queuez::kAccountFamilyType, - middleware::datagen::kItemInstanceSlot, - itemInstanceObjectId) - || before.family4Residents.front().objectSoid != account.primarySoid - || before.family4Residents.front().definitionId == 0) { - return report_failure("season_package_definitions"); - } - std::size_t characterResidentMatches = 0; - for (std::size_t index = 0; index < before.family4ResidentCount; ++index) { - const queuez::ResidentObject& resident = before.family4Residents[index]; - characterResidentMatches += - static_cast(resident.objectSoid == character.soid - && resident.definitionId == selected.characterObjectId); - } - if (characterResidentMatches != 1) { - return report_failure("season_package_character_resident"); - } - - family4_datagen::loadout::ResolvedInstances acquired{}; - const std::size_t firstGranted = mutation.expectedInventoryCount; - for (std::size_t grantIndex = 0; grantIndex < itemCount; ++grantIndex) { - const auto& granted = character.inventory.values[firstGranted + grantIndex]; - if (granted.instanceSoid != mutation.firstInstanceSoid + grantIndex - || granted.mutationSerial < 0) { - return report_failure("season_package_item_identity"); - } - for (std::size_t residentIndex = 0; residentIndex < before.family4ResidentCount; - ++residentIndex) { - if (before.family4Residents[residentIndex].objectSoid == granted.instanceSoid) { - return report_failure("season_package_item_resident"); - } - } - std::size_t matches = 0; - for (std::size_t loadoutIndex = 0; loadoutIndex < selected.loadout.itemCount; - ++loadoutIndex) { - const auto& resolvedItem = selected.loadout.items[loadoutIndex]; - if (resolvedItem.instance.instanceSoid != granted.instanceSoid) { - continue; - } - acquired.items[grantIndex].equipmentSlot = resolvedItem.equipmentSlot; - acquired.items[grantIndex].instance = resolvedItem.instance; - if (resolvedItem.mutationSerial != granted.mutationSerial) { - return report_failure("season_package_item_serial"); - } - ++matches; - } - if (matches != 1) { - return report_failure("season_package_item_loadout"); - } - } - acquired.itemCount = itemCount; - - const Reservation reservation = reserve_prior(scratch, prepared); - const auto rawStorage = std::span(scratch.plaintext).subspan(reservation.rawWriteOffset); - if (character_layout::kObjectSize > rawStorage.size() - || family4_datagen::account::layout::kObjectSize > rawStorage.size()) { - return report_failure("season_package_storage"); - } - - Prepared staged{}; - std::size_t compressedExtent = reservation.compressedWriteOffset; - std::size_t itemCursor = 0; - if (!append_items(scratch, - rawStorage, - itemInstanceObjectId, - acquired, - 0, - staged, - itemCursor, - compressedExtent) - || itemCursor != itemCount) { - clear_after(scratch, reservation); - return report_failure("season_package_item_objects"); - } - - const auto characterBytes = rawStorage.first(character_layout::kObjectSize); - if (!family4_datagen::character::encode( - character, selected.loadout, selected.lightEvaluation, characterBytes)) { - clear_after(scratch, reservation); - return report_failure("season_package_character_encode"); - } - auto& characterObject = *reinterpret_cast(characterBytes.data()); - if (characterObject.inventoryChanges.writeSlot != 0 - || characterObject.inventoryChanges.nextSequence != 0 - || !std::all_of(characterObject.inventoryChanges.records.cbegin(), - characterObject.inventoryChanges.records.cend(), - kChangeRecordIsZero)) { - clear_after(scratch, reservation); - return report_failure("season_package_change_state"); - } - for (std::size_t index = 0; index < itemCount; ++index) { - auto& change = characterObject.inventoryChanges.records[index]; - change.sequence = static_cast(index); - change.mutationSerial = character.inventory.values[firstGranted + index].mutationSerial; - change.kind = kChangeKind; - change.flags = kChangeFlags; - } - characterObject.inventoryChanges.writeSlot = static_cast(itemCount); - characterObject.inventoryChanges.nextSequence = static_cast(itemCount); - if (!apply_acquisition_presentation( - characterBytes, selected.loadout, acquisitionPresentationRows)) { - clear_after(scratch, reservation); - return report_failure("season_package_presentation"); - } - if (!append_object(scratch, - characterBytes, - selected.characterObjectId, - character.soid, - staged.objects[itemCount], - compressedExtent)) { - clear_after(scratch, reservation); - return report_failure("season_package_character_object"); - } - - const auto accountBytes = rawStorage.first(family4_datagen::account::layout::kObjectSize); - if (!family4_datagen::account::encode(account, accountBytes) - || !append_object(scratch, - accountBytes, - before.family4Residents.front().definitionId, - account.primarySoid, - staged.objects[itemCount + 1U], - compressedExtent)) { - clear_after(scratch, reservation); - return report_failure("season_package_account_object"); - } - - staged.rawClearSize = - (std::max)(reservation.rawClearSize, - reservation.rawWriteOffset - + (std::max)(character_layout::kObjectSize, - family4_datagen::account::layout::kObjectSize)); - staged.compressedClearSize = (std::max)(reservation.compressedClearSize, compressedExtent); - staged.family = middleware::queuez::Family{queuez::kAccountFamilyType, - account.primarySoid, - before.family4Version + 1, - 0, - std::span(staged.objects).first(itemCount + 2U)}; - if (!commit(staged, prepared)) { - clear_after(scratch, reservation); - return report_failure("season_package_commit"); - } - return true; -} - /** Builds all record rewards and the pending claim into one Family-4 revision. */ bool prepare_record_reward_grant( Scratch& scratch, @@ -346,7 +160,8 @@ bool prepare_record_reward_grant( std::size_t characterChanges = 0; for (std::size_t rewardIndex = 0; rewardIndex < mutation.rewardCount; ++rewardIndex) { const state::PreparedRecordReward& reward = mutation.rewards[rewardIndex]; - if (reward.kind == state::RecordRewardKind::profileStack) { + if (reward.kind == state::RecordRewardKind::profileStack + || reward.kind == state::RecordRewardKind::accountUnlock) { continue; } state::build_data::items::Definition definition{}; @@ -402,7 +217,9 @@ bool prepare_record_reward_grant( } const auto accountBytes = rawStorage.first(account_layout::kObjectSize); - if (!family4_datagen::account::encode(account, accountBytes)) { + state::unlocks::Table unlocks{}; + if (!state::preview_reward_unlocks(mutation, unlocks) + || !family4_datagen::account::encode(account, accountBytes, unlocks)) { clear_after(scratch, reservation); return report_failure("record_reward_account_encode"); } diff --git a/Sunrise/src/server/bap/encrypted/push/snapshot/internal.h b/Sunrise/src/server/bap/encrypted/push/snapshot/internal.h index 12721fa37..7411bca73 100644 --- a/Sunrise/src/server/bap/encrypted/push/snapshot/internal.h +++ b/Sunrise/src/server/bap/encrypted/push/snapshot/internal.h @@ -236,15 +236,6 @@ prepare_profile_item_acquisition(Scratch& scratch, std::span acquisitionPresentationRows, Prepared& prepared) noexcept; -/** Builds one package increment containing its new instances, character, and account objects. */ -[[nodiscard]] bool prepare_season_pass_package( - Scratch& scratch, - const queuez::SessionState& before, - const state::PendingDirectItemBundle& mutation, - std::uint16_t rewardIndex, - std::span acquisitionPresentationRows, - Prepared& prepared) noexcept; - /** Builds one atomic record-reward batch in resident, character, account order. */ [[nodiscard]] bool prepare_record_reward_grant( Scratch& scratch, diff --git a/Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp b/Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp index a35d2f946..e3d80d864 100644 --- a/Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp +++ b/Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp @@ -2,15 +2,19 @@ #include #include +#include +#include #include "../../../../core/logging/log.h" #include "../../../../middleware/secure_channel/runtime.h" #include "../../../../state/account/account_state.h" #include "../../../../state/activity/destination/definition.h" #include "../../../../state/activity/runtime.h" +#include "../../../../state/build_data/runtime.h" #include "../../../../state/runtime/runtime.h" #include "../internal.h" #include "../push/activity/activity_keepalive_push.h" +#include "queuez_reward_staging.h" #include "queuez_state_validation.h" #include "state/investment/store_internal.h" @@ -52,6 +56,63 @@ selected_character(const state::AccountState& account) noexcept { return nullptr; } +bool consume_world_record_reward(const WorldRewardRequest& request, + Session& session, + Scratch& scratch, + std::span response, + std::size_t& written, + bool& touchesScratch) noexcept { + const std::unique_ptr pending( + new (std::nothrow) state::PendingRecordRewardGrant); + const auto fail = [&](const char* reason) noexcept { + report_reward_refusal("world_publish", request.itemDefinitionIndex, reason); + return false; + }; + const char* reason = "transaction"; + state::investment::store::Transaction transaction; + if (!pending || !transaction.ready() || request.quantity <= 0 + || !state::prepare_item_reward(request.itemDefinitionIndex, + static_cast(request.quantity), + *pending, + &reason)) { + return fail(reason); + } + queuez::RecordRewardGrant update{}; + if (!queuez::stage_record_reward_grant(session.queuez, *pending, update)) { + return fail("inventory_stage"); + } + touchesScratch = true; + std::size_t framedSize = 0; + if (!push::append_record_reward_notification(scratch, + session.queuez, + update, + *pending, + active_acquisition_presentation_rows(session), + session.sessionKey, + session.sendNonce, + scratch.framed, + framedSize) + || framedSize == 0) { + return fail("inventory_encode"); + } + if (framedSize > response.size()) { + return false; + } + if (!state::commit_record_reward(*pending)) { + return fail("inventory_commit"); + } + if (!bap::complete_world_reward(request.id) || !transaction.commit()) { + return fail("queue_commit"); + } + std::copy_n(scratch.framed.begin(), framedSize, response.begin()); + written = framedSize; + middleware::secure_channel::advance_nonce(session.sendNonce); + session.queuez = update.after; + bap::arm_account_resync_elsewhere(session); + bap::arm_acquisition_presentation_hold(session); + return true; +} + /** Publishes and commits one character-inventory world reward. */ [[nodiscard]] bool consume_world_item_acquisition(const WorldRewardRequest& request, Session& session, @@ -59,6 +120,14 @@ selected_character(const state::AccountState& account) noexcept { std::span response, std::size_t& written, bool& touchesScratch) noexcept { + state::build_data::items::Definition item{}; + if (!state::build_data::find_item_definition_index(request.itemDefinitionIndex, item)) { + return false; + } + if (state::item_grant_route(request.itemDefinitionIndex) != state::ItemGrantRoute::quest) { + return consume_world_record_reward( + request, session, scratch, response, written, touchesScratch); + } state::investment::store::Transaction transaction; if (!transaction.ready()) { return false; diff --git a/Sunrise/src/server/bap/encrypted/queuez/queuez_reward_staging.cpp b/Sunrise/src/server/bap/encrypted/queuez/queuez_reward_staging.cpp index 0789ee199..29c5b445c 100644 --- a/Sunrise/src/server/bap/encrypted/queuez/queuez_reward_staging.cpp +++ b/Sunrise/src/server/bap/encrypted/queuez/queuez_reward_staging.cpp @@ -2,9 +2,6 @@ #include "queuez_reward_staging.h" -#include -#include - #include "../../../../core/logging/log.h" #include "../../../../middleware/secure_channel/runtime.h" @@ -56,70 +53,24 @@ stage_profile_item_acquisition_push(Scratch& scratch, return true; } -/** Stages the one grant a season pass rank owes, whichever of the four shapes it carries. */ +/** Publishes a reward batch before advancing the nonce and peer revision. */ [[nodiscard]] bool -stage_season_pass_reward(Scratch& scratch, +stage_record_reward_push(Scratch& scratch, const SessionState& before, - const SeasonPassRewardTransaction& reward, + const RecordRewardGrant& update, + const state::PendingRecordRewardGrant& pending, std::span presentationRows, std::span key, std::array& nonce, std::span response, std::size_t& written, SessionState& after) noexcept { - const auto& pending = *reward.pending; - if (const auto* itemUpdate = std::get_if(&reward.update)) { - const auto* itemPending = std::get_if(&pending.grant); - return itemPending != nullptr - && stage_item_acquisition_push(scratch, - *itemUpdate, - *itemPending, - presentationRows, - key, - nonce, - response, - written, - after); - } - if (const auto* profileUpdate = std::get_if(&reward.update)) { - const auto* profilePending = - std::get_if(&pending.grant); - return profilePending != nullptr - && stage_profile_item_acquisition_push( - scratch, *profileUpdate, *profilePending, key, nonce, response, written, after); - } - if (const auto* bundle = std::get_if(&pending.grant)) { - if (!push::append_season_pass_package_notification(scratch, - before, - *bundle, - pending.rewardIndex, - presentationRows, - key, - nonce, - response, - written, - after)) { - return false; - } - middleware::secure_channel::advance_nonce(nonce); - return true; - } - const auto* resourceUpdate = std::get_if(&reward.update); - const auto* resources = std::get_if(&pending.grant); - if (resourceUpdate == nullptr || resources == nullptr - || !push::append_record_reward_notification(scratch, - before, - *resourceUpdate, - *resources, - presentationRows, - key, - nonce, - response, - written)) { + if (!push::append_record_reward_notification( + scratch, before, update, pending, presentationRows, key, nonce, response, written)) { return false; } middleware::secure_channel::advance_nonce(nonce); - after = resourceUpdate->after; + after = update.after; return true; } @@ -184,28 +135,35 @@ bool stage_reward_outcome(Scratch& scratch, } if (const auto* record = transaction_if(outcome)) { if (record->pending == nullptr - || !push::append_record_reward_notification(scratch, - before, - record->update, - *record->pending, - presentationRows, - key, - nonce, - response, - written)) { + || !stage_record_reward_push(scratch, + before, + record->update, + *record->pending, + presentationRows, + key, + nonce, + response, + written, + after)) { core::log::write(core::log::Channel::server, core::log::Level::warn, "ev=queuez stage=record_reward result=fail"); return false; } - middleware::secure_channel::advance_nonce(nonce); - after = record->update.after; return true; } const auto* seasonPass = transaction_if(outcome); if (seasonPass == nullptr || seasonPass->pending == nullptr - || !stage_season_pass_reward( - scratch, before, *seasonPass, presentationRows, key, nonce, response, written, after)) { + || !stage_record_reward_push(scratch, + before, + seasonPass->update, + seasonPass->pending->grant, + presentationRows, + key, + nonce, + response, + written, + after)) { core::log::write(core::log::Channel::server, core::log::Level::warn, "ev=ws2400 stage=queuez_reward result=fail"); diff --git a/Sunrise/src/server/bap/encrypted/queuez/queuez_state_validation.h b/Sunrise/src/server/bap/encrypted/queuez/queuez_state_validation.h index 2e32fa88a..7e345a29f 100644 --- a/Sunrise/src/server/bap/encrypted/queuez/queuez_state_validation.h +++ b/Sunrise/src/server/bap/encrypted/queuez/queuez_state_validation.h @@ -7,6 +7,10 @@ #include "../../../../middleware/queuez/subscription.h" #include "definition.h" +namespace sunrise::state { +struct PendingRecordRewardGrant; +} + namespace sunrise::server::bap::encrypted::queuez { /** @return True when one peer queuez state is canonical for the implemented versions. */ @@ -158,14 +162,6 @@ namespace sunrise::server::bap::encrypted::queuez { bool updatesAccount, ItemAcquisition& acquisition) noexcept; -/** Validates one same-version bundle append and returns the revision its response may promise. */ -[[nodiscard]] bool stage_direct_item_bundle(const SessionState& before, - std::uint64_t accountSoid, - std::uint64_t characterSoid, - std::uint64_t firstInstanceSoid, - std::size_t itemCount, - std::int32_t& family4Version) noexcept; - /** * Stages one Family-4 version increment for a full resident account-object upsert. * A profile row with a nonzero action-source SOID must already be resident when its stack grows, @@ -192,6 +188,10 @@ namespace sunrise::server::bap::encrypted::queuez { std::span appendedResidents, RecordRewardGrant& grant) noexcept; +[[nodiscard]] bool stage_record_reward_grant(const SessionState& before, + const state::PendingRecordRewardGrant& pending, + RecordRewardGrant& grant) noexcept; + /** * Stages one Family-4 increment that removes an item resident and updates its character. * diff --git a/Sunrise/src/server/bap/encrypted/queuez/staging/queuez_character_staging.cpp b/Sunrise/src/server/bap/encrypted/queuez/staging/queuez_character_staging.cpp index f5ed8b243..e945bb314 100644 --- a/Sunrise/src/server/bap/encrypted/queuez/staging/queuez_character_staging.cpp +++ b/Sunrise/src/server/bap/encrypted/queuez/staging/queuez_character_staging.cpp @@ -7,6 +7,7 @@ #include "../../../../../core/logging/log.h" #include "../../../../../middleware/datagen/definitions.h" +#include "../../../../../state/runtime/runtime.h" #include "../queuez_state_validation.h" namespace sunrise::server::bap::encrypted::queuez { @@ -472,55 +473,30 @@ bool stage_item_acquisition(const SessionState& before, return staged; } -/** Checks that a fixed bundle can append all residents in one Family-4 increment. */ -bool stage_direct_item_bundle(const SessionState& before, - std::uint64_t accountSoid, - std::uint64_t characterSoid, - std::uint64_t firstInstanceSoid, - std::size_t itemCount, - std::int32_t& family4Version) noexcept { - family4Version = 0; - std::uint32_t accountDefinitionId = 0; - std::uint32_t characterDefinitionId = 0; - std::uint32_t itemDefinitionId = 0; - if (!valid(before) || !before.family4Active || accountSoid == 0 || characterSoid == 0 - || firstInstanceSoid == 0 || itemCount == 0 || accountSoid != before.family4RootSoid - || before.family4ResidentCount == 0 - || itemCount > before.family4Residents.size() - before.family4ResidentCount - || itemCount - 1U > (std::numeric_limits::max)() - firstInstanceSoid - || before.family4Version == (std::numeric_limits::max)() - || !middleware::datagen::object_id( - kAccountFamilyType, middleware::datagen::kAccountSlot, accountDefinitionId) - || !middleware::datagen::object_id( - kAccountFamilyType, middleware::datagen::kCharacterSlot, characterDefinitionId) - || !middleware::datagen::object_id( - kAccountFamilyType, middleware::datagen::kItemInstanceSlot, itemDefinitionId)) { +/** Stages one atomic record-reward manifest update. */ +bool stage_record_reward_grant(const SessionState& before, + const state::PendingRecordRewardGrant& pending, + RecordRewardGrant& grant) noexcept { + grant = {}; + if (!pending.prepared || pending.rewardCount > pending.rewards.size()) { return false; } - - std::size_t accountMatches = 0; - std::size_t characterMatches = 0; - for (std::size_t residentIndex = 0; residentIndex < before.family4ResidentCount; - ++residentIndex) { - const ResidentObject& resident = before.family4Residents[residentIndex]; - accountMatches += static_cast(resident.objectSoid == accountSoid - && resident.definitionId == accountDefinitionId); - characterMatches += static_cast( - resident.objectSoid == characterSoid && resident.definitionId == characterDefinitionId); - for (std::size_t itemIndex = 0; itemIndex < itemCount; ++itemIndex) { - if (resident.objectSoid == firstInstanceSoid + itemIndex) { - return false; - } + std::array residents{}; + std::size_t count = 0; + for (std::size_t i = 0; i < pending.rewardCount; ++i) { + const auto& reward = pending.rewards[i]; + if (reward.kind == state::RecordRewardKind::characterInstance + || reward.appendedProfileResident) { + residents[count++] = reward.instanceSoid; } } - if (accountMatches != 1 || characterMatches != 1) { - return false; - } - family4Version = before.family4Version + 1; - return true; + return stage_record_reward_grant(before, + pending.accountSoid, + pending.characterSoid, + std::span(residents).first(count), + grant); } -/** Stages one atomic record-reward manifest update. */ bool stage_record_reward_grant(const SessionState& before, std::uint64_t accountSoid, std::uint64_t characterSoid, diff --git a/Sunrise/src/server/bap/internal.h b/Sunrise/src/server/bap/internal.h index ac99ff4b5..947a61631 100644 --- a/Sunrise/src/server/bap/internal.h +++ b/Sunrise/src/server/bap/internal.h @@ -323,6 +323,9 @@ struct ReplicationEpochPublication { bool staged{}; }; +/** 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; + /** Which inventory one world reward lands in. */ enum class WorldRewardKind : std::uint8_t { item, diff --git a/Sunrise/src/server/web_service/web_service_record_actions.cpp b/Sunrise/src/server/web_service/web_service_record_actions.cpp index 34dd754be..fae163449 100644 --- a/Sunrise/src/server/web_service/web_service_record_actions.cpp +++ b/Sunrise/src/server/web_service/web_service_record_actions.cpp @@ -1,19 +1,14 @@ /** Triumph, title and season-pass reward actions the web service prepares from one request. */ -#include #include #include #include #include #include "../../core/logging/log.h" -#include "../../middleware/crypto/random_bytes.h" -#include "../../middleware/encoding/byte_order.h" #include "../../middleware/web_service/messages/opcode1801.h" #include "../../middleware/web_service/messages/opcode1821.h" #include "../../middleware/web_service/messages/opcode2400.h" -#include "../../state/account/account_state.h" -#include "../../state/build_data/items/item_catalog.h" #include "../../state/build_data/runtime.h" #include "../../state/progression/season_pass_reward_catalog.h" #include "../../state/runtime/runtime.h" @@ -23,226 +18,6 @@ namespace sunrise::server::web_service { -namespace { - -/** Prepares one rank-one class package without changing account State. */ -[[nodiscard]] bool -prepare_premium_class_package(const state::build_data::season_pass::Package& package, - state::PendingDirectItemBundle& mutation) noexcept { - std::array itemIndices{}; - if (package.itemCount == 0 || package.itemCount > itemIndices.size()) { - return false; - } - for (std::size_t index = 0; index < package.itemCount; ++index) { - state::build_data::items::Definition definition{}; - if (!state::build_data::find_item_definition_hash(package.items[index], definition)) { - return false; - } - itemIndices[index] = definition.definitionIndex; - } - return state::prepare_direct_item_bundle( - package.definitionHash, std::span(itemIndices).first(package.itemCount), mutation); -} - -/** Expands the manifest-authored destination package into all nine material stacks. */ -[[nodiscard]] bool -prepare_destination_resource_bundle(state::PendingSeasonPassReward& grant) noexcept { - namespace pass = state::progression::season_pass; - std::array rewards{}; - for (std::size_t index = 0; index < rewards.size(); ++index) { - state::build_data::items::Definition definition{}; - if (!state::build_data::find_item_definition_hash(pass::kDestinationResourceHashes[index], - definition)) { - return false; - } - rewards[index] = {definition.definitionIndex, pass::kDestinationResourceQuantity}; - } - auto& mutation = grant.grant.emplace(); - return state::prepare_record_reward_grant(rewards, {}, mutation); -} - -/** Chooses one installed weapon or selected-class armour item from an auto-decrypting engram. */ -template -[[nodiscard]] std::span -class_armour_pool(state::CharacterClass characterClass, - const std::array& titan, - const std::array& hunter, - const std::array& warlock) noexcept { - switch (characterClass) { - case state::CharacterClass::hunter: - return hunter; - case state::CharacterClass::warlock: - return warlock; - case state::CharacterClass::titan: - default: - return titan; - } -} - -/** - * Picks one random installed item from the engram's weapon and selected-class armour pools. - * @param itemIndex Receives the picked item definition index; unchanged on failure. - * @return False when no character is selected, the engram is unknown, or no pool item is installed. - */ -[[nodiscard]] bool choose_engram_reward(std::uint32_t engramHash, - std::uint16_t& itemIndex) noexcept { - namespace pass = state::progression::season_pass; - std::span weapons; - std::span armour; - const state::AccountState account = state::account_snapshot(); - const state::CharacterState* character = nullptr; - for (std::size_t index = 0; index < account.characterCount; ++index) { - if (account.characters[index].selected) { - character = &account.characters[index]; - break; - } - } - if (character == nullptr) { - return false; - } - - if (engramHash == pass::kLegendaryEngramHash) { - weapons = pass::kLegendaryEngramWeapons; - armour = class_armour_pool(character->characterClass, - pass::kLegendaryTitanArmour, - pass::kLegendaryHunterArmour, - pass::kLegendaryWarlockArmour); - } else if (engramHash == pass::kExoticEngramHash) { - weapons = pass::kExoticEngramWeapons; - armour = class_armour_pool(character->characterClass, - pass::kExoticTitanArmour, - pass::kExoticHunterArmour, - pass::kExoticWarlockArmour); - } else { - return false; - } - - std::array randomBytes{}; - if (!middleware::crypto::random::fill(randomBytes)) { - return false; - } - const std::size_t count = weapons.size() + armour.size(); - const std::size_t first = middleware::encoding::read_u32_le(randomBytes) % count; - for (std::size_t offset = 0; offset < count; ++offset) { - const std::size_t selected = (first + offset) % count; - const std::uint32_t hash = - selected < weapons.size() ? weapons[selected] : armour[selected - weapons.size()]; - state::build_data::items::Definition definition{}; - if (state::build_data::find_item_definition_hash(hash, definition)) { - itemIndex = definition.definitionIndex; - return true; - } - } - return false; -} - -/** Stat plugs occupy the four ordinary socket lanes after the six the armour piece itself uses. */ -constexpr std::size_t kSeasonArmourStatLaneBase = 6; -/** One roll names one lane of each of the four armour stats. */ -constexpr std::size_t kSeasonArmourStatLaneCount = 4; - -struct SeasonArmourRoll { - std::uint16_t rewardIndex{}; - std::uint32_t itemHash{}; - std::array statPlugs{}; -}; - -/** - * Exact rolls shown by the free, early premium, and high-stat pass tiles. - * TODO: extract; the pass reward row names the item, and no package field names its stat plugs. - */ -constexpr std::array kSeasonArmourRolls{{ - // Hunter - {29, 3097544525U, {1232924472U, 3898228147U, 1177742666U, 1322519294U}}, - {106, 3097544525U, {2191854732U, 3198072315U, 1322519294U, 597281635U}}, - {139, 3097544525U, {3072193746U, 772656086U, 2692705068U, 1300719726U}}, - {3, 3750210364U, {1232924472U, 3898228147U, 1177742666U, 757147114U}}, - {78, 3750210364U, {2120002858U, 1526511067U, 1277668557U, 3014984195U}}, - {111, 3750210364U, {2600679334U, 3955940376U, 3026009912U, 115819390U}}, - {24, 2930001572U, {1232924472U, 2518640481U, 1177742666U, 4237052128U}}, - {97, 2930001572U, {2191854732U, 2440285137U, 1322519294U, 597281635U}}, - {134, 2930001572U, {4286256569U, 2148570570U, 594234536U, 2664898188U}}, - {10, 3136019014U, {2191854732U, 2236091344U, 1177742666U, 2596709069U}}, - {83, 3136019014U, {3407231789U, 1798836563U, 1322519294U, 3203727595U}}, - {120, 3136019014U, {3346214146U, 2399358832U, 594234536U, 2701881372U}}, - // Titan - {30, 1214477175U, {1232924472U, 3898228147U, 1177742666U, 1322519294U}}, - {107, 1214477175U, {479726201U, 176934377U, 1322519294U, 597281635U}}, - {140, 1214477175U, {2868325782U, 2387165386U, 2692705068U, 1300719726U}}, - {4, 287888126U, {1232924472U, 3898228147U, 1177742666U, 757147114U}}, - {79, 287888126U, {2120002858U, 1369119565U, 1277668557U, 3014984195U}}, - {112, 287888126U, {2148570570U, 2148570570U, 3026009912U, 115819390U}}, - {25, 1585947570U, {1232924472U, 2518640481U, 1177742666U, 4237052128U}}, - {98, 1585947570U, {1232924472U, 1533918171U, 1322519294U, 597281635U}}, - {135, 1585947570U, {2868325782U, 1087628722U, 594234536U, 2664898188U}}, - {11, 1131831128U, {2191854732U, 2236091344U, 1177742666U, 2596709069U}}, - {84, 1131831128U, {1232924472U, 4002613733U, 1322519294U, 3203727595U}}, - {121, 1131831128U, {2387165386U, 2148570570U, 594234536U, 2701881372U}}, - // Warlock - {31, 1173249516U, {1232924472U, 3898228147U, 1177742666U, 1322519294U}}, - {108, 1173249516U, {2191854732U, 2433764085U, 1322519294U, 597281635U}}, - {141, 1173249516U, {2868325782U, 3072193746U, 2692705068U, 1300719726U}}, - {5, 327547301U, {1232924472U, 3898228147U, 1177742666U, 757147114U}}, - {80, 327547301U, {1232924472U, 3554741641U, 1277668557U, 3014984195U}}, - {113, 327547301U, {4248662490U, 1281324436U, 3026009912U, 115819390U}}, - {26, 119457531U, {1232924472U, 2518640481U, 1177742666U, 4237052128U}}, - {99, 119457531U, {1232924472U, 1858911761U, 1322519294U, 597281635U}}, - {136, 119457531U, {3198618292U, 643846500U, 594234536U, 2664898188U}}, - {12, 674876967U, {2191854732U, 2236091344U, 1177742666U, 2596709069U}}, - {85, 674876967U, {2576224072U, 1534361879U, 1322519294U, 3203727595U}}, - {122, 674876967U, {643846500U, 4248662490U, 594234536U, 2701881372U}}, -}}; - -/** Replaces native stat plugs with the fixed roll represented by one known pass tile. */ -[[nodiscard]] bool apply_season_armour_roll(std::uint16_t rewardIndex, - state::PendingItemAcquisition& mutation) noexcept { - const auto roll = std::find_if(kSeasonArmourRolls.begin(), - kSeasonArmourRolls.end(), - [rewardIndex](const SeasonArmourRoll& candidate) { - return candidate.rewardIndex == rewardIndex; - }); - if (roll == kSeasonArmourRolls.end()) { - return true; - } - if (mutation.inventoryIndex >= mutation.afterCharacter.inventory.count) { - return false; - } - auto& acquired = mutation.afterCharacter.inventory.values[mutation.inventoryIndex]; - state::build_data::items::Definition item{}; - state::build_data::items::details::Definition detail{}; - if (acquired.definitionHash != mutation.acquiredDefinitionHash - || acquired.definitionHash != roll->itemHash - || !state::build_data::find_item_definition_hash(acquired.definitionHash, item) - || !state::build_data::find_configured_item_detail(item.definitionIndex, detail) - || detail.definitionIndex != item.definitionIndex - || detail.ordinarySocketCount < kSeasonArmourStatLaneBase + kSeasonArmourStatLaneCount - || detail.ordinarySocketCount > acquired.sockets.plugs.size()) { - return false; - } - - state::account::inventory::Sockets sockets{}; - sockets.policy = state::account::inventory::SocketPolicy::authored; - sockets.plugCount = detail.ordinarySocketCount; - for (std::size_t lane = 0; lane < sockets.plugCount; ++lane) { - const std::uint16_t plugIndex = detail.initialPlugIndices[lane]; - if (plugIndex == state::build_data::items::details::kUnavailableItemIndex) { - continue; - } - state::build_data::items::Definition plug{}; - if (!state::build_data::find_item_definition_index(plugIndex, plug)) { - return false; - } - sockets.plugs[lane] = plug.definitionHash; - } - for (std::size_t index = 0; index < roll->statPlugs.size(); ++index) { - sockets.plugs[kSeasonArmourStatLaneBase + index] = roll->statPlugs[index]; - } - acquired.sockets = sockets; - return state::account::inventory::valid(acquired.sockets); -} - -} // namespace - /** Reports an opcode-1801 claim failure. */ void report_record_claim(const middleware::web_service::Message& message, std::string_view reason, @@ -286,52 +61,6 @@ void report_record_reward(const middleware::web_service::Message& message, write_warning(line, count); } -/** Prepares one direct Season-pass item, returning a failure reason or null. */ -[[nodiscard]] const char* prepare_direct_reward(std::uint16_t itemIndex, - std::int32_t quantity, - state::PendingSeasonPassReward& grant) noexcept { - if (quantity <= 0) { - return "quantity"; - } - state::build_data::items::Definition definition{}; - if (!state::build_data::find_item_definition_index(itemIndex, definition)) { - return "item_definition"; - } - - state::build_data::items::details::Definition detail{}; - state::build_data::inventory::buckets::Descriptor bucket{}; - if (!state::build_data::find_configured_item_detail(itemIndex, detail) - || detail.definitionIndex != itemIndex || detail.definitionHash != definition.definitionHash - || detail.bucketId != definition.bucketId - || !state::build_data::find_inventory_bucket_descriptor(detail.bucketId, bucket)) { - return "item_detail_or_bucket"; - } - - namespace bucket_domain = state::build_data::inventory::buckets; - namespace detail_domain = state::build_data::items::details; - if (bucket.arraySelector == bucket_domain::ArraySelector::profile) { - if (detail.instancedDefinitionState != detail_domain::InstancedDefinitionState::stackable) { - return "profile_item_instanced"; - } - auto& mutation = grant.grant.emplace(); - return state::prepare_profile_item_acquisition_for_item(itemIndex, quantity, mutation) - ? nullptr - : "profile_state"; - } - if (bucket.arraySelector == bucket_domain::ArraySelector::character) { - if (quantity != 1) { - return "character_item_quantity"; - } - auto& mutation = grant.grant.emplace(); - if (!state::prepare_item_acquisition_for_item(itemIndex, mutation)) { - return "state"; - } - mutation.profileChanged = false; - return nullptr; - } - return "unsupported_inventory_array"; -} - /** Whether a claimed record owes a reward, and whether that reward could be prepared. */ enum class RecordRewardPreparation : std::uint8_t { /** The record grants nothing, so only the claim is written. */ @@ -427,50 +156,16 @@ void claim_season_pass_reward(const middleware::web_service::Message& message, if (grant == nullptr) { return fail("storage"); } - state::build_data::season_pass::Package package{}; - if (state::build_data::find_season_pass_package(reward.itemHash, package)) { - auto& bundle = grant->grant.emplace(); - if (!prepare_premium_class_package(package, bundle)) { - clear_mutation(outcome); - return fail("package_grant"); - } - } else if (reward.itemHash == pass::kDestinationResourceBundleHash) { - if (!prepare_destination_resource_bundle(*grant)) { - clear_mutation(outcome); - return fail("resource_bundle"); - } - } else if (reward.itemHash == pass::kLegendaryEngramHash - || reward.itemHash == pass::kExoticEngramHash) { - std::uint16_t decryptedItemIndex = 0; - if (!choose_engram_reward(reward.itemHash, decryptedItemIndex)) { - clear_mutation(outcome); - return fail("engram_pool"); - } - if (const char* reason = prepare_direct_reward(decryptedItemIndex, 1, *grant)) { - clear_mutation(outcome); - return fail(reason); - } - } else { - if (const char* reason = prepare_direct_reward( - item.definitionIndex, static_cast(reward.quantity), *grant)) { - clear_mutation(outcome); - return fail(reason); - } - if (auto* armour = std::get_if(&grant->grant); - armour != nullptr && !apply_season_armour_roll(request.rewardIndex, *armour)) { - clear_mutation(outcome); - return fail("armour_roll"); - } + const char* reason = nullptr; + if (!state::prepare_season_pass_reward(request.rewardIndex, *grant, &reason)) { + clear_mutation(outcome); + return fail(reason != nullptr ? reason : "reward_preparation"); } - // The claim is world state the moment the grant is accepted, so it is written before the - // reply and the push are staged from the banks. + // Stage the claim before encoding the reply and account push from the transaction's banks. if (!state::claim_season_pass_reward(request.rewardIndex)) { clear_mutation(outcome); return fail("claim_rejected"); } - grant->sourceDefinitionHash = reward.itemHash; - grant->rewardIndex = request.rewardIndex; - grant->prepared = true; } /** Decodes one opcode-1801 Triumphs claim and reports the record it names. */ diff --git a/Sunrise/src/state/account/account_state.h b/Sunrise/src/state/account/account_state.h index b76d74f94..13be4ee26 100644 --- a/Sunrise/src/state/account/account_state.h +++ b/Sunrise/src/state/account/account_state.h @@ -79,6 +79,10 @@ enum class CharacterClass : std::uint8_t { warlock = 2, }; +/** Number of character classes represented on the wire. */ +inline constexpr std::size_t kCharacterClassCount = + static_cast(CharacterClass::warlock) + 1; + /** Default movement entry. Each subclass offers 3, as entries 4, 5 and 6 of its group. */ inline constexpr std::uint8_t kDefaultMovementAbilityEntry = 4; diff --git a/Sunrise/src/state/build_data/build_data_runtime.cpp b/Sunrise/src/state/build_data/build_data_runtime.cpp index 5564f1c07..416fb743d 100644 --- a/Sunrise/src/state/build_data/build_data_runtime.cpp +++ b/Sunrise/src/state/build_data/build_data_runtime.cpp @@ -21,6 +21,7 @@ #include "nodes/node_catalog.h" #include "progressions/progression_catalog.h" #include "records/record_catalog.h" +#include "rewards/reward_catalog.h" #include "runtime.h" #include "runtime/build_data_catalog_runtime.h" #include "runtime/domain_markers.h" @@ -113,6 +114,12 @@ bool initialize(void* module, std::uint64_t configuredEquipmentHash) noexcept { if (status != cache::LoadStatus::loaded || !constants::replace(cachedConstants) || !content::replace(domains.named) || !content::seal() || !items::replace(domains.items) || !collectibles::replace(domains.collectibles) + || !rewards::replace({domains.rewardPools, + domains.rewardEntries, + domains.rewardItems, + domains.rewardInstructions, + domains.rewardModifiers, + domains.rewardSockets}) || !material_requirements::replace(domains.materialRequirementSets) || !inventory::buckets::replace(domains.inventoryBuckets) || !socket_entry_lists::replace(domains.socketEntryLists) @@ -124,8 +131,7 @@ bool initialize(void* module, std::uint64_t configuredEquipmentHash) noexcept { || !catalystsReplaced || !abilities::replace(domains.abilityBuckets) || !progressions::replace(domains.progressions, domains.progressionSteps) // An empty catalog is complete: a build with no installed pass declares no reward. - || (!domains.seasonPassRewards.empty() - && !season_pass::replace(domains.seasonPassRewards, domains.seasonPassPackages)) + || (!domains.seasonPassRewards.empty() && !season_pass::replace(domains.seasonPassRewards)) || !bounties::replace(domains.bounties) || !records::replace(domains.records, domains.recordObjectives, diff --git a/Sunrise/src/state/build_data/cache/read/cache_file_reader.cpp b/Sunrise/src/state/build_data/cache/read/cache_file_reader.cpp index cd2b0f1ae..b25d51e61 100644 --- a/Sunrise/src/state/build_data/cache/read/cache_file_reader.cpp +++ b/Sunrise/src/state/build_data/cache/read/cache_file_reader.cpp @@ -53,8 +53,13 @@ namespace { && counts.recordRewards <= output.recordRewards.size() && counts.progressionSteps <= output.progressionSteps.size() && counts.seasonPassRewards <= output.seasonPassRewards.size() - && counts.seasonPassPackages <= output.seasonPassPackages.size() - && counts.bounties <= output.bounties.size(); + && counts.bounties <= output.bounties.size() + && counts.rewardPools <= output.rewardPools.size() + && counts.rewardEntries <= output.rewardEntries.size() + && counts.rewardItems <= output.rewardItems.size() + && counts.rewardInstructions <= output.rewardInstructions.size() + && counts.rewardModifiers <= output.rewardModifiers.size() + && counts.rewardSockets <= output.rewardSockets.size(); } /** @return The header's row counts, as platform sizes. */ @@ -94,8 +99,13 @@ namespace { header.recordRewardCount, header.progressionStepCount, header.seasonPassRewardCount, - header.seasonPassPackageCount, header.bountyCount, + header.rewardPoolCount, + header.rewardEntryCount, + header.rewardItemCount, + header.rewardInstructionCount, + header.rewardModifierCount, + header.rewardSocketCount, }; } diff --git a/Sunrise/src/state/build_data/cache/read/cache_payload_reader.cpp b/Sunrise/src/state/build_data/cache/read/cache_payload_reader.cpp index 8b455acc2..ba407f9d7 100644 --- a/Sunrise/src/state/build_data/cache/read/cache_payload_reader.cpp +++ b/Sunrise/src/state/build_data/cache/read/cache_payload_reader.cpp @@ -123,9 +123,14 @@ void clear(records::MutableDomains output) noexcept { std::fill(output.progressionSteps.begin(), output.progressionSteps.end(), progressions::Step{}); std::fill( output.seasonPassRewards.begin(), output.seasonPassRewards.end(), season_pass::Reward{}); - std::fill( - output.seasonPassPackages.begin(), output.seasonPassPackages.end(), season_pass::Package{}); std::fill(output.bounties.begin(), output.bounties.end(), bounties::Definition{}); + std::fill(output.rewardPools.begin(), output.rewardPools.end(), rewards::Pool{}); + std::fill(output.rewardEntries.begin(), output.rewardEntries.end(), rewards::Entry{}); + std::fill(output.rewardItems.begin(), output.rewardItems.end(), rewards::Item{}); + std::fill( + output.rewardInstructions.begin(), output.rewardInstructions.end(), rewards::Instruction{}); + std::fill(output.rewardModifiers.begin(), output.rewardModifiers.end(), rewards::Modifier{}); + std::fill(output.rewardSockets.begin(), output.rewardSockets.end(), rewards::SocketOverride{}); } /** Computes the exact file size for every record array. */ @@ -167,8 +172,13 @@ bool expected_size(const records::DomainCounts& counts, std::uint64_t& size) noe && add_records(counts.recordRewards, sizeof(records::RecordRewardRecord), size) && add_records(counts.progressionSteps, sizeof(records::ProgressionStepRecord), size) && add_records(counts.seasonPassRewards, sizeof(records::SeasonPassRewardRecord), size) - && add_records(counts.seasonPassPackages, sizeof(records::SeasonPassPackageRecord), size) - && add_records(counts.bounties, sizeof(records::BountyRecord), size); + && add_records(counts.bounties, sizeof(records::BountyRecord), size) + && add_records(counts.rewardPools, sizeof(records::RewardPoolRecord), size) + && add_records(counts.rewardEntries, sizeof(records::RewardEntryRecord), size) + && add_records(counts.rewardItems, sizeof(records::RewardItemRecord), size) + && add_records(counts.rewardInstructions, sizeof(records::RewardInstructionRecord), size) + && add_records(counts.rewardModifiers, sizeof(records::RewardModifierRecord), size) + && add_records(counts.rewardSockets, sizeof(records::RewardSocketOverrideRecord), size); } /** Reads every payload array and checks the decoded domains as one transaction. */ @@ -282,12 +292,28 @@ bool read_payload(HANDLE file, valid = valid && read_domain( file, output.seasonPassRewards.first(counts.seasonPassRewards), checksum); - valid = valid - && read_domain( - file, output.seasonPassPackages.first(counts.seasonPassPackages), checksum); valid = valid && read_domain( file, output.bounties.first(counts.bounties), checksum); + valid = valid + && read_domain( + file, output.rewardPools.first(counts.rewardPools), checksum); + valid = valid + && read_domain( + file, output.rewardEntries.first(counts.rewardEntries), checksum); + valid = valid + && read_domain( + file, output.rewardItems.first(counts.rewardItems), checksum); + valid = valid + && read_domain( + file, output.rewardInstructions.first(counts.rewardInstructions), checksum); + valid = valid + && read_domain( + file, output.rewardModifiers.first(counts.rewardModifiers), checksum); + valid = valid + && read_domain( + file, output.rewardSockets.first(counts.rewardSockets), checksum); + if (!valid) { return false; } @@ -330,8 +356,13 @@ bool read_payload(HANDLE file, output.recordRewards.first(counts.recordRewards), output.progressionSteps.first(counts.progressionSteps), output.seasonPassRewards.first(counts.seasonPassRewards), - output.seasonPassPackages.first(counts.seasonPassPackages), output.bounties.first(counts.bounties), + output.rewardPools.first(counts.rewardPools), + output.rewardEntries.first(counts.rewardEntries), + output.rewardItems.first(counts.rewardItems), + output.rewardInstructions.first(counts.rewardInstructions), + output.rewardModifiers.first(counts.rewardModifiers), + output.rewardSockets.first(counts.rewardSockets), }); } diff --git a/Sunrise/src/state/build_data/cache/records/cache_domain_validation.cpp b/Sunrise/src/state/build_data/cache/records/cache_domain_validation.cpp index b5f17d125..dbdee109e 100644 --- a/Sunrise/src/state/build_data/cache/records/cache_domain_validation.cpp +++ b/Sunrise/src/state/build_data/cache/records/cache_domain_validation.cpp @@ -13,6 +13,7 @@ #include "../../nodes/node_catalog.h" #include "../../progressions/progression_catalog.h" #include "../../records/record_catalog.h" +#include "../../rewards/reward_catalog.h" #include "../../scenarios/scenario_catalog.h" #include "../../season_pass/season_pass_catalog.h" #include "../../sobjects/sobject_catalog.h" @@ -169,8 +170,13 @@ template && counts.recordRewards <= domains.recordRewards.size() && counts.progressionSteps <= domains.progressionSteps.size() && counts.seasonPassRewards <= domains.seasonPassRewards.size() - && counts.seasonPassPackages <= domains.seasonPassPackages.size() - && counts.bounties <= domains.bounties.size(); + && counts.bounties <= domains.bounties.size() + && counts.rewardPools <= domains.rewardPools.size() + && counts.rewardEntries <= domains.rewardEntries.size() + && counts.rewardItems <= domains.rewardItems.size() + && counts.rewardInstructions <= domains.rewardInstructions.size() + && counts.rewardModifiers <= domains.rewardModifiers.size() + && counts.rewardSockets <= domains.rewardSockets.size(); } } // namespace @@ -236,6 +242,12 @@ bool valid_domains(const BuildIdentity& build, Domains domains) noexcept { || !strictly_ordered(domains.named, named_less) || !items::valid(domains.items) || !collectibles::valid(domains.collectibles) || !strictly_ordered(domains.collectibles, collectible_less) + || !rewards::valid({domains.rewardPools, + domains.rewardEntries, + domains.rewardItems, + domains.rewardInstructions, + domains.rewardModifiers, + domains.rewardSockets}) || !material_requirements::valid(domains.materialRequirementSets) || !strictly_ordered(domains.materialRequirementSets, material_requirement_less) || !inventory::buckets::valid(domains.inventoryBuckets) @@ -249,8 +261,7 @@ bool valid_domains(const BuildIdentity& build, Domains domains) noexcept { || !strictly_ordered(domains.abilityBuckets, ability_less) || !progressions::valid(domains.progressions, domains.progressionSteps) // An empty catalog is complete: a build with no installed pass declares no reward. - || (!domains.seasonPassRewards.empty() - && !season_pass::valid(domains.seasonPassRewards, domains.seasonPassPackages)) + || (!domains.seasonPassRewards.empty() && !season_pass::valid(domains.seasonPassRewards)) || !bounties::valid(domains.bounties) || !build_data::records::valid(domains.records, domains.recordObjectives, @@ -277,10 +288,31 @@ bool valid_domains(const BuildIdentity& build, Domains domains) noexcept { return false; } for (std::size_t index = 0; index < domains.items.size(); ++index) { - if (domains.items[index].definitionIndex != index) { + if (domains.items[index].definitionIndex != index + || domains.rewardItems.size() != domains.items.size() + || (domains.rewardItems[index].definitionHash != 0 + && domains.rewardItems[index].definitionHash + != domains.items[index].definitionHash)) { return false; } } + for (const auto& reward : domains.seasonPassRewards) { + if (reward.itemHash == 0) { + continue; + } + if (reward.itemIndex >= domains.items.size() + || domains.items[reward.itemIndex].definitionHash != reward.itemHash) { + return false; + } + for (std::size_t i = 0; i < reward.socketCount; ++i) { + const auto& socket = reward.sockets[i]; + if (socket.socketType == rewards::kAbsent + || (socket.plugItem != rewards::kAbsent + && socket.plugItem >= domains.items.size())) { + return false; + } + } + } for (const material_requirements::Definition& definition : domains.materialRequirementSets) { for (std::size_t index = 0; index < definition.requirementCount; ++index) { const std::uint16_t itemIndex = definition.requirements[index].itemDefinitionIndex; diff --git a/Sunrise/src/state/build_data/cache/records/cache_investment_records.cpp b/Sunrise/src/state/build_data/cache/records/cache_investment_records.cpp index 90927bb6d..f60a86ec9 100644 --- a/Sunrise/src/state/build_data/cache/records/cache_investment_records.cpp +++ b/Sunrise/src/state/build_data/cache/records/cache_investment_records.cpp @@ -98,6 +98,22 @@ bool encode(const season_pass::Reward& value, SeasonPassRewardRecord& record) no record.itemIndex = value.itemIndex; record.claimFlagIndex = value.claimFlagIndex; record.requiredRank = value.requiredRank; + record.socketCount = value.socketCount; + record.conditionCount = value.conditionCount; + if (record.conditionCount > record.condition.size() + || record.socketCount > record.sockets.size()) { + return false; + } + for (std::size_t i = 0; i < record.condition.size(); ++i) { + if (!encode(value.condition[i], record.condition[i])) { + return false; + } + } + for (std::size_t i = 0; i < record.sockets.size(); ++i) { + if (!encode(value.sockets[i], record.sockets[i])) { + return false; + } + } return true; } @@ -112,30 +128,24 @@ bool decode(const SeasonPassRewardRecord& record, season_pass::Reward& value) no value.itemIndex = record.itemIndex; value.claimFlagIndex = record.claimFlagIndex; value.requiredRank = record.requiredRank; - return true; -} - -/** Encodes one season pass wrapper with its unused item slots zeroed. */ -bool encode(const season_pass::Package& value, SeasonPassPackageRecord& record) noexcept { - if (value.itemCount > value.items.size()) { + value.socketCount = record.socketCount; + value.conditionCount = record.conditionCount; + if (value.conditionCount > value.condition.size()) { return false; } - record = {}; - record.definitionHash = value.definitionHash; - record.items = value.items; - record.itemCount = value.itemCount; - return true; -} - -/** Decodes one season pass wrapper after checking its padding and item count. */ -bool decode(const SeasonPassPackageRecord& record, season_pass::Package& value) noexcept { - value = {}; - if (record.reserved != decltype(record.reserved){} || record.itemCount > record.items.size()) { + for (std::size_t i = 0; i < value.condition.size(); ++i) { + if (!decode(record.condition[i], value.condition[i])) { + return false; + } + } + if (value.socketCount > value.sockets.size()) { return false; } - value.definitionHash = record.definitionHash; - value.items = record.items; - value.itemCount = record.itemCount; + for (std::size_t i = 0; i < value.sockets.size(); ++i) { + if (!decode(record.sockets[i], value.sockets[i])) { + return false; + } + } return true; } diff --git a/Sunrise/src/state/build_data/cache/records/cache_reward_records.cpp b/Sunrise/src/state/build_data/cache/records/cache_reward_records.cpp new file mode 100644 index 000000000..237c0a0be --- /dev/null +++ b/Sunrise/src/state/build_data/cache/records/cache_reward_records.cpp @@ -0,0 +1,123 @@ +#include "codec.h" + +namespace sunrise::state::build_data::cache::records { + +bool encode(const rewards::Pool& value, RewardPoolRecord& record) noexcept { + record = {}; + record.definitionHash = value.definitionHash; + record.entries = {value.entries.first, value.entries.count}; + return true; +} + +bool decode(const RewardPoolRecord& record, rewards::Pool& value) noexcept { + value = {}; + value.definitionHash = record.definitionHash; + value.entries = {record.entries.first, record.entries.count}; + return true; +} + +bool encode(const rewards::Entry& value, RewardEntryRecord& record) noexcept { + record = {}; + record.itemIndex = value.itemIndex; + record.poolIndex = value.poolIndex; + record.quantity = value.quantity; + record.categoryHash = value.categoryHash; + record.bucketHash = value.bucketHash; + record.weight = value.weight; + record.condition = {value.condition.first, value.condition.count}; + record.modifiers = {value.modifiers.first, value.modifiers.count}; + record.sockets = {value.sockets.first, value.sockets.count}; + return true; +} + +bool decode(const RewardEntryRecord& record, rewards::Entry& value) noexcept { + value = {}; + value.itemIndex = record.itemIndex; + value.poolIndex = record.poolIndex; + value.quantity = record.quantity; + value.categoryHash = record.categoryHash; + value.bucketHash = record.bucketHash; + value.weight = record.weight; + value.condition = {record.condition.first, record.condition.count}; + value.modifiers = {record.modifiers.first, record.modifiers.count}; + value.sockets = {record.sockets.first, record.sockets.count}; + return true; +} + +bool encode(const rewards::Item& value, RewardItemRecord& record) noexcept { + record = {}; + record.definitionHash = value.definitionHash; + record.poolIndex = value.poolIndex; + record.acquiredFlag = value.acquiredFlag; + for (std::size_t i = 0; i < record.selections.size(); ++i) { + record.selections[i] = {value.selections[i].categoryHash, value.selections[i].count}; + } + record.selectionCount = value.selectionCount; + record.flags = value.flags; + return true; +} + +bool decode(const RewardItemRecord& record, rewards::Item& value) noexcept { + value = {}; + value.definitionHash = record.definitionHash; + value.poolIndex = record.poolIndex; + value.acquiredFlag = record.acquiredFlag; + for (std::size_t i = 0; i < value.selections.size(); ++i) { + value.selections[i] = {record.selections[i].categoryHash, record.selections[i].count}; + } + value.selectionCount = record.selectionCount; + value.flags = record.flags; + return true; +} + +bool encode(const rewards::Instruction& value, RewardInstructionRecord& record) noexcept { + record = {}; + record.opcode = value.opcode; + record.operand = value.operand; + return true; +} + +bool decode(const RewardInstructionRecord& record, rewards::Instruction& value) noexcept { + value = {}; + value.opcode = record.opcode; + value.operand = record.operand; + return true; +} + +bool encode(const rewards::Modifier& value, RewardModifierRecord& record) noexcept { + record = {}; + record.condition = {value.condition.first, value.condition.count}; + record.valueIndex = value.valueIndex; + record.value = value.value; + return true; +} + +bool decode(const RewardModifierRecord& record, rewards::Modifier& value) noexcept { + value = {}; + value.condition = {record.condition.first, record.condition.count}; + value.valueIndex = record.valueIndex; + value.value = record.value; + return true; +} + +bool encode(const rewards::SocketOverride& value, RewardSocketOverrideRecord& record) noexcept { + record = {}; + record.socketType = value.socketType; + record.plugItem = value.plugItem; + record.plugSet = value.plugSet; + record.rollSet = value.rollSet; + record.selection = value.selection; + return true; +} + +bool decode(const RewardSocketOverrideRecord& record, rewards::SocketOverride& value) noexcept { + value = {}; + value.socketType = record.socketType; + value.plugItem = record.plugItem; + value.plugSet = record.plugSet; + value.rollSet = record.rollSet; + value.selection = record.selection; + return true; +} + +} // namespace sunrise::state::build_data::cache::records diff --git a/Sunrise/src/state/build_data/cache/records/codec.h b/Sunrise/src/state/build_data/cache/records/codec.h index d535bbc78..58dc06b99 100644 --- a/Sunrise/src/state/build_data/cache/records/codec.h +++ b/Sunrise/src/state/build_data/cache/records/codec.h @@ -95,11 +95,6 @@ namespace sunrise::state::build_data::cache::records { [[nodiscard]] bool decode(const SeasonPassRewardRecord& record, season_pass::Reward& value) noexcept; -[[nodiscard]] bool encode(const season_pass::Package& value, - SeasonPassPackageRecord& record) noexcept; -[[nodiscard]] bool decode(const SeasonPassPackageRecord& record, - season_pass::Package& value) noexcept; - [[nodiscard]] bool encode(const bounties::Definition& value, BountyRecord& record) noexcept; [[nodiscard]] bool decode(const BountyRecord& record, bounties::Definition& value) noexcept; @@ -165,4 +160,21 @@ namespace sunrise::state::build_data::cache::records { [[nodiscard]] bool decode(const VendorInstalledRowRecord& record, vendors::InstalledRow& value) noexcept; +[[nodiscard]] bool encode(const rewards::Pool& value, RewardPoolRecord& record) noexcept; +[[nodiscard]] bool decode(const RewardPoolRecord& record, rewards::Pool& value) noexcept; +[[nodiscard]] bool encode(const rewards::Entry& value, RewardEntryRecord& record) noexcept; +[[nodiscard]] bool decode(const RewardEntryRecord& record, rewards::Entry& value) noexcept; +[[nodiscard]] bool encode(const rewards::Item& value, RewardItemRecord& record) noexcept; +[[nodiscard]] bool decode(const RewardItemRecord& record, rewards::Item& value) noexcept; +[[nodiscard]] bool encode(const rewards::Instruction& value, + RewardInstructionRecord& record) noexcept; +[[nodiscard]] bool decode(const RewardInstructionRecord& record, + rewards::Instruction& value) noexcept; +[[nodiscard]] bool encode(const rewards::Modifier& value, RewardModifierRecord& record) noexcept; +[[nodiscard]] bool decode(const RewardModifierRecord& record, rewards::Modifier& value) noexcept; +[[nodiscard]] bool encode(const rewards::SocketOverride& value, + RewardSocketOverrideRecord& record) noexcept; +[[nodiscard]] bool decode(const RewardSocketOverrideRecord& record, + rewards::SocketOverride& value) noexcept; + } // namespace sunrise::state::build_data::cache::records diff --git a/Sunrise/src/state/build_data/cache/records/domains.h b/Sunrise/src/state/build_data/cache/records/domains.h index 1dd1cf125..35845c336 100644 --- a/Sunrise/src/state/build_data/cache/records/domains.h +++ b/Sunrise/src/state/build_data/cache/records/domains.h @@ -19,6 +19,7 @@ #include "../../nodes/definition.h" #include "../../progressions/definition.h" #include "../../records/definition.h" +#include "../../rewards/definition.h" #include "../../scenarios/definition.h" #include "../../season_pass/definition.h" #include "../../sobjects/sobject_catalog.h" @@ -65,8 +66,13 @@ struct DomainCounts { std::size_t recordRewards{}; std::size_t progressionSteps{}; std::size_t seasonPassRewards{}; - std::size_t seasonPassPackages{}; std::size_t bounties{}; + std::size_t rewardPools{}; + std::size_t rewardEntries{}; + std::size_t rewardItems{}; + std::size_t rewardInstructions{}; + std::size_t rewardModifiers{}; + std::size_t rewardSockets{}; }; /** Fixed caller storage used while decoding the cache domains. */ @@ -108,8 +114,13 @@ struct MutableDomains { std::span recordRewards; std::span progressionSteps; std::span seasonPassRewards; - std::span seasonPassPackages; std::span bounties; + std::span rewardPools; + std::span rewardEntries; + std::span rewardItems; + std::span rewardInstructions; + std::span rewardModifiers; + std::span rewardSockets; }; /** Read-only complete views used for the checks and for cache encoding. */ @@ -150,8 +161,13 @@ struct Domains { std::span recordRewards; std::span progressionSteps; std::span seasonPassRewards; - std::span seasonPassPackages; std::span bounties; + std::span rewardPools; + std::span rewardEntries; + std::span rewardItems; + std::span rewardInstructions; + std::span rewardModifiers; + std::span rewardSockets; }; } // namespace sunrise::state::build_data::cache::records diff --git a/Sunrise/src/state/build_data/cache/records/format.h b/Sunrise/src/state/build_data/cache/records/format.h index 6100e824b..4d8d9c61e 100644 --- a/Sunrise/src/state/build_data/cache/records/format.h +++ b/Sunrise/src/state/build_data/cache/records/format.h @@ -20,6 +20,7 @@ #include "../../nodes/definition.h" #include "../../progressions/definition.h" #include "../../records/definition.h" +#include "../../rewards/definition.h" #include "../../scenarios/definition.h" #include "../../season_pass/definition.h" #include "../../sobjects/sobject_catalog.h" @@ -31,7 +32,7 @@ namespace sunrise::state::build_data::cache::records { /** These 8 ASCII bytes mark a Sunrise build-data file. */ inline constexpr std::array kCacheMagic{'S', 'U', 'N', 'R', 'I', 'S', 'E', 'B'}; /** Bump when stored layouts or extracted values change; other versions are rebuilt. */ -inline constexpr std::uint32_t kCacheFormatVersion = 66; +inline constexpr std::uint32_t kCacheFormatVersion = 67; /** Signed -1 on disk means there is no equipment slot. */ inline constexpr std::int8_t kAbsentEquipmentSlot = -1; /** The standard 64-bit FNV-1a offset basis starts the payload checksum. */ @@ -102,8 +103,13 @@ struct Header { std::uint32_t recordRewardCount{}; std::uint32_t progressionStepCount{}; std::uint32_t seasonPassRewardCount{}; - std::uint32_t seasonPassPackageCount{}; std::uint32_t bountyCount{}; + std::uint32_t rewardPoolCount{}; + std::uint32_t rewardEntryCount{}; + std::uint32_t rewardItemCount{}; + std::uint32_t rewardInstructionCount{}; + std::uint32_t rewardModifierCount{}; + std::uint32_t rewardSocketCount{}; gameplay::entity_position_profiles::Fingerprint positionFingerprint{}; InvestmentConstants constants{}; std::uint64_t payloadChecksum{}; @@ -302,6 +308,21 @@ struct ProgressionStepRecord { std::int32_t cost{}; }; +/** Disk form of one reward socket override. */ +struct RewardSocketOverrideRecord { + std::uint16_t socketType{}; + std::uint16_t plugItem{}; + std::uint16_t plugSet{}; + std::uint16_t rollSet{}; + std::uint32_t selection{}; +}; + +/** One normalized reward-condition instruction. */ +struct RewardInstructionRecord { + std::uint32_t opcode{}; + std::uint32_t operand{}; +}; + /** Disk form of one season pass reward row. */ struct SeasonPassRewardRecord { std::uint32_t itemHash{}; @@ -309,19 +330,14 @@ struct SeasonPassRewardRecord { std::uint16_t itemIndex{}; std::uint16_t claimFlagIndex{season_pass::kUnavailableFlagIndex}; std::uint8_t requiredRank{}; + std::array sockets{}; + std::uint8_t socketCount{}; + std::array condition{}; + std::uint8_t conditionCount{}; /** Must be zero, so the packed reward row always matches. */ std::array reserved{}; }; -/** Disk form of one season pass wrapper item and the set it opens into. */ -struct SeasonPassPackageRecord { - std::uint32_t definitionHash{}; - std::array items{}; - std::uint8_t itemCount{}; - /** Must be zero, so the packed wrapper row always matches. */ - std::array reserved{}; -}; - /** Disk form of one repeatable bounty and the item-type its pool is keyed by. */ struct BountyRecord { std::uint32_t itemTypeBank{}; @@ -583,13 +599,76 @@ struct RosterGroupRecord { std::array slotIndices{}; }; +/** One declared category selection in a reward wrapper. */ +struct RewardSelectionRecord { + std::uint32_t categoryHash{}; + std::uint32_t count{}; +}; + +/** Offset and count into one reward bank. */ +struct RewardRangeRecord { + std::uint32_t first{}; + std::uint32_t count{}; +}; + +/** Pool identity and its contiguous member range. */ +struct RewardPoolRecord { + std::uint32_t definitionHash{}; + RewardRangeRecord entries{}; +}; + +/** Reward target, quantity, weight and its dependent bank ranges. */ +struct RewardEntryRecord { + std::uint16_t itemIndex{}; + std::uint16_t poolIndex{}; + std::uint32_t quantity{}; + std::uint32_t categoryHash{}; + std::uint32_t bucketHash{}; + float weight{}; + RewardRangeRecord condition{}; + RewardRangeRecord modifiers{}; + RewardRangeRecord sockets{}; +}; + +/** Native item-to-pool relation and acquisition flag. */ +struct RewardItemRecord { + std::uint32_t definitionHash{}; + std::uint16_t poolIndex{}; + std::uint16_t acquiredFlag{}; + std::array selections{}; + std::uint8_t selectionCount{}; + std::uint32_t flags{}; +}; + +/** Conditional replacement weight or indexed value reference. */ +struct RewardModifierRecord { + RewardRangeRecord condition{}; + std::uint16_t valueIndex{}; + float value{}; +}; + #pragma pack(pop) +static_assert(sizeof(RewardSelectionRecord) == 2 * sizeof(std::uint32_t)); +static_assert(sizeof(RewardRangeRecord) == 2 * sizeof(std::uint32_t)); +static_assert(sizeof(RewardPoolRecord) == sizeof(std::uint32_t) + sizeof(RewardRangeRecord)); +static_assert(sizeof(RewardEntryRecord) + == 2 * sizeof(std::uint16_t) + 3 * sizeof(std::uint32_t) + sizeof(float) + + 3 * sizeof(RewardRangeRecord)); +static_assert(sizeof(RewardItemRecord) + == 2 * sizeof(std::uint32_t) + 2 * sizeof(std::uint16_t) + sizeof(std::uint8_t) + + rewards::kSelectionCapacity * sizeof(RewardSelectionRecord)); +static_assert(sizeof(RewardModifierRecord) + == sizeof(RewardRangeRecord) + sizeof(std::uint16_t) + sizeof(float)); +static_assert(sizeof(RewardSocketOverrideRecord) + == 4 * sizeof(std::uint16_t) + sizeof(std::uint32_t)); +static_assert(sizeof(RewardInstructionRecord) == 2 * sizeof(std::uint32_t)); + static_assert(sizeof(Prefix) == kCacheMagic.size() + sizeof(std::uint32_t)); static_assert(sizeof(InvestmentConstants) == constants::kCharacterStatRowCount + 3 * sizeof(std::uint8_t)); static_assert(sizeof(Header) - == kCacheMagic.size() + 39 * sizeof(std::uint32_t) + 2 * sizeof(std::uint64_t) + == kCacheMagic.size() + 44 * sizeof(std::uint32_t) + 2 * sizeof(std::uint64_t) + sizeof(InvestmentConstants) + sizeof(gameplay::entity_position_profiles::Fingerprint)); static_assert(sizeof(SpawnPointRecord) @@ -630,10 +709,9 @@ static_assert(sizeof(RosterGroupRecord) static_assert(sizeof(ProgressionRecord) == 2 * sizeof(std::uint16_t) + 2 * sizeof(std::uint8_t)); static_assert(sizeof(ProgressionStepRecord) == sizeof(std::int32_t)); static_assert(sizeof(SeasonPassRewardRecord) - == 2 * sizeof(std::uint32_t) + 2 * sizeof(std::uint16_t) + 4 * sizeof(std::uint8_t)); -static_assert(sizeof(SeasonPassPackageRecord) - == (1 + season_pass::kPackageItemCapacity) * sizeof(std::uint32_t) - + 4 * sizeof(std::uint8_t)); + == 2 * sizeof(std::uint32_t) + 2 * sizeof(std::uint16_t) + 6 * sizeof(std::uint8_t) + + rewards::kSocketsPerItem * sizeof(RewardSocketOverrideRecord) + + season_pass::kConditionCapacity * sizeof(RewardInstructionRecord)); static_assert(sizeof(BountyRecord) == 2 * sizeof(std::uint32_t) + 2 * sizeof(std::uint16_t)); static_assert(sizeof(RecordDefinitionRecord) == sizeof(std::uint32_t) + 10 * sizeof(std::uint16_t) + 5 * sizeof(std::uint8_t)); diff --git a/Sunrise/src/state/build_data/cache/write/cache_file_writer.cpp b/Sunrise/src/state/build_data/cache/write/cache_file_writer.cpp index 0632a9edf..0e9882356 100644 --- a/Sunrise/src/state/build_data/cache/write/cache_file_writer.cpp +++ b/Sunrise/src/state/build_data/cache/write/cache_file_writer.cpp @@ -48,8 +48,13 @@ namespace { && domains.recordRewards.size() <= kMaximumCount && domains.progressionSteps.size() <= kMaximumCount && domains.seasonPassRewards.size() <= kMaximumCount - && domains.seasonPassPackages.size() <= kMaximumCount - && domains.bounties.size() <= kMaximumCount; + && domains.bounties.size() <= kMaximumCount + && domains.rewardPools.size() <= kMaximumCount + && domains.rewardEntries.size() <= kMaximumCount + && domains.rewardItems.size() <= kMaximumCount + && domains.rewardInstructions.size() <= kMaximumCount + && domains.rewardModifiers.size() <= kMaximumCount + && domains.rewardSockets.size() <= kMaximumCount; } /** @return True when the requested final-name rule is one of the declared values. */ diff --git a/Sunrise/src/state/build_data/cache/write/cache_payload_writer.cpp b/Sunrise/src/state/build_data/cache/write/cache_payload_writer.cpp index 0c4b610fd..911a0154c 100644 --- a/Sunrise/src/state/build_data/cache/write/cache_payload_writer.cpp +++ b/Sunrise/src/state/build_data/cache/write/cache_payload_writer.cpp @@ -101,9 +101,14 @@ bool payload_checksum(records::Domains domains, std::uint64_t& checksum) noexcep && checksum_domain(domains.recordRewards, checksum) && checksum_domain(domains.progressionSteps, checksum) && checksum_domain(domains.seasonPassRewards, checksum) - && checksum_domain(domains.seasonPassPackages, + && checksum_domain(domains.bounties, checksum) + && checksum_domain(domains.rewardPools, checksum) + && checksum_domain(domains.rewardEntries, checksum) + && checksum_domain(domains.rewardItems, checksum) + && checksum_domain(domains.rewardInstructions, checksum) - && checksum_domain(domains.bounties, checksum); + && checksum_domain(domains.rewardModifiers, checksum) + && checksum_domain(domains.rewardSockets, checksum); } /** Writes every array in the same order used by the payload checksum. */ @@ -143,8 +148,13 @@ bool write_payload(HANDLE file, records::Domains domains) noexcept { && write_domain(file, domains.recordRewards) && write_domain(file, domains.progressionSteps) && write_domain(file, domains.seasonPassRewards) - && write_domain(file, domains.seasonPassPackages) - && write_domain(file, domains.bounties); + && write_domain(file, domains.bounties) + && write_domain(file, domains.rewardPools) + && write_domain(file, domains.rewardEntries) + && write_domain(file, domains.rewardItems) + && write_domain(file, domains.rewardInstructions) + && write_domain(file, domains.rewardModifiers) + && write_domain(file, domains.rewardSockets); } } // namespace sunrise::state::build_data::cache::writer diff --git a/Sunrise/src/state/build_data/cache/write/temporary/temporary_cache_file.cpp b/Sunrise/src/state/build_data/cache/write/temporary/temporary_cache_file.cpp index 5f4301683..5ee69b3b2 100644 --- a/Sunrise/src/state/build_data/cache/write/temporary/temporary_cache_file.cpp +++ b/Sunrise/src/state/build_data/cache/write/temporary/temporary_cache_file.cpp @@ -140,8 +140,13 @@ enum class WriteStatus { static_cast(domains.recordRewards.size()), static_cast(domains.progressionSteps.size()), static_cast(domains.seasonPassRewards.size()), - static_cast(domains.seasonPassPackages.size()), static_cast(domains.bounties.size()), + static_cast(domains.rewardPools.size()), + static_cast(domains.rewardEntries.size()), + static_cast(domains.rewardItems.size()), + static_cast(domains.rewardInstructions.size()), + static_cast(domains.rewardModifiers.size()), + static_cast(domains.rewardSockets.size()), domains.positionFingerprint, domains.constants, checksum, diff --git a/Sunrise/src/state/build_data/inventory/buckets/definition.h b/Sunrise/src/state/build_data/inventory/buckets/definition.h index 92feb8979..22637090b 100644 --- a/Sunrise/src/state/build_data/inventory/buckets/definition.h +++ b/Sunrise/src/state/build_data/inventory/buckets/definition.h @@ -25,6 +25,8 @@ inline constexpr std::uint32_t kProfileSlotCapacity = 701; inline constexpr std::uint32_t kSmallProfileSlotCapacity = 6; /** An all-one bucket id means no runtime bucket is available. */ inline constexpr std::uint8_t kUnavailableBucketId = (std::numeric_limits::max)(); +/** Character bucket containing unopened engrams. */ +inline constexpr std::uint8_t kEngramBucketId = 31; /** Leaving out the unavailable id leaves at most 255 unique bucket records. */ inline constexpr std::size_t kDescriptorCapacity = kUnavailableBucketId; /** Signed -1 marks a bucket that has no equipment slot. */ diff --git a/Sunrise/src/state/build_data/rewards/definition.h b/Sunrise/src/state/build_data/rewards/definition.h new file mode 100644 index 000000000..f00eabc4a --- /dev/null +++ b/Sunrise/src/state/build_data/rewards/definition.h @@ -0,0 +1,114 @@ +#pragma once + +#include +#include +#include +#include + +namespace sunrise::state::build_data::rewards { + +/** Native references use all bits set for an absent row. */ +inline constexpr std::uint16_t kAbsent = 0xFFFF; +/** Bounds cover signed native indices and the flat banks they reference. */ +inline constexpr std::size_t kPoolCapacity = 4096; +inline constexpr std::size_t kEntryCapacity = 32768; +inline constexpr std::size_t kItemCapacity = 32768; +inline constexpr std::size_t kInstructionCapacity = 131072; +inline constexpr std::size_t kModifierCapacity = 32768; +inline constexpr std::size_t kSocketOverrideCapacity = 32768; +/** Native reward overrides address the 12 ordinary sockets on an item. */ +inline constexpr std::size_t kSocketsPerItem = 12; +/** A wrapper can select independently from four reward categories. */ +inline constexpr std::size_t kSelectionCapacity = 4; +/** A resolved grant fits one bounded inventory transaction. */ +inline constexpr std::size_t kGrantCapacity = 32; +/** Nested pools and referenced conditions share a bounded traversal depth. */ +inline constexpr std::size_t kTraversalDepth = 32; + +struct Range { + std::uint32_t first{}; + std::uint32_t count{}; +}; + +struct Instruction { + std::uint32_t opcode{}; + std::uint32_t operand{}; +}; + +/** Resolved bank reads occupy a separate range from native expression operators. */ +enum class BankRead : std::uint32_t { + accountFlag = 256, + characterFlag, + accountValue, + characterValue, + externalFlag, + externalValue, + characterClass, + profileFlag, +}; + +/** A fixed override names its plug directly rather than selecting from a set. */ +inline constexpr std::uint32_t kFixedPlugSelection = 0xFFFFFFFFU; +/** Wrappers without this bit remain inventory items until explicitly opened. */ +inline constexpr std::uint32_t kOpenOnAcquisition = 1; + +struct SocketOverride { + std::uint16_t socketType{kAbsent}; + std::uint16_t plugItem{kAbsent}; + std::uint16_t plugSet{kAbsent}; + std::uint16_t rollSet{kAbsent}; + std::uint32_t selection{kFixedPlugSelection}; +}; + +struct Modifier { + Range condition{}; + std::uint16_t valueIndex{kAbsent}; + float value{}; +}; + +struct Entry { + std::uint16_t itemIndex{kAbsent}; + std::uint16_t poolIndex{kAbsent}; + std::uint32_t quantity{}; + std::uint32_t categoryHash{}; + std::uint32_t bucketHash{}; + float weight{}; + Range condition{}; + Range modifiers{}; + Range sockets{}; +}; + +struct Pool { + std::uint32_t definitionHash{}; + Range entries{}; +}; + +struct Selection { + std::uint32_t categoryHash{}; + std::uint32_t count{}; +}; + +/** Dense item rows bind inventory definitions to their reward pools and acquisition flags. */ +struct Item { + std::uint32_t definitionHash{}; + std::uint16_t poolIndex{kAbsent}; + std::uint16_t acquiredFlag{kAbsent}; + std::array selections{}; + std::uint8_t selectionCount{}; + std::uint32_t flags{}; +}; + +struct View { + std::span pools; + std::span entries; + std::span items; + std::span instructions; + std::span modifiers; + std::span sockets; +}; + +template [[nodiscard]] constexpr bool fits(Range range, std::span bank) noexcept { + return range.first <= bank.size() && range.count <= bank.size() - range.first; +} + +} // namespace sunrise::state::build_data::rewards diff --git a/Sunrise/src/state/build_data/rewards/reward_catalog.cpp b/Sunrise/src/state/build_data/rewards/reward_catalog.cpp new file mode 100644 index 000000000..00c1126aa --- /dev/null +++ b/Sunrise/src/state/build_data/rewards/reward_catalog.cpp @@ -0,0 +1,231 @@ +#include "reward_catalog.h" + +#include +#include +#include +#include +#include + +#include "../../account/account_state.h" +#include "../../unlocks/definition.h" +#include "../table.h" +#include "core/threading/srw_lock.h" +#include "middleware/content/packages/tables/definition_index_table.h" + +namespace sunrise::state::build_data::rewards { +namespace { + +core::threading::SrwLock g_lock; +Table g_pools; +Table g_entries; +Table g_items; +Table g_instructions; +Table g_modifiers; +Table g_sockets; + +View view() noexcept { + return {g_pools.rows(), + g_entries.rows(), + g_items.rows(), + g_instructions.rows(), + g_modifiers.rows(), + g_sockets.rows()}; +} + +bool valid_depth(View data, + std::size_t index, + std::array& heights, + std::size_t depth = 0) noexcept { + constexpr std::uint8_t kVisiting = 0xFF; + if (depth >= kTraversalDepth) { + return false; + } + if (heights[index] != 0) { + // Reused subtrees must also fit when reached through a deeper path. + return heights[index] != kVisiting && heights[index] <= kTraversalDepth - depth; + } + heights[index] = kVisiting; + std::uint8_t height = 1; + const Range range = data.pools[index].entries; + for (const Entry& entry : data.entries.subspan(range.first, range.count)) { + if (entry.poolIndex == kAbsent) { + continue; + } + if (!valid_depth(data, entry.poolIndex, heights, depth + 1)) { + return false; + } + height = (std::max)(height, static_cast(heights[entry.poolIndex] + 1)); + } + heights[index] = height; + return true; +} + +} // namespace + +void clear() noexcept { + const std::lock_guard guard(g_lock); + g_pools.clear(); + g_entries.clear(); + g_items.clear(); + g_instructions.clear(); + g_modifiers.clear(); + g_sockets.clear(); +} + +bool ready() noexcept { + const std::shared_lock guard(g_lock); + return g_pools.count() != 0 && g_items.count() != 0; +} + +bool valid_instruction(const Instruction& instruction) noexcept { + const auto slot = instruction.operand; + switch (static_cast(instruction.opcode)) { + case BankRead::accountFlag: + return slot < unlocks::kAccountFlagCapacity; + case BankRead::profileFlag: + return slot < unlocks::kProfileFlagCapacity; + case BankRead::characterFlag: + return slot < unlocks::kCharacterObjectFlagCapacity; + case BankRead::accountValue: + return slot < unlocks::kObjectiveValueCapacity; + case BankRead::characterValue: + return slot < unlocks::kCharacterObjectValueCapacity; + case BankRead::characterClass: + return slot < kCharacterClassCount; + case BankRead::externalFlag: + case BankRead::externalValue: + return true; + default: + break; + } + using Op = middleware::content::packages::tables::UnlockOpcode; + switch (static_cast(instruction.opcode)) { + case Op::constant: + case Op::logicalNot: + case Op::logicalOr: + case Op::logicalAnd: + case Op::equal: + case Op::greaterThan: + case Op::greaterOrEqual: + case Op::lessThan: + case Op::lessOrEqual: + case Op::add: + case Op::negate: + return true; + default: + return false; + } +} + +bool valid(View data) noexcept { + if (data.pools.empty() || data.pools.size() > kPoolCapacity || data.entries.empty() + || data.entries.size() > kEntryCapacity || data.items.empty() + || data.items.size() > kItemCapacity || data.instructions.size() > kInstructionCapacity + || data.modifiers.size() > kModifierCapacity + || data.sockets.size() > kSocketOverrideCapacity) { + return false; + } + for (const Pool& pool : data.pools) { + if ((pool.definitionHash == 0 && pool.entries.count != 0) + || !fits(pool.entries, data.entries)) { + return false; + } + } + for (const Entry& entry : data.entries) { + if ((entry.itemIndex != kAbsent && entry.itemIndex >= data.items.size()) + || (entry.poolIndex != kAbsent && entry.poolIndex >= data.pools.size()) + || !fits(entry.condition, data.instructions) || !fits(entry.modifiers, data.modifiers) + || !fits(entry.sockets, data.sockets) || !std::isfinite(entry.weight) + || entry.weight < 0) { + return false; + } + } + for (const Item& item : data.items) { + if ((item.definitionHash == 0 + && (item.poolIndex != kAbsent || item.acquiredFlag != kAbsent + || item.selectionCount != 0)) + || item.selectionCount > item.selections.size() + || (item.acquiredFlag != kAbsent && item.acquiredFlag >= unlocks::kAccountFlagCapacity) + || (item.poolIndex != kAbsent && item.poolIndex >= data.pools.size())) { + return false; + } + } + if (!std::all_of(data.instructions.begin(), data.instructions.end(), valid_instruction)) { + return false; + } + for (const Modifier& modifier : data.modifiers) { + if (!fits(modifier.condition, data.instructions) || !std::isfinite(modifier.value)) { + return false; + } + } + for (const SocketOverride& socket : data.sockets) { + if (socket.socketType == kAbsent + || (socket.plugItem != kAbsent && socket.plugItem >= data.items.size())) { + return false; + } + } + std::array heights{}; + for (std::size_t index = 0; index < data.pools.size(); ++index) { + if (!valid_depth(data, index, heights)) { + return false; + } + } + return true; +} + +bool replace(View data) noexcept { + if (!valid(data)) { + return false; + } + const std::lock_guard guard(g_lock); + return g_pools.replace(data.pools) && g_entries.replace(data.entries) + && g_items.replace(data.items) && g_instructions.replace(data.instructions) + && g_modifiers.replace(data.modifiers) && g_sockets.replace(data.sockets); +} + +bool read(void* context, bool (*consume)(void*, View) noexcept) noexcept { + const std::shared_lock guard(g_lock); + return consume != nullptr && g_pools.count() != 0 && consume(context, view()); +} + +bool find_item(std::uint16_t itemIndex, Item& item) noexcept { + item = {}; + const std::shared_lock guard(g_lock); + if (itemIndex >= g_items.count() || g_items.rows()[itemIndex].definitionHash == 0) { + return false; + } + item = g_items.rows()[itemIndex]; + return true; +} + +bool snapshot(std::span output, std::size_t& count) noexcept { + const std::shared_lock guard(g_lock); + return g_pools.snapshot(output, count); +} + +bool snapshot(std::span output, std::size_t& count) noexcept { + const std::shared_lock guard(g_lock); + return g_entries.snapshot(output, count); +} + +bool snapshot(std::span output, std::size_t& count) noexcept { + const std::shared_lock guard(g_lock); + return g_items.snapshot(output, count); +} + +bool snapshot(std::span output, std::size_t& count) noexcept { + const std::shared_lock guard(g_lock); + return g_instructions.snapshot(output, count); +} + +bool snapshot(std::span output, std::size_t& count) noexcept { + const std::shared_lock guard(g_lock); + return g_modifiers.snapshot(output, count); +} + +bool snapshot(std::span output, std::size_t& count) noexcept { + const std::shared_lock guard(g_lock); + return g_sockets.snapshot(output, count); +} + +} // namespace sunrise::state::build_data::rewards diff --git a/Sunrise/src/state/build_data/rewards/reward_catalog.h b/Sunrise/src/state/build_data/rewards/reward_catalog.h new file mode 100644 index 000000000..b7f4733b9 --- /dev/null +++ b/Sunrise/src/state/build_data/rewards/reward_catalog.h @@ -0,0 +1,30 @@ +#pragma once + +#include "definition.h" + +namespace sunrise::state::build_data::rewards { + +/** Discards the reward graph and every borrowed bank. */ +void clear() noexcept; +/** True once pool and item banks have been published. */ +[[nodiscard]] bool ready() noexcept; +/** Checks a bound instruction against its saved bank and the native opcode range. */ +[[nodiscard]] bool valid_instruction(const Instruction& instruction) noexcept; +/** Checks bank ranges, item references and bounded acyclic pool traversal. */ +[[nodiscard]] bool valid(View data) noexcept; +/** Publishes validated banks together under the catalog lock. */ +[[nodiscard]] bool replace(View data) noexcept; +/** The borrowed view remains valid only during the callback. */ +[[nodiscard]] bool read(void* context, bool (*consume)(void*, View) noexcept) noexcept; +/** Copies a native item row, refusing unavailable extraction entries. */ +[[nodiscard]] bool find_item(std::uint16_t itemIndex, Item& item) noexcept; + +/** Copies one complete bank; count is zero if the output is too small. */ +[[nodiscard]] bool snapshot(std::span output, std::size_t& count) noexcept; +[[nodiscard]] bool snapshot(std::span output, std::size_t& count) noexcept; +[[nodiscard]] bool snapshot(std::span output, std::size_t& count) noexcept; +[[nodiscard]] bool snapshot(std::span output, std::size_t& count) noexcept; +[[nodiscard]] bool snapshot(std::span output, std::size_t& count) noexcept; +[[nodiscard]] bool snapshot(std::span output, std::size_t& count) noexcept; + +} // namespace sunrise::state::build_data::rewards diff --git a/Sunrise/src/state/build_data/runtime.h b/Sunrise/src/state/build_data/runtime.h index ab72cb43a..75afd78d7 100644 --- a/Sunrise/src/state/build_data/runtime.h +++ b/Sunrise/src/state/build_data/runtime.h @@ -23,6 +23,7 @@ #include "nodes/definition.h" #include "progressions/definition.h" #include "records/definition.h" +#include "rewards/definition.h" #include "scenarios/definition.h" #include "season_pass/definition.h" #include "sobjects/sobject_catalog.h" @@ -390,13 +391,11 @@ publish_progression_definitions(std::span defini [[nodiscard]] bool season_pass_ready() noexcept; /** - * Publishes the season pass reward list and the wrapper items it grants, in one step. + * Publishes the season pass reward list with its fixed socket overrides. * @param rewards Complete reward rows in native reward order. - * @param packages Complete wrapper packages. * @return True when the rows pass the checks and any needed cache write succeeds. */ -[[nodiscard]] bool publish_season_pass(std::span rewards, - std::span packages) noexcept; +[[nodiscard]] bool publish_season_pass(std::span rewards) noexcept; /** * Reads one season pass reward row. @@ -407,18 +406,6 @@ publish_progression_definitions(std::span defini [[nodiscard]] bool find_season_pass_reward(std::uint16_t rewardIndex, season_pass::Reward& reward) noexcept; -/** - * Finds the item set one season pass wrapper opens into. - * @param definitionHash Authored wrapper item hash. - * @param package Receives the wrapper and its items. - * @return True when the catalog is ready and a wrapper carries that hash. - */ -[[nodiscard]] bool find_season_pass_package(std::uint32_t definitionHash, - season_pass::Package& package) noexcept; - -/** @return Season pass reward rows in State. */ -[[nodiscard]] std::size_t season_pass_reward_count() noexcept; - /** @return True when the repeatable bounty table is in State. */ [[nodiscard]] bool repeatable_bounties_ready() noexcept; @@ -756,4 +743,6 @@ publish_vendor_catalog(std::span index, */ [[nodiscard]] bool persist() noexcept; +/** Publishes a complete, validated reward graph. */ +[[nodiscard]] bool publish_reward_definitions(rewards::View definitions) noexcept; } // namespace sunrise::state::build_data diff --git a/Sunrise/src/state/build_data/runtime/build_data_catalog_runtime.cpp b/Sunrise/src/state/build_data/runtime/build_data_catalog_runtime.cpp index 95946bf31..13be95a9d 100644 --- a/Sunrise/src/state/build_data/runtime/build_data_catalog_runtime.cpp +++ b/Sunrise/src/state/build_data/runtime/build_data_catalog_runtime.cpp @@ -14,6 +14,7 @@ #include "../nodes/node_catalog.h" #include "../progressions/progression_catalog.h" #include "../records/record_catalog.h" +#include "../rewards/reward_catalog.h" #include "../runtime.h" #include "../scenarios/scenario_catalog.h" #include "../season_pass/season_pass_catalog.h" @@ -372,6 +373,7 @@ void clear_catalogs() noexcept { rollback_ability_publication(); progressions::clear(); season_pass::clear(); + rewards::clear(); bounties::clear(); records::clear(); nodes::clear(); @@ -385,4 +387,10 @@ void clear_catalogs() noexcept { } // namespace runtime +bool publish_reward_definitions(rewards::View definitions) noexcept { + runtime::persistence::Transaction transaction; + return transaction.active() + && transaction.finish(rewards::replace(definitions), rewards::clear); +} + } // namespace sunrise::state::build_data diff --git a/Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.cpp b/Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.cpp index a02ffb60d..118f13795 100644 --- a/Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.cpp +++ b/Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.cpp @@ -21,6 +21,7 @@ #include "../../nodes/node_catalog.h" #include "../../progressions/progression_catalog.h" #include "../../records/record_catalog.h" +#include "../../rewards/reward_catalog.h" #include "../../runtime.h" #include "../../scenarios/scenario_catalog.h" #include "../../season_pass/season_pass_catalog.h" @@ -90,8 +91,13 @@ to_record(const constants::InvestmentConstants& value) noexcept { && progressions::snapshot(scratch.progressions, counts.progressions) && progressions::snapshot_steps(scratch.progressionSteps, counts.progressionSteps) && season_pass::snapshot(scratch.seasonPassRewards, counts.seasonPassRewards) - && season_pass::snapshot_packages(scratch.seasonPassPackages, counts.seasonPassPackages) && bounties::snapshot(scratch.bounties, counts.bounties) + && rewards::snapshot(scratch.rewardPools, counts.rewardPools) + && rewards::snapshot(scratch.rewardEntries, counts.rewardEntries) + && rewards::snapshot(scratch.rewardItems, counts.rewardItems) + && rewards::snapshot(scratch.rewardInstructions, counts.rewardInstructions) + && rewards::snapshot(scratch.rewardModifiers, counts.rewardModifiers) + && rewards::snapshot(scratch.rewardSockets, counts.rewardSockets) && records::snapshot(scratch.records, counts.records) && records::snapshot_objectives(scratch.recordObjectives, counts.recordObjectives) && records::snapshot_intervals(scratch.recordIntervals, counts.recordIntervals) @@ -125,9 +131,10 @@ to_record(const constants::InvestmentConstants& value) noexcept { && material_requirement_sets_ready() && inventory_bucket_descriptors_ready() && socket_entry_lists_ready() && ability_buckets_ready() && progression_definitions_ready() && season_pass_ready() && repeatable_bounties_ready() - && record_definitions_ready() && node_definitions_ready() && sobject_definitions_ready() - && scenario_layouts_ready() && spawn_sets_ready() && hash_names_ready() - && vendor_catalog_ready() && gameplay::entity_position_profiles::available() + && rewards::ready() && record_definitions_ready() && node_definitions_ready() + && sobject_definitions_ready() && scenario_layouts_ready() && spawn_sets_ready() + && hash_names_ready() && vendor_catalog_ready() + && gameplay::entity_position_profiles::available() && gameplay::entity_object_types::available() && constants::find(published); } @@ -222,9 +229,6 @@ cache::records::MutableDomains scratch_domains(Context& state) noexcept { const auto seasonPassRewards = ensure_scratch( state.seasonPassRewardScratch); - const auto seasonPassPackages = - ensure_scratch( - state.seasonPassPackageScratch); const auto bountyRows = ensure_scratch(state.bountyScratch); const auto positionProfiles = ensure_scratch(state.rewardPoolsScratch), + ensure_scratch(state.rewardEntriesScratch), + ensure_scratch(state.rewardItemsScratch), + ensure_scratch( + state.rewardInstructionsScratch), + ensure_scratch(state.rewardModifiersScratch), + ensure_scratch( + state.rewardSocketsScratch), }; } @@ -299,8 +310,14 @@ void release_scratch_locked(Context& state) noexcept { release_bank(state.progressionScratch); release_bank(state.progressionStepScratch); release_bank(state.seasonPassRewardScratch); - release_bank(state.seasonPassPackageScratch); release_bank(state.bountyScratch); + release_bank(state.rewardPoolsScratch); + release_bank(state.rewardEntriesScratch); + release_bank(state.rewardItemsScratch); + release_bank(state.rewardInstructionsScratch); + release_bank(state.rewardModifiersScratch); + release_bank(state.rewardSocketsScratch); + release_bank(state.recordScratch); release_bank(state.recordObjectiveScratch); release_bank(state.recordIntervalScratch); @@ -402,9 +419,16 @@ cache::records::Domains occupied_domains(Context& state, counts.progressionSteps}, std::span{state.seasonPassRewardScratch.data(), counts.seasonPassRewards}, - std::span{state.seasonPassPackageScratch.data(), - counts.seasonPassPackages}, std::span{state.bountyScratch.data(), counts.bounties}, + std::span{state.rewardPoolsScratch.data(), counts.rewardPools}, + std::span{state.rewardEntriesScratch.data(), counts.rewardEntries}, + std::span{state.rewardItemsScratch.data(), counts.rewardItems}, + std::span{state.rewardInstructionsScratch.data(), + counts.rewardInstructions}, + std::span{state.rewardModifiersScratch.data(), + counts.rewardModifiers}, + std::span{state.rewardSocketsScratch.data(), + counts.rewardSockets}, }; } diff --git a/Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.h b/Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.h index da466ba7d..0aca94956 100644 --- a/Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.h +++ b/Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.h @@ -79,8 +79,14 @@ struct Context { std::vector progressionScratch{}; std::vector progressionStepScratch{}; std::vector seasonPassRewardScratch{}; - std::vector seasonPassPackageScratch{}; std::vector bountyScratch{}; + std::vector rewardPoolsScratch{}; + std::vector rewardEntriesScratch{}; + std::vector rewardItemsScratch{}; + std::vector rewardInstructionsScratch{}; + std::vector rewardModifiersScratch{}; + std::vector rewardSocketsScratch{}; + std::vector recordScratch{}; std::vector recordObjectiveScratch{}; std::vector recordIntervalScratch{}; diff --git a/Sunrise/src/state/build_data/season_pass/definition.h b/Sunrise/src/state/build_data/season_pass/definition.h index fb15fb7b8..bd4e80e91 100644 --- a/Sunrise/src/state/build_data/season_pass/definition.h +++ b/Sunrise/src/state/build_data/season_pass/definition.h @@ -4,23 +4,22 @@ #include #include +#include "../rewards/definition.h" + namespace sunrise::state::build_data::season_pass { /** Reward rows the installed pass declares. The shipped build carries 196. */ inline constexpr std::size_t kRewardCapacity = 256; -/** Wrapper rewards that open into a set. The shipped pass carries one per character class. */ -inline constexpr std::size_t kPackageCapacity = 8; - -/** Items one wrapper opens into. The shipped wrappers each carry six. */ -inline constexpr std::size_t kPackageItemCapacity = 8; - /** A reward whose claim flag no mapping table addresses carries this instead of an index. */ inline constexpr std::uint16_t kUnavailableFlagIndex = 0xFFFFU; +/** Bound on the expanded eligibility expression of one pass row. */ +inline constexpr std::size_t kConditionCapacity = 64; + /** One reward row of the pass, in the native order the opcode-2400 claim names by index. */ struct Reward { - /** Authored definition hash of the granted item. */ + /** Installed definition hash of the granted item. */ std::uint32_t itemHash{}; /** Units granted. */ std::uint32_t quantity{}; @@ -30,15 +29,10 @@ struct Reward { std::uint16_t claimFlagIndex{kUnavailableFlagIndex}; /** Rank the account needs before the row may be claimed. */ std::uint8_t requiredRank{}; -}; - -/** One wrapper reward and the items it opens into. */ -struct Package { - /** Authored definition hash of the wrapper item. */ - std::uint32_t definitionHash{}; - /** Authored definition hashes the wrapper opens into, in declared order. */ - std::array items{}; - std::uint8_t itemCount{}; + std::array sockets{}; + std::uint8_t socketCount{}; + std::array condition{}; + std::uint8_t conditionCount{}; }; } // namespace sunrise::state::build_data::season_pass diff --git a/Sunrise/src/state/build_data/season_pass/season_pass_build_data_runtime.cpp b/Sunrise/src/state/build_data/season_pass/season_pass_build_data_runtime.cpp index 42fa6dd66..e296f6f82 100644 --- a/Sunrise/src/state/build_data/season_pass/season_pass_build_data_runtime.cpp +++ b/Sunrise/src/state/build_data/season_pass/season_pass_build_data_runtime.cpp @@ -9,12 +9,11 @@ bool season_pass_ready() noexcept { return season_pass::count() != 0; } -/** Publishes the season pass reward list and the wrapper items it grants, in one step. */ -bool publish_season_pass(std::span rewards, - std::span packages) noexcept { +/** Publishes the season pass reward list with its fixed socket overrides. */ +bool publish_season_pass(std::span rewards) noexcept { runtime::persistence::Transaction transaction; return transaction.active() - && transaction.finish(season_pass::replace(rewards, packages), season_pass::clear); + && transaction.finish(season_pass::replace(rewards), season_pass::clear); } /** Reads one season pass reward row. */ @@ -22,15 +21,4 @@ bool find_season_pass_reward(std::uint16_t rewardIndex, season_pass::Reward& rew return season_pass::find(rewardIndex, reward); } -/** Finds the item set one season pass wrapper opens into. */ -bool find_season_pass_package(std::uint32_t definitionHash, - season_pass::Package& package) noexcept { - return season_pass::find_package(definitionHash, package); -} - -/** @return Season pass reward rows in State. */ -std::size_t season_pass_reward_count() noexcept { - return season_pass::count(); -} - } // namespace sunrise::state::build_data diff --git a/Sunrise/src/state/build_data/season_pass/season_pass_catalog.cpp b/Sunrise/src/state/build_data/season_pass/season_pass_catalog.cpp index b02d6038a..b0b6af5d0 100644 --- a/Sunrise/src/state/build_data/season_pass/season_pass_catalog.cpp +++ b/Sunrise/src/state/build_data/season_pass/season_pass_catalog.cpp @@ -1,105 +1,70 @@ #include "season_pass_catalog.h" #include +#include #include +#include "../../unlocks/definition.h" +#include "../rewards/reward_catalog.h" #include "../table.h" #include "core/threading/srw_lock.h" namespace sunrise::state::build_data::season_pass { namespace { - -// One lock covers both tables. A reward names a wrapper by hash, so a reader must never see one -// table replaced and the other not. core::threading::SrwLock g_lock; Table g_rewards; -Table g_packages; - } // namespace -/** Clears the reward rows and the wrapper packages under the catalog lock. */ void clear() noexcept { const std::lock_guard guard(g_lock); g_rewards.clear(); - g_packages.clear(); } -/** Checks one complete pass catalog. */ -bool valid(std::span rewards, std::span packages) noexcept { - if (rewards.empty() || rewards.size() > kRewardCapacity || packages.size() > kPackageCapacity) { - return false; - } - // A row granting nothing would claim its flag and hand the account no item. - const bool granting = std::all_of(rewards.begin(), rewards.end(), [](const Reward& reward) { - return reward.itemHash != 0 && reward.quantity != 0; - }); - return granting && std::all_of(packages.begin(), packages.end(), [](const Package& package) { - return package.definitionHash != 0 && package.itemCount != 0 - && package.itemCount <= kPackageItemCapacity; - }); +bool valid(std::span rewards) noexcept { + return !rewards.empty() && rewards.size() <= kRewardCapacity + && std::all_of(rewards.begin(), rewards.end(), [](const Reward& reward) { + if (reward.itemHash == 0) { + return reward.quantity == 0 && reward.conditionCount == 0 + && reward.socketCount == 0 + && reward.claimFlagIndex == kUnavailableFlagIndex; + } + return reward.quantity != 0 && reward.quantity <= INT32_MAX + && (reward.claimFlagIndex == kUnavailableFlagIndex + || reward.claimFlagIndex < unlocks::kAccountFlagCapacity) + && reward.socketCount <= reward.sockets.size() + && reward.conditionCount <= reward.condition.size() + && std::all_of(reward.condition.begin(), + reward.condition.begin() + reward.conditionCount, + rewards::valid_instruction); + }); } -/** Replaces the complete pass catalog in one step. */ -bool replace(std::span rewards, std::span packages) noexcept { - if (!valid(rewards, packages)) { +bool replace(std::span rewards) noexcept { + if (!valid(rewards)) { return false; } const std::lock_guard guard(g_lock); - const bool replaced = g_rewards.replace(rewards) && g_packages.replace(packages); - if (!replaced) { - g_rewards.clear(); - g_packages.clear(); - } - return replaced; + return g_rewards.replace(rewards); } -/** Reads one reward row by the index an opcode-2400 claim names. */ bool find(std::uint16_t rewardIndex, Reward& reward) noexcept { reward = {}; const std::shared_lock guard(g_lock); - const std::span rows = g_rewards.rows(); - if (rewardIndex >= rows.size()) { + if (rewardIndex >= g_rewards.count() || g_rewards.rows()[rewardIndex].itemHash == 0) { return false; } - reward = rows[rewardIndex]; + reward = g_rewards.rows()[rewardIndex]; return true; } -/** Finds the wrapper package one item hash opens into. */ -bool find_package(std::uint32_t definitionHash, Package& package) noexcept { - package = {}; - const std::shared_lock guard(g_lock); - for (const Package& row : g_packages.rows()) { - if (row.definitionHash == definitionHash) { - package = row; - return true; - } - } - return false; -} - -/** Copies every reward row in native reward order. */ bool snapshot(std::span output, std::size_t& count) noexcept { const std::shared_lock guard(g_lock); return g_rewards.snapshot(output, count); } -/** Copies every wrapper package in declared order. */ -bool snapshot_packages(std::span output, std::size_t& count) noexcept { - const std::shared_lock guard(g_lock); - return g_packages.snapshot(output, count); -} - -/** @return The reward row count, read under the lock. */ std::size_t count() noexcept { const std::shared_lock guard(g_lock); return g_rewards.count(); } -/** @return The wrapper package count, read under the lock. */ -std::size_t package_count() noexcept { - const std::shared_lock guard(g_lock); - return g_packages.count(); -} - } // namespace sunrise::state::build_data::season_pass diff --git a/Sunrise/src/state/build_data/season_pass/season_pass_catalog.h b/Sunrise/src/state/build_data/season_pass/season_pass_catalog.h index 21dd3a99b..bda9fc209 100644 --- a/Sunrise/src/state/build_data/season_pass/season_pass_catalog.h +++ b/Sunrise/src/state/build_data/season_pass/season_pass_catalog.h @@ -1,70 +1,22 @@ #pragma once -#include -#include #include #include "definition.h" namespace sunrise::state::build_data::season_pass { -/** Clears the reward rows and the wrapper packages. */ +/** Discards the installed pass, including its native claim indices. */ void clear() noexcept; - -/** - * Checks one complete pass catalog. - * @param rewards Candidate reward rows in native reward order. - * @param packages Candidate wrapper packages, which may be empty. - * @return True when every count and item range holds. - */ -[[nodiscard]] bool valid(std::span rewards, - std::span packages) noexcept; - -/** - * Replaces the complete pass catalog in one step. - * @param rewards Complete reward rows in native reward order. - * @param packages Complete wrapper packages. - * @return True when the catalog passes validation and fits fixed State storage. - */ -[[nodiscard]] bool replace(std::span rewards, - std::span packages) noexcept; - -/** - * Reads one reward row by the index an opcode-2400 claim names. - * @param rewardIndex Native reward-array index. - * @param reward Receives the row. - * @return True when the catalog holds that row. - */ +/** Checks row bounds; unavailable rows retain their native positions. */ +[[nodiscard]] bool valid(std::span rewards) noexcept; +/** Publishes a validated pass without reordering its claim indices. */ +[[nodiscard]] bool replace(std::span rewards) noexcept; +/** Copies one native claim row; clears the output when absent or unavailable. */ [[nodiscard]] bool find(std::uint16_t rewardIndex, Reward& reward) noexcept; - -/** - * Finds the wrapper package one item hash opens into. - * @param definitionHash Authored wrapper item hash. - * @param package Receives the matching wrapper. - * @return True when a wrapper carries that hash. - */ -[[nodiscard]] bool find_package(std::uint32_t definitionHash, Package& package) noexcept; - -/** - * Copies every reward row in native reward order. - * @param output Caller-owned fixed row storage. - * @param count Receives the copied row count, or zero when output is too small. - * @return True when output can hold every row. - */ +/** Copies the complete pass; count is zero when the destination is too small. */ [[nodiscard]] bool snapshot(std::span output, std::size_t& count) noexcept; - -/** - * Copies every wrapper package in declared order. - * @param output Caller-owned fixed row storage. - * @param count Receives the copied row count, or zero when output is too small. - * @return True when output can hold every row. - */ -[[nodiscard]] bool snapshot_packages(std::span output, std::size_t& count) noexcept; - -/** @return The reward row count, read under the lock. */ +/** Number of native positions, including unavailable rows. */ [[nodiscard]] std::size_t count() noexcept; -/** @return The wrapper package count, read under the lock. */ -[[nodiscard]] std::size_t package_count() noexcept; - } // namespace sunrise::state::build_data::season_pass diff --git a/Sunrise/src/state/progression/season_pass_reward_catalog.h b/Sunrise/src/state/progression/season_pass_reward_catalog.h index cb32e3d33..a3cc88e75 100644 --- a/Sunrise/src/state/progression/season_pass_reward_catalog.h +++ b/Sunrise/src/state/progression/season_pass_reward_catalog.h @@ -1,9 +1,9 @@ #pragma once -#include -#include #include +#include "../build_data/season_pass/definition.h" + namespace sunrise::state::progression::season_pass { /** Account progression used by the installed Season of Arrivals pass. */ @@ -11,187 +11,7 @@ inline constexpr std::uint16_t kProgressionDefinitionIndex = 40; /** Repeating HUD bar paired with the Arrivals progression. */ inline constexpr std::uint16_t kHudProgressionDefinitionIndex = 41; -/** - * The pass package expands into every installed Season 11 planetary-material stack. - * TODO: extract; the package names a sack reward set, not an item list, and no reader - * resolves a reward set to its members yet. - */ -inline constexpr std::uint32_t kDestinationResourceBundleHash = 3104539653U; -inline constexpr std::uint16_t kDestinationResourceQuantity = 50; -inline constexpr std::array kDestinationResourceHashes{ - 3592324052U, // Helium Filaments - 31293053U, // Seraphite - 49145143U, // Simulation Seed - 2014411539U, // Alkane Dust - 1305274547U, // Phaseglass Needle - 950899352U, // Dusklight Shard - 3487922223U, // Microphasic Datalattice - 592227263U, // Baryon Bough - 1177810185U, // Etheric Spiral -}; - -/** The two auto-decrypting engram item definitions. */ -inline constexpr std::uint32_t kLegendaryEngramHash = 2223145359U; -inline constexpr std::uint32_t kExoticEngramHash = 3875551374U; - -/** - * Exact non-class rewards advertised by the two auto-decrypting engram definitions. - * TODO: extract; an engram names a sack reward set rather than items, and no reader resolves - * a reward set to its members yet. - */ -inline constexpr std::array kLegendaryEngramWeapons{ - 991314988U, 720351795U, 4230993599U, 253196586U, 4146702548U, 2957367743U, - 2009277538U, 3745990145U, 3356526253U, 821154603U, 3504336176U, 2199171672U, - 188882152U, 3863882743U, 4106983932U, 3569802112U, 1529450902U, 1807343361U, - 3622137132U, 3055192515U, 2257180473U, 2742838701U, 2742838700U, 1162247618U, - 1723380073U, 2807687156U, 1786797708U, 2857348871U, 1946491241U, 1835747805U, -}; -/** Legendary engram Titan armour rewards. */ -inline constexpr std::array kLegendaryTitanArmour{ - 3852389988U, - 3250360146U, - 362404956U, - 3299386902U, - 1648238545U, - 2629014079U, - 881579413U, - 2112821379U, - 1063507982U, - 3651598572U, - 1076538456U, - 1560040304U, - 445618861U, - 238618945U, - 3406670226U, - 3238424670U, - 1566911695U, - 1407026808U, -}; -/** Legendary engram Hunter armour rewards. */ -inline constexpr std::array kLegendaryHunterArmour{ - 3239215026U, - 974507844U, - 1399263478U, - 2808379196U, - 1153347999U, - 265279665U, - 2567710435U, - 2298664693U, - 4064910796U, - 2905153902U, - 3750877150U, - 344824594U, - 940065571U, - 382498903U, - 299852984U, - 3554672786U, - 902989307U, - 2944336620U, -}; -/** Legendary engram Warlock armour rewards. */ -inline constexpr std::array kLegendaryWarlockArmour{ - 1920259123U, - 4239920089U, - 695071581U, - 2364041279U, - 2581516944U, - 3611199822U, - 2339155434U, - 597618504U, - 1655109893U, - 2837138379U, - 785967407U, - 3931361417U, - 1416697412U, - 1557571326U, - 936010065U, - 2640935765U, - 4245441464U, - 3523809305U, -}; - -/** Exotic engram non-class rewards. */ -inline constexpr std::array kExoticEngramWeapons{ - 4068264807U, - 2130065553U, - 3325463374U, - 1541131350U, - 814876685U, - 2694576561U, - 3766045777U, - 1852863732U, - 2044500762U, - 3413860063U, -}; -/** Exotic engram Titan armour rewards. */ -inline constexpr std::array kExoticTitanArmour{ - 1190497097U, - 2326396534U, - 2255796155U, - 2240152949U, - 2578771006U, - 2423243921U, - 1734844650U, - 2808156426U, - 106575079U, - 1591207518U, -}; -/** Exotic engram Hunter armour rewards. */ -inline constexpr std::array kExoticHunterArmour{ - 2268523867U, - 1219761634U, - 2757274117U, - 4165919945U, - 1474735276U, - 978537162U, - 1688602431U, - 1163283805U, - 896224899U, - 903984858U, -}; -/** Exotic engram Warlock armour rewards. */ -inline constexpr std::array kExoticWarlockArmour{ - 2822465023U, - 235591051U, - 3948284065U, - 3084282676U, - 4057299719U, - 121305948U, - 3288917178U, - 3627185503U, - 1725917554U, - 1030017949U, -}; - -template -/** @return True when the reward array holds that item hash. */ -[[nodiscard]] constexpr bool contains(const std::array& hashes, - std::uint32_t hash) noexcept { - for (const std::uint32_t candidate : hashes) { - if (candidate == hash) { - return true; - } - } - return false; -} - -/** Checks one decrypted result against its wrapper's weapon and active-class pools. */ -[[nodiscard]] constexpr bool contains_engram_reward(std::uint32_t engramHash, - std::uint32_t rewardHash, - std::uint8_t characterClass) noexcept { - if (engramHash == kLegendaryEngramHash) { - return contains(kLegendaryEngramWeapons, rewardHash) - || (characterClass == 0 && contains(kLegendaryTitanArmour, rewardHash)) - || (characterClass == 1 && contains(kLegendaryHunterArmour, rewardHash)) - || (characterClass == 2 && contains(kLegendaryWarlockArmour, rewardHash)); - } - if (engramHash == kExoticEngramHash) { - return contains(kExoticEngramWeapons, rewardHash) - || (characterClass == 0 && contains(kExoticTitanArmour, rewardHash)) - || (characterClass == 1 && contains(kExoticHunterArmour, rewardHash)) - || (characterClass == 2 && contains(kExoticWarlockArmour, rewardHash)); - } - return false; -} +/** Acquisition flag of a direct perk reward, or rewards::kAbsent for an item or package. */ +[[nodiscard]] std::uint16_t progress_flag(const build_data::season_pass::Reward& reward) noexcept; } // namespace sunrise::state::progression::season_pass diff --git a/Sunrise/src/state/rewards/reward_resolver.cpp b/Sunrise/src/state/rewards/reward_resolver.cpp new file mode 100644 index 000000000..14c7d6cc2 --- /dev/null +++ b/Sunrise/src/state/rewards/reward_resolver.cpp @@ -0,0 +1,418 @@ +#include "reward_resolver.h" + +#include +#include + +#include "../build_data/rewards/reward_catalog.h" +#include "../build_data/runtime.h" +#include "middleware/content/packages/tables/definition_index_table.h" + +namespace sunrise::state::rewards { +namespace { + +namespace definitions = build_data::rewards; +/** The empty bucket tag inherits the enclosing pool's bucket constraint. */ +constexpr std::uint32_t kEmptyTag = 0x811C9DC5U; +/** Native postfix expressions use at most 256 signed 32-bit stack values. */ +constexpr std::size_t kExpressionCapacity = 256; + +bool refuse(const Context& context, const char* reason) noexcept { + if (context.refusal != nullptr) { + *context.refusal = reason; + } + return false; +} + +bool apply_condition_operator(const definitions::Instruction& instruction, + std::span stack, + std::size_t& size, + const Context& context) noexcept { + if (!definitions::valid_instruction(instruction)) { + return refuse(context, "condition_opcode"); + } + using Op = middleware::content::packages::tables::UnlockOpcode; + const auto opcode = static_cast(instruction.opcode); + const bool unary = opcode == Op::logicalNot || opcode == Op::negate; + if (size < (unary ? 1U : 2U)) { + return refuse(context, "condition_shape"); + } + const auto right = unary ? 0 : stack[--size]; + auto& left = stack[size - 1]; + switch (opcode) { + case Op::logicalNot: + left = left == 0; + break; + case Op::negate: + left = static_cast(0U - static_cast(left)); + break; + case Op::logicalOr: + left = left != 0 || right != 0; + break; + case Op::logicalAnd: + left = left != 0 && right != 0; + break; + case Op::equal: + left = left == right; + break; + case Op::greaterThan: + left = left > right; + break; + case Op::greaterOrEqual: + left = left >= right; + break; + case Op::lessOrEqual: + left = left <= right; + break; + case Op::add: + left = static_cast(static_cast(left) + + static_cast(right)); + break; + case Op::lessThan: + left = left < right; + break; + default: + return refuse(context, "condition_opcode"); + } + return true; +} + +bool condition(definitions::View data, + definitions::Range expression, + const Context& context, + bool& result) noexcept { + result = expression.count == 0; + if (!definitions::fits(expression, data.instructions)) { + return refuse(context, "condition_shape"); + } + std::array stack{}; + std::size_t size = 0; + for (const auto& instruction : data.instructions.subspan(expression.first, expression.count)) { + const auto operand = instruction.operand; + std::int32_t value = 0; + using Read = definitions::BankRead; + using Op = middleware::content::packages::tables::UnlockOpcode; + switch (instruction.opcode) { + case static_cast(Read::accountFlag): + if (operand >= context.unlocks.accountFlags.size()) { + return refuse(context, "condition_shape"); + } + value = context.unlocks.accountFlags[operand] == unlocks::kFlagSet; + break; + case static_cast(Read::profileFlag): + if (operand >= context.unlocks.profileFlags.size()) { + return refuse(context, "condition_shape"); + } + value = context.unlocks.profileFlags[operand] == unlocks::kFlagSet; + break; + case static_cast(Read::characterFlag): + if (operand >= context.unlocks.characterObjectFlags.size()) { + return refuse(context, "condition_shape"); + } + value = context.unlocks.characterObjectFlags[operand] == unlocks::kFlagSet; + break; + case static_cast(Read::accountValue): + if (operand >= context.unlocks.objectiveValues.size()) { + return refuse(context, "condition_shape"); + } + value = context.unlocks.objectiveValues[operand]; + break; + case static_cast(Read::characterValue): + if (operand >= context.unlocks.characterObjectValues.size()) { + return refuse(context, "condition_shape"); + } + value = context.unlocks.characterObjectValues[operand]; + break; + case static_cast(Read::characterClass): + value = operand == static_cast(context.characterClass); + break; + case static_cast(Op::constant): + value = static_cast(operand); + break; + case static_cast(Read::externalFlag): + return refuse(context, "external_flag"); + case static_cast(Read::externalValue): + return refuse(context, "external_value"); + default: + if (!apply_condition_operator(instruction, stack, size, context)) { + return false; + } + continue; + } + if (size == stack.size()) { + return refuse(context, "condition_shape"); + } + stack[size++] = value; + } + if (expression.count != 0) { + if (size != 1) { + return refuse(context, "condition_shape"); + } + result = stack[0] != 0; + } + return true; +} + +struct Resolver { + definitions::View data; + const Context& context; + Result& result; + std::uint64_t random; + Selection selection; + + double fraction() noexcept { + // SplitMix64 makes a prepared seed replayable without shared random state. + random += 0x9E3779B97F4A7C15ULL; + auto bits = random; + bits = (bits ^ (bits >> 30)) * 0xBF58476D1CE4E5B9ULL; + bits = (bits ^ (bits >> 27)) * 0x94D049BB133111EBULL; + bits ^= bits >> 31; + return static_cast(bits >> 11) * 0x1.0p-53; + } + + bool weight(const definitions::Entry& entry, + std::uint32_t category, + std::uint32_t bucket, + std::size_t depth, + double& output) noexcept { + output = 0; + if (entry.categoryHash != category + || (bucket != kEmptyTag && entry.bucketHash != kEmptyTag + && entry.bucketHash != bucket)) { + return true; + } + if (selection == Selection::equipment && entry.poolIndex == definitions::kAbsent) { + build_data::items::details::Definition item{}; + if (!build_data::find_configured_item_detail(entry.itemIndex, item) + || !item.equipmentSlot.has_value()) { + return true; + } + } + bool enabled = false; + if (!condition(data, entry.condition, context, enabled)) { + return false; + } + if (!enabled) { + return true; + } + double value = entry.weight; + if (!definitions::fits(entry.modifiers, data.modifiers)) { + return false; + } + for (const auto& modifier : + data.modifiers.subspan(entry.modifiers.first, entry.modifiers.count)) { + if (!condition(data, modifier.condition, context, enabled)) { + return false; + } + if (enabled) { + if (modifier.valueIndex != definitions::kAbsent) { + return refuse(context, "indexed_weight"); + } + value = modifier.value; + } + } + if (!std::isfinite(value) || value < 0) { + return false; + } + if (value == 0) { + return true; + } + if (entry.poolIndex != definitions::kAbsent) { + double total = 0; + if (!pool_weight(entry.poolIndex, + category, + entry.bucketHash == kEmptyTag ? bucket : entry.bucketHash, + depth + 1, + total)) { + return false; + } + if (total == 0) { + return true; + } + } else if (entry.itemIndex != definitions::kAbsent) { + if (entry.itemIndex >= data.items.size()) { + return false; + } + for (std::size_t i = 0; i < result.count; ++i) { + if (result.grants[i].itemIndex == entry.itemIndex) { + return true; + } + } + } else { + return refuse(context, "reward_mapping"); + } + output = value; + return true; + } + + bool pool_weight(std::uint16_t index, + std::uint32_t category, + std::uint32_t bucket, + std::size_t depth, + double& total) noexcept { + total = 0; + if (depth >= definitions::kTraversalDepth || index >= data.pools.size()) { + return false; + } + const auto range = data.pools[index].entries; + if (!definitions::fits(range, data.entries)) { + return false; + } + for (const auto& entry : data.entries.subspan(range.first, range.count)) { + double value = 0; + if (!weight(entry, category, bucket, depth, value)) { + return false; + } + total += value; + } + return std::isfinite(total); + } + + bool draw(std::uint16_t index, + std::uint32_t category, + std::uint32_t bucket, + std::size_t depth, + double total) noexcept { + const auto range = data.pools[index].entries; + double remaining = fraction() * total; + const definitions::Entry* chosen = nullptr; + for (const auto& entry : data.entries.subspan(range.first, range.count)) { + double value = 0; + if (!weight(entry, category, bucket, depth, value)) { + return false; + } + if (value == 0) { + continue; + } + chosen = &entry; + remaining -= value; + if (remaining < 0) { + break; + } + } + if (chosen == nullptr + || (chosen->quantity != 1 && chosen->poolIndex != definitions::kAbsent)) { + return refuse(context, "nested_quantity"); + } + if (chosen->poolIndex != definitions::kAbsent) { + const auto childBucket = chosen->bucketHash == kEmptyTag ? bucket : chosen->bucketHash; + double childTotal = 0; + return pool_weight(chosen->poolIndex, category, childBucket, depth + 1, childTotal) + && childTotal > 0 + && draw(chosen->poolIndex, category, childBucket, depth + 1, childTotal); + } + if (chosen->quantity == 0 || chosen->quantity > INT32_MAX + || result.count == result.grants.size() + || !definitions::fits(chosen->sockets, data.sockets)) { + return false; + } + auto& grant = result.grants[result.count++]; + grant.itemIndex = chosen->itemIndex; + grant.quantity = static_cast(chosen->quantity); + if (chosen->sockets.count > grant.sockets.size()) { + return false; + } + grant.socketCount = chosen->sockets.count; + std::copy_n( + data.sockets.begin() + chosen->sockets.first, grant.socketCount, grant.sockets.begin()); + return true; + } +}; + +bool resolve_item(definitions::View data, + const Context& context, + std::uint16_t itemIndex, + std::uint32_t quantity, + Selection selection, + Result& result) noexcept { + result = {}; + if (context.refusal != nullptr) { + *context.refusal = "reward_shape"; + } + if (itemIndex >= data.items.size() || quantity == 0 || quantity > INT32_MAX) { + return false; + } + const auto& item = data.items[itemIndex]; + if (item.definitionHash == 0) { + return refuse(context, "item_unavailable"); + } + Result staged{}; + // Stored engrams retain their wrapper until an opening transaction. + if (item.poolIndex == definitions::kAbsent + || (item.flags & definitions::kOpenOnAcquisition) == 0) { + staged.grants[0].itemIndex = itemIndex; + staged.grants[0].quantity = static_cast(quantity); + staged.count = 1; + } else { + if (quantity != 1 || item.selectionCount == 0 + || item.selectionCount > item.selections.size()) { + return false; + } + Resolver resolver{data, context, staged, context.seed, selection}; + for (std::size_t i = 0; i < item.selectionCount; ++i) { + const auto& declared = item.selections[i]; + if (declared.count > staged.grants.size()) { + return false; + } + for (std::size_t j = 0; j < declared.count; ++j) { + double total = 0; + if (!resolver.pool_weight( + item.poolIndex, declared.categoryHash, kEmptyTag, 0, total)) { + return false; + } + // A fixed bundle can have fewer eligible members after an acquisition unlock. + if (total == 0) { + break; + } + if (!resolver.draw(item.poolIndex, declared.categoryHash, kEmptyTag, 0, total)) { + return false; + } + } + } + if (staged.count == 0) { + return false; + } + } + result = staged; + if (context.refusal != nullptr) { + *context.refusal = nullptr; + } + return true; +} + +} // namespace + +bool eligible(std::span instructions, + const Context& context, + bool& result) noexcept { + definitions::View view{}; + view.instructions = instructions; + if (instructions.size() > UINT32_MAX) { + return false; + } + return condition(view, {0, static_cast(instructions.size())}, context, result); +} + +bool resolve(const Context& context, + std::uint16_t itemIndex, + std::uint32_t quantity, + Selection selection, + Result& result) noexcept { + struct Request { + const Context& context; + std::uint16_t item; + std::uint32_t quantity; + Selection selection; + Result& result; + } request{context, itemIndex, quantity, selection, result}; + result = {}; + if (context.refusal != nullptr) { + *context.refusal = "reward_item"; + } + return definitions::read(&request, [](void* raw, definitions::View data) noexcept { + auto& value = *static_cast(raw); + return resolve_item( + data, value.context, value.item, value.quantity, value.selection, value.result); + }); +} + +} // namespace sunrise::state::rewards diff --git a/Sunrise/src/state/rewards/reward_resolver.h b/Sunrise/src/state/rewards/reward_resolver.h new file mode 100644 index 000000000..934674e3a --- /dev/null +++ b/Sunrise/src/state/rewards/reward_resolver.h @@ -0,0 +1,44 @@ +#pragma once + +#include "../account/account_state.h" +#include "../build_data/rewards/definition.h" +#include "../unlocks/definition.h" + +namespace sunrise::state::rewards { + +/** Pass engrams select equipment; ordinary acquisitions expand every declared lane. */ +enum class Selection : std::uint8_t { all, equipment }; + +struct Grant { + std::uint16_t itemIndex{build_data::rewards::kAbsent}; + std::int32_t quantity{}; + std::array sockets{}; + std::size_t socketCount{}; +}; + +struct Result { + std::array grants{}; + std::size_t count{}; +}; + +/** The seed belongs to the prepared server transaction, so validation repeats the same draw. */ +struct Context { + const unlocks::Table& unlocks; + CharacterClass characterClass{}; + std::uint64_t seed{}; + /** Optional refusal reason; points to a static diagnostic label. */ + const char** refusal{}; +}; + +[[nodiscard]] bool eligible(std::span instructions, + const Context& context, + bool& result) noexcept; + +/** Plans an acquisition without changing State; retains wrappers opened separately. */ +[[nodiscard]] bool resolve(const Context& context, + std::uint16_t itemIndex, + std::uint32_t quantity, + Selection selection, + Result& result) noexcept; + +} // namespace sunrise::state::rewards diff --git a/Sunrise/src/state/runtime/runtime.h b/Sunrise/src/state/runtime/runtime.h index 76525e31d..9971d568b 100644 --- a/Sunrise/src/state/runtime/runtime.h +++ b/Sunrise/src/state/runtime/runtime.h @@ -5,11 +5,12 @@ #include #include #include -#include #include "../build_data/items/quest_initialization.h" #include "../build_data/records/definition.h" +#include "../build_data/rewards/definition.h" #include "../build_data/vendors/definition.h" +#include "../unlocks/definition.h" #include "state.h" namespace sunrise::state::account::settings { @@ -20,6 +21,9 @@ struct SettingsDelta; namespace sunrise::state { +/** Acquisition planner selected from installed wrapper, quest and bucket definitions. */ +enum class ItemGrantRoute : std::uint8_t { unavailable, quest, profile, reward }; +[[nodiscard]] ItemGrantRoute item_grant_route(std::uint16_t itemIndex) noexcept; /** * Assigns runtime SOIDs only to installed profile mod/shader rows which are socket action sources. * Currency, material, and consumable profile rows remain canonically non-instanced. @@ -201,34 +205,23 @@ struct PendingProfileItemAcquisition { bool prepared{}; }; -/** Prepared fixed package expansion kept private until every object and response byte fits. */ -struct PendingDirectItemBundle { - CharacterState beforeCharacter{}; - CharacterState afterCharacter{}; - std::uint64_t accountSoid{}; - std::uint64_t characterSoid{}; - std::uint64_t firstInstanceSoid{}; - std::uint32_t sourceDefinitionHash{}; - std::size_t characterIndex{}; - std::size_t expectedInventoryCount{}; - std::size_t itemCount{}; - bool prepared{}; -}; - -/** Shared batch capacity covers both Triumph rewards and the nine-row Season package. */ -inline constexpr std::size_t kRecordRewardGrantCapacity = 9; +/** Shared batch capacity bounds one reward transaction. */ +inline constexpr std::size_t kRecordRewardGrantCapacity = build_data::rewards::kGrantCapacity; static_assert(kRecordRewardGrantCapacity >= build_data::records::kRewardPerRecordCapacity); /** One direct item requested by a record reward policy. */ struct DirectRecordReward { std::uint16_t itemDefinitionIndex{}; std::int32_t quantity{}; + std::span sockets{}; + bool acquireUnlock{}; }; enum class RecordRewardKind : std::uint8_t { characterInstance, characterStack, profileStack, + accountUnlock, }; /** Native row identity of one item inside a prepared record-reward batch. */ @@ -242,6 +235,8 @@ struct PreparedRecordReward { std::uint16_t inventoryRow{}; RecordRewardKind kind{}; bool appendedProfileResident{}; + std::uint16_t acquiredFlag{build_data::rewards::kAbsent}; + std::uint8_t previousFlag{}; }; /** A reward grant that claims no record carries this instead of a record row. */ @@ -269,12 +264,9 @@ struct PendingRecordRewardGrant { /** One uncommitted Season reward and the exact native row or bundle it will claim. */ struct PendingSeasonPassReward { - std::variant - grant{}; + PendingRecordRewardGrant grant{}; std::uint32_t sourceDefinitionHash{}; + std::uint64_t seed{}; std::uint16_t rewardIndex{}; bool prepared{}; }; @@ -539,17 +531,19 @@ prepare_item_acquisition(std::uint16_t collectibleIndex, [[nodiscard]] bool prepare_item_acquisition_for_item(std::uint16_t itemDefinitionIndex, PendingItemAcquisition& mutation) noexcept; -/** Prepares one fixed wrapper expansion without changing account State. */ -[[nodiscard]] bool prepare_direct_item_bundle(std::uint32_t sourceDefinitionHash, - std::span itemDefinitionIndices, - PendingDirectItemBundle& mutation) noexcept; - -/** Builds the full account after-image while a prepared bundle remains current. */ -[[nodiscard]] bool preview_direct_item_bundle(const PendingDirectItemBundle& mutation, - AccountState& after) noexcept; - /** Atomically commits one prepared reward grant and its durable Season claim. */ [[nodiscard]] bool commit_season_pass_reward(PendingSeasonPassReward& mutation) noexcept; +/** Resolves an installed item or wrapper; refusal receives a static reason on failure. */ +[[nodiscard]] bool prepare_item_reward(std::uint16_t itemIndex, + std::uint32_t quantity, + PendingRecordRewardGrant& mutation, + const char** refusal = nullptr) noexcept; +/** Prepares an eligible native pass row without writing its claim or acquisition flags. */ +[[nodiscard]] bool prepare_season_pass_reward(std::uint16_t rewardIndex, + PendingSeasonPassReward& mutation, + const char** refusal = nullptr) noexcept; +[[nodiscard]] bool preview_reward_unlocks(const PendingRecordRewardGrant& mutation, + unlocks::Table& after) noexcept; /** Atomically commits one prepared Triumph reward and its durable record claim. */ [[nodiscard]] bool commit_record_reward(PendingRecordRewardGrant& mutation) noexcept; @@ -563,7 +557,8 @@ prepare_item_acquisition(std::uint16_t collectibleIndex, */ [[nodiscard]] bool prepare_record_reward_grant(std::span rewards, std::uint16_t claimedRecordIndex, - PendingRecordRewardGrant& mutation) noexcept; + PendingRecordRewardGrant& mutation, + const char** refusal = nullptr) noexcept; /** Builds the full account after-image while a record reward remains current. */ [[nodiscard]] bool preview_record_reward_grant(const PendingRecordRewardGrant& mutation, diff --git a/Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp b/Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp index 3abddbe91..65fd12f2e 100644 --- a/Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp +++ b/Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp @@ -247,100 +247,6 @@ bool prepare_item_acquisition_for_item(std::uint16_t itemDefinitionIndex, account, account, grantedDefinition.definitionHash, false, {.direct = true}, mutation); } -/** Prepares a fixed Season wrapper expansion without exposing a partial package. */ -bool prepare_direct_item_bundle(std::uint32_t sourceDefinitionHash, - std::span itemDefinitionIndices, - PendingDirectItemBundle& mutation) noexcept { - mutation = {}; - build_data::season_pass::Package package{}; - if (!build_data::find_season_pass_package(sourceDefinitionHash, package) - || itemDefinitionIndices.size() != package.itemCount) { - return false; - } - - std::array hashes{}; - for (std::size_t index = 0; index < itemDefinitionIndices.size(); ++index) { - const std::uint16_t definitionIndex = itemDefinitionIndices[index]; - build_data::items::Definition definition{}; - item_details::Definition detail{}; - inventory_buckets::Descriptor bucket{}; - if (!build_data::find_item_definition_index(definitionIndex, definition) - || definition.definitionHash != package.items[index] - || !build_data::find_configured_item_detail(definitionIndex, detail) - || detail.definitionIndex != definition.definitionIndex - || detail.definitionHash != definition.definitionHash - || detail.bucketId != definition.bucketId - || detail.instancedDefinitionState != item_details::InstancedDefinitionState::instanced - || !detail.equipmentSlot.has_value() - || !build_data::find_inventory_bucket_descriptor(detail.bucketId, bucket) - || bucket.arraySelector != inventory_buckets::ArraySelector::character) { - return false; - } - hashes[index] = definition.definitionHash; - } - - const AccountState account = account_snapshot(); - const std::size_t characterIndex = selected_character_index(account); - if (!account::valid(account) || characterIndex >= account.characterCount) { - return false; - } - const CharacterState& before = account.characters[characterIndex]; - if (before.nextInventorySerial - > static_cast((std::numeric_limits::max)()) - || itemDefinitionIndices.size() > before.inventory.values.size() - before.inventory.count - || itemDefinitionIndices.size() - > static_cast((std::numeric_limits::max)()) - - before.nextInventorySerial) { - return false; - } - - std::uint64_t firstSoid = 0; - if (!next_item_instance_soid(account, firstSoid) - || itemDefinitionIndices.size() - 1U - > (std::numeric_limits::max)() - firstSoid) { - return false; - } - - CharacterState after = before; - const std::int32_t level = acquisition_level(before); - for (std::size_t index = 0; index < itemDefinitionIndices.size(); ++index) { - authored_inventory::Item granted{}; - granted.instanceSoid = firstSoid + index; - granted.definitionHash = hashes[index]; - granted.level = level; - granted.quantity = 1; - granted.mutationSerial = static_cast(after.nextInventorySerial++); - after.inventory.values[after.inventory.count++] = granted; - } - - AccountState candidate = account; - candidate.characters[characterIndex] = after; - family4_loadout::ResolvedLoadout resolved{}; - if (!account::valid(candidate) - || !family4_loadout::resolve(candidate, characterIndex, resolved)) { - return false; - } - for (std::size_t index = 0; index < itemDefinitionIndices.size(); ++index) { - std::uint16_t row = 0; - std::uint8_t slot = 0; - if (!find_unequipped_row(resolved, firstSoid + index, row, slot)) { - return false; - } - } - - mutation.beforeCharacter = before; - mutation.afterCharacter = after; - mutation.accountSoid = account.primarySoid; - mutation.characterSoid = before.soid; - mutation.firstInstanceSoid = firstSoid; - mutation.sourceDefinitionHash = sourceDefinitionHash; - mutation.characterIndex = characterIndex; - mutation.expectedInventoryCount = before.inventory.count; - mutation.itemCount = itemDefinitionIndices.size(); - mutation.prepared = true; - return true; -} - /** * Takes the selected character's next inventory mutation serial under the State lock. * @param mutationSerial Receives the reserved serial; zero when nothing was reserved. @@ -461,80 +367,6 @@ valid_item_acquisition_source(const PendingItemAcquisition& mutation) noexcept { && row == mutation.inventoryRow && slot == mutation.equipmentSlot; } -/** Rebuilds one package from installed policy and rejects any altered after-image. */ -[[nodiscard]] bool materialize_direct_item_bundle(const AccountState& current, - const PendingDirectItemBundle& mutation, - AccountState& after) noexcept { - build_data::season_pass::Package package{}; - std::uint64_t firstSoid = 0; - if (!mutation.prepared - || !build_data::find_season_pass_package(mutation.sourceDefinitionHash, package) - || mutation.itemCount != package.itemCount || mutation.accountSoid == 0 - || mutation.characterSoid == 0 || mutation.firstInstanceSoid == 0 - || mutation.characterIndex >= current.characterCount - || mutation.expectedInventoryCount >= authored_inventory::kCharacterItemCapacity - || current.primarySoid != mutation.accountSoid - || !same_character(current.characters[mutation.characterIndex], mutation.beforeCharacter) - || !current.characters[mutation.characterIndex].selected - || current.characters[mutation.characterIndex].soid != mutation.characterSoid - || mutation.beforeCharacter.inventory.count != mutation.expectedInventoryCount - || mutation.itemCount - > mutation.beforeCharacter.inventory.values.size() - mutation.expectedInventoryCount - || mutation.beforeCharacter.nextInventorySerial - > static_cast((std::numeric_limits::max)()) - || mutation.itemCount > static_cast((std::numeric_limits::max)()) - - mutation.beforeCharacter.nextInventorySerial - || !next_item_instance_soid(current, firstSoid) || firstSoid != mutation.firstInstanceSoid - || mutation.itemCount - 1U > (std::numeric_limits::max)() - firstSoid) { - return false; - } - - CharacterState canonical = mutation.beforeCharacter; - const std::int32_t level = acquisition_level(canonical); - for (std::size_t index = 0; index < mutation.itemCount; ++index) { - build_data::items::Definition definition{}; - item_details::Definition detail{}; - inventory_buckets::Descriptor bucket{}; - if (!build_data::find_item_definition_hash(package.items[index], definition) - || !build_data::find_configured_item_detail(definition.definitionIndex, detail) - || detail.definitionHash != definition.definitionHash - || detail.definitionIndex != definition.definitionIndex - || detail.bucketId != definition.bucketId - || detail.instancedDefinitionState != item_details::InstancedDefinitionState::instanced - || !detail.equipmentSlot.has_value() - || !build_data::find_inventory_bucket_descriptor(detail.bucketId, bucket) - || bucket.arraySelector != inventory_buckets::ArraySelector::character) { - return false; - } - authored_inventory::Item granted{}; - granted.instanceSoid = firstSoid + index; - granted.definitionHash = package.items[index]; - granted.level = level; - granted.quantity = 1; - granted.mutationSerial = static_cast(canonical.nextInventorySerial++); - canonical.inventory.values[canonical.inventory.count++] = granted; - } - if (!same_character(canonical, mutation.afterCharacter)) { - return false; - } - - after = current; - after.characters[mutation.characterIndex] = canonical; - family4_loadout::ResolvedLoadout resolved{}; - if (!account::valid(after) - || !family4_loadout::resolve(after, mutation.characterIndex, resolved)) { - return false; - } - for (std::size_t index = 0; index < mutation.itemCount; ++index) { - std::uint16_t row = 0; - std::uint8_t slot = 0; - if (!find_unequipped_row(resolved, firstSoid + index, row, slot)) { - return false; - } - } - return true; -} - } // namespace runtime::detail /** @@ -565,13 +397,6 @@ bool preview_item_acquisition(const PendingItemAcquisition& mutation, return true; } -/** Produces the full account after-image while a prepared package remains current. */ -bool preview_direct_item_bundle(const PendingDirectItemBundle& mutation, - AccountState& after) noexcept { - after = {}; - return materialize_direct_item_bundle(account_snapshot(), mutation, after); -} - /** * Inventory and first-step state share one transaction; failure rolls both back. * @param mutation Prepared grant consumed on either success or failure. diff --git a/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp b/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp index 6918f54ae..8b670c0e9 100644 --- a/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp +++ b/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp @@ -2,18 +2,21 @@ * Grants that no purchase pays for: season pass rewards, record rewards, and the * default emote collection. */ -#include - #include #include #include +#include #include +#include "../../middleware/crypto/random_bytes.h" #include "../../middleware/datagen/family4/loadout/loadout_resolver.h" +#include "../build_data/rewards/reward_catalog.h" #include "../build_data/runtime.h" #include "../investment/store_internal.h" -#include "../progression/season_pass_reward_catalog.h" +#include "../rewards/reward_resolver.h" #include "../unlocks/unlocks_records.h" +#include "../unlocks/unlocks_runtime.h" +#include "middleware/content/packages/tables/definition_index_table.h" #include "runtime.h" #include "state_account_transaction_helpers.h" #include "storage/internal.h" @@ -32,86 +35,353 @@ namespace { const PendingRecordRewardGrant& mutation, AccountState& after) noexcept; -/** - * Checks that a staged grant is the one the authored reward row describes. - * @param reward Authored season pass reward row. - * @return False when the grant's item, quantity or bundle does not match the row. - */ -[[nodiscard]] bool reward_matches(const build_data::season_pass::Reward& reward, - const PendingSeasonPassReward& mutation) noexcept { - if (!mutation.prepared || mutation.sourceDefinitionHash != reward.itemHash) { +/** A reward overrides socket types, so adding an ordinary lane cannot shift a fixed roll. */ +[[nodiscard]] bool +apply_reward_sockets(const item_details::Definition& detail, + std::span overrides, + authored_inventory::Sockets& sockets, + const char** refusal = nullptr) noexcept { + const auto fail = [&](const char* reason) noexcept { + if (refusal != nullptr) { + *refusal = reason; + } + return false; + }; + if (overrides.empty()) { + return true; + } + if (detail.ordinarySocketCount > sockets.plugs.size()) { + return fail("socket_layout"); + } + authored_inventory::Sockets staged{}; + staged.policy = authored_inventory::SocketPolicy::authored; + staged.plugCount = detail.ordinarySocketCount; + for (std::size_t lane = 0; lane < staged.plugCount; ++lane) { + const auto index = detail.initialPlugIndices[lane]; + if (index == item_details::kUnavailableItemIndex) { + continue; + } + build_data::items::Definition plug{}; + if (!build_data::find_item_definition_index(index, plug)) { + return fail("socket_layout"); + } + staged.plugs[lane] = plug.definitionHash; + } + std::array replaced{}; + for (const auto& override : overrides) { + if (override.plugSet != build_data::rewards::kAbsent) { + return fail("socket_plug_set"); + } + if (override.rollSet != build_data::rewards::kAbsent) { + return fail("socket_roll_set"); + } + // Zero selection preserves the initial plug in every lane of the named type. + const bool preserve = + override.plugItem == build_data::rewards::kAbsent && override.selection == 0; + if (!preserve && override.selection != build_data::rewards::kFixedPlugSelection) { + return fail("socket_selection"); + } + build_data::items::Definition plug{}; + if (!preserve && !build_data::find_item_definition_index(override.plugItem, plug)) { + return fail("socket_layout"); + } + bool found = false; + for (std::size_t lane = 0; lane < staged.plugCount; ++lane) { + if (detail.socketTypes[lane] != override.socketType) { + continue; + } + if (replaced[lane] || (!preserve && found)) { + return fail("socket_layout"); + } + replaced[lane] = true; + found = true; + if (!preserve) { + staged.plugs[lane] = plug.definitionHash; + } + } + if (!found) { + return fail("socket_layout"); + } + } + if (!authored_inventory::valid(staged)) { + return fail("socket_layout"); + } + sockets = staged; + return true; +} + +[[nodiscard]] bool prepare_resolved_reward(const rewards::Result& resolved, + PendingRecordRewardGrant& mutation, + const char** refusal) noexcept { + std::array rows{}; + if (resolved.count > rows.size()) { + return false; + } + for (std::size_t i = 0; i < resolved.count; ++i) { + const auto& grant = resolved.grants[i]; + rows[i] = {grant.itemIndex, + grant.quantity, + std::span(grant.sockets).first(grant.socketCount), + true}; + } + return prepare_record_reward_grant( + std::span(rows).first(resolved.count), kUnclaimedRecordIndex, mutation, refusal); +} + +[[nodiscard]] bool resolve_pass(const build_data::season_pass::Reward& reward, + const AccountState& account, + std::uint64_t seed, + rewards::Result& result, + bool replay = false, + const char** reason = nullptr) noexcept { + const auto character = selected_character_index(account); + unlocks::Table flags{}; + build_data::rewards::Item source{}; + if (character >= account.characterCount + || !investment::store::read_unlocks(flags, static_cast(character)) + || !build_data::rewards::find_item(reward.itemIndex, source) + || source.definitionHash != reward.itemHash || reward.socketCount > reward.sockets.size() + || reward.conditionCount > reward.condition.size()) { + return false; + } + if (reason != nullptr) { + *reason = "reward_condition"; + } + if (replay && reward.claimFlagIndex < flags.accountFlags.size()) { + flags.accountFlags[reward.claimFlagIndex] = unlocks::kFlagClear; + } + bool enabled = false; + if (!rewards::eligible(std::span(reward.condition).first(reward.conditionCount), + {flags, account.characters[character].characterClass, seed, reason}, + enabled) + || !enabled) { return false; } - if (const auto* item = std::get_if(&mutation.grant)) { - if (reward.quantity != 1) { + build_data::items::Definition item{}; + const bool engram = build_data::find_item_definition_index(reward.itemIndex, item) + && item.bucketId == inventory_buckets::kEngramBucketId; + if (!rewards::resolve({flags, account.characters[character].characterClass, seed, reason}, + reward.itemIndex, + reward.quantity, + engram ? rewards::Selection::equipment : rewards::Selection::all, + result)) { + return false; + } + if (reward.socketCount != 0) { + if (result.count != 1 || result.grants[0].itemIndex != reward.itemIndex) { + if (reason != nullptr) { + *reason = "socket_owner"; + } return false; } - if (reward.itemHash != progression::season_pass::kLegendaryEngramHash - && reward.itemHash != progression::season_pass::kExoticEngramHash) { - return item->acquiredDefinitionHash == reward.itemHash; - } - return progression::season_pass::contains_engram_reward( - reward.itemHash, - item->acquiredDefinitionHash, - static_cast(item->afterCharacter.characterClass)); - } - if (const auto* profile = std::get_if(&mutation.grant)) { - return profile->acquiredDefinitionHash == reward.itemHash - && profile->acquiredQuantity - profile->previousQuantity - == static_cast(reward.quantity); - } - if (const auto* bundle = std::get_if(&mutation.grant)) { - build_data::season_pass::Package package{}; - return reward.quantity == 1 && bundle->sourceDefinitionHash == reward.itemHash - && build_data::find_season_pass_package(reward.itemHash, package); - } - const auto* resources = std::get_if(&mutation.grant); - if (resources == nullptr || reward.quantity != 1 - || reward.itemHash != progression::season_pass::kDestinationResourceBundleHash - || resources->rewardCount != progression::season_pass::kDestinationResourceHashes.size()) { + result.grants[0].sockets = reward.sockets; + result.grants[0].socketCount = reward.socketCount; + } + return true; +} + +[[nodiscard]] bool reward_matches(const build_data::season_pass::Reward& reward, + const PendingSeasonPassReward& mutation) noexcept { + const auto* grant = &mutation.grant; + rewards::Result expected{}; + if (!mutation.prepared || mutation.sourceDefinitionHash != reward.itemHash + || !resolve_pass(reward, account_snapshot(), mutation.seed, expected, true) + || expected.count != grant->rewardCount) { return false; } - for (std::size_t index = 0; index < resources->rewardCount; ++index) { - if (resources->rewards[index].definitionHash - != progression::season_pass::kDestinationResourceHashes[index] - || resources->rewards[index].quantity - != progression::season_pass::kDestinationResourceQuantity) { + for (std::size_t i = 0; i < expected.count; ++i) { + const auto& planned = expected.grants[i]; + const auto& prepared = grant->rewards[i]; + build_data::items::Definition definition{}; + build_data::rewards::Item source{}; + if (!build_data::find_item_definition_index(planned.itemIndex, definition) + || !build_data::rewards::find_item(planned.itemIndex, source) + || prepared.definitionHash != definition.definitionHash + || prepared.quantity != planned.quantity + || prepared.acquiredFlag != source.acquiredFlag) { return false; } + if (planned.socketCount != 0) { + item_details::Definition detail{}; + authored_inventory::Sockets sockets{}; + if (prepared.kind != RecordRewardKind::characterInstance + || prepared.stateIndex >= grant->afterCharacter.inventory.count + || !build_data::find_configured_item_detail(planned.itemIndex, detail) + || !apply_reward_sockets( + detail, std::span(planned.sockets).first(planned.socketCount), sockets)) { + return false; + } + const auto& installed = + grant->afterCharacter.inventory.values[prepared.stateIndex].sockets; + if (installed.policy != sockets.policy || installed.plugCount != sockets.plugCount + || installed.plugs != sockets.plugs) { + return false; + } + } } return true; } } // namespace -/** Commits a reward and its claim together after every outbound byte has been staged. */ -bool commit_season_pass_reward(PendingSeasonPassReward& mutation) noexcept { - const PendingConsumption consume{mutation}; +bool prepare_season_pass_reward(std::uint16_t rewardIndex, + PendingSeasonPassReward& mutation, + const char** refusal) noexcept { + const char* unused = nullptr; + auto& reason = refusal != nullptr ? *refusal : unused; + reason = "reward_index"; + mutation = {}; + const std::lock_guard lock(investment::store::g_mutex); build_data::season_pass::Reward reward{}; - if (!build_data::find_season_pass_reward(mutation.rewardIndex, reward) - || !reward_matches(reward, mutation)) { - revoke_season_pass_reward(mutation.rewardIndex); + if (!build_data::find_season_pass_reward(rewardIndex, reward)) { return false; } + reason = "rank"; + if (reward.requiredRank > seasonal_rank()) { + return false; + } + reason = "already_claimed"; + if (season_pass_reward_claimed(rewardIndex)) { + return false; + } + reason = "random_source"; + std::array random{}; + if (!middleware::crypto::random::fill(random)) { + return false; + } + std::memcpy(&mutation.seed, random.data(), random.size()); + rewards::Result resolved{}; + reason = "reward_condition"; + if (!resolve_pass(reward, account_snapshot(), mutation.seed, resolved, false, &reason)) { + return false; + } + reason = "reward_placement"; + if (!prepare_resolved_reward(resolved, mutation.grant, &reason)) { + return false; + } + reason = nullptr; + mutation.sourceDefinitionHash = reward.itemHash; + mutation.rewardIndex = rewardIndex; + mutation.prepared = true; + return true; +} - investment::store::g_mutex.lock(); - AccountState after{}; +ItemGrantRoute item_grant_route(std::uint16_t itemIndex) noexcept { + build_data::items::Definition item{}; + build_data::rewards::Item reward{}; + if (!build_data::find_item_definition_index(itemIndex, item)) { + return ItemGrantRoute::unavailable; + } + if (build_data::rewards::find_item(itemIndex, reward) + && (reward.poolIndex != build_data::rewards::kAbsent + || (item.bucketId == middleware::content::packages::tables::kNonInventoryBucketId + && reward.acquiredFlag != build_data::rewards::kAbsent))) { + return ItemGrantRoute::reward; + } + if (item.questInitialization.scope != build_data::items::QuestInitialization::Scope::none) { + return ItemGrantRoute::quest; + } + item_details::Definition detail{}; + inventory_buckets::Descriptor bucket{}; + if (!build_data::find_configured_item_detail(itemIndex, detail) + || !build_data::find_inventory_bucket_descriptor(item.bucketId, bucket)) { + return ItemGrantRoute::unavailable; + } + if (bucket.arraySelector == inventory_buckets::ArraySelector::profile + && detail.instancedDefinitionState == item_details::InstancedDefinitionState::stackable) { + return ItemGrantRoute::profile; + } + return bucket.arraySelector == inventory_buckets::ArraySelector::character + ? ItemGrantRoute::reward + : ItemGrantRoute::unavailable; +} + +bool prepare_item_reward(std::uint16_t itemIndex, + std::uint32_t quantity, + PendingRecordRewardGrant& mutation, + const char** refusal) noexcept { + const char* unused = nullptr; + auto& reason = refusal != nullptr ? *refusal : unused; + reason = "item_definition"; + mutation = {}; + const std::lock_guard lock(investment::store::g_mutex); + build_data::items::Definition item{}; + std::array random{}; + if (!build_data::find_item_definition_index(itemIndex, item)) { + return false; + } + reason = "random_source"; + if (!middleware::crypto::random::fill(random)) { + return false; + } + std::uint64_t seed = 0; + std::memcpy(&seed, random.data(), random.size()); + reason = "selected_character"; + const AccountState account = account_snapshot(); + const auto character = selected_character_index(account); + unlocks::Table flags{}; + if (character >= account.characterCount + || !investment::store::read_unlocks(flags, static_cast(character))) { + return false; + } + rewards::Result resolved{}; + if (!rewards::resolve({flags, account.characters[character].characterClass, seed, &reason}, + itemIndex, + quantity, + rewards::Selection::all, + resolved)) { + return false; + } + reason = "reward_placement"; + if (!prepare_resolved_reward(resolved, mutation, &reason)) { + return false; + } + reason = nullptr; + return true; +} + +bool preview_reward_unlocks(const PendingRecordRewardGrant& mutation, + unlocks::Table& after) noexcept { + if (!mutation.prepared || mutation.rewardCount > mutation.rewards.size() + || !investment::store::read_unlocks(after, static_cast(mutation.characterIndex))) { + return false; + } + for (std::size_t i = 0; i < mutation.rewardCount; ++i) { + const auto& reward = mutation.rewards[i]; + if (reward.acquiredFlag == build_data::rewards::kAbsent) { + continue; + } + if (reward.acquiredFlag >= after.accountFlags.size() + || after.accountFlags[reward.acquiredFlag] != reward.previousFlag) { + return false; + } + } + for (std::size_t i = 0; i < mutation.rewardCount; ++i) { + const auto flag = mutation.rewards[i].acquiredFlag; + if (flag != build_data::rewards::kAbsent) { + after.accountFlags[flag] = unlocks::kFlagSet; + } + } + return true; +} + +/** Revalidates the native draw under the same lock that commits its inventory and flags. */ +bool commit_season_pass_reward(PendingSeasonPassReward& mutation) noexcept { + if (!mutation.prepared) { + return false; + } + const PendingConsumption consume{mutation}; bool ready = false; - if (const auto* item = std::get_if(&mutation.grant)) { - ready = materialize_item_acquisition(investment::store::account(), *item, after); - } else if (const auto* profile = std::get_if(&mutation.grant)) { - ready = materialize_profile_acquisition(investment::store::account(), *profile, after); - } else if (const auto* bundle = std::get_if(&mutation.grant)) { - ready = materialize_direct_item_bundle(investment::store::account(), *bundle, after); - } else if (const auto* resources = std::get_if(&mutation.grant)) { - ready = materialize_record_reward(investment::store::account(), *resources, after); - } - if (ready) { - ready = investment::store::write_account(after); + { + investment::store::Transaction transaction; + build_data::season_pass::Reward reward{}; + ready = transaction.ready() + && build_data::find_season_pass_reward(mutation.rewardIndex, reward) + && season_pass_reward_claimed(mutation.rewardIndex) + && reward.requiredRank <= seasonal_rank() && reward_matches(reward, mutation) + && commit_record_reward(mutation.grant) && transaction.commit(); } - investment::store::g_mutex.unlock(); if (!ready) { - // The claim was written when the reward was prepared, so a refused install undoes it. revoke_season_pass_reward(mutation.rewardIndex); } return ready; @@ -157,8 +427,21 @@ namespace { inventory_buckets::Descriptor bucket{}; if (reward.definitionHash == authored_inventory::kNoDefinitionHash || reward.quantity <= 0 || reward.afterQuantity < reward.quantity || reward.mutationSerial < 0 - || !build_data::find_item_definition_hash(reward.definitionHash, item) - || !build_data::find_configured_item_detail(item.definitionIndex, detail) + || !build_data::find_item_definition_hash(reward.definitionHash, item)) { + return false; + } + if (reward.kind == RecordRewardKind::accountUnlock) { + build_data::rewards::Item source{}; + if (reward.quantity != 1 || reward.afterQuantity != 1 || reward.instanceSoid != 0 + || item.bucketId != middleware::content::packages::tables::kNonInventoryBucketId + || !build_data::rewards::find_item(item.definitionIndex, source) + || source.acquiredFlag == build_data::rewards::kAbsent + || source.acquiredFlag != reward.acquiredFlag) { + return false; + } + continue; + } + if (!build_data::find_configured_item_detail(item.definitionIndex, detail) || detail.definitionIndex != item.definitionIndex || detail.definitionHash != item.definitionHash || detail.bucketId != item.bucketId || !build_data::find_inventory_bucket_descriptor(item.bucketId, bucket)) { @@ -166,7 +449,7 @@ namespace { } if (reward.kind == RecordRewardKind::characterInstance) { if (reward.quantity != 1 || reward.afterQuantity != 1 || reward.instanceSoid == 0 - || reward.appendedProfileResident || !detail.equipmentSlot.has_value() + || reward.appendedProfileResident || detail.instancedDefinitionState != item_details::InstancedDefinitionState::instanced || bucket.arraySelector != inventory_buckets::ArraySelector::character @@ -230,11 +513,16 @@ namespace { /** Prepares every reward over one cumulative account view. */ bool prepare_record_reward_grant(std::span rewards, std::uint16_t claimedRecordIndex, - PendingRecordRewardGrant& mutation) noexcept { + PendingRecordRewardGrant& mutation, + const char** refusal) noexcept { + const char* unused = nullptr; + auto& reason = refusal != nullptr ? *refusal : unused; + reason = "reward_count"; mutation = {}; if (rewards.empty() || rewards.size() > mutation.rewards.size()) { return false; } + reason = "account_state"; const AccountState account = account_snapshot(); const std::size_t characterIndex = selected_character_index(account); if (!account::valid(account) || !valid_profile_inventory(account) @@ -244,18 +532,57 @@ bool prepare_record_reward_grant(std::span rewards, AccountState working = account; for (std::size_t index = 0; index < rewards.size(); ++index) { + reason = "item_identity"; const DirectRecordReward& requested = rewards[index]; build_data::items::Definition item{}; item_details::Definition detail{}; inventory_buckets::Descriptor bucket{}; if (requested.quantity <= 0 || !build_data::find_item_definition_index(requested.itemDefinitionIndex, item) - || !build_data::find_configured_item_detail(requested.itemDefinitionIndex, detail) + || item.questInitialization.scope + != build_data::items::QuestInitialization::Scope::none) { + return false; + } + PreparedRecordReward prepared{}; + prepared.definitionHash = item.definitionHash; + prepared.quantity = requested.quantity; + if (requested.acquireUnlock) { + reason = "acquisition_flag"; + build_data::rewards::Item source{}; + if (!build_data::rewards::find_item(item.definitionIndex, source) + || source.definitionHash != item.definitionHash) { + return false; + } + prepared.acquiredFlag = source.acquiredFlag; + if (source.acquiredFlag != build_data::rewards::kAbsent) { + std::int32_t before = 0; + if (!investment::store::read_unlock( + investment::store::Bank::accountFlags, source.acquiredFlag, before) + || before < 0 || before > unlocks::kFlagSet) { + return false; + } + prepared.previousFlag = static_cast(before); + } + } + if (item.bucketId == middleware::content::packages::tables::kNonInventoryBucketId) { + reason = "perk_acquisition"; + if (prepared.acquiredFlag == build_data::rewards::kAbsent || requested.quantity != 1 + || !requested.sockets.empty()) { + return false; + } + prepared.kind = RecordRewardKind::accountUnlock; + prepared.afterQuantity = 1; + mutation.rewards[index] = prepared; + continue; + } + reason = "item_detail"; + if (!build_data::find_configured_item_detail(requested.itemDefinitionIndex, detail) || detail.definitionIndex != item.definitionIndex || detail.definitionHash != item.definitionHash || detail.bucketId != item.bucketId || !build_data::find_inventory_bucket_descriptor(item.bucketId, bucket)) { return false; } + reason = "duplicate_stack"; if (detail.instancedDefinitionState == item_details::InstancedDefinitionState::stackable) { for (std::size_t prior = 0; prior < index; ++prior) { if (mutation.rewards[prior].definitionHash == item.definitionHash) { @@ -264,10 +591,16 @@ bool prepare_record_reward_grant(std::span rewards, } } - PreparedRecordReward prepared{}; - prepared.definitionHash = item.definitionHash; - prepared.quantity = requested.quantity; + reason = "socket_owner"; + if (!requested.sockets.empty() + && (bucket.arraySelector != inventory_buckets::ArraySelector::character + || detail.instancedDefinitionState + != item_details::InstancedDefinitionState::instanced)) { + return false; + } + reason = "inventory_destination"; if (bucket.arraySelector == inventory_buckets::ArraySelector::profile) { + reason = "profile_capacity"; if (detail.instancedDefinitionState != item_details::InstancedDefinitionState::stackable) { return false; @@ -296,14 +629,23 @@ bool prepare_record_reward_grant(std::span rewards, } else if (bucket.arraySelector == inventory_buckets::ArraySelector::character && detail.instancedDefinitionState == item_details::InstancedDefinitionState::instanced) { - if (requested.quantity != 1 || !detail.equipmentSlot.has_value()) { + reason = "instance_quantity"; + if (requested.quantity != 1) { return false; } + reason = "instance_capacity"; PendingItemAcquisition staged{}; if (!finalize_item_acquisition( working, working, item.definitionHash, false, {.direct = true}, staged)) { return false; } + if (!apply_reward_sockets( + detail, + requested.sockets, + staged.afterCharacter.inventory.values[staged.inventoryIndex].sockets, + &reason)) { + return false; + } working.characters[characterIndex] = staged.afterCharacter; prepared.instanceSoid = staged.acquiredInstanceSoid; prepared.stateIndex = staged.inventoryIndex; @@ -316,6 +658,7 @@ bool prepare_record_reward_grant(std::span rewards, && detail.instancedDefinitionState == item_details::InstancedDefinitionState::stackable && !detail.equipmentSlot.has_value()) { + reason = "character_stack_capacity"; CharacterState& character = working.characters[characterIndex]; if (requested.quantity > detail.maxStackSize || character.nextInventorySerial @@ -353,6 +696,7 @@ bool prepare_record_reward_grant(std::span rewards, mutation.rewards[index] = prepared; } + reason = "loadout"; family4_loadout::ResolvedLoadout loadout{}; if (!account::valid(working) || !valid_profile_inventory(working) || !family4_loadout::resolve(working, characterIndex, loadout)) { @@ -369,6 +713,7 @@ bool prepare_record_reward_grant(std::span rewards, mutation.beforeProfileItemCount = account.profileItemCount; mutation.afterProfileItemCount = working.profileItemCount; mutation.rewardCount = rewards.size(); + reason = nullptr; mutation.prepared = true; return true; } @@ -381,14 +726,28 @@ bool preview_record_reward_grant(const PendingRecordRewardGrant& mutation, /** Commits the shared reward after-image and claim together. */ bool commit_record_reward(PendingRecordRewardGrant& mutation) noexcept { + if (!mutation.prepared) { + return false; + } const PendingConsumption consume{mutation}; - investment::store::g_mutex.lock(); - AccountState after{}; - bool ready = materialize_record_reward(investment::store::account(), mutation, after); - if (ready) { - ready = investment::store::write_account(after); + bool ready = false; + { + investment::store::Transaction transaction; + AccountState after{}; + unlocks::Table flags{}; + ready = transaction.ready() + && materialize_record_reward(investment::store::account(), mutation, after) + && preview_reward_unlocks(mutation, flags) + && investment::store::write_account(after); + for (std::size_t i = 0; ready && i < mutation.rewardCount; ++i) { + const auto flag = mutation.rewards[i].acquiredFlag; + if (flag != build_data::rewards::kAbsent) { + ready = investment::store::write_unlock( + investment::store::Bank::accountFlags, flag, unlocks::kFlagSet); + } + } + ready = ready && transaction.commit(); } - investment::store::g_mutex.unlock(); if (!ready && mutation.claimedRecordIndex != kUnclaimedRecordIndex) { // The claim was written when the reward was prepared, so a refused install undoes it. unlocks::records::revoke(mutation.claimedRecordIndex); diff --git a/Sunrise/src/state/runtime/state_account_transaction_helpers.h b/Sunrise/src/state/runtime/state_account_transaction_helpers.h index 5b77c5039..a31d2be01 100644 --- a/Sunrise/src/state/runtime/state_account_transaction_helpers.h +++ b/Sunrise/src/state/runtime/state_account_transaction_helpers.h @@ -166,13 +166,6 @@ finalize_profile_item_acquisition(const AccountState& account, [[nodiscard]] bool materialize_item_acquisition(const AccountState& current, const PendingItemAcquisition& mutation, AccountState& after) noexcept; -/** - * Rebuilds one package from installed policy and rejects any altered after-image. - * @return False when the account moved or the rebuilt character differs from the mutation. - */ -[[nodiscard]] bool materialize_direct_item_bundle(const AccountState& current, - const PendingDirectItemBundle& mutation, - AccountState& after) noexcept; [[nodiscard]] bool native_equipment_slot(const account::inventory::Item& item, std::uint8_t& slot) noexcept; [[nodiscard]] bool semantic_equipment_slot(std::uint8_t nativeSlot, diff --git a/Sunrise/src/state/runtime/state_progression_runtime.cpp b/Sunrise/src/state/runtime/state_progression_runtime.cpp index ffe609608..9047e4b93 100644 --- a/Sunrise/src/state/runtime/state_progression_runtime.cpp +++ b/Sunrise/src/state/runtime/state_progression_runtime.cpp @@ -8,14 +8,20 @@ #include #include +#include "../build_data/rewards/reward_catalog.h" #include "../build_data/runtime.h" +#include "../build_data/season_pass/season_pass_catalog.h" #include "../investment/investment.h" #include "../investment/store_internal.h" #include "../progression/season_pass_reward_catalog.h" +#include "../rewards/reward_resolver.h" #include "../unlocks/definition.h" #include "../unlocks/unlocks_runtime.h" +#include "core/logging/log.h" +#include "middleware/content/packages/tables/definition_index_table.h" #include "runtime.h" #include "state.h" +#include "state_account_transaction_helpers.h" #include "storage/internal.h" namespace sunrise::state { @@ -244,6 +250,79 @@ bool publish_artifact_character_banks(CharacterArtifactWrite& write) noexcept { return publish_artifact_character_banks(write); } +void report_perk_refusal(std::size_t index, const char* reason) noexcept { + core::log::writef(core::log::Channel::server, + core::log::Level::warn, + "ev=season_xp stage=automatic_perk index=%zu result=skip reason=%s", + index, + reason); +} + +/** Earned perks set both flags in the transaction that publishes their qualifying rank. */ +bool grant_progress_flags() noexcept { + if (!build_data::season_pass_ready() || !build_data::rewards::ready() + || !build_data::item_definitions_ready()) { + return true; + } + AccountState account; + if (!investment::store::read_account(account)) { + return false; + } + const auto character = runtime::detail::selected_character_index(account); + if (character >= account.characterCount) { + return true; + } + unlocks::Table flags{}; + if (!investment::store::read_unlocks(flags, static_cast(character))) { + return false; + } + const auto rank = seasonal_rank(); + const auto count = build_data::season_pass::count(); + for (std::size_t index = 0; index < count; ++index) { + build_data::season_pass::Reward reward{}; + if (!build_data::find_season_pass_reward(static_cast(index), reward)) { + continue; + } + if (reward.requiredRank > rank) { + continue; + } + const auto acquired = pass::progress_flag(reward); + if (acquired == build_data::rewards::kAbsent) { + continue; + } + const auto claim = reward.claimFlagIndex; + if (acquired >= flags.accountFlags.size() || reward.quantity != 1 || reward.socketCount != 0 + || reward.conditionCount > reward.condition.size() + || (claim != build_data::season_pass::kUnavailableFlagIndex + && claim >= flags.accountFlags.size())) { + report_perk_refusal(index, "perk_shape"); + continue; + } + const char* reason = nullptr; + bool eligible = false; + if (!rewards::eligible(std::span(reward.condition).first(reward.conditionCount), + {flags, account.characters[character].characterClass, 0, &reason}, + eligible)) { + report_perk_refusal(index, reason != nullptr ? reason : "perk_condition"); + continue; + } + if (!eligible) { + continue; + } + for (const auto flag : {acquired, claim}) { + if (flag == build_data::rewards::kAbsent + || flags.accountFlags[flag] == unlocks::kFlagSet) { + continue; + } + if (!unlocks::set_account_flag(flag, unlocks::kFlagSet)) { + return false; + } + flags.accountFlags[flag] = unlocks::kFlagSet; + } + } + return true; +} + /** Publishes the four seasonal progression lanes the current XP total implies. */ bool publish_experience_lanes(std::int32_t experience) noexcept { return unlocks::set_account_progression(kArtifactPowerProgressionIndex, experience) @@ -258,6 +337,23 @@ bool publish_experience_lanes(std::int32_t experience) noexcept { } // namespace +// Packages share the perk bucket; only rewards without a pool grant the flag directly. +std::uint16_t +progression::season_pass::progress_flag(const build_data::season_pass::Reward& reward) noexcept { + namespace rewards = build_data::rewards; + build_data::items::Definition item{}; + rewards::Item acquisition{}; + if (build_data::find_item_definition_index(reward.itemIndex, item) + && item.definitionHash == reward.itemHash + && item.bucketId == middleware::content::packages::tables::kNonInventoryBucketId + && rewards::find_item(reward.itemIndex, acquisition) + && acquisition.definitionHash == reward.itemHash + && acquisition.poolIndex == rewards::kAbsent) { + return acquisition.acquiredFlag; + } + return rewards::kAbsent; +} + /** @return Seasonal XP published in the account progression bank. */ std::int32_t seasonal_experience() noexcept { return unlocks::account_progression(kArtifactPowerProgressionIndex); @@ -303,7 +399,8 @@ bool seed_seasonal_progression() noexcept { const std::uint32_t mask = artifact_mask(rows, count); const bool published = publish_experience_lanes(experience) && publish_artifact_locked(family, mask, experience) - && investment::store::write_family5(family) && transaction.commit(); + && investment::store::write_family5(family) && grant_progress_flags() + && transaction.commit(); investment::store::g_mutex.unlock(); return published; } @@ -331,7 +428,7 @@ std::uint16_t artifact_power_bonus() noexcept { return bonus > 0 ? static_cast(bonus) : 0U; } -/** Adds base XP to the seasonal lanes and republishes every value derived from the total. */ +/** Publishes seasonal XP, artifact progress and newly earned acquisition flags atomically. */ bool grant_seasonal_experience(std::int32_t amount) noexcept { if (amount <= 0) { return false; @@ -341,22 +438,21 @@ bool grant_seasonal_experience(std::int32_t amount) noexcept { return false; } const std::int32_t previous = seasonal_experience(); - if (previous > (std::numeric_limits::max)() - amount) { + if (previous < 0 || amount > (std::numeric_limits::max)() - previous) { return false; } - const std::int32_t total = previous + amount; + const auto total = previous + amount; Family5State family; - if (!transaction.ready() || !investment::store::read_family5(family)) { + if (!investment::store::read_family5(family)) { return false; } SaleRows rows{}; std::size_t count = 0; const std::uint32_t mask = sale_rows(rows, count) ? artifact_mask(rows, count) : 0U; - const bool saved = publish_experience_lanes(total) - && publish_artifact_locked(family, mask, total) - && investment::store::write_family5(family) && transaction.commit(); - return saved; + return publish_experience_lanes(total) && publish_artifact_locked(family, mask, total) + && investment::store::write_family5(family) && grant_progress_flags() + && transaction.commit(); } /** @return True when this Season pass reward row is already claimed. */ From dc882feb9dc9924e30f1399cd159fa8bd7d72f67 Mon Sep 17 00:00:00 2001 From: Zach Date: Wed, 23 Sep 2026 14:36:27 -0500 Subject: [PATCH 02/12] Tighten reward definition ownership and validation --- Sunrise/Sunrise.vcxproj | 1 + Sunrise/Sunrise.vcxproj.filters | 12 + .../items/packages/package_reward_build.cpp | 248 ++++++++++-------- .../items/packages/package_reward_build.h | 14 +- .../packages/package_season_pass_build.cpp | 3 +- .../packages/tables/definition_index_table.h | 37 +-- .../tables/quest_initialization_reader.cpp | 3 +- .../content/packages/tables/unlock_opcode.h | 39 +++ .../build_data/inventory/buckets/definition.h | 2 + .../build_data/rewards/reward_catalog.cpp | 32 ++- Sunrise/src/state/runtime/runtime.h | 4 + .../state_account_reward_grant_runtime.cpp | 36 ++- .../runtime/state_progression_runtime.cpp | 3 +- 13 files changed, 257 insertions(+), 177 deletions(-) create mode 100644 Sunrise/src/middleware/content/packages/tables/unlock_opcode.h diff --git a/Sunrise/Sunrise.vcxproj b/Sunrise/Sunrise.vcxproj index 1a9537ecd..96aa73878 100644 --- a/Sunrise/Sunrise.vcxproj +++ b/Sunrise/Sunrise.vcxproj @@ -2149,6 +2149,7 @@ + diff --git a/Sunrise/Sunrise.vcxproj.filters b/Sunrise/Sunrise.vcxproj.filters index 7953e0a7c..9c359b893 100644 --- a/Sunrise/Sunrise.vcxproj.filters +++ b/Sunrise/Sunrise.vcxproj.filters @@ -67,4 +67,16 @@ Vendor\Lua Vendor\Lua + + + + + + + + + + + + diff --git a/Sunrise/src/client/content/items/packages/package_reward_build.cpp b/Sunrise/src/client/content/items/packages/package_reward_build.cpp index f943983c6..b88f20e4d 100644 --- a/Sunrise/src/client/content/items/packages/package_reward_build.cpp +++ b/Sunrise/src/client/content/items/packages/package_reward_build.cpp @@ -32,31 +32,56 @@ 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, kFlagRowClass = 0x80807D4FU; -constexpr std::uint32_t kValueTableClass = 0x80807C92U, kValueRowClass = 0x80807C96U; -constexpr std::uint32_t kExpressionTableClass = 0x80807C49U, kExpressionRowClass = 0x80807C4FU; +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, kEntryStride = 80, kModifierStride = 24; -constexpr std::size_t kBindingStride = 8, kExpressionRowStride = 24; -constexpr std::size_t kSocketStride = 12, kSelectionStride = 12; +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; /** Byte offsets inside the serialized reward rows. */ -constexpr std::size_t kEntryQuantityOffset = 4, kEntryPoolOffset = 8; -constexpr std::size_t kEntryCategoryOffset = 20, kEntryWeightOffset = 24; -constexpr std::size_t kEntryBucketOffset = 28, kEntryConditionOffset = 32; -constexpr std::size_t kEntryModifiersOffset = 48, kEntrySocketsOffset = 64; -constexpr std::size_t kModifierValueIndexOffset = 16, kModifierValueOffset = 20; -constexpr std::size_t kPoolEntriesOffset = 8, kExpressionBodyOffset = 8; -constexpr std::size_t kWrapperSelectionsOffset = 8, kWrapperFlagsOffset = 24; +constexpr std::size_t kEntryQuantityOffset = 4; +constexpr std::size_t kEntryPoolOffset = 8; +constexpr std::size_t kEntryCategoryOffset = 20; +constexpr std::size_t kEntryWeightOffset = 24; +constexpr std::size_t kEntryBucketOffset = 28; +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, kSocketPlugSetOffset = 4; -constexpr std::size_t kSocketRollSetOffset = 6, kSocketSelectionOffset = 8; +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; + template bool append(std::vector& bank, T value, std::size_t capacity) noexcept { if (bank.size() >= capacity) { return false; @@ -94,12 +119,42 @@ bool read_reward_entry(std::span blob, && 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::int64_t relative = 0; + if (!tables::read(blob, kUseConditionPointer, relative) || relative <= 0 + || static_cast(relative) > blob.size() + || kUseConditionPointer > blob.size() - static_cast(relative)) { + return false; + } + const auto at = kUseConditionPointer + static_cast(relative); + std::uint32_t cls = 0; + tables::Array expression{}; + if (!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 != static_cast(tables::UnlockOpcode::flag) + || !tables::read( + blob, expression.dataOffset + tables::kUnlockInstructionOperandOffset, flag) + || flag >= domain::kAbsent) { + return false; + } + output = static_cast(flag); + return true; +} + } // namespace bool RewardBuild::entry(std::span blob, std::size_t at) noexcept { domain::Entry out{}; if (!read_reward_entry(blob, at, out) - || !conditions.read(blob, at + kEntryConditionOffset, instructions, out.condition)) { + || !conditions.read(blob, at + kEntryConditionOffset, instructions_, out.condition)) { return false; } tables::Array rows{}; @@ -107,46 +162,44 @@ bool RewardBuild::entry(std::span blob, std::size_t at) noexcep blob, at + kEntryModifiersOffset, kModifierClass, kModifierStride, rows)) { return false; } - out.modifiers = {static_cast(modifiers.size()), + 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) + if (!conditions.read(blob, offset, instructions_, modifier.condition) || !tables::read(blob, offset + kModifierValueIndexOffset, modifier.valueIndex) || !tables::read(blob, offset + kModifierValueOffset, modifier.value) || !std::isfinite(modifier.value) - || !append(modifiers, modifier, domain::kModifierCapacity)) { + || !append(modifiers_, modifier, domain::kModifierCapacity)) { return false; } } std::array overrides{}; std::size_t count = 0; if (!read_reward_sockets(blob, at + kEntrySocketsOffset, overrides, count) - || count > domain::kSocketOverrideCapacity - sockets.size()) { + || count > domain::kSocketOverrideCapacity - sockets_.size()) { return false; } - out.sockets = {static_cast(sockets.size()), static_cast(count)}; + out.sockets = {static_cast(sockets_.size()), static_cast(count)}; for (std::size_t i = 0; i < count; ++i) { if (overrides[i].socketType == domain::kAbsent - || !append(sockets, overrides[i], domain::kSocketOverrideCapacity)) { + || !append(sockets_, overrides[i], domain::kSocketOverrideCapacity)) { return false; } } - return append(entries, out, domain::kEntryCapacity); + return append(entries_, out, domain::kEntryCapacity); } void RewardConditions::load_class_flags(const reader::Source& source, reader::Scratch& scratch, std::span root) noexcept { classFlags_.fill(domain::kAbsent); - // Class rows name a default finisher whose use gate supplies the computed class flag. - constexpr std::size_t kClassSlot = 12, kClassStride = 32, kDefaultFinisherOffset = 24; - constexpr std::uint32_t kClassTable = 0x808075BEU, kClassRow = 0x808074FAU; - constexpr std::size_t kUseConditionPointer = 24; - constexpr std::uint32_t kUseConditionClass = 0x80802980U; - std::vector classes, index, blob; - tables::Array classRows{}, itemRows{}; + std::vector classes; + std::vector index; + std::vector blob; + tables::Array classRows{}; + tables::Array itemRows{}; if (!root_table(source, scratch, root, kClassSlot, classes, kClassTable) || !tables::read_array( classes, tables::kTableArrayDescriptor, kClassRow, kClassStride, classRows) @@ -165,18 +218,13 @@ void RewardConditions::load_class_flags(const reader::Source& source, for (std::size_t i = 0; i < classFlags_.size(); ++i) { std::uint16_t itemIndex = domain::kAbsent; tables::IndexRow item{}; - tables::Array condition{}; - std::uint32_t cls = 0, opcode = 0, flag = domain::kAbsent; - std::int64_t relative = 0; + std::uint32_t cls = 0; if (!tables::read(std::span{classes}, classRows.dataOffset + i * kClassStride + kDefaultFinisherOffset, itemIndex) || !tables::index_row(index, itemRows, itemIndex, item) || !reader::read_tag(source, scratch, item.targetTag, blob, cls) - || cls != tables::kItemDefinitionClass - || !tables::read(std::span{blob}, kUseConditionPointer, relative) - || relative <= 0 || static_cast(relative) > blob.size() - || kUseConditionPointer > blob.size() - static_cast(relative)) { + || cls != tables::kItemDefinitionClass) { core::log::writef( core::log::Channel::client, core::log::Level::warn, @@ -184,20 +232,7 @@ void RewardConditions::load_class_flags(const reader::Source& source, i); continue; } - const auto at = kUseConditionPointer + static_cast(relative); - if (tables::read(std::span{blob}, at - kDefinitionClassPrefixSize, cls) - && cls == kUseConditionClass - && tables::read_array( - blob, at, kExpressionClass, tables::kUnlockInstructionStride, condition) - && condition.count == 1 - && tables::read(std::span{blob}, condition.dataOffset, opcode) - && opcode == static_cast(tables::UnlockOpcode::flag) - && tables::read(std::span{blob}, - condition.dataOffset + tables::kUnlockInstructionOperandOffset, - flag) - && flag < domain::kAbsent) { - classFlags_[i] = static_cast(flag); - } else { + if (!read_class_flag(blob, classFlags_[i])) { core::log::writef(core::log::Channel::client, core::log::Level::warn, "ev=pkg stage=class_flags class=%zu result=skip reason=use_condition", @@ -229,10 +264,9 @@ bool RewardConditions::load(const reader::Source& source, } bool RewardConditions::bind(domain::Instruction& instruction) const noexcept { - const auto opcode = - static_cast(instruction.opcode); - const bool flag = opcode == middleware::content::packages::tables::UnlockOpcode::flag; - if (!flag && opcode != middleware::content::packages::tables::UnlockOpcode::loadValue) { + const auto opcode = static_cast(instruction.opcode); + const bool flag = opcode == tables::UnlockOpcode::flag; + if (!flag && opcode != tables::UnlockOpcode::loadValue) { return true; } const auto& rows = flag ? flagRows_ : valueRows_; @@ -245,13 +279,13 @@ bool RewardConditions::bind(domain::Instruction& instruction) const noexcept { if (!tables::read(blob, at, hash)) { return false; } - using B = domain::BankRead; + using Read = domain::BankRead; if (flag) { for (std::size_t characterClass = 0; characterClass < classFlags_.size(); ++characterClass) { if (classFlags_[characterClass] != domain::kAbsent && instruction.operand == classFlags_[characterClass]) { - instruction = {static_cast(B::characterClass), + instruction = {static_cast(Read::characterClass), static_cast(characterClass)}; return true; } @@ -263,19 +297,21 @@ bool RewardConditions::bind(domain::Instruction& instruction) const noexcept { const auto characterIndex = bank_index(character, static_cast(instruction.operand)); if (accountIndex != kUnmappedSlot) { - instruction = {static_cast(flag ? B::accountFlag : B::accountValue), + instruction = {static_cast(flag ? Read::accountFlag : Read::accountValue), accountIndex}; } else if (flag && bank_index(maps_->profileFlag, static_cast(instruction.operand)) != kUnmappedSlot) { instruction = { - static_cast(B::profileFlag), + static_cast(Read::profileFlag), bank_index(maps_->profileFlag, static_cast(instruction.operand))}; } else if (characterIndex != kUnmappedSlot) { - instruction = {static_cast(flag ? B::characterFlag : B::characterValue), - characterIndex}; + instruction = { + static_cast(flag ? Read::characterFlag : Read::characterValue), + characterIndex}; } else { - instruction = {static_cast(flag ? B::externalFlag : B::externalValue), hash}; + instruction = {static_cast(flag ? Read::externalFlag : Read::externalValue), + hash}; } return true; } @@ -300,8 +336,8 @@ bool RewardConditions::append_expression(std::span blob, instruction.operand)) { return false; } - if (static_cast(instruction.opcode) - == middleware::content::packages::tables::UnlockOpcode::expression) { + if (static_cast(instruction.opcode) + == tables::UnlockOpcode::expression) { const auto before = bank.size(); if (instruction.operand >= expressionRows_.count || !append_expression(expressions_, @@ -357,9 +393,7 @@ bool RewardConditions::read_list(std::span blob, if (i != 0 && !append(instructions, domain::Instruction{ - static_cast( - middleware::content::packages::tables::UnlockOpcode::logicalAnd), - 0}, + static_cast(tables::UnlockOpcode::logicalAnd), 0}, output.size())) { return false; } @@ -402,11 +436,11 @@ bool RewardBuild::load(const reader::Source& source, std::span root, const SlotMaps& maps) noexcept { loaded_ = false; - pools.clear(); - entries.clear(); - instructions.clear(); - modifiers.clear(); - sockets.clear(); + pools_.clear(); + entries_.clear(); + instructions_.clear(); + modifiers_.clear(); + sockets_.clear(); std::vector blob; tables::Array rows{}; if (!conditions.load(source, scratch, root, maps) @@ -424,32 +458,33 @@ bool RewardBuild::load(const reader::Source& source, 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()); + pool.entries.first = static_cast(entries_.size()); for (std::size_t j = 0; j < members.count; ++j) { - const auto beforeInstructions = instructions.size(), - beforeModifiers = modifiers.size(), beforeSockets = sockets.size(); + const auto beforeInstructions = instructions_.size(); + const auto beforeModifiers = modifiers_.size(); + const auto beforeSockets = sockets_.size(); if (!entry(blob, members.dataOffset + j * kEntryStride)) { - instructions.resize(beforeInstructions); - modifiers.resize(beforeModifiers); - sockets.resize(beforeSockets); + instructions_.resize(beforeInstructions); + modifiers_.resize(beforeModifiers); + sockets_.resize(beforeSockets); ++skipped; } } - pool.entries.count = static_cast(entries.size()) - pool.entries.first; + pool.entries.count = static_cast(entries_.size()) - pool.entries.first; } else { pool = {}; ++skipped; } - if (!append(pools, pool, domain::kPoolCapacity)) { + if (!append(pools_, pool, domain::kPoolCapacity)) { return false; } } - if (skipped != 0) { - core::log::writef(core::log::Channel::client, - core::log::Level::warn, - "ev=pkg stage=rewards skipped=%zu", - skipped); - } + 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 skipped=%zu", + pools_.size(), + entries_.size(), + skipped); loaded_ = true; return true; } @@ -466,9 +501,7 @@ bool RewardBuild::read_item(std::uint32_t hash, } item.definitionHash = hash; if (acquired != domain::kAbsent) { - domain::Instruction flag{ - static_cast(middleware::content::packages::tables::UnlockOpcode::flag), - acquired}; + domain::Instruction flag{static_cast(tables::UnlockOpcode::flag), acquired}; if (!conditions.bind(flag) || !domain::valid_instruction(flag)) { return false; } @@ -509,7 +542,7 @@ bool RewardBuild::begin_items(std::size_t count) noexcept { if (!loaded_ || count == 0 || count > domain::kItemCapacity) { return false; } - items.assign(count, {}); + items_.assign(count, {}); return true; } catch (...) { return false; @@ -520,8 +553,8 @@ 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; + if (index < items_.size() && read_item(hash, blob, parsed)) { + items_[index] = parsed; } } @@ -529,39 +562,40 @@ bool RewardBuild::publish() noexcept { if (!loaded_) { return false; } - std::size_t write = 0, socketWrite = 0; - for (auto& pool : pools) { + 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 || row.itemIndex < items.size()) - && (row.poolIndex == domain::kAbsent || row.poolIndex < pools.size()); + const auto& row = entries_[i]; + bool valid = (row.itemIndex == domain::kAbsent || row.itemIndex < items_.size()) + && (row.poolIndex == domain::kAbsent || row.poolIndex < pools_.size()); for (const auto& socket : - std::span(sockets).subspan(row.sockets.first, row.sockets.count)) { - valid &= socket.plugItem == domain::kAbsent || socket.plugItem < items.size(); + std::span(sockets_).subspan(row.sockets.first, row.sockets.count)) { + valid &= socket.plugItem == domain::kAbsent || socket.plugItem < items_.size(); } 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; + std::span(sockets_).subspan(row.sockets.first, row.sockets.count)) { + sockets_[socketWrite++] = socket; } - entries[write++] = retained; + entries_[write++] = retained; } } pool.entries.count = static_cast(write) - pool.entries.first; } - entries.resize(write); - sockets.resize(socketWrite); - for (auto& item : items) { - if (item.poolIndex != domain::kAbsent && item.poolIndex >= pools.size()) { + entries_.resize(write); + sockets_.resize(socketWrite); + for (auto& item : items_) { + if (item.poolIndex != domain::kAbsent && item.poolIndex >= pools_.size()) { item = {}; } } return state::build_data::publish_reward_definitions( - {pools, entries, items, instructions, modifiers, sockets}); + {pools_, entries_, items_, instructions_, modifiers_, sockets_}); } } // 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 index 5ac927368..014877e79 100644 --- a/Sunrise/src/client/content/items/packages/package_reward_build.h +++ b/Sunrise/src/client/content/items/packages/package_reward_build.h @@ -2,8 +2,8 @@ #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" -#include "state/account/account_state.h" namespace sunrise::client::content::items::packages { @@ -62,12 +62,12 @@ class RewardBuild { std::span blob, state::build_data::rewards::Item& item) noexcept; bool loaded_{}; - std::vector pools; - std::vector entries; - std::vector items; - std::vector instructions; - std::vector modifiers; - std::vector sockets; + 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. */ 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 0d48675b0..1c0128508 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 @@ -29,7 +29,8 @@ bool read_season_reward(Storage& storage, progressionTable, at + tables::kProgressionRewardQuantityOffset, reward.quantity) || !tables::read( progressionTable, at + tables::kProgressionRewardClaimSlotOffset, claimSlot) - || reward.quantity == 0 || reward.quantity > INT32_MAX + || reward.quantity == 0 + || reward.quantity > static_cast((std::numeric_limits::max)()) || rank > (std::numeric_limits::max)() || itemIndex > (std::numeric_limits::max)() || claimSlot > (std::numeric_limits::max)() 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 b581f8b86..fb78789a6 100644 --- a/Sunrise/src/middleware/content/packages/tables/definition_index_table.h +++ b/Sunrise/src/middleware/content/packages/tables/definition_index_table.h @@ -4,6 +4,8 @@ #include #include +#include "unlock_opcode.h" + namespace sunrise::middleware::content::packages::tables { /** Definition tags sit in this closed range. */ @@ -28,9 +30,6 @@ inline constexpr std::uint16_t kAbsentPackageId = 0xFFFFU; return static_cast((tag - kTagLowerBound) >> kTagPackageShift); } -/** Hidden plugs, perks and quest roots use this bucket instead of held inventory. */ -inline constexpr std::uint8_t kNonInventoryBucketId = 37; - /** Element class of the item index table inside the investment container. */ inline constexpr std::uint32_t kItemIndexTableClass = 0x80807BE8U; /** Serialized item definition class, distinct from the item index table class. */ @@ -99,38 +98,6 @@ inline constexpr std::size_t kUnlockInstructionOperandOffset = 4; /** An expression field is a 64-bit count then a 64-bit self-relative offset. */ inline constexpr std::size_t kUnlockExpressionFieldSize = 16; inline constexpr std::size_t kUnlockExpressionPointerOffset = 8; -/** Native instruction values before unlock references are bound. */ -enum class UnlockOpcode : std::uint32_t { - flag = 1, - logicalNot = 2, - logicalOr = 3, - logicalAnd = 4, - logicalNor = 5, - notEqualAlternate = 6, - logicalNand = 7, - equal = 8, - notEqual = 9, - loadValue = 10, - constant = 11, - expression = 12, - greaterThan = 13, - greaterOrEqual = 14, - lessThan = 15, - lessOrEqual = 16, - add = 17, - subtract = 18, - multiply = 19, - divide = 20, - remainder = 21, - negate = 22, - hash = 23, - hashCombine = 24, - bitwiseAnd = 25, - bitwiseOr = 26, - bitwiseXor = 27, - bitwiseNot = 28, -}; - /** The opcode that reads a value slot. */ inline constexpr std::uint32_t kUnlockReadValueOpcode = static_cast(UnlockOpcode::loadValue); diff --git a/Sunrise/src/middleware/content/packages/tables/quest_initialization_reader.cpp b/Sunrise/src/middleware/content/packages/tables/quest_initialization_reader.cpp index 0181daa99..b8a282baa 100644 --- a/Sunrise/src/middleware/content/packages/tables/quest_initialization_reader.cpp +++ b/Sunrise/src/middleware/content/packages/tables/quest_initialization_reader.cpp @@ -2,6 +2,7 @@ #include +#include "../../../../state/build_data/inventory/buckets/definition.h" #include "definition_index_table.h" #include "internal.h" @@ -196,7 +197,7 @@ Quest read_quest_initialization(std::span definition, std::uint8_t parentBucket = 0; std::int64_t parentObjective = 0; if (parentIndex == itemIndex || !read(parent, kBucketIdOffset, parentBucket) - || parentBucket != kNonInventoryBucketId + || parentBucket != state::build_data::inventory::buckets::kNonInventoryBucketId || !read(parent, kItemObjectiveBlockOffset, parentObjective) || parentObjective != 0) { return {}; } diff --git a/Sunrise/src/middleware/content/packages/tables/unlock_opcode.h b/Sunrise/src/middleware/content/packages/tables/unlock_opcode.h new file mode 100644 index 000000000..6a8982996 --- /dev/null +++ b/Sunrise/src/middleware/content/packages/tables/unlock_opcode.h @@ -0,0 +1,39 @@ +#pragma once + +#include + +namespace sunrise::middleware::content::packages::tables { + +/** Native instruction values before unlock references are bound. */ +enum class UnlockOpcode : std::uint32_t { + flag = 1, + logicalNot = 2, + logicalOr = 3, + logicalAnd = 4, + logicalNor = 5, + notEqualAlternate = 6, + logicalNand = 7, + equal = 8, + notEqual = 9, + loadValue = 10, + constant = 11, + expression = 12, + greaterThan = 13, + greaterOrEqual = 14, + lessThan = 15, + lessOrEqual = 16, + add = 17, + subtract = 18, + multiply = 19, + divide = 20, + remainder = 21, + negate = 22, + hash = 23, + hashCombine = 24, + bitwiseAnd = 25, + bitwiseOr = 26, + bitwiseXor = 27, + bitwiseNot = 28, +}; + +} // namespace sunrise::middleware::content::packages::tables diff --git a/Sunrise/src/state/build_data/inventory/buckets/definition.h b/Sunrise/src/state/build_data/inventory/buckets/definition.h index 22637090b..0e56af918 100644 --- a/Sunrise/src/state/build_data/inventory/buckets/definition.h +++ b/Sunrise/src/state/build_data/inventory/buckets/definition.h @@ -25,6 +25,8 @@ inline constexpr std::uint32_t kProfileSlotCapacity = 701; inline constexpr std::uint32_t kSmallProfileSlotCapacity = 6; /** An all-one bucket id means no runtime bucket is available. */ inline constexpr std::uint8_t kUnavailableBucketId = (std::numeric_limits::max)(); +/** Hidden plugs, perks and quest roots use this bucket instead of held inventory. */ +inline constexpr std::uint8_t kNonInventoryBucketId = 37; /** Character bucket containing unopened engrams. */ inline constexpr std::uint8_t kEngramBucketId = 31; /** Leaving out the unavailable id leaves at most 255 unique bucket records. */ diff --git a/Sunrise/src/state/build_data/rewards/reward_catalog.cpp b/Sunrise/src/state/build_data/rewards/reward_catalog.cpp index 00c1126aa..2fc14d9b4 100644 --- a/Sunrise/src/state/build_data/rewards/reward_catalog.cpp +++ b/Sunrise/src/state/build_data/rewards/reward_catalog.cpp @@ -10,7 +10,7 @@ #include "../../unlocks/definition.h" #include "../table.h" #include "core/threading/srw_lock.h" -#include "middleware/content/packages/tables/definition_index_table.h" +#include "middleware/content/packages/tables/unlock_opcode.h" namespace sunrise::state::build_data::rewards { namespace { @@ -60,6 +60,23 @@ bool valid_depth(View data, return true; } +bool valid_entry(View data, const Entry& entry) noexcept { + return (entry.itemIndex == kAbsent || entry.itemIndex < data.items.size()) + && (entry.poolIndex == kAbsent || entry.poolIndex < data.pools.size()) + && fits(entry.condition, data.instructions) && fits(entry.modifiers, data.modifiers) + && fits(entry.sockets, data.sockets) && std::isfinite(entry.weight) && entry.weight >= 0; +} + +bool valid_item(View data, const Item& item) noexcept { + if (item.definitionHash == 0) { + return item.poolIndex == kAbsent && item.acquiredFlag == kAbsent + && item.selectionCount == 0; + } + return item.selectionCount <= item.selections.size() + && (item.acquiredFlag == kAbsent || item.acquiredFlag < unlocks::kAccountFlagCapacity) + && (item.poolIndex == kAbsent || item.poolIndex < data.pools.size()); +} + } // namespace void clear() noexcept { @@ -132,21 +149,12 @@ bool valid(View data) noexcept { } } for (const Entry& entry : data.entries) { - if ((entry.itemIndex != kAbsent && entry.itemIndex >= data.items.size()) - || (entry.poolIndex != kAbsent && entry.poolIndex >= data.pools.size()) - || !fits(entry.condition, data.instructions) || !fits(entry.modifiers, data.modifiers) - || !fits(entry.sockets, data.sockets) || !std::isfinite(entry.weight) - || entry.weight < 0) { + if (!valid_entry(data, entry)) { return false; } } for (const Item& item : data.items) { - if ((item.definitionHash == 0 - && (item.poolIndex != kAbsent || item.acquiredFlag != kAbsent - || item.selectionCount != 0)) - || item.selectionCount > item.selections.size() - || (item.acquiredFlag != kAbsent && item.acquiredFlag >= unlocks::kAccountFlagCapacity) - || (item.poolIndex != kAbsent && item.poolIndex >= data.pools.size())) { + if (!valid_item(data, item)) { return false; } } diff --git a/Sunrise/src/state/runtime/runtime.h b/Sunrise/src/state/runtime/runtime.h index 9971d568b..25db08302 100644 --- a/Sunrise/src/state/runtime/runtime.h +++ b/Sunrise/src/state/runtime/runtime.h @@ -13,6 +13,10 @@ #include "../unlocks/definition.h" #include "state.h" +namespace sunrise::state::unlocks { +struct Table; +} + namespace sunrise::state::account::settings { struct SettingsDelta; diff --git a/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp b/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp index 8b670c0e9..aaddbb28a 100644 --- a/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp +++ b/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp @@ -16,7 +16,6 @@ #include "../rewards/reward_resolver.h" #include "../unlocks/unlocks_records.h" #include "../unlocks/unlocks_runtime.h" -#include "middleware/content/packages/tables/definition_index_table.h" #include "runtime.h" #include "state_account_transaction_helpers.h" #include "storage/internal.h" @@ -29,6 +28,8 @@ namespace item_details = build_data::items::details; namespace inventory_buckets = build_data::inventory::buckets; namespace family4_loadout = middleware::datagen::family4::loadout; +static_assert(build_data::rewards::kSocketsPerItem == item_details::kInitialPlugCapacity); + namespace { [[nodiscard]] bool materialize_record_reward(const AccountState& current, @@ -128,11 +129,24 @@ apply_reward_sockets(const item_details::Definition& detail, std::span(rows).first(resolved.count), kUnclaimedRecordIndex, mutation, refusal); } +/** Wrappers and direct account perks bypass quest initialization. */ +bool uses_reward_definition(const build_data::items::Definition& item) noexcept { + build_data::rewards::Item reward{}; + if (!build_data::rewards::find_item(item.definitionIndex, reward)) { + return false; + } + return reward.poolIndex != build_data::rewards::kAbsent + || (item.bucketId == inventory_buckets::kNonInventoryBucketId + && reward.acquiredFlag != build_data::rewards::kAbsent); +} + +enum class PassResolution { claim, replay }; + [[nodiscard]] bool resolve_pass(const build_data::season_pass::Reward& reward, const AccountState& account, std::uint64_t seed, rewards::Result& result, - bool replay = false, + PassResolution resolution, const char** reason = nullptr) noexcept { const auto character = selected_character_index(account); unlocks::Table flags{}; @@ -147,7 +161,7 @@ apply_reward_sockets(const item_details::Definition& detail, if (reason != nullptr) { *reason = "reward_condition"; } - if (replay && reward.claimFlagIndex < flags.accountFlags.size()) { + if (resolution == PassResolution::replay && reward.claimFlagIndex < flags.accountFlags.size()) { flags.accountFlags[reward.claimFlagIndex] = unlocks::kFlagClear; } bool enabled = false; @@ -185,7 +199,8 @@ apply_reward_sockets(const item_details::Definition& detail, const auto* grant = &mutation.grant; rewards::Result expected{}; if (!mutation.prepared || mutation.sourceDefinitionHash != reward.itemHash - || !resolve_pass(reward, account_snapshot(), mutation.seed, expected, true) + || !resolve_pass( + reward, account_snapshot(), mutation.seed, expected, PassResolution::replay) || expected.count != grant->rewardCount) { return false; } @@ -252,7 +267,8 @@ bool prepare_season_pass_reward(std::uint16_t rewardIndex, std::memcpy(&mutation.seed, random.data(), random.size()); rewards::Result resolved{}; reason = "reward_condition"; - if (!resolve_pass(reward, account_snapshot(), mutation.seed, resolved, false, &reason)) { + if (!resolve_pass( + reward, account_snapshot(), mutation.seed, resolved, PassResolution::claim, &reason)) { return false; } reason = "reward_placement"; @@ -268,14 +284,10 @@ bool prepare_season_pass_reward(std::uint16_t rewardIndex, ItemGrantRoute item_grant_route(std::uint16_t itemIndex) noexcept { build_data::items::Definition item{}; - build_data::rewards::Item reward{}; if (!build_data::find_item_definition_index(itemIndex, item)) { return ItemGrantRoute::unavailable; } - if (build_data::rewards::find_item(itemIndex, reward) - && (reward.poolIndex != build_data::rewards::kAbsent - || (item.bucketId == middleware::content::packages::tables::kNonInventoryBucketId - && reward.acquiredFlag != build_data::rewards::kAbsent))) { + if (uses_reward_definition(item)) { return ItemGrantRoute::reward; } if (item.questInitialization.scope != build_data::items::QuestInitialization::Scope::none) { @@ -433,7 +445,7 @@ namespace { if (reward.kind == RecordRewardKind::accountUnlock) { build_data::rewards::Item source{}; if (reward.quantity != 1 || reward.afterQuantity != 1 || reward.instanceSoid != 0 - || item.bucketId != middleware::content::packages::tables::kNonInventoryBucketId + || item.bucketId != build_data::inventory::buckets::kNonInventoryBucketId || !build_data::rewards::find_item(item.definitionIndex, source) || source.acquiredFlag == build_data::rewards::kAbsent || source.acquiredFlag != reward.acquiredFlag) { @@ -564,7 +576,7 @@ bool prepare_record_reward_grant(std::span rewards, prepared.previousFlag = static_cast(before); } } - if (item.bucketId == middleware::content::packages::tables::kNonInventoryBucketId) { + if (item.bucketId == build_data::inventory::buckets::kNonInventoryBucketId) { reason = "perk_acquisition"; if (prepared.acquiredFlag == build_data::rewards::kAbsent || requested.quantity != 1 || !requested.sockets.empty()) { diff --git a/Sunrise/src/state/runtime/state_progression_runtime.cpp b/Sunrise/src/state/runtime/state_progression_runtime.cpp index 9047e4b93..cdc9661a6 100644 --- a/Sunrise/src/state/runtime/state_progression_runtime.cpp +++ b/Sunrise/src/state/runtime/state_progression_runtime.cpp @@ -18,7 +18,6 @@ #include "../unlocks/definition.h" #include "../unlocks/unlocks_runtime.h" #include "core/logging/log.h" -#include "middleware/content/packages/tables/definition_index_table.h" #include "runtime.h" #include "state.h" #include "state_account_transaction_helpers.h" @@ -345,7 +344,7 @@ progression::season_pass::progress_flag(const build_data::season_pass::Reward& r rewards::Item acquisition{}; if (build_data::find_item_definition_index(reward.itemIndex, item) && item.definitionHash == reward.itemHash - && item.bucketId == middleware::content::packages::tables::kNonInventoryBucketId + && item.bucketId == build_data::inventory::buckets::kNonInventoryBucketId && rewards::find_item(reward.itemIndex, acquisition) && acquisition.definitionHash == reward.itemHash && acquisition.poolIndex == rewards::kAbsent) { From 03ea7b49d9b45a0ec79c23115922e4f9c9e80838 Mon Sep 17 00:00:00 2001 From: Zach Date: Wed, 23 Sep 2026 14:36:27 -0500 Subject: [PATCH 03/12] Preserve queued grants on presentation failure and correct pool selection --- .../src/server/bap/bap_world_reward_queue.cpp | 16 +- .../encrypted/queuez/queuez_deferred_push.cpp | 152 +++++++++--------- Sunrise/src/state/rewards/reward_resolver.cpp | 58 ++++--- 3 files changed, 110 insertions(+), 116 deletions(-) diff --git a/Sunrise/src/server/bap/bap_world_reward_queue.cpp b/Sunrise/src/server/bap/bap_world_reward_queue.cpp index 5b19d3a71..389477de5 100644 --- a/Sunrise/src/server/bap/bap_world_reward_queue.cpp +++ b/Sunrise/src/server/bap/bap_world_reward_queue.cpp @@ -18,10 +18,6 @@ bool commit_world_reward(const WorldRewardRequest& request) noexcept { } bool committed = false; if (request.kind == WorldRewardKind::item) { - state::build_data::items::Definition item{}; - if (!state::build_data::find_item_definition_index(request.itemDefinitionIndex, item)) { - return false; - } if (state::item_grant_route(request.itemDefinitionIndex) == state::ItemGrantRoute::quest) { state::PendingItemAcquisition acquisition; committed = request.quantity == 1 @@ -31,11 +27,19 @@ bool commit_world_reward(const WorldRewardRequest& request) noexcept { } else { const std::unique_ptr grant( new (std::nothrow) state::PendingRecordRewardGrant); + const char* reason = "reward_allocation"; committed = grant && request.quantity > 0 && state::prepare_item_reward(request.itemDefinitionIndex, static_cast(request.quantity), - *grant) - && state::commit_record_reward(*grant); + *grant, + &reason); + if (committed) { + reason = "inventory_commit"; + committed = state::commit_record_reward(*grant); + } + if (!committed) { + report_reward_refusal("world_settle", request.itemDefinitionIndex, reason); + } } } else { state::PendingProfileItemAcquisition acquisition; diff --git a/Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp b/Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp index e3d80d864..864518683 100644 --- a/Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp +++ b/Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp @@ -78,32 +78,34 @@ bool consume_world_record_reward(const WorldRewardRequest& request, return fail(reason); } queuez::RecordRewardGrant update{}; - if (!queuez::stage_record_reward_grant(session.queuez, *pending, update)) { - return fail("inventory_stage"); - } touchesScratch = true; std::size_t framedSize = 0; - if (!push::append_record_reward_notification(scratch, - session.queuez, - update, - *pending, - active_acquisition_presentation_rows(session), - session.sessionKey, - session.sendNonce, - scratch.framed, - framedSize) - || framedSize == 0) { - return fail("inventory_encode"); - } - if (framedSize > response.size()) { - return false; - } + const bool staged = queuez::stage_record_reward_grant(session.queuez, *pending, update); + const bool encoded = + staged + && push::append_record_reward_notification(scratch, + session.queuez, + update, + *pending, + active_acquisition_presentation_rows(session), + session.sessionKey, + session.sendNonce, + scratch.framed, + framedSize) + && framedSize != 0; if (!state::commit_record_reward(*pending)) { return fail("inventory_commit"); } if (!bap::complete_world_reward(request.id) || !transaction.commit()) { return fail("queue_commit"); } + // A failed presentation still commits the prepared draw and retires its queue row. + if (!encoded || framedSize > response.size()) { + bap::arm_account_resync_everywhere(); + return fail(!staged ? "inventory_stage" + : !encoded ? "inventory_encode" + : "response_capacity"); + } std::copy_n(scratch.framed.begin(), framedSize, response.begin()); written = framedSize; middleware::secure_channel::advance_nonce(session.sendNonce); @@ -120,10 +122,6 @@ bool consume_world_record_reward(const WorldRewardRequest& request, std::span response, std::size_t& written, bool& touchesScratch) noexcept { - state::build_data::items::Definition item{}; - if (!state::build_data::find_item_definition_index(request.itemDefinitionIndex, item)) { - return false; - } if (state::item_grant_route(request.itemDefinitionIndex) != state::ItemGrantRoute::quest) { return consume_world_record_reward( request, session, scratch, response, written, touchesScratch); @@ -138,46 +136,43 @@ bool consume_world_record_reward(const WorldRewardRequest& request, core::log::write(core::log::Channel::server, core::log::Level::warn, "ev=queuez stage=world_acquisition result=fail reason=prepare"); - bap::settle_world_reward(); return false; } touchesScratch = true; queuez::ItemAcquisition acquisition{}; - if (!queuez::stage_item_acquisition(session.queuez, - pending.accountSoid, - pending.characterSoid, - pending.acquiredInstanceSoid, - pending.updates_account(), - acquisition)) { - core::log::write(core::log::Channel::server, - core::log::Level::warn, - "ev=queuez stage=world_acquisition result=fail reason=stage"); - bap::settle_world_reward(); - return false; - } + const bool staged = queuez::stage_item_acquisition(session.queuez, + pending.accountSoid, + pending.characterSoid, + pending.acquiredInstanceSoid, + pending.updates_account(), + acquisition); auto nextSendNonce = session.sendNonce; std::size_t framedSize = 0; - if (!push::append_item_acquisition_notification(scratch, - acquisition, - pending, - active_acquisition_presentation_rows(session), - session.sessionKey, - nextSendNonce, - scratch.framed, - framedSize) - || framedSize == 0 || framedSize > response.size()) { - core::log::write(core::log::Channel::server, - core::log::Level::warn, - "ev=queuez stage=world_acquisition result=fail reason=encode"); - bap::settle_world_reward(); - return false; - } + const bool encoded = + staged + && push::append_item_acquisition_notification(scratch, + acquisition, + pending, + active_acquisition_presentation_rows(session), + session.sessionKey, + nextSendNonce, + scratch.framed, + framedSize) + && framedSize != 0; if (!state::commit_item_acquisition(pending) || !bap::complete_world_reward(request.id) || !transaction.commit()) { core::log::write(core::log::Channel::server, core::log::Level::warn, "ev=queuez stage=world_acquisition result=fail reason=commit"); - bap::settle_world_reward(); + return false; + } + if (!encoded || framedSize > response.size()) { + bap::arm_account_resync_everywhere(); + report_reward_refusal("world_acquisition", + request.itemDefinitionIndex, + !staged ? "inventory_stage" + : !encoded ? "inventory_encode" + : "response_capacity"); return false; } std::copy_n(scratch.framed.begin(), framedSize, response.begin()); @@ -208,45 +203,41 @@ bool consume_world_record_reward(const WorldRewardRequest& request, core::log::write(core::log::Channel::server, core::log::Level::warn, "ev=queuez stage=world_profile_acquisition result=fail reason=prepare"); - bap::settle_world_reward(); return false; } touchesScratch = true; queuez::ProfileItemAcquisition acquisition{}; - if (!queuez::stage_profile_item_acquisition(session.queuez, - pending.accountSoid, - pending.acquiredInstanceSoid, - pending.actionSource, - pending.appended, - acquisition)) { - core::log::write(core::log::Channel::server, - core::log::Level::warn, - "ev=queuez stage=world_profile_acquisition result=fail reason=stage"); - bap::settle_world_reward(); - return false; - } + const bool staged = queuez::stage_profile_item_acquisition(session.queuez, + pending.accountSoid, + pending.acquiredInstanceSoid, + pending.actionSource, + pending.appended, + acquisition); auto nextSendNonce = session.sendNonce; std::size_t framedSize = 0; - if (!push::append_profile_item_acquisition_notification(scratch, - acquisition, - pending, - session.sessionKey, - nextSendNonce, - scratch.framed, - framedSize) - || framedSize == 0 || framedSize > response.size()) { - core::log::write(core::log::Channel::server, - core::log::Level::warn, - "ev=queuez stage=world_profile_acquisition result=fail reason=encode"); - bap::settle_world_reward(); - return false; - } + const bool encoded = staged + && push::append_profile_item_acquisition_notification(scratch, + acquisition, + pending, + session.sessionKey, + nextSendNonce, + scratch.framed, + framedSize) + && framedSize != 0; if (!state::commit_profile_item_acquisition(pending) || !bap::complete_world_reward(request.id) || !transaction.commit()) { core::log::write(core::log::Channel::server, core::log::Level::warn, "ev=queuez stage=world_profile_acquisition result=fail reason=commit"); - bap::settle_world_reward(); + return false; + } + if (!encoded || framedSize > response.size()) { + bap::arm_account_resync_everywhere(); + report_reward_refusal("world_profile_acquisition", + request.itemDefinitionIndex, + !staged ? "inventory_stage" + : !encoded ? "inventory_encode" + : "response_capacity"); return false; } std::copy_n(scratch.framed.begin(), framedSize, response.begin()); @@ -765,6 +756,9 @@ bool consume_deferred(Session& session, if (published) { return true; } + if (session.accountResyncArmed) { + return false; + } } if (consume_seasonal_experience_presentation( session, scratch, response, written, touchesScratch)) { diff --git a/Sunrise/src/state/rewards/reward_resolver.cpp b/Sunrise/src/state/rewards/reward_resolver.cpp index 14c7d6cc2..a2dc55f8c 100644 --- a/Sunrise/src/state/rewards/reward_resolver.cpp +++ b/Sunrise/src/state/rewards/reward_resolver.cpp @@ -5,13 +5,13 @@ #include "../build_data/rewards/reward_catalog.h" #include "../build_data/runtime.h" -#include "middleware/content/packages/tables/definition_index_table.h" +#include "middleware/content/packages/tables/unlock_opcode.h" namespace sunrise::state::rewards { namespace { namespace definitions = build_data::rewards; -/** The empty bucket tag inherits the enclosing pool's bucket constraint. */ +/** The empty category tag matches every wrapper selection. */ constexpr std::uint32_t kEmptyTag = 0x811C9DC5U; /** Native postfix expressions use at most 256 signed 32-bit stack values. */ constexpr std::size_t kExpressionCapacity = 256; @@ -158,6 +158,8 @@ struct Resolver { Result& result; std::uint64_t random; Selection selection; + std::array selectedEntries{}; + std::array selectedCategories{}; double fraction() noexcept { // SplitMix64 makes a prepared seed replayable without shared random state. @@ -171,15 +173,18 @@ struct Resolver { bool weight(const definitions::Entry& entry, std::uint32_t category, - std::uint32_t bucket, std::size_t depth, double& output) noexcept { output = 0; - if (entry.categoryHash != category - || (bucket != kEmptyTag && entry.bucketHash != kEmptyTag - && entry.bucketHash != bucket)) { + if (entry.categoryHash != kEmptyTag && entry.categoryHash != category) { return true; } + // Draws exclude the selected pool row within its category, not every copy of its item. + for (std::size_t i = 0; i < result.count; ++i) { + if (selectedEntries[i] == &entry && selectedCategories[i] == category) { + return true; + } + } if (selection == Selection::equipment && entry.poolIndex == definitions::kAbsent) { build_data::items::details::Definition item{}; if (!build_data::find_configured_item_detail(entry.itemIndex, item) @@ -207,7 +212,11 @@ struct Resolver { if (modifier.valueIndex != definitions::kAbsent) { return refuse(context, "indexed_weight"); } - value = modifier.value; + // The first matching modifier replaces the weight; a negative value keeps it. + if (modifier.value >= 0) { + value = modifier.value; + } + break; } } if (!std::isfinite(value) || value < 0) { @@ -218,11 +227,7 @@ struct Resolver { } if (entry.poolIndex != definitions::kAbsent) { double total = 0; - if (!pool_weight(entry.poolIndex, - category, - entry.bucketHash == kEmptyTag ? bucket : entry.bucketHash, - depth + 1, - total)) { + if (!pool_weight(entry.poolIndex, category, depth + 1, total)) { return false; } if (total == 0) { @@ -232,11 +237,6 @@ struct Resolver { if (entry.itemIndex >= data.items.size()) { return false; } - for (std::size_t i = 0; i < result.count; ++i) { - if (result.grants[i].itemIndex == entry.itemIndex) { - return true; - } - } } else { return refuse(context, "reward_mapping"); } @@ -246,7 +246,6 @@ struct Resolver { bool pool_weight(std::uint16_t index, std::uint32_t category, - std::uint32_t bucket, std::size_t depth, double& total) noexcept { total = 0; @@ -259,7 +258,7 @@ struct Resolver { } for (const auto& entry : data.entries.subspan(range.first, range.count)) { double value = 0; - if (!weight(entry, category, bucket, depth, value)) { + if (!weight(entry, category, depth, value)) { return false; } total += value; @@ -269,7 +268,6 @@ struct Resolver { bool draw(std::uint16_t index, std::uint32_t category, - std::uint32_t bucket, std::size_t depth, double total) noexcept { const auto range = data.pools[index].entries; @@ -277,7 +275,7 @@ struct Resolver { const definitions::Entry* chosen = nullptr; for (const auto& entry : data.entries.subspan(range.first, range.count)) { double value = 0; - if (!weight(entry, category, bucket, depth, value)) { + if (!weight(entry, category, depth, value)) { return false; } if (value == 0) { @@ -289,22 +287,21 @@ struct Resolver { break; } } - if (chosen == nullptr - || (chosen->quantity != 1 && chosen->poolIndex != definitions::kAbsent)) { - return refuse(context, "nested_quantity"); + if (chosen == nullptr) { + return refuse(context, "empty_pool"); } if (chosen->poolIndex != definitions::kAbsent) { - const auto childBucket = chosen->bucketHash == kEmptyTag ? bucket : chosen->bucketHash; double childTotal = 0; - return pool_weight(chosen->poolIndex, category, childBucket, depth + 1, childTotal) - && childTotal > 0 - && draw(chosen->poolIndex, category, childBucket, depth + 1, childTotal); + return pool_weight(chosen->poolIndex, category, depth + 1, childTotal) && childTotal > 0 + && draw(chosen->poolIndex, category, depth + 1, childTotal); } if (chosen->quantity == 0 || chosen->quantity > INT32_MAX || result.count == result.grants.size() || !definitions::fits(chosen->sockets, data.sockets)) { return false; } + selectedEntries[result.count] = chosen; + selectedCategories[result.count] = category; auto& grant = result.grants[result.count++]; grant.itemIndex = chosen->itemIndex; grant.quantity = static_cast(chosen->quantity); @@ -355,15 +352,14 @@ bool resolve_item(definitions::View data, } for (std::size_t j = 0; j < declared.count; ++j) { double total = 0; - if (!resolver.pool_weight( - item.poolIndex, declared.categoryHash, kEmptyTag, 0, total)) { + if (!resolver.pool_weight(item.poolIndex, declared.categoryHash, 0, total)) { return false; } // A fixed bundle can have fewer eligible members after an acquisition unlock. if (total == 0) { break; } - if (!resolver.draw(item.poolIndex, declared.categoryHash, kEmptyTag, 0, total)) { + if (!resolver.draw(item.poolIndex, declared.categoryHash, 0, total)) { return false; } } From 5709f9557bf2f750f62e78ca6386bd1b390e44b9 Mon Sep 17 00:00:00 2001 From: Zach Date: Wed, 23 Sep 2026 15:09:08 -0500 Subject: [PATCH 04/12] Preserve required reward mappings and finish review cleanup --- Sunrise/Sunrise.vcxproj.filters | 12 ------------ .../content/items/packages/package_reward_build.cpp | 4 ++-- .../content/packages/tables/unlock_expression.h | 2 +- .../cache/records/cache_reward_records.cpp | 4 ++-- Sunrise/src/state/build_data/cache/records/format.h | 6 +++--- Sunrise/src/state/build_data/rewards/definition.h | 2 +- Sunrise/src/state/rewards/reward_resolver.cpp | 13 ++++++++----- .../runtime/state_account_reward_grant_runtime.cpp | 2 +- 8 files changed, 18 insertions(+), 27 deletions(-) diff --git a/Sunrise/Sunrise.vcxproj.filters b/Sunrise/Sunrise.vcxproj.filters index 9c359b893..7953e0a7c 100644 --- a/Sunrise/Sunrise.vcxproj.filters +++ b/Sunrise/Sunrise.vcxproj.filters @@ -67,16 +67,4 @@ Vendor\Lua Vendor\Lua - - - - - - - - - - - - diff --git a/Sunrise/src/client/content/items/packages/package_reward_build.cpp b/Sunrise/src/client/content/items/packages/package_reward_build.cpp index b88f20e4d..a9db8b3aa 100644 --- a/Sunrise/src/client/content/items/packages/package_reward_build.cpp +++ b/Sunrise/src/client/content/items/packages/package_reward_build.cpp @@ -53,9 +53,9 @@ constexpr std::size_t kAcquiredFlagField = 0xDA; /** Byte offsets inside the serialized reward rows. */ constexpr std::size_t kEntryQuantityOffset = 4; constexpr std::size_t kEntryPoolOffset = 8; +constexpr std::size_t kEntryMappingOffset = 10; constexpr std::size_t kEntryCategoryOffset = 20; constexpr std::size_t kEntryWeightOffset = 24; -constexpr std::size_t kEntryBucketOffset = 28; constexpr std::size_t kEntryConditionOffset = 32; constexpr std::size_t kEntryModifiersOffset = 48; constexpr std::size_t kEntrySocketsOffset = 64; @@ -113,9 +113,9 @@ bool read_reward_entry(std::span blob, 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 + kEntryMappingOffset, entry.mappingIndex) && tables::read(blob, at + kEntryCategoryOffset, entry.categoryHash) && tables::read(blob, at + kEntryWeightOffset, entry.weight) - && tables::read(blob, at + kEntryBucketOffset, entry.bucketHash) && std::isfinite(entry.weight) && entry.weight >= 0; } diff --git a/Sunrise/src/middleware/content/packages/tables/unlock_expression.h b/Sunrise/src/middleware/content/packages/tables/unlock_expression.h index 235c94b41..4c7688081 100644 --- a/Sunrise/src/middleware/content/packages/tables/unlock_expression.h +++ b/Sunrise/src/middleware/content/packages/tables/unlock_expression.h @@ -9,7 +9,7 @@ namespace sunrise::middleware::content::packages::tables { -/** Record and node field detection rejects values above 20 as non-expression data. */ +/** Upstream's record/node field heuristic; this is not the reward evaluator's opcode limit. */ inline constexpr std::uint32_t kExpressionDetectionOpcodeCeiling = 20; /** diff --git a/Sunrise/src/state/build_data/cache/records/cache_reward_records.cpp b/Sunrise/src/state/build_data/cache/records/cache_reward_records.cpp index 237c0a0be..ad35757e8 100644 --- a/Sunrise/src/state/build_data/cache/records/cache_reward_records.cpp +++ b/Sunrise/src/state/build_data/cache/records/cache_reward_records.cpp @@ -20,9 +20,9 @@ bool encode(const rewards::Entry& value, RewardEntryRecord& record) noexcept { record = {}; record.itemIndex = value.itemIndex; record.poolIndex = value.poolIndex; + record.mappingIndex = value.mappingIndex; record.quantity = value.quantity; record.categoryHash = value.categoryHash; - record.bucketHash = value.bucketHash; record.weight = value.weight; record.condition = {value.condition.first, value.condition.count}; record.modifiers = {value.modifiers.first, value.modifiers.count}; @@ -34,9 +34,9 @@ bool decode(const RewardEntryRecord& record, rewards::Entry& value) noexcept { value = {}; value.itemIndex = record.itemIndex; value.poolIndex = record.poolIndex; + value.mappingIndex = record.mappingIndex; value.quantity = record.quantity; value.categoryHash = record.categoryHash; - value.bucketHash = record.bucketHash; value.weight = record.weight; value.condition = {record.condition.first, record.condition.count}; value.modifiers = {record.modifiers.first, record.modifiers.count}; diff --git a/Sunrise/src/state/build_data/cache/records/format.h b/Sunrise/src/state/build_data/cache/records/format.h index 4d8d9c61e..a4eaa9520 100644 --- a/Sunrise/src/state/build_data/cache/records/format.h +++ b/Sunrise/src/state/build_data/cache/records/format.h @@ -32,7 +32,7 @@ namespace sunrise::state::build_data::cache::records { /** These 8 ASCII bytes mark a Sunrise build-data file. */ inline constexpr std::array kCacheMagic{'S', 'U', 'N', 'R', 'I', 'S', 'E', 'B'}; /** Bump when stored layouts or extracted values change; other versions are rebuilt. */ -inline constexpr std::uint32_t kCacheFormatVersion = 67; +inline constexpr std::uint32_t kCacheFormatVersion = 68; /** Signed -1 on disk means there is no equipment slot. */ inline constexpr std::int8_t kAbsentEquipmentSlot = -1; /** The standard 64-bit FNV-1a offset basis starts the payload checksum. */ @@ -621,9 +621,9 @@ struct RewardPoolRecord { struct RewardEntryRecord { std::uint16_t itemIndex{}; std::uint16_t poolIndex{}; + std::uint16_t mappingIndex{}; std::uint32_t quantity{}; std::uint32_t categoryHash{}; - std::uint32_t bucketHash{}; float weight{}; RewardRangeRecord condition{}; RewardRangeRecord modifiers{}; @@ -653,7 +653,7 @@ static_assert(sizeof(RewardSelectionRecord) == 2 * sizeof(std::uint32_t)); static_assert(sizeof(RewardRangeRecord) == 2 * sizeof(std::uint32_t)); static_assert(sizeof(RewardPoolRecord) == sizeof(std::uint32_t) + sizeof(RewardRangeRecord)); static_assert(sizeof(RewardEntryRecord) - == 2 * sizeof(std::uint16_t) + 3 * sizeof(std::uint32_t) + sizeof(float) + == 3 * sizeof(std::uint16_t) + 2 * sizeof(std::uint32_t) + sizeof(float) + 3 * sizeof(RewardRangeRecord)); static_assert(sizeof(RewardItemRecord) == 2 * sizeof(std::uint32_t) + 2 * sizeof(std::uint16_t) + sizeof(std::uint8_t) diff --git a/Sunrise/src/state/build_data/rewards/definition.h b/Sunrise/src/state/build_data/rewards/definition.h index f00eabc4a..5475b53fb 100644 --- a/Sunrise/src/state/build_data/rewards/definition.h +++ b/Sunrise/src/state/build_data/rewards/definition.h @@ -69,9 +69,9 @@ struct Modifier { struct Entry { std::uint16_t itemIndex{kAbsent}; std::uint16_t poolIndex{kAbsent}; + std::uint16_t mappingIndex{kAbsent}; std::uint32_t quantity{}; std::uint32_t categoryHash{}; - std::uint32_t bucketHash{}; float weight{}; Range condition{}; Range modifiers{}; diff --git a/Sunrise/src/state/rewards/reward_resolver.cpp b/Sunrise/src/state/rewards/reward_resolver.cpp index a2dc55f8c..ce325abbe 100644 --- a/Sunrise/src/state/rewards/reward_resolver.cpp +++ b/Sunrise/src/state/rewards/reward_resolver.cpp @@ -225,6 +225,10 @@ struct Resolver { if (value == 0) { return true; } + // A mapping can accompany an item or nested pool; all effects belong to the grant. + if (entry.mappingIndex != definitions::kAbsent) { + return refuse(context, "reward_mapping"); + } if (entry.poolIndex != definitions::kAbsent) { double total = 0; if (!pool_weight(entry.poolIndex, category, depth + 1, total)) { @@ -238,7 +242,7 @@ struct Resolver { return false; } } else { - return refuse(context, "reward_mapping"); + return refuse(context, "reward_target"); } output = value; return true; @@ -266,10 +270,8 @@ struct Resolver { return std::isfinite(total); } - bool draw(std::uint16_t index, - std::uint32_t category, - std::size_t depth, - double total) noexcept { + bool + draw(std::uint16_t index, std::uint32_t category, std::size_t depth, double total) noexcept { const auto range = data.pools[index].entries; double remaining = fraction() * total; const definitions::Entry* chosen = nullptr; @@ -291,6 +293,7 @@ struct Resolver { return refuse(context, "empty_pool"); } if (chosen->poolIndex != definitions::kAbsent) { + // A pool reference selects one leaf; quantity belongs to that leaf. double childTotal = 0; return pool_weight(chosen->poolIndex, category, depth + 1, childTotal) && childTotal > 0 && draw(chosen->poolIndex, category, depth + 1, childTotal); diff --git a/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp b/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp index aaddbb28a..864cab49d 100644 --- a/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp +++ b/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp @@ -377,7 +377,7 @@ bool preview_reward_unlocks(const PendingRecordRewardGrant& mutation, return true; } -/** Revalidates the native draw under the same lock that commits its inventory and flags. */ +/** Revalidates the prepared reward under the lock that commits its inventory and flags. */ bool commit_season_pass_reward(PendingSeasonPassReward& mutation) noexcept { if (!mutation.prepared) { return false; From e07a7fe1972efd91c20f9eb08f75e06b92d8aac5 Mon Sep 17 00:00:00 2001 From: Zach Date: Wed, 23 Sep 2026 19:31:49 -0500 Subject: [PATCH 05/12] Resolve supplemental reward rows and drop the unused entry discriminator --- .../items/packages/package_reward_build.cpp | 14 +++-- .../items/packages/package_reward_build.h | 1 + .../cache/records/cache_reward_records.cpp | 10 +++- .../state/build_data/cache/records/format.h | 7 +-- .../build_data/inventory/buckets/definition.h | 2 - .../src/state/build_data/rewards/definition.h | 4 +- .../build_data/rewards/reward_catalog.cpp | 1 + Sunrise/src/state/rewards/reward_resolver.cpp | 52 +++++++++---------- Sunrise/src/state/rewards/reward_resolver.h | 4 -- .../state_account_reward_grant_runtime.cpp | 5 -- 10 files changed, 52 insertions(+), 48 deletions(-) diff --git a/Sunrise/src/client/content/items/packages/package_reward_build.cpp b/Sunrise/src/client/content/items/packages/package_reward_build.cpp index a9db8b3aa..5a53a8cd8 100644 --- a/Sunrise/src/client/content/items/packages/package_reward_build.cpp +++ b/Sunrise/src/client/content/items/packages/package_reward_build.cpp @@ -19,10 +19,13 @@ namespace tables = middleware::content::packages::tables; namespace domain = state::build_data::rewards; /** 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; @@ -53,7 +56,7 @@ constexpr std::size_t kAcquiredFlagField = 0xDA; /** Byte offsets inside the serialized reward rows. */ constexpr std::size_t kEntryQuantityOffset = 4; constexpr std::size_t kEntryPoolOffset = 8; -constexpr std::size_t kEntryMappingOffset = 10; +constexpr std::size_t kEntrySupplementalOffset = 10; constexpr std::size_t kEntryCategoryOffset = 20; constexpr std::size_t kEntryWeightOffset = 24; constexpr std::size_t kEntryConditionOffset = 32; @@ -113,7 +116,7 @@ bool read_reward_entry(std::span blob, 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 + kEntryMappingOffset, entry.mappingIndex) + && 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; @@ -157,6 +160,7 @@ bool RewardBuild::entry(std::span blob, std::size_t at) noexcep || !conditions.read(blob, at + kEntryConditionOffset, instructions_, out.condition)) { return false; } + out.supplementalMissing = supplementalMissing_ && out.supplementalIndex != domain::kAbsent; tables::Array rows{}; if (!tables::read_array( blob, at + kEntryModifiersOffset, kModifierClass, kModifierStride, rows)) { @@ -436,6 +440,7 @@ bool RewardBuild::load(const reader::Source& source, std::span root, const SlotMaps& maps) noexcept { loaded_ = false; + supplementalMissing_ = false; pools_.clear(); entries_.clear(); instructions_.clear(); @@ -443,13 +448,16 @@ bool RewardBuild::load(const reader::Source& source, sockets_.clear(); std::vector blob; tables::Array rows{}; - if (!conditions.load(source, scratch, root, maps) + std::uint32_t supplementalTag = 0; + if (!tables::slot_tag(root, kSupplementalRewardSlot, supplementalTag) + || !conditions.load(source, scratch, root, maps) || !root_table(source, scratch, root, kPoolSlot, blob, kPoolClass) || !tables::read_array( blob, tables::kTableArrayDescriptor, kPoolRowClass, kPoolStride, rows) || rows.count == 0 || rows.count > domain::kPoolCapacity) { 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; diff --git a/Sunrise/src/client/content/items/packages/package_reward_build.h b/Sunrise/src/client/content/items/packages/package_reward_build.h index 014877e79..8cffb7eca 100644 --- a/Sunrise/src/client/content/items/packages/package_reward_build.h +++ b/Sunrise/src/client/content/items/packages/package_reward_build.h @@ -62,6 +62,7 @@ class RewardBuild { std::span blob, state::build_data::rewards::Item& item) noexcept; bool loaded_{}; + bool supplementalMissing_{}; std::vector pools_; std::vector entries_; std::vector items_; diff --git a/Sunrise/src/state/build_data/cache/records/cache_reward_records.cpp b/Sunrise/src/state/build_data/cache/records/cache_reward_records.cpp index ad35757e8..f1198af61 100644 --- a/Sunrise/src/state/build_data/cache/records/cache_reward_records.cpp +++ b/Sunrise/src/state/build_data/cache/records/cache_reward_records.cpp @@ -20,7 +20,8 @@ bool encode(const rewards::Entry& value, RewardEntryRecord& record) noexcept { record = {}; record.itemIndex = value.itemIndex; record.poolIndex = value.poolIndex; - record.mappingIndex = value.mappingIndex; + record.supplementalIndex = value.supplementalIndex; + record.supplementalMissing = static_cast(value.supplementalMissing); record.quantity = value.quantity; record.categoryHash = value.categoryHash; record.weight = value.weight; @@ -32,9 +33,14 @@ bool encode(const rewards::Entry& value, RewardEntryRecord& record) noexcept { bool decode(const RewardEntryRecord& record, rewards::Entry& value) noexcept { value = {}; + if (record.supplementalMissing > 1 + || (record.supplementalMissing != 0 && record.supplementalIndex == rewards::kAbsent)) { + return false; + } value.itemIndex = record.itemIndex; value.poolIndex = record.poolIndex; - value.mappingIndex = record.mappingIndex; + value.supplementalIndex = record.supplementalIndex; + value.supplementalMissing = record.supplementalMissing != 0; value.quantity = record.quantity; value.categoryHash = record.categoryHash; value.weight = record.weight; diff --git a/Sunrise/src/state/build_data/cache/records/format.h b/Sunrise/src/state/build_data/cache/records/format.h index a4eaa9520..fac6c805e 100644 --- a/Sunrise/src/state/build_data/cache/records/format.h +++ b/Sunrise/src/state/build_data/cache/records/format.h @@ -32,7 +32,7 @@ namespace sunrise::state::build_data::cache::records { /** These 8 ASCII bytes mark a Sunrise build-data file. */ inline constexpr std::array kCacheMagic{'S', 'U', 'N', 'R', 'I', 'S', 'E', 'B'}; /** Bump when stored layouts or extracted values change; other versions are rebuilt. */ -inline constexpr std::uint32_t kCacheFormatVersion = 68; +inline constexpr std::uint32_t kCacheFormatVersion = 69; /** Signed -1 on disk means there is no equipment slot. */ inline constexpr std::int8_t kAbsentEquipmentSlot = -1; /** The standard 64-bit FNV-1a offset basis starts the payload checksum. */ @@ -621,7 +621,8 @@ struct RewardPoolRecord { struct RewardEntryRecord { std::uint16_t itemIndex{}; std::uint16_t poolIndex{}; - std::uint16_t mappingIndex{}; + std::uint16_t supplementalIndex{}; + std::uint8_t supplementalMissing{}; std::uint32_t quantity{}; std::uint32_t categoryHash{}; float weight{}; @@ -654,7 +655,7 @@ static_assert(sizeof(RewardRangeRecord) == 2 * sizeof(std::uint32_t)); static_assert(sizeof(RewardPoolRecord) == sizeof(std::uint32_t) + sizeof(RewardRangeRecord)); static_assert(sizeof(RewardEntryRecord) == 3 * sizeof(std::uint16_t) + 2 * sizeof(std::uint32_t) + sizeof(float) - + 3 * sizeof(RewardRangeRecord)); + + sizeof(std::uint8_t) + 3 * sizeof(RewardRangeRecord)); static_assert(sizeof(RewardItemRecord) == 2 * sizeof(std::uint32_t) + 2 * sizeof(std::uint16_t) + sizeof(std::uint8_t) + rewards::kSelectionCapacity * sizeof(RewardSelectionRecord)); diff --git a/Sunrise/src/state/build_data/inventory/buckets/definition.h b/Sunrise/src/state/build_data/inventory/buckets/definition.h index 0e56af918..9dbbe5510 100644 --- a/Sunrise/src/state/build_data/inventory/buckets/definition.h +++ b/Sunrise/src/state/build_data/inventory/buckets/definition.h @@ -27,8 +27,6 @@ inline constexpr std::uint32_t kSmallProfileSlotCapacity = 6; inline constexpr std::uint8_t kUnavailableBucketId = (std::numeric_limits::max)(); /** Hidden plugs, perks and quest roots use this bucket instead of held inventory. */ inline constexpr std::uint8_t kNonInventoryBucketId = 37; -/** Character bucket containing unopened engrams. */ -inline constexpr std::uint8_t kEngramBucketId = 31; /** Leaving out the unavailable id leaves at most 255 unique bucket records. */ inline constexpr std::size_t kDescriptorCapacity = kUnavailableBucketId; /** Signed -1 marks a bucket that has no equipment slot. */ diff --git a/Sunrise/src/state/build_data/rewards/definition.h b/Sunrise/src/state/build_data/rewards/definition.h index 5475b53fb..62285fbd9 100644 --- a/Sunrise/src/state/build_data/rewards/definition.h +++ b/Sunrise/src/state/build_data/rewards/definition.h @@ -69,7 +69,9 @@ struct Modifier { struct Entry { std::uint16_t itemIndex{kAbsent}; std::uint16_t poolIndex{kAbsent}; - std::uint16_t mappingIndex{kAbsent}; + std::uint16_t supplementalIndex{kAbsent}; + /** True only when the source bank is explicitly absent from the investment root. */ + bool supplementalMissing{}; std::uint32_t quantity{}; std::uint32_t categoryHash{}; float weight{}; diff --git a/Sunrise/src/state/build_data/rewards/reward_catalog.cpp b/Sunrise/src/state/build_data/rewards/reward_catalog.cpp index 2fc14d9b4..269ea34e6 100644 --- a/Sunrise/src/state/build_data/rewards/reward_catalog.cpp +++ b/Sunrise/src/state/build_data/rewards/reward_catalog.cpp @@ -63,6 +63,7 @@ bool valid_depth(View data, bool valid_entry(View data, const Entry& entry) noexcept { return (entry.itemIndex == kAbsent || entry.itemIndex < data.items.size()) && (entry.poolIndex == kAbsent || entry.poolIndex < data.pools.size()) + && (!entry.supplementalMissing || entry.supplementalIndex != kAbsent) && fits(entry.condition, data.instructions) && fits(entry.modifiers, data.modifiers) && fits(entry.sockets, data.sockets) && std::isfinite(entry.weight) && entry.weight >= 0; } diff --git a/Sunrise/src/state/rewards/reward_resolver.cpp b/Sunrise/src/state/rewards/reward_resolver.cpp index ce325abbe..d9a544687 100644 --- a/Sunrise/src/state/rewards/reward_resolver.cpp +++ b/Sunrise/src/state/rewards/reward_resolver.cpp @@ -4,7 +4,6 @@ #include #include "../build_data/rewards/reward_catalog.h" -#include "../build_data/runtime.h" #include "middleware/content/packages/tables/unlock_opcode.h" namespace sunrise::state::rewards { @@ -15,6 +14,8 @@ namespace definitions = build_data::rewards; constexpr std::uint32_t kEmptyTag = 0x811C9DC5U; /** Native postfix expressions use at most 256 signed 32-bit stack values. */ constexpr std::size_t kExpressionCapacity = 256; +/** Native wrappers retain up to 64 selections, including rows without an item grant. */ +constexpr std::size_t kDrawCapacity = 64; bool refuse(const Context& context, const char* reason) noexcept { if (context.refusal != nullptr) { @@ -157,9 +158,9 @@ struct Resolver { const Context& context; Result& result; std::uint64_t random; - Selection selection; - std::array selectedEntries{}; - std::array selectedCategories{}; + std::array selectedEntries{}; + std::array selectedCategories{}; + std::size_t selectedCount{}; double fraction() noexcept { // SplitMix64 makes a prepared seed replayable without shared random state. @@ -180,18 +181,11 @@ struct Resolver { return true; } // Draws exclude the selected pool row within its category, not every copy of its item. - for (std::size_t i = 0; i < result.count; ++i) { + for (std::size_t i = 0; i < selectedCount; ++i) { if (selectedEntries[i] == &entry && selectedCategories[i] == category) { return true; } } - if (selection == Selection::equipment && entry.poolIndex == definitions::kAbsent) { - build_data::items::details::Definition item{}; - if (!build_data::find_configured_item_detail(entry.itemIndex, item) - || !item.equipmentSlot.has_value()) { - return true; - } - } bool enabled = false; if (!condition(data, entry.condition, context, enabled)) { return false; @@ -225,10 +219,6 @@ struct Resolver { if (value == 0) { return true; } - // A mapping can accompany an item or nested pool; all effects belong to the grant. - if (entry.mappingIndex != definitions::kAbsent) { - return refuse(context, "reward_mapping"); - } if (entry.poolIndex != definitions::kAbsent) { double total = 0; if (!pool_weight(entry.poolIndex, category, depth + 1, total)) { @@ -241,7 +231,7 @@ struct Resolver { if (entry.itemIndex >= data.items.size()) { return false; } - } else { + } else if (entry.supplementalIndex == definitions::kAbsent) { return refuse(context, "reward_target"); } output = value; @@ -292,19 +282,29 @@ struct Resolver { if (chosen == nullptr) { return refuse(context, "empty_pool"); } + if (chosen->supplementalIndex != definitions::kAbsent && !chosen->supplementalMissing) { + return refuse(context, "supplemental_reward"); + } + // A missing supplemental definition does not cancel the selected item grant. if (chosen->poolIndex != definitions::kAbsent) { // A pool reference selects one leaf; quantity belongs to that leaf. double childTotal = 0; return pool_weight(chosen->poolIndex, category, depth + 1, childTotal) && childTotal > 0 && draw(chosen->poolIndex, category, depth + 1, childTotal); } + if (selectedCount == selectedEntries.size()) { + return refuse(context, "reward_selection_capacity"); + } + selectedEntries[selectedCount] = chosen; + selectedCategories[selectedCount++] = category; + if (chosen->itemIndex == definitions::kAbsent) { + return true; + } if (chosen->quantity == 0 || chosen->quantity > INT32_MAX || result.count == result.grants.size() || !definitions::fits(chosen->sockets, data.sockets)) { return false; } - selectedEntries[result.count] = chosen; - selectedCategories[result.count] = category; auto& grant = result.grants[result.count++]; grant.itemIndex = chosen->itemIndex; grant.quantity = static_cast(chosen->quantity); @@ -322,7 +322,6 @@ bool resolve_item(definitions::View data, const Context& context, std::uint16_t itemIndex, std::uint32_t quantity, - Selection selection, Result& result) noexcept { result = {}; if (context.refusal != nullptr) { @@ -347,10 +346,10 @@ bool resolve_item(definitions::View data, || item.selectionCount > item.selections.size()) { return false; } - Resolver resolver{data, context, staged, context.seed, selection}; + Resolver resolver{data, context, staged, context.seed}; for (std::size_t i = 0; i < item.selectionCount; ++i) { const auto& declared = item.selections[i]; - if (declared.count > staged.grants.size()) { + if (declared.count > kDrawCapacity) { return false; } for (std::size_t j = 0; j < declared.count; ++j) { @@ -368,7 +367,7 @@ bool resolve_item(definitions::View data, } } if (staged.count == 0) { - return false; + return refuse(context, "empty_reward"); } } result = staged; @@ -394,23 +393,20 @@ bool eligible(std::span instructions, bool resolve(const Context& context, std::uint16_t itemIndex, std::uint32_t quantity, - Selection selection, Result& result) noexcept { struct Request { const Context& context; std::uint16_t item; std::uint32_t quantity; - Selection selection; Result& result; - } request{context, itemIndex, quantity, selection, result}; + } request{context, itemIndex, quantity, result}; result = {}; if (context.refusal != nullptr) { *context.refusal = "reward_item"; } return definitions::read(&request, [](void* raw, definitions::View data) noexcept { auto& value = *static_cast(raw); - return resolve_item( - data, value.context, value.item, value.quantity, value.selection, value.result); + return resolve_item(data, value.context, value.item, value.quantity, value.result); }); } diff --git a/Sunrise/src/state/rewards/reward_resolver.h b/Sunrise/src/state/rewards/reward_resolver.h index 934674e3a..82ce5fc45 100644 --- a/Sunrise/src/state/rewards/reward_resolver.h +++ b/Sunrise/src/state/rewards/reward_resolver.h @@ -6,9 +6,6 @@ namespace sunrise::state::rewards { -/** Pass engrams select equipment; ordinary acquisitions expand every declared lane. */ -enum class Selection : std::uint8_t { all, equipment }; - struct Grant { std::uint16_t itemIndex{build_data::rewards::kAbsent}; std::int32_t quantity{}; @@ -38,7 +35,6 @@ struct Context { [[nodiscard]] bool resolve(const Context& context, std::uint16_t itemIndex, std::uint32_t quantity, - Selection selection, Result& result) noexcept; } // namespace sunrise::state::rewards diff --git a/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp b/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp index 864cab49d..060962837 100644 --- a/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp +++ b/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp @@ -171,13 +171,9 @@ enum class PassResolution { claim, replay }; || !enabled) { return false; } - build_data::items::Definition item{}; - const bool engram = build_data::find_item_definition_index(reward.itemIndex, item) - && item.bucketId == inventory_buckets::kEngramBucketId; if (!rewards::resolve({flags, account.characters[character].characterClass, seed, reason}, reward.itemIndex, reward.quantity, - engram ? rewards::Selection::equipment : rewards::Selection::all, result)) { return false; } @@ -340,7 +336,6 @@ bool prepare_item_reward(std::uint16_t itemIndex, if (!rewards::resolve({flags, account.characters[character].characterClass, seed, &reason}, itemIndex, quantity, - rewards::Selection::all, resolved)) { return false; } From dc6774b631251e77201291c3a2ed7c99b8b8aaff Mon Sep 17 00:00:00 2001 From: Zach Date: Wed, 23 Sep 2026 21:33:28 -0500 Subject: [PATCH 06/12] Share one unlock-expression evaluator and tighten reward layering --- Sunrise/Sunrise.vcxproj | 6 +- .../client/content/items/packages/internal.h | 1 + .../items/packages/package_item_build.cpp | 16 +- .../items/packages/package_item_rows.cpp | 3 +- .../items/packages/package_reward_build.cpp | 137 ++++++------ .../items/packages/package_reward_build.h | 18 +- .../packages/package_season_pass_build.cpp | 79 ++++--- .../content/packages/tables/activity_table.h | 21 +- .../tables/definition_index_table.cpp | 1 + .../packages/tables/definition_index_table.h | 34 +-- .../content/packages/tables/field_reader.h | 23 ++ .../tables/quest_initialization_reader.cpp | 6 +- .../packages/tables/unlock_expression.h | 5 +- .../content/packages/tables/unlock_opcode.h | 39 ---- .../src/server/bap/bap_world_reward_queue.cpp | 14 +- .../push/snapshot/family4_reward_updates.cpp | 5 + .../encrypted/queuez/queuez_deferred_push.cpp | 22 +- .../queuez/queuez_state_validation.h | 1 + .../web_service_record_actions.cpp | 8 +- .../cache/records/cache_domain_validation.cpp | 10 +- .../cache/records/cache_reward_records.cpp | 22 +- .../state/build_data/cache/records/format.h | 10 +- .../build_data/inventory/buckets/definition.h | 7 +- .../src/state/build_data/rewards/definition.h | 48 +++-- .../rewards/reward_build_data_runtime.cpp | 30 +++ .../build_data/rewards/reward_catalog.cpp | 63 ++---- .../state/build_data/rewards/reward_catalog.h | 2 - Sunrise/src/state/build_data/runtime.h | 32 ++- .../runtime/build_data_catalog_runtime.cpp | 6 - .../persistence/build_data_persistence.cpp | 39 ++-- .../persistence/build_data_persistence.h | 13 +- .../state/build_data/season_pass/definition.h | 6 +- .../season_pass_build_data_runtime.cpp | 5 + .../season_pass/season_pass_catalog.cpp | 51 +++-- .../build_data/vendors/vendor_expression.cpp | 119 ---------- .../build_data/vendors/vendor_expression.h | 58 ----- .../progression/season_pass_reward_catalog.h | 3 +- Sunrise/src/state/rewards/reward_resolver.cpp | 204 +++++++----------- Sunrise/src/state/rewards/reward_resolver.h | 3 + Sunrise/src/state/runtime/runtime.h | 20 +- .../state_account_reward_grant_runtime.cpp | 38 +--- .../runtime/state_progression_runtime.cpp | 24 +-- .../src/state/unlocks/unlocks_expression.cpp | 203 +++++++++++++++++ .../src/state/unlocks/unlocks_expression.h | 92 ++++++++ 44 files changed, 843 insertions(+), 704 deletions(-) delete mode 100644 Sunrise/src/middleware/content/packages/tables/unlock_opcode.h create mode 100644 Sunrise/src/state/build_data/rewards/reward_build_data_runtime.cpp delete mode 100644 Sunrise/src/state/build_data/vendors/vendor_expression.cpp delete mode 100644 Sunrise/src/state/build_data/vendors/vendor_expression.h create mode 100644 Sunrise/src/state/unlocks/unlocks_expression.cpp create mode 100644 Sunrise/src/state/unlocks/unlocks_expression.h diff --git a/Sunrise/Sunrise.vcxproj b/Sunrise/Sunrise.vcxproj index 96aa73878..563404e2a 100644 --- a/Sunrise/Sunrise.vcxproj +++ b/Sunrise/Sunrise.vcxproj @@ -103,6 +103,7 @@ + @@ -646,7 +647,7 @@ - + @@ -1519,7 +1520,7 @@ - + @@ -2149,7 +2150,6 @@ - diff --git a/Sunrise/src/client/content/items/packages/internal.h b/Sunrise/src/client/content/items/packages/internal.h index 76f23e8ff..8dc53f38b 100644 --- a/Sunrise/src/client/content/items/packages/internal.h +++ b/Sunrise/src/client/content/items/packages/internal.h @@ -85,6 +85,7 @@ 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 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 f90dd1a2d..2b1fbfb08 100644 --- a/Sunrise/src/client/content/items/packages/package_item_build.cpp +++ b/Sunrise/src/client/content/items/packages/package_item_build.cpp @@ -8,7 +8,6 @@ #include "../../../../middleware/content/packages/reader/reader.h" #include "../../../../middleware/content/packages/tables/definition_index_table.h" #include "../../../../state/build_data/activities/activity_catalog.h" -#include "../../../../state/build_data/rewards/reward_catalog.h" #include "../../../../state/build_data/runtime.h" #include "../../activity/activity_catalog_build.h" #include "../../activity/entity_position_profile_build.h" @@ -36,7 +35,8 @@ 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::rewards::ready() + && state::build_data::season_pass_ready() + && state::build_data::reward_definitions_ready() && state::build_data::repeatable_bounties_ready() && state::build_data::record_definitions_ready() && state::build_data::node_definitions_ready() @@ -128,8 +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() || !state::build_data::rewards::ready() - || !exotic_catalysts_settled()) { + || !state::build_data::season_pass_ready() + || !state::build_data::reward_definitions_ready() || !exotic_catalysts_settled()) { reason = "unlock_maps"; if (!read_unlock_slot_maps( source, storage, std::span{storage.root})) { @@ -187,11 +187,13 @@ bool build() noexcept { std::span(storage.progressionSteps).first(storage.progressionStepCount)); } } - const bool rewardTables = - (!state::build_data::rewards::ready() || !state::build_data::season_pass_ready()) + // Pass rows bind their conditions through the reward condition tables. + const bool conditionTablesLoaded = + (!state::build_data::reward_definitions_ready() + || !state::build_data::season_pass_ready()) && storage.rewardBuild.load( source, storage.scratch, storage.root, storage.slotMaps); - if (rewardTables && !state::build_data::season_pass_ready() + if (conditionTablesLoaded && !state::build_data::season_pass_ready() && build_season_pass(source, storage, std::span{storage.root})) { (void)state::build_data::publish_season_pass( std::span(storage.seasonPassRewards).first(storage.seasonPassRewardCount)); 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 f6f9f842e..a15b3ed9b 100644 --- a/Sunrise/src/client/content/items/packages/package_item_rows.cpp +++ b/Sunrise/src/client/content/items/packages/package_item_rows.cpp @@ -4,7 +4,6 @@ #include "../../../../middleware/content/packages/tables/quest_initialization_reader.h" #include "../../../../state/build_data/items/catalysts/exotic_catalyst_builder.h" #include "../../../../state/build_data/items/details/item_detail_catalog.h" -#include "../../../../state/build_data/rewards/reward_catalog.h" #include "../../../../state/build_data/runtime.h" #include "internal.h" #include "package_socket_plug_build.h" @@ -62,7 +61,7 @@ bool build_item_rows(const reader::Source& source, const tables::Array& table, std::size_t& rowCount, const char*& reason) noexcept { - const bool needRewards = !state::build_data::rewards::ready(); + const bool needRewards = !state::build_data::reward_definitions_ready(); const bool rewardStorageReady = !needRewards || storage.rewardBuild.begin_items(static_cast(table.count)); const bool needDefinitions = !state::build_data::item_definitions_ready(); diff --git a/Sunrise/src/client/content/items/packages/package_reward_build.cpp b/Sunrise/src/client/content/items/packages/package_reward_build.cpp index 5a53a8cd8..631069bd5 100644 --- a/Sunrise/src/client/content/items/packages/package_reward_build.cpp +++ b/Sunrise/src/client/content/items/packages/package_reward_build.cpp @@ -4,11 +4,11 @@ #include #include +#include "../../../../core/logging/log.h" #include "../../../../middleware/content/packages/tables/definition_index_table.h" -#include "../../../../middleware/content/packages/tables/internal.h" -#include "../../../../state/build_data/rewards/reward_catalog.h" +#include "../../../../middleware/content/packages/tables/field_reader.h" #include "../../../../state/build_data/runtime.h" -#include "core/logging/log.h" +#include "../../../../state/unlocks/unlocks_expression.h" #include "internal.h" namespace sunrise::client::content::items::packages { @@ -17,6 +17,7 @@ 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; @@ -76,7 +77,7 @@ 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. +/** 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; @@ -85,6 +86,7 @@ constexpr std::uint32_t kClassRow = 0x808074FAU; constexpr std::size_t kUseConditionPointer = 24; constexpr std::uint32_t kUseConditionClass = 0x80802980U; +/** 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; @@ -97,6 +99,7 @@ template bool append(std::vector& bank, T value, std::size_t cap } } +/** Reads the table an investment-root slot names, checking its class when one is expected. */ bool root_table(const reader::Source& source, reader::Scratch& scratch, std::span root, @@ -110,6 +113,7 @@ bool root_table(const reader::Source& source, && (expectedClass == 0 || cls == expectedClass); } +/** 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 { @@ -124,16 +128,11 @@ bool read_reward_entry(std::span blob, /** 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::int64_t relative = 0; - if (!tables::read(blob, kUseConditionPointer, relative) || relative <= 0 - || static_cast(relative) > blob.size() - || kUseConditionPointer > blob.size() - static_cast(relative)) { - return false; - } - const auto at = kUseConditionPointer + static_cast(relative); + std::size_t at = 0; std::uint32_t cls = 0; tables::Array expression{}; - if (!tables::read(blob, at - kDefinitionClassPrefixSize, cls) || cls != kUseConditionClass + 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) { @@ -142,7 +141,7 @@ bool read_class_flag(std::span blob, std::uint16_t& output) noe std::uint32_t opcode = 0; std::uint32_t flag = domain::kAbsent; if (!tables::read(blob, expression.dataOffset, opcode) - || opcode != static_cast(tables::UnlockOpcode::flag) + || opcode != tables::kUnlockReadFlagOpcode || !tables::read( blob, expression.dataOffset + tables::kUnlockInstructionOperandOffset, flag) || flag >= domain::kAbsent) { @@ -267,55 +266,50 @@ bool RewardConditions::load(const reader::Source& source, expressionRows_); } -bool RewardConditions::bind(domain::Instruction& instruction) const noexcept { - const auto opcode = static_cast(instruction.opcode); - const bool flag = opcode == tables::UnlockOpcode::flag; - if (!flag && opcode != tables::UnlockOpcode::loadValue) { - return true; +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_; - if (instruction.operand >= rows.count) { - return false; - } - const std::size_t at = rows.dataOffset + instruction.operand * kBindingStride; std::uint32_t hash = 0; - if (!tables::read(blob, at, hash)) { + if (operand >= rows.count + || !tables::read(blob, rows.dataOffset + operand * kBindingStride, hash)) { return false; } - using Read = domain::BankRead; + 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 - && instruction.operand == classFlags_[characterClass]) { - instruction = {static_cast(Read::characterClass), - static_cast(characterClass)}; + && operand == classFlags_[characterClass]) { + instruction.bank = unlocks::Bank::characterClass; + instruction.operand = static_cast(characterClass); return true; } } } - const auto& account = flag ? maps_->accountFlag : maps_->accountValue; - const auto& character = flag ? maps_->characterFlag : maps_->characterValue; - const auto accountIndex = bank_index(account, static_cast(instruction.operand)); + 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(character, static_cast(instruction.operand)); + bank_index(flag ? maps_->characterFlag : maps_->characterValue, slot); if (accountIndex != kUnmappedSlot) { - instruction = {static_cast(flag ? Read::accountFlag : Read::accountValue), - accountIndex}; - } else if (flag - && bank_index(maps_->profileFlag, static_cast(instruction.operand)) - != kUnmappedSlot) { - instruction = { - static_cast(Read::profileFlag), - bank_index(maps_->profileFlag, static_cast(instruction.operand))}; + 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 = { - static_cast(flag ? Read::characterFlag : Read::characterValue), - characterIndex}; + instruction.bank = unlocks::Bank::character; + instruction.operand = characterIndex; } else { - instruction = {static_cast(flag ? Read::externalFlag : Read::externalValue), - hash}; + instruction.bank = unlocks::Bank::external; + instruction.operand = hash; } return true; } @@ -331,30 +325,29 @@ bool RewardConditions::append_expression(std::span blob, return false; } for (std::size_t i = 0; i < rows.count; ++i) { - domain::Instruction instruction{}; - if (!tables::read( - blob, rows.dataOffset + i * tables::kUnlockInstructionStride, instruction.opcode) - || !tables::read(blob, - rows.dataOffset + i * tables::kUnlockInstructionStride - + tables::kUnlockInstructionOperandOffset, - instruction.operand)) { + 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 (static_cast(instruction.opcode) - == tables::UnlockOpcode::expression) { + if (native == tables::kUnlockExpressionOpcode) { const auto before = bank.size(); - if (instruction.operand >= expressionRows_.count + if (operand >= expressionRows_.count || !append_expression(expressions_, - expressionRows_.dataOffset - + instruction.operand * kExpressionRowStride + expressionRows_.dataOffset + operand * kExpressionRowStride + kExpressionBodyOffset, bank, depth + 1) || bank.size() == before) { return false; } - } else if (!bind(instruction) || !domain::valid_instruction(instruction) - || !append(bank, instruction, domain::kInstructionCapacity)) { + continue; + } + domain::Instruction instruction{}; + if (!bind(native, operand, instruction) || !unlocks::valid(instruction) + || !append(bank, instruction, domain::kInstructionCapacity)) { return false; } } @@ -396,8 +389,7 @@ bool RewardConditions::read_list(std::span blob, // Every expression attached to a progression reward must hold. if (i != 0 && !append(instructions, - domain::Instruction{ - static_cast(tables::UnlockOpcode::logicalAnd), 0}, + domain::Instruction{unlocks::Opcode::logicalAnd, unlocks::Bank::none, 0}, output.size())) { return false; } @@ -501,30 +493,29 @@ bool RewardBuild::read_item(std::uint32_t hash, std::span blob, domain::Item& item) noexcept { std::uint16_t acquired = domain::kAbsent; - std::int64_t relative = 0; + std::int64_t wrapper = 0; std::uint32_t cls = 0; - if (!tables::read(blob, kWrapperField, relative) + if (!tables::read(blob, kWrapperField, wrapper) || !tables::read(blob, kAcquiredFlagField, acquired)) { return false; } item.definitionHash = hash; if (acquired != domain::kAbsent) { - domain::Instruction flag{static_cast(tables::UnlockOpcode::flag), acquired}; - if (!conditions.bind(flag) || !domain::valid_instruction(flag)) { + domain::Instruction flag{}; + if (!conditions.bind(tables::kUnlockReadFlagOpcode, acquired, flag) + || !unlocks::valid(flag)) { return false; } - if (flag.opcode == static_cast(domain::BankRead::accountFlag)) { + if (flag.bank == unlocks::Bank::account) { item.acquiredFlag = static_cast(flag.operand); } } - if (relative != 0) { - if (relative < 0 || static_cast(relative) > blob.size() - || kWrapperField > blob.size() - static_cast(relative)) { - return false; - } - const auto at = kWrapperField + static_cast(relative); + // A zero delta means the item is not a wrapper. + if (wrapper != 0) { + std::size_t at = 0; tables::Array selections{}; - if (!tables::read(blob, at - kDefinitionClassPrefixSize, cls) || cls != kWrapperClass + 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( @@ -581,7 +572,7 @@ bool RewardBuild::publish() noexcept { && (row.poolIndex == domain::kAbsent || row.poolIndex < pools_.size()); for (const auto& socket : std::span(sockets_).subspan(row.sockets.first, row.sockets.count)) { - valid &= socket.plugItem == domain::kAbsent || socket.plugItem < items_.size(); + valid &= domain::valid_socket(socket, items_.size()); } if (valid) { auto retained = row; diff --git a/Sunrise/src/client/content/items/packages/package_reward_build.h b/Sunrise/src/client/content/items/packages/package_reward_build.h index 8cffb7eca..baf23df5a 100644 --- a/Sunrise/src/client/content/items/packages/package_reward_build.h +++ b/Sunrise/src/client/content/items/packages/package_reward_build.h @@ -1,5 +1,11 @@ #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" @@ -12,15 +18,21 @@ struct SlotMaps; /** 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; - [[nodiscard]] bool bind(state::build_data::rewards::Instruction& instruction) 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 to the bank; 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) 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, @@ -48,12 +60,16 @@ class RewardConditions { class RewardBuild { public: RewardConditions conditions; + /** Reads every reward pool; entries that fail to read are skipped and counted. */ [[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; + /** Drops rows naming items or pools that were not read, then publishes the banks. */ [[nodiscard]] bool publish() noexcept; private: 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 1c0128508..ae63336a7 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,10 +1,10 @@ #include #include -#include "../../../../middleware/content/packages/tables/internal.h" +#include "../../../../core/logging/log.h" +#include "../../../../middleware/content/packages/tables/field_reader.h" #include "../../../../state/build_data/season_pass/season_pass_catalog.h" #include "../../../../state/progression/season_pass_reward_catalog.h" -#include "core/logging/log.h" #include "internal.h" #include "package_reward_build.h" @@ -13,6 +13,10 @@ namespace { namespace domain = state::build_data::season_pass; +/** + * Reads one pass row: item, rank, quantity and claim flag, then its sockets and conditions. + * @return False when any field is unreadable or out of range. + */ bool read_season_reward(Storage& storage, std::span progressionTable, const tables::Array& itemRows, @@ -21,17 +25,19 @@ bool read_season_reward(Storage& storage, std::uint32_t rank = 0; std::uint32_t itemIndex = 0; std::uint32_t claimSlot = 0; - tables::IndexRow entry{}; 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) - || reward.quantity == 0 - || reward.quantity > static_cast((std::numeric_limits::max)()) - || rank > (std::numeric_limits::max)() + 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)) { @@ -40,45 +46,50 @@ bool read_season_reward(Storage& storage, 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)) { + socketCount) + || !storage.rewardBuild.conditions.read_list( + progressionTable, + at + tables::kProgressionRewardConditionsOffset, + reward.condition, + conditionCount)) { return false; } reward.socketCount = static_cast(socketCount); - std::size_t conditionCount = 0; - if (!storage.rewardBuild.conditions.read_list(progressionTable, - at + tables::kProgressionRewardConditionsOffset, - reward.condition, - conditionCount)) { - return false; - } reward.conditionCount = static_cast(conditionCount); - // A reward with no claim flag carries slot 0. - if (claimSlot != 0) { - reward.claimFlagIndex = - bank_index(storage.slotMaps.accountFlag, static_cast(claimSlot)); - } - - if (!domain::valid(std::span(&reward, 1)) - || (claimSlot != 0 && reward.claimFlagIndex == domain::kUnavailableFlagIndex) - || !std::all_of(reward.sockets.begin(), - reward.sockets.begin() + reward.socketCount, - [&](const auto& socket) { - return socket.socketType != state::build_data::rewards::kAbsent - && (socket.plugItem == state::build_data::rewards::kAbsent - || socket.plugItem < itemRows.count); - })) { - return false; - } - return true; + return domain::valid(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 -/** Preserves native reward order for opcode-2400 claim indices. */ +/** + * 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. + * @param root Investment root bytes. + * @return True when the reward list read and produced at least one row. + */ bool build_season_pass(const reader::Source& source, Storage& storage, std::span root) noexcept { 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 68b58d85e..e926ff11a 100644 --- a/Sunrise/src/middleware/content/packages/tables/definition_index_table.cpp +++ b/Sunrise/src/middleware/content/packages/tables/definition_index_table.cpp @@ -127,6 +127,7 @@ 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, 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 fb78789a6..ee0bb2624 100644 --- a/Sunrise/src/middleware/content/packages/tables/definition_index_table.h +++ b/Sunrise/src/middleware/content/packages/tables/definition_index_table.h @@ -4,8 +4,6 @@ #include #include -#include "unlock_opcode.h" - namespace sunrise::middleware::content::packages::tables { /** Definition tags sit in this closed range. */ @@ -98,26 +96,27 @@ inline constexpr std::size_t kUnlockInstructionOperandOffset = 4; /** An expression field is a 64-bit count then a 64-bit self-relative offset. */ inline constexpr std::size_t kUnlockExpressionFieldSize = 16; inline constexpr std::size_t kUnlockExpressionPointerOffset = 8; +/** + * Record and node field detection accepts a field only when every opcode is at most 20. This is a + * detection bound, not the opcode range: installed reward conditions also use opcode 22. + */ +inline constexpr std::uint32_t kUnlockOpcodeCeiling = 20; /** The opcode that reads a value slot. */ -inline constexpr std::uint32_t kUnlockReadValueOpcode = - static_cast(UnlockOpcode::loadValue); +inline constexpr std::uint32_t kUnlockReadValueOpcode = 10; /** The opcode that tests a flag. */ -inline constexpr std::uint32_t kUnlockReadFlagOpcode = - static_cast(UnlockOpcode::flag); +inline constexpr std::uint32_t kUnlockReadFlagOpcode = 1; /** The opcode that pushes a literal. */ -inline constexpr std::uint32_t kUnlockLiteralOpcode = - static_cast(UnlockOpcode::constant); +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 = - static_cast(UnlockOpcode::greaterOrEqual); +inline constexpr std::uint32_t kUnlockGreaterEqualOpcode = 14; /** The opcode that inverts the value on top of the stack. Its operand is unused. */ -inline constexpr std::uint32_t kUnlockNotOpcode = - static_cast(UnlockOpcode::logicalNot); +inline constexpr std::uint32_t kUnlockNotOpcode = 2; /** The opcode that folds the top two values with logical and. */ -inline constexpr std::uint32_t kUnlockAndOpcode = - static_cast(UnlockOpcode::logicalAnd); +inline constexpr std::uint32_t kUnlockAndOpcode = 4; /** The opcode that tests the top two values for equality. */ -inline constexpr std::uint32_t kUnlockEqualOpcode = static_cast(UnlockOpcode::equal); +inline constexpr std::uint32_t kUnlockEqualOpcode = 8; /** High half every installed array marker and element class carries. */ inline constexpr std::uint32_t kDefinitionClassHigh = 0x8080U; /** Shift that leaves `kDefinitionClassHigh` from a marker or element class. */ @@ -136,7 +135,10 @@ 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 account object's profile unlock flag mapping table. */ +/** + * 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; diff --git a/Sunrise/src/middleware/content/packages/tables/field_reader.h b/Sunrise/src/middleware/content/packages/tables/field_reader.h index 018d4169b..55ed6d27e 100644 --- a/Sunrise/src/middleware/content/packages/tables/field_reader.h +++ b/Sunrise/src/middleware/content/packages/tables/field_reader.h @@ -1,7 +1,9 @@ #pragma once #include +#include #include +#include #include namespace sunrise::middleware::content::packages::tables { @@ -23,4 +25,25 @@ read(std::span blob, std::size_t offset, Value& value) noexcept 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/quest_initialization_reader.cpp b/Sunrise/src/middleware/content/packages/tables/quest_initialization_reader.cpp index b8a282baa..70230840e 100644 --- a/Sunrise/src/middleware/content/packages/tables/quest_initialization_reader.cpp +++ b/Sunrise/src/middleware/content/packages/tables/quest_initialization_reader.cpp @@ -2,7 +2,6 @@ #include -#include "../../../../state/build_data/inventory/buckets/definition.h" #include "definition_index_table.h" #include "internal.h" @@ -58,6 +57,9 @@ constexpr std::uint32_t kItemPresenceFlagClass = 0x80807D4BU; constexpr std::size_t kItemPresenceFlagStride = sizeof(std::uint16_t); /** Authored value/flag slots must fit the nonnegative range of a signed 16-bit mapping. */ constexpr std::uint16_t kUnlockSlotLimit = 0x8000U; +/** Policy: pursuits without presence flags need a bucket-37 root with no objective block. */ +constexpr std::uint8_t kSeparateQuestRootBucketId = 37; + /** Map +40 has no supported save bank; a matching slot makes initialization unsafe. */ constexpr std::size_t kThirdValueMapDescriptor = 40; /** Map +56 is also checked for duplicate slots but has no supported save bank. */ @@ -197,7 +199,7 @@ Quest read_quest_initialization(std::span definition, std::uint8_t parentBucket = 0; std::int64_t parentObjective = 0; if (parentIndex == itemIndex || !read(parent, kBucketIdOffset, parentBucket) - || parentBucket != state::build_data::inventory::buckets::kNonInventoryBucketId + || parentBucket != kSeparateQuestRootBucketId || !read(parent, kItemObjectiveBlockOffset, parentObjective) || parentObjective != 0) { return {}; } diff --git a/Sunrise/src/middleware/content/packages/tables/unlock_expression.h b/Sunrise/src/middleware/content/packages/tables/unlock_expression.h index 4c7688081..541116cb9 100644 --- a/Sunrise/src/middleware/content/packages/tables/unlock_expression.h +++ b/Sunrise/src/middleware/content/packages/tables/unlock_expression.h @@ -9,9 +9,6 @@ namespace sunrise::middleware::content::packages::tables { -/** Upstream's record/node field heuristic; this is not the reward evaluator's opcode limit. */ -inline constexpr std::uint32_t kExpressionDetectionOpcodeCeiling = 20; - /** * Reads the first operand of a given opcode out of one expression field. * The same field holds a value read on one row and a flag test on another, so the opcode is asked @@ -54,7 +51,7 @@ inline constexpr std::uint32_t kExpressionDetectionOpcodeCeiling = 20; const std::size_t at = base + static_cast(index) * kUnlockInstructionStride; std::memcpy(&instruction, table.data() + at, sizeof instruction); std::memcpy(&operand, table.data() + at + kUnlockInstructionOperandOffset, sizeof operand); - if (instruction > kExpressionDetectionOpcodeCeiling) { + if (instruction > kUnlockOpcodeCeiling) { return false; } if (instruction == opcode && operand <= static_cast(INT16_MAX)) { diff --git a/Sunrise/src/middleware/content/packages/tables/unlock_opcode.h b/Sunrise/src/middleware/content/packages/tables/unlock_opcode.h deleted file mode 100644 index 6a8982996..000000000 --- a/Sunrise/src/middleware/content/packages/tables/unlock_opcode.h +++ /dev/null @@ -1,39 +0,0 @@ -#pragma once - -#include - -namespace sunrise::middleware::content::packages::tables { - -/** Native instruction values before unlock references are bound. */ -enum class UnlockOpcode : std::uint32_t { - flag = 1, - logicalNot = 2, - logicalOr = 3, - logicalAnd = 4, - logicalNor = 5, - notEqualAlternate = 6, - logicalNand = 7, - equal = 8, - notEqual = 9, - loadValue = 10, - constant = 11, - expression = 12, - greaterThan = 13, - greaterOrEqual = 14, - lessThan = 15, - lessOrEqual = 16, - add = 17, - subtract = 18, - multiply = 19, - divide = 20, - remainder = 21, - negate = 22, - hash = 23, - hashCombine = 24, - bitwiseAnd = 25, - bitwiseOr = 26, - bitwiseXor = 27, - bitwiseNot = 28, -}; - -} // namespace sunrise::middleware::content::packages::tables diff --git a/Sunrise/src/server/bap/bap_world_reward_queue.cpp b/Sunrise/src/server/bap/bap_world_reward_queue.cpp index 389477de5..106c02572 100644 --- a/Sunrise/src/server/bap/bap_world_reward_queue.cpp +++ b/Sunrise/src/server/bap/bap_world_reward_queue.cpp @@ -1,10 +1,11 @@ #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 "core/logging/log.h" #include "internal.h" namespace sunrise::server::bap { @@ -27,10 +28,14 @@ bool commit_world_reward(const WorldRewardRequest& request) noexcept { } else { const std::unique_ptr grant( new (std::nothrow) state::PendingRecordRewardGrant); - const char* reason = "reward_allocation"; - committed = grant && request.quantity > 0 + std::uint64_t seed = 0; + const bool seeded = + middleware::crypto::random::fill(std::as_writable_bytes(std::span(&seed, 1))); + const char* reason = seeded ? "reward_allocation" : "random_source"; + committed = seeded && grant && request.quantity > 0 && state::prepare_item_reward(request.itemDefinitionIndex, static_cast(request.quantity), + seed, *grant, &reason); if (committed) { @@ -68,6 +73,7 @@ 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, diff --git a/Sunrise/src/server/bap/encrypted/push/snapshot/family4_reward_updates.cpp b/Sunrise/src/server/bap/encrypted/push/snapshot/family4_reward_updates.cpp index 9ca41d5b4..ec2d83c00 100644 --- a/Sunrise/src/server/bap/encrypted/push/snapshot/family4_reward_updates.cpp +++ b/Sunrise/src/server/bap/encrypted/push/snapshot/family4_reward_updates.cpp @@ -21,6 +21,11 @@ namespace sunrise::server::bap::encrypted::push::snapshot { namespace family4_datagen = middleware::datagen::family4; +// Every row one reward grant publishes is named in the character or the profile change list. +static_assert(state::kRecordRewardGrantCapacity + == family4_datagen::character::layout::kInventoryChangeRecordCapacity + + family4_datagen::account::layout::kProfileInventoryChangeRecordCapacity); + /** Builds all record rewards and the pending claim into one Family-4 revision. */ bool prepare_record_reward_grant( Scratch& scratch, diff --git a/Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp b/Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp index 864518683..609aed85b 100644 --- a/Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp +++ b/Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp @@ -4,17 +4,17 @@ #include #include #include +#include #include "../../../../core/logging/log.h" +#include "../../../../middleware/crypto/random_bytes.h" #include "../../../../middleware/secure_channel/runtime.h" #include "../../../../state/account/account_state.h" #include "../../../../state/activity/destination/definition.h" #include "../../../../state/activity/runtime.h" -#include "../../../../state/build_data/runtime.h" #include "../../../../state/runtime/runtime.h" #include "../internal.h" #include "../push/activity/activity_keepalive_push.h" -#include "queuez_reward_staging.h" #include "queuez_state_validation.h" #include "state/investment/store_internal.h" @@ -56,23 +56,29 @@ selected_character(const state::AccountState& account) noexcept { return nullptr; } -bool consume_world_record_reward(const WorldRewardRequest& request, - Session& session, - Scratch& scratch, - std::span response, - std::size_t& written, - bool& touchesScratch) noexcept { +/** Publishes and commits one world reward resolved through its installed reward definition. */ +[[nodiscard]] bool consume_world_record_reward(const WorldRewardRequest& request, + Session& session, + Scratch& scratch, + std::span response, + std::size_t& written, + bool& touchesScratch) noexcept { const std::unique_ptr pending( new (std::nothrow) state::PendingRecordRewardGrant); const auto fail = [&](const char* reason) noexcept { report_reward_refusal("world_publish", request.itemDefinitionIndex, reason); return false; }; + std::uint64_t seed = 0; + if (!middleware::crypto::random::fill(std::as_writable_bytes(std::span(&seed, 1)))) { + return fail("random_source"); + } const char* reason = "transaction"; state::investment::store::Transaction transaction; if (!pending || !transaction.ready() || request.quantity <= 0 || !state::prepare_item_reward(request.itemDefinitionIndex, static_cast(request.quantity), + seed, *pending, &reason)) { return fail(reason); diff --git a/Sunrise/src/server/bap/encrypted/queuez/queuez_state_validation.h b/Sunrise/src/server/bap/encrypted/queuez/queuez_state_validation.h index 7e345a29f..489777526 100644 --- a/Sunrise/src/server/bap/encrypted/queuez/queuez_state_validation.h +++ b/Sunrise/src/server/bap/encrypted/queuez/queuez_state_validation.h @@ -188,6 +188,7 @@ namespace sunrise::server::bap::encrypted::queuez { std::span appendedResidents, RecordRewardGrant& grant) noexcept; +/** Stages a prepared record-reward grant, appending the residents its new instances need. */ [[nodiscard]] bool stage_record_reward_grant(const SessionState& before, const state::PendingRecordRewardGrant& pending, RecordRewardGrant& grant) noexcept; diff --git a/Sunrise/src/server/web_service/web_service_record_actions.cpp b/Sunrise/src/server/web_service/web_service_record_actions.cpp index fae163449..119f03982 100644 --- a/Sunrise/src/server/web_service/web_service_record_actions.cpp +++ b/Sunrise/src/server/web_service/web_service_record_actions.cpp @@ -6,6 +6,7 @@ #include #include "../../core/logging/log.h" +#include "../../middleware/crypto/random_bytes.h" #include "../../middleware/web_service/messages/opcode1801.h" #include "../../middleware/web_service/messages/opcode1821.h" #include "../../middleware/web_service/messages/opcode2400.h" @@ -156,8 +157,13 @@ void claim_season_pass_reward(const middleware::web_service::Message& message, if (grant == nullptr) { return fail("storage"); } + std::uint64_t seed = 0; + if (!middleware::crypto::random::fill(std::as_writable_bytes(std::span(&seed, 1)))) { + clear_mutation(outcome); + return fail("random_source"); + } const char* reason = nullptr; - if (!state::prepare_season_pass_reward(request.rewardIndex, *grant, &reason)) { + if (!state::prepare_season_pass_reward(request.rewardIndex, seed, *grant, &reason)) { clear_mutation(outcome); return fail(reason != nullptr ? reason : "reward_preparation"); } diff --git a/Sunrise/src/state/build_data/cache/records/cache_domain_validation.cpp b/Sunrise/src/state/build_data/cache/records/cache_domain_validation.cpp index dbdee109e..0efe0c2ec 100644 --- a/Sunrise/src/state/build_data/cache/records/cache_domain_validation.cpp +++ b/Sunrise/src/state/build_data/cache/records/cache_domain_validation.cpp @@ -287,9 +287,12 @@ bool valid_domains(const BuildIdentity& build, Domains domains) noexcept { || !hash_names::valid(domains.hashNames)) { return false; } + // Reward rows are dense by item index, so each must name its item or be unavailable. + if (domains.rewardItems.size() != domains.items.size()) { + return false; + } for (std::size_t index = 0; index < domains.items.size(); ++index) { if (domains.items[index].definitionIndex != index - || domains.rewardItems.size() != domains.items.size() || (domains.rewardItems[index].definitionHash != 0 && domains.rewardItems[index].definitionHash != domains.items[index].definitionHash)) { @@ -305,10 +308,7 @@ bool valid_domains(const BuildIdentity& build, Domains domains) noexcept { return false; } for (std::size_t i = 0; i < reward.socketCount; ++i) { - const auto& socket = reward.sockets[i]; - if (socket.socketType == rewards::kAbsent - || (socket.plugItem != rewards::kAbsent - && socket.plugItem >= domains.items.size())) { + if (!rewards::valid_socket(reward.sockets[i], domains.items.size())) { return false; } } diff --git a/Sunrise/src/state/build_data/cache/records/cache_reward_records.cpp b/Sunrise/src/state/build_data/cache/records/cache_reward_records.cpp index f1198af61..4779f1526 100644 --- a/Sunrise/src/state/build_data/cache/records/cache_reward_records.cpp +++ b/Sunrise/src/state/build_data/cache/records/cache_reward_records.cpp @@ -2,6 +2,7 @@ namespace sunrise::state::build_data::cache::records { +/** Encodes one pool identity and its member range. */ bool encode(const rewards::Pool& value, RewardPoolRecord& record) noexcept { record = {}; record.definitionHash = value.definitionHash; @@ -9,6 +10,7 @@ bool encode(const rewards::Pool& value, RewardPoolRecord& record) noexcept { return true; } +/** Decodes one pool; the complete-domain validator checks its member range. */ bool decode(const RewardPoolRecord& record, rewards::Pool& value) noexcept { value = {}; value.definitionHash = record.definitionHash; @@ -16,6 +18,7 @@ bool decode(const RewardPoolRecord& record, rewards::Pool& value) noexcept { return true; } +/** Encodes one pool entry with its dependent bank ranges. */ bool encode(const rewards::Entry& value, RewardEntryRecord& record) noexcept { record = {}; record.itemIndex = value.itemIndex; @@ -31,6 +34,7 @@ bool encode(const rewards::Entry& value, RewardEntryRecord& record) noexcept { return true; } +/** Decodes one pool entry, refusing an absent-bank marker without a supplemental reference. */ bool decode(const RewardEntryRecord& record, rewards::Entry& value) noexcept { value = {}; if (record.supplementalMissing > 1 @@ -50,6 +54,7 @@ bool decode(const RewardEntryRecord& record, rewards::Entry& value) noexcept { return true; } +/** Encodes one item's wrapper and acquisition flag. */ bool encode(const rewards::Item& value, RewardItemRecord& record) noexcept { record = {}; record.definitionHash = value.definitionHash; @@ -63,6 +68,7 @@ bool encode(const rewards::Item& value, RewardItemRecord& record) noexcept { return true; } +/** Decodes one item's wrapper; the complete-domain validator checks its references. */ bool decode(const RewardItemRecord& record, rewards::Item& value) noexcept { value = {}; value.definitionHash = record.definitionHash; @@ -76,20 +82,27 @@ bool decode(const RewardItemRecord& record, rewards::Item& value) noexcept { return true; } +/** Encodes one bound condition instruction. */ bool encode(const rewards::Instruction& value, RewardInstructionRecord& record) noexcept { record = {}; - record.opcode = value.opcode; + record.opcode = static_cast(value.opcode); + record.bank = static_cast(value.bank); record.operand = value.operand; return true; } +/** Decodes one bound condition instruction, refusing an unknown opcode or bank. */ bool decode(const RewardInstructionRecord& record, rewards::Instruction& value) noexcept { value = {}; - value.opcode = record.opcode; + if (record.reserved != 0 || !unlocks::decode_opcode(record.opcode, value.opcode)) { + return false; + } + value.bank = static_cast(record.bank); value.operand = record.operand; - return true; + return unlocks::valid(value); } +/** Encodes one conditional weight modifier. */ bool encode(const rewards::Modifier& value, RewardModifierRecord& record) noexcept { record = {}; record.condition = {value.condition.first, value.condition.count}; @@ -98,6 +111,7 @@ bool encode(const rewards::Modifier& value, RewardModifierRecord& record) noexce return true; } +/** Decodes one weight modifier; the complete-domain validator checks its condition range. */ bool decode(const RewardModifierRecord& record, rewards::Modifier& value) noexcept { value = {}; value.condition = {record.condition.first, record.condition.count}; @@ -106,6 +120,7 @@ bool decode(const RewardModifierRecord& record, rewards::Modifier& value) noexce return true; } +/** Encodes one socket override. */ bool encode(const rewards::SocketOverride& value, RewardSocketOverrideRecord& record) noexcept { record = {}; record.socketType = value.socketType; @@ -116,6 +131,7 @@ bool encode(const rewards::SocketOverride& value, RewardSocketOverrideRecord& re return true; } +/** Decodes one socket override; the complete-domain validator checks its plug reference. */ bool decode(const RewardSocketOverrideRecord& record, rewards::SocketOverride& value) noexcept { value = {}; value.socketType = record.socketType; diff --git a/Sunrise/src/state/build_data/cache/records/format.h b/Sunrise/src/state/build_data/cache/records/format.h index fac6c805e..a7f1cb1be 100644 --- a/Sunrise/src/state/build_data/cache/records/format.h +++ b/Sunrise/src/state/build_data/cache/records/format.h @@ -32,7 +32,7 @@ namespace sunrise::state::build_data::cache::records { /** These 8 ASCII bytes mark a Sunrise build-data file. */ inline constexpr std::array kCacheMagic{'S', 'U', 'N', 'R', 'I', 'S', 'E', 'B'}; /** Bump when stored layouts or extracted values change; other versions are rebuilt. */ -inline constexpr std::uint32_t kCacheFormatVersion = 69; +inline constexpr std::uint32_t kCacheFormatVersion = 70; /** Signed -1 on disk means there is no equipment slot. */ inline constexpr std::int8_t kAbsentEquipmentSlot = -1; /** The standard 64-bit FNV-1a offset basis starts the payload checksum. */ @@ -319,7 +319,10 @@ struct RewardSocketOverrideRecord { /** One normalized reward-condition instruction. */ struct RewardInstructionRecord { - std::uint32_t opcode{}; + std::uint8_t opcode{}; + std::uint8_t bank{}; + /** Must be zero, so the packed instruction always matches. */ + std::uint16_t reserved{}; std::uint32_t operand{}; }; @@ -663,7 +666,8 @@ static_assert(sizeof(RewardModifierRecord) == sizeof(RewardRangeRecord) + sizeof(std::uint16_t) + sizeof(float)); static_assert(sizeof(RewardSocketOverrideRecord) == 4 * sizeof(std::uint16_t) + sizeof(std::uint32_t)); -static_assert(sizeof(RewardInstructionRecord) == 2 * sizeof(std::uint32_t)); +static_assert(sizeof(RewardInstructionRecord) + == 2 * sizeof(std::uint8_t) + sizeof(std::uint16_t) + sizeof(std::uint32_t)); static_assert(sizeof(Prefix) == kCacheMagic.size() + sizeof(std::uint32_t)); static_assert(sizeof(InvestmentConstants) diff --git a/Sunrise/src/state/build_data/inventory/buckets/definition.h b/Sunrise/src/state/build_data/inventory/buckets/definition.h index 9dbbe5510..58d0b7200 100644 --- a/Sunrise/src/state/build_data/inventory/buckets/definition.h +++ b/Sunrise/src/state/build_data/inventory/buckets/definition.h @@ -25,8 +25,11 @@ inline constexpr std::uint32_t kProfileSlotCapacity = 701; inline constexpr std::uint32_t kSmallProfileSlotCapacity = 6; /** An all-one bucket id means no runtime bucket is available. */ inline constexpr std::uint8_t kUnavailableBucketId = (std::numeric_limits::max)(); -/** Hidden plugs, perks and quest roots use this bucket instead of held inventory. */ -inline constexpr std::uint8_t kNonInventoryBucketId = 37; +/** + * Season Pass perks share this 5-slot character bucket with quest roots. A reward item here that + * carries an acquisition flag is granted by setting that flag rather than as an inventory row. + */ +inline constexpr std::uint8_t kPerkBucketId = 37; /** Leaving out the unavailable id leaves at most 255 unique bucket records. */ inline constexpr std::size_t kDescriptorCapacity = kUnavailableBucketId; /** Signed -1 marks a bucket that has no equipment slot. */ diff --git a/Sunrise/src/state/build_data/rewards/definition.h b/Sunrise/src/state/build_data/rewards/definition.h index 62285fbd9..2ce20c39d 100644 --- a/Sunrise/src/state/build_data/rewards/definition.h +++ b/Sunrise/src/state/build_data/rewards/definition.h @@ -5,22 +5,31 @@ #include #include +#include "../../unlocks/unlocks_expression.h" +#include "../items/details/definition.h" +#include "../items/item_catalog.h" + namespace sunrise::state::build_data::rewards { /** Native references use all bits set for an absent row. */ inline constexpr std::uint16_t kAbsent = 0xFFFF; -/** Bounds cover signed native indices and the flat banks they reference. */ +/** The shipped build declares 979 pools. The domain leaves room above that. */ inline constexpr std::size_t kPoolCapacity = 4096; +/** Item rows are dense over the installed item-definition table. */ +inline constexpr std::size_t kItemCapacity = items::kDefinitionCapacity; +/** + * The shipped build declares 10,549 entries, 14,185 condition instructions, 3,714 modifiers, and + * 1,812 socket overrides. Each bank leaves room above that. + */ inline constexpr std::size_t kEntryCapacity = 32768; -inline constexpr std::size_t kItemCapacity = 32768; -inline constexpr std::size_t kInstructionCapacity = 131072; +inline constexpr std::size_t kInstructionCapacity = 32768; inline constexpr std::size_t kModifierCapacity = 32768; inline constexpr std::size_t kSocketOverrideCapacity = 32768; -/** Native reward overrides address the 12 ordinary sockets on an item. */ -inline constexpr std::size_t kSocketsPerItem = 12; -/** A wrapper can select independently from four reward categories. */ +/** Reward overrides address the ordinary socket lanes an item's initial plugs fill. */ +inline constexpr std::size_t kSocketsPerItem = items::details::kInitialPlugCapacity; +/** Category selections one wrapper declares. The most any shipped wrapper declares is four. */ inline constexpr std::size_t kSelectionCapacity = 4; -/** A resolved grant fits one bounded inventory transaction. */ +/** Rows one grant may publish: one push's 16-record character and profile change lists. */ inline constexpr std::size_t kGrantCapacity = 32; /** Nested pools and referenced conditions share a bounded traversal depth. */ inline constexpr std::size_t kTraversalDepth = 32; @@ -30,22 +39,8 @@ struct Range { std::uint32_t count{}; }; -struct Instruction { - std::uint32_t opcode{}; - std::uint32_t operand{}; -}; - -/** Resolved bank reads occupy a separate range from native expression operators. */ -enum class BankRead : std::uint32_t { - accountFlag = 256, - characterFlag, - accountValue, - characterValue, - externalFlag, - externalValue, - characterClass, - profileFlag, -}; +/** Reward and pass conditions are unlock expressions bound to their saved banks. */ +using Instruction = unlocks::Instruction; /** A fixed override names its plug directly rather than selecting from a set. */ inline constexpr std::uint32_t kFixedPlugSelection = 0xFFFFFFFFU; @@ -113,4 +108,11 @@ template [[nodiscard]] constexpr bool fits(Range range, std::span #include -#include "../../account/account_state.h" #include "../../unlocks/definition.h" #include "../table.h" #include "core/threading/srw_lock.h" -#include "middleware/content/packages/tables/unlock_opcode.h" namespace sunrise::state::build_data::rewards { namespace { @@ -23,6 +21,7 @@ Table g_instructions; Table g_modifiers; Table g_sockets; +/** Borrows every published bank; the caller holds the catalog lock. */ View view() noexcept { return {g_pools.rows(), g_entries.rows(), @@ -32,6 +31,7 @@ View view() noexcept { g_sockets.rows()}; } +/** Rejects a pool cycle or a nested path deeper than kTraversalDepth, memoizing subtree heights. */ bool valid_depth(View data, std::size_t index, std::array& heights, @@ -60,6 +60,7 @@ bool valid_depth(View data, return true; } +/** Checks one entry's references, dependent ranges and weight. */ bool valid_entry(View data, const Entry& entry) noexcept { return (entry.itemIndex == kAbsent || entry.itemIndex < data.items.size()) && (entry.poolIndex == kAbsent || entry.poolIndex < data.pools.size()) @@ -68,6 +69,7 @@ bool valid_entry(View data, const Entry& entry) noexcept { && fits(entry.sockets, data.sockets) && std::isfinite(entry.weight) && entry.weight >= 0; } +/** An unavailable item row carries nothing; an available one names valid banks. */ bool valid_item(View data, const Item& item) noexcept { if (item.definitionHash == 0) { return item.poolIndex == kAbsent && item.acquiredFlag == kAbsent @@ -80,6 +82,7 @@ bool valid_item(View data, const Item& item) noexcept { } // namespace +/** Discards the reward graph and every borrowed bank. */ void clear() noexcept { const std::lock_guard guard(g_lock); g_pools.clear(); @@ -90,51 +93,13 @@ void clear() noexcept { g_sockets.clear(); } +/** @return True once pool and item banks have been published. */ bool ready() noexcept { const std::shared_lock guard(g_lock); return g_pools.count() != 0 && g_items.count() != 0; } -bool valid_instruction(const Instruction& instruction) noexcept { - const auto slot = instruction.operand; - switch (static_cast(instruction.opcode)) { - case BankRead::accountFlag: - return slot < unlocks::kAccountFlagCapacity; - case BankRead::profileFlag: - return slot < unlocks::kProfileFlagCapacity; - case BankRead::characterFlag: - return slot < unlocks::kCharacterObjectFlagCapacity; - case BankRead::accountValue: - return slot < unlocks::kObjectiveValueCapacity; - case BankRead::characterValue: - return slot < unlocks::kCharacterObjectValueCapacity; - case BankRead::characterClass: - return slot < kCharacterClassCount; - case BankRead::externalFlag: - case BankRead::externalValue: - return true; - default: - break; - } - using Op = middleware::content::packages::tables::UnlockOpcode; - switch (static_cast(instruction.opcode)) { - case Op::constant: - case Op::logicalNot: - case Op::logicalOr: - case Op::logicalAnd: - case Op::equal: - case Op::greaterThan: - case Op::greaterOrEqual: - case Op::lessThan: - case Op::lessOrEqual: - case Op::add: - case Op::negate: - return true; - default: - return false; - } -} - +/** Checks bank ranges, item references and bounded acyclic pool traversal. */ bool valid(View data) noexcept { if (data.pools.empty() || data.pools.size() > kPoolCapacity || data.entries.empty() || data.entries.size() > kEntryCapacity || data.items.empty() @@ -159,7 +124,7 @@ bool valid(View data) noexcept { return false; } } - if (!std::all_of(data.instructions.begin(), data.instructions.end(), valid_instruction)) { + if (!std::all_of(data.instructions.begin(), data.instructions.end(), unlocks::valid)) { return false; } for (const Modifier& modifier : data.modifiers) { @@ -168,8 +133,7 @@ bool valid(View data) noexcept { } } for (const SocketOverride& socket : data.sockets) { - if (socket.socketType == kAbsent - || (socket.plugItem != kAbsent && socket.plugItem >= data.items.size())) { + if (!valid_socket(socket, data.items.size())) { return false; } } @@ -182,6 +146,7 @@ bool valid(View data) noexcept { return true; } +/** Publishes validated banks together under the catalog lock. */ bool replace(View data) noexcept { if (!valid(data)) { return false; @@ -192,11 +157,13 @@ bool replace(View data) noexcept { && g_modifiers.replace(data.modifiers) && g_sockets.replace(data.sockets); } +/** Lends every bank to one callback under the read lock. */ bool read(void* context, bool (*consume)(void*, View) noexcept) noexcept { const std::shared_lock guard(g_lock); return consume != nullptr && g_pools.count() != 0 && consume(context, view()); } +/** Copies one extracted item row; unavailable rows are refused. */ bool find_item(std::uint16_t itemIndex, Item& item) noexcept { item = {}; const std::shared_lock guard(g_lock); @@ -207,31 +174,37 @@ bool find_item(std::uint16_t itemIndex, Item& item) noexcept { return true; } +/** Copies every pool; count is zero when the output is too small. */ bool snapshot(std::span output, std::size_t& count) noexcept { const std::shared_lock guard(g_lock); return g_pools.snapshot(output, count); } +/** Copies every entry; count is zero when the output is too small. */ bool snapshot(std::span output, std::size_t& count) noexcept { const std::shared_lock guard(g_lock); return g_entries.snapshot(output, count); } +/** Copies every item row; count is zero when the output is too small. */ bool snapshot(std::span output, std::size_t& count) noexcept { const std::shared_lock guard(g_lock); return g_items.snapshot(output, count); } +/** Copies every condition instruction; count is zero when the output is too small. */ bool snapshot(std::span output, std::size_t& count) noexcept { const std::shared_lock guard(g_lock); return g_instructions.snapshot(output, count); } +/** Copies every modifier; count is zero when the output is too small. */ bool snapshot(std::span output, std::size_t& count) noexcept { const std::shared_lock guard(g_lock); return g_modifiers.snapshot(output, count); } +/** Copies every socket override; count is zero when the output is too small. */ bool snapshot(std::span output, std::size_t& count) noexcept { const std::shared_lock guard(g_lock); return g_sockets.snapshot(output, count); diff --git a/Sunrise/src/state/build_data/rewards/reward_catalog.h b/Sunrise/src/state/build_data/rewards/reward_catalog.h index b7f4733b9..2818fa8da 100644 --- a/Sunrise/src/state/build_data/rewards/reward_catalog.h +++ b/Sunrise/src/state/build_data/rewards/reward_catalog.h @@ -8,8 +8,6 @@ namespace sunrise::state::build_data::rewards { void clear() noexcept; /** True once pool and item banks have been published. */ [[nodiscard]] bool ready() noexcept; -/** Checks a bound instruction against its saved bank and the native opcode range. */ -[[nodiscard]] bool valid_instruction(const Instruction& instruction) noexcept; /** Checks bank ranges, item references and bounded acyclic pool traversal. */ [[nodiscard]] bool valid(View data) noexcept; /** Publishes validated banks together under the catalog lock. */ diff --git a/Sunrise/src/state/build_data/runtime.h b/Sunrise/src/state/build_data/runtime.h index 75afd78d7..037079c6a 100644 --- a/Sunrise/src/state/build_data/runtime.h +++ b/Sunrise/src/state/build_data/runtime.h @@ -406,6 +406,36 @@ publish_progression_definitions(std::span defini [[nodiscard]] bool find_season_pass_reward(std::uint16_t rewardIndex, season_pass::Reward& reward) noexcept; +/** @return Season pass reward rows in State, including unavailable rows. */ +[[nodiscard]] std::size_t season_pass_reward_count() noexcept; + +/** @return True when the reward pools and item wrappers are in State. */ +[[nodiscard]] bool reward_definitions_ready() noexcept; + +/** + * Publishes the complete reward graph in one step. + * @param definitions Pools, entries, item rows, conditions, modifiers and socket overrides. + * @return True when the graph passes the checks and any needed cache write succeeds. + */ +[[nodiscard]] bool publish_reward_definitions(rewards::View definitions) noexcept; + +/** + * Reads one item's reward row. + * @param itemIndex Native item-definition index. + * @param item Receives the row. + * @return True when the catalog holds an extracted row for that item. + */ +[[nodiscard]] bool find_reward_item(std::uint16_t itemIndex, rewards::Item& item) noexcept; + +/** + * Lends the complete reward graph to one callback under the catalog's read lock. + * @param context Caller state passed through unchanged. + * @param consume Receives the graph, which stays valid only during the call. + * @return The callback's result, or false when no graph is published. + */ +[[nodiscard]] bool read_reward_definitions(void* context, + bool (*consume)(void*, rewards::View) noexcept) noexcept; + /** @return True when the repeatable bounty table is in State. */ [[nodiscard]] bool repeatable_bounties_ready() noexcept; @@ -743,6 +773,4 @@ publish_vendor_catalog(std::span index, */ [[nodiscard]] bool persist() noexcept; -/** Publishes a complete, validated reward graph. */ -[[nodiscard]] bool publish_reward_definitions(rewards::View definitions) noexcept; } // namespace sunrise::state::build_data diff --git a/Sunrise/src/state/build_data/runtime/build_data_catalog_runtime.cpp b/Sunrise/src/state/build_data/runtime/build_data_catalog_runtime.cpp index 13be95a9d..602a019f7 100644 --- a/Sunrise/src/state/build_data/runtime/build_data_catalog_runtime.cpp +++ b/Sunrise/src/state/build_data/runtime/build_data_catalog_runtime.cpp @@ -387,10 +387,4 @@ void clear_catalogs() noexcept { } // namespace runtime -bool publish_reward_definitions(rewards::View definitions) noexcept { - runtime::persistence::Transaction transaction; - return transaction.active() - && transaction.finish(rewards::replace(definitions), rewards::clear); -} - } // namespace sunrise::state::build_data diff --git a/Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.cpp b/Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.cpp index 118f13795..aabf7b6ad 100644 --- a/Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.cpp +++ b/Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.cpp @@ -131,7 +131,7 @@ to_record(const constants::InvestmentConstants& value) noexcept { && material_requirement_sets_ready() && inventory_bucket_descriptors_ready() && socket_entry_lists_ready() && ability_buckets_ready() && progression_definitions_ready() && season_pass_ready() && repeatable_bounties_ready() - && rewards::ready() && record_definitions_ready() && node_definitions_ready() + && reward_definitions_ready() && record_definitions_ready() && node_definitions_ready() && sobject_definitions_ready() && scenario_layouts_ready() && spawn_sets_ready() && hash_names_ready() && vendor_catalog_ready() && gameplay::entity_position_profiles::available() @@ -273,14 +273,14 @@ cache::records::MutableDomains scratch_domains(Context& state) noexcept { progressionSteps, seasonPassRewards, bountyRows, - ensure_scratch(state.rewardPoolsScratch), - ensure_scratch(state.rewardEntriesScratch), - ensure_scratch(state.rewardItemsScratch), + ensure_scratch(state.rewardPoolScratch), + ensure_scratch(state.rewardEntryScratch), + ensure_scratch(state.rewardItemScratch), ensure_scratch( - state.rewardInstructionsScratch), - ensure_scratch(state.rewardModifiersScratch), + state.rewardInstructionScratch), + ensure_scratch(state.rewardModifierScratch), ensure_scratch( - state.rewardSocketsScratch), + state.rewardSocketScratch), }; } @@ -311,13 +311,12 @@ void release_scratch_locked(Context& state) noexcept { release_bank(state.progressionStepScratch); release_bank(state.seasonPassRewardScratch); release_bank(state.bountyScratch); - release_bank(state.rewardPoolsScratch); - release_bank(state.rewardEntriesScratch); - release_bank(state.rewardItemsScratch); - release_bank(state.rewardInstructionsScratch); - release_bank(state.rewardModifiersScratch); - release_bank(state.rewardSocketsScratch); - + release_bank(state.rewardPoolScratch); + release_bank(state.rewardEntryScratch); + release_bank(state.rewardItemScratch); + release_bank(state.rewardInstructionScratch); + release_bank(state.rewardModifierScratch); + release_bank(state.rewardSocketScratch); release_bank(state.recordScratch); release_bank(state.recordObjectiveScratch); release_bank(state.recordIntervalScratch); @@ -420,14 +419,14 @@ cache::records::Domains occupied_domains(Context& state, std::span{state.seasonPassRewardScratch.data(), counts.seasonPassRewards}, std::span{state.bountyScratch.data(), counts.bounties}, - std::span{state.rewardPoolsScratch.data(), counts.rewardPools}, - std::span{state.rewardEntriesScratch.data(), counts.rewardEntries}, - std::span{state.rewardItemsScratch.data(), counts.rewardItems}, - std::span{state.rewardInstructionsScratch.data(), + std::span{state.rewardPoolScratch.data(), counts.rewardPools}, + std::span{state.rewardEntryScratch.data(), counts.rewardEntries}, + std::span{state.rewardItemScratch.data(), counts.rewardItems}, + std::span{state.rewardInstructionScratch.data(), counts.rewardInstructions}, - std::span{state.rewardModifiersScratch.data(), + std::span{state.rewardModifierScratch.data(), counts.rewardModifiers}, - std::span{state.rewardSocketsScratch.data(), + std::span{state.rewardSocketScratch.data(), counts.rewardSockets}, }; } diff --git a/Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.h b/Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.h index 0aca94956..2de8ae5ca 100644 --- a/Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.h +++ b/Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.h @@ -80,13 +80,12 @@ struct Context { std::vector progressionStepScratch{}; std::vector seasonPassRewardScratch{}; std::vector bountyScratch{}; - std::vector rewardPoolsScratch{}; - std::vector rewardEntriesScratch{}; - std::vector rewardItemsScratch{}; - std::vector rewardInstructionsScratch{}; - std::vector rewardModifiersScratch{}; - std::vector rewardSocketsScratch{}; - + std::vector rewardPoolScratch{}; + std::vector rewardEntryScratch{}; + std::vector rewardItemScratch{}; + std::vector rewardInstructionScratch{}; + std::vector rewardModifierScratch{}; + std::vector rewardSocketScratch{}; std::vector recordScratch{}; std::vector recordObjectiveScratch{}; std::vector recordIntervalScratch{}; diff --git a/Sunrise/src/state/build_data/season_pass/definition.h b/Sunrise/src/state/build_data/season_pass/definition.h index bd4e80e91..83bada05d 100644 --- a/Sunrise/src/state/build_data/season_pass/definition.h +++ b/Sunrise/src/state/build_data/season_pass/definition.h @@ -14,8 +14,8 @@ inline constexpr std::size_t kRewardCapacity = 256; /** A reward whose claim flag no mapping table addresses carries this instead of an index. */ inline constexpr std::uint16_t kUnavailableFlagIndex = 0xFFFFU; -/** Bound on the expanded eligibility expression of one pass row. */ -inline constexpr std::size_t kConditionCapacity = 64; +/** Instructions in one row's expanded eligibility condition. The longest shipped row needs 5. */ +inline constexpr std::size_t kConditionCapacity = 16; /** One reward row of the pass, in the native order the opcode-2400 claim names by index. */ struct Reward { @@ -29,8 +29,10 @@ struct Reward { std::uint16_t claimFlagIndex{kUnavailableFlagIndex}; /** Rank the account needs before the row may be claimed. */ std::uint8_t requiredRank{}; + /** Fixed plugs this row places on the granted item, in native order. */ std::array sockets{}; std::uint8_t socketCount{}; + /** Bound eligibility condition; every native expression on the row must hold. */ std::array condition{}; std::uint8_t conditionCount{}; }; diff --git a/Sunrise/src/state/build_data/season_pass/season_pass_build_data_runtime.cpp b/Sunrise/src/state/build_data/season_pass/season_pass_build_data_runtime.cpp index e296f6f82..8994ec159 100644 --- a/Sunrise/src/state/build_data/season_pass/season_pass_build_data_runtime.cpp +++ b/Sunrise/src/state/build_data/season_pass/season_pass_build_data_runtime.cpp @@ -21,4 +21,9 @@ bool find_season_pass_reward(std::uint16_t rewardIndex, season_pass::Reward& rew return season_pass::find(rewardIndex, reward); } +/** @return Season pass reward rows in State. */ +std::size_t season_pass_reward_count() noexcept { + return season_pass::count(); +} + } // namespace sunrise::state::build_data diff --git a/Sunrise/src/state/build_data/season_pass/season_pass_catalog.cpp b/Sunrise/src/state/build_data/season_pass/season_pass_catalog.cpp index b0b6af5d0..bb04f701c 100644 --- a/Sunrise/src/state/build_data/season_pass/season_pass_catalog.cpp +++ b/Sunrise/src/state/build_data/season_pass/season_pass_catalog.cpp @@ -1,11 +1,12 @@ #include "season_pass_catalog.h" #include +#include #include #include #include "../../unlocks/definition.h" -#include "../rewards/reward_catalog.h" +#include "../../unlocks/unlocks_expression.h" #include "../table.h" #include "core/threading/srw_lock.h" @@ -15,30 +16,43 @@ core::threading::SrwLock g_lock; Table g_rewards; } // namespace +/** Clears the reward rows under the catalog lock. */ void clear() noexcept { const std::lock_guard guard(g_lock); g_rewards.clear(); } +/** Checks one complete pass catalog; unavailable rows keep their native positions. */ bool valid(std::span rewards) noexcept { - return !rewards.empty() && rewards.size() <= kRewardCapacity - && std::all_of(rewards.begin(), rewards.end(), [](const Reward& reward) { - if (reward.itemHash == 0) { - return reward.quantity == 0 && reward.conditionCount == 0 - && reward.socketCount == 0 - && reward.claimFlagIndex == kUnavailableFlagIndex; - } - return reward.quantity != 0 && reward.quantity <= INT32_MAX - && (reward.claimFlagIndex == kUnavailableFlagIndex - || reward.claimFlagIndex < unlocks::kAccountFlagCapacity) - && reward.socketCount <= reward.sockets.size() - && reward.conditionCount <= reward.condition.size() - && std::all_of(reward.condition.begin(), - reward.condition.begin() + reward.conditionCount, - rewards::valid_instruction); - }); + if (rewards.empty() || rewards.size() > kRewardCapacity) { + return false; + } + constexpr auto kMaximumQuantity = + static_cast((std::numeric_limits::max)()); + for (const Reward& reward : rewards) { + // An unavailable row keeps its native claim index and grants nothing. + if (reward.itemHash == 0) { + if (reward.quantity != 0 || reward.conditionCount != 0 || reward.socketCount != 0 + || reward.claimFlagIndex != kUnavailableFlagIndex) { + return false; + } + continue; + } + if (reward.quantity == 0 || reward.quantity > kMaximumQuantity + || (reward.claimFlagIndex != kUnavailableFlagIndex + && reward.claimFlagIndex >= unlocks::kAccountFlagCapacity) + || reward.socketCount > reward.sockets.size() + || reward.conditionCount > reward.condition.size() + || !std::all_of(reward.condition.begin(), + reward.condition.begin() + reward.conditionCount, + unlocks::valid)) { + return false; + } + } + return true; } +/** Replaces the complete pass catalog in one step. */ bool replace(std::span rewards) noexcept { if (!valid(rewards)) { return false; @@ -47,6 +61,7 @@ bool replace(std::span rewards) noexcept { return g_rewards.replace(rewards); } +/** Reads one available reward row by the index an opcode-2400 claim names. */ bool find(std::uint16_t rewardIndex, Reward& reward) noexcept { reward = {}; const std::shared_lock guard(g_lock); @@ -57,11 +72,13 @@ bool find(std::uint16_t rewardIndex, Reward& reward) noexcept { return true; } +/** Copies every reward row in native reward order. */ bool snapshot(std::span output, std::size_t& count) noexcept { const std::shared_lock guard(g_lock); return g_rewards.snapshot(output, count); } +/** @return The reward row count, including unavailable rows, read under the lock. */ std::size_t count() noexcept { const std::shared_lock guard(g_lock); return g_rewards.count(); diff --git a/Sunrise/src/state/build_data/vendors/vendor_expression.cpp b/Sunrise/src/state/build_data/vendors/vendor_expression.cpp deleted file mode 100644 index 6e3464e41..000000000 --- a/Sunrise/src/state/build_data/vendors/vendor_expression.cpp +++ /dev/null @@ -1,119 +0,0 @@ -#include "vendor_expression.h" - -#include - -namespace sunrise::state::build_data::vendors { -namespace { - -/** What one stack slot holds. The two kinds never substitute for each other. */ -enum class Kind : std::uint8_t { - boolean, - number, -}; - -/** One typed stack slot. */ -struct Slot { - Kind kind{}; - bool boolean{}; - std::int32_t number{}; -}; - -/** Fixed evaluation stack, which refuses rather than growing. */ -class Stack final { -public: - /** @param slot Value to push. @return True when there was room. */ - [[nodiscard]] bool push(const Slot& slot) noexcept { - if (depth_ == slots_.size()) { - return false; - } - slots_[depth_++] = slot; - return true; - } - - /** @param kind Required kind. @param slot Receives the value. @return True when it matched. */ - [[nodiscard]] bool pop(Kind kind, Slot& slot) noexcept { - if (depth_ == 0 || slots_[depth_ - 1].kind != kind) { - return false; - } - slot = slots_[--depth_]; - return true; - } - - /** @return Slots currently held. */ - [[nodiscard]] std::size_t depth() const noexcept { - return depth_; - } - -private: - std::array slots_{}; - std::size_t depth_{}; -}; - -/** @param value Boolean result. @return The stack slot holding it. */ -[[nodiscard]] Slot boolean_slot(bool value) noexcept { - return {Kind::boolean, value, 0}; -} - -/** @param value Numeric result. @return The stack slot holding it. */ -[[nodiscard]] Slot number_slot(std::int32_t value) noexcept { - return {Kind::number, false, value}; -} - -/** - * Runs one instruction against the stack. - * @param instruction Instruction to run. - * @param inputs Flag and value readers. - * @param stack Evaluation stack. - * @return True when the opcode is known and its operands were there in the right kind. - */ -[[nodiscard]] bool -step(const Instruction& instruction, const Inputs& inputs, Stack& stack) noexcept { - Slot left{}; - Slot right{}; - switch (instruction.opcode) { - case Opcode::flag: { - std::uint8_t logical = 0; - return inputs.flag(inputs.context, instruction.operand, logical) - && stack.push(boolean_slot(logical == kFlagActive)); - } - case Opcode::logicalNot: - return stack.pop(Kind::boolean, left) && stack.push(boolean_slot(!left.boolean)); - case Opcode::loadValue: { - std::int32_t value = 0; - return inputs.value(inputs.context, instruction.operand, value) - && stack.push(number_slot(value)); - } - case Opcode::constant: - return stack.push(number_slot(instruction.operand)); - case Opcode::lessThan: - // The right operand was pushed last, so it comes off first. - return stack.pop(Kind::number, right) && stack.pop(Kind::number, left) - && stack.push(boolean_slot(left.number < right.number)); - default: - return false; - } -} - -} // namespace - -/** Evaluates one expression program. */ -bool evaluate(std::span program, const Inputs& inputs, bool& result) noexcept { - result = false; - if (program.empty() || inputs.flag == nullptr || inputs.value == nullptr) { - return false; - } - Stack stack; - for (const Instruction& instruction : program) { - if (!step(instruction, inputs, stack)) { - return false; - } - } - Slot final{}; - if (stack.depth() != 1 || !stack.pop(Kind::boolean, final)) { - return false; - } - result = final.boolean; - return true; -} - -} // namespace sunrise::state::build_data::vendors diff --git a/Sunrise/src/state/build_data/vendors/vendor_expression.h b/Sunrise/src/state/build_data/vendors/vendor_expression.h deleted file mode 100644 index 3bc56eb29..000000000 --- a/Sunrise/src/state/build_data/vendors/vendor_expression.h +++ /dev/null @@ -1,58 +0,0 @@ -#pragma once - -#include -#include -#include - -namespace sunrise::state::build_data::vendors { - -/** Stack slots one expression may use. Known gates need two, so this leaves headroom. */ -inline constexpr std::size_t kExpressionStackCapacity = 16; - -/** Opcodes the client's expression evaluator implements. */ -enum class Opcode : std::uint8_t { - /** Pushes true when the flag's logical byte is `kFlagActive`. Operand is the flag slot. */ - flag = 1, - /** Pops one boolean and pushes its negation. */ - logicalNot = 2, - /** Pushes one progression value. Operand is the value slot. */ - loadValue = 10, - /** Pushes the operand as a constant. */ - constant = 11, - /** Pops right then left and pushes left less than right. */ - lessThan = 15, -}; - -/** A flag is active only for this logical byte. Logical 0 and 1 are both inactive. */ -inline constexpr std::uint8_t kFlagActive = 2; - -/** One expression instruction. Opcodes with no operand leave it unread. */ -struct Instruction { - Opcode opcode{}; - std::uint16_t operand{}; -}; - -/** Reads one flag's logical byte. Returns false when the slot has no value. */ -using FlagReader = bool (*)(void* context, std::uint16_t slot, std::uint8_t& logical) noexcept; -/** Reads one progression value. Returns false when the slot has no value. */ -using ValueReader = bool (*)(void* context, std::uint16_t slot, std::int32_t& value) noexcept; - -/** The two readers one evaluation needs, and the caller state they share. */ -struct Inputs { - FlagReader flag{}; - ValueReader value{}; - void* context{}; -}; - -/** - * Evaluates one expression program. - * Every failure is refused, never defaulted: bad opcode, bad stack, bad kind, unreadable slot. - * @param program Instructions in evaluation order. - * @param inputs Flag and value readers. - * @param result Receives the expression result, and is cleared on any refusal. - * @return True only when the whole program evaluated to one boolean. - */ -[[nodiscard]] bool -evaluate(std::span program, const Inputs& inputs, bool& result) noexcept; - -} // namespace sunrise::state::build_data::vendors diff --git a/Sunrise/src/state/progression/season_pass_reward_catalog.h b/Sunrise/src/state/progression/season_pass_reward_catalog.h index a3cc88e75..2ce03e78b 100644 --- a/Sunrise/src/state/progression/season_pass_reward_catalog.h +++ b/Sunrise/src/state/progression/season_pass_reward_catalog.h @@ -11,7 +11,8 @@ inline constexpr std::uint16_t kProgressionDefinitionIndex = 40; /** Repeating HUD bar paired with the Arrivals progression. */ inline constexpr std::uint16_t kHudProgressionDefinitionIndex = 41; -/** Acquisition flag of a direct perk reward, or rewards::kAbsent for an item or package. */ +/** Acquisition flag of a direct perk reward, or build_data::rewards::kAbsent for an item or + * package. */ [[nodiscard]] std::uint16_t progress_flag(const build_data::season_pass::Reward& reward) noexcept; } // namespace sunrise::state::progression::season_pass diff --git a/Sunrise/src/state/rewards/reward_resolver.cpp b/Sunrise/src/state/rewards/reward_resolver.cpp index d9a544687..a3e766e31 100644 --- a/Sunrise/src/state/rewards/reward_resolver.cpp +++ b/Sunrise/src/state/rewards/reward_resolver.cpp @@ -2,9 +2,10 @@ #include #include +#include -#include "../build_data/rewards/reward_catalog.h" -#include "middleware/content/packages/tables/unlock_opcode.h" +#include "../build_data/runtime.h" +#include "../unlocks/unlocks_expression.h" namespace sunrise::state::rewards { namespace { @@ -12,10 +13,11 @@ namespace { namespace definitions = build_data::rewards; /** The empty category tag matches every wrapper selection. */ constexpr std::uint32_t kEmptyTag = 0x811C9DC5U; -/** Native postfix expressions use at most 256 signed 32-bit stack values. */ -constexpr std::size_t kExpressionCapacity = 256; /** Native wrappers retain up to 64 selections, including rows without an item grant. */ constexpr std::size_t kDrawCapacity = 64; +/** A grant quantity is published as a signed 32-bit stack size. */ +constexpr auto kMaximumQuantity = + static_cast((std::numeric_limits::max)()); bool refuse(const Context& context, const char* reason) noexcept { if (context.refusal != nullptr) { @@ -24,133 +26,77 @@ bool refuse(const Context& context, const char* reason) noexcept { return false; } -bool apply_condition_operator(const definitions::Instruction& instruction, - std::span stack, - std::size_t& size, - const Context& context) noexcept { - if (!definitions::valid_instruction(instruction)) { - return refuse(context, "condition_opcode"); - } - using Op = middleware::content::packages::tables::UnlockOpcode; - const auto opcode = static_cast(instruction.opcode); - const bool unary = opcode == Op::logicalNot || opcode == Op::negate; - if (size < (unary ? 1U : 2U)) { - return refuse(context, "condition_shape"); +/** Reads one validated flag from the caller's unlock banks or selected character. */ +bool read_flag(const void* raw, const unlocks::Instruction& instruction, bool& set) noexcept { + const auto& context = *static_cast(raw); + const auto index = instruction.operand; + switch (instruction.bank) { + case unlocks::Bank::account: + set = context.unlocks.accountFlags[index] == unlocks::kFlagSet; + return true; + case unlocks::Bank::profile: + set = context.unlocks.profileFlags[index] == unlocks::kFlagSet; + return true; + case unlocks::Bank::character: + set = context.unlocks.characterObjectFlags[index] == unlocks::kFlagSet; + return true; + case unlocks::Bank::characterClass: + set = index == static_cast(context.characterClass); + return true; + default: + return false; } - const auto right = unary ? 0 : stack[--size]; - auto& left = stack[size - 1]; - switch (opcode) { - case Op::logicalNot: - left = left == 0; - break; - case Op::negate: - left = static_cast(0U - static_cast(left)); - break; - case Op::logicalOr: - left = left != 0 || right != 0; - break; - case Op::logicalAnd: - left = left != 0 && right != 0; - break; - case Op::equal: - left = left == right; - break; - case Op::greaterThan: - left = left > right; - break; - case Op::greaterOrEqual: - left = left >= right; - break; - case Op::lessOrEqual: - left = left <= right; - break; - case Op::add: - left = static_cast(static_cast(left) - + static_cast(right)); - break; - case Op::lessThan: - left = left < right; - break; +} + +/** Reads one validated value from the caller's unlock banks. */ +bool read_value(const void* raw, + const unlocks::Instruction& instruction, + std::int32_t& value) noexcept { + const auto& context = *static_cast(raw); + switch (instruction.bank) { + case unlocks::Bank::account: + value = context.unlocks.objectiveValues[instruction.operand]; + return true; + case unlocks::Bank::character: + value = context.unlocks.characterObjectValues[instruction.operand]; + return true; default: - return refuse(context, "condition_opcode"); + return false; } - return true; } -bool condition(definitions::View data, - definitions::Range expression, +/** An empty program is unconditional; external identities are not in the saved banks. */ +bool condition(std::span program, const Context& context, bool& result) noexcept { - result = expression.count == 0; - if (!definitions::fits(expression, data.instructions)) { - return refuse(context, "condition_shape"); + result = program.empty(); + if (program.empty()) { + return true; } - std::array stack{}; - std::size_t size = 0; - for (const auto& instruction : data.instructions.subspan(expression.first, expression.count)) { - const auto operand = instruction.operand; - std::int32_t value = 0; - using Read = definitions::BankRead; - using Op = middleware::content::packages::tables::UnlockOpcode; - switch (instruction.opcode) { - case static_cast(Read::accountFlag): - if (operand >= context.unlocks.accountFlags.size()) { - return refuse(context, "condition_shape"); - } - value = context.unlocks.accountFlags[operand] == unlocks::kFlagSet; - break; - case static_cast(Read::profileFlag): - if (operand >= context.unlocks.profileFlags.size()) { - return refuse(context, "condition_shape"); - } - value = context.unlocks.profileFlags[operand] == unlocks::kFlagSet; - break; - case static_cast(Read::characterFlag): - if (operand >= context.unlocks.characterObjectFlags.size()) { - return refuse(context, "condition_shape"); - } - value = context.unlocks.characterObjectFlags[operand] == unlocks::kFlagSet; - break; - case static_cast(Read::accountValue): - if (operand >= context.unlocks.objectiveValues.size()) { - return refuse(context, "condition_shape"); - } - value = context.unlocks.objectiveValues[operand]; - break; - case static_cast(Read::characterValue): - if (operand >= context.unlocks.characterObjectValues.size()) { - return refuse(context, "condition_shape"); - } - value = context.unlocks.characterObjectValues[operand]; - break; - case static_cast(Read::characterClass): - value = operand == static_cast(context.characterClass); - break; - case static_cast(Op::constant): - value = static_cast(operand); - break; - case static_cast(Read::externalFlag): - return refuse(context, "external_flag"); - case static_cast(Read::externalValue): - return refuse(context, "external_value"); - default: - if (!apply_condition_operator(instruction, stack, size, context)) { - return false; - } - continue; - } - if (size == stack.size()) { + for (const auto& instruction : program) { + if (!unlocks::valid(instruction)) { return refuse(context, "condition_shape"); } - stack[size++] = value; - } - if (expression.count != 0) { - if (size != 1) { - return refuse(context, "condition_shape"); + if (instruction.bank == unlocks::Bank::external) { + return refuse(context, + instruction.opcode == unlocks::Opcode::flag ? "external_flag" + : "external_value"); } - result = stack[0] != 0; } - return true; + const unlocks::Inputs inputs{read_flag, read_value, &context}; + return unlocks::evaluate(program, inputs, result) || refuse(context, "condition_shape"); +} + +bool condition(definitions::View data, + definitions::Range expression, + const Context& context, + bool& result) noexcept { + if (!definitions::fits(expression, data.instructions)) { + result = false; + return refuse(context, "condition_shape"); + } + return condition( + data.instructions.subspan(expression.first, expression.count), context, result); } struct Resolver { @@ -300,7 +246,7 @@ struct Resolver { if (chosen->itemIndex == definitions::kAbsent) { return true; } - if (chosen->quantity == 0 || chosen->quantity > INT32_MAX + if (chosen->quantity == 0 || chosen->quantity > kMaximumQuantity || result.count == result.grants.size() || !definitions::fits(chosen->sockets, data.sockets)) { return false; @@ -327,7 +273,7 @@ bool resolve_item(definitions::View data, if (context.refusal != nullptr) { *context.refusal = "reward_shape"; } - if (itemIndex >= data.items.size() || quantity == 0 || quantity > INT32_MAX) { + if (itemIndex >= data.items.size() || quantity == 0 || quantity > kMaximumQuantity) { return false; } const auto& item = data.items[itemIndex]; @@ -382,12 +328,7 @@ bool resolve_item(definitions::View data, bool eligible(std::span instructions, const Context& context, bool& result) noexcept { - definitions::View view{}; - view.instructions = instructions; - if (instructions.size() > UINT32_MAX) { - return false; - } - return condition(view, {0, static_cast(instructions.size())}, context, result); + return condition(instructions, context, result); } bool resolve(const Context& context, @@ -404,10 +345,11 @@ bool resolve(const Context& context, if (context.refusal != nullptr) { *context.refusal = "reward_item"; } - return definitions::read(&request, [](void* raw, definitions::View data) noexcept { - auto& value = *static_cast(raw); - return resolve_item(data, value.context, value.item, value.quantity, value.result); - }); + return build_data::read_reward_definitions( + &request, [](void* raw, definitions::View data) noexcept { + auto& value = *static_cast(raw); + return resolve_item(data, value.context, value.item, value.quantity, value.result); + }); } } // namespace sunrise::state::rewards diff --git a/Sunrise/src/state/rewards/reward_resolver.h b/Sunrise/src/state/rewards/reward_resolver.h index 82ce5fc45..97034441b 100644 --- a/Sunrise/src/state/rewards/reward_resolver.h +++ b/Sunrise/src/state/rewards/reward_resolver.h @@ -6,6 +6,7 @@ namespace sunrise::state::rewards { +/** One item a resolved reward grants, with the socket overrides its row carries. */ struct Grant { std::uint16_t itemIndex{build_data::rewards::kAbsent}; std::int32_t quantity{}; @@ -13,6 +14,7 @@ struct Grant { std::size_t socketCount{}; }; +/** Every grant one resolution produces, in draw order. */ struct Result { std::array grants{}; std::size_t count{}; @@ -27,6 +29,7 @@ struct Context { const char** refusal{}; }; +/** Evaluates one bound condition; an empty condition is always eligible. */ [[nodiscard]] bool eligible(std::span instructions, const Context& context, bool& result) noexcept; diff --git a/Sunrise/src/state/runtime/runtime.h b/Sunrise/src/state/runtime/runtime.h index 25db08302..62aa4a21b 100644 --- a/Sunrise/src/state/runtime/runtime.h +++ b/Sunrise/src/state/runtime/runtime.h @@ -25,8 +25,9 @@ struct SettingsDelta; namespace sunrise::state { -/** Acquisition planner selected from installed wrapper, quest and bucket definitions. */ +/** Which prepare path grants one installed item. */ enum class ItemGrantRoute : std::uint8_t { unavailable, quest, profile, reward }; +/** Chooses the prepare path from the item's wrapper, quest setup and bucket. */ [[nodiscard]] ItemGrantRoute item_grant_route(std::uint16_t itemIndex) noexcept; /** * Assigns runtime SOIDs only to installed profile mod/shader rows which are socket action sources. @@ -266,7 +267,7 @@ struct PendingRecordRewardGrant { bool prepared{}; }; -/** One uncommitted Season reward and the exact native row or bundle it will claim. */ +/** One uncommitted Season reward, the native row it will claim, and the seed of its draw. */ struct PendingSeasonPassReward { PendingRecordRewardGrant grant{}; std::uint32_t sourceDefinitionHash{}; @@ -537,15 +538,26 @@ prepare_item_acquisition(std::uint16_t collectibleIndex, /** Atomically commits one prepared reward grant and its durable Season claim. */ [[nodiscard]] bool commit_season_pass_reward(PendingSeasonPassReward& mutation) noexcept; -/** Resolves an installed item or wrapper; refusal receives a static reason on failure. */ +/** + * Resolves an installed item or wrapper for the selected character. + * @param seed Caller-drawn entropy for any wrapper draws. + * @param refusal Receives a static reason on failure. + */ [[nodiscard]] bool prepare_item_reward(std::uint16_t itemIndex, std::uint32_t quantity, + std::uint64_t seed, PendingRecordRewardGrant& mutation, const char** refusal = nullptr) noexcept; -/** Prepares an eligible native pass row without writing its claim or acquisition flags. */ +/** + * Prepares an eligible native pass row without writing its claim or acquisition flags. + * @param seed Caller-drawn entropy; the commit replays the same draw from it. + * @param refusal Receives a static reason on failure. + */ [[nodiscard]] bool prepare_season_pass_reward(std::uint16_t rewardIndex, + std::uint64_t seed, PendingSeasonPassReward& mutation, const char** refusal = nullptr) noexcept; +/** Applies a prepared grant's acquisition flags to the saved banks; false if one moved. */ [[nodiscard]] bool preview_reward_unlocks(const PendingRecordRewardGrant& mutation, unlocks::Table& after) noexcept; diff --git a/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp b/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp index 060962837..be9a48271 100644 --- a/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp +++ b/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp @@ -5,17 +5,13 @@ #include #include #include -#include #include -#include "../../middleware/crypto/random_bytes.h" #include "../../middleware/datagen/family4/loadout/loadout_resolver.h" -#include "../build_data/rewards/reward_catalog.h" #include "../build_data/runtime.h" #include "../investment/store_internal.h" #include "../rewards/reward_resolver.h" #include "../unlocks/unlocks_records.h" -#include "../unlocks/unlocks_runtime.h" #include "runtime.h" #include "state_account_transaction_helpers.h" #include "storage/internal.h" @@ -28,8 +24,6 @@ namespace item_details = build_data::items::details; namespace inventory_buckets = build_data::inventory::buckets; namespace family4_loadout = middleware::datagen::family4::loadout; -static_assert(build_data::rewards::kSocketsPerItem == item_details::kInitialPlugCapacity); - namespace { [[nodiscard]] bool materialize_record_reward(const AccountState& current, @@ -132,11 +126,11 @@ apply_reward_sockets(const item_details::Definition& detail, /** Wrappers and direct account perks bypass quest initialization. */ bool uses_reward_definition(const build_data::items::Definition& item) noexcept { build_data::rewards::Item reward{}; - if (!build_data::rewards::find_item(item.definitionIndex, reward)) { + if (!build_data::find_reward_item(item.definitionIndex, reward)) { return false; } return reward.poolIndex != build_data::rewards::kAbsent - || (item.bucketId == inventory_buckets::kNonInventoryBucketId + || (item.bucketId == inventory_buckets::kPerkBucketId && reward.acquiredFlag != build_data::rewards::kAbsent); } @@ -153,7 +147,7 @@ enum class PassResolution { claim, replay }; build_data::rewards::Item source{}; if (character >= account.characterCount || !investment::store::read_unlocks(flags, static_cast(character)) - || !build_data::rewards::find_item(reward.itemIndex, source) + || !build_data::find_reward_item(reward.itemIndex, source) || source.definitionHash != reward.itemHash || reward.socketCount > reward.sockets.size() || reward.conditionCount > reward.condition.size()) { return false; @@ -206,7 +200,7 @@ enum class PassResolution { claim, replay }; build_data::items::Definition definition{}; build_data::rewards::Item source{}; if (!build_data::find_item_definition_index(planned.itemIndex, definition) - || !build_data::rewards::find_item(planned.itemIndex, source) + || !build_data::find_reward_item(planned.itemIndex, source) || prepared.definitionHash != definition.definitionHash || prepared.quantity != planned.quantity || prepared.acquiredFlag != source.acquiredFlag) { @@ -236,6 +230,7 @@ enum class PassResolution { claim, replay }; } // namespace bool prepare_season_pass_reward(std::uint16_t rewardIndex, + std::uint64_t seed, PendingSeasonPassReward& mutation, const char** refusal) noexcept { const char* unused = nullptr; @@ -255,12 +250,7 @@ bool prepare_season_pass_reward(std::uint16_t rewardIndex, if (season_pass_reward_claimed(rewardIndex)) { return false; } - reason = "random_source"; - std::array random{}; - if (!middleware::crypto::random::fill(random)) { - return false; - } - std::memcpy(&mutation.seed, random.data(), random.size()); + mutation.seed = seed; rewards::Result resolved{}; reason = "reward_condition"; if (!resolve_pass( @@ -306,6 +296,7 @@ ItemGrantRoute item_grant_route(std::uint16_t itemIndex) noexcept { bool prepare_item_reward(std::uint16_t itemIndex, std::uint32_t quantity, + std::uint64_t seed, PendingRecordRewardGrant& mutation, const char** refusal) noexcept { const char* unused = nullptr; @@ -314,16 +305,9 @@ bool prepare_item_reward(std::uint16_t itemIndex, mutation = {}; const std::lock_guard lock(investment::store::g_mutex); build_data::items::Definition item{}; - std::array random{}; if (!build_data::find_item_definition_index(itemIndex, item)) { return false; } - reason = "random_source"; - if (!middleware::crypto::random::fill(random)) { - return false; - } - std::uint64_t seed = 0; - std::memcpy(&seed, random.data(), random.size()); reason = "selected_character"; const AccountState account = account_snapshot(); const auto character = selected_character_index(account); @@ -440,8 +424,8 @@ namespace { if (reward.kind == RecordRewardKind::accountUnlock) { build_data::rewards::Item source{}; if (reward.quantity != 1 || reward.afterQuantity != 1 || reward.instanceSoid != 0 - || item.bucketId != build_data::inventory::buckets::kNonInventoryBucketId - || !build_data::rewards::find_item(item.definitionIndex, source) + || item.bucketId != build_data::inventory::buckets::kPerkBucketId + || !build_data::find_reward_item(item.definitionIndex, source) || source.acquiredFlag == build_data::rewards::kAbsent || source.acquiredFlag != reward.acquiredFlag) { return false; @@ -556,7 +540,7 @@ bool prepare_record_reward_grant(std::span rewards, if (requested.acquireUnlock) { reason = "acquisition_flag"; build_data::rewards::Item source{}; - if (!build_data::rewards::find_item(item.definitionIndex, source) + if (!build_data::find_reward_item(item.definitionIndex, source) || source.definitionHash != item.definitionHash) { return false; } @@ -571,7 +555,7 @@ bool prepare_record_reward_grant(std::span rewards, prepared.previousFlag = static_cast(before); } } - if (item.bucketId == build_data::inventory::buckets::kNonInventoryBucketId) { + if (item.bucketId == build_data::inventory::buckets::kPerkBucketId) { reason = "perk_acquisition"; if (prepared.acquiredFlag == build_data::rewards::kAbsent || requested.quantity != 1 || !requested.sockets.empty()) { diff --git a/Sunrise/src/state/runtime/state_progression_runtime.cpp b/Sunrise/src/state/runtime/state_progression_runtime.cpp index cdc9661a6..ce0214a95 100644 --- a/Sunrise/src/state/runtime/state_progression_runtime.cpp +++ b/Sunrise/src/state/runtime/state_progression_runtime.cpp @@ -8,16 +8,14 @@ #include #include -#include "../build_data/rewards/reward_catalog.h" +#include "../../core/logging/log.h" #include "../build_data/runtime.h" -#include "../build_data/season_pass/season_pass_catalog.h" #include "../investment/investment.h" #include "../investment/store_internal.h" #include "../progression/season_pass_reward_catalog.h" #include "../rewards/reward_resolver.h" #include "../unlocks/definition.h" #include "../unlocks/unlocks_runtime.h" -#include "core/logging/log.h" #include "runtime.h" #include "state.h" #include "state_account_transaction_helpers.h" @@ -249,8 +247,9 @@ bool publish_artifact_character_banks(CharacterArtifactWrite& write) noexcept { return publish_artifact_character_banks(write); } +/** Logs one earned perk whose flags were not granted, and why. */ void report_perk_refusal(std::size_t index, const char* reason) noexcept { - core::log::writef(core::log::Channel::server, + core::log::writef(core::log::Channel::state, core::log::Level::warn, "ev=season_xp stage=automatic_perk index=%zu result=skip reason=%s", index, @@ -259,7 +258,7 @@ void report_perk_refusal(std::size_t index, const char* reason) noexcept { /** Earned perks set both flags in the transaction that publishes their qualifying rank. */ bool grant_progress_flags() noexcept { - if (!build_data::season_pass_ready() || !build_data::rewards::ready() + if (!build_data::season_pass_ready() || !build_data::reward_definitions_ready() || !build_data::item_definitions_ready()) { return true; } @@ -276,7 +275,7 @@ bool grant_progress_flags() noexcept { return false; } const auto rank = seasonal_rank(); - const auto count = build_data::season_pass::count(); + const auto count = build_data::season_pass_reward_count(); for (std::size_t index = 0; index < count; ++index) { build_data::season_pass::Reward reward{}; if (!build_data::find_season_pass_reward(static_cast(index), reward)) { @@ -336,21 +335,20 @@ bool publish_experience_lanes(std::int32_t experience) noexcept { } // namespace -// Packages share the perk bucket; only rewards without a pool grant the flag directly. +/** Packages share the perk bucket; only rewards without a pool grant the flag directly. */ std::uint16_t progression::season_pass::progress_flag(const build_data::season_pass::Reward& reward) noexcept { - namespace rewards = build_data::rewards; build_data::items::Definition item{}; - rewards::Item acquisition{}; + build_data::rewards::Item acquisition{}; if (build_data::find_item_definition_index(reward.itemIndex, item) && item.definitionHash == reward.itemHash - && item.bucketId == build_data::inventory::buckets::kNonInventoryBucketId - && rewards::find_item(reward.itemIndex, acquisition) + && item.bucketId == build_data::inventory::buckets::kPerkBucketId + && build_data::find_reward_item(reward.itemIndex, acquisition) && acquisition.definitionHash == reward.itemHash - && acquisition.poolIndex == rewards::kAbsent) { + && acquisition.poolIndex == build_data::rewards::kAbsent) { return acquisition.acquiredFlag; } - return rewards::kAbsent; + return build_data::rewards::kAbsent; } /** @return Seasonal XP published in the account progression bank. */ diff --git a/Sunrise/src/state/unlocks/unlocks_expression.cpp b/Sunrise/src/state/unlocks/unlocks_expression.cpp new file mode 100644 index 000000000..7ec9a6e0d --- /dev/null +++ b/Sunrise/src/state/unlocks/unlocks_expression.cpp @@ -0,0 +1,203 @@ +#include "unlocks_expression.h" + +#include +#include + +#include "../account/account_state.h" +#include "definition.h" + +namespace sunrise::state::unlocks { +namespace { + +/** Fixed evaluation stack, which refuses rather than growing. */ +class Stack final { +public: + /** @param value Value to push. @return True when there was room. */ + [[nodiscard]] bool push(std::int32_t value) noexcept { + if (depth_ == values_.size()) { + return false; + } + values_[depth_++] = value; + return true; + } + + /** @param value Receives the top value. @return True when there was one. */ + [[nodiscard]] bool pop(std::int32_t& value) noexcept { + if (depth_ == 0) { + return false; + } + value = values_[--depth_]; + return true; + } + + /** @return Values currently held. */ + [[nodiscard]] std::size_t depth() const noexcept { + return depth_; + } + +private: + std::array values_{}; + std::size_t depth_{}; +}; + +/** Signed arithmetic wraps rather than overflowing. */ +[[nodiscard]] std::int32_t wrapping_add(std::int32_t left, std::int32_t right) noexcept { + return static_cast(static_cast(left) + + static_cast(right)); +} + +/** + * Applies one binary operator. + * @return False when the opcode is not a binary operator. + */ +[[nodiscard]] bool +binary_result(Opcode opcode, std::int32_t left, std::int32_t right, std::int32_t& value) noexcept { + switch (opcode) { + case Opcode::logicalOr: + value = left != 0 || right != 0; + return true; + case Opcode::logicalAnd: + value = left != 0 && right != 0; + return true; + case Opcode::equal: + value = left == right; + return true; + case Opcode::greaterThan: + value = left > right; + return true; + case Opcode::greaterOrEqual: + value = left >= right; + return true; + case Opcode::lessThan: + value = left < right; + return true; + case Opcode::lessOrEqual: + value = left <= right; + return true; + case Opcode::add: + value = wrapping_add(left, right); + return true; + default: + return false; + } +} + +/** + * Runs one instruction against the stack. + * @param instruction Instruction to run. + * @param inputs Flag and value readers. + * @param stack Evaluation stack. + * @return True when the opcode is known and its operands were there. + */ +[[nodiscard]] bool +step(const Instruction& instruction, const Inputs& inputs, Stack& stack) noexcept { + std::int32_t left = 0; + std::int32_t right = 0; + std::int32_t value = 0; + switch (instruction.opcode) { + case Opcode::flag: { + bool set = false; + return inputs.flag(inputs.context, instruction, set) && stack.push(set ? 1 : 0); + } + case Opcode::loadValue: + return inputs.value(inputs.context, instruction, value) && stack.push(value); + case Opcode::constant: + return stack.push(static_cast(instruction.operand)); + case Opcode::logicalNot: + return stack.pop(left) && stack.push(left == 0 ? 1 : 0); + case Opcode::negate: + return stack.pop(left) && stack.push(wrapping_add(~left, 1)); + default: + // The right operand was pushed last, so it comes off first. + return stack.pop(right) && stack.pop(left) + && binary_result(instruction.opcode, left, right, value) && stack.push(value); + } +} + +} // namespace + +/** Converts one native opcode word to an evaluated opcode. */ +bool decode_opcode(std::uint32_t native, Opcode& opcode) noexcept { + if (native > (std::numeric_limits::max)()) { + return false; + } + switch (static_cast(native)) { + case Opcode::flag: + case Opcode::logicalNot: + case Opcode::logicalOr: + case Opcode::logicalAnd: + case Opcode::equal: + case Opcode::loadValue: + case Opcode::constant: + case Opcode::greaterThan: + case Opcode::greaterOrEqual: + case Opcode::lessThan: + case Opcode::lessOrEqual: + case Opcode::add: + case Opcode::negate: + opcode = static_cast(native); + return true; + default: + return false; + } +} + +/** Checks one instruction's bank and its operand against that bank's capacity. */ +bool valid(const Instruction& instruction) noexcept { + Opcode decoded{}; + if (!decode_opcode(static_cast(instruction.opcode), decoded)) { + return false; + } + const std::uint32_t index = instruction.operand; + if (instruction.opcode == Opcode::flag) { + switch (instruction.bank) { + case Bank::account: + return index < kAccountFlagCapacity; + case Bank::profile: + return index < kProfileFlagCapacity; + case Bank::character: + return index < kCharacterObjectFlagCapacity; + case Bank::characterClass: + return index < kCharacterClassCount; + case Bank::external: + return true; + default: + return false; + } + } + if (instruction.opcode == Opcode::loadValue) { + switch (instruction.bank) { + case Bank::account: + return index < kObjectiveValueCapacity; + case Bank::character: + return index < kCharacterObjectValueCapacity; + case Bank::external: + return true; + default: + return false; + } + } + return instruction.bank == Bank::none; +} + +/** Evaluates one expression program. */ +bool evaluate(std::span program, const Inputs& inputs, bool& result) noexcept { + result = false; + if (program.empty() || inputs.flag == nullptr || inputs.value == nullptr) { + return false; + } + Stack stack; + for (const Instruction& instruction : program) { + if (!step(instruction, inputs, stack)) { + return false; + } + } + std::int32_t final = 0; + if (stack.depth() != 1 || !stack.pop(final)) { + return false; + } + result = final != 0; + return true; +} + +} // namespace sunrise::state::unlocks diff --git a/Sunrise/src/state/unlocks/unlocks_expression.h b/Sunrise/src/state/unlocks/unlocks_expression.h new file mode 100644 index 000000000..52e09df63 --- /dev/null +++ b/Sunrise/src/state/unlocks/unlocks_expression.h @@ -0,0 +1,92 @@ +#pragma once + +#include +#include +#include + +namespace sunrise::state::unlocks { + +/** Stack slots one expression may use. The deepest installed reward condition needs nine. */ +inline constexpr std::size_t kExpressionStackCapacity = 16; + +/** Opcodes the client's expression evaluator implements that the server evaluates. */ +enum class Opcode : std::uint8_t { + /** Pushes 1 when the bound flag is set, otherwise 0. */ + flag = 1, + /** Pops one value and pushes 1 when it was zero. */ + logicalNot = 2, + /** Binary operators pop right then left and push the result. */ + logicalOr = 3, + logicalAnd = 4, + equal = 8, + /** Pushes one bound value. */ + loadValue = 10, + /** Pushes the operand as a signed constant. */ + constant = 11, + greaterThan = 13, + greaterOrEqual = 14, + lessThan = 15, + lessOrEqual = 16, + add = 17, + /** Pops one value and pushes its two's-complement negation. */ + negate = 22, +}; + +/** Saved bank a flag or value instruction reads once its authored unlock slot is bound. */ +enum class Bank : std::uint8_t { + /** Operators and constants read no bank. */ + none, + account, + profile, + character, + /** A flag that is set when the operand names the selected character's class. */ + characterClass, + /** Resolved outside the saved banks. The operand is its identity hash. */ + external, +}; + +/** One bound expression instruction. */ +struct Instruction { + Opcode opcode{}; + Bank bank{Bank::none}; + /** Bank index, class index, external identity hash, or signed constant. */ + std::uint32_t operand{}; +}; + +/** Reads one bound flag. Returns false when the flag cannot be read. */ +using FlagReader = bool (*)(const void* context, + const Instruction& instruction, + bool& set) noexcept; +/** Reads one bound value. Returns false when the value cannot be read. */ +using ValueReader = bool (*)(const void* context, + const Instruction& instruction, + std::int32_t& value) noexcept; + +/** The two readers one evaluation needs, and the caller state they share. */ +struct Inputs { + FlagReader flag{}; + ValueReader value{}; + const void* context{}; +}; + +/** + * Converts one native opcode word to an evaluated opcode. + * @return False for an opcode the server does not evaluate. + */ +[[nodiscard]] bool decode_opcode(std::uint32_t native, Opcode& opcode) noexcept; + +/** Checks one instruction's bank and its operand against that bank's capacity. */ +[[nodiscard]] bool valid(const Instruction& instruction) noexcept; + +/** + * Evaluates one expression program. + * Every failure is refused, never defaulted: bad opcode, bad stack, unreadable flag or value. + * @param program Instructions in evaluation order. + * @param inputs Flag and value readers. + * @param result Receives whether the final value is nonzero, and is cleared on any refusal. + * @return True only when the whole program evaluated to one value. + */ +[[nodiscard]] bool +evaluate(std::span program, const Inputs& inputs, bool& result) noexcept; + +} // namespace sunrise::state::unlocks From 6510bc098d38b80077d1daa4ebfc9864badb1602 Mon Sep 17 00:00:00 2001 From: Zach Date: Sun, 27 Sep 2026 15:24:30 -0500 Subject: [PATCH 07/12] Retire unresolvable world rewards and load cached pass rows --- .../client/content/items/packages/internal.h | 9 +- .../items/packages/package_item_build.cpp | 27 +-- .../items/packages/package_item_rows.cpp | 20 +- .../items/packages/package_reward_build.cpp | 186 ++++++++++++++---- .../items/packages/package_reward_build.h | 15 +- .../packages/package_season_pass_build.cpp | 4 +- .../packages/tables/definition_index_table.h | 4 - .../src/server/bap/bap_world_reward_queue.cpp | 125 ++++++++---- .../encrypted/queuez/queuez_deferred_push.cpp | 58 ++++-- Sunrise/src/server/bap/internal.h | 15 +- .../web_service_record_actions.cpp | 5 +- .../records/cache_investment_records.cpp | 40 +++- .../src/state/build_data/rewards/definition.h | 16 +- .../rewards/reward_build_data_runtime.cpp | 5 + Sunrise/src/state/build_data/runtime.h | 6 + .../season_pass_build_data_runtime.cpp | 5 + Sunrise/src/state/rewards/reward_resolver.cpp | 71 ++++--- Sunrise/src/state/runtime/runtime.h | 32 +-- .../state_account_reward_grant_runtime.cpp | 116 ++++++----- .../state/runtime/state_account_runtime.cpp | 3 +- .../runtime/state_progression_runtime.cpp | 49 +++-- Sunrise/src/state/runtime/state_runtime.cpp | 3 +- 22 files changed, 562 insertions(+), 252 deletions(-) diff --git a/Sunrise/src/client/content/items/packages/internal.h b/Sunrise/src/client/content/items/packages/internal.h index 8dc53f38b..c2ace82e7 100644 --- a/Sunrise/src/client/content/items/packages/internal.h +++ b/Sunrise/src/client/content/items/packages/internal.h @@ -158,7 +158,7 @@ struct Storage { state::build_data::progressions::kStepCapacity> progressionSteps{}; std::size_t progressionStepCount{}; - /** Season pass reward rows and the wrapper items they grant. */ + /** Season pass reward rows in native claim order. */ std::array seasonPassRewards{}; @@ -207,6 +207,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; @@ -324,7 +327,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. */ @@ -379,7 +382,7 @@ read_investment_constants(const reader::Source& source, std::span steps, std::size_t& stepCount) noexcept; -/** Reads the season pass reward list and the wrapper items it grants. */ +/** Reads the season pass reward list, keeping an unreadable row as an unavailable one. */ [[nodiscard]] bool build_season_pass(const reader::Source& source, Storage& storage, std::span root) noexcept; 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 2b1fbfb08..0fa0b810d 100644 --- a/Sunrise/src/client/content/items/packages/package_item_build.cpp +++ b/Sunrise/src/client/content/items/packages/package_item_build.cpp @@ -35,8 +35,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::reward_definitions_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() @@ -128,8 +127,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() - || !state::build_data::reward_definitions_ready() || !exotic_catalysts_settled()) { + || !state::build_data::season_pass_ready() || !reward_definitions_settled() + || !exotic_catalysts_settled()) { reason = "unlock_maps"; if (!read_unlock_slot_maps( source, storage, std::span{storage.root})) { @@ -187,16 +186,18 @@ bool build() noexcept { std::span(storage.progressionSteps).first(storage.progressionStepCount)); } } - // Pass rows bind their conditions through the reward condition tables. - const bool conditionTablesLoaded = - (!state::build_data::reward_definitions_ready() - || !state::build_data::season_pass_ready()) - && storage.rewardBuild.load( + // 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 (conditionTablesLoaded && !state::build_data::season_pass_ready() - && build_season_pass(source, storage, std::span{storage.root})) { - (void)state::build_data::publish_season_pass( - std::span(storage.seasonPassRewards).first(storage.seasonPassRewardCount)); + } + 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))) { + 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. 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 a15b3ed9b..dbad60128 100644 --- a/Sunrise/src/client/content/items/packages/package_item_rows.cpp +++ b/Sunrise/src/client/content/items/packages/package_item_rows.cpp @@ -54,16 +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 = !state::build_data::reward_definitions_ready(); + const bool needRewards = !reward_definitions_settled(); const bool rewardStorageReady = - !needRewards || storage.rewardBuild.begin_items(static_cast(table.count)); + 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(); @@ -74,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 || needRewards; + const bool needRows = needDefinitions || needDetailRows || needBuckets || rewardStorageReady; bool published = !needRows; if (retainDetails && storage.details.size() != kDetailCapacity) { storage.details.assign(kDetailCapacity, build_details::Definition{}); @@ -115,7 +115,7 @@ bool build_item_rows(const reader::Source& source, item)) { continue; } - if (needRewards && rewardStorageReady && itemClass == tables::kItemDefinitionClass) { + if (rewardStorageReady && itemClass == tables::kItemDefinitionClass) { storage.rewardBuild.item(item.definitionIndex, item.definitionHash, storage.definition); } const std::uint32_t plugCategoryHash = @@ -166,9 +166,9 @@ bool build_item_rows(const reader::Source& source, append_initial_plugs(item, table.count, storage.detailRequests); } } - if (needRewards && rewardStorageReady) { - (void)storage.rewardBuild.publish(); - } + // A reward refusal is reported by RewardBuild and does not block the other item domains. + const bool rewardsPublished = + !needRewards || (rewardStorageReady && storage.rewardBuild.publish()); bool requestsFit = true; if (needRows) { // Every walked entry either published a row or was skipped, so the count of one @@ -310,6 +310,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_reward_build.cpp b/Sunrise/src/client/content/items/packages/package_reward_build.cpp index 631069bd5..0d1f7cf54 100644 --- a/Sunrise/src/client/content/items/packages/package_reward_build.cpp +++ b/Sunrise/src/client/content/items/packages/package_reward_build.cpp @@ -86,6 +86,33 @@ 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) { @@ -153,10 +180,20 @@ bool read_class_flag(std::span blob, std::uint16_t& output) noe } // namespace -bool RewardBuild::entry(std::span blob, std::size_t at) noexcept { +/** @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, + const char*& fullBank) noexcept { domain::Entry out{}; + bool instructionsFull = false; if (!read_reward_entry(blob, at, out) - || !conditions.read(blob, at + kEntryConditionOffset, instructions_, out.condition)) { + || !conditions.read( + blob, at + kEntryConditionOffset, instructions_, out.condition, instructionsFull)) { + fullBank = instructionsFull ? "instruction_capacity" : nullptr; return false; } out.supplementalMissing = supplementalMissing_ && out.supplementalIndex != domain::kAbsent; @@ -170,18 +207,28 @@ bool RewardBuild::entry(std::span blob, std::size_t at) noexcep 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) + if (!conditions.read(blob, offset, instructions_, modifier.condition, instructionsFull) || !tables::read(blob, offset + kModifierValueIndexOffset, modifier.valueIndex) || !tables::read(blob, offset + kModifierValueOffset, modifier.value) - || !std::isfinite(modifier.value) - || !append(modifiers_, modifier, domain::kModifierCapacity)) { + || !std::isfinite(modifier.value)) { + fullBank = instructionsFull ? "instruction_capacity" : nullptr; + return false; + } + if (modifiers_.size() >= domain::kModifierCapacity) { + fullBank = "modifier_capacity"; + return false; + } + if (!append(modifiers_, modifier, domain::kModifierCapacity)) { return false; } } std::array overrides{}; std::size_t count = 0; - if (!read_reward_sockets(blob, at + kEntrySocketsOffset, overrides, count) - || count > domain::kSocketOverrideCapacity - sockets_.size()) { + if (!read_reward_sockets(blob, at + kEntrySocketsOffset, overrides, count)) { + return false; + } + if (count > domain::kSocketOverrideCapacity - sockets_.size()) { + fullBank = "socket_capacity"; return false; } out.sockets = {static_cast(sockets_.size()), static_cast(count)}; @@ -191,6 +238,10 @@ bool RewardBuild::entry(std::span blob, std::size_t at) noexcep return false; } } + if (entries_.size() >= domain::kEntryCapacity) { + fullBank = "entry_capacity"; + return false; + } return append(entries_, out, domain::kEntryCapacity); } @@ -251,19 +302,24 @@ bool RewardConditions::load(const reader::Source& source, maps_ = &maps; load_class_flags(source, scratch, root); // Shared expressions are expanded before the runtime evaluates reward conditions. - return root_table(source, scratch, root, kFlagSlot, flags_, kFlagTableClass) - && tables::read_array( - flags_, tables::kTableArrayDescriptor, kFlagRowClass, kBindingStride, flagRows_) - && root_table(source, scratch, root, kValueSlot, values_, kValueTableClass) - && tables::read_array( - values_, tables::kTableArrayDescriptor, kValueRowClass, kBindingStride, valueRows_) - && root_table( - source, scratch, root, kExpressionSlot, expressions_, kExpressionTableClass) - && tables::read_array(expressions_, - tables::kTableArrayDescriptor, - kExpressionRowClass, - kExpressionRowStride, - expressionRows_); + loaded_ = + root_table(source, scratch, root, kFlagSlot, flags_, kFlagTableClass) + && tables::read_array( + flags_, tables::kTableArrayDescriptor, kFlagRowClass, kBindingStride, flagRows_) + && root_table(source, scratch, root, kValueSlot, values_, kValueTableClass) + && tables::read_array( + values_, tables::kTableArrayDescriptor, kValueRowClass, kBindingStride, valueRows_) + && root_table(source, scratch, root, kExpressionSlot, expressions_, kExpressionTableClass) + && tables::read_array(expressions_, + tables::kTableArrayDescriptor, + kExpressionRowClass, + kExpressionRowStride, + expressionRows_); + return loaded_; +} + +bool RewardConditions::loaded() const noexcept { + return loaded_; } bool RewardConditions::bind(std::uint32_t native, @@ -357,9 +413,12 @@ bool RewardConditions::append_expression(std::span blob, bool RewardConditions::read(std::span blob, std::size_t at, std::vector& bank, - domain::Range& range) const noexcept { + domain::Range& range, + bool& bankFull) const noexcept { const auto first = bank.size(); + bankFull = false; if (!append_expression(blob, at, bank, 0)) { + bankFull = bank.size() >= domain::kInstructionCapacity; bank.resize(first); return false; } @@ -377,12 +436,15 @@ bool RewardConditions::read_list(std::span blob, if (!tables::read_array(blob, at, kConditionClass, tables::kUnlockExpressionFieldSize, rows)) { return false; } + // The row's condition capacity below bounds this bank. + bool bankFull = false; for (std::size_t i = 0; i < rows.count; ++i) { domain::Range expression{}; if (!read(blob, rows.dataOffset + i * tables::kUnlockExpressionFieldSize, instructions, - expression) + expression, + bankFull) || expression.count == 0) { return false; } @@ -441,12 +503,17 @@ bool RewardBuild::load(const reader::Source& source, std::vector blob; tables::Array rows{}; std::uint32_t supplementalTag = 0; - if (!tables::slot_tag(root, kSupplementalRewardSlot, supplementalTag) - || !conditions.load(source, scratch, root, maps) + // Conditions load first; the season pass binds through them even if the pools are refused. + if (!conditions.load(source, scratch, root, maps) + || !tables::slot_tag(root, kSupplementalRewardSlot, supplementalTag) || !root_table(source, scratch, root, kPoolSlot, blob, kPoolClass) || !tables::read_array( - blob, tables::kTableArrayDescriptor, kPoolRowClass, kPoolStride, rows) - || rows.count == 0 || rows.count > domain::kPoolCapacity) { + blob, tables::kTableArrayDescriptor, kPoolRowClass, kPoolStride, rows)) { + settle_unsupported("tables"); + return false; + } + if (rows.count == 0 || rows.count > domain::kPoolCapacity) { + settle_unsupported("pool_count"); return false; } supplementalMissing_ = supplementalTag == kAbsentTableTag; @@ -463,7 +530,13 @@ bool RewardBuild::load(const reader::Source& source, const auto beforeInstructions = instructions_.size(); const auto beforeModifiers = modifiers_.size(); const auto beforeSockets = sockets_.size(); - if (!entry(blob, members.dataOffset + j * kEntryStride)) { + const char* fullBank = nullptr; + if (!entry(blob, members.dataOffset + j * kEntryStride, fullBank)) { + // Skipping past a full bank would publish a pool missing some of its entries. + if (fullBank != nullptr) { + settle_unsupported(fullBank); + return false; + } instructions_.resize(beforeInstructions); modifiers_.resize(beforeModifiers); sockets_.resize(beforeSockets); @@ -476,14 +549,19 @@ bool RewardBuild::load(const reader::Source& source, ++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 skipped=%zu", + "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; @@ -504,9 +582,9 @@ bool RewardBuild::read_item(std::uint32_t hash, domain::Instruction flag{}; if (!conditions.bind(tables::kUnlockReadFlagOpcode, acquired, flag) || !unlocks::valid(flag)) { - return false; - } - if (flag.bank == unlocks::Bank::account) { + // The grant is kept; only its acquisition flag is unknown. + ++unboundAcquiredFlags_; + } else if (flag.bank == unlocks::Bank::account) { item.acquiredFlag = static_cast(flag.operand); } } @@ -537,13 +615,20 @@ bool RewardBuild::read_item(std::uint32_t hash, } bool RewardBuild::begin_items(std::size_t count) noexcept { + unboundAcquiredFlags_ = 0; + // A refused load has already reported itself. + if (!loaded_) { + return false; + } try { - if (!loaded_ || count == 0 || count > domain::kItemCapacity) { + if (count == 0 || count > domain::kItemCapacity) { + report_refusal("item_storage"); return false; } items_.assign(count, {}); return true; } catch (...) { + report_refusal("item_storage"); return false; } } @@ -561,6 +646,12 @@ bool RewardBuild::publish() noexcept { if (!loaded_) { return false; } + // 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_) { @@ -568,11 +659,13 @@ bool RewardBuild::publish() noexcept { 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 || row.itemIndex < items_.size()) + 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()); + valid &= + domain::valid_socket(socket, items_.size()) + && (socket.plugItem == domain::kAbsent || item_read(items_, socket.plugItem)); } if (valid) { auto retained = row; @@ -586,15 +679,28 @@ bool RewardBuild::publish() noexcept { } pool.entries.count = static_cast(write) - pool.entries.first; } + const std::size_t droppedEntries = entries_.size() - write; entries_.resize(write); sockets_.resize(socketWrite); - for (auto& item : items_) { - if (item.poolIndex != domain::kAbsent && item.poolIndex >= pools_.size()) { - item = {}; - } + if (unboundAcquiredFlags_ != 0 || droppedEntries != 0) { + core::log::writef(core::log::Channel::client, + core::log::Level::warn, + "ev=pkg stage=rewards result=partial unbound_acquired_flags=%zu " + "dropped_entries=%zu", + unboundAcquiredFlags_, + droppedEntries); + } + const domain::View graph{pools_, entries_, items_, instructions_, modifiers_, sockets_}; + if (!state::build_data::valid_reward_definitions(graph)) { + settle_unsupported("graph"); + return false; } - return state::build_data::publish_reward_definitions( - {pools_, entries_, items_, instructions_, modifiers_, sockets_}); + // 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 index baf23df5a..c77f287c8 100644 --- a/Sunrise/src/client/content/items/packages/package_reward_build.h +++ b/Sunrise/src/client/content/items/packages/package_reward_build.h @@ -23,15 +23,18 @@ class RewardConditions { 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 to the bank; the bank is unchanged on failure. */ + /** 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) const noexcept; + state::build_data::rewards::Range& range, + bool& bankFull) 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, @@ -48,6 +51,7 @@ class RewardConditions { std::span root) noexcept; std::array classFlags_{}; const SlotMaps* maps_{}; + bool loaded_{}; std::vector flags_; std::vector values_; std::vector expressions_; @@ -60,7 +64,7 @@ class RewardConditions { class RewardBuild { public: RewardConditions conditions; - /** Reads every reward pool; entries that fail to read are skipped and counted. */ + /** 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, @@ -73,12 +77,15 @@ class RewardBuild { [[nodiscard]] bool publish() noexcept; private: - [[nodiscard]] bool entry(std::span blob, std::size_t at) noexcept; + /** Appends one entry; fullBank names the bank that refused it, if any. */ + [[nodiscard]] bool + entry(std::span blob, std::size_t at, const char*& fullBank) 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::vector pools_; std::vector entries_; std::vector items_; 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 ae63336a7..1d0b20e59 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 @@ -3,7 +3,6 @@ #include "../../../../core/logging/log.h" #include "../../../../middleware/content/packages/tables/field_reader.h" -#include "../../../../state/build_data/season_pass/season_pass_catalog.h" #include "../../../../state/progression/season_pass_reward_catalog.h" #include "internal.h" #include "package_reward_build.h" @@ -71,7 +70,8 @@ bool read_season_reward(Storage& storage, } reward.socketCount = static_cast(socketCount); reward.conditionCount = static_cast(conditionCount); - return domain::valid(std::span(&reward, 1)) + // 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) { 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 ee0bb2624..7cd162b7f 100644 --- a/Sunrise/src/middleware/content/packages/tables/definition_index_table.h +++ b/Sunrise/src/middleware/content/packages/tables/definition_index_table.h @@ -236,10 +236,6 @@ inline constexpr std::size_t kProgressionRewardClaimSlotOffset = 20; /** Eligibility expressions and socket overrides carried by one progression reward. */ inline constexpr std::size_t kProgressionRewardConditionsOffset = 24; inline constexpr std::size_t kProgressionRewardSocketsOffset = 40; -/** Items a wrapper item opens into, two bytes each as an item-definition index. */ -inline constexpr std::size_t kGearsetItemField = 392; -inline constexpr std::uint32_t kGearsetItemRowClass = 0x808087DBU; -inline constexpr std::size_t kGearsetItemStride = 2; /** Expression that makes one collectible acquired, which a purchase or a grant sets. */ inline constexpr std::size_t kCollectibleAcquiredExpressionField = 112; diff --git a/Sunrise/src/server/bap/bap_world_reward_queue.cpp b/Sunrise/src/server/bap/bap_world_reward_queue.cpp index 106c02572..92fcb4b3b 100644 --- a/Sunrise/src/server/bap/bap_world_reward_queue.cpp +++ b/Sunrise/src/server/bap/bap_world_reward_queue.cpp @@ -1,3 +1,4 @@ +#include #include #include #include @@ -11,38 +12,55 @@ 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) { if (state::item_grant_route(request.itemDefinitionIndex) == state::ItemGrantRoute::quest) { - state::PendingItemAcquisition acquisition; - committed = request.quantity == 1 - && state::prepare_item_acquisition_for_item(request.itemDefinitionIndex, - acquisition) - && state::commit_item_acquisition(acquisition); + 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))); - const char* reason = seeded ? "reward_allocation" : "random_source"; - committed = seeded && grant && request.quantity > 0 - && state::prepare_item_reward(request.itemDefinitionIndex, - static_cast(request.quantity), - seed, - *grant, - &reason); + 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) { + if (!committed && !unresolvable) { report_reward_refusal("world_settle", request.itemDefinitionIndex, reason); } } @@ -52,7 +70,14 @@ bool commit_world_reward(const WorldRewardRequest& request) noexcept { 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. */ @@ -95,40 +120,72 @@ 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.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(); + report_reward_refusal( + "world_retire", request.itemDefinitionIndex, retired ? reason : "queue_commit"); + 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/queuez/queuez_deferred_push.cpp b/Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp index 609aed85b..4f8469f68 100644 --- a/Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp +++ b/Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp @@ -75,12 +75,20 @@ selected_character(const state::AccountState& account) noexcept { } const char* reason = "transaction"; state::investment::store::Transaction transaction; - if (!pending || !transaction.ready() || request.quantity <= 0 - || !state::prepare_item_reward(request.itemDefinitionIndex, - static_cast(request.quantity), - seed, - *pending, - &reason)) { + if (!pending || !transaction.ready()) { + return fail(reason); + } + const auto preparation = + state::prepare_item_reward(request.itemDefinitionIndex, + static_cast(request.quantity), + seed, + *pending, + &reason); + if (preparation == state::RewardPreparation::unresolvable) { + (void)bap::retire_world_reward(transaction, request, reason); + return false; + } + if (preparation != state::RewardPreparation::prepared) { return fail(reason); } queuez::RecordRewardGrant update{}; @@ -99,18 +107,20 @@ selected_character(const state::AccountState& account) noexcept { scratch.framed, framedSize) && framedSize != 0; + // Only a piggyback slot can be short; the grant waits for a full poll. + if (encoded && framedSize > response.size()) { + return fail("response_capacity"); + } if (!state::commit_record_reward(*pending)) { return fail("inventory_commit"); } if (!bap::complete_world_reward(request.id) || !transaction.commit()) { return fail("queue_commit"); } - // A failed presentation still commits the prepared draw and retires its queue row. - if (!encoded || framedSize > response.size()) { + // No buffer fixes a failed stage or encode, so the grant commits and the row retires. + if (!encoded) { bap::arm_account_resync_everywhere(); - return fail(!staged ? "inventory_stage" - : !encoded ? "inventory_encode" - : "response_capacity"); + return fail(!staged ? "inventory_stage" : "inventory_encode"); } std::copy_n(scratch.framed.begin(), framedSize, response.begin()); written = framedSize; @@ -165,6 +175,12 @@ selected_character(const state::AccountState& account) noexcept { scratch.framed, framedSize) && framedSize != 0; + // Only a piggyback slot can be short; the grant waits for a full poll. + if (encoded && framedSize > response.size()) { + report_reward_refusal( + "world_acquisition", request.itemDefinitionIndex, "response_capacity"); + return false; + } if (!state::commit_item_acquisition(pending) || !bap::complete_world_reward(request.id) || !transaction.commit()) { core::log::write(core::log::Channel::server, @@ -172,13 +188,12 @@ selected_character(const state::AccountState& account) noexcept { "ev=queuez stage=world_acquisition result=fail reason=commit"); return false; } - if (!encoded || framedSize > response.size()) { + // No buffer fixes a failed stage or encode, so the grant commits and the row retires. + if (!encoded) { bap::arm_account_resync_everywhere(); report_reward_refusal("world_acquisition", request.itemDefinitionIndex, - !staged ? "inventory_stage" - : !encoded ? "inventory_encode" - : "response_capacity"); + !staged ? "inventory_stage" : "inventory_encode"); return false; } std::copy_n(scratch.framed.begin(), framedSize, response.begin()); @@ -230,6 +245,12 @@ selected_character(const state::AccountState& account) noexcept { scratch.framed, framedSize) && framedSize != 0; + // Only a piggyback slot can be short; the grant waits for a full poll. + if (encoded && framedSize > response.size()) { + report_reward_refusal( + "world_profile_acquisition", request.itemDefinitionIndex, "response_capacity"); + return false; + } if (!state::commit_profile_item_acquisition(pending) || !bap::complete_world_reward(request.id) || !transaction.commit()) { core::log::write(core::log::Channel::server, @@ -237,13 +258,12 @@ selected_character(const state::AccountState& account) noexcept { "ev=queuez stage=world_profile_acquisition result=fail reason=commit"); return false; } - if (!encoded || framedSize > response.size()) { + // No buffer fixes a failed stage or encode, so the grant commits and the row retires. + if (!encoded) { bap::arm_account_resync_everywhere(); report_reward_refusal("world_profile_acquisition", request.itemDefinitionIndex, - !staged ? "inventory_stage" - : !encoded ? "inventory_encode" - : "response_capacity"); + !staged ? "inventory_stage" : "inventory_encode"); return false; } std::copy_n(scratch.framed.begin(), framedSize, response.begin()); diff --git a/Sunrise/src/server/bap/internal.h b/Sunrise/src/server/bap/internal.h index 947a61631..897296c6a 100644 --- a/Sunrise/src/server/bap/internal.h +++ b/Sunrise/src/server/bap/internal.h @@ -25,6 +25,10 @@ #include "encrypted/queuez/definition.h" #include "runtime.h" +namespace sunrise::state::investment::store { +class Transaction; +} + namespace sunrise::server::bap { /** One session per transport peer slot, so a connection id indexes this array directly. */ @@ -539,7 +543,7 @@ unique_activity_link_locked(const state::activity::SessionBinding& binding, session_scenario_layout(const Session& session, state::build_data::scenarios::Definition& output) noexcept; -/** Commits every queued world reward with no presentation and empties the queue. */ +/** Commits saved world rewards with no presentation until one must wait. */ void drain_world_rewards() noexcept; /** Arms every other Family-4 peer after the origin publishes a complete account mutation. */ @@ -558,13 +562,18 @@ void arm_acquisition_presentation_hold(Session& session) noexcept; [[nodiscard]] bool arm_world_profile_item_acquisition(std::uint16_t itemDefinitionIndex, std::int32_t quantity) noexcept; -/** Reads the oldest queued world reward without removing it. */ +/** Reads the oldest saved world reward that can still be granted, retiring rows that cannot. */ [[nodiscard]] bool current_world_reward(WorldRewardRequest& request) noexcept; /** Removes the world reward returned by current_world_reward. */ [[nodiscard]] bool complete_world_reward(std::uint64_t id) noexcept; -/** Commits the queued reward with no flyout once its presentation cannot be built. */ +/** Deletes an ungrantable reward on the caller's open savepoint, commits, and logs why. */ +[[nodiscard]] bool retire_world_reward(state::investment::store::Transaction& transaction, + const WorldRewardRequest& request, + const char* reason) noexcept; + +/** Grants the oldest saved reward with no flyout, retiring rewards that can never be granted. */ void settle_world_reward() noexcept; /** Queues one transient XP item update so the native HUD presents a seasonal XP gain. */ diff --git a/Sunrise/src/server/web_service/web_service_record_actions.cpp b/Sunrise/src/server/web_service/web_service_record_actions.cpp index 119f03982..e8ca97d84 100644 --- a/Sunrise/src/server/web_service/web_service_record_actions.cpp +++ b/Sunrise/src/server/web_service/web_service_record_actions.cpp @@ -99,8 +99,9 @@ prepare_record_reward(const middleware::web_service::Message& message, message, "storage", recordIndex, rewards[0].itemDefinitionIndex, rewards[0].quantity); return RecordRewardPreparation::failed; } - if (!state::prepare_record_reward_grant( - std::span(rewards).first(rewardCount), recordIndex, *grant)) { + if (state::prepare_record_reward_grant( + std::span(rewards).first(rewardCount), recordIndex, *grant) + != state::RewardPreparation::prepared) { clear_mutation(outcome); report_record_reward( message, "state", recordIndex, rewards[0].itemDefinitionIndex, rewards[0].quantity); diff --git a/Sunrise/src/state/build_data/cache/records/cache_investment_records.cpp b/Sunrise/src/state/build_data/cache/records/cache_investment_records.cpp index f60a86ec9..78f9003a6 100644 --- a/Sunrise/src/state/build_data/cache/records/cache_investment_records.cpp +++ b/Sunrise/src/state/build_data/cache/records/cache_investment_records.cpp @@ -1,3 +1,5 @@ +#include + #include "codec.h" namespace sunrise::state::build_data::cache::records { @@ -6,6 +8,22 @@ namespace { /** Cache padding fields are always written as zero. */ constexpr unsigned int kReservedFieldValue = 0; +/** A condition slot past a pass row's count holds what a default instruction encodes to. */ +bool unused_condition(const RewardInstructionRecord& record) noexcept { + const rewards::Instruction unused{}; + return record.opcode == static_cast(unused.opcode) + && record.bank == static_cast(unused.bank) + && record.reserved == kReservedFieldValue && record.operand == unused.operand; +} + +/** A socket slot past a pass row's count holds what a default override encodes to. */ +bool unused_socket(const RewardSocketOverrideRecord& record) noexcept { + const rewards::SocketOverride unused{}; + return record.socketType == unused.socketType && record.plugItem == unused.plugItem + && record.plugSet == unused.plugSet && record.rollSet == unused.rollSet + && record.selection == unused.selection; +} + } // namespace /** Flattens the 12 buckets into 3 parallel arrays with no per-bucket padding. */ @@ -117,10 +135,18 @@ bool encode(const season_pass::Reward& value, SeasonPassRewardRecord& record) no return true; } -/** Decodes one season pass reward row after checking its padding. */ +/** Decodes one season pass reward row after checking its padding and unused slots. */ bool decode(const SeasonPassRewardRecord& record, season_pass::Reward& value) noexcept { value = {}; - if (record.reserved != decltype(record.reserved){}) { + if (record.reserved != decltype(record.reserved){} + || record.conditionCount > record.condition.size() + || record.socketCount > record.sockets.size()) { + return false; + } + const auto unusedConditions = std::span(record.condition).subspan(record.conditionCount); + const auto unusedSockets = std::span(record.sockets).subspan(record.socketCount); + if (!std::all_of(unusedConditions.begin(), unusedConditions.end(), unused_condition) + || !std::all_of(unusedSockets.begin(), unusedSockets.end(), unused_socket)) { return false; } value.itemHash = record.itemHash; @@ -130,18 +156,12 @@ bool decode(const SeasonPassRewardRecord& record, season_pass::Reward& value) no value.requiredRank = record.requiredRank; value.socketCount = record.socketCount; value.conditionCount = record.conditionCount; - if (value.conditionCount > value.condition.size()) { - return false; - } - for (std::size_t i = 0; i < value.condition.size(); ++i) { + for (std::size_t i = 0; i < value.conditionCount; ++i) { if (!decode(record.condition[i], value.condition[i])) { return false; } } - if (value.socketCount > value.sockets.size()) { - return false; - } - for (std::size_t i = 0; i < value.sockets.size(); ++i) { + for (std::size_t i = 0; i < value.socketCount; ++i) { if (!decode(record.sockets[i], value.sockets[i])) { return false; } diff --git a/Sunrise/src/state/build_data/rewards/definition.h b/Sunrise/src/state/build_data/rewards/definition.h index 2ce20c39d..912c51cdc 100644 --- a/Sunrise/src/state/build_data/rewards/definition.h +++ b/Sunrise/src/state/build_data/rewards/definition.h @@ -17,14 +17,14 @@ inline constexpr std::uint16_t kAbsent = 0xFFFF; inline constexpr std::size_t kPoolCapacity = 4096; /** Item rows are dense over the installed item-definition table. */ inline constexpr std::size_t kItemCapacity = items::kDefinitionCapacity; -/** - * The shipped build declares 10,549 entries, 14,185 condition instructions, 3,714 modifiers, and - * 1,812 socket overrides. Each bank leaves room above that. - */ -inline constexpr std::size_t kEntryCapacity = 32768; -inline constexpr std::size_t kInstructionCapacity = 32768; -inline constexpr std::size_t kModifierCapacity = 32768; -inline constexpr std::size_t kSocketOverrideCapacity = 32768; +/** The shipped build publishes 10,549 entries. The bank leaves room above that. */ +inline constexpr std::size_t kEntryCapacity = 16384; +/** The shipped build publishes 14,185 expanded condition instructions, with room above that. */ +inline constexpr std::size_t kInstructionCapacity = 16384; +/** The shipped build publishes 3,714 modifiers. The bank leaves room above that. */ +inline constexpr std::size_t kModifierCapacity = 4096; +/** The shipped build publishes 1,812 socket overrides. The bank leaves room above that. */ +inline constexpr std::size_t kSocketOverrideCapacity = 2048; /** Reward overrides address the ordinary socket lanes an item's initial plugs fill. */ inline constexpr std::size_t kSocketsPerItem = items::details::kInitialPlugCapacity; /** Category selections one wrapper declares. The most any shipped wrapper declares is four. */ diff --git a/Sunrise/src/state/build_data/rewards/reward_build_data_runtime.cpp b/Sunrise/src/state/build_data/rewards/reward_build_data_runtime.cpp index 2b45a14c8..4621b139c 100644 --- a/Sunrise/src/state/build_data/rewards/reward_build_data_runtime.cpp +++ b/Sunrise/src/state/build_data/rewards/reward_build_data_runtime.cpp @@ -16,6 +16,11 @@ bool publish_reward_definitions(rewards::View definitions) noexcept { && transaction.finish(rewards::replace(definitions), rewards::clear); } +/** Checks a reward graph against the catalog's publication rules. */ +bool valid_reward_definitions(rewards::View definitions) noexcept { + return rewards::valid(definitions); +} + /** Reads one item's reward row. */ bool find_reward_item(std::uint16_t itemIndex, rewards::Item& item) noexcept { return rewards::find_item(itemIndex, item); diff --git a/Sunrise/src/state/build_data/runtime.h b/Sunrise/src/state/build_data/runtime.h index 037079c6a..5608e7a28 100644 --- a/Sunrise/src/state/build_data/runtime.h +++ b/Sunrise/src/state/build_data/runtime.h @@ -397,6 +397,9 @@ publish_progression_definitions(std::span defini */ [[nodiscard]] bool publish_season_pass(std::span rewards) noexcept; +/** Checks rows against the rules publish_season_pass applies to the whole list. */ +[[nodiscard]] bool valid_season_pass_rewards(std::span rewards) noexcept; + /** * Reads one season pass reward row. * @param rewardIndex Native reward-array index the opcode-2400 claim names. @@ -419,6 +422,9 @@ publish_progression_definitions(std::span defini */ [[nodiscard]] bool publish_reward_definitions(rewards::View definitions) noexcept; +/** Checks a reward graph against the rules publish_reward_definitions applies. */ +[[nodiscard]] bool valid_reward_definitions(rewards::View definitions) noexcept; + /** * Reads one item's reward row. * @param itemIndex Native item-definition index. diff --git a/Sunrise/src/state/build_data/season_pass/season_pass_build_data_runtime.cpp b/Sunrise/src/state/build_data/season_pass/season_pass_build_data_runtime.cpp index 8994ec159..b33424811 100644 --- a/Sunrise/src/state/build_data/season_pass/season_pass_build_data_runtime.cpp +++ b/Sunrise/src/state/build_data/season_pass/season_pass_build_data_runtime.cpp @@ -16,6 +16,11 @@ bool publish_season_pass(std::span rewards) noexcept && transaction.finish(season_pass::replace(rewards), season_pass::clear); } +/** Checks season pass rows against the catalog's publication rules. */ +bool valid_season_pass_rewards(std::span rewards) noexcept { + return season_pass::valid(rewards); +} + /** Reads one season pass reward row. */ bool find_season_pass_reward(std::uint16_t rewardIndex, season_pass::Reward& reward) noexcept { return season_pass::find(rewardIndex, reward); diff --git a/Sunrise/src/state/rewards/reward_resolver.cpp b/Sunrise/src/state/rewards/reward_resolver.cpp index a3e766e31..4a6a422e8 100644 --- a/Sunrise/src/state/rewards/reward_resolver.cpp +++ b/Sunrise/src/state/rewards/reward_resolver.cpp @@ -141,7 +141,7 @@ struct Resolver { } double value = entry.weight; if (!definitions::fits(entry.modifiers, data.modifiers)) { - return false; + return refuse(context, "modifier_range"); } for (const auto& modifier : data.modifiers.subspan(entry.modifiers.first, entry.modifiers.count)) { @@ -160,7 +160,7 @@ struct Resolver { } } if (!std::isfinite(value) || value < 0) { - return false; + return refuse(context, "weight_shape"); } if (value == 0) { return true; @@ -175,7 +175,7 @@ struct Resolver { } } else if (entry.itemIndex != definitions::kAbsent) { if (entry.itemIndex >= data.items.size()) { - return false; + return refuse(context, "item_range"); } } else if (entry.supplementalIndex == definitions::kAbsent) { return refuse(context, "reward_target"); @@ -189,12 +189,15 @@ struct Resolver { std::size_t depth, double& total) noexcept { total = 0; - if (depth >= definitions::kTraversalDepth || index >= data.pools.size()) { - return false; + if (depth >= definitions::kTraversalDepth) { + return refuse(context, "pool_depth"); + } + if (index >= data.pools.size()) { + return refuse(context, "pool_range"); } const auto range = data.pools[index].entries; if (!definitions::fits(range, data.entries)) { - return false; + return refuse(context, "entry_range"); } for (const auto& entry : data.entries.subspan(range.first, range.count)) { double value = 0; @@ -203,7 +206,7 @@ struct Resolver { } total += value; } - return std::isfinite(total); + return std::isfinite(total) || refuse(context, "weight_shape"); } bool @@ -235,8 +238,13 @@ struct Resolver { if (chosen->poolIndex != definitions::kAbsent) { // A pool reference selects one leaf; quantity belongs to that leaf. double childTotal = 0; - return pool_weight(chosen->poolIndex, category, depth + 1, childTotal) && childTotal > 0 - && draw(chosen->poolIndex, category, depth + 1, childTotal); + if (!pool_weight(chosen->poolIndex, category, depth + 1, childTotal)) { + return false; + } + if (childTotal == 0) { + return refuse(context, "empty_pool"); + } + return draw(chosen->poolIndex, category, depth + 1, childTotal); } if (selectedCount == selectedEntries.size()) { return refuse(context, "reward_selection_capacity"); @@ -246,20 +254,29 @@ struct Resolver { if (chosen->itemIndex == definitions::kAbsent) { return true; } - if (chosen->quantity == 0 || chosen->quantity > kMaximumQuantity - || result.count == result.grants.size() - || !definitions::fits(chosen->sockets, data.sockets)) { - return false; + return append_grant(*chosen); + } + + /** Appends the chosen leaf's item grant with the socket overrides its row carries. */ + bool append_grant(const definitions::Entry& chosen) noexcept { + if (chosen.quantity == 0 || chosen.quantity > kMaximumQuantity) { + return refuse(context, "reward_quantity"); + } + if (result.count == result.grants.size()) { + return refuse(context, "grant_capacity"); + } + if (!definitions::fits(chosen.sockets, data.sockets)) { + return refuse(context, "socket_range"); } auto& grant = result.grants[result.count++]; - grant.itemIndex = chosen->itemIndex; - grant.quantity = static_cast(chosen->quantity); - if (chosen->sockets.count > grant.sockets.size()) { - return false; + grant.itemIndex = chosen.itemIndex; + grant.quantity = static_cast(chosen.quantity); + if (chosen.sockets.count > grant.sockets.size()) { + return refuse(context, "socket_capacity"); } - grant.socketCount = chosen->sockets.count; + grant.socketCount = chosen.sockets.count; std::copy_n( - data.sockets.begin() + chosen->sockets.first, grant.socketCount, grant.sockets.begin()); + data.sockets.begin() + chosen.sockets.first, grant.socketCount, grant.sockets.begin()); return true; } }; @@ -270,11 +287,8 @@ bool resolve_item(definitions::View data, std::uint32_t quantity, Result& result) noexcept { result = {}; - if (context.refusal != nullptr) { - *context.refusal = "reward_shape"; - } if (itemIndex >= data.items.size() || quantity == 0 || quantity > kMaximumQuantity) { - return false; + return refuse(context, "reward_shape"); } const auto& item = data.items[itemIndex]; if (item.definitionHash == 0) { @@ -288,16 +302,15 @@ bool resolve_item(definitions::View data, staged.grants[0].quantity = static_cast(quantity); staged.count = 1; } else { - if (quantity != 1 || item.selectionCount == 0 - || item.selectionCount > item.selections.size()) { - return false; + if (quantity != 1) { + return refuse(context, "wrapper_quantity"); + } + if (item.selectionCount == 0 || item.selectionCount > item.selections.size()) { + return refuse(context, "wrapper_selections"); } Resolver resolver{data, context, staged, context.seed}; for (std::size_t i = 0; i < item.selectionCount; ++i) { const auto& declared = item.selections[i]; - if (declared.count > kDrawCapacity) { - return false; - } for (std::size_t j = 0; j < declared.count; ++j) { double total = 0; if (!resolver.pool_weight(item.poolIndex, declared.categoryHash, 0, total)) { diff --git a/Sunrise/src/state/runtime/runtime.h b/Sunrise/src/state/runtime/runtime.h index 62aa4a21b..b89f4f5eb 100644 --- a/Sunrise/src/state/runtime/runtime.h +++ b/Sunrise/src/state/runtime/runtime.h @@ -267,6 +267,9 @@ struct PendingRecordRewardGrant { bool prepared{}; }; +/** How one reward preparation ended. */ +enum class RewardPreparation : std::uint8_t { prepared, deferred, unresolvable }; + /** One uncommitted Season reward, the native row it will claim, and the seed of its draw. */ struct PendingSeasonPassReward { PendingRecordRewardGrant grant{}; @@ -543,11 +546,11 @@ prepare_item_acquisition(std::uint16_t collectibleIndex, * @param seed Caller-drawn entropy for any wrapper draws. * @param refusal Receives a static reason on failure. */ -[[nodiscard]] bool prepare_item_reward(std::uint16_t itemIndex, - std::uint32_t quantity, - std::uint64_t seed, - PendingRecordRewardGrant& mutation, - const char** refusal = nullptr) noexcept; +[[nodiscard]] RewardPreparation prepare_item_reward(std::uint16_t itemIndex, + std::uint32_t quantity, + std::uint64_t seed, + PendingRecordRewardGrant& mutation, + const char** refusal = nullptr) noexcept; /** * Prepares an eligible native pass row without writing its claim or acquisition flags. * @param seed Caller-drawn entropy; the commit replays the same draw from it. @@ -569,12 +572,13 @@ prepare_item_acquisition(std::uint16_t collectibleIndex, * @param rewards Item rows the record grants. * @param claimedRecordIndex Record already claimed in the banks, or kUnclaimedRecordIndex. * @param mutation Receives the prepared grant. - * @return True when every row fits the account after-image. + * @return Prepared when every row fits the account after-image. */ -[[nodiscard]] bool prepare_record_reward_grant(std::span rewards, - std::uint16_t claimedRecordIndex, - PendingRecordRewardGrant& mutation, - const char** refusal = nullptr) noexcept; +[[nodiscard]] RewardPreparation +prepare_record_reward_grant(std::span rewards, + std::uint16_t claimedRecordIndex, + PendingRecordRewardGrant& mutation, + const char** refusal = nullptr) noexcept; /** Builds the full account after-image while a record reward remains current. */ [[nodiscard]] bool preview_record_reward_grant(const PendingRecordRewardGrant& mutation, @@ -780,8 +784,12 @@ inline constexpr std::uint16_t kArtifactUnlockProgressionIndex = 39; /** @return Seasonal XP published in the account progression bank. */ [[nodiscard]] std::int32_t seasonal_experience() noexcept; -/** Publishes every seasonal value the seeded XP and artifact ownership imply. */ -[[nodiscard]] bool seed_seasonal_progression() noexcept; +/** + * Publishes every seasonal value the seeded XP and artifact ownership imply. + * @param accountObjectPublished Whether the caller's push carries the account object; earned-perk + * flags reach the client only there, so otherwise the perk walk waits for an XP grant. + */ +[[nodiscard]] bool seed_seasonal_progression(bool accountObjectPublished) noexcept; /** @return One-based Season of Arrivals rank the published XP earns. */ [[nodiscard]] std::uint16_t seasonal_rank() noexcept; diff --git a/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp b/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp index be9a48271..f581fcfe9 100644 --- a/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp +++ b/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp @@ -105,12 +105,12 @@ apply_reward_sockets(const item_details::Definition& detail, return true; } -[[nodiscard]] bool prepare_resolved_reward(const rewards::Result& resolved, - PendingRecordRewardGrant& mutation, - const char** refusal) noexcept { +[[nodiscard]] RewardPreparation prepare_resolved_reward(const rewards::Result& resolved, + PendingRecordRewardGrant& mutation, + const char** refusal) noexcept { std::array rows{}; if (resolved.count > rows.size()) { - return false; + return RewardPreparation::unresolvable; } for (std::size_t i = 0; i < resolved.count; ++i) { const auto& grant = resolved.grants[i]; @@ -123,6 +123,15 @@ apply_reward_sockets(const item_details::Definition& detail, std::span(rows).first(resolved.count), kUnclaimedRecordIndex, mutation, refusal); } +/** Classifies a catalog refusal: a verdict unless a catalog was cleared meanwhile. */ +[[nodiscard]] RewardPreparation catalog_refusal(const char*& reason) noexcept { + if (build_data::item_definitions_ready() && build_data::reward_definitions_ready()) { + return RewardPreparation::unresolvable; + } + reason = "not_ready"; + return RewardPreparation::deferred; +} + /** Wrappers and direct account perks bypass quest initialization. */ bool uses_reward_definition(const build_data::items::Definition& item) noexcept { build_data::rewards::Item reward{}; @@ -258,7 +267,7 @@ bool prepare_season_pass_reward(std::uint16_t rewardIndex, return false; } reason = "reward_placement"; - if (!prepare_resolved_reward(resolved, mutation.grant, &reason)) { + if (prepare_resolved_reward(resolved, mutation.grant, &reason) != RewardPreparation::prepared) { return false; } reason = nullptr; @@ -294,19 +303,24 @@ ItemGrantRoute item_grant_route(std::uint16_t itemIndex) noexcept { : ItemGrantRoute::unavailable; } -bool prepare_item_reward(std::uint16_t itemIndex, - std::uint32_t quantity, - std::uint64_t seed, - PendingRecordRewardGrant& mutation, - const char** refusal) noexcept { +RewardPreparation prepare_item_reward(std::uint16_t itemIndex, + std::uint32_t quantity, + std::uint64_t seed, + PendingRecordRewardGrant& mutation, + const char** refusal) noexcept { const char* unused = nullptr; auto& reason = refusal != nullptr ? *refusal : unused; - reason = "item_definition"; + reason = "not_ready"; mutation = {}; + // Unpublished domains decide nothing; retry later. + if (!build_data::item_definitions_ready() || !build_data::reward_definitions_ready()) { + return RewardPreparation::deferred; + } + reason = "item_definition"; const std::lock_guard lock(investment::store::g_mutex); build_data::items::Definition item{}; if (!build_data::find_item_definition_index(itemIndex, item)) { - return false; + return catalog_refusal(reason); } reason = "selected_character"; const AccountState account = account_snapshot(); @@ -314,21 +328,17 @@ bool prepare_item_reward(std::uint16_t itemIndex, unlocks::Table flags{}; if (character >= account.characterCount || !investment::store::read_unlocks(flags, static_cast(character))) { - return false; + return RewardPreparation::deferred; } rewards::Result resolved{}; if (!rewards::resolve({flags, account.characters[character].characterClass, seed, &reason}, itemIndex, quantity, resolved)) { - return false; + return catalog_refusal(reason); } reason = "reward_placement"; - if (!prepare_resolved_reward(resolved, mutation, &reason)) { - return false; - } - reason = nullptr; - return true; + return prepare_resolved_reward(resolved, mutation, &reason); } bool preview_reward_unlocks(const PendingRecordRewardGrant& mutation, @@ -502,23 +512,30 @@ namespace { } // namespace /** Prepares every reward over one cumulative account view. */ -bool prepare_record_reward_grant(std::span rewards, - std::uint16_t claimedRecordIndex, - PendingRecordRewardGrant& mutation, - const char** refusal) noexcept { +RewardPreparation prepare_record_reward_grant(std::span rewards, + std::uint16_t claimedRecordIndex, + PendingRecordRewardGrant& mutation, + const char** refusal) noexcept { const char* unused = nullptr; auto& reason = refusal != nullptr ? *refusal : unused; reason = "reward_count"; mutation = {}; if (rewards.empty() || rewards.size() > mutation.rewards.size()) { - return false; + return RewardPreparation::unresolvable; + } + reason = "not_ready"; + // The loadout check needs these domains, so nothing is decided before they publish. + if (!build_data::item_definitions_ready() || !build_data::configured_item_details_ready() + || !build_data::inventory_bucket_descriptors_ready() + || !build_data::socket_entry_lists_ready()) { + return RewardPreparation::deferred; } reason = "account_state"; const AccountState account = account_snapshot(); const std::size_t characterIndex = selected_character_index(account); if (!account::valid(account) || !valid_profile_inventory(account) || characterIndex >= account.characterCount) { - return false; + return RewardPreparation::deferred; } AccountState working = account; @@ -532,25 +549,27 @@ bool prepare_record_reward_grant(std::span rewards, || !build_data::find_item_definition_index(requested.itemDefinitionIndex, item) || item.questInitialization.scope != build_data::items::QuestInitialization::Scope::none) { - return false; + return RewardPreparation::unresolvable; } PreparedRecordReward prepared{}; prepared.definitionHash = item.definitionHash; prepared.quantity = requested.quantity; if (requested.acquireUnlock) { reason = "acquisition_flag"; + // Only a resolved reward asks for its flag, so the graph is published. build_data::rewards::Item source{}; if (!build_data::find_reward_item(item.definitionIndex, source) || source.definitionHash != item.definitionHash) { - return false; + return RewardPreparation::unresolvable; } prepared.acquiredFlag = source.acquiredFlag; if (source.acquiredFlag != build_data::rewards::kAbsent) { std::int32_t before = 0; + // The saved flag refuses here, not the definition. if (!investment::store::read_unlock( investment::store::Bank::accountFlags, source.acquiredFlag, before) || before < 0 || before > unlocks::kFlagSet) { - return false; + return RewardPreparation::deferred; } prepared.previousFlag = static_cast(before); } @@ -559,7 +578,7 @@ bool prepare_record_reward_grant(std::span rewards, reason = "perk_acquisition"; if (prepared.acquiredFlag == build_data::rewards::kAbsent || requested.quantity != 1 || !requested.sockets.empty()) { - return false; + return RewardPreparation::unresolvable; } prepared.kind = RecordRewardKind::accountUnlock; prepared.afterQuantity = 1; @@ -571,13 +590,13 @@ bool prepare_record_reward_grant(std::span rewards, || detail.definitionIndex != item.definitionIndex || detail.definitionHash != item.definitionHash || detail.bucketId != item.bucketId || !build_data::find_inventory_bucket_descriptor(item.bucketId, bucket)) { - return false; + return RewardPreparation::unresolvable; } reason = "duplicate_stack"; if (detail.instancedDefinitionState == item_details::InstancedDefinitionState::stackable) { for (std::size_t prior = 0; prior < index; ++prior) { if (mutation.rewards[prior].definitionHash == item.definitionHash) { - return false; + return RewardPreparation::unresolvable; } } } @@ -587,14 +606,14 @@ bool prepare_record_reward_grant(std::span rewards, && (bucket.arraySelector != inventory_buckets::ArraySelector::character || detail.instancedDefinitionState != item_details::InstancedDefinitionState::instanced)) { - return false; + return RewardPreparation::unresolvable; } reason = "inventory_destination"; if (bucket.arraySelector == inventory_buckets::ArraySelector::profile) { reason = "profile_capacity"; - if (detail.instancedDefinitionState - != item_details::InstancedDefinitionState::stackable) { - return false; + if (detail.instancedDefinitionState != item_details::InstancedDefinitionState::stackable + || requested.quantity > detail.maxStackSize) { + return RewardPreparation::unresolvable; } PendingProfileItemAcquisition staged{}; const bool actionSource = @@ -607,7 +626,7 @@ bool prepare_record_reward_grant(std::span rewards, requested.quantity, {.direct = true}, staged)) { - return false; + return RewardPreparation::deferred; } working.profileItems = staged.afterItems; working.profileItemCount = staged.afterItemCount; @@ -622,20 +641,20 @@ bool prepare_record_reward_grant(std::span rewards, == item_details::InstancedDefinitionState::instanced) { reason = "instance_quantity"; if (requested.quantity != 1) { - return false; + return RewardPreparation::unresolvable; } reason = "instance_capacity"; PendingItemAcquisition staged{}; if (!finalize_item_acquisition( working, working, item.definitionHash, false, {.direct = true}, staged)) { - return false; + return RewardPreparation::deferred; } if (!apply_reward_sockets( detail, requested.sockets, staged.afterCharacter.inventory.values[staged.inventoryIndex].sockets, &reason)) { - return false; + return RewardPreparation::unresolvable; } working.characters[characterIndex] = staged.afterCharacter; prepared.instanceSoid = staged.acquiredInstanceSoid; @@ -651,10 +670,12 @@ bool prepare_record_reward_grant(std::span rewards, && !detail.equipmentSlot.has_value()) { reason = "character_stack_capacity"; CharacterState& character = working.characters[characterIndex]; - if (requested.quantity > detail.maxStackSize - || character.nextInventorySerial - >= static_cast((std::numeric_limits::max)())) { - return false; + if (requested.quantity > detail.maxStackSize) { + return RewardPreparation::unresolvable; + } + if (character.nextInventorySerial + >= static_cast((std::numeric_limits::max)())) { + return RewardPreparation::deferred; } std::size_t stackIndex = character.stacks.count; for (std::size_t candidate = 0; candidate < character.stacks.count; ++candidate) { @@ -668,7 +689,7 @@ bool prepare_record_reward_grant(std::span rewards, || (!appended && character.stacks.values[stackIndex].quantity > detail.maxStackSize - requested.quantity)) { - return false; + return RewardPreparation::deferred; } auto& stack = character.stacks.values[stackIndex]; if (appended) { @@ -682,16 +703,17 @@ bool prepare_record_reward_grant(std::span rewards, prepared.mutationSerial = stack.mutationSerial; prepared.kind = RecordRewardKind::characterStack; } else { - return false; + return RewardPreparation::unresolvable; } mutation.rewards[index] = prepared; } reason = "loadout"; + // A full native bucket rejects the whole loadout; retry later. family4_loadout::ResolvedLoadout loadout{}; if (!account::valid(working) || !valid_profile_inventory(working) || !family4_loadout::resolve(working, characterIndex, loadout)) { - return false; + return RewardPreparation::deferred; } mutation.beforeCharacter = account.characters[characterIndex]; mutation.afterCharacter = working.characters[characterIndex]; @@ -706,7 +728,7 @@ bool prepare_record_reward_grant(std::span rewards, mutation.rewardCount = rewards.size(); reason = nullptr; mutation.prepared = true; - return true; + return RewardPreparation::prepared; } bool preview_record_reward_grant(const PendingRecordRewardGrant& mutation, diff --git a/Sunrise/src/state/runtime/state_account_runtime.cpp b/Sunrise/src/state/runtime/state_account_runtime.cpp index 55b34b836..6559ac1f0 100644 --- a/Sunrise/src/state/runtime/state_account_runtime.cpp +++ b/Sunrise/src/state/runtime/state_account_runtime.cpp @@ -242,7 +242,8 @@ bool set_selected_character(std::uint64_t characterSoid) noexcept { investment::store::g_session.selected[index] = candidate.characters[index].selected; } investment::store::g_mutex.unlock(); - (void)seed_seasonal_progression(); + // The selection patch carries no account object, so earned perks wait for the next XP grant. + (void)seed_seasonal_progression(false); return true; } diff --git a/Sunrise/src/state/runtime/state_progression_runtime.cpp b/Sunrise/src/state/runtime/state_progression_runtime.cpp index ce0214a95..9ab93d5d0 100644 --- a/Sunrise/src/state/runtime/state_progression_runtime.cpp +++ b/Sunrise/src/state/runtime/state_progression_runtime.cpp @@ -94,6 +94,13 @@ using SaleRows = std::array( + (std::min)(static_cast(kMaximumRank), earned + 1)); +} + /** * Sets one global unlock value override, replacing any existing entry for the slot. * @return False when the slot is new and the override list is full. @@ -256,10 +263,14 @@ void report_perk_refusal(std::size_t index, const char* reason) noexcept { reason); } +/** Set while a perk walk is owed to the next XP grant; only touched under g_mutex. */ +bool g_perkWalkOwed = true; + /** Earned perks set both flags in the transaction that publishes their qualifying rank. */ -bool grant_progress_flags() noexcept { +bool grant_progress_flags(bool& walked) noexcept { if (!build_data::season_pass_ready() || !build_data::reward_definitions_ready() || !build_data::item_definitions_ready()) { + g_perkWalkOwed = true; return true; } AccountState account; @@ -318,6 +329,7 @@ bool grant_progress_flags() noexcept { flags.accountFlags[flag] = unlocks::kFlagSet; } } + walked = true; return true; } @@ -357,7 +369,7 @@ std::int32_t seasonal_experience() noexcept { } /** Publishes every seasonal value the seeded XP and artifact ownership imply. */ -bool seed_seasonal_progression() noexcept { +bool seed_seasonal_progression(bool accountObjectPublished) noexcept { const std::int32_t experience = seasonal_experience(); SaleRows rows{}; std::size_t count = 0; @@ -365,6 +377,9 @@ bool seed_seasonal_progression() noexcept { return false; } investment::store::g_mutex.lock(); + if (!accountObjectPublished) { + g_perkWalkOwed = true; + } investment::store::Transaction transaction; Family5State family; if (!transaction.ready() || !investment::store::read_family5(family)) { @@ -394,19 +409,21 @@ bool seed_seasonal_progression() noexcept { } } const std::uint32_t mask = artifact_mask(rows, count); - const bool published = publish_experience_lanes(experience) - && publish_artifact_locked(family, mask, experience) - && investment::store::write_family5(family) && grant_progress_flags() - && transaction.commit(); + bool walked = false; + const bool published = + publish_experience_lanes(experience) && publish_artifact_locked(family, mask, experience) + && investment::store::write_family5(family) + && (!accountObjectPublished || grant_progress_flags(walked)) && transaction.commit(); + if (published && walked) { + g_perkWalkOwed = false; + } investment::store::g_mutex.unlock(); return published; } /** @return One-based Season of Arrivals rank the published XP earns. */ std::uint16_t seasonal_rank() noexcept { - const std::int32_t earned = seasonal_experience() / kExperiencePerRank; - return static_cast( - (std::min)(static_cast(kMaximumRank), earned + 1)); + return seasonal_rank_for(seasonal_experience()); } /** @return Account-wide Power bonus published by the seasonal artifact. */ @@ -439,6 +456,8 @@ bool grant_seasonal_experience(std::int32_t amount) noexcept { return false; } const auto total = previous + amount; + const bool walkPerks = + g_perkWalkOwed || seasonal_rank_for(previous) != seasonal_rank_for(total); Family5State family; if (!investment::store::read_family5(family)) { @@ -447,9 +466,15 @@ bool grant_seasonal_experience(std::int32_t amount) noexcept { SaleRows rows{}; std::size_t count = 0; const std::uint32_t mask = sale_rows(rows, count) ? artifact_mask(rows, count) : 0U; - return publish_experience_lanes(total) && publish_artifact_locked(family, mask, total) - && investment::store::write_family5(family) && grant_progress_flags() - && transaction.commit(); + bool walked = false; + const bool granted = publish_experience_lanes(total) + && publish_artifact_locked(family, mask, total) + && investment::store::write_family5(family) + && (!walkPerks || grant_progress_flags(walked)) && transaction.commit(); + if (granted && walked) { + g_perkWalkOwed = false; + } + return granted; } /** @return True when this Season pass reward row is already claimed. */ diff --git a/Sunrise/src/state/runtime/state_runtime.cpp b/Sunrise/src/state/runtime/state_runtime.cpp index 61e9dbe34..e97acd0fd 100644 --- a/Sunrise/src/state/runtime/state_runtime.cpp +++ b/Sunrise/src/state/runtime/state_runtime.cpp @@ -300,7 +300,8 @@ bool initialize(void* module, ReleaseSRWLockExclusive(&runtime::storage::g_stateLock); secure_reset(*initialized); // The seeded banks decide every derived bar, gate and seasonal counter, so both run last. - (void)seed_seasonal_progression(); + // The login snapshot carries the account object, so the seed walks earned perks now. + (void)seed_seasonal_progression(true); unlocks::records::seed(); return transaction.commit(); } From 6ac611759b3196f3edc7e87333157ed5b8b18e4d Mon Sep 17 00:00:00 2001 From: Zach Date: Sun, 27 Sep 2026 15:24:30 -0500 Subject: [PATCH 08/12] Bump the cache format version past upstream --- Sunrise/src/state/build_data/cache/records/format.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sunrise/src/state/build_data/cache/records/format.h b/Sunrise/src/state/build_data/cache/records/format.h index a7f1cb1be..275c12be7 100644 --- a/Sunrise/src/state/build_data/cache/records/format.h +++ b/Sunrise/src/state/build_data/cache/records/format.h @@ -32,7 +32,7 @@ namespace sunrise::state::build_data::cache::records { /** These 8 ASCII bytes mark a Sunrise build-data file. */ inline constexpr std::array kCacheMagic{'S', 'U', 'N', 'R', 'I', 'S', 'E', 'B'}; /** Bump when stored layouts or extracted values change; other versions are rebuilt. */ -inline constexpr std::uint32_t kCacheFormatVersion = 70; +inline constexpr std::uint32_t kCacheFormatVersion = 71; /** Signed -1 on disk means there is no equipment slot. */ inline constexpr std::int8_t kAbsentEquipmentSlot = -1; /** The standard 64-bit FNV-1a offset basis starts the payload checksum. */ From 05ce3ea7ea5e74449d2a6a98a4b58b80945be127 Mon Sep 17 00:00:00 2001 From: Zach Date: Sun, 27 Sep 2026 16:28:07 -0500 Subject: [PATCH 09/12] Defer unlock-gated rewards, draw record rewards in full frames, and walk owed pass perks --- .../encrypted/queuez/queuez_deferred_push.cpp | 8 ++-- Sunrise/src/state/rewards/reward_resolver.cpp | 37 +++++++++++++------ Sunrise/src/state/rewards/reward_resolver.h | 11 ++++-- Sunrise/src/state/runtime/runtime.h | 8 +--- .../state_account_reward_grant_runtime.cpp | 23 ++++++++---- .../state/runtime/state_account_runtime.cpp | 3 +- .../runtime/state_progression_runtime.cpp | 10 ++--- Sunrise/src/state/runtime/state_runtime.cpp | 3 +- 8 files changed, 60 insertions(+), 43 deletions(-) diff --git a/Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp b/Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp index 4f8469f68..7d6139993 100644 --- a/Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp +++ b/Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp @@ -69,6 +69,10 @@ selected_character(const state::AccountState& account) noexcept { report_reward_refusal("world_publish", request.itemDefinitionIndex, reason); return false; }; + // A draw is not retained, so it waits for a buffer its whole frame is sure to fit. + if (response.size() < scratch.framed.size()) { + return false; + } std::uint64_t seed = 0; if (!middleware::crypto::random::fill(std::as_writable_bytes(std::span(&seed, 1)))) { return fail("random_source"); @@ -107,10 +111,6 @@ selected_character(const state::AccountState& account) noexcept { scratch.framed, framedSize) && framedSize != 0; - // Only a piggyback slot can be short; the grant waits for a full poll. - if (encoded && framedSize > response.size()) { - return fail("response_capacity"); - } if (!state::commit_record_reward(*pending)) { return fail("inventory_commit"); } diff --git a/Sunrise/src/state/rewards/reward_resolver.cpp b/Sunrise/src/state/rewards/reward_resolver.cpp index 4a6a422e8..b62d9d707 100644 --- a/Sunrise/src/state/rewards/reward_resolver.cpp +++ b/Sunrise/src/state/rewards/reward_resolver.cpp @@ -107,6 +107,7 @@ struct Resolver { std::array selectedEntries{}; std::array selectedCategories{}; std::size_t selectedCount{}; + bool excludedByUnlocks{}; double fraction() noexcept { // SplitMix64 makes a prepared seed replayable without shared random state. @@ -137,6 +138,7 @@ struct Resolver { return false; } if (!enabled) { + excludedByUnlocks = true; return true; } double value = entry.weight; @@ -163,6 +165,8 @@ struct Resolver { return refuse(context, "weight_shape"); } if (value == 0) { + // A modifier can change this weight when the unlocks change. + excludedByUnlocks |= entry.modifiers.count != 0; return true; } if (entry.poolIndex != definitions::kAbsent) { @@ -285,7 +289,8 @@ bool resolve_item(definitions::View data, const Context& context, std::uint16_t itemIndex, std::uint32_t quantity, - Result& result) noexcept { + Result& result, + bool& ineligible) noexcept { result = {}; if (itemIndex >= data.items.size() || quantity == 0 || quantity > kMaximumQuantity) { return refuse(context, "reward_shape"); @@ -326,6 +331,7 @@ bool resolve_item(definitions::View data, } } if (staged.count == 0) { + ineligible = resolver.excludedByUnlocks; return refuse(context, "empty_reward"); } } @@ -344,25 +350,34 @@ bool eligible(std::span instructions, return condition(instructions, context, result); } -bool resolve(const Context& context, - std::uint16_t itemIndex, - std::uint32_t quantity, - Result& result) noexcept { +Resolution resolve(const Context& context, + std::uint16_t itemIndex, + std::uint32_t quantity, + Result& result) noexcept { struct Request { const Context& context; std::uint16_t item; std::uint32_t quantity; Result& result; - } request{context, itemIndex, quantity, result}; + bool ineligible; + } request{context, itemIndex, quantity, result, false}; result = {}; if (context.refusal != nullptr) { *context.refusal = "reward_item"; } - return build_data::read_reward_definitions( - &request, [](void* raw, definitions::View data) noexcept { - auto& value = *static_cast(raw); - return resolve_item(data, value.context, value.item, value.quantity, value.result); - }); + if (build_data::read_reward_definitions(&request, + [](void* raw, definitions::View data) noexcept { + auto& value = *static_cast(raw); + return resolve_item(data, + value.context, + value.item, + value.quantity, + value.result, + value.ineligible); + })) { + return Resolution::resolved; + } + return request.ineligible ? Resolution::ineligible : Resolution::refused; } } // namespace sunrise::state::rewards diff --git a/Sunrise/src/state/rewards/reward_resolver.h b/Sunrise/src/state/rewards/reward_resolver.h index 97034441b..758b28439 100644 --- a/Sunrise/src/state/rewards/reward_resolver.h +++ b/Sunrise/src/state/rewards/reward_resolver.h @@ -34,10 +34,13 @@ struct Context { const Context& context, bool& result) noexcept; +/** How one resolution ended; ineligible means the saved unlocks left nothing to draw. */ +enum class Resolution : std::uint8_t { resolved, ineligible, refused }; + /** Plans an acquisition without changing State; retains wrappers opened separately. */ -[[nodiscard]] bool resolve(const Context& context, - std::uint16_t itemIndex, - std::uint32_t quantity, - Result& result) noexcept; +[[nodiscard]] Resolution resolve(const Context& context, + std::uint16_t itemIndex, + std::uint32_t quantity, + Result& result) noexcept; } // namespace sunrise::state::rewards diff --git a/Sunrise/src/state/runtime/runtime.h b/Sunrise/src/state/runtime/runtime.h index b89f4f5eb..dd1154450 100644 --- a/Sunrise/src/state/runtime/runtime.h +++ b/Sunrise/src/state/runtime/runtime.h @@ -784,12 +784,8 @@ inline constexpr std::uint16_t kArtifactUnlockProgressionIndex = 39; /** @return Seasonal XP published in the account progression bank. */ [[nodiscard]] std::int32_t seasonal_experience() noexcept; -/** - * Publishes every seasonal value the seeded XP and artifact ownership imply. - * @param accountObjectPublished Whether the caller's push carries the account object; earned-perk - * flags reach the client only there, so otherwise the perk walk waits for an XP grant. - */ -[[nodiscard]] bool seed_seasonal_progression(bool accountObjectPublished) noexcept; +/** Publishes every seasonal value the seeded XP and artifact ownership imply. */ +[[nodiscard]] bool seed_seasonal_progression() noexcept; /** @return One-based Season of Arrivals rank the published XP earns. */ [[nodiscard]] std::uint16_t seasonal_rank() noexcept; diff --git a/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp b/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp index f581fcfe9..947eb69cd 100644 --- a/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp +++ b/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp @@ -174,10 +174,11 @@ enum class PassResolution { claim, replay }; || !enabled) { return false; } - if (!rewards::resolve({flags, account.characters[character].characterClass, seed, reason}, - reward.itemIndex, - reward.quantity, - result)) { + if (rewards::resolve({flags, account.characters[character].characterClass, seed, reason}, + reward.itemIndex, + reward.quantity, + result) + != rewards::Resolution::resolved) { return false; } if (reward.socketCount != 0) { @@ -331,10 +332,16 @@ RewardPreparation prepare_item_reward(std::uint16_t itemIndex, return RewardPreparation::deferred; } rewards::Result resolved{}; - if (!rewards::resolve({flags, account.characters[character].characterClass, seed, &reason}, - itemIndex, - quantity, - resolved)) { + const auto resolution = + rewards::resolve({flags, account.characters[character].characterClass, seed, &reason}, + itemIndex, + quantity, + resolved); + // The saved unlocks can change, so an empty draw they caused is retried, not retired. + if (resolution == rewards::Resolution::ineligible) { + return RewardPreparation::deferred; + } + if (resolution != rewards::Resolution::resolved) { return catalog_refusal(reason); } reason = "reward_placement"; diff --git a/Sunrise/src/state/runtime/state_account_runtime.cpp b/Sunrise/src/state/runtime/state_account_runtime.cpp index 6559ac1f0..55b34b836 100644 --- a/Sunrise/src/state/runtime/state_account_runtime.cpp +++ b/Sunrise/src/state/runtime/state_account_runtime.cpp @@ -242,8 +242,7 @@ bool set_selected_character(std::uint64_t characterSoid) noexcept { investment::store::g_session.selected[index] = candidate.characters[index].selected; } investment::store::g_mutex.unlock(); - // The selection patch carries no account object, so earned perks wait for the next XP grant. - (void)seed_seasonal_progression(false); + (void)seed_seasonal_progression(); return true; } diff --git a/Sunrise/src/state/runtime/state_progression_runtime.cpp b/Sunrise/src/state/runtime/state_progression_runtime.cpp index 9ab93d5d0..d98faee70 100644 --- a/Sunrise/src/state/runtime/state_progression_runtime.cpp +++ b/Sunrise/src/state/runtime/state_progression_runtime.cpp @@ -263,7 +263,7 @@ void report_perk_refusal(std::size_t index, const char* reason) noexcept { reason); } -/** Set while a perk walk is owed to the next XP grant; only touched under g_mutex. */ +/** Set while a perk walk is owed; only touched under g_mutex. */ bool g_perkWalkOwed = true; /** Earned perks set both flags in the transaction that publishes their qualifying rank. */ @@ -369,7 +369,7 @@ std::int32_t seasonal_experience() noexcept { } /** Publishes every seasonal value the seeded XP and artifact ownership imply. */ -bool seed_seasonal_progression(bool accountObjectPublished) noexcept { +bool seed_seasonal_progression() noexcept { const std::int32_t experience = seasonal_experience(); SaleRows rows{}; std::size_t count = 0; @@ -377,9 +377,6 @@ bool seed_seasonal_progression(bool accountObjectPublished) noexcept { return false; } investment::store::g_mutex.lock(); - if (!accountObjectPublished) { - g_perkWalkOwed = true; - } investment::store::Transaction transaction; Family5State family; if (!transaction.ready() || !investment::store::read_family5(family)) { @@ -410,10 +407,11 @@ bool seed_seasonal_progression(bool accountObjectPublished) noexcept { } const std::uint32_t mask = artifact_mask(rows, count); bool walked = false; + // A later character pick publishes only a selection patch, so only an owed walk runs here. const bool published = publish_experience_lanes(experience) && publish_artifact_locked(family, mask, experience) && investment::store::write_family5(family) - && (!accountObjectPublished || grant_progress_flags(walked)) && transaction.commit(); + && (!g_perkWalkOwed || grant_progress_flags(walked)) && transaction.commit(); if (published && walked) { g_perkWalkOwed = false; } diff --git a/Sunrise/src/state/runtime/state_runtime.cpp b/Sunrise/src/state/runtime/state_runtime.cpp index e97acd0fd..61e9dbe34 100644 --- a/Sunrise/src/state/runtime/state_runtime.cpp +++ b/Sunrise/src/state/runtime/state_runtime.cpp @@ -300,8 +300,7 @@ bool initialize(void* module, ReleaseSRWLockExclusive(&runtime::storage::g_stateLock); secure_reset(*initialized); // The seeded banks decide every derived bar, gate and seasonal counter, so both run last. - // The login snapshot carries the account object, so the seed walks earned perks now. - (void)seed_seasonal_progression(true); + (void)seed_seasonal_progression(); unlocks::records::seed(); return transaction.commit(); } From 8a30ca6fd963ba525b87b5599db7a247667eda69 Mon Sep 17 00:00:00 2001 From: Zach Date: Sun, 27 Sep 2026 23:42:01 -0500 Subject: [PATCH 10/12] Open nested reward wrappers, retry transient failures, and bound each change list --- .../items/packages/package_reward_build.cpp | 247 ++++++++++++------ .../items/packages/package_reward_build.h | 27 +- .../packages/package_season_pass_build.cpp | 16 +- .../src/server/bap/bap_world_reward_queue.cpp | 14 +- .../push/snapshot/family4_reward_updates.cpp | 8 +- .../encrypted/queuez/queuez_deferred_push.cpp | 9 +- Sunrise/src/server/bap/internal.h | 1 + .../cache/records/cache_reward_records.cpp | 22 +- .../state/build_data/cache/records/format.h | 2 +- .../src/state/build_data/rewards/definition.h | 7 +- .../build_data/rewards/reward_catalog.cpp | 18 +- .../state/build_data/rewards/reward_catalog.h | 2 +- Sunrise/src/state/investment/store_internal.h | 2 + Sunrise/src/state/rewards/reward_resolver.cpp | 191 ++++++++++---- Sunrise/src/state/rewards/reward_resolver.h | 8 +- Sunrise/src/state/runtime/runtime.h | 17 +- .../state_account_reward_grant_runtime.cpp | 186 ++++++++++--- .../runtime/state_progression_runtime.cpp | 28 +- .../src/state/unlocks/unlocks_expression.h | 4 +- 19 files changed, 595 insertions(+), 214 deletions(-) diff --git a/Sunrise/src/client/content/items/packages/package_reward_build.cpp b/Sunrise/src/client/content/items/packages/package_reward_build.cpp index 0d1f7cf54..2f9a42e02 100644 --- a/Sunrise/src/client/content/items/packages/package_reward_build.cpp +++ b/Sunrise/src/client/content/items/packages/package_reward_build.cpp @@ -126,18 +126,42 @@ template bool append(std::vector& bank, T value, std::size_t cap } } +/** 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. */ -bool root_table(const reader::Source& source, - reader::Scratch& scratch, - std::span root, - std::size_t slot, - std::vector& blob, - std::uint32_t expectedClass = 0) noexcept { +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; - return tables::slot_tag(root, slot, tag) && tables::package_of(tag) != tables::kAbsentPackageId - && reader::read_tag(source, scratch, tag, blob, cls) - && (expectedClass == 0 || cls == expectedClass); + 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. */ @@ -187,13 +211,14 @@ bool reward_definitions_settled() noexcept { bool RewardBuild::entry(std::span blob, std::size_t at, - const char*& fullBank) noexcept { + BankFailure& failure, + const char*& reason) noexcept { domain::Entry out{}; - bool instructionsFull = false; + failure = BankFailure::none; if (!read_reward_entry(blob, at, out) || !conditions.read( - blob, at + kEntryConditionOffset, instructions_, out.condition, instructionsFull)) { - fullBank = instructionsFull ? "instruction_capacity" : nullptr; + blob, at + kEntryConditionOffset, instructions_, out.condition, failure)) { + reason = failure == BankFailure::full ? "instruction_capacity" : "instruction_storage"; return false; } out.supplementalMissing = supplementalMissing_ && out.supplementalIndex != domain::kAbsent; @@ -207,18 +232,21 @@ bool RewardBuild::entry(std::span blob, 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, instructionsFull) + 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)) { - fullBank = instructionsFull ? "instruction_capacity" : nullptr; + reason = failure == BankFailure::full ? "instruction_capacity" : "instruction_storage"; return false; } if (modifiers_.size() >= domain::kModifierCapacity) { - fullBank = "modifier_capacity"; + failure = BankFailure::full; + reason = "modifier_capacity"; return false; } if (!append(modifiers_, modifier, domain::kModifierCapacity)) { + failure = BankFailure::storage; + reason = "modifier_storage"; return false; } } @@ -228,24 +256,35 @@ bool RewardBuild::entry(std::span blob, return false; } if (count > domain::kSocketOverrideCapacity - sockets_.size()) { - fullBank = "socket_capacity"; + 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 - || !append(sockets_, overrides[i], domain::kSocketOverrideCapacity)) { + 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) { - fullBank = "entry_capacity"; + failure = BankFailure::full; + reason = "entry_capacity"; + return false; + } + if (!append(entries_, out, domain::kEntryCapacity)) { + failure = BankFailure::storage; + reason = "entry_storage"; return false; } - return append(entries_, out, domain::kEntryCapacity); + return true; } -void RewardConditions::load_class_flags(const reader::Source& source, +bool RewardConditions::load_class_flags(const reader::Source& source, reader::Scratch& scratch, std::span root) noexcept { classFlags_.fill(domain::kAbsent); @@ -254,20 +293,21 @@ void RewardConditions::load_class_flags(const reader::Source& source, std::vector blob; tables::Array classRows{}; tables::Array itemRows{}; - if (!root_table(source, scratch, root, kClassSlot, classes, kClassTable) - || !tables::read_array( + 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() - || !root_table(source, scratch, root, tables::kItemTableSlot, index) || !tables::read_array(index, tables::kTableArrayDescriptor, tables::kItemIndexTableClass, tables::kItemIndexRowStride, itemRows)) { - core::log::write(core::log::Channel::client, - core::log::Level::warn, - "ev=pkg stage=class_flags result=skip reason=class_tables"); - return; + settle_unsupported("class_schema"); + return false; } for (std::size_t i = 0; i < classFlags_.size(); ++i) { std::uint16_t itemIndex = domain::kAbsent; @@ -276,46 +316,60 @@ void RewardConditions::load_class_flags(const reader::Source& source, if (!tables::read(std::span{classes}, classRows.dataOffset + i * kClassStride + kDefaultFinisherOffset, itemIndex) - || !tables::index_row(index, itemRows, itemIndex, item) - || !reader::read_tag(source, scratch, item.targetTag, blob, cls) - || cls != tables::kItemDefinitionClass) { - core::log::writef( - core::log::Channel::client, - core::log::Level::warn, - "ev=pkg stage=class_flags class=%zu result=skip reason=default_finisher", - i); - continue; + || !tables::index_row(index, itemRows, itemIndex, item)) { + settle_unsupported("class_finisher_index"); + return false; } - if (!read_class_flag(blob, classFlags_[i])) { + 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=skip reason=use_condition", + "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; - load_class_flags(source, scratch, root); + 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. - loaded_ = - root_table(source, scratch, root, kFlagSlot, flags_, kFlagTableClass) - && tables::read_array( + if (!tables::read_array( flags_, tables::kTableArrayDescriptor, kFlagRowClass, kBindingStride, flagRows_) - && root_table(source, scratch, root, kValueSlot, values_, kValueTableClass) - && tables::read_array( + || !tables::read_array( values_, tables::kTableArrayDescriptor, kValueRowClass, kBindingStride, valueRows_) - && root_table(source, scratch, root, kExpressionSlot, expressions_, kExpressionTableClass) - && tables::read_array(expressions_, - tables::kTableArrayDescriptor, - kExpressionRowClass, - kExpressionRowStride, - expressionRows_); - return loaded_; + || !tables::read_array(expressions_, + tables::kTableArrayDescriptor, + kExpressionRowClass, + kExpressionRowStride, + expressionRows_)) { + settle_unsupported("condition_schema"); + return false; + } + loaded_ = true; + return true; } bool RewardConditions::loaded() const noexcept { @@ -373,7 +427,8 @@ bool RewardConditions::bind(std::uint32_t native, bool RewardConditions::append_expression(std::span blob, std::size_t at, std::vector& bank, - std::size_t depth) const noexcept { + std::size_t depth, + BankFailure& failure) const noexcept { tables::Array rows{}; if (depth >= domain::kTraversalDepth || !tables::read_array( @@ -395,15 +450,23 @@ bool RewardConditions::append_expression(std::span blob, expressionRows_.dataOffset + operand * kExpressionRowStride + kExpressionBodyOffset, bank, - depth + 1) + depth + 1, + failure) || bank.size() == before) { return false; } continue; } domain::Instruction instruction{}; - if (!bind(native, operand, instruction) || !unlocks::valid(instruction) - || !append(bank, instruction, domain::kInstructionCapacity)) { + 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; } } @@ -414,11 +477,10 @@ bool RewardConditions::read(std::span blob, std::size_t at, std::vector& bank, domain::Range& range, - bool& bankFull) const noexcept { + BankFailure& failure) const noexcept { const auto first = bank.size(); - bankFull = false; - if (!append_expression(blob, at, bank, 0)) { - bankFull = bank.size() >= domain::kInstructionCapacity; + failure = BankFailure::none; + if (!append_expression(blob, at, bank, 0, failure)) { bank.resize(first); return false; } @@ -429,31 +491,36 @@ bool RewardConditions::read(std::span blob, bool RewardConditions::read_list(std::span blob, std::size_t at, std::span output, - std::size_t& count) const noexcept { + 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; } - // The row's condition capacity below bounds this bank. - bool bankFull = false; for (std::size_t i = 0; i < rows.count; ++i) { domain::Range expression{}; if (!read(blob, rows.dataOffset + i * tables::kUnlockExpressionFieldSize, instructions, expression, - bankFull) + failure) || expression.count == 0) { return false; } // Every expression attached to a progression reward must hold. - if (i != 0 - && !append(instructions, - domain::Instruction{unlocks::Opcode::logicalAnd, unlocks::Bank::none, 0}, - output.size())) { - return false; + 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; @@ -504,12 +571,19 @@ bool RewardBuild::load(const reader::Source& source, 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) - || !tables::slot_tag(root, kSupplementalRewardSlot, supplementalTag) - || !root_table(source, scratch, root, kPoolSlot, blob, kPoolClass) - || !tables::read_array( + 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("tables"); + settle_unsupported("pool_schema"); return false; } if (rows.count == 0 || rows.count > domain::kPoolCapacity) { @@ -530,11 +604,16 @@ bool RewardBuild::load(const reader::Source& source, const auto beforeInstructions = instructions_.size(); const auto beforeModifiers = modifiers_.size(); const auto beforeSockets = sockets_.size(); - const char* fullBank = nullptr; - if (!entry(blob, members.dataOffset + j * kEntryStride, fullBank)) { - // Skipping past a full bank would publish a pool missing some of its entries. - if (fullBank != nullptr) { - settle_unsupported(fullBank); + 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); @@ -586,6 +665,8 @@ bool RewardBuild::read_item(std::uint32_t hash, ++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. @@ -616,6 +697,7 @@ bool RewardBuild::read_item(std::uint32_t hash, bool RewardBuild::begin_items(std::size_t count) noexcept { unboundAcquiredFlags_ = 0; + discardedAcquiredFlags_ = 0; // A refused load has already reported itself. if (!loaded_) { return false; @@ -682,12 +764,13 @@ bool RewardBuild::publish() noexcept { const std::size_t droppedEntries = entries_.size() - write; entries_.resize(write); sockets_.resize(socketWrite); - if (unboundAcquiredFlags_ != 0 || droppedEntries != 0) { + 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 " - "dropped_entries=%zu", + "discarded_acquired_flags=%zu dropped_entries=%zu", unboundAcquiredFlags_, + discardedAcquiredFlags_, droppedEntries); } const domain::View graph{pools_, entries_, items_, instructions_, modifiers_, sockets_}; diff --git a/Sunrise/src/client/content/items/packages/package_reward_build.h b/Sunrise/src/client/content/items/packages/package_reward_build.h index c77f287c8..a8c227c0a 100644 --- a/Sunrise/src/client/content/items/packages/package_reward_build.h +++ b/Sunrise/src/client/content/items/packages/package_reward_build.h @@ -15,6 +15,9 @@ 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: @@ -34,21 +37,24 @@ class RewardConditions { std::size_t at, std::vector& bank, state::build_data::rewards::Range& range, - bool& bankFull) const noexcept; + 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) const noexcept; + 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) const noexcept; - void load_class_flags(const middleware::content::packages::reader::Source& source, - middleware::content::packages::reader::Scratch& scratch, - std::span root) noexcept; + 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_{}; @@ -77,15 +83,18 @@ class RewardBuild { [[nodiscard]] bool publish() noexcept; private: - /** Appends one entry; fullBank names the bank that refused it, if any. */ - [[nodiscard]] bool - entry(std::span blob, std::size_t at, const char*& fullBank) noexcept; + /** 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::vector pools_; std::vector entries_; std::vector items_; 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 1d0b20e59..9a689b186 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 @@ -20,7 +20,8 @@ bool read_season_reward(Storage& storage, std::span progressionTable, const tables::Array& itemRows, std::size_t at, - domain::Reward& reward) noexcept { + domain::Reward& reward, + BankFailure& failure) noexcept { std::uint32_t rank = 0; std::uint32_t itemIndex = 0; std::uint32_t claimSlot = 0; @@ -65,7 +66,8 @@ bool read_season_reward(Storage& storage, progressionTable, at + tables::kProgressionRewardConditionsOffset, reward.condition, - conditionCount)) { + conditionCount, + failure)) { return false; } reward.socketCount = static_cast(socketCount); @@ -137,7 +139,15 @@ bool build_season_pass(const reader::Source& source, rewards.dataOffset + static_cast(row) * tables::kProgressionRewardStride; auto& reward = storage.seasonPassRewards[row]; reward = {}; - if (!read_season_reward(storage, progressionTable, itemRows, at, 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; } diff --git a/Sunrise/src/server/bap/bap_world_reward_queue.cpp b/Sunrise/src/server/bap/bap_world_reward_queue.cpp index 92fcb4b3b..3cbd24b66 100644 --- a/Sunrise/src/server/bap/bap_world_reward_queue.cpp +++ b/Sunrise/src/server/bap/bap_world_reward_queue.cpp @@ -126,6 +126,7 @@ bool current_world_reward(WorldRewardRequest& request) noexcept { 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(); @@ -162,8 +163,17 @@ 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(); - report_reward_refusal( - "world_retire", request.itemDefinitionIndex, retired ? reason : "queue_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; } diff --git a/Sunrise/src/server/bap/encrypted/push/snapshot/family4_reward_updates.cpp b/Sunrise/src/server/bap/encrypted/push/snapshot/family4_reward_updates.cpp index ec2d83c00..b28fead96 100644 --- a/Sunrise/src/server/bap/encrypted/push/snapshot/family4_reward_updates.cpp +++ b/Sunrise/src/server/bap/encrypted/push/snapshot/family4_reward_updates.cpp @@ -1,12 +1,10 @@ -/** Family-4 reward updates: season pass packages and record rewards, one revision each. */ +/** Family-4 reward updates: record rewards, one revision each. */ #include #include #include -#include #include -#include "../../../../../middleware/datagen/definitions.h" #include "../../../../../middleware/datagen/family4/account/account_encoder.h" #include "../../../../../middleware/datagen/family4/account/layout.h" #include "../../../../../middleware/datagen/family4/character/character_encoder.h" @@ -21,7 +19,7 @@ namespace sunrise::server::bap::encrypted::push::snapshot { namespace family4_datagen = middleware::datagen::family4; -// Every row one reward grant publishes is named in the character or the profile change list. +// The grant array holds both change lists in full; State bounds each list separately. static_assert(state::kRecordRewardGrantCapacity == family4_datagen::character::layout::kInventoryChangeRecordCapacity + family4_datagen::account::layout::kProfileInventoryChangeRecordCapacity); @@ -36,7 +34,7 @@ bool prepare_record_reward_grant( Prepared& prepared) noexcept { namespace account_layout = family4_datagen::account::layout; namespace character_layout = family4_datagen::character::layout; - if (!mutation.prepared || mutation.rewardCount == 0 + if (!mutation.prepared || (mutation.rewardCount == 0 && mutation.wrapperFlags.empty()) || mutation.rewardCount > mutation.rewards.size() || !queuez::valid(before) || !queuez::valid(update.after) || !before.family4Active || before.family4ResidentCount == 0 || before.family4Version == (std::numeric_limits::max)() diff --git a/Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp b/Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp index 7d6139993..fc8d3a119 100644 --- a/Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp +++ b/Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp @@ -63,8 +63,6 @@ selected_character(const state::AccountState& account) noexcept { std::span response, std::size_t& written, bool& touchesScratch) noexcept { - const std::unique_ptr pending( - new (std::nothrow) state::PendingRecordRewardGrant); const auto fail = [&](const char* reason) noexcept { report_reward_refusal("world_publish", request.itemDefinitionIndex, reason); return false; @@ -73,13 +71,18 @@ selected_character(const state::AccountState& account) noexcept { if (response.size() < scratch.framed.size()) { return false; } + const std::unique_ptr pending( + new (std::nothrow) state::PendingRecordRewardGrant); + if (!pending) { + return fail("reward_allocation"); + } std::uint64_t seed = 0; if (!middleware::crypto::random::fill(std::as_writable_bytes(std::span(&seed, 1)))) { return fail("random_source"); } const char* reason = "transaction"; state::investment::store::Transaction transaction; - if (!pending || !transaction.ready()) { + if (!transaction.ready()) { return fail(reason); } const auto preparation = diff --git a/Sunrise/src/server/bap/internal.h b/Sunrise/src/server/bap/internal.h index 897296c6a..e265b7bdb 100644 --- a/Sunrise/src/server/bap/internal.h +++ b/Sunrise/src/server/bap/internal.h @@ -339,6 +339,7 @@ enum class WorldRewardKind : std::uint8_t { /** One reward earned in world, held until a Family-4 peer can publish it. */ struct WorldRewardRequest { std::uint64_t id{}; + std::uint32_t definitionHash{}; std::int32_t quantity{}; std::uint16_t itemDefinitionIndex{}; WorldRewardKind kind{}; diff --git a/Sunrise/src/state/build_data/cache/records/cache_reward_records.cpp b/Sunrise/src/state/build_data/cache/records/cache_reward_records.cpp index 4779f1526..02c1a7ec2 100644 --- a/Sunrise/src/state/build_data/cache/records/cache_reward_records.cpp +++ b/Sunrise/src/state/build_data/cache/records/cache_reward_records.cpp @@ -1,6 +1,17 @@ +#include + #include "codec.h" namespace sunrise::state::build_data::cache::records { +namespace { + +/** A selection slot past an item's count holds what a default selection encodes to. */ +bool unused_selection(const RewardSelectionRecord& record) noexcept { + const rewards::Selection unused{}; + return record.categoryHash == unused.categoryHash && record.count == unused.count; +} + +} // namespace /** Encodes one pool identity and its member range. */ bool encode(const rewards::Pool& value, RewardPoolRecord& record) noexcept { @@ -68,13 +79,20 @@ bool encode(const rewards::Item& value, RewardItemRecord& record) noexcept { return true; } -/** Decodes one item's wrapper; the complete-domain validator checks its references. */ +/** Decodes one item's wrapper and checks its unused selections; references are checked later. */ bool decode(const RewardItemRecord& record, rewards::Item& value) noexcept { value = {}; + if (record.selectionCount > record.selections.size()) { + return false; + } + const auto unused = std::span(record.selections).subspan(record.selectionCount); + if (!std::all_of(unused.begin(), unused.end(), unused_selection)) { + return false; + } value.definitionHash = record.definitionHash; value.poolIndex = record.poolIndex; value.acquiredFlag = record.acquiredFlag; - for (std::size_t i = 0; i < value.selections.size(); ++i) { + for (std::size_t i = 0; i < record.selectionCount; ++i) { value.selections[i] = {record.selections[i].categoryHash, record.selections[i].count}; } value.selectionCount = record.selectionCount; diff --git a/Sunrise/src/state/build_data/cache/records/format.h b/Sunrise/src/state/build_data/cache/records/format.h index 275c12be7..d60220e76 100644 --- a/Sunrise/src/state/build_data/cache/records/format.h +++ b/Sunrise/src/state/build_data/cache/records/format.h @@ -32,7 +32,7 @@ namespace sunrise::state::build_data::cache::records { /** These 8 ASCII bytes mark a Sunrise build-data file. */ inline constexpr std::array kCacheMagic{'S', 'U', 'N', 'R', 'I', 'S', 'E', 'B'}; /** Bump when stored layouts or extracted values change; other versions are rebuilt. */ -inline constexpr std::uint32_t kCacheFormatVersion = 71; +inline constexpr std::uint32_t kCacheFormatVersion = 72; /** Signed -1 on disk means there is no equipment slot. */ inline constexpr std::int8_t kAbsentEquipmentSlot = -1; /** The standard 64-bit FNV-1a offset basis starts the payload checksum. */ diff --git a/Sunrise/src/state/build_data/rewards/definition.h b/Sunrise/src/state/build_data/rewards/definition.h index 912c51cdc..04dff5961 100644 --- a/Sunrise/src/state/build_data/rewards/definition.h +++ b/Sunrise/src/state/build_data/rewards/definition.h @@ -29,9 +29,12 @@ inline constexpr std::size_t kSocketOverrideCapacity = 2048; inline constexpr std::size_t kSocketsPerItem = items::details::kInitialPlugCapacity; /** Category selections one wrapper declares. The most any shipped wrapper declares is four. */ inline constexpr std::size_t kSelectionCapacity = 4; -/** Rows one grant may publish: one push's 16-record character and profile change lists. */ +/** Grant rows one resolution may prepare: both change lists in full; perk rows use no list. */ inline constexpr std::size_t kGrantCapacity = 32; -/** Nested pools and referenced conditions share a bounded traversal depth. */ +/** + * Nested pools, automatic wrappers and referenced conditions share one traversal depth. The + * installed graph reaches 7. + */ inline constexpr std::size_t kTraversalDepth = 32; struct Range { diff --git a/Sunrise/src/state/build_data/rewards/reward_catalog.cpp b/Sunrise/src/state/build_data/rewards/reward_catalog.cpp index 7b82a86bc..3eea25f89 100644 --- a/Sunrise/src/state/build_data/rewards/reward_catalog.cpp +++ b/Sunrise/src/state/build_data/rewards/reward_catalog.cpp @@ -31,7 +31,7 @@ View view() noexcept { g_sockets.rows()}; } -/** Rejects a pool cycle or a nested path deeper than kTraversalDepth, memoizing subtree heights. */ +/** Rejects cycles and bounds combined pool and automatic-wrapper paths, memoizing heights. */ bool valid_depth(View data, std::size_t index, std::array& heights, @@ -48,13 +48,21 @@ bool valid_depth(View data, std::uint8_t height = 1; const Range range = data.pools[index].entries; for (const Entry& entry : data.entries.subspan(range.first, range.count)) { - if (entry.poolIndex == kAbsent) { + auto child = entry.poolIndex; + // Pool references take precedence over an item named by the same entry. + if (child == kAbsent && entry.itemIndex != kAbsent) { + const Item& item = data.items[entry.itemIndex]; + if ((item.flags & kOpenOnAcquisition) != 0) { + child = item.poolIndex; + } + } + if (child == kAbsent) { continue; } - if (!valid_depth(data, entry.poolIndex, heights, depth + 1)) { + if (!valid_depth(data, child, heights, depth + 1)) { return false; } - height = (std::max)(height, static_cast(heights[entry.poolIndex] + 1)); + height = (std::max)(height, static_cast(heights[child] + 1)); } heights[index] = height; return true; @@ -99,7 +107,7 @@ bool ready() noexcept { return g_pools.count() != 0 && g_items.count() != 0; } -/** Checks bank ranges, item references and bounded acyclic pool traversal. */ +/** Checks bank ranges, item references and bounded acyclic pool and wrapper traversal. */ bool valid(View data) noexcept { if (data.pools.empty() || data.pools.size() > kPoolCapacity || data.entries.empty() || data.entries.size() > kEntryCapacity || data.items.empty() diff --git a/Sunrise/src/state/build_data/rewards/reward_catalog.h b/Sunrise/src/state/build_data/rewards/reward_catalog.h index 2818fa8da..3b1a71766 100644 --- a/Sunrise/src/state/build_data/rewards/reward_catalog.h +++ b/Sunrise/src/state/build_data/rewards/reward_catalog.h @@ -8,7 +8,7 @@ namespace sunrise::state::build_data::rewards { void clear() noexcept; /** True once pool and item banks have been published. */ [[nodiscard]] bool ready() noexcept; -/** Checks bank ranges, item references and bounded acyclic pool traversal. */ +/** Checks bank ranges, item references and bounded acyclic pool and wrapper traversal. */ [[nodiscard]] bool valid(View data) noexcept; /** Publishes validated banks together under the catalog lock. */ [[nodiscard]] bool replace(View data) noexcept; diff --git a/Sunrise/src/state/investment/store_internal.h b/Sunrise/src/state/investment/store_internal.h index 49178575a..2b6dab6b3 100644 --- a/Sunrise/src/state/investment/store_internal.h +++ b/Sunrise/src/state/investment/store_internal.h @@ -21,6 +21,8 @@ struct Session { std::array selected{}; std::array activities{}; std::uint64_t signInSeconds{}; + /** Set while an earned-perk walk is owed; a rolled-back transaction restores it. */ + bool perkWalkOwed{true}; }; extern Session g_session; diff --git a/Sunrise/src/state/rewards/reward_resolver.cpp b/Sunrise/src/state/rewards/reward_resolver.cpp index b62d9d707..44954d415 100644 --- a/Sunrise/src/state/rewards/reward_resolver.cpp +++ b/Sunrise/src/state/rewards/reward_resolver.cpp @@ -7,10 +7,18 @@ #include "../build_data/runtime.h" #include "../unlocks/unlocks_expression.h" -namespace sunrise::state::rewards { +namespace sunrise::state::reward_resolution { namespace { namespace definitions = build_data::rewards; +using AcquiredFlags = std::bitset; + +/** Completed acquisitions are visible to later draws without changing saved unlocks. */ +struct ConditionInputs { + const Context& context; + const AcquiredFlags* acquiredFlags{}; +}; + /** The empty category tag matches every wrapper selection. */ constexpr std::uint32_t kEmptyTag = 0x811C9DC5U; /** Native wrappers retain up to 64 selections, including rows without an item grant. */ @@ -28,11 +36,13 @@ bool refuse(const Context& context, const char* reason) noexcept { /** Reads one validated flag from the caller's unlock banks or selected character. */ bool read_flag(const void* raw, const unlocks::Instruction& instruction, bool& set) noexcept { - const auto& context = *static_cast(raw); + const auto& inputs = *static_cast(raw); + const auto& context = inputs.context; const auto index = instruction.operand; switch (instruction.bank) { case unlocks::Bank::account: - set = context.unlocks.accountFlags[index] == unlocks::kFlagSet; + set = (inputs.acquiredFlags != nullptr && (*inputs.acquiredFlags)[index]) + || context.unlocks.accountFlags[index] == unlocks::kFlagSet; return true; case unlocks::Bank::profile: set = context.unlocks.profileFlags[index] == unlocks::kFlagSet; @@ -52,7 +62,7 @@ bool read_flag(const void* raw, const unlocks::Instruction& instruction, bool& s bool read_value(const void* raw, const unlocks::Instruction& instruction, std::int32_t& value) noexcept { - const auto& context = *static_cast(raw); + const auto& context = static_cast(raw)->context; switch (instruction.bank) { case unlocks::Bank::account: value = context.unlocks.objectiveValues[instruction.operand]; @@ -68,7 +78,8 @@ bool read_value(const void* raw, /** An empty program is unconditional; external identities are not in the saved banks. */ bool condition(std::span program, const Context& context, - bool& result) noexcept { + bool& result, + const AcquiredFlags* acquiredFlags = nullptr) noexcept { result = program.empty(); if (program.empty()) { return true; @@ -83,20 +94,33 @@ bool condition(std::span program, : "external_value"); } } - const unlocks::Inputs inputs{read_flag, read_value, &context}; + const ConditionInputs conditionInputs{context, acquiredFlags}; + const unlocks::Inputs inputs{read_flag, read_value, &conditionInputs}; return unlocks::evaluate(program, inputs, result) || refuse(context, "condition_shape"); } bool condition(definitions::View data, definitions::Range expression, const Context& context, - bool& result) noexcept { + bool& result, + const AcquiredFlags& acquiredFlags) noexcept { if (!definitions::fits(expression, data.instructions)) { result = false; return refuse(context, "condition_shape"); } - return condition( - data.instructions.subspan(expression.first, expression.count), context, result); + return condition(data.instructions.subspan(expression.first, expression.count), + context, + result, + &acquiredFlags); +} + +/** SplitMix64 supplies replayable wrapper seeds and wrapper-local draws. */ +std::uint64_t next_random(std::uint64_t& state) noexcept { + state += 0x9E3779B97F4A7C15ULL; + auto bits = state; + bits = (bits ^ (bits >> 30)) * 0xBF58476D1CE4E5B9ULL; + bits = (bits ^ (bits >> 27)) * 0x94D049BB133111EBULL; + return bits ^ (bits >> 31); } struct Resolver { @@ -104,19 +128,17 @@ struct Resolver { const Context& context; Result& result; std::uint64_t random; + std::uint64_t& wrapperSeed; + AcquiredFlags& acquiredFlags; + bool& excludedByUnlocks; + bool& leafDrawn; + std::size_t wrapperDepth{}; std::array selectedEntries{}; std::array selectedCategories{}; std::size_t selectedCount{}; - bool excludedByUnlocks{}; double fraction() noexcept { - // SplitMix64 makes a prepared seed replayable without shared random state. - random += 0x9E3779B97F4A7C15ULL; - auto bits = random; - bits = (bits ^ (bits >> 30)) * 0xBF58476D1CE4E5B9ULL; - bits = (bits ^ (bits >> 27)) * 0x94D049BB133111EBULL; - bits ^= bits >> 31; - return static_cast(bits >> 11) * 0x1.0p-53; + return static_cast(next_random(random) >> 11) * 0x1.0p-53; } bool weight(const definitions::Entry& entry, @@ -134,7 +156,7 @@ struct Resolver { } } bool enabled = false; - if (!condition(data, entry.condition, context, enabled)) { + if (!condition(data, entry.condition, context, enabled, acquiredFlags)) { return false; } if (!enabled) { @@ -147,7 +169,7 @@ struct Resolver { } for (const auto& modifier : data.modifiers.subspan(entry.modifiers.first, entry.modifiers.count)) { - if (!condition(data, modifier.condition, context, enabled)) { + if (!condition(data, modifier.condition, context, enabled, acquiredFlags)) { return false; } if (enabled) { @@ -256,22 +278,72 @@ struct Resolver { selectedEntries[selectedCount] = chosen; selectedCategories[selectedCount++] = category; if (chosen->itemIndex == definitions::kAbsent) { + leafDrawn = true; + return true; + } + return acquire_item(*chosen); + } + + /** Completes one acquisition after its children have finished. */ + bool acquire_flag(const definitions::Item& item, bool wrapper) noexcept { + if (item.acquiredFlag == definitions::kAbsent) { return true; } - return append_grant(*chosen); + if (item.acquiredFlag >= acquiredFlags.size()) { + return refuse(context, "acquisition_flag"); + } + acquiredFlags.set(item.acquiredFlag); + if (wrapper) { + result.wrapperFlags.set(item.acquiredFlag); + } + return true; } - /** Appends the chosen leaf's item grant with the socket overrides its row carries. */ - bool append_grant(const definitions::Entry& chosen) noexcept { + /** Acquires a selected item, opening automatic wrappers before the next parent draw. */ + bool acquire_item(const definitions::Entry& chosen) noexcept { if (chosen.quantity == 0 || chosen.quantity > kMaximumQuantity) { return refuse(context, "reward_quantity"); } - if (result.count == result.grants.size()) { - return refuse(context, "grant_capacity"); - } if (!definitions::fits(chosen.sockets, data.sockets)) { return refuse(context, "socket_range"); } + const auto& item = data.items[chosen.itemIndex]; + if (item.definitionHash == 0) { + return refuse(context, "item_unavailable"); + } + if (item.poolIndex != definitions::kAbsent + && (item.flags & definitions::kOpenOnAcquisition) != 0) { + if (chosen.quantity != 1) { + return refuse(context, "wrapper_quantity"); + } + // These sockets belong to the consumed instance, not its descendant items. + if (chosen.sockets.count != 0) { + return refuse(context, "wrapper_socket_override"); + } + bool childLeafDrawn = false; + Resolver child{data, + context, + result, + next_random(wrapperSeed), + wrapperSeed, + acquiredFlags, + excludedByUnlocks, + childLeafDrawn, + wrapperDepth + 1}; + if (!child.open_wrapper(item)) { + return false; + } + // A child acquires its flag only when a draw inside it completed on a leaf row. + if (!childLeafDrawn) { + return true; + } + leafDrawn = true; + return acquire_flag(item, true); + } + if (result.count == result.grants.size()) { + return refuse(context, "grant_capacity"); + } + leafDrawn = true; auto& grant = result.grants[result.count++]; grant.itemIndex = chosen.itemIndex; grant.quantity = static_cast(chosen.quantity); @@ -281,6 +353,33 @@ struct Resolver { grant.socketCount = chosen.sockets.count; std::copy_n( data.sockets.begin() + chosen.sockets.first, grant.socketCount, grant.sockets.begin()); + return acquire_flag(item, false); + } + + /** Each wrapper owns its selection history and random stream; pool references share them. */ + bool open_wrapper(const definitions::Item& item) noexcept { + if (wrapperDepth >= definitions::kTraversalDepth) { + return refuse(context, "wrapper_depth"); + } + if (item.selectionCount == 0 || item.selectionCount > item.selections.size()) { + return refuse(context, "wrapper_selections"); + } + for (std::size_t i = 0; i < item.selectionCount; ++i) { + const auto& declared = item.selections[i]; + for (std::size_t j = 0; j < declared.count; ++j) { + double total = 0; + if (!pool_weight(item.poolIndex, declared.categoryHash, 0, total)) { + return false; + } + // Completed acquisitions can leave fewer eligible members than requested. + if (total == 0) { + break; + } + if (!draw(item.poolIndex, declared.categoryHash, 0, total)) { + return false; + } + } + } return true; } }; @@ -310,30 +409,28 @@ bool resolve_item(definitions::View data, if (quantity != 1) { return refuse(context, "wrapper_quantity"); } - if (item.selectionCount == 0 || item.selectionCount > item.selections.size()) { - return refuse(context, "wrapper_selections"); - } - Resolver resolver{data, context, staged, context.seed}; - for (std::size_t i = 0; i < item.selectionCount; ++i) { - const auto& declared = item.selections[i]; - for (std::size_t j = 0; j < declared.count; ++j) { - double total = 0; - if (!resolver.pool_weight(item.poolIndex, declared.categoryHash, 0, total)) { - return false; - } - // A fixed bundle can have fewer eligible members after an acquisition unlock. - if (total == 0) { - break; - } - if (!resolver.draw(item.poolIndex, declared.categoryHash, 0, total)) { - return false; - } - } + std::uint64_t wrapperSeed = context.seed; + AcquiredFlags acquiredFlags{}; + bool excludedByUnlocks = false; + bool leafDrawn = false; + Resolver resolver{data, + context, + staged, + context.seed, + wrapperSeed, + acquiredFlags, + excludedByUnlocks, + leafDrawn}; + if (!resolver.open_wrapper(item)) { + return false; } - if (staged.count == 0) { - ineligible = resolver.excludedByUnlocks; + if (staged.count == 0 && staged.wrapperFlags.none()) { + ineligible = excludedByUnlocks && !leafDrawn; return refuse(context, "empty_reward"); } + if (!resolver.acquire_flag(item, true)) { + return false; + } } result = staged; if (context.refusal != nullptr) { @@ -380,4 +477,4 @@ Resolution resolve(const Context& context, return request.ineligible ? Resolution::ineligible : Resolution::refused; } -} // namespace sunrise::state::rewards +} // namespace sunrise::state::reward_resolution diff --git a/Sunrise/src/state/rewards/reward_resolver.h b/Sunrise/src/state/rewards/reward_resolver.h index 758b28439..5130735ef 100644 --- a/Sunrise/src/state/rewards/reward_resolver.h +++ b/Sunrise/src/state/rewards/reward_resolver.h @@ -1,10 +1,12 @@ #pragma once +#include + #include "../account/account_state.h" #include "../build_data/rewards/definition.h" #include "../unlocks/definition.h" -namespace sunrise::state::rewards { +namespace sunrise::state::reward_resolution { /** One item a resolved reward grants, with the socket overrides its row carries. */ struct Grant { @@ -18,6 +20,8 @@ struct Grant { struct Result { std::array grants{}; std::size_t count{}; + /** Consumed wrappers acquire flags without occupying inventory grant rows. */ + std::bitset wrapperFlags{}; }; /** The seed belongs to the prepared server transaction, so validation repeats the same draw. */ @@ -43,4 +47,4 @@ enum class Resolution : std::uint8_t { resolved, ineligible, refused }; std::uint32_t quantity, Result& result) noexcept; -} // namespace sunrise::state::rewards +} // namespace sunrise::state::reward_resolution diff --git a/Sunrise/src/state/runtime/runtime.h b/Sunrise/src/state/runtime/runtime.h index dd1154450..9282bdda4 100644 --- a/Sunrise/src/state/runtime/runtime.h +++ b/Sunrise/src/state/runtime/runtime.h @@ -5,6 +5,7 @@ #include #include #include +#include #include "../build_data/items/quest_initialization.h" #include "../build_data/records/definition.h" @@ -244,6 +245,12 @@ struct PreparedRecordReward { std::uint8_t previousFlag{}; }; +/** One consumed wrapper's acquisition flag and the value its preparation observed. */ +struct PreparedRewardFlag { + std::uint16_t index{}; + std::uint8_t previousValue{}; +}; + /** A reward grant that claims no record carries this instead of a record row. */ inline constexpr std::uint16_t kUnclaimedRecordIndex = 0xFFFFU; @@ -256,6 +263,8 @@ struct PendingRecordRewardGrant { std::array afterProfileItems{}; std::array rewards{}; + /** Sorted acquisition flags from wrappers which opened without remaining in inventory. */ + std::vector wrapperFlags; /** Record claimed with this grant, already written to the banks, or the unclaimed row. */ std::uint16_t claimedRecordIndex{kUnclaimedRecordIndex}; std::uint64_t accountSoid{}; @@ -784,7 +793,10 @@ inline constexpr std::uint16_t kArtifactUnlockProgressionIndex = 39; /** @return Seasonal XP published in the account progression bank. */ [[nodiscard]] std::int32_t seasonal_experience() noexcept; -/** Publishes every seasonal value the seeded XP and artifact ownership imply. */ +/** + * Publishes every seasonal value the seeded XP and artifact ownership imply. + * An owed earned-perk walk runs here too. + */ [[nodiscard]] bool seed_seasonal_progression() noexcept; /** @return One-based Season of Arrivals rank the published XP earns. */ @@ -794,7 +806,8 @@ inline constexpr std::uint16_t kArtifactUnlockProgressionIndex = 39; [[nodiscard]] std::uint16_t artifact_power_bonus() noexcept; /** - * Adds base XP to the seasonal lanes and republishes every value derived from the total. + * Adds base XP to the seasonal lanes and republishes every value derived from the total, + * including earned-perk flags when a rank is reached or a walk is owed. * @param amount Positive XP to grant. * @return False when the amount is not positive or the total would overflow. */ diff --git a/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp b/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp index 947eb69cd..ac6212d4f 100644 --- a/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp +++ b/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp @@ -7,6 +7,8 @@ #include #include +#include "../../middleware/datagen/family4/account/layout.h" +#include "../../middleware/datagen/family4/character/layout.h" #include "../../middleware/datagen/family4/loadout/loadout_resolver.h" #include "../build_data/runtime.h" #include "../investment/store_internal.h" @@ -26,10 +28,47 @@ namespace family4_loadout = middleware::datagen::family4::loadout; namespace { +/** Prepares inventory rows and consumed-wrapper flags against the same account view. */ +[[nodiscard]] RewardPreparation +prepare_reward_grant(std::span rewards, + std::uint16_t claimedRecordIndex, + const std::bitset* wrapperFlags, + PendingRecordRewardGrant& mutation, + const char** refusal) noexcept; + [[nodiscard]] bool materialize_record_reward(const AccountState& current, const PendingRecordRewardGrant& mutation, AccountState& after) noexcept; +/** Each native change list must fit independently; unlock flags consume neither list. */ +[[nodiscard]] const char* +reward_change_refusal(std::span rewards) noexcept { + std::size_t characterChanges = 0; + std::size_t profileChanges = 0; + for (const auto& reward : rewards) { + switch (reward.kind) { + case RecordRewardKind::characterInstance: + case RecordRewardKind::characterStack: + if (++characterChanges + > middleware::datagen::family4::character::layout::kInventoryChangeRecordCapacity) { + return "character_change_capacity"; + } + break; + case RecordRewardKind::profileStack: + if (++profileChanges > middleware::datagen::family4::account::layout:: + kProfileInventoryChangeRecordCapacity) { + return "profile_change_capacity"; + } + break; + case RecordRewardKind::accountUnlock: + break; + default: + return "reward_kind"; + } + } + return nullptr; +} + /** A reward overrides socket types, so adding an ordinary lane cannot shift a fixed roll. */ [[nodiscard]] bool apply_reward_sockets(const item_details::Definition& detail, @@ -105,7 +144,7 @@ apply_reward_sockets(const item_details::Definition& detail, return true; } -[[nodiscard]] RewardPreparation prepare_resolved_reward(const rewards::Result& resolved, +[[nodiscard]] RewardPreparation prepare_resolved_reward(const reward_resolution::Result& resolved, PendingRecordRewardGrant& mutation, const char** refusal) noexcept { std::array rows{}; @@ -119,8 +158,11 @@ apply_reward_sockets(const item_details::Definition& detail, std::span(grant.sockets).first(grant.socketCount), true}; } - return prepare_record_reward_grant( - std::span(rows).first(resolved.count), kUnclaimedRecordIndex, mutation, refusal); + return prepare_reward_grant(std::span(rows).first(resolved.count), + kUnclaimedRecordIndex, + &resolved.wrapperFlags, + mutation, + refusal); } /** Classifies a catalog refusal: a verdict unless a catalog was cleared meanwhile. */ @@ -148,7 +190,7 @@ enum class PassResolution { claim, replay }; [[nodiscard]] bool resolve_pass(const build_data::season_pass::Reward& reward, const AccountState& account, std::uint64_t seed, - rewards::Result& result, + reward_resolution::Result& result, PassResolution resolution, const char** reason = nullptr) noexcept { const auto character = selected_character_index(account); @@ -168,17 +210,19 @@ enum class PassResolution { claim, replay }; flags.accountFlags[reward.claimFlagIndex] = unlocks::kFlagClear; } bool enabled = false; - if (!rewards::eligible(std::span(reward.condition).first(reward.conditionCount), - {flags, account.characters[character].characterClass, seed, reason}, - enabled) + if (!reward_resolution::eligible( + std::span(reward.condition).first(reward.conditionCount), + {flags, account.characters[character].characterClass, seed, reason}, + enabled) || !enabled) { return false; } - if (rewards::resolve({flags, account.characters[character].characterClass, seed, reason}, - reward.itemIndex, - reward.quantity, - result) - != rewards::Resolution::resolved) { + if (reward_resolution::resolve( + {flags, account.characters[character].characterClass, seed, reason}, + reward.itemIndex, + reward.quantity, + result) + != reward_resolution::Resolution::resolved) { return false; } if (reward.socketCount != 0) { @@ -197,13 +241,19 @@ enum class PassResolution { claim, replay }; [[nodiscard]] bool reward_matches(const build_data::season_pass::Reward& reward, const PendingSeasonPassReward& mutation) noexcept { const auto* grant = &mutation.grant; - rewards::Result expected{}; + reward_resolution::Result expected{}; if (!mutation.prepared || mutation.sourceDefinitionHash != reward.itemHash || !resolve_pass( reward, account_snapshot(), mutation.seed, expected, PassResolution::replay) - || expected.count != grant->rewardCount) { + || expected.count != grant->rewardCount + || expected.wrapperFlags.count() != grant->wrapperFlags.size()) { return false; } + for (const auto& flag : grant->wrapperFlags) { + if (flag.index >= expected.wrapperFlags.size() || !expected.wrapperFlags[flag.index]) { + return false; + } + } for (std::size_t i = 0; i < expected.count; ++i) { const auto& planned = expected.grants[i]; const auto& prepared = grant->rewards[i]; @@ -261,7 +311,7 @@ bool prepare_season_pass_reward(std::uint16_t rewardIndex, return false; } mutation.seed = seed; - rewards::Result resolved{}; + reward_resolution::Result resolved{}; reason = "reward_condition"; if (!resolve_pass( reward, account_snapshot(), mutation.seed, resolved, PassResolution::claim, &reason)) { @@ -331,17 +381,17 @@ RewardPreparation prepare_item_reward(std::uint16_t itemIndex, || !investment::store::read_unlocks(flags, static_cast(character))) { return RewardPreparation::deferred; } - rewards::Result resolved{}; - const auto resolution = - rewards::resolve({flags, account.characters[character].characterClass, seed, &reason}, - itemIndex, - quantity, - resolved); + reward_resolution::Result resolved{}; + const auto resolution = reward_resolution::resolve( + {flags, account.characters[character].characterClass, seed, &reason}, + itemIndex, + quantity, + resolved); // The saved unlocks can change, so an empty draw they caused is retried, not retired. - if (resolution == rewards::Resolution::ineligible) { + if (resolution == reward_resolution::Resolution::ineligible) { return RewardPreparation::deferred; } - if (resolution != rewards::Resolution::resolved) { + if (resolution != reward_resolution::Resolution::resolved) { return catalog_refusal(reason); } reason = "reward_placement"; @@ -364,6 +414,17 @@ bool preview_reward_unlocks(const PendingRecordRewardGrant& mutation, return false; } } + for (std::size_t i = 0; i < mutation.wrapperFlags.size(); ++i) { + const auto& flag = mutation.wrapperFlags[i]; + if (flag.index >= after.accountFlags.size() || flag.previousValue > unlocks::kFlagSet + || (i != 0 && mutation.wrapperFlags[i - 1].index >= flag.index) + || after.accountFlags[flag.index] != flag.previousValue) { + return false; + } + } + for (const auto& flag : mutation.wrapperFlags) { + after.accountFlags[flag.index] = unlocks::kFlagSet; + } for (std::size_t i = 0; i < mutation.rewardCount; ++i) { const auto flag = mutation.rewards[i].acquiredFlag; if (flag != build_data::rewards::kAbsent) { @@ -406,9 +467,11 @@ namespace { [[nodiscard]] bool materialize_record_reward(const AccountState& current, const PendingRecordRewardGrant& mutation, AccountState& after) noexcept { - if (!mutation.prepared || mutation.rewardCount == 0 - || mutation.rewardCount > mutation.rewards.size() || mutation.accountSoid == 0 - || mutation.characterSoid == 0 || mutation.characterIndex >= current.characterCount + if (!mutation.prepared || (mutation.rewardCount == 0 && mutation.wrapperFlags.empty()) + || mutation.rewardCount > mutation.rewards.size() + || reward_change_refusal(std::span(mutation.rewards).first(mutation.rewardCount)) != nullptr + || mutation.accountSoid == 0 || mutation.characterSoid == 0 + || mutation.characterIndex >= current.characterCount || current.primarySoid != mutation.accountSoid || !same_character(current.characters[mutation.characterIndex], mutation.beforeCharacter) || !same_profile_inventory( @@ -516,18 +579,50 @@ namespace { return true; } -} // namespace +/** Captures only flags carried by consumed wrappers; storage failure remains retryable. */ +[[nodiscard]] bool prepare_wrapper_flags(const std::bitset* flags, + PendingRecordRewardGrant& mutation, + const char*& reason) noexcept { + if (flags == nullptr) { + return true; + } + try { + mutation.wrapperFlags.reserve(flags->count()); + for (std::size_t index = 0; index < flags->size(); ++index) { + if (!(*flags)[index]) { + continue; + } + reason = "wrapper_acquisition_flag"; + std::int32_t before = 0; + if (!investment::store::read_unlock(investment::store::Bank::accountFlags, + static_cast(index), + before) + || before < 0 || before > unlocks::kFlagSet) { + return false; + } + mutation.wrapperFlags.push_back( + {static_cast(index), static_cast(before)}); + } + } catch (...) { + reason = "wrapper_flag_storage"; + return false; + } + return true; +} -/** Prepares every reward over one cumulative account view. */ -RewardPreparation prepare_record_reward_grant(std::span rewards, - std::uint16_t claimedRecordIndex, - PendingRecordRewardGrant& mutation, - const char** refusal) noexcept { +/** Prepares inventory rows and consumed-wrapper flags against the same account view. */ +RewardPreparation +prepare_reward_grant(std::span rewards, + std::uint16_t claimedRecordIndex, + const std::bitset* wrapperFlags, + PendingRecordRewardGrant& mutation, + const char** refusal) noexcept { const char* unused = nullptr; auto& reason = refusal != nullptr ? *refusal : unused; reason = "reward_count"; mutation = {}; - if (rewards.empty() || rewards.size() > mutation.rewards.size()) { + if ((rewards.empty() && (wrapperFlags == nullptr || wrapperFlags->none())) + || rewards.size() > mutation.rewards.size()) { return RewardPreparation::unresolvable; } reason = "not_ready"; @@ -545,6 +640,9 @@ RewardPreparation prepare_record_reward_grant(std::span rewards, + std::uint16_t claimedRecordIndex, + PendingRecordRewardGrant& mutation, + const char** refusal) noexcept { + return prepare_reward_grant(rewards, claimedRecordIndex, nullptr, mutation, refusal); +} + bool preview_record_reward_grant(const PendingRecordRewardGrant& mutation, AccountState& after) noexcept { after = {}; @@ -766,6 +879,13 @@ bool commit_record_reward(PendingRecordRewardGrant& mutation) noexcept { investment::store::Bank::accountFlags, flag, unlocks::kFlagSet); } } + for (const auto& flag : mutation.wrapperFlags) { + if (!ready) { + break; + } + ready = investment::store::write_unlock( + investment::store::Bank::accountFlags, flag.index, unlocks::kFlagSet); + } ready = ready && transaction.commit(); } if (!ready && mutation.claimedRecordIndex != kUnclaimedRecordIndex) { diff --git a/Sunrise/src/state/runtime/state_progression_runtime.cpp b/Sunrise/src/state/runtime/state_progression_runtime.cpp index d98faee70..c8585c66b 100644 --- a/Sunrise/src/state/runtime/state_progression_runtime.cpp +++ b/Sunrise/src/state/runtime/state_progression_runtime.cpp @@ -263,14 +263,14 @@ void report_perk_refusal(std::size_t index, const char* reason) noexcept { reason); } -/** Set while a perk walk is owed; only touched under g_mutex. */ -bool g_perkWalkOwed = true; +// The walk skips an absent acquisition flag and an absent claim flag on one sentinel. +static_assert(build_data::rewards::kAbsent == build_data::season_pass::kUnavailableFlagIndex); -/** Earned perks set both flags in the transaction that publishes their qualifying rank. */ +/** Earned perks set both flags when their rank is reached or an owed walk runs. */ bool grant_progress_flags(bool& walked) noexcept { if (!build_data::season_pass_ready() || !build_data::reward_definitions_ready() || !build_data::item_definitions_ready()) { - g_perkWalkOwed = true; + investment::store::g_session.perkWalkOwed = true; return true; } AccountState account; @@ -309,9 +309,10 @@ bool grant_progress_flags(bool& walked) noexcept { } const char* reason = nullptr; bool eligible = false; - if (!rewards::eligible(std::span(reward.condition).first(reward.conditionCount), - {flags, account.characters[character].characterClass, 0, &reason}, - eligible)) { + if (!reward_resolution::eligible( + std::span(reward.condition).first(reward.conditionCount), + {flags, account.characters[character].characterClass, 0, &reason}, + eligible)) { report_perk_refusal(index, reason != nullptr ? reason : "perk_condition"); continue; } @@ -411,9 +412,10 @@ bool seed_seasonal_progression() noexcept { const bool published = publish_experience_lanes(experience) && publish_artifact_locked(family, mask, experience) && investment::store::write_family5(family) - && (!g_perkWalkOwed || grant_progress_flags(walked)) && transaction.commit(); + && (!investment::store::g_session.perkWalkOwed || grant_progress_flags(walked)) + && transaction.commit(); if (published && walked) { - g_perkWalkOwed = false; + investment::store::g_session.perkWalkOwed = false; } investment::store::g_mutex.unlock(); return published; @@ -450,12 +452,12 @@ bool grant_seasonal_experience(std::int32_t amount) noexcept { return false; } const std::int32_t previous = seasonal_experience(); - if (previous < 0 || amount > (std::numeric_limits::max)() - previous) { + if (previous > (std::numeric_limits::max)() - amount) { return false; } const auto total = previous + amount; - const bool walkPerks = - g_perkWalkOwed || seasonal_rank_for(previous) != seasonal_rank_for(total); + const bool walkPerks = investment::store::g_session.perkWalkOwed + || seasonal_rank_for(previous) != seasonal_rank_for(total); Family5State family; if (!investment::store::read_family5(family)) { @@ -470,7 +472,7 @@ bool grant_seasonal_experience(std::int32_t amount) noexcept { && investment::store::write_family5(family) && (!walkPerks || grant_progress_flags(walked)) && transaction.commit(); if (granted && walked) { - g_perkWalkOwed = false; + investment::store::g_session.perkWalkOwed = false; } return granted; } diff --git a/Sunrise/src/state/unlocks/unlocks_expression.h b/Sunrise/src/state/unlocks/unlocks_expression.h index 52e09df63..715b296b7 100644 --- a/Sunrise/src/state/unlocks/unlocks_expression.h +++ b/Sunrise/src/state/unlocks/unlocks_expression.h @@ -6,8 +6,8 @@ namespace sunrise::state::unlocks { -/** Stack slots one expression may use. The deepest installed reward condition needs nine. */ -inline constexpr std::size_t kExpressionStackCapacity = 16; +/** Stack slots one expression may use. The deepest installed reward condition needs 20. */ +inline constexpr std::size_t kExpressionStackCapacity = 32; /** Opcodes the client's expression evaluator implements that the server evaluates. */ enum class Opcode : std::uint8_t { From fda795d0b6c67fe9070df7f9158b79940160af9b Mon Sep 17 00:00:00 2001 From: Zach Date: Mon, 28 Sep 2026 09:39:00 -0500 Subject: [PATCH 11/12] Defer draw overflows, merge repeated stack draws, keep child wrapper flags, and match rewards to the item catalog --- .../items/packages/package_item_rows.cpp | 25 ++++++--- .../items/packages/package_reward_build.cpp | 24 ++++++++- .../items/packages/package_reward_build.h | 8 ++- Sunrise/src/state/rewards/reward_resolver.cpp | 49 +++++++++-------- Sunrise/src/state/rewards/reward_resolver.h | 4 +- .../state_account_reward_grant_runtime.cpp | 52 ++++++++++++++++++- 6 files changed, 128 insertions(+), 34 deletions(-) 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 dbad60128..4c8db5eb0 100644 --- a/Sunrise/src/client/content/items/packages/package_item_rows.cpp +++ b/Sunrise/src/client/content/items/packages/package_item_rows.cpp @@ -110,9 +110,18 @@ 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) { @@ -166,9 +175,6 @@ bool build_item_rows(const reader::Source& source, append_initial_plugs(item, table.count, storage.detailRequests); } } - // A reward refusal is reported by RewardBuild and does not block the other item domains. - const bool rewardsPublished = - !needRewards || (rewardStorageReady && storage.rewardBuild.publish()); bool requestsFit = true; if (needRows) { // Every walked entry either published a row or was skipped, so the count of one @@ -184,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" diff --git a/Sunrise/src/client/content/items/packages/package_reward_build.cpp b/Sunrise/src/client/content/items/packages/package_reward_build.cpp index 2f9a42e02..ef9e0a272 100644 --- a/Sunrise/src/client/content/items/packages/package_reward_build.cpp +++ b/Sunrise/src/client/content/items/packages/package_reward_build.cpp @@ -698,6 +698,7 @@ bool RewardBuild::read_item(std::uint32_t hash, 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; @@ -724,10 +725,31 @@ void RewardBuild::item(std::uint16_t index, } } -bool RewardBuild::publish() noexcept { +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()) { diff --git a/Sunrise/src/client/content/items/packages/package_reward_build.h b/Sunrise/src/client/content/items/packages/package_reward_build.h index a8c227c0a..08015f885 100644 --- a/Sunrise/src/client/content/items/packages/package_reward_build.h +++ b/Sunrise/src/client/content/items/packages/package_reward_build.h @@ -79,8 +79,10 @@ class RewardBuild { [[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; - /** Drops rows naming items or pools that were not read, then publishes the banks. */ - [[nodiscard]] bool publish() 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. */ @@ -95,6 +97,8 @@ class RewardBuild { 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_; diff --git a/Sunrise/src/state/rewards/reward_resolver.cpp b/Sunrise/src/state/rewards/reward_resolver.cpp index 44954d415..757296e0b 100644 --- a/Sunrise/src/state/rewards/reward_resolver.cpp +++ b/Sunrise/src/state/rewards/reward_resolver.cpp @@ -132,6 +132,8 @@ struct Resolver { AcquiredFlags& acquiredFlags; bool& excludedByUnlocks; bool& leafDrawn; + bool& leafFlagAcquired; + Resolution& failure; std::size_t wrapperDepth{}; std::array selectedEntries{}; std::array selectedCategories{}; @@ -273,6 +275,7 @@ struct Resolver { return draw(chosen->poolIndex, category, depth + 1, childTotal); } if (selectedCount == selectedEntries.size()) { + failure = Resolution::exceeded; return refuse(context, "reward_selection_capacity"); } selectedEntries[selectedCount] = chosen; @@ -329,18 +332,22 @@ struct Resolver { acquiredFlags, excludedByUnlocks, childLeafDrawn, + leafFlagAcquired, + failure, wrapperDepth + 1}; if (!child.open_wrapper(item)) { return false; } - // A child acquires its flag only when a draw inside it completed on a leaf row. - if (!childLeafDrawn) { - return true; + // A child's flag is set after opening whatever it drew, but only a child that drew + // a leaf row makes the resolution non-empty. + if (childLeafDrawn) { + leafDrawn = true; + leafFlagAcquired |= item.acquiredFlag != definitions::kAbsent; } - leafDrawn = true; return acquire_flag(item, true); } if (result.count == result.grants.size()) { + failure = Resolution::exceeded; return refuse(context, "grant_capacity"); } leafDrawn = true; @@ -389,7 +396,7 @@ bool resolve_item(definitions::View data, std::uint16_t itemIndex, std::uint32_t quantity, Result& result, - bool& ineligible) noexcept { + Resolution& failure) noexcept { result = {}; if (itemIndex >= data.items.size() || quantity == 0 || quantity > kMaximumQuantity) { return refuse(context, "reward_shape"); @@ -413,6 +420,7 @@ bool resolve_item(definitions::View data, AcquiredFlags acquiredFlags{}; bool excludedByUnlocks = false; bool leafDrawn = false; + bool leafFlagAcquired = false; Resolver resolver{data, context, staged, @@ -420,12 +428,15 @@ bool resolve_item(definitions::View data, wrapperSeed, acquiredFlags, excludedByUnlocks, - leafDrawn}; + leafDrawn, + leafFlagAcquired, + failure}; if (!resolver.open_wrapper(item)) { return false; } - if (staged.count == 0 && staged.wrapperFlags.none()) { - ineligible = excludedByUnlocks && !leafDrawn; + if (staged.count == 0 && !leafFlagAcquired) { + failure = + excludedByUnlocks && !leafDrawn ? Resolution::ineligible : Resolution::refused; return refuse(context, "empty_reward"); } if (!resolver.acquire_flag(item, true)) { @@ -456,25 +467,21 @@ Resolution resolve(const Context& context, std::uint16_t item; std::uint32_t quantity; Result& result; - bool ineligible; - } request{context, itemIndex, quantity, result, false}; + Resolution failure; + } request{context, itemIndex, quantity, result, Resolution::refused}; result = {}; if (context.refusal != nullptr) { *context.refusal = "reward_item"; } - if (build_data::read_reward_definitions(&request, - [](void* raw, definitions::View data) noexcept { - auto& value = *static_cast(raw); - return resolve_item(data, - value.context, - value.item, - value.quantity, - value.result, - value.ineligible); - })) { + if (build_data::read_reward_definitions( + &request, [](void* raw, definitions::View data) noexcept { + auto& value = *static_cast(raw); + return resolve_item( + data, value.context, value.item, value.quantity, value.result, value.failure); + })) { return Resolution::resolved; } - return request.ineligible ? Resolution::ineligible : Resolution::refused; + return request.failure; } } // namespace sunrise::state::reward_resolution diff --git a/Sunrise/src/state/rewards/reward_resolver.h b/Sunrise/src/state/rewards/reward_resolver.h index 5130735ef..27c151371 100644 --- a/Sunrise/src/state/rewards/reward_resolver.h +++ b/Sunrise/src/state/rewards/reward_resolver.h @@ -38,8 +38,8 @@ struct Context { const Context& context, bool& result) noexcept; -/** How one resolution ended; ineligible means the saved unlocks left nothing to draw. */ -enum class Resolution : std::uint8_t { resolved, ineligible, refused }; +/** Ineligible: the saved unlocks left nothing to draw. Exceeded: this draw outgrew a bound. */ +enum class Resolution : std::uint8_t { resolved, ineligible, exceeded, refused }; /** Plans an acquisition without changing State; retains wrappers opened separately. */ [[nodiscard]] Resolution resolve(const Context& context, diff --git a/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp b/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp index ac6212d4f..fc98a8377 100644 --- a/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp +++ b/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp @@ -165,6 +165,43 @@ apply_reward_sockets(const item_details::Definition& detail, refusal); } +/** Finds the kept unsocketed grant of this item, or null. */ +[[nodiscard]] reward_resolution::Grant* kept_stack(reward_resolution::Result& resolved, + std::size_t kept, + std::uint16_t itemIndex) noexcept { + for (std::size_t prior = 0; prior < kept; ++prior) { + auto& grant = resolved.grants[prior]; + if (grant.itemIndex == itemIndex && grant.socketCount == 0) { + return &grant; + } + } + return nullptr; +} + +/** Folds repeat draws of a stackable item into its first draw; false when one stack overflows. */ +[[nodiscard]] bool consolidate_stacks(reward_resolution::Result& resolved) noexcept { + std::size_t kept = 0; + for (std::size_t index = 0; index < resolved.count; ++index) { + const reward_resolution::Grant grant = resolved.grants[index]; + item_details::Definition detail{}; + const bool stackable = + grant.socketCount == 0 + && build_data::find_configured_item_detail(grant.itemIndex, detail) + && detail.instancedDefinitionState == item_details::InstancedDefinitionState::stackable; + auto* const merged = stackable ? kept_stack(resolved, kept, grant.itemIndex) : nullptr; + if (merged == nullptr) { + resolved.grants[kept++] = grant; + continue; + } + if (static_cast(merged->quantity) + grant.quantity > detail.maxStackSize) { + return false; + } + merged->quantity += grant.quantity; + } + resolved.count = kept; + return true; +} + /** Classifies a catalog refusal: a verdict unless a catalog was cleared meanwhile. */ [[nodiscard]] RewardPreparation catalog_refusal(const char*& reason) noexcept { if (build_data::item_definitions_ready() && build_data::reward_definitions_ready()) { @@ -225,6 +262,12 @@ enum class PassResolution { claim, replay }; != reward_resolution::Resolution::resolved) { return false; } + if (!consolidate_stacks(result)) { + if (reason != nullptr) { + *reason = "stack_capacity"; + } + return false; + } if (reward.socketCount != 0) { if (result.count != 1 || result.grants[0].itemIndex != reward.itemIndex) { if (reason != nullptr) { @@ -387,13 +430,18 @@ RewardPreparation prepare_item_reward(std::uint16_t itemIndex, itemIndex, quantity, resolved); - // The saved unlocks can change, so an empty draw they caused is retried, not retired. - if (resolution == reward_resolution::Resolution::ineligible) { + // Unlocks can change and another draw can fit, so these are retried, not retired. + if (resolution == reward_resolution::Resolution::ineligible + || resolution == reward_resolution::Resolution::exceeded) { return RewardPreparation::deferred; } if (resolution != reward_resolution::Resolution::resolved) { return catalog_refusal(reason); } + reason = "stack_capacity"; + if (!consolidate_stacks(resolved)) { + return RewardPreparation::deferred; + } reason = "reward_placement"; return prepare_resolved_reward(resolved, mutation, &reason); } From deca158d43a9fc2a02f10028ae88b5ef32ff477b Mon Sep 17 00:00:00 2001 From: Zach Date: Mon, 28 Sep 2026 09:41:31 -0500 Subject: [PATCH 12/12] Keep repeated perk draws out of the stack merge --- .../state/runtime/state_account_reward_grant_runtime.cpp | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp b/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp index fc98a8377..a4ea32ee2 100644 --- a/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp +++ b/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp @@ -183,12 +183,15 @@ apply_reward_sockets(const item_details::Definition& detail, std::size_t kept = 0; for (std::size_t index = 0; index < resolved.count; ++index) { const reward_resolution::Grant grant = resolved.grants[index]; + build_data::items::Definition item{}; item_details::Definition detail{}; - const bool stackable = - grant.socketCount == 0 + // A perk grants its account flag, never an inventory stack. + const bool inventoryStack = + grant.socketCount == 0 && build_data::find_item_definition_index(grant.itemIndex, item) + && item.bucketId != inventory_buckets::kPerkBucketId && build_data::find_configured_item_detail(grant.itemIndex, detail) && detail.instancedDefinitionState == item_details::InstancedDefinitionState::stackable; - auto* const merged = stackable ? kept_stack(resolved, kept, grant.itemIndex) : nullptr; + auto* const merged = inventoryStack ? kept_stack(resolved, kept, grant.itemIndex) : nullptr; if (merged == nullptr) { resolved.grants[kept++] = grant; continue;