From b148d5fb50242a6c7248f94fea7403b6e2e18f7d Mon Sep 17 00:00:00 2001 From: Gage Fulwood Date: Wed, 16 Sep 2026 11:17:08 -0500 Subject: [PATCH 01/59] Restore server-side vendor reputation turn-ins --- Sunrise/Sunrise.vcxproj | 2 + .../bap/encrypted/body/bap_service_body.cpp | 20 + .../bap/encrypted/encrypted_runtime.cpp | 6 +- Sunrise/src/server/bap/encrypted/internal.h | 3 +- .../transactions/service_outcome_commit.cpp | 5 + .../server/web_service/web_service_runtime.h | 4 +- .../web_service_vendor_actions.cpp | 31 +- .../state_vendor_reputation_runtime.cpp | 225 +++++++++++ .../runtime/state_vendor_reputation_runtime.h | 39 ++ tests/vendor_reputation.cpp | 382 ++++++++++++++++++ tests/vendor_reputation.ps1 | 41 ++ 11 files changed, 748 insertions(+), 10 deletions(-) create mode 100644 Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp create mode 100644 Sunrise/src/state/runtime/state_vendor_reputation_runtime.h create mode 100644 tests/vendor_reputation.cpp create mode 100644 tests/vendor_reputation.ps1 diff --git a/Sunrise/Sunrise.vcxproj b/Sunrise/Sunrise.vcxproj index 8d78ef49d..7c7ea5b3e 100644 --- a/Sunrise/Sunrise.vcxproj +++ b/Sunrise/Sunrise.vcxproj @@ -155,6 +155,8 @@ + + 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 660ff46e7..cba36a517 100644 --- a/Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp +++ b/Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp @@ -325,6 +325,26 @@ bool process(const ServiceRoute& route, return refuse_web_action(message, output, written); } } + const auto* reputation = + web_service::mutation_if(webOutcome); + if (reputation != nullptr) { + if (!queuezState.family4Active || queuezState.family4RootSoid != reputation->accountSoid + || queuezState.family4Version == (std::numeric_limits::max)() + || emplace_transaction(outcome, *reputation) + == nullptr) { + return refuse_web_action(message, output, written); + } + // The next account image carries both the material debit and character progression. + middleware::web_service::StatusResponse status{}; + status.value = queuezState.family4Version + 1; + middleware::web_service::ResponseShape shape{}; + web_service::resolve_response_shape(message.opcode, shape); + if (!middleware::web_service::encode_response( + message, shape, status, output, written)) { + clear_transaction(outcome); + return refuse_web_action(message, output, written); + } + } if (equipmentSwap != nullptr) { // Promise the Family-4 revision carrying this optimistic equip. auto* transaction = emplace_transaction(outcome); diff --git a/Sunrise/src/server/bap/encrypted/encrypted_runtime.cpp b/Sunrise/src/server/bap/encrypted/encrypted_runtime.cpp index 538691a43..8fa79137d 100644 --- a/Sunrise/src/server/bap/encrypted/encrypted_runtime.cpp +++ b/Sunrise/src/server/bap/encrypted/encrypted_runtime.cpp @@ -382,6 +382,8 @@ bool consume(Session& session, } } const bool artifactPurchase = transaction_if(outcome) != nullptr; + const bool vendorReputation = + transaction_if(outcome) != nullptr; const bool mutatesAccount = outcome.hasSelectCharacter || outcome.hasRecordClaim || outcome.hasArtifactReset || transaction_if(outcome) != nullptr @@ -394,7 +396,7 @@ bool consume(Session& session, || transaction_if(outcome) != nullptr || transaction_if(outcome) != nullptr || transaction_if(outcome) != nullptr - || transaction_if(outcome) != nullptr; + || transaction_if(outcome) != nullptr || vendorReputation; const bool presentsAcquisition = transaction_if(outcome) != nullptr || transaction_if(outcome) != nullptr @@ -520,7 +522,7 @@ bool consume(Session& session, session.activityKeepaliveDueTick = GetTickCount64() + kActivityKeepaliveIntervalMs; } const bool resyncsCommittedAccount = - hasPrecommittedAccountAction && !queuezPublication.hasState; + vendorReputation || (hasPrecommittedAccountAction && !queuezPublication.hasState); if (resyncsCommittedAccount) { bap::arm_account_resync_everywhere(); } diff --git a/Sunrise/src/server/bap/encrypted/internal.h b/Sunrise/src/server/bap/encrypted/internal.h index 2377aabe0..005f3cbcc 100644 --- a/Sunrise/src/server/bap/encrypted/internal.h +++ b/Sunrise/src/server/bap/encrypted/internal.h @@ -221,7 +221,8 @@ struct ServiceOutcome { std::unique_ptr, - std::unique_ptr>; + std::unique_ptr, + std::unique_ptr>; Transaction transaction{}; }; diff --git a/Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp b/Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp index 9782d96f5..279586545 100644 --- a/Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp +++ b/Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp @@ -312,6 +312,11 @@ bool commit(ServiceOutcome& outcome, Publication& publication, const char*& reas reason = "current_activity"; return committed; } + if (auto* reputation = transaction_if(outcome)) { + reason = "vendor_reputation"; + return report_commit(state::commit_vendor_reputation(*reputation), + "ev=vendor_reputation stage=transaction_commit result=fail"); + } if (auto* transaction = transaction_if(outcome)) { const bool committed = transaction->pending != nullptr && state::commit_profile_item_acquisition(*transaction->pending); diff --git a/Sunrise/src/server/web_service/web_service_runtime.h b/Sunrise/src/server/web_service/web_service_runtime.h index 08ad2abda..4d3309da4 100644 --- a/Sunrise/src/server/web_service/web_service_runtime.h +++ b/Sunrise/src/server/web_service/web_service_runtime.h @@ -12,6 +12,7 @@ #include "../../middleware/web_service/messages/opcode206.h" #include "../../state/account/inventory/seen_state.h" #include "../../state/runtime/runtime.h" +#include "../../state/runtime/state_vendor_reputation_runtime.h" namespace sunrise::server::web_service { @@ -44,7 +45,8 @@ struct Outcome { std::unique_ptr, std::unique_ptr, std::unique_ptr, - std::unique_ptr>; + std::unique_ptr, + std::unique_ptr>; Mutation mutation{}; }; diff --git a/Sunrise/src/server/web_service/web_service_vendor_actions.cpp b/Sunrise/src/server/web_service/web_service_vendor_actions.cpp index a54ccdf11..d28bb9e2c 100644 --- a/Sunrise/src/server/web_service/web_service_vendor_actions.cpp +++ b/Sunrise/src/server/web_service/web_service_vendor_actions.cpp @@ -627,9 +627,7 @@ constexpr std::uint32_t kAbsentNameHash = 0x811C9DC5U; } /** - * Settles one resolved vendor row, in the order a row's behaviours are tried. - * A row is a bounty roll, an exchange or a grant. The row does not say which, so each is tried in - * turn and the first that claims the row owns it. Both vendor opcodes end here. + * The first matching behavior owns the sale, including a refused placeholder action. * @param message Request being answered. * @param opcode Opcode to report under. * @param vendorIndex Vendor the request names. @@ -645,6 +643,29 @@ void settle_vendor_row(const middleware::web_service::Message& message, std::int32_t categoryIndex, std::uint16_t itemDefinitionIndex, Outcome& outcome) noexcept { + if (vendorIndex >= 0 && rowIndex >= 0 + && vendorIndex <= (std::numeric_limits::max)() + && rowIndex <= (std::numeric_limits::max)()) { + auto* reputation = emplace_mutation(outcome); + if (reputation == nullptr) { + report_purchase(opcode, "fail", "storage", vendorIndex, rowIndex, itemDefinitionIndex); + return; + } + const auto disposition = + state::prepare_vendor_reputation(static_cast(vendorIndex), + static_cast(rowIndex), + *reputation); + if (disposition == state::VendorReputationDisposition::prepared) { + report_purchase(opcode, "ok", "reputation", vendorIndex, rowIndex, itemDefinitionIndex); + return; + } + clear_mutation(outcome); + if (disposition == state::VendorReputationDisposition::refused) { + report_purchase( + opcode, "fail", "reputation", vendorIndex, rowIndex, itemDefinitionIndex); + return; + } + } std::uint16_t rolledBounty = kUnavailableDefinitionIndex; if (roll_vendor_bounty(vendorIndex, categoryIndex, rolledBounty)) { report_purchase(opcode, @@ -756,9 +777,7 @@ void acquire_quest(const middleware::web_service::Message& message, Outcome& out } /** - * Prepares one opcode-901 vendor purchase, for any Tower vendor. - * The sale row names an item-definition index, so this hands over to the Collections grant. - * Only a recycle row charges: an ordinary row's cost is read but not yet spent. + * Prepares one installed vendor sale through the shared settlement path. */ void purchase_item(const middleware::web_service::Message& message, Outcome& outcome) noexcept { namespace purchase = middleware::web_service::messages::opcode901; diff --git a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp new file mode 100644 index 000000000..80f7d518f --- /dev/null +++ b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp @@ -0,0 +1,225 @@ +#include "state_vendor_reputation_runtime.h" + +#include +#include + +#include "../build_data/vendors/vendor_catalog.h" +#include "../investment/store_internal.h" +#include "state_account_transaction_helpers.h" + +namespace sunrise::state { +namespace { + +/** Build-86657 faction tokenValues apply only to the matching vendor sale placeholder. */ +struct ReputationRule { + std::uint32_t vendorHash; + std::uint32_t placeholderHash; + std::uint32_t costHash; + std::uint16_t progressionIndex; + std::int32_t experiencePerUnit; +}; + +/** Vendor/item hashes, character progression indices and XP per unit from build 86657. */ +constexpr std::array kReputationRules{{ + // Banshee: Gunsmith Rewards charges Gunsmith Materials for Gunsmith progression. + {672118013U, 3831705402U, 685157383U, 55, 30}, + // Banshee: the same placeholder also accepts Weapon Telemetry at its own XP rate. + {672118013U, 3831705402U, 685157381U, 55, 25}, + // Zavala: Vanguard Tactician Rewards charges Vanguard Tactician Tokens. + {69482069U, 3987308529U, 3899548068U, 62, 100}, + // Shaxx: Crucible Rewards charges Crucible Tokens, not Valor or Glory points. + {3603221665U, 265113466U, 183980811U, 49, 100}, + // Devrim: EDZ token turn-ins use a different placeholder from destination materials. + {396892126U, 61430328U, 2640973641U, 52, 100}, + // Devrim: destination-material turn-ins accept Dusklight Shards. + {396892126U, 1317670974U, 950899352U, 52, 50}, + // Devrim: the same material placeholder accepts Dusklight Crystals at their own XP rate. + {396892126U, 1317670974U, 478751073U, 52, 250}, +}}; + +/** Native progression level walks read experience from lane zero. */ +constexpr std::size_t kExperienceLane = 0; + +/** + * Resolves a turn-in without treating another purchase using the same material as reputation. + * @param vendorIndex Installed vendor selector. + * @param saleIndex Installed sale selector. + * @param award Receives the checked cost and XP; use only when prepared is returned. + * @return Not applicable for other sales, refused for unsupported costs on a known placeholder. + */ +VendorReputationDisposition resolve_award(std::uint16_t vendorIndex, + std::uint16_t saleIndex, + VendorReputationAward& award) noexcept { + namespace vendors = build_data::vendors; + vendors::IndexEntry entry{}; + vendors::Definition vendor{}; + vendors::SaleRow sale{}; + build_data::items::Definition sold{}, cost{}; + award = {}; + if (!vendors::find_index(vendorIndex, entry) || !vendors::find(entry.definitionHash, vendor) + || !vendors::sale_row(vendor, saleIndex, sale) + || !build_data::find_item_definition_index(sale.itemIndex, sold)) { + return VendorReputationDisposition::notApplicable; + } + bool recognized = false; + for (const auto& rule : kReputationRules) { + if (rule.vendorHash != entry.definitionHash + || rule.placeholderHash != sold.definitionHash) { + continue; + } + recognized = true; + if (sale.costItemIndex == vendors::kAbsentCostItem || sale.costQuantity == 0 + || !build_data::find_item_definition_index(sale.costItemIndex, cost) + || cost.definitionHash != rule.costHash) { + continue; + } + std::array slots{}; + std::size_t count = 0; + const std::int64_t experience = + static_cast(sale.costQuantity) * rule.experiencePerUnit; + if (experience > (std::numeric_limits::max)() + || !build_data::find_progression_slots( + build_data::progressions::Scope::character, slots, count) + || count > slots.size() + || std::find(slots.begin(), + slots.begin() + static_cast(count), + rule.progressionIndex) + == slots.begin() + static_cast(count)) { + return VendorReputationDisposition::refused; + } + award = {cost.definitionHash, + sale.costQuantity, + static_cast(experience), + rule.progressionIndex}; + return VendorReputationDisposition::prepared; + } + return recognized ? VendorReputationDisposition::refused + : VendorReputationDisposition::notApplicable; +} + +/** + * Charges profile stacks in their existing order without announcing an item acquisition. + * @param account Candidate account; discard it on failure. + * @param award Validated sale payment. + * @return False when materials are insufficient or the resulting profile is invalid. + */ +bool charge_materials(AccountState& account, const VendorReputationAward& award) noexcept { + std::uint32_t remaining = award.costQuantity; + std::size_t write = 0; + for (std::size_t read = 0; read < account.profileItemCount; ++read) { + auto item = account.profileItems[read]; + if (item.definitionHash == award.costHash) { + if (item.instanceSoid != 0 || item.quantity <= 0) { + return false; + } + const auto charged = (std::min)(remaining, static_cast(item.quantity)); + item.quantity -= static_cast(charged); + remaining -= charged; + } + if (item.quantity != 0) { + account.profileItems[write++] = item; + } + } + std::fill(account.profileItems.begin() + static_cast(write), + account.profileItems.end(), + account::inventory::ProfileItem{}); + account.profileItemCount = write; + return remaining == 0 && account::valid(account) + && runtime::detail::valid_profile_inventory(account); +} + +} // namespace + +/** + * Captures an affordable turn-in without writing inventory or progression. + * @param vendorIndex Installed vendor selector. + * @param saleIndex Installed sale selector. + * @param mutation Receives the before-image; cleared on failure. + * @return Whether this sale is a prepared, refused or unrelated reputation action. + */ +VendorReputationDisposition prepare_vendor_reputation(std::uint16_t vendorIndex, + std::uint16_t saleIndex, + PendingVendorReputation& mutation) noexcept { + mutation = {}; + VendorReputationAward award{}; + const auto disposition = resolve_award(vendorIndex, saleIndex, award); + if (disposition != VendorReputationDisposition::prepared) { + return disposition; + } + const std::lock_guard lock(investment::store::g_mutex); + AccountState account{}; + unlocks::Table banks{}; + if (!investment::store::read_account(account) || !account::valid(account) + || !runtime::detail::valid_profile_inventory(account)) { + return VendorReputationDisposition::refused; + } + const auto selected = runtime::detail::selected_character_index(account); + if (selected >= account.characterCount || account.primarySoid == 0 + || !investment::store::read_unlocks(banks, static_cast(selected))) { + return VendorReputationDisposition::refused; + } + const auto before = banks.characterProgressions[award.progressionIndex]; + if (before[kExperienceLane] < 0 + || before[kExperienceLane] + > (std::numeric_limits::max)() - award.experience) { + return VendorReputationDisposition::refused; + } + AccountState after = account; + if (!charge_materials(after, award)) { + return VendorReputationDisposition::refused; + } + mutation.beforeItems = account.profileItems; + mutation.beforeItemCount = account.profileItemCount; + mutation.beforeProgression = before; + mutation.award = award; + mutation.accountSoid = account.primarySoid; + mutation.characterSoid = account.characters[selected].soid; + mutation.characterIndex = selected; + mutation.vendorIndex = vendorIndex; + mutation.saleIndex = saleIndex; + mutation.prepared = true; + return VendorReputationDisposition::prepared; +} + +/** + * Debits materials and credits character XP within the caller's response transaction. + * @param mutation Before-image to consume, including on refusal. + * @return False for stale state, changed content or any failed write; neither change is kept. + */ +bool commit_vendor_reputation(PendingVendorReputation& mutation) noexcept { + const runtime::detail::PendingConsumption consume(mutation); + VendorReputationAward award{}; + if (!mutation.prepared + || resolve_award(mutation.vendorIndex, mutation.saleIndex, award) + != VendorReputationDisposition::prepared + || award != mutation.award) { + return false; + } + investment::store::Transaction transaction; + AccountState account{}; + unlocks::Table banks{}; + if (!transaction.ready() || !investment::store::read_account(account) + || !account::valid(account) || account.primarySoid != mutation.accountSoid + || !runtime::detail::valid_profile_inventory(account) + || mutation.characterIndex >= account.characterCount + || account.characters[mutation.characterIndex].soid != mutation.characterSoid + || !account.characters[mutation.characterIndex].selected + || !runtime::detail::same_profile_inventory( + account, mutation.beforeItems, mutation.beforeItemCount) + || !investment::store::read_unlocks(banks, static_cast(mutation.characterIndex))) { + return false; + } + auto& progression = banks.characterProgressions[award.progressionIndex]; + if (progression != mutation.beforeProgression || progression[kExperienceLane] < 0 + || progression[kExperienceLane] + > (std::numeric_limits::max)() - award.experience + || !charge_materials(account, award)) { + return false; + } + progression[kExperienceLane] += award.experience; + return investment::store::write_account(account) + && investment::store::write_unlocks(banks, static_cast(mutation.characterIndex)) + && transaction.commit(); +} + +} // namespace sunrise::state diff --git a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h new file mode 100644 index 000000000..96f7f0cf1 --- /dev/null +++ b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h @@ -0,0 +1,39 @@ +#pragma once + +#include "../account/account_state.h" +#include "../unlocks/definition.h" + +namespace sunrise::state { + +/** A recognized reputation placeholder must never fall through to an item grant. */ +enum class VendorReputationDisposition : std::uint8_t { notApplicable, refused, prepared }; + +/** Installed sale cost and its build-matched faction award. */ +struct VendorReputationAward { + std::uint32_t costHash{}; + std::uint32_t costQuantity{}; + std::int32_t experience{}; + std::uint16_t progressionIndex{}; + bool operator==(const VendorReputationAward&) const = default; +}; + +/** Captured payment and character state; no inventory item is granted by this transaction. */ +struct PendingVendorReputation { + std::array + beforeItems{}; + unlocks::ProgressionLanes beforeProgression{}; + VendorReputationAward award{}; + std::uint64_t accountSoid{}; + std::uint64_t characterSoid{}; + std::size_t characterIndex{}; + std::size_t beforeItemCount{}; + std::uint16_t vendorIndex{}; + std::uint16_t saleIndex{}; + bool prepared{}; +}; + +[[nodiscard]] VendorReputationDisposition prepare_vendor_reputation( + std::uint16_t vendorIndex, std::uint16_t saleIndex, PendingVendorReputation& mutation) noexcept; +[[nodiscard]] bool commit_vendor_reputation(PendingVendorReputation& mutation) noexcept; + +} // namespace sunrise::state diff --git a/tests/vendor_reputation.cpp b/tests/vendor_reputation.cpp new file mode 100644 index 000000000..213790e80 --- /dev/null +++ b/tests/vendor_reputation.cpp @@ -0,0 +1,382 @@ +#include +#include +#include +#include + +#include "state/build_data/runtime.h" +#include "state/build_data/vendors/vendor_catalog.h" +#include "state/investment/store_internal.h" +#include "state/runtime/state_vendor_reputation_runtime.h" + +namespace { +namespace state = sunrise::state; +namespace store = state::investment::store; +using Disposition = state::VendorReputationDisposition; + +/** Synthetic account identities isolate every check from player saves. */ +constexpr std::uint64_t kAccount = 1001, kCharacter = 1002, kOtherCharacter = 1003; +/** Fixture rows are selectors, not installed vendor or item indices. */ +constexpr std::uint16_t kVendor = 1, kSale = 2, kSoldIndex = 3, kCostIndex = 4; +/** Build-86657 Banshee identity, reward placeholder and Gunsmith Material identity. */ +constexpr std::uint32_t kBanshee = 672118013, kGunsmithRewards = 3831705402, + kGunsmithMaterials = 685157383; +/** Build-86657 Zavala identity, reward placeholder and Vanguard token identity. */ +constexpr std::uint32_t kZavala = 69482069, kVanguardRewards = 3987308529, + kVanguardToken = 3899548068; +/** Native character progression indices from build 86657. */ +constexpr std::uint16_t kGunsmithProgression = 55, kVanguardProgression = 62; +/** A five-material turn-in earns 150 XP from Banshee's 30-XP material rate. */ +constexpr std::int32_t kCost = 5, kAward = 150, kInitialQuantity = 10; +/** Build-86657 Vanguard tokens award 100 XP each. */ +constexpr std::int32_t kVanguardXpPerToken = 100; +/** Nonzero untouched lanes detect accidental replacement of the whole progression row. */ +constexpr state::unlocks::ProgressionLanes kInitialProgression{40, 7, 9}; +/** A synthetic bucket holds all fixture profile stacks without instanced item sources. */ +constexpr std::uint8_t kBucket = 1; +std::uint32_t g_vendorHash = kBanshee, g_soldHash = kGunsmithRewards, + g_costHash = kGunsmithMaterials; +std::uint32_t g_cost = kCost; +std::uint16_t g_progression = kGunsmithProgression; +bool g_characterScope = true; +bool g_materialAvailable = true; + +/** @param passed Check result. @param label Failure description. */ +void check(bool passed, const char* label) { + if (!passed) { + std::fprintf(stderr, "FAIL: %s\n", label); + std::abort(); + } +} + +/** + * Reads repository SQL resources, never a player database. + * @param path Resource path. + * @return File contents. + */ +std::string read_text(const std::string& path) { + std::FILE* file = nullptr; + check(fopen_s(&file, path.c_str(), "rb") == 0 && file != nullptr, "open SQL resource"); + std::string text; + // SQL resource reads use a fixed 4-KiB buffer. + std::array buffer{}; + while (const auto count = std::fread(buffer.data(), 1, buffer.size(), file)) { + text.append(buffer.data(), count); + } + check(std::ferror(file) == 0, "read SQL resource"); + std::fclose(file); + return text; +} + +/** Resets only the disposable fixture and its controlled content catalogue. */ +void reset() { + g_vendorHash = kBanshee; + g_soldHash = kGunsmithRewards; + g_costHash = kGunsmithMaterials; + g_cost = kCost; + g_progression = kGunsmithProgression; + g_characterScope = true; + g_materialAvailable = true; + state::AccountState account{}; + account.primarySoid = kAccount; + account.characterCount = 2; + account.characters[0].soid = kCharacter; + account.characters[0].selected = true; + account.characters[1].soid = kOtherCharacter; + account.profileItemCount = 1; + account.profileItems[0] = {0, kGunsmithMaterials, kInitialQuantity, 1, true}; + check(store::read_settings(account.settings) && store::write_account(account), "seed account"); + check(store::execute("DELETE FROM unlocks"), "clear fixture banks"); + state::unlocks::Table banks{}; + banks.characterProgressions[kGunsmithProgression] = kInitialProgression; + check(store::write_unlocks(banks, 0), "seed progression"); +} + +/** @return Character-zero Gunsmith lanes from the disposable database. */ +state::unlocks::ProgressionLanes gunsmith() { + state::unlocks::Table banks{}; + check(store::read_unlocks(banks, 0), "read progression"); + return banks.characterProgressions[kGunsmithProgression]; +} + +/** Covers successful, stale, insufficient, overflow and rolled-back turn-ins. */ +void verify() { + state::PendingVendorReputation pending{}; + reset(); + check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared, + "prepare Banshee"); + check(store::account().profileItems[0].quantity == kInitialQuantity + && gunsmith() == kInitialProgression, + "prepare writes nothing"); + auto duplicate = pending; + check(state::commit_vendor_reputation(pending) && !pending.prepared, "commit and consume"); + const auto expected = state::unlocks::ProgressionLanes{ + kInitialProgression[0] + kAward, kInitialProgression[1], kInitialProgression[2]}; + check(gunsmith() == expected + && store::account().profileItems[0].quantity == kInitialQuantity - kCost, + "exact cost and XP, other lanes preserved"); + check(!state::commit_vendor_reputation(pending) && !state::commit_vendor_reputation(duplicate), + "reused and duplicate plans refused"); + state::unlocks::Table other{}; + check(store::read_unlocks(other, 1) + && other.characterProgressions[kGunsmithProgression] + == state::unlocks::ProgressionLanes{}, + "other character untouched"); + check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared + && state::commit_vendor_reputation(pending) && store::account().profileItemCount == 0, + "exact balance removes empty stack"); + check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::refused, + "empty inventory refused"); + + reset(); + auto split = store::account(); + split.profileItemCount = 2; + split.profileItems[0].quantity = kCost - 1; + split.profileItems[1] = {0, kGunsmithMaterials, kInitialQuantity, 2, true}; + check(store::write_account(split), "seed split material stacks"); + check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared + && state::commit_vendor_reputation(pending), + "charge across material stacks"); + check(store::account().profileItemCount == 1 + && store::account().profileItems[0].quantity == kInitialQuantity - 1 + && store::account().profileItems[0].mutationSerial == 2, + "dense remainder preserves its ordering serial"); + + reset(); + g_cost = kInitialQuantity + 1; + check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::refused + && gunsmith() == kInitialProgression + && store::account().profileItems[0].quantity == kInitialQuantity, + "insufficient materials leave both sides unchanged"); + g_cost = (std::numeric_limits::max)(); + check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::refused, + "award multiplication overflow refused"); + reset(); + check(store::write_unlock(store::Bank::characterProgressions, + kGunsmithProgression, + (std::numeric_limits::max)()), + "seed overflow"); + check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::refused, + "XP total overflow refused"); + + reset(); + check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared, + "prepare stale inventory"); + auto account = store::account(); + --account.profileItems[0].quantity; + check(store::write_account(account) && !state::commit_vendor_reputation(pending) + && gunsmith() == kInitialProgression, + "stale inventory refused"); + reset(); + check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared, + "prepare stale character"); + account = store::account(); + account.characters[0].selected = false; + account.characters[1].selected = true; + check(store::write_account(account) && !state::commit_vendor_reputation(pending), + "changed selection refused"); + reset(); + check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared, + "prepare stale XP"); + check(store::write_unlock(store::Bank::characterProgressions, kGunsmithProgression, 0) + && !state::commit_vendor_reputation(pending), + "changed XP refused"); + reset(); + check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared, + "prepare stale cost"); + ++g_cost; + check(!state::commit_vendor_reputation(pending), "changed sale cost refused"); + reset(); + g_cost = kInitialQuantity; + check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared, + "prepare whole-stack charge"); + g_materialAvailable = false; + check(!state::commit_vendor_reputation(pending) + && store::account().profileItems[0].quantity == kInitialQuantity, + "missing material definition cannot be hidden by removing its stack"); + + reset(); + { + store::Transaction outer; + check(outer.ready() + && state::prepare_vendor_reputation(kVendor, kSale, pending) + == Disposition::prepared + && state::commit_vendor_reputation(pending), + "stage inside response transaction"); + } + check(gunsmith() == kInitialProgression + && store::account().profileItems[0].quantity == kInitialQuantity, + "abandoned response transaction rolls back both writes"); + check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared, + "prepare SQL failure"); + check(store::execute("CREATE TEMP TRIGGER reject_xp BEFORE INSERT ON unlocks " + "BEGIN SELECT RAISE(ABORT,'fixture'); END"), + "inject XP write failure"); + check(!state::commit_vendor_reputation(pending) + && store::account().profileItems[0].quantity == kInitialQuantity + && gunsmith() == kInitialProgression, + "failed XP write rolls back material debit"); + check(store::execute("DROP TRIGGER reject_xp"), "remove fixture fault"); + + reset(); + g_soldHash = kVanguardRewards; + check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::notApplicable, + "ordinary purchase using the same material does not award XP"); + g_soldHash = kGunsmithRewards; + g_costHash = kVanguardToken; + check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::refused, + "known placeholder with wrong payment cannot fall through"); + reset(); + g_characterScope = false; + check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::refused, + "wrong progression scope refused"); + + reset(); + g_vendorHash = kZavala; + g_soldHash = kVanguardRewards; + g_costHash = kVanguardToken; + g_progression = kVanguardProgression; + account = store::account(); + account.profileItems[0].definitionHash = kVanguardToken; + check(store::write_account(account), "seed Vanguard tokens"); + check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared + && state::commit_vendor_reputation(pending), + "shared transaction handles Zavala"); + check(store::read_unlocks(other, 0) + && other.characterProgressions[kVanguardProgression][0] + == kCost * kVanguardXpPerToken, + "Vanguard token rate, not Gunsmith rate"); +} +} // namespace + +namespace sunrise::state::build_data::vendors { +/** Catalogue doubles expose one controlled vendor and sale, not runtime inventory. */ +bool find_index(std::uint16_t index, IndexEntry& entry) noexcept { + entry = {}; + entry.definitionHash = g_vendorHash; + return index == kVendor; +} +bool find(std::uint32_t hash, Definition& definition) noexcept { + definition = {}; + return hash == g_vendorHash; +} +bool sale_row(const Definition&, std::size_t row, SaleRow& output) noexcept { + output = {}; + output.itemIndex = kSoldIndex; + output.costItemIndex = kCostIndex; + output.costQuantity = g_cost; + return row == kSale; +} +} // namespace sunrise::state::build_data::vendors + +namespace sunrise::state::build_data { +/** Unrelated acquisition code shares the profile helper translation unit. */ +bool find_collectible_definition(std::uint16_t, collectibles::Definition&) noexcept { + return false; +} +/** Item catalogue doubles retain production profile-shape validation. */ +bool find_item_definition_index(std::uint16_t definitionIndex, + items::Definition& definition) noexcept { + definition = {}; + definition.definitionIndex = definitionIndex; + definition.definitionHash = definitionIndex == kSoldIndex ? g_soldHash : g_costHash; + definition.bucketId = kBucket; + return definitionIndex == kSoldIndex || definitionIndex == kCostIndex; +} +bool find_item_definition_hash(std::uint32_t definitionHash, + items::Definition& definition) noexcept { + return definitionHash == g_costHash && find_item_definition_index(kCostIndex, definition); +} +bool find_configured_item_detail(std::uint16_t definitionIndex, + items::details::Definition& definition) noexcept { + definition = {}; + definition.definitionIndex = definitionIndex; + definition.definitionHash = g_costHash; + definition.bucketId = kBucket; + definition.instancedDefinitionState = items::details::InstancedDefinitionState::stackable; + return g_materialAvailable && definitionIndex == kCostIndex; +} +bool find_inventory_bucket_descriptor(std::uint8_t bucketId, + inventory::buckets::Descriptor& descriptor) noexcept { + descriptor = {}; + descriptor.arraySelector = inventory::buckets::ArraySelector::profile; + descriptor.slotCount = inventory::buckets::kProfileSlotCapacity; + return bucketId == kBucket; +} +bool is_profile_action_source(std::uint16_t, std::uint8_t) noexcept { + return false; +} +/** + * Exposes only the fixture's selected progression in the requested object scope. + * @param scope Requested object scope. + * @param output Receives the one fixture index. + * @param count Receives one on success, zero on failure. + * @return False for missing storage or a scope the fixture does not publish. + */ +bool find_progression_slots(progressions::Scope scope, + std::span output, + std::size_t& count) noexcept { + count = 0; + if (scope != progressions::Scope::character || !g_characterScope || output.empty()) { + return false; + } + output[0] = g_progression; + count = 1; + return true; +} +} // namespace sunrise::state::build_data + +namespace sunrise::state { +/** Unrelated profile exchange code reads the same disposable store. */ +AccountState account_snapshot() noexcept { + return investment::store::account(); +} +} // namespace sunrise::state + +namespace sunrise::state::runtime::detail { +/** Isolates character selection from the unrelated item acquisition implementation. */ +std::size_t selected_character_index(const AccountState& account) noexcept { + for (std::size_t index = 0; index < account.characterCount; ++index) { + if (account.characters[index].selected) { + return index; + } + } + return account.characterCount; +} +} // namespace sunrise::state::runtime::detail + +/** + * Runs against disposable SQLite only, then checks persistence by closing and reopening it. + * @param argc Argument count. + * @param argv Executable, repository SQL directory and new disposable database path. + * @return Zero when every check passes; failed checks abort. + */ +int main(int argc, char** argv) { + check(argc == 3, "resource directory and disposable database arguments"); + const std::string root = argv[1]; + check(store::open(":memory:", + read_text(root + "/investment_schema.sql"), + "INSERT INTO account VALUES (1,1001,0);", + read_text(root + "/account_settings_schema.sql"), + read_text(root + "/account_settings_defaults.sql")), + "open disposable store"); + verify(); + store::shutdown(); + check(store::open(argv[2], + read_text(root + "/investment_schema.sql"), + "INSERT INTO account VALUES (1,1001,0);", + read_text(root + "/account_settings_schema.sql"), + read_text(root + "/account_settings_defaults.sql")), + "open disposable disk store"); + reset(); + state::PendingVendorReputation pending{}; + check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared + && state::commit_vendor_reputation(pending), + "commit before closing store"); + store::shutdown(); + check(store::open(argv[2], {}, {}, {}, {}), "reopen saved store without seeding"); + check(gunsmith()[0] == kInitialProgression[0] + kAward + && store::account().profileItems[0].quantity == kInitialQuantity - kCost, + "material debit and XP survive reopening database"); + store::shutdown(); + std::puts("PASS: reputation transaction, exact debit/credit, refusal, staleness and rollback"); +} diff --git a/tests/vendor_reputation.ps1 b/tests/vendor_reputation.ps1 new file mode 100644 index 000000000..ce14cd409 --- /dev/null +++ b/tests/vendor_reputation.ps1 @@ -0,0 +1,41 @@ +$ErrorActionPreference = 'Stop' +$repRoot = Split-Path $PSScriptRoot -Parent +$repBuild = Join-Path $repRoot 'build/vendor-reputation-tests' +New-Item -ItemType Directory -Path $repBuild -Force | Out-Null +$repVsWhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio/Installer/vswhere.exe' +$repVs = & $repVsWhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath +if (-not $repVs) { throw 'Visual Studio C++ tools are required.' } +$repVcVars = Join-Path $repVs 'VC/Auxiliary/Build/vcvars64.bat' +$repSource = Join-Path $repRoot 'Sunrise/src' +$repSqlite = Join-Path $repRoot 'Sunrise/vendor/sqlite' +$repSources = @( + 'tests/vendor_reputation.cpp', + 'Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp', + 'Sunrise/src/state/runtime/state_account_profile_runtime.cpp', + 'Sunrise/src/state/account/account_state.cpp', + 'Sunrise/src/state/account/inventory/inventory_state.cpp', + 'Sunrise/src/state/account/settings/settings_state.cpp', + 'Sunrise/src/state/investment/investment_database.cpp', + 'Sunrise/src/state/investment/investment_account_store.cpp', + 'Sunrise/src/state/investment/investment_inventory_store.cpp', + 'Sunrise/src/state/investment/investment_settings_store.cpp', + 'Sunrise/src/state/investment/investment_unlock_store.cpp' +) | ForEach-Object { '"' + (Join-Path $repRoot $_) + '"' } +Push-Location $repBuild +try { + $repSqliteObject = Join-Path $repBuild 'sqlite3.obj' + if (-not (Test-Path -LiteralPath $repSqliteObject) -or + (Get-Item -LiteralPath "$repSqlite/sqlite3.c").LastWriteTimeUtc -gt + (Get-Item -LiteralPath $repSqliteObject).LastWriteTimeUtc) { + & cmd.exe /d /s /c "`"$repVcVars`" >nul && cl /nologo /O2 /w /TC /c /DSQLITE_OMIT_LOAD_EXTENSION /DSQLITE_THREADSAFE=1 `"$repSqlite/sqlite3.c`" /Fosqlite3.obj" + if ($LASTEXITCODE -ne 0) { throw 'SQLite test build failed.' } + } + $repCommand = "`"$repVcVars`" >nul && cl /nologo /std:c++20 /EHsc /O2 /Gy /W4 /WX /utf-8 /DWIN32_LEAN_AND_MEAN /DNOMINMAX /I`"$repSource`" /external:I`"$repSqlite`" /external:W0 " + ($repSources -join ' ') + ' sqlite3.obj /Fevendor_reputation.exe /link /OPT:REF /STACK:8388608' + & cmd.exe /d /s /c $repCommand + if ($LASTEXITCODE -ne 0) { throw 'Reputation test build failed.' } + $repScratch = Join-Path $repBuild ('reputation-' + [guid]::NewGuid().ToString('N') + '.sqlite3') + & (Join-Path $repBuild 'vendor_reputation.exe') (Join-Path $repRoot 'Sunrise/resources/database') $repScratch + if ($LASTEXITCODE -ne 0) { throw 'Reputation checks failed.' } +} finally { + Pop-Location +} From a5834f710a7092b489d85464d441dde3109c6236 Mon Sep 17 00:00:00 2001 From: Gage Fulwood Date: Wed, 16 Sep 2026 17:48:05 -0500 Subject: [PATCH 02/59] Award owned faction engrams on newly crossed reputation ranks --- .../state_vendor_reputation_runtime.cpp | 75 ++++++- .../runtime/state_vendor_reputation_runtime.h | 5 +- tests/vendor_reputation.cpp | 189 +++++++++++++++++- tests/vendor_reputation.ps1 | 2 + 4 files changed, 250 insertions(+), 21 deletions(-) diff --git a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp index 80f7d518f..ce92d0205 100644 --- a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp +++ b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp @@ -17,18 +17,28 @@ struct ReputationRule { std::uint32_t costHash; std::uint16_t progressionIndex; std::int32_t experiencePerUnit; + std::uint32_t rewardHash{}; + std::int32_t rankCost{}; }; +/** Build-86657 faction rewardItemHash values identify owned engrams, not sale placeholders. */ +constexpr std::uint32_t kVanguardEngram = 3578462974U, kCrucibleEngram = 1368565477U, + kGunsmithEngram = 3531414277U; +/** Build-86657 Vanguard and Crucible use 2000-XP steps and repeat the last step. */ +constexpr std::int32_t kTokenRankCost = 2000; +/** Build-86657 Gunsmith uses 3000-XP steps and repeats the last step. */ +constexpr std::int32_t kGunsmithRankCost = 3000; + /** Vendor/item hashes, character progression indices and XP per unit from build 86657. */ constexpr std::array kReputationRules{{ // Banshee: Gunsmith Rewards charges Gunsmith Materials for Gunsmith progression. - {672118013U, 3831705402U, 685157383U, 55, 30}, + {672118013U, 3831705402U, 685157383U, 55, 30, kGunsmithEngram, kGunsmithRankCost}, // Banshee: the same placeholder also accepts Weapon Telemetry at its own XP rate. - {672118013U, 3831705402U, 685157381U, 55, 25}, + {672118013U, 3831705402U, 685157381U, 55, 25, kGunsmithEngram, kGunsmithRankCost}, // Zavala: Vanguard Tactician Rewards charges Vanguard Tactician Tokens. - {69482069U, 3987308529U, 3899548068U, 62, 100}, + {69482069U, 3987308529U, 3899548068U, 62, 100, kVanguardEngram, kTokenRankCost}, // Shaxx: Crucible Rewards charges Crucible Tokens, not Valor or Glory points. - {3603221665U, 265113466U, 183980811U, 49, 100}, + {3603221665U, 265113466U, 183980811U, 49, 100, kCrucibleEngram, kTokenRankCost}, // Devrim: EDZ token turn-ins use a different placeholder from destination materials. {396892126U, 61430328U, 2640973641U, 52, 100}, // Devrim: destination-material turn-ins accept Dusklight Shards. @@ -90,7 +100,9 @@ VendorReputationDisposition resolve_award(std::uint16_t vendorIndex, award = {cost.definitionHash, sale.costQuantity, static_cast(experience), - rule.progressionIndex}; + rule.progressionIndex, + rule.rewardHash, + rule.rankCost}; return VendorReputationDisposition::prepared; } return recognized ? VendorReputationDisposition::refused @@ -128,6 +140,49 @@ bool charge_materials(AccountState& account, const VendorReputationAward& award) && runtime::detail::valid_profile_inventory(account); } +/** + * Hold the store lock; discard the candidate if any earned engram does not fit. + * @param account Candidate after charging materials; no saved state is written here. + * @param award Build-matched turn-in and repeating rank rule. + * @param beforeExperience Nonnegative XP before this turn-in. + * @return False when the reward definition or inventory cannot support the complete grant. + */ +bool grant_rank_rewards(AccountState& account, + const VendorReputationAward& award, + std::int32_t beforeExperience) noexcept { + if (award.rewardHash == 0) { + return true; + } + if (award.rankCost <= 0 || beforeExperience < 0 || award.experience <= 0) { + return false; + } + const auto afterExperience = static_cast(beforeExperience) + award.experience; + // Only thresholds crossed by this payment earn items; existing XP is never backfilled. + const auto count = static_cast(afterExperience / award.rankCost + - beforeExperience / award.rankCost); + if (count == 0) { + return true; + } + const auto selected = runtime::detail::selected_character_index(account); + build_data::items::Definition reward{}; + if (selected >= account.characterCount + || !build_data::find_item_definition_hash(award.rewardHash, reward) + || reward.questInitialization.scope != build_data::items::QuestInitialization::Scope::none + || count > account.characters[selected].inventory.values.size() + - account.characters[selected].inventory.count) { + return false; + } + for (std::size_t index = 0; index < count; ++index) { + PendingItemAcquisition grant{}; + if (!runtime::detail::finalize_item_acquisition( + account, account, award.rewardHash, false, {.direct = true}, grant)) { + return false; + } + account.characters[selected] = grant.afterCharacter; + } + return true; +} + } // namespace /** @@ -165,7 +220,8 @@ VendorReputationDisposition prepare_vendor_reputation(std::uint16_t vendorIndex, return VendorReputationDisposition::refused; } AccountState after = account; - if (!charge_materials(after, award)) { + if (!charge_materials(after, award) + || !grant_rank_rewards(after, award, before[kExperienceLane])) { return VendorReputationDisposition::refused; } mutation.beforeItems = account.profileItems; @@ -182,9 +238,9 @@ VendorReputationDisposition prepare_vendor_reputation(std::uint16_t vendorIndex, } /** - * Debits materials and credits character XP within the caller's response transaction. + * Materials, XP and earned engrams share the caller's response transaction. * @param mutation Before-image to consume, including on refusal. - * @return False for stale state, changed content or any failed write; neither change is kept. + * @return False for stale state, changed content or any failed grant/write; all changes roll back. */ bool commit_vendor_reputation(PendingVendorReputation& mutation) noexcept { const runtime::detail::PendingConsumption consume(mutation); @@ -213,7 +269,8 @@ bool commit_vendor_reputation(PendingVendorReputation& mutation) noexcept { if (progression != mutation.beforeProgression || progression[kExperienceLane] < 0 || progression[kExperienceLane] > (std::numeric_limits::max)() - award.experience - || !charge_materials(account, award)) { + || !charge_materials(account, award) + || !grant_rank_rewards(account, award, progression[kExperienceLane])) { return false; } progression[kExperienceLane] += award.experience; diff --git a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h index 96f7f0cf1..8f41d6c03 100644 --- a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h +++ b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h @@ -14,10 +14,13 @@ struct VendorReputationAward { std::uint32_t costQuantity{}; std::int32_t experience{}; std::uint16_t progressionIndex{}; + /** Zero preserves XP-only handling for factions without a supported reward rule. */ + std::uint32_t rewardHash{}; + std::int32_t rankCost{}; bool operator==(const VendorReputationAward&) const = default; }; -/** Captured payment and character state; no inventory item is granted by this transaction. */ +/** Captured payment and XP; rank rewards commit with the same turn-in. */ struct PendingVendorReputation { std::array beforeItems{}; diff --git a/tests/vendor_reputation.cpp b/tests/vendor_reputation.cpp index 213790e80..e3992cf55 100644 --- a/tests/vendor_reputation.cpp +++ b/tests/vendor_reputation.cpp @@ -3,6 +3,7 @@ #include #include +#include "middleware/datagen/family4/loadout/loadout_resolver.h" #include "state/build_data/runtime.h" #include "state/build_data/vendors/vendor_catalog.h" #include "state/investment/store_internal.h" @@ -39,6 +40,17 @@ std::uint32_t g_cost = kCost; std::uint16_t g_progression = kGunsmithProgression; bool g_characterScope = true; bool g_materialAvailable = true; +/** Build-86657 owned faction engrams, separate from the turn-in placeholders. */ +constexpr std::uint32_t kVanguardEngram = 3578462974U, kCrucibleEngram = 1368565477U, + kGunsmithEngram = 3531414277U; +/** Synthetic item selector for the currently tested reward. */ +constexpr std::uint16_t kRewardIndex = 5; +/** Build-86657 Gunsmith's repeating rank costs 3000 XP. */ +constexpr std::int32_t kGunsmithRankCost = 3000; +/** Build-86657 engram bucket has ten character inventory slots. */ +constexpr std::size_t kEngramCapacity = 10; +std::size_t g_rewardCapacity = kEngramCapacity; +bool g_rewardAvailable = true; /** @param passed Check result. @param label Failure description. */ void check(bool passed, const char* label) { @@ -76,6 +88,8 @@ void reset() { g_progression = kGunsmithProgression; g_characterScope = true; g_materialAvailable = true; + g_rewardCapacity = kEngramCapacity; + g_rewardAvailable = true; state::AccountState account{}; account.primarySoid = kAccount; account.characterCount = 2; @@ -246,6 +260,121 @@ void verify() { == kCost * kVanguardXpPerToken, "Vanguard token rate, not Gunsmith rate"); } + +/** Exercises real grants and SQLite transactions with controlled content and loadout resolution. */ +void verify_rank_rewards() { + /** Build-86657 vendor mappings; each ladder repeats its fixed XP cost. */ + struct RankCase { + std::uint32_t vendor, placeholder, material, reward; + std::uint16_t progression; + std::int32_t rankCost, rate; + }; + constexpr std::array cases{{ + {kBanshee, kGunsmithRewards, kGunsmithMaterials, kGunsmithEngram, 55, 3000, 30}, + {kZavala, kVanguardRewards, kVanguardToken, kVanguardEngram, 62, 2000, 100}, + {3603221665U, 265113466U, 183980811U, kCrucibleEngram, 49, 2000, 100}, + }}; + state::PendingVendorReputation pending{}; + for (const auto& entry : cases) { + reset(); + g_vendorHash = entry.vendor; + g_soldHash = entry.placeholder; + g_costHash = entry.material; + g_progression = entry.progression; + auto account = store::account(); + account.profileItems[0].definitionHash = entry.material; + check(store::write_account(account) + && store::write_unlock(store::Bank::characterProgressions, + entry.progression, + entry.rankCost - kCost * entry.rate), + "seed exact-threshold turn-in"); + check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared + && store::account().characters[0].inventory.count == 0, + "prepare rank grant without writes"); + auto duplicate = pending; + check(state::commit_vendor_reputation(pending), "commit rank reward"); + const auto& inventory = store::account().characters[0].inventory; + check(inventory.count == 1 && inventory.values[0].definitionHash == entry.reward + && inventory.values[0].quantity == 1 && inventory.values[0].instanceSoid != 0 + && store::account().characters[1].inventory.count == 0, + "one owned reward for selected character"); + check(!state::commit_vendor_reputation(duplicate) + && store::account().characters[0].inventory.count == 1, + "duplicate cannot grant again"); + check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared + && state::commit_vendor_reputation(pending) + && store::account().characters[0].inventory.count == 1, + "below next threshold does not grant again"); + } + reset(); + check(store::write_unlock( + store::Bank::characterProgressions, kGunsmithProgression, kGunsmithRankCost - kAward), + "seed crossing"); + g_rewardCapacity = 0; + check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::refused + && store::account().profileItems[0].quantity == kInitialQuantity + && gunsmith()[0] == kGunsmithRankCost - kAward, + "full bucket refuses before charging"); + g_rewardCapacity = kEngramCapacity; + g_rewardAvailable = false; + check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::refused, + "missing reward content refused"); + g_rewardAvailable = true; + check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared, + "prepare before capacity change"); + g_rewardCapacity = 0; + check(!state::commit_vendor_reputation(pending) + && store::account().profileItems[0].quantity == kInitialQuantity, + "commit rechecks capacity without charging"); + g_rewardCapacity = kEngramCapacity; + { + store::Transaction outer; + check(outer.ready() + && state::prepare_vendor_reputation(kVendor, kSale, pending) + == Disposition::prepared + && state::commit_vendor_reputation(pending), + "stage reward inside response"); + } + check(store::account().characters[0].inventory.count == 0 + && store::account().profileItems[0].quantity == kInitialQuantity + && gunsmith()[0] == kGunsmithRankCost - kAward, + "abandoned response rolls back reward, XP and payment"); + check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared + && store::execute("CREATE TEMP TRIGGER reject_rank BEFORE INSERT ON unlocks " + "BEGIN SELECT RAISE(ABORT,'fixture'); END"), + "inject rank write failure"); + check(!state::commit_vendor_reputation(pending) + && store::account().characters[0].inventory.count == 0 + && store::account().profileItems[0].quantity == kInitialQuantity + && gunsmith()[0] == kGunsmithRankCost - kAward, + "failed XP write rolls back grant and materials"); + check(store::execute("DROP TRIGGER reject_rank"), "remove rank fault"); + reset(); + check(store::write_unlock( + store::Bank::characterProgressions, kGunsmithProgression, kGunsmithRankCost * 2) + && state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared + && state::commit_vendor_reputation(pending) + && store::account().characters[0].inventory.count == 0, + "historical ranks never backfilled"); + reset(); + auto account = store::account(); + // Two complete Gunsmith ranks in one controlled payment exercise unique item allocation. + g_cost = static_cast(kGunsmithRankCost * 2 / 30); + account.profileItems[0].quantity = static_cast(g_cost); + check(store::write_account(account), "seed multi-rank materials"); + g_rewardCapacity = 1; + check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::refused + && store::account().characters[0].inventory.count == 0, + "partial multi-rank grant is not persisted"); + g_rewardCapacity = kEngramCapacity; + check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared + && state::commit_vendor_reputation(pending), + "commit two earned ranks"); + const auto& inventory = store::account().characters[0].inventory; + check(inventory.count == 2 + && inventory.values[0].instanceSoid != inventory.values[1].instanceSoid, + "one unique item per crossed threshold"); +} } // namespace namespace sunrise::state::build_data::vendors { @@ -269,6 +398,10 @@ bool sale_row(const Definition&, std::size_t row, SaleRow& output) noexcept { } // namespace sunrise::state::build_data::vendors namespace sunrise::state::build_data { +/** Season bundles are not part of the faction grant under test. */ +bool find_season_pass_package(std::uint32_t, season_pass::Package&) noexcept { + return false; +} /** Unrelated acquisition code shares the profile helper translation unit. */ bool find_collectible_definition(std::uint16_t, collectibles::Definition&) noexcept { return false; @@ -284,6 +417,13 @@ bool find_item_definition_index(std::uint16_t definitionIndex, } bool find_item_definition_hash(std::uint32_t definitionHash, items::Definition& definition) noexcept { + if (definitionHash == kVanguardEngram || definitionHash == kCrucibleEngram + || definitionHash == kGunsmithEngram) { + definition = {}; + definition.definitionHash = definitionHash; + definition.definitionIndex = kRewardIndex; + return g_rewardAvailable; + } return definitionHash == g_costHash && find_item_definition_index(kCostIndex, definition); } bool find_configured_item_detail(std::uint16_t definitionIndex, @@ -333,17 +473,37 @@ AccountState account_snapshot() noexcept { } // namespace sunrise::state namespace sunrise::state::runtime::detail { -/** Isolates character selection from the unrelated item acquisition implementation. */ -std::size_t selected_character_index(const AccountState& account) noexcept { - for (std::size_t index = 0; index < account.characterCount; ++index) { - if (account.characters[index].selected) { - return index; - } - } - return account.characterCount; +/** Unused acquisition commit paths must not succeed through a fixture equality shortcut. */ +bool same_character(const CharacterState&, const CharacterState&) noexcept { + return false; } } // namespace sunrise::state::runtime::detail +namespace sunrise::middleware::datagen::family4::loadout { +/** + * Models bucket capacity without installed game content; acquisition and persistence are real. + * @param account Candidate account. + * @param selectedCharacterIndex Character whose items must fit. + * @param output Receives synthetic inventory positions. + * @return False when the fixture bucket has insufficient space. + */ +bool resolve(const state::AccountState& account, + std::size_t selectedCharacterIndex, + ResolvedLoadout& output) noexcept { + output = {}; + const auto& inventory = account.characters[selectedCharacterIndex].inventory; + if (inventory.count > g_rewardCapacity) { + return false; + } + for (std::size_t index = 0; index < inventory.count; ++index) { + auto& item = output.items[output.itemCount++]; + item.inventoryRow = static_cast(index); + item.instance.instanceSoid = inventory.values[index].instanceSoid; + } + return true; +} +} // namespace sunrise::middleware::datagen::family4::loadout + /** * Runs against disposable SQLite only, then checks persistence by closing and reopening it. * @param argc Argument count. @@ -360,6 +520,7 @@ int main(int argc, char** argv) { read_text(root + "/account_settings_defaults.sql")), "open disposable store"); verify(); + verify_rank_rewards(); store::shutdown(); check(store::open(argv[2], read_text(root + "/investment_schema.sql"), @@ -368,15 +529,21 @@ int main(int argc, char** argv) { read_text(root + "/account_settings_defaults.sql")), "open disposable disk store"); reset(); + check(store::write_unlock( + store::Bank::characterProgressions, kGunsmithProgression, kGunsmithRankCost - kAward), + "seed persistent rank crossing"); state::PendingVendorReputation pending{}; check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared && state::commit_vendor_reputation(pending), "commit before closing store"); store::shutdown(); check(store::open(argv[2], {}, {}, {}, {}), "reopen saved store without seeding"); - check(gunsmith()[0] == kInitialProgression[0] + kAward - && store::account().profileItems[0].quantity == kInitialQuantity - kCost, - "material debit and XP survive reopening database"); + check(gunsmith()[0] == kGunsmithRankCost + && store::account().profileItems[0].quantity == kInitialQuantity - kCost + && store::account().characters[0].inventory.count == 1 + && store::account().characters[0].inventory.values[0].definitionHash + == kGunsmithEngram, + "material debit, XP and reward survive reopening database"); store::shutdown(); std::puts("PASS: reputation transaction, exact debit/credit, refusal, staleness and rollback"); } diff --git a/tests/vendor_reputation.ps1 b/tests/vendor_reputation.ps1 index ce14cd409..8a7693d2a 100644 --- a/tests/vendor_reputation.ps1 +++ b/tests/vendor_reputation.ps1 @@ -12,6 +12,8 @@ $repSources = @( 'tests/vendor_reputation.cpp', 'Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp', 'Sunrise/src/state/runtime/state_account_profile_runtime.cpp', + 'Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp', + 'Sunrise/src/state/runtime/state_account_identity_runtime.cpp', 'Sunrise/src/state/account/account_state.cpp', 'Sunrise/src/state/account/inventory/inventory_state.cpp', 'Sunrise/src/state/account/settings/settings_state.cpp', From c30bf7e91d45f9991b9be589d82f060164dc079a Mon Sep 17 00:00:00 2001 From: Gage Fulwood Date: Thu, 17 Sep 2026 01:35:24 -0500 Subject: [PATCH 03/59] Reconstruct faction claim credits and atomic Vanguard rewards --- Sunrise/Sunrise.vcxproj | 1 + .../messages/opcode904/opcode904_codec.cpp | 2 +- .../messages/opcode904/opcode904_codec.h | 5 +- .../web_service_vendor_actions.cpp | 60 +++ Sunrise/src/state/runtime/runtime.h | 2 + .../state_account_acquisition_runtime.cpp | 19 +- .../state_vendor_reputation_runtime.cpp | 253 ++++++++++-- .../runtime/state_vendor_reputation_runtime.h | 24 +- .../src/state/runtime/vendor_reward_pool.h | 73 ++++ tests/vendor_reputation.cpp | 379 ++++++++++++++---- tests/vendor_reputation.ps1 | 1 + 11 files changed, 699 insertions(+), 120 deletions(-) create mode 100644 Sunrise/src/state/runtime/vendor_reward_pool.h diff --git a/Sunrise/Sunrise.vcxproj b/Sunrise/Sunrise.vcxproj index 7c7ea5b3e..bd203b039 100644 --- a/Sunrise/Sunrise.vcxproj +++ b/Sunrise/Sunrise.vcxproj @@ -157,6 +157,7 @@ + diff --git a/Sunrise/src/middleware/web_service/messages/opcode904/opcode904_codec.cpp b/Sunrise/src/middleware/web_service/messages/opcode904/opcode904_codec.cpp index 7bdc38282..3d594a58d 100644 --- a/Sunrise/src/middleware/web_service/messages/opcode904/opcode904_codec.cpp +++ b/Sunrise/src/middleware/web_service/messages/opcode904/opcode904_codec.cpp @@ -1,6 +1,6 @@ /** * Opcode 904 acquires a quest or other pursuit from a vendor. - * Three 16-bit fields biased by 0x8000 (vendor, clicked slot, one always zero), then a 32-bit + * Three 16-bit selectors biased by 0x8000, then a 32-bit * field biased by 0x80000000 naming the sale row, then one trailing byte that is skipped. */ diff --git a/Sunrise/src/middleware/web_service/messages/opcode904/opcode904_codec.h b/Sunrise/src/middleware/web_service/messages/opcode904/opcode904_codec.h index 0e8df1f54..c41c4b69a 100644 --- a/Sunrise/src/middleware/web_service/messages/opcode904/opcode904_codec.h +++ b/Sunrise/src/middleware/web_service/messages/opcode904/opcode904_codec.h @@ -8,6 +8,8 @@ namespace sunrise::middleware::web_service::messages::opcode904 { /** Web Service opcode for acquiring a quest or other pursuit from a vendor. */ inline constexpr std::uint16_t kOpcode = 904; +/** Logical -1 selects an interaction without a sale row. */ +inline constexpr std::int32_t kAbsentSaleIndex = -1; /** One decoded quest-acquire request: three biased 16-bit fields then one biased 32-bit row. */ struct Request { @@ -15,7 +17,8 @@ struct Request { std::int16_t vendorIndex{}; /** UI slot the click landed on. Not a sale row. */ std::int16_t slotIndex{}; - /** Always zero on the wire; meaning unknown. */ + /** Supported rowless interactions use this as the reply selector; sale-backed meaning is open. + */ std::int16_t third{}; /** * Sale row of the vendor definition, 32-bit biased by 0x80000000. diff --git a/Sunrise/src/server/web_service/web_service_vendor_actions.cpp b/Sunrise/src/server/web_service/web_service_vendor_actions.cpp index d28bb9e2c..70acd318d 100644 --- a/Sunrise/src/server/web_service/web_service_vendor_actions.cpp +++ b/Sunrise/src/server/web_service/web_service_vendor_actions.cpp @@ -9,6 +9,8 @@ #include #include "../../core/logging/log.h" +#include "../../middleware/crypto/random_bytes.h" +#include "../../middleware/encoding/byte_order.h" #include "../../middleware/web_service/messages/opcode1820.h" #include "../../middleware/web_service/messages/opcode901/opcode901_codec.h" #include "../../middleware/web_service/messages/opcode904/opcode904_codec.h" @@ -643,6 +645,14 @@ void settle_vendor_row(const middleware::web_service::Message& message, std::int32_t categoryIndex, std::uint16_t itemDefinitionIndex, Outcome& outcome) noexcept { + // Rank rewards are claimed by interaction; a sale request must not bypass its credit debit. + if (vendorIndex >= 0 && vendorIndex <= (std::numeric_limits::max)() + && state::is_vendor_reward_category(static_cast(vendorIndex), + categoryIndex)) { + report_purchase( + opcode, "fail", "reward_requires_reply", vendorIndex, rowIndex, itemDefinitionIndex); + return; + } if (vendorIndex >= 0 && rowIndex >= 0 && vendorIndex <= (std::numeric_limits::max)() && rowIndex <= (std::numeric_limits::max)()) { @@ -743,6 +753,56 @@ void acquire_quest(const middleware::web_service::Message& message, Outcome& out return; } const std::int32_t row = request.saleIndex; + // Logical -1 is the only absent sale marker; all three selectors must be nonnegative. + if (row < quest::kAbsentSaleIndex || request.vendorIndex < 0 || request.slotIndex < 0 + || request.third < 0) { + report_purchase(quest::kOpcode, + "fail", + "selectors", + request.vendorIndex, + row, + kUnavailableDefinitionIndex); + return; + } + if (row == quest::kAbsentSaleIndex) { + auto* reward = emplace_mutation(outcome); + std::array randomBytes{}; + if (reward == nullptr || !middleware::crypto::random::fill(randomBytes)) { + clear_mutation(outcome); + report_purchase(quest::kOpcode, + "fail", + "reward_prepare", + request.vendorIndex, + row, + kUnavailableDefinitionIndex); + return; + } + const auto disposition = + state::prepare_vendor_reward(static_cast(request.vendorIndex), + static_cast(request.slotIndex), + static_cast(request.third), + middleware::encoding::read_u32_le(randomBytes), + *reward); + if (disposition == state::VendorReputationDisposition::prepared) { + report_purchase(quest::kOpcode, + "ok", + "rank_reward", + request.vendorIndex, + row, + kUnavailableDefinitionIndex); + return; + } + clear_mutation(outcome); + if (disposition == state::VendorReputationDisposition::refused) { + report_purchase(quest::kOpcode, + "fail", + "rank_reward", + request.vendorIndex, + row, + kUnavailableDefinitionIndex); + return; + } + } std::uint16_t itemDefinitionIndex = 0; const char* reason = "unknown"; // A row of -1 says the tile is not a sale row at all, so the installed array answers it. diff --git a/Sunrise/src/state/runtime/runtime.h b/Sunrise/src/state/runtime/runtime.h index 63f05145c..e1ec19de0 100644 --- a/Sunrise/src/state/runtime/runtime.h +++ b/Sunrise/src/state/runtime/runtime.h @@ -9,6 +9,7 @@ #include "../build_data/items/quest_initialization.h" #include "../build_data/records/definition.h" #include "state.h" +#include "state_vendor_reputation_runtime.h" namespace sunrise::state::account::settings { @@ -138,6 +139,7 @@ struct PendingItemAcquisition { bool directGrant{}; build_data::items::QuestInitialization questInitialization{}; std::int32_t previousQuestValue{}; + VendorRewardClaim vendorReward{}; bool prepared{}; /** diff --git a/Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp b/Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp index 969a91706..c6bdce644 100644 --- a/Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp +++ b/Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp @@ -431,7 +431,8 @@ valid_item_acquisition_source(const PendingItemAcquisition& mutation) noexcept { || mutation.characterIndex >= current.characterCount || !current.characters[mutation.characterIndex].selected || current.characters[mutation.characterIndex].soid != mutation.characterSoid - || !quest_current(mutation) || current.primarySoid != mutation.accountSoid + || !quest_current(mutation) || !vendor_reward_current(mutation) + || current.primarySoid != mutation.accountSoid || !same_character(current.characters[mutation.characterIndex], mutation.beforeCharacter) || !same_profile_inventory( current, mutation.beforeProfileItems, mutation.expectedProfileItemCount) @@ -530,7 +531,7 @@ valid_item_acquisition_source(const PendingItemAcquisition& mutation) noexcept { } // namespace runtime::detail /** - * Preview inventory and quest values together without changing the save. + * Preview inventory, quest values and claim credit together without changing the save. * @param mutation Prepared acquisition checked against current saved state. * @param after Receives the candidate account; use only on success. * @param afterUnlocks Receives matching account and selected-character unlocks on success. @@ -554,6 +555,10 @@ bool preview_item_acquisition(const PendingItemAcquisition& mutation, } else if (quest.scope == Quest::Scope::character) { afterUnlocks.characterObjectValues[quest.row] = value; } + if (mutation.vendorReward.beforeCredits > 0) { + afterUnlocks.characterObjectValues[kVanguardRewardValueRow] = + mutation.vendorReward.beforeCredits - 1; + } return true; } @@ -565,9 +570,9 @@ bool preview_direct_item_bundle(const PendingDirectItemBundle& mutation, } /** - * Inventory and first-step state share one transaction; failure rolls both back. + * Inventory, first-step state and claim credit share one transaction; failure rolls all back. * @param mutation Prepared grant consumed on either success or failure. - * @return True when both writes commit against the unchanged prepared state. + * @return True when all writes commit against the unchanged prepared state. */ bool commit_item_acquisition(PendingItemAcquisition& mutation) noexcept { const PendingItemAcquisition& prepared = mutation; @@ -585,6 +590,12 @@ bool commit_item_acquisition(PendingItemAcquisition& mutation) noexcept { && !investment::store::write_unlock(quest_bank(quest), quest.row, quest.value)) { return false; } + if (prepared.vendorReward.beforeCredits > 0 + && !investment::store::write_unlock(investment::store::Bank::characterObjectValues, + kVanguardRewardValueRow, + prepared.vendorReward.beforeCredits - 1)) { + return false; + } return transaction.commit(); } diff --git a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp index ce92d0205..ccad43459 100644 --- a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp +++ b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp @@ -6,6 +6,7 @@ #include "../build_data/vendors/vendor_catalog.h" #include "../investment/store_internal.h" #include "state_account_transaction_helpers.h" +#include "vendor_reward_pool.h" namespace sunrise::state { namespace { @@ -17,13 +18,12 @@ struct ReputationRule { std::uint32_t costHash; std::uint16_t progressionIndex; std::int32_t experiencePerUnit; - std::uint32_t rewardHash{}; + std::uint16_t rewardValueRow{}; std::int32_t rankCost{}; }; -/** Build-86657 faction rewardItemHash values identify owned engrams, not sale placeholders. */ -constexpr std::uint32_t kVanguardEngram = 3578462974U, kCrucibleEngram = 1368565477U, - kGunsmithEngram = 3531414277U; +/** Build-86657 VALUE[888] and VALUE[906] map to these character-object value rows. */ +constexpr std::uint16_t kCrucibleRewardValueRow = 45, kGunsmithRewardValueRow = 49; /** Build-86657 Vanguard and Crucible use 2000-XP steps and repeat the last step. */ constexpr std::int32_t kTokenRankCost = 2000; /** Build-86657 Gunsmith uses 3000-XP steps and repeats the last step. */ @@ -32,13 +32,13 @@ constexpr std::int32_t kGunsmithRankCost = 3000; /** Vendor/item hashes, character progression indices and XP per unit from build 86657. */ constexpr std::array kReputationRules{{ // Banshee: Gunsmith Rewards charges Gunsmith Materials for Gunsmith progression. - {672118013U, 3831705402U, 685157383U, 55, 30, kGunsmithEngram, kGunsmithRankCost}, + {672118013U, 3831705402U, 685157383U, 55, 30, kGunsmithRewardValueRow, kGunsmithRankCost}, // Banshee: the same placeholder also accepts Weapon Telemetry at its own XP rate. - {672118013U, 3831705402U, 685157381U, 55, 25, kGunsmithEngram, kGunsmithRankCost}, + {672118013U, 3831705402U, 685157381U, 55, 25, kGunsmithRewardValueRow, kGunsmithRankCost}, // Zavala: Vanguard Tactician Rewards charges Vanguard Tactician Tokens. - {69482069U, 3987308529U, 3899548068U, 62, 100, kVanguardEngram, kTokenRankCost}, + {69482069U, 3987308529U, 3899548068U, 62, 100, kVanguardRewardValueRow, kTokenRankCost}, // Shaxx: Crucible Rewards charges Crucible Tokens, not Valor or Glory points. - {3603221665U, 265113466U, 183980811U, 49, 100, kCrucibleEngram, kTokenRankCost}, + {3603221665U, 265113466U, 183980811U, 49, 100, kCrucibleRewardValueRow, kTokenRankCost}, // Devrim: EDZ token turn-ins use a different placeholder from destination materials. {396892126U, 61430328U, 2640973641U, 52, 100}, // Devrim: destination-material turn-ins accept Dusklight Shards. @@ -50,6 +50,87 @@ constexpr std::array kReputationRules{{ /** Native progression level walks read experience from lane zero. */ constexpr std::size_t kExperienceLane = 0; +/** Build-86657 vendor hashes, normal claim interactions and reward categories. */ +struct RewardRule { + std::uint32_t vendorHash; + std::uint16_t interaction; + std::int32_t category; +}; +/** Only these build-86657 normal claim interactions own the mapped reward categories. */ +constexpr std::array kRewardRules{{ + {69482069U, 40, 3}, // Zavala: Accept Reward. + {3603221665U, 28, 10}, // Shaxx: Accept Reward; payout not yet supported. + {672118013U, 35, 8}, // Banshee: Accept Reward; payout not yet supported. +}}; +/** Only Zavala's current package has a supported payout binding. */ +constexpr std::uint32_t kZavalaHash = 69482069U; +/** Build-86657 current Vanguard package occupies sale row 93. */ +constexpr std::uint16_t kVanguardPackageSale = 93; +/** The package opens on acquisition and previews vendor 1016620613. */ +constexpr std::uint32_t kVanguardPackageHash = 2746484552U; +/** Reply zero completes Zavala's normal reward interaction. */ +constexpr std::uint16_t kAcceptRewardReply = 0; +/** FLAG[5901] selects the supported Vanguard package instead of the older one. */ +constexpr std::uint16_t kVanguardPackageFlag = 5901; +/** The normal reward interaction requires VALUE[465] >= 20. */ +constexpr std::uint16_t kRewardLevelSlot = 465; +/** The normal reward interaction excludes levels below this content threshold. */ +constexpr std::int32_t kMinimumRewardLevel = 20; + +/** + * Accept only the supported package and explicit evaluated gates; do not guess missing values. + * @param vendorIndex Installed vendor selector. + * @return True when the saved evaluated gates and installed package binding agree. + */ +bool reward_binding_current(std::uint16_t vendorIndex) noexcept { + namespace vendors = build_data::vendors; + vendors::IndexEntry entry{}; + vendors::Definition vendor{}; + vendors::SaleRow sale{}; + build_data::items::Definition package{}; + Family5State family{}; + if (!vendors::find_index(vendorIndex, entry) || entry.definitionHash != kZavalaHash + || !vendors::find(entry.definitionHash, vendor) + || !vendors::sale_row(vendor, kVanguardPackageSale, sale) + || sale.categoryIndex != kRewardRules.front().category || sale.costQuantity != 0 + || !build_data::find_item_definition_index(sale.itemIndex, package) + || package.definitionHash != kVanguardPackageHash + || !investment::store::read_family5(family)) { + return false; + } + bool packageEnabled = false, levelMet = false; + for (std::size_t index = 0; index < family.flagCount; ++index) { + const auto& flag = family.flags[index]; + if (flag.slot == kVanguardPackageFlag) { + packageEnabled = flag.value == unlocks::kFlagSet; + } + } + for (std::size_t index = 0; index < family.valueCount; ++index) { + const auto& value = family.values[index]; + if (value.slot == kRewardLevelSlot) { + levelMet = value.value >= kMinimumRewardLevel; + } + } + return packageEnabled && levelMet; +} + +/** + * Supported rewards must be installed instanced gear, not packages, cosmetics or quests. + * @param hash Candidate preview item hash. + * @return True when the installed item and detail rows agree on supported gear. + */ +bool reward_item_supported(std::uint32_t hash) noexcept { + build_data::items::Definition item{}; + build_data::items::details::Definition detail{}; + return build_data::find_item_definition_hash(hash, item) + && item.questInitialization.scope == build_data::items::QuestInitialization::Scope::none + && build_data::find_configured_item_detail(item.definitionIndex, detail) + && detail.definitionHash == hash && detail.definitionIndex == item.definitionIndex + && detail.bucketId == item.bucketId && detail.equipmentSlot.has_value() + && detail.instancedDefinitionState + == build_data::items::details::InstancedDefinitionState::instanced; +} + /** * Resolves a turn-in without treating another purchase using the same material as reputation. * @param vendorIndex Installed vendor selector. @@ -101,7 +182,7 @@ VendorReputationDisposition resolve_award(std::uint16_t vendorIndex, sale.costQuantity, static_cast(experience), rule.progressionIndex, - rule.rewardHash, + rule.rewardValueRow, rule.rankCost}; return VendorReputationDisposition::prepared; } @@ -141,45 +222,28 @@ bool charge_materials(AccountState& account, const VendorReputationAward& award) } /** - * Hold the store lock; discard the candidate if any earned engram does not fit. - * @param account Candidate after charging materials; no saved state is written here. + * Reconstructed policy awards one claim credit per newly crossed rank, without backfilling XP. + * @param banks Candidate unlock banks; no saved state is written here. * @param award Build-matched turn-in and repeating rank rule. * @param beforeExperience Nonnegative XP before this turn-in. - * @return False when the reward definition or inventory cannot support the complete grant. + * @return False when the counter is negative or the complete credit would overflow. */ -bool grant_rank_rewards(AccountState& account, +bool grant_rank_rewards(unlocks::Table& banks, const VendorReputationAward& award, std::int32_t beforeExperience) noexcept { - if (award.rewardHash == 0) { + if (award.rankCost == 0) { return true; } if (award.rankCost <= 0 || beforeExperience < 0 || award.experience <= 0) { return false; } const auto afterExperience = static_cast(beforeExperience) + award.experience; - // Only thresholds crossed by this payment earn items; existing XP is never backfilled. - const auto count = static_cast(afterExperience / award.rankCost - - beforeExperience / award.rankCost); - if (count == 0) { - return true; - } - const auto selected = runtime::detail::selected_character_index(account); - build_data::items::Definition reward{}; - if (selected >= account.characterCount - || !build_data::find_item_definition_hash(award.rewardHash, reward) - || reward.questInitialization.scope != build_data::items::QuestInitialization::Scope::none - || count > account.characters[selected].inventory.values.size() - - account.characters[selected].inventory.count) { + const auto count = afterExperience / award.rankCost - beforeExperience / award.rankCost; + auto& credits = banks.characterObjectValues[award.rewardValueRow]; + if (credits < 0 || count > (std::numeric_limits::max)() - credits) { return false; } - for (std::size_t index = 0; index < count; ++index) { - PendingItemAcquisition grant{}; - if (!runtime::detail::finalize_item_acquisition( - account, account, award.rewardHash, false, {.direct = true}, grant)) { - return false; - } - account.characters[selected] = grant.afterCharacter; - } + credits += static_cast(count); return true; } @@ -220,13 +284,15 @@ VendorReputationDisposition prepare_vendor_reputation(std::uint16_t vendorIndex, return VendorReputationDisposition::refused; } AccountState after = account; + const auto beforeCredits = banks.characterObjectValues[award.rewardValueRow]; if (!charge_materials(after, award) - || !grant_rank_rewards(after, award, before[kExperienceLane])) { + || !grant_rank_rewards(banks, award, before[kExperienceLane])) { return VendorReputationDisposition::refused; } mutation.beforeItems = account.profileItems; mutation.beforeItemCount = account.profileItemCount; mutation.beforeProgression = before; + mutation.beforeRewardCredits = beforeCredits; mutation.award = award; mutation.accountSoid = account.primarySoid; mutation.characterSoid = account.characters[selected].soid; @@ -238,7 +304,7 @@ VendorReputationDisposition prepare_vendor_reputation(std::uint16_t vendorIndex, } /** - * Materials, XP and earned engrams share the caller's response transaction. + * Materials, XP and claim credits share the caller's response transaction. * @param mutation Before-image to consume, including on refusal. * @return False for stale state, changed content or any failed grant/write; all changes roll back. */ @@ -267,10 +333,12 @@ bool commit_vendor_reputation(PendingVendorReputation& mutation) noexcept { } auto& progression = banks.characterProgressions[award.progressionIndex]; if (progression != mutation.beforeProgression || progression[kExperienceLane] < 0 + || (award.rankCost > 0 + && banks.characterObjectValues[award.rewardValueRow] != mutation.beforeRewardCredits) || progression[kExperienceLane] > (std::numeric_limits::max)() - award.experience || !charge_materials(account, award) - || !grant_rank_rewards(account, award, progression[kExperienceLane])) { + || !grant_rank_rewards(banks, award, progression[kExperienceLane])) { return false; } progression[kExperienceLane] += award.experience; @@ -279,4 +347,113 @@ bool commit_vendor_reputation(PendingVendorReputation& mutation) noexcept { && transaction.commit(); } +/** + * Reward categories cannot fall through to a free package acquisition. + * @param vendorIndex Installed vendor selector. + * @param categoryIndex Installed sale category. + * @return True for a mapped rank-reward category, including unsupported payout vendors. + */ +bool is_vendor_reward_category(std::uint16_t vendorIndex, std::int32_t categoryIndex) noexcept { + build_data::vendors::IndexEntry entry{}; + if (!build_data::vendors::find_index(vendorIndex, entry)) { + return false; + } + return std::any_of(kRewardRules.begin(), kRewardRules.end(), [&](const auto& rule) { + return rule.vendorHash == entry.definitionHash && rule.category == categoryIndex; + }); +} + +/** + * Reconstructs one gear claim without publishing inventory or consuming its credit yet. + * @param vendorIndex Installed vendor selector. + * @param interactionIndex Rowless interaction selector. + * @param replyIndex Reply within that interaction. + * @param random Server-generated selection value; never supplied by the client. + * @param mutation Receives the prepared grant; cleared on refusal. + * @return Recognized but unsupported or unaffordable claims are refused, never generic grants. + */ +VendorReputationDisposition prepare_vendor_reward(std::uint16_t vendorIndex, + std::uint16_t interactionIndex, + std::uint16_t replyIndex, + std::uint32_t random, + PendingItemAcquisition& mutation) noexcept { + mutation = {}; + build_data::vendors::IndexEntry entry{}; + if (!build_data::vendors::find_index(vendorIndex, entry)) { + return VendorReputationDisposition::notApplicable; + } + const auto rule = + std::find_if(kRewardRules.begin(), kRewardRules.end(), [&](const auto& value) { + return value.vendorHash == entry.definitionHash + && value.interaction == interactionIndex; + }); + if (rule == kRewardRules.end()) { + return VendorReputationDisposition::notApplicable; + } + const std::lock_guard lock(investment::store::g_mutex); + AccountState account{}; + std::int32_t credits = 0; + if (replyIndex != kAcceptRewardReply || !reward_binding_current(vendorIndex) + || !investment::store::read_account(account) || !account::valid(account) + || !runtime::detail::valid_profile_inventory(account) + || !investment::store::read_unlock( + investment::store::Bank::characterObjectValues, kVanguardRewardValueRow, credits) + || credits <= 0) { + return VendorReputationDisposition::refused; + } + const auto selected = runtime::detail::selected_character_index(account); + if (selected >= account.characterCount) { + return VendorReputationDisposition::refused; + } + const auto armour = vendor_rewards::armour(account.characters[selected].characterClass); + std::array + candidates{}; + std::size_t count = 0; + for (const auto pool : + {std::span(vendor_rewards::kVanguardWeapons), armour}) { + for (const auto hash : pool) { + if (reward_item_supported(hash)) { + candidates[count++] = hash; + } + } + } + // Reconstructed policy chooses one installed gear row; retail weights and extras are unknown. + if (count == 0 + || !runtime::detail::finalize_item_acquisition( + account, account, candidates[random % count], false, {.direct = true}, mutation)) { + mutation = {}; + return VendorReputationDisposition::refused; + } + mutation.vendorReward = {credits, vendorIndex}; + return VendorReputationDisposition::prepared; +} + +/** + * Hold the store lock; claim credit and content gates must still match before publication. + * @param mutation Prepared item acquisition with optional vendor claim state. + * @return False for a stale credit, changed gate or an item outside the selected class's pool. + */ +bool vendor_reward_current(const PendingItemAcquisition& mutation) noexcept { + const auto& claim = mutation.vendorReward; + if (claim.beforeCredits == 0) { + return true; + } + std::int32_t current = 0; + if (claim.beforeCredits < 0 || !mutation.directGrant + || !reward_binding_current(claim.vendorIndex) + || !investment::store::read_unlock( + investment::store::Bank::characterObjectValues, kVanguardRewardValueRow, current) + || current != claim.beforeCredits + || !reward_item_supported(mutation.acquiredDefinitionHash)) { + return false; + } + const auto armour = vendor_rewards::armour(mutation.beforeCharacter.characterClass); + const auto& weapons = vendor_rewards::kVanguardWeapons; + return std::find(weapons.begin(), weapons.end(), mutation.acquiredDefinitionHash) + != weapons.end() + || std::find(armour.begin(), armour.end(), mutation.acquiredDefinitionHash) + != armour.end(); +} + } // namespace sunrise::state diff --git a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h index 8f41d6c03..df555c7dc 100644 --- a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h +++ b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h @@ -14,8 +14,9 @@ struct VendorReputationAward { std::uint32_t costQuantity{}; std::int32_t experience{}; std::uint16_t progressionIndex{}; + /** Character-object value row; used only when rankCost is positive. */ + std::uint16_t rewardValueRow{}; /** Zero preserves XP-only handling for factions without a supported reward rule. */ - std::uint32_t rewardHash{}; std::int32_t rankCost{}; bool operator==(const VendorReputationAward&) const = default; }; @@ -25,6 +26,7 @@ struct PendingVendorReputation { std::array beforeItems{}; unlocks::ProgressionLanes beforeProgression{}; + std::int32_t beforeRewardCredits{}; VendorReputationAward award{}; std::uint64_t accountSoid{}; std::uint64_t characterSoid{}; @@ -39,4 +41,24 @@ struct PendingVendorReputation { std::uint16_t vendorIndex, std::uint16_t saleIndex, PendingVendorReputation& mutation) noexcept; [[nodiscard]] bool commit_vendor_reputation(PendingVendorReputation& mutation) noexcept; +/** Build-86657 VALUE[927] reads character-object value row 55 for Vanguard claims. */ +inline constexpr std::uint16_t kVanguardRewardValueRow = 55; + +/** Positive credits identify a prepared claim; zero leaves ordinary acquisitions unchanged. */ +struct VendorRewardClaim { + std::int32_t beforeCredits{}; + std::uint16_t vendorIndex{}; +}; + +struct PendingItemAcquisition; +[[nodiscard]] VendorReputationDisposition +prepare_vendor_reward(std::uint16_t vendorIndex, + std::uint16_t interactionIndex, + std::uint16_t replyIndex, + std::uint32_t random, + PendingItemAcquisition& mutation) noexcept; +[[nodiscard]] bool vendor_reward_current(const PendingItemAcquisition& mutation) noexcept; +[[nodiscard]] bool is_vendor_reward_category(std::uint16_t vendorIndex, + std::int32_t categoryIndex) noexcept; + } // namespace sunrise::state diff --git a/Sunrise/src/state/runtime/vendor_reward_pool.h b/Sunrise/src/state/runtime/vendor_reward_pool.h new file mode 100644 index 000000000..b86438cc3 --- /dev/null +++ b/Sunrise/src/state/runtime/vendor_reward_pool.h @@ -0,0 +1,73 @@ +#pragma once + +#include +#include +#include + +#include "../account/account_state.h" + +namespace sunrise::state::vendor_rewards { + +/** Build-86657 preview vendor 1016620613 lists these weapons for package 2746484552. */ +inline constexpr std::array kVanguardWeapons{ + 991314988U, // Bad Omens. + 720351795U, // Arsenic Bite-4b. + 4230993599U, // Steel Sybil Z-14. + 253196586U, // Main Ingredient. + 4146702548U, // Outrageous Fortune. + 2957367743U, // Toil and Trouble. + 2009277538U, // The Last Dance. + 3745990145U, // Long Shadow. + 3356526253U, // Wishbringer. + 821154603U, // Gnawing Hunger. + 3504336176U, // Night Watch. + 2199171672U, // Lonesome. + 188882152U, // Last Perdition. + 3863882743U, // Uriel's Gift. + 4106983932U, // Elatha FR4. + 3569802112U, // The Old Fashioned. + 1529450902U, // Mos Epoch III. + 1807343361U, // Hawthorne's Field-Forged Shotgun. + 3622137132U, // Last Hope. + 3055192515U, // Timelines' Vertex. + 2257180473U, // Interference VI. + 2742838701U, // Dire Promise. + 2742838700U, // True Prophecy. + 1162247618U, // Jian 7 Rifle. + 1723380073U, // Enigma's Draw. + 2807687156U, // Distant Tumulus. + 1786797708U, // Escape Velocity. + 2857348871U, // Honor's Edge. + 1946491241U, // Truthteller. + 1835747805U, // Nature of the Beast. +}; + +/** Titan-only Vigil of Heroes hashes retain the same preview's item order. */ +inline constexpr std::array kVanguardTitan{ + 273457849U, 2009892127U, 3722981806U, 1392054568U, 3207116971U}; +/** Hunter-only Vigil of Heroes hashes retain the same preview's item order. */ +inline constexpr std::array kVanguardHunter{ + 3761819011U, 178749005U, 2629204288U, 1524444346U, 1812185909U}; +/** Warlock-only Vigil of Heroes hashes retain the same preview's item order. */ +inline constexpr std::array kVanguardWarlock{ + 1578461326U, 1108278178U, 332170995U, 3631862279U, 4086100104U}; + +/** + * Class-specific armour must never enter another class's reward pool. + * @param characterClass Selected character's class. + * @return Matching armour hashes, or an empty span for an invalid class. + */ +inline std::span armour(CharacterClass characterClass) noexcept { + switch (characterClass) { + case CharacterClass::titan: + return kVanguardTitan; + case CharacterClass::hunter: + return kVanguardHunter; + case CharacterClass::warlock: + return kVanguardWarlock; + default: + return {}; + } +} + +} // namespace sunrise::state::vendor_rewards diff --git a/tests/vendor_reputation.cpp b/tests/vendor_reputation.cpp index e3992cf55..5b509b76f 100644 --- a/tests/vendor_reputation.cpp +++ b/tests/vendor_reputation.cpp @@ -3,11 +3,13 @@ #include #include +#include "core/logging/log.h" #include "middleware/datagen/family4/loadout/loadout_resolver.h" #include "state/build_data/runtime.h" #include "state/build_data/vendors/vendor_catalog.h" #include "state/investment/store_internal.h" -#include "state/runtime/state_vendor_reputation_runtime.h" +#include "state/runtime/runtime.h" +#include "state/runtime/vendor_reward_pool.h" namespace { namespace state = sunrise::state; @@ -43,8 +45,30 @@ bool g_materialAvailable = true; /** Build-86657 owned faction engrams, separate from the turn-in placeholders. */ constexpr std::uint32_t kVanguardEngram = 3578462974U, kCrucibleEngram = 1368565477U, kGunsmithEngram = 3531414277U; -/** Synthetic item selector for the currently tested reward. */ -constexpr std::uint16_t kRewardIndex = 5; +/** Synthetic selectors isolate package and gear from the material fixture rows. */ +constexpr std::uint16_t kRewardIndex = 5, kGearIndexBase = 100; +/** Build-86657 Vanguard package, sale, interaction, category and evaluated gate bindings. */ +constexpr std::uint32_t kPackageHash = 2746484552U; +constexpr std::uint16_t kPackageSale = 93, kClaimInteraction = 40, kClaimCategory = 3, + kPackageFlag = 5901, kLevelSlot = 465; +constexpr std::int32_t kMinimumLevel = 20; +/** Build-86657 Gunsmith reward counter and normal claim interaction. */ +constexpr std::uint16_t kGunsmithCreditRow = 49, kBansheeClaimInteraction = 35; + +/** @return Flat fixture pool in weapon, Titan, Hunter, Warlock order. */ +std::uint32_t gear_hash(std::size_t index) { + for (const auto pool : + {std::span(state::vendor_rewards::kVanguardWeapons), + std::span(state::vendor_rewards::kVanguardTitan), + std::span(state::vendor_rewards::kVanguardHunter), + std::span(state::vendor_rewards::kVanguardWarlock)}) { + if (index < pool.size()) { + return pool[index]; + } + index -= pool.size(); + } + return 0; +} /** Build-86657 Gunsmith's repeating rank costs 3000 XP. */ constexpr std::int32_t kGunsmithRankCost = 3000; /** Build-86657 engram bucket has ten character inventory slots. */ @@ -261,18 +285,25 @@ void verify() { "Vanguard token rate, not Gunsmith rate"); } -/** Exercises real grants and SQLite transactions with controlled content and loadout resolution. */ +/** @return One selected-character claim counter from the disposable database. */ +std::int32_t credits(std::uint16_t row) { + std::int32_t value = 0; + check(store::read_unlock(store::Bank::characterObjectValues, row, value), "read claim credits"); + return value; +} + +/** Covers exact thresholds, multiple ranks, no backfill, overflow and transaction rollback. */ void verify_rank_rewards() { - /** Build-86657 vendor mappings; each ladder repeats its fixed XP cost. */ + /** Build-86657 vendor, progression and saved claim-counter mappings. */ struct RankCase { - std::uint32_t vendor, placeholder, material, reward; - std::uint16_t progression; + std::uint32_t vendor, placeholder, material; + std::uint16_t progression, rewardRow; std::int32_t rankCost, rate; }; constexpr std::array cases{{ - {kBanshee, kGunsmithRewards, kGunsmithMaterials, kGunsmithEngram, 55, 3000, 30}, - {kZavala, kVanguardRewards, kVanguardToken, kVanguardEngram, 62, 2000, 100}, - {3603221665U, 265113466U, 183980811U, kCrucibleEngram, 49, 2000, 100}, + {kBanshee, kGunsmithRewards, kGunsmithMaterials, 55, 49, 3000, 30}, + {kZavala, kVanguardRewards, kVanguardToken, 62, 55, 2000, 100}, + {3603221665U, 265113466U, 183980811U, 49, 45, 2000, 100}, }}; state::PendingVendorReputation pending{}; for (const auto& entry : cases) { @@ -288,95 +319,258 @@ void verify_rank_rewards() { entry.progression, entry.rankCost - kCost * entry.rate), "seed exact-threshold turn-in"); + g_rewardCapacity = 0; + g_rewardAvailable = false; check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared - && store::account().characters[0].inventory.count == 0, - "prepare rank grant without writes"); + && credits(entry.rewardRow) == 0, + "credit preparation needs no inventory capacity or reward item"); auto duplicate = pending; - check(state::commit_vendor_reputation(pending), "commit rank reward"); - const auto& inventory = store::account().characters[0].inventory; - check(inventory.count == 1 && inventory.values[0].definitionHash == entry.reward - && inventory.values[0].quantity == 1 && inventory.values[0].instanceSoid != 0 - && store::account().characters[1].inventory.count == 0, - "one owned reward for selected character"); - check(!state::commit_vendor_reputation(duplicate) - && store::account().characters[0].inventory.count == 1, - "duplicate cannot grant again"); + check(state::commit_vendor_reputation(pending) && credits(entry.rewardRow) == 1 + && store::account().characters[0].inventory.count == 0, + "one credit, no owned engram"); + check(!state::commit_vendor_reputation(duplicate) && credits(entry.rewardRow) == 1, + "duplicate cannot credit again"); check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared - && state::commit_vendor_reputation(pending) - && store::account().characters[0].inventory.count == 1, - "below next threshold does not grant again"); + && state::commit_vendor_reputation(pending) && credits(entry.rewardRow) == 1, + "below next threshold adds no credit"); + state::unlocks::Table other{}; + check(store::read_unlocks(other, 1) && other.characterObjectValues[entry.rewardRow] == 0, + "other character receives no credit"); } reset(); check(store::write_unlock( store::Bank::characterProgressions, kGunsmithProgression, kGunsmithRankCost - kAward), "seed crossing"); - g_rewardCapacity = 0; - check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::refused - && store::account().profileItems[0].quantity == kInitialQuantity - && gunsmith()[0] == kGunsmithRankCost - kAward, - "full bucket refuses before charging"); - g_rewardCapacity = kEngramCapacity; - g_rewardAvailable = false; - check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::refused, - "missing reward content refused"); - g_rewardAvailable = true; - check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared, - "prepare before capacity change"); - g_rewardCapacity = 0; - check(!state::commit_vendor_reputation(pending) - && store::account().profileItems[0].quantity == kInitialQuantity, - "commit rechecks capacity without charging"); - g_rewardCapacity = kEngramCapacity; { store::Transaction outer; check(outer.ready() && state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared && state::commit_vendor_reputation(pending), - "stage reward inside response"); + "stage credit inside response"); } - check(store::account().characters[0].inventory.count == 0 + check(credits(kGunsmithCreditRow) == 0 && store::account().profileItems[0].quantity == kInitialQuantity && gunsmith()[0] == kGunsmithRankCost - kAward, - "abandoned response rolls back reward, XP and payment"); + "abandoned response rolls back credit, XP and payment"); check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared && store::execute("CREATE TEMP TRIGGER reject_rank BEFORE INSERT ON unlocks " "BEGIN SELECT RAISE(ABORT,'fixture'); END"), - "inject rank write failure"); - check(!state::commit_vendor_reputation(pending) - && store::account().characters[0].inventory.count == 0 - && store::account().profileItems[0].quantity == kInitialQuantity - && gunsmith()[0] == kGunsmithRankCost - kAward, - "failed XP write rolls back grant and materials"); + "inject unlock write failure"); + check(!state::commit_vendor_reputation(pending) && credits(kGunsmithCreditRow) == 0 + && store::account().profileItems[0].quantity == kInitialQuantity, + "failed unlock write rolls back payment"); check(store::execute("DROP TRIGGER reject_rank"), "remove rank fault"); + check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared + && store::write_unlock(store::Bank::characterObjectValues, kGunsmithCreditRow, 1) + && !state::commit_vendor_reputation(pending), + "concurrent credit change refuses stale turn-in"); + check(store::write_unlock(store::Bank::characterObjectValues, + kGunsmithCreditRow, + (std::numeric_limits::max)()) + && state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::refused, + "credit overflow refused"); + check(store::write_unlock(store::Bank::characterObjectValues, kGunsmithCreditRow, -1) + && state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::refused, + "negative credit refused"); reset(); check(store::write_unlock( store::Bank::characterProgressions, kGunsmithProgression, kGunsmithRankCost * 2) && state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared - && state::commit_vendor_reputation(pending) - && store::account().characters[0].inventory.count == 0, + && state::commit_vendor_reputation(pending) && credits(kGunsmithCreditRow) == 0, "historical ranks never backfilled"); reset(); auto account = store::account(); - // Two complete Gunsmith ranks in one controlled payment exercise unique item allocation. + // Two complete Gunsmith ranks in one payment exercise multi-credit accounting. g_cost = static_cast(kGunsmithRankCost * 2 / 30); account.profileItems[0].quantity = static_cast(g_cost); - check(store::write_account(account), "seed multi-rank materials"); - g_rewardCapacity = 1; - check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::refused + check(store::write_account(account) + && state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared + && state::commit_vendor_reputation(pending) && credits(kGunsmithCreditRow) == 2 + && store::account().characters[0].inventory.count == 0, + "two thresholds award two credits, no items"); +} + +/** Configures a disposable eligible Zavala claim without changing any player save. */ +void reset_claim() { + reset(); + g_vendorHash = kZavala; + state::Family5State family{}; + family.flagCount = 1; + family.flags[0] = {kPackageFlag, state::unlocks::kFlagSet}; + family.valueCount = 1; + family.values[0] = {kLevelSlot, kMinimumLevel}; + check(store::write_family5(family) + && store::write_unlock( + store::Bank::characterObjectValues, state::kVanguardRewardValueRow, 2), + "seed claim fixture"); +} + +/** Covers claim publication, exact debit, refusal, class filtering and atomic persistence. */ +void verify_claims() { + state::PendingItemAcquisition grant{}; + reset_claim(); + check(state::prepare_vendor_reward(kVendor, kClaimInteraction, 0, 0, grant) + == Disposition::prepared, + "prepare claim"); + auto duplicate = grant; + state::AccountState after{}; + state::unlocks::Table banks{}; + check(state::preview_item_acquisition(grant, after, banks) + && after.characters[0].inventory.count == 1 + && banks.characterObjectValues[state::kVanguardRewardValueRow] == 1 + && credits(state::kVanguardRewardValueRow) == 2 && store::account().characters[0].inventory.count == 0, - "partial multi-rank grant is not persisted"); + "preview publishes item and decremented credit without saving"); + check(state::commit_item_acquisition(grant) && credits(state::kVanguardRewardValueRow) == 1 + && store::account().characters[0].inventory.count == 1 + && store::account().characters[0].inventory.values[0].definitionHash + == state::vendor_rewards::kVanguardWeapons.front(), + "one item and one credit commit together"); + check(!state::commit_item_acquisition(duplicate) && !state::commit_item_acquisition(grant), + "duplicate and reused claims refused"); + check(store::read_unlocks(banks, 1) + && banks.characterObjectValues[state::kVanguardRewardValueRow] == 0 + && store::account().characters[1].inventory.count == 0, + "other character unchanged"); + reset_claim(); + { + store::Transaction outer; + check(outer.ready() + && state::prepare_vendor_reward(kVendor, kClaimInteraction, 0, 0, grant) + == Disposition::prepared + && state::commit_item_acquisition(grant), + "claim inside outer response"); + } + check(credits(state::kVanguardRewardValueRow) == 2 + && store::account().characters[0].inventory.count == 0, + "response failure rolls back item and credit"); + check(state::prepare_vendor_reward(kVendor, kClaimInteraction, 0, 0, grant) + == Disposition::prepared + && store::execute("CREATE TEMP TRIGGER reject_claim BEFORE INSERT ON unlocks " + "BEGIN SELECT RAISE(ABORT,'fixture'); END"), + "inject claim write failure"); + check(!state::commit_item_acquisition(grant) && credits(state::kVanguardRewardValueRow) == 2 + && store::account().characters[0].inventory.count == 0, + "credit write failure rolls back item"); + check(store::execute("DROP TRIGGER reject_claim"), "remove claim fault"); + check(state::prepare_vendor_reward(kVendor, kClaimInteraction, 1, 0, grant) + == Disposition::refused, + "wrong reply refused"); + check(state::prepare_vendor_reward(kVendor, kClaimInteraction + 1, 0, 0, grant) + == Disposition::notApplicable, + "other interactions left alone"); + check(state::is_vendor_reward_category(kVendor, kClaimCategory) + && !state::is_vendor_reward_category(kVendor, kClaimCategory + 1), + "reward category blocks sale bypass only"); + g_vendorHash = kBanshee; + check(state::prepare_vendor_reward(kVendor, kBansheeClaimInteraction, 0, 0, grant) + == Disposition::refused, + "unsupported faction payout refused"); + + reset_claim(); + g_rewardCapacity = 0; + check(state::prepare_vendor_reward(kVendor, kClaimInteraction, 0, 0, grant) + == Disposition::refused + && credits(state::kVanguardRewardValueRow) == 2, + "full inventory preserves credit"); g_rewardCapacity = kEngramCapacity; - check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared - && state::commit_vendor_reputation(pending), - "commit two earned ranks"); - const auto& inventory = store::account().characters[0].inventory; - check(inventory.count == 2 - && inventory.values[0].instanceSoid != inventory.values[1].instanceSoid, - "one unique item per crossed threshold"); + g_rewardAvailable = false; + check(state::prepare_vendor_reward(kVendor, kClaimInteraction, 0, 0, grant) + == Disposition::refused, + "missing gear refuses payout"); + reset_claim(); + check(state::prepare_vendor_reward(kVendor, kClaimInteraction, 0, 0, grant) + == Disposition::prepared + && store::write_unlock( + store::Bank::characterObjectValues, state::kVanguardRewardValueRow, 1) + && !state::commit_item_acquisition(grant), + "stale credit refuses claim"); + check(store::write_unlock(store::Bank::characterObjectValues, state::kVanguardRewardValueRow, 0) + && state::prepare_vendor_reward(kVendor, kClaimInteraction, 0, 0, grant) + == Disposition::refused, + "no credit refuses claim"); + reset_claim(); + check(state::prepare_vendor_reward(kVendor, kClaimInteraction, 0, 0, grant) + == Disposition::prepared, + "prepare before gate change"); + check(store::write_family5({}) && !state::commit_item_acquisition(grant) + && credits(state::kVanguardRewardValueRow) == 2, + "changed or absent package gates cannot consume credit"); + check(state::prepare_vendor_reward(kVendor, kClaimInteraction, 0, 0, grant) + == Disposition::refused, + "missing evaluated gates refused"); + + reset_claim(); + state::Family5State gates{}; + check(store::read_family5(gates), "read fixture gates"); + gates.values[0].value = kMinimumLevel - 1; + check(store::write_family5(gates) + && state::prepare_vendor_reward(kVendor, kClaimInteraction, 0, 0, grant) + == Disposition::refused, + "low-level claim refused"); + reset_claim(); + check(state::prepare_vendor_reward(kVendor, kClaimInteraction, 0, 0, grant) + == Disposition::prepared, + "prepare before capacity loss"); + g_rewardCapacity = 0; + check(!state::commit_item_acquisition(grant) && credits(state::kVanguardRewardValueRow) == 2, + "capacity lost after prepare preserves credit"); + reset_claim(); + check(state::prepare_vendor_reward(kVendor, kClaimInteraction, 0, 0, grant) + == Disposition::prepared, + "prepare before character change"); + auto changed = store::account(); + changed.characters[0].selected = false; + changed.characters[1].selected = true; + check(store::write_account(changed) && !state::commit_item_acquisition(grant), + "changed selected character refuses claim"); + + reset_claim(); + auto existing = store::account(); + auto& inventory = existing.characters[0].inventory; + inventory.count = 1; + inventory.values[0].definitionHash = kVanguardEngram; + inventory.values[0].instanceSoid = kAccount + kCharacter; + inventory.values[0].quantity = 1; + const auto savedSoid = inventory.values[0].instanceSoid; + check(store::write_account(existing) + && state::prepare_vendor_reward(kVendor, kClaimInteraction, 0, 0, grant) + == Disposition::prepared + && state::commit_item_acquisition(grant), + "claim alongside a previously saved experimental engram"); + const auto saved = store::account(); + check(saved.characters[0].inventory.count == 2 + && saved.characters[0].inventory.values[0].definitionHash == kVanguardEngram + && saved.characters[0].inventory.values[0].instanceSoid == savedSoid, + "existing experimental item preserved without conversion"); + + for (auto characterClass : {state::CharacterClass::titan, + state::CharacterClass::hunter, + state::CharacterClass::warlock}) { + reset_claim(); + auto account = store::account(); + account.characters[0].characterClass = characterClass; + check(store::write_account(account), "set fixture class"); + const auto armour = state::vendor_rewards::armour(characterClass); + for (std::size_t slot = 0; slot < armour.size(); ++slot) { + const auto roll = + static_cast(state::vendor_rewards::kVanguardWeapons.size() + slot); + check(state::prepare_vendor_reward(kVendor, kClaimInteraction, 0, roll, grant) + == Disposition::prepared + && grant.acquiredDefinitionHash == armour[slot], + "armour selection matches class"); + } + } } + } // namespace +namespace sunrise::core::log { +/** Logging is not an assertion; state checks use the real store and transaction helpers. */ +void write(Channel, Level, std::string_view) noexcept {} +} // namespace sunrise::core::log + namespace sunrise::state::build_data::vendors { /** Catalogue doubles expose one controlled vendor and sale, not runtime inventory. */ bool find_index(std::uint16_t index, IndexEntry& entry) noexcept { @@ -393,6 +587,13 @@ bool sale_row(const Definition&, std::size_t row, SaleRow& output) noexcept { output.itemIndex = kSoldIndex; output.costItemIndex = kCostIndex; output.costQuantity = g_cost; + if (row == kPackageSale) { + output.itemIndex = kRewardIndex; + output.categoryIndex = kClaimCategory; + output.costQuantity = 0; + output.costItemIndex = kAbsentCostItem; + return true; + } return row == kSale; } } // namespace sunrise::state::build_data::vendors @@ -413,6 +614,14 @@ bool find_item_definition_index(std::uint16_t definitionIndex, definition.definitionIndex = definitionIndex; definition.definitionHash = definitionIndex == kSoldIndex ? g_soldHash : g_costHash; definition.bucketId = kBucket; + if (definitionIndex == kRewardIndex) { + definition.definitionHash = kPackageHash; + return true; + } + if (definitionIndex >= kGearIndexBase) { + definition.definitionHash = gear_hash(definitionIndex - kGearIndexBase); + return g_rewardAvailable && definition.definitionHash != 0; + } return definitionIndex == kSoldIndex || definitionIndex == kCostIndex; } bool find_item_definition_hash(std::uint32_t definitionHash, @@ -424,6 +633,12 @@ bool find_item_definition_hash(std::uint32_t definitionHash, definition.definitionIndex = kRewardIndex; return g_rewardAvailable; } + for (std::size_t index = 0; gear_hash(index) != 0; ++index) { + if (definitionHash == gear_hash(index)) { + return find_item_definition_index(static_cast(kGearIndexBase + index), + definition); + } + } return definitionHash == g_costHash && find_item_definition_index(kCostIndex, definition); } bool find_configured_item_detail(std::uint16_t definitionIndex, @@ -433,6 +648,12 @@ bool find_configured_item_detail(std::uint16_t definitionIndex, definition.definitionHash = g_costHash; definition.bucketId = kBucket; definition.instancedDefinitionState = items::details::InstancedDefinitionState::stackable; + if (definitionIndex >= kGearIndexBase) { + definition.definitionHash = gear_hash(definitionIndex - kGearIndexBase); + definition.equipmentSlot = 0; + definition.instancedDefinitionState = items::details::InstancedDefinitionState::instanced; + return g_rewardAvailable && definition.definitionHash != 0; + } return g_materialAvailable && definitionIndex == kCostIndex; } bool find_inventory_bucket_descriptor(std::uint8_t bucketId, @@ -472,13 +693,6 @@ AccountState account_snapshot() noexcept { } } // namespace sunrise::state -namespace sunrise::state::runtime::detail { -/** Unused acquisition commit paths must not succeed through a fixture equality shortcut. */ -bool same_character(const CharacterState&, const CharacterState&) noexcept { - return false; -} -} // namespace sunrise::state::runtime::detail - namespace sunrise::middleware::datagen::family4::loadout { /** * Models bucket capacity without installed game content; acquisition and persistence are real. @@ -521,6 +735,7 @@ int main(int argc, char** argv) { "open disposable store"); verify(); verify_rank_rewards(); + verify_claims(); store::shutdown(); check(store::open(argv[2], read_text(root + "/investment_schema.sql"), @@ -540,10 +755,24 @@ int main(int argc, char** argv) { check(store::open(argv[2], {}, {}, {}, {}), "reopen saved store without seeding"); check(gunsmith()[0] == kGunsmithRankCost && store::account().profileItems[0].quantity == kInitialQuantity - kCost - && store::account().characters[0].inventory.count == 1 - && store::account().characters[0].inventory.values[0].definitionHash - == kGunsmithEngram, - "material debit, XP and reward survive reopening database"); + && store::account().characters[0].inventory.count == 0, + "material debit and XP survive reopening database"); + state::unlocks::Table reopened{}; + check(store::read_unlocks(reopened, 0) + && reopened.characterObjectValues[kGunsmithCreditRow] == 1, + "claim credit survives reopening database"); + reset_claim(); + state::PendingItemAcquisition claim{}; + check(state::prepare_vendor_reward(kVendor, kClaimInteraction, 0, 0, claim) + == Disposition::prepared + && state::commit_item_acquisition(claim), + "commit persistent claim"); + store::shutdown(); + check(store::open(argv[2], {}, {}, {}, {}), "reopen claimed save"); + check(store::read_unlocks(reopened, 0) + && reopened.characterObjectValues[state::kVanguardRewardValueRow] == 1 + && store::account().characters[0].inventory.count == 1, + "claim debit and item persist together"); store::shutdown(); std::puts("PASS: reputation transaction, exact debit/credit, refusal, staleness and rollback"); } diff --git a/tests/vendor_reputation.ps1 b/tests/vendor_reputation.ps1 index 8a7693d2a..d84bf1e68 100644 --- a/tests/vendor_reputation.ps1 +++ b/tests/vendor_reputation.ps1 @@ -13,6 +13,7 @@ $repSources = @( 'Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp', 'Sunrise/src/state/runtime/state_account_profile_runtime.cpp', 'Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp', + 'Sunrise/src/state/runtime/state_account_equipment_runtime.cpp', 'Sunrise/src/state/runtime/state_account_identity_runtime.cpp', 'Sunrise/src/state/account/account_state.cpp', 'Sunrise/src/state/account/inventory/inventory_state.cpp', From f2f675a3d166c815da766e465692c83e9f40e9a6 Mon Sep 17 00:00:00 2001 From: Gage Fulwood Date: Thu, 17 Sep 2026 02:15:32 -0500 Subject: [PATCH 04/59] Settle faction reward sales through shared credit transactions --- .../web_service_vendor_actions.cpp | 24 ++- .../state_account_acquisition_runtime.cpp | 4 +- .../state_vendor_reputation_runtime.cpp | 147 +++++++++++------- .../runtime/state_vendor_reputation_runtime.h | 8 + .../src/state/runtime/vendor_reward_pool.h | 41 +++-- tests/vendor_reputation.cpp | 135 +++++++++++++--- 6 files changed, 269 insertions(+), 90 deletions(-) diff --git a/Sunrise/src/server/web_service/web_service_vendor_actions.cpp b/Sunrise/src/server/web_service/web_service_vendor_actions.cpp index 70acd318d..649e4921f 100644 --- a/Sunrise/src/server/web_service/web_service_vendor_actions.cpp +++ b/Sunrise/src/server/web_service/web_service_vendor_actions.cpp @@ -645,12 +645,30 @@ void settle_vendor_row(const middleware::web_service::Message& message, std::int32_t categoryIndex, std::uint16_t itemDefinitionIndex, Outcome& outcome) noexcept { - // Rank rewards are claimed by interaction; a sale request must not bypass its credit debit. + // A recognized reward sale owns its credit debit and must never fall through to a free grant. if (vendorIndex >= 0 && vendorIndex <= (std::numeric_limits::max)() && state::is_vendor_reward_category(static_cast(vendorIndex), categoryIndex)) { - report_purchase( - opcode, "fail", "reward_requires_reply", vendorIndex, rowIndex, itemDefinitionIndex); + auto* reward = emplace_mutation(outcome); + std::array randomBytes{}; + const bool prepared = + reward != nullptr && rowIndex >= 0 + && rowIndex <= (std::numeric_limits::max)() + && middleware::crypto::random::fill(randomBytes) + && state::prepare_vendor_reward_sale(static_cast(vendorIndex), + static_cast(rowIndex), + middleware::encoding::read_u32_le(randomBytes), + *reward) + == state::VendorReputationDisposition::prepared; + if (!prepared) { + clear_mutation(outcome); + } + report_purchase(opcode, + prepared ? "ok" : "fail", + "rank_reward", + vendorIndex, + rowIndex, + itemDefinitionIndex); return; } if (vendorIndex >= 0 && rowIndex >= 0 diff --git a/Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp b/Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp index c6bdce644..c2b89de52 100644 --- a/Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp +++ b/Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp @@ -556,7 +556,7 @@ bool preview_item_acquisition(const PendingItemAcquisition& mutation, afterUnlocks.characterObjectValues[quest.row] = value; } if (mutation.vendorReward.beforeCredits > 0) { - afterUnlocks.characterObjectValues[kVanguardRewardValueRow] = + afterUnlocks.characterObjectValues[mutation.vendorReward.rewardValueRow] = mutation.vendorReward.beforeCredits - 1; } return true; @@ -592,7 +592,7 @@ bool commit_item_acquisition(PendingItemAcquisition& mutation) noexcept { } if (prepared.vendorReward.beforeCredits > 0 && !investment::store::write_unlock(investment::store::Bank::characterObjectValues, - kVanguardRewardValueRow, + prepared.vendorReward.rewardValueRow, prepared.vendorReward.beforeCredits - 1)) { return false; } diff --git a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp index ccad43459..87bf411e3 100644 --- a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp +++ b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp @@ -50,58 +50,74 @@ constexpr std::array kReputationRules{{ /** Native progression level walks read experience from lane zero. */ constexpr std::size_t kExperienceLane = 0; -/** Build-86657 vendor hashes, normal claim interactions and reward categories. */ +/** Checked build-86657 claim bindings; a missing pool leaves payout unsupported. */ struct RewardRule { std::uint32_t vendorHash; std::uint16_t interaction; std::int32_t category; + std::uint16_t rewardValueRow; + std::uint16_t saleIndex{}; + std::uint32_t packageHash{}; + const vendor_rewards::Pool* pool{}; }; -/** Only these build-86657 normal claim interactions own the mapped reward categories. */ +/** Vendor, interaction, category, saved counter, package sale and hash from build 86657. */ constexpr std::array kRewardRules{{ - {69482069U, 40, 3}, // Zavala: Accept Reward. - {3603221665U, 28, 10}, // Shaxx: Accept Reward; payout not yet supported. - {672118013U, 35, 8}, // Banshee: Accept Reward; payout not yet supported. + // Zavala's current package previews Vanguard gear. + {69482069U, 40, 3, kVanguardRewardValueRow, 93, 2746484552U, &vendor_rewards::kVanguardPool}, + // Shaxx's current package previews Crucible gear. + {3603221665U, 28, 10, kCrucibleRewardValueRow, 96, 3289621657U, &vendor_rewards::kCruciblePool}, + // Banshee's current package has no supported payout binding. + {672118013U, 35, 8, kGunsmithRewardValueRow}, }}; -/** Only Zavala's current package has a supported payout binding. */ -constexpr std::uint32_t kZavalaHash = 69482069U; -/** Build-86657 current Vanguard package occupies sale row 93. */ -constexpr std::uint16_t kVanguardPackageSale = 93; -/** The package opens on acquisition and previews vendor 1016620613. */ -constexpr std::uint32_t kVanguardPackageHash = 2746484552U; -/** Reply zero completes Zavala's normal reward interaction. */ +/** Reply zero completes the supported normal reward interactions. */ constexpr std::uint16_t kAcceptRewardReply = 0; -/** FLAG[5901] selects the supported Vanguard package instead of the older one. */ -constexpr std::uint16_t kVanguardPackageFlag = 5901; +/** FLAG[5901] selects both supported current packages instead of their older variants. */ +constexpr std::uint16_t kCurrentPackageFlag = 5901; /** The normal reward interaction requires VALUE[465] >= 20. */ constexpr std::uint16_t kRewardLevelSlot = 465; /** The normal reward interaction excludes levels below this content threshold. */ constexpr std::int32_t kMinimumRewardLevel = 20; /** - * Accept only the supported package and explicit evaluated gates; do not guess missing values. + * Resolves vendor identity independently of request selectors. * @param vendorIndex Installed vendor selector. - * @return True when the saved evaluated gates and installed package binding agree. + * @return Checked binding, including unsupported payouts, or null for unrelated vendors. + */ +const RewardRule* reward_rule(std::uint16_t vendorIndex) noexcept { + build_data::vendors::IndexEntry entry{}; + if (!build_data::vendors::find_index(vendorIndex, entry)) { + return nullptr; + } + const auto found = + std::find_if(kRewardRules.begin(), kRewardRules.end(), [&](const auto& rule) { + return rule.vendorHash == entry.definitionHash; + }); + return found == kRewardRules.end() ? nullptr : &*found; +} + +/** + * Accept only the supported package and explicit evaluated gates; do not guess missing values. + * @param rule Checked vendor binding. + * @param saleIndex Requested sale, retained for commit revalidation. + * @return True when saved gates and the installed package binding agree. */ -bool reward_binding_current(std::uint16_t vendorIndex) noexcept { +bool reward_binding_current(const RewardRule& rule, std::uint16_t saleIndex) noexcept { namespace vendors = build_data::vendors; - vendors::IndexEntry entry{}; vendors::Definition vendor{}; vendors::SaleRow sale{}; build_data::items::Definition package{}; Family5State family{}; - if (!vendors::find_index(vendorIndex, entry) || entry.definitionHash != kZavalaHash - || !vendors::find(entry.definitionHash, vendor) - || !vendors::sale_row(vendor, kVanguardPackageSale, sale) - || sale.categoryIndex != kRewardRules.front().category || sale.costQuantity != 0 + if (rule.pool == nullptr || saleIndex != rule.saleIndex + || !vendors::find(rule.vendorHash, vendor) || !vendors::sale_row(vendor, saleIndex, sale) + || sale.categoryIndex != rule.category || sale.costQuantity != 0 || !build_data::find_item_definition_index(sale.itemIndex, package) - || package.definitionHash != kVanguardPackageHash - || !investment::store::read_family5(family)) { + || package.definitionHash != rule.packageHash || !investment::store::read_family5(family)) { return false; } bool packageEnabled = false, levelMet = false; for (std::size_t index = 0; index < family.flagCount; ++index) { const auto& flag = family.flags[index]; - if (flag.slot == kVanguardPackageFlag) { + if (flag.slot == kCurrentPackageFlag) { packageEnabled = flag.value == unlocks::kFlagSet; } } @@ -364,13 +380,13 @@ bool is_vendor_reward_category(std::uint16_t vendorIndex, std::int32_t categoryI } /** - * Reconstructs one gear claim without publishing inventory or consuming its credit yet. + * Resolves a supported rowless reply through the same settlement as its package sale. * @param vendorIndex Installed vendor selector. * @param interactionIndex Rowless interaction selector. * @param replyIndex Reply within that interaction. - * @param random Server-generated selection value; never supplied by the client. + * @param random Server-generated selection value. * @param mutation Receives the prepared grant; cleared on refusal. - * @return Recognized but unsupported or unaffordable claims are refused, never generic grants. + * @return Recognized but unsupported replies refuse rather than falling through to a free grant. */ VendorReputationDisposition prepare_vendor_reward(std::uint16_t vendorIndex, std::uint16_t interactionIndex, @@ -378,26 +394,49 @@ VendorReputationDisposition prepare_vendor_reward(std::uint16_t vendorIndex, std::uint32_t random, PendingItemAcquisition& mutation) noexcept { mutation = {}; - build_data::vendors::IndexEntry entry{}; - if (!build_data::vendors::find_index(vendorIndex, entry)) { + const auto* rule = reward_rule(vendorIndex); + if (rule == nullptr || rule->interaction != interactionIndex) { return VendorReputationDisposition::notApplicable; } - const auto rule = - std::find_if(kRewardRules.begin(), kRewardRules.end(), [&](const auto& value) { - return value.vendorHash == entry.definitionHash - && value.interaction == interactionIndex; - }); - if (rule == kRewardRules.end()) { + if (replyIndex != kAcceptRewardReply || rule->pool == nullptr) { + return VendorReputationDisposition::refused; + } + return prepare_vendor_reward_sale(vendorIndex, rule->saleIndex, random, mutation); +} + +/** + * A reward sale consumes its own faction credit only together with the granted gear. + * @param vendorIndex Installed vendor selector. + * @param saleIndex Requested package sale. + * @param random Server-generated selection value; never supplied by the client. + * @param mutation Receives the prepared grant; cleared on refusal. + * @return Unrelated sales are not applicable; unsupported or unaffordable rewards are refused. + */ +VendorReputationDisposition prepare_vendor_reward_sale(std::uint16_t vendorIndex, + std::uint16_t saleIndex, + std::uint32_t random, + PendingItemAcquisition& mutation) noexcept { + mutation = {}; + const auto* rule = reward_rule(vendorIndex); + build_data::vendors::Definition vendor{}; + build_data::vendors::SaleRow sale{}; + if (rule == nullptr) { + return VendorReputationDisposition::notApplicable; + } + if (!build_data::vendors::find(rule->vendorHash, vendor) + || !build_data::vendors::sale_row(vendor, saleIndex, sale)) { + return VendorReputationDisposition::refused; + } + if (sale.categoryIndex != rule->category) { return VendorReputationDisposition::notApplicable; } const std::lock_guard lock(investment::store::g_mutex); AccountState account{}; std::int32_t credits = 0; - if (replyIndex != kAcceptRewardReply || !reward_binding_current(vendorIndex) - || !investment::store::read_account(account) || !account::valid(account) - || !runtime::detail::valid_profile_inventory(account) + if (!reward_binding_current(*rule, saleIndex) || !investment::store::read_account(account) + || !account::valid(account) || !runtime::detail::valid_profile_inventory(account) || !investment::store::read_unlock( - investment::store::Bank::characterObjectValues, kVanguardRewardValueRow, credits) + investment::store::Bank::characterObjectValues, rule->rewardValueRow, credits) || credits <= 0) { return VendorReputationDisposition::refused; } @@ -405,13 +444,14 @@ VendorReputationDisposition prepare_vendor_reward(std::uint16_t vendorIndex, if (selected >= account.characterCount) { return VendorReputationDisposition::refused; } - const auto armour = vendor_rewards::armour(account.characters[selected].characterClass); - std::array - candidates{}; + const auto armour = + vendor_rewards::armour(*rule->pool, account.characters[selected].characterClass); + std::array candidates{}; + if (rule->pool->weapons.size() + armour.size() > candidates.size()) { + return VendorReputationDisposition::refused; + } std::size_t count = 0; - for (const auto pool : - {std::span(vendor_rewards::kVanguardWeapons), armour}) { + for (const auto pool : {rule->pool->weapons, armour}) { for (const auto hash : pool) { if (reward_item_supported(hash)) { candidates[count++] = hash; @@ -425,7 +465,7 @@ VendorReputationDisposition prepare_vendor_reward(std::uint16_t vendorIndex, mutation = {}; return VendorReputationDisposition::refused; } - mutation.vendorReward = {credits, vendorIndex}; + mutation.vendorReward = {credits, vendorIndex, saleIndex, rule->rewardValueRow}; return VendorReputationDisposition::prepared; } @@ -440,16 +480,19 @@ bool vendor_reward_current(const PendingItemAcquisition& mutation) noexcept { return true; } std::int32_t current = 0; - if (claim.beforeCredits < 0 || !mutation.directGrant - || !reward_binding_current(claim.vendorIndex) + const auto* rule = reward_rule(claim.vendorIndex); + if (claim.beforeCredits < 0 || !mutation.directGrant || rule == nullptr + || claim.rewardValueRow != rule->rewardValueRow + || !reward_binding_current(*rule, claim.saleIndex) || !investment::store::read_unlock( - investment::store::Bank::characterObjectValues, kVanguardRewardValueRow, current) + investment::store::Bank::characterObjectValues, claim.rewardValueRow, current) || current != claim.beforeCredits || !reward_item_supported(mutation.acquiredDefinitionHash)) { return false; } - const auto armour = vendor_rewards::armour(mutation.beforeCharacter.characterClass); - const auto& weapons = vendor_rewards::kVanguardWeapons; + const auto armour = + vendor_rewards::armour(*rule->pool, mutation.beforeCharacter.characterClass); + const auto weapons = rule->pool->weapons; return std::find(weapons.begin(), weapons.end(), mutation.acquiredDefinitionHash) != weapons.end() || std::find(armour.begin(), armour.end(), mutation.acquiredDefinitionHash) diff --git a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h index df555c7dc..7221eea1a 100644 --- a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h +++ b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h @@ -48,10 +48,18 @@ inline constexpr std::uint16_t kVanguardRewardValueRow = 55; struct VendorRewardClaim { std::int32_t beforeCredits{}; std::uint16_t vendorIndex{}; + std::uint16_t saleIndex{}; + /** Rechecked against the vendor binding before preview or commit. */ + std::uint16_t rewardValueRow{}; }; struct PendingItemAcquisition; [[nodiscard]] VendorReputationDisposition +prepare_vendor_reward_sale(std::uint16_t vendorIndex, + std::uint16_t saleIndex, + std::uint32_t random, + PendingItemAcquisition& mutation) noexcept; +[[nodiscard]] VendorReputationDisposition prepare_vendor_reward(std::uint16_t vendorIndex, std::uint16_t interactionIndex, std::uint16_t replyIndex, diff --git a/Sunrise/src/state/runtime/vendor_reward_pool.h b/Sunrise/src/state/runtime/vendor_reward_pool.h index b86438cc3..3ca3e982a 100644 --- a/Sunrise/src/state/runtime/vendor_reward_pool.h +++ b/Sunrise/src/state/runtime/vendor_reward_pool.h @@ -52,22 +52,41 @@ inline constexpr std::array kVanguardHunter{ inline constexpr std::array kVanguardWarlock{ 1578461326U, 1108278178U, 332170995U, 3631862279U, 4086100104U}; +/** Preview vendor 1894103790 lists Titan-only Wing Discipline armour in this order. */ +inline constexpr std::array kCrucibleTitan{ + 657606375U, 2899275886U, 2389585538U, 687386728U, 1613581523U}; +/** The same preview lists Hunter-only Wing Contender armour in this order. */ +inline constexpr std::array kCrucibleHunter{ + 3153956825U, 3091776080U, 1914589560U, 3408834730U, 693067797U}; +/** The same preview lists Warlock-only Wing Theorem armour in this order. */ +inline constexpr std::array kCrucibleWarlock{ + 3684978064U, 3441081953U, 2286507447U, 641063251U, 119859462U}; + +/** Checked package candidates; class ordinals are Titan, Hunter and Warlock. */ +struct Pool { + std::span weapons; + std::array, 3> classArmour; +}; +/** Package 2746484552 uses preview 1016620613. */ +inline constexpr Pool kVanguardPool{kVanguardWeapons, + {kVanguardTitan, kVanguardHunter, kVanguardWarlock}}; +/** Package 3289621657 uses preview 1894103790, with the same weapons but its own armour. */ +inline constexpr Pool kCruciblePool{kVanguardWeapons, + {kCrucibleTitan, kCrucibleHunter, kCrucibleWarlock}}; +/** Supported previews contain at most thirty weapons and five eligible armour pieces. */ +inline constexpr std::size_t kCandidateCapacity = 35; + /** * Class-specific armour must never enter another class's reward pool. + * @param pool Checked package preview candidates. * @param characterClass Selected character's class. * @return Matching armour hashes, or an empty span for an invalid class. */ -inline std::span armour(CharacterClass characterClass) noexcept { - switch (characterClass) { - case CharacterClass::titan: - return kVanguardTitan; - case CharacterClass::hunter: - return kVanguardHunter; - case CharacterClass::warlock: - return kVanguardWarlock; - default: - return {}; - } +inline std::span armour(const Pool& pool, + CharacterClass characterClass) noexcept { + const auto index = static_cast(characterClass); + return index < pool.classArmour.size() ? pool.classArmour[index] + : std::span{}; } } // namespace sunrise::state::vendor_rewards diff --git a/tests/vendor_reputation.cpp b/tests/vendor_reputation.cpp index 5b509b76f..dae3b2fe7 100644 --- a/tests/vendor_reputation.cpp +++ b/tests/vendor_reputation.cpp @@ -52,6 +52,11 @@ constexpr std::uint32_t kPackageHash = 2746484552U; constexpr std::uint16_t kPackageSale = 93, kClaimInteraction = 40, kClaimCategory = 3, kPackageFlag = 5901, kLevelSlot = 465; constexpr std::int32_t kMinimumLevel = 20; +/** Build-86657 Shaxx package sale, claim category and character-object counter. */ +constexpr std::uint32_t kShaxxHash = 3603221665U, kCruciblePackageHash = 3289621657U; +constexpr std::uint16_t kCruciblePackageSale = 96, kCrucibleCategory = 10, kCrucibleCreditRow = 45; +std::uint32_t g_packageHash = kPackageHash, g_packageCost = 0; +std::uint16_t g_packageSale = kPackageSale, g_packageCategory = kClaimCategory; /** Build-86657 Gunsmith reward counter and normal claim interaction. */ constexpr std::uint16_t kGunsmithCreditRow = 49, kBansheeClaimInteraction = 35; @@ -61,7 +66,10 @@ std::uint32_t gear_hash(std::size_t index) { {std::span(state::vendor_rewards::kVanguardWeapons), std::span(state::vendor_rewards::kVanguardTitan), std::span(state::vendor_rewards::kVanguardHunter), - std::span(state::vendor_rewards::kVanguardWarlock)}) { + std::span(state::vendor_rewards::kVanguardWarlock), + std::span(state::vendor_rewards::kCrucibleTitan), + std::span(state::vendor_rewards::kCrucibleHunter), + std::span(state::vendor_rewards::kCrucibleWarlock)}) { if (index < pool.size()) { return pool[index]; } @@ -105,6 +113,10 @@ std::string read_text(const std::string& path) { /** Resets only the disposable fixture and its controlled content catalogue. */ void reset() { + g_packageHash = kPackageHash; + g_packageSale = kPackageSale; + g_packageCategory = kClaimCategory; + g_packageCost = 0; g_vendorHash = kBanshee; g_soldHash = kGunsmithRewards; g_costHash = kGunsmithMaterials; @@ -410,7 +422,7 @@ void reset_claim() { void verify_claims() { state::PendingItemAcquisition grant{}; reset_claim(); - check(state::prepare_vendor_reward(kVendor, kClaimInteraction, 0, 0, grant) + check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, 0, grant) == Disposition::prepared, "prepare claim"); auto duplicate = grant; @@ -437,7 +449,7 @@ void verify_claims() { { store::Transaction outer; check(outer.ready() - && state::prepare_vendor_reward(kVendor, kClaimInteraction, 0, 0, grant) + && state::prepare_vendor_reward_sale(kVendor, kPackageSale, 0, grant) == Disposition::prepared && state::commit_item_acquisition(grant), "claim inside outer response"); @@ -445,7 +457,7 @@ void verify_claims() { check(credits(state::kVanguardRewardValueRow) == 2 && store::account().characters[0].inventory.count == 0, "response failure rolls back item and credit"); - check(state::prepare_vendor_reward(kVendor, kClaimInteraction, 0, 0, grant) + check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, 0, grant) == Disposition::prepared && store::execute("CREATE TEMP TRIGGER reject_claim BEFORE INSERT ON unlocks " "BEGIN SELECT RAISE(ABORT,'fixture'); END"), @@ -470,34 +482,33 @@ void verify_claims() { reset_claim(); g_rewardCapacity = 0; - check(state::prepare_vendor_reward(kVendor, kClaimInteraction, 0, 0, grant) - == Disposition::refused + check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, 0, grant) == Disposition::refused && credits(state::kVanguardRewardValueRow) == 2, "full inventory preserves credit"); g_rewardCapacity = kEngramCapacity; g_rewardAvailable = false; - check(state::prepare_vendor_reward(kVendor, kClaimInteraction, 0, 0, grant) + check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, 0, grant) == Disposition::refused, "missing gear refuses payout"); reset_claim(); - check(state::prepare_vendor_reward(kVendor, kClaimInteraction, 0, 0, grant) + check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, 0, grant) == Disposition::prepared && store::write_unlock( store::Bank::characterObjectValues, state::kVanguardRewardValueRow, 1) && !state::commit_item_acquisition(grant), "stale credit refuses claim"); check(store::write_unlock(store::Bank::characterObjectValues, state::kVanguardRewardValueRow, 0) - && state::prepare_vendor_reward(kVendor, kClaimInteraction, 0, 0, grant) + && state::prepare_vendor_reward_sale(kVendor, kPackageSale, 0, grant) == Disposition::refused, "no credit refuses claim"); reset_claim(); - check(state::prepare_vendor_reward(kVendor, kClaimInteraction, 0, 0, grant) + check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, 0, grant) == Disposition::prepared, "prepare before gate change"); check(store::write_family5({}) && !state::commit_item_acquisition(grant) && credits(state::kVanguardRewardValueRow) == 2, "changed or absent package gates cannot consume credit"); - check(state::prepare_vendor_reward(kVendor, kClaimInteraction, 0, 0, grant) + check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, 0, grant) == Disposition::refused, "missing evaluated gates refused"); @@ -506,18 +517,18 @@ void verify_claims() { check(store::read_family5(gates), "read fixture gates"); gates.values[0].value = kMinimumLevel - 1; check(store::write_family5(gates) - && state::prepare_vendor_reward(kVendor, kClaimInteraction, 0, 0, grant) + && state::prepare_vendor_reward_sale(kVendor, kPackageSale, 0, grant) == Disposition::refused, "low-level claim refused"); reset_claim(); - check(state::prepare_vendor_reward(kVendor, kClaimInteraction, 0, 0, grant) + check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, 0, grant) == Disposition::prepared, "prepare before capacity loss"); g_rewardCapacity = 0; check(!state::commit_item_acquisition(grant) && credits(state::kVanguardRewardValueRow) == 2, "capacity lost after prepare preserves credit"); reset_claim(); - check(state::prepare_vendor_reward(kVendor, kClaimInteraction, 0, 0, grant) + check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, 0, grant) == Disposition::prepared, "prepare before character change"); auto changed = store::account(); @@ -535,7 +546,7 @@ void verify_claims() { inventory.values[0].quantity = 1; const auto savedSoid = inventory.values[0].instanceSoid; check(store::write_account(existing) - && state::prepare_vendor_reward(kVendor, kClaimInteraction, 0, 0, grant) + && state::prepare_vendor_reward_sale(kVendor, kPackageSale, 0, grant) == Disposition::prepared && state::commit_item_acquisition(grant), "claim alongside a previously saved experimental engram"); @@ -552,11 +563,12 @@ void verify_claims() { auto account = store::account(); account.characters[0].characterClass = characterClass; check(store::write_account(account), "set fixture class"); - const auto armour = state::vendor_rewards::armour(characterClass); + const auto armour = + state::vendor_rewards::armour(state::vendor_rewards::kVanguardPool, characterClass); for (std::size_t slot = 0; slot < armour.size(); ++slot) { const auto roll = static_cast(state::vendor_rewards::kVanguardWeapons.size() + slot); - check(state::prepare_vendor_reward(kVendor, kClaimInteraction, 0, roll, grant) + check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, roll, grant) == Disposition::prepared && grant.acquiredDefinitionHash == armour[slot], "armour selection matches class"); @@ -564,6 +576,84 @@ void verify_claims() { } } +/** Both supported vendors use one sale transaction without sharing entitlement or armour. */ +void verify_shared_sales() { + state::PendingItemAcquisition grant{}; + for (const bool crucible : {false, true}) { + for (const auto characterClass : {state::CharacterClass::titan, + state::CharacterClass::hunter, + state::CharacterClass::warlock}) { + reset_claim(); + if (crucible) { + g_vendorHash = kShaxxHash; + g_packageHash = kCruciblePackageHash; + g_packageSale = kCruciblePackageSale; + g_packageCategory = kCrucibleCategory; + } + const auto row = crucible ? kCrucibleCreditRow : state::kVanguardRewardValueRow; + const auto otherRow = crucible ? state::kVanguardRewardValueRow : kCrucibleCreditRow; + const auto& pool = crucible ? state::vendor_rewards::kCruciblePool + : state::vendor_rewards::kVanguardPool; + auto account = store::account(); + account.characters[0].characterClass = characterClass; + check(store::write_account(account) + && store::write_unlock(store::Bank::characterObjectValues, row, 1) + && store::write_unlock(store::Bank::characterObjectValues, otherRow, 2), + "seed independent fixture faction credits"); + const auto roll = static_cast(pool.weapons.size()); + check(state::prepare_vendor_reward_sale(kVendor, g_packageSale, roll, grant) + == Disposition::prepared + && grant.acquiredDefinitionHash + == state::vendor_rewards::armour(pool, characterClass).front(), + "sale chooses the vendor's own class armour"); + state::AccountState after{}; + state::unlocks::Table banks{}; + check(state::preview_item_acquisition(grant, after, banks) + && banks.characterObjectValues[row] == 0 + && banks.characterObjectValues[otherRow] == 2, + "preview debits only the matching faction"); + check(state::commit_item_acquisition(grant) && credits(row) == 0 + && credits(otherRow) == 2, + "shared commit debits only the matching faction"); + check(state::prepare_vendor_reward_sale(kVendor, g_packageSale, roll, grant) + == Disposition::refused, + "another faction's credit cannot authorize this sale"); + } + } + reset_claim(); + check(state::prepare_vendor_reward(kVendor, kClaimInteraction, 0, 0, grant) + == Disposition::prepared + && state::commit_item_acquisition(grant), + "rowless adapter retains the same guarded settlement"); + check(state::prepare_vendor_reward_sale(kVendor, kSale, 0, grant) == Disposition::notApplicable, + "ordinary sale does not enter reward settlement"); + check(state::prepare_vendor_reward_sale(kVendor, kPackageSale + 1, 0, grant) + == Disposition::refused, + "missing sale cannot claim a package"); + ++g_packageHash; + check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, 0, grant) + == Disposition::refused, + "wrong installed package is refused"); + reset_claim(); + g_packageCost = 1; + check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, 0, grant) + == Disposition::refused, + "nonzero material cost cannot be ignored"); + reset_claim(); + check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, 0, grant) + == Disposition::prepared, + "prepare row tamper check"); + grant.vendorReward.rewardValueRow = kCrucibleCreditRow; + check(!state::commit_item_acquisition(grant) && credits(state::kVanguardRewardValueRow) == 2, + "altered saved counter rejected before indexing or writing"); + check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, 0, grant) + == Disposition::prepared, + "prepare stale package check"); + ++g_packageHash; + check(!state::commit_item_acquisition(grant) && credits(state::kVanguardRewardValueRow) == 2, + "changed package cannot consume credit"); +} + } // namespace namespace sunrise::core::log { @@ -587,10 +677,10 @@ bool sale_row(const Definition&, std::size_t row, SaleRow& output) noexcept { output.itemIndex = kSoldIndex; output.costItemIndex = kCostIndex; output.costQuantity = g_cost; - if (row == kPackageSale) { + if (row == g_packageSale) { output.itemIndex = kRewardIndex; - output.categoryIndex = kClaimCategory; - output.costQuantity = 0; + output.categoryIndex = g_packageCategory; + output.costQuantity = g_packageCost; output.costItemIndex = kAbsentCostItem; return true; } @@ -615,7 +705,7 @@ bool find_item_definition_index(std::uint16_t definitionIndex, definition.definitionHash = definitionIndex == kSoldIndex ? g_soldHash : g_costHash; definition.bucketId = kBucket; if (definitionIndex == kRewardIndex) { - definition.definitionHash = kPackageHash; + definition.definitionHash = g_packageHash; return true; } if (definitionIndex >= kGearIndexBase) { @@ -736,6 +826,7 @@ int main(int argc, char** argv) { verify(); verify_rank_rewards(); verify_claims(); + verify_shared_sales(); store::shutdown(); check(store::open(argv[2], read_text(root + "/investment_schema.sql"), @@ -763,7 +854,7 @@ int main(int argc, char** argv) { "claim credit survives reopening database"); reset_claim(); state::PendingItemAcquisition claim{}; - check(state::prepare_vendor_reward(kVendor, kClaimInteraction, 0, 0, claim) + check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, 0, claim) == Disposition::prepared && state::commit_item_acquisition(claim), "commit persistent claim"); From 727ae4345a4f55618ae0487b48624d60f333bf25 Mon Sep 17 00:00:00 2001 From: Gage Fulwood Date: Thu, 17 Sep 2026 02:48:14 -0500 Subject: [PATCH 05/59] Support Gunsmith weapon rewards through shared faction claims --- .../state_vendor_reputation_runtime.cpp | 25 ++++-- .../src/state/runtime/vendor_reward_pool.h | 2 + tests/vendor_reputation.cpp | 88 ++++++++++++++++++- 3 files changed, 109 insertions(+), 6 deletions(-) diff --git a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp index 87bf411e3..53978c88a 100644 --- a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp +++ b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp @@ -59,6 +59,8 @@ struct RewardRule { std::uint16_t saleIndex{}; std::uint32_t packageHash{}; const vendor_rewards::Pool* pool{}; + /** Only the Vanguard and Crucible sales require FLAG[5901] and VALUE[465]. */ + bool requiresSelectionGates{true}; }; /** Vendor, interaction, category, saved counter, package sale and hash from build 86657. */ constexpr std::array kRewardRules{{ @@ -66,12 +68,19 @@ constexpr std::array kRewardRules{{ {69482069U, 40, 3, kVanguardRewardValueRow, 93, 2746484552U, &vendor_rewards::kVanguardPool}, // Shaxx's current package previews Crucible gear. {3603221665U, 28, 10, kCrucibleRewardValueRow, 96, 3289621657U, &vendor_rewards::kCruciblePool}, - // Banshee's current package has no supported payout binding. - {672118013U, 35, 8, kGunsmithRewardValueRow}, + // Banshee's sale has empty selection gates and uses the shared weapon pool without armour. + {672118013U, + 35, + 8, + kGunsmithRewardValueRow, + 16, + 2422825785U, + &vendor_rewards::kGunsmithPool, + false}, }}; /** Reply zero completes the supported normal reward interactions. */ constexpr std::uint16_t kAcceptRewardReply = 0; -/** FLAG[5901] selects both supported current packages instead of their older variants. */ +/** FLAG[5901] selects the current Vanguard and Crucible packages over their older variants. */ constexpr std::uint16_t kCurrentPackageFlag = 5901; /** The normal reward interaction requires VALUE[465] >= 20. */ constexpr std::uint16_t kRewardLevelSlot = 465; @@ -106,12 +115,18 @@ bool reward_binding_current(const RewardRule& rule, std::uint16_t saleIndex) noe vendors::Definition vendor{}; vendors::SaleRow sale{}; build_data::items::Definition package{}; - Family5State family{}; if (rule.pool == nullptr || saleIndex != rule.saleIndex || !vendors::find(rule.vendorHash, vendor) || !vendors::sale_row(vendor, saleIndex, sale) || sale.categoryIndex != rule.category || sale.costQuantity != 0 || !build_data::find_item_definition_index(sale.itemIndex, package) - || package.definitionHash != rule.packageHash || !investment::store::read_family5(family)) { + || package.definitionHash != rule.packageHash) { + return false; + } + if (!rule.requiresSelectionGates) { + return true; + } + Family5State family{}; + if (!investment::store::read_family5(family)) { return false; } bool packageEnabled = false, levelMet = false; diff --git a/Sunrise/src/state/runtime/vendor_reward_pool.h b/Sunrise/src/state/runtime/vendor_reward_pool.h index 3ca3e982a..5ace9a429 100644 --- a/Sunrise/src/state/runtime/vendor_reward_pool.h +++ b/Sunrise/src/state/runtime/vendor_reward_pool.h @@ -73,6 +73,8 @@ inline constexpr Pool kVanguardPool{kVanguardWeapons, /** Package 3289621657 uses preview 1894103790, with the same weapons but its own armour. */ inline constexpr Pool kCruciblePool{kVanguardWeapons, {kCrucibleTitan, kCrucibleHunter, kCrucibleWarlock}}; +/** Build-86657 package 2422825785 uses reward list 546 -> 528 for these weapons, without armour. */ +inline constexpr Pool kGunsmithPool{kVanguardWeapons, {}}; /** Supported previews contain at most thirty weapons and five eligible armour pieces. */ inline constexpr std::size_t kCandidateCapacity = 35; diff --git a/tests/vendor_reputation.cpp b/tests/vendor_reputation.cpp index dae3b2fe7..814b70470 100644 --- a/tests/vendor_reputation.cpp +++ b/tests/vendor_reputation.cpp @@ -59,6 +59,9 @@ std::uint32_t g_packageHash = kPackageHash, g_packageCost = 0; std::uint16_t g_packageSale = kPackageSale, g_packageCategory = kClaimCategory; /** Build-86657 Gunsmith reward counter and normal claim interaction. */ constexpr std::uint16_t kGunsmithCreditRow = 49, kBansheeClaimInteraction = 35; +/** Build-86657 Banshee's current weapon package sale and reward category. */ +constexpr std::uint32_t kGunsmithPackageHash = 2422825785U; +constexpr std::uint16_t kGunsmithPackageSale = 16, kGunsmithCategory = 8; /** @return Flat fixture pool in weapon, Titan, Hunter, Warlock order. */ std::uint32_t gear_hash(std::size_t index) { @@ -478,7 +481,7 @@ void verify_claims() { g_vendorHash = kBanshee; check(state::prepare_vendor_reward(kVendor, kBansheeClaimInteraction, 0, 0, grant) == Disposition::refused, - "unsupported faction payout refused"); + "missing Gunsmith package binding refused"); reset_claim(); g_rewardCapacity = 0; @@ -654,6 +657,79 @@ void verify_shared_sales() { "changed package cannot consume credit"); } +/** Configures Gunsmith credits without unrelated Vanguard/Crucible selection gates. */ +void reset_gunsmith_claim() { + reset(); + g_packageHash = kGunsmithPackageHash; + g_packageSale = kGunsmithPackageSale; + g_packageCategory = kGunsmithCategory; + check(store::write_family5({}) + && store::write_unlock(store::Bank::characterObjectValues, kGunsmithCreditRow, 1) + && store::write_unlock( + store::Bank::characterObjectValues, state::kVanguardRewardValueRow, 2), + "seed isolated Gunsmith fixture credit"); +} + +/** Covers weapon-only claims, sale isolation and atomic Gunsmith credit consumption. */ +void verify_gunsmith_sales() { + state::PendingItemAcquisition grant{}; + const auto& weapons = state::vendor_rewards::kGunsmithPool.weapons; + for (const auto characterClass : {state::CharacterClass::titan, + state::CharacterClass::hunter, + state::CharacterClass::warlock}) { + reset_gunsmith_claim(); + auto account = store::account(); + account.characters[0].characterClass = characterClass; + check(store::write_account(account), "select fixture class"); + for (std::uint32_t roll = 0; roll <= weapons.size(); ++roll) { + check(state::prepare_vendor_reward_sale(kVendor, kGunsmithPackageSale, roll, grant) + == Disposition::prepared + && grant.acquiredDefinitionHash == weapons[roll % weapons.size()], + "all classes receive only the native Gunsmith weapons"); + } + { + store::Transaction outer; + check(outer.ready() && state::commit_item_acquisition(grant), + "commit Gunsmith claim in response transaction"); + } + check(credits(kGunsmithCreditRow) == 1 + && store::account().characters[0].inventory.count == 0, + "failed response restores Gunsmith item and credit"); + check(state::prepare_vendor_reward_sale(kVendor, kGunsmithPackageSale, 0, grant) + == Disposition::prepared, + "prepare Gunsmith claim without selection gates"); + auto duplicate = grant; + check(state::commit_item_acquisition(grant) && credits(kGunsmithCreditRow) == 0 + && credits(state::kVanguardRewardValueRow) == 2 + && !state::commit_item_acquisition(duplicate) + && state::prepare_vendor_reward_sale(kVendor, kGunsmithPackageSale, 0, grant) + == Disposition::refused, + "claim spends only Gunsmith credit and refuses duplicates or borrowed credit"); + } + reset_gunsmith_claim(); + g_rewardCapacity = 0; + check(state::prepare_vendor_reward_sale(kVendor, kGunsmithPackageSale, 0, grant) + == Disposition::refused + && credits(kGunsmithCreditRow) == 1, + "full inventory preserves Gunsmith credit"); + reset_gunsmith_claim(); + check(state::prepare_vendor_reward_sale(kVendor, kGunsmithPackageSale + 1, 0, grant) + == Disposition::refused, + "another sale cannot use Gunsmith credit"); + ++g_packageCategory; + check(state::prepare_vendor_reward_sale(kVendor, kGunsmithPackageSale, 0, grant) + == Disposition::notApplicable + && credits(kGunsmithCreditRow) == 1, + "same package outside reward category cannot use Gunsmith credit"); + reset_gunsmith_claim(); + check(state::prepare_vendor_reward(kVendor, kBansheeClaimInteraction, 0, 0, grant) + == Disposition::prepared, + "Gunsmith reply uses shared settlement"); + ++g_packageHash; + check(!state::commit_item_acquisition(grant) && credits(kGunsmithCreditRow) == 1, + "changed Gunsmith package preserves credit"); +} + } // namespace namespace sunrise::core::log { @@ -827,6 +903,7 @@ int main(int argc, char** argv) { verify_rank_rewards(); verify_claims(); verify_shared_sales(); + verify_gunsmith_sales(); store::shutdown(); check(store::open(argv[2], read_text(root + "/investment_schema.sql"), @@ -864,6 +941,15 @@ int main(int argc, char** argv) { && reopened.characterObjectValues[state::kVanguardRewardValueRow] == 1 && store::account().characters[0].inventory.count == 1, "claim debit and item persist together"); + reset_gunsmith_claim(); + check(state::prepare_vendor_reward_sale(kVendor, kGunsmithPackageSale, 0, claim) + == Disposition::prepared + && state::commit_item_acquisition(claim), + "commit persistent Gunsmith claim"); + store::shutdown(); + check(store::open(argv[2], {}, {}, {}, {}), "reopen Gunsmith claim"); + check(credits(kGunsmithCreditRow) == 0 && store::account().characters[0].inventory.count == 1, + "Gunsmith item and debit persist together"); store::shutdown(); std::puts("PASS: reputation transaction, exact debit/credit, refusal, staleness and rollback"); } From 8b0d1a1baec0fe74ec593ba9b2f802dbc8c7179c Mon Sep 17 00:00:00 2001 From: Gage Fulwood Date: Tue, 22 Sep 2026 21:32:55 -0500 Subject: [PATCH 06/59] Derive faction rank credits from installed progression steps --- .../state_vendor_reputation_runtime.cpp | 97 +++++++++++++++---- .../runtime/state_vendor_reputation_runtime.h | 9 +- tests/vendor_reputation.cpp | 68 ++++++++++++- 3 files changed, 147 insertions(+), 27 deletions(-) diff --git a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp index 53978c88a..0b68cbb54 100644 --- a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp +++ b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp @@ -3,6 +3,7 @@ #include #include +#include "../build_data/progressions/progression_catalog.h" #include "../build_data/vendors/vendor_catalog.h" #include "../investment/store_internal.h" #include "state_account_transaction_helpers.h" @@ -19,26 +20,24 @@ struct ReputationRule { std::uint16_t progressionIndex; std::int32_t experiencePerUnit; std::uint16_t rewardValueRow{}; - std::int32_t rankCost{}; + bool repeatLastStep{}; }; /** Build-86657 VALUE[888] and VALUE[906] map to these character-object value rows. */ constexpr std::uint16_t kCrucibleRewardValueRow = 45, kGunsmithRewardValueRow = 49; -/** Build-86657 Vanguard and Crucible use 2000-XP steps and repeat the last step. */ -constexpr std::int32_t kTokenRankCost = 2000; -/** Build-86657 Gunsmith uses 3000-XP steps and repeats the last step. */ -constexpr std::int32_t kGunsmithRankCost = 3000; +/** Build-86657 progressions 49, 55 and 62 repeat their final installed step. */ +constexpr bool kRepeatFactionRankStep = true; /** Vendor/item hashes, character progression indices and XP per unit from build 86657. */ constexpr std::array kReputationRules{{ // Banshee: Gunsmith Rewards charges Gunsmith Materials for Gunsmith progression. - {672118013U, 3831705402U, 685157383U, 55, 30, kGunsmithRewardValueRow, kGunsmithRankCost}, + {672118013U, 3831705402U, 685157383U, 55, 30, kGunsmithRewardValueRow, kRepeatFactionRankStep}, // Banshee: the same placeholder also accepts Weapon Telemetry at its own XP rate. - {672118013U, 3831705402U, 685157381U, 55, 25, kGunsmithRewardValueRow, kGunsmithRankCost}, + {672118013U, 3831705402U, 685157381U, 55, 25, kGunsmithRewardValueRow, kRepeatFactionRankStep}, // Zavala: Vanguard Tactician Rewards charges Vanguard Tactician Tokens. - {69482069U, 3987308529U, 3899548068U, 62, 100, kVanguardRewardValueRow, kTokenRankCost}, + {69482069U, 3987308529U, 3899548068U, 62, 100, kVanguardRewardValueRow, kRepeatFactionRankStep}, // Shaxx: Crucible Rewards charges Crucible Tokens, not Valor or Glory points. - {3603221665U, 265113466U, 183980811U, 49, 100, kCrucibleRewardValueRow, kTokenRankCost}, + {3603221665U, 265113466U, 183980811U, 49, 100, kCrucibleRewardValueRow, kRepeatFactionRankStep}, // Devrim: EDZ token turn-ins use a different placeholder from destination materials. {396892126U, 61430328U, 2640973641U, 52, 100}, // Devrim: destination-material turn-ins accept Dusklight Shards. @@ -209,12 +208,30 @@ VendorReputationDisposition resolve_award(std::uint16_t vendorIndex, == slots.begin() + static_cast(count)) { return VendorReputationDisposition::refused; } - award = {cost.definitionHash, - sale.costQuantity, - static_cast(experience), - rule.progressionIndex, - rule.rewardValueRow, - rule.rankCost}; + award.costHash = cost.definitionHash; + award.costQuantity = sale.costQuantity; + award.experience = static_cast(experience); + award.progressionIndex = rule.progressionIndex; + award.rewardValueRow = rule.rewardValueRow; + award.repeatLastStep = rule.repeatLastStep; + if (rule.rewardValueRow != 0) { + std::array + steps{}; + if (!build_data::progressions::steps(rule.progressionIndex, steps, count) || count < 2 + || steps[0].cost != 0 + || std::any_of( + steps.begin() + 1, + steps.begin() + static_cast(count), + [](const auto& step) { return step.cost <= 0; })) { + return VendorReputationDisposition::refused; + } + award.rankStepCount = count; + std::transform(steps.begin(), + steps.begin() + static_cast(count), + award.rankStepCosts.begin(), + [](const auto& step) { return step.cost; }); + } return VendorReputationDisposition::prepared; } return recognized ? VendorReputationDisposition::refused @@ -252,24 +269,62 @@ bool charge_materials(AccountState& account, const VendorReputationAward& award) && runtime::detail::valid_profile_inventory(account); } +/** + * Walks one captured ladder and repeats its final cost only when installed content says to. + * @param award Prepared award carrying the installed rank costs. + * @param experience Nonnegative progression XP. + * @param rank Receives the number of completed steps, including the zero-cost first step. + * @return False when the captured ladder cannot define ranks. + */ +bool rank_at_experience(const VendorReputationAward& award, + std::int64_t experience, + std::int64_t& rank) noexcept { + rank = 0; + if (experience < 0 || award.rankStepCount < 2 + || award.rankStepCount > award.rankStepCosts.size() || award.rankStepCosts[0] != 0) { + return false; + } + std::int64_t remaining = experience; + for (std::size_t step = 0; step < award.rankStepCount; ++step) { + const auto cost = award.rankStepCosts[step]; + if (step != 0 && cost <= 0) { + return false; + } + if (remaining < cost) { + return true; + } + remaining -= cost; + ++rank; + } + if (award.repeatLastStep) { + rank += remaining / award.rankStepCosts[award.rankStepCount - 1]; + } + return true; +} + /** * Reconstructed policy awards one claim credit per newly crossed rank, without backfilling XP. * @param banks Candidate unlock banks; no saved state is written here. - * @param award Build-matched turn-in and repeating rank rule. + * @param award Build-matched turn-in and captured rank ladder. * @param beforeExperience Nonnegative XP before this turn-in. - * @return False when the counter is negative or the complete credit would overflow. + * @return False when the ladder or counter is invalid or the complete credit would overflow. */ bool grant_rank_rewards(unlocks::Table& banks, const VendorReputationAward& award, std::int32_t beforeExperience) noexcept { - if (award.rankCost == 0) { + if (award.rankStepCount == 0) { return true; } - if (award.rankCost <= 0 || beforeExperience < 0 || award.experience <= 0) { + if (beforeExperience < 0 || award.experience <= 0) { return false; } const auto afterExperience = static_cast(beforeExperience) + award.experience; - const auto count = afterExperience / award.rankCost - beforeExperience / award.rankCost; + std::int64_t beforeRank = 0, afterRank = 0; + if (!rank_at_experience(award, beforeExperience, beforeRank) + || !rank_at_experience(award, afterExperience, afterRank) || afterRank < beforeRank) { + return false; + } + const auto count = afterRank - beforeRank; auto& credits = banks.characterObjectValues[award.rewardValueRow]; if (credits < 0 || count > (std::numeric_limits::max)() - credits) { return false; @@ -364,7 +419,7 @@ bool commit_vendor_reputation(PendingVendorReputation& mutation) noexcept { } auto& progression = banks.characterProgressions[award.progressionIndex]; if (progression != mutation.beforeProgression || progression[kExperienceLane] < 0 - || (award.rankCost > 0 + || (award.rankStepCount != 0 && banks.characterObjectValues[award.rewardValueRow] != mutation.beforeRewardCredits) || progression[kExperienceLane] > (std::numeric_limits::max)() - award.experience diff --git a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h index 7221eea1a..a01b5f401 100644 --- a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h +++ b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h @@ -1,6 +1,7 @@ #pragma once #include "../account/account_state.h" +#include "../build_data/progressions/definition.h" #include "../unlocks/definition.h" namespace sunrise::state { @@ -14,10 +15,14 @@ struct VendorReputationAward { std::uint32_t costQuantity{}; std::int32_t experience{}; std::uint16_t progressionIndex{}; - /** Character-object value row; used only when rankCost is positive. */ + /** Character-object value row; used only when rankStepCount is positive. */ std::uint16_t rewardValueRow{}; + /** Installed rank costs captured for commit revalidation. */ + std::array rankStepCosts{}; /** Zero preserves XP-only handling for factions without a supported reward rule. */ - std::int32_t rankCost{}; + std::size_t rankStepCount{}; + /** Build-86657 progression metadata marks these faction ladders as repeating. */ + bool repeatLastStep{}; bool operator==(const VendorReputationAward&) const = default; }; diff --git a/tests/vendor_reputation.cpp b/tests/vendor_reputation.cpp index 814b70470..b334da61b 100644 --- a/tests/vendor_reputation.cpp +++ b/tests/vendor_reputation.cpp @@ -82,6 +82,14 @@ std::uint32_t gear_hash(std::size_t index) { } /** Build-86657 Gunsmith's repeating rank costs 3000 XP. */ constexpr std::int32_t kGunsmithRankCost = 3000; +/** Four rows exercise the zero-cost first rank and three installed rank costs. */ +constexpr std::size_t kFixtureRankStepCount = 4; +std::array g_rankSteps{{ + {0}, + {kGunsmithRankCost}, + {kGunsmithRankCost}, + {kGunsmithRankCost}, +}}; /** Build-86657 engram bucket has ten character inventory slots. */ constexpr std::size_t kEngramCapacity = 10; std::size_t g_rewardCapacity = kEngramCapacity; @@ -127,6 +135,7 @@ void reset() { g_progression = kGunsmithProgression; g_characterScope = true; g_materialAvailable = true; + g_rankSteps = {{{0}, {kGunsmithRankCost}, {kGunsmithRankCost}, {kGunsmithRankCost}}}; g_rewardCapacity = kEngramCapacity; g_rewardAvailable = true; state::AccountState account{}; @@ -327,6 +336,7 @@ void verify_rank_rewards() { g_soldHash = entry.placeholder; g_costHash = entry.material; g_progression = entry.progression; + g_rankSteps = {{{0}, {entry.rankCost}, {entry.rankCost}, {entry.rankCost}}}; auto account = store::account(); account.profileItems[0].definitionHash = entry.material; check(store::write_account(account) @@ -353,6 +363,30 @@ void verify_rank_rewards() { "other character receives no credit"); } reset(); + check(store::write_unlock( + store::Bank::characterProgressions, kGunsmithProgression, kGunsmithRankCost - kAward) + && state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared, + "prepare before progression cost change"); + ++g_rankSteps[1].cost; + check(!state::commit_vendor_reputation(pending) && credits(kGunsmithCreditRow) == 0 + && store::account().profileItems[0].quantity == kInitialQuantity + && gunsmith()[0] == kGunsmithRankCost - kAward, + "changed progression cost refuses stale turn-in"); + reset(); + g_rankSteps[0].cost = 1; + check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::refused, + "rank ladder without a zero first step refused"); + reset(); + /** Three installed costs plus one repeated final cost reach the first tail threshold. */ + constexpr std::int32_t kFirstRepeatedRankThreshold = + kGunsmithRankCost * static_cast(kFixtureRankStepCount); + check(store::write_unlock(store::Bank::characterProgressions, + kGunsmithProgression, + kFirstRepeatedRankThreshold - kAward) + && state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared + && state::commit_vendor_reputation(pending) && credits(kGunsmithCreditRow) == 1, + "installed repeat-tail rule awards its crossed credit"); + reset(); check(store::write_unlock( store::Bank::characterProgressions, kGunsmithProgression, kGunsmithRankCost - kAward), "seed crossing"); @@ -396,14 +430,19 @@ void verify_rank_rewards() { "historical ranks never backfilled"); reset(); auto account = store::account(); - // Two complete Gunsmith ranks in one payment exercise multi-credit accounting. - g_cost = static_cast(kGunsmithRankCost * 2 / 30); + /** Uneven costs prove one payment can cross several installed thresholds. */ + constexpr std::array + kUnevenRankSteps{{{0}, {90}, {180}, {360}}}; + /** Twenty materials award 600 XP and cross all three paid fixture steps. */ + constexpr std::uint32_t kUnevenRankTurnIn = 20; + g_rankSteps = kUnevenRankSteps; + g_cost = kUnevenRankTurnIn; account.profileItems[0].quantity = static_cast(g_cost); check(store::write_account(account) && state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared - && state::commit_vendor_reputation(pending) && credits(kGunsmithCreditRow) == 2 + && state::commit_vendor_reputation(pending) && credits(kGunsmithCreditRow) == 3 && store::account().characters[0].inventory.count == 0, - "two thresholds award two credits, no items"); + "uneven thresholds award every crossed credit, no items"); } /** Configures a disposable eligible Zavala claim without changing any player save. */ @@ -850,6 +889,27 @@ bool find_progression_slots(progressions::Scope scope, count = 1; return true; } + +namespace progressions { +/** + * Copies the controlled installed-cost fixture for the selected progression. + * @param definitionIndex Requested native progression index. + * @param output Caller-owned step storage. + * @param count Receives the copied row count. + * @return False when the request does not match the fixture or storage is too small. + */ +bool steps(std::uint16_t definitionIndex, std::span output, std::size_t& count) noexcept { + count = 0; + if (definitionIndex != g_progression || output.size() < g_rankSteps.size()) { + return false; + } + for (std::size_t step = 0; step < g_rankSteps.size(); ++step) { + output[step] = g_rankSteps[step]; + } + count = g_rankSteps.size(); + return true; +} +} // namespace progressions } // namespace sunrise::state::build_data namespace sunrise::state { From a70ccd81d14824c8a210131e1b3c12c8506c05e2 Mon Sep 17 00:00:00 2001 From: Gage Fulwood Date: Tue, 22 Sep 2026 22:18:29 -0500 Subject: [PATCH 07/59] Read faction reward gear from installed package lists --- Sunrise/Sunrise.vcxproj | 1 + .../items/packages/package_item_build.cpp | 30 +- Sunrise/src/client/content/vendors/layout.h | 39 +++ .../content/vendors/package_vendor_build.cpp | 303 ++++++++++++++++++ .../src/client/content/vendors/vendor_build.h | 20 ++ .../state_vendor_reputation_runtime.cpp | 56 ++-- .../src/state/runtime/vendor_reward_pool.cpp | 123 +++++++ .../src/state/runtime/vendor_reward_pool.h | 115 +++---- tests/vendor_reputation.cpp | 108 +++++-- tests/vendor_reputation.ps1 | 1 + 10 files changed, 660 insertions(+), 136 deletions(-) create mode 100644 Sunrise/src/state/runtime/vendor_reward_pool.cpp diff --git a/Sunrise/Sunrise.vcxproj b/Sunrise/Sunrise.vcxproj index 522c88bb9..180e6d918 100644 --- a/Sunrise/Sunrise.vcxproj +++ b/Sunrise/Sunrise.vcxproj @@ -644,6 +644,7 @@ + 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..003b1ab56 100644 --- a/Sunrise/src/client/content/items/packages/package_item_build.cpp +++ b/Sunrise/src/client/content/items/packages/package_item_build.cpp @@ -9,6 +9,7 @@ #include "../../../../middleware/content/packages/tables/definition_index_table.h" #include "../../../../state/build_data/activities/activity_catalog.h" #include "../../../../state/build_data/runtime.h" +#include "../../../../state/runtime/vendor_reward_pool.h" #include "../../activity/activity_catalog_build.h" #include "../../activity/entity_position_profile_build.h" #include "../../hash_names/hash_name_build.h" @@ -48,7 +49,7 @@ namespace { bool ready() noexcept { return root_domains_ready() && state::build_data::scenario_layouts_ready() && state::build_data::spawn_sets_ready() && state::build_data::hash_names_ready() - && state::build_data::vendor_catalog_ready() + && state::build_data::vendor_catalog_ready() && state::vendor_rewards::settled() && content::activity::entity_position_profiles::ready() && (state::build_data::activities::ready() || state::build_data::activities::extraction_failed()); @@ -89,7 +90,7 @@ bool build() noexcept { return true; } } - if (root_domains_ready()) { + if (root_domains_ready() && state::vendor_rewards::settled()) { SecureZeroMemory(&keys, sizeof keys); return ready(); } @@ -232,7 +233,24 @@ bool build() noexcept { table) && table.elementClass == tables::kItemIndexTableClass; } - if (located && build_item_rows(source, storage, table, rowCount, reason)) { + const bool rowsBuilt = located && !root_domains_ready() + && build_item_rows(source, storage, table, rowCount, reason); + // Other root builders reuse child for their own tables; consume the item index now. + if (located && state::build_data::item_definitions_ready() + && state::build_data::configured_item_details_ready() + && !state::vendor_rewards::settled()) { + if (!content::vendors::build_rewards(source, + storage.scratch, + std::span{storage.root}, + std::span{storage.child}, + table)) { + core::log::writef(core::log::Channel::state, + core::log::Level::warn, + "ev=build_data stage=vendor_rewards result=unavailable"); + state::vendor_rewards::settle_unavailable(); + } + } + if (rowsBuilt) { if (!build_material_requirements( source, storage, std::span{storage.root}, table.count)) { reason = "materials"; @@ -248,6 +266,12 @@ bool build() noexcept { } } } + if (root_domains_ready() && !state::vendor_rewards::settled()) { + state::vendor_rewards::settle_unavailable(); + core::log::writef(core::log::Channel::state, + core::log::Level::warn, + "ev=build_data stage=vendor_rewards result=unavailable_root"); + } SecureZeroMemory(&keys, sizeof keys); const bool complete = ready(); const bool itemDomainsReady = root_domains_ready(); diff --git a/Sunrise/src/client/content/vendors/layout.h b/Sunrise/src/client/content/vendors/layout.h index 82ea6b3bf..4946336f4 100644 --- a/Sunrise/src/client/content/vendors/layout.h +++ b/Sunrise/src/client/content/vendors/layout.h @@ -8,6 +8,45 @@ namespace sunrise::client::content::vendors { /** Tag of the installed vendor index blob, which names every vendor definition. */ inline constexpr std::uint32_t kIndexRootTag = 0x8131931DU; +/** Investment-root slot holding nested item reward lists used by faction packages. */ +inline constexpr std::size_t kRewardListTableSlot = 176; +/** Native reward-list table row class. */ +inline constexpr std::uint32_t kRewardListRowClass = 0x8080748CU; +/** Native reward-list table row size. */ +inline constexpr std::size_t kRewardListRowStride = 24; +/** Native reward-list entry class. */ +inline constexpr std::uint32_t kRewardEntryClass = 0x8080748EU; +/** Native reward-list entry size. */ +inline constexpr std::size_t kRewardEntryStride = 80; +/** Item-definition index in one reward entry. */ +inline constexpr std::size_t kRewardEntryItemOffset = 0; +/** Direct child-list selector in one reward entry. */ +inline constexpr std::size_t kRewardEntryChildOffset = 8; +/** Separate selector with an unresolved target space; do not treat it as a child list. */ +inline constexpr std::size_t kRewardEntryOtherSelectorOffset = 10; +/** Expression array descriptor in one reward entry. */ +inline constexpr std::size_t kRewardEntryConditionOffset = 32; +/** All-one item/list selectors do not name an installed row. */ +inline constexpr std::uint16_t kNoRewardSelector = 0xFFFFU; +/** Self-relative reward-sack pointer in an item definition. */ +inline constexpr std::size_t kItemRewardSackPointer = 0x58; +/** Native marker immediately before a resolved sack block. */ +inline constexpr std::uint32_t kRewardSackClass = 0x808077CCU; +/** Native reward-list selector at the start of a sack block. */ +inline constexpr std::size_t kRewardSackListOffset = 0; +/** Sack-entry array descriptor in the resolved sack block. */ +inline constexpr std::size_t kRewardSackEntriesOffset = 8; +/** Native sack-entry class. */ +inline constexpr std::uint32_t kRewardSackEntryClass = 0x808077CFU; +/** Native sack-entry row size. */ +inline constexpr std::size_t kRewardSackEntryStride = 12; +/** Hunter armour leaves use FLAG[239] in the installed reward list. */ +inline constexpr std::uint32_t kHunterArmourFlag = 239; +/** Titan armour leaves use FLAG[264] in the installed reward list. */ +inline constexpr std::uint32_t kTitanArmourFlag = 264; +/** Warlock armour leaves use FLAG[271] in the installed reward list. */ +inline constexpr std::uint32_t kWarlockArmourFlag = 271; + /** A vendor definition holds its installed array descriptor here. */ inline constexpr std::size_t kInstalledArrayDescriptor = 32; /** A vendor definition holds its sale array descriptor here. */ diff --git a/Sunrise/src/client/content/vendors/package_vendor_build.cpp b/Sunrise/src/client/content/vendors/package_vendor_build.cpp index 9269d4105..3ca82c1d8 100644 --- a/Sunrise/src/client/content/vendors/package_vendor_build.cpp +++ b/Sunrise/src/client/content/vendors/package_vendor_build.cpp @@ -10,6 +10,7 @@ #include "../../../middleware/content/packages/tables/definition_index_table.h" #include "../../../state/build_data/runtime.h" #include "../../../state/build_data/vendors/definition.h" +#include "../../../state/runtime/vendor_reward_pool.h" #include "layout.h" #include "vendor_build.h" @@ -19,6 +20,9 @@ namespace { namespace reader = middleware::content::packages::reader; namespace tables = middleware::content::packages::tables; namespace domain = state::build_data::vendors; +namespace rewards = state::vendor_rewards; +/** All three playable character classes may receive an unguarded reward leaf. */ +constexpr std::uint8_t kAllCharacterClasses = 0b111; /** Every extracted row, kept off the caller stack. */ struct Storage { @@ -286,6 +290,243 @@ void report(const Storage& storage, std::size_t skipped, const char* result) noe } } +/** + * Only installed instanced gear may enter the supported one-item payout pool. + * @param itemIndex Native item-definition index in one reward entry. + * @param hash Receives the item hash on success. + * @return False for missing rows, quests, cosmetics or non-equippable items. + */ +[[nodiscard]] bool reward_gear(std::uint16_t itemIndex, std::uint32_t& hash) noexcept { + state::build_data::items::Definition item{}; + state::build_data::items::details::Definition detail{}; + if (!state::build_data::find_item_definition_index(itemIndex, item) + || item.questInitialization.scope + != state::build_data::items::QuestInitialization::Scope::none + || !state::build_data::find_configured_item_detail(itemIndex, detail) + || detail.definitionIndex != itemIndex || detail.definitionHash != item.definitionHash + || detail.bucketId != item.bucketId || !detail.equipmentSlot.has_value() + || detail.instancedDefinitionState + != state::build_data::items::details::InstancedDefinitionState::instanced) { + return false; + } + hash = item.definitionHash; + return true; +} + +/** + * Gear leaves accept no gate or one class FLAG instruction; other programs are not payout data. + * @param blob Whole reward-list table. + * @param entry Offset of the entry holding the expression. + * @param mask Receives permitted character-class bits on success. + * @return False when the expression uses an unsupported operation or flag. + */ +[[nodiscard]] bool +reward_class_mask(std::span blob, std::size_t entry, std::uint8_t& mask) noexcept { + ArrayView expression{}; + if (!read_array(blob, + entry + kRewardEntryConditionOffset, + tables::kUnlockInstructionStride, + expression)) { + return false; + } + if (expression.count == 0) { + mask = kAllCharacterClasses; + return true; + } + std::uint32_t opcode = 0, operand = 0; + if (expression.count != 1 || expression.classId != tables::kInvestmentExpressionRowClass + || !read(blob, expression.base, opcode) + || !read(blob, expression.base + tables::kUnlockInstructionOperandOffset, operand) + || opcode != tables::kUnlockReadFlagOpcode) { + return false; + } + switch (operand) { + case kTitanArmourFlag: + mask = 1U << static_cast(state::CharacterClass::titan); + return true; + case kHunterArmourFlag: + mask = 1U << static_cast(state::CharacterClass::hunter); + return true; + case kWarlockArmourFlag: + mask = 1U << static_cast(state::CharacterClass::warlock); + return true; + default: + return false; + } +} + +/** + * Appends one gear hash only to the classes admitted by its installed expression. + * @param pool Candidate package pool under construction. + * @param hash Installed gear item hash. + * @param classMask Allowed character classes. + * @return False when a class list exceeds the supported capacity. + */ +[[nodiscard]] bool +append_reward(rewards::Pool& pool, std::uint32_t hash, std::uint8_t classMask) noexcept { + for (std::size_t classIndex = 0; classIndex < pool.items.size(); ++classIndex) { + if ((classMask & (1U << classIndex)) == 0) { + continue; + } + auto& count = pool.counts[classIndex]; + if (count == rewards::kCandidateCapacity) { + return false; + } + pool.items[classIndex][count++] = hash; + } + return true; +} + +/** + * Resolves one reward-list row without treating a selector as a direct item index. + * @param blob Whole reward-list table. + * @param lists Bounded top-level list array. + * @param listIndex Native reward-list selector. + * @param entries Receives the selected entry array on success. + * @return False for an absent, malformed or wrong-class list. + */ +[[nodiscard]] bool reward_entries(std::span blob, + const tables::Array& lists, + std::uint16_t listIndex, + ArrayView& entries) noexcept { + if (listIndex >= lists.count) { + return false; + } + const auto row = + lists.dataOffset + (static_cast(listIndex) * kRewardListRowStride); + return read_array(blob, row + tables::kTableArrayDescriptor, kRewardEntryStride, entries) + && entries.classId == kRewardEntryClass; +} + +/** + * Reads one direct child list; nested and non-gear leaves cannot become one-gear payouts. + * @param blob Whole reward-list table. + * @param lists Bounded top-level list array. + * @param child Native selector of the direct child list. + * @param pool Receives supported gear candidates. + * @return False when a gear leaf has an unknown condition or malformed layout. + */ +[[nodiscard]] bool read_reward_branch(std::span blob, + const tables::Array& lists, + std::uint16_t child, + rewards::Pool& pool) noexcept { + ArrayView entries{}; + if (!reward_entries(blob, lists, child, entries)) { + return false; + } + for (std::size_t index = 0; index < entries.count; ++index) { + const auto at = entries.base + (index * kRewardEntryStride); + std::uint16_t itemIndex = kNoRewardSelector, nested = 0, other = 0; + if (!read(blob, at + kRewardEntryItemOffset, itemIndex) + || !read(blob, at + kRewardEntryChildOffset, nested) + || !read(blob, at + kRewardEntryOtherSelectorOffset, other)) { + return false; + } + std::uint32_t hash = 0; + if (itemIndex == kNoRewardSelector || !reward_gear(itemIndex, hash)) { + continue; + } + std::uint8_t classMask = 0; + if (nested != kNoRewardSelector || other != kNoRewardSelector + || !reward_class_mask(blob, at, classMask) || !append_reward(pool, hash, classMask)) { + return false; + } + } + return true; +} + +/** + * Reads a package's sack selector from its installed item definition. + * @param source Installed package directory and borrowed keys. + * @param scratch Shared package block storage. + * @param itemTable Installed item index table bytes. + * @param itemRows Item index array within itemTable. + * @param packageHash Package item hash to resolve. + * @param definition Reusable storage for the package definition. + * @param listIndex Receives the native reward-list selector. + * @return False when the package or its sack block is unreadable. + */ +[[nodiscard]] bool package_reward_list(const reader::Source& source, + reader::Scratch& scratch, + std::span itemTable, + const tables::Array& itemRows, + std::uint32_t packageHash, + std::vector& definition, + std::uint16_t& listIndex) noexcept { + state::build_data::items::Definition package{}; + tables::IndexRow index{}; + std::uint32_t classId = 0, marker = 0; + if (!state::build_data::find_item_definition_hash(packageHash, package) + || package.definitionIndex >= itemRows.count + || !tables::index_row(itemTable, itemRows, package.definitionIndex, index) + || index.definitionHash != packageHash + || !reader::read_tag(source, scratch, index.targetTag, definition, classId) + || classId != tables::kItemDefinitionClass) { + return false; + } + const std::span blob{definition}; + std::int64_t relative = 0; + if (!read(blob, kItemRewardSackPointer, relative) + || relative < -static_cast(kItemRewardSackPointer) + || relative > static_cast(blob.size() - kItemRewardSackPointer)) { + return false; + } + const auto block = + static_cast(static_cast(kItemRewardSackPointer) + relative); + ArrayView sackEntries{}; + return block >= sizeof marker && read(blob, block - sizeof marker, marker) + && marker == kRewardSackClass && read(blob, block + kRewardSackListOffset, listIndex) + && read_array( + blob, block + kRewardSackEntriesOffset, kRewardSackEntryStride, sackEntries) + && sackEntries.classId == kRewardSackEntryClass; +} + +/** + * Reads direct gear branches only; the other selector's target is unresolved. + * @param source Installed package directory and borrowed keys. + * @param scratch Shared package block storage. + * @param itemTable Installed item index table bytes. + * @param itemRows Item index array within itemTable. + * @param listsBlob Whole reward-list table. + * @param lists Bounded top-level reward-list array. + * @param packageHash Package item hash to resolve. + * @param pool Receives supported class-specific gear. + * @param definition Reusable package-definition storage. + * @return False when the supported tree has an unreadable list or gear gate. + */ +[[nodiscard]] bool read_reward_pool(const reader::Source& source, + reader::Scratch& scratch, + std::span itemTable, + const tables::Array& itemRows, + std::span listsBlob, + const tables::Array& lists, + std::uint32_t packageHash, + rewards::Pool& pool, + std::vector& definition) noexcept { + pool = {}; + pool.packageHash = packageHash; + std::uint16_t parent = kNoRewardSelector; + ArrayView entries{}; + if (!package_reward_list(source, scratch, itemTable, itemRows, packageHash, definition, parent) + || !reward_entries(listsBlob, lists, parent, entries)) { + return false; + } + for (std::size_t index = 0; index < entries.count; ++index) { + const auto at = entries.base + (index * kRewardEntryStride); + std::uint16_t child = kNoRewardSelector; + std::uint8_t gate = 0; + if (!read(listsBlob, at + kRewardEntryChildOffset, child) + || !reward_class_mask(listsBlob, at, gate) || gate != kAllCharacterClasses) { + return false; + } + // The +10 selector is unresolved; do not follow it as a reward-list child. + if (child != kNoRewardSelector && !read_reward_branch(listsBlob, lists, child, pool)) { + return false; + } + } + return true; +} + } // namespace /** Extracts and publishes the vendor catalog from the installed packages. */ @@ -326,4 +567,66 @@ bool build(const reader::Source& source, reader::Scratch& scratch) noexcept { return published; } +/** + * Publishes the supported nested faction reward pools from the installed investment root. + * @param source Installed package directory and borrowed keys. + * @param scratch Shared package block storage. + * @param root Installed investment-root bytes. + * @param itemTable Installed item index table bytes. + * @param itemRows Item index array within itemTable. + * @return False when any supported package pool is unreadable or invalid. + */ +bool build_rewards(const reader::Source& source, + reader::Scratch& scratch, + std::span root, + std::span itemTable, + const tables::Array& itemRows) noexcept { + if (rewards::ready()) { + return true; + } + std::uint32_t tag = 0; + static std::vector listsBlob{}; + static std::vector definition{}; + tables::Array lists{}; + if (!state::build_data::item_definitions_ready() + || !state::build_data::configured_item_details_ready() + || !tables::slot_tag(root, kRewardListTableSlot, tag) || tag == 0 + || !reader::read_tag(source, scratch, tag, listsBlob) + || !tables::find_array_at(listsBlob, tables::kTableArrayDescriptor, lists) + || lists.elementClass != kRewardListRowClass + || lists.count > (std::numeric_limits::max)() + || lists.dataOffset > listsBlob.size() + || lists.count > (listsBlob.size() - lists.dataOffset) / kRewardListRowStride) { + return false; + } + std::array pools{}; + for (std::size_t index = 0; index < pools.size(); ++index) { + if (!read_reward_pool(source, + scratch, + itemTable, + itemRows, + listsBlob, + lists, + rewards::kPackageHashes[index], + pools[index], + definition)) { + core::log::writef(core::log::Channel::state, + core::log::Level::warn, + "ev=build_data stage=vendor_rewards result=skip package=0x%08X", + rewards::kPackageHashes[index]); + return false; + } + } + const bool published = rewards::replace(pools); + core::log::writef( + core::log::Channel::state, + published ? core::log::Level::info : core::log::Level::warn, + "ev=build_data stage=vendor_rewards result=%s vanguard=%zu crucible=%zu gunsmith=%zu", + published ? "ok" : "invalid", + pools[0].counts[0], + pools[1].counts[0], + pools[2].counts[0]); + return published; +} + } // namespace sunrise::client::content::vendors diff --git a/Sunrise/src/client/content/vendors/vendor_build.h b/Sunrise/src/client/content/vendors/vendor_build.h index 5d01ea917..9ca57a5c2 100644 --- a/Sunrise/src/client/content/vendors/vendor_build.h +++ b/Sunrise/src/client/content/vendors/vendor_build.h @@ -1,6 +1,10 @@ #pragma once +#include +#include + #include "../../../middleware/content/packages/reader/reader.h" +#include "../../../middleware/content/packages/tables/definition_index_table.h" namespace sunrise::client::content::vendors { @@ -14,4 +18,20 @@ namespace sunrise::client::content::vendors { [[nodiscard]] bool build(const middleware::content::packages::reader::Source& source, middleware::content::packages::reader::Scratch& scratch) noexcept; +/** + * Reads supported faction package gear leaves from the installed nested reward lists. + * @param source Installed package directory and borrowed keys. + * @param scratch Lock-owned package block storage. + * @param root Resolved investment root. + * @param itemTable Resolved item index table bytes. + * @param itemRows Item index array in itemTable. + * @return True when all supported packages have published their candidate pools. + */ +[[nodiscard]] bool +build_rewards(const middleware::content::packages::reader::Source& source, + middleware::content::packages::reader::Scratch& scratch, + std::span root, + std::span itemTable, + const middleware::content::packages::tables::Array& itemRows) noexcept; + } // namespace sunrise::client::content::vendors diff --git a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp index 0b68cbb54..a280dc809 100644 --- a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp +++ b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp @@ -49,7 +49,7 @@ constexpr std::array kReputationRules{{ /** Native progression level walks read experience from lane zero. */ constexpr std::size_t kExperienceLane = 0; -/** Checked build-86657 claim bindings; a missing pool leaves payout unsupported. */ +/** Checked build-86657 claim bindings; an unreadable package pool leaves payout unsupported. */ struct RewardRule { std::uint32_t vendorHash; std::uint16_t interaction; @@ -57,25 +57,17 @@ struct RewardRule { std::uint16_t rewardValueRow; std::uint16_t saleIndex{}; std::uint32_t packageHash{}; - const vendor_rewards::Pool* pool{}; /** Only the Vanguard and Crucible sales require FLAG[5901] and VALUE[465]. */ bool requiresSelectionGates{true}; }; /** Vendor, interaction, category, saved counter, package sale and hash from build 86657. */ constexpr std::array kRewardRules{{ // Zavala's current package previews Vanguard gear. - {69482069U, 40, 3, kVanguardRewardValueRow, 93, 2746484552U, &vendor_rewards::kVanguardPool}, + {69482069U, 40, 3, kVanguardRewardValueRow, 93, vendor_rewards::kVanguardPackageHash}, // Shaxx's current package previews Crucible gear. - {3603221665U, 28, 10, kCrucibleRewardValueRow, 96, 3289621657U, &vendor_rewards::kCruciblePool}, + {3603221665U, 28, 10, kCrucibleRewardValueRow, 96, vendor_rewards::kCruciblePackageHash}, // Banshee's sale has empty selection gates and uses the shared weapon pool without armour. - {672118013U, - 35, - 8, - kGunsmithRewardValueRow, - 16, - 2422825785U, - &vendor_rewards::kGunsmithPool, - false}, + {672118013U, 35, 8, kGunsmithRewardValueRow, 16, vendor_rewards::kGunsmithPackageHash, false}, }}; /** Reply zero completes the supported normal reward interactions. */ constexpr std::uint16_t kAcceptRewardReply = 0; @@ -114,9 +106,9 @@ bool reward_binding_current(const RewardRule& rule, std::uint16_t saleIndex) noe vendors::Definition vendor{}; vendors::SaleRow sale{}; build_data::items::Definition package{}; - if (rule.pool == nullptr || saleIndex != rule.saleIndex - || !vendors::find(rule.vendorHash, vendor) || !vendors::sale_row(vendor, saleIndex, sale) - || sale.categoryIndex != rule.category || sale.costQuantity != 0 + if (saleIndex != rule.saleIndex || !vendors::find(rule.vendorHash, vendor) + || !vendors::sale_row(vendor, saleIndex, sale) || sale.categoryIndex != rule.category + || sale.costQuantity != 0 || !build_data::find_item_definition_index(sale.itemIndex, package) || package.definitionHash != rule.packageHash) { return false; @@ -468,7 +460,7 @@ VendorReputationDisposition prepare_vendor_reward(std::uint16_t vendorIndex, if (rule == nullptr || rule->interaction != interactionIndex) { return VendorReputationDisposition::notApplicable; } - if (replyIndex != kAcceptRewardReply || rule->pool == nullptr) { + if (replyIndex != kAcceptRewardReply) { return VendorReputationDisposition::refused; } return prepare_vendor_reward_sale(vendorIndex, rule->saleIndex, random, mutation); @@ -514,24 +506,22 @@ VendorReputationDisposition prepare_vendor_reward_sale(std::uint16_t vendorIndex if (selected >= account.characterCount) { return VendorReputationDisposition::refused; } - const auto armour = - vendor_rewards::armour(*rule->pool, account.characters[selected].characterClass); - std::array candidates{}; - if (rule->pool->weapons.size() + armour.size() > candidates.size()) { + vendor_rewards::Pool pool{}; + if (!vendor_rewards::find(rule->packageHash, pool)) { return VendorReputationDisposition::refused; } + std::array eligible{}; std::size_t count = 0; - for (const auto pool : {rule->pool->weapons, armour}) { - for (const auto hash : pool) { - if (reward_item_supported(hash)) { - candidates[count++] = hash; - } + for (const auto hash : + vendor_rewards::candidates(pool, account.characters[selected].characterClass)) { + if (reward_item_supported(hash)) { + eligible[count++] = hash; } } // Reconstructed policy chooses one installed gear row; retail weights and extras are unknown. if (count == 0 || !runtime::detail::finalize_item_acquisition( - account, account, candidates[random % count], false, {.direct = true}, mutation)) { + account, account, eligible[random % count], false, {.direct = true}, mutation)) { mutation = {}; return VendorReputationDisposition::refused; } @@ -560,13 +550,13 @@ bool vendor_reward_current(const PendingItemAcquisition& mutation) noexcept { || !reward_item_supported(mutation.acquiredDefinitionHash)) { return false; } - const auto armour = - vendor_rewards::armour(*rule->pool, mutation.beforeCharacter.characterClass); - const auto weapons = rule->pool->weapons; - return std::find(weapons.begin(), weapons.end(), mutation.acquiredDefinitionHash) - != weapons.end() - || std::find(armour.begin(), armour.end(), mutation.acquiredDefinitionHash) - != armour.end(); + vendor_rewards::Pool pool{}; + if (!vendor_rewards::find(rule->packageHash, pool)) { + return false; + } + const auto eligible = vendor_rewards::candidates(pool, mutation.beforeCharacter.characterClass); + return std::find(eligible.begin(), eligible.end(), mutation.acquiredDefinitionHash) + != eligible.end(); } } // namespace sunrise::state diff --git a/Sunrise/src/state/runtime/vendor_reward_pool.cpp b/Sunrise/src/state/runtime/vendor_reward_pool.cpp new file mode 100644 index 000000000..d4f04a790 --- /dev/null +++ b/Sunrise/src/state/runtime/vendor_reward_pool.cpp @@ -0,0 +1,123 @@ +#include "vendor_reward_pool.h" + +#include +#include + +namespace sunrise::state::vendor_rewards { +namespace { + +std::mutex g_mutex; +std::array g_pools{}; +bool g_ready = false; +bool g_settled = false; + +/** + * Refuses an incomplete or duplicate candidate set before publication. + * @param pool One package and its class-specific gear candidates. + * @return True when all three class lists hold supported unique hashes. + */ +bool valid(const Pool& pool) noexcept { + if (std::find(kPackageHashes.begin(), kPackageHashes.end(), pool.packageHash) + == kPackageHashes.end()) { + return false; + } + for (std::size_t classIndex = 0; classIndex < pool.items.size(); ++classIndex) { + const auto count = pool.counts[classIndex]; + if (count == 0 || count > kCandidateCapacity) { + return false; + } + const auto begin = pool.items[classIndex].begin(); + for (std::size_t index = 0; index < count; ++index) { + const auto hash = pool.items[classIndex][index]; + if (hash == 0 + || std::find(begin, begin + static_cast(index), hash) + != begin + static_cast(index)) { + return false; + } + } + } + return true; +} + +} // namespace + +/** @return True when supported package pools are available for claims. */ +bool ready() noexcept { + const std::lock_guard lock(g_mutex); + return g_ready; +} + +/** @return True when extraction has finished, with or without supported pools. */ +bool settled() noexcept { + const std::lock_guard lock(g_mutex); + return g_settled; +} + +/** An unsupported installed build must not block unrelated content loading. */ +void settle_unavailable() noexcept { + const std::lock_guard lock(g_mutex); + g_settled = true; +} + +/** + * Publishes complete class-specific package membership together. + * @param pools Exactly one pool per supported package. + * @return False without changing the published set when validation fails. + */ +bool replace(std::span pools) noexcept { + if (pools.size() != g_pools.size()) { + return false; + } + for (std::size_t index = 0; index < pools.size(); ++index) { + if (!valid(pools[index]) + || std::find_if( + pools.begin(), + pools.begin() + static_cast(index), + [&](const Pool& other) { return other.packageHash == pools[index].packageHash; }) + != pools.begin() + static_cast(index)) { + return false; + } + } + const std::lock_guard lock(g_mutex); + std::copy(pools.begin(), pools.end(), g_pools.begin()); + g_ready = true; + g_settled = true; + return true; +} + +/** + * Copies one published pool under the catalog lock. + * @param packageHash Installed package item hash. + * @param output Receives the pool on success; unchanged on failure. + * @return False when no supported pool holds the package. + */ +bool find(std::uint32_t packageHash, Pool& output) noexcept { + const std::lock_guard lock(g_mutex); + if (!g_ready) { + return false; + } + const auto found = std::find_if(g_pools.begin(), g_pools.end(), [&](const Pool& pool) { + return pool.packageHash == packageHash; + }); + if (found == g_pools.end()) { + return false; + } + output = *found; + return true; +} + +/** + * Invalid class selectors cannot inherit another class's armour candidates. + * @param pool Installed package candidates. + * @param characterClass Selected character class. + * @return This class's candidates, or empty for an invalid class. + */ +std::span candidates(const Pool& pool, + CharacterClass characterClass) noexcept { + const auto index = static_cast(characterClass); + return index < pool.items.size() && pool.counts[index] <= kCandidateCapacity + ? std::span{pool.items[index].data(), pool.counts[index]} + : std::span{}; +} + +} // namespace sunrise::state::vendor_rewards diff --git a/Sunrise/src/state/runtime/vendor_reward_pool.h b/Sunrise/src/state/runtime/vendor_reward_pool.h index 5ace9a429..1d876675b 100644 --- a/Sunrise/src/state/runtime/vendor_reward_pool.h +++ b/Sunrise/src/state/runtime/vendor_reward_pool.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include @@ -8,87 +9,51 @@ namespace sunrise::state::vendor_rewards { -/** Build-86657 preview vendor 1016620613 lists these weapons for package 2746484552. */ -inline constexpr std::array kVanguardWeapons{ - 991314988U, // Bad Omens. - 720351795U, // Arsenic Bite-4b. - 4230993599U, // Steel Sybil Z-14. - 253196586U, // Main Ingredient. - 4146702548U, // Outrageous Fortune. - 2957367743U, // Toil and Trouble. - 2009277538U, // The Last Dance. - 3745990145U, // Long Shadow. - 3356526253U, // Wishbringer. - 821154603U, // Gnawing Hunger. - 3504336176U, // Night Watch. - 2199171672U, // Lonesome. - 188882152U, // Last Perdition. - 3863882743U, // Uriel's Gift. - 4106983932U, // Elatha FR4. - 3569802112U, // The Old Fashioned. - 1529450902U, // Mos Epoch III. - 1807343361U, // Hawthorne's Field-Forged Shotgun. - 3622137132U, // Last Hope. - 3055192515U, // Timelines' Vertex. - 2257180473U, // Interference VI. - 2742838701U, // Dire Promise. - 2742838700U, // True Prophecy. - 1162247618U, // Jian 7 Rifle. - 1723380073U, // Enigma's Draw. - 2807687156U, // Distant Tumulus. - 1786797708U, // Escape Velocity. - 2857348871U, // Honor's Edge. - 1946491241U, // Truthteller. - 1835747805U, // Nature of the Beast. +/** Current Vanguard package item hash in build 86657. */ +inline constexpr std::uint32_t kVanguardPackageHash = 2746484552U; +/** Current Crucible package item hash in build 86657. */ +inline constexpr std::uint32_t kCruciblePackageHash = 3289621657U; +/** Current Gunsmith package item hash in build 86657. */ +inline constexpr std::uint32_t kGunsmithPackageHash = 2422825785U; +/** Only these three packages have a supported one-gear payout policy. */ +inline constexpr std::array kPackageHashes{ + kVanguardPackageHash, kCruciblePackageHash, kGunsmithPackageHash}; + +/** The largest build-86657 class list has 30 weapons and five armour pieces. */ +inline constexpr std::size_t kCandidateCapacity = 35; + +/** Installed candidate order for each character class; no item hashes are compiled here. */ +struct Pool { + std::uint32_t packageHash{}; + std::array, 3> items{}; + std::array counts{}; }; -/** Titan-only Vigil of Heroes hashes retain the same preview's item order. */ -inline constexpr std::array kVanguardTitan{ - 273457849U, 2009892127U, 3722981806U, 1392054568U, 3207116971U}; -/** Hunter-only Vigil of Heroes hashes retain the same preview's item order. */ -inline constexpr std::array kVanguardHunter{ - 3761819011U, 178749005U, 2629204288U, 1524444346U, 1812185909U}; -/** Warlock-only Vigil of Heroes hashes retain the same preview's item order. */ -inline constexpr std::array kVanguardWarlock{ - 1578461326U, 1108278178U, 332170995U, 3631862279U, 4086100104U}; +/** @return True after the complete supported package set has been published. */ +[[nodiscard]] bool ready() noexcept; -/** Preview vendor 1894103790 lists Titan-only Wing Discipline armour in this order. */ -inline constexpr std::array kCrucibleTitan{ - 657606375U, 2899275886U, 2389585538U, 687386728U, 1613581523U}; -/** The same preview lists Hunter-only Wing Contender armour in this order. */ -inline constexpr std::array kCrucibleHunter{ - 3153956825U, 3091776080U, 1914589560U, 3408834730U, 693067797U}; -/** The same preview lists Warlock-only Wing Theorem armour in this order. */ -inline constexpr std::array kCrucibleWarlock{ - 3684978064U, 3441081953U, 2286507447U, 641063251U, 119859462U}; +/** @return True after extraction either published the pools or ruled them unavailable. */ +[[nodiscard]] bool settled() noexcept; -/** Checked package candidates; class ordinals are Titan, Hunter and Warlock. */ -struct Pool { - std::span weapons; - std::array, 3> classArmour; -}; -/** Package 2746484552 uses preview 1016620613. */ -inline constexpr Pool kVanguardPool{kVanguardWeapons, - {kVanguardTitan, kVanguardHunter, kVanguardWarlock}}; -/** Package 3289621657 uses preview 1894103790, with the same weapons but its own armour. */ -inline constexpr Pool kCruciblePool{kVanguardWeapons, - {kCrucibleTitan, kCrucibleHunter, kCrucibleWarlock}}; -/** Build-86657 package 2422825785 uses reward list 546 -> 528 for these weapons, without armour. */ -inline constexpr Pool kGunsmithPool{kVanguardWeapons, {}}; -/** Supported previews contain at most thirty weapons and five eligible armour pieces. */ -inline constexpr std::size_t kCandidateCapacity = 35; +/** Records that this installed build has no readable supported pool; claims still fail closed. */ +void settle_unavailable() noexcept; /** - * Class-specific armour must never enter another class's reward pool. - * @param pool Checked package preview candidates. - * @param characterClass Selected character's class. - * @return Matching armour hashes, or an empty span for an invalid class. + * Replaces the supported package pools together, refusing malformed or duplicate candidates. + * @param pools One pool for each supported installed package. + * @return True when all pools are valid and published. */ -inline std::span armour(const Pool& pool, - CharacterClass characterClass) noexcept { - const auto index = static_cast(characterClass); - return index < pool.classArmour.size() ? pool.classArmour[index] - : std::span{}; -} +[[nodiscard]] bool replace(std::span pools) noexcept; + +/** + * @param packageHash Installed package item hash. + * @param output Receives its pool on success. + * @return True only for a published supported package. + */ +[[nodiscard]] bool find(std::uint32_t packageHash, Pool& output) noexcept; + +/** @return Candidates for a valid selected class, or empty for an invalid class. */ +[[nodiscard]] std::span candidates(const Pool& pool, + CharacterClass characterClass) noexcept; } // namespace sunrise::state::vendor_rewards diff --git a/tests/vendor_reputation.cpp b/tests/vendor_reputation.cpp index b334da61b..f85e13d25 100644 --- a/tests/vendor_reputation.cpp +++ b/tests/vendor_reputation.cpp @@ -63,16 +63,18 @@ constexpr std::uint16_t kGunsmithCreditRow = 49, kBansheeClaimInteraction = 35; constexpr std::uint32_t kGunsmithPackageHash = 2422825785U; constexpr std::uint16_t kGunsmithPackageSale = 16, kGunsmithCategory = 8; -/** @return Flat fixture pool in weapon, Titan, Hunter, Warlock order. */ +/** Small installed-looking fixture; production candidates come from packages, never these rows. */ +constexpr std::array kFixtureWeapons{991314988U, 720351795U}; +/** Vanguard class armour in Titan, Hunter, Warlock order. */ +constexpr std::array kFixtureVanguardArmour{273457849U, 3761819011U, 1578461326U}; +/** Crucible class armour in Titan, Hunter, Warlock order. */ +constexpr std::array kFixtureCrucibleArmour{657606375U, 3153956825U, 3684978064U}; + +/** @return Flat fixture gear hashes across the three supported package pools. */ std::uint32_t gear_hash(std::size_t index) { - for (const auto pool : - {std::span(state::vendor_rewards::kVanguardWeapons), - std::span(state::vendor_rewards::kVanguardTitan), - std::span(state::vendor_rewards::kVanguardHunter), - std::span(state::vendor_rewards::kVanguardWarlock), - std::span(state::vendor_rewards::kCrucibleTitan), - std::span(state::vendor_rewards::kCrucibleHunter), - std::span(state::vendor_rewards::kCrucibleWarlock)}) { + for (const auto pool : {std::span(kFixtureWeapons), + std::span(kFixtureVanguardArmour), + std::span(kFixtureCrucibleArmour)}) { if (index < pool.size()) { return pool[index]; } @@ -80,6 +82,34 @@ std::uint32_t gear_hash(std::size_t index) { } return 0; } + +/** Seeds one faction package pool with two shared weapons and its own class armour. */ +state::vendor_rewards::Pool fixture_pool(std::uint32_t packageHash, + const std::array* armour) { + state::vendor_rewards::Pool pool{}; + pool.packageHash = packageHash; + for (std::size_t classIndex = 0; classIndex < pool.items.size(); ++classIndex) { + for (const auto hash : kFixtureWeapons) { + pool.items[classIndex][pool.counts[classIndex]++] = hash; + } + if (armour != nullptr) { + pool.items[classIndex][pool.counts[classIndex]++] = (*armour)[classIndex]; + } + } + return pool; +} + +/** Restores the disposable test catalog after each controlled membership mutation. */ +void check(bool passed, const char* label); + +void reset_pools() { + const std::array pools{ + fixture_pool(kPackageHash, &kFixtureVanguardArmour), + fixture_pool(kCruciblePackageHash, &kFixtureCrucibleArmour), + fixture_pool(kGunsmithPackageHash, nullptr), + }; + check(state::vendor_rewards::replace(pools), "seed faction reward pools"); +} /** Build-86657 Gunsmith's repeating rank costs 3000 XP. */ constexpr std::int32_t kGunsmithRankCost = 3000; /** Four rows exercise the zero-cost first rank and three installed rank costs. */ @@ -479,7 +509,7 @@ void verify_claims() { check(state::commit_item_acquisition(grant) && credits(state::kVanguardRewardValueRow) == 1 && store::account().characters[0].inventory.count == 1 && store::account().characters[0].inventory.values[0].definitionHash - == state::vendor_rewards::kVanguardWeapons.front(), + == kFixtureWeapons.front(), "one item and one credit commit together"); check(!state::commit_item_acquisition(duplicate) && !state::commit_item_acquisition(grant), "duplicate and reused claims refused"); @@ -605,16 +635,14 @@ void verify_claims() { auto account = store::account(); account.characters[0].characterClass = characterClass; check(store::write_account(account), "set fixture class"); - const auto armour = - state::vendor_rewards::armour(state::vendor_rewards::kVanguardPool, characterClass); - for (std::size_t slot = 0; slot < armour.size(); ++slot) { - const auto roll = - static_cast(state::vendor_rewards::kVanguardWeapons.size() + slot); - check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, roll, grant) - == Disposition::prepared - && grant.acquiredDefinitionHash == armour[slot], - "armour selection matches class"); - } + state::vendor_rewards::Pool pool{}; + check(state::vendor_rewards::find(kPackageHash, pool), "find Vanguard fixture pool"); + const auto candidates = state::vendor_rewards::candidates(pool, characterClass); + const auto roll = static_cast(kFixtureWeapons.size()); + check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, roll, grant) + == Disposition::prepared + && grant.acquiredDefinitionHash == candidates[roll], + "armour selection matches class"); } } @@ -634,19 +662,20 @@ void verify_shared_sales() { } const auto row = crucible ? kCrucibleCreditRow : state::kVanguardRewardValueRow; const auto otherRow = crucible ? state::kVanguardRewardValueRow : kCrucibleCreditRow; - const auto& pool = crucible ? state::vendor_rewards::kCruciblePool - : state::vendor_rewards::kVanguardPool; + state::vendor_rewards::Pool pool{}; + check(state::vendor_rewards::find(crucible ? kCruciblePackageHash : kPackageHash, pool), + "find faction fixture pool"); auto account = store::account(); account.characters[0].characterClass = characterClass; check(store::write_account(account) && store::write_unlock(store::Bank::characterObjectValues, row, 1) && store::write_unlock(store::Bank::characterObjectValues, otherRow, 2), "seed independent fixture faction credits"); - const auto roll = static_cast(pool.weapons.size()); + const auto roll = static_cast(kFixtureWeapons.size()); check(state::prepare_vendor_reward_sale(kVendor, g_packageSale, roll, grant) == Disposition::prepared && grant.acquiredDefinitionHash - == state::vendor_rewards::armour(pool, characterClass).front(), + == state::vendor_rewards::candidates(pool, characterClass)[roll], "sale chooses the vendor's own class armour"); state::AccountState after{}; state::unlocks::Table banks{}; @@ -694,6 +723,34 @@ void verify_shared_sales() { ++g_packageHash; check(!state::commit_item_acquisition(grant) && credits(state::kVanguardRewardValueRow) == 2, "changed package cannot consume credit"); + + reset_claim(); + check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, 0, grant) + == Disposition::prepared, + "prepare before installed membership change"); + auto changedPool = fixture_pool(kPackageHash, &kFixtureVanguardArmour); + for (std::size_t classIndex = 0; classIndex < changedPool.items.size(); ++classIndex) { + changedPool.items[classIndex][0] = changedPool.items[classIndex][1]; + changedPool.items[classIndex][1] = changedPool.items[classIndex][2]; + --changedPool.counts[classIndex]; + } + const std::array changedPools{ + changedPool, + fixture_pool(kCruciblePackageHash, &kFixtureCrucibleArmour), + fixture_pool(kGunsmithPackageHash, nullptr), + }; + check(state::vendor_rewards::replace(changedPools) && !state::commit_item_acquisition(grant) + && credits(state::kVanguardRewardValueRow) == 2, + "removed installed member cannot consume a prepared claim credit"); + check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, 0, grant) + == Disposition::prepared + && grant.acquiredDefinitionHash == kFixtureWeapons[1], + "new claim selects only the remaining installed members"); + auto invalidPools = changedPools; + invalidPools[0].items[0][1] = invalidPools[0].items[0][0]; + check(!state::vendor_rewards::replace(invalidPools), + "duplicate installed member refuses catalog replacement"); + reset_pools(); } /** Configures Gunsmith credits without unrelated Vanguard/Crucible selection gates. */ @@ -712,7 +769,7 @@ void reset_gunsmith_claim() { /** Covers weapon-only claims, sale isolation and atomic Gunsmith credit consumption. */ void verify_gunsmith_sales() { state::PendingItemAcquisition grant{}; - const auto& weapons = state::vendor_rewards::kGunsmithPool.weapons; + const auto& weapons = kFixtureWeapons; for (const auto characterClass : {state::CharacterClass::titan, state::CharacterClass::hunter, state::CharacterClass::warlock}) { @@ -959,6 +1016,7 @@ int main(int argc, char** argv) { read_text(root + "/account_settings_schema.sql"), read_text(root + "/account_settings_defaults.sql")), "open disposable store"); + reset_pools(); verify(); verify_rank_rewards(); verify_claims(); diff --git a/tests/vendor_reputation.ps1 b/tests/vendor_reputation.ps1 index d84bf1e68..58d04ae0f 100644 --- a/tests/vendor_reputation.ps1 +++ b/tests/vendor_reputation.ps1 @@ -11,6 +11,7 @@ $repSqlite = Join-Path $repRoot 'Sunrise/vendor/sqlite' $repSources = @( 'tests/vendor_reputation.cpp', 'Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp', + 'Sunrise/src/state/runtime/vendor_reward_pool.cpp', 'Sunrise/src/state/runtime/state_account_profile_runtime.cpp', 'Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp', 'Sunrise/src/state/runtime/state_account_equipment_runtime.cpp', From 8b0284c665695f2dcaa58b608d00ddf721226581 Mon Sep 17 00:00:00 2001 From: Zach Date: Tue, 22 Sep 2026 22:46:25 -0500 Subject: [PATCH 08/59] 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 e00f7f219..a15185610 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 = 65; +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{}; @@ -573,13 +589,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) @@ -617,10 +696,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 6561b2aef..c7c5a26ba 100644 --- a/Sunrise/src/state/runtime/runtime.h +++ b/Sunrise/src/state/runtime/runtime.h @@ -4,10 +4,11 @@ #include #include #include -#include #include "../build_data/items/quest_initialization.h" #include "../build_data/records/definition.h" +#include "../build_data/rewards/definition.h" +#include "../unlocks/definition.h" #include "state.h" namespace sunrise::state::account::settings { @@ -18,6 +19,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. @@ -199,34 +203,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. */ @@ -240,6 +233,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. */ @@ -267,12 +262,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{}; }; @@ -533,17 +525,19 @@ set_selected_title(std::uint16_t recordIndex, std::uint64_t& characterSoid, bool [[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; @@ -557,7 +551,8 @@ set_selected_title(std::uint16_t recordIndex, std::uint64_t& characterSoid, bool */ [[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 969a91706..4bdf258af 100644 --- a/Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp +++ b/Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp @@ -239,100 +239,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. @@ -453,80 +359,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 /** @@ -557,13 +389,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 10fda40d0..bb936740e 100644 --- a/Sunrise/src/state/runtime/state_account_transaction_helpers.h +++ b/Sunrise/src/state/runtime/state_account_transaction_helpers.h @@ -156,13 +156,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 1f45472e9b9419b3878364dc075dbdbfcff71535 Mon Sep 17 00:00:00 2001 From: Gage Fulwood Date: Tue, 22 Sep 2026 22:46:36 -0500 Subject: [PATCH 09/59] Support version-3 investment saves in vendor reputation build --- .../resources/database/investment_schema.sql | 9 ++++- .../state/investment/investment_database.cpp | 21 ++++++++++-- tests/vendor_reputation.cpp | 33 +++++++++++++++++-- 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/Sunrise/resources/database/investment_schema.sql b/Sunrise/resources/database/investment_schema.sql index 662d673fd..a96d6c495 100644 --- a/Sunrise/resources/database/investment_schema.sql +++ b/Sunrise/resources/database/investment_schema.sql @@ -1,5 +1,5 @@ PRAGMA application_id = 1397902921; -PRAGMA user_version = 2; +PRAGMA user_version = 3; CREATE TABLE account ( id INTEGER PRIMARY KEY CHECK (id = 1), @@ -99,6 +99,13 @@ CREATE TABLE family5 ( UNIQUE (kind, slot) ) STRICT; +CREATE TABLE character_objective_values ( + character_soid INTEGER NOT NULL REFERENCES characters(soid) DEFERRABLE INITIALLY DEFERRED, + slot INTEGER NOT NULL CHECK (slot BETWEEN 0 AND 32767), + value INTEGER NOT NULL CHECK (value BETWEEN 0 AND 2147483647), + PRIMARY KEY (character_soid, slot) +) STRICT; + CREATE TABLE entitlements ( position INTEGER PRIMARY KEY, name TEXT NOT NULL, diff --git a/Sunrise/src/state/investment/investment_database.cpp b/Sunrise/src/state/investment/investment_database.cpp index bce004936..6422136f5 100644 --- a/Sunrise/src/state/investment/investment_database.cpp +++ b/Sunrise/src/state/investment/investment_database.cpp @@ -119,12 +119,12 @@ bool open(std::string_view path, && execute(preferenceSchema.c_str()) && execute(preferenceDefaults.c_str()) && transaction.commit(); } else if (ready) { - // Version 2 adds account preferences and per-item seen state. - constexpr int kSchemaVersion = 2; + // Version 3 adds character-owned objective counters. + constexpr int kSchemaVersion = 3; constexpr int kApplicationId = 1397902921; int application = 0; Statement query("PRAGMA application_id"); - ready = (version == 1 || version == kSchemaVersion) && query.step() == SQLITE_ROW + ready = (version >= 1 && version <= kSchemaVersion) && query.step() == SQLITE_ROW && query.column(0, application) && application == kApplicationId; } if (ready && version == 1) { @@ -137,6 +137,21 @@ bool open(std::string_view path, "IN(0,1));") && execute(preferenceSchema.c_str()) && execute(preferenceDefaults.c_str()) && execute("PRAGMA user_version=2") && transaction.commit(); + if (ready) { + version = 2; + } + } + if (ready && version == 2) { + Transaction transaction; + // Account writes rebuild character rows before the outer transaction ends. + ready = transaction.ready() + && execute("CREATE TABLE character_objective_values (" + "character_soid INTEGER NOT NULL REFERENCES characters(soid) " + "DEFERRABLE INITIALLY DEFERRED," + "slot INTEGER NOT NULL CHECK (slot BETWEEN 0 AND 32767)," + "value INTEGER NOT NULL CHECK (value BETWEEN 0 AND 2147483647)," + "PRIMARY KEY (character_soid,slot)) STRICT;") + && execute("PRAGMA user_version=3") && transaction.commit(); } if (!ready) { shutdown(); diff --git a/tests/vendor_reputation.cpp b/tests/vendor_reputation.cpp index f85e13d25..965d3e392 100644 --- a/tests/vendor_reputation.cpp +++ b/tests/vendor_reputation.cpp @@ -1004,11 +1004,11 @@ bool resolve(const state::AccountState& account, /** * Runs against disposable SQLite only, then checks persistence by closing and reopening it. * @param argc Argument count. - * @param argv Executable, repository SQL directory and new disposable database path. + * @param argv Executable, SQL directory, disposable database, and optional existing-save copy. * @return Zero when every check passes; failed checks abort. */ int main(int argc, char** argv) { - check(argc == 3, "resource directory and disposable database arguments"); + check(argc == 3 || argc == 4, "resource directory and disposable database arguments"); const std::string root = argv[1]; check(store::open(":memory:", read_text(root + "/investment_schema.sql"), @@ -1069,5 +1069,34 @@ int main(int argc, char** argv) { check(credits(kGunsmithCreditRow) == 0 && store::account().characters[0].inventory.count == 1, "Gunsmith item and debit persist together"); store::shutdown(); + sqlite3* legacy{}; + check(sqlite3_open_v2(argv[2], &legacy, SQLITE_OPEN_READWRITE, nullptr) == SQLITE_OK, + "open disposable store as a version-2 fixture"); + check(sqlite3_exec(legacy, + "DROP TABLE character_objective_values; PRAGMA user_version=2;", + nullptr, + nullptr, + nullptr) + == SQLITE_OK, + "prepare version-2 schema without objective counters"); + check(sqlite3_close(legacy) == SQLITE_OK, "close version-2 fixture"); + check(store::open(argv[2], {}, {}, {}, {}), "migrate saved store from version 2"); + check(credits(kGunsmithCreditRow) == 0 && store::account().characters[0].inventory.count == 1, + "migration preserves vendor claim and inventory"); + store::shutdown(); + check(sqlite3_open_v2(argv[2], &legacy, SQLITE_OPEN_READONLY, nullptr) == SQLITE_OK, + "inspect migrated disposable store"); + sqlite3_stmt* version{}; + check(sqlite3_prepare_v2(legacy, "PRAGMA user_version", -1, &version, nullptr) == SQLITE_OK + && sqlite3_step(version) == SQLITE_ROW && sqlite3_column_int(version, 0) == 3, + "migration advances schema version"); + check(sqlite3_finalize(version) == SQLITE_OK && sqlite3_close(legacy) == SQLITE_OK, + "close migrated fixture"); + if (argc == 4) { + check(store::open(argv[3], {}, {}, {}, {}), "open existing version-3 save copy"); + state::AccountState existing{}; + check(store::read_account(existing), "read account from existing save copy"); + store::shutdown(); + } std::puts("PASS: reputation transaction, exact debit/credit, refusal, staleness and rollback"); } From 5455c170931863c3d48e5119443f7bc5d258c08a Mon Sep 17 00:00:00 2001 From: Gage Fulwood Date: Tue, 22 Sep 2026 23:05:44 -0500 Subject: [PATCH 10/59] Use authored faction rank costs and reward-list slot --- Sunrise/src/client/content/vendors/layout.h | 4 ++-- .../state_vendor_reputation_runtime.cpp | 9 ++++----- tests/vendor_reputation.cpp | 19 ++++++++++--------- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/Sunrise/src/client/content/vendors/layout.h b/Sunrise/src/client/content/vendors/layout.h index 4946336f4..46c4a4a28 100644 --- a/Sunrise/src/client/content/vendors/layout.h +++ b/Sunrise/src/client/content/vendors/layout.h @@ -8,8 +8,8 @@ namespace sunrise::client::content::vendors { /** Tag of the installed vendor index blob, which names every vendor definition. */ inline constexpr std::uint32_t kIndexRootTag = 0x8131931DU; -/** Investment-root slot holding nested item reward lists used by faction packages. */ -inline constexpr std::size_t kRewardListTableSlot = 176; +/** Build-86657 investment-root slot holding nested faction reward lists. */ +inline constexpr std::size_t kRewardListTableSlot = 88; /** Native reward-list table row class. */ inline constexpr std::uint32_t kRewardListRowClass = 0x8080748CU; /** Native reward-list table row size. */ diff --git a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp index a280dc809..a9988c457 100644 --- a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp +++ b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp @@ -211,9 +211,8 @@ VendorReputationDisposition resolve_award(std::uint16_t vendorIndex, build_data::progressions::kStepPerDefinitionCapacity> steps{}; if (!build_data::progressions::steps(rule.progressionIndex, steps, count) || count < 2 - || steps[0].cost != 0 || std::any_of( - steps.begin() + 1, + steps.begin(), steps.begin() + static_cast(count), [](const auto& step) { return step.cost <= 0; })) { return VendorReputationDisposition::refused; @@ -265,7 +264,7 @@ bool charge_materials(AccountState& account, const VendorReputationAward& award) * Walks one captured ladder and repeats its final cost only when installed content says to. * @param award Prepared award carrying the installed rank costs. * @param experience Nonnegative progression XP. - * @param rank Receives the number of completed steps, including the zero-cost first step. + * @param rank Receives the number of completed positive-cost steps. * @return False when the captured ladder cannot define ranks. */ bool rank_at_experience(const VendorReputationAward& award, @@ -273,13 +272,13 @@ bool rank_at_experience(const VendorReputationAward& award, std::int64_t& rank) noexcept { rank = 0; if (experience < 0 || award.rankStepCount < 2 - || award.rankStepCount > award.rankStepCosts.size() || award.rankStepCosts[0] != 0) { + || award.rankStepCount > award.rankStepCosts.size() || award.rankStepCosts[0] <= 0) { return false; } std::int64_t remaining = experience; for (std::size_t step = 0; step < award.rankStepCount; ++step) { const auto cost = award.rankStepCosts[step]; - if (step != 0 && cost <= 0) { + if (cost <= 0) { return false; } if (remaining < cost) { diff --git a/tests/vendor_reputation.cpp b/tests/vendor_reputation.cpp index 965d3e392..58fa63934 100644 --- a/tests/vendor_reputation.cpp +++ b/tests/vendor_reputation.cpp @@ -112,10 +112,10 @@ void reset_pools() { } /** Build-86657 Gunsmith's repeating rank costs 3000 XP. */ constexpr std::int32_t kGunsmithRankCost = 3000; -/** Four rows exercise the zero-cost first rank and three installed rank costs. */ +/** Four positive-cost rows exercise finite ranks and the repeating tail. */ constexpr std::size_t kFixtureRankStepCount = 4; std::array g_rankSteps{{ - {0}, + {kGunsmithRankCost}, {kGunsmithRankCost}, {kGunsmithRankCost}, {kGunsmithRankCost}, @@ -165,7 +165,8 @@ void reset() { g_progression = kGunsmithProgression; g_characterScope = true; g_materialAvailable = true; - g_rankSteps = {{{0}, {kGunsmithRankCost}, {kGunsmithRankCost}, {kGunsmithRankCost}}}; + g_rankSteps = { + {{kGunsmithRankCost}, {kGunsmithRankCost}, {kGunsmithRankCost}, {kGunsmithRankCost}}}; g_rewardCapacity = kEngramCapacity; g_rewardAvailable = true; state::AccountState account{}; @@ -366,7 +367,7 @@ void verify_rank_rewards() { g_soldHash = entry.placeholder; g_costHash = entry.material; g_progression = entry.progression; - g_rankSteps = {{{0}, {entry.rankCost}, {entry.rankCost}, {entry.rankCost}}}; + g_rankSteps = {{{entry.rankCost}, {entry.rankCost}, {entry.rankCost}, {entry.rankCost}}}; auto account = store::account(); account.profileItems[0].definitionHash = entry.material; check(store::write_account(account) @@ -403,13 +404,13 @@ void verify_rank_rewards() { && gunsmith()[0] == kGunsmithRankCost - kAward, "changed progression cost refuses stale turn-in"); reset(); - g_rankSteps[0].cost = 1; + g_rankSteps[0].cost = 0; check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::refused, - "rank ladder without a zero first step refused"); + "rank ladder with a zero first step refused"); reset(); - /** Three installed costs plus one repeated final cost reach the first tail threshold. */ + /** Four fixture costs plus one repeated final cost reach the first tail threshold. */ constexpr std::int32_t kFirstRepeatedRankThreshold = - kGunsmithRankCost * static_cast(kFixtureRankStepCount); + kGunsmithRankCost * static_cast(kFixtureRankStepCount + 1); check(store::write_unlock(store::Bank::characterProgressions, kGunsmithProgression, kFirstRepeatedRankThreshold - kAward) @@ -462,7 +463,7 @@ void verify_rank_rewards() { auto account = store::account(); /** Uneven costs prove one payment can cross several installed thresholds. */ constexpr std::array - kUnevenRankSteps{{{0}, {90}, {180}, {360}}}; + kUnevenRankSteps{{{90}, {180}, {300}, {360}}}; /** Twenty materials award 600 XP and cross all three paid fixture steps. */ constexpr std::uint32_t kUnevenRankTurnIn = 20; g_rankSteps = kUnevenRankSteps; From f20c6ff6d13ca7b4af9ae7ce118b5a5166d4aed1 Mon Sep 17 00:00:00 2001 From: Gage Fulwood Date: Tue, 22 Sep 2026 23:55:49 -0500 Subject: [PATCH 11/59] Update vendor reputation test fixture for runtime rewards --- tests/vendor_reputation.cpp | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/vendor_reputation.cpp b/tests/vendor_reputation.cpp index 58fa63934..ba3d7011a 100644 --- a/tests/vendor_reputation.cpp +++ b/tests/vendor_reputation.cpp @@ -862,10 +862,6 @@ bool sale_row(const Definition&, std::size_t row, SaleRow& output) noexcept { } // namespace sunrise::state::build_data::vendors namespace sunrise::state::build_data { -/** Season bundles are not part of the faction grant under test. */ -bool find_season_pass_package(std::uint32_t, season_pass::Package&) noexcept { - return false; -} /** Unrelated acquisition code shares the profile helper translation unit. */ bool find_collectible_definition(std::uint16_t, collectibles::Definition&) noexcept { return false; From ce7a3487fcab2b9d18b91c780ce80b9cce4d6cf4 Mon Sep 17 00:00:00 2001 From: Gage Fulwood Date: Wed, 23 Sep 2026 00:52:00 -0500 Subject: [PATCH 12/59] Route vendor rank claims through installed reward grants --- Sunrise/Sunrise.vcxproj | 2 - .../items/packages/package_item_build.cpp | 26 +- Sunrise/src/client/content/vendors/layout.h | 39 -- .../content/vendors/package_vendor_build.cpp | 303 ----------- .../src/client/content/vendors/vendor_build.h | 16 - .../web_service_vendor_actions.cpp | 14 +- Sunrise/src/state/runtime/runtime.h | 2 +- .../state_account_acquisition_runtime.cpp | 17 +- .../state_account_reward_grant_runtime.cpp | 15 + .../state_vendor_reputation_runtime.cpp | 111 ++-- .../runtime/state_vendor_reputation_runtime.h | 12 +- .../src/state/runtime/vendor_reward_pool.cpp | 123 ----- .../src/state/runtime/vendor_reward_pool.h | 59 --- tests/vendor_reputation.cpp | 487 +++++++----------- tests/vendor_reputation.ps1 | 7 +- 15 files changed, 246 insertions(+), 987 deletions(-) delete mode 100644 Sunrise/src/state/runtime/vendor_reward_pool.cpp delete mode 100644 Sunrise/src/state/runtime/vendor_reward_pool.h diff --git a/Sunrise/Sunrise.vcxproj b/Sunrise/Sunrise.vcxproj index 139678609..66e465896 100644 --- a/Sunrise/Sunrise.vcxproj +++ b/Sunrise/Sunrise.vcxproj @@ -161,7 +161,6 @@ - @@ -648,7 +647,6 @@ - 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 dc1b37599..098fb3cb0 100644 --- a/Sunrise/src/client/content/items/packages/package_item_build.cpp +++ b/Sunrise/src/client/content/items/packages/package_item_build.cpp @@ -10,7 +10,6 @@ #include "../../../../state/build_data/activities/activity_catalog.h" #include "../../../../state/build_data/rewards/reward_catalog.h" #include "../../../../state/build_data/runtime.h" -#include "../../../../state/runtime/vendor_reward_pool.h" #include "../../activity/activity_catalog_build.h" #include "../../activity/entity_position_profile_build.h" #include "../../hash_names/hash_name_build.h" @@ -51,7 +50,7 @@ namespace { bool ready() noexcept { return root_domains_ready() && state::build_data::scenario_layouts_ready() && state::build_data::spawn_sets_ready() && state::build_data::hash_names_ready() - && state::build_data::vendor_catalog_ready() && state::vendor_rewards::settled() + && state::build_data::vendor_catalog_ready() && content::activity::entity_position_profiles::ready() && (state::build_data::activities::ready() || state::build_data::activities::extraction_failed()); @@ -92,7 +91,7 @@ bool build() noexcept { return true; } } - if (root_domains_ready() && state::vendor_rewards::settled()) { + if (root_domains_ready()) { SecureZeroMemory(&keys, sizeof keys); return ready(); } @@ -241,21 +240,6 @@ bool build() noexcept { } const bool rowsBuilt = located && !root_domains_ready() && build_item_rows(source, storage, table, rowCount, reason); - // Other root builders reuse child for their own tables; consume the item index now. - if (located && state::build_data::item_definitions_ready() - && state::build_data::configured_item_details_ready() - && !state::vendor_rewards::settled()) { - if (!content::vendors::build_rewards(source, - storage.scratch, - std::span{storage.root}, - std::span{storage.child}, - table)) { - core::log::writef(core::log::Channel::state, - core::log::Level::warn, - "ev=build_data stage=vendor_rewards result=unavailable"); - state::vendor_rewards::settle_unavailable(); - } - } if (rowsBuilt) { if (!build_material_requirements( source, storage, std::span{storage.root}, table.count)) { @@ -272,12 +256,6 @@ bool build() noexcept { } } } - if (root_domains_ready() && !state::vendor_rewards::settled()) { - state::vendor_rewards::settle_unavailable(); - core::log::writef(core::log::Channel::state, - core::log::Level::warn, - "ev=build_data stage=vendor_rewards result=unavailable_root"); - } SecureZeroMemory(&keys, sizeof keys); const bool complete = ready(); const bool itemDomainsReady = root_domains_ready(); diff --git a/Sunrise/src/client/content/vendors/layout.h b/Sunrise/src/client/content/vendors/layout.h index 46c4a4a28..82ea6b3bf 100644 --- a/Sunrise/src/client/content/vendors/layout.h +++ b/Sunrise/src/client/content/vendors/layout.h @@ -8,45 +8,6 @@ namespace sunrise::client::content::vendors { /** Tag of the installed vendor index blob, which names every vendor definition. */ inline constexpr std::uint32_t kIndexRootTag = 0x8131931DU; -/** Build-86657 investment-root slot holding nested faction reward lists. */ -inline constexpr std::size_t kRewardListTableSlot = 88; -/** Native reward-list table row class. */ -inline constexpr std::uint32_t kRewardListRowClass = 0x8080748CU; -/** Native reward-list table row size. */ -inline constexpr std::size_t kRewardListRowStride = 24; -/** Native reward-list entry class. */ -inline constexpr std::uint32_t kRewardEntryClass = 0x8080748EU; -/** Native reward-list entry size. */ -inline constexpr std::size_t kRewardEntryStride = 80; -/** Item-definition index in one reward entry. */ -inline constexpr std::size_t kRewardEntryItemOffset = 0; -/** Direct child-list selector in one reward entry. */ -inline constexpr std::size_t kRewardEntryChildOffset = 8; -/** Separate selector with an unresolved target space; do not treat it as a child list. */ -inline constexpr std::size_t kRewardEntryOtherSelectorOffset = 10; -/** Expression array descriptor in one reward entry. */ -inline constexpr std::size_t kRewardEntryConditionOffset = 32; -/** All-one item/list selectors do not name an installed row. */ -inline constexpr std::uint16_t kNoRewardSelector = 0xFFFFU; -/** Self-relative reward-sack pointer in an item definition. */ -inline constexpr std::size_t kItemRewardSackPointer = 0x58; -/** Native marker immediately before a resolved sack block. */ -inline constexpr std::uint32_t kRewardSackClass = 0x808077CCU; -/** Native reward-list selector at the start of a sack block. */ -inline constexpr std::size_t kRewardSackListOffset = 0; -/** Sack-entry array descriptor in the resolved sack block. */ -inline constexpr std::size_t kRewardSackEntriesOffset = 8; -/** Native sack-entry class. */ -inline constexpr std::uint32_t kRewardSackEntryClass = 0x808077CFU; -/** Native sack-entry row size. */ -inline constexpr std::size_t kRewardSackEntryStride = 12; -/** Hunter armour leaves use FLAG[239] in the installed reward list. */ -inline constexpr std::uint32_t kHunterArmourFlag = 239; -/** Titan armour leaves use FLAG[264] in the installed reward list. */ -inline constexpr std::uint32_t kTitanArmourFlag = 264; -/** Warlock armour leaves use FLAG[271] in the installed reward list. */ -inline constexpr std::uint32_t kWarlockArmourFlag = 271; - /** A vendor definition holds its installed array descriptor here. */ inline constexpr std::size_t kInstalledArrayDescriptor = 32; /** A vendor definition holds its sale array descriptor here. */ diff --git a/Sunrise/src/client/content/vendors/package_vendor_build.cpp b/Sunrise/src/client/content/vendors/package_vendor_build.cpp index 3ca82c1d8..9269d4105 100644 --- a/Sunrise/src/client/content/vendors/package_vendor_build.cpp +++ b/Sunrise/src/client/content/vendors/package_vendor_build.cpp @@ -10,7 +10,6 @@ #include "../../../middleware/content/packages/tables/definition_index_table.h" #include "../../../state/build_data/runtime.h" #include "../../../state/build_data/vendors/definition.h" -#include "../../../state/runtime/vendor_reward_pool.h" #include "layout.h" #include "vendor_build.h" @@ -20,9 +19,6 @@ namespace { namespace reader = middleware::content::packages::reader; namespace tables = middleware::content::packages::tables; namespace domain = state::build_data::vendors; -namespace rewards = state::vendor_rewards; -/** All three playable character classes may receive an unguarded reward leaf. */ -constexpr std::uint8_t kAllCharacterClasses = 0b111; /** Every extracted row, kept off the caller stack. */ struct Storage { @@ -290,243 +286,6 @@ void report(const Storage& storage, std::size_t skipped, const char* result) noe } } -/** - * Only installed instanced gear may enter the supported one-item payout pool. - * @param itemIndex Native item-definition index in one reward entry. - * @param hash Receives the item hash on success. - * @return False for missing rows, quests, cosmetics or non-equippable items. - */ -[[nodiscard]] bool reward_gear(std::uint16_t itemIndex, std::uint32_t& hash) noexcept { - state::build_data::items::Definition item{}; - state::build_data::items::details::Definition detail{}; - if (!state::build_data::find_item_definition_index(itemIndex, item) - || item.questInitialization.scope - != state::build_data::items::QuestInitialization::Scope::none - || !state::build_data::find_configured_item_detail(itemIndex, detail) - || detail.definitionIndex != itemIndex || detail.definitionHash != item.definitionHash - || detail.bucketId != item.bucketId || !detail.equipmentSlot.has_value() - || detail.instancedDefinitionState - != state::build_data::items::details::InstancedDefinitionState::instanced) { - return false; - } - hash = item.definitionHash; - return true; -} - -/** - * Gear leaves accept no gate or one class FLAG instruction; other programs are not payout data. - * @param blob Whole reward-list table. - * @param entry Offset of the entry holding the expression. - * @param mask Receives permitted character-class bits on success. - * @return False when the expression uses an unsupported operation or flag. - */ -[[nodiscard]] bool -reward_class_mask(std::span blob, std::size_t entry, std::uint8_t& mask) noexcept { - ArrayView expression{}; - if (!read_array(blob, - entry + kRewardEntryConditionOffset, - tables::kUnlockInstructionStride, - expression)) { - return false; - } - if (expression.count == 0) { - mask = kAllCharacterClasses; - return true; - } - std::uint32_t opcode = 0, operand = 0; - if (expression.count != 1 || expression.classId != tables::kInvestmentExpressionRowClass - || !read(blob, expression.base, opcode) - || !read(blob, expression.base + tables::kUnlockInstructionOperandOffset, operand) - || opcode != tables::kUnlockReadFlagOpcode) { - return false; - } - switch (operand) { - case kTitanArmourFlag: - mask = 1U << static_cast(state::CharacterClass::titan); - return true; - case kHunterArmourFlag: - mask = 1U << static_cast(state::CharacterClass::hunter); - return true; - case kWarlockArmourFlag: - mask = 1U << static_cast(state::CharacterClass::warlock); - return true; - default: - return false; - } -} - -/** - * Appends one gear hash only to the classes admitted by its installed expression. - * @param pool Candidate package pool under construction. - * @param hash Installed gear item hash. - * @param classMask Allowed character classes. - * @return False when a class list exceeds the supported capacity. - */ -[[nodiscard]] bool -append_reward(rewards::Pool& pool, std::uint32_t hash, std::uint8_t classMask) noexcept { - for (std::size_t classIndex = 0; classIndex < pool.items.size(); ++classIndex) { - if ((classMask & (1U << classIndex)) == 0) { - continue; - } - auto& count = pool.counts[classIndex]; - if (count == rewards::kCandidateCapacity) { - return false; - } - pool.items[classIndex][count++] = hash; - } - return true; -} - -/** - * Resolves one reward-list row without treating a selector as a direct item index. - * @param blob Whole reward-list table. - * @param lists Bounded top-level list array. - * @param listIndex Native reward-list selector. - * @param entries Receives the selected entry array on success. - * @return False for an absent, malformed or wrong-class list. - */ -[[nodiscard]] bool reward_entries(std::span blob, - const tables::Array& lists, - std::uint16_t listIndex, - ArrayView& entries) noexcept { - if (listIndex >= lists.count) { - return false; - } - const auto row = - lists.dataOffset + (static_cast(listIndex) * kRewardListRowStride); - return read_array(blob, row + tables::kTableArrayDescriptor, kRewardEntryStride, entries) - && entries.classId == kRewardEntryClass; -} - -/** - * Reads one direct child list; nested and non-gear leaves cannot become one-gear payouts. - * @param blob Whole reward-list table. - * @param lists Bounded top-level list array. - * @param child Native selector of the direct child list. - * @param pool Receives supported gear candidates. - * @return False when a gear leaf has an unknown condition or malformed layout. - */ -[[nodiscard]] bool read_reward_branch(std::span blob, - const tables::Array& lists, - std::uint16_t child, - rewards::Pool& pool) noexcept { - ArrayView entries{}; - if (!reward_entries(blob, lists, child, entries)) { - return false; - } - for (std::size_t index = 0; index < entries.count; ++index) { - const auto at = entries.base + (index * kRewardEntryStride); - std::uint16_t itemIndex = kNoRewardSelector, nested = 0, other = 0; - if (!read(blob, at + kRewardEntryItemOffset, itemIndex) - || !read(blob, at + kRewardEntryChildOffset, nested) - || !read(blob, at + kRewardEntryOtherSelectorOffset, other)) { - return false; - } - std::uint32_t hash = 0; - if (itemIndex == kNoRewardSelector || !reward_gear(itemIndex, hash)) { - continue; - } - std::uint8_t classMask = 0; - if (nested != kNoRewardSelector || other != kNoRewardSelector - || !reward_class_mask(blob, at, classMask) || !append_reward(pool, hash, classMask)) { - return false; - } - } - return true; -} - -/** - * Reads a package's sack selector from its installed item definition. - * @param source Installed package directory and borrowed keys. - * @param scratch Shared package block storage. - * @param itemTable Installed item index table bytes. - * @param itemRows Item index array within itemTable. - * @param packageHash Package item hash to resolve. - * @param definition Reusable storage for the package definition. - * @param listIndex Receives the native reward-list selector. - * @return False when the package or its sack block is unreadable. - */ -[[nodiscard]] bool package_reward_list(const reader::Source& source, - reader::Scratch& scratch, - std::span itemTable, - const tables::Array& itemRows, - std::uint32_t packageHash, - std::vector& definition, - std::uint16_t& listIndex) noexcept { - state::build_data::items::Definition package{}; - tables::IndexRow index{}; - std::uint32_t classId = 0, marker = 0; - if (!state::build_data::find_item_definition_hash(packageHash, package) - || package.definitionIndex >= itemRows.count - || !tables::index_row(itemTable, itemRows, package.definitionIndex, index) - || index.definitionHash != packageHash - || !reader::read_tag(source, scratch, index.targetTag, definition, classId) - || classId != tables::kItemDefinitionClass) { - return false; - } - const std::span blob{definition}; - std::int64_t relative = 0; - if (!read(blob, kItemRewardSackPointer, relative) - || relative < -static_cast(kItemRewardSackPointer) - || relative > static_cast(blob.size() - kItemRewardSackPointer)) { - return false; - } - const auto block = - static_cast(static_cast(kItemRewardSackPointer) + relative); - ArrayView sackEntries{}; - return block >= sizeof marker && read(blob, block - sizeof marker, marker) - && marker == kRewardSackClass && read(blob, block + kRewardSackListOffset, listIndex) - && read_array( - blob, block + kRewardSackEntriesOffset, kRewardSackEntryStride, sackEntries) - && sackEntries.classId == kRewardSackEntryClass; -} - -/** - * Reads direct gear branches only; the other selector's target is unresolved. - * @param source Installed package directory and borrowed keys. - * @param scratch Shared package block storage. - * @param itemTable Installed item index table bytes. - * @param itemRows Item index array within itemTable. - * @param listsBlob Whole reward-list table. - * @param lists Bounded top-level reward-list array. - * @param packageHash Package item hash to resolve. - * @param pool Receives supported class-specific gear. - * @param definition Reusable package-definition storage. - * @return False when the supported tree has an unreadable list or gear gate. - */ -[[nodiscard]] bool read_reward_pool(const reader::Source& source, - reader::Scratch& scratch, - std::span itemTable, - const tables::Array& itemRows, - std::span listsBlob, - const tables::Array& lists, - std::uint32_t packageHash, - rewards::Pool& pool, - std::vector& definition) noexcept { - pool = {}; - pool.packageHash = packageHash; - std::uint16_t parent = kNoRewardSelector; - ArrayView entries{}; - if (!package_reward_list(source, scratch, itemTable, itemRows, packageHash, definition, parent) - || !reward_entries(listsBlob, lists, parent, entries)) { - return false; - } - for (std::size_t index = 0; index < entries.count; ++index) { - const auto at = entries.base + (index * kRewardEntryStride); - std::uint16_t child = kNoRewardSelector; - std::uint8_t gate = 0; - if (!read(listsBlob, at + kRewardEntryChildOffset, child) - || !reward_class_mask(listsBlob, at, gate) || gate != kAllCharacterClasses) { - return false; - } - // The +10 selector is unresolved; do not follow it as a reward-list child. - if (child != kNoRewardSelector && !read_reward_branch(listsBlob, lists, child, pool)) { - return false; - } - } - return true; -} - } // namespace /** Extracts and publishes the vendor catalog from the installed packages. */ @@ -567,66 +326,4 @@ bool build(const reader::Source& source, reader::Scratch& scratch) noexcept { return published; } -/** - * Publishes the supported nested faction reward pools from the installed investment root. - * @param source Installed package directory and borrowed keys. - * @param scratch Shared package block storage. - * @param root Installed investment-root bytes. - * @param itemTable Installed item index table bytes. - * @param itemRows Item index array within itemTable. - * @return False when any supported package pool is unreadable or invalid. - */ -bool build_rewards(const reader::Source& source, - reader::Scratch& scratch, - std::span root, - std::span itemTable, - const tables::Array& itemRows) noexcept { - if (rewards::ready()) { - return true; - } - std::uint32_t tag = 0; - static std::vector listsBlob{}; - static std::vector definition{}; - tables::Array lists{}; - if (!state::build_data::item_definitions_ready() - || !state::build_data::configured_item_details_ready() - || !tables::slot_tag(root, kRewardListTableSlot, tag) || tag == 0 - || !reader::read_tag(source, scratch, tag, listsBlob) - || !tables::find_array_at(listsBlob, tables::kTableArrayDescriptor, lists) - || lists.elementClass != kRewardListRowClass - || lists.count > (std::numeric_limits::max)() - || lists.dataOffset > listsBlob.size() - || lists.count > (listsBlob.size() - lists.dataOffset) / kRewardListRowStride) { - return false; - } - std::array pools{}; - for (std::size_t index = 0; index < pools.size(); ++index) { - if (!read_reward_pool(source, - scratch, - itemTable, - itemRows, - listsBlob, - lists, - rewards::kPackageHashes[index], - pools[index], - definition)) { - core::log::writef(core::log::Channel::state, - core::log::Level::warn, - "ev=build_data stage=vendor_rewards result=skip package=0x%08X", - rewards::kPackageHashes[index]); - return false; - } - } - const bool published = rewards::replace(pools); - core::log::writef( - core::log::Channel::state, - published ? core::log::Level::info : core::log::Level::warn, - "ev=build_data stage=vendor_rewards result=%s vanguard=%zu crucible=%zu gunsmith=%zu", - published ? "ok" : "invalid", - pools[0].counts[0], - pools[1].counts[0], - pools[2].counts[0]); - return published; -} - } // namespace sunrise::client::content::vendors diff --git a/Sunrise/src/client/content/vendors/vendor_build.h b/Sunrise/src/client/content/vendors/vendor_build.h index 9ca57a5c2..d7677a36b 100644 --- a/Sunrise/src/client/content/vendors/vendor_build.h +++ b/Sunrise/src/client/content/vendors/vendor_build.h @@ -18,20 +18,4 @@ namespace sunrise::client::content::vendors { [[nodiscard]] bool build(const middleware::content::packages::reader::Source& source, middleware::content::packages::reader::Scratch& scratch) noexcept; -/** - * Reads supported faction package gear leaves from the installed nested reward lists. - * @param source Installed package directory and borrowed keys. - * @param scratch Lock-owned package block storage. - * @param root Resolved investment root. - * @param itemTable Resolved item index table bytes. - * @param itemRows Item index array in itemTable. - * @return True when all supported packages have published their candidate pools. - */ -[[nodiscard]] bool -build_rewards(const middleware::content::packages::reader::Source& source, - middleware::content::packages::reader::Scratch& scratch, - std::span root, - std::span itemTable, - const middleware::content::packages::tables::Array& itemRows) noexcept; - } // namespace sunrise::client::content::vendors diff --git a/Sunrise/src/server/web_service/web_service_vendor_actions.cpp b/Sunrise/src/server/web_service/web_service_vendor_actions.cpp index 649e4921f..adcfe294b 100644 --- a/Sunrise/src/server/web_service/web_service_vendor_actions.cpp +++ b/Sunrise/src/server/web_service/web_service_vendor_actions.cpp @@ -1,6 +1,5 @@ /** Vendor, bounty, exchange and quest actions the web service prepares from one request. */ -#include #include #include #include @@ -9,8 +8,6 @@ #include #include "../../core/logging/log.h" -#include "../../middleware/crypto/random_bytes.h" -#include "../../middleware/encoding/byte_order.h" #include "../../middleware/web_service/messages/opcode1820.h" #include "../../middleware/web_service/messages/opcode901/opcode901_codec.h" #include "../../middleware/web_service/messages/opcode904/opcode904_codec.h" @@ -649,15 +646,12 @@ void settle_vendor_row(const middleware::web_service::Message& message, if (vendorIndex >= 0 && vendorIndex <= (std::numeric_limits::max)() && state::is_vendor_reward_category(static_cast(vendorIndex), categoryIndex)) { - auto* reward = emplace_mutation(outcome); - std::array randomBytes{}; + auto* reward = emplace_mutation(outcome); const bool prepared = reward != nullptr && rowIndex >= 0 && rowIndex <= (std::numeric_limits::max)() - && middleware::crypto::random::fill(randomBytes) && state::prepare_vendor_reward_sale(static_cast(vendorIndex), static_cast(rowIndex), - middleware::encoding::read_u32_le(randomBytes), *reward) == state::VendorReputationDisposition::prepared; if (!prepared) { @@ -783,9 +777,8 @@ void acquire_quest(const middleware::web_service::Message& message, Outcome& out return; } if (row == quest::kAbsentSaleIndex) { - auto* reward = emplace_mutation(outcome); - std::array randomBytes{}; - if (reward == nullptr || !middleware::crypto::random::fill(randomBytes)) { + auto* reward = emplace_mutation(outcome); + if (reward == nullptr) { clear_mutation(outcome); report_purchase(quest::kOpcode, "fail", @@ -799,7 +792,6 @@ void acquire_quest(const middleware::web_service::Message& message, Outcome& out state::prepare_vendor_reward(static_cast(request.vendorIndex), static_cast(request.slotIndex), static_cast(request.third), - middleware::encoding::read_u32_le(randomBytes), *reward); if (disposition == state::VendorReputationDisposition::prepared) { report_purchase(quest::kOpcode, diff --git a/Sunrise/src/state/runtime/runtime.h b/Sunrise/src/state/runtime/runtime.h index 3f4d67025..97aa12ffe 100644 --- a/Sunrise/src/state/runtime/runtime.h +++ b/Sunrise/src/state/runtime/runtime.h @@ -143,7 +143,6 @@ struct PendingItemAcquisition { bool directGrant{}; build_data::items::QuestInitialization questInitialization{}; std::int32_t previousQuestValue{}; - VendorRewardClaim vendorReward{}; bool prepared{}; /** @@ -259,6 +258,7 @@ struct PendingRecordRewardGrant { std::size_t beforeProfileItemCount{}; std::size_t afterProfileItemCount{}; std::size_t rewardCount{}; + VendorRewardClaim vendorReward{}; bool prepared{}; }; diff --git a/Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp b/Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp index 21ed8d668..17cd5c612 100644 --- a/Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp +++ b/Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp @@ -337,8 +337,7 @@ valid_item_acquisition_source(const PendingItemAcquisition& mutation) noexcept { || mutation.characterIndex >= current.characterCount || !current.characters[mutation.characterIndex].selected || current.characters[mutation.characterIndex].soid != mutation.characterSoid - || !quest_current(mutation) || !vendor_reward_current(mutation) - || current.primarySoid != mutation.accountSoid + || !quest_current(mutation) || current.primarySoid != mutation.accountSoid || !same_character(current.characters[mutation.characterIndex], mutation.beforeCharacter) || !same_profile_inventory( current, mutation.beforeProfileItems, mutation.expectedProfileItemCount) @@ -363,7 +362,7 @@ valid_item_acquisition_source(const PendingItemAcquisition& mutation) noexcept { } // namespace runtime::detail /** - * Preview inventory, quest values and claim credit together without changing the save. + * Preview inventory and quest values together without changing the save. * @param mutation Prepared acquisition checked against current saved state. * @param after Receives the candidate account; use only on success. * @param afterUnlocks Receives matching account and selected-character unlocks on success. @@ -387,15 +386,11 @@ bool preview_item_acquisition(const PendingItemAcquisition& mutation, } else if (quest.scope == Quest::Scope::character) { afterUnlocks.characterObjectValues[quest.row] = value; } - if (mutation.vendorReward.beforeCredits > 0) { - afterUnlocks.characterObjectValues[mutation.vendorReward.rewardValueRow] = - mutation.vendorReward.beforeCredits - 1; - } return true; } /** - * Inventory, first-step state and claim credit share one transaction; failure rolls all back. + * Inventory and first-step state share one transaction; failure rolls both back. * @param mutation Prepared grant consumed on either success or failure. * @return True when all writes commit against the unchanged prepared state. */ @@ -415,12 +410,6 @@ bool commit_item_acquisition(PendingItemAcquisition& mutation) noexcept { && !investment::store::write_unlock(quest_bank(quest), quest.row, quest.value)) { return false; } - if (prepared.vendorReward.beforeCredits > 0 - && !investment::store::write_unlock(investment::store::Bank::characterObjectValues, - prepared.vendorReward.rewardValueRow, - prepared.vendorReward.beforeCredits - 1)) { - return false; - } return transaction.commit(); } 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..1b6bf79de 100644 --- a/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp +++ b/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp @@ -342,6 +342,7 @@ bool prepare_item_reward(std::uint16_t itemIndex, bool preview_reward_unlocks(const PendingRecordRewardGrant& mutation, unlocks::Table& after) noexcept { + const std::lock_guard lock(investment::store::g_mutex); if (!mutation.prepared || mutation.rewardCount > mutation.rewards.size() || !investment::store::read_unlocks(after, static_cast(mutation.characterIndex))) { return false; @@ -362,6 +363,15 @@ bool preview_reward_unlocks(const PendingRecordRewardGrant& mutation, after.accountFlags[flag] = unlocks::kFlagSet; } } + const auto& claim = mutation.vendorReward; + if (claim.beforeCredits != 0) { + if (claim.rewardValueRow >= after.characterObjectValues.size() + || !vendor_reward_current(claim) + || after.characterObjectValues[claim.rewardValueRow] != claim.beforeCredits) { + return false; + } + after.characterObjectValues[claim.rewardValueRow] = claim.beforeCredits - 1; + } return true; } @@ -746,6 +756,11 @@ bool commit_record_reward(PendingRecordRewardGrant& mutation) noexcept { investment::store::Bank::accountFlags, flag, unlocks::kFlagSet); } } + if (ready && mutation.vendorReward.beforeCredits > 0) { + ready = investment::store::write_unlock(investment::store::Bank::characterObjectValues, + mutation.vendorReward.rewardValueRow, + mutation.vendorReward.beforeCredits - 1); + } ready = ready && transaction.commit(); } if (!ready && mutation.claimedRecordIndex != kUnclaimedRecordIndex) { diff --git a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp index a9988c457..8c0604cfc 100644 --- a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp +++ b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp @@ -4,10 +4,10 @@ #include #include "../build_data/progressions/progression_catalog.h" +#include "../build_data/rewards/reward_catalog.h" #include "../build_data/vendors/vendor_catalog.h" #include "../investment/store_internal.h" #include "state_account_transaction_helpers.h" -#include "vendor_reward_pool.h" namespace sunrise::state { namespace { @@ -49,7 +49,7 @@ constexpr std::array kReputationRules{{ /** Native progression level walks read experience from lane zero. */ constexpr std::size_t kExperienceLane = 0; -/** Checked build-86657 claim bindings; an unreadable package pool leaves payout unsupported. */ +/** Checked build-86657 claim bindings; the installed reward catalog resolves payout. */ struct RewardRule { std::uint32_t vendorHash; std::uint16_t interaction; @@ -60,14 +60,20 @@ struct RewardRule { /** Only the Vanguard and Crucible sales require FLAG[5901] and VALUE[465]. */ bool requiresSelectionGates{true}; }; +/** Build-86657 Vanguard rank package item hash. */ +constexpr std::uint32_t kVanguardPackageHash = 2746484552U; +/** Build-86657 Crucible rank package item hash. */ +constexpr std::uint32_t kCruciblePackageHash = 3289621657U; +/** Build-86657 Gunsmith rank package item hash. */ +constexpr std::uint32_t kGunsmithPackageHash = 2422825785U; /** Vendor, interaction, category, saved counter, package sale and hash from build 86657. */ constexpr std::array kRewardRules{{ // Zavala's current package previews Vanguard gear. - {69482069U, 40, 3, kVanguardRewardValueRow, 93, vendor_rewards::kVanguardPackageHash}, + {69482069U, 40, 3, kVanguardRewardValueRow, 93, kVanguardPackageHash}, // Shaxx's current package previews Crucible gear. - {3603221665U, 28, 10, kCrucibleRewardValueRow, 96, vendor_rewards::kCruciblePackageHash}, - // Banshee's sale has empty selection gates and uses the shared weapon pool without armour. - {672118013U, 35, 8, kGunsmithRewardValueRow, 16, vendor_rewards::kGunsmithPackageHash, false}, + {3603221665U, 28, 10, kCrucibleRewardValueRow, 96, kCruciblePackageHash}, + // Banshee's sale has no Vanguard/Crucible selection gates. + {672118013U, 35, 8, kGunsmithRewardValueRow, 16, kGunsmithPackageHash, false}, }}; /** Reply zero completes the supported normal reward interactions. */ constexpr std::uint16_t kAcceptRewardReply = 0; @@ -96,7 +102,8 @@ const RewardRule* reward_rule(std::uint16_t vendorIndex) noexcept { } /** - * Accept only the supported package and explicit evaluated gates; do not guess missing values. + * Accept only the supported auto-opening package and explicit evaluated gates. + * Caller holds the investment-store lock while reading the current gates. * @param rule Checked vendor binding. * @param saleIndex Requested sale, retained for commit revalidation. * @return True when saved gates and the installed package binding agree. @@ -106,11 +113,16 @@ bool reward_binding_current(const RewardRule& rule, std::uint16_t saleIndex) noe vendors::Definition vendor{}; vendors::SaleRow sale{}; build_data::items::Definition package{}; + build_data::rewards::Item reward{}; if (saleIndex != rule.saleIndex || !vendors::find(rule.vendorHash, vendor) || !vendors::sale_row(vendor, saleIndex, sale) || sale.categoryIndex != rule.category || sale.costQuantity != 0 || !build_data::find_item_definition_index(sale.itemIndex, package) - || package.definitionHash != rule.packageHash) { + || package.definitionHash != rule.packageHash + || !build_data::rewards::find_item(sale.itemIndex, reward) + || reward.definitionHash != package.definitionHash + || reward.poolIndex == build_data::rewards::kAbsent + || (reward.flags & build_data::rewards::kOpenOnAcquisition) == 0) { return false; } if (!rule.requiresSelectionGates) { @@ -136,23 +148,6 @@ bool reward_binding_current(const RewardRule& rule, std::uint16_t saleIndex) noe return packageEnabled && levelMet; } -/** - * Supported rewards must be installed instanced gear, not packages, cosmetics or quests. - * @param hash Candidate preview item hash. - * @return True when the installed item and detail rows agree on supported gear. - */ -bool reward_item_supported(std::uint32_t hash) noexcept { - build_data::items::Definition item{}; - build_data::items::details::Definition detail{}; - return build_data::find_item_definition_hash(hash, item) - && item.questInitialization.scope == build_data::items::QuestInitialization::Scope::none - && build_data::find_configured_item_detail(item.definitionIndex, detail) - && detail.definitionHash == hash && detail.definitionIndex == item.definitionIndex - && detail.bucketId == item.bucketId && detail.equipmentSlot.has_value() - && detail.instancedDefinitionState - == build_data::items::details::InstancedDefinitionState::instanced; -} - /** * Resolves a turn-in without treating another purchase using the same material as reputation. * @param vendorIndex Installed vendor selector. @@ -445,15 +440,13 @@ bool is_vendor_reward_category(std::uint16_t vendorIndex, std::int32_t categoryI * @param vendorIndex Installed vendor selector. * @param interactionIndex Rowless interaction selector. * @param replyIndex Reply within that interaction. - * @param random Server-generated selection value. * @param mutation Receives the prepared grant; cleared on refusal. * @return Recognized but unsupported replies refuse rather than falling through to a free grant. */ VendorReputationDisposition prepare_vendor_reward(std::uint16_t vendorIndex, std::uint16_t interactionIndex, std::uint16_t replyIndex, - std::uint32_t random, - PendingItemAcquisition& mutation) noexcept { + PendingRecordRewardGrant& mutation) noexcept { mutation = {}; const auto* rule = reward_rule(vendorIndex); if (rule == nullptr || rule->interaction != interactionIndex) { @@ -462,21 +455,20 @@ VendorReputationDisposition prepare_vendor_reward(std::uint16_t vendorIndex, if (replyIndex != kAcceptRewardReply) { return VendorReputationDisposition::refused; } - return prepare_vendor_reward_sale(vendorIndex, rule->saleIndex, random, mutation); + return prepare_vendor_reward_sale(vendorIndex, rule->saleIndex, mutation); } /** - * A reward sale consumes its own faction credit only together with the granted gear. + * A reward sale binds its rank credit to the installed package's prepared grant. * @param vendorIndex Installed vendor selector. * @param saleIndex Requested package sale. - * @param random Server-generated selection value; never supplied by the client. * @param mutation Receives the prepared grant; cleared on refusal. * @return Unrelated sales are not applicable; unsupported or unaffordable rewards are refused. */ -VendorReputationDisposition prepare_vendor_reward_sale(std::uint16_t vendorIndex, - std::uint16_t saleIndex, - std::uint32_t random, - PendingItemAcquisition& mutation) noexcept { +VendorReputationDisposition +prepare_vendor_reward_sale(std::uint16_t vendorIndex, + std::uint16_t saleIndex, + PendingRecordRewardGrant& mutation) noexcept { mutation = {}; const auto* rule = reward_rule(vendorIndex); build_data::vendors::Definition vendor{}; @@ -492,35 +484,14 @@ VendorReputationDisposition prepare_vendor_reward_sale(std::uint16_t vendorIndex return VendorReputationDisposition::notApplicable; } const std::lock_guard lock(investment::store::g_mutex); - AccountState account{}; std::int32_t credits = 0; - if (!reward_binding_current(*rule, saleIndex) || !investment::store::read_account(account) - || !account::valid(account) || !runtime::detail::valid_profile_inventory(account) + if (!reward_binding_current(*rule, saleIndex) || !investment::store::read_unlock( investment::store::Bank::characterObjectValues, rule->rewardValueRow, credits) || credits <= 0) { return VendorReputationDisposition::refused; } - const auto selected = runtime::detail::selected_character_index(account); - if (selected >= account.characterCount) { - return VendorReputationDisposition::refused; - } - vendor_rewards::Pool pool{}; - if (!vendor_rewards::find(rule->packageHash, pool)) { - return VendorReputationDisposition::refused; - } - std::array eligible{}; - std::size_t count = 0; - for (const auto hash : - vendor_rewards::candidates(pool, account.characters[selected].characterClass)) { - if (reward_item_supported(hash)) { - eligible[count++] = hash; - } - } - // Reconstructed policy chooses one installed gear row; retail weights and extras are unknown. - if (count == 0 - || !runtime::detail::finalize_item_acquisition( - account, account, eligible[random % count], false, {.direct = true}, mutation)) { + if (!prepare_item_reward(sale.itemIndex, 1, mutation)) { mutation = {}; return VendorReputationDisposition::refused; } @@ -529,33 +500,25 @@ VendorReputationDisposition prepare_vendor_reward_sale(std::uint16_t vendorIndex } /** - * Hold the store lock; claim credit and content gates must still match before publication. - * @param mutation Prepared item acquisition with optional vendor claim state. - * @return False for a stale credit, changed gate or an item outside the selected class's pool. + * Claim credit and the installed sale must still match before publication. + * Caller holds the investment-store lock across this check and the reward preview. + * @param claim Prepared claim state; zero credits mean no vendor claim. + * @return False for a stale credit or changed package gate. */ -bool vendor_reward_current(const PendingItemAcquisition& mutation) noexcept { - const auto& claim = mutation.vendorReward; +bool vendor_reward_current(const VendorRewardClaim& claim) noexcept { if (claim.beforeCredits == 0) { return true; } std::int32_t current = 0; const auto* rule = reward_rule(claim.vendorIndex); - if (claim.beforeCredits < 0 || !mutation.directGrant || rule == nullptr - || claim.rewardValueRow != rule->rewardValueRow + if (claim.beforeCredits < 0 || rule == nullptr || claim.rewardValueRow != rule->rewardValueRow || !reward_binding_current(*rule, claim.saleIndex) || !investment::store::read_unlock( investment::store::Bank::characterObjectValues, claim.rewardValueRow, current) - || current != claim.beforeCredits - || !reward_item_supported(mutation.acquiredDefinitionHash)) { + || current != claim.beforeCredits) { return false; } - vendor_rewards::Pool pool{}; - if (!vendor_rewards::find(rule->packageHash, pool)) { - return false; - } - const auto eligible = vendor_rewards::candidates(pool, mutation.beforeCharacter.characterClass); - return std::find(eligible.begin(), eligible.end(), mutation.acquiredDefinitionHash) - != eligible.end(); + return true; } } // namespace sunrise::state diff --git a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h index a01b5f401..af5534202 100644 --- a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h +++ b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h @@ -49,7 +49,7 @@ struct PendingVendorReputation { /** Build-86657 VALUE[927] reads character-object value row 55 for Vanguard claims. */ inline constexpr std::uint16_t kVanguardRewardValueRow = 55; -/** Positive credits identify a prepared claim; zero leaves ordinary acquisitions unchanged. */ +/** Positive credits identify a prepared claim; zero leaves ordinary rewards unchanged. */ struct VendorRewardClaim { std::int32_t beforeCredits{}; std::uint16_t vendorIndex{}; @@ -58,19 +58,17 @@ struct VendorRewardClaim { std::uint16_t rewardValueRow{}; }; -struct PendingItemAcquisition; +struct PendingRecordRewardGrant; [[nodiscard]] VendorReputationDisposition prepare_vendor_reward_sale(std::uint16_t vendorIndex, std::uint16_t saleIndex, - std::uint32_t random, - PendingItemAcquisition& mutation) noexcept; + PendingRecordRewardGrant& mutation) noexcept; [[nodiscard]] VendorReputationDisposition prepare_vendor_reward(std::uint16_t vendorIndex, std::uint16_t interactionIndex, std::uint16_t replyIndex, - std::uint32_t random, - PendingItemAcquisition& mutation) noexcept; -[[nodiscard]] bool vendor_reward_current(const PendingItemAcquisition& mutation) noexcept; + PendingRecordRewardGrant& mutation) noexcept; +[[nodiscard]] bool vendor_reward_current(const VendorRewardClaim& claim) noexcept; [[nodiscard]] bool is_vendor_reward_category(std::uint16_t vendorIndex, std::int32_t categoryIndex) noexcept; diff --git a/Sunrise/src/state/runtime/vendor_reward_pool.cpp b/Sunrise/src/state/runtime/vendor_reward_pool.cpp deleted file mode 100644 index d4f04a790..000000000 --- a/Sunrise/src/state/runtime/vendor_reward_pool.cpp +++ /dev/null @@ -1,123 +0,0 @@ -#include "vendor_reward_pool.h" - -#include -#include - -namespace sunrise::state::vendor_rewards { -namespace { - -std::mutex g_mutex; -std::array g_pools{}; -bool g_ready = false; -bool g_settled = false; - -/** - * Refuses an incomplete or duplicate candidate set before publication. - * @param pool One package and its class-specific gear candidates. - * @return True when all three class lists hold supported unique hashes. - */ -bool valid(const Pool& pool) noexcept { - if (std::find(kPackageHashes.begin(), kPackageHashes.end(), pool.packageHash) - == kPackageHashes.end()) { - return false; - } - for (std::size_t classIndex = 0; classIndex < pool.items.size(); ++classIndex) { - const auto count = pool.counts[classIndex]; - if (count == 0 || count > kCandidateCapacity) { - return false; - } - const auto begin = pool.items[classIndex].begin(); - for (std::size_t index = 0; index < count; ++index) { - const auto hash = pool.items[classIndex][index]; - if (hash == 0 - || std::find(begin, begin + static_cast(index), hash) - != begin + static_cast(index)) { - return false; - } - } - } - return true; -} - -} // namespace - -/** @return True when supported package pools are available for claims. */ -bool ready() noexcept { - const std::lock_guard lock(g_mutex); - return g_ready; -} - -/** @return True when extraction has finished, with or without supported pools. */ -bool settled() noexcept { - const std::lock_guard lock(g_mutex); - return g_settled; -} - -/** An unsupported installed build must not block unrelated content loading. */ -void settle_unavailable() noexcept { - const std::lock_guard lock(g_mutex); - g_settled = true; -} - -/** - * Publishes complete class-specific package membership together. - * @param pools Exactly one pool per supported package. - * @return False without changing the published set when validation fails. - */ -bool replace(std::span pools) noexcept { - if (pools.size() != g_pools.size()) { - return false; - } - for (std::size_t index = 0; index < pools.size(); ++index) { - if (!valid(pools[index]) - || std::find_if( - pools.begin(), - pools.begin() + static_cast(index), - [&](const Pool& other) { return other.packageHash == pools[index].packageHash; }) - != pools.begin() + static_cast(index)) { - return false; - } - } - const std::lock_guard lock(g_mutex); - std::copy(pools.begin(), pools.end(), g_pools.begin()); - g_ready = true; - g_settled = true; - return true; -} - -/** - * Copies one published pool under the catalog lock. - * @param packageHash Installed package item hash. - * @param output Receives the pool on success; unchanged on failure. - * @return False when no supported pool holds the package. - */ -bool find(std::uint32_t packageHash, Pool& output) noexcept { - const std::lock_guard lock(g_mutex); - if (!g_ready) { - return false; - } - const auto found = std::find_if(g_pools.begin(), g_pools.end(), [&](const Pool& pool) { - return pool.packageHash == packageHash; - }); - if (found == g_pools.end()) { - return false; - } - output = *found; - return true; -} - -/** - * Invalid class selectors cannot inherit another class's armour candidates. - * @param pool Installed package candidates. - * @param characterClass Selected character class. - * @return This class's candidates, or empty for an invalid class. - */ -std::span candidates(const Pool& pool, - CharacterClass characterClass) noexcept { - const auto index = static_cast(characterClass); - return index < pool.items.size() && pool.counts[index] <= kCandidateCapacity - ? std::span{pool.items[index].data(), pool.counts[index]} - : std::span{}; -} - -} // namespace sunrise::state::vendor_rewards diff --git a/Sunrise/src/state/runtime/vendor_reward_pool.h b/Sunrise/src/state/runtime/vendor_reward_pool.h deleted file mode 100644 index 1d876675b..000000000 --- a/Sunrise/src/state/runtime/vendor_reward_pool.h +++ /dev/null @@ -1,59 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -#include "../account/account_state.h" - -namespace sunrise::state::vendor_rewards { - -/** Current Vanguard package item hash in build 86657. */ -inline constexpr std::uint32_t kVanguardPackageHash = 2746484552U; -/** Current Crucible package item hash in build 86657. */ -inline constexpr std::uint32_t kCruciblePackageHash = 3289621657U; -/** Current Gunsmith package item hash in build 86657. */ -inline constexpr std::uint32_t kGunsmithPackageHash = 2422825785U; -/** Only these three packages have a supported one-gear payout policy. */ -inline constexpr std::array kPackageHashes{ - kVanguardPackageHash, kCruciblePackageHash, kGunsmithPackageHash}; - -/** The largest build-86657 class list has 30 weapons and five armour pieces. */ -inline constexpr std::size_t kCandidateCapacity = 35; - -/** Installed candidate order for each character class; no item hashes are compiled here. */ -struct Pool { - std::uint32_t packageHash{}; - std::array, 3> items{}; - std::array counts{}; -}; - -/** @return True after the complete supported package set has been published. */ -[[nodiscard]] bool ready() noexcept; - -/** @return True after extraction either published the pools or ruled them unavailable. */ -[[nodiscard]] bool settled() noexcept; - -/** Records that this installed build has no readable supported pool; claims still fail closed. */ -void settle_unavailable() noexcept; - -/** - * Replaces the supported package pools together, refusing malformed or duplicate candidates. - * @param pools One pool for each supported installed package. - * @return True when all pools are valid and published. - */ -[[nodiscard]] bool replace(std::span pools) noexcept; - -/** - * @param packageHash Installed package item hash. - * @param output Receives its pool on success. - * @return True only for a published supported package. - */ -[[nodiscard]] bool find(std::uint32_t packageHash, Pool& output) noexcept; - -/** @return Candidates for a valid selected class, or empty for an invalid class. */ -[[nodiscard]] std::span candidates(const Pool& pool, - CharacterClass characterClass) noexcept; - -} // namespace sunrise::state::vendor_rewards diff --git a/tests/vendor_reputation.cpp b/tests/vendor_reputation.cpp index ba3d7011a..e1583a057 100644 --- a/tests/vendor_reputation.cpp +++ b/tests/vendor_reputation.cpp @@ -5,11 +5,11 @@ #include "core/logging/log.h" #include "middleware/datagen/family4/loadout/loadout_resolver.h" +#include "state/build_data/rewards/reward_catalog.h" #include "state/build_data/runtime.h" #include "state/build_data/vendors/vendor_catalog.h" #include "state/investment/store_internal.h" #include "state/runtime/runtime.h" -#include "state/runtime/vendor_reward_pool.h" namespace { namespace state = sunrise::state; @@ -63,52 +63,38 @@ constexpr std::uint16_t kGunsmithCreditRow = 49, kBansheeClaimInteraction = 35; constexpr std::uint32_t kGunsmithPackageHash = 2422825785U; constexpr std::uint16_t kGunsmithPackageSale = 16, kGunsmithCategory = 8; -/** Small installed-looking fixture; production candidates come from packages, never these rows. */ +/** Synthetic resolved items exercise the installed wrapper-to-reward path. */ constexpr std::array kFixtureWeapons{991314988U, 720351795U}; -/** Vanguard class armour in Titan, Hunter, Warlock order. */ -constexpr std::array kFixtureVanguardArmour{273457849U, 3761819011U, 1578461326U}; -/** Crucible class armour in Titan, Hunter, Warlock order. */ -constexpr std::array kFixtureCrucibleArmour{657606375U, 3153956825U, 3684978064U}; +/** The synthetic character bucket holds a resolved instanced reward. */ +constexpr std::uint8_t kRewardBucket = 2; +/** Native empty bucket tag inherits the pool's bucket constraint. */ +constexpr std::uint32_t kInheritedRewardBucket = 0x811C9DC5U; -/** @return Flat fixture gear hashes across the three supported package pools. */ +/** @param index Synthetic gear row offset. @return The fixture hash, or zero. */ std::uint32_t gear_hash(std::size_t index) { - for (const auto pool : {std::span(kFixtureWeapons), - std::span(kFixtureVanguardArmour), - std::span(kFixtureCrucibleArmour)}) { - if (index < pool.size()) { - return pool[index]; - } - index -= pool.size(); - } - return 0; -} - -/** Seeds one faction package pool with two shared weapons and its own class armour. */ -state::vendor_rewards::Pool fixture_pool(std::uint32_t packageHash, - const std::array* armour) { - state::vendor_rewards::Pool pool{}; - pool.packageHash = packageHash; - for (std::size_t classIndex = 0; classIndex < pool.items.size(); ++classIndex) { - for (const auto hash : kFixtureWeapons) { - pool.items[classIndex][pool.counts[classIndex]++] = hash; - } - if (armour != nullptr) { - pool.items[classIndex][pool.counts[classIndex]++] = (*armour)[classIndex]; - } - } - return pool; + return index < kFixtureWeapons.size() ? kFixtureWeapons[index] : 0; } -/** Restores the disposable test catalog after each controlled membership mutation. */ +/** Reports a failed fixture check. */ void check(bool passed, const char* label); -void reset_pools() { - const std::array pools{ - fixture_pool(kPackageHash, &kFixtureVanguardArmour), - fixture_pool(kCruciblePackageHash, &kFixtureCrucibleArmour), - fixture_pool(kGunsmithPackageHash, nullptr), - }; - check(state::vendor_rewards::replace(pools), "seed faction reward pools"); +/** @param opensOnAcquisition Whether the fixture wrapper expands at acquisition. */ +void seed_reward_catalog(bool opensOnAcquisition = true) { + namespace rewards = state::build_data::rewards; + std::array pools{{{g_packageHash, {0, 1}}}}; + std::array entries{}; + entries[0].itemIndex = kGearIndexBase; + entries[0].quantity = 1; + entries[0].bucketHash = kInheritedRewardBucket; + entries[0].weight = 1; + std::array items{}; + items[kRewardIndex].definitionHash = g_packageHash; + items[kRewardIndex].poolIndex = 0; + items[kRewardIndex].flags = opensOnAcquisition ? rewards::kOpenOnAcquisition : 0; + items[kRewardIndex].selectionCount = 1; + items[kRewardIndex].selections[0].count = 1; + items[kGearIndexBase].definitionHash = kFixtureWeapons.front(); + check(rewards::replace({pools, entries, items, {}, {}, {}}), "seed reward catalog"); } /** Build-86657 Gunsmith's repeating rank costs 3000 XP. */ constexpr std::int32_t kGunsmithRankCost = 3000; @@ -476,10 +462,11 @@ void verify_rank_rewards() { "uneven thresholds award every crossed credit, no items"); } -/** Configures a disposable eligible Zavala claim without changing any player save. */ +/** Configures a disposable eligible Zavala claim without changing player saves. */ void reset_claim() { reset(); g_vendorHash = kZavala; + seed_reward_catalog(); state::Family5State family{}; family.flagCount = 1; family.flags[0] = {kPackageFlag, state::unlocks::kFlagSet}; @@ -491,342 +478,177 @@ void reset_claim() { "seed claim fixture"); } -/** Covers claim publication, exact debit, refusal, class filtering and atomic persistence. */ +/** The native wrapper payout and rank credit share preview, commit and rollback. */ void verify_claims() { - state::PendingItemAcquisition grant{}; + state::PendingRecordRewardGrant grant{}; reset_claim(); - check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, 0, grant) - == Disposition::prepared, - "prepare claim"); + check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, grant) == Disposition::prepared + && grant.rewardCount == 1 + && grant.rewards[0].definitionHash == kFixtureWeapons.front(), + "prepare installed wrapper reward"); auto duplicate = grant; state::AccountState after{}; state::unlocks::Table banks{}; - check(state::preview_item_acquisition(grant, after, banks) + check(state::preview_record_reward_grant(grant, after) + && state::preview_reward_unlocks(grant, banks) && after.characters[0].inventory.count == 1 && banks.characterObjectValues[state::kVanguardRewardValueRow] == 1 - && credits(state::kVanguardRewardValueRow) == 2 - && store::account().characters[0].inventory.count == 0, - "preview publishes item and decremented credit without saving"); - check(state::commit_item_acquisition(grant) && credits(state::kVanguardRewardValueRow) == 1 + && credits(state::kVanguardRewardValueRow) == 2, + "preview grants item and debits credit without saving"); + check(state::commit_record_reward(grant) && credits(state::kVanguardRewardValueRow) == 1 && store::account().characters[0].inventory.count == 1 && store::account().characters[0].inventory.values[0].definitionHash == kFixtureWeapons.front(), - "one item and one credit commit together"); - check(!state::commit_item_acquisition(duplicate) && !state::commit_item_acquisition(grant), + "one reward and one credit commit together"); + check(!state::commit_record_reward(duplicate) && !state::commit_record_reward(grant), "duplicate and reused claims refused"); check(store::read_unlocks(banks, 1) && banks.characterObjectValues[state::kVanguardRewardValueRow] == 0 && store::account().characters[1].inventory.count == 0, "other character unchanged"); + reset_claim(); { store::Transaction outer; check(outer.ready() - && state::prepare_vendor_reward_sale(kVendor, kPackageSale, 0, grant) + && state::prepare_vendor_reward_sale(kVendor, kPackageSale, grant) == Disposition::prepared - && state::commit_item_acquisition(grant), - "claim inside outer response"); + && state::commit_record_reward(grant), + "claim inside response transaction"); } check(credits(state::kVanguardRewardValueRow) == 2 && store::account().characters[0].inventory.count == 0, - "response failure rolls back item and credit"); - check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, 0, grant) - == Disposition::prepared + "response failure rolls back reward and credit"); + check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, grant) == Disposition::prepared && store::execute("CREATE TEMP TRIGGER reject_claim BEFORE INSERT ON unlocks " "BEGIN SELECT RAISE(ABORT,'fixture'); END"), - "inject claim write failure"); - check(!state::commit_item_acquisition(grant) && credits(state::kVanguardRewardValueRow) == 2 + "inject credit write failure"); + check(!state::commit_record_reward(grant) && credits(state::kVanguardRewardValueRow) == 2 && store::account().characters[0].inventory.count == 0, - "credit write failure rolls back item"); + "credit failure rolls back reward"); check(store::execute("DROP TRIGGER reject_claim"), "remove claim fault"); - check(state::prepare_vendor_reward(kVendor, kClaimInteraction, 1, 0, grant) + + check(state::prepare_vendor_reward(kVendor, kClaimInteraction, 1, grant) == Disposition::refused, "wrong reply refused"); - check(state::prepare_vendor_reward(kVendor, kClaimInteraction + 1, 0, 0, grant) + check(state::prepare_vendor_reward(kVendor, kClaimInteraction + 1, 0, grant) == Disposition::notApplicable, "other interactions left alone"); check(state::is_vendor_reward_category(kVendor, kClaimCategory) && !state::is_vendor_reward_category(kVendor, kClaimCategory + 1), - "reward category blocks sale bypass only"); - g_vendorHash = kBanshee; - check(state::prepare_vendor_reward(kVendor, kBansheeClaimInteraction, 0, 0, grant) - == Disposition::refused, - "missing Gunsmith package binding refused"); + "reward category blocks sale bypass"); reset_claim(); g_rewardCapacity = 0; - check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, 0, grant) == Disposition::refused + check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, grant) == Disposition::refused && credits(state::kVanguardRewardValueRow) == 2, "full inventory preserves credit"); - g_rewardCapacity = kEngramCapacity; + reset_claim(); g_rewardAvailable = false; - check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, 0, grant) - == Disposition::refused, - "missing gear refuses payout"); + check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, grant) == Disposition::refused, + "missing reward definition refuses payout"); reset_claim(); - check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, 0, grant) - == Disposition::prepared + check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, grant) == Disposition::prepared && store::write_unlock( store::Bank::characterObjectValues, state::kVanguardRewardValueRow, 1) - && !state::commit_item_acquisition(grant), + && !state::commit_record_reward(grant), "stale credit refuses claim"); check(store::write_unlock(store::Bank::characterObjectValues, state::kVanguardRewardValueRow, 0) - && state::prepare_vendor_reward_sale(kVendor, kPackageSale, 0, grant) + && state::prepare_vendor_reward_sale(kVendor, kPackageSale, grant) == Disposition::refused, "no credit refuses claim"); reset_claim(); - check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, 0, grant) - == Disposition::prepared, - "prepare before gate change"); - check(store::write_family5({}) && !state::commit_item_acquisition(grant) + check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, grant) == Disposition::prepared + && store::write_family5({}) && !state::commit_record_reward(grant) && credits(state::kVanguardRewardValueRow) == 2, - "changed or absent package gates cannot consume credit"); - check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, 0, grant) - == Disposition::refused, - "missing evaluated gates refused"); + "changed package gates preserve credit"); reset_claim(); - state::Family5State gates{}; - check(store::read_family5(gates), "read fixture gates"); - gates.values[0].value = kMinimumLevel - 1; - check(store::write_family5(gates) - && state::prepare_vendor_reward_sale(kVendor, kPackageSale, 0, grant) - == Disposition::refused, - "low-level claim refused"); - reset_claim(); - check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, 0, grant) - == Disposition::prepared, - "prepare before capacity loss"); - g_rewardCapacity = 0; - check(!state::commit_item_acquisition(grant) && credits(state::kVanguardRewardValueRow) == 2, - "capacity lost after prepare preserves credit"); - reset_claim(); - check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, 0, grant) - == Disposition::prepared, - "prepare before character change"); - auto changed = store::account(); - changed.characters[0].selected = false; - changed.characters[1].selected = true; - check(store::write_account(changed) && !state::commit_item_acquisition(grant), - "changed selected character refuses claim"); - + check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, grant) == Disposition::prepared, + "prepare row tamper"); + grant.vendorReward.rewardValueRow = kCrucibleCreditRow; + check(!state::commit_record_reward(grant) && credits(state::kVanguardRewardValueRow) == 2, + "tampered credit row refused"); + check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, grant) == Disposition::prepared, + "prepare sale tamper"); + ++g_packageHash; + check(!state::commit_record_reward(grant) && credits(state::kVanguardRewardValueRow) == 2, + "changed installed package preserves credit"); reset_claim(); - auto existing = store::account(); - auto& inventory = existing.characters[0].inventory; - inventory.count = 1; - inventory.values[0].definitionHash = kVanguardEngram; - inventory.values[0].instanceSoid = kAccount + kCharacter; - inventory.values[0].quantity = 1; - const auto savedSoid = inventory.values[0].instanceSoid; - check(store::write_account(existing) - && state::prepare_vendor_reward_sale(kVendor, kPackageSale, 0, grant) - == Disposition::prepared - && state::commit_item_acquisition(grant), - "claim alongside a previously saved experimental engram"); - const auto saved = store::account(); - check(saved.characters[0].inventory.count == 2 - && saved.characters[0].inventory.values[0].definitionHash == kVanguardEngram - && saved.characters[0].inventory.values[0].instanceSoid == savedSoid, - "existing experimental item preserved without conversion"); - - for (auto characterClass : {state::CharacterClass::titan, - state::CharacterClass::hunter, - state::CharacterClass::warlock}) { - reset_claim(); - auto account = store::account(); - account.characters[0].characterClass = characterClass; - check(store::write_account(account), "set fixture class"); - state::vendor_rewards::Pool pool{}; - check(state::vendor_rewards::find(kPackageHash, pool), "find Vanguard fixture pool"); - const auto candidates = state::vendor_rewards::candidates(pool, characterClass); - const auto roll = static_cast(kFixtureWeapons.size()); - check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, roll, grant) - == Disposition::prepared - && grant.acquiredDefinitionHash == candidates[roll], - "armour selection matches class"); - } + seed_reward_catalog(false); + check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, grant) == Disposition::refused + && credits(state::kVanguardRewardValueRow) == 2, + "non-opening wrapper cannot consume rank credit"); } -/** Both supported vendors use one sale transaction without sharing entitlement or armour. */ +/** Each vendor debits only its own entitlement through the same reward batch. */ void verify_shared_sales() { - state::PendingItemAcquisition grant{}; + state::PendingRecordRewardGrant grant{}; for (const bool crucible : {false, true}) { - for (const auto characterClass : {state::CharacterClass::titan, - state::CharacterClass::hunter, - state::CharacterClass::warlock}) { - reset_claim(); - if (crucible) { - g_vendorHash = kShaxxHash; - g_packageHash = kCruciblePackageHash; - g_packageSale = kCruciblePackageSale; - g_packageCategory = kCrucibleCategory; - } - const auto row = crucible ? kCrucibleCreditRow : state::kVanguardRewardValueRow; - const auto otherRow = crucible ? state::kVanguardRewardValueRow : kCrucibleCreditRow; - state::vendor_rewards::Pool pool{}; - check(state::vendor_rewards::find(crucible ? kCruciblePackageHash : kPackageHash, pool), - "find faction fixture pool"); - auto account = store::account(); - account.characters[0].characterClass = characterClass; - check(store::write_account(account) - && store::write_unlock(store::Bank::characterObjectValues, row, 1) - && store::write_unlock(store::Bank::characterObjectValues, otherRow, 2), - "seed independent fixture faction credits"); - const auto roll = static_cast(kFixtureWeapons.size()); - check(state::prepare_vendor_reward_sale(kVendor, g_packageSale, roll, grant) - == Disposition::prepared - && grant.acquiredDefinitionHash - == state::vendor_rewards::candidates(pool, characterClass)[roll], - "sale chooses the vendor's own class armour"); - state::AccountState after{}; - state::unlocks::Table banks{}; - check(state::preview_item_acquisition(grant, after, banks) - && banks.characterObjectValues[row] == 0 - && banks.characterObjectValues[otherRow] == 2, - "preview debits only the matching faction"); - check(state::commit_item_acquisition(grant) && credits(row) == 0 - && credits(otherRow) == 2, - "shared commit debits only the matching faction"); - check(state::prepare_vendor_reward_sale(kVendor, g_packageSale, roll, grant) - == Disposition::refused, - "another faction's credit cannot authorize this sale"); + reset_claim(); + if (crucible) { + g_vendorHash = kShaxxHash; + g_packageHash = kCruciblePackageHash; + g_packageSale = kCruciblePackageSale; + g_packageCategory = kCrucibleCategory; + seed_reward_catalog(); } + const auto row = crucible ? kCrucibleCreditRow : state::kVanguardRewardValueRow; + const auto otherRow = crucible ? state::kVanguardRewardValueRow : kCrucibleCreditRow; + check(store::write_unlock(store::Bank::characterObjectValues, row, 1) + && store::write_unlock(store::Bank::characterObjectValues, otherRow, 2), + "seed independent faction credits"); + check(state::prepare_vendor_reward_sale(kVendor, g_packageSale, grant) + == Disposition::prepared + && grant.rewardCount == 1, + "prepare vendor wrapper through shared resolver"); + state::unlocks::Table banks{}; + check(state::preview_reward_unlocks(grant, banks) && banks.characterObjectValues[row] == 0 + && banks.characterObjectValues[otherRow] == 2, + "preview debits only matching faction"); + check(state::commit_record_reward(grant) && credits(row) == 0 && credits(otherRow) == 2, + "shared grant debits matching faction"); + check(state::prepare_vendor_reward_sale(kVendor, g_packageSale, grant) + == Disposition::refused, + "another faction's credit cannot authorize this sale"); } reset_claim(); - check(state::prepare_vendor_reward(kVendor, kClaimInteraction, 0, 0, grant) + check(state::prepare_vendor_reward(kVendor, kClaimInteraction, 0, grant) == Disposition::prepared - && state::commit_item_acquisition(grant), - "rowless adapter retains the same guarded settlement"); - check(state::prepare_vendor_reward_sale(kVendor, kSale, 0, grant) == Disposition::notApplicable, - "ordinary sale does not enter reward settlement"); - check(state::prepare_vendor_reward_sale(kVendor, kPackageSale + 1, 0, grant) + && state::commit_record_reward(grant), + "rowless reply uses shared settlement"); + check(state::prepare_vendor_reward_sale(kVendor, kSale, grant) == Disposition::notApplicable, + "ordinary sale stays outside rank settlement"); + check(state::prepare_vendor_reward_sale(kVendor, kPackageSale + 1, grant) == Disposition::refused, - "missing sale cannot claim a package"); - ++g_packageHash; - check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, 0, grant) - == Disposition::refused, - "wrong installed package is refused"); - reset_claim(); + "wrong sale cannot claim a package"); g_packageCost = 1; - check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, 0, grant) - == Disposition::refused, + check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, grant) == Disposition::refused, "nonzero material cost cannot be ignored"); - reset_claim(); - check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, 0, grant) - == Disposition::prepared, - "prepare row tamper check"); - grant.vendorReward.rewardValueRow = kCrucibleCreditRow; - check(!state::commit_item_acquisition(grant) && credits(state::kVanguardRewardValueRow) == 2, - "altered saved counter rejected before indexing or writing"); - check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, 0, grant) - == Disposition::prepared, - "prepare stale package check"); - ++g_packageHash; - check(!state::commit_item_acquisition(grant) && credits(state::kVanguardRewardValueRow) == 2, - "changed package cannot consume credit"); - - reset_claim(); - check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, 0, grant) - == Disposition::prepared, - "prepare before installed membership change"); - auto changedPool = fixture_pool(kPackageHash, &kFixtureVanguardArmour); - for (std::size_t classIndex = 0; classIndex < changedPool.items.size(); ++classIndex) { - changedPool.items[classIndex][0] = changedPool.items[classIndex][1]; - changedPool.items[classIndex][1] = changedPool.items[classIndex][2]; - --changedPool.counts[classIndex]; - } - const std::array changedPools{ - changedPool, - fixture_pool(kCruciblePackageHash, &kFixtureCrucibleArmour), - fixture_pool(kGunsmithPackageHash, nullptr), - }; - check(state::vendor_rewards::replace(changedPools) && !state::commit_item_acquisition(grant) - && credits(state::kVanguardRewardValueRow) == 2, - "removed installed member cannot consume a prepared claim credit"); - check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, 0, grant) - == Disposition::prepared - && grant.acquiredDefinitionHash == kFixtureWeapons[1], - "new claim selects only the remaining installed members"); - auto invalidPools = changedPools; - invalidPools[0].items[0][1] = invalidPools[0].items[0][0]; - check(!state::vendor_rewards::replace(invalidPools), - "duplicate installed member refuses catalog replacement"); - reset_pools(); } -/** Configures Gunsmith credits without unrelated Vanguard/Crucible selection gates. */ -void reset_gunsmith_claim() { +/** Banshee needs no Vanguard/Crucible selection gates. */ +void verify_gunsmith_sales() { reset(); g_packageHash = kGunsmithPackageHash; g_packageSale = kGunsmithPackageSale; g_packageCategory = kGunsmithCategory; + seed_reward_catalog(); check(store::write_family5({}) && store::write_unlock(store::Bank::characterObjectValues, kGunsmithCreditRow, 1) && store::write_unlock( store::Bank::characterObjectValues, state::kVanguardRewardValueRow, 2), - "seed isolated Gunsmith fixture credit"); -} - -/** Covers weapon-only claims, sale isolation and atomic Gunsmith credit consumption. */ -void verify_gunsmith_sales() { - state::PendingItemAcquisition grant{}; - const auto& weapons = kFixtureWeapons; - for (const auto characterClass : {state::CharacterClass::titan, - state::CharacterClass::hunter, - state::CharacterClass::warlock}) { - reset_gunsmith_claim(); - auto account = store::account(); - account.characters[0].characterClass = characterClass; - check(store::write_account(account), "select fixture class"); - for (std::uint32_t roll = 0; roll <= weapons.size(); ++roll) { - check(state::prepare_vendor_reward_sale(kVendor, kGunsmithPackageSale, roll, grant) - == Disposition::prepared - && grant.acquiredDefinitionHash == weapons[roll % weapons.size()], - "all classes receive only the native Gunsmith weapons"); - } - { - store::Transaction outer; - check(outer.ready() && state::commit_item_acquisition(grant), - "commit Gunsmith claim in response transaction"); - } - check(credits(kGunsmithCreditRow) == 1 - && store::account().characters[0].inventory.count == 0, - "failed response restores Gunsmith item and credit"); - check(state::prepare_vendor_reward_sale(kVendor, kGunsmithPackageSale, 0, grant) - == Disposition::prepared, - "prepare Gunsmith claim without selection gates"); - auto duplicate = grant; - check(state::commit_item_acquisition(grant) && credits(kGunsmithCreditRow) == 0 - && credits(state::kVanguardRewardValueRow) == 2 - && !state::commit_item_acquisition(duplicate) - && state::prepare_vendor_reward_sale(kVendor, kGunsmithPackageSale, 0, grant) - == Disposition::refused, - "claim spends only Gunsmith credit and refuses duplicates or borrowed credit"); - } - reset_gunsmith_claim(); - g_rewardCapacity = 0; - check(state::prepare_vendor_reward_sale(kVendor, kGunsmithPackageSale, 0, grant) - == Disposition::refused - && credits(kGunsmithCreditRow) == 1, - "full inventory preserves Gunsmith credit"); - reset_gunsmith_claim(); - check(state::prepare_vendor_reward_sale(kVendor, kGunsmithPackageSale + 1, 0, grant) + "seed isolated Gunsmith credit"); + state::PendingRecordRewardGrant grant{}; + check(state::prepare_vendor_reward(kVendor, kBansheeClaimInteraction, 0, grant) + == Disposition::prepared + && state::commit_record_reward(grant) && credits(kGunsmithCreditRow) == 0 + && credits(state::kVanguardRewardValueRow) == 2, + "Gunsmith claim works without unrelated gates"); + check(state::prepare_vendor_reward_sale(kVendor, kGunsmithPackageSale, grant) == Disposition::refused, - "another sale cannot use Gunsmith credit"); - ++g_packageCategory; - check(state::prepare_vendor_reward_sale(kVendor, kGunsmithPackageSale, 0, grant) - == Disposition::notApplicable - && credits(kGunsmithCreditRow) == 1, - "same package outside reward category cannot use Gunsmith credit"); - reset_gunsmith_claim(); - check(state::prepare_vendor_reward(kVendor, kBansheeClaimInteraction, 0, 0, grant) - == Disposition::prepared, - "Gunsmith reply uses shared settlement"); - ++g_packageHash; - check(!state::commit_item_acquisition(grant) && credits(kGunsmithCreditRow) == 1, - "changed Gunsmith package preserves credit"); + "spent Gunsmith credit cannot be reused"); } - } // namespace namespace sunrise::core::log { @@ -862,6 +684,22 @@ bool sale_row(const Definition&, std::size_t row, SaleRow& output) noexcept { } // namespace sunrise::state::build_data::vendors namespace sunrise::state::build_data { +/** Unrelated emote and season-pass paths are not exercised by this fixture. */ +bool item_definitions_ready() noexcept { + return false; +} +bool configured_item_details_ready() noexcept { + return false; +} +bool socket_plug_rules_ready() noexcept { + return false; +} +bool is_socket_plug_allowed(std::uint16_t, std::uint8_t, std::uint16_t) noexcept { + return false; +} +bool find_season_pass_reward(std::uint16_t, season_pass::Reward&) noexcept { + return false; +} /** Unrelated acquisition code shares the profile helper translation unit. */ bool find_collectible_definition(std::uint16_t, collectibles::Definition&) noexcept { return false; @@ -879,6 +717,7 @@ bool find_item_definition_index(std::uint16_t definitionIndex, } if (definitionIndex >= kGearIndexBase) { definition.definitionHash = gear_hash(definitionIndex - kGearIndexBase); + definition.bucketId = kRewardBucket; return g_rewardAvailable && definition.definitionHash != 0; } return definitionIndex == kSoldIndex || definitionIndex == kCostIndex; @@ -909,6 +748,7 @@ bool find_configured_item_detail(std::uint16_t definitionIndex, definition.instancedDefinitionState = items::details::InstancedDefinitionState::stackable; if (definitionIndex >= kGearIndexBase) { definition.definitionHash = gear_hash(definitionIndex - kGearIndexBase); + definition.bucketId = kRewardBucket; definition.equipmentSlot = 0; definition.instancedDefinitionState = items::details::InstancedDefinitionState::instanced; return g_rewardAvailable && definition.definitionHash != 0; @@ -918,6 +758,11 @@ bool find_configured_item_detail(std::uint16_t definitionIndex, bool find_inventory_bucket_descriptor(std::uint8_t bucketId, inventory::buckets::Descriptor& descriptor) noexcept { descriptor = {}; + if (bucketId == kRewardBucket) { + descriptor.arraySelector = inventory::buckets::ArraySelector::character; + descriptor.slotCount = kEngramCapacity; + return true; + } descriptor.arraySelector = inventory::buckets::ArraySelector::profile; descriptor.slotCount = inventory::buckets::kProfileSlotCapacity; return bucketId == kBucket; @@ -971,8 +816,21 @@ namespace sunrise::state { AccountState account_snapshot() noexcept { return investment::store::account(); } +/** Season-pass settlement is outside the rank-claim fixture. */ +std::uint16_t seasonal_rank() noexcept { + return 0; +} +bool season_pass_reward_claimed(std::uint16_t) noexcept { + return false; +} +void revoke_season_pass_reward(std::uint16_t) noexcept {} } // namespace sunrise::state +namespace sunrise::state::unlocks::records { +/** Vendor batches never claim a record row. */ +void revoke(std::uint16_t) noexcept {} +} // namespace sunrise::state::unlocks::records + namespace sunrise::middleware::datagen::family4::loadout { /** * Models bucket capacity without installed game content; acquisition and persistence are real. @@ -1013,7 +871,7 @@ int main(int argc, char** argv) { read_text(root + "/account_settings_schema.sql"), read_text(root + "/account_settings_defaults.sql")), "open disposable store"); - reset_pools(); + seed_reward_catalog(); verify(); verify_rank_rewards(); verify_claims(); @@ -1045,10 +903,9 @@ int main(int argc, char** argv) { && reopened.characterObjectValues[kGunsmithCreditRow] == 1, "claim credit survives reopening database"); reset_claim(); - state::PendingItemAcquisition claim{}; - check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, 0, claim) - == Disposition::prepared - && state::commit_item_acquisition(claim), + state::PendingRecordRewardGrant claim{}; + check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, claim) == Disposition::prepared + && state::commit_record_reward(claim), "commit persistent claim"); store::shutdown(); check(store::open(argv[2], {}, {}, {}, {}), "reopen claimed save"); @@ -1056,10 +913,16 @@ int main(int argc, char** argv) { && reopened.characterObjectValues[state::kVanguardRewardValueRow] == 1 && store::account().characters[0].inventory.count == 1, "claim debit and item persist together"); - reset_gunsmith_claim(); - check(state::prepare_vendor_reward_sale(kVendor, kGunsmithPackageSale, 0, claim) - == Disposition::prepared - && state::commit_item_acquisition(claim), + reset(); + g_packageHash = kGunsmithPackageHash; + g_packageSale = kGunsmithPackageSale; + g_packageCategory = kGunsmithCategory; + seed_reward_catalog(); + check(store::write_family5({}) + && store::write_unlock(store::Bank::characterObjectValues, kGunsmithCreditRow, 1) + && state::prepare_vendor_reward_sale(kVendor, kGunsmithPackageSale, claim) + == Disposition::prepared + && state::commit_record_reward(claim), "commit persistent Gunsmith claim"); store::shutdown(); check(store::open(argv[2], {}, {}, {}, {}), "reopen Gunsmith claim"); diff --git a/tests/vendor_reputation.ps1 b/tests/vendor_reputation.ps1 index 58d04ae0f..17bda200a 100644 --- a/tests/vendor_reputation.ps1 +++ b/tests/vendor_reputation.ps1 @@ -11,7 +11,10 @@ $repSqlite = Join-Path $repRoot 'Sunrise/vendor/sqlite' $repSources = @( 'tests/vendor_reputation.cpp', 'Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp', - 'Sunrise/src/state/runtime/vendor_reward_pool.cpp', + 'Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp', + 'Sunrise/src/state/build_data/rewards/reward_catalog.cpp', + 'Sunrise/src/state/rewards/reward_resolver.cpp', + 'Sunrise/src/middleware/crypto/random_bytes.cpp', 'Sunrise/src/state/runtime/state_account_profile_runtime.cpp', 'Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp', 'Sunrise/src/state/runtime/state_account_equipment_runtime.cpp', @@ -34,7 +37,7 @@ try { & cmd.exe /d /s /c "`"$repVcVars`" >nul && cl /nologo /O2 /w /TC /c /DSQLITE_OMIT_LOAD_EXTENSION /DSQLITE_THREADSAFE=1 `"$repSqlite/sqlite3.c`" /Fosqlite3.obj" if ($LASTEXITCODE -ne 0) { throw 'SQLite test build failed.' } } - $repCommand = "`"$repVcVars`" >nul && cl /nologo /std:c++20 /EHsc /O2 /Gy /W4 /WX /utf-8 /DWIN32_LEAN_AND_MEAN /DNOMINMAX /I`"$repSource`" /external:I`"$repSqlite`" /external:W0 " + ($repSources -join ' ') + ' sqlite3.obj /Fevendor_reputation.exe /link /OPT:REF /STACK:8388608' + $repCommand = "`"$repVcVars`" >nul && cl /nologo /std:c++20 /EHsc /O2 /Gy /W4 /WX /utf-8 /DWIN32_LEAN_AND_MEAN /DNOMINMAX /I`"$repSource`" /external:I`"$repSqlite`" /external:W0 " + ($repSources -join ' ') + ' sqlite3.obj bcrypt.lib /Fevendor_reputation.exe /link /OPT:REF /STACK:8388608' & cmd.exe /d /s /c $repCommand if ($LASTEXITCODE -ne 0) { throw 'Reputation test build failed.' } $repScratch = Join-Path $repBuild ('reputation-' + [guid]::NewGuid().ToString('N') + '.sqlite3') From ae6d3599131c8fffbc9e26bb0f93dba446d4c79a Mon Sep 17 00:00:00 2001 From: Gage Fulwood Date: Wed, 23 Sep 2026 02:32:41 -0500 Subject: [PATCH 13/59] Handle site-only entries in vendor rank rewards --- .../items/packages/package_reward_build.cpp | 18 ++++- .../cache/records/cache_reward_records.cpp | 2 + .../state/build_data/cache/records/format.h | 7 +- .../src/state/build_data/rewards/definition.h | 2 + Sunrise/src/state/rewards/reward_resolver.cpp | 5 ++ tests/vendor_reputation.cpp | 70 ++++++++++++++++--- tests/vendor_reputation.ps1 | 1 + 7 files changed, 90 insertions(+), 15 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 f943983c6..269980e7d 100644 --- a/Sunrise/src/client/content/items/packages/package_reward_build.cpp +++ b/Sunrise/src/client/content/items/packages/package_reward_build.cpp @@ -20,6 +20,10 @@ namespace domain = state::build_data::rewards; /** Investment-root slots identify reward pools and unlock-slot bindings. */ constexpr std::size_t kPoolSlot = 88; +/** The Reward Site handle is at investment-root +0x548. */ +constexpr std::size_t kSiteTableHandleOffset = 0x548; +/** The supported content has no Reward Site table and marks its handle absent. */ +constexpr std::uint32_t kAbsentSiteTableHandle = 0xFFFFFFFFU; constexpr std::size_t kExpressionSlot = 109; constexpr std::size_t kFlagSlot = 112; constexpr std::size_t kValueSlot = 114; @@ -43,8 +47,8 @@ constexpr std::size_t kSocketStride = 12, kSelectionStride = 12; 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; +/** Serialized reward offsets; entry +10 is a Reward Site reference, not a nested pool. */ +constexpr std::size_t kEntryQuantityOffset = 4, kEntryPoolOffset = 8, kEntrySiteOffset = 10; constexpr std::size_t kEntryCategoryOffset = 20, kEntryWeightOffset = 24; constexpr std::size_t kEntryBucketOffset = 28, kEntryConditionOffset = 32; constexpr std::size_t kEntryModifiersOffset = 48, kEntrySocketsOffset = 64; @@ -88,6 +92,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 + kEntrySiteOffset, entry.rewardSiteIndex) && tables::read(blob, at + kEntryCategoryOffset, entry.categoryHash) && tables::read(blob, at + kEntryWeightOffset, entry.weight) && tables::read(blob, at + kEntryBucketOffset, entry.bucketHash) @@ -444,6 +449,15 @@ bool RewardBuild::load(const reader::Source& source, return false; } } + if (std::any_of(entries.begin(), entries.end(), [](const domain::Entry& entry) { + return entry.rewardSiteIndex != domain::kAbsent; + })) { + std::uint32_t siteHandle = 0; + if (!tables::read(root, kSiteTableHandleOffset, siteHandle) + || siteHandle != kAbsentSiteTableHandle) { + return false; + } + } if (skipped != 0) { core::log::writef(core::log::Channel::client, core::log::Level::warn, 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..5ff053bc5 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,6 +20,7 @@ bool encode(const rewards::Entry& value, RewardEntryRecord& record) noexcept { record = {}; record.itemIndex = value.itemIndex; record.poolIndex = value.poolIndex; + record.rewardSiteIndex = value.rewardSiteIndex; record.quantity = value.quantity; record.categoryHash = value.categoryHash; record.bucketHash = value.bucketHash; @@ -34,6 +35,7 @@ bool decode(const RewardEntryRecord& record, rewards::Entry& value) noexcept { value = {}; value.itemIndex = record.itemIndex; value.poolIndex = record.poolIndex; + value.rewardSiteIndex = record.rewardSiteIndex; value.quantity = record.quantity; value.categoryHash = record.categoryHash; value.bucketHash = record.bucketHash; diff --git a/Sunrise/src/state/build_data/cache/records/format.h b/Sunrise/src/state/build_data/cache/records/format.h index a15185610..dae22a8e8 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. */ @@ -607,10 +607,11 @@ struct RewardPoolRecord { RewardRangeRecord entries{}; }; -/** Reward target, quantity, weight and its dependent bank ranges. */ +/** Reward item, pool or site reference, weight and dependent bank ranges. */ struct RewardEntryRecord { std::uint16_t itemIndex{}; std::uint16_t poolIndex{}; + std::uint16_t rewardSiteIndex{}; std::uint32_t quantity{}; std::uint32_t categoryHash{}; std::uint32_t bucketHash{}; @@ -643,7 +644,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) + 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) diff --git a/Sunrise/src/state/build_data/rewards/definition.h b/Sunrise/src/state/build_data/rewards/definition.h index f00eabc4a..aca880248 100644 --- a/Sunrise/src/state/build_data/rewards/definition.h +++ b/Sunrise/src/state/build_data/rewards/definition.h @@ -69,6 +69,8 @@ struct Modifier { struct Entry { std::uint16_t itemIndex{kAbsent}; std::uint16_t poolIndex{kAbsent}; + /** Native entry +10 names a Reward Site, not an item or nested pool. */ + std::uint16_t rewardSiteIndex{kAbsent}; std::uint32_t quantity{}; std::uint32_t categoryHash{}; std::uint32_t bucketHash{}; diff --git a/Sunrise/src/state/rewards/reward_resolver.cpp b/Sunrise/src/state/rewards/reward_resolver.cpp index 14c7d6cc2..f5e5d424d 100644 --- a/Sunrise/src/state/rewards/reward_resolver.cpp +++ b/Sunrise/src/state/rewards/reward_resolver.cpp @@ -180,6 +180,11 @@ struct Resolver { && entry.bucketHash != bucket)) { return true; } + if (entry.itemIndex == definitions::kAbsent && entry.poolIndex == definitions::kAbsent + && entry.rewardSiteIndex != definitions::kAbsent) { + // The installed build has no Reward Site rows; this entry selects no inventory item. + 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) diff --git a/tests/vendor_reputation.cpp b/tests/vendor_reputation.cpp index e1583a057..d6ebb0787 100644 --- a/tests/vendor_reputation.cpp +++ b/tests/vendor_reputation.cpp @@ -1,10 +1,12 @@ #include #include #include +#include #include #include "core/logging/log.h" #include "middleware/datagen/family4/loadout/loadout_resolver.h" +#include "state/build_data/cache/records/codec.h" #include "state/build_data/rewards/reward_catalog.h" #include "state/build_data/runtime.h" #include "state/build_data/vendors/vendor_catalog.h" @@ -69,6 +71,12 @@ constexpr std::array kFixtureWeapons{991314988U, 720351795U}; constexpr std::uint8_t kRewardBucket = 2; /** Native empty bucket tag inherits the pool's bucket constraint. */ constexpr std::uint32_t kInheritedRewardBucket = 0x811C9DC5U; +/** Native faction packages select gear, a Reward Site and optional shaders by category. */ +constexpr std::uint32_t kFactionGearCategory = 1172844112U; +constexpr std::uint32_t kFactionSiteCategory = 2590539385U; +constexpr std::uint32_t kFactionShaderCategory = 328593737U; +/** The retained Vanguard and Crucible package rows reference this site. */ +constexpr std::uint16_t kFactionRewardSite = 213; /** @param index Synthetic gear row offset. @return The fixture hash, or zero. */ std::uint32_t gear_hash(std::size_t index) { @@ -78,23 +86,44 @@ std::uint32_t gear_hash(std::size_t index) { /** Reports a failed fixture check. */ void check(bool passed, const char* label); -/** @param opensOnAcquisition Whether the fixture wrapper expands at acquisition. */ -void seed_reward_catalog(bool opensOnAcquisition = true) { +/** + * Seeds an item reward with optional site-only and later item categories. + * @param opensOnAcquisition Whether the fixture wrapper expands at acquisition. + * @param secondSite Present to add a second category; kAbsent makes it malformed. + */ +void seed_reward_catalog(bool opensOnAcquisition = true, + std::optional secondSite = std::nullopt) { namespace rewards = state::build_data::rewards; - std::array pools{{{g_packageHash, {0, 1}}}}; - std::array entries{}; + const auto entryCount = secondSite.has_value() ? 3U : 1U; + std::array pools{{{g_packageHash, {0, entryCount}}}}; + std::array entries{}; entries[0].itemIndex = kGearIndexBase; entries[0].quantity = 1; + entries[0].categoryHash = kFactionGearCategory; entries[0].bucketHash = kInheritedRewardBucket; entries[0].weight = 1; - std::array items{}; + entries[1].rewardSiteIndex = secondSite.value_or(rewards::kAbsent); + entries[1].quantity = 1; + entries[1].categoryHash = kFactionSiteCategory; + entries[1].bucketHash = kInheritedRewardBucket; + entries[1].weight = 1; + entries[2].itemIndex = kGearIndexBase + 1; + entries[2].quantity = 1; + entries[2].categoryHash = kFactionShaderCategory; + entries[2].bucketHash = kInheritedRewardBucket; + entries[2].weight = 1; + std::array items{}; items[kRewardIndex].definitionHash = g_packageHash; items[kRewardIndex].poolIndex = 0; items[kRewardIndex].flags = opensOnAcquisition ? rewards::kOpenOnAcquisition : 0; - items[kRewardIndex].selectionCount = 1; - items[kRewardIndex].selections[0].count = 1; + items[kRewardIndex].selectionCount = static_cast(entryCount); + items[kRewardIndex].selections[0] = {kFactionGearCategory, 1}; + items[kRewardIndex].selections[1] = {kFactionSiteCategory, 1}; + items[kRewardIndex].selections[2] = {kFactionShaderCategory, 1}; items[kGearIndexBase].definitionHash = kFixtureWeapons.front(); - check(rewards::replace({pools, entries, items, {}, {}, {}}), "seed reward catalog"); + items[kGearIndexBase + 1].definitionHash = kFixtureWeapons.back(); + check(rewards::replace({pools, std::span{entries}.first(entryCount), items, {}, {}, {}}), + "seed reward catalog"); } /** Build-86657 Gunsmith's repeating rank costs 3000 XP. */ constexpr std::int32_t kGunsmithRankCost = 3000; @@ -463,10 +492,10 @@ void verify_rank_rewards() { } /** Configures a disposable eligible Zavala claim without changing player saves. */ -void reset_claim() { +void reset_claim(std::optional secondSite = std::nullopt) { reset(); g_vendorHash = kZavala; - seed_reward_catalog(); + seed_reward_catalog(true, secondSite); state::Family5State family{}; family.flagCount = 1; family.flags[0] = {kPackageFlag, state::unlocks::kFlagSet}; @@ -481,6 +510,27 @@ void reset_claim() { /** The native wrapper payout and rank credit share preview, commit and rollback. */ void verify_claims() { state::PendingRecordRewardGrant grant{}; + namespace cache = state::build_data::cache::records; + state::build_data::rewards::Entry source{}; + source.rewardSiteIndex = kFactionRewardSite; + cache::RewardEntryRecord stored{}; + state::build_data::rewards::Entry loaded{}; + check(cache::encode(source, stored) && cache::decode(stored, loaded) + && loaded.rewardSiteIndex == kFactionRewardSite, + "Reward Site reference survives build-data cache"); + reset_claim(kFactionRewardSite); + check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, grant) == Disposition::prepared + && grant.rewardCount == 2 + && grant.rewards[0].definitionHash == kFixtureWeapons.front() + && grant.rewards[1].definitionHash == kFixtureWeapons.back() + && credits(state::kVanguardRewardValueRow) == 2 && state::commit_record_reward(grant) + && credits(state::kVanguardRewardValueRow) == 1 + && store::account().characters[0].inventory.count == 2, + "absent site row does not block item reward and credit commit"); + reset_claim(state::build_data::rewards::kAbsent); + check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, grant) == Disposition::refused + && credits(state::kVanguardRewardValueRow) == 2, + "targetless category still refuses claim"); reset_claim(); check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, grant) == Disposition::prepared && grant.rewardCount == 1 diff --git a/tests/vendor_reputation.ps1 b/tests/vendor_reputation.ps1 index 17bda200a..b5219538d 100644 --- a/tests/vendor_reputation.ps1 +++ b/tests/vendor_reputation.ps1 @@ -13,6 +13,7 @@ $repSources = @( 'Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp', 'Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp', 'Sunrise/src/state/build_data/rewards/reward_catalog.cpp', + 'Sunrise/src/state/build_data/cache/records/cache_reward_records.cpp', 'Sunrise/src/state/rewards/reward_resolver.cpp', 'Sunrise/src/middleware/crypto/random_bytes.cpp', 'Sunrise/src/state/runtime/state_account_profile_runtime.cpp', From fcba022fb9aecdc94e20dc9fc4890216093d4982 Mon Sep 17 00:00:00 2001 From: Gage Fulwood Date: Wed, 23 Sep 2026 13:33:22 -0500 Subject: [PATCH 14/59] test: preserve vendor rank credit when gear bucket is full --- tests/vendor_reputation.cpp | 42 +++++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/tests/vendor_reputation.cpp b/tests/vendor_reputation.cpp index d6ebb0787..476d3b134 100644 --- a/tests/vendor_reputation.cpp +++ b/tests/vendor_reputation.cpp @@ -1,3 +1,4 @@ +#include #include #include #include @@ -67,6 +68,8 @@ constexpr std::uint16_t kGunsmithPackageSale = 16, kGunsmithCategory = 8; /** Synthetic resolved items exercise the installed wrapper-to-reward path. */ constexpr std::array kFixtureWeapons{991314988U, 720351795U}; +/** The fixture uses the installed Energy bucket's ten-row limit. */ +constexpr std::size_t kFixtureGearBucketCapacity = 10; /** The synthetic character bucket holds a resolved instanced reward. */ constexpr std::uint8_t kRewardBucket = 2; /** Native empty bucket tag inherits the pool's bucket constraint. */ @@ -594,6 +597,34 @@ void verify_claims() { && credits(state::kVanguardRewardValueRow) == 2, "full inventory preserves credit"); reset_claim(); + g_rewardCapacity = kFixtureGearBucketCapacity + 1; + auto account = store::account(); + for (std::size_t index = 0; index < kFixtureGearBucketCapacity - 1; ++index) { + auto& item = account.characters[0].inventory.values[index]; + item.instanceSoid = kOtherCharacter + index + 1; + item.definitionHash = kFixtureWeapons.front(); + item.quantity = 1; + item.mutationSerial = static_cast(index); + } + account.characters[0].inventory.count = kFixtureGearBucketCapacity - 1; + account.characters[0].nextInventorySerial = kFixtureGearBucketCapacity; + check(store::write_account(account) + && state::prepare_vendor_reward_sale(kVendor, kPackageSale, grant) + == Disposition::prepared, + "reward fits with one gear-bucket row free"); + auto& lastItem = account.characters[0].inventory.values[kFixtureGearBucketCapacity - 1]; + lastItem.instanceSoid = kOtherCharacter + kFixtureGearBucketCapacity; + lastItem.definitionHash = kFixtureWeapons.front(); + lastItem.quantity = 1; + lastItem.mutationSerial = static_cast(kFixtureGearBucketCapacity - 1); + account.characters[0].inventory.count = kFixtureGearBucketCapacity; + check(store::write_account(account) + && state::prepare_vendor_reward_sale(kVendor, kPackageSale, grant) + == Disposition::refused + && credits(state::kVanguardRewardValueRow) == 2 + && store::account().characters[0].inventory.count == kFixtureGearBucketCapacity, + "full gear bucket preserves credit despite account capacity"); + reset_claim(); g_rewardAvailable = false; check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, grant) == Disposition::refused, "missing reward definition refuses payout"); @@ -883,11 +914,11 @@ void revoke(std::uint16_t) noexcept {} namespace sunrise::middleware::datagen::family4::loadout { /** - * Models bucket capacity without installed game content; acquisition and persistence are real. + * Models total and per-gear-bucket capacity without installed game content. * @param account Candidate account. * @param selectedCharacterIndex Character whose items must fit. * @param output Receives synthetic inventory positions. - * @return False when the fixture bucket has insufficient space. + * @return False when total or gear-bucket capacity is exhausted. */ bool resolve(const state::AccountState& account, std::size_t selectedCharacterIndex, @@ -897,6 +928,13 @@ bool resolve(const state::AccountState& account, if (inventory.count > g_rewardCapacity) { return false; } + const auto gearCount = std::count_if( + inventory.values.begin(), + inventory.values.begin() + static_cast(inventory.count), + [](const auto& item) { return item.definitionHash == kFixtureWeapons.front(); }); + if (gearCount > kFixtureGearBucketCapacity) { + return false; + } for (std::size_t index = 0; index < inventory.count; ++index) { auto& item = output.items[output.itemCount++]; item.inventoryRow = static_cast(index); From 35baf8b22af851b808ebfbc9a9b7bcb08c1bb2bc Mon Sep 17 00:00:00 2001 From: Zach Date: Wed, 23 Sep 2026 14:36:27 -0500 Subject: [PATCH 15/59] 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 | 5 +- .../state_account_reward_grant_runtime.cpp | 36 ++- .../runtime/state_progression_runtime.cpp | 3 +- 13 files changed, 257 insertions(+), 178 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 c7c5a26ba..a68150873 100644 --- a/Sunrise/src/state/runtime/runtime.h +++ b/Sunrise/src/state/runtime/runtime.h @@ -8,9 +8,12 @@ #include "../build_data/items/quest_initialization.h" #include "../build_data/records/definition.h" #include "../build_data/rewards/definition.h" -#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 8e280783f0fbb7b4aa92ec04f33085e9b02efb85 Mon Sep 17 00:00:00 2001 From: Zach Date: Wed, 23 Sep 2026 14:36:27 -0500 Subject: [PATCH 16/59] 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 62935d9d35da1454f14701e7b1bb5a6689acd465 Mon Sep 17 00:00:00 2001 From: Zach Date: Wed, 23 Sep 2026 15:09:08 -0500 Subject: [PATCH 17/59] 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 a15185610..ff6ae54ea 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. */ @@ -611,9 +611,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{}; @@ -643,7 +643,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 cab821f3dfa7b908db7c8876e44d668c140b5fec Mon Sep 17 00:00:00 2001 From: Gage Fulwood Date: Wed, 23 Sep 2026 17:55:41 -0500 Subject: [PATCH 18/59] fix: publish spent vendor reward credit in character snapshot --- .../push/snapshot/family4_reward_updates.cpp | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) 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..0b3d3a432 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 @@ -143,8 +143,14 @@ bool prepare_record_reward_grant( const auto characterBytes = rawStorage.first(character_layout::kObjectSize); const state::CharacterState& character = account.characters[mutation.characterIndex]; + state::unlocks::Table unlocks{}; + if (!state::preview_reward_unlocks(mutation, unlocks)) { + clear_after(scratch, reservation); + return report_failure("record_reward_unlocks"); + } + // Both objects must publish the same reward after-image, including spent claim credit. if (!family4_datagen::character::encode( - character, selected.loadout, selected.lightEvaluation, characterBytes)) { + character, selected.loadout, selected.lightEvaluation, characterBytes, unlocks)) { clear_after(scratch, reservation); return report_failure("record_reward_character_encode"); } @@ -217,9 +223,7 @@ bool prepare_record_reward_grant( } const auto accountBytes = rawStorage.first(account_layout::kObjectSize); - state::unlocks::Table unlocks{}; - if (!state::preview_reward_unlocks(mutation, unlocks) - || !family4_datagen::account::encode(account, accountBytes, unlocks)) { + if (!family4_datagen::account::encode(account, accountBytes, unlocks)) { clear_after(scratch, reservation); return report_failure("record_reward_account_encode"); } From d72907b5bd83a32bfb7a517e6f768bad2a5e0c36 Mon Sep 17 00:00:00 2001 From: Gage Fulwood Date: Wed, 23 Sep 2026 18:47:45 -0500 Subject: [PATCH 19/59] Report vendor rank reward refusal stages --- .../web_service_vendor_actions.cpp | 6 +++-- .../state_vendor_reputation_runtime.cpp | 24 +++++++++++++------ .../runtime/state_vendor_reputation_runtime.h | 3 ++- tests/vendor_reputation.cpp | 15 ++++++++---- 4 files changed, 33 insertions(+), 15 deletions(-) diff --git a/Sunrise/src/server/web_service/web_service_vendor_actions.cpp b/Sunrise/src/server/web_service/web_service_vendor_actions.cpp index adcfe294b..19cac11fe 100644 --- a/Sunrise/src/server/web_service/web_service_vendor_actions.cpp +++ b/Sunrise/src/server/web_service/web_service_vendor_actions.cpp @@ -647,19 +647,21 @@ void settle_vendor_row(const middleware::web_service::Message& message, && state::is_vendor_reward_category(static_cast(vendorIndex), categoryIndex)) { auto* reward = emplace_mutation(outcome); + const char* refusal = "storage"; const bool prepared = reward != nullptr && rowIndex >= 0 && rowIndex <= (std::numeric_limits::max)() && state::prepare_vendor_reward_sale(static_cast(vendorIndex), static_cast(rowIndex), - *reward) + *reward, + &refusal) == state::VendorReputationDisposition::prepared; if (!prepared) { clear_mutation(outcome); } report_purchase(opcode, prepared ? "ok" : "fail", - "rank_reward", + prepared ? "rank_reward" : refusal, vendorIndex, rowIndex, itemDefinitionIndex); diff --git a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp index 8c0604cfc..44f333b6a 100644 --- a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp +++ b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp @@ -463,12 +463,16 @@ VendorReputationDisposition prepare_vendor_reward(std::uint16_t vendorIndex, * @param vendorIndex Installed vendor selector. * @param saleIndex Requested package sale. * @param mutation Receives the prepared grant; cleared on refusal. + * @param refusal Receives the first failed guard, when requested. * @return Unrelated sales are not applicable; unsupported or unaffordable rewards are refused. */ -VendorReputationDisposition -prepare_vendor_reward_sale(std::uint16_t vendorIndex, - std::uint16_t saleIndex, - PendingRecordRewardGrant& mutation) noexcept { +VendorReputationDisposition prepare_vendor_reward_sale(std::uint16_t vendorIndex, + std::uint16_t saleIndex, + PendingRecordRewardGrant& mutation, + const char** refusal) noexcept { + const char* unused = nullptr; + auto& reason = refusal != nullptr ? *refusal : unused; + reason = "vendor_rule"; mutation = {}; const auto* rule = reward_rule(vendorIndex); build_data::vendors::Definition vendor{}; @@ -476,6 +480,7 @@ prepare_vendor_reward_sale(std::uint16_t vendorIndex, if (rule == nullptr) { return VendorReputationDisposition::notApplicable; } + reason = "vendor_sale"; if (!build_data::vendors::find(rule->vendorHash, vendor) || !build_data::vendors::sale_row(vendor, saleIndex, sale)) { return VendorReputationDisposition::refused; @@ -484,17 +489,22 @@ prepare_vendor_reward_sale(std::uint16_t vendorIndex, return VendorReputationDisposition::notApplicable; } const std::lock_guard lock(investment::store::g_mutex); + reason = "reward_binding"; + if (!reward_binding_current(*rule, saleIndex)) { + return VendorReputationDisposition::refused; + } + reason = "rank_credit"; std::int32_t credits = 0; - if (!reward_binding_current(*rule, saleIndex) - || !investment::store::read_unlock( + if (!investment::store::read_unlock( investment::store::Bank::characterObjectValues, rule->rewardValueRow, credits) || credits <= 0) { return VendorReputationDisposition::refused; } - if (!prepare_item_reward(sale.itemIndex, 1, mutation)) { + if (!prepare_item_reward(sale.itemIndex, 1, mutation, &reason)) { mutation = {}; return VendorReputationDisposition::refused; } + reason = nullptr; mutation.vendorReward = {credits, vendorIndex, saleIndex, rule->rewardValueRow}; return VendorReputationDisposition::prepared; } diff --git a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h index af5534202..6ff3f061b 100644 --- a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h +++ b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h @@ -62,7 +62,8 @@ struct PendingRecordRewardGrant; [[nodiscard]] VendorReputationDisposition prepare_vendor_reward_sale(std::uint16_t vendorIndex, std::uint16_t saleIndex, - PendingRecordRewardGrant& mutation) noexcept; + PendingRecordRewardGrant& mutation, + const char** refusal = nullptr) noexcept; [[nodiscard]] VendorReputationDisposition prepare_vendor_reward(std::uint16_t vendorIndex, std::uint16_t interactionIndex, diff --git a/tests/vendor_reputation.cpp b/tests/vendor_reputation.cpp index 476d3b134..3bb8daff6 100644 --- a/tests/vendor_reputation.cpp +++ b/tests/vendor_reputation.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include "core/logging/log.h" #include "middleware/datagen/family4/loadout/loadout_resolver.h" @@ -593,9 +594,12 @@ void verify_claims() { reset_claim(); g_rewardCapacity = 0; - check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, grant) == Disposition::refused + const char* refusal = nullptr; + check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, grant, &refusal) + == Disposition::refused + && refusal != nullptr && std::string_view(refusal) == "instance_capacity" && credits(state::kVanguardRewardValueRow) == 2, - "full inventory preserves credit"); + "full inventory reports capacity and preserves credit"); reset_claim(); g_rewardCapacity = kFixtureGearBucketCapacity + 1; auto account = store::account(); @@ -635,9 +639,10 @@ void verify_claims() { && !state::commit_record_reward(grant), "stale credit refuses claim"); check(store::write_unlock(store::Bank::characterObjectValues, state::kVanguardRewardValueRow, 0) - && state::prepare_vendor_reward_sale(kVendor, kPackageSale, grant) - == Disposition::refused, - "no credit refuses claim"); + && state::prepare_vendor_reward_sale(kVendor, kPackageSale, grant, &refusal) + == Disposition::refused + && refusal != nullptr && std::string_view(refusal) == "rank_credit", + "no credit reports binding refusal"); reset_claim(); check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, grant) == Disposition::prepared && store::write_family5({}) && !state::commit_record_reward(grant) From af175a216f7f9635e41484de617782742550d802 Mon Sep 17 00:00:00 2001 From: Zach Date: Wed, 23 Sep 2026 19:31:49 -0500 Subject: [PATCH 20/59] 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 ff6ae54ea..7507fc8e6 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. */ @@ -611,7 +611,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{}; @@ -644,7 +645,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 a444712755b9a18de3c2b2b24aa4010fff622cd0 Mon Sep 17 00:00:00 2001 From: Gage Fulwood Date: Wed, 23 Sep 2026 20:06:31 -0500 Subject: [PATCH 21/59] Remove residual vendor review changes --- Sunrise/src/client/content/vendors/vendor_build.h | 4 ---- .../src/state/runtime/state_account_acquisition_runtime.cpp | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/Sunrise/src/client/content/vendors/vendor_build.h b/Sunrise/src/client/content/vendors/vendor_build.h index d7677a36b..5d01ea917 100644 --- a/Sunrise/src/client/content/vendors/vendor_build.h +++ b/Sunrise/src/client/content/vendors/vendor_build.h @@ -1,10 +1,6 @@ #pragma once -#include -#include - #include "../../../middleware/content/packages/reader/reader.h" -#include "../../../middleware/content/packages/tables/definition_index_table.h" namespace sunrise::client::content::vendors { diff --git a/Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp b/Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp index 17cd5c612..4bdf258af 100644 --- a/Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp +++ b/Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp @@ -392,7 +392,7 @@ bool preview_item_acquisition(const PendingItemAcquisition& mutation, /** * Inventory and first-step state share one transaction; failure rolls both back. * @param mutation Prepared grant consumed on either success or failure. - * @return True when all writes commit against the unchanged prepared state. + * @return True when both writes commit against the unchanged prepared state. */ bool commit_item_acquisition(PendingItemAcquisition& mutation) noexcept { const PendingItemAcquisition& prepared = mutation; From 089226ebb24051e0b4e0ffc6545103a64af267d2 Mon Sep 17 00:00:00 2001 From: Zach Date: Wed, 23 Sep 2026 21:33:28 -0500 Subject: [PATCH 22/59] 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 7507fc8e6..0b333ef65 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{}; }; @@ -653,7 +656,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 a68150873..7853dbb51 100644 --- a/Sunrise/src/state/runtime/runtime.h +++ b/Sunrise/src/state/runtime/runtime.h @@ -22,8 +22,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. @@ -263,7 +264,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{}; @@ -530,15 +531,26 @@ set_selected_title(std::uint16_t recordIndex, std::uint64_t& characterSoid, bool /** 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 481840e2538dc3b302a52593baf32b671be0a7f1 Mon Sep 17 00:00:00 2001 From: Gage Fulwood Date: Wed, 23 Sep 2026 22:12:47 -0500 Subject: [PATCH 23/59] Read faction rank repetition from installed progressions --- .../packages/package_progression_build.cpp | 10 ++++- .../packages/tables/definition_index_table.h | 2 + .../records/cache_investment_records.cpp | 8 +++- .../state/build_data/cache/records/format.h | 5 ++- .../build_data/progressions/definition.h | 2 + .../progressions/progression_catalog.cpp | 17 +++++++ .../progressions/progression_catalog.h | 8 ++++ .../state_vendor_reputation_runtime.cpp | 19 ++++---- tests/vendor_reputation.cpp | 44 +++++++++++++++++++ tests/vendor_reputation.ps1 | 1 + 10 files changed, 102 insertions(+), 14 deletions(-) diff --git a/Sunrise/src/client/content/items/packages/package_progression_build.cpp b/Sunrise/src/client/content/items/packages/package_progression_build.cpp index 20cb3476f..e546973ac 100644 --- a/Sunrise/src/client/content/items/packages/package_progression_build.cpp +++ b/Sunrise/src/client/content/items/packages/package_progression_build.cpp @@ -36,6 +36,13 @@ bool build_progressions(const reader::Source& source, } const auto scope = std::to_integer(table[at + tables::kProgressionScopeOffset]); + const auto repeatLastStep = + std::to_integer(table[at + tables::kProgressionRepeatLastStepOffset]); + if (repeatLastStep > 1) { + count = 0; + stepCount = 0; + return false; + } domain::Definition& definition = output[count]; // Any scope beyond the two replicated objects belongs to an object this server does not // push, so it claims no slot in either array. @@ -44,7 +51,8 @@ bool build_progressions(const reader::Source& source, 0, scope <= static_cast(domain::Scope::character) ? static_cast(scope) - : domain::Scope::unreplicated}; + : domain::Scope::unreplicated, + repeatLastStep != 0}; tables::Array ladder{}; if (!tables::find_optional_array_at(table, at + tables::kProgressionStepField, ladder)) { count = 0; 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..fbcba02be 100644 --- a/Sunrise/src/middleware/content/packages/tables/definition_index_table.h +++ b/Sunrise/src/middleware/content/packages/tables/definition_index_table.h @@ -217,6 +217,8 @@ inline constexpr std::uint32_t kProgressionTableClass = 0x80807CDDU; inline constexpr std::size_t kProgressionRowStride = 88; /** A progression definition names the object array holding its record here. */ inline constexpr std::size_t kProgressionScopeOffset = 4; +/** A progression definition repeats its final step when this byte is one. */ +inline constexpr std::size_t kProgressionRepeatLastStepOffset = 5; /** Rank steps a progression declares: the experience each rank costs, in rank order. */ inline constexpr std::size_t kProgressionStepField = 24; inline constexpr std::uint32_t kProgressionStepRowClass = 0x80807CEDU; 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..4930a0864 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 @@ -65,16 +65,22 @@ bool encode(const progressions::Definition& value, ProgressionRecord& record) no value.stepOffset, value.stepCount, static_cast(value.scope), + static_cast(value.repeatLastStep), }; return true; } /** Decodes one progression definition; the complete-domain validator checks its step range. */ bool decode(const ProgressionRecord& record, progressions::Definition& value) noexcept { + value = {}; + if (record.repeatLastStep > 1) { + return false; + } value = {record.definitionIndex, record.stepOffset, record.stepCount, - static_cast(record.scope)}; + static_cast(record.scope), + record.repeatLastStep != 0}; return true; } diff --git a/Sunrise/src/state/build_data/cache/records/format.h b/Sunrise/src/state/build_data/cache/records/format.h index bb07c45d4..94c5fb7f4 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. */ @@ -301,6 +301,7 @@ struct ProgressionRecord { std::uint16_t stepOffset{}; std::uint8_t stepCount{}; std::uint8_t scope{}; + std::uint8_t repeatLastStep{}; }; /** Disk form of one progression rank step. */ @@ -694,7 +695,7 @@ static_assert(sizeof(RosterGroupRecord) == 2 * sizeof(std::uint32_t) + sizeof(std::uint16_t) + 2 * scenarios::kRosterSlotCapacity * sizeof(std::uint8_t) + scenarios::kRosterSlotCapacity * sizeof(std::uint16_t)); -static_assert(sizeof(ProgressionRecord) == 2 * sizeof(std::uint16_t) + 2 * sizeof(std::uint8_t)); +static_assert(sizeof(ProgressionRecord) == 2 * sizeof(std::uint16_t) + 3 * 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) + 6 * sizeof(std::uint8_t) diff --git a/Sunrise/src/state/build_data/progressions/definition.h b/Sunrise/src/state/build_data/progressions/definition.h index 9d332f2f8..60daa2f8c 100644 --- a/Sunrise/src/state/build_data/progressions/definition.h +++ b/Sunrise/src/state/build_data/progressions/definition.h @@ -41,6 +41,8 @@ struct Definition { std::uint16_t stepOffset{}; std::uint8_t stepCount{}; Scope scope{Scope::unreplicated}; + /** Installed progression row +5 decides whether the final rank step repeats. */ + bool repeatLastStep{}; }; } // namespace sunrise::state::build_data::progressions diff --git a/Sunrise/src/state/build_data/progressions/progression_catalog.cpp b/Sunrise/src/state/build_data/progressions/progression_catalog.cpp index b266e70c3..efe21d2e0 100644 --- a/Sunrise/src/state/build_data/progressions/progression_catalog.cpp +++ b/Sunrise/src/state/build_data/progressions/progression_catalog.cpp @@ -56,6 +56,23 @@ bool replace(std::span definitions, std::span step return replaced; } +/** + * Reads one definition under the catalog lock. + * @param definitionIndex Native progression definition index. + * @param definition Receives the row, or an empty row when absent. + * @return False when the catalog does not hold the index. + */ +bool find(std::uint16_t definitionIndex, Definition& definition) noexcept { + definition = {}; + const std::shared_lock guard(g_lock); + const auto rows = g_definitions.rows(); + if (definitionIndex >= rows.size()) { + return false; + } + definition = rows[definitionIndex]; + return true; +} + /** Copies the rank steps one progression declares, in rank order. */ bool steps(std::uint16_t definitionIndex, std::span output, std::size_t& count) noexcept { count = 0; diff --git a/Sunrise/src/state/build_data/progressions/progression_catalog.h b/Sunrise/src/state/build_data/progressions/progression_catalog.h index 6c442f9ca..e8f828e44 100644 --- a/Sunrise/src/state/build_data/progressions/progression_catalog.h +++ b/Sunrise/src/state/build_data/progressions/progression_catalog.h @@ -29,6 +29,14 @@ void clear() noexcept; [[nodiscard]] bool replace(std::span definitions, std::span steps) noexcept; +/** + * Reads one definition by its native index. + * @param definitionIndex Native progression definition index. + * @param definition Receives the definition on success. + * @return False when the catalog does not hold the index. + */ +[[nodiscard]] bool find(std::uint16_t definitionIndex, Definition& definition) noexcept; + /** * Copies the rank steps one progression declares, in rank order. * @param definitionIndex Native progression definition index. diff --git a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp index 44f333b6a..8b45abf5b 100644 --- a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp +++ b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp @@ -20,24 +20,20 @@ struct ReputationRule { std::uint16_t progressionIndex; std::int32_t experiencePerUnit; std::uint16_t rewardValueRow{}; - bool repeatLastStep{}; }; /** Build-86657 VALUE[888] and VALUE[906] map to these character-object value rows. */ constexpr std::uint16_t kCrucibleRewardValueRow = 45, kGunsmithRewardValueRow = 49; -/** Build-86657 progressions 49, 55 and 62 repeat their final installed step. */ -constexpr bool kRepeatFactionRankStep = true; - /** Vendor/item hashes, character progression indices and XP per unit from build 86657. */ constexpr std::array kReputationRules{{ // Banshee: Gunsmith Rewards charges Gunsmith Materials for Gunsmith progression. - {672118013U, 3831705402U, 685157383U, 55, 30, kGunsmithRewardValueRow, kRepeatFactionRankStep}, + {672118013U, 3831705402U, 685157383U, 55, 30, kGunsmithRewardValueRow}, // Banshee: the same placeholder also accepts Weapon Telemetry at its own XP rate. - {672118013U, 3831705402U, 685157381U, 55, 25, kGunsmithRewardValueRow, kRepeatFactionRankStep}, + {672118013U, 3831705402U, 685157381U, 55, 25, kGunsmithRewardValueRow}, // Zavala: Vanguard Tactician Rewards charges Vanguard Tactician Tokens. - {69482069U, 3987308529U, 3899548068U, 62, 100, kVanguardRewardValueRow, kRepeatFactionRankStep}, + {69482069U, 3987308529U, 3899548068U, 62, 100, kVanguardRewardValueRow}, // Shaxx: Crucible Rewards charges Crucible Tokens, not Valor or Glory points. - {3603221665U, 265113466U, 183980811U, 49, 100, kCrucibleRewardValueRow, kRepeatFactionRankStep}, + {3603221665U, 265113466U, 183980811U, 49, 100, kCrucibleRewardValueRow}, // Devrim: EDZ token turn-ins use a different placeholder from destination materials. {396892126U, 61430328U, 2640973641U, 52, 100}, // Devrim: destination-material turn-ins accept Dusklight Shards. @@ -183,6 +179,7 @@ VendorReputationDisposition resolve_award(std::uint16_t vendorIndex, } std::array slots{}; std::size_t count = 0; + build_data::progressions::Definition progression{}; const std::int64_t experience = static_cast(sale.costQuantity) * rule.experiencePerUnit; if (experience > (std::numeric_limits::max)() @@ -192,7 +189,9 @@ VendorReputationDisposition resolve_award(std::uint16_t vendorIndex, || std::find(slots.begin(), slots.begin() + static_cast(count), rule.progressionIndex) - == slots.begin() + static_cast(count)) { + == slots.begin() + static_cast(count) + || !build_data::progressions::find(rule.progressionIndex, progression) + || progression.scope != build_data::progressions::Scope::character) { return VendorReputationDisposition::refused; } award.costHash = cost.definitionHash; @@ -200,7 +199,7 @@ VendorReputationDisposition resolve_award(std::uint16_t vendorIndex, award.experience = static_cast(experience); award.progressionIndex = rule.progressionIndex; award.rewardValueRow = rule.rewardValueRow; - award.repeatLastStep = rule.repeatLastStep; + award.repeatLastStep = progression.repeatLastStep; if (rule.rewardValueRow != 0) { std::array diff --git a/tests/vendor_reputation.cpp b/tests/vendor_reputation.cpp index 708172fc7..1aa63422d 100644 --- a/tests/vendor_reputation.cpp +++ b/tests/vendor_reputation.cpp @@ -45,6 +45,7 @@ std::uint32_t g_vendorHash = kBanshee, g_soldHash = kGunsmithRewards, std::uint32_t g_cost = kCost; std::uint16_t g_progression = kGunsmithProgression; bool g_characterScope = true; +bool g_repeatLastStep = true; bool g_materialAvailable = true; /** Build-86657 owned faction engrams, separate from the turn-in placeholders. */ constexpr std::uint32_t kVanguardEngram = 3578462974U, kCrucibleEngram = 1368565477U, @@ -182,6 +183,7 @@ void reset() { g_cost = kCost; g_progression = kGunsmithProgression; g_characterScope = true; + g_repeatLastStep = true; g_materialAvailable = true; g_rankSteps = { {{kGunsmithRankCost}, {kGunsmithRankCost}, {kGunsmithRankCost}, {kGunsmithRankCost}}}; @@ -365,6 +367,23 @@ std::int32_t credits(std::uint16_t row) { return value; } +/** The cache preserves the installed repeat rule and refuses an invalid byte. */ +void verify_progression_cache() { + namespace cache = state::build_data::cache::records; + state::build_data::progressions::Definition source{}; + source.definitionIndex = kGunsmithProgression; + source.scope = state::build_data::progressions::Scope::character; + source.repeatLastStep = true; + cache::ProgressionRecord record{}; + state::build_data::progressions::Definition decoded{}; + check(cache::encode(source, record) && record.repeatLastStep == 1 + && cache::decode(record, decoded) && decoded.repeatLastStep, + "progression repeat rule survives cache"); + record.repeatLastStep = 2; + check(!cache::decode(record, decoded) && decoded.definitionIndex == 0, + "invalid repeat rule refuses cache"); +} + /** Covers exact thresholds, multiple ranks, no backfill, overflow and transaction rollback. */ void verify_rank_rewards() { /** Build-86657 vendor, progression and saved claim-counter mappings. */ @@ -436,6 +455,14 @@ void verify_rank_rewards() { && state::commit_vendor_reputation(pending) && credits(kGunsmithCreditRow) == 1, "installed repeat-tail rule awards its crossed credit"); reset(); + g_repeatLastStep = false; + check(store::write_unlock(store::Bank::characterProgressions, + kGunsmithProgression, + kFirstRepeatedRankThreshold - kAward) + && state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared + && state::commit_vendor_reputation(pending) && credits(kGunsmithCreditRow) == 0, + "finite installed ladder does not award beyond its final step"); + reset(); check(store::write_unlock( store::Bank::characterProgressions, kGunsmithProgression, kGunsmithRankCost - kAward), "seed crossing"); @@ -884,6 +911,22 @@ bool find_progression_slots(progressions::Scope scope, } namespace progressions { +/** + * Reads the fixture's rank rule for its selected progression. + * @param definitionIndex Requested native progression index. + * @param definition Receives the matching definition, or an empty row. + * @return False when the fixture does not hold the index. + */ +bool find(std::uint16_t definitionIndex, Definition& definition) noexcept { + definition = {}; + if (definitionIndex != g_progression) { + return false; + } + definition.definitionIndex = definitionIndex; + definition.scope = g_characterScope ? Scope::character : Scope::account; + definition.repeatLastStep = g_repeatLastStep; + return true; +} /** * Copies the controlled installed-cost fixture for the selected progression. * @param definitionIndex Requested native progression index. @@ -974,6 +1017,7 @@ int main(int argc, char** argv) { "open disposable store"); seed_reward_catalog(); verify(); + verify_progression_cache(); verify_rank_rewards(); verify_claims(); verify_shared_sales(); diff --git a/tests/vendor_reputation.ps1 b/tests/vendor_reputation.ps1 index b5219538d..027b35663 100644 --- a/tests/vendor_reputation.ps1 +++ b/tests/vendor_reputation.ps1 @@ -14,6 +14,7 @@ $repSources = @( 'Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp', 'Sunrise/src/state/build_data/rewards/reward_catalog.cpp', 'Sunrise/src/state/build_data/cache/records/cache_reward_records.cpp', + 'Sunrise/src/state/build_data/cache/records/cache_investment_records.cpp', 'Sunrise/src/state/rewards/reward_resolver.cpp', 'Sunrise/src/middleware/crypto/random_bytes.cpp', 'Sunrise/src/state/runtime/state_account_profile_runtime.cpp', From 395726f37b4e91e118f1350163462b9bf7f3c400 Mon Sep 17 00:00:00 2001 From: Gage Fulwood Date: Wed, 23 Sep 2026 22:40:15 -0500 Subject: [PATCH 24/59] Resolve vendor progression from installed faction rows --- Sunrise/src/client/content/vendors/layout.h | 14 +++++ .../content/vendors/package_vendor_build.cpp | 54 ++++++++++++++++--- .../cache/records/cache_vendor_records.cpp | 6 +++ .../state/build_data/cache/records/format.h | 7 ++- .../src/state/build_data/vendors/definition.h | 8 +++ .../state_vendor_reputation_runtime.cpp | 30 ++++++----- 6 files changed, 96 insertions(+), 23 deletions(-) diff --git a/Sunrise/src/client/content/vendors/layout.h b/Sunrise/src/client/content/vendors/layout.h index 82ea6b3bf..745e3da8f 100644 --- a/Sunrise/src/client/content/vendors/layout.h +++ b/Sunrise/src/client/content/vendors/layout.h @@ -7,6 +7,20 @@ namespace sunrise::client::content::vendors { /** Tag of the installed vendor index blob, which names every vendor definition. */ inline constexpr std::uint32_t kIndexRootTag = 0x8131931DU; +/** Installed faction table named by the build-86657 investment content. */ +inline constexpr std::uint32_t kFactionTableTag = 0x81327CD5U; +/** The faction table's rows begin at this array descriptor. */ +inline constexpr std::size_t kFactionArrayDescriptor = 8; +/** One faction row holds its hash and progression index in the first eight bytes. */ +inline constexpr std::size_t kFactionRowStride = 24; +/** The installed faction table's row class. */ +inline constexpr std::uint32_t kFactionRowClass = 0x808074CEU; +/** Faction definition hash at the start of a faction row. */ +inline constexpr std::size_t kFactionHashOffset = 0; +/** Progression definition index following the faction hash. */ +inline constexpr std::size_t kFactionProgressionIndexOffset = 4; +/** Vendor definition +18 selects a faction row; out-of-range values name no faction. */ +inline constexpr std::size_t kVendorFactionIndexOffset = 18; /** A vendor definition holds its installed array descriptor here. */ inline constexpr std::size_t kInstalledArrayDescriptor = 32; diff --git a/Sunrise/src/client/content/vendors/package_vendor_build.cpp b/Sunrise/src/client/content/vendors/package_vendor_build.cpp index 9269d4105..9a1b97e37 100644 --- a/Sunrise/src/client/content/vendors/package_vendor_build.cpp +++ b/Sunrise/src/client/content/vendors/package_vendor_build.cpp @@ -20,9 +20,18 @@ namespace reader = middleware::content::packages::reader; namespace tables = middleware::content::packages::tables; namespace domain = state::build_data::vendors; +/** One array a definition or a sale row declares, reduced to what the catalog stores. */ +struct ArrayView { + std::uint32_t base{}; + std::uint32_t classId{}; + std::uint16_t count{}; +}; + /** Every extracted row, kept off the caller stack. */ struct Storage { std::vector blob{}; + std::vector factionBlob{}; + ArrayView factionRows{}; std::array index{}; std::array definitions{}; std::array saleRows{}; @@ -33,13 +42,6 @@ struct Storage { std::size_t installedRowCount{}; }; -/** One array a definition or a sale row declares, reduced to what the catalog stores. */ -struct ArrayView { - std::uint32_t base{}; - std::uint32_t classId{}; - std::uint16_t count{}; -}; - /** @param blob Source bytes. @param offset Field offset. @param value Receives the field. */ template [[nodiscard]] bool @@ -88,6 +90,17 @@ read(std::span blob, std::size_t offset, Value& value) noexcept return true; } +/** Reads the native faction rows used by vendor definition +18. */ +[[nodiscard]] bool +read_factions(const reader::Source& source, reader::Scratch& scratch, Storage& storage) noexcept { + return reader::read_tag(source, scratch, kFactionTableTag, storage.factionBlob) + && read_array(std::span{storage.factionBlob}, + kFactionArrayDescriptor, + kFactionRowStride, + storage.factionRows) + && storage.factionRows.count != 0 && storage.factionRows.classId == kFactionRowClass; +} + /** * Reads what one sale row charges, from the first row of its price-override array. * A row charging nothing declares no override, which is data rather than a malformed row. @@ -241,6 +254,26 @@ read_index(const reader::Source& source, reader::Scratch& scratch, Storage& stor definition.thirdRowBase = third.base; definition.thirdRowClass = third.classId; definition.thirdCount = third.count; + if (!read(blob, kVendorFactionIndexOffset, definition.factionIndexRaw)) { + return false; + } + if (definition.factionIndexRaw < storage.factionRows.count) { + const std::size_t at = + storage.factionRows.base + + static_cast(definition.factionIndexRaw) * kFactionRowStride; + std::uint32_t progressionIndex = 0; + if (!read(std::span{storage.factionBlob}, + at + kFactionHashOffset, + definition.factionHash) + || !read(std::span{storage.factionBlob}, + at + kFactionProgressionIndexOffset, + progressionIndex) + || definition.factionHash == 0 + || progressionIndex > (std::numeric_limits::max)()) { + return false; + } + definition.factionProgressionIndex = static_cast(progressionIndex); + } definition.saleRowOffset = static_cast(storage.saleRowCount); definition.installedRowOffset = static_cast(storage.installedRowCount); // A skipped definition must leave both banks exactly as it found them; an orphan sale row @@ -295,6 +328,13 @@ bool build(const reader::Source& source, reader::Scratch& scratch) noexcept { } static Storage storage{}; storage = {}; + if (!read_factions(source, scratch, storage)) { + // A missing faction table removes reputation links, not ordinary vendor sales. + storage.factionRows = {}; + core::log::write(core::log::Channel::state, + core::log::Level::warn, + "ev=build_data stage=vendors result=factions_unavailable"); + } if (!read_index(source, scratch, storage)) { report(storage, 0, "index"); return false; diff --git a/Sunrise/src/state/build_data/cache/records/cache_vendor_records.cpp b/Sunrise/src/state/build_data/cache/records/cache_vendor_records.cpp index e03a26f53..80fdd15eb 100644 --- a/Sunrise/src/state/build_data/cache/records/cache_vendor_records.cpp +++ b/Sunrise/src/state/build_data/cache/records/cache_vendor_records.cpp @@ -42,6 +42,9 @@ bool encode(const vendors::Definition& value, VendorDefinitionRecord& record) no record.installedCount = value.installedCount; record.saleCount = value.saleCount; record.thirdCount = value.thirdCount; + record.factionIndexRaw = value.factionIndexRaw; + record.factionProgressionIndex = value.factionProgressionIndex; + record.factionHash = value.factionHash; return true; } @@ -71,6 +74,9 @@ bool decode(const VendorDefinitionRecord& record, vendors::Definition& value) no value.installedCount = record.installedCount; value.saleCount = record.saleCount; value.thirdCount = record.thirdCount; + value.factionIndexRaw = record.factionIndexRaw; + value.factionProgressionIndex = record.factionProgressionIndex; + value.factionHash = record.factionHash; return true; } diff --git a/Sunrise/src/state/build_data/cache/records/format.h b/Sunrise/src/state/build_data/cache/records/format.h index 94c5fb7f4..6f0cd264d 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. */ @@ -561,6 +561,9 @@ struct VendorDefinitionRecord { std::uint16_t installedCount{}; std::uint16_t saleCount{}; std::uint16_t thirdCount{}; + std::uint16_t factionIndexRaw{}; + std::uint16_t factionProgressionIndex{vendors::kUnavailableFactionProgressionIndex}; + std::uint32_t factionHash{}; }; /** Disk form of one vendor sale row. */ @@ -668,7 +671,7 @@ static_assert(sizeof(SpawnPointRecord) + sizeof(std::uint16_t) + 2 * sizeof(std::uint8_t)); static_assert(sizeof(VendorIndexRecord) == 2 * sizeof(std::uint32_t) + 2 * sizeof(std::uint16_t)); static_assert(sizeof(VendorDefinitionRecord) - == 14 * sizeof(std::uint32_t) + 4 * sizeof(std::uint16_t)); + == 15 * sizeof(std::uint32_t) + 6 * sizeof(std::uint16_t)); static_assert(sizeof(VendorSaleRowRecord) == 4 * sizeof(std::uint16_t) + 2 * sizeof(std::uint32_t)); static_assert(sizeof(VendorInstalledRowRecord) == sizeof(std::uint32_t)); static_assert(sizeof(HashNameRecord) diff --git a/Sunrise/src/state/build_data/vendors/definition.h b/Sunrise/src/state/build_data/vendors/definition.h index aec7753f7..90fe8b0c2 100644 --- a/Sunrise/src/state/build_data/vendors/definition.h +++ b/Sunrise/src/state/build_data/vendors/definition.h @@ -26,6 +26,8 @@ inline constexpr std::size_t kThirdRowStride = 80; inline constexpr std::size_t kSaleCostRowStride = 48; /** Element class of a sale row's price-override array. */ inline constexpr std::uint32_t kSaleCostRowClass = 0x80807865U; +/** A vendor without a resolved faction has no progression index to award. */ +inline constexpr std::uint16_t kUnavailableFactionProgressionIndex = 0xFFFFU; /** Wrapper class of the vendor index blob. */ inline constexpr std::uint32_t kIndexWrapperClass = 0x8080784AU; @@ -81,6 +83,12 @@ struct Definition { std::uint16_t installedCount{}; std::uint16_t saleCount{}; std::uint16_t thirdCount{}; + /** Raw vendor definition +18; some vendors carry an out-of-range no-faction value. */ + std::uint16_t factionIndexRaw{}; + /** Faction table row selected by `factionIndexRaw`, or zero when it names none. */ + std::uint32_t factionHash{}; + /** Native faction row +4; unavailable when the vendor has no faction row. */ + std::uint16_t factionProgressionIndex{kUnavailableFactionProgressionIndex}; }; /** A sale row charging nothing carries this instead of a cost item. */ diff --git a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp index 8b45abf5b..70717437e 100644 --- a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp +++ b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp @@ -17,29 +17,28 @@ struct ReputationRule { std::uint32_t vendorHash; std::uint32_t placeholderHash; std::uint32_t costHash; - std::uint16_t progressionIndex; std::int32_t experiencePerUnit; std::uint16_t rewardValueRow{}; }; /** Build-86657 VALUE[888] and VALUE[906] map to these character-object value rows. */ constexpr std::uint16_t kCrucibleRewardValueRow = 45, kGunsmithRewardValueRow = 49; -/** Vendor/item hashes, character progression indices and XP per unit from build 86657. */ +/** Checked build-86657 sale identities and token XP; native faction rows supply progression. */ constexpr std::array kReputationRules{{ // Banshee: Gunsmith Rewards charges Gunsmith Materials for Gunsmith progression. - {672118013U, 3831705402U, 685157383U, 55, 30, kGunsmithRewardValueRow}, + {672118013U, 3831705402U, 685157383U, 30, kGunsmithRewardValueRow}, // Banshee: the same placeholder also accepts Weapon Telemetry at its own XP rate. - {672118013U, 3831705402U, 685157381U, 55, 25, kGunsmithRewardValueRow}, + {672118013U, 3831705402U, 685157381U, 25, kGunsmithRewardValueRow}, // Zavala: Vanguard Tactician Rewards charges Vanguard Tactician Tokens. - {69482069U, 3987308529U, 3899548068U, 62, 100, kVanguardRewardValueRow}, + {69482069U, 3987308529U, 3899548068U, 100, kVanguardRewardValueRow}, // Shaxx: Crucible Rewards charges Crucible Tokens, not Valor or Glory points. - {3603221665U, 265113466U, 183980811U, 49, 100, kCrucibleRewardValueRow}, + {3603221665U, 265113466U, 183980811U, 100, kCrucibleRewardValueRow}, // Devrim: EDZ token turn-ins use a different placeholder from destination materials. - {396892126U, 61430328U, 2640973641U, 52, 100}, + {396892126U, 61430328U, 2640973641U, 100}, // Devrim: destination-material turn-ins accept Dusklight Shards. - {396892126U, 1317670974U, 950899352U, 52, 50}, + {396892126U, 1317670974U, 950899352U, 50}, // Devrim: the same material placeholder accepts Dusklight Crystals at their own XP rate. - {396892126U, 1317670974U, 478751073U, 52, 250}, + {396892126U, 1317670974U, 478751073U, 250}, }}; /** Native progression level walks read experience from lane zero. */ @@ -180,31 +179,34 @@ VendorReputationDisposition resolve_award(std::uint16_t vendorIndex, std::array slots{}; std::size_t count = 0; build_data::progressions::Definition progression{}; + const auto progressionIndex = vendor.factionProgressionIndex; const std::int64_t experience = static_cast(sale.costQuantity) * rule.experiencePerUnit; - if (experience > (std::numeric_limits::max)() + if (vendor.factionHash == 0 + || progressionIndex == vendors::kUnavailableFactionProgressionIndex + || experience > (std::numeric_limits::max)() || !build_data::find_progression_slots( build_data::progressions::Scope::character, slots, count) || count > slots.size() || std::find(slots.begin(), slots.begin() + static_cast(count), - rule.progressionIndex) + progressionIndex) == slots.begin() + static_cast(count) - || !build_data::progressions::find(rule.progressionIndex, progression) + || !build_data::progressions::find(progressionIndex, progression) || progression.scope != build_data::progressions::Scope::character) { return VendorReputationDisposition::refused; } award.costHash = cost.definitionHash; award.costQuantity = sale.costQuantity; award.experience = static_cast(experience); - award.progressionIndex = rule.progressionIndex; + award.progressionIndex = progressionIndex; award.rewardValueRow = rule.rewardValueRow; award.repeatLastStep = progression.repeatLastStep; if (rule.rewardValueRow != 0) { std::array steps{}; - if (!build_data::progressions::steps(rule.progressionIndex, steps, count) || count < 2 + if (!build_data::progressions::steps(progressionIndex, steps, count) || count < 2 || std::any_of( steps.begin(), steps.begin() + static_cast(count), From a861d878b0ce25facc9a0a8fb2d15d0c42fa169f Mon Sep 17 00:00:00 2001 From: Gage Fulwood Date: Wed, 23 Sep 2026 22:48:09 -0500 Subject: [PATCH 25/59] Keep vendor verification outside contribution branch --- tests/vendor_reputation.cpp | 1107 ----------------------------------- tests/vendor_reputation.ps1 | 50 -- 2 files changed, 1157 deletions(-) delete mode 100644 tests/vendor_reputation.cpp delete mode 100644 tests/vendor_reputation.ps1 diff --git a/tests/vendor_reputation.cpp b/tests/vendor_reputation.cpp deleted file mode 100644 index 1aa63422d..000000000 --- a/tests/vendor_reputation.cpp +++ /dev/null @@ -1,1107 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include - -#include "core/logging/log.h" -#include "middleware/datagen/family4/loadout/loadout_resolver.h" -#include "state/build_data/cache/records/codec.h" -#include "state/build_data/rewards/reward_catalog.h" -#include "state/build_data/runtime.h" -#include "state/build_data/vendors/vendor_catalog.h" -#include "state/investment/store_internal.h" -#include "state/runtime/runtime.h" - -namespace { -namespace state = sunrise::state; -namespace store = state::investment::store; -using Disposition = state::VendorReputationDisposition; - -/** Synthetic account identities isolate every check from player saves. */ -constexpr std::uint64_t kAccount = 1001, kCharacter = 1002, kOtherCharacter = 1003; -/** Fixture rows are selectors, not installed vendor or item indices. */ -constexpr std::uint16_t kVendor = 1, kSale = 2, kSoldIndex = 3, kCostIndex = 4; -/** Build-86657 Banshee identity, reward placeholder and Gunsmith Material identity. */ -constexpr std::uint32_t kBanshee = 672118013, kGunsmithRewards = 3831705402, - kGunsmithMaterials = 685157383; -/** Build-86657 Zavala identity, reward placeholder and Vanguard token identity. */ -constexpr std::uint32_t kZavala = 69482069, kVanguardRewards = 3987308529, - kVanguardToken = 3899548068; -/** Native character progression indices from build 86657. */ -constexpr std::uint16_t kGunsmithProgression = 55, kVanguardProgression = 62; -/** A five-material turn-in earns 150 XP from Banshee's 30-XP material rate. */ -constexpr std::int32_t kCost = 5, kAward = 150, kInitialQuantity = 10; -/** Build-86657 Vanguard tokens award 100 XP each. */ -constexpr std::int32_t kVanguardXpPerToken = 100; -/** Nonzero untouched lanes detect accidental replacement of the whole progression row. */ -constexpr state::unlocks::ProgressionLanes kInitialProgression{40, 7, 9}; -/** A synthetic bucket holds all fixture profile stacks without instanced item sources. */ -constexpr std::uint8_t kBucket = 1; -std::uint32_t g_vendorHash = kBanshee, g_soldHash = kGunsmithRewards, - g_costHash = kGunsmithMaterials; -std::uint32_t g_cost = kCost; -std::uint16_t g_progression = kGunsmithProgression; -bool g_characterScope = true; -bool g_repeatLastStep = true; -bool g_materialAvailable = true; -/** Build-86657 owned faction engrams, separate from the turn-in placeholders. */ -constexpr std::uint32_t kVanguardEngram = 3578462974U, kCrucibleEngram = 1368565477U, - kGunsmithEngram = 3531414277U; -/** Synthetic selectors isolate package and gear from the material fixture rows. */ -constexpr std::uint16_t kRewardIndex = 5, kGearIndexBase = 100; -/** Build-86657 Vanguard package, sale, interaction, category and evaluated gate bindings. */ -constexpr std::uint32_t kPackageHash = 2746484552U; -constexpr std::uint16_t kPackageSale = 93, kClaimInteraction = 40, kClaimCategory = 3, - kPackageFlag = 5901, kLevelSlot = 465; -constexpr std::int32_t kMinimumLevel = 20; -/** Build-86657 Shaxx package sale, claim category and character-object counter. */ -constexpr std::uint32_t kShaxxHash = 3603221665U, kCruciblePackageHash = 3289621657U; -constexpr std::uint16_t kCruciblePackageSale = 96, kCrucibleCategory = 10, kCrucibleCreditRow = 45; -std::uint32_t g_packageHash = kPackageHash, g_packageCost = 0; -std::uint16_t g_packageSale = kPackageSale, g_packageCategory = kClaimCategory; -/** Build-86657 Gunsmith reward counter and normal claim interaction. */ -constexpr std::uint16_t kGunsmithCreditRow = 49, kBansheeClaimInteraction = 35; -/** Build-86657 Banshee's current weapon package sale and reward category. */ -constexpr std::uint32_t kGunsmithPackageHash = 2422825785U; -constexpr std::uint16_t kGunsmithPackageSale = 16, kGunsmithCategory = 8; - -/** Synthetic resolved items exercise the installed wrapper-to-reward path. */ -constexpr std::array kFixtureWeapons{991314988U, 720351795U}; -/** The fixture uses the installed Energy bucket's ten-row limit. */ -constexpr std::size_t kFixtureGearBucketCapacity = 10; -/** The synthetic character bucket holds a resolved instanced reward. */ -constexpr std::uint8_t kRewardBucket = 2; -/** Native faction packages select gear, a Reward Site and optional shaders by category. */ -constexpr std::uint32_t kFactionGearCategory = 1172844112U; -constexpr std::uint32_t kFactionSiteCategory = 2590539385U; -constexpr std::uint32_t kFactionShaderCategory = 328593737U; -/** The retained Vanguard and Crucible package rows reference this site. */ -constexpr std::uint16_t kFactionRewardSite = 213; - -/** @param index Synthetic gear row offset. @return The fixture hash, or zero. */ -std::uint32_t gear_hash(std::size_t index) { - return index < kFixtureWeapons.size() ? kFixtureWeapons[index] : 0; -} - -/** Reports a failed fixture check. */ -void check(bool passed, const char* label); - -/** - * Seeds an item reward with optional site-only and later item categories. - * @param opensOnAcquisition Whether the fixture wrapper expands at acquisition. - * @param secondSite Present to add a second category; kAbsent makes it malformed. - * @param supplementalMissing Whether the supplemental definition bank is absent. - */ -void seed_reward_catalog(bool opensOnAcquisition = true, - std::optional secondSite = std::nullopt, - bool supplementalMissing = true) { - namespace rewards = state::build_data::rewards; - const auto entryCount = secondSite.has_value() ? 3U : 1U; - std::array pools{{{g_packageHash, {0, entryCount}}}}; - std::array entries{}; - entries[0].itemIndex = kGearIndexBase; - entries[0].quantity = 1; - entries[0].categoryHash = kFactionGearCategory; - entries[0].weight = 1; - entries[1].supplementalIndex = secondSite.value_or(rewards::kAbsent); - entries[1].supplementalMissing = - secondSite.has_value() && secondSite.value() != rewards::kAbsent && supplementalMissing; - entries[1].quantity = 1; - entries[1].categoryHash = kFactionSiteCategory; - entries[1].weight = 1; - entries[2].itemIndex = kGearIndexBase + 1; - entries[2].quantity = 1; - entries[2].categoryHash = kFactionShaderCategory; - entries[2].weight = 1; - std::array items{}; - items[kRewardIndex].definitionHash = g_packageHash; - items[kRewardIndex].poolIndex = 0; - items[kRewardIndex].flags = opensOnAcquisition ? rewards::kOpenOnAcquisition : 0; - items[kRewardIndex].selectionCount = static_cast(entryCount); - items[kRewardIndex].selections[0] = {kFactionGearCategory, 1}; - items[kRewardIndex].selections[1] = {kFactionSiteCategory, 1}; - items[kRewardIndex].selections[2] = {kFactionShaderCategory, 1}; - items[kGearIndexBase].definitionHash = kFixtureWeapons.front(); - items[kGearIndexBase + 1].definitionHash = kFixtureWeapons.back(); - check(rewards::replace({pools, std::span{entries}.first(entryCount), items, {}, {}, {}}), - "seed reward catalog"); -} -/** Build-86657 Gunsmith's repeating rank costs 3000 XP. */ -constexpr std::int32_t kGunsmithRankCost = 3000; -/** Four positive-cost rows exercise finite ranks and the repeating tail. */ -constexpr std::size_t kFixtureRankStepCount = 4; -std::array g_rankSteps{{ - {kGunsmithRankCost}, - {kGunsmithRankCost}, - {kGunsmithRankCost}, - {kGunsmithRankCost}, -}}; -/** Build-86657 engram bucket has ten character inventory slots. */ -constexpr std::size_t kEngramCapacity = 10; -std::size_t g_rewardCapacity = kEngramCapacity; -bool g_rewardAvailable = true; - -/** @param passed Check result. @param label Failure description. */ -void check(bool passed, const char* label) { - if (!passed) { - std::fprintf(stderr, "FAIL: %s\n", label); - std::abort(); - } -} - -/** - * Reads repository SQL resources, never a player database. - * @param path Resource path. - * @return File contents. - */ -std::string read_text(const std::string& path) { - std::FILE* file = nullptr; - check(fopen_s(&file, path.c_str(), "rb") == 0 && file != nullptr, "open SQL resource"); - std::string text; - // SQL resource reads use a fixed 4-KiB buffer. - std::array buffer{}; - while (const auto count = std::fread(buffer.data(), 1, buffer.size(), file)) { - text.append(buffer.data(), count); - } - check(std::ferror(file) == 0, "read SQL resource"); - std::fclose(file); - return text; -} - -/** Resets only the disposable fixture and its controlled content catalogue. */ -void reset() { - g_packageHash = kPackageHash; - g_packageSale = kPackageSale; - g_packageCategory = kClaimCategory; - g_packageCost = 0; - g_vendorHash = kBanshee; - g_soldHash = kGunsmithRewards; - g_costHash = kGunsmithMaterials; - g_cost = kCost; - g_progression = kGunsmithProgression; - g_characterScope = true; - g_repeatLastStep = true; - g_materialAvailable = true; - g_rankSteps = { - {{kGunsmithRankCost}, {kGunsmithRankCost}, {kGunsmithRankCost}, {kGunsmithRankCost}}}; - g_rewardCapacity = kEngramCapacity; - g_rewardAvailable = true; - state::AccountState account{}; - account.primarySoid = kAccount; - account.characterCount = 2; - account.characters[0].soid = kCharacter; - account.characters[0].selected = true; - account.characters[1].soid = kOtherCharacter; - account.profileItemCount = 1; - account.profileItems[0] = {0, kGunsmithMaterials, kInitialQuantity, 1, true}; - check(store::read_settings(account.settings) && store::write_account(account), "seed account"); - check(store::execute("DELETE FROM unlocks"), "clear fixture banks"); - state::unlocks::Table banks{}; - banks.characterProgressions[kGunsmithProgression] = kInitialProgression; - check(store::write_unlocks(banks, 0), "seed progression"); -} - -/** @return Character-zero Gunsmith lanes from the disposable database. */ -state::unlocks::ProgressionLanes gunsmith() { - state::unlocks::Table banks{}; - check(store::read_unlocks(banks, 0), "read progression"); - return banks.characterProgressions[kGunsmithProgression]; -} - -/** Covers successful, stale, insufficient, overflow and rolled-back turn-ins. */ -void verify() { - state::PendingVendorReputation pending{}; - reset(); - check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared, - "prepare Banshee"); - check(store::account().profileItems[0].quantity == kInitialQuantity - && gunsmith() == kInitialProgression, - "prepare writes nothing"); - auto duplicate = pending; - check(state::commit_vendor_reputation(pending) && !pending.prepared, "commit and consume"); - const auto expected = state::unlocks::ProgressionLanes{ - kInitialProgression[0] + kAward, kInitialProgression[1], kInitialProgression[2]}; - check(gunsmith() == expected - && store::account().profileItems[0].quantity == kInitialQuantity - kCost, - "exact cost and XP, other lanes preserved"); - check(!state::commit_vendor_reputation(pending) && !state::commit_vendor_reputation(duplicate), - "reused and duplicate plans refused"); - state::unlocks::Table other{}; - check(store::read_unlocks(other, 1) - && other.characterProgressions[kGunsmithProgression] - == state::unlocks::ProgressionLanes{}, - "other character untouched"); - check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared - && state::commit_vendor_reputation(pending) && store::account().profileItemCount == 0, - "exact balance removes empty stack"); - check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::refused, - "empty inventory refused"); - - reset(); - auto split = store::account(); - split.profileItemCount = 2; - split.profileItems[0].quantity = kCost - 1; - split.profileItems[1] = {0, kGunsmithMaterials, kInitialQuantity, 2, true}; - check(store::write_account(split), "seed split material stacks"); - check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared - && state::commit_vendor_reputation(pending), - "charge across material stacks"); - check(store::account().profileItemCount == 1 - && store::account().profileItems[0].quantity == kInitialQuantity - 1 - && store::account().profileItems[0].mutationSerial == 2, - "dense remainder preserves its ordering serial"); - - reset(); - g_cost = kInitialQuantity + 1; - check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::refused - && gunsmith() == kInitialProgression - && store::account().profileItems[0].quantity == kInitialQuantity, - "insufficient materials leave both sides unchanged"); - g_cost = (std::numeric_limits::max)(); - check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::refused, - "award multiplication overflow refused"); - reset(); - check(store::write_unlock(store::Bank::characterProgressions, - kGunsmithProgression, - (std::numeric_limits::max)()), - "seed overflow"); - check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::refused, - "XP total overflow refused"); - - reset(); - check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared, - "prepare stale inventory"); - auto account = store::account(); - --account.profileItems[0].quantity; - check(store::write_account(account) && !state::commit_vendor_reputation(pending) - && gunsmith() == kInitialProgression, - "stale inventory refused"); - reset(); - check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared, - "prepare stale character"); - account = store::account(); - account.characters[0].selected = false; - account.characters[1].selected = true; - check(store::write_account(account) && !state::commit_vendor_reputation(pending), - "changed selection refused"); - reset(); - check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared, - "prepare stale XP"); - check(store::write_unlock(store::Bank::characterProgressions, kGunsmithProgression, 0) - && !state::commit_vendor_reputation(pending), - "changed XP refused"); - reset(); - check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared, - "prepare stale cost"); - ++g_cost; - check(!state::commit_vendor_reputation(pending), "changed sale cost refused"); - reset(); - g_cost = kInitialQuantity; - check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared, - "prepare whole-stack charge"); - g_materialAvailable = false; - check(!state::commit_vendor_reputation(pending) - && store::account().profileItems[0].quantity == kInitialQuantity, - "missing material definition cannot be hidden by removing its stack"); - - reset(); - { - store::Transaction outer; - check(outer.ready() - && state::prepare_vendor_reputation(kVendor, kSale, pending) - == Disposition::prepared - && state::commit_vendor_reputation(pending), - "stage inside response transaction"); - } - check(gunsmith() == kInitialProgression - && store::account().profileItems[0].quantity == kInitialQuantity, - "abandoned response transaction rolls back both writes"); - check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared, - "prepare SQL failure"); - check(store::execute("CREATE TEMP TRIGGER reject_xp BEFORE INSERT ON unlocks " - "BEGIN SELECT RAISE(ABORT,'fixture'); END"), - "inject XP write failure"); - check(!state::commit_vendor_reputation(pending) - && store::account().profileItems[0].quantity == kInitialQuantity - && gunsmith() == kInitialProgression, - "failed XP write rolls back material debit"); - check(store::execute("DROP TRIGGER reject_xp"), "remove fixture fault"); - - reset(); - g_soldHash = kVanguardRewards; - check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::notApplicable, - "ordinary purchase using the same material does not award XP"); - g_soldHash = kGunsmithRewards; - g_costHash = kVanguardToken; - check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::refused, - "known placeholder with wrong payment cannot fall through"); - reset(); - g_characterScope = false; - check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::refused, - "wrong progression scope refused"); - - reset(); - g_vendorHash = kZavala; - g_soldHash = kVanguardRewards; - g_costHash = kVanguardToken; - g_progression = kVanguardProgression; - account = store::account(); - account.profileItems[0].definitionHash = kVanguardToken; - check(store::write_account(account), "seed Vanguard tokens"); - check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared - && state::commit_vendor_reputation(pending), - "shared transaction handles Zavala"); - check(store::read_unlocks(other, 0) - && other.characterProgressions[kVanguardProgression][0] - == kCost * kVanguardXpPerToken, - "Vanguard token rate, not Gunsmith rate"); -} - -/** @return One selected-character claim counter from the disposable database. */ -std::int32_t credits(std::uint16_t row) { - std::int32_t value = 0; - check(store::read_unlock(store::Bank::characterObjectValues, row, value), "read claim credits"); - return value; -} - -/** The cache preserves the installed repeat rule and refuses an invalid byte. */ -void verify_progression_cache() { - namespace cache = state::build_data::cache::records; - state::build_data::progressions::Definition source{}; - source.definitionIndex = kGunsmithProgression; - source.scope = state::build_data::progressions::Scope::character; - source.repeatLastStep = true; - cache::ProgressionRecord record{}; - state::build_data::progressions::Definition decoded{}; - check(cache::encode(source, record) && record.repeatLastStep == 1 - && cache::decode(record, decoded) && decoded.repeatLastStep, - "progression repeat rule survives cache"); - record.repeatLastStep = 2; - check(!cache::decode(record, decoded) && decoded.definitionIndex == 0, - "invalid repeat rule refuses cache"); -} - -/** Covers exact thresholds, multiple ranks, no backfill, overflow and transaction rollback. */ -void verify_rank_rewards() { - /** Build-86657 vendor, progression and saved claim-counter mappings. */ - struct RankCase { - std::uint32_t vendor, placeholder, material; - std::uint16_t progression, rewardRow; - std::int32_t rankCost, rate; - }; - constexpr std::array cases{{ - {kBanshee, kGunsmithRewards, kGunsmithMaterials, 55, 49, 3000, 30}, - {kZavala, kVanguardRewards, kVanguardToken, 62, 55, 2000, 100}, - {3603221665U, 265113466U, 183980811U, 49, 45, 2000, 100}, - }}; - state::PendingVendorReputation pending{}; - for (const auto& entry : cases) { - reset(); - g_vendorHash = entry.vendor; - g_soldHash = entry.placeholder; - g_costHash = entry.material; - g_progression = entry.progression; - g_rankSteps = {{{entry.rankCost}, {entry.rankCost}, {entry.rankCost}, {entry.rankCost}}}; - auto account = store::account(); - account.profileItems[0].definitionHash = entry.material; - check(store::write_account(account) - && store::write_unlock(store::Bank::characterProgressions, - entry.progression, - entry.rankCost - kCost * entry.rate), - "seed exact-threshold turn-in"); - g_rewardCapacity = 0; - g_rewardAvailable = false; - check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared - && credits(entry.rewardRow) == 0, - "credit preparation needs no inventory capacity or reward item"); - auto duplicate = pending; - check(state::commit_vendor_reputation(pending) && credits(entry.rewardRow) == 1 - && store::account().characters[0].inventory.count == 0, - "one credit, no owned engram"); - check(!state::commit_vendor_reputation(duplicate) && credits(entry.rewardRow) == 1, - "duplicate cannot credit again"); - check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared - && state::commit_vendor_reputation(pending) && credits(entry.rewardRow) == 1, - "below next threshold adds no credit"); - state::unlocks::Table other{}; - check(store::read_unlocks(other, 1) && other.characterObjectValues[entry.rewardRow] == 0, - "other character receives no credit"); - } - reset(); - check(store::write_unlock( - store::Bank::characterProgressions, kGunsmithProgression, kGunsmithRankCost - kAward) - && state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared, - "prepare before progression cost change"); - ++g_rankSteps[1].cost; - check(!state::commit_vendor_reputation(pending) && credits(kGunsmithCreditRow) == 0 - && store::account().profileItems[0].quantity == kInitialQuantity - && gunsmith()[0] == kGunsmithRankCost - kAward, - "changed progression cost refuses stale turn-in"); - reset(); - g_rankSteps[0].cost = 0; - check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::refused, - "rank ladder with a zero first step refused"); - reset(); - /** Four fixture costs plus one repeated final cost reach the first tail threshold. */ - constexpr std::int32_t kFirstRepeatedRankThreshold = - kGunsmithRankCost * static_cast(kFixtureRankStepCount + 1); - check(store::write_unlock(store::Bank::characterProgressions, - kGunsmithProgression, - kFirstRepeatedRankThreshold - kAward) - && state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared - && state::commit_vendor_reputation(pending) && credits(kGunsmithCreditRow) == 1, - "installed repeat-tail rule awards its crossed credit"); - reset(); - g_repeatLastStep = false; - check(store::write_unlock(store::Bank::characterProgressions, - kGunsmithProgression, - kFirstRepeatedRankThreshold - kAward) - && state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared - && state::commit_vendor_reputation(pending) && credits(kGunsmithCreditRow) == 0, - "finite installed ladder does not award beyond its final step"); - reset(); - check(store::write_unlock( - store::Bank::characterProgressions, kGunsmithProgression, kGunsmithRankCost - kAward), - "seed crossing"); - { - store::Transaction outer; - check(outer.ready() - && state::prepare_vendor_reputation(kVendor, kSale, pending) - == Disposition::prepared - && state::commit_vendor_reputation(pending), - "stage credit inside response"); - } - check(credits(kGunsmithCreditRow) == 0 - && store::account().profileItems[0].quantity == kInitialQuantity - && gunsmith()[0] == kGunsmithRankCost - kAward, - "abandoned response rolls back credit, XP and payment"); - check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared - && store::execute("CREATE TEMP TRIGGER reject_rank BEFORE INSERT ON unlocks " - "BEGIN SELECT RAISE(ABORT,'fixture'); END"), - "inject unlock write failure"); - check(!state::commit_vendor_reputation(pending) && credits(kGunsmithCreditRow) == 0 - && store::account().profileItems[0].quantity == kInitialQuantity, - "failed unlock write rolls back payment"); - check(store::execute("DROP TRIGGER reject_rank"), "remove rank fault"); - check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared - && store::write_unlock(store::Bank::characterObjectValues, kGunsmithCreditRow, 1) - && !state::commit_vendor_reputation(pending), - "concurrent credit change refuses stale turn-in"); - check(store::write_unlock(store::Bank::characterObjectValues, - kGunsmithCreditRow, - (std::numeric_limits::max)()) - && state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::refused, - "credit overflow refused"); - check(store::write_unlock(store::Bank::characterObjectValues, kGunsmithCreditRow, -1) - && state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::refused, - "negative credit refused"); - reset(); - check(store::write_unlock( - store::Bank::characterProgressions, kGunsmithProgression, kGunsmithRankCost * 2) - && state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared - && state::commit_vendor_reputation(pending) && credits(kGunsmithCreditRow) == 0, - "historical ranks never backfilled"); - reset(); - auto account = store::account(); - /** Uneven costs prove one payment can cross several installed thresholds. */ - constexpr std::array - kUnevenRankSteps{{{90}, {180}, {300}, {360}}}; - /** Twenty materials award 600 XP and cross all three paid fixture steps. */ - constexpr std::uint32_t kUnevenRankTurnIn = 20; - g_rankSteps = kUnevenRankSteps; - g_cost = kUnevenRankTurnIn; - account.profileItems[0].quantity = static_cast(g_cost); - check(store::write_account(account) - && state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared - && state::commit_vendor_reputation(pending) && credits(kGunsmithCreditRow) == 3 - && store::account().characters[0].inventory.count == 0, - "uneven thresholds award every crossed credit, no items"); -} - -/** Configures a disposable eligible Zavala claim without changing player saves. - * @param secondSite Present to add a second category; kAbsent makes it malformed. - * @param supplementalMissing Whether the supplemental definition bank is absent. - */ -void reset_claim(std::optional secondSite = std::nullopt, - bool supplementalMissing = true) { - reset(); - g_vendorHash = kZavala; - seed_reward_catalog(true, secondSite, supplementalMissing); - state::Family5State family{}; - family.flagCount = 1; - family.flags[0] = {kPackageFlag, state::unlocks::kFlagSet}; - family.valueCount = 1; - family.values[0] = {kLevelSlot, kMinimumLevel}; - check(store::write_family5(family) - && store::write_unlock( - store::Bank::characterObjectValues, state::kVanguardRewardValueRow, 2), - "seed claim fixture"); -} - -/** The native wrapper payout and rank credit share preview, commit and rollback. */ -void verify_claims() { - state::PendingRecordRewardGrant grant{}; - namespace cache = state::build_data::cache::records; - state::build_data::rewards::Entry source{}; - source.supplementalIndex = kFactionRewardSite; - source.supplementalMissing = true; - cache::RewardEntryRecord stored{}; - state::build_data::rewards::Entry loaded{}; - check(cache::encode(source, stored) && cache::decode(stored, loaded) - && loaded.supplementalIndex == kFactionRewardSite && loaded.supplementalMissing, - "supplemental reward reference survives build-data cache"); - reset_claim(kFactionRewardSite); - check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, grant) == Disposition::prepared - && grant.rewardCount == 2 - && grant.rewards[0].definitionHash == kFixtureWeapons.front() - && grant.rewards[1].definitionHash == kFixtureWeapons.back() - && credits(state::kVanguardRewardValueRow) == 2 && state::commit_record_reward(grant) - && credits(state::kVanguardRewardValueRow) == 1 - && store::account().characters[0].inventory.count == 2, - "absent supplemental bank does not block item reward and credit commit"); - reset_claim(kFactionRewardSite, false); - check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, grant) == Disposition::refused - && credits(state::kVanguardRewardValueRow) == 2, - "present unsupported supplemental reward refuses without credit debit"); - reset_claim(state::build_data::rewards::kAbsent); - check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, grant) == Disposition::refused - && credits(state::kVanguardRewardValueRow) == 2, - "targetless category still refuses claim"); - reset_claim(); - check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, grant) == Disposition::prepared - && grant.rewardCount == 1 - && grant.rewards[0].definitionHash == kFixtureWeapons.front(), - "prepare installed wrapper reward"); - auto duplicate = grant; - state::AccountState after{}; - state::unlocks::Table banks{}; - check(state::preview_record_reward_grant(grant, after) - && state::preview_reward_unlocks(grant, banks) - && after.characters[0].inventory.count == 1 - && banks.characterObjectValues[state::kVanguardRewardValueRow] == 1 - && credits(state::kVanguardRewardValueRow) == 2, - "preview grants item and debits credit without saving"); - check(state::commit_record_reward(grant) && credits(state::kVanguardRewardValueRow) == 1 - && store::account().characters[0].inventory.count == 1 - && store::account().characters[0].inventory.values[0].definitionHash - == kFixtureWeapons.front(), - "one reward and one credit commit together"); - check(!state::commit_record_reward(duplicate) && !state::commit_record_reward(grant), - "duplicate and reused claims refused"); - check(store::read_unlocks(banks, 1) - && banks.characterObjectValues[state::kVanguardRewardValueRow] == 0 - && store::account().characters[1].inventory.count == 0, - "other character unchanged"); - - reset_claim(); - { - store::Transaction outer; - check(outer.ready() - && state::prepare_vendor_reward_sale(kVendor, kPackageSale, grant) - == Disposition::prepared - && state::commit_record_reward(grant), - "claim inside response transaction"); - } - check(credits(state::kVanguardRewardValueRow) == 2 - && store::account().characters[0].inventory.count == 0, - "response failure rolls back reward and credit"); - check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, grant) == Disposition::prepared - && store::execute("CREATE TEMP TRIGGER reject_claim BEFORE INSERT ON unlocks " - "BEGIN SELECT RAISE(ABORT,'fixture'); END"), - "inject credit write failure"); - check(!state::commit_record_reward(grant) && credits(state::kVanguardRewardValueRow) == 2 - && store::account().characters[0].inventory.count == 0, - "credit failure rolls back reward"); - check(store::execute("DROP TRIGGER reject_claim"), "remove claim fault"); - - check(state::prepare_vendor_reward(kVendor, kClaimInteraction, 1, grant) - == Disposition::refused, - "wrong reply refused"); - check(state::prepare_vendor_reward(kVendor, kClaimInteraction + 1, 0, grant) - == Disposition::notApplicable, - "other interactions left alone"); - check(state::is_vendor_reward_category(kVendor, kClaimCategory) - && !state::is_vendor_reward_category(kVendor, kClaimCategory + 1), - "reward category blocks sale bypass"); - - reset_claim(); - g_rewardCapacity = 0; - const char* refusal = nullptr; - check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, grant, &refusal) - == Disposition::refused - && refusal != nullptr && std::string_view(refusal) == "instance_capacity" - && credits(state::kVanguardRewardValueRow) == 2, - "full inventory reports capacity and preserves credit"); - reset_claim(); - g_rewardCapacity = kFixtureGearBucketCapacity + 1; - auto account = store::account(); - for (std::size_t index = 0; index < kFixtureGearBucketCapacity - 1; ++index) { - auto& item = account.characters[0].inventory.values[index]; - item.instanceSoid = kOtherCharacter + index + 1; - item.definitionHash = kFixtureWeapons.front(); - item.quantity = 1; - item.mutationSerial = static_cast(index); - } - account.characters[0].inventory.count = kFixtureGearBucketCapacity - 1; - account.characters[0].nextInventorySerial = kFixtureGearBucketCapacity; - check(store::write_account(account) - && state::prepare_vendor_reward_sale(kVendor, kPackageSale, grant) - == Disposition::prepared, - "reward fits with one gear-bucket row free"); - auto& lastItem = account.characters[0].inventory.values[kFixtureGearBucketCapacity - 1]; - lastItem.instanceSoid = kOtherCharacter + kFixtureGearBucketCapacity; - lastItem.definitionHash = kFixtureWeapons.front(); - lastItem.quantity = 1; - lastItem.mutationSerial = static_cast(kFixtureGearBucketCapacity - 1); - account.characters[0].inventory.count = kFixtureGearBucketCapacity; - check(store::write_account(account) - && state::prepare_vendor_reward_sale(kVendor, kPackageSale, grant) - == Disposition::refused - && credits(state::kVanguardRewardValueRow) == 2 - && store::account().characters[0].inventory.count == kFixtureGearBucketCapacity, - "full gear bucket preserves credit despite account capacity"); - reset_claim(); - g_rewardAvailable = false; - check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, grant) == Disposition::refused, - "missing reward definition refuses payout"); - reset_claim(); - check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, grant) == Disposition::prepared - && store::write_unlock( - store::Bank::characterObjectValues, state::kVanguardRewardValueRow, 1) - && !state::commit_record_reward(grant), - "stale credit refuses claim"); - check(store::write_unlock(store::Bank::characterObjectValues, state::kVanguardRewardValueRow, 0) - && state::prepare_vendor_reward_sale(kVendor, kPackageSale, grant, &refusal) - == Disposition::refused - && refusal != nullptr && std::string_view(refusal) == "rank_credit", - "no credit reports binding refusal"); - reset_claim(); - check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, grant) == Disposition::prepared - && store::write_family5({}) && !state::commit_record_reward(grant) - && credits(state::kVanguardRewardValueRow) == 2, - "changed package gates preserve credit"); - - reset_claim(); - check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, grant) == Disposition::prepared, - "prepare row tamper"); - grant.vendorReward.rewardValueRow = kCrucibleCreditRow; - check(!state::commit_record_reward(grant) && credits(state::kVanguardRewardValueRow) == 2, - "tampered credit row refused"); - check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, grant) == Disposition::prepared, - "prepare sale tamper"); - ++g_packageHash; - check(!state::commit_record_reward(grant) && credits(state::kVanguardRewardValueRow) == 2, - "changed installed package preserves credit"); - reset_claim(); - seed_reward_catalog(false); - check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, grant) == Disposition::refused - && credits(state::kVanguardRewardValueRow) == 2, - "non-opening wrapper cannot consume rank credit"); -} - -/** Each vendor debits only its own entitlement through the same reward batch. */ -void verify_shared_sales() { - state::PendingRecordRewardGrant grant{}; - for (const bool crucible : {false, true}) { - reset_claim(); - if (crucible) { - g_vendorHash = kShaxxHash; - g_packageHash = kCruciblePackageHash; - g_packageSale = kCruciblePackageSale; - g_packageCategory = kCrucibleCategory; - seed_reward_catalog(); - } - const auto row = crucible ? kCrucibleCreditRow : state::kVanguardRewardValueRow; - const auto otherRow = crucible ? state::kVanguardRewardValueRow : kCrucibleCreditRow; - check(store::write_unlock(store::Bank::characterObjectValues, row, 1) - && store::write_unlock(store::Bank::characterObjectValues, otherRow, 2), - "seed independent faction credits"); - check(state::prepare_vendor_reward_sale(kVendor, g_packageSale, grant) - == Disposition::prepared - && grant.rewardCount == 1, - "prepare vendor wrapper through shared resolver"); - state::unlocks::Table banks{}; - check(state::preview_reward_unlocks(grant, banks) && banks.characterObjectValues[row] == 0 - && banks.characterObjectValues[otherRow] == 2, - "preview debits only matching faction"); - check(state::commit_record_reward(grant) && credits(row) == 0 && credits(otherRow) == 2, - "shared grant debits matching faction"); - check(state::prepare_vendor_reward_sale(kVendor, g_packageSale, grant) - == Disposition::refused, - "another faction's credit cannot authorize this sale"); - } - reset_claim(); - check(state::prepare_vendor_reward(kVendor, kClaimInteraction, 0, grant) - == Disposition::prepared - && state::commit_record_reward(grant), - "rowless reply uses shared settlement"); - check(state::prepare_vendor_reward_sale(kVendor, kSale, grant) == Disposition::notApplicable, - "ordinary sale stays outside rank settlement"); - check(state::prepare_vendor_reward_sale(kVendor, kPackageSale + 1, grant) - == Disposition::refused, - "wrong sale cannot claim a package"); - g_packageCost = 1; - check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, grant) == Disposition::refused, - "nonzero material cost cannot be ignored"); -} - -/** Banshee needs no Vanguard/Crucible selection gates. */ -void verify_gunsmith_sales() { - reset(); - g_packageHash = kGunsmithPackageHash; - g_packageSale = kGunsmithPackageSale; - g_packageCategory = kGunsmithCategory; - seed_reward_catalog(); - check(store::write_family5({}) - && store::write_unlock(store::Bank::characterObjectValues, kGunsmithCreditRow, 1) - && store::write_unlock( - store::Bank::characterObjectValues, state::kVanguardRewardValueRow, 2), - "seed isolated Gunsmith credit"); - state::PendingRecordRewardGrant grant{}; - check(state::prepare_vendor_reward(kVendor, kBansheeClaimInteraction, 0, grant) - == Disposition::prepared - && state::commit_record_reward(grant) && credits(kGunsmithCreditRow) == 0 - && credits(state::kVanguardRewardValueRow) == 2, - "Gunsmith claim works without unrelated gates"); - check(state::prepare_vendor_reward_sale(kVendor, kGunsmithPackageSale, grant) - == Disposition::refused, - "spent Gunsmith credit cannot be reused"); -} -} // namespace - -namespace sunrise::core::log { -/** Logging is not an assertion; state checks use the real store and transaction helpers. */ -void write(Channel, Level, std::string_view) noexcept {} -} // namespace sunrise::core::log - -namespace sunrise::state::build_data::vendors { -/** Catalogue doubles expose one controlled vendor and sale, not runtime inventory. */ -bool find_index(std::uint16_t index, IndexEntry& entry) noexcept { - entry = {}; - entry.definitionHash = g_vendorHash; - return index == kVendor; -} -bool find(std::uint32_t hash, Definition& definition) noexcept { - definition = {}; - return hash == g_vendorHash; -} -bool sale_row(const Definition&, std::size_t row, SaleRow& output) noexcept { - output = {}; - output.itemIndex = kSoldIndex; - output.costItemIndex = kCostIndex; - output.costQuantity = g_cost; - if (row == g_packageSale) { - output.itemIndex = kRewardIndex; - output.categoryIndex = g_packageCategory; - output.costQuantity = g_packageCost; - output.costItemIndex = kAbsentCostItem; - return true; - } - return row == kSale; -} -} // namespace sunrise::state::build_data::vendors - -namespace sunrise::state::build_data { -/** Unrelated emote and season-pass paths are not exercised by this fixture. */ -bool item_definitions_ready() noexcept { - return false; -} -bool configured_item_details_ready() noexcept { - return false; -} -bool socket_plug_rules_ready() noexcept { - return false; -} -bool is_socket_plug_allowed(std::uint16_t, std::uint8_t, std::uint16_t) noexcept { - return false; -} -bool find_season_pass_reward(std::uint16_t, season_pass::Reward&) noexcept { - return false; -} -/** Unrelated acquisition code shares the profile helper translation unit. */ -bool find_collectible_definition(std::uint16_t, collectibles::Definition&) noexcept { - return false; -} -/** Item catalogue doubles retain production profile-shape validation. */ -bool find_item_definition_index(std::uint16_t definitionIndex, - items::Definition& definition) noexcept { - definition = {}; - definition.definitionIndex = definitionIndex; - definition.definitionHash = definitionIndex == kSoldIndex ? g_soldHash : g_costHash; - definition.bucketId = kBucket; - if (definitionIndex == kRewardIndex) { - definition.definitionHash = g_packageHash; - return true; - } - if (definitionIndex >= kGearIndexBase) { - definition.definitionHash = gear_hash(definitionIndex - kGearIndexBase); - definition.bucketId = kRewardBucket; - return g_rewardAvailable && definition.definitionHash != 0; - } - return definitionIndex == kSoldIndex || definitionIndex == kCostIndex; -} -bool find_item_definition_hash(std::uint32_t definitionHash, - items::Definition& definition) noexcept { - if (definitionHash == kVanguardEngram || definitionHash == kCrucibleEngram - || definitionHash == kGunsmithEngram) { - definition = {}; - definition.definitionHash = definitionHash; - definition.definitionIndex = kRewardIndex; - return g_rewardAvailable; - } - for (std::size_t index = 0; gear_hash(index) != 0; ++index) { - if (definitionHash == gear_hash(index)) { - return find_item_definition_index(static_cast(kGearIndexBase + index), - definition); - } - } - return definitionHash == g_costHash && find_item_definition_index(kCostIndex, definition); -} -bool find_configured_item_detail(std::uint16_t definitionIndex, - items::details::Definition& definition) noexcept { - definition = {}; - definition.definitionIndex = definitionIndex; - definition.definitionHash = g_costHash; - definition.bucketId = kBucket; - definition.instancedDefinitionState = items::details::InstancedDefinitionState::stackable; - if (definitionIndex >= kGearIndexBase) { - definition.definitionHash = gear_hash(definitionIndex - kGearIndexBase); - definition.bucketId = kRewardBucket; - definition.equipmentSlot = 0; - definition.instancedDefinitionState = items::details::InstancedDefinitionState::instanced; - return g_rewardAvailable && definition.definitionHash != 0; - } - return g_materialAvailable && definitionIndex == kCostIndex; -} -bool find_inventory_bucket_descriptor(std::uint8_t bucketId, - inventory::buckets::Descriptor& descriptor) noexcept { - descriptor = {}; - if (bucketId == kRewardBucket) { - descriptor.arraySelector = inventory::buckets::ArraySelector::character; - descriptor.slotCount = kEngramCapacity; - return true; - } - descriptor.arraySelector = inventory::buckets::ArraySelector::profile; - descriptor.slotCount = inventory::buckets::kProfileSlotCapacity; - return bucketId == kBucket; -} -bool is_profile_action_source(std::uint16_t, std::uint8_t) noexcept { - return false; -} -/** - * Exposes only the fixture's selected progression in the requested object scope. - * @param scope Requested object scope. - * @param output Receives the one fixture index. - * @param count Receives one on success, zero on failure. - * @return False for missing storage or a scope the fixture does not publish. - */ -bool find_progression_slots(progressions::Scope scope, - std::span output, - std::size_t& count) noexcept { - count = 0; - if (scope != progressions::Scope::character || !g_characterScope || output.empty()) { - return false; - } - output[0] = g_progression; - count = 1; - return true; -} - -namespace progressions { -/** - * Reads the fixture's rank rule for its selected progression. - * @param definitionIndex Requested native progression index. - * @param definition Receives the matching definition, or an empty row. - * @return False when the fixture does not hold the index. - */ -bool find(std::uint16_t definitionIndex, Definition& definition) noexcept { - definition = {}; - if (definitionIndex != g_progression) { - return false; - } - definition.definitionIndex = definitionIndex; - definition.scope = g_characterScope ? Scope::character : Scope::account; - definition.repeatLastStep = g_repeatLastStep; - return true; -} -/** - * Copies the controlled installed-cost fixture for the selected progression. - * @param definitionIndex Requested native progression index. - * @param output Caller-owned step storage. - * @param count Receives the copied row count. - * @return False when the request does not match the fixture or storage is too small. - */ -bool steps(std::uint16_t definitionIndex, std::span output, std::size_t& count) noexcept { - count = 0; - if (definitionIndex != g_progression || output.size() < g_rankSteps.size()) { - return false; - } - for (std::size_t step = 0; step < g_rankSteps.size(); ++step) { - output[step] = g_rankSteps[step]; - } - count = g_rankSteps.size(); - return true; -} -} // namespace progressions -} // namespace sunrise::state::build_data - -namespace sunrise::state { -/** Unrelated profile exchange code reads the same disposable store. */ -AccountState account_snapshot() noexcept { - return investment::store::account(); -} -/** Season-pass settlement is outside the rank-claim fixture. */ -std::uint16_t seasonal_rank() noexcept { - return 0; -} -bool season_pass_reward_claimed(std::uint16_t) noexcept { - return false; -} -void revoke_season_pass_reward(std::uint16_t) noexcept {} -} // namespace sunrise::state - -namespace sunrise::state::unlocks::records { -/** Vendor batches never claim a record row. */ -void revoke(std::uint16_t) noexcept {} -} // namespace sunrise::state::unlocks::records - -namespace sunrise::middleware::datagen::family4::loadout { -/** - * Models total and per-gear-bucket capacity without installed game content. - * @param account Candidate account. - * @param selectedCharacterIndex Character whose items must fit. - * @param output Receives synthetic inventory positions. - * @return False when total or gear-bucket capacity is exhausted. - */ -bool resolve(const state::AccountState& account, - std::size_t selectedCharacterIndex, - ResolvedLoadout& output) noexcept { - output = {}; - const auto& inventory = account.characters[selectedCharacterIndex].inventory; - if (inventory.count > g_rewardCapacity) { - return false; - } - const auto gearCount = std::count_if( - inventory.values.begin(), - inventory.values.begin() + static_cast(inventory.count), - [](const auto& item) { return item.definitionHash == kFixtureWeapons.front(); }); - if (gearCount > kFixtureGearBucketCapacity) { - return false; - } - for (std::size_t index = 0; index < inventory.count; ++index) { - auto& item = output.items[output.itemCount++]; - item.inventoryRow = static_cast(index); - item.instance.instanceSoid = inventory.values[index].instanceSoid; - } - return true; -} -} // namespace sunrise::middleware::datagen::family4::loadout - -/** - * Runs against disposable SQLite only, then checks persistence by closing and reopening it. - * @param argc Argument count. - * @param argv Executable, SQL directory, disposable database, and optional existing-save copy. - * @return Zero when every check passes; failed checks abort. - */ -int main(int argc, char** argv) { - check(argc == 3 || argc == 4, "resource directory and disposable database arguments"); - const std::string root = argv[1]; - check(store::open(":memory:", - read_text(root + "/investment_schema.sql"), - "INSERT INTO account VALUES (1,1001,0);", - read_text(root + "/account_settings_schema.sql"), - read_text(root + "/account_settings_defaults.sql")), - "open disposable store"); - seed_reward_catalog(); - verify(); - verify_progression_cache(); - verify_rank_rewards(); - verify_claims(); - verify_shared_sales(); - verify_gunsmith_sales(); - store::shutdown(); - check(store::open(argv[2], - read_text(root + "/investment_schema.sql"), - "INSERT INTO account VALUES (1,1001,0);", - read_text(root + "/account_settings_schema.sql"), - read_text(root + "/account_settings_defaults.sql")), - "open disposable disk store"); - reset(); - check(store::write_unlock( - store::Bank::characterProgressions, kGunsmithProgression, kGunsmithRankCost - kAward), - "seed persistent rank crossing"); - state::PendingVendorReputation pending{}; - check(state::prepare_vendor_reputation(kVendor, kSale, pending) == Disposition::prepared - && state::commit_vendor_reputation(pending), - "commit before closing store"); - store::shutdown(); - check(store::open(argv[2], {}, {}, {}, {}), "reopen saved store without seeding"); - check(gunsmith()[0] == kGunsmithRankCost - && store::account().profileItems[0].quantity == kInitialQuantity - kCost - && store::account().characters[0].inventory.count == 0, - "material debit and XP survive reopening database"); - state::unlocks::Table reopened{}; - check(store::read_unlocks(reopened, 0) - && reopened.characterObjectValues[kGunsmithCreditRow] == 1, - "claim credit survives reopening database"); - reset_claim(); - state::PendingRecordRewardGrant claim{}; - check(state::prepare_vendor_reward_sale(kVendor, kPackageSale, claim) == Disposition::prepared - && state::commit_record_reward(claim), - "commit persistent claim"); - store::shutdown(); - check(store::open(argv[2], {}, {}, {}, {}), "reopen claimed save"); - check(store::read_unlocks(reopened, 0) - && reopened.characterObjectValues[state::kVanguardRewardValueRow] == 1 - && store::account().characters[0].inventory.count == 1, - "claim debit and item persist together"); - reset(); - g_packageHash = kGunsmithPackageHash; - g_packageSale = kGunsmithPackageSale; - g_packageCategory = kGunsmithCategory; - seed_reward_catalog(); - check(store::write_family5({}) - && store::write_unlock(store::Bank::characterObjectValues, kGunsmithCreditRow, 1) - && state::prepare_vendor_reward_sale(kVendor, kGunsmithPackageSale, claim) - == Disposition::prepared - && state::commit_record_reward(claim), - "commit persistent Gunsmith claim"); - store::shutdown(); - check(store::open(argv[2], {}, {}, {}, {}), "reopen Gunsmith claim"); - check(credits(kGunsmithCreditRow) == 0 && store::account().characters[0].inventory.count == 1, - "Gunsmith item and debit persist together"); - store::shutdown(); - sqlite3* legacy{}; - check(sqlite3_open_v2(argv[2], &legacy, SQLITE_OPEN_READWRITE, nullptr) == SQLITE_OK, - "open disposable store as a version-2 fixture"); - check(sqlite3_exec(legacy, - "DROP TABLE character_objective_values; PRAGMA user_version=2;", - nullptr, - nullptr, - nullptr) - == SQLITE_OK, - "prepare version-2 schema without objective counters"); - check(sqlite3_close(legacy) == SQLITE_OK, "close version-2 fixture"); - check(store::open(argv[2], {}, {}, {}, {}), "migrate saved store from version 2"); - check(credits(kGunsmithCreditRow) == 0 && store::account().characters[0].inventory.count == 1, - "migration preserves vendor claim and inventory"); - store::shutdown(); - check(sqlite3_open_v2(argv[2], &legacy, SQLITE_OPEN_READONLY, nullptr) == SQLITE_OK, - "inspect migrated disposable store"); - sqlite3_stmt* version{}; - check(sqlite3_prepare_v2(legacy, "PRAGMA user_version", -1, &version, nullptr) == SQLITE_OK - && sqlite3_step(version) == SQLITE_ROW && sqlite3_column_int(version, 0) == 3, - "migration advances schema version"); - check(sqlite3_finalize(version) == SQLITE_OK && sqlite3_close(legacy) == SQLITE_OK, - "close migrated fixture"); - if (argc == 4) { - check(store::open(argv[3], {}, {}, {}, {}), "open existing version-3 save copy"); - state::AccountState existing{}; - check(store::read_account(existing), "read account from existing save copy"); - store::shutdown(); - } - std::puts("PASS: reputation transaction, exact debit/credit, refusal, staleness and rollback"); -} diff --git a/tests/vendor_reputation.ps1 b/tests/vendor_reputation.ps1 deleted file mode 100644 index 027b35663..000000000 --- a/tests/vendor_reputation.ps1 +++ /dev/null @@ -1,50 +0,0 @@ -$ErrorActionPreference = 'Stop' -$repRoot = Split-Path $PSScriptRoot -Parent -$repBuild = Join-Path $repRoot 'build/vendor-reputation-tests' -New-Item -ItemType Directory -Path $repBuild -Force | Out-Null -$repVsWhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio/Installer/vswhere.exe' -$repVs = & $repVsWhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath -if (-not $repVs) { throw 'Visual Studio C++ tools are required.' } -$repVcVars = Join-Path $repVs 'VC/Auxiliary/Build/vcvars64.bat' -$repSource = Join-Path $repRoot 'Sunrise/src' -$repSqlite = Join-Path $repRoot 'Sunrise/vendor/sqlite' -$repSources = @( - 'tests/vendor_reputation.cpp', - 'Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp', - 'Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp', - 'Sunrise/src/state/build_data/rewards/reward_catalog.cpp', - 'Sunrise/src/state/build_data/cache/records/cache_reward_records.cpp', - 'Sunrise/src/state/build_data/cache/records/cache_investment_records.cpp', - 'Sunrise/src/state/rewards/reward_resolver.cpp', - 'Sunrise/src/middleware/crypto/random_bytes.cpp', - 'Sunrise/src/state/runtime/state_account_profile_runtime.cpp', - 'Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp', - 'Sunrise/src/state/runtime/state_account_equipment_runtime.cpp', - 'Sunrise/src/state/runtime/state_account_identity_runtime.cpp', - 'Sunrise/src/state/account/account_state.cpp', - 'Sunrise/src/state/account/inventory/inventory_state.cpp', - 'Sunrise/src/state/account/settings/settings_state.cpp', - 'Sunrise/src/state/investment/investment_database.cpp', - 'Sunrise/src/state/investment/investment_account_store.cpp', - 'Sunrise/src/state/investment/investment_inventory_store.cpp', - 'Sunrise/src/state/investment/investment_settings_store.cpp', - 'Sunrise/src/state/investment/investment_unlock_store.cpp' -) | ForEach-Object { '"' + (Join-Path $repRoot $_) + '"' } -Push-Location $repBuild -try { - $repSqliteObject = Join-Path $repBuild 'sqlite3.obj' - if (-not (Test-Path -LiteralPath $repSqliteObject) -or - (Get-Item -LiteralPath "$repSqlite/sqlite3.c").LastWriteTimeUtc -gt - (Get-Item -LiteralPath $repSqliteObject).LastWriteTimeUtc) { - & cmd.exe /d /s /c "`"$repVcVars`" >nul && cl /nologo /O2 /w /TC /c /DSQLITE_OMIT_LOAD_EXTENSION /DSQLITE_THREADSAFE=1 `"$repSqlite/sqlite3.c`" /Fosqlite3.obj" - if ($LASTEXITCODE -ne 0) { throw 'SQLite test build failed.' } - } - $repCommand = "`"$repVcVars`" >nul && cl /nologo /std:c++20 /EHsc /O2 /Gy /W4 /WX /utf-8 /DWIN32_LEAN_AND_MEAN /DNOMINMAX /I`"$repSource`" /external:I`"$repSqlite`" /external:W0 " + ($repSources -join ' ') + ' sqlite3.obj bcrypt.lib /Fevendor_reputation.exe /link /OPT:REF /STACK:8388608' - & cmd.exe /d /s /c $repCommand - if ($LASTEXITCODE -ne 0) { throw 'Reputation test build failed.' } - $repScratch = Join-Path $repBuild ('reputation-' + [guid]::NewGuid().ToString('N') + '.sqlite3') - & (Join-Path $repBuild 'vendor_reputation.exe') (Join-Path $repRoot 'Sunrise/resources/database') $repScratch - if ($LASTEXITCODE -ne 0) { throw 'Reputation checks failed.' } -} finally { - Pop-Location -} From 5127ddd161801a4b9f06ece9090fec4260c2f91b Mon Sep 17 00:00:00 2001 From: Gage Fulwood Date: Wed, 23 Sep 2026 23:08:55 -0500 Subject: [PATCH 26/59] Load faction turn-ins from build-era sale definitions --- Sunrise/Sunrise.vcxproj | 2 + .../database/vendor_reputation_content.sql | 40 ++++ Sunrise/resources/resource.h | 2 + Sunrise/resources/sunrise.rc | 1 + .../state/build_data/build_data_runtime.cpp | 6 + .../runtime/build_data_catalog_runtime.cpp | 2 + .../vendors/reputation_sale_catalog.cpp | 198 ++++++++++++++++++ .../vendors/reputation_sale_catalog.h | 38 ++++ .../state_vendor_reputation_runtime.cpp | 138 +++++------- 9 files changed, 345 insertions(+), 82 deletions(-) create mode 100644 Sunrise/resources/database/vendor_reputation_content.sql create mode 100644 Sunrise/src/state/build_data/vendors/reputation_sale_catalog.cpp create mode 100644 Sunrise/src/state/build_data/vendors/reputation_sale_catalog.h diff --git a/Sunrise/Sunrise.vcxproj b/Sunrise/Sunrise.vcxproj index 8ac48605f..115af00ac 100644 --- a/Sunrise/Sunrise.vcxproj +++ b/Sunrise/Sunrise.vcxproj @@ -647,6 +647,7 @@ + @@ -1521,6 +1522,7 @@ + diff --git a/Sunrise/resources/database/vendor_reputation_content.sql b/Sunrise/resources/database/vendor_reputation_content.sql new file mode 100644 index 000000000..ac3d4af4a --- /dev/null +++ b/Sunrise/resources/database/vendor_reputation_content.sql @@ -0,0 +1,40 @@ +-- Build 86657.20.08.23.1800-9: authored Vendor itemList and Faction tokenValues. +-- Faction/Progression component: 83e9ee00-d5d1-453c-b454-ea08c1acefb4. +-- Decompressed Vendor SHA256: 649c78fc84b97070c7e859d6d587f162d7264a21f403d319de5a087d6ccc05c1. +-- Decompressed Faction SHA256: 8f6a3ec405b8c891935764e4a3c27a202029207f9d951c2054e6ae135bf2288e. +-- Installed vendor sales and faction links are checked again before any award. +CREATE TABLE reputation_sales ( + vendor_hash INTEGER NOT NULL, + sale_index INTEGER NOT NULL, + faction_hash INTEGER NOT NULL, + placeholder_hash INTEGER NOT NULL, + cost_hash INTEGER NOT NULL, + category_index INTEGER NOT NULL, + cost_quantity INTEGER NOT NULL, + xp_per_unit INTEGER NOT NULL, + PRIMARY KEY (vendor_hash, sale_index) +) WITHOUT ROWID; + +INSERT INTO reputation_sales VALUES + (69482069, 2, 611314723, 3987308529, 3899548068, 2, 1, 100), + (69482069, 3, 611314723, 3987308529, 3899548068, 2, 5, 100), + (69482069, 4, 611314723, 3987308529, 3899548068, 2, 10, 100), + (396892126, 32, 4235119312, 61430328, 2640973641, 4, 1, 100), + (396892126, 33, 4235119312, 61430328, 2640973641, 4, 5, 100), + (396892126, 34, 4235119312, 61430328, 2640973641, 4, 10, 100), + (396892126, 35, 4235119312, 1317670974, 950899352, 4, 1, 50), + (396892126, 36, 4235119312, 1317670974, 950899352, 4, 5, 50), + (396892126, 37, 4235119312, 1317670974, 950899352, 4, 10, 50), + (396892126, 38, 4235119312, 1317670974, 478751073, 4, 1, 250), + (396892126, 39, 4235119312, 1317670974, 478751073, 4, 5, 250), + (396892126, 40, 4235119312, 1317670974, 478751073, 4, 10, 250), + (672118013, 2, 1021210278, 3831705402, 685157383, 7, 1, 30), + (672118013, 3, 1021210278, 3831705402, 685157383, 7, 5, 30), + (672118013, 4, 1021210278, 3831705402, 685157383, 7, 10, 30), + (672118013, 5, 1021210278, 3831705402, 685157383, 7, 25, 30), + (672118013, 6, 1021210278, 3831705402, 685157381, 7, 1, 25), + (672118013, 7, 1021210278, 3831705402, 685157381, 7, 5, 25), + (672118013, 8, 1021210278, 3831705402, 685157381, 7, 10, 25), + (3603221665, 2, 697030790, 265113466, 183980811, 8, 1, 100), + (3603221665, 3, 697030790, 265113466, 183980811, 8, 5, 100), + (3603221665, 4, 697030790, 265113466, 183980811, 8, 10, 100); diff --git a/Sunrise/resources/resource.h b/Sunrise/resources/resource.h index 9f9fc4dca..65f535793 100644 --- a/Sunrise/resources/resource.h +++ b/Sunrise/resources/resource.h @@ -47,3 +47,5 @@ /** Account preferences have their own schema and seed data in the save. */ #define IDR_ACCOUNT_SETTINGS_SCHEMA 109 #define IDR_ACCOUNT_SETTINGS_DEFAULTS 110 +/** Build-scoped vendor turn-in definitions are static content, not player-save rows. */ +#define IDR_VENDOR_REPUTATION_CONTENT 111 diff --git a/Sunrise/resources/sunrise.rc b/Sunrise/resources/sunrise.rc index 7becac890..3ea22d6b1 100644 --- a/Sunrise/resources/sunrise.rc +++ b/Sunrise/resources/sunrise.rc @@ -61,3 +61,4 @@ IDR_SQLITE_NOTICE RCDATA "../vendor/sqlite/NOTICE.txt" IDR_ACCOUNT_SETTINGS_SCHEMA RCDATA "database/account_settings_schema.sql" IDR_ACCOUNT_SETTINGS_DEFAULTS RCDATA "database/account_settings_defaults.sql" +IDR_VENDOR_REPUTATION_CONTENT RCDATA "database/vendor_reputation_content.sql" diff --git a/Sunrise/src/state/build_data/build_data_runtime.cpp b/Sunrise/src/state/build_data/build_data_runtime.cpp index 416fb743d..8597f80e2 100644 --- a/Sunrise/src/state/build_data/build_data_runtime.cpp +++ b/Sunrise/src/state/build_data/build_data_runtime.cpp @@ -31,6 +31,7 @@ #include "sobjects/sobject_catalog.h" #include "socket_entry_lists/socket_entry_list_catalog.h" #include "spawn_sets/spawn_set_catalog.h" +#include "vendors/reputation_sale_catalog.h" #include "vendors/vendor_catalog.h" namespace sunrise::state::build_data { @@ -72,6 +73,11 @@ bool initialize(void* module, std::uint64_t configuredEquipmentHash) noexcept { return false; } persistenceState.enabled = true; + if (!vendors::load_reputation_sales(module)) { + runtime::persistence::clear_locked(persistenceState); + ReleaseSRWLockExclusive(&persistenceState.lock); + return false; + } cache::records::DomainCounts counts{}; const cache::LoadStatus status = 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..6cd6c0439 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 @@ -22,6 +22,7 @@ #include "../socket_entry_buckets/socket_entry_bucket_catalog.h" #include "../socket_entry_lists/socket_entry_list_catalog.h" #include "../spawn_sets/spawn_set_catalog.h" +#include "../vendors/reputation_sale_catalog.h" #include "../vendors/vendor_catalog.h" #include "domain_markers.h" #include "persistence/publication_transaction.h" @@ -382,6 +383,7 @@ void clear_catalogs() noexcept { rollback_spawn_catalog_publication(); rollback_name_catalog_publication(); vendors::clear(); + vendors::clear_reputation_sales(); constants::clear(); } diff --git a/Sunrise/src/state/build_data/vendors/reputation_sale_catalog.cpp b/Sunrise/src/state/build_data/vendors/reputation_sale_catalog.cpp new file mode 100644 index 000000000..74d22d024 --- /dev/null +++ b/Sunrise/src/state/build_data/vendors/reputation_sale_catalog.cpp @@ -0,0 +1,198 @@ +#include "reputation_sale_catalog.h" + +#include + +#include +#include +#include +#include +#include + +#include "../../../../resources/resource.h" +#include "../../../core/threading/srw_lock.h" +#include "sqlite3.h" + +namespace sunrise::state::build_data::vendors { +namespace { + +core::threading::SrwLock g_lock; +std::array g_sales{}; +std::size_t g_saleCount{}; + +/** + * Copy the DLL's SQL resource into a terminated buffer for SQLite. + * @param module DLL holding the content resource. + * @param sql Receives SQL; cleared on failure. + * @return False when the resource is missing or empty. + */ +bool resource_sql(void* module, std::string& sql) noexcept { + sql.clear(); + const auto loaded = static_cast(module); + if (loaded == nullptr) { + return false; + } + const HRSRC found = + FindResourceW(loaded, MAKEINTRESOURCEW(IDR_VENDOR_REPUTATION_CONTENT), RT_RCDATA); + if (found == nullptr) { + return false; + } + const DWORD size = SizeofResource(loaded, found); + const HGLOBAL data = LoadResource(loaded, found); + const auto* bytes = data != nullptr ? static_cast(LockResource(data)) : nullptr; + if (size == 0 || bytes == nullptr) { + return false; + } + sql.assign(bytes, size); + return true; +} + +/** + * Reject SQLite integers outside the destination type's range. + * @tparam Value Integral destination type. + * @param statement Active row statement. + * @param index Selected column. + * @param output Receives the value on success. + * @param minimum Smallest allowed value. + * @return False for a null, non-integer or out-of-range value. + */ +template +bool column(sqlite3_stmt* statement, int index, Value& output, std::int64_t minimum) noexcept { + if (sqlite3_column_type(statement, index) != SQLITE_INTEGER) { + return false; + } + const auto value = sqlite3_column_int64(statement, index); + if (value < minimum || value > (std::numeric_limits::max)()) { + return false; + } + output = static_cast(value); + return true; +} + +/** + * Accept only a nonempty, sorted, bounded set of complete sale rows. + * @param database Temporary content database. + * @param sales Receives rows; never published on failure. + * @param count Receives the number of rows read. + * @return False when any row or the query is invalid. + */ +bool read_sales(sqlite3* database, + std::array& sales, + std::size_t& count) noexcept { + count = 0; + constexpr char kQuery[] = + "SELECT vendor_hash, sale_index, faction_hash, placeholder_hash, cost_hash, " + "category_index, cost_quantity, xp_per_unit FROM reputation_sales " + "ORDER BY vendor_hash, sale_index"; + sqlite3_stmt* statement = nullptr; + if (sqlite3_prepare_v2(database, kQuery, -1, &statement, nullptr) != SQLITE_OK) { + return false; + } + int status = SQLITE_OK; + bool valid = true; + while ((status = sqlite3_step(statement)) == SQLITE_ROW) { + if (count == sales.size()) { + valid = false; + break; + } + ReputationSale sale{}; + valid = column(statement, 0, sale.vendorHash, 1) && column(statement, 1, sale.saleIndex, 0) + && column(statement, 2, sale.factionHash, 1) + && column(statement, 3, sale.placeholderHash, 1) + && column(statement, 4, sale.costHash, 1) + && column(statement, 5, sale.categoryIndex, 0) + && column(statement, 6, sale.costQuantity, 1) + && column(statement, 7, sale.experiencePerUnit, 1); + if (!valid + || (count != 0 + && (sales[count - 1].vendorHash > sale.vendorHash + || (sales[count - 1].vendorHash == sale.vendorHash + && sales[count - 1].saleIndex >= sale.saleIndex)))) { + valid = false; + break; + } + sales[count++] = sale; + } + const bool complete = valid && status == SQLITE_DONE && count != 0; + const int finalized = sqlite3_finalize(statement); + return complete && finalized == SQLITE_OK; +} + +} // namespace + +/** + * Publish only complete content sales, independent of player-save SQLite. + * @param module DLL holding the content resource. + * @return False when SQL or any sale row is invalid. + */ +bool load_reputation_sales(void* module) noexcept { + clear_reputation_sales(); + std::string sql; + if (!resource_sql(module, sql)) { + return false; + } + sqlite3* database = nullptr; + if (sqlite3_open(":memory:", &database) != SQLITE_OK) { + sqlite3_close(database); + return false; + } + std::array sales{}; + std::size_t count = 0; + const bool complete = + sqlite3_exec(database, sql.c_str(), nullptr, nullptr, nullptr) == SQLITE_OK + && read_sales(database, sales, count); + const int closed = sqlite3_close(database); + if (!complete || closed != SQLITE_OK) { + return false; + } + const std::lock_guard guard(g_lock); + g_sales = sales; + g_saleCount = count; + return true; +} + +/** Clears the small content catalog under its reader lock. */ +void clear_reputation_sales() noexcept { + const std::lock_guard guard(g_lock); + g_sales = {}; + g_saleCount = 0; +} + +/** + * Copy one exact vendor sale under the catalog lock. + * @param vendorHash Installed vendor hash. + * @param saleIndex Sale selector within that vendor. + * @param sale Receives the row, or an empty row when absent. + * @return True when the row exists. + */ +bool find_reputation_sale(std::uint32_t vendorHash, + std::uint16_t saleIndex, + ReputationSale& sale) noexcept { + sale = {}; + const std::shared_lock guard(g_lock); + for (std::size_t index = 0; index < g_saleCount; ++index) { + if (g_sales[index].vendorHash == vendorHash && g_sales[index].saleIndex == saleIndex) { + sale = g_sales[index]; + return true; + } + } + return false; +} + +/** + * Keep a known turn-in placeholder out of ordinary item acquisition. + * @param vendorHash Installed vendor hash. + * @param placeholderHash Requested item hash. + * @return True when this vendor uses the item as a turn-in placeholder. + */ +bool is_reputation_placeholder(std::uint32_t vendorHash, std::uint32_t placeholderHash) noexcept { + const std::shared_lock guard(g_lock); + for (std::size_t index = 0; index < g_saleCount; ++index) { + if (g_sales[index].vendorHash == vendorHash + && g_sales[index].placeholderHash == placeholderHash) { + return true; + } + } + return false; +} + +} // namespace sunrise::state::build_data::vendors diff --git a/Sunrise/src/state/build_data/vendors/reputation_sale_catalog.h b/Sunrise/src/state/build_data/vendors/reputation_sale_catalog.h new file mode 100644 index 000000000..a8879bde5 --- /dev/null +++ b/Sunrise/src/state/build_data/vendors/reputation_sale_catalog.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include + +namespace sunrise::state::build_data::vendors { + +/** Bounds the build-era manifest's 95 candidate turn-ins; only verified rows are loaded. */ +inline constexpr std::size_t kReputationSaleCapacity = 128; + +/** One authored turn-in sale; the installed vendor row must match before it awards XP. */ +struct ReputationSale { + std::uint32_t vendorHash{}; + std::uint16_t saleIndex{}; + std::uint32_t factionHash{}; + std::uint32_t placeholderHash{}; + std::uint32_t costHash{}; + std::int32_t categoryIndex{}; + std::uint32_t costQuantity{}; + std::int32_t experiencePerUnit{}; +}; + +/** Loads the build-scoped authored sales embedded in the DLL; no player save is opened. */ +[[nodiscard]] bool load_reputation_sales(void* module) noexcept; + +/** Withdraws every authored sale before build-data shutdown or failed publication. */ +void clear_reputation_sales() noexcept; + +/** Finds the authored sale for a vendor selector pair. */ +[[nodiscard]] bool find_reputation_sale(std::uint32_t vendorHash, + std::uint16_t saleIndex, + ReputationSale& sale) noexcept; + +/** Prevents a known reputation placeholder falling through to ordinary item acquisition. */ +[[nodiscard]] bool is_reputation_placeholder(std::uint32_t vendorHash, + std::uint32_t placeholderHash) noexcept; + +} // namespace sunrise::state::build_data::vendors diff --git a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp index 70717437e..c47ab43df 100644 --- a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp +++ b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp @@ -5,6 +5,7 @@ #include "../build_data/progressions/progression_catalog.h" #include "../build_data/rewards/reward_catalog.h" +#include "../build_data/vendors/reputation_sale_catalog.h" #include "../build_data/vendors/vendor_catalog.h" #include "../investment/store_internal.h" #include "state_account_transaction_helpers.h" @@ -12,34 +13,8 @@ namespace sunrise::state { namespace { -/** Build-86657 faction tokenValues apply only to the matching vendor sale placeholder. */ -struct ReputationRule { - std::uint32_t vendorHash; - std::uint32_t placeholderHash; - std::uint32_t costHash; - std::int32_t experiencePerUnit; - std::uint16_t rewardValueRow{}; -}; - /** Build-86657 VALUE[888] and VALUE[906] map to these character-object value rows. */ constexpr std::uint16_t kCrucibleRewardValueRow = 45, kGunsmithRewardValueRow = 49; -/** Checked build-86657 sale identities and token XP; native faction rows supply progression. */ -constexpr std::array kReputationRules{{ - // Banshee: Gunsmith Rewards charges Gunsmith Materials for Gunsmith progression. - {672118013U, 3831705402U, 685157383U, 30, kGunsmithRewardValueRow}, - // Banshee: the same placeholder also accepts Weapon Telemetry at its own XP rate. - {672118013U, 3831705402U, 685157381U, 25, kGunsmithRewardValueRow}, - // Zavala: Vanguard Tactician Rewards charges Vanguard Tactician Tokens. - {69482069U, 3987308529U, 3899548068U, 100, kVanguardRewardValueRow}, - // Shaxx: Crucible Rewards charges Crucible Tokens, not Valor or Glory points. - {3603221665U, 265113466U, 183980811U, 100, kCrucibleRewardValueRow}, - // Devrim: EDZ token turn-ins use a different placeholder from destination materials. - {396892126U, 61430328U, 2640973641U, 100}, - // Devrim: destination-material turn-ins accept Dusklight Shards. - {396892126U, 1317670974U, 950899352U, 50}, - // Devrim: the same material placeholder accepts Dusklight Crystals at their own XP rate. - {396892126U, 1317670974U, 478751073U, 250}, -}}; /** Native progression level walks read experience from lane zero. */ constexpr std::size_t kExperienceLane = 0; @@ -164,65 +139,64 @@ VendorReputationDisposition resolve_award(std::uint16_t vendorIndex, || !build_data::find_item_definition_index(sale.itemIndex, sold)) { return VendorReputationDisposition::notApplicable; } - bool recognized = false; - for (const auto& rule : kReputationRules) { - if (rule.vendorHash != entry.definitionHash - || rule.placeholderHash != sold.definitionHash) { - continue; - } - recognized = true; - if (sale.costItemIndex == vendors::kAbsentCostItem || sale.costQuantity == 0 - || !build_data::find_item_definition_index(sale.costItemIndex, cost) - || cost.definitionHash != rule.costHash) { - continue; - } - std::array slots{}; - std::size_t count = 0; - build_data::progressions::Definition progression{}; - const auto progressionIndex = vendor.factionProgressionIndex; - const std::int64_t experience = - static_cast(sale.costQuantity) * rule.experiencePerUnit; - if (vendor.factionHash == 0 - || progressionIndex == vendors::kUnavailableFactionProgressionIndex - || experience > (std::numeric_limits::max)() - || !build_data::find_progression_slots( - build_data::progressions::Scope::character, slots, count) - || count > slots.size() - || std::find(slots.begin(), - slots.begin() + static_cast(count), - progressionIndex) - == slots.begin() + static_cast(count) - || !build_data::progressions::find(progressionIndex, progression) - || progression.scope != build_data::progressions::Scope::character) { + vendors::ReputationSale authored{}; + if (!vendors::find_reputation_sale(entry.definitionHash, saleIndex, authored)) { + return vendors::is_reputation_placeholder(entry.definitionHash, sold.definitionHash) + ? VendorReputationDisposition::refused + : VendorReputationDisposition::notApplicable; + } + // The manifest's token value is trusted only for this exact installed sale and faction. + if (sold.definitionHash != authored.placeholderHash + || sale.categoryIndex != authored.categoryIndex + || sale.costItemIndex == vendors::kAbsentCostItem + || sale.costQuantity != authored.costQuantity + || !build_data::find_item_definition_index(sale.costItemIndex, cost) + || cost.definitionHash != authored.costHash || vendor.factionHash != authored.factionHash) { + return VendorReputationDisposition::refused; + } + std::array slots{}; + std::size_t count = 0; + build_data::progressions::Definition progression{}; + const auto progressionIndex = vendor.factionProgressionIndex; + const std::int64_t experience = + static_cast(sale.costQuantity) * authored.experiencePerUnit; + if (progressionIndex == vendors::kUnavailableFactionProgressionIndex + || experience > (std::numeric_limits::max)() + || !build_data::find_progression_slots( + build_data::progressions::Scope::character, slots, count) + || count > slots.size() + || std::find( + slots.begin(), slots.begin() + static_cast(count), progressionIndex) + == slots.begin() + static_cast(count) + || !build_data::progressions::find(progressionIndex, progression) + || progression.scope != build_data::progressions::Scope::character) { + return VendorReputationDisposition::refused; + } + award.costHash = cost.definitionHash; + award.costQuantity = sale.costQuantity; + award.experience = static_cast(experience); + award.progressionIndex = progressionIndex; + const auto* reward = reward_rule(vendorIndex); + award.rewardValueRow = reward != nullptr ? reward->rewardValueRow : 0; + award.repeatLastStep = progression.repeatLastStep; + if (award.rewardValueRow != 0) { + std::array + steps{}; + if (!build_data::progressions::steps(progressionIndex, steps, count) || count < 2 + || std::any_of( + steps.begin(), + steps.begin() + static_cast(count), + [](const auto& step) { return step.cost <= 0; })) { return VendorReputationDisposition::refused; } - award.costHash = cost.definitionHash; - award.costQuantity = sale.costQuantity; - award.experience = static_cast(experience); - award.progressionIndex = progressionIndex; - award.rewardValueRow = rule.rewardValueRow; - award.repeatLastStep = progression.repeatLastStep; - if (rule.rewardValueRow != 0) { - std::array - steps{}; - if (!build_data::progressions::steps(progressionIndex, steps, count) || count < 2 - || std::any_of( - steps.begin(), - steps.begin() + static_cast(count), - [](const auto& step) { return step.cost <= 0; })) { - return VendorReputationDisposition::refused; - } - award.rankStepCount = count; - std::transform(steps.begin(), - steps.begin() + static_cast(count), - award.rankStepCosts.begin(), - [](const auto& step) { return step.cost; }); - } - return VendorReputationDisposition::prepared; + award.rankStepCount = count; + std::transform(steps.begin(), + steps.begin() + static_cast(count), + award.rankStepCosts.begin(), + [](const auto& step) { return step.cost; }); } - return recognized ? VendorReputationDisposition::refused - : VendorReputationDisposition::notApplicable; + return VendorReputationDisposition::prepared; } /** From 5385271daef95662a57edcca58c90e479fd02779 Mon Sep 17 00:00:00 2001 From: Gage Fulwood Date: Thu, 24 Sep 2026 01:07:32 -0500 Subject: [PATCH 27/59] Decode native vendor condition programs --- .../build_data/vendors/vendor_expression.cpp | 89 ++++++++++++++++++- .../build_data/vendors/vendor_expression.h | 50 +++++++---- 2 files changed, 122 insertions(+), 17 deletions(-) diff --git a/Sunrise/src/state/build_data/vendors/vendor_expression.cpp b/Sunrise/src/state/build_data/vendors/vendor_expression.cpp index 6e3464e41..5dfc23526 100644 --- a/Sunrise/src/state/build_data/vendors/vendor_expression.cpp +++ b/Sunrise/src/state/build_data/vendors/vendor_expression.cpp @@ -1,10 +1,41 @@ #include "vendor_expression.h" #include +#include + +#include "middleware/content/packages/tables/definition_index_table.h" +#include "middleware/content/packages/tables/field_reader.h" namespace sunrise::state::build_data::vendors { namespace { +namespace tables = middleware::content::packages::tables; +/** Native class of an eight-byte unlock instruction inside an interaction or sale gate. */ +constexpr std::uint32_t kInstructionClass = 0x80807D31U; +/** Native class of a sale gate list's sixteen-byte expression descriptors. */ +constexpr std::uint32_t kProgramListClass = 0x80807D2FU; + +/** Appends one checked native instruction array without exposing a partial result. */ +[[nodiscard]] bool +append_program(std::span blob, std::size_t field, Program& program) noexcept { + tables::Array rows{}; + if (!tables::read_array(blob, field, kInstructionClass, tables::kUnlockInstructionStride, rows) + || rows.count > program.instructions.size() - program.count) { + return false; + } + for (std::size_t index = 0; index < rows.count; ++index) { + const auto at = rows.dataOffset + index * tables::kUnlockInstructionStride; + std::uint32_t opcode = 0; + std::int32_t operand = 0; + if (!tables::read(blob, at, opcode) + || !tables::read(blob, at + tables::kUnlockInstructionOperandOffset, operand)) { + return false; + } + program.instructions[program.count++] = {static_cast(opcode), operand}; + } + return true; +} + /** What one stack slot holds. The two kinds never substitute for each other. */ enum class Kind : std::uint8_t { boolean, @@ -73,18 +104,33 @@ step(const Instruction& instruction, const Inputs& inputs, Stack& stack) noexcep switch (instruction.opcode) { case Opcode::flag: { std::uint8_t logical = 0; - return inputs.flag(inputs.context, instruction.operand, logical) + return instruction.operand >= 0 + && instruction.operand <= (std::numeric_limits::max)() + && inputs.flag( + inputs.context, static_cast(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::logicalAnd: + return stack.pop(Kind::boolean, right) && stack.pop(Kind::boolean, left) + && stack.push(boolean_slot(left.boolean && right.boolean)); case Opcode::loadValue: { std::int32_t value = 0; - return inputs.value(inputs.context, instruction.operand, value) + return instruction.operand >= 0 + && instruction.operand <= (std::numeric_limits::max)() + && inputs.value( + inputs.context, static_cast(instruction.operand), value) && stack.push(number_slot(value)); } case Opcode::constant: return stack.push(number_slot(instruction.operand)); + case Opcode::greaterThan: + return stack.pop(Kind::number, right) && stack.pop(Kind::number, left) + && stack.push(boolean_slot(left.number > right.number)); + case Opcode::greaterOrEqual: + return stack.pop(Kind::number, right) && stack.pop(Kind::number, left) + && stack.push(boolean_slot(left.number >= right.number)); 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) @@ -96,6 +142,45 @@ step(const Instruction& instruction, const Inputs& inputs, Stack& stack) noexcep } // namespace +/** Reads one direct native interaction program. */ +bool read_program(std::span blob, std::size_t field, Program& output) noexcept { + output = {}; + Program parsed{}; + if (!append_program(blob, field, parsed)) { + return false; + } + output = parsed; + return true; +} + +/** Folds a sale's authored program list with logical AND. */ +bool read_program_list(std::span blob, + std::size_t field, + Program& output) noexcept { + output = {}; + tables::Array rows{}; + if (!tables::read_array( + blob, field, kProgramListClass, tables::kUnlockExpressionFieldSize, rows)) { + return false; + } + Program parsed{}; + for (std::size_t index = 0; index < rows.count; ++index) { + const auto at = rows.dataOffset + index * tables::kUnlockExpressionFieldSize; + const auto before = parsed.count; + if (!append_program(blob, at, parsed) || parsed.count == before) { + return false; + } + if (index != 0) { + if (parsed.count == parsed.instructions.size()) { + return false; + } + parsed.instructions[parsed.count++] = {Opcode::logicalAnd, 0}; + } + } + output = parsed; + return true; +} + /** Evaluates one expression program. */ bool evaluate(std::span program, const Inputs& inputs, bool& result) noexcept { result = false; diff --git a/Sunrise/src/state/build_data/vendors/vendor_expression.h b/Sunrise/src/state/build_data/vendors/vendor_expression.h index 3bc56eb29..5fb79236f 100644 --- a/Sunrise/src/state/build_data/vendors/vendor_expression.h +++ b/Sunrise/src/state/build_data/vendors/vendor_expression.h @@ -1,37 +1,57 @@ #pragma once +#include #include #include #include +#include "middleware/content/packages/tables/unlock_opcode.h" + 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; +/** The same 128-instruction safety bound used for installed node expressions. */ +inline constexpr std::size_t kVendorProgramCapacity = 128; -/** 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, -}; +/** Vendor gates use the shared native unlock instruction values. */ +using Opcode = middleware::content::packages::tables::UnlockOpcode; /** 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. */ +/** One native postfix instruction; unused operands may carry a negative sentinel. */ struct Instruction { Opcode opcode{}; - std::uint16_t operand{}; + std::int32_t operand{}; +}; + +/** One bounded native postfix program; an empty authored field has count zero. */ +struct Program { + std::array instructions{}; + std::size_t count{}; }; +/** + * Reads an interaction's direct native instruction array. + * @param blob Whole installed vendor definition. + * @param field Offset of the instruction-array descriptor. + * @param output Receives the program, or an empty program on failure. + * @return True when the field is empty or its instructions fit the bounded program. + */ +[[nodiscard]] bool +read_program(std::span blob, std::size_t field, Program& output) noexcept; + +/** + * Reads a sale's list of native programs, joining them with postfix AND. + * @param blob Whole installed vendor definition. + * @param field Offset of the program-list descriptor. + * @param output Receives the joined program, or an empty program on failure. + * @return True when the field is empty or every listed program is nonempty and fits. + */ +[[nodiscard]] bool +read_program_list(std::span blob, std::size_t field, Program& output) noexcept; + /** 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. */ From 326698141e5d10a091e75bd5631a5bad8caa893c Mon Sep 17 00:00:00 2001 From: Gage Fulwood Date: Thu, 24 Sep 2026 01:46:32 -0500 Subject: [PATCH 28/59] Evaluate installed vendor rank reward gates --- Sunrise/Sunrise.vcxproj | 3 + .../items/packages/package_item_build.cpp | 16 +- Sunrise/src/client/content/vendors/layout.h | 8 + .../vendors/package_vendor_gate_build.cpp | 228 ++++++++++++++++++ .../src/client/content/vendors/vendor_build.h | 23 ++ .../build_data/vendors/vendor_catalog.cpp | 4 + .../vendors/vendor_gate_catalog.cpp | 114 +++++++++ .../build_data/vendors/vendor_gate_catalog.h | 74 ++++++ .../state_vendor_reputation_runtime.cpp | 175 +++++++++++--- 9 files changed, 609 insertions(+), 36 deletions(-) create mode 100644 Sunrise/src/client/content/vendors/package_vendor_gate_build.cpp create mode 100644 Sunrise/src/state/build_data/vendors/vendor_gate_catalog.cpp create mode 100644 Sunrise/src/state/build_data/vendors/vendor_gate_catalog.h diff --git a/Sunrise/Sunrise.vcxproj b/Sunrise/Sunrise.vcxproj index 115af00ac..d152b5c29 100644 --- a/Sunrise/Sunrise.vcxproj +++ b/Sunrise/Sunrise.vcxproj @@ -650,7 +650,9 @@ + + @@ -1524,6 +1526,7 @@ + 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 098fb3cb0..9eaa6f838 100644 --- a/Sunrise/src/client/content/items/packages/package_item_build.cpp +++ b/Sunrise/src/client/content/items/packages/package_item_build.cpp @@ -10,6 +10,7 @@ #include "../../../../state/build_data/activities/activity_catalog.h" #include "../../../../state/build_data/rewards/reward_catalog.h" #include "../../../../state/build_data/runtime.h" +#include "../../../../state/build_data/vendors/vendor_gate_catalog.h" #include "../../activity/activity_catalog_build.h" #include "../../activity/entity_position_profile_build.h" #include "../../hash_names/hash_name_build.h" @@ -50,7 +51,7 @@ namespace { bool ready() noexcept { return root_domains_ready() && state::build_data::scenario_layouts_ready() && state::build_data::spawn_sets_ready() && state::build_data::hash_names_ready() - && state::build_data::vendor_catalog_ready() + && state::build_data::vendor_catalog_ready() && state::build_data::vendors::gates_ready() && content::activity::entity_position_profiles::ready() && (state::build_data::activities::ready() || state::build_data::activities::extraction_failed()); @@ -91,7 +92,7 @@ bool build() noexcept { return true; } } - if (root_domains_ready()) { + if (root_domains_ready() && state::build_data::vendors::gates_ready()) { SecureZeroMemory(&keys, sizeof keys); return ready(); } @@ -129,7 +130,7 @@ bool build() noexcept { || !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()) { + || !exotic_catalysts_settled() || !state::build_data::vendors::gates_ready()) { reason = "unlock_maps"; if (!read_unlock_slot_maps( source, storage, std::span{storage.root})) { @@ -255,6 +256,15 @@ bool build() noexcept { std::span(storage.bountyRows).first(storage.bountyCount)); } } + if (root_domains_ready() && !state::build_data::vendors::gates_ready()) { + reason = "vendor_gates"; + const content::vendors::GateMaps maps{storage.slotMaps.accountFlag, + storage.slotMaps.profileFlag, + storage.slotMaps.characterFlag, + storage.slotMaps.accountValue, + storage.slotMaps.characterValue}; + (void)content::vendors::build_gates(source, storage.scratch, maps); + } } SecureZeroMemory(&keys, sizeof keys); const bool complete = ready(); diff --git a/Sunrise/src/client/content/vendors/layout.h b/Sunrise/src/client/content/vendors/layout.h index 745e3da8f..dcc5512da 100644 --- a/Sunrise/src/client/content/vendors/layout.h +++ b/Sunrise/src/client/content/vendors/layout.h @@ -28,6 +28,12 @@ inline constexpr std::size_t kInstalledArrayDescriptor = 32; inline constexpr std::size_t kSaleArrayDescriptor = 48; /** A vendor definition holds its unnamed third array descriptor here. */ inline constexpr std::size_t kThirdArrayDescriptor = 80; +/** Element class of an interaction row in the third vendor array. */ +inline constexpr std::uint32_t kInteractionRowClass = 0x80807857U; +/** Interaction row +8 holds its direct availability expression. */ +inline constexpr std::size_t kInteractionConditionField = 8; +/** Interaction row +56 names the vendor category it presents. */ +inline constexpr std::size_t kInteractionCategoryOffset = 56; /** Raw reset interval. Its unit, epoch and scope are open, so it is stored unconverted. */ inline constexpr std::size_t kResetIntervalOffset = 20; /** Raw reset phase, paired with the interval. */ @@ -35,6 +41,8 @@ inline constexpr std::size_t kResetPhaseOffset = 24; /** Sale row price-override array descriptor, which is what the row charges. */ inline constexpr std::size_t kSaleCostArrayDescriptor = 32; +/** Sale row +8 carries admission programs; +120 carries selection programs. */ +inline constexpr std::size_t kSaleAdmissionField = 8, kSaleSelectionField = 120; /** Cost item-definition index inside one price-override row. */ inline constexpr std::size_t kSaleCostItemIndexOffset = 0; /** Units the price-override row charges. */ diff --git a/Sunrise/src/client/content/vendors/package_vendor_gate_build.cpp b/Sunrise/src/client/content/vendors/package_vendor_gate_build.cpp new file mode 100644 index 000000000..3f8d8c151 --- /dev/null +++ b/Sunrise/src/client/content/vendors/package_vendor_gate_build.cpp @@ -0,0 +1,228 @@ +#include +#include +#include +#include +#include + +#include "../../../core/logging/log.h" +#include "../../../middleware/content/packages/tables/definition_index_table.h" +#include "../../../middleware/content/packages/tables/field_reader.h" +#include "../../../state/build_data/rewards/reward_catalog.h" +#include "../../../state/build_data/runtime.h" +#include "../../../state/build_data/vendors/vendor_catalog.h" +#include "../../../state/build_data/vendors/vendor_gate_catalog.h" +#include "layout.h" +#include "vendor_build.h" + +namespace sunrise::client::content::vendors { +namespace { + +namespace reader = middleware::content::packages::reader; +namespace tables = middleware::content::packages::tables; +namespace domain = state::build_data::vendors; + +/** The native slot maps mark unmapped positions with the all-ones row value. */ +constexpr std::uint16_t kUnmappedRow = 0xFFFFU; + +/** Binds one input to at most one saved bank; unmapped slots require Family-5 values. */ +[[nodiscard]] bool bind_input(const domain::Instruction& instruction, + const GateMaps& maps, + domain::GateInput& output) noexcept { + output = {}; + const auto slot = instruction.operand; + if (slot < 0 || static_cast(slot) >= maps.accountFlag.size() + || static_cast(slot) >= maps.profileFlag.size() + || static_cast(slot) >= maps.characterFlag.size() + || static_cast(slot) >= maps.accountValue.size() + || static_cast(slot) >= maps.characterValue.size()) { + return false; + } + output.opcode = instruction.opcode; + output.slot = static_cast(slot); + output.bank = domain::GateBank::external; + const auto select = [&](std::span map, domain::GateBank bank) { + if (map[slot] == kUnmappedRow) { + return true; + } + if (output.bank != domain::GateBank::external) { + return false; + } + output.bank = bank; + output.row = map[slot]; + return true; + }; + if (instruction.opcode == domain::Opcode::flag) { + return select(maps.accountFlag, domain::GateBank::accountFlag) + && select(maps.profileFlag, domain::GateBank::profileFlag) + && select(maps.characterFlag, domain::GateBank::characterFlag); + } + return select(maps.accountValue, domain::GateBank::accountValue) + && select(maps.characterValue, domain::GateBank::characterValue); +} + +/** Resolves only the input instructions an expression actually reads. */ +[[nodiscard]] bool +bind_gate(const domain::Program& program, const GateMaps& maps, domain::Gate& gate) noexcept { + gate = {}; + gate.program = program; + for (std::size_t row = 0; row < program.count; ++row) { + const auto& instruction = program.instructions[row]; + if (instruction.opcode != domain::Opcode::flag + && instruction.opcode != domain::Opcode::loadValue) { + continue; + } + const auto inputs = std::span{gate.inputs}.first(gate.inputCount); + const auto found = std::find_if(inputs.begin(), inputs.end(), [&](const auto& input) { + return input.opcode == instruction.opcode && input.slot == instruction.operand; + }); + if (found != inputs.end()) { + continue; + } + if (gate.inputCount == gate.inputs.size() + || !bind_input(instruction, maps, gate.inputs[gate.inputCount])) { + return false; + } + ++gate.inputCount; + } + return true; +} + +/** A free sale is a candidate only when its installed wrapper opens a reward pool. */ +[[nodiscard]] bool reward_package(const domain::SaleRow& sale) noexcept { + state::build_data::items::Definition item{}; + state::build_data::rewards::Item reward{}; + return sale.categoryIndex != domain::kAbsentCategoryIndex && sale.costQuantity == 0 + && sale.costItemIndex == domain::kAbsentCostItem + && state::build_data::find_item_definition_index(sale.itemIndex, item) + && state::build_data::rewards::find_item(sale.itemIndex, reward) + && item.definitionHash == reward.definitionHash + && reward.poolIndex != state::build_data::rewards::kAbsent + && (reward.flags & state::build_data::rewards::kOpenOnAcquisition) != 0; +} + +/** Reads one vendor's relevant sale and matching interaction gates as one unit. */ +[[nodiscard]] bool read_vendor(const reader::Source& source, + reader::Scratch& scratch, + const GateMaps& maps, + const domain::Definition& vendor, + std::vector& interactions, + std::vector& sales) noexcept { + std::vector candidates; + for (std::size_t row = 0; row < vendor.saleCount; ++row) { + domain::SaleRow sale{}; + if (domain::sale_row(vendor, row, sale) && reward_package(sale)) { + candidates.push_back(static_cast(row)); + } + } + if (candidates.empty()) { + return true; + } + std::vector blob; + std::uint32_t classId = 0; + if (!reader::read_tag(source, scratch, vendor.definitionTag, blob, classId) + || classId != domain::kDefinitionClass || blob.size() != vendor.definitionSize) { + return false; + } + const std::span bytes{blob}; + tables::Array saleRows{}, interactionRows{}; + if (!tables::read_array( + bytes, kSaleArrayDescriptor, domain::kSaleRowClass, domain::kSaleRowStride, saleRows) + || saleRows.count != vendor.saleCount || saleRows.dataOffset != vendor.saleRowBase + || !tables::read_array(bytes, + kThirdArrayDescriptor, + kInteractionRowClass, + domain::kThirdRowStride, + interactionRows) + || interactionRows.count != vendor.thirdCount + || interactionRows.dataOffset != vendor.thirdRowBase) { + return false; + } + std::vector ownSales; + for (const auto row : candidates) { + const auto at = saleRows.dataOffset + row * domain::kSaleRowStride; + domain::SaleRow sale{}; + domain::Program admission{}, selection{}; + domain::SaleGates gates{}; + if (!domain::sale_row(vendor, row, sale) + || !domain::read_program_list(bytes, at + kSaleAdmissionField, admission) + || !domain::read_program_list(bytes, at + kSaleSelectionField, selection) + || !bind_gate(admission, maps, gates.admission) + || !bind_gate(selection, maps, gates.selection)) { + return false; + } + gates.vendorHash = vendor.definitionHash; + gates.index = row; + gates.categoryIndex = sale.categoryIndex; + ownSales.push_back(gates); + } + std::vector ownInteractions; + for (std::size_t row = 0; row < interactionRows.count; ++row) { + const auto at = interactionRows.dataOffset + row * domain::kThirdRowStride; + std::int32_t category = 0; + if (!tables::read(bytes, at + kInteractionCategoryOffset, category)) { + return false; + } + if (std::none_of(ownSales.begin(), ownSales.end(), [&](const auto& sale) { + return sale.categoryIndex == category; + })) { + continue; + } + domain::Program condition{}; + domain::InteractionGate gate{}; + if (!domain::read_program(bytes, at + kInteractionConditionField, condition) + || !bind_gate(condition, maps, gate.condition)) { + return false; + } + gate.vendorHash = vendor.definitionHash; + gate.index = static_cast(row); + gate.categoryIndex = category; + ownInteractions.push_back(gate); + } + sales.insert(sales.end(), ownSales.begin(), ownSales.end()); + interactions.insert(interactions.end(), ownInteractions.begin(), ownInteractions.end()); + return true; +} + +} // namespace + +/** Reads only faction vendors with free installed reward wrappers. */ +bool build_gates(const reader::Source& source, + reader::Scratch& scratch, + const GateMaps& maps) noexcept { + if (domain::gates_ready()) { + return true; + } + static std::array definitions{}; + std::size_t count = 0; + if (!domain::snapshot_definitions(definitions, count)) { + return false; + } + std::vector interactions; + std::vector sales; + std::size_t skipped = 0; + for (std::size_t row = 0; row < count; ++row) { + const auto& vendor = definitions[row]; + if (vendor.factionProgressionIndex == domain::kUnavailableFactionProgressionIndex) { + continue; + } + if (!read_vendor(source, scratch, maps, vendor, interactions, sales)) { + ++skipped; + core::log::writef(core::log::Channel::state, + core::log::Level::warn, + "ev=build_data stage=vendor_gates result=skip hash=0x%08X", + vendor.definitionHash); + } + } + const bool published = domain::replace_gates(interactions, sales); + core::log::writef( + core::log::Channel::state, + published ? core::log::Level::info : core::log::Level::warn, + "ev=build_data stage=vendor_gates result=%s interactions=%zu sales=%zu skipped=%zu", + published ? "ok" : "fail", + interactions.size(), + sales.size(), + skipped); + return published; +} + +} // namespace sunrise::client::content::vendors diff --git a/Sunrise/src/client/content/vendors/vendor_build.h b/Sunrise/src/client/content/vendors/vendor_build.h index 5d01ea917..4e172da60 100644 --- a/Sunrise/src/client/content/vendors/vendor_build.h +++ b/Sunrise/src/client/content/vendors/vendor_build.h @@ -1,5 +1,8 @@ #pragma once +#include +#include + #include "../../../middleware/content/packages/reader/reader.h" namespace sunrise::client::content::vendors { @@ -14,4 +17,24 @@ namespace sunrise::client::content::vendors { [[nodiscard]] bool build(const middleware::content::packages::reader::Source& source, middleware::content::packages::reader::Scratch& scratch) noexcept; +/** Installed unlock-slot maps used to bind native vendor expression operands to saved rows. */ +struct GateMaps { + std::span accountFlag; + std::span profileFlag; + std::span characterFlag; + std::span accountValue; + std::span characterValue; +}; + +/** + * Extracts faction-package gates each process, including when vendor rows came from cache. + * @param source Installed package directory and borrowed keys. + * @param scratch Caller-owned package reader storage. + * @param maps Native unlock-slot mappings retained from the investment root. + * @return True after the complete derived gate catalog is published. + */ +[[nodiscard]] bool build_gates(const middleware::content::packages::reader::Source& source, + middleware::content::packages::reader::Scratch& scratch, + const GateMaps& maps) noexcept; + } // namespace sunrise::client::content::vendors diff --git a/Sunrise/src/state/build_data/vendors/vendor_catalog.cpp b/Sunrise/src/state/build_data/vendors/vendor_catalog.cpp index d38d331f3..bd620695d 100644 --- a/Sunrise/src/state/build_data/vendors/vendor_catalog.cpp +++ b/Sunrise/src/state/build_data/vendors/vendor_catalog.cpp @@ -5,6 +5,7 @@ #include "../table.h" #include "core/threading/srw_lock.h" +#include "vendor_gate_catalog.h" namespace sunrise::state::build_data::vendors { namespace { @@ -132,6 +133,7 @@ template /** Clears the index, every held definition, and both row banks under the catalog lock. */ void clear() noexcept { + clear_gates(); const std::lock_guard guard(g_lock); g_index.clear(); g_definitions.clear(); @@ -179,6 +181,8 @@ bool replace(std::span index, if (!valid(index, definitions, saleRows, installedRows)) { return false; } + // Installed gate expressions must be rebuilt for the replacement rows. + clear_gates(); const std::lock_guard guard(g_lock); // All four run with no short-circuit, so the set cannot be left half replaced. Capacity is // the only reason one can refuse, and valid() already checked it. diff --git a/Sunrise/src/state/build_data/vendors/vendor_gate_catalog.cpp b/Sunrise/src/state/build_data/vendors/vendor_gate_catalog.cpp new file mode 100644 index 000000000..b9d812239 --- /dev/null +++ b/Sunrise/src/state/build_data/vendors/vendor_gate_catalog.cpp @@ -0,0 +1,114 @@ +#include "vendor_gate_catalog.h" + +#include +#include +#include + +#include "core/threading/srw_lock.h" + +namespace sunrise::state::build_data::vendors { +namespace { + +core::threading::SrwLock g_lock; +std::vector g_interactions; +std::vector g_sales; +bool g_ready{}; + +/** A reader must never span past one published gate's fixed storage. */ +[[nodiscard]] bool valid_gate(const Gate& gate) noexcept { + return gate.program.count <= gate.program.instructions.size() + && gate.inputCount <= gate.inputs.size() && gate.inputCount <= gate.program.count; +} + +/** @tparam Row Vendor-keyed gate row. @param rows Candidate bank. @return False for a duplicate. */ +template [[nodiscard]] bool unique_rows(std::span rows) noexcept { + for (std::size_t first = 0; first < rows.size(); ++first) { + for (std::size_t other = first + 1; other < rows.size(); ++other) { + if (rows[first].vendorHash == rows[other].vendorHash + && rows[first].index == rows[other].index) { + return false; + } + } + } + return true; +} + +/** + * @tparam Row Vendor-keyed gate row. + * @param rows Published bank. + * @param vendorHash Vendor definition identity. + * @param index Native row index. + * @param output Receives the row, cleared when absent. + * @return True when one exact row was retained. + */ +template +[[nodiscard]] bool find_row(std::span rows, + std::uint32_t vendorHash, + std::uint16_t index, + Row& output) noexcept { + output = {}; + const auto found = std::find_if(rows.begin(), rows.end(), [&](const Row& row) { + return row.vendorHash == vendorHash && row.index == index; + }); + if (found == rows.end()) { + return false; + } + output = *found; + return true; +} + +} // namespace + +/** Replaces the checked process-local gate set under one lock. */ +bool replace_gates(std::span interactions, + std::span sales) noexcept { + if (!unique_rows(interactions) || !unique_rows(sales)) { + return false; + } + if (std::any_of(interactions.begin(), + interactions.end(), + [](const auto& row) { return !valid_gate(row.condition); }) + || std::any_of(sales.begin(), sales.end(), [](const auto& row) { + return !valid_gate(row.admission) || !valid_gate(row.selection); + })) { + return false; + } + std::vector nextInteractions(interactions.begin(), interactions.end()); + std::vector nextSales(sales.begin(), sales.end()); + const std::lock_guard guard(g_lock); + g_interactions.swap(nextInteractions); + g_sales.swap(nextSales); + g_ready = true; + return true; +} + +/** Invalidates only derived gate expressions, leaving cached vendor rows intact. */ +void clear_gates() noexcept { + const std::lock_guard guard(g_lock); + g_interactions.clear(); + g_sales.clear(); + g_ready = false; +} + +/** @return True when one complete gate extraction was published this process. */ +bool gates_ready() noexcept { + const std::shared_lock guard(g_lock); + return g_ready; +} + +/** Copies one interaction gate without exposing the mutable published bank. */ +bool find_interaction_gate(std::uint32_t vendorHash, + std::uint16_t index, + InteractionGate& output) noexcept { + const std::shared_lock guard(g_lock); + return g_ready + && find_row(std::span{g_interactions}, vendorHash, index, output); +} + +/** Copies one sale's gates without exposing the mutable published bank. */ +bool find_sale_gates(std::uint32_t vendorHash, std::uint16_t index, SaleGates& output) noexcept { + const std::shared_lock guard(g_lock); + return g_ready && find_row(std::span{g_sales}, vendorHash, index, output); +} + +} // namespace sunrise::state::build_data::vendors diff --git a/Sunrise/src/state/build_data/vendors/vendor_gate_catalog.h b/Sunrise/src/state/build_data/vendors/vendor_gate_catalog.h new file mode 100644 index 000000000..fd2e188a9 --- /dev/null +++ b/Sunrise/src/state/build_data/vendors/vendor_gate_catalog.h @@ -0,0 +1,74 @@ +#pragma once + +#include +#include +#include +#include + +#include "vendor_expression.h" + +namespace sunrise::state::build_data::vendors { + +/** A Family-5 override takes precedence; otherwise a gate reads its mapped saved bank. */ +enum class GateBank : std::uint8_t { + external, + accountFlag, + profileFlag, + characterFlag, + accountValue, + characterValue, +}; + +/** One native slot resolved against the installed build's unlock mapping tables. */ +struct GateInput { + Opcode opcode{}; + std::uint16_t slot{}; + GateBank bank{}; + std::uint16_t row{}; +}; + +/** One interaction or sale expression with its bounded input bindings. */ +struct Gate { + Program program{}; + std::array inputs{}; + std::size_t inputCount{}; +}; + +/** Native interaction row belonging to a vendor category. */ +struct InteractionGate { + std::uint32_t vendorHash{}; + std::uint16_t index{}; + std::int32_t categoryIndex{}; + Gate condition{}; +}; + +/** Native sale condition fields for one installed package offer. */ +struct SaleGates { + std::uint32_t vendorHash{}; + std::uint16_t index{}; + std::int32_t categoryIndex{}; + Gate admission{}; + Gate selection{}; +}; + +/** + * Publishes one complete process-local extraction. Cached vendor rows do not store expressions. + * @param interactions Native interaction gates, unique by vendor and row index. + * @param sales Native sale gates, unique by vendor and row index. + * @return False when ranges or identities are invalid; no partial publication occurs. + */ +[[nodiscard]] bool replace_gates(std::span interactions, + std::span sales) noexcept; +/** Withdraws expressions before vendor rows are replaced or cleared. */ +void clear_gates() noexcept; +/** @return True when one complete extraction was published this process. */ +[[nodiscard]] bool gates_ready() noexcept; +/** @return True when the exact vendor and interaction row was retained. */ +[[nodiscard]] bool find_interaction_gate(std::uint32_t vendorHash, + std::uint16_t index, + InteractionGate& output) noexcept; +/** @return True when the exact vendor and sale row was retained. */ +[[nodiscard]] bool +find_sale_gates(std::uint32_t vendorHash, std::uint16_t index, SaleGates& output) noexcept; + +} // namespace sunrise::state::build_data::vendors diff --git a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp index c47ab43df..fe5591f30 100644 --- a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp +++ b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp @@ -7,6 +7,7 @@ #include "../build_data/rewards/reward_catalog.h" #include "../build_data/vendors/reputation_sale_catalog.h" #include "../build_data/vendors/vendor_catalog.h" +#include "../build_data/vendors/vendor_gate_catalog.h" #include "../investment/store_internal.h" #include "state_account_transaction_helpers.h" @@ -19,7 +20,7 @@ constexpr std::uint16_t kCrucibleRewardValueRow = 45, kGunsmithRewardValueRow = /** Native progression level walks read experience from lane zero. */ constexpr std::size_t kExperienceLane = 0; -/** Checked build-86657 claim bindings; the installed reward catalog resolves payout. */ +/** Supported reconstructed claim links; installed content supplies their conditions. */ struct RewardRule { std::uint32_t vendorHash; std::uint16_t interaction; @@ -27,8 +28,6 @@ struct RewardRule { std::uint16_t rewardValueRow; std::uint16_t saleIndex{}; std::uint32_t packageHash{}; - /** Only the Vanguard and Crucible sales require FLAG[5901] and VALUE[465]. */ - bool requiresSelectionGates{true}; }; /** Build-86657 Vanguard rank package item hash. */ constexpr std::uint32_t kVanguardPackageHash = 2746484552U; @@ -42,17 +41,127 @@ constexpr std::array kRewardRules{{ {69482069U, 40, 3, kVanguardRewardValueRow, 93, kVanguardPackageHash}, // Shaxx's current package previews Crucible gear. {3603221665U, 28, 10, kCrucibleRewardValueRow, 96, kCruciblePackageHash}, - // Banshee's sale has no Vanguard/Crucible selection gates. - {672118013U, 35, 8, kGunsmithRewardValueRow, 16, kGunsmithPackageHash, false}, + // Banshee's sale is ungated, but its interaction still checks the player's level. + {672118013U, 35, 8, kGunsmithRewardValueRow, 16, kGunsmithPackageHash}, }}; /** Reply zero completes the supported normal reward interactions. */ constexpr std::uint16_t kAcceptRewardReply = 0; -/** FLAG[5901] selects the current Vanguard and Crucible packages over their older variants. */ -constexpr std::uint16_t kCurrentPackageFlag = 5901; -/** The normal reward interaction requires VALUE[465] >= 20. */ -constexpr std::uint16_t kRewardLevelSlot = 465; -/** The normal reward interaction excludes levels below this content threshold. */ -constexpr std::int32_t kMinimumRewardLevel = 20; + +/** Saved unlocks and Family-5 overrides visible to one native vendor gate. */ +struct GateValues { + const build_data::vendors::Gate& gate; + const Family5State& family; + const unlocks::Table& banks; +}; + +/** @return The native slot binding, or null when extraction did not resolve it. */ +const build_data::vendors::GateInput* gate_input(const GateValues& values, + build_data::vendors::Opcode opcode, + std::uint16_t slot) noexcept { + const auto inputs = std::span{values.gate.inputs}.first(values.gate.inputCount); + const auto found = std::find_if(inputs.begin(), inputs.end(), [&](const auto& input) { + return input.opcode == opcode && input.slot == slot; + }); + return found == inputs.end() ? nullptr : &*found; +} + +/** A Family-5 override supersedes an installed bank mapping when it names the same slot. */ +bool gate_flag(void* context, std::uint16_t slot, std::uint8_t& logical) noexcept { + const auto& values = *static_cast(context); + const auto* input = gate_input(values, build_data::vendors::Opcode::flag, slot); + if (input == nullptr) { + return false; + } + for (std::size_t index = 0; index < values.family.flagCount; ++index) { + if (values.family.flags[index].slot == slot) { + logical = values.family.flags[index].value; + return true; + } + } + using Bank = build_data::vendors::GateBank; + switch (input->bank) { + case Bank::accountFlag: + if (input->row < values.banks.accountFlags.size()) { + logical = values.banks.accountFlags[input->row]; + return true; + } + break; + case Bank::profileFlag: + if (input->row < values.banks.profileFlags.size()) { + logical = values.banks.profileFlags[input->row]; + return true; + } + break; + case Bank::characterFlag: + if (input->row < values.banks.characterObjectFlags.size()) { + logical = values.banks.characterObjectFlags[input->row]; + return true; + } + break; + default: + break; + } + return false; +} + +/** Reads a native value slot from Family-5 or its installed saved-bank row. */ +bool gate_value(void* context, std::uint16_t slot, std::int32_t& value) noexcept { + const auto& values = *static_cast(context); + const auto* input = gate_input(values, build_data::vendors::Opcode::loadValue, slot); + if (input == nullptr) { + return false; + } + for (std::size_t index = 0; index < values.family.valueCount; ++index) { + if (values.family.values[index].slot == slot) { + value = values.family.values[index].value; + return true; + } + } + using Bank = build_data::vendors::GateBank; + switch (input->bank) { + case Bank::accountValue: + if (input->row < values.banks.objectiveValues.size()) { + value = values.banks.objectiveValues[input->row]; + return true; + } + break; + case Bank::characterValue: + if (input->row < values.banks.characterObjectValues.size()) { + value = values.banks.characterObjectValues[input->row]; + return true; + } + break; + default: + break; + } + return false; +} + +/** An empty authored sale field has no condition; a missing input refuses a nonempty gate. */ +bool gate_passes(const build_data::vendors::Gate& gate, + const Family5State& family, + const unlocks::Table& banks) noexcept { + if (gate.program.count == 0) { + return true; + } + GateValues values{gate, family, banks}; + const build_data::vendors::Inputs inputs{gate_flag, gate_value, &values}; + bool result = false; + return build_data::vendors::evaluate( + std::span{gate.program.instructions}.first(gate.program.count), inputs, result) + && result; +} + +/** The interaction must test the same saved character credit row this claim debits. */ +bool tests_reward_credit(const build_data::vendors::Gate& gate, + std::uint16_t rewardValueRow) noexcept { + const auto inputs = std::span{gate.inputs}.first(gate.inputCount); + return std::any_of(inputs.begin(), inputs.end(), [&](const auto& input) { + return input.opcode == build_data::vendors::Opcode::loadValue + && input.bank == build_data::vendors::GateBank::characterValue + && input.row == rewardValueRow; + }); +} /** * Resolves vendor identity independently of request selectors. @@ -72,7 +181,7 @@ const RewardRule* reward_rule(std::uint16_t vendorIndex) noexcept { } /** - * Accept only the supported auto-opening package and explicit evaluated gates. + * Accept only the supported auto-opening package and its installed native gates. * Caller holds the investment-store lock while reading the current gates. * @param rule Checked vendor binding. * @param saleIndex Requested sale, retained for commit revalidation. @@ -95,27 +204,27 @@ bool reward_binding_current(const RewardRule& rule, std::uint16_t saleIndex) noe || (reward.flags & build_data::rewards::kOpenOnAcquisition) == 0) { return false; } - if (!rule.requiresSelectionGates) { - return true; + vendors::InteractionGate interaction{}; + vendors::SaleGates gates{}; + if (!vendors::find_interaction_gate(rule.vendorHash, rule.interaction, interaction) + || !vendors::find_sale_gates(rule.vendorHash, saleIndex, gates) + || interaction.categoryIndex != rule.category || gates.categoryIndex != rule.category + || !tests_reward_credit(interaction.condition, rule.rewardValueRow)) { + return false; } Family5State family{}; - if (!investment::store::read_family5(family)) { + AccountState account{}; + unlocks::Table banks{}; + if (!investment::store::read_family5(family) || !investment::store::read_account(account) + || !account::valid(account)) { return false; } - bool packageEnabled = false, levelMet = false; - for (std::size_t index = 0; index < family.flagCount; ++index) { - const auto& flag = family.flags[index]; - if (flag.slot == kCurrentPackageFlag) { - packageEnabled = flag.value == unlocks::kFlagSet; - } - } - for (std::size_t index = 0; index < family.valueCount; ++index) { - const auto& value = family.values[index]; - if (value.slot == kRewardLevelSlot) { - levelMet = value.value >= kMinimumRewardLevel; - } - } - return packageEnabled && levelMet; + const auto selected = runtime::detail::selected_character_index(account); + return selected < account.characterCount + && investment::store::read_unlocks(banks, static_cast(selected)) + && gate_passes(interaction.condition, family, banks) + && gate_passes(gates.admission, family, banks) + && gate_passes(gates.selection, family, banks); } /** @@ -464,10 +573,6 @@ VendorReputationDisposition prepare_vendor_reward_sale(std::uint16_t vendorIndex return VendorReputationDisposition::notApplicable; } const std::lock_guard lock(investment::store::g_mutex); - reason = "reward_binding"; - if (!reward_binding_current(*rule, saleIndex)) { - return VendorReputationDisposition::refused; - } reason = "rank_credit"; std::int32_t credits = 0; if (!investment::store::read_unlock( @@ -475,6 +580,10 @@ VendorReputationDisposition prepare_vendor_reward_sale(std::uint16_t vendorIndex || credits <= 0) { return VendorReputationDisposition::refused; } + reason = "reward_binding"; + if (!reward_binding_current(*rule, saleIndex)) { + return VendorReputationDisposition::refused; + } if (!prepare_item_reward(sale.itemIndex, 1, mutation, &reason)) { mutation = {}; return VendorReputationDisposition::refused; From 63307113ffccc7ba674960e799d9edf5f6a47362 Mon Sep 17 00:00:00 2001 From: Gage Fulwood Date: Thu, 24 Sep 2026 01:59:25 -0500 Subject: [PATCH 29/59] Bind faction claim credit to native interaction value --- .../state_vendor_reputation_runtime.cpp | 88 ++++++++++++++----- .../runtime/state_vendor_reputation_runtime.h | 3 - 2 files changed, 65 insertions(+), 26 deletions(-) diff --git a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp index fe5591f30..95c79cf3f 100644 --- a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp +++ b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp @@ -14,9 +14,6 @@ namespace sunrise::state { namespace { -/** Build-86657 VALUE[888] and VALUE[906] map to these character-object value rows. */ -constexpr std::uint16_t kCrucibleRewardValueRow = 45, kGunsmithRewardValueRow = 49; - /** Native progression level walks read experience from lane zero. */ constexpr std::size_t kExperienceLane = 0; @@ -25,7 +22,6 @@ struct RewardRule { std::uint32_t vendorHash; std::uint16_t interaction; std::int32_t category; - std::uint16_t rewardValueRow; std::uint16_t saleIndex{}; std::uint32_t packageHash{}; }; @@ -35,14 +31,14 @@ constexpr std::uint32_t kVanguardPackageHash = 2746484552U; constexpr std::uint32_t kCruciblePackageHash = 3289621657U; /** Build-86657 Gunsmith rank package item hash. */ constexpr std::uint32_t kGunsmithPackageHash = 2422825785U; -/** Vendor, interaction, category, saved counter, package sale and hash from build 86657. */ +/** Vendor, interaction, category, package sale and hash from build 86657. */ constexpr std::array kRewardRules{{ // Zavala's current package previews Vanguard gear. - {69482069U, 40, 3, kVanguardRewardValueRow, 93, kVanguardPackageHash}, + {69482069U, 40, 3, 93, kVanguardPackageHash}, // Shaxx's current package previews Crucible gear. - {3603221665U, 28, 10, kCrucibleRewardValueRow, 96, kCruciblePackageHash}, + {3603221665U, 28, 10, 96, kCruciblePackageHash}, // Banshee's sale is ungated, but its interaction still checks the player's level. - {672118013U, 35, 8, kGunsmithRewardValueRow, 16, kGunsmithPackageHash}, + {672118013U, 35, 8, 16, kGunsmithPackageHash}, }}; /** Reply zero completes the supported normal reward interactions. */ constexpr std::uint16_t kAcceptRewardReply = 0; @@ -152,15 +148,47 @@ bool gate_passes(const build_data::vendors::Gate& gate, && result; } -/** The interaction must test the same saved character credit row this claim debits. */ -bool tests_reward_credit(const build_data::vendors::Gate& gate, - std::uint16_t rewardValueRow) noexcept { - const auto inputs = std::span{gate.inputs}.first(gate.inputCount); - return std::any_of(inputs.begin(), inputs.end(), [&](const auto& input) { - return input.opcode == build_data::vendors::Opcode::loadValue - && input.bank == build_data::vendors::GateBank::characterValue - && input.row == rewardValueRow; +/** + * The supported interaction starts with VALUE[credit] > 0. Its installed slot map names the + * character row to credit on a rank-up and debit on a claim. Other shapes are not guessed. + * @param rule Checked interaction link. + * @param interaction Receives the installed interaction gate. + * @param row Receives the saved character credit row. + * @return False if the tested value is missing, ambiguous or not character-owned. + */ +bool reward_credit_gate(const RewardRule& rule, + build_data::vendors::InteractionGate& interaction, + std::uint16_t& row) noexcept { + namespace vendors = build_data::vendors; + interaction = {}; + row = 0; + if (!vendors::find_interaction_gate(rule.vendorHash, rule.interaction, interaction) + || interaction.categoryIndex != rule.category) { + return false; + } + const auto program = std::span{interaction.condition.program.instructions}.first( + interaction.condition.program.count); + if (program.size() < 3 || program[0].opcode != vendors::Opcode::loadValue + || program[0].operand < 0 + || program[0].operand > (std::numeric_limits::max)() + || program[1].opcode != vendors::Opcode::constant || program[1].operand != 0 + || program[2].opcode != vendors::Opcode::greaterThan) { + return false; + } + const auto slot = static_cast(program[0].operand); + const auto inputs = + std::span{interaction.condition.inputs}.first(interaction.condition.inputCount); + const auto found = std::find_if(inputs.begin(), inputs.end(), [&](const auto& input) { + return input.opcode == vendors::Opcode::loadValue && input.slot == slot + && input.bank == vendors::GateBank::characterValue; }); + if (found == inputs.end() || std::any_of(inputs.begin(), inputs.end(), [&](const auto& input) { + return &input != &*found && input.bank == vendors::GateBank::characterValue; + })) { + return false; + } + row = found->row; + return row != 0; } /** @@ -206,10 +234,10 @@ bool reward_binding_current(const RewardRule& rule, std::uint16_t saleIndex) noe } vendors::InteractionGate interaction{}; vendors::SaleGates gates{}; - if (!vendors::find_interaction_gate(rule.vendorHash, rule.interaction, interaction) + std::uint16_t creditRow = 0; + if (!reward_credit_gate(rule, interaction, creditRow) || !vendors::find_sale_gates(rule.vendorHash, saleIndex, gates) - || interaction.categoryIndex != rule.category || gates.categoryIndex != rule.category - || !tests_reward_credit(interaction.condition, rule.rewardValueRow)) { + || gates.categoryIndex != rule.category) { return false; } Family5State family{}; @@ -286,7 +314,12 @@ VendorReputationDisposition resolve_award(std::uint16_t vendorIndex, award.experience = static_cast(experience); award.progressionIndex = progressionIndex; const auto* reward = reward_rule(vendorIndex); - award.rewardValueRow = reward != nullptr ? reward->rewardValueRow : 0; + if (reward != nullptr) { + vendors::InteractionGate interaction{}; + if (!reward_credit_gate(*reward, interaction, award.rewardValueRow)) { + return VendorReputationDisposition::refused; + } + } award.repeatLastStep = progression.repeatLastStep; if (award.rewardValueRow != 0) { std::arrayrewardValueRow, credits) + investment::store::Bank::characterObjectValues, creditRow, credits) || credits <= 0) { return VendorReputationDisposition::refused; } @@ -589,7 +628,7 @@ VendorReputationDisposition prepare_vendor_reward_sale(std::uint16_t vendorIndex return VendorReputationDisposition::refused; } reason = nullptr; - mutation.vendorReward = {credits, vendorIndex, saleIndex, rule->rewardValueRow}; + mutation.vendorReward = {credits, vendorIndex, saleIndex, creditRow}; return VendorReputationDisposition::prepared; } @@ -605,7 +644,10 @@ bool vendor_reward_current(const VendorRewardClaim& claim) noexcept { } std::int32_t current = 0; const auto* rule = reward_rule(claim.vendorIndex); - if (claim.beforeCredits < 0 || rule == nullptr || claim.rewardValueRow != rule->rewardValueRow + build_data::vendors::InteractionGate interaction{}; + std::uint16_t creditRow = 0; + if (claim.beforeCredits < 0 || rule == nullptr + || !reward_credit_gate(*rule, interaction, creditRow) || claim.rewardValueRow != creditRow || !reward_binding_current(*rule, claim.saleIndex) || !investment::store::read_unlock( investment::store::Bank::characterObjectValues, claim.rewardValueRow, current) diff --git a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h index 6ff3f061b..8df6f6852 100644 --- a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h +++ b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h @@ -46,9 +46,6 @@ struct PendingVendorReputation { std::uint16_t vendorIndex, std::uint16_t saleIndex, PendingVendorReputation& mutation) noexcept; [[nodiscard]] bool commit_vendor_reputation(PendingVendorReputation& mutation) noexcept; -/** Build-86657 VALUE[927] reads character-object value row 55 for Vanguard claims. */ -inline constexpr std::uint16_t kVanguardRewardValueRow = 55; - /** Positive credits identify a prepared claim; zero leaves ordinary rewards unchanged. */ struct VendorRewardClaim { std::int32_t beforeCredits{}; From b94a568b1c8cc4f470fbe76b4695295ed481b089 Mon Sep 17 00:00:00 2001 From: Gage Fulwood Date: Thu, 24 Sep 2026 03:26:48 -0500 Subject: [PATCH 30/59] Resolve sale-backed faction rewards from installed vendor gates --- .../web_service_vendor_actions.cpp | 42 +- .../build_data/vendors/vendor_expression.h | 2 +- .../state_vendor_reputation_runtime.cpp | 424 ++++++++++++------ .../runtime/state_vendor_reputation_runtime.h | 7 +- 4 files changed, 323 insertions(+), 152 deletions(-) diff --git a/Sunrise/src/server/web_service/web_service_vendor_actions.cpp b/Sunrise/src/server/web_service/web_service_vendor_actions.cpp index 19cac11fe..59d3952c7 100644 --- a/Sunrise/src/server/web_service/web_service_vendor_actions.cpp +++ b/Sunrise/src/server/web_service/web_service_vendor_actions.cpp @@ -642,30 +642,30 @@ void settle_vendor_row(const middleware::web_service::Message& message, std::int32_t categoryIndex, std::uint16_t itemDefinitionIndex, Outcome& outcome) noexcept { - // A recognized reward sale owns its credit debit and must never fall through to a free grant. - if (vendorIndex >= 0 && vendorIndex <= (std::numeric_limits::max)() - && state::is_vendor_reward_category(static_cast(vendorIndex), - categoryIndex)) { + if (vendorIndex >= 0 && rowIndex >= 0 + && vendorIndex <= (std::numeric_limits::max)() + && rowIndex <= (std::numeric_limits::max)()) { auto* reward = emplace_mutation(outcome); + if (reward == nullptr) { + report_purchase(opcode, "fail", "storage", vendorIndex, rowIndex, itemDefinitionIndex); + return; + } const char* refusal = "storage"; - const bool prepared = - reward != nullptr && rowIndex >= 0 - && rowIndex <= (std::numeric_limits::max)() - && state::prepare_vendor_reward_sale(static_cast(vendorIndex), - static_cast(rowIndex), - *reward, - &refusal) - == state::VendorReputationDisposition::prepared; - if (!prepared) { - clear_mutation(outcome); + const auto disposition = + state::prepare_vendor_reward_sale(static_cast(vendorIndex), + static_cast(rowIndex), + *reward, + &refusal); + if (disposition == state::VendorReputationDisposition::prepared) { + report_purchase( + opcode, "ok", "rank_reward", vendorIndex, rowIndex, itemDefinitionIndex); + return; + } + clear_mutation(outcome); + if (disposition == state::VendorReputationDisposition::refused) { + report_purchase(opcode, "fail", refusal, vendorIndex, rowIndex, itemDefinitionIndex); + return; } - report_purchase(opcode, - prepared ? "ok" : "fail", - prepared ? "rank_reward" : refusal, - vendorIndex, - rowIndex, - itemDefinitionIndex); - return; } if (vendorIndex >= 0 && rowIndex >= 0 && vendorIndex <= (std::numeric_limits::max)() diff --git a/Sunrise/src/state/build_data/vendors/vendor_expression.h b/Sunrise/src/state/build_data/vendors/vendor_expression.h index 5fb79236f..80a37ab5f 100644 --- a/Sunrise/src/state/build_data/vendors/vendor_expression.h +++ b/Sunrise/src/state/build_data/vendors/vendor_expression.h @@ -11,7 +11,7 @@ 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; -/** The same 128-instruction safety bound used for installed node expressions. */ +/** One vendor gate program stores at most 128 instructions. */ inline constexpr std::size_t kVendorProgramCapacity = 128; /** Vendor gates use the shared native unlock instruction values. */ diff --git a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp index 95c79cf3f..6bfdeb727 100644 --- a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp +++ b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp @@ -17,29 +17,39 @@ namespace { /** Native progression level walks read experience from lane zero. */ constexpr std::size_t kExperienceLane = 0; -/** Supported reconstructed claim links; installed content supplies their conditions. */ -struct RewardRule { +/** Reconstructed turn-in and rowless links; sale-backed claims resolve installed content. */ +struct ReconstructedRewardRule { std::uint32_t vendorHash; std::uint16_t interaction; std::int32_t category; std::uint16_t saleIndex{}; - std::uint32_t packageHash{}; }; -/** Build-86657 Vanguard rank package item hash. */ -constexpr std::uint32_t kVanguardPackageHash = 2746484552U; -/** Build-86657 Crucible rank package item hash. */ -constexpr std::uint32_t kCruciblePackageHash = 3289621657U; -/** Build-86657 Gunsmith rank package item hash. */ -constexpr std::uint32_t kGunsmithPackageHash = 2422825785U; -/** Vendor, interaction, category, package sale and hash from build 86657. */ -constexpr std::array kRewardRules{{ - // Zavala's current package previews Vanguard gear. - {69482069U, 40, 3, 93, kVanguardPackageHash}, - // Shaxx's current package previews Crucible gear. - {3603221665U, 28, 10, 96, kCruciblePackageHash}, - // Banshee's sale is ungated, but its interaction still checks the player's level. - {672118013U, 35, 8, 16, kGunsmithPackageHash}, -}}; +/** Build 86657 links Zavala interaction 40 in category 3 to rowless package sale 93. */ +constexpr ReconstructedRewardRule kVanguardRewardRule{ + .vendorHash = 69482069U, + .interaction = 40, + .category = 3, + .saleIndex = 93, +}; +/** Build 86657 links Shaxx interaction 28 in category 10 to rowless package sale 96. */ +constexpr ReconstructedRewardRule kCrucibleRewardRule{ + .vendorHash = 3603221665U, + .interaction = 28, + .category = 10, + .saleIndex = 96, +}; +/** Build 86657 links Banshee interaction 35 in category 8 to rowless package sale 16. */ +constexpr ReconstructedRewardRule kGunsmithRewardRule{ + .vendorHash = 672118013U, + .interaction = 35, + .category = 8, + .saleIndex = 16, +}; +constexpr std::array kReconstructedRewardRules{ + kVanguardRewardRule, + kCrucibleRewardRule, + kGunsmithRewardRule, +}; /** Reply zero completes the supported normal reward interactions. */ constexpr std::uint16_t kAcceptRewardReply = 0; @@ -61,7 +71,13 @@ const build_data::vendors::GateInput* gate_input(const GateValues& values, return found == inputs.end() ? nullptr : &*found; } -/** A Family-5 override supersedes an installed bank mapping when it names the same slot. */ +/** + * Reads a native flag slot, preferring a Family-5 override. + * @param context Gate values shared by this evaluation. + * @param slot Native flag slot. + * @param logical Receives the logical byte on success. + * @return False when the slot has no supported source. + */ bool gate_flag(void* context, std::uint16_t slot, std::uint8_t& logical) noexcept { const auto& values = *static_cast(context); const auto* input = gate_input(values, build_data::vendors::Opcode::flag, slot); @@ -100,7 +116,13 @@ bool gate_flag(void* context, std::uint16_t slot, std::uint8_t& logical) noexcep return false; } -/** Reads a native value slot from Family-5 or its installed saved-bank row. */ +/** + * Reads a native value slot, preferring a Family-5 override. + * @param context Gate values shared by this evaluation. + * @param slot Native value slot. + * @param value Receives the saved value on success. + * @return False when the slot has no supported source. + */ bool gate_value(void* context, std::uint16_t slot, std::int32_t& value) noexcept { const auto& values = *static_cast(context); const auto* input = gate_input(values, build_data::vendors::Opcode::loadValue, slot); @@ -133,7 +155,13 @@ bool gate_value(void* context, std::uint16_t slot, std::int32_t& value) noexcept return false; } -/** An empty authored sale field has no condition; a missing input refuses a nonempty gate. */ +/** + * Evaluates one authored gate against supplied saved values. + * @param gate Installed gate to evaluate. + * @param family Family-5 overrides. + * @param banks Saved unlock values. + * @return True for an empty gate or a complete expression that evaluates true. + */ bool gate_passes(const build_data::vendors::Gate& gate, const Family5State& family, const unlocks::Table& banks) noexcept { @@ -148,98 +176,246 @@ bool gate_passes(const build_data::vendors::Gate& gate, && result; } +enum class CreditGateKind { unrelated, claim, warning, malformed }; + /** - * The supported interaction starts with VALUE[credit] > 0. Its installed slot map names the - * character row to credit on a rank-up and debit on a claim. Other shapes are not guessed. - * @param rule Checked interaction link. - * @param interaction Receives the installed interaction gate. + * Resolves a native VALUE[credit] > 0 prefix without treating a level warning as a claim. + * @param interaction Installed interaction gate. * @param row Receives the saved character credit row. - * @return False if the tested value is missing, ambiguous or not character-owned. + * @return The supported claim shape, a warning branch, an unrelated gate or malformed input. */ -bool reward_credit_gate(const RewardRule& rule, - build_data::vendors::InteractionGate& interaction, - std::uint16_t& row) noexcept { +CreditGateKind credit_gate(const build_data::vendors::InteractionGate& interaction, + std::uint16_t& row) noexcept { namespace vendors = build_data::vendors; - interaction = {}; row = 0; - if (!vendors::find_interaction_gate(rule.vendorHash, rule.interaction, interaction) - || interaction.categoryIndex != rule.category) { - return false; - } const auto program = std::span{interaction.condition.program.instructions}.first( interaction.condition.program.count); - if (program.size() < 3 || program[0].opcode != vendors::Opcode::loadValue - || program[0].operand < 0 + if (program.size() < 2 || program[0].opcode != vendors::Opcode::loadValue + || program[1].opcode != vendors::Opcode::constant || program[1].operand != 0) { + return CreditGateKind::unrelated; + } + if (program.size() < 3 || program[0].operand < 0 || program[0].operand > (std::numeric_limits::max)() - || program[1].opcode != vendors::Opcode::constant || program[1].operand != 0 || program[2].opcode != vendors::Opcode::greaterThan) { - return false; + return CreditGateKind::malformed; } const auto slot = static_cast(program[0].operand); const auto inputs = std::span{interaction.condition.inputs}.first(interaction.condition.inputCount); - const auto found = std::find_if(inputs.begin(), inputs.end(), [&](const auto& input) { - return input.opcode == vendors::Opcode::loadValue && input.slot == slot - && input.bank == vendors::GateBank::characterValue; - }); - if (found == inputs.end() || std::any_of(inputs.begin(), inputs.end(), [&](const auto& input) { - return &input != &*found && input.bank == vendors::GateBank::characterValue; - })) { - return false; + const vendors::GateInput* found = nullptr; + for (const auto& input : inputs) { + if (input.opcode != vendors::Opcode::loadValue || input.slot != slot) { + continue; + } + if (found != nullptr || input.bank != vendors::GateBank::characterValue) { + return CreditGateKind::malformed; + } + found = &input; + } + if (found == nullptr || found->row == 0 + || found->row >= unlocks::kCharacterObjectValueCapacity) { + return CreditGateKind::malformed; } row = found->row; - return row != 0; + const auto remainder = program.subspan(3); + if (std::any_of(remainder.begin(), remainder.end(), [](const auto& instruction) { + return instruction.opcode == vendors::Opcode::lessThan + || instruction.opcode == vendors::Opcode::lessOrEqual; + })) { + return CreditGateKind::warning; + } + return CreditGateKind::claim; } /** - * Resolves vendor identity independently of request selectors. + * Resolves one reconstructed turn-in or rowless link independently of request selectors. * @param vendorIndex Installed vendor selector. - * @return Checked binding, including unsupported payouts, or null for unrelated vendors. + * @return Checked reconstructed link, or null for unrelated vendors. */ -const RewardRule* reward_rule(std::uint16_t vendorIndex) noexcept { +const ReconstructedRewardRule* reconstructed_reward_rule(std::uint16_t vendorIndex) noexcept { build_data::vendors::IndexEntry entry{}; if (!build_data::vendors::find_index(vendorIndex, entry)) { return nullptr; } const auto found = - std::find_if(kRewardRules.begin(), kRewardRules.end(), [&](const auto& rule) { - return rule.vendorHash == entry.definitionHash; - }); - return found == kRewardRules.end() ? nullptr : &*found; + std::find_if(kReconstructedRewardRules.begin(), + kReconstructedRewardRules.end(), + [&](const auto& rule) { return rule.vendorHash == entry.definitionHash; }); + return found == kReconstructedRewardRules.end() ? nullptr : &*found; +} + +/** + * Resolves the exact reconstructed interaction's installed credit row. + * @param rule Reconstructed interaction link. + * @param interaction Receives the installed interaction gate. + * @param row Receives the saved character credit row. + * @return False when the exact link is missing, changed or not a claim gate. + */ +bool reconstructed_credit_gate(const ReconstructedRewardRule& rule, + build_data::vendors::InteractionGate& interaction, + std::uint16_t& row) noexcept { + namespace vendors = build_data::vendors; + interaction = {}; + row = 0; + return vendors::find_interaction_gate(rule.vendorHash, rule.interaction, interaction) + && interaction.categoryIndex == rule.category + && credit_gate(interaction, row) == CreditGateKind::claim; +} + +/** Installed sale, reward and gate identities captured for commit revalidation. */ +struct RewardSaleBinding { + build_data::vendors::InteractionGate interaction{}; + build_data::vendors::SaleGates gates{}; + std::uint32_t packageHash{}; + std::int32_t categoryIndex{build_data::vendors::kAbsentCategoryIndex}; + std::uint16_t itemIndex{}; + std::uint16_t poolIndex{build_data::rewards::kAbsent}; + std::uint16_t rewardValueRow{}; +}; + +/** + * Confirms that the installed faction participates in the authored reputation catalog. + * @param vendor Installed vendor definition. + * @return True when at least one exact turn-in names this vendor and faction. + */ +bool has_reputation_context(const build_data::vendors::Definition& vendor) noexcept { + namespace vendors = build_data::vendors; + if (vendor.factionHash == 0 + || vendor.factionProgressionIndex == vendors::kUnavailableFactionProgressionIndex) { + return false; + } + for (std::size_t index = 0; index < vendor.saleCount; ++index) { + vendors::ReputationSale sale{}; + if (vendors::find_reputation_sale( + vendor.definitionHash, static_cast(index), sale) + && sale.factionHash == vendor.factionHash) { + return true; + } + } + return false; +} + +/** + * Finds one non-warning credit claim in the requested installed category. + * @param vendor Installed vendor definition. + * @param categoryIndex Requested sale category. + * @param interaction Receives the unique claim interaction. + * @param row Receives its saved character credit row. + * @return False for zero, duplicate or malformed claim candidates. + */ +bool find_claim_interaction(const build_data::vendors::Definition& vendor, + std::int32_t categoryIndex, + build_data::vendors::InteractionGate& interaction, + std::uint16_t& row) noexcept { + namespace vendors = build_data::vendors; + interaction = {}; + row = 0; + bool found = false; + for (std::size_t index = 0; index < vendor.thirdCount; ++index) { + vendors::InteractionGate candidate{}; + if (!vendors::find_interaction_gate( + vendor.definitionHash, static_cast(index), candidate) + || candidate.categoryIndex != categoryIndex) { + continue; + } + std::uint16_t candidateRow = 0; + switch (credit_gate(candidate, candidateRow)) { + case CreditGateKind::unrelated: + case CreditGateKind::warning: + continue; + case CreditGateKind::malformed: + return false; + case CreditGateKind::claim: + if (found) { + return false; + } + found = true; + interaction = candidate; + row = candidateRow; + break; + } + } + return found; } /** - * Accept only the supported auto-opening package and its installed native gates. - * Caller holds the investment-store lock while reading the current gates. - * @param rule Checked vendor binding. - * @param saleIndex Requested sale, retained for commit revalidation. - * @return True when saved gates and the installed package binding agree. + * Resolves one exact sale-backed rank wrapper from installed content. + * @param vendorIndex Installed vendor selector. + * @param saleIndex Requested sale selector. + * @param binding Receives current package, pool and gate identities. + * @return Unrelated ordinary sale, refused candidate or prepared rank wrapper. */ -bool reward_binding_current(const RewardRule& rule, std::uint16_t saleIndex) noexcept { +VendorReputationDisposition resolve_reward_sale(std::uint16_t vendorIndex, + std::uint16_t saleIndex, + RewardSaleBinding& binding) noexcept { namespace vendors = build_data::vendors; + binding = {}; + vendors::IndexEntry entry{}; vendors::Definition vendor{}; vendors::SaleRow sale{}; + if (!vendors::find_index(vendorIndex, entry) || !vendors::find(entry.definitionHash, vendor)) { + return VendorReputationDisposition::notApplicable; + } + if (!vendors::sale_row(vendor, saleIndex, sale)) { + return VendorReputationDisposition::refused; + } + vendors::SaleGates gates{}; + const bool retained = vendors::find_sale_gates(entry.definitionHash, saleIndex, gates); + const bool freeRewardCandidate = sale.categoryIndex != vendors::kAbsentCategoryIndex + && sale.costQuantity == 0 + && sale.costItemIndex == vendors::kAbsentCostItem; + if (!freeRewardCandidate) { + return retained ? VendorReputationDisposition::refused + : VendorReputationDisposition::notApplicable; + } build_data::items::Definition package{}; build_data::rewards::Item reward{}; - if (saleIndex != rule.saleIndex || !vendors::find(rule.vendorHash, vendor) - || !vendors::sale_row(vendor, saleIndex, sale) || sale.categoryIndex != rule.category - || sale.costQuantity != 0 - || !build_data::find_item_definition_index(sale.itemIndex, package) - || package.definitionHash != rule.packageHash - || !build_data::rewards::find_item(sale.itemIndex, reward) - || reward.definitionHash != package.definitionHash - || reward.poolIndex == build_data::rewards::kAbsent + const bool context = has_reputation_context(vendor); + const bool rewardCatalog = build_data::rewards::ready(); + const bool itemFound = build_data::find_item_definition_index(sale.itemIndex, package); + const bool rewardFound = + rewardCatalog && build_data::rewards::find_item(sale.itemIndex, reward); + if (!rewardCatalog || !itemFound) { + return retained || context ? VendorReputationDisposition::refused + : VendorReputationDisposition::notApplicable; + } + if (!rewardFound) { + return retained ? VendorReputationDisposition::refused + : VendorReputationDisposition::notApplicable; + } + if (package.definitionHash != reward.definitionHash) { + return retained || context ? VendorReputationDisposition::refused + : VendorReputationDisposition::notApplicable; + } + if (reward.poolIndex == build_data::rewards::kAbsent || (reward.flags & build_data::rewards::kOpenOnAcquisition) == 0) { - return false; + return retained ? VendorReputationDisposition::refused + : VendorReputationDisposition::notApplicable; } - vendors::InteractionGate interaction{}; - vendors::SaleGates gates{}; + if (!context || !retained || gates.categoryIndex != sale.categoryIndex) { + return VendorReputationDisposition::refused; + } + build_data::vendors::InteractionGate interaction{}; std::uint16_t creditRow = 0; - if (!reward_credit_gate(rule, interaction, creditRow) - || !vendors::find_sale_gates(rule.vendorHash, saleIndex, gates) - || gates.categoryIndex != rule.category) { - return false; + if (!find_claim_interaction(vendor, sale.categoryIndex, interaction, creditRow)) { + return VendorReputationDisposition::refused; } + binding.interaction = interaction; + binding.gates = gates; + binding.packageHash = package.definitionHash; + binding.categoryIndex = sale.categoryIndex; + binding.itemIndex = sale.itemIndex; + binding.poolIndex = reward.poolIndex; + binding.rewardValueRow = creditRow; + return VendorReputationDisposition::prepared; +} + +/** + * Evaluates the claim interaction and exact sale gates against current saved state. + * @param binding Resolved installed sale authority. + * @return False when any saved input is missing or any complete gate refuses the claim. + */ +bool reward_gates_pass(const RewardSaleBinding& binding) noexcept { Family5State family{}; AccountState account{}; unlocks::Table banks{}; @@ -250,9 +426,18 @@ bool reward_binding_current(const RewardRule& rule, std::uint16_t saleIndex) noe const auto selected = runtime::detail::selected_character_index(account); return selected < account.characterCount && investment::store::read_unlocks(banks, static_cast(selected)) - && gate_passes(interaction.condition, family, banks) - && gate_passes(gates.admission, family, banks) - && gate_passes(gates.selection, family, banks); + && gate_passes(binding.interaction.condition, family, banks) + && gate_passes(binding.gates.admission, family, banks) + && gate_passes(binding.gates.selection, family, banks); +} + +/** @return True when a prepared claim still names the same installed sale authority. */ +bool same_reward_binding(const VendorRewardClaim& claim, + const RewardSaleBinding& binding) noexcept { + return claim.packageHash == binding.packageHash && claim.categoryIndex == binding.categoryIndex + && claim.itemIndex == binding.itemIndex && claim.poolIndex == binding.poolIndex + && claim.interactionIndex == binding.interaction.index + && claim.rewardValueRow == binding.rewardValueRow; } /** @@ -313,10 +498,10 @@ VendorReputationDisposition resolve_award(std::uint16_t vendorIndex, award.costQuantity = sale.costQuantity; award.experience = static_cast(experience); award.progressionIndex = progressionIndex; - const auto* reward = reward_rule(vendorIndex); + const auto* reward = reconstructed_reward_rule(vendorIndex); if (reward != nullptr) { vendors::InteractionGate interaction{}; - if (!reward_credit_gate(*reward, interaction, award.rewardValueRow)) { + if (!reconstructed_credit_gate(*reward, interaction, award.rewardValueRow)) { return VendorReputationDisposition::refused; } } @@ -536,22 +721,6 @@ bool commit_vendor_reputation(PendingVendorReputation& mutation) noexcept { && transaction.commit(); } -/** - * Reward categories cannot fall through to a free package acquisition. - * @param vendorIndex Installed vendor selector. - * @param categoryIndex Installed sale category. - * @return True for a mapped rank-reward category, including unsupported payout vendors. - */ -bool is_vendor_reward_category(std::uint16_t vendorIndex, std::int32_t categoryIndex) noexcept { - build_data::vendors::IndexEntry entry{}; - if (!build_data::vendors::find_index(vendorIndex, entry)) { - return false; - } - return std::any_of(kRewardRules.begin(), kRewardRules.end(), [&](const auto& rule) { - return rule.vendorHash == entry.definitionHash && rule.category == categoryIndex; - }); -} - /** * Resolves a supported rowless reply through the same settlement as its package sale. * @param vendorIndex Installed vendor selector. @@ -565,14 +734,21 @@ VendorReputationDisposition prepare_vendor_reward(std::uint16_t vendorIndex, std::uint16_t replyIndex, PendingRecordRewardGrant& mutation) noexcept { mutation = {}; - const auto* rule = reward_rule(vendorIndex); + const auto* rule = reconstructed_reward_rule(vendorIndex); if (rule == nullptr || rule->interaction != interactionIndex) { return VendorReputationDisposition::notApplicable; } if (replyIndex != kAcceptRewardReply) { return VendorReputationDisposition::refused; } - return prepare_vendor_reward_sale(vendorIndex, rule->saleIndex, mutation); + const auto disposition = prepare_vendor_reward_sale(vendorIndex, rule->saleIndex, mutation); + if (disposition != VendorReputationDisposition::prepared + || mutation.vendorReward.interactionIndex != rule->interaction + || mutation.vendorReward.categoryIndex != rule->category) { + mutation = {}; + return VendorReputationDisposition::refused; + } + return VendorReputationDisposition::prepared; } /** @@ -589,46 +765,39 @@ VendorReputationDisposition prepare_vendor_reward_sale(std::uint16_t vendorIndex const char** refusal) noexcept { const char* unused = nullptr; auto& reason = refusal != nullptr ? *refusal : unused; - reason = "vendor_rule"; + reason = "reward_binding"; mutation = {}; - const auto* rule = reward_rule(vendorIndex); - build_data::vendors::Definition vendor{}; - build_data::vendors::SaleRow sale{}; - if (rule == nullptr) { - return VendorReputationDisposition::notApplicable; - } - reason = "vendor_sale"; - if (!build_data::vendors::find(rule->vendorHash, vendor) - || !build_data::vendors::sale_row(vendor, saleIndex, sale)) { - return VendorReputationDisposition::refused; - } - if (sale.categoryIndex != rule->category) { - return VendorReputationDisposition::notApplicable; - } const std::lock_guard lock(investment::store::g_mutex); - build_data::vendors::InteractionGate interaction{}; - std::uint16_t creditRow = 0; - reason = "reward_binding"; - if (!reward_credit_gate(*rule, interaction, creditRow)) { - return VendorReputationDisposition::refused; + RewardSaleBinding binding{}; + const auto disposition = resolve_reward_sale(vendorIndex, saleIndex, binding); + if (disposition != VendorReputationDisposition::prepared) { + return disposition; } reason = "rank_credit"; std::int32_t credits = 0; if (!investment::store::read_unlock( - investment::store::Bank::characterObjectValues, creditRow, credits) + investment::store::Bank::characterObjectValues, binding.rewardValueRow, credits) || credits <= 0) { return VendorReputationDisposition::refused; } reason = "reward_binding"; - if (!reward_binding_current(*rule, saleIndex)) { + if (!reward_gates_pass(binding)) { return VendorReputationDisposition::refused; } - if (!prepare_item_reward(sale.itemIndex, 1, mutation, &reason)) { + if (!prepare_item_reward(binding.itemIndex, 1, mutation, &reason)) { mutation = {}; return VendorReputationDisposition::refused; } reason = nullptr; - mutation.vendorReward = {credits, vendorIndex, saleIndex, creditRow}; + mutation.vendorReward.beforeCredits = credits; + mutation.vendorReward.vendorIndex = vendorIndex; + mutation.vendorReward.saleIndex = saleIndex; + mutation.vendorReward.packageHash = binding.packageHash; + mutation.vendorReward.categoryIndex = binding.categoryIndex; + mutation.vendorReward.itemIndex = binding.itemIndex; + mutation.vendorReward.poolIndex = binding.poolIndex; + mutation.vendorReward.interactionIndex = binding.interaction.index; + mutation.vendorReward.rewardValueRow = binding.rewardValueRow; return VendorReputationDisposition::prepared; } @@ -643,12 +812,11 @@ bool vendor_reward_current(const VendorRewardClaim& claim) noexcept { return true; } std::int32_t current = 0; - const auto* rule = reward_rule(claim.vendorIndex); - build_data::vendors::InteractionGate interaction{}; - std::uint16_t creditRow = 0; - if (claim.beforeCredits < 0 || rule == nullptr - || !reward_credit_gate(*rule, interaction, creditRow) || claim.rewardValueRow != creditRow - || !reward_binding_current(*rule, claim.saleIndex) + RewardSaleBinding binding{}; + if (claim.beforeCredits < 0 + || resolve_reward_sale(claim.vendorIndex, claim.saleIndex, binding) + != VendorReputationDisposition::prepared + || !same_reward_binding(claim, binding) || !reward_gates_pass(binding) || !investment::store::read_unlock( investment::store::Bank::characterObjectValues, claim.rewardValueRow, current) || current != claim.beforeCredits) { diff --git a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h index 8df6f6852..773a9117c 100644 --- a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h +++ b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h @@ -51,6 +51,11 @@ struct VendorRewardClaim { std::int32_t beforeCredits{}; std::uint16_t vendorIndex{}; std::uint16_t saleIndex{}; + std::uint32_t packageHash{}; + std::int32_t categoryIndex{}; + std::uint16_t itemIndex{}; + std::uint16_t poolIndex{}; + std::uint16_t interactionIndex{}; /** Rechecked against the vendor binding before preview or commit. */ std::uint16_t rewardValueRow{}; }; @@ -67,7 +72,5 @@ prepare_vendor_reward(std::uint16_t vendorIndex, std::uint16_t replyIndex, PendingRecordRewardGrant& mutation) noexcept; [[nodiscard]] bool vendor_reward_current(const VendorRewardClaim& claim) noexcept; -[[nodiscard]] bool is_vendor_reward_category(std::uint16_t vendorIndex, - std::int32_t categoryIndex) noexcept; } // namespace sunrise::state From 6b5129f0d88d1ab95c64fc0df705ad80b36f9b99 Mon Sep 17 00:00:00 2001 From: Gage Fulwood Date: Thu, 24 Sep 2026 10:15:55 -0500 Subject: [PATCH 31/59] Separate reconstructed rank-claim links from vendor settlement --- Sunrise/Sunrise.vcxproj | 1 + .../web_service_vendor_actions.cpp | 18 +-- .../vendors/reconstructed_rank_claim_links.h | 44 ++++++ Sunrise/src/state/runtime/runtime.h | 2 +- .../state_account_reward_grant_runtime.cpp | 10 +- .../state_vendor_reputation_runtime.cpp | 144 +++++++----------- .../runtime/state_vendor_reputation_runtime.h | 32 ++-- 7 files changed, 127 insertions(+), 124 deletions(-) create mode 100644 Sunrise/src/state/build_data/vendors/reconstructed_rank_claim_links.h diff --git a/Sunrise/Sunrise.vcxproj b/Sunrise/Sunrise.vcxproj index d152b5c29..fbc340f25 100644 --- a/Sunrise/Sunrise.vcxproj +++ b/Sunrise/Sunrise.vcxproj @@ -1524,6 +1524,7 @@ + diff --git a/Sunrise/src/server/web_service/web_service_vendor_actions.cpp b/Sunrise/src/server/web_service/web_service_vendor_actions.cpp index 59d3952c7..2a93e1f0c 100644 --- a/Sunrise/src/server/web_service/web_service_vendor_actions.cpp +++ b/Sunrise/src/server/web_service/web_service_vendor_actions.cpp @@ -652,10 +652,10 @@ void settle_vendor_row(const middleware::web_service::Message& message, } const char* refusal = "storage"; const auto disposition = - state::prepare_vendor_reward_sale(static_cast(vendorIndex), - static_cast(rowIndex), - *reward, - &refusal); + state::prepare_vendor_rank_reward_sale(static_cast(vendorIndex), + static_cast(rowIndex), + *reward, + &refusal); if (disposition == state::VendorReputationDisposition::prepared) { report_purchase( opcode, "ok", "rank_reward", vendorIndex, rowIndex, itemDefinitionIndex); @@ -790,11 +790,11 @@ void acquire_quest(const middleware::web_service::Message& message, Outcome& out kUnavailableDefinitionIndex); return; } - const auto disposition = - state::prepare_vendor_reward(static_cast(request.vendorIndex), - static_cast(request.slotIndex), - static_cast(request.third), - *reward); + const auto disposition = state::prepare_vendor_rank_reward_interaction( + static_cast(request.vendorIndex), + static_cast(request.slotIndex), + static_cast(request.third), + *reward); if (disposition == state::VendorReputationDisposition::prepared) { report_purchase(quest::kOpcode, "ok", diff --git a/Sunrise/src/state/build_data/vendors/reconstructed_rank_claim_links.h b/Sunrise/src/state/build_data/vendors/reconstructed_rank_claim_links.h new file mode 100644 index 000000000..206b9e875 --- /dev/null +++ b/Sunrise/src/state/build_data/vendors/reconstructed_rank_claim_links.h @@ -0,0 +1,44 @@ +#pragma once + +#include +#include + +namespace sunrise::state::build_data::vendors { + +/** Build-86657 rank credit links also supply sales omitted by rowless replies. */ +struct ReconstructedRankClaimLink { + std::uint32_t vendorHash; + std::uint16_t interactionIndex; + std::int32_t categoryIndex; + std::uint16_t saleIndex; +}; + +/** Zavala's rank claim links interaction 40 in category 3 to package sale 93. */ +inline constexpr ReconstructedRankClaimLink kVanguardRankClaimLink{69482069U, 40, 3, 93}; +/** Shaxx's rank claim links interaction 28 in category 10 to package sale 96. */ +inline constexpr ReconstructedRankClaimLink kCrucibleRankClaimLink{3603221665U, 28, 10, 96}; +/** Banshee's rank claim links interaction 35 in category 8 to package sale 16. */ +inline constexpr ReconstructedRankClaimLink kGunsmithRankClaimLink{672118013U, 35, 8, 16}; + +inline constexpr std::array kReconstructedRankClaimLinks{ + kVanguardRankClaimLink, + kCrucibleRankClaimLink, + kGunsmithRankClaimLink, +}; + +/** + * Finds the reconstructed claim link for one installed vendor. + * @param vendorHash Installed vendor definition hash. + * @return The link, or null for vendors without a supported rank claim. + */ +[[nodiscard]] constexpr const ReconstructedRankClaimLink* +find_reconstructed_rank_claim_link(std::uint32_t vendorHash) noexcept { + for (const ReconstructedRankClaimLink& link : kReconstructedRankClaimLinks) { + if (link.vendorHash == vendorHash) { + return &link; + } + } + return nullptr; +} + +} // namespace sunrise::state::build_data::vendors diff --git a/Sunrise/src/state/runtime/runtime.h b/Sunrise/src/state/runtime/runtime.h index cd7187c0e..f854082d7 100644 --- a/Sunrise/src/state/runtime/runtime.h +++ b/Sunrise/src/state/runtime/runtime.h @@ -261,7 +261,7 @@ struct PendingRecordRewardGrant { std::size_t beforeProfileItemCount{}; std::size_t afterProfileItemCount{}; std::size_t rewardCount{}; - VendorRewardClaim vendorReward{}; + VendorRankRewardClaim vendorReward{}; bool prepared{}; }; 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 ad1d466fa..2b144f4f8 100644 --- a/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp +++ b/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp @@ -372,12 +372,12 @@ bool preview_reward_unlocks(const PendingRecordRewardGrant& mutation, } const auto& claim = mutation.vendorReward; if (claim.beforeCredits != 0) { - if (claim.rewardValueRow >= after.characterObjectValues.size() - || !vendor_reward_current(claim) - || after.characterObjectValues[claim.rewardValueRow] != claim.beforeCredits) { + if (claim.rankCreditRow >= after.characterObjectValues.size() + || !vendor_rank_reward_current(claim) + || after.characterObjectValues[claim.rankCreditRow] != claim.beforeCredits) { return false; } - after.characterObjectValues[claim.rewardValueRow] = claim.beforeCredits - 1; + after.characterObjectValues[claim.rankCreditRow] = claim.beforeCredits - 1; } return true; } @@ -765,7 +765,7 @@ bool commit_record_reward(PendingRecordRewardGrant& mutation) noexcept { } if (ready && mutation.vendorReward.beforeCredits > 0) { ready = investment::store::write_unlock(investment::store::Bank::characterObjectValues, - mutation.vendorReward.rewardValueRow, + mutation.vendorReward.rankCreditRow, mutation.vendorReward.beforeCredits - 1); } ready = ready && transaction.commit(); diff --git a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp index 6bfdeb727..c45d14ca8 100644 --- a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp +++ b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp @@ -5,6 +5,7 @@ #include "../build_data/progressions/progression_catalog.h" #include "../build_data/rewards/reward_catalog.h" +#include "../build_data/vendors/reconstructed_rank_claim_links.h" #include "../build_data/vendors/reputation_sale_catalog.h" #include "../build_data/vendors/vendor_catalog.h" #include "../build_data/vendors/vendor_gate_catalog.h" @@ -17,39 +18,6 @@ namespace { /** Native progression level walks read experience from lane zero. */ constexpr std::size_t kExperienceLane = 0; -/** Reconstructed turn-in and rowless links; sale-backed claims resolve installed content. */ -struct ReconstructedRewardRule { - std::uint32_t vendorHash; - std::uint16_t interaction; - std::int32_t category; - std::uint16_t saleIndex{}; -}; -/** Build 86657 links Zavala interaction 40 in category 3 to rowless package sale 93. */ -constexpr ReconstructedRewardRule kVanguardRewardRule{ - .vendorHash = 69482069U, - .interaction = 40, - .category = 3, - .saleIndex = 93, -}; -/** Build 86657 links Shaxx interaction 28 in category 10 to rowless package sale 96. */ -constexpr ReconstructedRewardRule kCrucibleRewardRule{ - .vendorHash = 3603221665U, - .interaction = 28, - .category = 10, - .saleIndex = 96, -}; -/** Build 86657 links Banshee interaction 35 in category 8 to rowless package sale 16. */ -constexpr ReconstructedRewardRule kGunsmithRewardRule{ - .vendorHash = 672118013U, - .interaction = 35, - .category = 8, - .saleIndex = 16, -}; -constexpr std::array kReconstructedRewardRules{ - kVanguardRewardRule, - kCrucibleRewardRule, - kGunsmithRewardRule, -}; /** Reply zero completes the supported normal reward interactions. */ constexpr std::uint16_t kAcceptRewardReply = 0; @@ -227,38 +195,21 @@ CreditGateKind credit_gate(const build_data::vendors::InteractionGate& interacti return CreditGateKind::claim; } -/** - * Resolves one reconstructed turn-in or rowless link independently of request selectors. - * @param vendorIndex Installed vendor selector. - * @return Checked reconstructed link, or null for unrelated vendors. - */ -const ReconstructedRewardRule* reconstructed_reward_rule(std::uint16_t vendorIndex) noexcept { - build_data::vendors::IndexEntry entry{}; - if (!build_data::vendors::find_index(vendorIndex, entry)) { - return nullptr; - } - const auto found = - std::find_if(kReconstructedRewardRules.begin(), - kReconstructedRewardRules.end(), - [&](const auto& rule) { return rule.vendorHash == entry.definitionHash; }); - return found == kReconstructedRewardRules.end() ? nullptr : &*found; -} - /** * Resolves the exact reconstructed interaction's installed credit row. - * @param rule Reconstructed interaction link. + * @param link Reconstructed interaction link. * @param interaction Receives the installed interaction gate. * @param row Receives the saved character credit row. * @return False when the exact link is missing, changed or not a claim gate. */ -bool reconstructed_credit_gate(const ReconstructedRewardRule& rule, +bool reconstructed_credit_gate(const build_data::vendors::ReconstructedRankClaimLink& link, build_data::vendors::InteractionGate& interaction, std::uint16_t& row) noexcept { namespace vendors = build_data::vendors; interaction = {}; row = 0; - return vendors::find_interaction_gate(rule.vendorHash, rule.interaction, interaction) - && interaction.categoryIndex == rule.category + return vendors::find_interaction_gate(link.vendorHash, link.interactionIndex, interaction) + && interaction.categoryIndex == link.categoryIndex && credit_gate(interaction, row) == CreditGateKind::claim; } @@ -270,7 +221,7 @@ struct RewardSaleBinding { std::int32_t categoryIndex{build_data::vendors::kAbsentCategoryIndex}; std::uint16_t itemIndex{}; std::uint16_t poolIndex{build_data::rewards::kAbsent}; - std::uint16_t rewardValueRow{}; + std::uint16_t rankCreditRow{}; }; /** @@ -303,10 +254,10 @@ bool has_reputation_context(const build_data::vendors::Definition& vendor) noexc * @param row Receives its saved character credit row. * @return False for zero, duplicate or malformed claim candidates. */ -bool find_claim_interaction(const build_data::vendors::Definition& vendor, - std::int32_t categoryIndex, - build_data::vendors::InteractionGate& interaction, - std::uint16_t& row) noexcept { +bool find_rank_claim_interaction(const build_data::vendors::Definition& vendor, + std::int32_t categoryIndex, + build_data::vendors::InteractionGate& interaction, + std::uint16_t& row) noexcept { namespace vendors = build_data::vendors; interaction = {}; row = 0; @@ -397,7 +348,7 @@ VendorReputationDisposition resolve_reward_sale(std::uint16_t vendorIndex, } build_data::vendors::InteractionGate interaction{}; std::uint16_t creditRow = 0; - if (!find_claim_interaction(vendor, sale.categoryIndex, interaction, creditRow)) { + if (!find_rank_claim_interaction(vendor, sale.categoryIndex, interaction, creditRow)) { return VendorReputationDisposition::refused; } binding.interaction = interaction; @@ -406,7 +357,7 @@ VendorReputationDisposition resolve_reward_sale(std::uint16_t vendorIndex, binding.categoryIndex = sale.categoryIndex; binding.itemIndex = sale.itemIndex; binding.poolIndex = reward.poolIndex; - binding.rewardValueRow = creditRow; + binding.rankCreditRow = creditRow; return VendorReputationDisposition::prepared; } @@ -432,12 +383,12 @@ bool reward_gates_pass(const RewardSaleBinding& binding) noexcept { } /** @return True when a prepared claim still names the same installed sale authority. */ -bool same_reward_binding(const VendorRewardClaim& claim, +bool same_reward_binding(const VendorRankRewardClaim& claim, const RewardSaleBinding& binding) noexcept { return claim.packageHash == binding.packageHash && claim.categoryIndex == binding.categoryIndex && claim.itemIndex == binding.itemIndex && claim.poolIndex == binding.poolIndex && claim.interactionIndex == binding.interaction.index - && claim.rewardValueRow == binding.rewardValueRow; + && claim.rankCreditRow == binding.rankCreditRow; } /** @@ -447,9 +398,9 @@ bool same_reward_binding(const VendorRewardClaim& claim, * @param award Receives the checked cost and XP; use only when prepared is returned. * @return Not applicable for other sales, refused for unsupported costs on a known placeholder. */ -VendorReputationDisposition resolve_award(std::uint16_t vendorIndex, - std::uint16_t saleIndex, - VendorReputationAward& award) noexcept { +VendorReputationDisposition resolve_reputation_turn_in(std::uint16_t vendorIndex, + std::uint16_t saleIndex, + VendorReputationAward& award) noexcept { namespace vendors = build_data::vendors; vendors::IndexEntry entry{}; vendors::Definition vendor{}; @@ -498,15 +449,15 @@ VendorReputationDisposition resolve_award(std::uint16_t vendorIndex, award.costQuantity = sale.costQuantity; award.experience = static_cast(experience); award.progressionIndex = progressionIndex; - const auto* reward = reconstructed_reward_rule(vendorIndex); - if (reward != nullptr) { + const auto* link = vendors::find_reconstructed_rank_claim_link(entry.definitionHash); + if (link != nullptr) { vendors::InteractionGate interaction{}; - if (!reconstructed_credit_gate(*reward, interaction, award.rewardValueRow)) { + if (!reconstructed_credit_gate(*link, interaction, award.rankCreditRow)) { return VendorReputationDisposition::refused; } } award.repeatLastStep = progression.repeatLastStep; - if (award.rewardValueRow != 0) { + if (award.rankCreditRow != 0) { std::array steps{}; @@ -613,7 +564,7 @@ bool grant_rank_rewards(unlocks::Table& banks, return false; } const auto count = afterRank - beforeRank; - auto& credits = banks.characterObjectValues[award.rewardValueRow]; + auto& credits = banks.characterObjectValues[award.rankCreditRow]; if (credits < 0 || count > (std::numeric_limits::max)() - credits) { return false; } @@ -635,7 +586,7 @@ VendorReputationDisposition prepare_vendor_reputation(std::uint16_t vendorIndex, PendingVendorReputation& mutation) noexcept { mutation = {}; VendorReputationAward award{}; - const auto disposition = resolve_award(vendorIndex, saleIndex, award); + const auto disposition = resolve_reputation_turn_in(vendorIndex, saleIndex, award); if (disposition != VendorReputationDisposition::prepared) { return disposition; } @@ -658,7 +609,7 @@ VendorReputationDisposition prepare_vendor_reputation(std::uint16_t vendorIndex, return VendorReputationDisposition::refused; } AccountState after = account; - const auto beforeCredits = banks.characterObjectValues[award.rewardValueRow]; + const auto beforeCredits = banks.characterObjectValues[award.rankCreditRow]; if (!charge_materials(after, award) || !grant_rank_rewards(banks, award, before[kExperienceLane])) { return VendorReputationDisposition::refused; @@ -666,7 +617,7 @@ VendorReputationDisposition prepare_vendor_reputation(std::uint16_t vendorIndex, mutation.beforeItems = account.profileItems; mutation.beforeItemCount = account.profileItemCount; mutation.beforeProgression = before; - mutation.beforeRewardCredits = beforeCredits; + mutation.beforeRankCredits = beforeCredits; mutation.award = award; mutation.accountSoid = account.primarySoid; mutation.characterSoid = account.characters[selected].soid; @@ -686,7 +637,7 @@ bool commit_vendor_reputation(PendingVendorReputation& mutation) noexcept { const runtime::detail::PendingConsumption consume(mutation); VendorReputationAward award{}; if (!mutation.prepared - || resolve_award(mutation.vendorIndex, mutation.saleIndex, award) + || resolve_reputation_turn_in(mutation.vendorIndex, mutation.saleIndex, award) != VendorReputationDisposition::prepared || award != mutation.award) { return false; @@ -708,7 +659,7 @@ bool commit_vendor_reputation(PendingVendorReputation& mutation) noexcept { auto& progression = banks.characterProgressions[award.progressionIndex]; if (progression != mutation.beforeProgression || progression[kExperienceLane] < 0 || (award.rankStepCount != 0 - && banks.characterObjectValues[award.rewardValueRow] != mutation.beforeRewardCredits) + && banks.characterObjectValues[award.rankCreditRow] != mutation.beforeRankCredits) || progression[kExperienceLane] > (std::numeric_limits::max)() - award.experience || !charge_materials(account, award) @@ -729,22 +680,29 @@ bool commit_vendor_reputation(PendingVendorReputation& mutation) noexcept { * @param mutation Receives the prepared grant; cleared on refusal. * @return Recognized but unsupported replies refuse rather than falling through to a free grant. */ -VendorReputationDisposition prepare_vendor_reward(std::uint16_t vendorIndex, - std::uint16_t interactionIndex, - std::uint16_t replyIndex, - PendingRecordRewardGrant& mutation) noexcept { +VendorReputationDisposition +prepare_vendor_rank_reward_interaction(std::uint16_t vendorIndex, + std::uint16_t interactionIndex, + std::uint16_t replyIndex, + PendingRecordRewardGrant& mutation) noexcept { mutation = {}; - const auto* rule = reconstructed_reward_rule(vendorIndex); - if (rule == nullptr || rule->interaction != interactionIndex) { + build_data::vendors::IndexEntry entry{}; + if (!build_data::vendors::find_index(vendorIndex, entry)) { + return VendorReputationDisposition::notApplicable; + } + const auto* link = + build_data::vendors::find_reconstructed_rank_claim_link(entry.definitionHash); + if (link == nullptr || link->interactionIndex != interactionIndex) { return VendorReputationDisposition::notApplicable; } if (replyIndex != kAcceptRewardReply) { return VendorReputationDisposition::refused; } - const auto disposition = prepare_vendor_reward_sale(vendorIndex, rule->saleIndex, mutation); + const auto disposition = + prepare_vendor_rank_reward_sale(vendorIndex, link->saleIndex, mutation); if (disposition != VendorReputationDisposition::prepared - || mutation.vendorReward.interactionIndex != rule->interaction - || mutation.vendorReward.categoryIndex != rule->category) { + || mutation.vendorReward.interactionIndex != link->interactionIndex + || mutation.vendorReward.categoryIndex != link->categoryIndex) { mutation = {}; return VendorReputationDisposition::refused; } @@ -759,10 +717,10 @@ VendorReputationDisposition prepare_vendor_reward(std::uint16_t vendorIndex, * @param refusal Receives the first failed guard, when requested. * @return Unrelated sales are not applicable; unsupported or unaffordable rewards are refused. */ -VendorReputationDisposition prepare_vendor_reward_sale(std::uint16_t vendorIndex, - std::uint16_t saleIndex, - PendingRecordRewardGrant& mutation, - const char** refusal) noexcept { +VendorReputationDisposition prepare_vendor_rank_reward_sale(std::uint16_t vendorIndex, + std::uint16_t saleIndex, + PendingRecordRewardGrant& mutation, + const char** refusal) noexcept { const char* unused = nullptr; auto& reason = refusal != nullptr ? *refusal : unused; reason = "reward_binding"; @@ -776,7 +734,7 @@ VendorReputationDisposition prepare_vendor_reward_sale(std::uint16_t vendorIndex reason = "rank_credit"; std::int32_t credits = 0; if (!investment::store::read_unlock( - investment::store::Bank::characterObjectValues, binding.rewardValueRow, credits) + investment::store::Bank::characterObjectValues, binding.rankCreditRow, credits) || credits <= 0) { return VendorReputationDisposition::refused; } @@ -797,7 +755,7 @@ VendorReputationDisposition prepare_vendor_reward_sale(std::uint16_t vendorIndex mutation.vendorReward.itemIndex = binding.itemIndex; mutation.vendorReward.poolIndex = binding.poolIndex; mutation.vendorReward.interactionIndex = binding.interaction.index; - mutation.vendorReward.rewardValueRow = binding.rewardValueRow; + mutation.vendorReward.rankCreditRow = binding.rankCreditRow; return VendorReputationDisposition::prepared; } @@ -807,7 +765,7 @@ VendorReputationDisposition prepare_vendor_reward_sale(std::uint16_t vendorIndex * @param claim Prepared claim state; zero credits mean no vendor claim. * @return False for a stale credit or changed package gate. */ -bool vendor_reward_current(const VendorRewardClaim& claim) noexcept { +bool vendor_rank_reward_current(const VendorRankRewardClaim& claim) noexcept { if (claim.beforeCredits == 0) { return true; } @@ -818,7 +776,7 @@ bool vendor_reward_current(const VendorRewardClaim& claim) noexcept { != VendorReputationDisposition::prepared || !same_reward_binding(claim, binding) || !reward_gates_pass(binding) || !investment::store::read_unlock( - investment::store::Bank::characterObjectValues, claim.rewardValueRow, current) + investment::store::Bank::characterObjectValues, claim.rankCreditRow, current) || current != claim.beforeCredits) { return false; } diff --git a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h index 773a9117c..900a471f8 100644 --- a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h +++ b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h @@ -15,11 +15,11 @@ struct VendorReputationAward { std::uint32_t costQuantity{}; std::int32_t experience{}; std::uint16_t progressionIndex{}; - /** Character-object value row; used only when rankStepCount is positive. */ - std::uint16_t rewardValueRow{}; + /** Saved rank-credit row; used only when rankStepCount is positive. */ + std::uint16_t rankCreditRow{}; /** Installed rank costs captured for commit revalidation. */ std::array rankStepCosts{}; - /** Zero preserves XP-only handling for factions without a supported reward rule. */ + /** Zero preserves XP-only handling for factions without a rank-claim link. */ std::size_t rankStepCount{}; /** Build-86657 progression metadata marks these faction ladders as repeating. */ bool repeatLastStep{}; @@ -31,7 +31,7 @@ struct PendingVendorReputation { std::array beforeItems{}; unlocks::ProgressionLanes beforeProgression{}; - std::int32_t beforeRewardCredits{}; + std::int32_t beforeRankCredits{}; VendorReputationAward award{}; std::uint64_t accountSoid{}; std::uint64_t characterSoid{}; @@ -47,7 +47,7 @@ struct PendingVendorReputation { [[nodiscard]] bool commit_vendor_reputation(PendingVendorReputation& mutation) noexcept; /** Positive credits identify a prepared claim; zero leaves ordinary rewards unchanged. */ -struct VendorRewardClaim { +struct VendorRankRewardClaim { std::int32_t beforeCredits{}; std::uint16_t vendorIndex{}; std::uint16_t saleIndex{}; @@ -56,21 +56,21 @@ struct VendorRewardClaim { std::uint16_t itemIndex{}; std::uint16_t poolIndex{}; std::uint16_t interactionIndex{}; - /** Rechecked against the vendor binding before preview or commit. */ - std::uint16_t rewardValueRow{}; + /** Saved rank-credit row rechecked before preview or commit. */ + std::uint16_t rankCreditRow{}; }; struct PendingRecordRewardGrant; [[nodiscard]] VendorReputationDisposition -prepare_vendor_reward_sale(std::uint16_t vendorIndex, - std::uint16_t saleIndex, - PendingRecordRewardGrant& mutation, - const char** refusal = nullptr) noexcept; +prepare_vendor_rank_reward_sale(std::uint16_t vendorIndex, + std::uint16_t saleIndex, + PendingRecordRewardGrant& mutation, + const char** refusal = nullptr) noexcept; [[nodiscard]] VendorReputationDisposition -prepare_vendor_reward(std::uint16_t vendorIndex, - std::uint16_t interactionIndex, - std::uint16_t replyIndex, - PendingRecordRewardGrant& mutation) noexcept; -[[nodiscard]] bool vendor_reward_current(const VendorRewardClaim& claim) noexcept; +prepare_vendor_rank_reward_interaction(std::uint16_t vendorIndex, + std::uint16_t interactionIndex, + std::uint16_t replyIndex, + PendingRecordRewardGrant& mutation) noexcept; +[[nodiscard]] bool vendor_rank_reward_current(const VendorRankRewardClaim& claim) noexcept; } // namespace sunrise::state From 09fac1413eaa2f3a3ec3ef843728b270b3dfd47b Mon Sep 17 00:00:00 2001 From: Zach Date: Tue, 22 Sep 2026 22:46:25 -0500 Subject: [PATCH 32/59] 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 33/59] 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 34/59] 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 35/59] 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 36/59] 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 37/59] 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 38/59] 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 39/59] 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 40/59] 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 41/59] 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 42/59] 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 43/59] 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; From 6db3abba311dc8f2ec0043c7a13f8e9f6863109b Mon Sep 17 00:00:00 2001 From: Gage Fulwood Date: Thu, 15 Oct 2026 12:42:09 -0500 Subject: [PATCH 44/59] Support canonical native inverted expression in credit gate evaluator --- .../state_vendor_reputation_runtime.cpp | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp index fd00410b2..bccc0be43 100644 --- a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp +++ b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp @@ -171,12 +171,25 @@ CreditGateKind credit_gate(const build_data::vendors::InteractionGate& interacti row = 0; const auto program = std::span{interaction.condition.program.instructions}.first( interaction.condition.program.count); - if (program.size() < 2 || program[0].opcode != vendors::Opcode::loadValue - || program[1].opcode != vendors::Opcode::constant || program[1].operand != 0) { + if (program.empty() || program[0].opcode != vendors::Opcode::loadValue) { return CreditGateKind::unrelated; } - if (program.size() < 3 || program[0].operand > (std::numeric_limits::max)() - || program[2].opcode != vendors::Opcode::greaterThan) { + std::size_t prefixLength = 0; + if (program.size() >= 3 && program[1].opcode == vendors::Opcode::constant + && program[1].operand == 0 && program[2].opcode == vendors::Opcode::greaterThan) { + prefixLength = 3; + } else if (program.size() >= 4 && program[1].opcode == vendors::Opcode::constant + && program[1].operand == 1 && program[2].opcode == vendors::Opcode::lessThan + && program[3].opcode == vendors::Opcode::logicalNot) { + prefixLength = 4; + } else if (program.size() >= 2 && program[1].opcode == vendors::Opcode::constant + && (program[1].operand == 0 || program[1].operand == 1)) { + return CreditGateKind::malformed; + } else { + return CreditGateKind::unrelated; + } + + if (program[0].operand > (std::numeric_limits::max)()) { return CreditGateKind::malformed; } const auto slot = static_cast(program[0].operand); @@ -197,7 +210,7 @@ CreditGateKind credit_gate(const build_data::vendors::InteractionGate& interacti return CreditGateKind::malformed; } row = found->row; - const auto remainder = program.subspan(3); + const auto remainder = program.subspan(prefixLength); if (std::any_of(remainder.begin(), remainder.end(), [](const auto& instruction) { return instruction.opcode == vendors::Opcode::lessThan || instruction.opcode == vendors::Opcode::lessOrEqual; From 44392da1b678873b6644db65e591174d108103cf Mon Sep 17 00:00:00 2001 From: Gage Fulwood Date: Thu, 15 Oct 2026 15:53:08 -0500 Subject: [PATCH 45/59] Track answered vendor interactions and settle legacy campaign overrides --- .../bap/encrypted/body/bap_service_body.cpp | 4 ++ Sunrise/src/server/bap/encrypted/internal.h | 8 +++ .../transactions/service_outcome_commit.cpp | 13 +++++ .../server/web_service/web_service_runtime.h | 3 + .../web_service_vendor_actions.cpp | 19 +++++++ .../state_account_acquisition_runtime.cpp | 55 ++++++++++++++++++- .../state_account_reward_grant_runtime.cpp | 7 +++ Sunrise/src/state/runtime/state_runtime.cpp | 2 + .../state_vendor_reputation_runtime.cpp | 47 ++++++++++++++++ .../runtime/state_vendor_reputation_runtime.h | 9 +++ 10 files changed, 166 insertions(+), 1 deletion(-) 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 b9c9519fe..34c8a142f 100644 --- a/Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp +++ b/Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp @@ -478,6 +478,8 @@ bool process(const ServiceRoute& route, } transaction->pending = web_service::take_mutation(webOutcome); + transaction->answeredVendor = webOutcome.answeredVendor; + transaction->answeredInteraction = webOutcome.answeredInteraction; } if (profileItemAcquisition != nullptr) { // Actionable profile stacks may add a resident in the same revision. @@ -510,6 +512,8 @@ bool process(const ServiceRoute& route, } transaction->pending = web_service::take_mutation(webOutcome); + transaction->answeredVendor = webOutcome.answeredVendor; + transaction->answeredInteraction = webOutcome.answeredInteraction; } if (itemDismantle != nullptr) { // Promise the revision carrying the character update and resident release. diff --git a/Sunrise/src/server/bap/encrypted/internal.h b/Sunrise/src/server/bap/encrypted/internal.h index 479193418..844dea6ca 100644 --- a/Sunrise/src/server/bap/encrypted/internal.h +++ b/Sunrise/src/server/bap/encrypted/internal.h @@ -106,6 +106,10 @@ struct ItemAcquisitionTransaction { std::unique_ptr pending{}; queuez::ItemAcquisition update{}; + + std::int32_t answeredVendor{-1}; + + std::int32_t answeredInteraction{-1}; }; /** Profile acquisition and its exact account/resident QueueZ after-image. */ @@ -115,6 +119,10 @@ struct ProfileItemAcquisitionTransaction { std::unique_ptr pending{}; queuez::ProfileItemAcquisition update{}; + + std::int32_t answeredVendor{-1}; + + std::int32_t answeredInteraction{-1}; }; /** Dismantle mutation and its exact QueueZ after-image. */ diff --git a/Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp b/Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp index 00d816f11..f5b9022bb 100644 --- a/Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp +++ b/Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp @@ -8,6 +8,7 @@ #include "../../../../state/activity/runtime.h" #include "../../../../state/matchmaking/matchmaking_state.h" #include "../../../../state/runtime/runtime.h" +#include "../../../../state/runtime/state_vendor_reputation_runtime.h" #include "../bap_connection_publication.h" #include "../internal.h" @@ -276,6 +277,12 @@ bool commit(ServiceOutcome& outcome, Publication& publication, const char*& reas if (auto* transaction = transaction_if(outcome)) { const bool committed = transaction->pending != nullptr && state::commit_item_acquisition(*transaction->pending); + if (committed && transaction->answeredVendor >= 0 + && transaction->answeredInteraction >= 0) { + state::mark_vendor_interaction_answered( + static_cast(transaction->answeredVendor), + static_cast(transaction->answeredInteraction)); + } core::log::write(core::log::Channel::server, committed ? core::log::Level::debug : core::log::Level::warn, committed ? "ev=acquire stage=transaction_commit result=ok" @@ -311,6 +318,12 @@ bool commit(ServiceOutcome& outcome, Publication& publication, const char*& reas if (auto* transaction = transaction_if(outcome)) { const bool committed = transaction->pending != nullptr && state::commit_profile_item_acquisition(*transaction->pending); + if (committed && transaction->answeredVendor >= 0 + && transaction->answeredInteraction >= 0) { + state::mark_vendor_interaction_answered( + static_cast(transaction->answeredVendor), + static_cast(transaction->answeredInteraction)); + } core::log::write(core::log::Channel::server, committed ? core::log::Level::debug : core::log::Level::warn, committed ? "ev=profile_acquire stage=transaction_commit result=ok" diff --git a/Sunrise/src/server/web_service/web_service_runtime.h b/Sunrise/src/server/web_service/web_service_runtime.h index a197ec7bf..228f43aad 100644 --- a/Sunrise/src/server/web_service/web_service_runtime.h +++ b/Sunrise/src/server/web_service/web_service_runtime.h @@ -32,6 +32,9 @@ struct Outcome { state::ArtifactResetResult artifactReset{}; /** A WS-701 carried the profile-setup completion marker and State refused it. */ bool profileSetupRefused{}; + /** Answered vendor interaction for this action session (-1 if none). */ + std::int32_t answeredVendor{-1}; + std::int32_t answeredInteraction{-1}; /** A request prepares at most one State mutation and allocates only that exact payload. */ using Mutation = std::variant, diff --git a/Sunrise/src/server/web_service/web_service_vendor_actions.cpp b/Sunrise/src/server/web_service/web_service_vendor_actions.cpp index 07e5cea7d..65ab3f227 100644 --- a/Sunrise/src/server/web_service/web_service_vendor_actions.cpp +++ b/Sunrise/src/server/web_service/web_service_vendor_actions.cpp @@ -20,6 +20,7 @@ #include "../../state/build_data/vendors/repeatable_triggers.h" #include "../../state/build_data/vendors/vendor_catalog.h" #include "../../state/runtime/runtime.h" +#include "../../state/runtime/state_vendor_reputation_runtime.h" #include "internal_actions.h" #include "web_service_actions.h" @@ -849,6 +850,20 @@ void acquire_quest(const middleware::web_service::Message& message, Outcome& out // A row of -1 says the tile is not a sale row at all, so the installed array answers it. // Reading the slot as a sale row here would grant whatever sits at that row. const bool rowless = row < 0; + if (rowless && request.vendorIndex >= 0 + && request.vendorIndex <= (std::numeric_limits::max)() + && request.slotIndex >= 0 + && request.slotIndex <= (std::numeric_limits::max)() + && state::is_vendor_interaction_answered(static_cast(request.vendorIndex), + static_cast(request.slotIndex))) { + report_purchase(quest::kOpcode, + "fail", + "interaction_answered", + request.vendorIndex, + row, + kUnavailableDefinitionIndex); + return; + } // A rowless 904 is an interaction reply, so its slot names the interaction, not a sale row: // it has no category and no price. state::build_data::vendors::SaleRow sale{}; @@ -870,6 +885,10 @@ void acquire_quest(const middleware::web_service::Message& message, Outcome& out return; } settle_vendor_row(message, quest::kOpcode, request.vendorIndex, row, sale, outcome); + if (rowless) { + outcome.answeredVendor = request.vendorIndex; + outcome.answeredInteraction = request.slotIndex; + } } /** diff --git a/Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp b/Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp index 65fd12f2e..7f337f6d3 100644 --- a/Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp +++ b/Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp @@ -397,6 +397,53 @@ bool preview_item_acquisition(const PendingItemAcquisition& mutation, return true; } +namespace { + +constexpr std::uint32_t kRedWarHomecomingHash = 0x37DD26F0U; +constexpr std::uint32_t kCurseOfOsirisGatewayHash = 0x6706D3ECU; +constexpr std::uint32_t kWarmindIceAndShadowHash = 0xF5B78E7FU; + +constexpr std::uint16_t kRedWarValueSlot = 12578; +constexpr std::uint16_t kCurseOfOsirisValueSlot = 12609; +constexpr std::uint16_t kWarmindValueSlot = 12624; + +[[nodiscard]] std::uint16_t legacy_campaign_value_slot(std::uint32_t definitionHash) noexcept { + switch (definitionHash) { + case kRedWarHomecomingHash: + return kRedWarValueSlot; + case kCurseOfOsirisGatewayHash: + return kCurseOfOsirisValueSlot; + case kWarmindIceAndShadowHash: + return kWarmindValueSlot; + default: + return 0; + } +} + +[[nodiscard]] bool settle_legacy_content_override(std::uint32_t definitionHash) noexcept { + const auto slot = legacy_campaign_value_slot(definitionHash); + if (slot == 0) { + return true; + } + Family5State family{}; + if (!investment::store::read_family5(family)) { + return false; + } + for (std::size_t i = 0; i < family.valueCount; ++i) { + if (family.values[i].slot == slot) { + family.values[i].value = 1; + return investment::store::write_family5(family); + } + } + if (family.valueCount < family.values.size()) { + family.values[family.valueCount++] = {slot, 1}; + return investment::store::write_family5(family); + } + return false; +} + +} // namespace + /** * Inventory and first-step state share one transaction; failure rolls both back. * @param mutation Prepared grant consumed on either success or failure. @@ -418,6 +465,9 @@ bool commit_item_acquisition(PendingItemAcquisition& mutation) noexcept { && !investment::store::write_unlock(quest_bank(quest), quest.row, quest.value)) { return false; } + if (!settle_legacy_content_override(prepared.acquiredDefinitionHash)) { + return false; + } return transaction.commit(); } @@ -650,7 +700,10 @@ bool commit_profile_item_acquisition(PendingProfileItemAcquisition& mutation) no const bool ready = materialize_profile_acquisition(investment::store::account(), prepared, candidate); if (ready) { - if (!investment::store::write_account(candidate)) { + investment::store::Transaction transaction; + if (!transaction.ready() || !investment::store::write_account(candidate) + || !settle_legacy_content_override(prepared.acquiredDefinitionHash) + || !transaction.commit()) { investment::store::g_mutex.unlock(); return false; } 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 ccdb20210..535893307 100644 --- a/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp +++ b/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp @@ -16,6 +16,7 @@ #include "../unlocks/unlocks_records.h" #include "runtime.h" #include "state_account_transaction_helpers.h" +#include "state_vendor_reputation_runtime.h" #include "storage/internal.h" namespace sunrise::state { @@ -954,6 +955,12 @@ bool commit_record_reward(PendingRecordRewardGrant& mutation) noexcept { } ready = ready && transaction.commit(); } + if (ready + && (mutation.vendorReward.vendorIndex != 0 + || mutation.vendorReward.interactionIndex != 0)) { + state::mark_vendor_interaction_answered(mutation.vendorReward.vendorIndex, + mutation.vendorReward.interactionIndex); + } 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_runtime.cpp b/Sunrise/src/state/runtime/state_runtime.cpp index 61e9dbe34..bf67069bd 100644 --- a/Sunrise/src/state/runtime/state_runtime.cpp +++ b/Sunrise/src/state/runtime/state_runtime.cpp @@ -23,6 +23,7 @@ #include "runtime.h" #include "state.h" #include "state_account_transaction_helpers.h" +#include "state_vendor_reputation_runtime.h" #include "storage/internal.h" namespace sunrise::state { @@ -351,6 +352,7 @@ bool publish_bootstrap_token(std::span token) noexcept { /** Records when the account signed in, on every character the account owns. */ void publish_sign_in_time(std::uint64_t seconds) noexcept { + reset_answered_vendor_interactions(); investment::store::set_sign_in_time(seconds); } diff --git a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp index bccc0be43..aab682c79 100644 --- a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp +++ b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp @@ -2,8 +2,10 @@ #include #include +#include #include +#include "../../core/threading/srw_lock.h" #include "../../middleware/crypto/random_bytes.h" #include "../build_data/progressions/progression_catalog.h" #include "../build_data/vendors/reconstructed_rank_claim_links.h" @@ -19,6 +21,17 @@ namespace { /** Native progression level walks read experience from lane zero. */ constexpr std::size_t kExperienceLane = 0; +/** Session-scoped answered vendor interactions. */ +constexpr std::size_t kAnsweredInteractionsCapacity = 256; +std::array g_answeredInteractions{}; +std::size_t g_answeredInteractionsCount = 0; +core::threading::SrwLock g_answeredInteractionsLock; + +[[nodiscard]] std::uint32_t pack_answered_interaction(std::uint16_t vendorIndex, + std::uint16_t interactionIndex) noexcept { + return (static_cast(vendorIndex) << 16) | interactionIndex; +} + /** Saved unlocks and Family-5 overrides visible to one native vendor gate. */ struct GateValues { const build_data::vendors::Gate& gate; @@ -779,6 +792,9 @@ prepare_vendor_rank_reward_interaction(std::uint16_t vendorIndex, std::uint16_t replyIndex, PendingRecordRewardGrant& mutation) noexcept { mutation = {}; + if (is_vendor_interaction_answered(vendorIndex, interactionIndex)) { + return VendorReputationDisposition::refused; + } build_data::vendors::IndexEntry entry{}; if (!build_data::vendors::find_index(vendorIndex, entry)) { return VendorReputationDisposition::notApplicable; @@ -847,4 +863,35 @@ bool vendor_rank_reward_current(const VendorRankRewardClaim& claim) noexcept { return true; } +void mark_vendor_interaction_answered(std::uint16_t vendorIndex, + std::uint16_t interactionIndex) noexcept { + const auto packed = pack_answered_interaction(vendorIndex, interactionIndex); + std::unique_lock lock(g_answeredInteractionsLock); + for (std::size_t i = 0; i < g_answeredInteractionsCount; ++i) { + if (g_answeredInteractions[i] == packed) { + return; + } + } + if (g_answeredInteractionsCount < g_answeredInteractions.size()) { + g_answeredInteractions[g_answeredInteractionsCount++] = packed; + } +} + +bool is_vendor_interaction_answered(std::uint16_t vendorIndex, + std::uint16_t interactionIndex) noexcept { + const auto packed = pack_answered_interaction(vendorIndex, interactionIndex); + std::shared_lock lock(g_answeredInteractionsLock); + for (std::size_t i = 0; i < g_answeredInteractionsCount; ++i) { + if (g_answeredInteractions[i] == packed) { + return true; + } + } + return false; +} + +void reset_answered_vendor_interactions() noexcept { + std::unique_lock lock(g_answeredInteractionsLock); + g_answeredInteractionsCount = 0; +} + } // namespace sunrise::state diff --git a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h index c5a2ca422..f385d2344 100644 --- a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h +++ b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h @@ -77,4 +77,13 @@ prepare_vendor_rank_reward_interaction(std::uint16_t vendorIndex, PendingRecordRewardGrant& mutation) noexcept; [[nodiscard]] bool vendor_rank_reward_current(const VendorRankRewardClaim& claim) noexcept; +/** Marks an interaction answered for the current session. */ +void mark_vendor_interaction_answered(std::uint16_t vendorIndex, + std::uint16_t interactionIndex) noexcept; +/** Returns true if the interaction was already answered during this session. */ +[[nodiscard]] bool is_vendor_interaction_answered(std::uint16_t vendorIndex, + std::uint16_t interactionIndex) noexcept; +/** Clears all session-scoped answered interactions. */ +void reset_answered_vendor_interactions() noexcept; + } // namespace sunrise::state From 081743fc6fb67534937e6c4f6fb962717b6a876c Mon Sep 17 00:00:00 2001 From: Gage Fulwood Date: Thu, 15 Oct 2026 16:03:42 -0500 Subject: [PATCH 46/59] Prioritize 20 key vendor definitions before sequential catalog fill --- .../content/vendors/package_vendor_build.cpp | 130 ++++++++++++++++-- 1 file changed, 122 insertions(+), 8 deletions(-) diff --git a/Sunrise/src/client/content/vendors/package_vendor_build.cpp b/Sunrise/src/client/content/vendors/package_vendor_build.cpp index 879fca423..859a41aaf 100644 --- a/Sunrise/src/client/content/vendors/package_vendor_build.cpp +++ b/Sunrise/src/client/content/vendors/package_vendor_build.cpp @@ -1,8 +1,10 @@ +#include #include #include #include #include #include +#include #include #include @@ -27,6 +29,43 @@ struct ArrayView { std::uint16_t count{}; }; +/** + * 20 priority vendor hashes (re_report.pdf §70.25.1) guaranteed to be loaded + * before arbitrary sequential fill so that critical gameplay vendors are never + * crowded out by high-index catalog entries. + */ +constexpr std::array kPriorityVendors = { + 0x0ED2CB2FU, // The Drifter + 0x280FB4FDU, // Banshee-44 + 0x04243655U, // Commander Zavala + 0xD6C4CCA1U, // Lord Shaxx + 0x86748412U, // Master Rahool + 0x1B731E4FU, // Amanda Holliday + 0xADE600F9U, // Ada-1 + 0x2D9E6DC1U, // Saint-14 + 0x36D32C3CU, // Eva Levante + 0xEE0F473EU, // Prismatic Recaster + 0x6B147C2DU, // Yuna + 0x17A817DEU, // Devrim Kay + 0x3F59FB01U, // Sloane + 0x5DF413A9U, // Failsafe + 0xED6345FDU, // Asher Mir + 0x8EF4CCBAU, // Brother Vance + 0x6770811DU, // Ana Bray + 0x6053823DU, // Eris Morn + 0x337EAF04U, // The Spider + 0x6DC6627CU // Petra Venj +}; + +[[nodiscard]] constexpr bool is_priority_vendor(std::uint32_t hash) noexcept { + for (const auto priority : kPriorityVendors) { + if (priority == hash) { + return true; + } + } + return false; +} + /** Every extracted row, kept off the caller stack. */ struct Storage { std::vector blob{}; @@ -36,6 +75,9 @@ struct Storage { std::array definitions{}; std::array saleRows{}; std::array installedRows{}; + std::size_t maxDefinitions{domain::kDefinitionCapacity}; + std::size_t maxSaleRows{domain::kSaleRowCapacity}; + std::size_t maxInstalledRows{domain::kInstalledRowCapacity}; std::size_t indexCount{}; std::size_t definitionCount{}; std::size_t saleRowCount{}; @@ -197,7 +239,7 @@ read_index(const reader::Source& source, reader::Scratch& scratch, Storage& stor [[nodiscard]] bool read_sale_rows(std::span blob, const domain::Definition& definition, Storage& storage) noexcept { - if (definition.saleCount > domain::kSaleRowCapacity - storage.saleRowCount) { + if (definition.saleCount > storage.maxSaleRows - storage.saleRowCount) { return false; } for (std::size_t row = 0; row < definition.saleCount; ++row) { @@ -235,7 +277,7 @@ read_index(const reader::Source& source, reader::Scratch& scratch, Storage& stor [[nodiscard]] bool read_installed_rows(std::span blob, const domain::Definition& definition, Storage& storage) noexcept { - if (definition.installedCount > domain::kInstalledRowCapacity - storage.installedRowCount) { + if (definition.installedCount > storage.maxInstalledRows - storage.installedRowCount) { return false; } for (std::size_t row = 0; row < definition.installedCount; ++row) { @@ -265,7 +307,7 @@ read_index(const reader::Source& source, reader::Scratch& scratch, Storage& stor /** Definition sizes are stored as unsigned 32-bit values. */ constexpr std::size_t kMaximumSize = (std::numeric_limits::max)(); std::uint32_t classId = 0; - if (storage.definitionCount == domain::kDefinitionCapacity + if (storage.definitionCount >= storage.maxDefinitions || !reader::read_tag(source, scratch, entry.definitionTag, storage.blob, classId) || classId != domain::kDefinitionClass || storage.blob.size() > kMaximumSize) { return false; @@ -382,12 +424,45 @@ bool build(const reader::Source& source, reader::Scratch& scratch) noexcept { report(storage, 0, "index"); return false; } - // Walk the index in order: the catalog requires ascending definition order. A definition that - // will not read or will not fit costs that vendor alone, never the whole pass. + + auto priorityStorage = std::make_unique(); + priorityStorage->factionBlob = storage.factionBlob; + priorityStorage->factionRows = storage.factionRows; + std::size_t skipped = 0; + // Pass 1: Extract priority vendor definitions first to guarantee capacity. + for (std::size_t row = 0; row < storage.indexCount; ++row) { + const domain::IndexEntry entry = storage.index[row]; + if (!is_priority_vendor(entry.definitionHash)) { + continue; + } + if (read_definition(source, scratch, entry, *priorityStorage)) { + continue; + } + ++skipped; + core::log::writef(core::log::Channel::state, + core::log::Level::warn, + "ev=build_data stage=vendors result=skip_priority hash=0x%08X row=%zu", + entry.definitionHash, + row); + } + + // Pass 2: Fill remaining capacity with non-priority vendors. + auto sequentialStorage = std::make_unique(); + sequentialStorage->factionBlob = storage.factionBlob; + sequentialStorage->factionRows = storage.factionRows; + sequentialStorage->maxDefinitions = + domain::kDefinitionCapacity - priorityStorage->definitionCount; + sequentialStorage->maxSaleRows = domain::kSaleRowCapacity - priorityStorage->saleRowCount; + sequentialStorage->maxInstalledRows = + domain::kInstalledRowCapacity - priorityStorage->installedRowCount; + for (std::size_t row = 0; row < storage.indexCount; ++row) { const domain::IndexEntry entry = storage.index[row]; - if (read_definition(source, scratch, entry, storage)) { + if (is_priority_vendor(entry.definitionHash)) { + continue; + } + if (read_definition(source, scratch, entry, *sequentialStorage)) { continue; } ++skipped; @@ -397,9 +472,48 @@ bool build(const reader::Source& source, reader::Scratch& scratch) noexcept { "definitions=%zu sale=%zu", entry.definitionHash, row, - storage.definitionCount, - storage.saleRowCount); + priorityStorage->definitionCount + sequentialStorage->definitionCount, + priorityStorage->saleRowCount + sequentialStorage->saleRowCount); } + + // Pass 3: Merge priority and sequential vendors in strictly ascending index order. + std::size_t priorityIdx = 0; + std::size_t sequentialIdx = 0; + while (priorityIdx < priorityStorage->definitionCount + || sequentialIdx < sequentialStorage->definitionCount) { + const bool takePriority = (sequentialIdx >= sequentialStorage->definitionCount) + || (priorityIdx < priorityStorage->definitionCount + && priorityStorage->definitions[priorityIdx].index + < sequentialStorage->definitions[sequentialIdx].index); + + Storage& src = takePriority ? *priorityStorage : *sequentialStorage; + std::size_t& srcIdx = takePriority ? priorityIdx : sequentialIdx; + + domain::Definition def = src.definitions[srcIdx]; + const std::size_t srcSaleOffset = def.saleRowOffset; + const std::size_t srcInstalledOffset = def.installedRowOffset; + + def.saleRowOffset = static_cast(storage.saleRowCount); + def.installedRowOffset = static_cast(storage.installedRowCount); + + if (def.saleCount > 0) { + std::copy_n(&src.saleRows[srcSaleOffset], + def.saleCount, + &storage.saleRows[storage.saleRowCount]); + storage.saleRowCount += def.saleCount; + } + if (def.installedCount > 0) { + std::copy_n(&src.installedRows[srcInstalledOffset], + def.installedCount, + &storage.installedRows[storage.installedRowCount]); + storage.installedRowCount += def.installedCount; + } + storage.definitions[storage.definitionCount++] = def; + ++srcIdx; + } + storage.conditionalRows = priorityStorage->conditionalRows + sequentialStorage->conditionalRows; + storage.unreadableRows = priorityStorage->unreadableRows + sequentialStorage->unreadableRows; + const bool published = state::build_data::publish_vendor_catalog( std::span(storage.index).first(storage.indexCount), std::span(storage.definitions).first(storage.definitionCount), From 8aea0afa609cd663c17676b7929332ce67184989 Mon Sep 17 00:00:00 2001 From: Gage Fulwood Date: Thu, 15 Oct 2026 16:09:14 -0500 Subject: [PATCH 47/59] Resolve vendor rank reward replies dynamically with static fallback --- .../state_vendor_reputation_runtime.cpp | 99 ++++++++++++++++--- 1 file changed, 86 insertions(+), 13 deletions(-) diff --git a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp index aab682c79..ece59f569 100644 --- a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp +++ b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp @@ -504,6 +504,24 @@ VendorReputationDisposition resolve_reputation_turn_in(std::uint16_t vendorIndex if (!reconstructed_credit_gate(*link, interaction, award.rankCreditRow)) { return VendorReputationDisposition::refused; } + } else { + std::uint16_t dynamicCreditRow = 0; + std::size_t claimCount = 0; + for (std::size_t index = 0; index < vendor.thirdCount; ++index) { + vendors::InteractionGate candidate{}; + if (!vendors::find_interaction_gate( + vendor.definitionHash, static_cast(index), candidate)) { + continue; + } + std::uint16_t candidateRow = 0; + if (credit_gate(candidate, candidateRow) == CreditGateKind::claim) { + dynamicCreditRow = candidateRow; + ++claimCount; + } + } + if (claimCount == 1) { + award.rankCreditRow = dynamicCreditRow; + } } award.repeatLastStep = progression.repeatLastStep; if (award.rankCreditRow != 0) { @@ -799,24 +817,79 @@ prepare_vendor_rank_reward_interaction(std::uint16_t vendorIndex, if (!build_data::vendors::find_index(vendorIndex, entry)) { return VendorReputationDisposition::notApplicable; } - const auto* link = - build_data::vendors::find_reconstructed_rank_claim_link(entry.definitionHash); - if (link == nullptr || link->interactionIndex != interactionIndex) { - build_data::vendors::InteractionGate interaction{}; - std::uint16_t row = 0; - if (build_data::vendors::find_interaction_gate( - entry.definitionHash, interactionIndex, interaction) - && credit_gate(interaction, row) != CreditGateKind::unrelated) { - return VendorReputationDisposition::refused; + + build_data::vendors::InteractionGate interaction{}; + const bool hasInteraction = build_data::vendors::find_interaction_gate( + entry.definitionHash, interactionIndex, interaction); + + std::uint16_t interactionCreditRow = 0; + const auto gateKind = + hasInteraction ? credit_gate(interaction, interactionCreditRow) : CreditGateKind::unrelated; + + if (gateKind == CreditGateKind::warning || gateKind == CreditGateKind::malformed) { + return VendorReputationDisposition::refused; + } + + // Attempt dynamic reply matching by scanning the vendor's installed sales for a unique + // matching reward package within the interaction's category. + std::optional targetSaleIndex{}; + std::int32_t targetCategoryIndex = build_data::vendors::kAbsentCategoryIndex; + std::uint16_t targetInteractionIndex = interactionIndex; + + build_data::vendors::Definition vendor{}; + if (hasInteraction && gateKind == CreditGateKind::claim + && build_data::vendors::find(entry.definitionHash, vendor)) { + std::optional matchedSale{}; + std::size_t matchCount = 0; + for (std::size_t saleIdx = 0; saleIdx < vendor.saleCount; ++saleIdx) { + build_data::vendors::SaleRow sale{}; + if (!build_data::vendors::sale_row(vendor, static_cast(saleIdx), sale)) { + continue; + } + if (sale.categoryIndex != interaction.categoryIndex) { + continue; + } + if (sale.costCount != 0 || sale.priceState != build_data::vendors::PriceState::plain) { + continue; + } + RewardSaleBinding binding{}; + if (resolve_reward_sale(vendorIndex, static_cast(saleIdx), binding) + == VendorReputationDisposition::prepared + && binding.interaction.index == interactionIndex + && binding.categoryIndex == interaction.categoryIndex) { + matchedSale = static_cast(saleIdx); + ++matchCount; + } + } + if (matchCount == 1) { + targetSaleIndex = matchedSale; + targetCategoryIndex = interaction.categoryIndex; + targetInteractionIndex = interaction.index; + } + } + + // Fall back to reconstructed rank claim links if dynamic matching was unmapped or ambiguous. + if (!targetSaleIndex) { + const auto* link = + build_data::vendors::find_reconstructed_rank_claim_link(entry.definitionHash); + if (link != nullptr && link->interactionIndex == interactionIndex) { + targetSaleIndex = link->saleIndex; + targetCategoryIndex = link->categoryIndex; + targetInteractionIndex = link->interactionIndex; + } else { + if (gateKind != CreditGateKind::unrelated) { + return VendorReputationDisposition::refused; + } + return VendorReputationDisposition::notApplicable; } - return VendorReputationDisposition::notApplicable; } + const char* reason = nullptr; const auto disposition = - prepare_rank_reward(vendorIndex, link->saleIndex, replyIndex, mutation, reason); + prepare_rank_reward(vendorIndex, *targetSaleIndex, replyIndex, mutation, reason); if (disposition != VendorReputationDisposition::prepared - || mutation.vendorReward.interactionIndex != link->interactionIndex - || mutation.vendorReward.categoryIndex != link->categoryIndex) { + || mutation.vendorReward.interactionIndex != targetInteractionIndex + || mutation.vendorReward.categoryIndex != targetCategoryIndex) { mutation = {}; return VendorReputationDisposition::refused; } From 474fe0eb60af93fde547e15fc1b0da5088458f71 Mon Sep 17 00:00:00 2001 From: Gage Fulwood Date: Thu, 15 Oct 2026 16:17:40 -0500 Subject: [PATCH 48/59] Refine priority vendor catalog comment to match project style --- Sunrise/src/client/content/vendors/package_vendor_build.cpp | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Sunrise/src/client/content/vendors/package_vendor_build.cpp b/Sunrise/src/client/content/vendors/package_vendor_build.cpp index 859a41aaf..cfd42ea23 100644 --- a/Sunrise/src/client/content/vendors/package_vendor_build.cpp +++ b/Sunrise/src/client/content/vendors/package_vendor_build.cpp @@ -30,9 +30,8 @@ struct ArrayView { }; /** - * 20 priority vendor hashes (re_report.pdf §70.25.1) guaranteed to be loaded - * before arbitrary sequential fill so that critical gameplay vendors are never - * crowded out by high-index catalog entries. + * 20 core gameplay vendors guaranteed to be loaded before arbitrary sequential + * fill so that priority vendors are never crowded out by high-index catalog entries. */ constexpr std::array kPriorityVendors = { 0x0ED2CB2FU, // The Drifter From a1fdcd51c235a0b35cafe1125bc3de15cd434733 Mon Sep 17 00:00:00 2001 From: Gage Fulwood Date: Thu, 15 Oct 2026 16:46:45 -0500 Subject: [PATCH 49/59] Decompose vendor build catalog passes and eliminate heap allocations --- .../content/vendors/package_vendor_build.cpp | 184 +++++++++++------- 1 file changed, 119 insertions(+), 65 deletions(-) diff --git a/Sunrise/src/client/content/vendors/package_vendor_build.cpp b/Sunrise/src/client/content/vendors/package_vendor_build.cpp index cfd42ea23..37573d5f0 100644 --- a/Sunrise/src/client/content/vendors/package_vendor_build.cpp +++ b/Sunrise/src/client/content/vendors/package_vendor_build.cpp @@ -4,7 +4,6 @@ #include #include #include -#include #include #include @@ -57,7 +56,7 @@ constexpr std::array kPriorityVendors = { }; [[nodiscard]] constexpr bool is_priority_vendor(std::uint32_t hash) noexcept { - for (const auto priority : kPriorityVendors) { + for (const std::uint32_t priority : kPriorityVendors) { if (priority == hash) { return true; } @@ -403,39 +402,28 @@ void report(const Storage& storage, std::size_t skipped, const char* result) noe } } -} // namespace - -/** Extracts and publishes the vendor catalog from the installed packages. */ -bool build(const reader::Source& source, reader::Scratch& scratch) noexcept { - if (state::build_data::vendor_catalog_ready()) { - return true; - } - static Storage storage{}; - storage = {}; - if (!read_factions(source, scratch, storage)) { - // A missing faction table removes reputation links, not ordinary vendor sales. - storage.factionRows = {}; - core::log::write(core::log::Channel::state, - core::log::Level::warn, - "ev=build_data stage=vendors result=factions_unavailable"); - } - if (!read_index(source, scratch, storage)) { - report(storage, 0, "index"); - return false; - } - - auto priorityStorage = std::make_unique(); - priorityStorage->factionBlob = storage.factionBlob; - priorityStorage->factionRows = storage.factionRows; - +/** + * Reads all priority vendor definitions into the supplied pass storage. + * @param source Package directory and borrowed block keys. + * @param scratch Lock-owned block storage. + * @param index Full vendor index to scan. + * @param indexCount Number of valid entries in index. + * @param pass Pass storage pre-populated with faction data; receives definitions and rows. + * @return Number of priority entries that could not be read or fit. + */ +std::size_t +fill_priority_vendors(const reader::Source& source, + reader::Scratch& scratch, + const std::array& index, + std::size_t indexCount, + Storage& pass) noexcept { std::size_t skipped = 0; - // Pass 1: Extract priority vendor definitions first to guarantee capacity. - for (std::size_t row = 0; row < storage.indexCount; ++row) { - const domain::IndexEntry entry = storage.index[row]; + for (std::size_t row = 0; row < indexCount; ++row) { + const domain::IndexEntry entry = index[row]; if (!is_priority_vendor(entry.definitionHash)) { continue; } - if (read_definition(source, scratch, entry, *priorityStorage)) { + if (read_definition(source, scratch, entry, pass)) { continue; } ++skipped; @@ -445,23 +433,33 @@ bool build(const reader::Source& source, reader::Scratch& scratch) noexcept { entry.definitionHash, row); } + return skipped; +} - // Pass 2: Fill remaining capacity with non-priority vendors. - auto sequentialStorage = std::make_unique(); - sequentialStorage->factionBlob = storage.factionBlob; - sequentialStorage->factionRows = storage.factionRows; - sequentialStorage->maxDefinitions = - domain::kDefinitionCapacity - priorityStorage->definitionCount; - sequentialStorage->maxSaleRows = domain::kSaleRowCapacity - priorityStorage->saleRowCount; - sequentialStorage->maxInstalledRows = - domain::kInstalledRowCapacity - priorityStorage->installedRowCount; - - for (std::size_t row = 0; row < storage.indexCount; ++row) { - const domain::IndexEntry entry = storage.index[row]; +/** + * Reads non-priority vendor definitions into the supplied pass storage up to capacity limits. + * @param source Package directory and borrowed block keys. + * @param scratch Lock-owned block storage. + * @param index Full vendor index to scan. + * @param indexCount Number of valid entries in index. + * @param priorityPass Completed priority pass; its counts set the remaining capacity. + * @param pass Pass storage pre-populated with faction data and capped limits; receives definitions. + * @return Number of sequential entries that could not be read or fit. + */ +std::size_t +fill_sequential_vendors(const reader::Source& source, + reader::Scratch& scratch, + const std::array& index, + std::size_t indexCount, + const Storage& priorityPass, + Storage& pass) noexcept { + std::size_t skipped = 0; + for (std::size_t row = 0; row < indexCount; ++row) { + const domain::IndexEntry entry = index[row]; if (is_priority_vendor(entry.definitionHash)) { continue; } - if (read_definition(source, scratch, entry, *sequentialStorage)) { + if (read_definition(source, scratch, entry, pass)) { continue; } ++skipped; @@ -471,47 +469,103 @@ bool build(const reader::Source& source, reader::Scratch& scratch) noexcept { "definitions=%zu sale=%zu", entry.definitionHash, row, - priorityStorage->definitionCount + sequentialStorage->definitionCount, - priorityStorage->saleRowCount + sequentialStorage->saleRowCount); + priorityPass.definitionCount + pass.definitionCount, + priorityPass.saleRowCount + pass.saleRowCount); } + return skipped; +} - // Pass 3: Merge priority and sequential vendors in strictly ascending index order. +/** + * Merges two completed pass Storage objects into dest in strictly ascending catalog-index order. + * Offsets in each definition are rebased to the destination's running counts before insertion. + * @param priorityPass Completed priority-vendor pass. + * @param sequentialPass Completed sequential-vendor pass. + * @param dest Receives all definitions and rows in ascending index order. + */ +void merge_vendor_catalogs(const Storage& priorityPass, + const Storage& sequentialPass, + Storage& dest) noexcept { std::size_t priorityIdx = 0; std::size_t sequentialIdx = 0; - while (priorityIdx < priorityStorage->definitionCount - || sequentialIdx < sequentialStorage->definitionCount) { - const bool takePriority = (sequentialIdx >= sequentialStorage->definitionCount) - || (priorityIdx < priorityStorage->definitionCount - && priorityStorage->definitions[priorityIdx].index - < sequentialStorage->definitions[sequentialIdx].index); + while (priorityIdx < priorityPass.definitionCount + || sequentialIdx < sequentialPass.definitionCount) { + const bool takePriority = (sequentialIdx >= sequentialPass.definitionCount) + || (priorityIdx < priorityPass.definitionCount + && priorityPass.definitions[priorityIdx].index + < sequentialPass.definitions[sequentialIdx].index); - Storage& src = takePriority ? *priorityStorage : *sequentialStorage; + const Storage& src = takePriority ? priorityPass : sequentialPass; std::size_t& srcIdx = takePriority ? priorityIdx : sequentialIdx; domain::Definition def = src.definitions[srcIdx]; const std::size_t srcSaleOffset = def.saleRowOffset; const std::size_t srcInstalledOffset = def.installedRowOffset; - def.saleRowOffset = static_cast(storage.saleRowCount); - def.installedRowOffset = static_cast(storage.installedRowCount); + def.saleRowOffset = static_cast(dest.saleRowCount); + def.installedRowOffset = static_cast(dest.installedRowCount); if (def.saleCount > 0) { - std::copy_n(&src.saleRows[srcSaleOffset], - def.saleCount, - &storage.saleRows[storage.saleRowCount]); - storage.saleRowCount += def.saleCount; + std::copy_n( + &src.saleRows[srcSaleOffset], def.saleCount, &dest.saleRows[dest.saleRowCount]); + dest.saleRowCount += def.saleCount; } if (def.installedCount > 0) { std::copy_n(&src.installedRows[srcInstalledOffset], def.installedCount, - &storage.installedRows[storage.installedRowCount]); - storage.installedRowCount += def.installedCount; + &dest.installedRows[dest.installedRowCount]); + dest.installedRowCount += def.installedCount; } - storage.definitions[storage.definitionCount++] = def; + dest.definitions[dest.definitionCount++] = def; ++srcIdx; } - storage.conditionalRows = priorityStorage->conditionalRows + sequentialStorage->conditionalRows; - storage.unreadableRows = priorityStorage->unreadableRows + sequentialStorage->unreadableRows; + dest.conditionalRows = priorityPass.conditionalRows + sequentialPass.conditionalRows; + dest.unreadableRows = priorityPass.unreadableRows + sequentialPass.unreadableRows; +} + +} // namespace + +/** Extracts and publishes the vendor catalog from the installed packages. */ +bool build(const reader::Source& source, reader::Scratch& scratch) noexcept { + if (state::build_data::vendor_catalog_ready()) { + return true; + } + static Storage storage{}; + storage = {}; + if (!read_factions(source, scratch, storage)) { + // A missing faction table removes reputation links, not ordinary vendor sales. + storage.factionRows = {}; + core::log::write(core::log::Channel::state, + core::log::Level::warn, + "ev=build_data stage=vendors result=factions_unavailable"); + } + if (!read_index(source, scratch, storage)) { + report(storage, 0, "index"); + return false; + } + + // Pass 1: Extract priority vendor definitions first to guarantee capacity. + static Storage priorityStorage{}; + priorityStorage = {}; + priorityStorage.factionBlob = storage.factionBlob; + priorityStorage.factionRows = storage.factionRows; + std::size_t skipped = + fill_priority_vendors(source, scratch, storage.index, storage.indexCount, priorityStorage); + + // Pass 2: Fill remaining capacity with non-priority vendors. + static Storage sequentialStorage{}; + sequentialStorage = {}; + sequentialStorage.factionBlob = storage.factionBlob; + sequentialStorage.factionRows = storage.factionRows; + sequentialStorage.maxDefinitions = + domain::kDefinitionCapacity - priorityStorage.definitionCount; + sequentialStorage.maxSaleRows = domain::kSaleRowCapacity - priorityStorage.saleRowCount; + sequentialStorage.maxInstalledRows = + domain::kInstalledRowCapacity - priorityStorage.installedRowCount; + skipped += fill_sequential_vendors( + source, scratch, storage.index, storage.indexCount, priorityStorage, sequentialStorage); + + // Pass 3: Merge priority and sequential vendors in strictly ascending index order. + merge_vendor_catalogs(priorityStorage, sequentialStorage, storage); const bool published = state::build_data::publish_vendor_catalog( std::span(storage.index).first(storage.indexCount), From 4c9eceffae415cc323e70df286aeb3f40e631c4b Mon Sep 17 00:00:00 2001 From: Gage Fulwood Date: Thu, 15 Oct 2026 16:50:44 -0500 Subject: [PATCH 50/59] Decompose vendor reputation helpers and explicit-type gate values --- .../state_account_acquisition_runtime.cpp | 2 +- .../state_vendor_reputation_runtime.cpp | 138 +++++++++++------- 2 files changed, 90 insertions(+), 50 deletions(-) diff --git a/Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp b/Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp index 7f337f6d3..c14f24f2c 100644 --- a/Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp +++ b/Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp @@ -421,7 +421,7 @@ constexpr std::uint16_t kWarmindValueSlot = 12624; } [[nodiscard]] bool settle_legacy_content_override(std::uint32_t definitionHash) noexcept { - const auto slot = legacy_campaign_value_slot(definitionHash); + const std::uint16_t slot = legacy_campaign_value_slot(definitionHash); if (slot == 0) { return true; } diff --git a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp index ece59f569..d90435e1d 100644 --- a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp +++ b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp @@ -223,11 +223,12 @@ CreditGateKind credit_gate(const build_data::vendors::InteractionGate& interacti return CreditGateKind::malformed; } row = found->row; - const auto remainder = program.subspan(prefixLength); - if (std::any_of(remainder.begin(), remainder.end(), [](const auto& instruction) { - return instruction.opcode == vendors::Opcode::lessThan - || instruction.opcode == vendors::Opcode::lessOrEqual; - })) { + const std::span remainder = program.subspan(prefixLength); + if (std::any_of( + remainder.begin(), remainder.end(), [](const unlocks::Instruction& instruction) { + return instruction.opcode == vendors::Opcode::lessThan + || instruction.opcode == vendors::Opcode::lessOrEqual; + })) { return CreditGateKind::warning; } return CreditGateKind::claim; @@ -327,6 +328,36 @@ bool find_rank_claim_interaction(const build_data::vendors::Definition& vendor, return found; } +/** + * Scans third-array interaction gates for a unique credit claim row when no link is reconstructed. + * @param vendor Installed vendor definition. + * @param creditRow Receives the saved character credit row on unique resolution. + * @return True when exactly one claim gate is found. + */ +bool find_dynamic_rank_credit_row(const build_data::vendors::Definition& vendor, + std::uint16_t& creditRow) noexcept { + creditRow = 0; + std::uint16_t dynamicRow = 0; + std::size_t claimCount = 0; + for (std::size_t index = 0; index < vendor.thirdCount; ++index) { + build_data::vendors::InteractionGate candidate{}; + if (!build_data::vendors::find_interaction_gate( + vendor.definitionHash, static_cast(index), candidate)) { + continue; + } + std::uint16_t candidateRow = 0; + if (credit_gate(candidate, candidateRow) == CreditGateKind::claim) { + dynamicRow = candidateRow; + ++claimCount; + } + } + if (claimCount == 1) { + creditRow = dynamicRow; + return true; + } + return false; +} + /** * Resolves one exact sale-backed rank wrapper from installed content. * @param vendorIndex Installed vendor selector. @@ -498,7 +529,8 @@ VendorReputationDisposition resolve_reputation_turn_in(std::uint16_t vendorIndex award.costQuantity = saleCost.quantity; award.experience = static_cast(experience); award.progressionIndex = progressionIndex; - const auto* link = vendors::find_reconstructed_rank_claim_link(entry.definitionHash); + const build_data::vendors::ReconstructedRankClaimLink* link = + vendors::find_reconstructed_rank_claim_link(entry.definitionHash); if (link != nullptr) { vendors::InteractionGate interaction{}; if (!reconstructed_credit_gate(*link, interaction, award.rankCreditRow)) { @@ -506,20 +538,7 @@ VendorReputationDisposition resolve_reputation_turn_in(std::uint16_t vendorIndex } } else { std::uint16_t dynamicCreditRow = 0; - std::size_t claimCount = 0; - for (std::size_t index = 0; index < vendor.thirdCount; ++index) { - vendors::InteractionGate candidate{}; - if (!vendors::find_interaction_gate( - vendor.definitionHash, static_cast(index), candidate)) { - continue; - } - std::uint16_t candidateRow = 0; - if (credit_gate(candidate, candidateRow) == CreditGateKind::claim) { - dynamicCreditRow = candidateRow; - ++claimCount; - } - } - if (claimCount == 1) { + if (find_dynamic_rank_credit_row(vendor, dynamicCreditRow)) { award.rankCreditRow = dynamicCreditRow; } } @@ -796,6 +815,48 @@ bool commit_vendor_reputation(PendingVendorReputation& mutation) noexcept { && transaction.commit(); } +/** + * Scans installed vendor sales for a unique zero-cost reward package matching the interaction. + * @param vendorIndex Installed vendor selector. + * @param vendor Installed vendor definition. + * @param interaction Installed interaction gate defining category and index. + * @param saleIndex Receives the matching sale index on unique resolution. + * @return True when exactly one matching reward sale is found. + */ +bool find_unique_rank_reward_sale(std::uint16_t vendorIndex, + const build_data::vendors::Definition& vendor, + const build_data::vendors::InteractionGate& interaction, + std::uint16_t& saleIndex) noexcept { + saleIndex = 0; + std::optional matchedSale{}; + std::size_t matchCount = 0; + for (std::size_t saleIdx = 0; saleIdx < vendor.saleCount; ++saleIdx) { + build_data::vendors::SaleRow sale{}; + if (!build_data::vendors::sale_row(vendor, static_cast(saleIdx), sale)) { + continue; + } + if (sale.categoryIndex != interaction.categoryIndex) { + continue; + } + if (sale.costCount != 0 || sale.priceState != build_data::vendors::PriceState::plain) { + continue; + } + RewardSaleBinding binding{}; + if (resolve_reward_sale(vendorIndex, static_cast(saleIdx), binding) + == VendorReputationDisposition::prepared + && binding.interaction.index == interaction.index + && binding.categoryIndex == interaction.categoryIndex) { + matchedSale = static_cast(saleIdx); + ++matchCount; + } + } + if (matchCount == 1 && matchedSale) { + saleIndex = *matchedSale; + return true; + } + return false; +} + /** * Resolves a supported rowless reply through the same settlement as its package sale. * @param vendorIndex Installed vendor selector. @@ -823,7 +884,7 @@ prepare_vendor_rank_reward_interaction(std::uint16_t vendorIndex, entry.definitionHash, interactionIndex, interaction); std::uint16_t interactionCreditRow = 0; - const auto gateKind = + const CreditGateKind gateKind = hasInteraction ? credit_gate(interaction, interactionCreditRow) : CreditGateKind::unrelated; if (gateKind == CreditGateKind::warning || gateKind == CreditGateKind::malformed) { @@ -839,30 +900,9 @@ prepare_vendor_rank_reward_interaction(std::uint16_t vendorIndex, build_data::vendors::Definition vendor{}; if (hasInteraction && gateKind == CreditGateKind::claim && build_data::vendors::find(entry.definitionHash, vendor)) { - std::optional matchedSale{}; - std::size_t matchCount = 0; - for (std::size_t saleIdx = 0; saleIdx < vendor.saleCount; ++saleIdx) { - build_data::vendors::SaleRow sale{}; - if (!build_data::vendors::sale_row(vendor, static_cast(saleIdx), sale)) { - continue; - } - if (sale.categoryIndex != interaction.categoryIndex) { - continue; - } - if (sale.costCount != 0 || sale.priceState != build_data::vendors::PriceState::plain) { - continue; - } - RewardSaleBinding binding{}; - if (resolve_reward_sale(vendorIndex, static_cast(saleIdx), binding) - == VendorReputationDisposition::prepared - && binding.interaction.index == interactionIndex - && binding.categoryIndex == interaction.categoryIndex) { - matchedSale = static_cast(saleIdx); - ++matchCount; - } - } - if (matchCount == 1) { - targetSaleIndex = matchedSale; + std::uint16_t matchedSaleIndex = 0; + if (find_unique_rank_reward_sale(vendorIndex, vendor, interaction, matchedSaleIndex)) { + targetSaleIndex = matchedSaleIndex; targetCategoryIndex = interaction.categoryIndex; targetInteractionIndex = interaction.index; } @@ -870,7 +910,7 @@ prepare_vendor_rank_reward_interaction(std::uint16_t vendorIndex, // Fall back to reconstructed rank claim links if dynamic matching was unmapped or ambiguous. if (!targetSaleIndex) { - const auto* link = + const build_data::vendors::ReconstructedRankClaimLink* link = build_data::vendors::find_reconstructed_rank_claim_link(entry.definitionHash); if (link != nullptr && link->interactionIndex == interactionIndex) { targetSaleIndex = link->saleIndex; @@ -885,7 +925,7 @@ prepare_vendor_rank_reward_interaction(std::uint16_t vendorIndex, } const char* reason = nullptr; - const auto disposition = + const VendorReputationDisposition disposition = prepare_rank_reward(vendorIndex, *targetSaleIndex, replyIndex, mutation, reason); if (disposition != VendorReputationDisposition::prepared || mutation.vendorReward.interactionIndex != targetInteractionIndex @@ -938,7 +978,7 @@ bool vendor_rank_reward_current(const VendorRankRewardClaim& claim) noexcept { void mark_vendor_interaction_answered(std::uint16_t vendorIndex, std::uint16_t interactionIndex) noexcept { - const auto packed = pack_answered_interaction(vendorIndex, interactionIndex); + const std::uint32_t packed = pack_answered_interaction(vendorIndex, interactionIndex); std::unique_lock lock(g_answeredInteractionsLock); for (std::size_t i = 0; i < g_answeredInteractionsCount; ++i) { if (g_answeredInteractions[i] == packed) { @@ -952,7 +992,7 @@ void mark_vendor_interaction_answered(std::uint16_t vendorIndex, bool is_vendor_interaction_answered(std::uint16_t vendorIndex, std::uint16_t interactionIndex) noexcept { - const auto packed = pack_answered_interaction(vendorIndex, interactionIndex); + const std::uint32_t packed = pack_answered_interaction(vendorIndex, interactionIndex); std::shared_lock lock(g_answeredInteractionsLock); for (std::size_t i = 0; i < g_answeredInteractionsCount; ++i) { if (g_answeredInteractions[i] == packed) { From d90e5420781121892e660fe70ee2dddd18f92033 Mon Sep 17 00:00:00 2001 From: Gage Fulwood Date: Thu, 15 Oct 2026 17:11:26 -0500 Subject: [PATCH 51/59] Refine vendor catalog comments and parameters to eliminate pass terminology --- .../content/vendors/package_vendor_build.cpp | 59 +++++++++---------- 1 file changed, 29 insertions(+), 30 deletions(-) diff --git a/Sunrise/src/client/content/vendors/package_vendor_build.cpp b/Sunrise/src/client/content/vendors/package_vendor_build.cpp index 37573d5f0..73eef1361 100644 --- a/Sunrise/src/client/content/vendors/package_vendor_build.cpp +++ b/Sunrise/src/client/content/vendors/package_vendor_build.cpp @@ -403,12 +403,12 @@ void report(const Storage& storage, std::size_t skipped, const char* result) noe } /** - * Reads all priority vendor definitions into the supplied pass storage. + * Reads priority vendor definitions into destination storage. * @param source Package directory and borrowed block keys. * @param scratch Lock-owned block storage. * @param index Full vendor index to scan. * @param indexCount Number of valid entries in index. - * @param pass Pass storage pre-populated with faction data; receives definitions and rows. + * @param destination Pre-populated with faction data; receives definitions and rows. * @return Number of priority entries that could not be read or fit. */ std::size_t @@ -416,14 +416,14 @@ fill_priority_vendors(const reader::Source& source, reader::Scratch& scratch, const std::array& index, std::size_t indexCount, - Storage& pass) noexcept { + Storage& destination) noexcept { std::size_t skipped = 0; for (std::size_t row = 0; row < indexCount; ++row) { const domain::IndexEntry entry = index[row]; if (!is_priority_vendor(entry.definitionHash)) { continue; } - if (read_definition(source, scratch, entry, pass)) { + if (read_definition(source, scratch, entry, destination)) { continue; } ++skipped; @@ -437,13 +437,13 @@ fill_priority_vendors(const reader::Source& source, } /** - * Reads non-priority vendor definitions into the supplied pass storage up to capacity limits. + * Reads non-priority vendor definitions into destination storage up to capacity limits. * @param source Package directory and borrowed block keys. * @param scratch Lock-owned block storage. * @param index Full vendor index to scan. * @param indexCount Number of valid entries in index. - * @param priorityPass Completed priority pass; its counts set the remaining capacity. - * @param pass Pass storage pre-populated with faction data and capped limits; receives definitions. + * @param priority Prior storage whose counts determine remaining capacity. + * @param destination Pre-populated with faction data and capped limits; receives definitions. * @return Number of sequential entries that could not be read or fit. */ std::size_t @@ -451,15 +451,15 @@ fill_sequential_vendors(const reader::Source& source, reader::Scratch& scratch, const std::array& index, std::size_t indexCount, - const Storage& priorityPass, - Storage& pass) noexcept { + const Storage& priority, + Storage& destination) noexcept { std::size_t skipped = 0; for (std::size_t row = 0; row < indexCount; ++row) { const domain::IndexEntry entry = index[row]; if (is_priority_vendor(entry.definitionHash)) { continue; } - if (read_definition(source, scratch, entry, pass)) { + if (read_definition(source, scratch, entry, destination)) { continue; } ++skipped; @@ -469,32 +469,31 @@ fill_sequential_vendors(const reader::Source& source, "definitions=%zu sale=%zu", entry.definitionHash, row, - priorityPass.definitionCount + pass.definitionCount, - priorityPass.saleRowCount + pass.saleRowCount); + priority.definitionCount + destination.definitionCount, + priority.saleRowCount + destination.saleRowCount); } return skipped; } /** - * Merges two completed pass Storage objects into dest in strictly ascending catalog-index order. - * Offsets in each definition are rebased to the destination's running counts before insertion. - * @param priorityPass Completed priority-vendor pass. - * @param sequentialPass Completed sequential-vendor pass. + * Merges priority and sequential vendor rows into dest in strictly ascending catalog order. + * Offsets in each definition are rebased to the destination running counts before insertion. + * @param priority Priority-vendor definitions and rows. + * @param sequential Sequential-vendor definitions and rows. * @param dest Receives all definitions and rows in ascending index order. */ -void merge_vendor_catalogs(const Storage& priorityPass, - const Storage& sequentialPass, +void merge_vendor_catalogs(const Storage& priority, + const Storage& sequential, Storage& dest) noexcept { std::size_t priorityIdx = 0; std::size_t sequentialIdx = 0; - while (priorityIdx < priorityPass.definitionCount - || sequentialIdx < sequentialPass.definitionCount) { - const bool takePriority = (sequentialIdx >= sequentialPass.definitionCount) - || (priorityIdx < priorityPass.definitionCount - && priorityPass.definitions[priorityIdx].index - < sequentialPass.definitions[sequentialIdx].index); + while (priorityIdx < priority.definitionCount || sequentialIdx < sequential.definitionCount) { + const bool takePriority = (sequentialIdx >= sequential.definitionCount) + || (priorityIdx < priority.definitionCount + && priority.definitions[priorityIdx].index + < sequential.definitions[sequentialIdx].index); - const Storage& src = takePriority ? priorityPass : sequentialPass; + const Storage& src = takePriority ? priority : sequential; std::size_t& srcIdx = takePriority ? priorityIdx : sequentialIdx; domain::Definition def = src.definitions[srcIdx]; @@ -518,8 +517,8 @@ void merge_vendor_catalogs(const Storage& priorityPass, dest.definitions[dest.definitionCount++] = def; ++srcIdx; } - dest.conditionalRows = priorityPass.conditionalRows + sequentialPass.conditionalRows; - dest.unreadableRows = priorityPass.unreadableRows + sequentialPass.unreadableRows; + dest.conditionalRows = priority.conditionalRows + sequential.conditionalRows; + dest.unreadableRows = priority.unreadableRows + sequential.unreadableRows; } } // namespace @@ -543,7 +542,7 @@ bool build(const reader::Source& source, reader::Scratch& scratch) noexcept { return false; } - // Pass 1: Extract priority vendor definitions first to guarantee capacity. + // Priority vendor definitions are extracted first to guarantee capacity. static Storage priorityStorage{}; priorityStorage = {}; priorityStorage.factionBlob = storage.factionBlob; @@ -551,7 +550,7 @@ bool build(const reader::Source& source, reader::Scratch& scratch) noexcept { std::size_t skipped = fill_priority_vendors(source, scratch, storage.index, storage.indexCount, priorityStorage); - // Pass 2: Fill remaining capacity with non-priority vendors. + // Remaining capacity is filled with non-priority vendors. static Storage sequentialStorage{}; sequentialStorage = {}; sequentialStorage.factionBlob = storage.factionBlob; @@ -564,7 +563,7 @@ bool build(const reader::Source& source, reader::Scratch& scratch) noexcept { skipped += fill_sequential_vendors( source, scratch, storage.index, storage.indexCount, priorityStorage, sequentialStorage); - // Pass 3: Merge priority and sequential vendors in strictly ascending index order. + // Definitions and rows are merged in strictly ascending index order. merge_vendor_catalogs(priorityStorage, sequentialStorage, storage); const bool published = state::build_data::publish_vendor_catalog( From bfc9c271f01771f1f91609cfe530dbf6cc88192e Mon Sep 17 00:00:00 2001 From: Gage Fulwood Date: Thu, 15 Oct 2026 19:53:25 -0500 Subject: [PATCH 52/59] Stage answered vendor interaction until durable commit and allow multiple rank claims --- .../bap/encrypted/body/bap_service_body.cpp | 2 - .../bap/encrypted/encrypted_runtime.cpp | 6 ++ Sunrise/src/server/bap/encrypted/internal.h | 57 +++++++------------ .../transactions/service_outcome_commit.cpp | 32 ++++++----- .../server/web_service/web_service_runtime.h | 5 +- .../web_service_vendor_actions.cpp | 10 +++- Sunrise/src/state/runtime/runtime.h | 5 +- .../state_account_reward_grant_runtime.cpp | 6 -- Sunrise/src/state/runtime/state_runtime.cpp | 5 +- .../runtime/state_vendor_reputation_runtime.h | 7 +++ 10 files changed, 68 insertions(+), 67 deletions(-) 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 34c8a142f..20408a972 100644 --- a/Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp +++ b/Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp @@ -478,7 +478,6 @@ bool process(const ServiceRoute& route, } transaction->pending = web_service::take_mutation(webOutcome); - transaction->answeredVendor = webOutcome.answeredVendor; transaction->answeredInteraction = webOutcome.answeredInteraction; } if (profileItemAcquisition != nullptr) { @@ -512,7 +511,6 @@ bool process(const ServiceRoute& route, } transaction->pending = web_service::take_mutation(webOutcome); - transaction->answeredVendor = webOutcome.answeredVendor; transaction->answeredInteraction = webOutcome.answeredInteraction; } if (itemDismantle != nullptr) { diff --git a/Sunrise/src/server/bap/encrypted/encrypted_runtime.cpp b/Sunrise/src/server/bap/encrypted/encrypted_runtime.cpp index d741cc617..54d48ccfa 100644 --- a/Sunrise/src/server/bap/encrypted/encrypted_runtime.cpp +++ b/Sunrise/src/server/bap/encrypted/encrypted_runtime.cpp @@ -11,6 +11,7 @@ #include "../../../middleware/web_service/messages/opcode206.h" #include "../../../state/activity/bubble_authority/runtime.h" #include "../../../state/runtime/runtime.h" +#include "../../../state/runtime/state_vendor_reputation_runtime.h" #include "../../activity/host_runtime.h" #include "../../gameplay/peer/peer_transport.h" #include "../../gameplay/squad_entity_retirement.h" @@ -442,6 +443,11 @@ bool consume(Session& session, frame.serviceId, "commit", fits ? commitReason : "frame_capacity"); } if (handled) { + if (outcome.answeredInteraction) { + state::mark_vendor_interaction_answered( + outcome.answeredInteraction->vendorIndex, + outcome.answeredInteraction->interactionIndex); + } std::copy_n(scratch.framed.begin(), framedSize, response.begin()); written = framedSize; entityLease.release(); diff --git a/Sunrise/src/server/bap/encrypted/internal.h b/Sunrise/src/server/bap/encrypted/internal.h index 844dea6ca..4a502952b 100644 --- a/Sunrise/src/server/bap/encrypted/internal.h +++ b/Sunrise/src/server/bap/encrypted/internal.h @@ -99,84 +99,67 @@ struct ArtifactPurchaseTransaction { queuez::EquipmentSwap update{}; }; -/** Character acquisition and its exact QueueZ after-image. */ - +/** + * Character acquisition, its exact QueueZ after-image, and optional answered vendor interaction + * staged until durable transaction commit. + */ struct ItemAcquisitionTransaction { - std::unique_ptr pending{}; - queuez::ItemAcquisition update{}; - - std::int32_t answeredVendor{-1}; - - std::int32_t answeredInteraction{-1}; + /** Answered vendor interaction staged until durable transaction commit. */ + std::optional answeredInteraction{}; }; -/** Profile acquisition and its exact account/resident QueueZ after-image. */ - +/** + * Profile acquisition, its exact account/resident QueueZ after-image, and optional answered vendor + * interaction staged until durable transaction commit. + */ struct ProfileItemAcquisitionTransaction { - std::unique_ptr pending{}; - queuez::ProfileItemAcquisition update{}; - - std::int32_t answeredVendor{-1}; - - std::int32_t answeredInteraction{-1}; + /** Answered vendor interaction staged until durable transaction commit. */ + std::optional answeredInteraction{}; }; /** Dismantle mutation and its exact QueueZ after-image. */ - struct ItemDismantleTransaction { - std::unique_ptr pending{}; - queuez::ItemDismantle update{}; }; -/** Record-claim reward batch and its exact Queuez after-image. */ - +/** + * Record-claim reward batch and its exact Queuez after-image. + * If claiming a vendor rank reward, any exhausted interaction is staged in ServiceOutcome + * upon commit and marked answered only after the outer transaction commits. + */ struct RecordRewardGrantTransaction { - std::unique_ptr pending{}; - queuez::RecordRewardGrant update{}; }; /** Season reward grant plus the exact Queuez after-image promised by opcode 2400. */ - struct SeasonPassRewardTransaction { - std::unique_ptr pending{}; - queuez::RecordRewardGrant update{}; }; /** Optional side effect produced while decoding one authenticated service body. */ - struct ServiceOutcome { - bool hasSubscription{}; /** The ws-206 reply already carries the family's first snapshot, so no push repeats it. */ bool subscriptionAnswered{}; - /** A Triumph claim changed the account flag bank and its image has to follow. */ - bool hasRecordClaim{}; - bool hasArtifactReset{}; - state::ArtifactResetResult artifactReset{}; - middleware::queuez::Subscription subscription{}; - bool hasUnsubscription{}; - middleware::bap::family_unsubscription::Request unsubscription{}; - bool hasChangeCharacter{}; - bool hasSelectCharacter{}; + /** Vendor interaction marked answered only after the enclosing investment transaction commits. + */ + std::optional answeredInteraction{}; /** One service owns at most one independently versioned, exact-sized transaction. */ diff --git a/Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp b/Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp index f5b9022bb..66c19a1f7 100644 --- a/Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp +++ b/Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp @@ -277,11 +277,8 @@ bool commit(ServiceOutcome& outcome, Publication& publication, const char*& reas if (auto* transaction = transaction_if(outcome)) { const bool committed = transaction->pending != nullptr && state::commit_item_acquisition(*transaction->pending); - if (committed && transaction->answeredVendor >= 0 - && transaction->answeredInteraction >= 0) { - state::mark_vendor_interaction_answered( - static_cast(transaction->answeredVendor), - static_cast(transaction->answeredInteraction)); + if (committed && transaction->answeredInteraction) { + outcome.answeredInteraction = transaction->answeredInteraction; } core::log::write(core::log::Channel::server, committed ? core::log::Level::debug : core::log::Level::warn, @@ -318,11 +315,8 @@ bool commit(ServiceOutcome& outcome, Publication& publication, const char*& reas if (auto* transaction = transaction_if(outcome)) { const bool committed = transaction->pending != nullptr && state::commit_profile_item_acquisition(*transaction->pending); - if (committed && transaction->answeredVendor >= 0 - && transaction->answeredInteraction >= 0) { - state::mark_vendor_interaction_answered( - static_cast(transaction->answeredVendor), - static_cast(transaction->answeredInteraction)); + if (committed && transaction->answeredInteraction) { + outcome.answeredInteraction = transaction->answeredInteraction; } core::log::write(core::log::Channel::server, committed ? core::log::Level::debug : core::log::Level::warn, @@ -355,9 +349,21 @@ bool commit(ServiceOutcome& outcome, Publication& publication, const char*& reas } if (auto* transaction = transaction_if(outcome)) { reason = "record_reward"; - return transaction->pending != nullptr - && report_commit(state::commit_record_reward(*transaction->pending), - "ev=record_reward stage=transaction_commit result=fail"); + if (transaction->pending == nullptr) { + return false; + } + const auto& vendorReward = transaction->pending->vendorReward; + const bool willExhaustCredit = vendorReward.beforeCredits == 1; + const bool hasVendorInteraction = + vendorReward.vendorIndex != 0 || vendorReward.interactionIndex != 0; + const bool committed = + report_commit(state::commit_record_reward(*transaction->pending), + "ev=record_reward stage=transaction_commit result=fail"); + if (committed && willExhaustCredit && hasVendorInteraction) { + outcome.answeredInteraction = state::AnsweredVendorInteraction{ + vendorReward.vendorIndex, vendorReward.interactionIndex}; + } + return committed; } return true; } diff --git a/Sunrise/src/server/web_service/web_service_runtime.h b/Sunrise/src/server/web_service/web_service_runtime.h index 228f43aad..3e05971fd 100644 --- a/Sunrise/src/server/web_service/web_service_runtime.h +++ b/Sunrise/src/server/web_service/web_service_runtime.h @@ -32,9 +32,8 @@ struct Outcome { state::ArtifactResetResult artifactReset{}; /** A WS-701 carried the profile-setup completion marker and State refused it. */ bool profileSetupRefused{}; - /** Answered vendor interaction for this action session (-1 if none). */ - std::int32_t answeredVendor{-1}; - std::int32_t answeredInteraction{-1}; + /** Answered vendor interaction staged until durable transaction commit. */ + std::optional answeredInteraction{}; /** A request prepares at most one State mutation and allocates only that exact payload. */ using Mutation = std::variant, diff --git a/Sunrise/src/server/web_service/web_service_vendor_actions.cpp b/Sunrise/src/server/web_service/web_service_vendor_actions.cpp index 65ab3f227..d55f6dc72 100644 --- a/Sunrise/src/server/web_service/web_service_vendor_actions.cpp +++ b/Sunrise/src/server/web_service/web_service_vendor_actions.cpp @@ -885,9 +885,13 @@ void acquire_quest(const middleware::web_service::Message& message, Outcome& out return; } settle_vendor_row(message, quest::kOpcode, request.vendorIndex, row, sale, outcome); - if (rowless) { - outcome.answeredVendor = request.vendorIndex; - outcome.answeredInteraction = request.slotIndex; + if (rowless && request.vendorIndex >= 0 + && request.vendorIndex <= (std::numeric_limits::max)() + && request.slotIndex >= 0 + && request.slotIndex <= (std::numeric_limits::max)()) { + outcome.answeredInteraction = + state::AnsweredVendorInteraction{static_cast(request.vendorIndex), + static_cast(request.slotIndex)}; } } diff --git a/Sunrise/src/state/runtime/runtime.h b/Sunrise/src/state/runtime/runtime.h index df08f4596..7030c2cd3 100644 --- a/Sunrise/src/state/runtime/runtime.h +++ b/Sunrise/src/state/runtime/runtime.h @@ -444,8 +444,9 @@ void shutdown() noexcept; [[nodiscard]] bool publish_bootstrap_token(std::span token) noexcept; /** - * Records when the account signed in. - * Every character record publishes this as its last applied daily and weekly reset. + * Records when the account signed in and resets session-scoped answered vendor interactions. + * Every character record publishes this as its last applied daily and weekly reset, and each + * successful SignOn begins a new answered-interaction session. * @param seconds Unix seconds taken when the SignOn success is answered. */ void publish_sign_in_time(std::uint64_t seconds) 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 535893307..365caeea2 100644 --- a/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp +++ b/Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp @@ -955,12 +955,6 @@ bool commit_record_reward(PendingRecordRewardGrant& mutation) noexcept { } ready = ready && transaction.commit(); } - if (ready - && (mutation.vendorReward.vendorIndex != 0 - || mutation.vendorReward.interactionIndex != 0)) { - state::mark_vendor_interaction_answered(mutation.vendorReward.vendorIndex, - mutation.vendorReward.interactionIndex); - } 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_runtime.cpp b/Sunrise/src/state/runtime/state_runtime.cpp index bf67069bd..42e21bb13 100644 --- a/Sunrise/src/state/runtime/state_runtime.cpp +++ b/Sunrise/src/state/runtime/state_runtime.cpp @@ -350,7 +350,10 @@ bool publish_bootstrap_token(std::span token) noexcept { return true; } -/** Records when the account signed in, on every character the account owns. */ +/** + * Records when the account signed in, resets session-scoped answered vendor interactions, + * and updates every character the account owns. + */ void publish_sign_in_time(std::uint64_t seconds) noexcept { reset_answered_vendor_interactions(); investment::store::set_sign_in_time(seconds); diff --git a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h index f385d2344..8870954f3 100644 --- a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h +++ b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h @@ -77,6 +77,13 @@ prepare_vendor_rank_reward_interaction(std::uint16_t vendorIndex, PendingRecordRewardGrant& mutation) noexcept; [[nodiscard]] bool vendor_rank_reward_current(const VendorRankRewardClaim& claim) noexcept; +/** Identifies an interaction answered during this session. */ +struct AnsweredVendorInteraction { + std::uint16_t vendorIndex{}; + std::uint16_t interactionIndex{}; + bool operator==(const AnsweredVendorInteraction&) const = default; +}; + /** Marks an interaction answered for the current session. */ void mark_vendor_interaction_answered(std::uint16_t vendorIndex, std::uint16_t interactionIndex) noexcept; From c4c347e6a58fba67c3a4b15e13b94bd977e96a1c Mon Sep 17 00:00:00 2001 From: Gage Fulwood Date: Thu, 15 Oct 2026 20:10:47 -0500 Subject: [PATCH 53/59] Normalize formatting and remove redundant spacing in encrypted bap header --- Sunrise/src/server/bap/encrypted/internal.h | 331 -------------------- 1 file changed, 331 deletions(-) diff --git a/Sunrise/src/server/bap/encrypted/internal.h b/Sunrise/src/server/bap/encrypted/internal.h index 4a502952b..a273187bb 100644 --- a/Sunrise/src/server/bap/encrypted/internal.h +++ b/Sunrise/src/server/bap/encrypted/internal.h @@ -23,79 +23,49 @@ #include "transactions/definition.h" namespace sunrise::server::bap::encrypted { - /** Response-body codecs picked by the authenticated request service. */ - enum class BodyCodec : std::uint8_t { - empty, - accountTranslationResponse, - activityHostManagerResponse, - activityMessageRequest, - activityHostResponse, - clientConfigResponse, - familySubscription, - familyUnsubscription, - matchmakingResponse, - steamCertificate, - userMessageResponse, - webService, - }; /** Equipment mutation and the exact QueueZ after-image promised by its response. */ - struct EquipmentSwapTransaction { - std::unique_ptr pending{}; - queuez::EquipmentSwap update{}; }; /** Socket mutation and the exact QueueZ after-image promised by its response. */ - struct SocketPlugTransaction { - std::unique_ptr pending{}; - queuez::SocketPlug update{}; }; /** Subclass ability selection and the exact QueueZ after-image promised by its response. */ - struct SubclassSelectionTransaction { - std::unique_ptr pending{}; - queuez::SubclassSelection update{}; }; /** Item-state mutation and the exact QueueZ character after-image promised by its response. */ - struct ItemStateTransaction { - std::unique_ptr pending{}; - queuez::EquipmentSwap update{}; }; /** Artifact purchase and the exact selected-character after-image promised by opcode 901. */ - struct ArtifactPurchaseTransaction { - std::unique_ptr pending{}; - queuez::EquipmentSwap update{}; }; @@ -160,51 +130,31 @@ struct ServiceOutcome { /** Vendor interaction marked answered only after the enclosing investment transaction commits. */ std::optional answeredInteraction{}; - /** One service owns at most one independently versioned, exact-sized transaction. */ - using Transaction = std::variant, - std::unique_ptr, - std::unique_ptr, - std::unique_ptr, - std::unique_ptr, - std::unique_ptr, - std::unique_ptr, - std::unique_ptr, - std::unique_ptr, - std::unique_ptr, - std::unique_ptr, - std::unique_ptr, - std::unique_ptr, - std::unique_ptr, - std::unique_ptr, - std::unique_ptr, std::unique_ptr>; - Transaction transaction{}; }; /** Allocates only the selected transaction outside the request's already deep call stack. */ template [[nodiscard]] Transaction* emplace_transaction(ServiceOutcome& outcome, Args&&... args) noexcept { - static_assert(std::is_nothrow_constructible_v); outcome.transaction.template emplace(); @@ -214,7 +164,6 @@ template std::unique_ptr{new (std::nothrow) Transaction(std::forward(args)...)}; if (storage == nullptr) { - return nullptr; } @@ -226,90 +175,61 @@ template } /** @return The service transaction of the requested type, or null for another route. */ - template - [[nodiscard]] Transaction* transaction_if(ServiceOutcome& outcome) noexcept { - auto* storage = std::get_if>(&outcome.transaction); return storage == nullptr ? nullptr : storage->get(); } /** @return The service transaction of the requested type, or null for another route. */ - template - [[nodiscard]] const Transaction* transaction_if(const ServiceOutcome& outcome) noexcept { - const auto* storage = std::get_if>(&outcome.transaction); return storage == nullptr ? nullptr : storage->get(); } [[nodiscard]] inline bool has_transaction(const ServiceOutcome& outcome) noexcept { - return outcome.transaction.index() != 0; } inline void clear_transaction(ServiceOutcome& outcome) noexcept { - outcome.transaction.template emplace(); } /** Outbound delivery behavior picked for one authenticated request service. */ - enum class ResponseMode : std::uint8_t { - none, - reply, - /** Processes a request body and may emit notifications without a status response. */ - uncorrelatedPush, - }; /** Static response metadata for one supported encrypted request service. */ - struct ServiceRoute { - ResponseMode responseMode{}; - middleware::bap::ResponseService response{}; - BodyCodec bodyCodec{}; }; /** Owns encrypted service-to-response routing. */ - namespace routing { - [[nodiscard]] bool resolve(std::uint16_t request, ServiceRoute& route) noexcept; - } // namespace routing /** Owns failure reporting for encrypted requests. */ - namespace diagnostics { - void report_failure(std::uint16_t service, std::string_view stage) noexcept; /** Reports one refusal that names which branch refused. */ - void report_failure(std::uint16_t service, - std::string_view stage, - std::string_view reason) noexcept; - } // namespace diagnostics /** Owns correlated reply construction for one authenticated request. */ - namespace reply { - /** * Encodes, seals, and frames one correlated status-200 reply. * @param scratch Lock-owned transform buffers. @@ -321,27 +241,17 @@ namespace reply { * @param framedSize Receives the complete outer-frame size, or zero on failure. * @return True when the payload, seal, and outer frame all fit. */ - [[nodiscard]] bool encode(Scratch& scratch, - const ServiceRoute& route, - std::uint32_t taskId, - std::span key, - std::span nonce, - std::span body, - std::size_t& framedSize) noexcept; - } // namespace reply /** Owns request-body processing for an encrypted service route. */ - namespace body { - /** * Processes a request body and encodes a correlated body when the route needs one. * @param route Service route data found earlier. @@ -355,40 +265,26 @@ namespace body { * @param outcome Receives one validated transport action or deferred State transaction. * @return True when the chosen body codec succeeds. */ - [[nodiscard]] bool process(const ServiceRoute& route, - const queuez::SessionState& queuezState, - const ActivityClientBinding& activity, - const RosterDecodeMap& rosterDecode, - state::matchmaking::ContextHandle matchmakingContext, - std::span requestBody, - std::span output, - std::size_t& written, - ServiceOutcome& outcome, - std::span presentation = {}) noexcept; - } // namespace body /** Owns server-initiated encrypted frames appended after correlated replies. */ - namespace push { - /** * Canonicalizes the account before any family builder reads it. * Families 0, 3 and 4 each snapshot the account, so a migration inside one builder would leave * the others describing a different account. Idempotent. */ - void ensure_account_canonical() noexcept; /** @@ -406,25 +302,15 @@ void ensure_account_canonical() noexcept; * @param armsRepush Receives whether the Family-4 companion needs its delayed second copy. * @param armsBannerRepush Receives whether a family-zero body needs its delayed second copy. */ - void append_queuez_notification(Scratch& scratch, - const queuez::SessionState& before, - const middleware::queuez::Subscription& subscription, - std::span key, - std::array& nonce, - std::span response, - std::size_t& written, - queuez::SessionState& after, - bool& armsRepush, - bool& armsBannerRepush, bool ownSnapshotAnswered = false) noexcept; @@ -446,45 +332,26 @@ void append_queuez_notification(Scratch& scratch, std::size_t& bodySize) noexcept; /** Appends one next-version full Family-4 snapshot used to resynchronize another peer. */ - [[nodiscard]] bool append_account_resync_notification( - Scratch& scratch, - const queuez::SessionState& before, - std::span acquisitionPresentationRows, - std::span key, - std::array& 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( - Scratch& scratch, - const queuez::SessionState& before, - const queuez::RecordRewardGrant& update, - const state::PendingRecordRewardGrant& mutation, - std::span acquisitionPresentationRows, - std::span key, - std::span nonce, - std::span response, - std::size_t& written) noexcept; /** @@ -501,21 +368,13 @@ void append_queuez_notification(Scratch& scratch, * @param after Receives the state carrying the recorded delivery. * @return True when the banner frame is appended. */ - [[nodiscard]] bool append_banner_notification(Scratch& scratch, - const queuez::SessionState& before, - std::uint64_t familyRootSoid, - std::span key, - std::array& nonce, - std::span response, - std::size_t& written, - queuez::SessionState& after) noexcept; /** @@ -531,23 +390,14 @@ void append_queuez_notification(Scratch& scratch, * @param after Receives the state published once the frame is copied. * @return True when a frame went out and `after` carries the advanced ladder. */ - [[nodiscard]] bool - append_banner_move_notification(Scratch& scratch, - const queuez::SessionState& before, - std::uint64_t selectedCharacter, - std::span key, - std::array& nonce, - std::span response, - std::size_t& written, - queuez::SessionState& after) noexcept; /** @@ -560,19 +410,12 @@ append_banner_move_notification(Scratch& scratch, * @param written Existing byte count, updated after the complete push is appended. * @return True when the exact 17-byte patch and complete svc-123 frame fit. */ - [[nodiscard]] bool - append_change_character_notification(Scratch& scratch, - const queuez::ChangeCharacter& change, - std::span key, - std::span nonce, - std::span response, - std::size_t& written) noexcept; /** @@ -585,79 +428,45 @@ append_change_character_notification(Scratch& scratch, * @param written Existing byte count, updated after the complete push is appended. * @return True when the 3 object operations and the whole svc-123 frame fit. */ - [[nodiscard]] bool - append_select_character_notification(Scratch& scratch, - const queuez::SelectCharacter& select, - std::span key, - std::span nonce, - std::span response, - std::size_t& written) noexcept; /** Appends the opcode-403 Family-4 character upsert that exposes the equipped item swap. */ - [[nodiscard]] bool append_equipment_swap_notification( - Scratch& scratch, - const queuez::EquipmentSwap& swap, - const state::PendingEquipmentSwap& mutation, - std::span acquisitionPresentationRows, - std::span key, - std::span nonce, - std::span response, - std::size_t& written) noexcept; /** Appends the opcode-406 Family-4 character upsert carrying changed inventory-row flags. */ - [[nodiscard]] bool append_item_state_notification( - Scratch& scratch, - const queuez::EquipmentSwap& update, - const state::PendingItemState& mutation, - std::span acquisitionPresentationRows, - std::span key, - std::span nonce, - std::span response, - std::size_t& written) noexcept; /** Appends the selected-character upsert carrying one artifact ownership transition. */ - [[nodiscard]] bool append_artifact_purchase_notification( - Scratch& scratch, - const queuez::EquipmentSwap& update, - const state::PendingArtifactPurchase& mutation, - std::span acquisitionPresentationRows, - std::span key, - std::span nonce, - std::span response, - std::size_t& written) noexcept; /** @@ -670,319 +479,179 @@ append_select_character_notification(Scratch& scratch, * @param written Existing byte count, updated by a complete frame. * @return True when State encodes and the whole frame fits. */ - [[nodiscard]] bool - append_family5_override_notification(Scratch& scratch, - std::int32_t version, - std::span key, - std::span nonce, - std::span response, - std::size_t& written) noexcept; /** Appends the account and selected-character state changed by an artifact reset. */ - [[nodiscard]] bool - append_artifact_reset_notification(Scratch& scratch, - const queuez::EquipmentSwap& update, - std::span key, - std::span nonce, - std::span response, - std::size_t& written) noexcept; /** Appends one current item resident after artifact reset cleared an authored socket. */ - [[nodiscard]] bool - append_artifact_item_refresh_notification(Scratch& scratch, - const queuez::EquipmentSwap& update, - std::uint64_t instanceSoid, - std::span key, - std::span nonce, - std::span response, - std::size_t& written) noexcept; /** * Appends the same-character Family-0 appearance upsert paired with one equipment swap. * The update owns its nonce advance only after the complete notification fits. */ - [[nodiscard]] bool - append_equipment_appearance_refresh_notification(Scratch& scratch, - const queuez::CharacterAppearanceRefresh& refresh, - const state::PendingEquipmentSwap& mutation, - std::span key, - std::array& nonce, - std::span response, - std::size_t& written) noexcept; /** Appends the Family-0 refresh owed by a socket change on an equipped item. */ - [[nodiscard]] bool - append_socket_appearance_refresh_notification(Scratch& scratch, - const queuez::CharacterAppearanceRefresh& refresh, - const state::PendingSocketPlug& mutation, - std::span key, - std::array& nonce, - std::span response, - std::size_t& written) noexcept; /** Appends the Family-0 character ability refresh owed by a subclass selection. */ - [[nodiscard]] bool - append_subclass_appearance_refresh_notification(Scratch& scratch, - const queuez::CharacterAppearanceRefresh& refresh, - const state::PendingSubclassSelection& mutation, - std::span key, - std::array& nonce, - std::span response, - std::size_t& written) noexcept; /** Appends a Family-3 character record followed by the changed account roster after equip. */ - [[nodiscard]] bool - append_equipment_roster_refresh_notification(Scratch& scratch, - const queuez::RosterAppearanceRefresh& refresh, - const state::PendingEquipmentSwap& mutation, - std::span key, - std::array& nonce, - std::span response, - std::size_t& written) noexcept; /** Appends a Family-3 character-only appearance refresh after an equipped socket change. */ - [[nodiscard]] bool - append_socket_roster_refresh_notification(Scratch& scratch, - const queuez::RosterAppearanceRefresh& refresh, - const state::PendingSocketPlug& mutation, - std::span key, - std::array& nonce, - std::span response, - std::size_t& written) noexcept; /** Appends a Family-3 character-only appearance refresh after a subclass selection. */ - [[nodiscard]] bool - append_subclass_roster_refresh_notification(Scratch& scratch, - const queuez::RosterAppearanceRefresh& refresh, - const state::PendingSubclassSelection& mutation, - std::span key, - std::array& nonce, - std::span response, - std::size_t& written) noexcept; /** Refreshes the selected character's complete Family-0 appearance from committed State. */ - [[nodiscard]] bool - append_account_resync_appearance_notification(Scratch& scratch, - const queuez::SessionState& before, - std::span key, - std::array& nonce, - std::span response, - std::size_t& written, - queuez::SessionState& after) noexcept; /** Refreshes the selected character and optionally its account roster from committed State. */ - [[nodiscard]] bool - append_account_resync_roster_notification(Scratch& scratch, - const queuez::SessionState& before, - std::span key, - std::array& nonce, - std::span response, - std::size_t& written, - queuez::SessionState& after, bool includeRoster = true) noexcept; /** Appends the opcode-903 Family-4 item-instance upsert exposing one socket selection. */ - [[nodiscard]] bool - append_socket_plug_notification(Scratch& scratch, - const queuez::SocketPlug& socketPlug, - const state::PendingSocketPlug& mutation, - std::span key, - std::span nonce, - std::span response, - std::size_t& written) noexcept; /** Appends the opcode-801 Family-4 subclass item-instance upsert. */ - [[nodiscard]] bool - append_subclass_selection_notification(Scratch& scratch, - const queuez::SubclassSelection& selection, - const state::PendingSubclassSelection& mutation, - std::span key, - std::span nonce, - std::span response, - std::size_t& written) noexcept; /** Appends a Family-4 character upsert plus newly acquired item-instance upsert. */ - [[nodiscard]] bool append_item_acquisition_notification( - Scratch& scratch, - const queuez::ItemAcquisition& acquisition, - const state::PendingItemAcquisition& mutation, - std::span acquisitionPresentationRows, - std::span key, - std::span nonce, - std::span response, - std::size_t& written) noexcept; /** Appends one full Family-4 account upsert for a profile-stack acquisition. */ - [[nodiscard]] bool - append_profile_item_acquisition_notification(Scratch& scratch, - const queuez::ProfileItemAcquisition& acquisition, - const state::PendingProfileItemAcquisition& mutation, - std::span key, - std::span nonce, - std::span response, - std::size_t& written) noexcept; /** Appends the character/account increment that makes one seasonal XP gain visible in the HUD. */ - [[nodiscard]] bool append_seasonal_experience_notification( - Scratch& scratch, - const queuez::SessionState& before, - std::int32_t amount, - std::int32_t mutationSerial, - std::span acquisitionPresentationRows, - std::span key, - std::span nonce, - std::span response, - std::size_t& written, - queuez::SessionState& after) noexcept; /** Appends a Family-4 character upsert followed by one empty item-instance release. */ - [[nodiscard]] bool - append_item_dismantle_notification(Scratch& scratch, - const queuez::ItemDismantle& dismantle, - const state::PendingItemDismantle& mutation, - std::span key, - std::span nonce, - std::span response, - std::size_t& written) noexcept; - } // namespace push - } // namespace sunrise::server::bap::encrypted From e9d209b95d9362e607cca558e6bae6aa82c8af54 Mon Sep 17 00:00:00 2001 From: Gage Fulwood Date: Thu, 15 Oct 2026 20:22:55 -0500 Subject: [PATCH 54/59] Normalize spacing and comment formatting in encrypted bap header --- Sunrise/src/server/bap/encrypted/internal.h | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/Sunrise/src/server/bap/encrypted/internal.h b/Sunrise/src/server/bap/encrypted/internal.h index a273187bb..5830284cf 100644 --- a/Sunrise/src/server/bap/encrypted/internal.h +++ b/Sunrise/src/server/bap/encrypted/internal.h @@ -127,8 +127,7 @@ struct ServiceOutcome { middleware::bap::family_unsubscription::Request unsubscription{}; bool hasChangeCharacter{}; bool hasSelectCharacter{}; - /** Vendor interaction marked answered only after the enclosing investment transaction commits. - */ + /** Vendor interaction marked answered after the enclosing investment transaction commits. */ std::optional answeredInteraction{}; /** One service owns at most one independently versioned, exact-sized transaction. */ using Transaction = std::variant [[nodiscard]] Transaction* emplace_transaction(ServiceOutcome& outcome, Args&&... args) noexcept { static_assert(std::is_nothrow_constructible_v); - outcome.transaction.template emplace(); - auto storage = - std::unique_ptr{new (std::nothrow) Transaction(std::forward(args)...)}; - if (storage == nullptr) { return nullptr; } - auto* transaction = storage.get(); - outcome.transaction.template emplace>(std::move(storage)); - return transaction; } @@ -178,7 +170,6 @@ template template [[nodiscard]] Transaction* transaction_if(ServiceOutcome& outcome) noexcept { auto* storage = std::get_if>(&outcome.transaction); - return storage == nullptr ? nullptr : storage->get(); } @@ -186,7 +177,6 @@ template template [[nodiscard]] const Transaction* transaction_if(const ServiceOutcome& outcome) noexcept { const auto* storage = std::get_if>(&outcome.transaction); - return storage == nullptr ? nullptr : storage->get(); } From adc693032738b8a38db714a90cecc48454de38d9 Mon Sep 17 00:00:00 2001 From: Gage Fulwood Date: Thu, 15 Oct 2026 21:14:07 -0500 Subject: [PATCH 55/59] Fix answered interaction capacity, identity mapping, and transaction RAII --- .../transactions/service_outcome_commit.cpp | 2 +- .../web_service_vendor_actions.cpp | 2 +- .../state_account_acquisition_runtime.cpp | 23 ++++------ .../state_vendor_reputation_runtime.cpp | 43 ++++++++++++------- 4 files changed, 39 insertions(+), 31 deletions(-) diff --git a/Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp b/Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp index 66c19a1f7..7124bdbb5 100644 --- a/Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp +++ b/Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp @@ -355,7 +355,7 @@ bool commit(ServiceOutcome& outcome, Publication& publication, const char*& reas const auto& vendorReward = transaction->pending->vendorReward; const bool willExhaustCredit = vendorReward.beforeCredits == 1; const bool hasVendorInteraction = - vendorReward.vendorIndex != 0 || vendorReward.interactionIndex != 0; + vendorReward.beforeCredits > 0 && vendorReward.replyIndex.has_value(); const bool committed = report_commit(state::commit_record_reward(*transaction->pending), "ev=record_reward stage=transaction_commit result=fail"); diff --git a/Sunrise/src/server/web_service/web_service_vendor_actions.cpp b/Sunrise/src/server/web_service/web_service_vendor_actions.cpp index d55f6dc72..20fe1f8f3 100644 --- a/Sunrise/src/server/web_service/web_service_vendor_actions.cpp +++ b/Sunrise/src/server/web_service/web_service_vendor_actions.cpp @@ -885,7 +885,7 @@ void acquire_quest(const middleware::web_service::Message& message, Outcome& out return; } settle_vendor_row(message, quest::kOpcode, request.vendorIndex, row, sale, outcome); - if (rowless && request.vendorIndex >= 0 + if (rowless && outcome.mutation.index() != 0 && request.vendorIndex >= 0 && request.vendorIndex <= (std::numeric_limits::max)() && request.slotIndex >= 0 && request.slotIndex <= (std::numeric_limits::max)()) { diff --git a/Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp b/Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp index c14f24f2c..81ea7e92f 100644 --- a/Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp +++ b/Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp @@ -694,22 +694,17 @@ bool commit_profile_item_acquisition(PendingProfileItemAcquisition& mutation) no if (!valid_profile_mutation_shape(prepared)) { return false; } - - investment::store::g_mutex.lock(); + investment::store::Transaction transaction; + if (!transaction.ready()) { + return false; + } AccountState candidate{}; - const bool ready = - materialize_profile_acquisition(investment::store::account(), prepared, candidate); - if (ready) { - investment::store::Transaction transaction; - if (!transaction.ready() || !investment::store::write_account(candidate) - || !settle_legacy_content_override(prepared.acquiredDefinitionHash) - || !transaction.commit()) { - investment::store::g_mutex.unlock(); - return false; - } + if (!materialize_profile_acquisition(investment::store::account(), prepared, candidate) + || !investment::store::write_account(candidate) + || !settle_legacy_content_override(prepared.acquiredDefinitionHash)) { + return false; } - investment::store::g_mutex.unlock(); - return ready; + return transaction.commit(); } } // namespace sunrise::state diff --git a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp index d90435e1d..12097df0b 100644 --- a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp +++ b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include "../../core/threading/srw_lock.h" #include "../../middleware/crypto/random_bytes.h" @@ -22,9 +23,7 @@ namespace { constexpr std::size_t kExperienceLane = 0; /** Session-scoped answered vendor interactions. */ -constexpr std::size_t kAnsweredInteractionsCapacity = 256; -std::array g_answeredInteractions{}; -std::size_t g_answeredInteractionsCount = 0; +std::vector g_answeredInteractions; core::threading::SrwLock g_answeredInteractionsLock; [[nodiscard]] std::uint32_t pack_answered_interaction(std::uint16_t vendorIndex, @@ -347,11 +346,15 @@ bool find_dynamic_rank_credit_row(const build_data::vendors::Definition& vendor, } std::uint16_t candidateRow = 0; if (credit_gate(candidate, candidateRow) == CreditGateKind::claim) { + if (dynamicRow != 0 && dynamicRow != candidateRow) { + creditRow = 0; + return false; + } dynamicRow = candidateRow; ++claimCount; } } - if (claimCount == 1) { + if (claimCount > 0 && dynamicRow != 0) { creditRow = dynamicRow; return true; } @@ -540,6 +543,16 @@ VendorReputationDisposition resolve_reputation_turn_in(std::uint16_t vendorIndex std::uint16_t dynamicCreditRow = 0; if (find_dynamic_rank_credit_row(vendor, dynamicCreditRow)) { award.rankCreditRow = dynamicCreditRow; + } else { + for (std::size_t index = 0; index < vendor.thirdCount; ++index) { + build_data::vendors::InteractionGate candidate{}; + std::uint16_t candidateRow = 0; + if (build_data::vendors::find_interaction_gate( + vendor.definitionHash, static_cast(index), candidate) + && credit_gate(candidate, candidateRow) == CreditGateKind::claim) { + return VendorReputationDisposition::refused; + } + } } } award.repeatLastStep = progression.repeatLastStep; @@ -979,23 +992,23 @@ bool vendor_rank_reward_current(const VendorRankRewardClaim& claim) noexcept { void mark_vendor_interaction_answered(std::uint16_t vendorIndex, std::uint16_t interactionIndex) noexcept { const std::uint32_t packed = pack_answered_interaction(vendorIndex, interactionIndex); - std::unique_lock lock(g_answeredInteractionsLock); - for (std::size_t i = 0; i < g_answeredInteractionsCount; ++i) { - if (g_answeredInteractions[i] == packed) { - return; + try { + std::unique_lock lock(g_answeredInteractionsLock); + for (const auto existing : g_answeredInteractions) { + if (existing == packed) { + return; + } } - } - if (g_answeredInteractionsCount < g_answeredInteractions.size()) { - g_answeredInteractions[g_answeredInteractionsCount++] = packed; - } + g_answeredInteractions.push_back(packed); + } catch (...) {} } bool is_vendor_interaction_answered(std::uint16_t vendorIndex, std::uint16_t interactionIndex) noexcept { const std::uint32_t packed = pack_answered_interaction(vendorIndex, interactionIndex); std::shared_lock lock(g_answeredInteractionsLock); - for (std::size_t i = 0; i < g_answeredInteractionsCount; ++i) { - if (g_answeredInteractions[i] == packed) { + for (const auto existing : g_answeredInteractions) { + if (existing == packed) { return true; } } @@ -1004,7 +1017,7 @@ bool is_vendor_interaction_answered(std::uint16_t vendorIndex, void reset_answered_vendor_interactions() noexcept { std::unique_lock lock(g_answeredInteractionsLock); - g_answeredInteractionsCount = 0; + g_answeredInteractions.clear(); } } // namespace sunrise::state From 5f91165dda5521426d6dee85b3d03c0279d6ec0f Mon Sep 17 00:00:00 2001 From: Gage Fulwood Date: Thu, 15 Oct 2026 22:34:58 -0500 Subject: [PATCH 56/59] Guarantee answered tracker capacity, bind session identity, and resolve multi-sale gates --- .../bap/encrypted/encrypted_runtime.cpp | 11 ++- .../transactions/service_outcome_commit.cpp | 6 ++ .../web_service_vendor_actions.cpp | 22 ------ Sunrise/src/state/runtime/state_runtime.cpp | 2 +- .../state_vendor_reputation_runtime.cpp | 78 ++++++++++++++++--- .../runtime/state_vendor_reputation_runtime.h | 13 +++- 6 files changed, 92 insertions(+), 40 deletions(-) diff --git a/Sunrise/src/server/bap/encrypted/encrypted_runtime.cpp b/Sunrise/src/server/bap/encrypted/encrypted_runtime.cpp index 54d48ccfa..510625f43 100644 --- a/Sunrise/src/server/bap/encrypted/encrypted_runtime.cpp +++ b/Sunrise/src/server/bap/encrypted/encrypted_runtime.cpp @@ -435,7 +435,14 @@ bool consume(Session& session, if (!retirementValid) { commitReason = "entity_retirement_stale"; } - handled = fits && retirementValid + const bool trackerReady = !outcome.answeredInteraction.has_value() + || state::reserve_answered_vendor_interaction( + outcome.answeredInteraction->vendorIndex, + outcome.answeredInteraction->interactionIndex); + if (!trackerReady) { + commitReason = "tracker_capacity"; + } + handled = fits && retirementValid && trackerReady && transactions::commit(outcome, publication, commitReason) && investmentTransaction.commit(); if (!handled) { @@ -444,7 +451,7 @@ bool consume(Session& session, } if (handled) { if (outcome.answeredInteraction) { - state::mark_vendor_interaction_answered( + (void)state::mark_vendor_interaction_answered( outcome.answeredInteraction->vendorIndex, outcome.answeredInteraction->interactionIndex); } diff --git a/Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp b/Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp index 7124bdbb5..80735c229 100644 --- a/Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp +++ b/Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp @@ -356,6 +356,12 @@ bool commit(ServiceOutcome& outcome, Publication& publication, const char*& reas const bool willExhaustCredit = vendorReward.beforeCredits == 1; const bool hasVendorInteraction = vendorReward.beforeCredits > 0 && vendorReward.replyIndex.has_value(); + if (willExhaustCredit && hasVendorInteraction + && !state::reserve_answered_vendor_interaction(vendorReward.vendorIndex, + vendorReward.interactionIndex)) { + reason = "tracker_capacity"; + return false; + } const bool committed = report_commit(state::commit_record_reward(*transaction->pending), "ev=record_reward stage=transaction_commit result=fail"); diff --git a/Sunrise/src/server/web_service/web_service_vendor_actions.cpp b/Sunrise/src/server/web_service/web_service_vendor_actions.cpp index 20fe1f8f3..18117a48d 100644 --- a/Sunrise/src/server/web_service/web_service_vendor_actions.cpp +++ b/Sunrise/src/server/web_service/web_service_vendor_actions.cpp @@ -850,20 +850,6 @@ void acquire_quest(const middleware::web_service::Message& message, Outcome& out // A row of -1 says the tile is not a sale row at all, so the installed array answers it. // Reading the slot as a sale row here would grant whatever sits at that row. const bool rowless = row < 0; - if (rowless && request.vendorIndex >= 0 - && request.vendorIndex <= (std::numeric_limits::max)() - && request.slotIndex >= 0 - && request.slotIndex <= (std::numeric_limits::max)() - && state::is_vendor_interaction_answered(static_cast(request.vendorIndex), - static_cast(request.slotIndex))) { - report_purchase(quest::kOpcode, - "fail", - "interaction_answered", - request.vendorIndex, - row, - kUnavailableDefinitionIndex); - return; - } // A rowless 904 is an interaction reply, so its slot names the interaction, not a sale row: // it has no category and no price. state::build_data::vendors::SaleRow sale{}; @@ -885,14 +871,6 @@ void acquire_quest(const middleware::web_service::Message& message, Outcome& out return; } settle_vendor_row(message, quest::kOpcode, request.vendorIndex, row, sale, outcome); - if (rowless && outcome.mutation.index() != 0 && request.vendorIndex >= 0 - && request.vendorIndex <= (std::numeric_limits::max)() - && request.slotIndex >= 0 - && request.slotIndex <= (std::numeric_limits::max)()) { - outcome.answeredInteraction = - state::AnsweredVendorInteraction{static_cast(request.vendorIndex), - static_cast(request.slotIndex)}; - } } /** diff --git a/Sunrise/src/state/runtime/state_runtime.cpp b/Sunrise/src/state/runtime/state_runtime.cpp index 42e21bb13..8e157f2b7 100644 --- a/Sunrise/src/state/runtime/state_runtime.cpp +++ b/Sunrise/src/state/runtime/state_runtime.cpp @@ -355,7 +355,7 @@ bool publish_bootstrap_token(std::span token) noexcept { * and updates every character the account owns. */ void publish_sign_in_time(std::uint64_t seconds) noexcept { - reset_answered_vendor_interactions(); + reset_answered_vendor_interactions(seconds); investment::store::set_sign_in_time(seconds); } diff --git a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp index 12097df0b..838e1e131 100644 --- a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp +++ b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp @@ -24,6 +24,7 @@ constexpr std::size_t kExperienceLane = 0; /** Session-scoped answered vendor interactions. */ std::vector g_answeredInteractions; +std::uint64_t g_currentSessionId = 0; core::threading::SrwLock g_answeredInteractionsLock; [[nodiscard]] std::uint32_t pack_answered_interaction(std::uint16_t vendorIndex, @@ -830,19 +831,25 @@ bool commit_vendor_reputation(PendingVendorReputation& mutation) noexcept { /** * Scans installed vendor sales for a unique zero-cost reward package matching the interaction. + * Evaluates gate conditions across multiple candidates to resolve multi-sale vendors. * @param vendorIndex Installed vendor selector. * @param vendor Installed vendor definition. * @param interaction Installed interaction gate defining category and index. + * @param replyIndex Optional reply index within the interaction. * @param saleIndex Receives the matching sale index on unique resolution. * @return True when exactly one matching reward sale is found. */ bool find_unique_rank_reward_sale(std::uint16_t vendorIndex, const build_data::vendors::Definition& vendor, const build_data::vendors::InteractionGate& interaction, + std::optional replyIndex, std::uint16_t& saleIndex) noexcept { saleIndex = 0; - std::optional matchedSale{}; - std::size_t matchCount = 0; + struct CandidateSale { + std::uint16_t saleIndex{}; + RewardSaleBinding binding{}; + }; + std::vector candidates; for (std::size_t saleIdx = 0; saleIdx < vendor.saleCount; ++saleIdx) { build_data::vendors::SaleRow sale{}; if (!build_data::vendors::sale_row(vendor, static_cast(saleIdx), sale)) { @@ -859,14 +866,27 @@ bool find_unique_rank_reward_sale(std::uint16_t vendorIndex, == VendorReputationDisposition::prepared && binding.interaction.index == interaction.index && binding.categoryIndex == interaction.categoryIndex) { - matchedSale = static_cast(saleIdx); - ++matchCount; + candidates.push_back(CandidateSale{static_cast(saleIdx), binding}); } } - if (matchCount == 1 && matchedSale) { - saleIndex = *matchedSale; + if (candidates.size() == 1) { + saleIndex = candidates.front().saleIndex; return true; } + if (candidates.size() > 1) { + std::optional activeSale{}; + std::size_t activeCount = 0; + for (const auto& candidate : candidates) { + if (reward_gates_pass(candidate.binding, replyIndex)) { + activeSale = candidate.saleIndex; + ++activeCount; + } + } + if (activeCount == 1 && activeSale) { + saleIndex = *activeSale; + return true; + } + } return false; } @@ -914,7 +934,8 @@ prepare_vendor_rank_reward_interaction(std::uint16_t vendorIndex, if (hasInteraction && gateKind == CreditGateKind::claim && build_data::vendors::find(entry.definitionHash, vendor)) { std::uint16_t matchedSaleIndex = 0; - if (find_unique_rank_reward_sale(vendorIndex, vendor, interaction, matchedSaleIndex)) { + if (find_unique_rank_reward_sale( + vendorIndex, vendor, interaction, replyIndex, matchedSaleIndex)) { targetSaleIndex = matchedSaleIndex; targetCategoryIndex = interaction.categoryIndex; targetInteractionIndex = interaction.index; @@ -989,18 +1010,47 @@ bool vendor_rank_reward_current(const VendorRankRewardClaim& claim) noexcept { return true; } -void mark_vendor_interaction_answered(std::uint16_t vendorIndex, +bool reserve_answered_vendor_interaction(std::uint16_t vendorIndex, + std::uint16_t interactionIndex) noexcept { + const std::uint32_t packed = pack_answered_interaction(vendorIndex, interactionIndex); + try { + std::unique_lock lock(g_answeredInteractionsLock); + for (const auto existing : g_answeredInteractions) { + if (existing == packed) { + return true; + } + } + if (g_answeredInteractions.size() >= g_answeredInteractions.capacity()) { + const std::size_t nextCapacity = + g_answeredInteractions.capacity() == 0 ? 16 : g_answeredInteractions.capacity() * 2; + g_answeredInteractions.reserve(nextCapacity); + } + return true; + } catch (...) { + return false; + } +} + +bool mark_vendor_interaction_answered(std::uint16_t vendorIndex, std::uint16_t interactionIndex) noexcept { const std::uint32_t packed = pack_answered_interaction(vendorIndex, interactionIndex); try { std::unique_lock lock(g_answeredInteractionsLock); for (const auto existing : g_answeredInteractions) { if (existing == packed) { - return; + return true; } } + if (g_answeredInteractions.size() >= g_answeredInteractions.capacity()) { + const std::size_t nextCapacity = + g_answeredInteractions.capacity() == 0 ? 16 : g_answeredInteractions.capacity() * 2; + g_answeredInteractions.reserve(nextCapacity); + } g_answeredInteractions.push_back(packed); - } catch (...) {} + return true; + } catch (...) { + return false; + } } bool is_vendor_interaction_answered(std::uint16_t vendorIndex, @@ -1015,9 +1065,15 @@ bool is_vendor_interaction_answered(std::uint16_t vendorIndex, return false; } -void reset_answered_vendor_interactions() noexcept { +void reset_answered_vendor_interactions(std::uint64_t sessionId) noexcept { std::unique_lock lock(g_answeredInteractionsLock); + g_currentSessionId = sessionId; g_answeredInteractions.clear(); } +std::uint64_t current_answered_vendor_session() noexcept { + std::shared_lock lock(g_answeredInteractionsLock); + return g_currentSessionId; +} + } // namespace sunrise::state diff --git a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h index 8870954f3..59859ab61 100644 --- a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h +++ b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h @@ -84,13 +84,18 @@ struct AnsweredVendorInteraction { bool operator==(const AnsweredVendorInteraction&) const = default; }; +/** Guarantees capacity exists to track an answered interaction without failing. */ +[[nodiscard]] bool reserve_answered_vendor_interaction(std::uint16_t vendorIndex, + std::uint16_t interactionIndex) noexcept; /** Marks an interaction answered for the current session. */ -void mark_vendor_interaction_answered(std::uint16_t vendorIndex, - std::uint16_t interactionIndex) noexcept; +[[nodiscard]] bool mark_vendor_interaction_answered(std::uint16_t vendorIndex, + std::uint16_t interactionIndex) noexcept; /** Returns true if the interaction was already answered during this session. */ [[nodiscard]] bool is_vendor_interaction_answered(std::uint16_t vendorIndex, std::uint16_t interactionIndex) noexcept; -/** Clears all session-scoped answered interactions. */ -void reset_answered_vendor_interactions() noexcept; +/** Clears all session-scoped answered interactions and records active session identity. */ +void reset_answered_vendor_interactions(std::uint64_t sessionId = 0) noexcept; +/** Returns the active session identity for answered vendor interactions. */ +[[nodiscard]] std::uint64_t current_answered_vendor_session() noexcept; } // namespace sunrise::state From 54e6287bbb7332e8134db8f9407921ec44683e24 Mon Sep 17 00:00:00 2001 From: Gage Fulwood Date: Thu, 15 Oct 2026 23:22:23 -0500 Subject: [PATCH 57/59] Remove unsupported vendor policy additions --- .../content/vendors/package_vendor_build.cpp | 208 +--------- .../bap/encrypted/body/bap_service_body.cpp | 2 - .../bap/encrypted/encrypted_runtime.cpp | 15 +- Sunrise/src/server/bap/encrypted/internal.h | 388 +++++++++++++++++- .../transactions/service_outcome_commit.cpp | 31 +- .../server/web_service/web_service_runtime.h | 2 - .../web_service_vendor_actions.cpp | 1 - Sunrise/src/state/runtime/runtime.h | 5 +- .../state_account_acquisition_runtime.cpp | 70 +--- .../state_account_reward_grant_runtime.cpp | 1 - Sunrise/src/state/runtime/state_runtime.cpp | 7 +- .../state_vendor_reputation_runtime.cpp | 269 +----------- .../runtime/state_vendor_reputation_runtime.h | 21 - 13 files changed, 428 insertions(+), 592 deletions(-) diff --git a/Sunrise/src/client/content/vendors/package_vendor_build.cpp b/Sunrise/src/client/content/vendors/package_vendor_build.cpp index 73eef1361..879fca423 100644 --- a/Sunrise/src/client/content/vendors/package_vendor_build.cpp +++ b/Sunrise/src/client/content/vendors/package_vendor_build.cpp @@ -1,4 +1,3 @@ -#include #include #include #include @@ -28,42 +27,6 @@ struct ArrayView { std::uint16_t count{}; }; -/** - * 20 core gameplay vendors guaranteed to be loaded before arbitrary sequential - * fill so that priority vendors are never crowded out by high-index catalog entries. - */ -constexpr std::array kPriorityVendors = { - 0x0ED2CB2FU, // The Drifter - 0x280FB4FDU, // Banshee-44 - 0x04243655U, // Commander Zavala - 0xD6C4CCA1U, // Lord Shaxx - 0x86748412U, // Master Rahool - 0x1B731E4FU, // Amanda Holliday - 0xADE600F9U, // Ada-1 - 0x2D9E6DC1U, // Saint-14 - 0x36D32C3CU, // Eva Levante - 0xEE0F473EU, // Prismatic Recaster - 0x6B147C2DU, // Yuna - 0x17A817DEU, // Devrim Kay - 0x3F59FB01U, // Sloane - 0x5DF413A9U, // Failsafe - 0xED6345FDU, // Asher Mir - 0x8EF4CCBAU, // Brother Vance - 0x6770811DU, // Ana Bray - 0x6053823DU, // Eris Morn - 0x337EAF04U, // The Spider - 0x6DC6627CU // Petra Venj -}; - -[[nodiscard]] constexpr bool is_priority_vendor(std::uint32_t hash) noexcept { - for (const std::uint32_t priority : kPriorityVendors) { - if (priority == hash) { - return true; - } - } - return false; -} - /** Every extracted row, kept off the caller stack. */ struct Storage { std::vector blob{}; @@ -73,9 +36,6 @@ struct Storage { std::array definitions{}; std::array saleRows{}; std::array installedRows{}; - std::size_t maxDefinitions{domain::kDefinitionCapacity}; - std::size_t maxSaleRows{domain::kSaleRowCapacity}; - std::size_t maxInstalledRows{domain::kInstalledRowCapacity}; std::size_t indexCount{}; std::size_t definitionCount{}; std::size_t saleRowCount{}; @@ -237,7 +197,7 @@ read_index(const reader::Source& source, reader::Scratch& scratch, Storage& stor [[nodiscard]] bool read_sale_rows(std::span blob, const domain::Definition& definition, Storage& storage) noexcept { - if (definition.saleCount > storage.maxSaleRows - storage.saleRowCount) { + if (definition.saleCount > domain::kSaleRowCapacity - storage.saleRowCount) { return false; } for (std::size_t row = 0; row < definition.saleCount; ++row) { @@ -275,7 +235,7 @@ read_index(const reader::Source& source, reader::Scratch& scratch, Storage& stor [[nodiscard]] bool read_installed_rows(std::span blob, const domain::Definition& definition, Storage& storage) noexcept { - if (definition.installedCount > storage.maxInstalledRows - storage.installedRowCount) { + if (definition.installedCount > domain::kInstalledRowCapacity - storage.installedRowCount) { return false; } for (std::size_t row = 0; row < definition.installedCount; ++row) { @@ -305,7 +265,7 @@ read_index(const reader::Source& source, reader::Scratch& scratch, Storage& stor /** Definition sizes are stored as unsigned 32-bit values. */ constexpr std::size_t kMaximumSize = (std::numeric_limits::max)(); std::uint32_t classId = 0; - if (storage.definitionCount >= storage.maxDefinitions + if (storage.definitionCount == domain::kDefinitionCapacity || !reader::read_tag(source, scratch, entry.definitionTag, storage.blob, classId) || classId != domain::kDefinitionClass || storage.blob.size() > kMaximumSize) { return false; @@ -402,125 +362,6 @@ void report(const Storage& storage, std::size_t skipped, const char* result) noe } } -/** - * Reads priority vendor definitions into destination storage. - * @param source Package directory and borrowed block keys. - * @param scratch Lock-owned block storage. - * @param index Full vendor index to scan. - * @param indexCount Number of valid entries in index. - * @param destination Pre-populated with faction data; receives definitions and rows. - * @return Number of priority entries that could not be read or fit. - */ -std::size_t -fill_priority_vendors(const reader::Source& source, - reader::Scratch& scratch, - const std::array& index, - std::size_t indexCount, - Storage& destination) noexcept { - std::size_t skipped = 0; - for (std::size_t row = 0; row < indexCount; ++row) { - const domain::IndexEntry entry = index[row]; - if (!is_priority_vendor(entry.definitionHash)) { - continue; - } - if (read_definition(source, scratch, entry, destination)) { - continue; - } - ++skipped; - core::log::writef(core::log::Channel::state, - core::log::Level::warn, - "ev=build_data stage=vendors result=skip_priority hash=0x%08X row=%zu", - entry.definitionHash, - row); - } - return skipped; -} - -/** - * Reads non-priority vendor definitions into destination storage up to capacity limits. - * @param source Package directory and borrowed block keys. - * @param scratch Lock-owned block storage. - * @param index Full vendor index to scan. - * @param indexCount Number of valid entries in index. - * @param priority Prior storage whose counts determine remaining capacity. - * @param destination Pre-populated with faction data and capped limits; receives definitions. - * @return Number of sequential entries that could not be read or fit. - */ -std::size_t -fill_sequential_vendors(const reader::Source& source, - reader::Scratch& scratch, - const std::array& index, - std::size_t indexCount, - const Storage& priority, - Storage& destination) noexcept { - std::size_t skipped = 0; - for (std::size_t row = 0; row < indexCount; ++row) { - const domain::IndexEntry entry = index[row]; - if (is_priority_vendor(entry.definitionHash)) { - continue; - } - if (read_definition(source, scratch, entry, destination)) { - continue; - } - ++skipped; - core::log::writef(core::log::Channel::state, - core::log::Level::warn, - "ev=build_data stage=vendors result=skip hash=0x%08X row=%zu " - "definitions=%zu sale=%zu", - entry.definitionHash, - row, - priority.definitionCount + destination.definitionCount, - priority.saleRowCount + destination.saleRowCount); - } - return skipped; -} - -/** - * Merges priority and sequential vendor rows into dest in strictly ascending catalog order. - * Offsets in each definition are rebased to the destination running counts before insertion. - * @param priority Priority-vendor definitions and rows. - * @param sequential Sequential-vendor definitions and rows. - * @param dest Receives all definitions and rows in ascending index order. - */ -void merge_vendor_catalogs(const Storage& priority, - const Storage& sequential, - Storage& dest) noexcept { - std::size_t priorityIdx = 0; - std::size_t sequentialIdx = 0; - while (priorityIdx < priority.definitionCount || sequentialIdx < sequential.definitionCount) { - const bool takePriority = (sequentialIdx >= sequential.definitionCount) - || (priorityIdx < priority.definitionCount - && priority.definitions[priorityIdx].index - < sequential.definitions[sequentialIdx].index); - - const Storage& src = takePriority ? priority : sequential; - std::size_t& srcIdx = takePriority ? priorityIdx : sequentialIdx; - - domain::Definition def = src.definitions[srcIdx]; - const std::size_t srcSaleOffset = def.saleRowOffset; - const std::size_t srcInstalledOffset = def.installedRowOffset; - - def.saleRowOffset = static_cast(dest.saleRowCount); - def.installedRowOffset = static_cast(dest.installedRowCount); - - if (def.saleCount > 0) { - std::copy_n( - &src.saleRows[srcSaleOffset], def.saleCount, &dest.saleRows[dest.saleRowCount]); - dest.saleRowCount += def.saleCount; - } - if (def.installedCount > 0) { - std::copy_n(&src.installedRows[srcInstalledOffset], - def.installedCount, - &dest.installedRows[dest.installedRowCount]); - dest.installedRowCount += def.installedCount; - } - dest.definitions[dest.definitionCount++] = def; - ++srcIdx; - } - dest.conditionalRows = priority.conditionalRows + sequential.conditionalRows; - dest.unreadableRows = priority.unreadableRows + sequential.unreadableRows; -} - } // namespace /** Extracts and publishes the vendor catalog from the installed packages. */ @@ -541,31 +382,24 @@ bool build(const reader::Source& source, reader::Scratch& scratch) noexcept { report(storage, 0, "index"); return false; } - - // Priority vendor definitions are extracted first to guarantee capacity. - static Storage priorityStorage{}; - priorityStorage = {}; - priorityStorage.factionBlob = storage.factionBlob; - priorityStorage.factionRows = storage.factionRows; - std::size_t skipped = - fill_priority_vendors(source, scratch, storage.index, storage.indexCount, priorityStorage); - - // Remaining capacity is filled with non-priority vendors. - static Storage sequentialStorage{}; - sequentialStorage = {}; - sequentialStorage.factionBlob = storage.factionBlob; - sequentialStorage.factionRows = storage.factionRows; - sequentialStorage.maxDefinitions = - domain::kDefinitionCapacity - priorityStorage.definitionCount; - sequentialStorage.maxSaleRows = domain::kSaleRowCapacity - priorityStorage.saleRowCount; - sequentialStorage.maxInstalledRows = - domain::kInstalledRowCapacity - priorityStorage.installedRowCount; - skipped += fill_sequential_vendors( - source, scratch, storage.index, storage.indexCount, priorityStorage, sequentialStorage); - - // Definitions and rows are merged in strictly ascending index order. - merge_vendor_catalogs(priorityStorage, sequentialStorage, storage); - + // Walk the index in order: the catalog requires ascending definition order. A definition that + // will not read or will not fit costs that vendor alone, never the whole pass. + std::size_t skipped = 0; + for (std::size_t row = 0; row < storage.indexCount; ++row) { + const domain::IndexEntry entry = storage.index[row]; + if (read_definition(source, scratch, entry, storage)) { + continue; + } + ++skipped; + core::log::writef(core::log::Channel::state, + core::log::Level::warn, + "ev=build_data stage=vendors result=skip hash=0x%08X row=%zu " + "definitions=%zu sale=%zu", + entry.definitionHash, + row, + storage.definitionCount, + storage.saleRowCount); + } const bool published = state::build_data::publish_vendor_catalog( std::span(storage.index).first(storage.indexCount), std::span(storage.definitions).first(storage.definitionCount), 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 20408a972..b9c9519fe 100644 --- a/Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp +++ b/Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp @@ -478,7 +478,6 @@ bool process(const ServiceRoute& route, } transaction->pending = web_service::take_mutation(webOutcome); - transaction->answeredInteraction = webOutcome.answeredInteraction; } if (profileItemAcquisition != nullptr) { // Actionable profile stacks may add a resident in the same revision. @@ -511,7 +510,6 @@ bool process(const ServiceRoute& route, } transaction->pending = web_service::take_mutation(webOutcome); - transaction->answeredInteraction = webOutcome.answeredInteraction; } if (itemDismantle != nullptr) { // Promise the revision carrying the character update and resident release. diff --git a/Sunrise/src/server/bap/encrypted/encrypted_runtime.cpp b/Sunrise/src/server/bap/encrypted/encrypted_runtime.cpp index 510625f43..d741cc617 100644 --- a/Sunrise/src/server/bap/encrypted/encrypted_runtime.cpp +++ b/Sunrise/src/server/bap/encrypted/encrypted_runtime.cpp @@ -11,7 +11,6 @@ #include "../../../middleware/web_service/messages/opcode206.h" #include "../../../state/activity/bubble_authority/runtime.h" #include "../../../state/runtime/runtime.h" -#include "../../../state/runtime/state_vendor_reputation_runtime.h" #include "../../activity/host_runtime.h" #include "../../gameplay/peer/peer_transport.h" #include "../../gameplay/squad_entity_retirement.h" @@ -435,14 +434,7 @@ bool consume(Session& session, if (!retirementValid) { commitReason = "entity_retirement_stale"; } - const bool trackerReady = !outcome.answeredInteraction.has_value() - || state::reserve_answered_vendor_interaction( - outcome.answeredInteraction->vendorIndex, - outcome.answeredInteraction->interactionIndex); - if (!trackerReady) { - commitReason = "tracker_capacity"; - } - handled = fits && retirementValid && trackerReady + handled = fits && retirementValid && transactions::commit(outcome, publication, commitReason) && investmentTransaction.commit(); if (!handled) { @@ -450,11 +442,6 @@ bool consume(Session& session, frame.serviceId, "commit", fits ? commitReason : "frame_capacity"); } if (handled) { - if (outcome.answeredInteraction) { - (void)state::mark_vendor_interaction_answered( - outcome.answeredInteraction->vendorIndex, - outcome.answeredInteraction->interactionIndex); - } std::copy_n(scratch.framed.begin(), framedSize, response.begin()); written = framedSize; entityLease.release(); diff --git a/Sunrise/src/server/bap/encrypted/internal.h b/Sunrise/src/server/bap/encrypted/internal.h index 5830284cf..479193418 100644 --- a/Sunrise/src/server/bap/encrypted/internal.h +++ b/Sunrise/src/server/bap/encrypted/internal.h @@ -23,203 +23,302 @@ #include "transactions/definition.h" namespace sunrise::server::bap::encrypted { + /** Response-body codecs picked by the authenticated request service. */ + enum class BodyCodec : std::uint8_t { + empty, + accountTranslationResponse, + activityHostManagerResponse, + activityMessageRequest, + activityHostResponse, + clientConfigResponse, + familySubscription, + familyUnsubscription, + matchmakingResponse, + steamCertificate, + userMessageResponse, + webService, + }; /** Equipment mutation and the exact QueueZ after-image promised by its response. */ + struct EquipmentSwapTransaction { + std::unique_ptr pending{}; + queuez::EquipmentSwap update{}; }; /** Socket mutation and the exact QueueZ after-image promised by its response. */ + struct SocketPlugTransaction { + std::unique_ptr pending{}; + queuez::SocketPlug update{}; }; /** Subclass ability selection and the exact QueueZ after-image promised by its response. */ + struct SubclassSelectionTransaction { + std::unique_ptr pending{}; + queuez::SubclassSelection update{}; }; /** Item-state mutation and the exact QueueZ character after-image promised by its response. */ + struct ItemStateTransaction { + std::unique_ptr pending{}; + queuez::EquipmentSwap update{}; }; /** Artifact purchase and the exact selected-character after-image promised by opcode 901. */ + struct ArtifactPurchaseTransaction { + std::unique_ptr pending{}; + queuez::EquipmentSwap update{}; }; -/** - * Character acquisition, its exact QueueZ after-image, and optional answered vendor interaction - * staged until durable transaction commit. - */ +/** Character acquisition and its exact QueueZ after-image. */ + struct ItemAcquisitionTransaction { + std::unique_ptr pending{}; + queuez::ItemAcquisition update{}; - /** Answered vendor interaction staged until durable transaction commit. */ - std::optional answeredInteraction{}; }; -/** - * Profile acquisition, its exact account/resident QueueZ after-image, and optional answered vendor - * interaction staged until durable transaction commit. - */ +/** Profile acquisition and its exact account/resident QueueZ after-image. */ + struct ProfileItemAcquisitionTransaction { + std::unique_ptr pending{}; + queuez::ProfileItemAcquisition update{}; - /** Answered vendor interaction staged until durable transaction commit. */ - std::optional answeredInteraction{}; }; /** Dismantle mutation and its exact QueueZ after-image. */ + struct ItemDismantleTransaction { + std::unique_ptr pending{}; + queuez::ItemDismantle update{}; }; -/** - * Record-claim reward batch and its exact Queuez after-image. - * If claiming a vendor rank reward, any exhausted interaction is staged in ServiceOutcome - * upon commit and marked answered only after the outer transaction commits. - */ +/** Record-claim reward batch and its exact Queuez after-image. */ + struct RecordRewardGrantTransaction { + std::unique_ptr pending{}; + queuez::RecordRewardGrant update{}; }; /** Season reward grant plus the exact Queuez after-image promised by opcode 2400. */ + struct SeasonPassRewardTransaction { + std::unique_ptr pending{}; + queuez::RecordRewardGrant update{}; }; /** Optional side effect produced while decoding one authenticated service body. */ + struct ServiceOutcome { + bool hasSubscription{}; /** The ws-206 reply already carries the family's first snapshot, so no push repeats it. */ bool subscriptionAnswered{}; + /** A Triumph claim changed the account flag bank and its image has to follow. */ + bool hasRecordClaim{}; + bool hasArtifactReset{}; + state::ArtifactResetResult artifactReset{}; + middleware::queuez::Subscription subscription{}; + bool hasUnsubscription{}; + middleware::bap::family_unsubscription::Request unsubscription{}; + bool hasChangeCharacter{}; + bool hasSelectCharacter{}; - /** Vendor interaction marked answered after the enclosing investment transaction commits. */ - std::optional answeredInteraction{}; + /** One service owns at most one independently versioned, exact-sized transaction. */ + using Transaction = std::variant, + std::unique_ptr, + std::unique_ptr, + std::unique_ptr, + std::unique_ptr, + std::unique_ptr, + std::unique_ptr, + std::unique_ptr, + std::unique_ptr, + std::unique_ptr, + std::unique_ptr, + std::unique_ptr, + std::unique_ptr, + std::unique_ptr, + std::unique_ptr, + std::unique_ptr, std::unique_ptr>; + Transaction transaction{}; }; /** Allocates only the selected transaction outside the request's already deep call stack. */ template [[nodiscard]] Transaction* emplace_transaction(ServiceOutcome& outcome, Args&&... args) noexcept { + static_assert(std::is_nothrow_constructible_v); + outcome.transaction.template emplace(); + auto storage = + std::unique_ptr{new (std::nothrow) Transaction(std::forward(args)...)}; + if (storage == nullptr) { + return nullptr; } + auto* transaction = storage.get(); + outcome.transaction.template emplace>(std::move(storage)); + return transaction; } /** @return The service transaction of the requested type, or null for another route. */ + template + [[nodiscard]] Transaction* transaction_if(ServiceOutcome& outcome) noexcept { + auto* storage = std::get_if>(&outcome.transaction); + return storage == nullptr ? nullptr : storage->get(); } /** @return The service transaction of the requested type, or null for another route. */ + template + [[nodiscard]] const Transaction* transaction_if(const ServiceOutcome& outcome) noexcept { + const auto* storage = std::get_if>(&outcome.transaction); + return storage == nullptr ? nullptr : storage->get(); } [[nodiscard]] inline bool has_transaction(const ServiceOutcome& outcome) noexcept { + return outcome.transaction.index() != 0; } inline void clear_transaction(ServiceOutcome& outcome) noexcept { + outcome.transaction.template emplace(); } /** Outbound delivery behavior picked for one authenticated request service. */ + enum class ResponseMode : std::uint8_t { + none, + reply, + /** Processes a request body and may emit notifications without a status response. */ + uncorrelatedPush, + }; /** Static response metadata for one supported encrypted request service. */ + struct ServiceRoute { + ResponseMode responseMode{}; + middleware::bap::ResponseService response{}; + BodyCodec bodyCodec{}; }; /** Owns encrypted service-to-response routing. */ + namespace routing { + [[nodiscard]] bool resolve(std::uint16_t request, ServiceRoute& route) noexcept; + } // namespace routing /** Owns failure reporting for encrypted requests. */ + namespace diagnostics { + void report_failure(std::uint16_t service, std::string_view stage) noexcept; /** Reports one refusal that names which branch refused. */ + void report_failure(std::uint16_t service, + std::string_view stage, + std::string_view reason) noexcept; + } // namespace diagnostics /** Owns correlated reply construction for one authenticated request. */ + namespace reply { + /** * Encodes, seals, and frames one correlated status-200 reply. * @param scratch Lock-owned transform buffers. @@ -231,17 +330,27 @@ namespace reply { * @param framedSize Receives the complete outer-frame size, or zero on failure. * @return True when the payload, seal, and outer frame all fit. */ + [[nodiscard]] bool encode(Scratch& scratch, + const ServiceRoute& route, + std::uint32_t taskId, + std::span key, + std::span nonce, + std::span body, + std::size_t& framedSize) noexcept; + } // namespace reply /** Owns request-body processing for an encrypted service route. */ + namespace body { + /** * Processes a request body and encodes a correlated body when the route needs one. * @param route Service route data found earlier. @@ -255,26 +364,40 @@ namespace body { * @param outcome Receives one validated transport action or deferred State transaction. * @return True when the chosen body codec succeeds. */ + [[nodiscard]] bool process(const ServiceRoute& route, + const queuez::SessionState& queuezState, + const ActivityClientBinding& activity, + const RosterDecodeMap& rosterDecode, + state::matchmaking::ContextHandle matchmakingContext, + std::span requestBody, + std::span output, + std::size_t& written, + ServiceOutcome& outcome, + std::span presentation = {}) noexcept; + } // namespace body /** Owns server-initiated encrypted frames appended after correlated replies. */ + namespace push { + /** * Canonicalizes the account before any family builder reads it. * Families 0, 3 and 4 each snapshot the account, so a migration inside one builder would leave * the others describing a different account. Idempotent. */ + void ensure_account_canonical() noexcept; /** @@ -292,15 +415,25 @@ void ensure_account_canonical() noexcept; * @param armsRepush Receives whether the Family-4 companion needs its delayed second copy. * @param armsBannerRepush Receives whether a family-zero body needs its delayed second copy. */ + void append_queuez_notification(Scratch& scratch, + const queuez::SessionState& before, + const middleware::queuez::Subscription& subscription, + std::span key, + std::array& nonce, + std::span response, + std::size_t& written, + queuez::SessionState& after, + bool& armsRepush, + bool& armsBannerRepush, bool ownSnapshotAnswered = false) noexcept; @@ -322,26 +455,45 @@ void append_queuez_notification(Scratch& scratch, std::size_t& bodySize) noexcept; /** Appends one next-version full Family-4 snapshot used to resynchronize another peer. */ + [[nodiscard]] bool append_account_resync_notification( + Scratch& scratch, + const queuez::SessionState& before, + std::span acquisitionPresentationRows, + std::span key, + std::array& 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( + Scratch& scratch, + const queuez::SessionState& before, + const queuez::RecordRewardGrant& update, + const state::PendingRecordRewardGrant& mutation, + std::span acquisitionPresentationRows, + std::span key, + std::span nonce, + std::span response, + std::size_t& written) noexcept; /** @@ -358,13 +510,21 @@ void append_queuez_notification(Scratch& scratch, * @param after Receives the state carrying the recorded delivery. * @return True when the banner frame is appended. */ + [[nodiscard]] bool append_banner_notification(Scratch& scratch, + const queuez::SessionState& before, + std::uint64_t familyRootSoid, + std::span key, + std::array& nonce, + std::span response, + std::size_t& written, + queuez::SessionState& after) noexcept; /** @@ -380,14 +540,23 @@ void append_queuez_notification(Scratch& scratch, * @param after Receives the state published once the frame is copied. * @return True when a frame went out and `after` carries the advanced ladder. */ + [[nodiscard]] bool + append_banner_move_notification(Scratch& scratch, + const queuez::SessionState& before, + std::uint64_t selectedCharacter, + std::span key, + std::array& nonce, + std::span response, + std::size_t& written, + queuez::SessionState& after) noexcept; /** @@ -400,12 +569,19 @@ append_banner_move_notification(Scratch& scratch, * @param written Existing byte count, updated after the complete push is appended. * @return True when the exact 17-byte patch and complete svc-123 frame fit. */ + [[nodiscard]] bool + append_change_character_notification(Scratch& scratch, + const queuez::ChangeCharacter& change, + std::span key, + std::span nonce, + std::span response, + std::size_t& written) noexcept; /** @@ -418,45 +594,79 @@ append_change_character_notification(Scratch& scratch, * @param written Existing byte count, updated after the complete push is appended. * @return True when the 3 object operations and the whole svc-123 frame fit. */ + [[nodiscard]] bool + append_select_character_notification(Scratch& scratch, + const queuez::SelectCharacter& select, + std::span key, + std::span nonce, + std::span response, + std::size_t& written) noexcept; /** Appends the opcode-403 Family-4 character upsert that exposes the equipped item swap. */ + [[nodiscard]] bool append_equipment_swap_notification( + Scratch& scratch, + const queuez::EquipmentSwap& swap, + const state::PendingEquipmentSwap& mutation, + std::span acquisitionPresentationRows, + std::span key, + std::span nonce, + std::span response, + std::size_t& written) noexcept; /** Appends the opcode-406 Family-4 character upsert carrying changed inventory-row flags. */ + [[nodiscard]] bool append_item_state_notification( + Scratch& scratch, + const queuez::EquipmentSwap& update, + const state::PendingItemState& mutation, + std::span acquisitionPresentationRows, + std::span key, + std::span nonce, + std::span response, + std::size_t& written) noexcept; /** Appends the selected-character upsert carrying one artifact ownership transition. */ + [[nodiscard]] bool append_artifact_purchase_notification( + Scratch& scratch, + const queuez::EquipmentSwap& update, + const state::PendingArtifactPurchase& mutation, + std::span acquisitionPresentationRows, + std::span key, + std::span nonce, + std::span response, + std::size_t& written) noexcept; /** @@ -469,179 +679,319 @@ append_select_character_notification(Scratch& scratch, * @param written Existing byte count, updated by a complete frame. * @return True when State encodes and the whole frame fits. */ + [[nodiscard]] bool + append_family5_override_notification(Scratch& scratch, + std::int32_t version, + std::span key, + std::span nonce, + std::span response, + std::size_t& written) noexcept; /** Appends the account and selected-character state changed by an artifact reset. */ + [[nodiscard]] bool + append_artifact_reset_notification(Scratch& scratch, + const queuez::EquipmentSwap& update, + std::span key, + std::span nonce, + std::span response, + std::size_t& written) noexcept; /** Appends one current item resident after artifact reset cleared an authored socket. */ + [[nodiscard]] bool + append_artifact_item_refresh_notification(Scratch& scratch, + const queuez::EquipmentSwap& update, + std::uint64_t instanceSoid, + std::span key, + std::span nonce, + std::span response, + std::size_t& written) noexcept; /** * Appends the same-character Family-0 appearance upsert paired with one equipment swap. * The update owns its nonce advance only after the complete notification fits. */ + [[nodiscard]] bool + append_equipment_appearance_refresh_notification(Scratch& scratch, + const queuez::CharacterAppearanceRefresh& refresh, + const state::PendingEquipmentSwap& mutation, + std::span key, + std::array& nonce, + std::span response, + std::size_t& written) noexcept; /** Appends the Family-0 refresh owed by a socket change on an equipped item. */ + [[nodiscard]] bool + append_socket_appearance_refresh_notification(Scratch& scratch, + const queuez::CharacterAppearanceRefresh& refresh, + const state::PendingSocketPlug& mutation, + std::span key, + std::array& nonce, + std::span response, + std::size_t& written) noexcept; /** Appends the Family-0 character ability refresh owed by a subclass selection. */ + [[nodiscard]] bool + append_subclass_appearance_refresh_notification(Scratch& scratch, + const queuez::CharacterAppearanceRefresh& refresh, + const state::PendingSubclassSelection& mutation, + std::span key, + std::array& nonce, + std::span response, + std::size_t& written) noexcept; /** Appends a Family-3 character record followed by the changed account roster after equip. */ + [[nodiscard]] bool + append_equipment_roster_refresh_notification(Scratch& scratch, + const queuez::RosterAppearanceRefresh& refresh, + const state::PendingEquipmentSwap& mutation, + std::span key, + std::array& nonce, + std::span response, + std::size_t& written) noexcept; /** Appends a Family-3 character-only appearance refresh after an equipped socket change. */ + [[nodiscard]] bool + append_socket_roster_refresh_notification(Scratch& scratch, + const queuez::RosterAppearanceRefresh& refresh, + const state::PendingSocketPlug& mutation, + std::span key, + std::array& nonce, + std::span response, + std::size_t& written) noexcept; /** Appends a Family-3 character-only appearance refresh after a subclass selection. */ + [[nodiscard]] bool + append_subclass_roster_refresh_notification(Scratch& scratch, + const queuez::RosterAppearanceRefresh& refresh, + const state::PendingSubclassSelection& mutation, + std::span key, + std::array& nonce, + std::span response, + std::size_t& written) noexcept; /** Refreshes the selected character's complete Family-0 appearance from committed State. */ + [[nodiscard]] bool + append_account_resync_appearance_notification(Scratch& scratch, + const queuez::SessionState& before, + std::span key, + std::array& nonce, + std::span response, + std::size_t& written, + queuez::SessionState& after) noexcept; /** Refreshes the selected character and optionally its account roster from committed State. */ + [[nodiscard]] bool + append_account_resync_roster_notification(Scratch& scratch, + const queuez::SessionState& before, + std::span key, + std::array& nonce, + std::span response, + std::size_t& written, + queuez::SessionState& after, bool includeRoster = true) noexcept; /** Appends the opcode-903 Family-4 item-instance upsert exposing one socket selection. */ + [[nodiscard]] bool + append_socket_plug_notification(Scratch& scratch, + const queuez::SocketPlug& socketPlug, + const state::PendingSocketPlug& mutation, + std::span key, + std::span nonce, + std::span response, + std::size_t& written) noexcept; /** Appends the opcode-801 Family-4 subclass item-instance upsert. */ + [[nodiscard]] bool + append_subclass_selection_notification(Scratch& scratch, + const queuez::SubclassSelection& selection, + const state::PendingSubclassSelection& mutation, + std::span key, + std::span nonce, + std::span response, + std::size_t& written) noexcept; /** Appends a Family-4 character upsert plus newly acquired item-instance upsert. */ + [[nodiscard]] bool append_item_acquisition_notification( + Scratch& scratch, + const queuez::ItemAcquisition& acquisition, + const state::PendingItemAcquisition& mutation, + std::span acquisitionPresentationRows, + std::span key, + std::span nonce, + std::span response, + std::size_t& written) noexcept; /** Appends one full Family-4 account upsert for a profile-stack acquisition. */ + [[nodiscard]] bool + append_profile_item_acquisition_notification(Scratch& scratch, + const queuez::ProfileItemAcquisition& acquisition, + const state::PendingProfileItemAcquisition& mutation, + std::span key, + std::span nonce, + std::span response, + std::size_t& written) noexcept; /** Appends the character/account increment that makes one seasonal XP gain visible in the HUD. */ + [[nodiscard]] bool append_seasonal_experience_notification( + Scratch& scratch, + const queuez::SessionState& before, + std::int32_t amount, + std::int32_t mutationSerial, + std::span acquisitionPresentationRows, + std::span key, + std::span nonce, + std::span response, + std::size_t& written, + queuez::SessionState& after) noexcept; /** Appends a Family-4 character upsert followed by one empty item-instance release. */ + [[nodiscard]] bool + append_item_dismantle_notification(Scratch& scratch, + const queuez::ItemDismantle& dismantle, + const state::PendingItemDismantle& mutation, + std::span key, + std::span nonce, + std::span response, + std::size_t& written) noexcept; + } // namespace push + } // namespace sunrise::server::bap::encrypted diff --git a/Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp b/Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp index 80735c229..00d816f11 100644 --- a/Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp +++ b/Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp @@ -8,7 +8,6 @@ #include "../../../../state/activity/runtime.h" #include "../../../../state/matchmaking/matchmaking_state.h" #include "../../../../state/runtime/runtime.h" -#include "../../../../state/runtime/state_vendor_reputation_runtime.h" #include "../bap_connection_publication.h" #include "../internal.h" @@ -277,9 +276,6 @@ bool commit(ServiceOutcome& outcome, Publication& publication, const char*& reas if (auto* transaction = transaction_if(outcome)) { const bool committed = transaction->pending != nullptr && state::commit_item_acquisition(*transaction->pending); - if (committed && transaction->answeredInteraction) { - outcome.answeredInteraction = transaction->answeredInteraction; - } core::log::write(core::log::Channel::server, committed ? core::log::Level::debug : core::log::Level::warn, committed ? "ev=acquire stage=transaction_commit result=ok" @@ -315,9 +311,6 @@ bool commit(ServiceOutcome& outcome, Publication& publication, const char*& reas if (auto* transaction = transaction_if(outcome)) { const bool committed = transaction->pending != nullptr && state::commit_profile_item_acquisition(*transaction->pending); - if (committed && transaction->answeredInteraction) { - outcome.answeredInteraction = transaction->answeredInteraction; - } core::log::write(core::log::Channel::server, committed ? core::log::Level::debug : core::log::Level::warn, committed ? "ev=profile_acquire stage=transaction_commit result=ok" @@ -349,27 +342,9 @@ bool commit(ServiceOutcome& outcome, Publication& publication, const char*& reas } if (auto* transaction = transaction_if(outcome)) { reason = "record_reward"; - if (transaction->pending == nullptr) { - return false; - } - const auto& vendorReward = transaction->pending->vendorReward; - const bool willExhaustCredit = vendorReward.beforeCredits == 1; - const bool hasVendorInteraction = - vendorReward.beforeCredits > 0 && vendorReward.replyIndex.has_value(); - if (willExhaustCredit && hasVendorInteraction - && !state::reserve_answered_vendor_interaction(vendorReward.vendorIndex, - vendorReward.interactionIndex)) { - reason = "tracker_capacity"; - return false; - } - const bool committed = - report_commit(state::commit_record_reward(*transaction->pending), - "ev=record_reward stage=transaction_commit result=fail"); - if (committed && willExhaustCredit && hasVendorInteraction) { - outcome.answeredInteraction = state::AnsweredVendorInteraction{ - vendorReward.vendorIndex, vendorReward.interactionIndex}; - } - return committed; + return transaction->pending != nullptr + && report_commit(state::commit_record_reward(*transaction->pending), + "ev=record_reward stage=transaction_commit result=fail"); } return true; } diff --git a/Sunrise/src/server/web_service/web_service_runtime.h b/Sunrise/src/server/web_service/web_service_runtime.h index 3e05971fd..a197ec7bf 100644 --- a/Sunrise/src/server/web_service/web_service_runtime.h +++ b/Sunrise/src/server/web_service/web_service_runtime.h @@ -32,8 +32,6 @@ struct Outcome { state::ArtifactResetResult artifactReset{}; /** A WS-701 carried the profile-setup completion marker and State refused it. */ bool profileSetupRefused{}; - /** Answered vendor interaction staged until durable transaction commit. */ - std::optional answeredInteraction{}; /** A request prepares at most one State mutation and allocates only that exact payload. */ using Mutation = std::variant, diff --git a/Sunrise/src/server/web_service/web_service_vendor_actions.cpp b/Sunrise/src/server/web_service/web_service_vendor_actions.cpp index 18117a48d..07e5cea7d 100644 --- a/Sunrise/src/server/web_service/web_service_vendor_actions.cpp +++ b/Sunrise/src/server/web_service/web_service_vendor_actions.cpp @@ -20,7 +20,6 @@ #include "../../state/build_data/vendors/repeatable_triggers.h" #include "../../state/build_data/vendors/vendor_catalog.h" #include "../../state/runtime/runtime.h" -#include "../../state/runtime/state_vendor_reputation_runtime.h" #include "internal_actions.h" #include "web_service_actions.h" diff --git a/Sunrise/src/state/runtime/runtime.h b/Sunrise/src/state/runtime/runtime.h index 7030c2cd3..df08f4596 100644 --- a/Sunrise/src/state/runtime/runtime.h +++ b/Sunrise/src/state/runtime/runtime.h @@ -444,9 +444,8 @@ void shutdown() noexcept; [[nodiscard]] bool publish_bootstrap_token(std::span token) noexcept; /** - * Records when the account signed in and resets session-scoped answered vendor interactions. - * Every character record publishes this as its last applied daily and weekly reset, and each - * successful SignOn begins a new answered-interaction session. + * Records when the account signed in. + * Every character record publishes this as its last applied daily and weekly reset. * @param seconds Unix seconds taken when the SignOn success is answered. */ void publish_sign_in_time(std::uint64_t seconds) noexcept; diff --git a/Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp b/Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp index 81ea7e92f..65fd12f2e 100644 --- a/Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp +++ b/Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp @@ -397,53 +397,6 @@ bool preview_item_acquisition(const PendingItemAcquisition& mutation, return true; } -namespace { - -constexpr std::uint32_t kRedWarHomecomingHash = 0x37DD26F0U; -constexpr std::uint32_t kCurseOfOsirisGatewayHash = 0x6706D3ECU; -constexpr std::uint32_t kWarmindIceAndShadowHash = 0xF5B78E7FU; - -constexpr std::uint16_t kRedWarValueSlot = 12578; -constexpr std::uint16_t kCurseOfOsirisValueSlot = 12609; -constexpr std::uint16_t kWarmindValueSlot = 12624; - -[[nodiscard]] std::uint16_t legacy_campaign_value_slot(std::uint32_t definitionHash) noexcept { - switch (definitionHash) { - case kRedWarHomecomingHash: - return kRedWarValueSlot; - case kCurseOfOsirisGatewayHash: - return kCurseOfOsirisValueSlot; - case kWarmindIceAndShadowHash: - return kWarmindValueSlot; - default: - return 0; - } -} - -[[nodiscard]] bool settle_legacy_content_override(std::uint32_t definitionHash) noexcept { - const std::uint16_t slot = legacy_campaign_value_slot(definitionHash); - if (slot == 0) { - return true; - } - Family5State family{}; - if (!investment::store::read_family5(family)) { - return false; - } - for (std::size_t i = 0; i < family.valueCount; ++i) { - if (family.values[i].slot == slot) { - family.values[i].value = 1; - return investment::store::write_family5(family); - } - } - if (family.valueCount < family.values.size()) { - family.values[family.valueCount++] = {slot, 1}; - return investment::store::write_family5(family); - } - return false; -} - -} // namespace - /** * Inventory and first-step state share one transaction; failure rolls both back. * @param mutation Prepared grant consumed on either success or failure. @@ -465,9 +418,6 @@ bool commit_item_acquisition(PendingItemAcquisition& mutation) noexcept { && !investment::store::write_unlock(quest_bank(quest), quest.row, quest.value)) { return false; } - if (!settle_legacy_content_override(prepared.acquiredDefinitionHash)) { - return false; - } return transaction.commit(); } @@ -694,17 +644,19 @@ bool commit_profile_item_acquisition(PendingProfileItemAcquisition& mutation) no if (!valid_profile_mutation_shape(prepared)) { return false; } - investment::store::Transaction transaction; - if (!transaction.ready()) { - return false; - } + + investment::store::g_mutex.lock(); AccountState candidate{}; - if (!materialize_profile_acquisition(investment::store::account(), prepared, candidate) - || !investment::store::write_account(candidate) - || !settle_legacy_content_override(prepared.acquiredDefinitionHash)) { - return false; + const bool ready = + materialize_profile_acquisition(investment::store::account(), prepared, candidate); + if (ready) { + if (!investment::store::write_account(candidate)) { + investment::store::g_mutex.unlock(); + return false; + } } - return transaction.commit(); + investment::store::g_mutex.unlock(); + return ready; } } // namespace sunrise::state 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 365caeea2..ccdb20210 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 "../unlocks/unlocks_records.h" #include "runtime.h" #include "state_account_transaction_helpers.h" -#include "state_vendor_reputation_runtime.h" #include "storage/internal.h" namespace sunrise::state { diff --git a/Sunrise/src/state/runtime/state_runtime.cpp b/Sunrise/src/state/runtime/state_runtime.cpp index 8e157f2b7..61e9dbe34 100644 --- a/Sunrise/src/state/runtime/state_runtime.cpp +++ b/Sunrise/src/state/runtime/state_runtime.cpp @@ -23,7 +23,6 @@ #include "runtime.h" #include "state.h" #include "state_account_transaction_helpers.h" -#include "state_vendor_reputation_runtime.h" #include "storage/internal.h" namespace sunrise::state { @@ -350,12 +349,8 @@ bool publish_bootstrap_token(std::span token) noexcept { return true; } -/** - * Records when the account signed in, resets session-scoped answered vendor interactions, - * and updates every character the account owns. - */ +/** Records when the account signed in, on every character the account owns. */ void publish_sign_in_time(std::uint64_t seconds) noexcept { - reset_answered_vendor_interactions(seconds); investment::store::set_sign_in_time(seconds); } diff --git a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp index 838e1e131..bccc0be43 100644 --- a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp +++ b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp @@ -2,11 +2,8 @@ #include #include -#include #include -#include -#include "../../core/threading/srw_lock.h" #include "../../middleware/crypto/random_bytes.h" #include "../build_data/progressions/progression_catalog.h" #include "../build_data/vendors/reconstructed_rank_claim_links.h" @@ -22,16 +19,6 @@ namespace { /** Native progression level walks read experience from lane zero. */ constexpr std::size_t kExperienceLane = 0; -/** Session-scoped answered vendor interactions. */ -std::vector g_answeredInteractions; -std::uint64_t g_currentSessionId = 0; -core::threading::SrwLock g_answeredInteractionsLock; - -[[nodiscard]] std::uint32_t pack_answered_interaction(std::uint16_t vendorIndex, - std::uint16_t interactionIndex) noexcept { - return (static_cast(vendorIndex) << 16) | interactionIndex; -} - /** Saved unlocks and Family-5 overrides visible to one native vendor gate. */ struct GateValues { const build_data::vendors::Gate& gate; @@ -223,12 +210,11 @@ CreditGateKind credit_gate(const build_data::vendors::InteractionGate& interacti return CreditGateKind::malformed; } row = found->row; - const std::span remainder = program.subspan(prefixLength); - if (std::any_of( - remainder.begin(), remainder.end(), [](const unlocks::Instruction& instruction) { - return instruction.opcode == vendors::Opcode::lessThan - || instruction.opcode == vendors::Opcode::lessOrEqual; - })) { + const auto remainder = program.subspan(prefixLength); + if (std::any_of(remainder.begin(), remainder.end(), [](const auto& instruction) { + return instruction.opcode == vendors::Opcode::lessThan + || instruction.opcode == vendors::Opcode::lessOrEqual; + })) { return CreditGateKind::warning; } return CreditGateKind::claim; @@ -328,40 +314,6 @@ bool find_rank_claim_interaction(const build_data::vendors::Definition& vendor, return found; } -/** - * Scans third-array interaction gates for a unique credit claim row when no link is reconstructed. - * @param vendor Installed vendor definition. - * @param creditRow Receives the saved character credit row on unique resolution. - * @return True when exactly one claim gate is found. - */ -bool find_dynamic_rank_credit_row(const build_data::vendors::Definition& vendor, - std::uint16_t& creditRow) noexcept { - creditRow = 0; - std::uint16_t dynamicRow = 0; - std::size_t claimCount = 0; - for (std::size_t index = 0; index < vendor.thirdCount; ++index) { - build_data::vendors::InteractionGate candidate{}; - if (!build_data::vendors::find_interaction_gate( - vendor.definitionHash, static_cast(index), candidate)) { - continue; - } - std::uint16_t candidateRow = 0; - if (credit_gate(candidate, candidateRow) == CreditGateKind::claim) { - if (dynamicRow != 0 && dynamicRow != candidateRow) { - creditRow = 0; - return false; - } - dynamicRow = candidateRow; - ++claimCount; - } - } - if (claimCount > 0 && dynamicRow != 0) { - creditRow = dynamicRow; - return true; - } - return false; -} - /** * Resolves one exact sale-backed rank wrapper from installed content. * @param vendorIndex Installed vendor selector. @@ -533,28 +485,12 @@ VendorReputationDisposition resolve_reputation_turn_in(std::uint16_t vendorIndex award.costQuantity = saleCost.quantity; award.experience = static_cast(experience); award.progressionIndex = progressionIndex; - const build_data::vendors::ReconstructedRankClaimLink* link = - vendors::find_reconstructed_rank_claim_link(entry.definitionHash); + const auto* link = vendors::find_reconstructed_rank_claim_link(entry.definitionHash); if (link != nullptr) { vendors::InteractionGate interaction{}; if (!reconstructed_credit_gate(*link, interaction, award.rankCreditRow)) { return VendorReputationDisposition::refused; } - } else { - std::uint16_t dynamicCreditRow = 0; - if (find_dynamic_rank_credit_row(vendor, dynamicCreditRow)) { - award.rankCreditRow = dynamicCreditRow; - } else { - for (std::size_t index = 0; index < vendor.thirdCount; ++index) { - build_data::vendors::InteractionGate candidate{}; - std::uint16_t candidateRow = 0; - if (build_data::vendors::find_interaction_gate( - vendor.definitionHash, static_cast(index), candidate) - && credit_gate(candidate, candidateRow) == CreditGateKind::claim) { - return VendorReputationDisposition::refused; - } - } - } } award.repeatLastStep = progression.repeatLastStep; if (award.rankCreditRow != 0) { @@ -829,67 +765,6 @@ bool commit_vendor_reputation(PendingVendorReputation& mutation) noexcept { && transaction.commit(); } -/** - * Scans installed vendor sales for a unique zero-cost reward package matching the interaction. - * Evaluates gate conditions across multiple candidates to resolve multi-sale vendors. - * @param vendorIndex Installed vendor selector. - * @param vendor Installed vendor definition. - * @param interaction Installed interaction gate defining category and index. - * @param replyIndex Optional reply index within the interaction. - * @param saleIndex Receives the matching sale index on unique resolution. - * @return True when exactly one matching reward sale is found. - */ -bool find_unique_rank_reward_sale(std::uint16_t vendorIndex, - const build_data::vendors::Definition& vendor, - const build_data::vendors::InteractionGate& interaction, - std::optional replyIndex, - std::uint16_t& saleIndex) noexcept { - saleIndex = 0; - struct CandidateSale { - std::uint16_t saleIndex{}; - RewardSaleBinding binding{}; - }; - std::vector candidates; - for (std::size_t saleIdx = 0; saleIdx < vendor.saleCount; ++saleIdx) { - build_data::vendors::SaleRow sale{}; - if (!build_data::vendors::sale_row(vendor, static_cast(saleIdx), sale)) { - continue; - } - if (sale.categoryIndex != interaction.categoryIndex) { - continue; - } - if (sale.costCount != 0 || sale.priceState != build_data::vendors::PriceState::plain) { - continue; - } - RewardSaleBinding binding{}; - if (resolve_reward_sale(vendorIndex, static_cast(saleIdx), binding) - == VendorReputationDisposition::prepared - && binding.interaction.index == interaction.index - && binding.categoryIndex == interaction.categoryIndex) { - candidates.push_back(CandidateSale{static_cast(saleIdx), binding}); - } - } - if (candidates.size() == 1) { - saleIndex = candidates.front().saleIndex; - return true; - } - if (candidates.size() > 1) { - std::optional activeSale{}; - std::size_t activeCount = 0; - for (const auto& candidate : candidates) { - if (reward_gates_pass(candidate.binding, replyIndex)) { - activeSale = candidate.saleIndex; - ++activeCount; - } - } - if (activeCount == 1 && activeSale) { - saleIndex = *activeSale; - return true; - } - } - return false; -} - /** * Resolves a supported rowless reply through the same settlement as its package sale. * @param vendorIndex Installed vendor selector. @@ -904,66 +779,28 @@ prepare_vendor_rank_reward_interaction(std::uint16_t vendorIndex, std::uint16_t replyIndex, PendingRecordRewardGrant& mutation) noexcept { mutation = {}; - if (is_vendor_interaction_answered(vendorIndex, interactionIndex)) { - return VendorReputationDisposition::refused; - } build_data::vendors::IndexEntry entry{}; if (!build_data::vendors::find_index(vendorIndex, entry)) { return VendorReputationDisposition::notApplicable; } - - build_data::vendors::InteractionGate interaction{}; - const bool hasInteraction = build_data::vendors::find_interaction_gate( - entry.definitionHash, interactionIndex, interaction); - - std::uint16_t interactionCreditRow = 0; - const CreditGateKind gateKind = - hasInteraction ? credit_gate(interaction, interactionCreditRow) : CreditGateKind::unrelated; - - if (gateKind == CreditGateKind::warning || gateKind == CreditGateKind::malformed) { - return VendorReputationDisposition::refused; - } - - // Attempt dynamic reply matching by scanning the vendor's installed sales for a unique - // matching reward package within the interaction's category. - std::optional targetSaleIndex{}; - std::int32_t targetCategoryIndex = build_data::vendors::kAbsentCategoryIndex; - std::uint16_t targetInteractionIndex = interactionIndex; - - build_data::vendors::Definition vendor{}; - if (hasInteraction && gateKind == CreditGateKind::claim - && build_data::vendors::find(entry.definitionHash, vendor)) { - std::uint16_t matchedSaleIndex = 0; - if (find_unique_rank_reward_sale( - vendorIndex, vendor, interaction, replyIndex, matchedSaleIndex)) { - targetSaleIndex = matchedSaleIndex; - targetCategoryIndex = interaction.categoryIndex; - targetInteractionIndex = interaction.index; - } - } - - // Fall back to reconstructed rank claim links if dynamic matching was unmapped or ambiguous. - if (!targetSaleIndex) { - const build_data::vendors::ReconstructedRankClaimLink* link = - build_data::vendors::find_reconstructed_rank_claim_link(entry.definitionHash); - if (link != nullptr && link->interactionIndex == interactionIndex) { - targetSaleIndex = link->saleIndex; - targetCategoryIndex = link->categoryIndex; - targetInteractionIndex = link->interactionIndex; - } else { - if (gateKind != CreditGateKind::unrelated) { - return VendorReputationDisposition::refused; - } - return VendorReputationDisposition::notApplicable; + const auto* link = + build_data::vendors::find_reconstructed_rank_claim_link(entry.definitionHash); + if (link == nullptr || link->interactionIndex != interactionIndex) { + build_data::vendors::InteractionGate interaction{}; + std::uint16_t row = 0; + if (build_data::vendors::find_interaction_gate( + entry.definitionHash, interactionIndex, interaction) + && credit_gate(interaction, row) != CreditGateKind::unrelated) { + return VendorReputationDisposition::refused; } + return VendorReputationDisposition::notApplicable; } - const char* reason = nullptr; - const VendorReputationDisposition disposition = - prepare_rank_reward(vendorIndex, *targetSaleIndex, replyIndex, mutation, reason); + const auto disposition = + prepare_rank_reward(vendorIndex, link->saleIndex, replyIndex, mutation, reason); if (disposition != VendorReputationDisposition::prepared - || mutation.vendorReward.interactionIndex != targetInteractionIndex - || mutation.vendorReward.categoryIndex != targetCategoryIndex) { + || mutation.vendorReward.interactionIndex != link->interactionIndex + || mutation.vendorReward.categoryIndex != link->categoryIndex) { mutation = {}; return VendorReputationDisposition::refused; } @@ -1010,70 +847,4 @@ bool vendor_rank_reward_current(const VendorRankRewardClaim& claim) noexcept { return true; } -bool reserve_answered_vendor_interaction(std::uint16_t vendorIndex, - std::uint16_t interactionIndex) noexcept { - const std::uint32_t packed = pack_answered_interaction(vendorIndex, interactionIndex); - try { - std::unique_lock lock(g_answeredInteractionsLock); - for (const auto existing : g_answeredInteractions) { - if (existing == packed) { - return true; - } - } - if (g_answeredInteractions.size() >= g_answeredInteractions.capacity()) { - const std::size_t nextCapacity = - g_answeredInteractions.capacity() == 0 ? 16 : g_answeredInteractions.capacity() * 2; - g_answeredInteractions.reserve(nextCapacity); - } - return true; - } catch (...) { - return false; - } -} - -bool mark_vendor_interaction_answered(std::uint16_t vendorIndex, - std::uint16_t interactionIndex) noexcept { - const std::uint32_t packed = pack_answered_interaction(vendorIndex, interactionIndex); - try { - std::unique_lock lock(g_answeredInteractionsLock); - for (const auto existing : g_answeredInteractions) { - if (existing == packed) { - return true; - } - } - if (g_answeredInteractions.size() >= g_answeredInteractions.capacity()) { - const std::size_t nextCapacity = - g_answeredInteractions.capacity() == 0 ? 16 : g_answeredInteractions.capacity() * 2; - g_answeredInteractions.reserve(nextCapacity); - } - g_answeredInteractions.push_back(packed); - return true; - } catch (...) { - return false; - } -} - -bool is_vendor_interaction_answered(std::uint16_t vendorIndex, - std::uint16_t interactionIndex) noexcept { - const std::uint32_t packed = pack_answered_interaction(vendorIndex, interactionIndex); - std::shared_lock lock(g_answeredInteractionsLock); - for (const auto existing : g_answeredInteractions) { - if (existing == packed) { - return true; - } - } - return false; -} - -void reset_answered_vendor_interactions(std::uint64_t sessionId) noexcept { - std::unique_lock lock(g_answeredInteractionsLock); - g_currentSessionId = sessionId; - g_answeredInteractions.clear(); -} - -std::uint64_t current_answered_vendor_session() noexcept { - std::shared_lock lock(g_answeredInteractionsLock); - return g_currentSessionId; -} - } // namespace sunrise::state diff --git a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h index 59859ab61..c5a2ca422 100644 --- a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h +++ b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.h @@ -77,25 +77,4 @@ prepare_vendor_rank_reward_interaction(std::uint16_t vendorIndex, PendingRecordRewardGrant& mutation) noexcept; [[nodiscard]] bool vendor_rank_reward_current(const VendorRankRewardClaim& claim) noexcept; -/** Identifies an interaction answered during this session. */ -struct AnsweredVendorInteraction { - std::uint16_t vendorIndex{}; - std::uint16_t interactionIndex{}; - bool operator==(const AnsweredVendorInteraction&) const = default; -}; - -/** Guarantees capacity exists to track an answered interaction without failing. */ -[[nodiscard]] bool reserve_answered_vendor_interaction(std::uint16_t vendorIndex, - std::uint16_t interactionIndex) noexcept; -/** Marks an interaction answered for the current session. */ -[[nodiscard]] bool mark_vendor_interaction_answered(std::uint16_t vendorIndex, - std::uint16_t interactionIndex) noexcept; -/** Returns true if the interaction was already answered during this session. */ -[[nodiscard]] bool is_vendor_interaction_answered(std::uint16_t vendorIndex, - std::uint16_t interactionIndex) noexcept; -/** Clears all session-scoped answered interactions and records active session identity. */ -void reset_answered_vendor_interactions(std::uint64_t sessionId = 0) noexcept; -/** Returns the active session identity for answered vendor interactions. */ -[[nodiscard]] std::uint64_t current_answered_vendor_session() noexcept; - } // namespace sunrise::state From 4fbf99553b0c5a1251e0bc2d62ce694cb1f255d8 Mon Sep 17 00:00:00 2001 From: Gage Fulwood Date: Thu, 15 Oct 2026 23:27:59 -0500 Subject: [PATCH 58/59] Clarify vendor credit gate decoding --- .../state_vendor_reputation_runtime.cpp | 39 +++++++++++-------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp index bccc0be43..7f93cd14e 100644 --- a/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp +++ b/Sunrise/src/state/runtime/state_vendor_reputation_runtime.cpp @@ -159,8 +159,12 @@ bool gate_passes(const build_data::vendors::Gate& gate, enum class CreditGateKind { unrelated, claim, warning, malformed }; +/** Instructions consumed by the direct and inverted positive-credit prefixes. */ +constexpr std::size_t kDirectCreditPrefixSize = 3; +constexpr std::size_t kInvertedCreditPrefixSize = 4; + /** - * Resolves a native VALUE[credit] > 0 prefix without treating a level warning as a claim. + * Resolves a supported positive-credit prefix without treating a level warning as a claim. * @param interaction Installed interaction gate. * @param row Receives the saved character credit row. * @return The supported claim shape, a warning branch, an unrelated gate or malformed input. @@ -169,19 +173,21 @@ CreditGateKind credit_gate(const build_data::vendors::InteractionGate& interacti std::uint16_t& row) noexcept { namespace vendors = build_data::vendors; row = 0; - const auto program = std::span{interaction.condition.program.instructions}.first( - interaction.condition.program.count); + const std::span program = + std::span{interaction.condition.program.instructions}.first( + interaction.condition.program.count); if (program.empty() || program[0].opcode != vendors::Opcode::loadValue) { return CreditGateKind::unrelated; } std::size_t prefixLength = 0; - if (program.size() >= 3 && program[1].opcode == vendors::Opcode::constant + if (program.size() >= kDirectCreditPrefixSize && program[1].opcode == vendors::Opcode::constant && program[1].operand == 0 && program[2].opcode == vendors::Opcode::greaterThan) { - prefixLength = 3; - } else if (program.size() >= 4 && program[1].opcode == vendors::Opcode::constant - && program[1].operand == 1 && program[2].opcode == vendors::Opcode::lessThan + prefixLength = kDirectCreditPrefixSize; + } else if (program.size() >= kInvertedCreditPrefixSize + && program[1].opcode == vendors::Opcode::constant && program[1].operand == 1 + && program[2].opcode == vendors::Opcode::lessThan && program[3].opcode == vendors::Opcode::logicalNot) { - prefixLength = 4; + prefixLength = kInvertedCreditPrefixSize; } else if (program.size() >= 2 && program[1].opcode == vendors::Opcode::constant && (program[1].operand == 0 || program[1].operand == 1)) { return CreditGateKind::malformed; @@ -192,11 +198,11 @@ CreditGateKind credit_gate(const build_data::vendors::InteractionGate& interacti if (program[0].operand > (std::numeric_limits::max)()) { return CreditGateKind::malformed; } - const auto slot = static_cast(program[0].operand); - const auto inputs = + const std::uint16_t slot = static_cast(program[0].operand); + const std::span inputs = std::span{interaction.condition.inputs}.first(interaction.condition.inputCount); const vendors::GateInput* found = nullptr; - for (const auto& input : inputs) { + for (const vendors::GateInput& input : inputs) { if (input.opcode != vendors::Opcode::loadValue || input.slot != slot) { continue; } @@ -210,11 +216,12 @@ CreditGateKind credit_gate(const build_data::vendors::InteractionGate& interacti return CreditGateKind::malformed; } row = found->row; - const auto remainder = program.subspan(prefixLength); - if (std::any_of(remainder.begin(), remainder.end(), [](const auto& instruction) { - return instruction.opcode == vendors::Opcode::lessThan - || instruction.opcode == vendors::Opcode::lessOrEqual; - })) { + const std::span remainder = program.subspan(prefixLength); + if (std::any_of( + remainder.begin(), remainder.end(), [](const unlocks::Instruction& instruction) { + return instruction.opcode == vendors::Opcode::lessThan + || instruction.opcode == vendors::Opcode::lessOrEqual; + })) { return CreditGateKind::warning; } return CreditGateKind::claim; From 4c00f21713a5a61ed0636b38dbfd71668c5aca2b Mon Sep 17 00:00:00 2001 From: Gage Fulwood Date: Fri, 16 Oct 2026 01:16:56 -0500 Subject: [PATCH 59/59] Clarify reward catalog contracts --- .../packages/package_season_pass_build.cpp | 12 +++-- .../packages/tables/definition_index_table.h | 6 ++- .../state/build_data/cache/records/codec.h | 26 ++++++++++ .../state/build_data/cache/records/format.h | 8 +-- .../state/build_data/season_pass/definition.h | 1 + .../season_pass/season_pass_catalog.h | 51 ++++++++++++++++--- 6 files changed, 88 insertions(+), 16 deletions(-) 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 4081a7866..0d207bbac 100644 --- a/Sunrise/src/client/content/items/packages/package_season_pass_build.cpp +++ b/Sunrise/src/client/content/items/packages/package_season_pass_build.cpp @@ -13,12 +13,12 @@ namespace { namespace domain = state::build_data::season_pass; /** - * Reads the item set one reward wrapper opens into. + * Reads the item set declared by one reward wrapper. * @param definition Whole item definition blob. * @param itemTable Item index table blob. * @param itemRows Located item index array. - * @param package Receives the wrapper's items, or stays empty when the item opens into nothing. - * @return True when the item declares no set, or declares one that reads back whole. + * @param package Empty destination receiving resolved item hashes; may be partial on failure. + * @return True when the item declares no set, or its whole set resolves. */ [[nodiscard]] bool read_package(std::span definition, std::span itemTable, @@ -53,6 +53,12 @@ namespace domain = state::build_data::season_pass; /** * Reads one pass row: item, rank, quantity and claim flag, then its sockets and conditions. + * @param storage Build storage providing item rows, slot maps, and condition decoding. + * @param progressionTable Progression table containing the row. + * @param itemRows Located item index array. + * @param at Byte offset of the reward row. + * @param reward Receives the decoded row; may be partial on failure. + * @param failure Receives a condition-bank storage failure when one occurs. * @return False when any field is unreadable or out of range. */ bool read_season_reward(Storage& storage, 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 5a9412554..268589974 100644 --- a/Sunrise/src/middleware/content/packages/tables/definition_index_table.h +++ b/Sunrise/src/middleware/content/packages/tables/definition_index_table.h @@ -30,7 +30,7 @@ inline constexpr std::uint16_t kAbsentPackageId = 0xFFFFU; /** Element class of the item index table inside the investment container. */ inline constexpr std::uint32_t kItemIndexTableClass = 0x80807BE8U; -/** One item-index row. Row +0 carries the definition hash. */ +/** Serialized width of one item-index row; byte zero carries the definition hash. */ inline constexpr std::size_t kItemIndexRowStride = 24; /** Serialized item definition class, distinct from the item index table class. */ inline constexpr std::uint32_t kItemDefinitionClass = 0x80807BEAU; @@ -230,9 +230,11 @@ 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. */ +/** Item-definition field containing the wrapper's gear-set array. */ inline constexpr std::size_t kGearsetItemField = 392; +/** Element class of one gear-set item-index row. */ inline constexpr std::uint32_t kGearsetItemRowClass = 0x808087DBU; +/** Serialized width of one gear-set item index. */ inline constexpr std::size_t kGearsetItemStride = 2; /** Expression that makes one collectible acquired, which a purchase or a grant sets. */ diff --git a/Sunrise/src/state/build_data/cache/records/codec.h b/Sunrise/src/state/build_data/cache/records/codec.h index c9be70dd7..b0c688c17 100644 --- a/Sunrise/src/state/build_data/cache/records/codec.h +++ b/Sunrise/src/state/build_data/cache/records/codec.h @@ -90,13 +90,39 @@ namespace sunrise::state::build_data::cache::records { [[nodiscard]] bool encode(const progressions::Step& value, ProgressionStepRecord& record) noexcept; [[nodiscard]] bool decode(const ProgressionStepRecord& record, progressions::Step& value) noexcept; +/** + * Encodes one season pass reward into its canonical disk form. + * @param value Runtime reward row. + * @param record Receives the disk row; may be partial on failure. + * @return False when counts or nested reward data are invalid. + */ [[nodiscard]] bool encode(const season_pass::Reward& value, SeasonPassRewardRecord& record) noexcept; + +/** + * Decodes one canonical season pass reward. + * @param record Disk row to validate and decode. + * @param value Receives the runtime row; may be partial on failure. + * @return False when padding, counts, or nested reward data are invalid. + */ [[nodiscard]] bool decode(const SeasonPassRewardRecord& record, season_pass::Reward& value) noexcept; +/** + * Encodes one season pass wrapper into its canonical disk form. + * @param value Runtime wrapper package. + * @param record Receives the disk row; unchanged when the item count is invalid. + * @return False when the item count exceeds fixed storage. + */ [[nodiscard]] bool encode(const season_pass::Package& value, SeasonPassPackageRecord& record) noexcept; + +/** + * Decodes one canonical season pass wrapper. + * @param record Disk row to validate and decode. + * @param value Receives the runtime package, or is cleared on failure. + * @return False when padding or the item count is invalid. + */ [[nodiscard]] bool decode(const SeasonPassPackageRecord& record, season_pass::Package& value) noexcept; diff --git a/Sunrise/src/state/build_data/cache/records/format.h b/Sunrise/src/state/build_data/cache/records/format.h index 08557b6e5..46acbcdfb 100644 --- a/Sunrise/src/state/build_data/cache/records/format.h +++ b/Sunrise/src/state/build_data/cache/records/format.h @@ -31,7 +31,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. */ +/** Format 75 stores reward-catalog domains and season-pass wrappers; other versions are rebuilt. */ inline constexpr std::uint32_t kCacheFormatVersion = 75; /** Signed -1 on disk means there is no equipment slot. */ inline constexpr std::int8_t kAbsentEquipmentSlot = -1; @@ -324,7 +324,7 @@ struct RewardSocketOverrideRecord { struct RewardInstructionRecord { std::uint8_t opcode{}; std::uint8_t bank{}; - /** Must be zero, so the packed instruction always matches. */ + /** Must be zero for canonical cache encoding. */ std::uint16_t reserved{}; std::uint32_t operand{}; }; @@ -340,7 +340,7 @@ struct SeasonPassRewardRecord { std::uint8_t socketCount{}; std::array condition{}; std::uint8_t conditionCount{}; - /** Must be zero, so the packed reward row always matches. */ + /** Must be zero for canonical cache encoding. */ std::array reserved{}; }; @@ -349,7 +349,7 @@ struct SeasonPassPackageRecord { std::uint32_t definitionHash{}; std::array items{}; std::uint8_t itemCount{}; - /** Must be zero, so the packed wrapper row always matches. */ + /** Must be zero for canonical cache encoding. */ std::array reserved{}; }; diff --git a/Sunrise/src/state/build_data/season_pass/definition.h b/Sunrise/src/state/build_data/season_pass/definition.h index f6a8a2ddb..17a1cad9e 100644 --- a/Sunrise/src/state/build_data/season_pass/definition.h +++ b/Sunrise/src/state/build_data/season_pass/definition.h @@ -49,6 +49,7 @@ struct Package { std::uint32_t definitionHash{}; /** Authored definition hashes the wrapper opens into, in declared order. */ std::array items{}; + /** Number of populated entries at the start of items. */ std::uint8_t itemCount{}; }; 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 614cce1fa..1bc345136 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 @@ -8,21 +8,58 @@ namespace sunrise::state::build_data::season_pass { /** Discards the installed pass, including its native claim indices and wrapper packages. */ void clear() noexcept; -/** Checks row and wrapper bounds; unavailable rows retain their native positions. */ + +/** + * Checks one complete pass catalog without publishing it. + * @param rewards Reward rows in native claim order; unavailable rows retain their positions. + * @param packages Wrapper packages referenced by reward items. + * @return True when both banks fit fixed storage and every populated row is valid. + */ [[nodiscard]] bool valid(std::span rewards, std::span packages) noexcept; -/** Publishes a validated pass without reordering its claim indices. */ + +/** + * Replaces the complete pass catalog without reordering native claim indices. + * @param rewards Complete reward rows in native claim order. + * @param packages Complete wrapper packages. + * @return True when both banks validate and publish together. + */ [[nodiscard]] bool replace(std::span rewards, std::span packages) noexcept; -/** Copies one native claim row; clears the output when absent or unavailable. */ + +/** + * Copies one available native claim row. + * @param rewardIndex Native claim-row index. + * @param reward Receives the row, or is cleared when absent or unavailable. + * @return True when the index names an available row. + */ [[nodiscard]] bool find(std::uint16_t rewardIndex, Reward& reward) noexcept; -/** Finds the wrapper package one item hash opens into. */ + +/** + * Finds the wrapper package one item hash opens into. + * @param definitionHash Authored wrapper item hash. + * @param package Receives the package, or is cleared when absent. + * @return True when the catalog contains that wrapper. + */ [[nodiscard]] bool find_package(std::uint32_t definitionHash, Package& package) noexcept; -/** Copies the complete pass; count is zero when the destination is too small. */ + +/** + * Copies the complete pass in native claim order. + * @param output Caller-owned reward storage. + * @param count Receives the copied row count, or zero when output is too small. + * @return True when output can hold every reward row. + */ [[nodiscard]] bool snapshot(std::span output, std::size_t& count) noexcept; -/** Copies every wrapper package; count is zero when the destination is too small. */ + +/** + * Copies every wrapper package in publication order. + * @param output Caller-owned package storage. + * @param count Receives the copied package count, or zero when output is too small. + * @return True when output can hold every package. + */ [[nodiscard]] bool snapshot_packages(std::span output, std::size_t& count) noexcept; -/** Number of native positions, including unavailable rows. */ + +/** @return Number of native claim positions, including unavailable rows. */ [[nodiscard]] std::size_t count() noexcept; } // namespace sunrise::state::build_data::season_pass