From 6350659e95ceff2f7d4bc2be9d2abcd5a1a60ba9 Mon Sep 17 00:00:00 2001 From: cDmonio Date: Sun, 19 Jul 2026 11:23:57 +0000 Subject: [PATCH 01/14] [08.5] Make loot item and money claims atomic --- .../workflows/qa-world-server-artifact.yml | 1 + .github/workflows/rust-ci.yml | 19 +- Cargo.lock | 5 + crates/capture-diff/Cargo.toml | 1 + crates/capture-diff/README.md | 192 +- ...001-counter1-0x3765-AuthSession-len70.meta | 1 + ...er3-0x3767-EnterEncryptedModeAck-len2.meta | 1 + ...-counter12-0x35E9-EnumCharacters-len2.meta | 1 + ...tUndeleteCharacterCooldownStatus-len2.meta | 1 + ...-0x36C5-BattlePayGetPurchaseList-len2.meta | 1 + ...5-0x36C4-BattlePayGetProductList-len2.meta | 1 + ...6-0x36FB-UpdateVasPurchaseStates-len2.meta | 1 + ...r19-0x374C-SocialContractRequest-len2.meta | 1 + ...-counter20-0x35E6-HotfixRequest-len18.meta | 1 + ...unter21-0x36FD-BattlenetRequest-len41.meta | 1 + ...25-counter25-0x35E5-DbQueryBulk-len56.meta | 1 + ...8-counter38-0x35E5-DbQueryBulk-len136.meta | 1 + ...ter71-0x35F9-LoadingScreenNotify-len7.meta | 1 + ...2-0x36BF-GetAccountCharacterList-len7.meta | 1 + ...ter73-0x35E4-RequestRatedPvpInfo-len2.meta | 1 + ...-0x36C5-BattlePayGetPurchaseList-len2.meta | 1 + ...75-counter75-0x35EB-PlayerLogin-len11.meta | 1 + ...r78-0x3766-AuthContinuedSession-len58.meta | 1 + ...r80-0x3767-EnterEncryptedModeAck-len2.meta | 1 + ...er132-0x351E-OverrideScreenFlash-len3.meta | 1 + ...-counter133-0x3187-ViolenceLevel-len3.meta | 1 + ...nter134-0x3196-RequestPvpRewards-len2.meta | 1 + ...nter135-0x376C-QueuedMessagesEnd-len6.meta | 1 + ...nter137-0x3A3D-TimeSyncResponse-len10.meta | 1 + ...counter138-0x3A3C-SetActiveMover-len7.meta | 1 + ...nter139-0x327A-RequestPlayedTime-len3.meta | 1 + ...3A46-MoveInitActiveMoverComplete-len6.meta | 1 + ...0-counter0-0x3048-AuthChallenge-len51.meta | 1 + ...nter2-0x3049-EnterEncryptedMode-len67.meta | 1 + ...4-counter4-0x256D-AuthResponse-len434.meta | 1 + ...5-0x2677-SetTimeZoneInformation-len26.meta | 1 + ...0-FeatureSystemStatusGlueScreen-len91.meta | 1 + ...007-counter7-0x291C-CacheVersion-len6.meta | 1 + ...nter8-0x290F-AvailableHotfixes-len690.meta | 1 + ...unter9-0x270A-AccountDataTimes-len132.meta | 1 + ...-counter10-0x27BE-TutorialFlags-len34.meta | 1 + ...0x2809-BattleNetConnectionStatus-len3.meta | 1 + ...7-0x2583-EnumCharactersResult-len1262.meta | 1 + ...-UndeleteCooldownStatusResponse-len11.meta | 1 + ...92-SocialContractRequestResponse-len3.meta | 1 + ...counter23-0x2911-HotfixConnect-len262.meta | 1 + ...nter24-0x2807-BattlenetResponse-len30.meta | 1 + ...000026-counter26-0x290E-DbReply-len19.meta | 1 + ...000027-counter27-0x290E-DbReply-len19.meta | 1 + ...000028-counter28-0x290E-DbReply-len19.meta | 1 + ...000029-counter29-0x290E-DbReply-len19.meta | 1 + ...000030-counter30-0x290E-DbReply-len19.meta | 1 + ...000031-counter31-0x290E-DbReply-len19.meta | 1 + ...000032-counter32-0x290E-DbReply-len19.meta | 1 + ...000033-counter33-0x290E-DbReply-len19.meta | 1 + ...000034-counter34-0x290E-DbReply-len19.meta | 1 + ...000035-counter35-0x290E-DbReply-len19.meta | 1 + ...000036-counter36-0x290E-DbReply-len19.meta | 1 + ...000037-counter37-0x290E-DbReply-len19.meta | 1 + ...000039-counter39-0x290E-DbReply-len19.meta | 1 + ...000040-counter40-0x290E-DbReply-len19.meta | 1 + ...000041-counter41-0x290E-DbReply-len19.meta | 1 + ...000042-counter42-0x290E-DbReply-len19.meta | 1 + ...000043-counter43-0x290E-DbReply-len19.meta | 1 + ...000044-counter44-0x290E-DbReply-len19.meta | 1 + ...000045-counter45-0x290E-DbReply-len19.meta | 1 + ...000046-counter46-0x290E-DbReply-len19.meta | 1 + ...000047-counter47-0x290E-DbReply-len19.meta | 1 + ...000048-counter48-0x290E-DbReply-len19.meta | 1 + ...000049-counter49-0x290E-DbReply-len19.meta | 1 + ...000050-counter50-0x290E-DbReply-len19.meta | 1 + ...000051-counter51-0x290E-DbReply-len19.meta | 1 + ...000052-counter52-0x290E-DbReply-len19.meta | 1 + ...000053-counter53-0x290E-DbReply-len19.meta | 1 + ...000054-counter54-0x290E-DbReply-len19.meta | 1 + ...000055-counter55-0x290E-DbReply-len19.meta | 1 + ...000056-counter56-0x290E-DbReply-len19.meta | 1 + ...000057-counter57-0x290E-DbReply-len19.meta | 1 + ...000058-counter58-0x290E-DbReply-len19.meta | 1 + ...000059-counter59-0x290E-DbReply-len19.meta | 1 + ...000060-counter60-0x290E-DbReply-len19.meta | 1 + ...000061-counter61-0x290E-DbReply-len19.meta | 1 + ...000062-counter62-0x290E-DbReply-len19.meta | 1 + ...000063-counter63-0x290E-DbReply-len19.meta | 1 + ...000064-counter64-0x290E-DbReply-len19.meta | 1 + ...000065-counter65-0x290E-DbReply-len19.meta | 1 + ...000066-counter66-0x290E-DbReply-len19.meta | 1 + ...000067-counter67-0x290E-DbReply-len19.meta | 1 + ...000068-counter68-0x290E-DbReply-len19.meta | 1 + ...000069-counter69-0x290E-DbReply-len19.meta | 1 + ...000070-counter70-0x290E-DbReply-len19.meta | 1 + ...076-counter76-0x304D-ConnectTo-len278.meta | 1 + ...-counter77-0x3048-AuthChallenge-len51.meta | 1 + ...ter79-0x3049-EnterEncryptedMode-len67.meta | 1 + ...081-counter81-0x304B-ResumeComms-len2.meta | 1 + ...-counter82-0x2735-SetProficiency-len7.meta | 1 + ...-counter83-0x2735-SetProficiency-len7.meta | 1 + ...-counter84-0x2735-SetProficiency-len7.meta | 1 + ...-counter85-0x2735-SetProficiency-len7.meta | 1 + ...-counter86-0x2735-SetProficiency-len7.meta | 1 + ...-counter87-0x2735-SetProficiency-len7.meta | 1 + ...-counter88-0x2735-SetProficiency-len7.meta | 1 + ...-counter89-0x2735-SetProficiency-len7.meta | 1 + ...090-counter90-0x2C1F-AuraUpdate-len43.meta | 1 + ...091-counter91-0x2C1F-AuraUpdate-len44.meta | 1 + ...092-counter92-0x2C1F-AuraUpdate-len44.meta | 1 + ...093-counter93-0x2C1F-AuraUpdate-len44.meta | 1 + ...094-counter94-0x2C1F-AuraUpdate-len44.meta | 1 + ...095-counter95-0x2C1F-AuraUpdate-len44.meta | 1 + ...096-counter96-0x2C1F-AuraUpdate-len44.meta | 1 + ...097-counter97-0x2C1F-AuraUpdate-len44.meta | 1 + ...098-counter98-0x2C1F-AuraUpdate-len44.meta | 1 + ...099-counter99-0x2C1F-AuraUpdate-len44.meta | 1 + ...00-counter100-0x2C1F-AuraUpdate-len44.meta | 1 + ...01-counter101-0x2C1F-AuraUpdate-len44.meta | 1 + ...02-counter102-0x2C1F-AuraUpdate-len44.meta | 1 + ...r103-0x26A4-SetDungeonDifficulty-len6.meta | 1 + ...nter104-0x2597-LoginVerifyWorld-len26.meta | 1 + ...ter105-0x270A-AccountDataTimes-len135.meta | 1 + ...ounter106-0x2DD2-TimeSyncRequest-len6.meta | 1 + ...107-0x25BF-FeatureSystemStatus-len192.meta | 1 + ...ter108-0x2BC5-ChatServerMessage-len41.meta | 1 + ...9-0x2677-SetTimeZoneInformation-len26.meta | 1 + ...0-counter110-0x278C-ContactList-len40.meta | 1 + ...0x301B-QueryPlayerNamesResponse-len43.meta | 1 + ...2-counter112-0x2C51-ActiveGlyphs-len7.meta | 1 + ...unter113-0x257D-BindPointUpdate-len22.meta | 1 + ...nter114-0x25D7-UpdateTalentData-len49.meta | 1 + ...ter115-0x2C27-SendKnownSpells-len1191.meta | 1 + ...nter116-0x2C2B-SendUnlearnSpells-len6.meta | 1 + ...nter117-0x2C28-SendSpellHistory-len31.meta | 1 + ...unter118-0x2C2A-SendSpellCharges-len6.meta | 1 + ...19-0x25E0-UpdateActionButtons-len1443.meta | 1 + ...120-0x2724-InitializeFactions-len6127.meta | 1 + ...-counter121-0x2573-SetupCurrency-len6.meta | 1 + ...unter122-0x270E-LoadEquipmentSet-len6.meta | 1 + ...er123-0x2570-AllAchievementData-len10.meta | 1 + ...ter124-0x270D-LoginSetTimeSpeed-len22.meta | 1 + ...ounter125-0x25AD-WorldServerInfo-len7.meta | 1 + ...r126-0x2C33-SetFlatSpellModifier-len6.meta | 1 + ...er127-0x2C34-SetPctSpellModifier-len6.meta | 1 + ...er128-0x25AE-AccountMountUpdate-len22.meta | 1 + ...nter129-0x25B0-AccountToyUpdate-len15.meta | 1 + ...0-counter130-0x2580-InitialSetup-len4.meta | 1 + ...ter131-0x2DD5-MoveSetActiveMover-len7.meta | 1 + ...unter136-0x27CB-UpdateObject-len49227.meta | 1 + ...counter140-0x27CB-UpdateObject-len680.meta | 1 + ...nter141-0x2578-PhaseShiftChange-len29.meta | 1 + ...nter142-0x2746-InitWorldStates-len386.meta | 1 + ...nter143-0x2578-PhaseShiftChange-len29.meta | 1 + ...unter144-0x25BC-LoadCufProfiles-len30.meta | 1 + ...5-counter145-0x2C1F-AuraUpdate-len463.meta | 1 + ...counter146-0x27CB-UpdateObject-len506.meta | 1 + .../flows/loot-single-item-claim/README.md | 367 + .../flows/loot-single-item-claim/cpp.pkt | Bin 0 -> 795 bytes .../expected-divergences.json | 1 + .../flows/loot-single-item-claim/flow.json | 7 + .../loot-single-item-claim/requirement.json | 74 + ...0000000-counter0-0x3211-LootItem-len15.bin | Bin 0 -> 15 bytes ...000000-counter0-0x3211-LootItem-len15.meta | 8 + ...2s-00000005-counter5-0x3768-Ping-len10.bin | Bin 0 -> 10 bytes ...s-00000005-counter5-0x3768-Ping-len10.meta | 8 + ...01-counter1-0x27CB-UpdateObject-len312.bin | Bin 0 -> 312 bytes ...1-counter1-0x27CB-UpdateObject-len312.meta | 8 + ...0002-counter2-0x2615-LootRemoved-len20.bin | Bin 0 -> 20 bytes ...002-counter2-0x2615-LootRemoved-len20.meta | 8 + ...3-counter3-0x2623-ItemPushResult-len66.bin | Bin 0 -> 66 bytes ...-counter3-0x2623-ItemPushResult-len66.meta | 8 + ...004-counter4-0x27CB-UpdateObject-len48.bin | Bin 0 -> 48 bytes ...04-counter4-0x27CB-UpdateObject-len48.meta | 8 + crates/capture-diff/scripts/capture-cpp.sh | 715 +- crates/capture-diff/scripts/capture-rust.sh | 1292 +- .../scripts/capture-service-common.sh | 749 + .../scripts/loot-fixture-common.sh | 295 + crates/capture-diff/src/flow.rs | 764 +- crates/capture-diff/src/lib.rs | 15 +- crates/capture-diff/src/lineage.rs | 2183 ++ crates/capture-diff/src/main.rs | 878 +- crates/capture-diff/src/pkt.rs | 8 +- crates/capture-diff/src/rustdump.rs | 232 +- crates/capture-diff/src/semantic.rs | 1359 +- crates/capture-diff/tests/diff_engine.rs | 207 +- .../tests/loot_removed_semantic.rs | 431 + .../tests/loot_single_item_claim_contract.rs | 234 + ...pdate_object_empty_unit_parent_semantic.rs | 501 + .../world-server/src/creature_loaded_grid.rs | 167 +- crates/world-server/src/main.rs | 174 +- crates/wow-database/src/lib.rs | 5 +- .../wow-database/src/statements/character.rs | 31 +- crates/wow-database/src/transaction.rs | 465 +- crates/wow-entities/Cargo.toml | 1 + crates/wow-entities/src/creature.rs | 350 + crates/wow-entities/src/game_object.rs | 556 +- crates/wow-loot/Cargo.toml | 1 + crates/wow-loot/src/authority.rs | 3425 ++++ crates/wow-loot/src/lib.rs | 116 +- crates/wow-map/Cargo.toml | 1 + crates/wow-map/src/map.rs | 475 +- crates/wow-network/src/accept.rs | 20 +- crates/wow-network/src/group_registry.rs | 3 +- crates/wow-network/src/lib.rs | 24 +- crates/wow-network/src/player_registry.rs | 512 +- crates/wow-network/src/session_mgr.rs | 3 + crates/wow-network/src/world_socket.rs | 266 +- crates/wow-packet/Cargo.toml | 1 + crates/wow-packet/src/packets/loot.rs | 109 +- crates/wow-packet/src/packets/update.rs | 156 +- crates/wow-world/src/handlers/character.rs | 590 +- crates/wow-world/src/handlers/chat.rs | 1 + crates/wow-world/src/handlers/group.rs | 575 +- crates/wow-world/src/handlers/loot.rs | 16703 +++++++++++++--- crates/wow-world/src/handlers/misc.rs | 1 + crates/wow-world/src/handlers/movement.rs | 1 + crates/wow-world/src/handlers/quest.rs | 187 +- crates/wow-world/src/handlers/spell.rs | 14 +- crates/wow-world/src/handlers/talent.rs | 131 +- crates/wow-world/src/handlers/trainer.rs | 60 +- crates/wow-world/src/map_manager.rs | 20 + crates/wow-world/src/session.rs | 3775 +++- docs/migration/EXISTING-CODE-DEFECTS.md | 27 +- docs/operations/pr-preflight.md | 111 +- tools/pr-preflight.sh | 1836 +- tools/wow-test-bot/Cargo.lock | 2 + tools/wow-test-bot/Cargo.toml | 2 + tools/wow-test-bot/README.md | 199 +- .../wow-test-bot/run_rustycore_login_smoke.sh | 183 +- tools/wow-test-bot/src/bot_srp6.rs | 42 +- tools/wow-test-bot/src/loot_race.rs | 6577 ++++++ tools/wow-test-bot/src/main.rs | 1089 +- 229 files changed, 45548 insertions(+), 4165 deletions(-) create mode 100644 crates/capture-diff/flows/loot-single-item-claim/README.md create mode 100644 crates/capture-diff/flows/loot-single-item-claim/cpp.pkt create mode 100644 crates/capture-diff/flows/loot-single-item-claim/expected-divergences.json create mode 100644 crates/capture-diff/flows/loot-single-item-claim/flow.json create mode 100644 crates/capture-diff/flows/loot-single-item-claim/requirement.json create mode 100644 crates/capture-diff/flows/loot-single-item-claim/rust/rust-c2s-00000000-counter0-0x3211-LootItem-len15.bin create mode 100644 crates/capture-diff/flows/loot-single-item-claim/rust/rust-c2s-00000000-counter0-0x3211-LootItem-len15.meta create mode 100644 crates/capture-diff/flows/loot-single-item-claim/rust/rust-c2s-00000005-counter5-0x3768-Ping-len10.bin create mode 100644 crates/capture-diff/flows/loot-single-item-claim/rust/rust-c2s-00000005-counter5-0x3768-Ping-len10.meta create mode 100644 crates/capture-diff/flows/loot-single-item-claim/rust/rust-s2c-00000001-counter1-0x27CB-UpdateObject-len312.bin create mode 100644 crates/capture-diff/flows/loot-single-item-claim/rust/rust-s2c-00000001-counter1-0x27CB-UpdateObject-len312.meta create mode 100644 crates/capture-diff/flows/loot-single-item-claim/rust/rust-s2c-00000002-counter2-0x2615-LootRemoved-len20.bin create mode 100644 crates/capture-diff/flows/loot-single-item-claim/rust/rust-s2c-00000002-counter2-0x2615-LootRemoved-len20.meta create mode 100644 crates/capture-diff/flows/loot-single-item-claim/rust/rust-s2c-00000003-counter3-0x2623-ItemPushResult-len66.bin create mode 100644 crates/capture-diff/flows/loot-single-item-claim/rust/rust-s2c-00000003-counter3-0x2623-ItemPushResult-len66.meta create mode 100644 crates/capture-diff/flows/loot-single-item-claim/rust/rust-s2c-00000004-counter4-0x27CB-UpdateObject-len48.bin create mode 100644 crates/capture-diff/flows/loot-single-item-claim/rust/rust-s2c-00000004-counter4-0x27CB-UpdateObject-len48.meta create mode 100644 crates/capture-diff/scripts/capture-service-common.sh create mode 100644 crates/capture-diff/scripts/loot-fixture-common.sh create mode 100644 crates/capture-diff/src/lineage.rs create mode 100644 crates/capture-diff/tests/loot_removed_semantic.rs create mode 100644 crates/capture-diff/tests/loot_single_item_claim_contract.rs create mode 100644 crates/capture-diff/tests/update_object_empty_unit_parent_semantic.rs create mode 100644 crates/wow-loot/src/authority.rs create mode 100644 tools/wow-test-bot/src/loot_race.rs diff --git a/.github/workflows/qa-world-server-artifact.yml b/.github/workflows/qa-world-server-artifact.yml index 09e54dc06..27d41cea1 100644 --- a/.github/workflows/qa-world-server-artifact.yml +++ b/.github/workflows/qa-world-server-artifact.yml @@ -77,6 +77,7 @@ jobs: env: CARGO_BUILD_JOBS: "1" CARGO_INCREMENTAL: "0" + RUST_MIN_STACK: "268435456" CARGO_PROFILE_RELEASE_INCREMENTAL: "false" CARGO_PROFILE_RELEASE_LTO: "false" CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "16" diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index a21db948f..3bc21e511 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -20,6 +20,7 @@ concurrency: env: CARGO_TERM_COLOR: always + RUST_MIN_STACK: "268435456" jobs: fmt: @@ -53,6 +54,8 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@1.88.0 + with: + components: clippy - name: Cache cargo uses: Swatinem/rust-cache@v2 @@ -73,10 +76,16 @@ jobs: cargo +1.88.0 check --locked --manifest-path tools/wow-test-bot/Cargo.toml cargo +1.88.0 build --locked -p bnet-server -p world-server + - name: Clippy loot authority boundary + run: cargo +1.88.0 clippy --locked --no-deps --message-format short -p wow-loot -p wow-entities -p wow-map -p wow-network --lib + + - name: Clippy world target with inherited debt capped + run: cargo +1.88.0 clippy --locked --no-deps --message-format short -p wow-world --lib -- --cap-lints warn + tests: name: Focused library tests runs-on: ubuntu-24.04 - timeout-minutes: 20 + timeout-minutes: 30 steps: - name: Checkout uses: actions/checkout@v4 @@ -97,12 +106,18 @@ jobs: chmod +x "$HOME/.local/bin/protoc" echo "$HOME/.local/bin" >> "$GITHUB_PATH" - - name: Run packet/data/world tests + - name: Run focused library and loot-race harness tests run: | cargo +1.88.0 test --locked -p wow-data --lib cargo +1.88.0 test --locked -p wow-packet --lib + cargo +1.88.0 test --locked -p wow-loot --lib + cargo +1.88.0 test --locked -p wow-entities --lib cargo +1.88.0 test --locked -p wow-map --lib + cargo +1.88.0 test --locked -p wow-network --lib cargo +1.88.0 test --locked -p wow-world --lib + cargo +1.88.0 test --locked --manifest-path tools/wow-test-bot/Cargo.toml loot_race::tests + cargo +1.88.0 test --locked -p capture-diff + cargo +1.88.0 run --locked -p capture-diff -- verify-required loot-single-item-claim latest-stable: name: Latest stable compatibility diff --git a/Cargo.lock b/Cargo.lock index ffda822f8..d6eb4b723 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -293,6 +293,7 @@ name = "capture-diff" version = "0.1.0" dependencies = [ "anyhow", + "libc", "num-traits", "serde", "serde_json", @@ -3231,6 +3232,7 @@ dependencies = [ "bitflags", "wow-constants", "wow-core", + "wow-loot", ] [[package]] @@ -3267,6 +3269,7 @@ name = "wow-loot" version = "0.1.0" dependencies = [ "rand", + "tokio", "wow-constants", "wow-core", ] @@ -3283,6 +3286,7 @@ dependencies = [ "wow-core", "wow-ecs", "wow-entities", + "wow-loot", "wow-math", ] @@ -3337,6 +3341,7 @@ dependencies = [ "tracing", "wow-constants", "wow-core", + "wow-loot", "wow-movement", ] diff --git a/crates/capture-diff/Cargo.toml b/crates/capture-diff/Cargo.toml index 15da8bc6d..6b3194772 100644 --- a/crates/capture-diff/Cargo.toml +++ b/crates/capture-diff/Cargo.toml @@ -22,6 +22,7 @@ serde = { workspace = true } serde_json = { workspace = true } anyhow = { workspace = true } sha2 = { workspace = true } +libc = { workspace = true } [lints] workspace = true diff --git a/crates/capture-diff/README.md b/crates/capture-diff/README.md index 26bea5fa4..99793b096 100644 --- a/crates/capture-diff/README.md +++ b/crates/capture-diff/README.md @@ -21,8 +21,44 @@ cargo run -p capture-diff -- diff --cpp some.pkt --rust some/dump/dir # Regression gate (exit non-zero if the diff drifts from the accepted baseline): cargo run -p capture-diff -- diff login --strict + +# Milestone gate (operator-attested ready state, empty baseline and pinned shape): +cargo run -p capture-diff -- verify-required loot-single-item-claim ``` +`verify-required` is intentionally stricter than `diff --strict`: it refuses an +`awaiting-real-captures` contract, missing artifacts, accepted divergences, an +incorrect exact packet count/boundary/socket/order shape, an invalid correlated +payload, a selection that differs from the exact reviewed boundaries/ignores, +a missing or malformed RAW-to-derived lineage, any retained manifest/output +hash mismatch, or any C++↔Rust difference. This +lets a PR record exactly which capture it still owes without manufacturing a +golden. Issue #106's existing `loot-single-item-claim` action windows contain +six packets each and compare CLEAN with an empty accepted-divergence baseline, +but they predate the mandatory RAW manifests and cannot establish which +processes produced them. The contract therefore remains +`awaiting-real-captures` until a new, fully accredited C++/Rust pair is imported. + +The `ready` status is an operator attestation made only after inspecting the +capture run and its provenance. Each wrapper publishes a completed RAW +manifest only after cleanup succeeds. `import` validates that manifest against +the RAW bytes, preserves an exact copy, and hashes the filtered C++ packet, +normalized Rust dump, and baseline into `capture-lineage.json`. The manifest +also pins the harness/source commits and worktree-state digests, +expected/source/live executable paths and hashes, PM2 entrypoint path/hash, +entry/listener identity and restart count, plus a canonical +redacted capture-config hash. This is an integrity and review chain, not a +cryptographic signature: repository write access can still replace evidence +and all of its hashes together, which normal review must catch. + +For a required flow, `required_order` is the complete post-filter packet +sequence, not a subsequence. The issue-#106 contract therefore permits exactly +the six declared packets; an extra or duplicate packet fails even when it uses +the wrong direction or socket. Its versioned semantic contract independently +requires one loot request and correlates the LootObj/list id, Doctor removal, +created item, recipient, quantity, item GUID/entry, deterministic keyring slot +and inventory update before accepting either side of the pair. + To isolate one action from the surrounding login/session traffic, give the import command directional inclusive boundaries. The stand-state bot requires `SMSG_CONNECT_TO` plus distinct realm/instance sockets, waits for the realm ACK, @@ -50,6 +86,19 @@ cargo run -p capture-diff -- import stand-state \ cargo run -p capture-diff -- diff stand-state --strict ``` +The default RAW manifests are the wrapper outputs next to those captures: +`target/captures//cpp.capture-manifest.json` and +`target/captures//rust/rust.capture-manifest.json`. Use +`--cpp-manifest` or `--rust-manifest` only if those same completed manifests +were deliberately moved. Import rejects a missing/unknown field, wrong +flow/side, incomplete run, RAW size/count/hash mismatch, malformed UTC time, +process/executable inconsistency, a dirty RustyCore harness/source, or an +unpinned executable for a required flow. A legacy C++ source checkout may be +dirty: its HEAD is then informational, `source_worktree_dirty` is explicit, +and a deterministic changed/untracked path+mode+content-hash state digest is +retained. The pinned listener-binary path/SHA remains the primary C++ runtime +identity; the manifest does not claim that the recorded source HEAD built it. + The fence uses a fixed ping serial and zero latency; its Pong is verified live by the bot but intentionally falls after the inclusive import boundary. The import fails if either capture lacks either boundary. With `--strict`, it @@ -87,10 +136,55 @@ The one-packet fixture proves the reward wire shape; the bot workflow separately proves offline accrual, DB XP/rest consumption, relog persistence, fixture restoration, and the natural respawn timer. +The issue-#106 loot gate has an equally narrow comparator for +`SMSG_LOOT_REMOVED`: paired real captures assigned its one reviewed Doctor +Maleficus identity (Creature, realm 1, map 530, entry 21779, subtype/server 0) +different nonzero map-runtime counters. The bot preflight proves that this +entry has exactly one SQL spawn on that map. Only the lower 40 bits of that +Owner counter are omitted. Owner type/realm/map/entry/subtype/server id, the +complete map-530 LootObj GUID including its counter, LootListID 0, canonical +packed encoding, packet direction, and instance-socket routing remain exact. +Zero counters, malformed or non-canonical bodies, every other Creature +identity, and non-Creature owners are never normalized. + +The required-flow validator separately decodes the preceding item +`CreateObject`. It requires exactly one map-530 `UpdateType::CreateObject` +block with `TypeID::Item`, all item movement flags clear, the owner-visible +item-30712 value shape, owner and contained-in GUID equal to character 15, +StackCount 1, and the reviewed deterministic zero tail. Its Item GUID must be +the same canonical realm-1/server-0 GUID reported by `ItemPushResult` and the +one `InvSlots` child; the push must report quantity 1 and the observed keyring +slot 106. + +The same gate has one separate, fail-closed `SMSG_UPDATE_OBJECT` comparator for +the one-player `UpdateType::Values` inventory delta observed after the claim. +Two independent real C++ captures produced the same 51-byte body: before the +otherwise identical `ActivePlayerData::InvSlots` update, `UnitData` contained +only its five-byte mask fragment `08 00 10 00 00` (parent bit 116 for the Power +arrays), with no child bit and no payload. Rust produced the corresponding +46-byte body without that empty parent. This follows the throttled +`Player::Regenerate` path in `Player.cpp` (`DoWithSuppressingObjectUpdates`, +then `UnitData::Power::ClearChanged`): the parent can remain accumulated until +the next object-update flush even though its child was cleared. + +Normalization is allowed only for S2C opcode `0x27CB` when both bodies contain +exactly one VALUES block for the same canonical Player GUID and map, no +destroy/out-of-range section, and exactly the same single canonical non-empty +Item GUID at the same `InvSlots` slot after removing that C++-only fragment. +Every child or payload under Unit parent 116, every other Unit/ActivePlayer +mask, GUID, slot, value, malformed or non-canonical encoding, opcode, +direction, and socket route remains strict; CreateObject and every other +UpdateObject shape receive no normalization. This exception records proven +accumulated-change-mask/capture-cadence noise only. It is not evidence of power +regeneration—or any other gameplay—parity. + `--ignore-opcode` is repeatable, direction-required, and applied symmetrically after boundary selection. It is fail-closed to the reviewed periodic allowlist -(`s2c:0x2DD2 SMSG_TIME_SYNC_REQUEST` and `s2c:0x2DD4 -SMSG_ON_MONSTER_MOVE`) and cannot overlap either action boundary. The +(`s2c:0x2DD2 SMSG_TIME_SYNC_REQUEST`, its causally paired +`c2s:0x3A3D CMSG_TIME_SYNC_RESPONSE`, and `s2c:0x2DD4 +SMSG_ON_MONSTER_MOVE`) and cannot overlap either action boundary. Filtering +only the request would leave its client-time-bearing response in the strict +window, so a flow that answers time sync must exclude that reviewed pair. The stand-state flow excludes only `SMSG_ON_MONSTER_MOVE` produced by the independent global creature clock; the stand ACK, VALUES/aura side effects, connection ids, request, and ping fence remain strict. `update-baseline` @@ -146,16 +240,72 @@ artifact into `target/captures//`. See each script's header for the env vars (server paths, pm2 process names) it honors. The Rust script recreates the world process from a mode-0600 snapshot whose only capture-time difference is `RUSTYCORE_PACKET_DUMP_DIR`, verifies the exact PM2 profile and listener ports, -and rejects a capture if the process PID/restart counter changes. Set +and rejects a capture if the process PID/start-time/restart counter or redacted +PM2 launch-profile hash changes. Set `RUST_CAPTURE_EXEC` to an absolute canonical path when a feature-branch binary must be captured and set `RUST_CAPTURE_EXEC_SHA256` to that file's 64-hex SHA-256. The script verifies the source immediately before launch and checks that both `/proc//exe` and its bytes match the pinned path and digest before and after the interactive capture; a provenance mismatch fails closed. The snapshot's original executable is still restored on normal exit or a caught -termination signal. The dump parser also rejects -duplicate global sequence numbers, which would otherwise make a capture -spanning an autorestart ambiguous. +termination signal, but readiness requires both distinct world/instance +listeners to belong to one accredited PID that is the PM2 entry itself or its +verified descendant. Before any fixture restoration, cleanup removes the PM2 +entry and verifies the recorded entry, listener, and descendant process-tree +identities plus both listeners are absent; a stubborn tree is terminated and +rechecked. The dump parser accepts only a flat set of regular, non-symlink +`.meta`/`.bin` pairs with exact metadata keys, canonical filenames/opcodes and +contiguous global sequence numbers. Extras, orphans, subdirectories, and +symlinks fail closed. + +Each successful wrapper also publishes one completed RAW manifest (schema v3): C++ writes +`cpp.capture-manifest.json` next to `cpp.pkt`, while Rust writes +`rust.capture-manifest.json` inside the dump directory. Publication happens +without overwriting an existing generation and only after service/fixture +cleanup succeeds and the normal runtime is healthy again. The manifest records +repository HEADs and worktree state, expected/source/live executable identity, +PM2 entrypoint plus listener PID/start-time identity and restart count, the +redacted PM2 profile hash, and a capture-config digest. Direct Rust profiles +may have the same PM2-entry and listener PID; +legacy C++ uses a non-`exec` shell entrypoint, so its unique listener PID must +be a live descendant. The capture-config digest is calculated from an ordered +allowlist of capture-relevant settings; credential-like values are +replaced with a fixed presence marker before hashing, so no password, token, or +complete secret-bearing configuration is hashed or stored. C++ additionally +derives the effective config selected by PM2 argv or its pinned wrapper and +requires it to be the exact canonical `CPP_CONF`; caller declaration alone is +not evidence. + +`loot-single-item-claim` cannot opt out of its versioned fixture guard: both +C++ and Rust wrappers require guard/acknowledgement, a pinned +`WOW_BOT_EXEC`/SHA-256, and a fresh absolute `WOW_BOT_REPORT`. The report must +prove the exact successful TESTBOT2/character-15 Doctor-21779/spawn-1117/item- +30712 single-item flow before schema-v3 evidence can publish. Rust guards use +both `RUST_CAPTURE_LOOT_FIXTURE_GUARD=1` and +`RUST_CAPTURE_ACK_LOOT_FIXTURE_MUTATION=1`, plus an unused absolute +`WOW_BOT_FIXTURE_JOURNAL` path in a private directory. `loot-single-item-claim` retains its +temporary Doctor-health fixture. `loot-two-session-atomic-race` instead checks +the pinned entry-2846/loot-2278 prerequisites, temporarily installs shared QA +chest spawn `9106001` with item 38 and 10 copper, and removes/restores only +unchanged fixture-owned values—including its exact generated respawn row—before +restarting the original PM2 profile. Any external drift fails visibly and +leaves the normal world stopped for manual inspection. + +The bot writes a mode-0600 recovery journal before its first character/fixture +mutation, removes it only after verified restoration, and atomically creates +`${WOW_BOT_FIXTURE_JOURNAL}.cleanup-complete`. The capture wrapper removes that +marker only after the original PM2 profile is healthy again. A pending journal, +missing marker, caught TERM, state/metadata drift, or cleanup error leaves the +normal world stopped and retains recovery evidence; cleanup failure is the +reported exit status even when the capture already failed. `SIGKILL` cannot be +trapped, so an operator must recover a retained journal before restarting the +normal world. + +The shared-chest preflight claims no spawn that has pool, event, +linked-respawn, `gameobject_addon`, `gameobject_overrides`, or `spawn_group` +metadata. It pins every `gameobject_template_addon` field used by the fixture +and validates the complete spawn—including `state`—before any cleanup write. +Neither the script nor the bot may normalize that state merely to hide drift. ## Flows and the golden fixtures @@ -167,6 +317,9 @@ flows/login/cpp.pkt # C++ PKT 3.1 golden flows/login/rust/ # reference Rust dump (.bin/.meta) flows/login/expected-divergences.json # accepted-divergence baseline flows/login/flow.json # description + directions +flows//requirement.json # attestation + exact wire/payload contract +flows//capture-lineage.json # RAW/import/output hashes + exact selection +flows//capture-provenance/ # exact reviewed C++ and Rust RAW manifests ``` `cargo test -p capture-diff` runs the gate: it parses the committed pair, diffs @@ -196,13 +349,28 @@ cargo run -p capture-diff -- import login \ --until-opcode 0x3A46 --direction s2c ``` -`import` trims both captures at the boundary opcode, writes `cpp.pkt` + `rust/`, -and rewrites `expected-divergences.json`. +`import` trims both captures at the boundary opcode and prepares `cpp.pkt`, +`rust/`, the baseline, retained RAW manifests, and lineage as one complete +generation. It publishes that generation with one atomic directory rename (or +an atomic directory exchange when replacing a flow), so an interrupted import +leaves the previously installed generation intact. Existing metadata is copied +only from the explicit `README.md`, `flow.json`, and `requirement.json` +allowlist; unknown entries or symlinks fail closed. ## Adding a flow -1. Record a `cpp.pkt` (C++ `PacketLogFile`) and a `rust/` dump (scripts above). -2. `cargo run -p capture-diff -- import --cpp --rust [--from-opcode c2s:0xNNNN] [--until-opcode s2c:0xNNNN] [--ignore-opcode s2c:0xNNNN] [--direction s2c]` - — installs the fixtures under `flows//` and pins the baseline. +1. Record a `cpp.pkt` (C++ `PacketLogFile`) and a `rust/` dump with their + completed RAW manifests (scripts above). +2. `cargo run -p capture-diff -- import --cpp --rust [--cpp-manifest ] [--rust-manifest ] [--from-opcode c2s:0xNNNN] [--until-opcode s2c:0xNNNN] [--ignore-opcode s2c:0xNNNN] [--direction s2c]` + — validates the RAW artifacts and process provenance, installs the whole + fixture generation atomically, and pins the baseline plus lineage. 3. Optionally edit `flows//flow.json` (`description` / `directions`). -4. `cargo test -p capture-diff` — the new flow is now gated. +4. Inspect `capture-provenance/*.capture-manifest.json` and + `capture-lineage.json`, then run `cargo test -p capture-diff`. + +For a flow required by an issue before its real pair exists, add only +`requirement.json`, `flow.json`, and a capture runbook. Keep its status +`awaiting-real-captures`; do not add placeholder `cpp.pkt`, `rust/`, or an +accepted divergence. After a strict real import succeeds, inspect provenance, +set the requirement to `ready` as an explicit operator attestation, remove its +stale `blocked_reason`, and run `verify-required `. diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000001-counter1-0x3765-AuthSession-len70.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000001-counter1-0x3765-AuthSession-len70.meta index e91bb513e..284152bfe 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000001-counter1-0x3765-AuthSession-len70.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000001-counter1-0x3765-AuthSession-len70.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=1 counter=1 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000003-counter3-0x3767-EnterEncryptedModeAck-len2.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000003-counter3-0x3767-EnterEncryptedModeAck-len2.meta index 59d45fb6d..5fa4b0b5a 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000003-counter3-0x3767-EnterEncryptedModeAck-len2.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000003-counter3-0x3767-EnterEncryptedModeAck-len2.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=3 counter=3 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000012-counter12-0x35E9-EnumCharacters-len2.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000012-counter12-0x35E9-EnumCharacters-len2.meta index fa52ad6db..6ff448c34 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000012-counter12-0x35E9-EnumCharacters-len2.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000012-counter12-0x35E9-EnumCharacters-len2.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=12 counter=12 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000013-counter13-0x36E7-GetUndeleteCharacterCooldownStatus-len2.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000013-counter13-0x36E7-GetUndeleteCharacterCooldownStatus-len2.meta index ad2a8f642..c2c3ad240 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000013-counter13-0x36E7-GetUndeleteCharacterCooldownStatus-len2.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000013-counter13-0x36E7-GetUndeleteCharacterCooldownStatus-len2.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=13 counter=13 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000014-counter14-0x36C5-BattlePayGetPurchaseList-len2.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000014-counter14-0x36C5-BattlePayGetPurchaseList-len2.meta index 57d48beab..63917f2bd 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000014-counter14-0x36C5-BattlePayGetPurchaseList-len2.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000014-counter14-0x36C5-BattlePayGetPurchaseList-len2.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=14 counter=14 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000015-counter15-0x36C4-BattlePayGetProductList-len2.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000015-counter15-0x36C4-BattlePayGetProductList-len2.meta index 668e2d105..3fc4f57e4 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000015-counter15-0x36C4-BattlePayGetProductList-len2.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000015-counter15-0x36C4-BattlePayGetProductList-len2.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=15 counter=15 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000016-counter16-0x36FB-UpdateVasPurchaseStates-len2.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000016-counter16-0x36FB-UpdateVasPurchaseStates-len2.meta index e9b2244ac..f61e6856a 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000016-counter16-0x36FB-UpdateVasPurchaseStates-len2.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000016-counter16-0x36FB-UpdateVasPurchaseStates-len2.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=16 counter=16 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000019-counter19-0x374C-SocialContractRequest-len2.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000019-counter19-0x374C-SocialContractRequest-len2.meta index f71b3e115..a8f0ead90 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000019-counter19-0x374C-SocialContractRequest-len2.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000019-counter19-0x374C-SocialContractRequest-len2.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=19 counter=19 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000020-counter20-0x35E6-HotfixRequest-len18.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000020-counter20-0x35E6-HotfixRequest-len18.meta index 880de9e1f..63e4113e3 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000020-counter20-0x35E6-HotfixRequest-len18.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000020-counter20-0x35E6-HotfixRequest-len18.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=20 counter=20 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000021-counter21-0x36FD-BattlenetRequest-len41.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000021-counter21-0x36FD-BattlenetRequest-len41.meta index d5daf3e93..a2345fce0 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000021-counter21-0x36FD-BattlenetRequest-len41.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000021-counter21-0x36FD-BattlenetRequest-len41.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=21 counter=21 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000025-counter25-0x35E5-DbQueryBulk-len56.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000025-counter25-0x35E5-DbQueryBulk-len56.meta index 8967c7902..1aeae1473 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000025-counter25-0x35E5-DbQueryBulk-len56.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000025-counter25-0x35E5-DbQueryBulk-len56.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=25 counter=25 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000038-counter38-0x35E5-DbQueryBulk-len136.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000038-counter38-0x35E5-DbQueryBulk-len136.meta index f0e396123..69f5d88b6 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000038-counter38-0x35E5-DbQueryBulk-len136.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000038-counter38-0x35E5-DbQueryBulk-len136.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=38 counter=38 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000071-counter71-0x35F9-LoadingScreenNotify-len7.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000071-counter71-0x35F9-LoadingScreenNotify-len7.meta index cbd79513d..e92a63c40 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000071-counter71-0x35F9-LoadingScreenNotify-len7.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000071-counter71-0x35F9-LoadingScreenNotify-len7.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=71 counter=71 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000072-counter72-0x36BF-GetAccountCharacterList-len7.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000072-counter72-0x36BF-GetAccountCharacterList-len7.meta index 65cf9beba..046615aa7 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000072-counter72-0x36BF-GetAccountCharacterList-len7.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000072-counter72-0x36BF-GetAccountCharacterList-len7.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=72 counter=72 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000073-counter73-0x35E4-RequestRatedPvpInfo-len2.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000073-counter73-0x35E4-RequestRatedPvpInfo-len2.meta index 9f057ef64..37e3ed57c 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000073-counter73-0x35E4-RequestRatedPvpInfo-len2.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000073-counter73-0x35E4-RequestRatedPvpInfo-len2.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=73 counter=73 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000074-counter74-0x36C5-BattlePayGetPurchaseList-len2.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000074-counter74-0x36C5-BattlePayGetPurchaseList-len2.meta index b2e21a6ce..29c7a6f21 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000074-counter74-0x36C5-BattlePayGetPurchaseList-len2.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000074-counter74-0x36C5-BattlePayGetPurchaseList-len2.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=74 counter=74 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000075-counter75-0x35EB-PlayerLogin-len11.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000075-counter75-0x35EB-PlayerLogin-len11.meta index 9559035e1..e256d253d 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000075-counter75-0x35EB-PlayerLogin-len11.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000075-counter75-0x35EB-PlayerLogin-len11.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=75 counter=75 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000078-counter78-0x3766-AuthContinuedSession-len58.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000078-counter78-0x3766-AuthContinuedSession-len58.meta index 61d00c781..57494fa84 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000078-counter78-0x3766-AuthContinuedSession-len58.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000078-counter78-0x3766-AuthContinuedSession-len58.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=78 counter=78 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000080-counter80-0x3767-EnterEncryptedModeAck-len2.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000080-counter80-0x3767-EnterEncryptedModeAck-len2.meta index 004d56595..dba9ca987 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000080-counter80-0x3767-EnterEncryptedModeAck-len2.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000080-counter80-0x3767-EnterEncryptedModeAck-len2.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=80 counter=80 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000132-counter132-0x351E-OverrideScreenFlash-len3.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000132-counter132-0x351E-OverrideScreenFlash-len3.meta index 443e6ee84..7dee74c0a 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000132-counter132-0x351E-OverrideScreenFlash-len3.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000132-counter132-0x351E-OverrideScreenFlash-len3.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=132 counter=132 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000133-counter133-0x3187-ViolenceLevel-len3.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000133-counter133-0x3187-ViolenceLevel-len3.meta index 171451e86..43477fe10 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000133-counter133-0x3187-ViolenceLevel-len3.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000133-counter133-0x3187-ViolenceLevel-len3.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=133 counter=133 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000134-counter134-0x3196-RequestPvpRewards-len2.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000134-counter134-0x3196-RequestPvpRewards-len2.meta index 67bc1d96b..8e4b9871d 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000134-counter134-0x3196-RequestPvpRewards-len2.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000134-counter134-0x3196-RequestPvpRewards-len2.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=134 counter=134 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000135-counter135-0x376C-QueuedMessagesEnd-len6.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000135-counter135-0x376C-QueuedMessagesEnd-len6.meta index d9d726c97..f795b8811 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000135-counter135-0x376C-QueuedMessagesEnd-len6.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000135-counter135-0x376C-QueuedMessagesEnd-len6.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=135 counter=135 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000137-counter137-0x3A3D-TimeSyncResponse-len10.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000137-counter137-0x3A3D-TimeSyncResponse-len10.meta index 922e9574d..1896d0e2c 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000137-counter137-0x3A3D-TimeSyncResponse-len10.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000137-counter137-0x3A3D-TimeSyncResponse-len10.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=137 counter=137 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000138-counter138-0x3A3C-SetActiveMover-len7.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000138-counter138-0x3A3C-SetActiveMover-len7.meta index 5f5d921cf..0e97ad9c3 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000138-counter138-0x3A3C-SetActiveMover-len7.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000138-counter138-0x3A3C-SetActiveMover-len7.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=138 counter=138 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000139-counter139-0x327A-RequestPlayedTime-len3.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000139-counter139-0x327A-RequestPlayedTime-len3.meta index e31484e9e..18b2c7140 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000139-counter139-0x327A-RequestPlayedTime-len3.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000139-counter139-0x327A-RequestPlayedTime-len3.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=139 counter=139 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000147-counter147-0x3A46-MoveInitActiveMoverComplete-len6.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000147-counter147-0x3A46-MoveInitActiveMoverComplete-len6.meta index c73ae8a0c..e885a5e0c 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000147-counter147-0x3A46-MoveInitActiveMoverComplete-len6.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000147-counter147-0x3A46-MoveInitActiveMoverComplete-len6.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=147 counter=147 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000000-counter0-0x3048-AuthChallenge-len51.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000000-counter0-0x3048-AuthChallenge-len51.meta index 5184f335d..118e1ad0b 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000000-counter0-0x3048-AuthChallenge-len51.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000000-counter0-0x3048-AuthChallenge-len51.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=0 counter=0 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000002-counter2-0x3049-EnterEncryptedMode-len67.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000002-counter2-0x3049-EnterEncryptedMode-len67.meta index c61fbdc2a..5dfa98b21 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000002-counter2-0x3049-EnterEncryptedMode-len67.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000002-counter2-0x3049-EnterEncryptedMode-len67.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=2 counter=2 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000004-counter4-0x256D-AuthResponse-len434.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000004-counter4-0x256D-AuthResponse-len434.meta index 75c9c55f3..59d06e8ce 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000004-counter4-0x256D-AuthResponse-len434.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000004-counter4-0x256D-AuthResponse-len434.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=4 counter=4 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000005-counter5-0x2677-SetTimeZoneInformation-len26.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000005-counter5-0x2677-SetTimeZoneInformation-len26.meta index 18db8b647..9afe77a6a 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000005-counter5-0x2677-SetTimeZoneInformation-len26.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000005-counter5-0x2677-SetTimeZoneInformation-len26.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=5 counter=5 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000006-counter6-0x25C0-FeatureSystemStatusGlueScreen-len91.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000006-counter6-0x25C0-FeatureSystemStatusGlueScreen-len91.meta index 2c72e89ca..4929ebc49 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000006-counter6-0x25C0-FeatureSystemStatusGlueScreen-len91.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000006-counter6-0x25C0-FeatureSystemStatusGlueScreen-len91.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=6 counter=6 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000007-counter7-0x291C-CacheVersion-len6.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000007-counter7-0x291C-CacheVersion-len6.meta index cc89e5d53..2d552185b 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000007-counter7-0x291C-CacheVersion-len6.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000007-counter7-0x291C-CacheVersion-len6.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=7 counter=7 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000008-counter8-0x290F-AvailableHotfixes-len690.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000008-counter8-0x290F-AvailableHotfixes-len690.meta index 8c905e414..72d22fc7c 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000008-counter8-0x290F-AvailableHotfixes-len690.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000008-counter8-0x290F-AvailableHotfixes-len690.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=8 counter=8 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000009-counter9-0x270A-AccountDataTimes-len132.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000009-counter9-0x270A-AccountDataTimes-len132.meta index 58086508a..9de56de2d 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000009-counter9-0x270A-AccountDataTimes-len132.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000009-counter9-0x270A-AccountDataTimes-len132.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=9 counter=9 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000010-counter10-0x27BE-TutorialFlags-len34.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000010-counter10-0x27BE-TutorialFlags-len34.meta index 3434dae3b..34248e0e5 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000010-counter10-0x27BE-TutorialFlags-len34.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000010-counter10-0x27BE-TutorialFlags-len34.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=10 counter=10 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000011-counter11-0x2809-BattleNetConnectionStatus-len3.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000011-counter11-0x2809-BattleNetConnectionStatus-len3.meta index af8445b97..3c435338a 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000011-counter11-0x2809-BattleNetConnectionStatus-len3.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000011-counter11-0x2809-BattleNetConnectionStatus-len3.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=11 counter=11 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000017-counter17-0x2583-EnumCharactersResult-len1262.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000017-counter17-0x2583-EnumCharactersResult-len1262.meta index d679ee1d9..99dda809f 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000017-counter17-0x2583-EnumCharactersResult-len1262.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000017-counter17-0x2583-EnumCharactersResult-len1262.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=17 counter=17 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000018-counter18-0x27CE-UndeleteCooldownStatusResponse-len11.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000018-counter18-0x27CE-UndeleteCooldownStatusResponse-len11.meta index decc96190..7fc34f09a 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000018-counter18-0x27CE-UndeleteCooldownStatusResponse-len11.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000018-counter18-0x27CE-UndeleteCooldownStatusResponse-len11.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=18 counter=18 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000022-counter22-0x2892-SocialContractRequestResponse-len3.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000022-counter22-0x2892-SocialContractRequestResponse-len3.meta index 5d3879c7a..bdfe23f93 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000022-counter22-0x2892-SocialContractRequestResponse-len3.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000022-counter22-0x2892-SocialContractRequestResponse-len3.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=22 counter=22 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000023-counter23-0x2911-HotfixConnect-len262.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000023-counter23-0x2911-HotfixConnect-len262.meta index d79ef5267..d193daea8 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000023-counter23-0x2911-HotfixConnect-len262.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000023-counter23-0x2911-HotfixConnect-len262.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=23 counter=23 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000024-counter24-0x2807-BattlenetResponse-len30.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000024-counter24-0x2807-BattlenetResponse-len30.meta index 54d8c4965..d56a84176 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000024-counter24-0x2807-BattlenetResponse-len30.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000024-counter24-0x2807-BattlenetResponse-len30.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=24 counter=24 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000026-counter26-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000026-counter26-0x290E-DbReply-len19.meta index 0ae4176cb..5c29bcb30 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000026-counter26-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000026-counter26-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=26 counter=26 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000027-counter27-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000027-counter27-0x290E-DbReply-len19.meta index 20c103c2a..3d955297d 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000027-counter27-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000027-counter27-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=27 counter=27 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000028-counter28-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000028-counter28-0x290E-DbReply-len19.meta index 5b43afb69..a60e2d1f5 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000028-counter28-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000028-counter28-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=28 counter=28 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000029-counter29-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000029-counter29-0x290E-DbReply-len19.meta index 00fdd9f1a..ed03fe9ef 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000029-counter29-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000029-counter29-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=29 counter=29 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000030-counter30-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000030-counter30-0x290E-DbReply-len19.meta index ce73afab1..0aeafda4b 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000030-counter30-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000030-counter30-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=30 counter=30 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000031-counter31-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000031-counter31-0x290E-DbReply-len19.meta index 244fb97d1..023a98961 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000031-counter31-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000031-counter31-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=31 counter=31 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000032-counter32-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000032-counter32-0x290E-DbReply-len19.meta index 6f2b3148d..e7de1c153 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000032-counter32-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000032-counter32-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=32 counter=32 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000033-counter33-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000033-counter33-0x290E-DbReply-len19.meta index aee5c45bf..36b05ffaa 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000033-counter33-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000033-counter33-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=33 counter=33 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000034-counter34-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000034-counter34-0x290E-DbReply-len19.meta index 6095edc31..3c931df26 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000034-counter34-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000034-counter34-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=34 counter=34 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000035-counter35-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000035-counter35-0x290E-DbReply-len19.meta index 39b84189b..749906854 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000035-counter35-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000035-counter35-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=35 counter=35 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000036-counter36-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000036-counter36-0x290E-DbReply-len19.meta index 807bc90c1..b1ae295a1 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000036-counter36-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000036-counter36-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=36 counter=36 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000037-counter37-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000037-counter37-0x290E-DbReply-len19.meta index f3ff7d7f1..3700f62fa 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000037-counter37-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000037-counter37-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=37 counter=37 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000039-counter39-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000039-counter39-0x290E-DbReply-len19.meta index 747bdac77..2fbcdd761 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000039-counter39-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000039-counter39-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=39 counter=39 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000040-counter40-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000040-counter40-0x290E-DbReply-len19.meta index b71d2fa23..339015907 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000040-counter40-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000040-counter40-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=40 counter=40 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000041-counter41-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000041-counter41-0x290E-DbReply-len19.meta index d0e82adae..2e617d2b9 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000041-counter41-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000041-counter41-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=41 counter=41 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000042-counter42-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000042-counter42-0x290E-DbReply-len19.meta index ed7430649..9d6f67a7b 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000042-counter42-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000042-counter42-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=42 counter=42 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000043-counter43-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000043-counter43-0x290E-DbReply-len19.meta index 143d4c06d..4250c1f6f 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000043-counter43-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000043-counter43-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=43 counter=43 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000044-counter44-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000044-counter44-0x290E-DbReply-len19.meta index 046fb4a46..68859c8dd 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000044-counter44-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000044-counter44-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=44 counter=44 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000045-counter45-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000045-counter45-0x290E-DbReply-len19.meta index 410982029..b448b8b62 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000045-counter45-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000045-counter45-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=45 counter=45 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000046-counter46-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000046-counter46-0x290E-DbReply-len19.meta index e7d8b180c..dd9ea6e69 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000046-counter46-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000046-counter46-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=46 counter=46 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000047-counter47-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000047-counter47-0x290E-DbReply-len19.meta index 72b7a1f35..ce01f0c58 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000047-counter47-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000047-counter47-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=47 counter=47 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000048-counter48-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000048-counter48-0x290E-DbReply-len19.meta index 97f0a417f..52e151e02 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000048-counter48-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000048-counter48-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=48 counter=48 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000049-counter49-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000049-counter49-0x290E-DbReply-len19.meta index 5f0fca5d2..f48848a62 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000049-counter49-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000049-counter49-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=49 counter=49 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000050-counter50-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000050-counter50-0x290E-DbReply-len19.meta index ffcd28d64..d08e36c1a 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000050-counter50-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000050-counter50-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=50 counter=50 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000051-counter51-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000051-counter51-0x290E-DbReply-len19.meta index 75798a9ac..618b46420 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000051-counter51-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000051-counter51-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=51 counter=51 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000052-counter52-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000052-counter52-0x290E-DbReply-len19.meta index 9f3c81b3e..a2b72df11 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000052-counter52-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000052-counter52-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=52 counter=52 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000053-counter53-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000053-counter53-0x290E-DbReply-len19.meta index 53c56eeac..773ef9a7c 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000053-counter53-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000053-counter53-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=53 counter=53 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000054-counter54-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000054-counter54-0x290E-DbReply-len19.meta index d4ca9010e..1c561bbaa 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000054-counter54-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000054-counter54-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=54 counter=54 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000055-counter55-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000055-counter55-0x290E-DbReply-len19.meta index 738090dee..abe7c7048 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000055-counter55-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000055-counter55-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=55 counter=55 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000056-counter56-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000056-counter56-0x290E-DbReply-len19.meta index d1e616404..d007a0ce2 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000056-counter56-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000056-counter56-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=56 counter=56 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000057-counter57-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000057-counter57-0x290E-DbReply-len19.meta index 1e5eaa1e4..bd6dd344f 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000057-counter57-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000057-counter57-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=57 counter=57 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000058-counter58-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000058-counter58-0x290E-DbReply-len19.meta index 14a27cf50..5a467fe87 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000058-counter58-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000058-counter58-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=58 counter=58 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000059-counter59-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000059-counter59-0x290E-DbReply-len19.meta index 297ad805b..06c60a1e9 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000059-counter59-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000059-counter59-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=59 counter=59 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000060-counter60-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000060-counter60-0x290E-DbReply-len19.meta index f966b6286..4ce8e22c8 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000060-counter60-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000060-counter60-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=60 counter=60 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000061-counter61-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000061-counter61-0x290E-DbReply-len19.meta index e12eacbb4..048e53147 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000061-counter61-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000061-counter61-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=61 counter=61 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000062-counter62-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000062-counter62-0x290E-DbReply-len19.meta index 64f6b2493..800238561 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000062-counter62-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000062-counter62-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=62 counter=62 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000063-counter63-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000063-counter63-0x290E-DbReply-len19.meta index afa7a93bc..406ffd8fd 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000063-counter63-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000063-counter63-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=63 counter=63 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000064-counter64-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000064-counter64-0x290E-DbReply-len19.meta index d2134813f..761bf8964 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000064-counter64-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000064-counter64-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=64 counter=64 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000065-counter65-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000065-counter65-0x290E-DbReply-len19.meta index 81ca18671..89e26bba0 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000065-counter65-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000065-counter65-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=65 counter=65 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000066-counter66-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000066-counter66-0x290E-DbReply-len19.meta index d2a1d3f13..9e22c4eb0 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000066-counter66-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000066-counter66-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=66 counter=66 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000067-counter67-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000067-counter67-0x290E-DbReply-len19.meta index a634e03f0..366d901ac 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000067-counter67-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000067-counter67-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=67 counter=67 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000068-counter68-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000068-counter68-0x290E-DbReply-len19.meta index 771a821da..bc8d74db9 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000068-counter68-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000068-counter68-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=68 counter=68 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000069-counter69-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000069-counter69-0x290E-DbReply-len19.meta index ec8683812..eb7c505ef 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000069-counter69-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000069-counter69-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=69 counter=69 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000070-counter70-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000070-counter70-0x290E-DbReply-len19.meta index 205251886..95b166250 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000070-counter70-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000070-counter70-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=70 counter=70 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000076-counter76-0x304D-ConnectTo-len278.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000076-counter76-0x304D-ConnectTo-len278.meta index 20b5ed57a..a6609ecdb 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000076-counter76-0x304D-ConnectTo-len278.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000076-counter76-0x304D-ConnectTo-len278.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=76 counter=76 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000077-counter77-0x3048-AuthChallenge-len51.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000077-counter77-0x3048-AuthChallenge-len51.meta index fa43e3feb..99bb10042 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000077-counter77-0x3048-AuthChallenge-len51.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000077-counter77-0x3048-AuthChallenge-len51.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=77 counter=77 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000079-counter79-0x3049-EnterEncryptedMode-len67.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000079-counter79-0x3049-EnterEncryptedMode-len67.meta index 91a9ac192..baf17142b 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000079-counter79-0x3049-EnterEncryptedMode-len67.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000079-counter79-0x3049-EnterEncryptedMode-len67.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=79 counter=79 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000081-counter81-0x304B-ResumeComms-len2.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000081-counter81-0x304B-ResumeComms-len2.meta index f181efc7f..4c592cc46 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000081-counter81-0x304B-ResumeComms-len2.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000081-counter81-0x304B-ResumeComms-len2.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=81 counter=81 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000082-counter82-0x2735-SetProficiency-len7.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000082-counter82-0x2735-SetProficiency-len7.meta index 76432f4cc..84a10065a 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000082-counter82-0x2735-SetProficiency-len7.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000082-counter82-0x2735-SetProficiency-len7.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=82 counter=82 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000083-counter83-0x2735-SetProficiency-len7.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000083-counter83-0x2735-SetProficiency-len7.meta index 67a28cbea..43b21bb28 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000083-counter83-0x2735-SetProficiency-len7.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000083-counter83-0x2735-SetProficiency-len7.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=83 counter=83 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000084-counter84-0x2735-SetProficiency-len7.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000084-counter84-0x2735-SetProficiency-len7.meta index 4fd851bed..4ada60a9f 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000084-counter84-0x2735-SetProficiency-len7.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000084-counter84-0x2735-SetProficiency-len7.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=84 counter=84 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000085-counter85-0x2735-SetProficiency-len7.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000085-counter85-0x2735-SetProficiency-len7.meta index 90969cda8..19b7acbd1 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000085-counter85-0x2735-SetProficiency-len7.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000085-counter85-0x2735-SetProficiency-len7.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=85 counter=85 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000086-counter86-0x2735-SetProficiency-len7.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000086-counter86-0x2735-SetProficiency-len7.meta index 6cb7def53..575be440b 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000086-counter86-0x2735-SetProficiency-len7.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000086-counter86-0x2735-SetProficiency-len7.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=86 counter=86 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000087-counter87-0x2735-SetProficiency-len7.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000087-counter87-0x2735-SetProficiency-len7.meta index 6727ae6ba..9b6bd0a05 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000087-counter87-0x2735-SetProficiency-len7.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000087-counter87-0x2735-SetProficiency-len7.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=87 counter=87 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000088-counter88-0x2735-SetProficiency-len7.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000088-counter88-0x2735-SetProficiency-len7.meta index e45e34f0e..d8e9788dc 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000088-counter88-0x2735-SetProficiency-len7.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000088-counter88-0x2735-SetProficiency-len7.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=88 counter=88 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000089-counter89-0x2735-SetProficiency-len7.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000089-counter89-0x2735-SetProficiency-len7.meta index 10e4d7e2e..2bf736b8b 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000089-counter89-0x2735-SetProficiency-len7.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000089-counter89-0x2735-SetProficiency-len7.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=89 counter=89 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000090-counter90-0x2C1F-AuraUpdate-len43.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000090-counter90-0x2C1F-AuraUpdate-len43.meta index c223869ad..781536c5f 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000090-counter90-0x2C1F-AuraUpdate-len43.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000090-counter90-0x2C1F-AuraUpdate-len43.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=90 counter=90 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000091-counter91-0x2C1F-AuraUpdate-len44.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000091-counter91-0x2C1F-AuraUpdate-len44.meta index 712fbfd2c..a3d88d1a4 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000091-counter91-0x2C1F-AuraUpdate-len44.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000091-counter91-0x2C1F-AuraUpdate-len44.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=91 counter=91 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000092-counter92-0x2C1F-AuraUpdate-len44.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000092-counter92-0x2C1F-AuraUpdate-len44.meta index 11105a192..2297fd6a3 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000092-counter92-0x2C1F-AuraUpdate-len44.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000092-counter92-0x2C1F-AuraUpdate-len44.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=92 counter=92 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000093-counter93-0x2C1F-AuraUpdate-len44.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000093-counter93-0x2C1F-AuraUpdate-len44.meta index 16175e70e..86e32b9c1 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000093-counter93-0x2C1F-AuraUpdate-len44.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000093-counter93-0x2C1F-AuraUpdate-len44.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=93 counter=93 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000094-counter94-0x2C1F-AuraUpdate-len44.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000094-counter94-0x2C1F-AuraUpdate-len44.meta index fbc8f3eb0..fa9f0d0e2 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000094-counter94-0x2C1F-AuraUpdate-len44.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000094-counter94-0x2C1F-AuraUpdate-len44.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=94 counter=94 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000095-counter95-0x2C1F-AuraUpdate-len44.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000095-counter95-0x2C1F-AuraUpdate-len44.meta index fe465ace4..f35204fee 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000095-counter95-0x2C1F-AuraUpdate-len44.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000095-counter95-0x2C1F-AuraUpdate-len44.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=95 counter=95 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000096-counter96-0x2C1F-AuraUpdate-len44.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000096-counter96-0x2C1F-AuraUpdate-len44.meta index 995209376..d8d075a86 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000096-counter96-0x2C1F-AuraUpdate-len44.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000096-counter96-0x2C1F-AuraUpdate-len44.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=96 counter=96 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000097-counter97-0x2C1F-AuraUpdate-len44.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000097-counter97-0x2C1F-AuraUpdate-len44.meta index 329265cc7..2287cf2fb 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000097-counter97-0x2C1F-AuraUpdate-len44.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000097-counter97-0x2C1F-AuraUpdate-len44.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=97 counter=97 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000098-counter98-0x2C1F-AuraUpdate-len44.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000098-counter98-0x2C1F-AuraUpdate-len44.meta index 806103e16..ab80adcef 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000098-counter98-0x2C1F-AuraUpdate-len44.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000098-counter98-0x2C1F-AuraUpdate-len44.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=98 counter=98 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000099-counter99-0x2C1F-AuraUpdate-len44.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000099-counter99-0x2C1F-AuraUpdate-len44.meta index ca90afeb4..6316d9fed 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000099-counter99-0x2C1F-AuraUpdate-len44.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000099-counter99-0x2C1F-AuraUpdate-len44.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=99 counter=99 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000100-counter100-0x2C1F-AuraUpdate-len44.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000100-counter100-0x2C1F-AuraUpdate-len44.meta index f9e19ff69..d5afdfbbe 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000100-counter100-0x2C1F-AuraUpdate-len44.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000100-counter100-0x2C1F-AuraUpdate-len44.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=100 counter=100 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000101-counter101-0x2C1F-AuraUpdate-len44.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000101-counter101-0x2C1F-AuraUpdate-len44.meta index 686d5dbdf..288f3fd41 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000101-counter101-0x2C1F-AuraUpdate-len44.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000101-counter101-0x2C1F-AuraUpdate-len44.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=101 counter=101 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000102-counter102-0x2C1F-AuraUpdate-len44.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000102-counter102-0x2C1F-AuraUpdate-len44.meta index 0bb112093..13edbe021 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000102-counter102-0x2C1F-AuraUpdate-len44.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000102-counter102-0x2C1F-AuraUpdate-len44.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=102 counter=102 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000103-counter103-0x26A4-SetDungeonDifficulty-len6.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000103-counter103-0x26A4-SetDungeonDifficulty-len6.meta index f3d4ef4b4..eb47059fd 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000103-counter103-0x26A4-SetDungeonDifficulty-len6.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000103-counter103-0x26A4-SetDungeonDifficulty-len6.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=103 counter=103 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000104-counter104-0x2597-LoginVerifyWorld-len26.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000104-counter104-0x2597-LoginVerifyWorld-len26.meta index 5a4ecb48b..c8813cf68 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000104-counter104-0x2597-LoginVerifyWorld-len26.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000104-counter104-0x2597-LoginVerifyWorld-len26.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=104 counter=104 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000105-counter105-0x270A-AccountDataTimes-len135.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000105-counter105-0x270A-AccountDataTimes-len135.meta index e4df6ae14..585f49b09 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000105-counter105-0x270A-AccountDataTimes-len135.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000105-counter105-0x270A-AccountDataTimes-len135.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=105 counter=105 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000106-counter106-0x2DD2-TimeSyncRequest-len6.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000106-counter106-0x2DD2-TimeSyncRequest-len6.meta index f848757da..d0314c512 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000106-counter106-0x2DD2-TimeSyncRequest-len6.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000106-counter106-0x2DD2-TimeSyncRequest-len6.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=106 counter=106 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000107-counter107-0x25BF-FeatureSystemStatus-len192.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000107-counter107-0x25BF-FeatureSystemStatus-len192.meta index ac25d9d2b..6e5da126f 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000107-counter107-0x25BF-FeatureSystemStatus-len192.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000107-counter107-0x25BF-FeatureSystemStatus-len192.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=107 counter=107 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000108-counter108-0x2BC5-ChatServerMessage-len41.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000108-counter108-0x2BC5-ChatServerMessage-len41.meta index 89ac7bac0..a45d9404a 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000108-counter108-0x2BC5-ChatServerMessage-len41.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000108-counter108-0x2BC5-ChatServerMessage-len41.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=108 counter=108 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000109-counter109-0x2677-SetTimeZoneInformation-len26.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000109-counter109-0x2677-SetTimeZoneInformation-len26.meta index 15f8fc39a..9dab9521f 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000109-counter109-0x2677-SetTimeZoneInformation-len26.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000109-counter109-0x2677-SetTimeZoneInformation-len26.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=109 counter=109 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000110-counter110-0x278C-ContactList-len40.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000110-counter110-0x278C-ContactList-len40.meta index cdea9ae45..7882bb1eb 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000110-counter110-0x278C-ContactList-len40.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000110-counter110-0x278C-ContactList-len40.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=110 counter=110 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000111-counter111-0x301B-QueryPlayerNamesResponse-len43.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000111-counter111-0x301B-QueryPlayerNamesResponse-len43.meta index 09dceab95..92a6b7c53 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000111-counter111-0x301B-QueryPlayerNamesResponse-len43.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000111-counter111-0x301B-QueryPlayerNamesResponse-len43.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=111 counter=111 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000112-counter112-0x2C51-ActiveGlyphs-len7.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000112-counter112-0x2C51-ActiveGlyphs-len7.meta index 220ddf15e..d5687d142 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000112-counter112-0x2C51-ActiveGlyphs-len7.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000112-counter112-0x2C51-ActiveGlyphs-len7.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=112 counter=112 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000113-counter113-0x257D-BindPointUpdate-len22.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000113-counter113-0x257D-BindPointUpdate-len22.meta index d7ca3fe26..66951edea 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000113-counter113-0x257D-BindPointUpdate-len22.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000113-counter113-0x257D-BindPointUpdate-len22.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=113 counter=113 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000114-counter114-0x25D7-UpdateTalentData-len49.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000114-counter114-0x25D7-UpdateTalentData-len49.meta index 301e86a94..11a8c1a4a 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000114-counter114-0x25D7-UpdateTalentData-len49.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000114-counter114-0x25D7-UpdateTalentData-len49.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=114 counter=114 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000115-counter115-0x2C27-SendKnownSpells-len1191.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000115-counter115-0x2C27-SendKnownSpells-len1191.meta index cb7dd161f..4eb01d89a 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000115-counter115-0x2C27-SendKnownSpells-len1191.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000115-counter115-0x2C27-SendKnownSpells-len1191.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=115 counter=115 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000116-counter116-0x2C2B-SendUnlearnSpells-len6.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000116-counter116-0x2C2B-SendUnlearnSpells-len6.meta index 2eca31e11..cafa9ab55 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000116-counter116-0x2C2B-SendUnlearnSpells-len6.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000116-counter116-0x2C2B-SendUnlearnSpells-len6.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=116 counter=116 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000117-counter117-0x2C28-SendSpellHistory-len31.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000117-counter117-0x2C28-SendSpellHistory-len31.meta index 13b6edcc0..654316220 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000117-counter117-0x2C28-SendSpellHistory-len31.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000117-counter117-0x2C28-SendSpellHistory-len31.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=117 counter=117 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000118-counter118-0x2C2A-SendSpellCharges-len6.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000118-counter118-0x2C2A-SendSpellCharges-len6.meta index c82b50182..f14b069b5 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000118-counter118-0x2C2A-SendSpellCharges-len6.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000118-counter118-0x2C2A-SendSpellCharges-len6.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=118 counter=118 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000119-counter119-0x25E0-UpdateActionButtons-len1443.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000119-counter119-0x25E0-UpdateActionButtons-len1443.meta index 200206b32..2dc6bcca4 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000119-counter119-0x25E0-UpdateActionButtons-len1443.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000119-counter119-0x25E0-UpdateActionButtons-len1443.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=119 counter=119 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000120-counter120-0x2724-InitializeFactions-len6127.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000120-counter120-0x2724-InitializeFactions-len6127.meta index b902c4f43..ce122f56b 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000120-counter120-0x2724-InitializeFactions-len6127.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000120-counter120-0x2724-InitializeFactions-len6127.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=120 counter=120 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000121-counter121-0x2573-SetupCurrency-len6.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000121-counter121-0x2573-SetupCurrency-len6.meta index 89717f60b..d69f1067b 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000121-counter121-0x2573-SetupCurrency-len6.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000121-counter121-0x2573-SetupCurrency-len6.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=121 counter=121 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000122-counter122-0x270E-LoadEquipmentSet-len6.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000122-counter122-0x270E-LoadEquipmentSet-len6.meta index 34eee1845..1c10ee63c 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000122-counter122-0x270E-LoadEquipmentSet-len6.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000122-counter122-0x270E-LoadEquipmentSet-len6.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=122 counter=122 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000123-counter123-0x2570-AllAchievementData-len10.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000123-counter123-0x2570-AllAchievementData-len10.meta index a9dfd78b1..e92129a12 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000123-counter123-0x2570-AllAchievementData-len10.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000123-counter123-0x2570-AllAchievementData-len10.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=123 counter=123 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000124-counter124-0x270D-LoginSetTimeSpeed-len22.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000124-counter124-0x270D-LoginSetTimeSpeed-len22.meta index db80e167f..8ab8862a4 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000124-counter124-0x270D-LoginSetTimeSpeed-len22.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000124-counter124-0x270D-LoginSetTimeSpeed-len22.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=124 counter=124 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000125-counter125-0x25AD-WorldServerInfo-len7.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000125-counter125-0x25AD-WorldServerInfo-len7.meta index e04c8fab6..5ae38540b 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000125-counter125-0x25AD-WorldServerInfo-len7.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000125-counter125-0x25AD-WorldServerInfo-len7.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=125 counter=125 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000126-counter126-0x2C33-SetFlatSpellModifier-len6.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000126-counter126-0x2C33-SetFlatSpellModifier-len6.meta index 7aafa99fd..647ba9248 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000126-counter126-0x2C33-SetFlatSpellModifier-len6.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000126-counter126-0x2C33-SetFlatSpellModifier-len6.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=126 counter=126 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000127-counter127-0x2C34-SetPctSpellModifier-len6.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000127-counter127-0x2C34-SetPctSpellModifier-len6.meta index 6761661ea..aa94bdb34 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000127-counter127-0x2C34-SetPctSpellModifier-len6.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000127-counter127-0x2C34-SetPctSpellModifier-len6.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=127 counter=127 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000128-counter128-0x25AE-AccountMountUpdate-len22.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000128-counter128-0x25AE-AccountMountUpdate-len22.meta index 06e9e9eb4..99688ebbd 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000128-counter128-0x25AE-AccountMountUpdate-len22.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000128-counter128-0x25AE-AccountMountUpdate-len22.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=128 counter=128 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000129-counter129-0x25B0-AccountToyUpdate-len15.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000129-counter129-0x25B0-AccountToyUpdate-len15.meta index fa7d0fee5..4b518d6f2 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000129-counter129-0x25B0-AccountToyUpdate-len15.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000129-counter129-0x25B0-AccountToyUpdate-len15.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=129 counter=129 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000130-counter130-0x2580-InitialSetup-len4.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000130-counter130-0x2580-InitialSetup-len4.meta index d8ead5e94..5d3e1f971 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000130-counter130-0x2580-InitialSetup-len4.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000130-counter130-0x2580-InitialSetup-len4.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=130 counter=130 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000131-counter131-0x2DD5-MoveSetActiveMover-len7.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000131-counter131-0x2DD5-MoveSetActiveMover-len7.meta index 34fdcf69a..01be36af1 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000131-counter131-0x2DD5-MoveSetActiveMover-len7.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000131-counter131-0x2DD5-MoveSetActiveMover-len7.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=131 counter=131 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000136-counter136-0x27CB-UpdateObject-len49227.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000136-counter136-0x27CB-UpdateObject-len49227.meta index fa7ae6854..519e70138 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000136-counter136-0x27CB-UpdateObject-len49227.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000136-counter136-0x27CB-UpdateObject-len49227.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=136 counter=136 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000140-counter140-0x27CB-UpdateObject-len680.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000140-counter140-0x27CB-UpdateObject-len680.meta index 7736a774e..a2f090338 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000140-counter140-0x27CB-UpdateObject-len680.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000140-counter140-0x27CB-UpdateObject-len680.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=140 counter=140 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000141-counter141-0x2578-PhaseShiftChange-len29.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000141-counter141-0x2578-PhaseShiftChange-len29.meta index b3ab94057..c5fa52692 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000141-counter141-0x2578-PhaseShiftChange-len29.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000141-counter141-0x2578-PhaseShiftChange-len29.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=141 counter=141 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000142-counter142-0x2746-InitWorldStates-len386.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000142-counter142-0x2746-InitWorldStates-len386.meta index bb283d452..46774932d 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000142-counter142-0x2746-InitWorldStates-len386.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000142-counter142-0x2746-InitWorldStates-len386.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=142 counter=142 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000143-counter143-0x2578-PhaseShiftChange-len29.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000143-counter143-0x2578-PhaseShiftChange-len29.meta index a108cf910..4239550b5 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000143-counter143-0x2578-PhaseShiftChange-len29.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000143-counter143-0x2578-PhaseShiftChange-len29.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=143 counter=143 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000144-counter144-0x25BC-LoadCufProfiles-len30.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000144-counter144-0x25BC-LoadCufProfiles-len30.meta index 43ee4738e..c10736ea5 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000144-counter144-0x25BC-LoadCufProfiles-len30.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000144-counter144-0x25BC-LoadCufProfiles-len30.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=144 counter=144 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000145-counter145-0x2C1F-AuraUpdate-len463.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000145-counter145-0x2C1F-AuraUpdate-len463.meta index 67ec06180..1496d2886 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000145-counter145-0x2C1F-AuraUpdate-len463.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000145-counter145-0x2C1F-AuraUpdate-len463.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=145 counter=145 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000146-counter146-0x27CB-UpdateObject-len506.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000146-counter146-0x27CB-UpdateObject-len506.meta index 848f2ba3f..16ee51130 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000146-counter146-0x27CB-UpdateObject-len506.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000146-counter146-0x27CB-UpdateObject-len506.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=146 counter=146 diff --git a/crates/capture-diff/flows/loot-single-item-claim/README.md b/crates/capture-diff/flows/loot-single-item-claim/README.md new file mode 100644 index 000000000..74d89425b --- /dev/null +++ b/crates/capture-diff/flows/loot-single-item-claim/README.md @@ -0,0 +1,367 @@ +# Required capture: single-session loot item claim + +This directory is the required real-capture gate for issue #106. Its existing +isolated one-client C++/Rust action windows contain six packets each and report +`6 matched, 0 value-diffs, 0 routing-diffs, 0 missing, 0 extra`, with an empty +accepted-divergence baseline. They predate the mandatory completed RAW +manifests, however, so their executable/process/config lineage cannot be +verified. They are useful semantic regression fixtures but are not accredited +completion evidence. `requirement.json` must stay `awaiting-real-captures` and +`verify-required` must fail until this runbook produces and imports a fresh +manifest-backed pair. + +## C++-anchored wire contract + +Use exactly one logged-in character and no party. Kill and open one disposable, +stationary creature whose loot table contains exactly one unconditional +quantity-1 item. Drain the open response before the action window, then claim +that item and finish with a fixed-serial, zero-latency `CMSG_PING` on the +instance socket. The imported window must begin and end at those two CMSGs. + +After the three reviewed symmetric filters below, the required window is +exactly these six packets in this order: + +1. instance `CMSG_LOOT_ITEM` (`c2s`, connection 1, `0x3211`); +2. instance `SMSG_UPDATE_OBJECT` Item `CreateObject` (`s2c`, connection 1, + `0x27CB`); +3. instance `SMSG_LOOT_REMOVED` (`s2c`, connection 1, `0x2615`); +4. realm `SMSG_ITEM_PUSH_RESULT` (`s2c`, connection 0, `0x2623`); +5. instance `SMSG_UPDATE_OBJECT` one-slot `InvSlots` VALUES update (`s2c`, + connection 1, `0x27CB`); +6. instance `CMSG_PING` fence (`c2s`, connection 1, `0x3768`). + +This is grounded in the legacy source, not inferred from Rust: + +- `LootHandler.cpp::HandleAutostoreLootItemOpcode` delegates to + `Player::StoreLootItem`; +- `Player.cpp::StoreLootItem` calls `NotifyItemRemoved` before `SendNewItem`; +- `Opcodes.cpp` routes `SMSG_LOOT_REMOVED` to `CONNECTION_TYPE_INSTANCE` and + `SMSG_ITEM_PUSH_RESULT` to `CONNECTION_TYPE_REALM`. + +No extra, replacement, reordered, wrong-socket, wrong-direction, or duplicate +packet is accepted inside that post-filter window. The semantic contract also +decodes each side independently: the request is one non-soft claim for the +canonical type-15/map-530 LootObj at list `0`; the created Item is the exact +owner-visible quantity-1 entry `30712`; removal is owned by Doctor Maleficus +entry `21779` on map `530` and correlates the request LootObj/list; the push is +for character `15`, quantity `1`, the same canonical Item GUID and deterministic +slot/flags/bonus/modification shape; the one-child `InvSlots` VALUES update +correlates that player, item, and `SlotInBag`; and the fence body is fixed +`TOOL` serial plus zero latency. The bot sends the fence as soon as it has +received the one removal and one item-push response; it does not add a fixed +settle delay. Periodic time-sync request/response pairs and independent creature +movement are the only reviewed symmetric import filters. + +## Reproducibility and safety + +- The bot requires zero globally-online characters before setup, after logout + verification, and after cleanup. It snapshots both disposable characters' + position/money plus level, XP, health, powers, rest, exploration and title + state; it also snapshots all personal achievement/criteria/quest rows and + reputation. Cleanup restores these rows transactionally and reloads them for + exact verification. The characters must not belong to a guild, because C++ + fans kill/item criteria into guild-wide state. +- The versioned defaults are character GUIDs `15` and `16`; setup verifies + their configured account owners and every cleanup delete/update remains + bounded to those two GUIDs (plus the newly awarded item GUID discovered + under those owners). +- Item `30712` is The Doctor's Key. Character `15` must have exact top-level + keyring destination `bag=0, slot=106` empty before either run. Wire evidence, + persistence, and post-cleanup verification all pin that same slot; a grant + displaced into a backpack slot is a failure even if the item entry matches. +- Snapshot the inventory rows, creature respawn row, and current maximum + item-instance GUID before either run as an operator-visible cross-check. +- Use the same clean DB snapshot and restart each world server before its run. + This is necessary because both servers allocate the awarded item at the + process-global `MAX(item_instance.guid) + 1` frontier. +- Restore the snapshot before switching C++ → Rust. Do not run another item + creator between the two captures. +- Keep money outside this window. Doctor Maleficus (entry `21779`, database + spawn id `1117`) has exactly one guaranteed item (`30712`) and + `GoldMin=GoldMax=0`; a money claim is a separate capture contract. +- Do not use the two-client loot-race dump as this golden. Global C++/Rust logs + merge both clients, so concurrent claims cannot be attributed or ordered as a + reproducible single-session flow. +- The paired issue-#106 captures proved one unavoidable identity difference: + the same Doctor owner was Creature runtime counter `268` in C++ and `1` in + Rust. `capture-diff` therefore normalizes only that Owner GUID's lower 40-bit + map-runtime counter in `SMSG_LOOT_REMOVED`. Both counters must be nonzero; + the complete stable Owner identity, complete LootObj GUID (including its own + counter), LootListID, canonical packed shape, body boundaries, and instance + routing remain strict. Do not add any broader GUID normalization. +- Two independent C++ runs also emitted the same second + `SMSG_UPDATE_OBJECT` (`0x27CB`) body byte-for-byte. It was a 51-byte, + single-player VALUES update whose `UnitData` contribution was exactly + `08 00 10 00 00`: parent bit 116 for the Power arrays, with no child and no + payload. The rest was one `ActivePlayerData::InvSlots` update and matched the + 46-byte Rust body exactly after removing those five C++ bytes. The legacy + anchor is `Player.cpp::Regenerate` at the throttled + `DoWithSuppressingObjectUpdates` + `UnitData::Power::ClearChanged` path; + `UpdateFields.h` places the Power arrays under parent 116 and + `UpdateFields.cpp::UnitData::WriteUpdate` emits that parent mask before any + child payload. This is reproducible accumulated-change-mask/capture-cadence + noise, not proof that Rust power regeneration or gameplay timing is correct. +- The corresponding semantic exception is intentionally narrower than the + opcode. It requires S2C `0x27CB`, one VALUES block, the same map and canonical + Player GUID, no destroy/out-of-range data, and the exact same one-slot + canonical non-empty Item GUID update after deleting only the C++ parent-116 + mask. A Unit child or payload, any other Unit or ActivePlayer bit, a different + GUID/slot/value, malformed or non-canonical bytes, reversed C++/Rust + orientation, different direction/opcode/socket route, CreateObject, or any + other UpdateObject shape still fails. +- For the complete C++→Rust pair, use the host's external firewall/control plane + to allow world ports `8085` and `8086` only from loopback (`127.0.0.1` and + `::1`). Apply the block before Terminal A starts C++, verify it from a remote + host, keep it in place through Rust capture and strict import, then restore the + normal policy. The database online-count guards detect persisted sessions but + cannot close the race of an external client connecting after preflight. + +Record each side with the existing service-safe wrappers while performing the +same one-client action when they pause. There are two separate acknowledgements +and neither substitutes for the other: + +1. answer `y` to each capture script's service swap/restart prompt (or pass its + explicit `--yes` only in controlled automation); +2. set `WOW_BOT_ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=1` for the bot's destructive + fixture guard. + +Pin the exact feature binary and bot bytes before touching services. The Rust +capture wrapper checks the world executable both at its source path and through +`/proc//exe`; the bot wrapper likewise refuses a supplied executable whose +SHA-256 does not match. + +The RustyCore worktree must be committed and completely clean (including +non-ignored untracked files) for both recordings. The legacy C++ checkout is +known to contain local port/test changes, so its HEAD is informational rather +than a build attestation: the wrapper records `source_worktree_dirty` and a +stable path/mode/content-hash state digest. The pinned live `worldserver` +path/SHA-256 is the primary C++ executable evidence. RAW manifest schema v3 +records this explicitly. Its PM2 profile runs +`worldserver-wrapper.sh` without `exec`; the manifest therefore records that +PM2 entrypoint separately and proves that the one PID owning both listeners is +its descendant. + +```bash +# Preparation (no service mutation). +REPO_ROOT=/absolute/path/to/your/rustycore-worktree +cd "$REPO_ROOT" +test -z "$(git status --porcelain=v1 --untracked-files=normal)" +PROTOC=/home/cdmonio/.local/protoc/bin/protoc \ + cargo +1.88.0 build --locked -p world-server +cargo +1.88.0 build --locked \ + --manifest-path tools/wow-test-bot/Cargo.toml + +RUST_EXEC="$(realpath target/debug/world-server)" +BOT_EXEC="$(realpath tools/wow-test-bot/target/debug/wow-test-bot)" +git rev-parse HEAD +sha256sum /home/server/trinity-legacy-install/bin/worldserver \ + "$RUST_EXEC" "$BOT_EXEC" +``` + +The wrappers refuse to overwrite an existing RAW generation. Before starting +a replacement run, archive the old gitignored directory under another name (or +remove it only after separately preserving anything needed for investigation): + +```bash +test -d target/captures/loot-single-item-claim && \ + mv target/captures/loot-single-item-claim \ + "target/captures/loot-single-item-claim.pre-lineage-$(date -u +%Y%m%dT%H%M%SZ)" +``` + +Then use two terminals. For C++: + +```bash +# Terminal A: answer y, then leave this waiting at "Perform the flow". +REPO_ROOT=/absolute/path/to/your/rustycore-worktree +cd "$REPO_ROOT" +DB_CONF=/home/server/trinity-legacy-install/bin/worldserver.conf +CPP_EXEC="$(realpath /home/server/trinity-legacy-install/bin/worldserver)" +CPP_SHA="$(sha256sum "$CPP_EXEC" | awk '{print $1}')" +BOT_EXEC="$(realpath tools/wow-test-bot/target/debug/wow-test-bot)" +BOT_SHA="$(sha256sum "$BOT_EXEC" | awk '{print $1}')" +FIXTURE_DIR="${TMPDIR:-/tmp}/rustycore-loot-item-cpp-${USER:-$(id -u)}" +install -d -m 700 "$FIXTURE_DIR" +FIXTURE_JOURNAL="$FIXTURE_DIR/fixture.journal" +BOT_REPORT="$FIXTURE_DIR/bot-report.json" +BOT_LOG="$FIXTURE_DIR/bot.log" +test ! -e "$FIXTURE_JOURNAL" \ + && test ! -L "$FIXTURE_JOURNAL" \ + && test ! -e "${FIXTURE_JOURNAL}.cleanup-complete" \ + && test ! -L "${FIXTURE_JOURNAL}.cleanup-complete" \ + && test ! -e "$BOT_REPORT" \ + && test ! -L "$BOT_REPORT" \ + && test ! -e "$BOT_LOG" \ + && test ! -L "$BOT_LOG" +CPP_CAPTURE_LOOT_FIXTURE_GUARD=1 \ +CPP_CAPTURE_ACK_LOOT_FIXTURE_MUTATION=1 \ +CPP_CAPTURE_DB_CONF="$DB_CONF" \ +CPP_CAPTURE_EXEC="$CPP_EXEC" \ +CPP_CAPTURE_EXEC_SHA256="$CPP_SHA" \ +WOW_BOT_EXEC="$BOT_EXEC" \ +WOW_BOT_EXEC_SHA256="$BOT_SHA" \ +WOW_BOT_REPORT="$BOT_REPORT" \ +WOW_BOT_FIXTURE_JOURNAL="$FIXTURE_JOURNAL" \ +crates/capture-diff/scripts/capture-cpp.sh loot-single-item-claim + +# Terminal B: only after Terminal A is waiting. +REPO_ROOT=/absolute/path/to/your/rustycore-worktree +cd "$REPO_ROOT" +DB_CONF=/home/server/trinity-legacy-install/bin/worldserver.conf +FIXTURE_DIR="${TMPDIR:-/tmp}/rustycore-loot-item-cpp-${USER:-$(id -u)}" +FIXTURE_JOURNAL="$FIXTURE_DIR/fixture.journal" +BOT_EXEC="$(realpath tools/wow-test-bot/target/debug/wow-test-bot)" +BOT_SHA="$(sha256sum "$BOT_EXEC" | awk '{print $1}')" +BOT_REPORT="$FIXTURE_DIR/bot-report.json" +BOT_LOG="$FIXTURE_DIR/bot.log" +WOW_BOT_DB_CONF="$DB_CONF" \ +WOW_BOT_ENSURE_TEST_ACCOUNTS=0 \ +WOW_BOT_FIXTURE_JOURNAL="$FIXTURE_JOURNAL" \ +WOW_BOT_LOOT_ITEM_CAPTURE=1 \ +WOW_BOT_ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=1 \ +WOW_BOT_EXEC="$BOT_EXEC" \ +WOW_BOT_EXEC_SHA256="$BOT_SHA" \ +WOW_BOT_REPORT="$BOT_REPORT" \ +WOW_BOT_LOG="$BOT_LOG" \ +tools/wow-test-bot/run_rustycore_login_smoke.sh +``` + +The C++ wrapper uses the same shared guard as the Rust wrapper: after Rust is +stopped, it requires every character offline, atomically changes only Doctor +Maleficus entry `21779` difficulty-0 `HealthModifier` from `1` to `0.0001`, and +arms exact CAS restoration before C++ starts. Wait for Terminal B to exit `0`; +the pinned report and bot log remain under the private `FIXTURE_DIR` for the +operator and RAW-manifest validation. +Only then press +ENTER in Terminal A; that copies `cpp.pkt`, stops C++, restores its config, and +starts the normal Rust PM2 process only after the Doctor row is restored and the +bot journal is gone with a valid mode-0600 cleanup marker. The marker is consumed +only after PM2 starts successfully. + +If the bot fails or receives TERM/INT, do not collect the run. Interrupt +Terminal A normally; its trap stops C++, restores the outer Doctor guard, and +leaves the normal world stopped while the journal is pending. Then use the exact +retained path for explicit bot recovery: + +```bash +WOW_BOT_DB_CONF="$DB_CONF" \ +WOW_BOT_FIXTURE_JOURNAL="$FIXTURE_JOURNAL" \ +"$BOT_EXEC" --recover-loot-fixture +``` + +Only after recovery removes the journal and writes a valid marker may the +operator restart the normal PM2 world and remove that marker. A `SIGKILL` or +host death of Terminal A crosses the outer process boundary, so no shell trap runs. +In that case keep both worlds stopped, retain the `.capture-diff.bak`, journal, +and marker evidence, recover the bot journal explicitly, and inspect/restore the +Doctor CAS plus C++ config before any restart. Use a clean disposable DB snapshot +only if that outer state can no longer be proven; a normal bot failure is not a +reason to bypass journal recovery. + +For Rust, recalculate the hashes from the unchanged files and pin the feature +world executable in the capture wrapper: + +```bash +# Terminal A: answer y, then leave this waiting at "Perform the flow". +REPO_ROOT=/absolute/path/to/your/rustycore-worktree +cd "$REPO_ROOT" +DB_CONF=/home/server/trinity-legacy-install/bin/worldserver.conf +RUST_CONFIG=/home/server/trinity-legacy-install/etc/worldserver.conf +FIXTURE_DIR="${TMPDIR:-/tmp}/rustycore-loot-item-rust-${USER:-$(id -u)}" +install -d -m 700 "$FIXTURE_DIR" +FIXTURE_JOURNAL="$FIXTURE_DIR/fixture.journal" +BOT_REPORT="$FIXTURE_DIR/bot-report.json" +BOT_LOG="$FIXTURE_DIR/bot.log" +test ! -e "$FIXTURE_JOURNAL" \ + && test ! -L "$FIXTURE_JOURNAL" \ + && test ! -e "${FIXTURE_JOURNAL}.cleanup-complete" \ + && test ! -L "${FIXTURE_JOURNAL}.cleanup-complete" \ + && test ! -e "$BOT_REPORT" \ + && test ! -L "$BOT_REPORT" \ + && test ! -e "$BOT_LOG" \ + && test ! -L "$BOT_LOG" +RUST_EXEC="$(realpath target/debug/world-server)" +RUST_SHA="$(sha256sum "$RUST_EXEC" | awk '{print $1}')" +BOT_EXEC="$(realpath tools/wow-test-bot/target/debug/wow-test-bot)" +BOT_SHA="$(sha256sum "$BOT_EXEC" | awk '{print $1}')" +RUST_CONFIG="$(realpath "$RUST_CONFIG")" +RUST_CAPTURE_EXEC="$RUST_EXEC" \ +RUST_CAPTURE_EXEC_SHA256="$RUST_SHA" \ +RUST_CAPTURE_EFFECTIVE_CONFIG="$RUST_CONFIG" \ +RUST_CAPTURE_LOOT_FIXTURE_GUARD=1 \ +RUST_CAPTURE_ACK_LOOT_FIXTURE_MUTATION=1 \ +RUST_CAPTURE_DB_CONF="$DB_CONF" \ +WOW_BOT_EXEC="$BOT_EXEC" \ +WOW_BOT_EXEC_SHA256="$BOT_SHA" \ +WOW_BOT_REPORT="$BOT_REPORT" \ +WOW_BOT_FIXTURE_JOURNAL="$FIXTURE_JOURNAL" \ +crates/capture-diff/scripts/capture-rust.sh loot-single-item-claim + +# Terminal B: run the exact same pinned bot mode used for C++. +REPO_ROOT=/absolute/path/to/your/rustycore-worktree +cd "$REPO_ROOT" +DB_CONF=/home/server/trinity-legacy-install/bin/worldserver.conf +FIXTURE_DIR="${TMPDIR:-/tmp}/rustycore-loot-item-rust-${USER:-$(id -u)}" +FIXTURE_JOURNAL="$FIXTURE_DIR/fixture.journal" +BOT_EXEC="$(realpath tools/wow-test-bot/target/debug/wow-test-bot)" +BOT_SHA="$(sha256sum "$BOT_EXEC" | awk '{print $1}')" +BOT_REPORT="$FIXTURE_DIR/bot-report.json" +BOT_LOG="$FIXTURE_DIR/bot.log" +WOW_BOT_DB_CONF="$DB_CONF" \ +WOW_BOT_ENSURE_TEST_ACCOUNTS=0 \ +WOW_BOT_FIXTURE_JOURNAL="$FIXTURE_JOURNAL" \ +WOW_BOT_LOOT_ITEM_CAPTURE=1 \ +WOW_BOT_ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=1 \ +WOW_BOT_EXEC="$BOT_EXEC" \ +WOW_BOT_EXEC_SHA256="$BOT_SHA" \ +WOW_BOT_REPORT="$BOT_REPORT" \ +WOW_BOT_LOG="$BOT_LOG" \ +tools/wow-test-bot/run_rustycore_login_smoke.sh +``` + +Again, press ENTER in Terminal A only after Terminal B exits `0`. The Rust +wrapper then verifies that the live process still matches the pinned hash, +collects the dump, and restores the exact original PM2 profile. Do not import a +capture from a failed bot run or a process-provenance warning. A normal failed +run uses the same explicit `--recover-loot-fixture` procedure above after the +capture trap has stopped the QA world; do not delete a pending journal or invent +a cleanup marker. + +Finally, without either capture script running: + +```bash +REPO_ROOT=/absolute/path/to/your/rustycore-worktree +cd "$REPO_ROOT" +cargo +1.88.0 run -p capture-diff -- import loot-single-item-claim \ + --cpp target/captures/loot-single-item-claim/cpp.pkt \ + --rust target/captures/loot-single-item-claim/rust \ + --cpp-manifest target/captures/loot-single-item-claim/cpp.capture-manifest.json \ + --rust-manifest target/captures/loot-single-item-claim/rust/rust.capture-manifest.json \ + --from-opcode c2s:0x3211 \ + --until-opcode c2s:0x3768 \ + --ignore-opcode s2c:0x2DD2 \ + --ignore-opcode c2s:0x3A3D \ + --ignore-opcode s2c:0x2DD4 \ + --direction both \ + --strict +``` + +After that import reports `CLEAN`, maintainers must: + +1. inspect both artifacts with `capture-diff show`, both retained files under + `capture-provenance/`, and `capture-lineage.json`; confirm the one-client + provenance and all repository/executable/PM2/config identities; +2. set `requirement.json` to `"status": "ready"` with no stale + `blocked_reason`; +3. run `cargo +1.88.0 run -p capture-diff -- verify-required + loot-single-item-claim` and the local `capture` preflight. + +The pre-lineage issue-#106 import on 2026-07-18 did not produce the RAW +manifests now required by this gate, so it does not satisfy those steps. A new +capture must repeat the complete provenance, fixture restoration, strict +import, inspection, and verification sequence; `ready` is never permission to +replace the golden with uninspected input. + +`verify-required` rejects missing artifacts or manifests, lineage/output hash +drift, any import selection other than the exact declared boundaries and three +ordered ignores, a non-empty accepted-divergence baseline, routing/order drift, +extra packets inside the selected action window, or any byte/opcode diff. diff --git a/crates/capture-diff/flows/loot-single-item-claim/cpp.pkt b/crates/capture-diff/flows/loot-single-item-claim/cpp.pkt new file mode 100644 index 0000000000000000000000000000000000000000..d039b740cb39736763b31edd39af86b5e254d462 GIT binary patch literal 795 zcmWIW4q;>t@jb-AkeU}7%s>Wk_6>Gtgz6Ik(t=_sEvv7b3kQhuEOdgqL zWKcln4m&n1gqeUo1BI|S4j+oDF)%Ri=V3hHA_|Ne1y~G$w1ado12IUu3y6aSCF z!~1_0R2CHIxB?y;eH@4=18IUel?BKK*<}tA!~&2Kg9b<%Y_A-Uu>goU7z7v?I)DrY g5Ca4nptivhDJxJ6WIPW@5(~%x+8g5U?*ozo0Jp*@H2?qr literal 0 HcmV?d00001 diff --git a/crates/capture-diff/flows/loot-single-item-claim/expected-divergences.json b/crates/capture-diff/flows/loot-single-item-claim/expected-divergences.json new file mode 100644 index 000000000..0637a088a --- /dev/null +++ b/crates/capture-diff/flows/loot-single-item-claim/expected-divergences.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/crates/capture-diff/flows/loot-single-item-claim/flow.json b/crates/capture-diff/flows/loot-single-item-claim/flow.json new file mode 100644 index 000000000..14cbe318e --- /dev/null +++ b/crates/capture-diff/flows/loot-single-item-claim/flow.json @@ -0,0 +1,7 @@ +{ + "description": "Single-session deterministic item claim: CMSG_LOOT_ITEM on instance, C++ LootRemoved on instance before ItemPushResult on realm, bounded by a fixed instance CMSG_PING fence.", + "directions": [ + "s2c", + "c2s" + ] +} diff --git a/crates/capture-diff/flows/loot-single-item-claim/requirement.json b/crates/capture-diff/flows/loot-single-item-claim/requirement.json new file mode 100644 index 000000000..69f8f8488 --- /dev/null +++ b/crates/capture-diff/flows/loot-single-item-claim/requirement.json @@ -0,0 +1,74 @@ +{ + "issue": "#106", + "status": "awaiting-real-captures", + "blocked_reason": "The existing CLEAN six-packet C++/Rust pair predates mandatory completed RAW manifests, so executable/process/config provenance and RAW-to-derived lineage cannot be verified. Re-record and strictly import both sides with the guarded wrappers.", + "description": "One solo character claims one deterministic creature-loot item; the isolated wire window starts at CMSG_LOOT_ITEM and ends at a fixed instance CMSG_PING fence.", + "directions": [ + "s2c", + "c2s" + ], + "import_selection": { + "from_opcode": { + "direction": "c2s", + "opcode": 12817 + }, + "until_opcode": { + "direction": "c2s", + "opcode": 14184 + }, + "ignored_opcodes": [ + { + "direction": "s2c", + "opcode": 11730 + }, + { + "direction": "c2s", + "opcode": 14909 + }, + { + "direction": "s2c", + "opcode": 11732 + } + ] + }, + "require_each_anchor_exactly_once": true, + "semantic_contract": "loot-single-item-claim-v1", + "required_order": [ + { + "direction": "c2s", + "connection_id": 1, + "opcode": 12817, + "label": "CMSG_LOOT_ITEM" + }, + { + "direction": "s2c", + "connection_id": 1, + "opcode": 10187, + "label": "SMSG_UPDATE_OBJECT item CreateObject" + }, + { + "direction": "s2c", + "connection_id": 1, + "opcode": 9749, + "label": "SMSG_LOOT_REMOVED" + }, + { + "direction": "s2c", + "connection_id": 0, + "opcode": 9763, + "label": "SMSG_ITEM_PUSH_RESULT" + }, + { + "direction": "s2c", + "connection_id": 1, + "opcode": 10187, + "label": "SMSG_UPDATE_OBJECT InvSlots VALUES" + }, + { + "direction": "c2s", + "connection_id": 1, + "opcode": 14184, + "label": "CMSG_PING" + } + ] +} diff --git a/crates/capture-diff/flows/loot-single-item-claim/rust/rust-c2s-00000000-counter0-0x3211-LootItem-len15.bin b/crates/capture-diff/flows/loot-single-item-claim/rust/rust-c2s-00000000-counter0-0x3211-LootItem-len15.bin new file mode 100644 index 0000000000000000000000000000000000000000..040e1ddb368faf67deeeaf9128375c5cdb0de6a3 GIT binary patch literal 15 WcmWeMVq{=oVBEpz;KX9XzyJUZH37i@ literal 0 HcmV?d00001 diff --git a/crates/capture-diff/flows/loot-single-item-claim/rust/rust-c2s-00000000-counter0-0x3211-LootItem-len15.meta b/crates/capture-diff/flows/loot-single-item-claim/rust/rust-c2s-00000000-counter0-0x3211-LootItem-len15.meta new file mode 100644 index 000000000..7c35ea82b --- /dev/null +++ b/crates/capture-diff/flows/loot-single-item-claim/rust/rust-c2s-00000000-counter0-0x3211-LootItem-len15.meta @@ -0,0 +1,8 @@ +direction=c2s +connection_id=1 +addr=127.0.0.1:0 +seq=0 +counter=0 +opcode=0x3211 +name=LootItem +len=15 diff --git a/crates/capture-diff/flows/loot-single-item-claim/rust/rust-c2s-00000005-counter5-0x3768-Ping-len10.bin b/crates/capture-diff/flows/loot-single-item-claim/rust/rust-c2s-00000005-counter5-0x3768-Ping-len10.bin new file mode 100644 index 0000000000000000000000000000000000000000..78f8a9baa16fa6593d5a23a57d80dcff2f85553d GIT binary patch literal 10 Pcmc~W5ApZ+VE_UE4k!WM literal 0 HcmV?d00001 diff --git a/crates/capture-diff/flows/loot-single-item-claim/rust/rust-c2s-00000005-counter5-0x3768-Ping-len10.meta b/crates/capture-diff/flows/loot-single-item-claim/rust/rust-c2s-00000005-counter5-0x3768-Ping-len10.meta new file mode 100644 index 000000000..358e97652 --- /dev/null +++ b/crates/capture-diff/flows/loot-single-item-claim/rust/rust-c2s-00000005-counter5-0x3768-Ping-len10.meta @@ -0,0 +1,8 @@ +direction=c2s +connection_id=1 +addr=127.0.0.1:0 +seq=5 +counter=5 +opcode=0x3768 +name=Ping +len=10 diff --git a/crates/capture-diff/flows/loot-single-item-claim/rust/rust-s2c-00000001-counter1-0x27CB-UpdateObject-len312.bin b/crates/capture-diff/flows/loot-single-item-claim/rust/rust-s2c-00000001-counter1-0x27CB-UpdateObject-len312.bin new file mode 100644 index 0000000000000000000000000000000000000000..dd1a37bccc66ef1955830b5270b75079c64f2bba GIT binary patch literal 312 zcmX@D&d9*PAjHI=4Wt>_7yN3GW8nb_fq*zj>_<6-!@$sB&$xh}g#%20#9+!`^2jtJ Kg90*l*s%dO(g$h) literal 0 HcmV?d00001 diff --git a/crates/capture-diff/flows/loot-single-item-claim/rust/rust-s2c-00000001-counter1-0x27CB-UpdateObject-len312.meta b/crates/capture-diff/flows/loot-single-item-claim/rust/rust-s2c-00000001-counter1-0x27CB-UpdateObject-len312.meta new file mode 100644 index 000000000..ac08d3b2b --- /dev/null +++ b/crates/capture-diff/flows/loot-single-item-claim/rust/rust-s2c-00000001-counter1-0x27CB-UpdateObject-len312.meta @@ -0,0 +1,8 @@ +direction=s2c +connection_id=1 +addr=127.0.0.1:0 +seq=1 +counter=1 +opcode=0x27CB +name=UpdateObject +len=312 diff --git a/crates/capture-diff/flows/loot-single-item-claim/rust/rust-s2c-00000002-counter2-0x2615-LootRemoved-len20.bin b/crates/capture-diff/flows/loot-single-item-claim/rust/rust-s2c-00000002-counter2-0x2615-LootRemoved-len20.bin new file mode 100644 index 0000000000000000000000000000000000000000..c661ff12dbe081e4d643bbf263c41ce7bf12879f GIT binary patch literal 20 acmWetW8BYpz(v%-iA8~N2P2TOVE_OyWCS<> literal 0 HcmV?d00001 diff --git a/crates/capture-diff/flows/loot-single-item-claim/rust/rust-s2c-00000002-counter2-0x2615-LootRemoved-len20.meta b/crates/capture-diff/flows/loot-single-item-claim/rust/rust-s2c-00000002-counter2-0x2615-LootRemoved-len20.meta new file mode 100644 index 000000000..711885bc5 --- /dev/null +++ b/crates/capture-diff/flows/loot-single-item-claim/rust/rust-s2c-00000002-counter2-0x2615-LootRemoved-len20.meta @@ -0,0 +1,8 @@ +direction=s2c +connection_id=1 +addr=127.0.0.1:0 +seq=2 +counter=2 +opcode=0x2615 +name=LootRemoved +len=20 diff --git a/crates/capture-diff/flows/loot-single-item-claim/rust/rust-s2c-00000003-counter3-0x2623-ItemPushResult-len66.bin b/crates/capture-diff/flows/loot-single-item-claim/rust/rust-s2c-00000003-counter3-0x2623-ItemPushResult-len66.bin new file mode 100644 index 0000000000000000000000000000000000000000..2c16c9de47db063f16e64b9374cd971ddc03a74e GIT binary patch literal 66 lcmY#ZV_d+`!tpYu3=9HblA(cp!LJrM79Ie2{srp* literal 0 HcmV?d00001 diff --git a/crates/capture-diff/flows/loot-single-item-claim/rust/rust-s2c-00000004-counter4-0x27CB-UpdateObject-len48.meta b/crates/capture-diff/flows/loot-single-item-claim/rust/rust-s2c-00000004-counter4-0x27CB-UpdateObject-len48.meta new file mode 100644 index 000000000..11d4d5d6b --- /dev/null +++ b/crates/capture-diff/flows/loot-single-item-claim/rust/rust-s2c-00000004-counter4-0x27CB-UpdateObject-len48.meta @@ -0,0 +1,8 @@ +direction=s2c +connection_id=1 +addr=127.0.0.1:0 +seq=4 +counter=4 +opcode=0x27CB +name=UpdateObject +len=48 diff --git a/crates/capture-diff/scripts/capture-cpp.sh b/crates/capture-diff/scripts/capture-cpp.sh index 30381c024..bb097b5ec 100755 --- a/crates/capture-diff/scripts/capture-cpp.sh +++ b/crates/capture-diff/scripts/capture-cpp.sh @@ -7,6 +7,7 @@ # # Usage: crates/capture-diff/scripts/capture-cpp.sh [--yes] # Output: target/captures//cpp.pkt (gitignored) +# target/captures//cpp.capture-manifest.json # # Honored env vars (defaults target this machine's layout): # CPP_RUNTIME_DIR directory the PM2 wrapper enters before worldserver starts @@ -18,6 +19,30 @@ # an empty LogsDir means CPP_RUNTIME_DIR, matching C++. # PM2_CPP_WORLD pm2 name of the C++ world (default: cpp-world) # PM2_RUST_WORLD pm2 name of the Rust world (default: rustycore-world) +# CPP_WORLD_PORT shared realm listener port (default: 8085) +# CPP_INSTANCE_PORT shared instance listener port (default: 8086) +# CPP_CAPTURE_EXEC absolute canonical legacy worldserver executable. It and +# CPP_CAPTURE_EXEC_SHA256 are mandatory for the required +# loot-single-item-claim evidence flow +# CPP_CAPTURE_EXEC_SHA256 expected 64-hex SHA-256. The source file and live +# /proc//exe are checked before and after the flow +# CPP_CAPTURE_SOURCE_REPO clean legacy C++ source checkout whose exact HEAD +# is recorded (default: /home/server/woltk-trinity-legacy) +# CAPTURE_ORCHESTRATION_LOCK optional absolute private lock directory shared +# with capture-rust.sh (default: /tmp, keyed by uid+ports) +# CPP_CAPTURE_LOOT_FIXTURE_GUARD set to 1 only for the versioned +# loot-single-item-claim fixture. It CAS-lowers Doctor +# Maleficus 21779 HealthModifier while both worlds are +# stopped and restores it before normal Rust PM2 resumes +# CPP_CAPTURE_ACK_LOOT_FIXTURE_MUTATION must be 1 with the fixture guard +# CPP_CAPTURE_DB_CONF worldserver.conf containing WorldDatabaseInfo and +# CharacterDatabaseInfo (default: CPP_CONF) +# WOW_BOT_FIXTURE_JOURNAL absolute bot recovery-journal path. Guarded +# capture will not restart normal Rust until the journal is +# gone and its mode-0600 cleanup marker validates +# WOW_BOT_EXEC / WOW_BOT_EXEC_SHA256 pinned bot executable used for required +# loot-single-item-claim evidence +# WOW_BOT_REPORT fresh absolute bot JSON report path for that exact flow # # This stops the live RustyCore world server (disconnecting players). It refuses # to run without confirmation; pass --yes to skip the prompt. @@ -36,6 +61,247 @@ CPP_RUNTIME_DIR="${CPP_RUNTIME_DIR:-/home/server/trinity-legacy-install/bin}" CPP_CONF="${CPP_CONF:-${CPP_RUNTIME_DIR}/worldserver.conf}" PM2_CPP_WORLD="${PM2_CPP_WORLD:-cpp-world}" PM2_RUST_WORLD="${PM2_RUST_WORLD:-rustycore-world}" +CPP_WORLD_PORT="${CPP_WORLD_PORT:-8085}" +CPP_INSTANCE_PORT="${CPP_INSTANCE_PORT:-8086}" +CPP_CAPTURE_EXEC="${CPP_CAPTURE_EXEC:-}" +CPP_CAPTURE_EXEC_SHA256="${CPP_CAPTURE_EXEC_SHA256:-}" +CPP_CAPTURE_SOURCE_REPO="${CPP_CAPTURE_SOURCE_REPO:-/home/server/woltk-trinity-legacy}" +CAPTURE_WORLD_PORT="$CPP_WORLD_PORT" +CAPTURE_INSTANCE_PORT="$CPP_INSTANCE_PORT" +CAPTURE_ORCHESTRATION_LOCK="${CAPTURE_ORCHESTRATION_LOCK:-${XDG_RUNTIME_DIR:-/tmp}/rustycore-capture-$(id -u)-${CPP_WORLD_PORT}-${CPP_INSTANCE_PORT}.lock.d}" +CAPTURE_ORCHESTRATION_LOCK_FD="" +CPP_CAPTURE_LOOT_FIXTURE_GUARD="${CPP_CAPTURE_LOOT_FIXTURE_GUARD:-0}" +CPP_CAPTURE_ACK_LOOT_FIXTURE_MUTATION="${CPP_CAPTURE_ACK_LOOT_FIXTURE_MUTATION:-0}" +CPP_CAPTURE_DB_CONF="${CPP_CAPTURE_DB_CONF:-$CPP_CONF}" +LOOT_FIXTURE_DB_CONF="$CPP_CAPTURE_DB_CONF" +LOOT_FIXTURE_GUARD_ENABLED="$CPP_CAPTURE_LOOT_FIXTURE_GUARD" +WOW_BOT_FIXTURE_JOURNAL="${WOW_BOT_FIXTURE_JOURNAL:-}" +WOW_BOT_EXEC="${WOW_BOT_EXEC:-}" +WOW_BOT_EXEC_SHA256="${WOW_BOT_EXEC_SHA256:-}" +WOW_BOT_REPORT="${WOW_BOT_REPORT:-}" +LOOT_FIXTURE_CLEANUP_MARKER="" +LOOT_FIXTURE_ENTRY=21779 +LOOT_FIXTURE_EXPECTED_HEALTH_MODIFIER=1 +LOOT_FIXTURE_TEMP_HEALTH_MODIFIER=0.0001 +LOOT_FIXTURE_SNAPSHOT_READY=0 +LOOT_FIXTURE_WORLD_HOST="" +LOOT_FIXTURE_WORLD_PORT="" +LOOT_FIXTURE_WORLD_USER="" +LOOT_FIXTURE_WORLD_PASSWORD="" +LOOT_FIXTURE_WORLD_DATABASE="" +LOOT_FIXTURE_CHARACTER_HOST="" +LOOT_FIXTURE_CHARACTER_PORT="" +LOOT_FIXTURE_CHARACTER_USER="" +LOOT_FIXTURE_CHARACTER_PASSWORD="" +LOOT_FIXTURE_CHARACTER_DATABASE="" +CAPTURE_SWAPPED=0 +CAPTURE_RESTORE_FAILURE_STATUS=74 +RUST_ORIGINAL_IDENTITY="" +CPP_CAPTURE_IDENTITY="" +CPP_CAPTURE_PID="" +CPP_CAPTURE_LIVE_EXEC="" +CPP_CAPTURE_LIVE_SHA256="" +CPP_CAPTURE_EXPECTED_EXEC="" +CPP_CAPTURE_EXPECTED_SHA256="" +CPP_CAPTURE_SOURCE_EXEC="" +CPP_CAPTURE_SOURCE_SHA256="" +CPP_CAPTURE_HARNESS_REPO_HEAD="" +CPP_CAPTURE_SOURCE_REPO_HEAD="" +CPP_CAPTURE_HARNESS_WORKTREE_CLEAN=0 +CPP_CAPTURE_HARNESS_WORKTREE_SHA256="" +CPP_CAPTURE_SOURCE_WORKTREE_DIRTY=0 +CPP_CAPTURE_SOURCE_WORKTREE_SHA256="" +CPP_CAPTURE_PM2_ENTRY_PID="" +CPP_CAPTURE_PM2_ENTRY_STARTTIME="" +CPP_CAPTURE_PM2_EXEC_PATH="" +CPP_CAPTURE_PM2_EXEC_SHA256="" +CPP_CAPTURE_PM2_PROFILE_SHA256="" +CPP_CAPTURE_RESTART_COUNT="" +CPP_CAPTURE_LISTENER_STARTTIME="" +CPP_CAPTURE_EFFECTIVE_CONFIG_PATH="" +CPP_CAPTURE_EFFECTIVE_CONFIG_SHA256="" +CPP_CAPTURE_PINNED=0 +CAPTURE_ARTIFACT_READY=0 +OUT_PKT_STAGE="" +CPP_CAPTURE_FIXTURE_CLEANUP_VERIFIED=0 +CPP_CAPTURE_NORMAL_RUNTIME_RESTORED=0 +CPP_CAPTURE_BOT_EXEC="" +CPP_CAPTURE_BOT_EXEC_SHA256="" +CPP_CAPTURE_BOT_REPORT="" +CPP_CAPTURE_BOT_REPORT_SHA256="" +CPP_CONF_BACKUP_IDENTITY="" +CPP_CONF_BACKUP_SHA256="" + +# shellcheck source=loot-fixture-common.sh +source "$(dirname "${BASH_SOURCE[0]}")/loot-fixture-common.sh" +# shellcheck source=capture-service-common.sh +source "$(dirname "${BASH_SOURCE[0]}")/capture-service-common.sh" + +[[ "$CPP_WORLD_PORT" =~ ^[1-9][0-9]*$ ]] \ + && ((CPP_WORLD_PORT <= 65535)) || { + echo "error: CPP_WORLD_PORT must be an integer from 1 through 65535" >&2 + exit 2 + } +[[ "$CPP_INSTANCE_PORT" =~ ^[1-9][0-9]*$ ]] \ + && ((CPP_INSTANCE_PORT <= 65535)) || { + echo "error: CPP_INSTANCE_PORT must be an integer from 1 through 65535" >&2 + exit 2 + } +[ "$CPP_WORLD_PORT" != "$CPP_INSTANCE_PORT" ] || { + echo "error: CPP_WORLD_PORT and CPP_INSTANCE_PORT must be distinct" >&2 + exit 2 +} + +if [ "$FLOW" = "loot-single-item-claim" ] \ + && [ "$CPP_CAPTURE_LOOT_FIXTURE_GUARD" != "1" ]; then + echo "error: loot-single-item-claim requires CPP_CAPTURE_LOOT_FIXTURE_GUARD=1" >&2 + exit 2 +fi + +case "$CPP_CAPTURE_LOOT_FIXTURE_GUARD" in + 0) ;; + 1) + [ "$FLOW" = "loot-single-item-claim" ] || { + echo "error: the C++ loot fixture guard is defined only for loot-single-item-claim" >&2 + exit 2 + } + [ "$CPP_CAPTURE_ACK_LOOT_FIXTURE_MUTATION" = "1" ] || { + echo "error: CPP_CAPTURE_LOOT_FIXTURE_GUARD=1 requires CPP_CAPTURE_ACK_LOOT_FIXTURE_MUTATION=1" >&2 + exit 2 + } + [ -n "$CPP_CAPTURE_EXEC" ] && [ -n "$CPP_CAPTURE_EXEC_SHA256" ] || { + echo "error: guarded C++ evidence requires CPP_CAPTURE_EXEC and CPP_CAPTURE_EXEC_SHA256" >&2 + exit 2 + } + [ -n "$WOW_BOT_EXEC" ] && [ -n "$WOW_BOT_EXEC_SHA256" ] \ + && [ -n "$WOW_BOT_REPORT" ] || { + echo "error: guarded #106 evidence requires WOW_BOT_EXEC, WOW_BOT_EXEC_SHA256, and WOW_BOT_REPORT" >&2 + exit 2 + } + [[ "$WOW_BOT_EXEC_SHA256" =~ ^[0-9A-Fa-f]{64}$ ]] || { + echo "error: WOW_BOT_EXEC_SHA256 must contain exactly 64 hexadecimal characters" >&2 + exit 2 + } + WOW_BOT_EXEC_SHA256="${WOW_BOT_EXEC_SHA256,,}" + [[ "$WOW_BOT_REPORT" = /* && "$WOW_BOT_REPORT" != *$'\n'* ]] \ + && [ -d "$(dirname -- "$WOW_BOT_REPORT")" ] \ + && [ ! -e "$WOW_BOT_REPORT" ] && [ ! -L "$WOW_BOT_REPORT" ] || { + echo "error: WOW_BOT_REPORT must be a fresh absolute path with an existing parent" >&2 + exit 2 + } + WOW_BOT_REPORT_PARENT="$(dirname -- "$WOW_BOT_REPORT")" + [ "$(realpath -e -- "$WOW_BOT_REPORT_PARENT" 2>/dev/null)" \ + = "$WOW_BOT_REPORT_PARENT" ] \ + && [ ! -L "$WOW_BOT_REPORT_PARENT" ] || { + echo "error: WOW_BOT_REPORT parent must be canonical and non-symlink" >&2 + exit 2 + } + capture_exec_source_matches "$WOW_BOT_EXEC" "$WOW_BOT_EXEC_SHA256" || { + echo "error: WOW_BOT_EXEC is not a canonical pinned executable" >&2 + exit 2 + } + validate_fresh_loot_fixture_journal || exit 2 + for dependency in awk dirname jq mysql stat; do + command -v "$dependency" >/dev/null 2>&1 || { + echo "error: required command not found: $dependency" >&2 + exit 2 + } + done + load_loot_fixture_database_credentials || exit 2 + ;; + *) + echo "error: CPP_CAPTURE_LOOT_FIXTURE_GUARD must be 0 or 1" >&2 + exit 2 + ;; +esac + +if [ -n "$CPP_CAPTURE_EXEC" ]; then + [[ "$CPP_CAPTURE_EXEC_SHA256" =~ ^[0-9A-Fa-f]{64}$ ]] || { + echo "error: CPP_CAPTURE_EXEC_SHA256 must contain exactly 64 hexadecimal characters" >&2 + exit 2 + } + CPP_CAPTURE_EXEC_SHA256="${CPP_CAPTURE_EXEC_SHA256,,}" + CPP_CAPTURE_PINNED=1 +elif [ -n "$CPP_CAPTURE_EXEC_SHA256" ]; then + echo "error: CPP_CAPTURE_EXEC_SHA256 requires CPP_CAPTURE_EXEC" >&2 + exit 2 +fi + +for dependency in awk chmod cp date dirname flock git grep id jq mkdir mktemp mv \ + pm2 realpath rg sed sha256sum ss stat sync tail; do + command -v "$dependency" >/dev/null 2>&1 || { + echo "error: required command not found: $dependency" >&2 + exit 2 + } +done + +CPP_CAPTURE_HARNESS_REPO_HEAD="$(git -C "$REPO_ROOT" rev-parse HEAD 2>/dev/null)" || { + echo "error: cannot resolve RustyCore harness repository HEAD" >&2 + exit 2 +} +CPP_CAPTURE_SOURCE_REPO="$(realpath -e -- "$CPP_CAPTURE_SOURCE_REPO" 2>/dev/null)" || { + echo "error: CPP_CAPTURE_SOURCE_REPO does not resolve" >&2 + exit 2 +} +CPP_CAPTURE_SOURCE_REPO_HEAD="$(git -C "$CPP_CAPTURE_SOURCE_REPO" rev-parse HEAD 2>/dev/null)" || { + echo "error: cannot resolve legacy C++ source repository HEAD" >&2 + exit 2 +} +capture_git_repo_clean_at_head "$REPO_ROOT" "$CPP_CAPTURE_HARNESS_REPO_HEAD" \ + && CPP_CAPTURE_HARNESS_WORKTREE_CLEAN=1 +CPP_CAPTURE_HARNESS_WORKTREE_SHA256="$( + capture_git_worktree_state_sha256 "$REPO_ROOT" +)" || { + echo "error: cannot fingerprint the RustyCore harness worktree" >&2 + exit 2 +} +CPP_CAPTURE_SOURCE_WORKTREE_SHA256="$( + capture_git_worktree_state_sha256 "$CPP_CAPTURE_SOURCE_REPO" +)" || { + echo "error: cannot fingerprint CPP_CAPTURE_SOURCE_REPO" >&2 + exit 2 +} +if capture_git_repo_is_dirty "$CPP_CAPTURE_SOURCE_REPO"; then + CPP_CAPTURE_SOURCE_WORKTREE_DIRTY=1 +fi +if [ "$CPP_CAPTURE_HARNESS_WORKTREE_CLEAN" -ne 1 ]; then + echo "error: capture evidence requires a clean committed RustyCore harness worktree (including untracked files)" >&2 + exit 2 +fi + +if [ "$CPP_CAPTURE_PINNED" -eq 1 ] \ + && ! capture_exec_source_matches "$CPP_CAPTURE_EXEC" "$CPP_CAPTURE_EXEC_SHA256"; then + echo "error: CPP_CAPTURE_EXEC is not canonical/executable or does not match its pinned SHA-256" >&2 + exit 2 +fi +if [ "$CPP_CAPTURE_PINNED" -eq 1 ]; then + CPP_CAPTURE_EXPECTED_EXEC="$CPP_CAPTURE_EXEC" + CPP_CAPTURE_EXPECTED_SHA256="$CPP_CAPTURE_EXEC_SHA256" + CPP_CAPTURE_SOURCE_EXEC="$CPP_CAPTURE_EXEC" + CPP_CAPTURE_SOURCE_SHA256="$CPP_CAPTURE_EXEC_SHA256" +fi + +CPP_CONF_CANONICAL="$(realpath -e -- "$CPP_CONF" 2>/dev/null)" || { + echo "error: CPP_CONF does not resolve" >&2 + exit 2 +} +[ "$CPP_CONF_CANONICAL" = "$CPP_CONF" ] \ + && [ -f "$CPP_CONF" ] && [ ! -L "$CPP_CONF" ] || { + echo "error: CPP_CONF must be an absolute canonical regular non-symlink file" >&2 + exit 2 +} +CPP_PROFILE_EFFECTIVE_CONFIG="$(capture_pm2_effective_config_path "$PM2_CPP_WORLD")" || { + echo "error: cannot derive the effective C++ config from the PM2 profile/entrypoint" >&2 + exit 2 +} +[ "$CPP_PROFILE_EFFECTIVE_CONFIG" = "$CPP_CONF" ] || { + echo "error: CPP_CONF (${CPP_CONF}) differs from PM2's effective config (${CPP_PROFILE_EFFECTIVE_CONFIG})" >&2 + exit 2 +} +CPP_CAPTURE_PM2_PROFILE_SHA256="$(capture_pm2_profile_redacted_sha256 "$PM2_CPP_WORLD")" || { + echo "error: cannot hash the stable redacted C++ PM2 profile" >&2 + exit 2 +} if [ -z "${CPP_LOGS_DIR+x}" ]; then CONFIGURED_LOGS_DIR="$({ @@ -49,14 +315,261 @@ if [ -z "${CPP_LOGS_DIR+x}" ]; then CPP_LOGS_DIR="${CPP_RUNTIME_DIR}/${CONFIGURED_LOGS_DIR}" fi fi +CPP_LOGS_DIR_CANONICAL="$(realpath -e -- "$CPP_LOGS_DIR" 2>/dev/null)" || { + echo "error: CPP_LOGS_DIR does not resolve" >&2 + exit 2 +} +[ "$CPP_LOGS_DIR_CANONICAL" = "$CPP_LOGS_DIR" ] \ + && [ -d "$CPP_LOGS_DIR" ] && [ ! -L "$CPP_LOGS_DIR" ] || { + echo "error: CPP_LOGS_DIR must be an absolute canonical non-symlink directory" >&2 + exit 2 +} PKT_NAME="rustycore-capture-${FLOW}.pkt" OUT_DIR="${REPO_ROOT}/target/captures/${FLOW}" OUT_PKT="${OUT_DIR}/cpp.pkt" +OUT_MANIFEST="${OUT_DIR}/cpp.capture-manifest.json" +capture_require_canonical_directory "${REPO_ROOT}/target/captures" \ + && capture_require_canonical_directory "$OUT_DIR" || { + echo "error: capture output root is not canonical or contains a symlink" >&2 + exit 2 +} +[ ! -e "$OUT_PKT" ] && [ ! -L "$OUT_PKT" ] \ + && [ ! -e "$OUT_MANIFEST" ] && [ ! -L "$OUT_MANIFEST" ] || { + echo "error: raw C++ capture output already exists; archive/remove cpp.pkt and cpp.capture-manifest.json before recording a new generation" >&2 + exit 2 + } + +accredit_cpp_capture_executable() { + local proc_exe="/proc/${CPP_CAPTURE_PID}/exe" + local live_exec live_sha pm2_identity pm2_pid pm2_restart pm2_exec pm2_exec_sha + + [ -L "$proc_exe" ] || return 1 + live_exec="$(realpath -e -- "$proc_exe" 2>/dev/null)" || return 1 + live_sha="$(capture_sha256_of_file "$proc_exe")" || return 1 + if [ "$CPP_CAPTURE_PINNED" -eq 1 ]; then + [ "$live_exec" = "$CPP_CAPTURE_EXEC" ] \ + && [ "$live_sha" = "$CPP_CAPTURE_EXEC_SHA256" ] \ + && capture_live_exec_matches \ + "$CPP_CAPTURE_PID" "$CPP_CAPTURE_EXEC" "$CPP_CAPTURE_EXEC_SHA256" \ + || return 1 + fi + pm2_identity="$(capture_pm2_entrypoint_identity \ + "$PM2_CPP_WORLD" "$CPP_CAPTURE_PM2_ENTRY_PID")" \ + || return 1 + IFS=$'\t' read -r pm2_pid pm2_restart pm2_exec pm2_exec_sha <<<"$pm2_identity" + [ "$pm2_pid" = "$CPP_CAPTURE_PM2_ENTRY_PID" ] \ + && [[ "$pm2_restart" =~ ^[0-9]+$ ]] \ + && capture_pid_is_self_or_descendant \ + "$CPP_CAPTURE_PID" "$CPP_CAPTURE_PM2_ENTRY_PID" \ + || return 1 + CPP_CAPTURE_PM2_EXEC_PATH="$pm2_exec" + CPP_CAPTURE_PM2_EXEC_SHA256="$pm2_exec_sha" + CPP_CAPTURE_RESTART_COUNT="$pm2_restart" + CPP_CAPTURE_PM2_ENTRY_STARTTIME="$(capture_pid_starttime "$CPP_CAPTURE_PM2_ENTRY_PID")" \ + || return 1 + CPP_CAPTURE_LISTENER_STARTTIME="$(capture_pid_starttime "$CPP_CAPTURE_PID")" \ + || return 1 + CPP_CAPTURE_LIVE_EXEC="$live_exec" + CPP_CAPTURE_LIVE_SHA256="$live_sha" + if [ "$CPP_CAPTURE_PINNED" -eq 0 ]; then + CPP_CAPTURE_EXPECTED_EXEC="$live_exec" + CPP_CAPTURE_EXPECTED_SHA256="$live_sha" + CPP_CAPTURE_SOURCE_EXEC="$live_exec" + CPP_CAPTURE_SOURCE_SHA256="$live_sha" + fi +} + +cpp_capture_effective_config_sha256() { + capture_effective_config_redacted_sha256 \ + "$CPP_CAPTURE_EFFECTIVE_CONFIG_PATH" \ + "capture.world_port=${CPP_WORLD_PORT} +capture.instance_port=${CPP_INSTANCE_PORT} +capture.packet_log=enabled" \ + PacketLogFile LogsDir Bot.AccountPrefix WorldServerPort InstanceServerPort \ + LoginDatabaseInfo WorldDatabaseInfo CharacterDatabaseInfo \ + Rate.Drop.Item.Poor Rate.Drop.Item.Normal Rate.Drop.Item.Uncommon \ + Rate.Drop.Item.Rare Rate.Drop.Item.Epic Rate.Drop.Item.Legendary \ + Rate.Drop.Item.Artifact Rate.Drop.Item.Referenced Rate.Drop.Money +} + +cpp_capture_executable_unchanged() { + [ -n "$CPP_CAPTURE_LIVE_EXEC" ] \ + && [ -n "$CPP_CAPTURE_LIVE_SHA256" ] \ + && capture_live_exec_matches \ + "$CPP_CAPTURE_PID" "$CPP_CAPTURE_LIVE_EXEC" "$CPP_CAPTURE_LIVE_SHA256" \ + && [ "$(capture_pm2_entrypoint_identity \ + "$PM2_CPP_WORLD" "$CPP_CAPTURE_PM2_ENTRY_PID")" \ + = "${CPP_CAPTURE_PM2_ENTRY_PID}"$'\t'"${CPP_CAPTURE_RESTART_COUNT}"$'\t'"${CPP_CAPTURE_PM2_EXEC_PATH}"$'\t'"${CPP_CAPTURE_PM2_EXEC_SHA256}" ] \ + && [ "$(capture_world_ready_once "$PM2_CPP_WORLD")" = "$CPP_CAPTURE_IDENTITY" ] \ + && [ "$(capture_pid_starttime "$CPP_CAPTURE_PM2_ENTRY_PID")" \ + = "$CPP_CAPTURE_PM2_ENTRY_STARTTIME" ] \ + && [ "$(capture_pid_starttime "$CPP_CAPTURE_PID")" \ + = "$CPP_CAPTURE_LISTENER_STARTTIME" ] \ + && [ "$(capture_pm2_profile_redacted_sha256 "$PM2_CPP_WORLD")" \ + = "$CPP_CAPTURE_PM2_PROFILE_SHA256" ] \ + && [ "$(capture_pm2_effective_config_path "$PM2_CPP_WORLD")" \ + = "$CPP_CONF" ] \ + && [ "$(cpp_capture_effective_config_sha256)" \ + = "$CPP_CAPTURE_EFFECTIVE_CONFIG_SHA256" ] +} + +finalize_cpp_capture_artifact() { + [ "$CAPTURE_ARTIFACT_READY" -eq 1 ] && [ -f "$OUT_PKT_STAGE" ] || return 1 + + local created_at manifest_stage packet_sha packet_size bot_evidence + [ "$CPP_CAPTURE_NORMAL_RUNTIME_RESTORED" -eq 1 ] \ + && [ "$CPP_CAPTURE_FIXTURE_CLEANUP_VERIFIED" -eq 1 \ + || "$FLOW" != "loot-single-item-claim" ] || return 1 + if [ "$FLOW" = "loot-single-item-claim" ]; then + bot_evidence="$(capture_loot_item_bot_evidence \ + "$WOW_BOT_REPORT" "$WOW_BOT_EXEC" "$WOW_BOT_EXEC_SHA256")" || return 1 + IFS=$'\t' read -r CPP_CAPTURE_BOT_EXEC CPP_CAPTURE_BOT_EXEC_SHA256 \ + CPP_CAPTURE_BOT_REPORT CPP_CAPTURE_BOT_REPORT_SHA256 <<<"$bot_evidence" + fi + packet_sha="$(capture_sha256_of_file "$OUT_PKT_STAGE")" || return 1 + packet_size="$(stat -c '%s' -- "$OUT_PKT_STAGE")" || return 1 + created_at="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" || return 1 + capture_git_repo_clean_at_head "$REPO_ROOT" "$CPP_CAPTURE_HARNESS_REPO_HEAD" \ + && [ "$(capture_git_worktree_state_sha256 "$REPO_ROOT")" \ + = "$CPP_CAPTURE_HARNESS_WORKTREE_SHA256" ] \ + && [ "$(git -C "$CPP_CAPTURE_SOURCE_REPO" rev-parse HEAD 2>/dev/null)" \ + = "$CPP_CAPTURE_SOURCE_REPO_HEAD" ] \ + && [ "$(capture_git_worktree_state_sha256 "$CPP_CAPTURE_SOURCE_REPO")" \ + = "$CPP_CAPTURE_SOURCE_WORKTREE_SHA256" ] || return 1 + capture_require_canonical_directory "$OUT_DIR" \ + && [ ! -e "$OUT_PKT" ] && [ ! -L "$OUT_PKT" ] \ + && [ ! -e "$OUT_MANIFEST" ] && [ ! -L "$OUT_MANIFEST" ] || return 1 + manifest_stage="$(mktemp "${OUT_DIR}/.cpp.capture-manifest.partial.XXXXXX")" \ + || return 1 + if ! jq -n \ + --arg flow "$FLOW" \ + --arg created_at "$created_at" \ + --arg harness_repo_head "$CPP_CAPTURE_HARNESS_REPO_HEAD" \ + --arg source_repo_head "$CPP_CAPTURE_SOURCE_REPO_HEAD" \ + --arg harness_worktree_sha256 "$CPP_CAPTURE_HARNESS_WORKTREE_SHA256" \ + --arg source_worktree_sha256 "$CPP_CAPTURE_SOURCE_WORKTREE_SHA256" \ + --arg expected_exec_path "$CPP_CAPTURE_EXPECTED_EXEC" \ + --arg expected_exec_sha256 "$CPP_CAPTURE_EXPECTED_SHA256" \ + --arg source_exec_path "$CPP_CAPTURE_SOURCE_EXEC" \ + --arg source_exec_sha256 "$CPP_CAPTURE_SOURCE_SHA256" \ + --arg live_exec_path "$CPP_CAPTURE_LIVE_EXEC" \ + --arg live_exec_sha256 "$CPP_CAPTURE_LIVE_SHA256" \ + --arg pm2_exec_path "$CPP_CAPTURE_PM2_EXEC_PATH" \ + --arg pm2_exec_sha256 "$CPP_CAPTURE_PM2_EXEC_SHA256" \ + --arg pm2_profile_sha256 "$CPP_CAPTURE_PM2_PROFILE_SHA256" \ + --arg effective_config_path "$CPP_CAPTURE_EFFECTIVE_CONFIG_PATH" \ + --arg effective_config_sha256 "$CPP_CAPTURE_EFFECTIVE_CONFIG_SHA256" \ + --arg bot_exec_path "$CPP_CAPTURE_BOT_EXEC" \ + --arg bot_exec_sha256 "$CPP_CAPTURE_BOT_EXEC_SHA256" \ + --arg bot_report_path "$CPP_CAPTURE_BOT_REPORT" \ + --arg bot_report_sha256 "$CPP_CAPTURE_BOT_REPORT_SHA256" \ + --arg packet_sha256 "$packet_sha" \ + --argjson pm2_entry_pid "$CPP_CAPTURE_PM2_ENTRY_PID" \ + --argjson pm2_entry_starttime "$CPP_CAPTURE_PM2_ENTRY_STARTTIME" \ + --argjson listener_runtime_pid "$CPP_CAPTURE_PID" \ + --argjson listener_runtime_starttime "$CPP_CAPTURE_LISTENER_STARTTIME" \ + --argjson restart_count "$CPP_CAPTURE_RESTART_COUNT" \ + --argjson packet_size "$packet_size" \ + --argjson guarded "$([ "$FLOW" = "loot-single-item-claim" ] && printf true || printf false)" \ + --argjson pinned "$([ "$CPP_CAPTURE_PINNED" -eq 1 ] && printf true || printf false)" \ + --argjson source_worktree_dirty \ + "$([ "$CPP_CAPTURE_SOURCE_WORKTREE_DIRTY" -eq 1 ] && printf true || printf false)" \ + '{ + version: 3, + flow: $flow, + side: "cpp", + completed: true, + created_at: $created_at, + harness_repo_head: $harness_repo_head, + source_repo_head: $source_repo_head, + harness_worktree_clean: true, + harness_worktree_state_sha256: $harness_worktree_sha256, + source_worktree_dirty: $source_worktree_dirty, + source_worktree_state_sha256: $source_worktree_sha256, + worktree_state_algorithm: "git-head-path-mode-content-sha256-v1", + expected_exec_path: $expected_exec_path, + expected_exec_sha256: $expected_exec_sha256, + source_exec_path: $source_exec_path, + source_exec_sha256: $source_exec_sha256, + live_exec_path: $live_exec_path, + live_exec_sha256: $live_exec_sha256, + executable_pin_enforced: $pinned, + pm2_entry_pid: $pm2_entry_pid, + pm2_entry_starttime: $pm2_entry_starttime, + pm2_exec_path: $pm2_exec_path, + pm2_exec_sha256: $pm2_exec_sha256, + pm2_profile_redacted_sha256: $pm2_profile_sha256, + listener_runtime_pid: $listener_runtime_pid, + listener_runtime_starttime: $listener_runtime_starttime, + listener_relationship_verified: true, + restart_count: $restart_count, + effective_config_path: $effective_config_path, + effective_config_redacted_sha256: $effective_config_sha256, + effective_config_algorithm: "capture-relevant-redacted-v1", + runtime_cleanup_verified: true, + normal_runtime_restored: true, + fixture_guard: (if $guarded then { + enabled: true, + contract: "loot-single-item-claim-fixture-v1", + account: "TESTBOT2@bot.local", + account_id: 9, + character_guid: 15, + peer_account: "TESTBOT3@bot.local", + peer_account_id: 10, + peer_character_guid: 16, + creature_entry: 21779, + creature_spawn_guid: 1117, + item_entry: 30712, + cleanup_verified: true + } else null end), + bot_report: (if $guarded then { + contract: "wow-test-bot-loot-item-capture-report-v1", + exec_path: $bot_exec_path, + exec_sha256: $bot_exec_sha256, + report_path: $bot_report_path, + report_sha256: $bot_report_sha256, + account: "TESTBOT2@bot.local", + account_id: 9, + character_guid: 15, + report_validated: true + } else null end), + artifact: { + path: "cpp.pkt", + size: $packet_size, + sha256: $packet_sha256 + } + }' >"$manifest_stage"; then + rm -f -- "$manifest_stage" + return 1 + fi + chmod 600 "$OUT_PKT_STAGE" "$manifest_stage" || { + rm -f -- "$manifest_stage" + return 1 + } + sync -f "$OUT_PKT_STAGE" && sync -f "$manifest_stage" || { + rm -f -- "$manifest_stage" + return 1 + } + capture_publish_noreplace "$OUT_PKT_STAGE" "$OUT_PKT" || { + rm -f -- "$manifest_stage" + return 1 + } + OUT_PKT_STAGE="" + capture_publish_noreplace "$manifest_stage" "$OUT_MANIFEST" || return 1 + sync -f "$OUT_DIR" || return 1 + CAPTURE_ARTIFACT_READY=0 + echo "collected ${packet_size} bytes -> ${OUT_PKT}" + echo "provenance -> ${OUT_MANIFEST}" +} echo "flow : ${FLOW}" echo "C++ conf : ${CPP_CONF}" echo "C++ logs dir : ${CPP_LOGS_DIR}" +if [ "$CPP_CAPTURE_PINNED" -eq 1 ]; then + echo "C++ exec : ${CPP_CAPTURE_EXEC}" + echo "C++ SHA-256 : ${CPP_CAPTURE_EXEC_SHA256}" +fi echo "pkt file : ${CPP_LOGS_DIR}/${PKT_NAME}" echo "output : ${OUT_PKT}" echo @@ -67,6 +580,26 @@ if [ "$CONFIRM" != "--yes" ]; then [ "$ans" = "y" ] || [ "$ans" = "Y" ] || { echo "aborted"; exit 1; } fi +capture_acquire_orchestration_lock "$CAPTURE_ORCHESTRATION_LOCK" || { + echo "error: another capture/QA process holds ${CAPTURE_ORCHESTRATION_LOCK}" >&2 + exit 1 +} +if [ "$CPP_CAPTURE_PINNED" -eq 1 ] \ + && ! capture_exec_source_matches "$CPP_CAPTURE_EXEC" "$CPP_CAPTURE_EXEC_SHA256"; then + echo "error: pinned C++ executable changed before service mutation" >&2 + exit 1 +fi +capture_git_repo_clean_at_head "$REPO_ROOT" "$CPP_CAPTURE_HARNESS_REPO_HEAD" \ + && [ "$(capture_git_worktree_state_sha256 "$REPO_ROOT")" \ + = "$CPP_CAPTURE_HARNESS_WORKTREE_SHA256" ] \ + && [ "$(git -C "$CPP_CAPTURE_SOURCE_REPO" rev-parse HEAD 2>/dev/null)" \ + = "$CPP_CAPTURE_SOURCE_REPO_HEAD" ] \ + && [ "$(capture_git_worktree_state_sha256 "$CPP_CAPTURE_SOURCE_REPO")" \ + = "$CPP_CAPTURE_SOURCE_WORKTREE_SHA256" ] || { + echo "error: harness/source worktree provenance changed before service mutation" >&2 + exit 1 +} + [ -f "$CPP_CONF" ] || { echo "error: conf not found: $CPP_CONF" >&2; exit 1; } [ -d "$CPP_LOGS_DIR" ] || { echo "error: packet log directory not found: $CPP_LOGS_DIR" >&2; exit 1; } @@ -74,25 +607,131 @@ CONF_BAK="${CPP_CONF}.capture-diff.bak" # A leftover backup means a prior run was killed before restoring. Refuse to # overwrite it with the (possibly already-edited) conf — that would lose the # pristine original. The operator must inspect/restore it manually first. -if [ -e "$CONF_BAK" ]; then +if [ -e "$CONF_BAK" ] || [ -L "$CONF_BAK" ]; then echo "error: stale backup ${CONF_BAK} exists (a prior run did not restore)." >&2 echo " restore it over ${CPP_CONF} and delete it before re-running." >&2 exit 1 fi +RUST_ORIGINAL_IDENTITY="$(capture_wait_for_world_ready "$PM2_RUST_WORLD")" || { + echo "error: ${PM2_RUST_WORLD} is not one exact online PM2 process owning both configured listeners" >&2 + exit 1 +} +capture_pm2_process_stopped "$PM2_CPP_WORLD" || { + echo "error: ${PM2_CPP_WORLD} must be one exact stopped PM2 process before capture" >&2 + exit 1 +} # Preserve the active config's ownership/mode/timestamps exactly. A restrictive # caller umask must not turn the restored worldserver.conf into a different # runtime file after the capture. cp -a "$CPP_CONF" "$CONF_BAK" +[ -f "$CONF_BAK" ] && [ ! -L "$CONF_BAK" ] \ + && [ "$(realpath -e -- "$CONF_BAK" 2>/dev/null)" = "$CONF_BAK" ] || { + echo "error: failed to create a canonical regular config backup" >&2 + exit 1 +} +CPP_CONF_BACKUP_IDENTITY="$(stat -c '%d:%i' -- "$CONF_BAK")" || exit 1 +CPP_CONF_BACKUP_SHA256="$(capture_sha256_of_file "$CONF_BAK")" || exit 1 restore() { - echo "restoring ${PM2_CPP_WORLD} -> ${PM2_RUST_WORLD} and conf..." - pm2 stop "$PM2_CPP_WORLD" >/dev/null 2>&1 || true - if ! mv -f "$CONF_BAK" "$CPP_CONF"; then + local capture_status=$? + local restored_rust_pid="" + local restore_status=0 + trap - EXIT HUP INT TERM + trap '' HUP INT TERM + set +e + + if [ "$CAPTURE_SWAPPED" -eq 1 ]; then + echo "restoring ${PM2_CPP_WORLD} -> ${PM2_RUST_WORLD} and conf..." + if ! pm2 stop "$PM2_CPP_WORLD" >/dev/null 2>&1; then + echo "WARNING: failed to stop ${PM2_CPP_WORLD}; refusing fixture restoration while it may still own runtime state" >&2 + restore_status=1 + fi + if ! capture_wait_for_world_stopped "$PM2_CPP_WORLD" "$CPP_CAPTURE_IDENTITY"; then + echo "WARNING: ${PM2_CPP_WORLD} PID/PM2 entry or ports ${CPP_WORLD_PORT}/${CPP_INSTANCE_PORT} remain active; refusing DB restoration" >&2 + restore_status=1 + fi + if [ "$restore_status" -eq 0 ] \ + && [ "$CPP_CAPTURE_LOOT_FIXTURE_GUARD" = "1" ] \ + && ! loot_fixture_wait_until_all_characters_offline; then + echo "WARNING: characters remain online after stopping C++; refusing fixture restoration" >&2 + restore_status=1 + fi + if [ "$restore_status" -eq 0 ] \ + && [ "$CPP_CAPTURE_LOOT_FIXTURE_GUARD" = "1" ] \ + && ! restore_creature_health_fixture_guard; then + echo "WARNING: failed to restore the bounded Doctor loot fixture" >&2 + restore_status=1 + fi + if [ "$restore_status" -eq 0 ] \ + && ! loot_fixture_bot_cleanup_complete; then + echo "WARNING: bot fixture cleanup is unproven; the normal Rust world will remain stopped" >&2 + restore_status=1 + fi + if [ "$restore_status" -eq 0 ] \ + && [ "$CPP_CAPTURE_LOOT_FIXTURE_GUARD" = "1" ]; then + CPP_CAPTURE_FIXTURE_CLEANUP_VERIFIED=1 + fi + fi + + if [ ! -f "$CONF_BAK" ] || [ -L "$CONF_BAK" ] \ + || [ "$(stat -c '%d:%i' -- "$CONF_BAK" 2>/dev/null)" \ + != "$CPP_CONF_BACKUP_IDENTITY" ] \ + || [ "$(capture_sha256_of_file "$CONF_BAK" 2>/dev/null)" \ + != "$CPP_CONF_BACKUP_SHA256" ]; then + echo "WARNING: ${CONF_BAK} changed or became unsafe; refusing to install it over ${CPP_CONF}" >&2 + restore_status=1 + elif ! mv -f "$CONF_BAK" "$CPP_CONF"; then echo "WARNING: failed to restore ${CPP_CONF} from ${CONF_BAK} — packet logging may still be ON; restore it manually." >&2 + restore_status=1 + fi + if [ "$restore_status" -eq 0 ] \ + && { [ ! -f "$CPP_CONF" ] || [ -L "$CPP_CONF" ] \ + || [ "$(stat -c '%d:%i' -- "$CPP_CONF" 2>/dev/null)" \ + != "$CPP_CONF_BACKUP_IDENTITY" ] \ + || [ "$(capture_sha256_of_file "$CPP_CONF" 2>/dev/null)" \ + != "$CPP_CONF_BACKUP_SHA256" ]; }; then + echo "WARNING: restored ${CPP_CONF} does not match the accredited backup; normal Rust will remain stopped" >&2 + restore_status=1 + fi + if [ "$CAPTURE_SWAPPED" -eq 1 ] && [ "$restore_status" -eq 0 ]; then + if ! pm2 start "$PM2_RUST_WORLD" >/dev/null 2>&1; then + echo "WARNING: failed to restart ${PM2_RUST_WORLD}; inspect PM2 before another capture" >&2 + restore_status=1 + elif ! restored_rust_pid="$(capture_wait_for_world_ready "$PM2_RUST_WORLD")"; then + echo "WARNING: restored ${PM2_RUST_WORLD} did not become one stable PID owning both configured listeners" >&2 + restore_status=1 + else + CPP_CAPTURE_NORMAL_RUNTIME_RESTORED=1 + fi + fi + if [ "$restore_status" -eq 0 ] \ + && [ "$CAPTURE_SWAPPED" -eq 1 ] \ + && [ "$CPP_CAPTURE_LOOT_FIXTURE_GUARD" = "1" ] \ + && ! rm -f -- "$LOOT_FIXTURE_CLEANUP_MARKER"; then + echo "WARNING: failed to remove the consumed bot cleanup marker" >&2 + restore_status=1 fi - pm2 start "$PM2_RUST_WORLD" >/dev/null 2>&1 || true + if [ "$restore_status" -eq 0 ] && [ "$capture_status" -eq 0 ]; then + if ! finalize_cpp_capture_artifact; then + echo "WARNING: capture cleanup succeeded, but atomic packet/manifest publication failed" >&2 + restore_status=1 + fi + fi + if [ "$restore_status" -ne 0 ] || [ "$capture_status" -ne 0 ]; then + [ -z "$OUT_PKT_STAGE" ] || rm -f -- "$OUT_PKT_STAGE" + CAPTURE_ARTIFACT_READY=0 + fi + capture_release_orchestration_lock + if [ "$restore_status" -ne 0 ]; then + echo "WARNING: guarded C++ capture cleanup or provenance publication failed; recover explicitly before reuse" >&2 + exit "$CAPTURE_RESTORE_FAILURE_STATUS" + fi + exit "$capture_status" } trap restore EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM # Enable PacketLogFile in the conf (replace existing line or append). if grep -qE '^[[:space:]]*PacketLogFile' "$CPP_CONF"; then @@ -111,17 +750,73 @@ else printf '\nBot.AccountPrefix = ""\n' >>"$CPP_CONF" fi +CPP_CAPTURE_EFFECTIVE_CONFIG_PATH="$(realpath -e -- "$CPP_CONF" 2>/dev/null)" || { + echo "error: cannot canonicalize effective C++ capture config" >&2 + exit 1 +} +CPP_CAPTURE_EFFECTIVE_CONFIG_SHA256="$(cpp_capture_effective_config_sha256)" || { + echo "error: cannot hash the canonical redacted effective C++ capture config" >&2 + exit 1 +} + rm -f "${CPP_LOGS_DIR}/${PKT_NAME}" echo "swapping to C++ world server..." -pm2 stop "$PM2_RUST_WORLD" >/dev/null 2>&1 || true +CAPTURE_SWAPPED=1 +pm2 stop "$PM2_RUST_WORLD" >/dev/null 2>&1 +capture_wait_for_world_stopped "$PM2_RUST_WORLD" "$RUST_ORIGINAL_IDENTITY" || { + echo "error: ${PM2_RUST_WORLD} PID/PM2 entry or ports ${CPP_WORLD_PORT}/${CPP_INSTANCE_PORT} remain active after stop" >&2 + exit 1 +} +if [ "$CPP_CAPTURE_LOOT_FIXTURE_GUARD" = "1" ]; then + loot_fixture_wait_until_all_characters_offline + apply_creature_health_fixture_guard +fi pm2 start "$PM2_CPP_WORLD" +CPP_CAPTURE_IDENTITY="$(capture_wait_for_world_ready "$PM2_CPP_WORLD")" || { + echo "error: ${PM2_CPP_WORLD} did not become one stable PID owning both configured listeners" >&2 + exit 1 +} +IFS=$'\t' read -r CPP_CAPTURE_PM2_ENTRY_PID CPP_CAPTURE_PID \ + <<<"$CPP_CAPTURE_IDENTITY" +[[ "$CPP_CAPTURE_PM2_ENTRY_PID" =~ ^[1-9][0-9]*$ \ + && "$CPP_CAPTURE_PID" =~ ^[1-9][0-9]*$ ]] || { + echo "error: ${PM2_CPP_WORLD} returned an invalid PM2-parent/listener identity" >&2 + exit 1 +} +accredit_cpp_capture_executable || { + echo "error: ${PM2_CPP_WORLD} live /proc executable does not match the pinned C++ path/SHA-256" >&2 + exit 1 +} echo echo ">>> Perform the '${FLOW}' flow with the client now." read -r -p ">>> Press ENTER when the flow is complete to collect the capture... " _ -mkdir -p "$OUT_DIR" -cp -f "${CPP_LOGS_DIR}/${PKT_NAME}" "$OUT_PKT" -echo "collected $(stat -c%s "$OUT_PKT" 2>/dev/null || echo '?') bytes -> ${OUT_PKT}" -echo "next: crates/capture-diff/scripts/capture-rust.sh ${FLOW}" +[ "$(capture_world_ready_once "$PM2_CPP_WORLD")" = "$CPP_CAPTURE_IDENTITY" ] || { + echo "error: ${PM2_CPP_WORLD} PID/listener identity changed during capture" >&2 + exit 1 +} +cpp_capture_executable_unchanged || { + echo "error: ${PM2_CPP_WORLD} executable path/bytes changed during capture" >&2 + exit 1 +} + +CPP_PKT_SOURCE="${CPP_LOGS_DIR}/${PKT_NAME}" +[ -f "$CPP_PKT_SOURCE" ] && [ ! -L "$CPP_PKT_SOURCE" ] \ + && [ "$(realpath -e -- "$CPP_PKT_SOURCE" 2>/dev/null)" \ + = "$CPP_PKT_SOURCE" ] || { + echo "error: C++ packet logger output is missing, non-canonical, or a symlink" >&2 + exit 1 +} +capture_require_canonical_directory "$OUT_DIR" \ + && [ ! -e "$OUT_PKT" ] && [ ! -L "$OUT_PKT" ] \ + && [ ! -e "$OUT_MANIFEST" ] && [ ! -L "$OUT_MANIFEST" ] || { + echo "error: C++ capture output path changed or became unsafe during capture" >&2 + exit 1 +} +OUT_PKT_STAGE="$(mktemp "${OUT_DIR}/.cpp.pkt.partial.XXXXXX")" +cp -f -- "$CPP_PKT_SOURCE" "$OUT_PKT_STAGE" +CAPTURE_ARTIFACT_READY=1 +echo "packet artifact staged; it will publish only after guarded cleanup succeeds" +echo "next (after this command exits 0): crates/capture-diff/scripts/capture-rust.sh ${FLOW}" diff --git a/crates/capture-diff/scripts/capture-rust.sh b/crates/capture-diff/scripts/capture-rust.sh index 8445cb3c8..3e5f11a8b 100755 --- a/crates/capture-diff/scripts/capture-rust.sh +++ b/crates/capture-diff/scripts/capture-rust.sh @@ -6,7 +6,8 @@ # the dump in place and restarts the server cleanly. # # Usage: crates/capture-diff/scripts/capture-rust.sh [--yes] -# Output: target/captures//rust/ (gitignored; .bin/.meta per packet) +# Output: target/captures//rust/ (gitignored; .bin/.meta per packet, +# including rust.capture-manifest.json) # # Honored env vars: # PM2_RUST_WORLD pm2 name of the Rust world (default: rustycore-world) @@ -17,6 +18,30 @@ # capturing; the original PM2 executable is still restored # RUST_CAPTURE_EXEC_SHA256 mandatory 64-hex SHA-256 when RUST_CAPTURE_EXEC is # set; both the file and live /proc executable must match +# RUST_CAPTURE_LOOT_FIXTURE_GUARD set to 1 only for the versioned +# loot-single-item-claim or loot-two-session-atomic-race +# fixtures; the single-item flow temporarily lowers +# its creature HealthModifier, while the two-session +# flow installs a guarded shared QA chest. Every +# mutation is restored before the original PM2 +# profile starts again +# RUST_CAPTURE_ACK_LOOT_FIXTURE_MUTATION must be 1 with the fixture guard +# RUST_CAPTURE_DB_CONF worldserver.conf containing WorldDatabaseInfo and +# CharacterDatabaseInfo (default: legacy runtime conf) +# RUST_CAPTURE_EFFECTIVE_CONFIG exact config file used by the Rust capture +# process for capture-relevant settings; required +# explicitly for guarded evidence (defaults to +# RUST_CAPTURE_DB_CONF for non-guarded captures) +# WOW_BOT_FIXTURE_JOURNAL absolute path to the bot's mode-0600 recovery +# journal. Guarded loot captures require the bot to +# remove this pending journal and atomically create +# ${WOW_BOT_FIXTURE_JOURNAL}.cleanup-complete before +# the normal PM2 world may be restored +# CAPTURE_ORCHESTRATION_LOCK optional absolute private lock directory shared +# with capture-cpp.sh (default: /tmp, keyed by uid+ports) +# WOW_BOT_EXEC / WOW_BOT_EXEC_SHA256 pinned bot executable used for required +# loot-single-item-claim evidence +# WOW_BOT_REPORT fresh absolute bot JSON report path for that flow # # This restarts the live world server (disconnecting players). Pass --yes to skip # the confirmation prompt. @@ -37,8 +62,177 @@ RUST_WORLD_PORT="${RUST_WORLD_PORT:-8085}" RUST_INSTANCE_PORT="${RUST_INSTANCE_PORT:-8086}" RUST_CAPTURE_EXEC="${RUST_CAPTURE_EXEC:-}" RUST_CAPTURE_EXEC_SHA256="${RUST_CAPTURE_EXEC_SHA256:-}" +CAPTURE_WORLD_PORT="$RUST_WORLD_PORT" +CAPTURE_INSTANCE_PORT="$RUST_INSTANCE_PORT" +CAPTURE_ORCHESTRATION_LOCK="${CAPTURE_ORCHESTRATION_LOCK:-${XDG_RUNTIME_DIR:-/tmp}/rustycore-capture-$(id -u)-${RUST_WORLD_PORT}-${RUST_INSTANCE_PORT}.lock.d}" +CAPTURE_ORCHESTRATION_LOCK_FD="" +RUST_CAPTURE_LOOT_FIXTURE_GUARD="${RUST_CAPTURE_LOOT_FIXTURE_GUARD:-0}" +LOOT_FIXTURE_GUARD_ENABLED="$RUST_CAPTURE_LOOT_FIXTURE_GUARD" +RUST_CAPTURE_ACK_LOOT_FIXTURE_MUTATION="${RUST_CAPTURE_ACK_LOOT_FIXTURE_MUTATION:-0}" +RUST_CAPTURE_DB_CONF="${RUST_CAPTURE_DB_CONF:-/home/server/trinity-legacy-install/bin/worldserver.conf}" +RUST_CAPTURE_EFFECTIVE_CONFIG_WAS_SET="${RUST_CAPTURE_EFFECTIVE_CONFIG+x}" +RUST_CAPTURE_EFFECTIVE_CONFIG="${RUST_CAPTURE_EFFECTIVE_CONFIG:-$RUST_CAPTURE_DB_CONF}" +LOOT_FIXTURE_DB_CONF="$RUST_CAPTURE_DB_CONF" +WOW_BOT_FIXTURE_JOURNAL="${WOW_BOT_FIXTURE_JOURNAL:-}" +WOW_BOT_EXEC="${WOW_BOT_EXEC:-}" +WOW_BOT_EXEC_SHA256="${WOW_BOT_EXEC_SHA256:-}" +WOW_BOT_REPORT="${WOW_BOT_REPORT:-}" CAPTURE_EXEC="" CAPTURE_EXEC_SHA256="" +CAPTURE_EXPECTED_EXEC="" +CAPTURE_EXPECTED_SHA256="" +CAPTURE_SOURCE_EXEC="" +CAPTURE_SOURCE_SHA256="" +CAPTURE_HARNESS_REPO_HEAD="" +CAPTURE_SOURCE_REPO_HEAD="" +CAPTURE_HARNESS_WORKTREE_SHA256="" +CAPTURE_PM2_ENTRY_PID="" +CAPTURE_PM2_ENTRY_STARTTIME="" +CAPTURE_PM2_EXEC_PATH="" +CAPTURE_PM2_EXEC_SHA256="" +CAPTURE_PM2_PROFILE_SHA256="" +CAPTURE_RESTART_COUNT="" +CAPTURE_LISTENER_STARTTIME="" +CAPTURE_EFFECTIVE_CONFIG_PATH="" +CAPTURE_EFFECTIVE_CONFIG_SHA256="" +CAPTURE_RESTORE_FAILURE_STATUS=74 +LOOT_FIXTURE_KIND="" +LOOT_FIXTURE_ENTRY="" +LOOT_FIXTURE_EXPECTED_HEALTH_MODIFIER="" +LOOT_FIXTURE_TEMP_HEALTH_MODIFIER="0.0001" +LOOT_FIXTURE_SNAPSHOT_READY=0 +LOOT_FIXTURE_CHEST_TEMPLATE_ENTRY=2846 +LOOT_FIXTURE_CHEST_LOOT_ENTRY=2278 +LOOT_FIXTURE_CHEST_ITEM=38 +LOOT_FIXTURE_CHEST_GUID=9106001 +LOOT_FIXTURE_CHEST_ADDON_FACTION=101 +LOOT_FIXTURE_CHEST_ADDON_RESTORE_READY=0 +LOOT_FIXTURE_CHEST_SPAWN_DELETE_READY=0 +LOOT_FIXTURE_CHEST_RESPAWN_DELETE_READY=0 +LOOT_FIXTURE_CLEANUP_MARKER="" +LOOT_FIXTURE_WORLD_HOST="" +LOOT_FIXTURE_WORLD_PORT="" +LOOT_FIXTURE_WORLD_USER="" +LOOT_FIXTURE_WORLD_PASSWORD="" +LOOT_FIXTURE_WORLD_DATABASE="" +LOOT_FIXTURE_CHARACTER_HOST="" +LOOT_FIXTURE_CHARACTER_PORT="" +LOOT_FIXTURE_CHARACTER_USER="" +LOOT_FIXTURE_CHARACTER_PASSWORD="" +LOOT_FIXTURE_CHARACTER_DATABASE="" +CAPTURE_PROCESS_PID="" +CAPTURE_LIVE_EXEC="" +CAPTURE_LIVE_SHA256="" +CAPTURE_ARTIFACT_READY=0 +DUMP_STAGE_DIR="" +CAPTURE_PROCESS_TREE_IDENTITIES="" +ORIGINAL_PM2_ENTRY_PID="" +ORIGINAL_PM2_ENTRY_STARTTIME="" +ORIGINAL_LISTENER_PID="" +ORIGINAL_LISTENER_STARTTIME="" +ORIGINAL_PROCESS_TREE_IDENTITIES="" +CAPTURE_RUNTIME_CLEANUP_VERIFIED=0 +CAPTURE_NORMAL_RUNTIME_RESTORED=0 +CAPTURE_FIXTURE_CLEANUP_VERIFIED=0 +CAPTURE_BOT_EXEC="" +CAPTURE_BOT_EXEC_SHA256="" +CAPTURE_BOT_REPORT="" +CAPTURE_BOT_REPORT_SHA256="" +RESTORE_FILE_SHA256="" +CAPTURE_CONFIG_FILE_SHA256="" + +# Keep the bounded SQL mutation and durable cleanup-marker contract identical +# for C++ and Rust recordings. +# shellcheck source=loot-fixture-common.sh +source "$(dirname "${BASH_SOURCE[0]}")/loot-fixture-common.sh" +# shellcheck source=capture-service-common.sh +source "$(dirname "${BASH_SOURCE[0]}")/capture-service-common.sh" + +[[ "$RUST_WORLD_PORT" =~ ^[1-9][0-9]*$ ]] \ + && ((RUST_WORLD_PORT <= 65535)) || { + echo "error: RUST_WORLD_PORT must be an integer from 1 through 65535" >&2 + exit 2 + } +[[ "$RUST_INSTANCE_PORT" =~ ^[1-9][0-9]*$ ]] \ + && ((RUST_INSTANCE_PORT <= 65535)) || { + echo "error: RUST_INSTANCE_PORT must be an integer from 1 through 65535" >&2 + exit 2 + } +[ "$RUST_WORLD_PORT" != "$RUST_INSTANCE_PORT" ] || { + echo "error: RUST_WORLD_PORT and RUST_INSTANCE_PORT must be distinct" >&2 + exit 2 +} + +if [ "$FLOW" = "loot-single-item-claim" ] \ + && [ "$RUST_CAPTURE_LOOT_FIXTURE_GUARD" != "1" ]; then + echo "error: loot-single-item-claim requires RUST_CAPTURE_LOOT_FIXTURE_GUARD=1" >&2 + exit 2 +fi + +case "$RUST_CAPTURE_LOOT_FIXTURE_GUARD" in + 0) ;; + 1) + [ "$RUST_CAPTURE_ACK_LOOT_FIXTURE_MUTATION" = "1" ] || { + echo "error: RUST_CAPTURE_LOOT_FIXTURE_GUARD=1 requires RUST_CAPTURE_ACK_LOOT_FIXTURE_MUTATION=1" >&2 + exit 2 + } + [ -n "$RUST_CAPTURE_EXEC" ] && [ -n "$RUST_CAPTURE_EXEC_SHA256" ] || { + echo "error: guarded Rust evidence requires RUST_CAPTURE_EXEC and RUST_CAPTURE_EXEC_SHA256" >&2 + exit 2 + } + [ "$RUST_CAPTURE_EFFECTIVE_CONFIG_WAS_SET" = "x" ] \ + && [ -n "$RUST_CAPTURE_EFFECTIVE_CONFIG" ] || { + echo "error: guarded Rust evidence requires RUST_CAPTURE_EFFECTIVE_CONFIG" >&2 + exit 2 + } + case "$FLOW" in + loot-single-item-claim) + LOOT_FIXTURE_KIND=creature-health + LOOT_FIXTURE_ENTRY=21779 + LOOT_FIXTURE_EXPECTED_HEALTH_MODIFIER=1 + [ -n "$WOW_BOT_EXEC" ] && [ -n "$WOW_BOT_EXEC_SHA256" ] \ + && [ -n "$WOW_BOT_REPORT" ] || { + echo "error: guarded #106 evidence requires WOW_BOT_EXEC, WOW_BOT_EXEC_SHA256, and WOW_BOT_REPORT" >&2 + exit 2 + } + [[ "$WOW_BOT_EXEC_SHA256" =~ ^[0-9A-Fa-f]{64}$ ]] || { + echo "error: WOW_BOT_EXEC_SHA256 must contain exactly 64 hexadecimal characters" >&2 + exit 2 + } + WOW_BOT_EXEC_SHA256="${WOW_BOT_EXEC_SHA256,,}" + [[ "$WOW_BOT_REPORT" = /* && "$WOW_BOT_REPORT" != *$'\n'* ]] \ + && [ -d "$(dirname -- "$WOW_BOT_REPORT")" ] \ + && [ ! -e "$WOW_BOT_REPORT" ] && [ ! -L "$WOW_BOT_REPORT" ] || { + echo "error: WOW_BOT_REPORT must be a fresh absolute path with an existing parent" >&2 + exit 2 + } + WOW_BOT_REPORT_PARENT="$(dirname -- "$WOW_BOT_REPORT")" + [ "$(realpath -e -- "$WOW_BOT_REPORT_PARENT" 2>/dev/null)" \ + = "$WOW_BOT_REPORT_PARENT" ] \ + && [ ! -L "$WOW_BOT_REPORT_PARENT" ] || { + echo "error: WOW_BOT_REPORT parent must be canonical and non-symlink" >&2 + exit 2 + } + capture_exec_source_matches "$WOW_BOT_EXEC" "$WOW_BOT_EXEC_SHA256" || { + echo "error: WOW_BOT_EXEC is not a canonical pinned executable" >&2 + exit 2 + } + ;; + loot-two-session-atomic-race) + LOOT_FIXTURE_KIND=shared-chest + ;; + *) + echo "error: the loot fixture guard is not defined for flow '${FLOW}'" >&2 + exit 2 + ;; + esac + validate_fresh_loot_fixture_journal || exit 2 + ;; + *) + echo "error: RUST_CAPTURE_LOOT_FIXTURE_GUARD must be 0 or 1" >&2 + exit 2 + ;; +esac sha256_of_file() { local output digest @@ -48,13 +242,400 @@ sha256_of_file() { printf '%s\n' "$digest" } +apply_loot_fixture_guard() { + [ "$RUST_CAPTURE_LOOT_FIXTURE_GUARD" = "1" ] || return 0 + + loot_fixture_wait_until_all_characters_offline || return 1 + + if [ "$LOOT_FIXTURE_KIND" = "shared-chest" ]; then + apply_shared_chest_fixture_guard + return + fi + apply_creature_health_fixture_guard +} + +restore_loot_fixture_guard() { + if [ "$LOOT_FIXTURE_KIND" = "shared-chest" ]; then + restore_shared_chest_fixture_guard + return + fi + + restore_creature_health_fixture_guard +} + +shared_chest_spawn_exact_count() { + loot_fixture_world_mysql -e \ + "SELECT COUNT(*) FROM gameobject + WHERE guid = ${LOOT_FIXTURE_CHEST_GUID} + AND id = ${LOOT_FIXTURE_CHEST_TEMPLATE_ENTRY} + AND map = 0 + AND zoneId = 0 + AND areaId = 0 + AND spawnDifficulties = '0' + AND phaseUseFlags = 0 + AND PhaseId = 0 + AND PhaseGroup = 0 + AND terrainSwapMap = -1 + AND position_x = CAST(-8946.95 AS FLOAT) + AND position_y = CAST(-132.493 AS FLOAT) + AND position_z = CAST(83.5312 AS FLOAT) + AND orientation = 0 + AND rotation0 = 0 + AND rotation1 = 0 + AND rotation2 = 0 + AND rotation3 = 0 + AND spawntimesecs = 300 + AND animprogress = 255 + AND state = 1 + AND ScriptName = '' + AND StringId IS NULL + AND VerifiedBuild = 0" +} + +shared_chest_addon_exact_count() { + local min_gold="$1" + local max_gold="$2" + loot_fixture_world_mysql -e \ + "SELECT COUNT(*) FROM gameobject_template_addon + WHERE entry = ${LOOT_FIXTURE_CHEST_TEMPLATE_ENTRY} + AND faction = ${LOOT_FIXTURE_CHEST_ADDON_FACTION} + AND flags = 0 + AND mingold = ${min_gold} + AND maxgold = ${max_gold} + AND artkit0 = 0 + AND artkit1 = 0 + AND artkit2 = 0 + AND artkit3 = 0 + AND artkit4 = 0 + AND WorldEffectID = 0 + AND AIAnimKitID = 0" +} + +shared_chest_spawn_metadata_counts() { + loot_fixture_world_mysql -e \ + "SELECT + (SELECT COUNT(*) FROM pool_members + WHERE type = 1 AND spawnId = ${LOOT_FIXTURE_CHEST_GUID}), + (SELECT COUNT(*) FROM game_event_gameobject + WHERE guid = ${LOOT_FIXTURE_CHEST_GUID}), + (SELECT COUNT(*) FROM linked_respawn + WHERE guid = ${LOOT_FIXTURE_CHEST_GUID} + OR linkedGuid = ${LOOT_FIXTURE_CHEST_GUID}), + (SELECT COUNT(*) FROM gameobject_addon + WHERE guid = ${LOOT_FIXTURE_CHEST_GUID}), + (SELECT COUNT(*) FROM gameobject_overrides + WHERE spawnId = ${LOOT_FIXTURE_CHEST_GUID}), + (SELECT COUNT(*) FROM spawn_group + WHERE spawnType = 1 AND spawnId = ${LOOT_FIXTURE_CHEST_GUID})" +} + +apply_shared_chest_fixture_guard() { + local total matching conditions inserted ownership + + matching="$(loot_fixture_world_mysql -e \ + "SELECT COUNT(*) FROM gameobject_template + WHERE entry = ${LOOT_FIXTURE_CHEST_TEMPLATE_ENTRY} + AND type = 3 + AND Data1 = ${LOOT_FIXTURE_CHEST_LOOT_ENTRY} + AND Data15 = 1")" || return 1 + [ "$matching" = "1" ] || { + echo "error: shared chest template ${LOOT_FIXTURE_CHEST_TEMPLATE_ENTRY} must exist with type=3, Data1=${LOOT_FIXTURE_CHEST_LOOT_ENTRY}, Data15=1" >&2 + return 1 + } + + total="$(loot_fixture_world_mysql -e \ + "SELECT COUNT(*) FROM gameobject_loot_template + WHERE Entry = ${LOOT_FIXTURE_CHEST_LOOT_ENTRY}")" || return 1 + matching="$(loot_fixture_world_mysql -e \ + "SELECT COUNT(*) FROM gameobject_loot_template + WHERE Entry = ${LOOT_FIXTURE_CHEST_LOOT_ENTRY} + AND Item = ${LOOT_FIXTURE_CHEST_ITEM} + AND Reference = 0 + AND Chance = 100 + AND QuestRequired = 0 + AND LootMode = 1 + AND GroupId = 0 + AND MinCount = 1 + AND MaxCount = 1")" || return 1 + [ "$total" = "1" ] && [ "$matching" = "1" ] || { + echo "error: shared chest loot ${LOOT_FIXTURE_CHEST_LOOT_ENTRY} must contain exactly item ${LOOT_FIXTURE_CHEST_ITEM} at 100% with the pinned normal-loot fields" >&2 + return 1 + } + + conditions="$(loot_fixture_world_mysql -e \ + "SELECT COUNT(*) FROM conditions + WHERE SourceTypeOrReferenceId = 4 + AND SourceGroup = ${LOOT_FIXTURE_CHEST_LOOT_ENTRY}")" || return 1 + [ "$conditions" = "0" ] || { + echo "error: shared chest loot ${LOOT_FIXTURE_CHEST_LOOT_ENTRY} must not have gameobject-loot conditions" >&2 + return 1 + } + + total="$(loot_fixture_world_mysql -e \ + "SELECT COUNT(*) FROM gameobject_template_addon + WHERE entry = ${LOOT_FIXTURE_CHEST_TEMPLATE_ENTRY}")" || return 1 + matching="$(shared_chest_addon_exact_count 0 0)" || return 1 + [ "$total" = "1" ] && [ "$matching" = "1" ] || { + echo "error: shared chest addon ${LOOT_FIXTURE_CHEST_TEMPLATE_ENTRY} does not match the pinned faction/flags/artkits/effects and 0/0 money contract" >&2 + return 1 + } + + total="$(loot_fixture_world_mysql -e \ + "SELECT COUNT(*) FROM gameobject WHERE guid = ${LOOT_FIXTURE_CHEST_GUID}")" || return 1 + [ "$total" = "0" ] || { + echo "error: refusing to replace pre-existing gameobject guid ${LOOT_FIXTURE_CHEST_GUID}" >&2 + return 1 + } + + # Reject every spawn-scoped row loaded by ObjectMgr for this GameObject. A + # nominally absent `gameobject` row is not safe to claim when orphan addon, + # override, spawn-group, pool, event, or linked-respawn metadata still exists. + ownership="$(shared_chest_spawn_metadata_counts)" || return 1 + [ "$ownership" = $'0\t0\t0\t0\t0\t0' ] || { + echo "error: shared chest guid ${LOOT_FIXTURE_CHEST_GUID} has spawn-scoped metadata (pool/event/linked/addon/override/spawn-group: ${ownership:-query-failed})" >&2 + return 1 + } + + total="$(loot_fixture_character_mysql -e \ + "SELECT COUNT(*) FROM respawn + WHERE type = 1 + AND spawnId = ${LOOT_FIXTURE_CHEST_GUID}")" || return 1 + [ "$total" = "0" ] || { + echo "error: shared chest guid ${LOOT_FIXTURE_CHEST_GUID} already owns ${total} gameobject respawn row(s)" >&2 + return 1 + } + + # The exact world process may create this row after the chest is consumed. + # Absence was proven above; arm its fail-closed cleanup before that process + # can start, even if a later fixture mutation or verification is interrupted. + LOOT_FIXTURE_CHEST_RESPAWN_DELETE_READY=1 + + # Arm restoration before each write so EXIT/HUP/INT/TERM between the write + # and verification still runs a fail-closed cleanup. + LOOT_FIXTURE_CHEST_ADDON_RESTORE_READY=1 + matching="$(loot_fixture_world_mysql -e \ + "UPDATE gameobject_template_addon + SET mingold = 10, maxgold = 10 + WHERE entry = ${LOOT_FIXTURE_CHEST_TEMPLATE_ENTRY} + AND faction = ${LOOT_FIXTURE_CHEST_ADDON_FACTION} + AND flags = 0 + AND mingold = 0 + AND maxgold = 0 + AND artkit0 = 0 + AND artkit1 = 0 + AND artkit2 = 0 + AND artkit3 = 0 + AND artkit4 = 0 + AND WorldEffectID = 0 + AND AIAnimKitID = 0; + SELECT ROW_COUNT();")" || return 1 + [ "$matching" = "1" ] \ + && [ "$(shared_chest_addon_exact_count 10 10)" = "1" ] || { + echo "error: failed to activate shared chest money fixture" >&2 + return 1 + } + + LOOT_FIXTURE_CHEST_SPAWN_DELETE_READY=1 + inserted="$(loot_fixture_world_mysql -e \ + "INSERT INTO gameobject + (guid, id, map, zoneId, areaId, spawnDifficulties, phaseUseFlags, + PhaseId, PhaseGroup, terrainSwapMap, position_x, position_y, position_z, + orientation, rotation0, rotation1, rotation2, rotation3, spawntimesecs, + animprogress, state, ScriptName, StringId, VerifiedBuild) + SELECT ${LOOT_FIXTURE_CHEST_GUID}, ${LOOT_FIXTURE_CHEST_TEMPLATE_ENTRY}, + 0, 0, 0, '0', 0, 0, 0, -1, + CAST(-8946.95 AS FLOAT), CAST(-132.493 AS FLOAT), + CAST(83.5312 AS FLOAT), + 0, 0, 0, 0, 0, 300, 255, 1, '', NULL, 0 + WHERE NOT EXISTS + (SELECT 1 FROM gameobject WHERE guid = ${LOOT_FIXTURE_CHEST_GUID}); + SELECT ROW_COUNT();")" || return 1 + [ "$inserted" = "1" ] || { + # A successful zero-row statement proves this invocation did not own the + # row; disarm deletion so cleanup never removes somebody else's spawn. + LOOT_FIXTURE_CHEST_SPAWN_DELETE_READY=0 + echo "error: shared chest guid ${LOOT_FIXTURE_CHEST_GUID} appeared before fixture insertion" >&2 + return 1 + } + matching="$(shared_chest_spawn_exact_count)" || return 1 + [ "$matching" = "1" ] || { + echo "error: failed to verify exact shared chest spawn ${LOOT_FIXTURE_CHEST_GUID}" >&2 + return 1 + } + + echo "loot fixture: installed shared chest guid ${LOOT_FIXTURE_CHEST_GUID} (item ${LOOT_FIXTURE_CHEST_ITEM}, money 10; restore armed)" +} + +restore_shared_chest_fixture_guard() { + local restore_status=0 total matching respawn_time ownership deleted + + # Preflight every wrapper-owned surface before the first cleanup write. In + # particular, do not erase a generated respawn and only afterwards discover + # that the world spawn's persisted `state` or spawn metadata drifted. + if [ "$LOOT_FIXTURE_CHEST_SPAWN_DELETE_READY" -eq 1 ]; then + total="$(loot_fixture_world_mysql -e \ + "SELECT COUNT(*) FROM gameobject WHERE guid = ${LOOT_FIXTURE_CHEST_GUID}")" || total="" + matching="$(shared_chest_spawn_exact_count)" || matching="" + ownership="$(shared_chest_spawn_metadata_counts)" || ownership="" + if [ "$total" != "1" ] || [ "$matching" != "1" ] \ + || [ "$ownership" != $'0\t0\t0\t0\t0\t0' ]; then + echo "WARNING: shared chest spawn/state/metadata drifted; refusing every cleanup write (${total:-query-failed}/${matching:-query-failed}/${ownership:-query-failed})" >&2 + return 1 + fi + fi + if [ "$LOOT_FIXTURE_CHEST_ADDON_RESTORE_READY" -eq 1 ] \ + && [ "$(shared_chest_addon_exact_count 0 0 2>/dev/null || true)" != "1" ] \ + && [ "$(shared_chest_addon_exact_count 10 10 2>/dev/null || true)" != "1" ]; then + echo "WARNING: shared chest template addon drifted; refusing every cleanup write" >&2 + return 1 + fi + + if [ "$LOOT_FIXTURE_CHEST_RESPAWN_DELETE_READY" -eq 1 ]; then + total="$(loot_fixture_character_mysql -e \ + "SELECT COUNT(*) FROM respawn + WHERE type = 1 + AND spawnId = ${LOOT_FIXTURE_CHEST_GUID}")" || total="" + if [ "$total" = "0" ]; then + LOOT_FIXTURE_CHEST_RESPAWN_DELETE_READY=0 + elif [ "$total" = "1" ]; then + respawn_time="$(loot_fixture_character_mysql -e \ + "SELECT respawnTime FROM respawn + WHERE type = 1 + AND spawnId = ${LOOT_FIXTURE_CHEST_GUID} + AND mapId = 0 + AND instanceId = 0")" || respawn_time="" + if [[ "$respawn_time" =~ ^[1-9][0-9]*$ ]]; then + deleted="$(loot_fixture_character_mysql -e \ + "DELETE FROM respawn + WHERE type = 1 + AND spawnId = ${LOOT_FIXTURE_CHEST_GUID} + AND respawnTime = ${respawn_time} + AND mapId = 0 + AND instanceId = 0; + SELECT ROW_COUNT();")" || deleted="" + else + deleted="" + fi + if [ "$deleted" = "1" ] \ + && [ "$(loot_fixture_character_mysql -e \ + "SELECT COUNT(*) FROM respawn + WHERE type = 1 + AND spawnId = ${LOOT_FIXTURE_CHEST_GUID}")" = "0" ]; then + LOOT_FIXTURE_CHEST_RESPAWN_DELETE_READY=0 + echo "loot fixture: removed generated respawn for shared chest guid ${LOOT_FIXTURE_CHEST_GUID}" + else + echo "WARNING: shared chest respawn ${LOOT_FIXTURE_CHEST_GUID} changed externally; refusing a non-exact delete" >&2 + restore_status=1 + fi + else + echo "WARNING: shared chest guid ${LOOT_FIXTURE_CHEST_GUID} has unexpected respawn ownership (${total:-query-failed}); refusing to delete it" >&2 + restore_status=1 + fi + fi + + if [ "$LOOT_FIXTURE_CHEST_SPAWN_DELETE_READY" -eq 1 ]; then + total="$(loot_fixture_world_mysql -e \ + "SELECT COUNT(*) FROM gameobject WHERE guid = ${LOOT_FIXTURE_CHEST_GUID}")" || total="" + matching="$(shared_chest_spawn_exact_count)" || matching="" + ownership="$(shared_chest_spawn_metadata_counts)" || ownership="" + if [ "$ownership" != $'0\t0\t0\t0\t0\t0' ]; then + echo "WARNING: shared chest guid ${LOOT_FIXTURE_CHEST_GUID} gained spawn-scoped metadata; refusing to hide or delete the drift (${ownership:-query-failed})" >&2 + restore_status=1 + elif [ "$total" = "0" ]; then + LOOT_FIXTURE_CHEST_SPAWN_DELETE_READY=0 + elif [ "$total" = "1" ] && [ "$matching" = "1" ]; then + deleted="$(loot_fixture_world_mysql -e \ + "DELETE FROM gameobject + WHERE guid = ${LOOT_FIXTURE_CHEST_GUID} + AND id = ${LOOT_FIXTURE_CHEST_TEMPLATE_ENTRY} + AND map = 0 + AND zoneId = 0 + AND areaId = 0 + AND spawnDifficulties = '0' + AND phaseUseFlags = 0 + AND PhaseId = 0 + AND PhaseGroup = 0 + AND terrainSwapMap = -1 + AND position_x = CAST(-8946.95 AS FLOAT) + AND position_y = CAST(-132.493 AS FLOAT) + AND position_z = CAST(83.5312 AS FLOAT) + AND orientation = 0 + AND rotation0 = 0 + AND rotation1 = 0 + AND rotation2 = 0 + AND rotation3 = 0 + AND spawntimesecs = 300 + AND animprogress = 255 + AND state = 1 + AND ScriptName = '' + AND StringId IS NULL + AND VerifiedBuild = 0; + SELECT ROW_COUNT();")" \ + || deleted="" + if [ "$deleted" = "1" ] \ + && [ "$(loot_fixture_world_mysql -e \ + "SELECT COUNT(*) FROM gameobject WHERE guid = ${LOOT_FIXTURE_CHEST_GUID}")" = "0" ]; then + LOOT_FIXTURE_CHEST_SPAWN_DELETE_READY=0 + echo "loot fixture: removed shared chest guid ${LOOT_FIXTURE_CHEST_GUID}" + else + echo "WARNING: failed to remove shared chest guid ${LOOT_FIXTURE_CHEST_GUID}" >&2 + restore_status=1 + fi + else + echo "WARNING: shared chest guid ${LOOT_FIXTURE_CHEST_GUID} changed externally; refusing to delete it" >&2 + restore_status=1 + fi + fi + + if [ "$LOOT_FIXTURE_CHEST_ADDON_RESTORE_READY" -eq 1 ]; then + matching="$(shared_chest_addon_exact_count 0 0)" || matching="" + if [ "$matching" = "1" ]; then + LOOT_FIXTURE_CHEST_ADDON_RESTORE_READY=0 + else + matching="$(shared_chest_addon_exact_count 10 10)" || matching="" + if [ "$matching" != "1" ]; then + echo "WARNING: shared chest addon ${LOOT_FIXTURE_CHEST_TEMPLATE_ENTRY} changed externally; refusing to overwrite it" >&2 + restore_status=1 + else + deleted="$(loot_fixture_world_mysql -e \ + "UPDATE gameobject_template_addon + SET mingold = 0, maxgold = 0 + WHERE entry = ${LOOT_FIXTURE_CHEST_TEMPLATE_ENTRY} + AND faction = ${LOOT_FIXTURE_CHEST_ADDON_FACTION} + AND flags = 0 + AND mingold = 10 + AND maxgold = 10 + AND artkit0 = 0 + AND artkit1 = 0 + AND artkit2 = 0 + AND artkit3 = 0 + AND artkit4 = 0 + AND WorldEffectID = 0 + AND AIAnimKitID = 0; + SELECT ROW_COUNT();")" || deleted="" + if [ "$deleted" = "1" ] \ + && [ "$(shared_chest_addon_exact_count 0 0)" = "1" ]; then + LOOT_FIXTURE_CHEST_ADDON_RESTORE_READY=0 + echo "loot fixture: restored chest addon ${LOOT_FIXTURE_CHEST_TEMPLATE_ENTRY} mingold/maxgold 0/0" + else + echo "WARNING: failed to restore shared chest addon ${LOOT_FIXTURE_CHEST_TEMPLATE_ENTRY}" >&2 + restore_status=1 + fi + fi + fi + fi + + return "$restore_status" +} + capture_exec_source_matches() { [ -n "$CAPTURE_EXEC" ] || return 0 local canonical digest canonical="$(realpath -e -- "$CAPTURE_EXEC" 2>/dev/null)" || return 1 [ "$canonical" = "$CAPTURE_EXEC" ] || return 1 - [ -f "$CAPTURE_EXEC" ] && [ -x "$CAPTURE_EXEC" ] || return 1 + [ -f "$CAPTURE_EXEC" ] && [ -x "$CAPTURE_EXEC" ] \ + && [ ! -L "$CAPTURE_EXEC" ] || return 1 digest="$(sha256_of_file "$CAPTURE_EXEC")" || return 1 [ "$digest" = "$CAPTURE_EXEC_SHA256" ] } @@ -63,9 +644,11 @@ capture_process_exec_matches() { local identity="$1" [ -n "$CAPTURE_EXEC" ] || return 0 - local pid proc_exe live_exec source_digest live_digest + local pm2_pid pid proc_exe live_exec source_digest live_digest [[ "$identity" == *$'\t'* ]] || return 1 - pid="${identity%%$'\t'*}" + pm2_pid="${identity%%$'\t'*}" + pid="$(capture_world_listener_pid)" || return 1 + capture_pid_is_self_or_descendant "$pid" "$pm2_pid" || return 1 [[ "$pid" =~ ^[1-9][0-9]*$ ]] || return 1 proc_exe="/proc/${pid}/exe" [ -L "$proc_exe" ] || return 1 @@ -100,7 +683,8 @@ if [ -n "$RUST_CAPTURE_EXEC" ]; then echo "error: RUST_CAPTURE_EXEC must already be canonical: ${CAPTURE_EXEC}" >&2 exit 1 } - [ -f "$CAPTURE_EXEC" ] && [ -x "$CAPTURE_EXEC" ] || { + [ -f "$CAPTURE_EXEC" ] && [ -x "$CAPTURE_EXEC" ] \ + && [ ! -L "$CAPTURE_EXEC" ] || { echo "error: RUST_CAPTURE_EXEC is not an executable regular file" >&2 exit 1 } @@ -121,12 +705,243 @@ if [ -n "$RUST_CAPTURE_EXEC" ]; then echo "error: RUST_CAPTURE_EXEC does not match RUST_CAPTURE_EXEC_SHA256" >&2 exit 1 fi + CAPTURE_EXPECTED_EXEC="$CAPTURE_EXEC" + CAPTURE_EXPECTED_SHA256="$CAPTURE_EXEC_SHA256" + CAPTURE_SOURCE_EXEC="$CAPTURE_EXEC" + CAPTURE_SOURCE_SHA256="$CAPTURE_EXEC_SHA256" elif [ -n "$RUST_CAPTURE_EXEC_SHA256" ]; then echo "error: RUST_CAPTURE_EXEC_SHA256 requires RUST_CAPTURE_EXEC" >&2 exit 1 fi -DUMP_DIR="${REPO_ROOT}/target/captures/${FLOW}/rust" +DUMP_PARENT_DIR="${REPO_ROOT}/target/captures/${FLOW}" +DUMP_DIR="${DUMP_PARENT_DIR}/rust" +capture_require_canonical_directory "${REPO_ROOT}/target/captures" \ + && capture_require_canonical_directory "$DUMP_PARENT_DIR" || { + echo "error: capture output root is not canonical or contains a symlink" >&2 + exit 2 +} +[ ! -e "$DUMP_DIR" ] && [ ! -L "$DUMP_DIR" ] || { + echo "error: raw Rust capture directory already exists; archive/remove it before recording a new atomic generation: ${DUMP_DIR}" >&2 + exit 2 +} + +CAPTURE_HARNESS_REPO_HEAD="$(git -C "$REPO_ROOT" rev-parse HEAD 2>/dev/null)" || { + echo "error: cannot resolve RustyCore harness repository HEAD" >&2 + exit 1 +} +CAPTURE_SOURCE_REPO_HEAD="$CAPTURE_HARNESS_REPO_HEAD" +capture_git_repo_clean_at_head "$REPO_ROOT" "$CAPTURE_HARNESS_REPO_HEAD" || { + echo "error: capture evidence requires a clean committed RustyCore harness/source worktree (including untracked files)" >&2 + exit 1 +} +CAPTURE_HARNESS_WORKTREE_SHA256="$( + capture_git_worktree_state_sha256 "$REPO_ROOT" +)" || { + echo "error: cannot fingerprint the RustyCore harness/source worktree" >&2 + exit 1 +} +CAPTURE_EFFECTIVE_CONFIG_PATH="$(realpath -e -- "$RUST_CAPTURE_EFFECTIVE_CONFIG" 2>/dev/null)" || { + echo "error: RUST_CAPTURE_EFFECTIVE_CONFIG does not resolve" >&2 + exit 1 +} +[ "$CAPTURE_EFFECTIVE_CONFIG_PATH" = "$RUST_CAPTURE_EFFECTIVE_CONFIG" ] \ + && [ -f "$CAPTURE_EFFECTIVE_CONFIG_PATH" ] \ + && [ ! -L "$CAPTURE_EFFECTIVE_CONFIG_PATH" ] || { + echo "error: RUST_CAPTURE_EFFECTIVE_CONFIG must be an absolute canonical regular non-symlink file" >&2 + exit 1 +} + +rust_capture_effective_config_sha256() { + capture_effective_config_redacted_sha256 \ + "$CAPTURE_EFFECTIVE_CONFIG_PATH" \ + "capture.world_port=${RUST_WORLD_PORT} +capture.instance_port=${RUST_INSTANCE_PORT} +capture.packet_dump=enabled" \ + PacketLogFile LogsDir Bot.AccountPrefix WorldServerPort InstanceServerPort \ + LoginDatabaseInfo WorldDatabaseInfo CharacterDatabaseInfo \ + Rate.Drop.Item.Poor Rate.Drop.Item.Normal Rate.Drop.Item.Uncommon \ + Rate.Drop.Item.Rare Rate.Drop.Item.Epic Rate.Drop.Item.Legendary \ + Rate.Drop.Item.Artifact Rate.Drop.Item.Referenced Rate.Drop.Money +} + +CAPTURE_EFFECTIVE_CONFIG_SHA256="$(rust_capture_effective_config_sha256)" || { + echo "error: cannot hash the canonical redacted effective Rust capture config" >&2 + exit 1 +} + +rust_capture_tree_digest() { + local directory="$1" + local output digest file_sha path + rust_capture_flat_tree_is_safe "$directory" || return 1 + output="$({ + cd "$directory" || exit 1 + while IFS= read -r -d '' path; do + file_sha="$(sha256_of_file "$path")" || exit 1 + printf '%s\0%s\0' "${path#./}" "$file_sha" + done < <(find . -mindepth 1 -maxdepth 1 -type f -print0 | LC_ALL=C sort -z) + } | sha256sum)" || return 1 + digest="${output%% *}" + [[ "$digest" =~ ^[0-9a-f]{64}$ ]] || return 1 + printf '%s\n' "$digest" +} + +rust_capture_flat_tree_is_safe() { + local directory="$1" + local path base + [ -d "$directory" ] && [ ! -L "$directory" ] || return 1 + while IFS= read -r -d '' path; do + [ -f "$path" ] && [ ! -L "$path" ] || return 1 + base="${path##*/}" + case "$base" in + *.bin|*.meta|rust.capture-manifest.json) ;; + *) return 1 ;; + esac + done < <(find "$directory" -mindepth 1 -maxdepth 1 -print0) +} + +finalize_rust_capture_artifact() { + [ "$CAPTURE_ARTIFACT_READY" -eq 1 ] \ + && [ -d "$DUMP_STAGE_DIR" ] \ + && [ -n "$CAPTURE_LIVE_EXEC" ] \ + && [ -n "$CAPTURE_LIVE_SHA256" ] || return 1 + + local created_at manifest packet_count tree_sha bot_evidence path + [ "$CAPTURE_RUNTIME_CLEANUP_VERIFIED" -eq 1 ] \ + && [ "$CAPTURE_NORMAL_RUNTIME_RESTORED" -eq 1 ] \ + && [ "$CAPTURE_FIXTURE_CLEANUP_VERIFIED" -eq 1 \ + || "$FLOW" != "loot-single-item-claim" ] || return 1 + if [ "$FLOW" = "loot-single-item-claim" ]; then + bot_evidence="$(capture_loot_item_bot_evidence \ + "$WOW_BOT_REPORT" "$WOW_BOT_EXEC" "$WOW_BOT_EXEC_SHA256")" || return 1 + IFS=$'\t' read -r CAPTURE_BOT_EXEC CAPTURE_BOT_EXEC_SHA256 \ + CAPTURE_BOT_REPORT CAPTURE_BOT_REPORT_SHA256 <<<"$bot_evidence" + fi + tree_sha="$(rust_capture_tree_digest "$DUMP_STAGE_DIR")" || return 1 + packet_count="$(find "$DUMP_STAGE_DIR" -mindepth 1 -maxdepth 1 \ + -type f -name '*.meta' -print | wc -l)" \ + || return 1 + [[ "$packet_count" =~ ^[0-9]+$ ]] || return 1 + created_at="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" || return 1 + capture_git_repo_clean_at_head "$REPO_ROOT" "$CAPTURE_HARNESS_REPO_HEAD" \ + && [ "$(capture_git_worktree_state_sha256 "$REPO_ROOT")" \ + = "$CAPTURE_HARNESS_WORKTREE_SHA256" ] || return 1 + manifest="$DUMP_STAGE_DIR/rust.capture-manifest.json" + if ! jq -n \ + --arg flow "$FLOW" \ + --arg created_at "$created_at" \ + --arg harness_repo_head "$CAPTURE_HARNESS_REPO_HEAD" \ + --arg source_repo_head "$CAPTURE_SOURCE_REPO_HEAD" \ + --arg harness_worktree_sha256 "$CAPTURE_HARNESS_WORKTREE_SHA256" \ + --arg expected_exec_path "$CAPTURE_EXPECTED_EXEC" \ + --arg expected_exec_sha256 "$CAPTURE_EXPECTED_SHA256" \ + --arg source_exec_path "$CAPTURE_SOURCE_EXEC" \ + --arg source_exec_sha256 "$CAPTURE_SOURCE_SHA256" \ + --arg live_exec_path "$CAPTURE_LIVE_EXEC" \ + --arg live_exec_sha256 "$CAPTURE_LIVE_SHA256" \ + --arg pm2_exec_path "$CAPTURE_PM2_EXEC_PATH" \ + --arg pm2_exec_sha256 "$CAPTURE_PM2_EXEC_SHA256" \ + --arg pm2_profile_sha256 "$CAPTURE_PM2_PROFILE_SHA256" \ + --arg effective_config_path "$CAPTURE_EFFECTIVE_CONFIG_PATH" \ + --arg effective_config_sha256 "$CAPTURE_EFFECTIVE_CONFIG_SHA256" \ + --arg bot_exec_path "$CAPTURE_BOT_EXEC" \ + --arg bot_exec_sha256 "$CAPTURE_BOT_EXEC_SHA256" \ + --arg bot_report_path "$CAPTURE_BOT_REPORT" \ + --arg bot_report_sha256 "$CAPTURE_BOT_REPORT_SHA256" \ + --arg tree_sha256 "$tree_sha" \ + --argjson pm2_entry_pid "$CAPTURE_PM2_ENTRY_PID" \ + --argjson pm2_entry_starttime "$CAPTURE_PM2_ENTRY_STARTTIME" \ + --argjson listener_runtime_pid "$CAPTURE_PROCESS_PID" \ + --argjson listener_runtime_starttime "$CAPTURE_LISTENER_STARTTIME" \ + --argjson restart_count "$CAPTURE_RESTART_COUNT" \ + --argjson packet_count "$packet_count" \ + --argjson pinned "$([ -n "$CAPTURE_EXEC" ] && printf true || printf false)" \ + --argjson guarded "$([ "$FLOW" = "loot-single-item-claim" ] && printf true || printf false)" \ + '{ + version: 3, + flow: $flow, + side: "rust", + completed: true, + created_at: $created_at, + harness_repo_head: $harness_repo_head, + source_repo_head: $source_repo_head, + harness_worktree_clean: true, + harness_worktree_state_sha256: $harness_worktree_sha256, + source_worktree_dirty: false, + source_worktree_state_sha256: $harness_worktree_sha256, + worktree_state_algorithm: "git-head-path-mode-content-sha256-v1", + expected_exec_path: $expected_exec_path, + expected_exec_sha256: $expected_exec_sha256, + source_exec_path: $source_exec_path, + source_exec_sha256: $source_exec_sha256, + live_exec_path: $live_exec_path, + live_exec_sha256: $live_exec_sha256, + executable_pin_enforced: $pinned, + pm2_entry_pid: $pm2_entry_pid, + pm2_entry_starttime: $pm2_entry_starttime, + pm2_exec_path: $pm2_exec_path, + pm2_exec_sha256: $pm2_exec_sha256, + pm2_profile_redacted_sha256: $pm2_profile_sha256, + listener_runtime_pid: $listener_runtime_pid, + listener_runtime_starttime: $listener_runtime_starttime, + listener_relationship_verified: true, + restart_count: $restart_count, + effective_config_path: $effective_config_path, + effective_config_redacted_sha256: $effective_config_sha256, + effective_config_algorithm: "capture-relevant-redacted-v1", + runtime_cleanup_verified: true, + normal_runtime_restored: true, + fixture_guard: (if $guarded then { + enabled: true, + contract: "loot-single-item-claim-fixture-v1", + account: "TESTBOT2@bot.local", + account_id: 9, + character_guid: 15, + peer_account: "TESTBOT3@bot.local", + peer_account_id: 10, + peer_character_guid: 16, + creature_entry: 21779, + creature_spawn_guid: 1117, + item_entry: 30712, + cleanup_verified: true + } else null end), + bot_report: (if $guarded then { + contract: "wow-test-bot-loot-item-capture-report-v1", + exec_path: $bot_exec_path, + exec_sha256: $bot_exec_sha256, + report_path: $bot_report_path, + report_sha256: $bot_report_sha256, + account: "TESTBOT2@bot.local", + account_id: 9, + character_guid: 15, + report_validated: true + } else null end), + artifact: { + path: "rust", + packet_count: $packet_count, + tree_sha256: $tree_sha256 + } + }' >"$manifest"; then + rm -f -- "$manifest" + return 1 + fi + chmod 600 "$manifest" || return 1 + rust_capture_flat_tree_is_safe "$DUMP_STAGE_DIR" || return 1 + while IFS= read -r -d '' path; do + sync -f "$path" || return 1 + done < <(find "$DUMP_STAGE_DIR" -mindepth 1 -maxdepth 1 -type f -print0) + sync -f "$DUMP_STAGE_DIR" || return 1 + capture_require_canonical_directory "$DUMP_PARENT_DIR" \ + && [ ! -e "$DUMP_DIR" ] && [ ! -L "$DUMP_DIR" ] || return 1 + capture_publish_noreplace "$DUMP_STAGE_DIR" "$DUMP_DIR" || return 1 + DUMP_STAGE_DIR="" + sync -f "$(dirname -- "$DUMP_DIR")" || return 1 + CAPTURE_ARTIFACT_READY=0 + echo "collected ${packet_count} packets -> ${DUMP_DIR}" + echo "provenance -> ${DUMP_DIR}/rust.capture-manifest.json" + echo "diff against the C++ golden:" + echo " cargo run -p capture-diff -- diff ${FLOW} --rust ${DUMP_DIR}" +} command -v jq >/dev/null 2>&1 || { echo "error: jq is required to snapshot and safely restore the PM2 process" >&2 @@ -140,10 +955,35 @@ command -v ss >/dev/null 2>&1 || { echo "error: ss is required to verify Rust world listener readiness" >&2 exit 1 } +command -v rg >/dev/null 2>&1 || { + echo "error: rg is required to verify Rust world listener readiness" >&2 + exit 1 +} +for dependency in awk chmod date dirname find flock git id mkdir mktemp mv \ + realpath sed seq sha256sum sleep sort stat sync wc; do + command -v "$dependency" >/dev/null 2>&1 || { + echo "error: required command not found: $dependency" >&2 + exit 1 + } +done +if [ "$RUST_CAPTURE_LOOT_FIXTURE_GUARD" = "1" ]; then + command -v mysql >/dev/null 2>&1 || { + echo "error: mysql is required by the loot fixture guard" >&2 + exit 1 + } + load_loot_fixture_database_credentials || exit 1 +fi echo "flow : ${FLOW}" echo "dump dir : ${DUMP_DIR}" echo "pm2 world : ${PM2_RUST_WORLD}" +if [ "$RUST_CAPTURE_LOOT_FIXTURE_GUARD" = "1" ]; then + if [ "$LOOT_FIXTURE_KIND" = "shared-chest" ]; then + echo "DB fixture : shared chest guid ${LOOT_FIXTURE_CHEST_GUID}, item ${LOOT_FIXTURE_CHEST_ITEM}, money 10" + else + echo "DB fixture : entry ${LOOT_FIXTURE_ENTRY}, temporary HealthModifier ${LOOT_FIXTURE_TEMP_HEALTH_MODIFIER}" + fi +fi echo echo "This will restart ${PM2_RUST_WORLD} with RUSTYCORE_PACKET_DUMP_DIR set." @@ -152,6 +992,15 @@ if [ "$CONFIRM" != "--yes" ]; then [ "$ans" = "y" ] || [ "$ans" = "Y" ] || { echo "aborted"; exit 1; } fi +capture_acquire_orchestration_lock "$CAPTURE_ORCHESTRATION_LOCK" || { + echo "error: another capture/QA process holds ${CAPTURE_ORCHESTRATION_LOCK}" >&2 + exit 1 +} +capture_pm2_process_stopped "$PM2_CPP_WORLD" || { + echo "error: ${PM2_CPP_WORLD} must be one exact stopped PM2 process before Rust capture" >&2 + exit 1 +} + # `pm2 restart --update-env` merges the caller's whole environment and does not # remove a key merely because the caller later unsets it. After confirmation, # snapshot a supported one-process ecosystem entry, then derive a second config @@ -243,17 +1092,127 @@ snapshot_process_identity() { ' } +resolve_rust_profile_effective_config() { + local snapshot_file="$1" + local cwd config_arg="" config_dir_arg="" candidate index + local -a args=() + + cwd="$(jq -er '.apps[0].cwd | select(type == "string" and length > 0)' \ + "$snapshot_file")" || return 1 + jq -e '.apps[0].args | type == "array"' "$snapshot_file" >/dev/null \ + || return 1 + mapfile -t args < <(jq -r '.apps[0].args[]' "$snapshot_file") + for ((index = 0; index < ${#args[@]}; index++)); do + case "${args[index]}" in + -c|--config) + ((index + 1 < ${#args[@]})) || return 1 + config_arg="${args[index + 1]}" + index=$((index + 1)) + ;; + --config=*) config_arg="${args[index]#--config=}" ;; + -cd|--config-dir) + ((index + 1 < ${#args[@]})) || return 1 + config_dir_arg="${args[index + 1]}" + index=$((index + 1)) + ;; + --config-dir=*) config_dir_arg="${args[index]#--config-dir=}" ;; + esac + done + + if [ -z "$config_arg" ]; then + for candidate in worldserver.conf worldserver.conf.dist WorldServer.conf WorldServer.conf.dist; do + if [ -f "$cwd/$candidate" ]; then + config_arg="$cwd/$candidate" + break + fi + done + elif [[ "$config_arg" != /* ]]; then + config_arg="$cwd/$config_arg" + fi + [ -n "$config_arg" ] || return 1 + + if [ -z "$config_dir_arg" ]; then + config_dir_arg="$cwd/worldserver.conf.d" + elif [[ "$config_dir_arg" != /* ]]; then + config_dir_arg="$cwd/$config_dir_arg" + fi + if [ -d "$config_dir_arg" ] \ + && find "$config_dir_arg" -type f -name '*.conf' -print -quit | rg -q .; then + echo "error: guarded capture provenance does not yet support additional config overlays in ${config_dir_arg}" >&2 + return 1 + fi + if [ "$RUST_CAPTURE_LOOT_FIXTURE_GUARD" = "1" ] \ + && ! jq -e ' + (.apps[0].env // {}) | keys + | map(select(startswith("TC_"))) | length == 0 + ' "$snapshot_file" >/dev/null; then + echo "error: guarded capture provenance does not permit unrecorded TC_* config overrides" >&2 + return 1 + fi + realpath -e -- "$config_arg" +} + rust_world_ports_ready() { - ss -H -ltn | rg -q ":${RUST_WORLD_PORT}\\b" \ - && ss -H -ltn | rg -q ":${RUST_INSTANCE_PORT}\\b" + local identity="$1" + local pm2_pid listener_pid + pm2_pid="${identity%%$'\t'*}" + [[ "$pm2_pid" =~ ^[1-9][0-9]*$ ]] || return 1 + listener_pid="$(capture_world_listener_pid)" || return 1 + capture_pid_is_self_or_descendant "$listener_pid" "$pm2_pid" \ + && capture_world_ports_owned_by_pid "$listener_pid" +} + +rust_world_ports_absent() { + local sockets + sockets="$(ss -H -ltn)" || return 1 + ! rg -q ":(${RUST_WORLD_PORT}|${RUST_INSTANCE_PORT})\\b" <<<"$sockets" +} + +rust_world_pm2_process_absent() { + pm2 jlist | jq -e --arg name "$PM2_RUST_WORLD" \ + '[.[] | select(.name == $name)] | length == 0' >/dev/null +} + +rust_remove_world_and_verify() { + local process_tree="$1" + local listener_identity="$2" + local current_root="" current_tree="" + + pm2 delete "$PM2_RUST_WORLD" >/dev/null 2>&1 || true + capture_process_tree_absent "$process_tree" \ + || capture_terminate_process_tree "$process_tree" || true + current_root="$(capture_pm2_online_pid "$PM2_RUST_WORLD" 2>/dev/null || true)" + if [ -n "$current_root" ]; then + current_tree="$(capture_process_tree_identity "$current_root" 2>/dev/null || true)" + process_tree="$(printf '%s\n%s\n' "$process_tree" "$current_tree" \ + | sed '/^$/d' | LC_ALL=C sort -t: -k1,1n -k2,2n -u)" + fi + # If PM2 retained an autorestart-capable entry while its old tree was being + # terminated, delete that registration again before accepting absence. + pm2 delete "$PM2_RUST_WORLD" >/dev/null 2>&1 || true + capture_process_tree_absent "$process_tree" \ + || capture_terminate_process_tree "$process_tree" || true + pm2 delete "$PM2_RUST_WORLD" >/dev/null 2>&1 || true + capture_process_tree_absent "$process_tree" \ + && { [ -z "$listener_identity" ] \ + || capture_pid_identity_absent "$listener_identity"; } \ + && rust_world_pm2_process_absent \ + && rust_world_ports_absent } cleanup() { local capture_status=$? + local restore_status=0 + local current_root="" current_tree="" listener_identity="" + local process_tree_to_stop="$(printf '%s\n%s\n' \ + "$ORIGINAL_PROCESS_TREE_IDENTITIES" "$CAPTURE_PROCESS_TREE_IDENTITIES" \ + | sed '/^$/d' | LC_ALL=C sort -t: -k1,1n -k2,2n -u)" trap - EXIT trap '' HUP INT TERM if [ "$CAPTURE_MUTATED" -eq 0 ]; then rm -f "$RESTORE_FILE" "$CAPTURE_CONFIG_FILE" + [ -z "$DUMP_STAGE_DIR" ] || rm -rf -- "$DUMP_STAGE_DIR" + capture_release_orchestration_lock exit "$capture_status" fi echo "recreating ${PM2_RUST_WORLD} without RUSTYCORE_PACKET_DUMP_DIR..." @@ -261,26 +1220,79 @@ cleanup() { if [ "$RESTORE_READY" -ne 1 ]; then echo "WARNING: PM2 restore snapshot is incomplete; inspect PM2 manually" >&2 rm -f "$RESTORE_FILE" "$CAPTURE_CONFIG_FILE" - exit 1 + [ -z "$DUMP_STAGE_DIR" ] || rm -rf -- "$DUMP_STAGE_DIR" + capture_release_orchestration_lock + exit "$CAPTURE_RESTORE_FAILURE_STATUS" fi unset RUSTYCORE_PACKET_DUMP_DIR - local restore_status=0 - if ! pm2 delete "$PM2_RUST_WORLD" >/dev/null 2>&1; then + if [ -n "$CAPTURE_PM2_ENTRY_PID" ] \ + && [ -n "$CAPTURE_PM2_ENTRY_STARTTIME" ] \ + && capture_pid_identity_is_live \ + "${CAPTURE_PM2_ENTRY_PID}:${CAPTURE_PM2_ENTRY_STARTTIME}"; then + current_root="$CAPTURE_PM2_ENTRY_PID" + else + current_root="$(capture_pm2_online_pid "$PM2_RUST_WORLD" 2>/dev/null || true)" + fi + if [ -n "$current_root" ]; then + current_tree="$(capture_process_tree_identity "$current_root" 2>/dev/null || true)" + process_tree_to_stop="$(printf '%s\n%s\n' \ + "$process_tree_to_stop" "$current_tree" \ + | sed '/^$/d' | LC_ALL=C sort -t: -k1,1n -k2,2n -u)" + fi + if [ -n "$CAPTURE_PROCESS_PID" ] && [ -n "$CAPTURE_LISTENER_STARTTIME" ]; then + listener_identity="${CAPTURE_PROCESS_PID}:${CAPTURE_LISTENER_STARTTIME}" + elif [ -n "$ORIGINAL_LISTENER_PID" ] \ + && [ -n "$ORIGINAL_LISTENER_STARTTIME" ]; then + listener_identity="${ORIGINAL_LISTENER_PID}:${ORIGINAL_LISTENER_STARTTIME}" + else + CAPTURE_PROCESS_PID="$(capture_world_listener_pid 2>/dev/null || true)" + if [ -n "$CAPTURE_PROCESS_PID" ]; then + listener_identity="$(capture_pid_identity "$CAPTURE_PROCESS_PID" 2>/dev/null || true)" + process_tree_to_stop="$(printf '%s\n%s\n' \ + "$process_tree_to_stop" "$listener_identity" \ + | sed '/^$/d' | LC_ALL=C sort -t: -k1,1n -k2,2n -u)" + fi + fi + if ! rust_remove_world_and_verify \ + "$process_tree_to_stop" "$listener_identity"; then + echo "WARNING: capture PM2 entry/listener/descendant tree is still present or serving; refusing fixture restoration while it may still own runtime state" >&2 + restore_status=1 + else + CAPTURE_RUNTIME_CLEANUP_VERIFIED=1 + fi + if [ "$restore_status" -eq 0 ] && ! restore_loot_fixture_guard; then + echo "WARNING: failed to restore the loot fixture; the normal world will remain stopped" >&2 + restore_status=1 + fi + if [ "$restore_status" -eq 0 ] && ! loot_fixture_bot_cleanup_complete; then + echo "WARNING: bot fixture cleanup is unproven; the normal world will remain stopped" >&2 restore_status=1 - elif ! env -i \ + fi + if [ "$restore_status" -eq 0 ] \ + && [ "$FLOW" = "loot-single-item-claim" ]; then + CAPTURE_FIXTURE_CLEANUP_VERIFIED=1 + fi + if [ "$restore_status" -eq 0 ] \ + && { [ ! -f "$RESTORE_FILE" ] || [ -L "$RESTORE_FILE" ] \ + || [ "$(sha256_of_file "$RESTORE_FILE" 2>/dev/null)" \ + != "$RESTORE_FILE_SHA256" ]; }; then + echo "WARNING: PM2 restore snapshot changed or became unsafe; fixture is clean but the normal world will remain stopped" >&2 + restore_status=1 + fi + if [ "$restore_status" -eq 0 ] && ! env -i \ HOME="$HOME" \ PATH="$PATH" \ PM2_HOME="${PM2_HOME:-$HOME/.pm2}" \ "$PM2_BIN" start "$RESTORE_FILE" --only "$PM2_RUST_WORLD" >/dev/null 2>&1; then restore_status=1 - else + elif [ "$restore_status" -eq 0 ]; then restore_status=1 local last_identity="" local stable_samples=0 local identity="" for _ in $(seq 1 40); do if identity="$(snapshot_process_identity "$RESTORE_FILE" 2>/dev/null)" \ - && rust_world_ports_ready; then + && rust_world_ports_ready "$identity"; then if [ "$identity" = "$last_identity" ]; then stable_samples=$((stable_samples + 1)) else @@ -289,6 +1301,7 @@ cleanup() { fi if [ "$stable_samples" -ge 4 ]; then restore_status=0 + CAPTURE_NORMAL_RUNTIME_RESTORED=1 break fi else @@ -302,9 +1315,29 @@ cleanup() { echo "WARNING: failed to restore ${PM2_RUST_WORLD} exactly; inspect PM2 before another capture" >&2 echo "WARNING: mode-0600 recovery snapshot retained at ${RESTORE_FILE}" >&2 rm -f "$CAPTURE_CONFIG_FILE" - exit 1 + [ -z "$DUMP_STAGE_DIR" ] || rm -rf -- "$DUMP_STAGE_DIR" + CAPTURE_ARTIFACT_READY=0 + capture_release_orchestration_lock + exit "$CAPTURE_RESTORE_FAILURE_STATUS" fi rm -f "$RESTORE_FILE" "$CAPTURE_CONFIG_FILE" + if [ "$RUST_CAPTURE_LOOT_FIXTURE_GUARD" = "1" ] \ + && ! rm -f -- "$LOOT_FIXTURE_CLEANUP_MARKER"; then + echo "WARNING: failed to remove the consumed bot cleanup marker" >&2 + restore_status=1 + fi + if [ "$restore_status" -eq 0 ] && [ "$capture_status" -eq 0 ]; then + if ! finalize_rust_capture_artifact; then + echo "WARNING: capture cleanup succeeded, but atomic dump/manifest publication failed" >&2 + restore_status=1 + fi + fi + if [ "$restore_status" -ne 0 ] || [ "$capture_status" -ne 0 ]; then + [ -z "$DUMP_STAGE_DIR" ] || rm -rf -- "$DUMP_STAGE_DIR" + CAPTURE_ARTIFACT_READY=0 + fi + capture_release_orchestration_lock + [ "$restore_status" -eq 0 ] || exit "$CAPTURE_RESTORE_FAILURE_STATUS" exit "$capture_status" } trap cleanup EXIT @@ -397,8 +1430,29 @@ fi echo "error: failed to create PM2 restore snapshot" >&2 exit 1 } +PROFILE_EFFECTIVE_CONFIG="$(resolve_rust_profile_effective_config "$RESTORE_FILE")" || { + echo "error: cannot resolve the exact effective Rust config from the PM2 profile" >&2 + exit 1 +} +[ "$PROFILE_EFFECTIVE_CONFIG" = "$CAPTURE_EFFECTIVE_CONFIG_PATH" ] || { + echo "error: RUST_CAPTURE_EFFECTIVE_CONFIG does not match the config selected by the PM2 cwd/args" >&2 + exit 1 +} +capture_require_canonical_directory "$DUMP_PARENT_DIR" \ + && [ ! -e "$DUMP_DIR" ] && [ ! -L "$DUMP_DIR" ] || { + echo "error: Rust capture output path changed or became unsafe" >&2 + exit 1 +} +DUMP_STAGE_DIR="$(mktemp -d "${DUMP_DIR}.partial.XXXXXX")" || { + echo "error: failed to create private Rust capture staging directory" >&2 + exit 1 +} +chmod 700 "$DUMP_STAGE_DIR" || { + echo "error: failed to make Rust capture staging directory private" >&2 + exit 1 +} if ! jq \ - --arg dump_dir "$DUMP_DIR" \ + --arg dump_dir "$DUMP_STAGE_DIR" \ --arg capture_exec "$CAPTURE_EXEC" ' .apps[0].env.RUSTYCORE_PACKET_DUMP_DIR = $dump_dir | if $capture_exec == "" then . @@ -412,6 +1466,12 @@ fi echo "error: failed to create PM2 capture snapshot" >&2 exit 1 } +chmod 600 "$RESTORE_FILE" "$CAPTURE_CONFIG_FILE" || { + echo "error: failed to keep PM2 snapshots private" >&2 + exit 1 +} +RESTORE_FILE_SHA256="$(sha256_of_file "$RESTORE_FILE")" || exit 1 +CAPTURE_CONFIG_FILE_SHA256="$(sha256_of_file "$CAPTURE_CONFIG_FILE")" || exit 1 RESTORE_READY=1 # Recheck immediately before touching either server. The earlier validation is @@ -420,17 +1480,73 @@ if ! capture_exec_source_matches; then echo "error: RUST_CAPTURE_EXEC changed or no longer matches its pinned SHA-256" >&2 exit 1 fi +capture_git_repo_clean_at_head "$REPO_ROOT" "$CAPTURE_HARNESS_REPO_HEAD" \ + && [ "$(capture_git_worktree_state_sha256 "$REPO_ROOT")" \ + = "$CAPTURE_HARNESS_WORKTREE_SHA256" ] || { + echo "error: RustyCore harness/source worktree changed before service mutation" >&2 + exit 1 +} +[ "$(sha256_of_file "$RESTORE_FILE")" = "$RESTORE_FILE_SHA256" ] \ + && [ "$(sha256_of_file "$CAPTURE_CONFIG_FILE")" \ + = "$CAPTURE_CONFIG_FILE_SHA256" ] || { + echo "error: PM2 capture/restore snapshot changed before service mutation" >&2 + exit 1 +} -# Make sure the C++ swap server is not holding the ports. -pm2 stop "$PM2_CPP_WORLD" >/dev/null 2>&1 || true - -# Fresh dump directory so the capture only contains this flow. -rm -rf "$DUMP_DIR" -mkdir -p "$DUMP_DIR" +# Revalidate the mutually exclusive C++ service state immediately before the +# first PM2/fixture mutation. Never silently stop an unexpected C++ process. +capture_pm2_process_stopped "$PM2_CPP_WORLD" || { + echo "error: ${PM2_CPP_WORLD} changed state before Rust capture mutation" >&2 + exit 1 +} echo "recreating ${PM2_RUST_WORLD} from the clean snapshot with dump enabled..." +ORIGINAL_SNAPSHOT_IDENTITY="$(snapshot_process_identity "$RESTORE_FILE")" || { + echo "error: original Rust PM2 profile changed before capture mutation" >&2 + exit 1 +} +rust_world_ports_ready "$ORIGINAL_SNAPSHOT_IDENTITY" || { + echo "error: original Rust PM2 profile is not the sole owner of both listeners" >&2 + exit 1 +} +ORIGINAL_PM2_ENTRY_PID="${ORIGINAL_SNAPSHOT_IDENTITY%%$'\t'*}" +ORIGINAL_LISTENER_PID="$(capture_world_listener_pid)" || { + echo "error: cannot discover the original Rust listener PID" >&2 + exit 1 +} +[[ "$ORIGINAL_PM2_ENTRY_PID" =~ ^[1-9][0-9]*$ \ + && "$ORIGINAL_LISTENER_PID" =~ ^[1-9][0-9]*$ ]] \ + && capture_pid_is_self_or_descendant \ + "$ORIGINAL_LISTENER_PID" "$ORIGINAL_PM2_ENTRY_PID" || { + echo "error: original Rust PM2 entry/listener relationship is invalid" >&2 + exit 1 +} +ORIGINAL_PM2_ENTRY_STARTTIME="$( + capture_pid_starttime "$ORIGINAL_PM2_ENTRY_PID" +)" || { + echo "error: cannot bind the original PM2 entry PID to its start time" >&2 + exit 1 +} +ORIGINAL_LISTENER_STARTTIME="$( + capture_pid_starttime "$ORIGINAL_LISTENER_PID" +)" || { + echo "error: cannot bind the original listener PID to its start time" >&2 + exit 1 +} +ORIGINAL_PROCESS_TREE_IDENTITIES="$( + capture_process_tree_identity "$ORIGINAL_PM2_ENTRY_PID" +)" || { + echo "error: cannot accredit the original Rust process tree" >&2 + exit 1 +} CAPTURE_MUTATED=1 -pm2 delete "$PM2_RUST_WORLD" >/dev/null +rust_remove_world_and_verify \ + "$ORIGINAL_PROCESS_TREE_IDENTITIES" \ + "${ORIGINAL_LISTENER_PID}:${ORIGINAL_LISTENER_STARTTIME}" || { + echo "error: original Rust PM2 entry/listener/descendants did not stop; refusing fixture mutation" >&2 + exit 1 +} +apply_loot_fixture_guard env -i \ HOME="$HOME" \ PATH="$PATH" \ @@ -441,7 +1557,7 @@ CAPTURE_READY=0 CAPTURE_IDENTITY="" for _ in $(seq 1 40); do if CAPTURE_IDENTITY="$(snapshot_process_identity "$CAPTURE_CONFIG_FILE" 2>/dev/null)" \ - && rust_world_ports_ready \ + && rust_world_ports_ready "$CAPTURE_IDENTITY" \ && capture_process_exec_matches "$CAPTURE_IDENTITY"; then CAPTURE_READY=1 break @@ -452,6 +1568,78 @@ done echo "error: ${PM2_RUST_WORLD} did not start online with the pinned capture executable and packet dumping enabled" >&2 exit 1 } +CAPTURE_PM2_ENTRY_PID="${CAPTURE_IDENTITY%%$'\t'*}" +CAPTURE_PROCESS_PID="$(capture_world_listener_pid)" || { + echo "error: cannot discover the unique Rust world/instance listener PID" >&2 + exit 1 +} +[[ "$CAPTURE_PM2_ENTRY_PID" =~ ^[1-9][0-9]*$ \ + && "$CAPTURE_PROCESS_PID" =~ ^[1-9][0-9]*$ ]] \ + && capture_pid_is_self_or_descendant \ + "$CAPTURE_PROCESS_PID" "$CAPTURE_PM2_ENTRY_PID" || { + echo "error: capture PM2 entry/listener process relationship is invalid" >&2 + exit 1 +} +CAPTURE_LIVE_EXEC="$(realpath -e -- "/proc/${CAPTURE_PROCESS_PID}/exe" 2>/dev/null)" \ + || { + echo "error: cannot resolve the live Rust capture executable" >&2 + exit 1 + } +CAPTURE_LIVE_SHA256="$(sha256_of_file "/proc/${CAPTURE_PROCESS_PID}/exe")" || { + echo "error: cannot hash the live Rust capture executable" >&2 + exit 1 +} +capture_live_exec_matches \ + "$CAPTURE_PROCESS_PID" "$CAPTURE_LIVE_EXEC" "$CAPTURE_LIVE_SHA256" || { + echo "error: live Rust capture executable provenance is unstable" >&2 + exit 1 + } +CAPTURE_RESTART_COUNT="${CAPTURE_IDENTITY#*$'\t'}" +[[ "$CAPTURE_RESTART_COUNT" =~ ^[0-9]+$ ]] || { + echo "error: capture PM2 restart count is invalid" >&2 + exit 1 +} +CAPTURE_PM2_METADATA="$( + capture_pm2_entrypoint_identity "$PM2_RUST_WORLD" "$CAPTURE_PM2_ENTRY_PID" +)" || { + echo "error: cannot accredit Rust PM2 entrypoint metadata" >&2 + exit 1 +} +IFS=$'\t' read -r _pm2_pid _pm2_restart \ + CAPTURE_PM2_EXEC_PATH CAPTURE_PM2_EXEC_SHA256 <<<"$CAPTURE_PM2_METADATA" +[ "$_pm2_pid" = "$CAPTURE_PM2_ENTRY_PID" ] \ + && [ "$_pm2_restart" = "$CAPTURE_RESTART_COUNT" ] || { + echo "error: Rust PM2 entry PID/restart metadata is inconsistent" >&2 + exit 1 + } +CAPTURE_PM2_ENTRY_STARTTIME="$(capture_pid_starttime "$CAPTURE_PM2_ENTRY_PID")" \ + || { + echo "error: cannot bind Rust PM2 entry PID to its process start time" >&2 + exit 1 + } +CAPTURE_LISTENER_STARTTIME="$(capture_pid_starttime "$CAPTURE_PROCESS_PID")" \ + || { + echo "error: cannot bind Rust listener PID to its process start time" >&2 + exit 1 + } +CAPTURE_PM2_PROFILE_SHA256="$( + capture_pm2_profile_redacted_sha256 "$PM2_RUST_WORLD" +)" || { + echo "error: cannot hash the stable redacted Rust capture PM2 profile" >&2 + exit 1 +} +CAPTURE_PROCESS_TREE_IDENTITIES="$( + capture_process_tree_identity "$CAPTURE_PM2_ENTRY_PID" +)" || { + echo "error: cannot accredit the Rust capture process tree" >&2 + exit 1 +} +if [ -z "$CAPTURE_EXEC" ]; then + CAPTURE_EXPECTED_EXEC="$CAPTURE_LIVE_EXEC" + CAPTURE_EXPECTED_SHA256="$CAPTURE_LIVE_SHA256" + CAPTURE_SOURCE_EXEC="$CAPTURE_LIVE_EXEC" + CAPTURE_SOURCE_SHA256="$CAPTURE_LIVE_SHA256" +fi echo echo ">>> Perform the '${FLOW}' flow with the client now." @@ -459,7 +1647,7 @@ read -r -p ">>> Press ENTER when the flow is complete to finish the capture... " FINAL_CAPTURE_IDENTITY="" if ! FINAL_CAPTURE_IDENTITY="$(snapshot_process_identity "$CAPTURE_CONFIG_FILE" 2>/dev/null)" \ - || ! rust_world_ports_ready \ + || ! rust_world_ports_ready "$FINAL_CAPTURE_IDENTITY" \ || ! capture_process_exec_matches "$FINAL_CAPTURE_IDENTITY"; then echo "error: ${PM2_RUST_WORLD} changed configuration, executable provenance, or stopped serving during capture" >&2 exit 1 @@ -468,9 +1656,53 @@ if [ "$FINAL_CAPTURE_IDENTITY" != "$CAPTURE_IDENTITY" ]; then echo "error: ${PM2_RUST_WORLD} restarted during capture; refusing a mixed packet dump" >&2 exit 1 fi +capture_live_exec_matches \ + "$CAPTURE_PROCESS_PID" "$CAPTURE_LIVE_EXEC" "$CAPTURE_LIVE_SHA256" || { + echo "error: ${PM2_RUST_WORLD} executable path/bytes changed during capture" >&2 + exit 1 + } +[ "$(capture_pm2_entrypoint_identity \ + "$PM2_RUST_WORLD" "$CAPTURE_PM2_ENTRY_PID")" \ + = "$CAPTURE_PM2_METADATA" ] \ + && [ "$(capture_world_listener_pid)" = "$CAPTURE_PROCESS_PID" ] \ + && capture_pid_is_self_or_descendant \ + "$CAPTURE_PROCESS_PID" "$CAPTURE_PM2_ENTRY_PID" \ + && [ "$(capture_pid_starttime "$CAPTURE_PM2_ENTRY_PID")" \ + = "$CAPTURE_PM2_ENTRY_STARTTIME" ] \ + && [ "$(capture_pid_starttime "$CAPTURE_PROCESS_PID")" \ + = "$CAPTURE_LISTENER_STARTTIME" ] \ + && [ "$(capture_pm2_profile_redacted_sha256 "$PM2_RUST_WORLD")" \ + = "$CAPTURE_PM2_PROFILE_SHA256" ] || { + echo "error: ${PM2_RUST_WORLD} PM2 entry/listener metadata changed during capture" >&2 + exit 1 + } +[ "$(rust_capture_effective_config_sha256)" = "$CAPTURE_EFFECTIVE_CONFIG_SHA256" ] || { + echo "error: effective Rust capture configuration changed during capture" >&2 + exit 1 +} +[ "$(sha256_of_file "$CAPTURE_CONFIG_FILE")" \ + = "$CAPTURE_CONFIG_FILE_SHA256" ] || { + echo "error: PM2 capture snapshot changed during capture" >&2 + exit 1 +} -COUNT=$(find "$DUMP_DIR" -name '*.meta' | wc -l) -echo "collected ${COUNT} packets -> ${DUMP_DIR}" -echo -echo "diff against the C++ golden:" -echo " cargo run -p capture-diff -- diff ${FLOW} --rust ${DUMP_DIR}" +CURRENT_CAPTURE_TREE="$(capture_process_tree_identity "$CAPTURE_PM2_ENTRY_PID")" || { + echo "error: cannot re-accredit the Rust capture process tree" >&2 + exit 1 +} +CAPTURE_PROCESS_TREE_IDENTITIES="$(printf '%s\n%s\n' \ + "$CAPTURE_PROCESS_TREE_IDENTITIES" "$CURRENT_CAPTURE_TREE" \ + | sed '/^$/d' | LC_ALL=C sort -t: -k1,1n -k2,2n -u)" + +rust_capture_flat_tree_is_safe "$DUMP_STAGE_DIR" || { + echo "error: Rust packet dump contains a symlink, subdirectory, special file, or unexpected filename" >&2 + exit 1 +} +COUNT=$(find "$DUMP_STAGE_DIR" -mindepth 1 -maxdepth 1 \ + -type f -name '*.meta' -print | wc -l) +[[ "$COUNT" =~ ^[0-9]+$ ]] || { + echo "error: failed to count staged Rust packets" >&2 + exit 1 +} +CAPTURE_ARTIFACT_READY=1 +echo "${COUNT} packet(s) staged; dump and provenance publish only after guarded cleanup succeeds" diff --git a/crates/capture-diff/scripts/capture-service-common.sh b/crates/capture-diff/scripts/capture-service-common.sh new file mode 100644 index 000000000..90d411437 --- /dev/null +++ b/crates/capture-diff/scripts/capture-service-common.sh @@ -0,0 +1,749 @@ +#!/usr/bin/env bash +# Source-only PM2/PID/listener accreditation shared by capture wrapper tests. +# Callers define distinct CAPTURE_WORLD_PORT and CAPTURE_INSTANCE_PORT. + +capture_pm2_online_pid() { + local process_name="$1" + pm2 jlist | jq -er --arg name "$process_name" ' + [.[] | select(.name == $name)] as $entries + | if ($entries | length) == 1 + and $entries[0].pm2_env.status == "online" + and ($entries[0].pid | type) == "number" + and $entries[0].pid > 0 + then $entries[0].pid + else empty + end + ' +} + +capture_pm2_runtime_metadata() { + local process_name="$1" + local expected_pm2_pid="$2" + pm2 jlist | jq -er --arg name "$process_name" --argjson pid "$expected_pm2_pid" ' + [.[] | select(.name == $name)] as $entries + | if ($entries | length) == 1 + and $entries[0].pm2_env.status == "online" + and $entries[0].pid == $pid + and (($entries[0].pm2_env.restart_time // 0) | type) == "number" + and ($entries[0].pm2_env.restart_time // 0) >= 0 + and ($entries[0].pm2_env.pm_exec_path | type) == "string" + and ($entries[0].pm2_env.pm_exec_path | length) > 0 + then [$entries[0].pid, ($entries[0].pm2_env.restart_time // 0), $entries[0].pm2_env.pm_exec_path] | @tsv + else empty + end + ' +} + +capture_parent_pid() { + local runtime_pid="$1" + local parent_pid + [[ "$runtime_pid" =~ ^[1-9][0-9]*$ ]] || return 1 + parent_pid="$(awk '$1 == "PPid:" { print $2 }' \ + "${CAPTURE_PROC_ROOT:-/proc}/${runtime_pid}/status" 2>/dev/null)" \ + || return 1 + [[ "$parent_pid" =~ ^[0-9]+$ ]] && [ "$parent_pid" != "$runtime_pid" ] \ + || return 1 + printf '%s\n' "$parent_pid" +} + +# PM2's reported PID is the configured entry process. For a direct binary it +# also owns the listeners; for shell wrappers which do not `exec` (the legacy +# C++ profile), the listener is a descendant. Walk the live process tree rather +# than assuming either topology. +capture_pid_is_self_or_descendant() { + local candidate_pid="$1" + local ancestor_pid="$2" + local current_pid="$candidate_pid" + local parent_pid attempt + + [[ "$candidate_pid" =~ ^[1-9][0-9]*$ \ + && "$ancestor_pid" =~ ^[1-9][0-9]*$ ]] || return 1 + for ((attempt = 0; attempt < 64; attempt++)); do + [ "$current_pid" = "$ancestor_pid" ] && return 0 + parent_pid="$(capture_parent_pid "$current_pid")" || return 1 + [ "$parent_pid" != 0 ] || return 1 + current_pid="$parent_pid" + done + return 1 +} + +capture_pm2_process_stopped() { + local process_name="$1" + pm2 jlist | jq -e --arg name "$process_name" ' + [.[] | select(.name == $name)] as $entries + | ($entries | length) == 1 + and $entries[0].pm2_env.status == "stopped" + and (($entries[0].pid // 0) == 0) + ' >/dev/null +} + +capture_world_ports_owned_by_pid() { + local pid="$1" + [[ "$pid" =~ ^[1-9][0-9]*$ ]] || return 1 + capture_port_owned_exclusively_by_pid "$CAPTURE_WORLD_PORT" "$pid" \ + && capture_port_owned_exclusively_by_pid "$CAPTURE_INSTANCE_PORT" "$pid" +} + +capture_unique_listener_pid_for_port() { + local port="$1" + local sockets socket remaining seen_pid matched_pid listener_pid="" + sockets="$(ss -H -ltnp "sport = :${port}" 2>/dev/null)" || return 1 + [ -n "$sockets" ] || return 1 + + while IFS= read -r socket; do + remaining="$socket" + seen_pid=0 + while [[ "$remaining" =~ pid=([0-9]+), ]]; do + matched_pid="${BASH_REMATCH[1]}" + [[ "$matched_pid" =~ ^[1-9][0-9]*$ ]] || return 1 + if [ -z "$listener_pid" ]; then + listener_pid="$matched_pid" + else + [ "$matched_pid" = "$listener_pid" ] || return 1 + fi + seen_pid=1 + remaining="${remaining#*"${BASH_REMATCH[0]}"}" + done + [ "$seen_pid" -eq 1 ] || return 1 + done <<<"$sockets" + [ -n "$listener_pid" ] || return 1 + printf '%s\n' "$listener_pid" +} + +capture_world_listener_pid() { + local world_pid instance_pid + world_pid="$(capture_unique_listener_pid_for_port "$CAPTURE_WORLD_PORT")" \ + || return 1 + instance_pid="$(capture_unique_listener_pid_for_port "$CAPTURE_INSTANCE_PORT")" \ + || return 1 + [ "$world_pid" = "$instance_pid" ] || return 1 + printf '%s\n' "$world_pid" +} + +capture_port_owned_exclusively_by_pid() { + local port="$1" + local pid="$2" + local sockets socket remaining seen_pid matched_pid + sockets="$(ss -H -ltnp "sport = :${port}" 2>/dev/null)" || return 1 + [ -n "$sockets" ] || return 1 + + # SO_REUSEPORT can expose more than one listener for the same local port. + # Every row must therefore identify the accredited PID; finding merely one + # matching row would let an additional process share the capture endpoint. + while IFS= read -r socket; do + remaining="$socket" + seen_pid=0 + while [[ "$remaining" =~ pid=([0-9]+), ]]; do + matched_pid="${BASH_REMATCH[1]}" + [ "$matched_pid" = "$pid" ] || return 1 + seen_pid=1 + remaining="${remaining#*"${BASH_REMATCH[0]}"}" + done + [ "$seen_pid" -eq 1 ] || return 1 + done <<<"$sockets" +} + +capture_sha256_of_file() { + local output digest + output="$(sha256sum <"$1")" || return 1 + digest="${output%% *}" + [[ "$digest" =~ ^[0-9a-f]{64}$ ]] || return 1 + printf '%s\n' "$digest" +} + +# Bind PM2's entry PID to the exact configured entrypoint file. For an +# interpreted wrapper this is the wrapper script from pm_exec_path (not +# /proc//exe, which is the shell interpreter); the listener executable is +# accredited separately through /proc below. +capture_pm2_entrypoint_identity() { + local process_name="$1" + local expected_pm2_pid="$2" + local metadata pm2_pid restart_count configured_path canonical_path digest + + metadata="$(capture_pm2_runtime_metadata "$process_name" "$expected_pm2_pid")" \ + || return 1 + IFS=$'\t' read -r pm2_pid restart_count configured_path <<<"$metadata" + [ "$pm2_pid" = "$expected_pm2_pid" ] \ + && [[ "$restart_count" =~ ^[0-9]+$ ]] || return 1 + canonical_path="$(realpath -e -- "$configured_path" 2>/dev/null)" || return 1 + [ "$canonical_path" = "$configured_path" ] \ + && [ -f "$canonical_path" ] \ + && [ ! -L "$canonical_path" ] || return 1 + digest="$(capture_sha256_of_file "$canonical_path")" || return 1 + printf '%s\t%s\t%s\t%s\n' \ + "$pm2_pid" "$restart_count" "$canonical_path" "$digest" +} + +# Hash the stable PM2 launch profile without serializing environment values. +# PID/status/restart counters are runtime state and intentionally excluded; the +# executable, cwd, interpreter, argv, and environment key names define how PM2 +# will start the process. Secret values can therefore never enter the digest +# preimage emitted by this helper. +capture_pm2_profile_redacted_sha256() { + local process_name="$1" + local output digest + output="$(pm2 jlist | jq -cSe --arg name "$process_name" ' + [.[] | select(.name == $name)] as $entries + | if ($entries | length) != 1 then empty else $entries[0] end + | { + name: .name, + pm_exec_path: .pm2_env.pm_exec_path, + pm_cwd: (.pm2_env.pm_cwd // ""), + exec_interpreter: (.pm2_env.exec_interpreter // ""), + args: (.pm2_env.args // []), + env_keys: ((.pm2_env.env // {}) | keys | sort) + } + ' | sha256sum)" || return 1 + digest="${output%% *}" + [[ "$digest" =~ ^[0-9a-f]{64}$ ]] || return 1 + printf '%s\n' "$digest" +} + +# Resolve the config actually selected by PM2. Prefer an exact -c/--config +# argument. For the legacy shell entrypoint, accept exactly one absolute +# literal config argument in the wrapper body; variable expansion or multiple +# candidates are ambiguous and fail closed. +_capture_pm2_effective_config_path_untraced() { + local process_name="$1" + local entry_json configured_path configured_cwd configured_exec canonical + + entry_json="$(pm2 jlist | jq -cSe --arg name "$process_name" ' + [.[] | select(.name == $name)] as $entries + | if ($entries | length) == 1 then $entries[0] else empty end + ')" || return 1 + configured_exec="$(jq -er '.pm2_env.pm_exec_path | select(type == "string" and length > 0)' <<<"$entry_json")" \ + || return 1 + configured_cwd="$(jq -er '.pm2_env.pm_cwd // "" | select(type == "string")' <<<"$entry_json")" \ + || return 1 + configured_path="$(jq -er ' + (.pm2_env.args // []) as $args + | if ($args | type) != "array" + or any($args[]; type != "string") + then error("PM2 args must be an array of strings") + else + ([range(0; $args | length) as $i + | if ($args[$i] == "-c" or $args[$i] == "--config") + then if ($i + 1) < ($args | length) + then $args[$i + 1] + else error("config switch has no value") + end + elif ($args[$i] | startswith("--config=")) + then ($args[$i] | sub("^--config="; "")) + else empty end] + | if length == 0 then "__CAPTURE_NO_CONFIG_ARG__" + elif length == 1 and (.[0] | length) > 0 then .[0] + else error("PM2 config selection is empty or ambiguous") end) + end + ' <<<"$entry_json" 2>/dev/null)" || return 1 + + if [ "$configured_path" != "__CAPTURE_NO_CONFIG_ARG__" ]; then + if [[ "$configured_path" != /* ]]; then + [ -n "$configured_cwd" ] || return 1 + configured_path="$configured_cwd/$configured_path" + fi + else + canonical="$(realpath -e -- "$configured_exec" 2>/dev/null)" || return 1 + [ "$canonical" = "$configured_exec" ] \ + && [ -f "$canonical" ] && [ ! -L "$canonical" ] || return 1 + configured_path="$(awk ' + /^[[:space:]]*#/ { next } + { + for (i = 1; i <= NF; i++) { + if ($i == "-c" || $i == "--config") { + if (i == NF) exit 2 + candidate = $(i + 1) + gsub(/^["\047]|["\047]$/, "", candidate) + found[++count] = candidate + } else if ($i ~ /^--config=/) { + candidate = $i + sub(/^--config=/, "", candidate) + gsub(/^["\047]|["\047]$/, "", candidate) + found[++count] = candidate + } + } + } + END { + if (count != 1 || found[1] !~ /^\/[A-Za-z0-9._\/-]+$/) exit 3 + print found[1] + } + ' "$canonical")" || return 1 + fi + + canonical="$(realpath -e -- "$configured_path" 2>/dev/null)" || return 1 + [ "$canonical" = "$configured_path" ] \ + && [ -f "$canonical" ] && [ ! -L "$canonical" ] || return 1 + printf '%s\n' "$canonical" +} + +capture_pm2_effective_config_path() { + local restore_xtrace=0 result status + if [[ "$-" == *x* ]]; then + restore_xtrace=1 + set +x + fi + result="$(_capture_pm2_effective_config_path_untraced "$@")" + status=$? + if [ "$restore_xtrace" -eq 1 ]; then + set -x + fi + [ "$status" -eq 0 ] || return "$status" + printf '%s\n' "$result" +} + +capture_git_repo_clean_at_head() { + local repository="$1" + local expected_head="$2" + local current_head status + + current_head="$(git -C "$repository" rev-parse HEAD 2>/dev/null)" || return 1 + [ "$current_head" = "$expected_head" ] || return 1 + status="$(git -C "$repository" status --porcelain=v1 \ + --untracked-files=all --ignore-submodules=none 2>/dev/null)" || return 1 + [ -z "$status" ] +} + +capture_git_repo_is_dirty() { + local repository="$1" + local status + status="$(git -C "$repository" status --porcelain=v1 \ + --untracked-files=all --ignore-submodules=none 2>/dev/null)" || return 1 + [ -n "$status" ] +} + +# Fingerprint the complete non-ignored worktree state without putting file +# contents into a manifest. The canonical stream contains HEAD plus each +# changed/untracked path, file type/mode, and a SHA-256 of its current bytes. +# Ignored runtime configs/credentials are deliberately outside Git's reported +# state. Special files and submodules fail closed rather than receiving an +# ambiguous identity. +capture_git_worktree_state_sha256() { + local repository="$1" + local head output digest path absolute kind mode content_sha + + head="$(git -C "$repository" rev-parse HEAD 2>/dev/null)" || return 1 + output="$({ + printf 'HEAD\0%s\0' "$head" + { + git -C "$repository" diff --name-only -z HEAD -- || exit 1 + git -C "$repository" ls-files --others --exclude-standard -z || exit 1 + } | LC_ALL=C sort -zu | while IFS= read -r -d '' path; do + absolute="$repository/$path" + if [ -L "$absolute" ]; then + kind=L + mode="$(stat -c '%a' -- "$absolute")" || exit 1 + content_sha="$(readlink -- "$absolute" | sha256sum)" || exit 1 + content_sha="${content_sha%% *}" + elif [ -f "$absolute" ]; then + kind=F + mode="$(stat -c '%a' -- "$absolute")" || exit 1 + content_sha="$(capture_sha256_of_file "$absolute")" || exit 1 + elif [ ! -e "$absolute" ]; then + kind=D + mode=- + content_sha=- + else + exit 1 + fi + printf '%s\0%s\0%s\0%s\0' "$kind" "$path" "$mode" "$content_sha" + done + } | sha256sum)" || return 1 + digest="${output%% *}" + [[ "$digest" =~ ^[0-9a-f]{64}$ ]] || return 1 + printf '%s\n' "$digest" +} + +# Hash only an explicit ordered set of capture-relevant effective config keys. +# The last assignment wins, matching the server config parser. Values for any +# credential-like key are replaced with the literal ; missing +# keys become . `extra_canonical` contains only caller-owned non-secret +# facts such as listener ports and whether packet dumping is enabled. The +# canonical plaintext is piped directly into sha256sum and is never printed or +# stored, so the digest cannot be used to dictionary-attack DB credentials. +capture_effective_config_redacted_sha256() { + local config_file="$1" + local extra_canonical="$2" + shift 2 + local wanted="$*" + local output digest + + [ -f "$config_file" ] && [ ! -L "$config_file" ] || return 1 + output="$({ + awk -v wanted="$wanted" ' + function trim(value) { + sub(/^[[:space:]]+/, "", value) + sub(/[[:space:]]+$/, "", value) + return value + } + /^[[:space:]]*[#;]/ { next } + { + separator = index($0, "=") + if (separator == 0) next + key = trim(substr($0, 1, separator - 1)) + value = trim(substr($0, separator + 1)) + values[key] = value + } + END { + count = split(wanted, keys, " ") + for (i = 1; i <= count; i++) { + key = keys[i] + if (key == "") continue + if (!(key in values)) value = "" + else if (key ~ /(DatabaseInfo|Password|Secret|Token|PrivateKey|SessionKey)/) + value = "" + else value = values[key] + printf "%s=%s\n", key, value + } + } + ' "$config_file" || exit 1 + printf '%s\n' "$extra_canonical" + } | sha256sum)" || return 1 + digest="${output%% *}" + [[ "$digest" =~ ^[0-9a-f]{64}$ ]] || return 1 + printf '%s\n' "$digest" +} + +capture_exec_source_matches() { + local expected_exec="$1" + local expected_sha="$2" + local canonical digest + + canonical="$(realpath -e -- "$expected_exec" 2>/dev/null)" || return 1 + [ "$canonical" = "$expected_exec" ] || return 1 + [ -f "$expected_exec" ] && [ -x "$expected_exec" ] \ + && [ ! -L "$expected_exec" ] || return 1 + digest="$(capture_sha256_of_file "$expected_exec")" || return 1 + [ "$digest" = "$expected_sha" ] +} + +capture_live_exec_matches() { + local pid="$1" + local expected_exec="$2" + local expected_sha="$3" + local proc_exe live_exec source_sha live_sha + + [[ "$pid" =~ ^[1-9][0-9]*$ ]] || return 1 + proc_exe="/proc/${pid}/exe" + [ -L "$proc_exe" ] || return 1 + live_exec="$(realpath -e -- "$proc_exe" 2>/dev/null)" || return 1 + [ "$live_exec" = "$expected_exec" ] || return 1 + source_sha="$(capture_sha256_of_file "$expected_exec")" || return 1 + live_sha="$(capture_sha256_of_file "$proc_exe")" || return 1 + [ "$source_sha" = "$expected_sha" ] && [ "$live_sha" = "$expected_sha" ] +} + +capture_acquire_orchestration_lock() { + local lock_dir="$1" + local parent canonical_parent canonical_dir owner mode before after fd_target + local path_after fd_target_after + + [[ "$lock_dir" = /* && "$lock_dir" != *$'\n'* ]] || return 1 + parent="$(dirname -- "$lock_dir")" + canonical_parent="$(realpath -e -- "$parent" 2>/dev/null)" || return 1 + [ "$canonical_parent" = "$parent" ] || return 1 + if [ ! -e "$lock_dir" ] && [ ! -L "$lock_dir" ]; then + mkdir -m 700 -- "$lock_dir" || return 1 + fi + [ -d "$lock_dir" ] && [ ! -L "$lock_dir" ] || return 1 + canonical_dir="$(realpath -e -- "$lock_dir" 2>/dev/null)" || return 1 + [ "$canonical_dir" = "$lock_dir" ] || return 1 + owner="$(stat -c '%u' -- "$lock_dir")" || return 1 + mode="$(stat -c '%a' -- "$lock_dir")" || return 1 + [ "$owner" = "$(id -u)" ] && [ "$mode" = 700 ] || return 1 + before="$(stat -c '%d:%i' -- "$lock_dir")" || return 1 + exec {CAPTURE_ORCHESTRATION_LOCK_FD}<"$lock_dir" || return 1 + fd_target="$(realpath -e -- "/proc/${BASHPID:-$$}/fd/${CAPTURE_ORCHESTRATION_LOCK_FD}" 2>/dev/null)" \ + || return 1 + after="$(stat -Lc '%d:%i' -- "/proc/${BASHPID:-$$}/fd/${CAPTURE_ORCHESTRATION_LOCK_FD}")" \ + || return 1 + [ "$fd_target" = "$lock_dir" ] && [ "$before" = "$after" ] || { + exec {CAPTURE_ORCHESTRATION_LOCK_FD}>&- + CAPTURE_ORCHESTRATION_LOCK_FD="" + return 1 + } + flock -n "$CAPTURE_ORCHESTRATION_LOCK_FD" || { + exec {CAPTURE_ORCHESTRATION_LOCK_FD}>&- + CAPTURE_ORCHESTRATION_LOCK_FD="" + return 1 + } + path_after="$(stat -c '%d:%i' -- "$lock_dir" 2>/dev/null)" || path_after="" + fd_target_after="$(realpath -e -- "/proc/${BASHPID:-$$}/fd/${CAPTURE_ORCHESTRATION_LOCK_FD}" 2>/dev/null)" \ + || fd_target_after="" + [ "$path_after" = "$before" ] && [ "$fd_target_after" = "$lock_dir" ] || { + flock -u "$CAPTURE_ORCHESTRATION_LOCK_FD" 2>/dev/null || true + exec {CAPTURE_ORCHESTRATION_LOCK_FD}>&- + CAPTURE_ORCHESTRATION_LOCK_FD="" + return 1 + } +} + +capture_require_canonical_directory() { + local directory="$1" + local canonical + [[ "$directory" = /* && "$directory" != *$'\n'* ]] || return 1 + mkdir -p -- "$directory" || return 1 + [ -d "$directory" ] && [ ! -L "$directory" ] || return 1 + canonical="$(realpath -e -- "$directory" 2>/dev/null)" || return 1 + [ "$canonical" = "$directory" ] +} + +capture_publish_noreplace() { + local source="$1" + local target="$2" + local source_identity target_identity + [ -e "$source" ] && [ ! -L "$source" ] || return 1 + [ ! -e "$target" ] && [ ! -L "$target" ] || return 1 + source_identity="$(stat -c '%d:%i' -- "$source")" || return 1 + mv --no-clobber --no-target-directory -- "$source" "$target" || return 1 + [ ! -e "$source" ] && [ ! -L "$source" ] || return 1 + [ -e "$target" ] && [ ! -L "$target" ] || return 1 + target_identity="$(stat -c '%d:%i' -- "$target")" || return 1 + [ "$target_identity" = "$source_identity" ] +} + +capture_loot_item_bot_evidence() { + local report_path="$1" + local bot_exec="$2" + local expected_bot_sha="$3" + local canonical_report canonical_exec report_sha bot_sha account_id + + [[ "$report_path" = /* && "$bot_exec" = /* \ + && "$expected_bot_sha" =~ ^[0-9a-f]{64}$ ]] || return 1 + canonical_report="$(realpath -e -- "$report_path" 2>/dev/null)" || return 1 + canonical_exec="$(realpath -e -- "$bot_exec" 2>/dev/null)" || return 1 + [ "$canonical_report" = "$report_path" ] \ + && [ -f "$report_path" ] && [ ! -L "$report_path" ] \ + && [ "$canonical_exec" = "$bot_exec" ] \ + && [ -f "$bot_exec" ] && [ -x "$bot_exec" ] && [ ! -L "$bot_exec" ] \ + || return 1 + bot_sha="$(capture_sha256_of_file "$bot_exec")" || return 1 + [ "$bot_sha" = "$expected_bot_sha" ] || return 1 + account_id="$(jq -er ' + if .loot_item_capture != true + or .loot_race_smoke != false + or (.results | type) != "array" + or (.results | length) != 1 + then empty + else .results[0] + | select( + .account == "TESTBOT2@bot.local" + and .account_id == 9 + and .character_guid == 15 + and .world_auth == true + and .enum_characters == true + and .player_login_verified == true + and .loot_race_smoke == true + and .loot_race_smoke_passed == true + and .loot_race_target_entry == 21779 + and .loot_race_target_spawn_guid == 1117 + and .loot_race_target_discovered == true + and .loot_race_loot_opened == true + and .loot_race_item_push_seen == true + and .loot_race_loot_removed_seen == true + and .loot_race_loot_coins == 0 + and .loot_race_coin_removed_seen == false + and .loot_race_db_item_total == 1 + and .loot_race_db_money_delta == 0 + and .loot_race_relog_verified == true + and .loot_race_failure == null) + | .account_id + end + ' "$report_path")" || return 1 + [ "$account_id" = 9 ] || return 1 + report_sha="$(capture_sha256_of_file "$report_path")" || return 1 + printf '%s\t%s\t%s\t%s\n' \ + "$canonical_exec" "$bot_sha" "$canonical_report" "$report_sha" +} + +capture_release_orchestration_lock() { + if [[ "${CAPTURE_ORCHESTRATION_LOCK_FD:-}" =~ ^[0-9]+$ ]]; then + flock -u "$CAPTURE_ORCHESTRATION_LOCK_FD" 2>/dev/null || true + exec {CAPTURE_ORCHESTRATION_LOCK_FD}>&- || true + fi + CAPTURE_ORCHESTRATION_LOCK_FD="" +} + +capture_world_ports_absent() { + local sockets + sockets="$(ss -H -ltn)" || return 1 + ! rg -q ":(${CAPTURE_WORLD_PORT}|${CAPTURE_INSTANCE_PORT})\\b" <<<"$sockets" +} + +capture_pid_starttime() { + local pid="$1" + local stat_line remainder + local -a fields + [[ "$pid" =~ ^[1-9][0-9]*$ ]] || return 1 + stat_line="$(<"${CAPTURE_PROC_ROOT:-/proc}/${pid}/stat")" || return 1 + [[ "$stat_line" == *") "* ]] || return 1 + remainder="${stat_line##*) }" + read -r -a fields <<<"$remainder" + # remainder starts at proc field 3; array index 19 is field 22/starttime. + [ "${#fields[@]}" -ge 20 ] || return 1 + [[ "${fields[19]}" =~ ^[1-9][0-9]*$ ]] || return 1 + printf '%s\n' "${fields[19]}" +} + +capture_pid_identity() { + local pid="$1" + local starttime + starttime="$(capture_pid_starttime "$pid")" || return 1 + printf '%s:%s\n' "$pid" "$starttime" +} + +capture_pid_identity_is_live() { + local identity="$1" + local pid expected actual + IFS=: read -r pid expected <<<"$identity" + [[ "$pid" =~ ^[1-9][0-9]*$ && "$expected" =~ ^[1-9][0-9]*$ ]] || return 1 + actual="$(capture_pid_starttime "$pid" 2>/dev/null)" || return 1 + [ "$actual" = "$expected" ] && kill -0 -- "$pid" 2>/dev/null +} + +capture_pid_identity_absent() { + ! capture_pid_identity_is_live "$1" +} + +capture_process_tree_identity() { + local root_pid="$1" + local proc_dir pid parent identity + local -a descendants=("$root_pid") + local changed=1 candidate known + [[ "$root_pid" =~ ^[1-9][0-9]*$ ]] || return 1 + + while ((changed)); do + changed=0 + for proc_dir in "${CAPTURE_PROC_ROOT:-/proc}"/[0-9]*; do + [ -d "$proc_dir" ] || continue + pid="${proc_dir##*/}" + parent="$(capture_parent_pid "$pid" 2>/dev/null)" || continue + candidate=0 + for known in "${descendants[@]}"; do + if [ "$parent" = "$known" ]; then + candidate=1 + break + fi + done + [ "$candidate" -eq 1 ] || continue + candidate=1 + for known in "${descendants[@]}"; do + if [ "$pid" = "$known" ]; then + candidate=0 + break + fi + done + if [ "$candidate" -eq 1 ]; then + descendants+=("$pid") + changed=1 + fi + done + done + + for pid in "${descendants[@]}"; do + identity="$(capture_pid_identity "$pid")" || return 1 + printf '%s\n' "$identity" + done | sort -t: -k1,1n +} + +capture_process_tree_absent() { + local identities="$1" + local identity + while IFS= read -r identity; do + [ -z "$identity" ] && continue + capture_pid_identity_absent "$identity" || return 1 + done <<<"$identities" +} + +capture_terminate_process_tree() { + local identities="$1" + local signal identity pid attempt + for signal in TERM KILL; do + while IFS= read -r identity; do + [ -z "$identity" ] && continue + if capture_pid_identity_is_live "$identity"; then + pid="${identity%%:*}" + kill -s "$signal" -- "$pid" 2>/dev/null || true + fi + done < <(sort -t: -k1,1nr <<<"$identities") + for ((attempt = 0; attempt < 20; attempt++)); do + capture_process_tree_absent "$identities" && return 0 + sleep 0.1 + done + done + capture_process_tree_absent "$identities" +} + +capture_saved_pid_absent() { + local pid="$1" + [ -z "$pid" ] || ! kill -0 -- "$pid" 2>/dev/null +} + +capture_saved_identity_absent() { + local identity="$1" + local pm2_pid listener_pid + if [[ "$identity" == *:* ]]; then + capture_process_tree_absent "$identity" + return + fi + if [[ "$identity" == *$'\t'* ]]; then + IFS=$'\t' read -r pm2_pid listener_pid <<<"$identity" + else + pm2_pid="$identity" + listener_pid="$identity" + fi + capture_saved_pid_absent "$pm2_pid" \ + && { [ "$listener_pid" = "$pm2_pid" ] \ + || capture_saved_pid_absent "$listener_pid"; } +} + +capture_world_stopped_once() { + local process_name="$1" + local saved_identity="$2" + capture_saved_identity_absent "$saved_identity" \ + && capture_pm2_process_stopped "$process_name" \ + && capture_world_ports_absent +} + +capture_world_ready_once() { + local process_name="$1" + local pm2_pid listener_pid + pm2_pid="$(capture_pm2_online_pid "$process_name")" || return 1 + listener_pid="$(capture_world_listener_pid)" || return 1 + capture_pid_is_self_or_descendant "$listener_pid" "$pm2_pid" || return 1 + capture_world_ports_owned_by_pid "$listener_pid" || return 1 + printf '%s\t%s\n' "$pm2_pid" "$listener_pid" +} + +capture_wait_for_world_stopped() { + local process_name="$1" + local saved_pid="$2" + local attempt + for ((attempt = 0; attempt < 40; attempt++)); do + capture_world_stopped_once "$process_name" "$saved_pid" && return 0 + sleep 0.5 + done + return 1 +} + +capture_wait_for_world_ready() { + local process_name="$1" + local attempt identity="" last_identity="" stable_samples=0 + for ((attempt = 0; attempt < 40; attempt++)); do + if identity="$(capture_world_ready_once "$process_name")"; then + if [ "$identity" = "$last_identity" ]; then + stable_samples=$((stable_samples + 1)) + else + last_identity="$identity" + stable_samples=1 + fi + if [ "$stable_samples" -ge 4 ]; then + printf '%s\n' "$identity" + return 0 + fi + else + last_identity="" + stable_samples=0 + fi + sleep 0.5 + done + return 1 +} diff --git a/crates/capture-diff/scripts/loot-fixture-common.sh b/crates/capture-diff/scripts/loot-fixture-common.sh new file mode 100644 index 000000000..6bc72d706 --- /dev/null +++ b/crates/capture-diff/scripts/loot-fixture-common.sh @@ -0,0 +1,295 @@ +#!/usr/bin/env bash +# Shared, source-only guard for the deterministic loot capture fixtures. +# +# Callers own service lifecycle and define these globals before using it: +# LOOT_FIXTURE_DB_CONF +# WOW_BOT_FIXTURE_JOURNAL +# LOOT_FIXTURE_CLEANUP_MARKER +# LOOT_FIXTURE_ENTRY +# LOOT_FIXTURE_EXPECTED_HEALTH_MODIFIER +# LOOT_FIXTURE_TEMP_HEALTH_MODIFIER +# LOOT_FIXTURE_SNAPSHOT_READY + +validate_fresh_loot_fixture_journal() { + local journal_parent canonical_parent owner mode + [ -n "$WOW_BOT_FIXTURE_JOURNAL" ] || { + echo "error: guarded loot capture requires WOW_BOT_FIXTURE_JOURNAL" >&2 + return 1 + } + [[ "$WOW_BOT_FIXTURE_JOURNAL" = /* \ + && "$WOW_BOT_FIXTURE_JOURNAL" != *$'\n'* ]] || { + echo "error: WOW_BOT_FIXTURE_JOURNAL must be an absolute single-line path" >&2 + return 1 + } + journal_parent="$(dirname -- "$WOW_BOT_FIXTURE_JOURNAL")" + [ -d "$journal_parent" ] && [ ! -L "$journal_parent" ] || { + echo "error: WOW_BOT_FIXTURE_JOURNAL parent directory does not exist" >&2 + return 1 + } + canonical_parent="$(realpath -e -- "$journal_parent" 2>/dev/null)" \ + || return 1 + owner="$(stat -c '%u' -- "$journal_parent" 2>/dev/null)" || return 1 + mode="$(stat -c '%a' -- "$journal_parent" 2>/dev/null)" || return 1 + [ "$canonical_parent" = "$journal_parent" ] \ + && [ "$owner" = "$(id -u)" ] && [ "$mode" = 700 ] || { + echo "error: WOW_BOT_FIXTURE_JOURNAL parent must be canonical, owned by this uid, mode 0700, and non-symlink" >&2 + return 1 + } + LOOT_FIXTURE_CLEANUP_MARKER="${WOW_BOT_FIXTURE_JOURNAL}.cleanup-complete" + [ ! -e "$WOW_BOT_FIXTURE_JOURNAL" ] \ + && [ ! -L "$WOW_BOT_FIXTURE_JOURNAL" ] \ + && [ ! -e "$LOOT_FIXTURE_CLEANUP_MARKER" ] \ + && [ ! -L "$LOOT_FIXTURE_CLEANUP_MARKER" ] || { + echo "error: fixture journal/cleanup marker already exists; recover or remove it explicitly before capture" >&2 + return 1 + } +} + +read_database_info() { + local key="$1" + local value + value="$({ + awk -F '"' -v key="$key" ' + $0 ~ "^[[:space:]]*" key "[[:space:]]*=" { print $2; exit } + ' "$LOOT_FIXTURE_DB_CONF" + })" + [ -n "$value" ] || return 1 + printf '%s\n' "$value" +} + +_load_loot_fixture_database_credentials_untraced() { + local world_info character_info extra + [ -f "$LOOT_FIXTURE_DB_CONF" ] || { + echo "error: loot fixture DB conf not found: ${LOOT_FIXTURE_DB_CONF}" >&2 + return 1 + } + world_info="$(read_database_info WorldDatabaseInfo)" || { + echo "error: WorldDatabaseInfo not found in ${LOOT_FIXTURE_DB_CONF}" >&2 + return 1 + } + character_info="$(read_database_info CharacterDatabaseInfo)" || { + echo "error: CharacterDatabaseInfo not found in ${LOOT_FIXTURE_DB_CONF}" >&2 + return 1 + } + IFS=';' read -r \ + LOOT_FIXTURE_WORLD_HOST \ + LOOT_FIXTURE_WORLD_PORT \ + LOOT_FIXTURE_WORLD_USER \ + LOOT_FIXTURE_WORLD_PASSWORD \ + LOOT_FIXTURE_WORLD_DATABASE \ + extra <<<"$world_info" + [ -z "${extra:-}" ] || { + echo "error: WorldDatabaseInfo has unsupported extra fields" >&2 + return 1 + } + IFS=';' read -r \ + LOOT_FIXTURE_CHARACTER_HOST \ + LOOT_FIXTURE_CHARACTER_PORT \ + LOOT_FIXTURE_CHARACTER_USER \ + LOOT_FIXTURE_CHARACTER_PASSWORD \ + LOOT_FIXTURE_CHARACTER_DATABASE \ + extra <<<"$character_info" + [ -z "${extra:-}" ] || { + echo "error: CharacterDatabaseInfo has unsupported extra fields" >&2 + return 1 + } + [ -n "$LOOT_FIXTURE_WORLD_HOST" ] \ + && [[ "$LOOT_FIXTURE_WORLD_PORT" =~ ^[1-9][0-9]*$ ]] \ + && [ -n "$LOOT_FIXTURE_WORLD_USER" ] \ + && [ -n "$LOOT_FIXTURE_WORLD_DATABASE" ] \ + && [ -n "$LOOT_FIXTURE_CHARACTER_HOST" ] \ + && [[ "$LOOT_FIXTURE_CHARACTER_PORT" =~ ^[1-9][0-9]*$ ]] \ + && [ -n "$LOOT_FIXTURE_CHARACTER_USER" ] \ + && [ -n "$LOOT_FIXTURE_CHARACTER_DATABASE" ] || { + echo "error: loot fixture DatabaseInfo is incomplete" >&2 + return 1 + } +} + +load_loot_fixture_database_credentials() { + local restore_xtrace=0 status + if [[ "$-" == *x* ]]; then + restore_xtrace=1 + set +x + fi + if _load_loot_fixture_database_credentials_untraced; then + status=0 + else + status=$? + fi + if [ "$restore_xtrace" -eq 1 ]; then + set -x + fi + return "$status" +} + +loot_fixture_world_mysql() { + local restore_xtrace=0 status + if [[ "$-" == *x* ]]; then + restore_xtrace=1 + set +x + fi + if MYSQL_PWD="$LOOT_FIXTURE_WORLD_PASSWORD" mysql \ + --protocol=TCP \ + -h "$LOOT_FIXTURE_WORLD_HOST" \ + -P "$LOOT_FIXTURE_WORLD_PORT" \ + -u "$LOOT_FIXTURE_WORLD_USER" \ + --batch --raw --skip-column-names \ + "$LOOT_FIXTURE_WORLD_DATABASE" "$@"; then + status=0 + else + status=$? + fi + if [ "$restore_xtrace" -eq 1 ]; then + set -x + fi + return "$status" +} + +loot_fixture_character_mysql() { + local restore_xtrace=0 status + if [[ "$-" == *x* ]]; then + restore_xtrace=1 + set +x + fi + if MYSQL_PWD="$LOOT_FIXTURE_CHARACTER_PASSWORD" mysql \ + --protocol=TCP \ + -h "$LOOT_FIXTURE_CHARACTER_HOST" \ + -P "$LOOT_FIXTURE_CHARACTER_PORT" \ + -u "$LOOT_FIXTURE_CHARACTER_USER" \ + --batch --raw --skip-column-names \ + "$LOOT_FIXTURE_CHARACTER_DATABASE" "$@"; then + status=0 + else + status=$? + fi + if [ "$restore_xtrace" -eq 1 ]; then + set -x + fi + return "$status" +} + +loot_fixture_wait_until_all_characters_offline() { + local attempt online="" + for ((attempt = 0; attempt < 300; attempt++)); do + online="$(loot_fixture_character_mysql \ + -e 'SELECT COUNT(*) FROM characters WHERE online <> 0')" || return 1 + [ "$online" = "0" ] && return 0 + sleep 0.1 + done + echo "error: refusing loot fixture mutation while ${online:-unknown} character(s) remain online" >&2 + return 1 +} + +apply_creature_health_fixture_guard() { + local matching updated + matching="$(loot_fixture_world_mysql -e \ + "SELECT COUNT(*) FROM creature_template_difficulty + WHERE Entry = ${LOOT_FIXTURE_ENTRY} + AND DifficultyID = 0 + AND ABS(HealthModifier - ${LOOT_FIXTURE_EXPECTED_HEALTH_MODIFIER}) < 0.0000001")" \ + || return 1 + [ "$matching" = "1" ] || { + echo "error: loot fixture ${LOOT_FIXTURE_ENTRY} is missing or its original HealthModifier is not ${LOOT_FIXTURE_EXPECTED_HEALTH_MODIFIER}" >&2 + return 1 + } + + # Arm cleanup before the CAS. If the caller exits after the UPDATE but before + # verification, restoration still inspects the exact temporary value. + LOOT_FIXTURE_SNAPSHOT_READY=1 + updated="$(loot_fixture_world_mysql -e \ + "UPDATE creature_template_difficulty + SET HealthModifier = ${LOOT_FIXTURE_TEMP_HEALTH_MODIFIER} + WHERE Entry = ${LOOT_FIXTURE_ENTRY} + AND DifficultyID = 0 + AND ABS(HealthModifier - ${LOOT_FIXTURE_EXPECTED_HEALTH_MODIFIER}) < 0.0000001; + SELECT ROW_COUNT();")" || return 1 + [ "$updated" = "1" ] || { + echo "error: loot fixture ${LOOT_FIXTURE_ENTRY} changed during activation (ROW_COUNT=${updated:-unknown})" >&2 + return 1 + } + matching="$(loot_fixture_world_mysql -e \ + "SELECT COUNT(*) FROM creature_template_difficulty + WHERE Entry = ${LOOT_FIXTURE_ENTRY} + AND DifficultyID = 0 + AND ABS(HealthModifier - ${LOOT_FIXTURE_TEMP_HEALTH_MODIFIER}) < 0.0000001")" \ + || return 1 + [ "$matching" = "1" ] || { + echo "error: failed to activate loot fixture ${LOOT_FIXTURE_ENTRY}" >&2 + return 1 + } + echo "loot fixture: entry ${LOOT_FIXTURE_ENTRY} HealthModifier ${LOOT_FIXTURE_EXPECTED_HEALTH_MODIFIER} -> ${LOOT_FIXTURE_TEMP_HEALTH_MODIFIER} (restore armed)" +} + +restore_creature_health_fixture_guard() { + [ "$LOOT_FIXTURE_SNAPSHOT_READY" -eq 1 ] || return 0 + + local original_matches temporary_matches updated + original_matches="$(loot_fixture_world_mysql -e \ + "SELECT COUNT(*) FROM creature_template_difficulty + WHERE Entry = ${LOOT_FIXTURE_ENTRY} + AND DifficultyID = 0 + AND ABS(HealthModifier - ${LOOT_FIXTURE_EXPECTED_HEALTH_MODIFIER}) < 0.0000001")" \ + || return 1 + if [ "$original_matches" = "1" ]; then + LOOT_FIXTURE_SNAPSHOT_READY=0 + return 0 + fi + temporary_matches="$(loot_fixture_world_mysql -e \ + "SELECT COUNT(*) FROM creature_template_difficulty + WHERE Entry = ${LOOT_FIXTURE_ENTRY} + AND DifficultyID = 0 + AND ABS(HealthModifier - ${LOOT_FIXTURE_TEMP_HEALTH_MODIFIER}) < 0.0000001")" \ + || return 1 + [ "$temporary_matches" = "1" ] || { + echo "WARNING: loot fixture ${LOOT_FIXTURE_ENTRY} changed externally; refusing to overwrite it" >&2 + return 1 + } + updated="$(loot_fixture_world_mysql -e \ + "UPDATE creature_template_difficulty + SET HealthModifier = ${LOOT_FIXTURE_EXPECTED_HEALTH_MODIFIER} + WHERE Entry = ${LOOT_FIXTURE_ENTRY} + AND DifficultyID = 0 + AND ABS(HealthModifier - ${LOOT_FIXTURE_TEMP_HEALTH_MODIFIER}) < 0.0000001; + SELECT ROW_COUNT();")" || return 1 + [ "$updated" = "1" ] || { + echo "WARNING: loot fixture ${LOOT_FIXTURE_ENTRY} changed during restoration (ROW_COUNT=${updated:-unknown})" >&2 + return 1 + } + original_matches="$(loot_fixture_world_mysql -e \ + "SELECT COUNT(*) FROM creature_template_difficulty + WHERE Entry = ${LOOT_FIXTURE_ENTRY} + AND DifficultyID = 0 + AND ABS(HealthModifier - ${LOOT_FIXTURE_EXPECTED_HEALTH_MODIFIER}) < 0.0000001")" \ + || return 1 + [ "$original_matches" = "1" ] || { + echo "WARNING: failed to verify restoration of loot fixture ${LOOT_FIXTURE_ENTRY}" >&2 + return 1 + } + LOOT_FIXTURE_SNAPSHOT_READY=0 + echo "loot fixture: restored entry ${LOOT_FIXTURE_ENTRY} HealthModifier ${LOOT_FIXTURE_EXPECTED_HEALTH_MODIFIER}" +} + +loot_fixture_bot_cleanup_complete() { + [ "${LOOT_FIXTURE_GUARD_ENABLED:-0}" = "1" ] || return 0 + + if [ -e "$WOW_BOT_FIXTURE_JOURNAL" ] \ + || [ -L "$WOW_BOT_FIXTURE_JOURNAL" ]; then + echo "WARNING: bot fixture recovery journal is still pending at ${WOW_BOT_FIXTURE_JOURNAL}; refusing to start the normal PM2 world" >&2 + return 1 + fi + if [ ! -f "$LOOT_FIXTURE_CLEANUP_MARKER" ] \ + || [ -L "$LOOT_FIXTURE_CLEANUP_MARKER" ]; then + echo "WARNING: bot cleanup-complete marker is missing or unsafe at ${LOOT_FIXTURE_CLEANUP_MARKER}; refusing to start the normal PM2 world" >&2 + return 1 + fi + if [ "$(stat -c '%a' -- "$LOOT_FIXTURE_CLEANUP_MARKER" 2>/dev/null)" != "600" ] \ + || ! jq -e ' + .version == 1 + and (.cleanup_pid | type == "number" and . > 0) + and (.journal_sha256 | type == "string" and test("^[0-9a-f]{64}$")) + ' "$LOOT_FIXTURE_CLEANUP_MARKER" >/dev/null 2>&1; then + echo "WARNING: bot cleanup-complete marker failed its mode-0600/schema contract; refusing to start the normal PM2 world" >&2 + return 1 + fi +} diff --git a/crates/capture-diff/src/flow.rs b/crates/capture-diff/src/flow.rs index b3b972264..010a46803 100644 --- a/crates/capture-diff/src/flow.rs +++ b/crates/capture-diff/src/flow.rs @@ -9,6 +9,9 @@ //! flows//rust/ # reference Rust dump (.bin/.meta) //! flows//expected-divergences.json # accepted-divergence baseline //! flows//flow.json # optional: description + directions +//! flows//requirement.json # optional: fail-closed capture contract +//! flows//capture-lineage.json # exact RAW-to-derived hash contract +//! flows//capture-provenance/ # retained exact RAW manifests //! ``` use std::path::{Path, PathBuf}; @@ -16,7 +19,8 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; use serde::Deserialize; -use crate::model::Direction; +use crate::model::{Capture, CapturedPacket, Direction, PacketBoundary}; +use crate::semantic; /// Root of the committed flow fixtures, resolved at compile time so the tool /// and tests work regardless of the current working directory. @@ -37,6 +41,7 @@ pub struct Flow { } #[derive(Debug, Deserialize, Default)] +#[serde(deny_unknown_fields)] struct FlowConfig { #[serde(default)] description: Option, @@ -44,11 +49,273 @@ struct FlowConfig { directions: Option>, } +/// Whether an operator has attested that reviewable artifacts are installed. +/// +/// `AwaitingRealCaptures` is deliberately not a soft warning: a required flow +/// in this state must fail its preflight gate. The tool validates the declared +/// pair's bytes and shape; it cannot independently prove how those bytes were +/// recorded, so promotion to `Ready` remains an explicit review action. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum RequirementStatus { + AwaitingRealCaptures, + Ready, +} + +/// Flow-specific semantic contract applied after the exact packet topology. +/// +/// Required captures are acceptance evidence, so an opcode-only contract is +/// insufficient: two equally malformed or unrelated packets would otherwise +/// compare byte-clean. Every required flow must name a reviewed semantic +/// contract explicitly. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum RequirementSemanticContract { + LootSingleItemClaimV1, +} + +/// One routing/order anchor that must occur in a required capture. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RequiredPacket { + pub direction: Direction, + pub connection_id: u32, + pub opcode: u16, + pub label: String, +} + +impl RequiredPacket { + fn matches(&self, packet: &CapturedPacket) -> bool { + self.direction == packet.direction + && self.connection_id == packet.connection_id + && self.opcode == packet.opcode + } + + fn render(&self) -> String { + format!( + "{} conn={} 0x{:04X} {}", + self.direction, self.connection_id, self.opcode, self.label + ) + } +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RequirementConfig { + issue: String, + status: RequirementStatus, + description: String, + #[serde(default)] + blocked_reason: Option, + directions: Vec, + import_selection: RequiredImportSelection, + require_each_anchor_exactly_once: bool, + semantic_contract: RequirementSemanticContract, + required_order: Vec, +} + +/// One directional action boundary pinned by a required import contract. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RequiredImportBoundary { + pub direction: Direction, + pub opcode: u16, +} + +impl From for PacketBoundary { + fn from(value: RequiredImportBoundary) -> Self { + Self { + direction: Some(value.direction), + opcode: value.opcode, + } + } +} + +/// The only filtering contract allowed to derive one required flow. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RequiredImportSelection { + pub from_opcode: RequiredImportBoundary, + pub until_opcode: RequiredImportBoundary, + pub ignored_opcodes: Vec, +} + +/// A fail-closed contract for a capture that is required by a milestone PR. +/// +/// `required_order` is the exact complete packet sequence after reviewed +/// ambient filtering. It is not a subsequence: an extra packet, including a +/// duplicate on the wrong direction or socket, invalidates the evidence. +#[derive(Debug, Clone)] +pub struct FlowRequirement { + pub name: String, + pub issue: String, + pub status: RequirementStatus, + pub description: String, + pub blocked_reason: Option, + pub directions: Vec, + pub import_selection: RequiredImportSelection, + pub require_each_anchor_exactly_once: bool, + pub semantic_contract: RequirementSemanticContract, + pub required_order: Vec, + pub directory: PathBuf, +} + +impl FlowRequirement { + /// Refuse to validate a flow until its manifest explicitly says that the + /// real C++ and Rust artifacts have been installed and reviewed. + pub fn require_ready(&self) -> Result<()> { + if self.status == RequirementStatus::Ready { + return Ok(()); + } + + let reason = self + .blocked_reason + .as_deref() + .unwrap_or("the required real capture pair has not been installed"); + bail!( + "required flow '{}' for {} is BLOCKED: {reason} See {}", + self.name, + self.issue, + self.directory.join("README.md").display() + ) + } + + /// Reject any import flags that differ from the reviewed action window. + pub fn validate_import_selection( + &self, + directions: &[Direction], + from_opcode: Option, + until_opcode: Option, + ignored_opcodes: &[PacketBoundary], + strict: bool, + ) -> Result<()> { + let required_from: PacketBoundary = self.import_selection.from_opcode.into(); + let required_until: PacketBoundary = self.import_selection.until_opcode.into(); + let required_ignores = self + .import_selection + .ignored_opcodes + .iter() + .copied() + .map(PacketBoundary::from) + .collect::>(); + if !strict + || directions != self.directions + || from_opcode != Some(required_from) + || until_opcode != Some(required_until) + || ignored_opcodes != required_ignores + { + bail!( + "required flow '{}' import selection differs from its reviewed contract: strict=true, directions {:?}, from {}, until {}, ignores {:?}", + self.name, + self.directions, + required_from, + required_until, + required_ignores + .iter() + .map(ToString::to_string) + .collect::>() + ); + } + Ok(()) + } + + /// Validate the action boundary, connection topology, and C++-anchored + /// packet order of one side of a required capture pair. + pub fn validate_capture_shape(&self, capture: &Capture) -> Result<()> { + if !self.require_each_anchor_exactly_once { + bail!( + "required flow '{}' disables exact packet cardinality", + self.name + ); + } + let Some(first_required) = self.required_order.first() else { + bail!("required flow '{}' has no packet-order contract", self.name); + }; + let last_required = self + .required_order + .last() + .expect("first required packet already proved the list is nonempty"); + let Some(first_actual) = capture.packets.first() else { + bail!("{} is empty", capture.source); + }; + let last_actual = capture + .packets + .last() + .expect("first packet already proved the capture is nonempty"); + + if !first_required.matches(first_actual) { + bail!( + "{} starts with {} conn={} 0x{:04X}; required boundary is {}", + capture.source, + first_actual.direction, + first_actual.connection_id, + first_actual.opcode, + first_required.render() + ); + } + if !last_required.matches(last_actual) { + bail!( + "{} ends with {} conn={} 0x{:04X}; required fence is {}", + capture.source, + last_actual.direction, + last_actual.connection_id, + last_actual.opcode, + last_required.render() + ); + } + + if capture.packets.len() != self.required_order.len() { + bail!( + "{} contains {} packet(s); required flow '{}' permits exactly {}", + capture.source, + capture.packets.len(), + self.name, + self.required_order.len() + ); + } + + for (index, (required, actual)) in + self.required_order.iter().zip(&capture.packets).enumerate() + { + if !required.matches(actual) { + bail!( + "{} packet {index} is {} conn={} 0x{:04X}; required packet is {}", + capture.source, + actual.direction, + actual.connection_id, + actual.opcode, + required.render() + ); + } + } + + Ok(()) + } + + /// Validate both the exact wire topology and the action's correlated + /// payload semantics. + pub fn validate_capture(&self, capture: &Capture) -> Result<()> { + self.validate_capture_shape(capture)?; + match self.semantic_contract { + RequirementSemanticContract::LootSingleItemClaimV1 => { + semantic::validate_loot_single_item_claim_capture(capture) + .map_err(anyhow::Error::msg)?; + } + } + Ok(()) + } +} + /// Load a flow by name from the committed fixtures root. pub fn load_flow(name: &str) -> Result { load_flow_from(&flows_root(), name) } +/// Load a required-flow contract from the committed fixtures root. +pub fn load_requirement(name: &str) -> Result { + load_requirement_from(&flows_root(), name) +} + /// Validate one path-component-safe flow name before joining it under a /// fixture/capture root. This guards every later write or recursive removal /// from absolute paths and `..` traversal. @@ -106,12 +373,156 @@ pub fn load_flow_from(root: &Path, name: &str) -> Result { }) } +/// Load and structurally validate a required-flow contract from an explicit +/// root (the explicit form is used by unit tests). +pub fn load_requirement_from(root: &Path, name: &str) -> Result { + validate_flow_name(name)?; + let directory = root.join(name); + let path = directory.join("requirement.json"); + if !path.is_file() { + bail!( + "flow '{name}' has no required-flow contract at {}", + path.display() + ); + } + + let text = + std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?; + let config: RequirementConfig = + serde_json::from_str(&text).with_context(|| format!("parsing {}", path.display()))?; + + if config.issue.trim().is_empty() { + bail!("{} has an empty issue identifier", path.display()); + } + if config.description.trim().is_empty() { + bail!("{} has an empty description", path.display()); + } + if config.directions.is_empty() { + bail!("{} has no comparison directions", path.display()); + } + if config.import_selection.from_opcode.opcode == 0 + || config.import_selection.until_opcode.opcode == 0 + || config + .import_selection + .ignored_opcodes + .iter() + .any(|boundary| boundary.opcode == 0) + { + bail!("{} contains a zero import-selection opcode", path.display()); + } + let import_directions = std::iter::once(config.import_selection.from_opcode.direction) + .chain(std::iter::once( + config.import_selection.until_opcode.direction, + )) + .chain( + config + .import_selection + .ignored_opcodes + .iter() + .map(|boundary| boundary.direction), + ); + if import_directions + .into_iter() + .any(|direction| !config.directions.contains(&direction)) + { + bail!( + "{} import selection uses a direction outside the comparison contract", + path.display() + ); + } + let mut unique_ignores = Vec::new(); + for ignored in &config.import_selection.ignored_opcodes { + if unique_ignores.contains(ignored) { + bail!("{} contains a duplicate ignored opcode", path.display()); + } + unique_ignores.push(*ignored); + } + if config.directions.len() > 2 + || config + .directions + .iter() + .enumerate() + .any(|(index, direction)| config.directions[..index].contains(direction)) + { + bail!("{} has duplicate comparison directions", path.display()); + } + if config.required_order.len() < 2 { + bail!( + "{} must pin at least an action boundary and an end fence", + path.display() + ); + } + if !config.require_each_anchor_exactly_once { + bail!( + "{} must set require_each_anchor_exactly_once=true", + path.display() + ); + } + for packet in &config.required_order { + if packet.opcode == 0 { + bail!("{} contains a zero required opcode", path.display()); + } + if packet.label.trim().is_empty() { + bail!("{} contains an unlabeled required packet", path.display()); + } + if !config.directions.contains(&packet.direction) { + bail!( + "{} requires {} but does not compare that direction", + path.display(), + packet.render() + ); + } + } + match config.status { + RequirementStatus::AwaitingRealCaptures => { + if config + .blocked_reason + .as_deref() + .is_none_or(|reason| reason.trim().is_empty()) + { + bail!( + "{} is awaiting real captures but has no blocked_reason", + path.display() + ); + } + } + RequirementStatus::Ready if config.blocked_reason.is_some() => { + bail!( + "{} is ready but still carries blocked_reason; remove stale blocked evidence", + path.display() + ); + } + RequirementStatus::Ready => {} + } + + Ok(FlowRequirement { + name: name.to_string(), + issue: config.issue, + status: config.status, + description: config.description, + blocked_reason: config.blocked_reason, + directions: config.directions, + import_selection: config.import_selection, + require_each_anchor_exactly_once: config.require_each_anchor_exactly_once, + semantic_contract: config.semantic_contract, + required_order: config.required_order, + directory, + }) +} + /// List the known flow names under the committed fixtures root. #[must_use] pub fn list_flows() -> Vec { list_flows_from(&flows_root()) } +/// List committed fail-closed capture contracts, including contracts that are +/// still awaiting real artifacts and therefore are not yet normal flows. +#[must_use] +pub fn list_requirements() -> Vec { + list_requirements_from(&flows_root()) +} + fn list_flows_from(root: &Path) -> Vec { let mut names = Vec::new(); if let Ok(entries) = std::fs::read_dir(root) { @@ -127,10 +538,110 @@ fn list_flows_from(root: &Path) -> Vec { names } +fn list_requirements_from(root: &Path) -> Vec { + let mut names = Vec::new(); + if let Ok(entries) = std::fs::read_dir(root) { + for entry in entries.flatten() { + if entry.path().join("requirement.json").is_file() + && let Some(name) = entry.file_name().to_str() + { + names.push(name.to_string()); + } + } + } + names.sort(); + names +} + #[cfg(test)] mod tests { use super::*; + fn packet(direction: Direction, connection_id: u32, opcode: u16) -> CapturedPacket { + CapturedPacket { + direction, + connection_id, + opcode, + body: Vec::new(), + } + } + + fn requirement() -> FlowRequirement { + FlowRequirement { + name: "loot-single-item-claim".to_string(), + issue: "#106".to_string(), + status: RequirementStatus::Ready, + description: "test contract".to_string(), + blocked_reason: None, + directions: vec![Direction::S2C, Direction::C2S], + import_selection: RequiredImportSelection { + from_opcode: RequiredImportBoundary { + direction: Direction::C2S, + opcode: 0x3211, + }, + until_opcode: RequiredImportBoundary { + direction: Direction::C2S, + opcode: 0x3768, + }, + ignored_opcodes: vec![ + RequiredImportBoundary { + direction: Direction::S2C, + opcode: 0x2DD2, + }, + RequiredImportBoundary { + direction: Direction::C2S, + opcode: 0x3A3D, + }, + RequiredImportBoundary { + direction: Direction::S2C, + opcode: 0x2DD4, + }, + ], + }, + require_each_anchor_exactly_once: true, + semantic_contract: RequirementSemanticContract::LootSingleItemClaimV1, + required_order: vec![ + RequiredPacket { + direction: Direction::C2S, + connection_id: 1, + opcode: 0x3211, + label: "CMSG_LOOT_ITEM".to_string(), + }, + RequiredPacket { + direction: Direction::S2C, + connection_id: 1, + opcode: 0x27CB, + label: "SMSG_UPDATE_OBJECT item CreateObject".to_string(), + }, + RequiredPacket { + direction: Direction::S2C, + connection_id: 1, + opcode: 0x2615, + label: "SMSG_LOOT_REMOVED".to_string(), + }, + RequiredPacket { + direction: Direction::S2C, + connection_id: 0, + opcode: 0x2623, + label: "SMSG_ITEM_PUSH_RESULT".to_string(), + }, + RequiredPacket { + direction: Direction::S2C, + connection_id: 1, + opcode: 0x27CB, + label: "SMSG_UPDATE_OBJECT InvSlots VALUES".to_string(), + }, + RequiredPacket { + direction: Direction::C2S, + connection_id: 1, + opcode: 0x3768, + label: "CMSG_PING".to_string(), + }, + ], + directory: PathBuf::from("flows/loot-single-item-claim"), + } + } + #[test] fn flow_names_are_single_safe_path_components() { for valid in ["stand-state", "login_3.4.3", "flow.01"] { @@ -151,4 +662,255 @@ mod tests { assert!(validate_flow_name(invalid).is_err(), "accepted {invalid:?}"); } } + + #[test] + fn required_shape_pins_loot_order_routes_and_boundaries() { + let requirement = requirement(); + let capture = Capture::new( + "real capture", + vec![ + packet(Direction::C2S, 1, 0x3211), + packet(Direction::S2C, 1, 0x27CB), + packet(Direction::S2C, 1, 0x2615), + packet(Direction::S2C, 0, 0x2623), + packet(Direction::S2C, 1, 0x27CB), + packet(Direction::C2S, 1, 0x3768), + ], + ); + + requirement.validate_capture_shape(&capture).unwrap(); + + let mut reversed = capture.clone(); + reversed.packets.swap(2, 3); + let error = requirement + .validate_capture_shape(&reversed) + .expect_err("ItemPushResult before LootRemoved must fail C++ order"); + assert!(error.to_string().contains("SMSG_LOOT_REMOVED")); + + let mut wrong_route = capture.clone(); + wrong_route.packets[3].connection_id = 1; + let error = requirement + .validate_capture_shape(&wrong_route) + .expect_err("ItemPushResult on the instance socket must fail"); + assert!(error.to_string().contains("SMSG_ITEM_PUSH_RESULT")); + + let mut duplicate_anchor = capture; + duplicate_anchor + .packets + .insert(4, packet(Direction::S2C, 0, 0x2623)); + let error = requirement + .validate_capture_shape(&duplicate_anchor) + .expect_err("a second item-push anchor must fail single-session provenance"); + assert!(error.to_string().contains("contains 7 packet(s)")); + + let mut wrong_route_duplicate = Capture::new( + "wrong-route duplicate", + vec![ + packet(Direction::C2S, 1, 0x3211), + packet(Direction::S2C, 1, 0x27CB), + packet(Direction::S2C, 1, 0x2615), + packet(Direction::S2C, 0, 0x2623), + packet(Direction::S2C, 1, 0x2623), + packet(Direction::S2C, 1, 0x27CB), + packet(Direction::C2S, 1, 0x3768), + ], + ); + let error = requirement + .validate_capture_shape(&wrong_route_duplicate) + .expect_err("an ItemPushResult duplicate on the wrong socket must fail"); + assert!(error.to_string().contains("contains 7 packet(s)")); + + wrong_route_duplicate.packets.remove(4); + wrong_route_duplicate.packets[1].direction = Direction::C2S; + let error = requirement + .validate_capture_shape(&wrong_route_duplicate) + .expect_err("replacing an exact packet without changing count must fail"); + assert!(error.to_string().contains("packet 1")); + } + + #[test] + fn required_shape_rejects_capture_outside_action_boundaries() { + let requirement = requirement(); + let prefixed = Capture::new( + "prefixed capture", + vec![ + packet(Direction::S2C, 0, 0x2DD2), + packet(Direction::C2S, 1, 0x3211), + packet(Direction::S2C, 1, 0x27CB), + packet(Direction::S2C, 1, 0x2615), + packet(Direction::S2C, 0, 0x2623), + packet(Direction::S2C, 1, 0x27CB), + packet(Direction::C2S, 1, 0x3768), + ], + ); + assert!( + requirement + .validate_capture_shape(&prefixed) + .unwrap_err() + .to_string() + .contains("required boundary") + ); + + let suffixed = Capture::new( + "suffixed capture", + vec![ + packet(Direction::C2S, 1, 0x3211), + packet(Direction::S2C, 1, 0x27CB), + packet(Direction::S2C, 1, 0x2615), + packet(Direction::S2C, 0, 0x2623), + packet(Direction::S2C, 1, 0x27CB), + packet(Direction::C2S, 1, 0x3768), + packet(Direction::S2C, 1, 0x304E), + ], + ); + assert!( + requirement + .validate_capture_shape(&suffixed) + .unwrap_err() + .to_string() + .contains("required fence") + ); + } + + #[test] + fn awaiting_required_flow_fails_closed_with_recorded_reason() { + let root = std::env::temp_dir().join(format!( + "capture-diff-required-flow-{}-{}", + std::process::id(), + std::thread::current().name().unwrap_or("unnamed") + )); + let directory = root.join("loot-single-item-claim"); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&directory).unwrap(); + std::fs::write( + directory.join("requirement.json"), + r##"{ + "issue": "#106", + "status": "awaiting-real-captures", + "description": "single-session loot claim", + "blocked_reason": "no matched real C++ capture exists", + "directions": ["s2c", "c2s"], + "import_selection": { + "from_opcode": {"direction": "c2s", "opcode": 12817}, + "until_opcode": {"direction": "c2s", "opcode": 14184}, + "ignored_opcodes": [] + }, + "require_each_anchor_exactly_once": true, + "semantic_contract": "loot-single-item-claim-v1", + "required_order": [ + {"direction": "c2s", "connection_id": 1, "opcode": 12817, "label": "CMSG_LOOT_ITEM"}, + {"direction": "c2s", "connection_id": 1, "opcode": 14184, "label": "CMSG_PING"} + ] +}"##, + ) + .unwrap(); + + let requirement = + load_requirement_from(&root, "loot-single-item-claim").expect("valid requirement"); + let error = requirement + .require_ready() + .expect_err("missing real pair must block preflight"); + assert!(error.to_string().contains("BLOCKED")); + assert!(error.to_string().contains("no matched real C++ capture")); + assert!(error.to_string().contains("README.md")); + + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn required_flow_cannot_omit_or_disable_exact_cardinality() { + let root = std::env::temp_dir().join(format!( + "capture-diff-required-cardinality-{}-{}", + std::process::id(), + std::thread::current().name().unwrap_or("unnamed") + )); + let directory = root.join("loot-single-item-claim"); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&directory).unwrap(); + + let manifest = |cardinality: Option| { + let cardinality = cardinality.map_or_else(String::new, |value| { + format!("\n \"require_each_anchor_exactly_once\": {value},") + }); + format!( + r##"{{ + "issue": "#106", + "status": "ready", + "description": "single-session loot claim", + "directions": ["s2c", "c2s"],{cardinality} + "import_selection": {{ + "from_opcode": {{"direction": "c2s", "opcode": 12817}}, + "until_opcode": {{"direction": "c2s", "opcode": 14184}}, + "ignored_opcodes": [] + }}, + "semantic_contract": "loot-single-item-claim-v1", + "required_order": [ + {{"direction": "c2s", "connection_id": 1, "opcode": 12817, "label": "CMSG_LOOT_ITEM"}}, + {{"direction": "c2s", "connection_id": 1, "opcode": 14184, "label": "CMSG_PING"}} + ] +}}"## + ) + }; + + std::fs::write(directory.join("requirement.json"), manifest(None)).unwrap(); + let missing = load_requirement_from(&root, "loot-single-item-claim") + .expect_err("omitting exact cardinality must fail"); + assert!(missing.to_string().contains("parsing")); + + std::fs::write(directory.join("requirement.json"), manifest(Some(false))).unwrap(); + let disabled = load_requirement_from(&root, "loot-single-item-claim") + .expect_err("disabling exact cardinality must fail"); + assert!( + disabled + .to_string() + .contains("require_each_anchor_exactly_once=true") + ); + + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn flow_and_requirement_json_reject_unknown_fields_at_every_contract_layer() { + let root = std::env::temp_dir().join(format!( + "capture-diff-deny-unknown-flow-{}-{}", + std::process::id(), + std::thread::current().name().unwrap_or("unnamed") + )); + let directory = root.join("strict-schema"); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&directory).unwrap(); + std::fs::write(directory.join("cpp.pkt"), b"fixture marker").unwrap(); + std::fs::write( + directory.join("flow.json"), + r#"{"description":"schema","directions":["s2c","c2s"],"typo":true}"#, + ) + .unwrap(); + let error = + load_flow_from(&root, "strict-schema").expect_err("unknown flow.json field must fail"); + assert!(error.to_string().contains("parsing")); + + let requirement = r##"{ + "issue": "#106", + "status": "ready", + "description": "strict schema", + "directions": ["s2c", "c2s"], + "import_selection": { + "from_opcode": {"direction": "c2s", "opcode": 12817, "typo": true}, + "until_opcode": {"direction": "c2s", "opcode": 14184}, + "ignored_opcodes": [] + }, + "require_each_anchor_exactly_once": true, + "semantic_contract": "loot-single-item-claim-v1", + "required_order": [ + {"direction": "c2s", "connection_id": 1, "opcode": 12817, "label": "request"}, + {"direction": "c2s", "connection_id": 1, "opcode": 14184, "label": "fence"} + ] +}"##; + std::fs::write(directory.join("requirement.json"), requirement).unwrap(); + let error = load_requirement_from(&root, "strict-schema") + .expect_err("unknown nested requirement field must fail"); + assert!(error.to_string().contains("parsing")); + + std::fs::remove_dir_all(root).unwrap(); + } } diff --git a/crates/capture-diff/src/lib.rs b/crates/capture-diff/src/lib.rs index 62032c028..710a4af5a 100644 --- a/crates/capture-diff/src/lib.rs +++ b/crates/capture-diff/src/lib.rs @@ -1,8 +1,8 @@ //! # capture-diff — the port's acceptance gate (issue `[01]` / #66) //! //! "Done" across the whole port plan means the Rust wire output is byte/opcode -//! clean versus a C++ capture of the same action (STATE.md §5), except for a -//! narrowly reviewed runtime identifier handled by [`semantic`]. This crate is +//! clean versus a C++ capture of the same action (STATE.md §5), except for +//! narrowly reviewed runtime identifiers handled by [`semantic`]. This crate is //! that harness: it parses a C++ **PKT 3.1** capture ([`pkt`]) and a RustyCore //! packet dump ([`rustdump`]), normalizes both to a common model ([`model`]), //! and diffs them opcode-by-opcode ([`diff`]). Flows ([`flow`]) pin a golden @@ -22,6 +22,7 @@ pub mod diff; pub mod flow; +pub mod lineage; pub mod model; pub mod pkt; pub mod rustdump; @@ -31,8 +32,14 @@ pub use diff::{ AlignedOp, BaselineDelta, BodyDiff, ConnectionDiff, DiffCounts, DiffReport, DivergenceKind, DivergenceSignature, OpKind, baseline_delta, }; -pub use flow::{Flow, list_flows, load_flow}; +pub use flow::{ + Flow, FlowRequirement, RequiredImportBoundary, RequiredImportSelection, RequiredPacket, + RequirementSemanticContract, RequirementStatus, list_flows, list_requirements, load_flow, + load_requirement, +}; pub use model::{Capture, CapturedPacket, Direction, PacketBoundary, opcode_name}; pub use semantic::{ - LogXpGainBody, SemanticBodyDiff, SemanticBodySide, StableObjectGuid, decode_log_xp_gain_body, + ExactObjectGuid, InvSlotValue, LogXpGainBody, LootRemovedBody, SemanticBodyDiff, + SemanticBodySide, StableObjectGuid, UpdateObjectInvSlotsBody, decode_log_xp_gain_body, + decode_loot_removed_body, validate_loot_single_item_claim_capture, }; diff --git a/crates/capture-diff/src/lineage.rs b/crates/capture-diff/src/lineage.rs new file mode 100644 index 000000000..bef14ddd8 --- /dev/null +++ b/crates/capture-diff/src/lineage.rs @@ -0,0 +1,2183 @@ +//! Fail-closed provenance for capture imports. +//! +//! Capture wrappers publish a raw artifact and a side-specific manifest only +//! after the accredited process is gone and the runtime has been restored. +//! Import validates those manifests against the raw bytes, keeps exact copies +//! beside the committed fixture, and publishes the complete derived flow with +//! one atomic directory exchange. [`verify_required_lineage`] then binds the +//! copied raw manifests to every filtered output used by `verify-required`. + +use std::collections::BTreeMap; +use std::ffi::CString; +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use anyhow::{Context, Result, bail, ensure}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::model::{Direction, PacketBoundary}; + +/// Completion marker for one fully derived capture flow. +pub const LINEAGE_FILE: &str = "capture-lineage.json"; +/// Exact raw manifests retained with the derived fixture. +pub const RAW_PROVENANCE_DIR: &str = "capture-provenance"; + +const CPP_RAW_MANIFEST_FILE: &str = "cpp.capture-manifest.json"; +const RUST_RAW_MANIFEST_FILE: &str = "rust.capture-manifest.json"; +const CPP_BOT_REPORT_FILE: &str = "cpp.bot-report.json"; +const RUST_BOT_REPORT_FILE: &str = "rust.bot-report.json"; +const SHA256_HEX_LEN: usize = 64; +const LINEAGE_VERSION: u32 = 3; +const RAW_MANIFEST_VERSION: u32 = 3; + +static STAGING_COUNTER: AtomicU64 = AtomicU64::new(0); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +enum RawSide { + Cpp, + Rust, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawArtifact { + path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + size: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + sha256: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + packet_count: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + tree_sha256: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct FixtureGuardEvidence { + enabled: bool, + contract: String, + account: String, + account_id: u32, + character_guid: u64, + peer_account: String, + peer_account_id: u32, + peer_character_guid: u64, + creature_entry: u32, + creature_spawn_guid: u64, + item_entry: u32, + cleanup_verified: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct BotReportEvidence { + contract: String, + exec_path: String, + exec_sha256: String, + report_path: String, + report_sha256: String, + account: String, + account_id: u32, + character_guid: u64, + report_validated: bool, +} + +/// Schema emitted by `capture-cpp.sh` and `capture-rust.sh`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawCaptureManifest { + version: u32, + flow: String, + side: RawSide, + completed: bool, + created_at: String, + harness_repo_head: String, + source_repo_head: String, + harness_worktree_clean: bool, + harness_worktree_state_sha256: String, + source_worktree_dirty: bool, + source_worktree_state_sha256: String, + worktree_state_algorithm: String, + expected_exec_path: String, + expected_exec_sha256: String, + source_exec_path: String, + source_exec_sha256: String, + live_exec_path: String, + live_exec_sha256: String, + executable_pin_enforced: bool, + pm2_entry_pid: u32, + pm2_entry_starttime: u64, + pm2_exec_path: String, + pm2_exec_sha256: String, + pm2_profile_redacted_sha256: String, + listener_runtime_pid: u32, + listener_runtime_starttime: u64, + listener_relationship_verified: bool, + restart_count: u64, + effective_config_path: String, + effective_config_redacted_sha256: String, + effective_config_algorithm: String, + runtime_cleanup_verified: bool, + normal_runtime_restored: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + fixture_guard: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + bot_report: Option, + artifact: RawArtifact, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ValidatedRawSide { + manifest_bytes: Vec, + manifest_sha256: String, + manifest: RawCaptureManifest, + bot_report_bytes: Option>, +} + +/// A pair of raw manifests whose declared artifacts matched the supplied raw +/// capture bytes at import time. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ValidatedRawPair { + cpp: ValidatedRawSide, + rust: ValidatedRawSide, +} + +/// One boundary recorded in the derived import contract. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LineageBoundary { + direction: Option, + opcode: u16, +} + +impl From for LineageBoundary { + fn from(value: PacketBoundary) -> Self { + Self { + direction: value.direction, + opcode: value.opcode, + } + } +} + +/// Exact selection applied symmetrically to the two raw captures. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ImportSelection { + directions: Vec, + from_opcode: Option, + until_opcode: Option, + ignored_opcodes: Vec, + strict: bool, +} + +impl ImportSelection { + /// Record all flags that can change the derived evidence. + #[must_use] + pub fn new( + directions: Vec, + from_opcode: Option, + until_opcode: Option, + ignored_opcodes: &[PacketBoundary], + strict: bool, + ) -> Self { + Self { + directions, + from_opcode: from_opcode.map(Into::into), + until_opcode: until_opcode.map(Into::into), + ignored_opcodes: ignored_opcodes.iter().copied().map(Into::into).collect(), + strict, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct SourceLineage { + manifest_path: String, + manifest_sha256: String, + raw_artifact_sha256: String, + raw_artifact_size: Option, + raw_packet_count: Option, + harness_repo_head: String, + source_repo_head: String, + harness_worktree_clean: bool, + harness_worktree_state_sha256: String, + source_worktree_dirty: bool, + source_worktree_state_sha256: String, + worktree_state_algorithm: String, + expected_exec_path: String, + expected_exec_sha256: String, + source_exec_path: String, + source_exec_sha256: String, + live_exec_path: String, + live_exec_sha256: String, + executable_pin_enforced: bool, + pm2_entry_pid: u32, + pm2_entry_starttime: u64, + pm2_exec_path: String, + pm2_exec_sha256: String, + pm2_profile_redacted_sha256: String, + listener_runtime_pid: u32, + listener_runtime_starttime: u64, + listener_relationship_verified: bool, + restart_count: u64, + effective_config_path: String, + effective_config_redacted_sha256: String, + effective_config_algorithm: String, + runtime_cleanup_verified: bool, + normal_runtime_restored: bool, + fixture_guard: Option, + bot_report: Option, + retained_bot_report_path: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct SourcePairLineage { + cpp: SourceLineage, + rust: SourceLineage, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct FileLineage { + path: String, + size: u64, + sha256: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct TreeLineage { + path: String, + file_count: u64, + packet_count: u64, + tree_sha256: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct DerivedOutputs { + cpp_pkt: FileLineage, + rust: TreeLineage, + expected_divergences: FileLineage, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct DerivedLineage { + version: u32, + flow: String, + completed: bool, + sources: SourcePairLineage, + selection: ImportSelection, + outputs: DerivedOutputs, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct TreeDigest { + sha256: String, + file_count: u64, + packet_count: u64, +} + +/// Validate raw capture manifests and bind them to the exact raw artifacts. +/// Required flows additionally require executable pinning on both sides. +pub fn validate_raw_pair( + flow: &str, + cpp_capture: &Path, + cpp_manifest: &Path, + rust_capture: &Path, + rust_manifest: &Path, + require_pinned_execs: bool, +) -> Result { + let mut cpp = + read_and_validate_raw_manifest(cpp_manifest, flow, RawSide::Cpp, require_pinned_execs) + .with_context(|| format!("validating C++ raw manifest {}", cpp_manifest.display()))?; + let mut rust = + read_and_validate_raw_manifest(rust_manifest, flow, RawSide::Rust, require_pinned_execs) + .with_context(|| format!("validating Rust raw manifest {}", rust_manifest.display()))?; + + validate_cpp_artifact(&cpp.manifest, cpp_capture)?; + validate_rust_artifact(&rust.manifest, rust_capture, Some(rust_manifest))?; + cpp.bot_report_bytes = validate_bot_report_artifact(&cpp.manifest)?; + rust.bot_report_bytes = validate_bot_report_artifact(&rust.manifest)?; + validate_cross_side_identity(flow, &cpp.manifest, &rust.manifest)?; + + Ok(ValidatedRawPair { cpp, rust }) +} + +fn read_and_validate_raw_manifest( + path: &Path, + flow: &str, + side: RawSide, + require_pinned_exec: bool, +) -> Result { + let bytes = read_regular_file(path)?; + let manifest: RawCaptureManifest = serde_json::from_slice(&bytes) + .with_context(|| format!("parsing raw manifest {}", path.display()))?; + validate_raw_manifest_schema(&manifest, flow, side, require_pinned_exec) + .with_context(|| format!("invalid raw manifest {}", path.display()))?; + Ok(ValidatedRawSide { + manifest_sha256: sha256_bytes(&bytes), + manifest_bytes: bytes, + manifest, + bot_report_bytes: None, + }) +} + +fn validate_raw_manifest_schema( + manifest: &RawCaptureManifest, + flow: &str, + side: RawSide, + require_pinned_exec: bool, +) -> Result<()> { + ensure!( + manifest.version == RAW_MANIFEST_VERSION, + "unsupported raw manifest version {}", + manifest.version + ); + ensure!( + manifest.flow == flow, + "flow is {:?}, expected {flow:?}", + manifest.flow + ); + ensure!( + manifest.side == side, + "side is {:?}, expected {side:?}", + manifest.side + ); + ensure!(manifest.completed, "completed must be true"); + validate_utc_timestamp(&manifest.created_at)?; + ensure!( + valid_git_oid(&manifest.harness_repo_head), + "harness_repo_head must be a lowercase 40- or 64-hex object id" + ); + ensure!( + valid_git_oid(&manifest.source_repo_head), + "source_repo_head must be a lowercase 40- or 64-hex object id" + ); + for (label, path) in [ + ("expected_exec_path", manifest.expected_exec_path.as_str()), + ("source_exec_path", manifest.source_exec_path.as_str()), + ("live_exec_path", manifest.live_exec_path.as_str()), + ("pm2_exec_path", manifest.pm2_exec_path.as_str()), + ( + "effective_config_path", + manifest.effective_config_path.as_str(), + ), + ] { + ensure!(Path::new(path).is_absolute(), "{label} must be absolute"); + } + if let Some(bot) = &manifest.bot_report { + ensure!( + Path::new(&bot.exec_path).is_absolute(), + "bot_report.exec_path must be absolute" + ); + ensure!( + Path::new(&bot.report_path).is_absolute(), + "bot_report.report_path must be absolute" + ); + validate_sha256(&bot.exec_sha256, "bot_report.exec_sha256")?; + validate_sha256(&bot.report_sha256, "bot_report.report_sha256")?; + } + validate_sha256(&manifest.expected_exec_sha256, "expected_exec_sha256")?; + validate_sha256(&manifest.source_exec_sha256, "source_exec_sha256")?; + validate_sha256(&manifest.live_exec_sha256, "live_exec_sha256")?; + validate_sha256( + &manifest.harness_worktree_state_sha256, + "harness_worktree_state_sha256", + )?; + validate_sha256( + &manifest.source_worktree_state_sha256, + "source_worktree_state_sha256", + )?; + validate_sha256(&manifest.pm2_exec_sha256, "pm2_exec_sha256")?; + validate_sha256( + &manifest.pm2_profile_redacted_sha256, + "pm2_profile_redacted_sha256", + )?; + validate_sha256( + &manifest.effective_config_redacted_sha256, + "effective_config_redacted_sha256", + )?; + ensure!( + manifest.expected_exec_path == manifest.source_exec_path + && manifest.source_exec_path == manifest.live_exec_path, + "expected/source/live executable paths must be identical after canonicalization" + ); + ensure!( + manifest.expected_exec_sha256 == manifest.source_exec_sha256 + && manifest.source_exec_sha256 == manifest.live_exec_sha256, + "expected/source/live executable SHA-256 values must be identical" + ); + ensure!( + manifest.harness_worktree_clean, + "capture harness worktree must be clean" + ); + ensure!( + manifest.worktree_state_algorithm == "git-head-path-mode-content-sha256-v1", + "unsupported worktree_state_algorithm {:?}", + manifest.worktree_state_algorithm + ); + ensure!(manifest.pm2_entry_pid != 0, "pm2_entry_pid must be nonzero"); + ensure!( + manifest.pm2_entry_starttime != 0, + "pm2_entry_starttime must be nonzero" + ); + ensure!( + manifest.listener_runtime_pid != 0, + "listener_runtime_pid must be nonzero" + ); + ensure!( + manifest.listener_runtime_starttime != 0, + "listener_runtime_starttime must be nonzero" + ); + ensure!( + manifest.listener_relationship_verified, + "PM2 entry/listener self-or-descendant relationship was not verified" + ); + ensure!( + manifest.effective_config_algorithm == "capture-relevant-redacted-v1", + "unsupported effective_config_algorithm {:?}", + manifest.effective_config_algorithm + ); + ensure!( + manifest.runtime_cleanup_verified, + "runtime_cleanup_verified must be true" + ); + ensure!( + manifest.normal_runtime_restored, + "normal_runtime_restored must be true" + ); + if require_pinned_exec { + ensure!( + manifest.executable_pin_enforced, + "required evidence must set executable_pin_enforced=true" + ); + } + if flow == "loot-single-item-claim" { + validate_canonical_loot_identity(manifest)?; + } + + match side { + RawSide::Cpp => { + ensure!( + manifest.artifact.path == "cpp.pkt", + "C++ artifact path must be cpp.pkt" + ); + ensure!( + manifest.artifact.size.is_some(), + "C++ artifact size is missing" + ); + validate_sha256( + manifest.artifact.sha256.as_deref().unwrap_or_default(), + "C++ artifact sha256", + )?; + ensure!( + manifest.artifact.packet_count.is_none() && manifest.artifact.tree_sha256.is_none(), + "C++ artifact contains Rust-only fields" + ); + } + RawSide::Rust => { + ensure!( + manifest.harness_repo_head == manifest.source_repo_head, + "Rust harness/source repository HEAD values must match" + ); + ensure!( + !manifest.source_worktree_dirty + && manifest.harness_worktree_state_sha256 + == manifest.source_worktree_state_sha256, + "Rust harness/source worktree must be the same clean state" + ); + ensure!( + manifest.artifact.path == "rust", + "Rust artifact path must be rust" + ); + ensure!( + manifest.artifact.packet_count.is_some(), + "Rust artifact packet_count is missing" + ); + validate_sha256( + manifest.artifact.tree_sha256.as_deref().unwrap_or_default(), + "Rust artifact tree_sha256", + )?; + ensure!( + manifest.artifact.size.is_none() && manifest.artifact.sha256.is_none(), + "Rust artifact contains C++-only fields" + ); + } + } + Ok(()) +} + +fn validate_canonical_loot_identity(manifest: &RawCaptureManifest) -> Result<()> { + let fixture = manifest + .fixture_guard + .as_ref() + .context("loot-single-item-claim requires fixture_guard evidence")?; + ensure!(fixture.enabled, "fixture_guard.enabled must be true"); + ensure!( + fixture.contract == "loot-single-item-claim-fixture-v1", + "unexpected fixture_guard contract" + ); + ensure!( + fixture.account == "TESTBOT2@bot.local" + && fixture.account_id == 9 + && fixture.character_guid == 15 + && fixture.peer_account == "TESTBOT3@bot.local" + && fixture.peer_account_id == 10 + && fixture.peer_character_guid == 16, + "fixture_guard bot identity is not the canonical TESTBOT2/TESTBOT3 fixture" + ); + ensure!( + fixture.creature_entry == 21_779 + && fixture.creature_spawn_guid == 1_117 + && fixture.item_entry == 30_712, + "fixture_guard world/item identity is not the canonical Doctor Maleficus fixture" + ); + ensure!( + fixture.cleanup_verified, + "fixture_guard cleanup was not verified" + ); + + let bot = manifest + .bot_report + .as_ref() + .context("loot-single-item-claim requires bot_report evidence")?; + ensure!( + bot.contract == "wow-test-bot-loot-item-capture-report-v1", + "unexpected bot_report contract" + ); + ensure!(bot.report_validated, "bot_report was not validated"); + ensure!( + bot.account == fixture.account + && bot.account_id == fixture.account_id + && bot.character_guid == fixture.character_guid, + "bot_report identity does not match fixture_guard identity" + ); + Ok(()) +} + +fn validate_cross_side_identity( + flow: &str, + cpp: &RawCaptureManifest, + rust: &RawCaptureManifest, +) -> Result<()> { + ensure!( + cpp.harness_repo_head == rust.harness_repo_head, + "C++ and Rust captures were produced from different harness HEAD values" + ); + ensure!( + cpp.harness_worktree_state_sha256 == rust.harness_worktree_state_sha256, + "C++ and Rust captures were produced from different harness worktree digests" + ); + ensure!( + cpp.worktree_state_algorithm == rust.worktree_state_algorithm, + "C++ and Rust harness digest algorithms differ" + ); + if flow == "loot-single-item-claim" { + ensure!( + cpp.fixture_guard == rust.fixture_guard, + "C++ and Rust fixture_guard identities differ" + ); + let cpp_bot = cpp.bot_report.as_ref().context("C++ bot report missing")?; + let rust_bot = rust + .bot_report + .as_ref() + .context("Rust bot report missing")?; + ensure!( + cpp_bot.contract == rust_bot.contract + && cpp_bot.exec_path == rust_bot.exec_path + && cpp_bot.exec_sha256 == rust_bot.exec_sha256 + && cpp_bot.account == rust_bot.account + && cpp_bot.account_id == rust_bot.account_id + && cpp_bot.character_guid == rust_bot.character_guid, + "C++ and Rust captures used different canonical bot identities" + ); + } + Ok(()) +} + +fn validate_bot_report_artifact(manifest: &RawCaptureManifest) -> Result>> { + let Some(evidence) = &manifest.bot_report else { + return Ok(None); + }; + let bytes = read_regular_file(Path::new(&evidence.report_path)) + .with_context(|| format!("reading bot report evidence {}", evidence.report_path))?; + ensure!( + sha256_bytes(&bytes) == evidence.report_sha256, + "bot report SHA-256 does not match its manifest" + ); + validate_bot_report_json(&bytes, evidence)?; + Ok(Some(bytes)) +} + +fn validate_bot_report_json(bytes: &[u8], evidence: &BotReportEvidence) -> Result<()> { + let report: serde_json::Value = + serde_json::from_slice(bytes).context("parsing bot report evidence")?; + let results = report + .get("results") + .and_then(serde_json::Value::as_array) + .context("bot report results must be an array")?; + ensure!( + report + .get("loot_item_capture") + .and_then(serde_json::Value::as_bool) + == Some(true) + && report + .get("loot_race_smoke") + .and_then(serde_json::Value::as_bool) + == Some(false) + && results.len() == 1, + "bot report is not a single-session loot-item capture" + ); + let result = &results[0]; + let string = |key: &str| result.get(key).and_then(serde_json::Value::as_str); + let u64_value = |key: &str| result.get(key).and_then(serde_json::Value::as_u64); + let boolean = |key: &str| result.get(key).and_then(serde_json::Value::as_bool); + ensure!( + string("account") == Some(evidence.account.as_str()) + && u64_value("account_id") == Some(u64::from(evidence.account_id)) + && u64_value("character_guid") == Some(evidence.character_guid), + "bot report subject does not match manifest identity" + ); + ensure!( + boolean("world_auth") == Some(true) + && boolean("enum_characters") == Some(true) + && boolean("player_login_verified") == Some(true) + && boolean("loot_race_smoke") == Some(true) + && boolean("loot_race_smoke_passed") == Some(true) + && u64_value("loot_race_target_entry") == Some(21_779) + && u64_value("loot_race_target_spawn_guid") == Some(1_117) + && boolean("loot_race_target_discovered") == Some(true) + && boolean("loot_race_loot_opened") == Some(true) + && boolean("loot_race_item_push_seen") == Some(true) + && boolean("loot_race_loot_removed_seen") == Some(true) + && u64_value("loot_race_loot_coins") == Some(0) + && boolean("loot_race_coin_removed_seen") == Some(false) + && u64_value("loot_race_db_item_total") == Some(1) + && u64_value("loot_race_db_money_delta") == Some(0) + && boolean("loot_race_relog_verified") == Some(true) + && result + .get("loot_race_failure") + .is_some_and(serde_json::Value::is_null), + "bot report does not prove the canonical successful loot-item flow" + ); + Ok(()) +} + +fn validate_cpp_artifact(manifest: &RawCaptureManifest, path: &Path) -> Result<()> { + let bytes = read_regular_file(path) + .with_context(|| format!("reading raw C++ capture {}", path.display()))?; + let actual_size = u64::try_from(bytes.len()).context("C++ capture size does not fit u64")?; + ensure!( + manifest.artifact.size == Some(actual_size), + "raw C++ capture size is {actual_size}, manifest declares {:?}", + manifest.artifact.size + ); + ensure!( + manifest.artifact.sha256.as_deref() == Some(sha256_bytes(&bytes).as_str()), + "raw C++ capture SHA-256 does not match its manifest" + ); + Ok(()) +} + +fn validate_rust_artifact( + manifest: &RawCaptureManifest, + path: &Path, + excluded_manifest: Option<&Path>, +) -> Result<()> { + let digest = digest_tree(path, excluded_manifest) + .with_context(|| format!("hashing raw Rust capture {}", path.display()))?; + ensure!( + manifest.artifact.tree_sha256.as_deref() == Some(digest.sha256.as_str()), + "raw Rust capture tree SHA-256 does not match its manifest" + ); + ensure!( + manifest.artifact.packet_count == Some(digest.packet_count), + "raw Rust capture contains {} packet(s), manifest declares {:?}", + digest.packet_count, + manifest.artifact.packet_count + ); + Ok(()) +} + +/// Copy exact raw manifests and write a derived lineage completion marker into +/// an otherwise complete staging flow. +pub fn write_derived_lineage( + flow: &str, + flow_dir: &Path, + raw: &ValidatedRawPair, + selection: ImportSelection, +) -> Result<()> { + let provenance_dir = flow_dir.join(RAW_PROVENANCE_DIR); + fs::create_dir_all(&provenance_dir) + .with_context(|| format!("creating {}", provenance_dir.display()))?; + write_synced_file( + &provenance_dir.join(CPP_RAW_MANIFEST_FILE), + &raw.cpp.manifest_bytes, + )?; + write_synced_file( + &provenance_dir.join(RUST_RAW_MANIFEST_FILE), + &raw.rust.manifest_bytes, + )?; + if let Some(bytes) = &raw.cpp.bot_report_bytes { + write_synced_file(&provenance_dir.join(CPP_BOT_REPORT_FILE), bytes)?; + } + if let Some(bytes) = &raw.rust.bot_report_bytes { + write_synced_file(&provenance_dir.join(RUST_BOT_REPORT_FILE), bytes)?; + } + + let lineage = build_lineage(flow, flow_dir, raw, selection)?; + let bytes = serde_json::to_vec_pretty(&lineage).context("serializing derived lineage")?; + atomic_write(&flow_dir.join(LINEAGE_FILE), &bytes)?; + sync_directory(&provenance_dir)?; + sync_directory(flow_dir)?; + Ok(()) +} + +fn build_lineage( + flow: &str, + flow_dir: &Path, + raw: &ValidatedRawPair, + selection: ImportSelection, +) -> Result { + let cpp_output = file_lineage(flow_dir, "cpp.pkt")?; + let expected_output = file_lineage(flow_dir, "expected-divergences.json")?; + let rust_digest = digest_tree(&flow_dir.join("rust"), None)?; + + Ok(DerivedLineage { + version: LINEAGE_VERSION, + flow: flow.to_string(), + completed: true, + sources: SourcePairLineage { + cpp: source_lineage( + &raw.cpp, + format!("{RAW_PROVENANCE_DIR}/{CPP_RAW_MANIFEST_FILE}"), + )?, + rust: source_lineage( + &raw.rust, + format!("{RAW_PROVENANCE_DIR}/{RUST_RAW_MANIFEST_FILE}"), + )?, + }, + selection, + outputs: DerivedOutputs { + cpp_pkt: cpp_output, + rust: TreeLineage { + path: "rust".to_string(), + file_count: rust_digest.file_count, + packet_count: rust_digest.packet_count, + tree_sha256: rust_digest.sha256, + }, + expected_divergences: expected_output, + }, + }) +} + +fn source_lineage(raw: &ValidatedRawSide, manifest_path: String) -> Result { + let (raw_artifact_sha256, raw_artifact_size, raw_packet_count) = match raw.manifest.side { + RawSide::Cpp => ( + raw.manifest + .artifact + .sha256 + .clone() + .context("validated C++ SHA missing")?, + raw.manifest.artifact.size, + None, + ), + RawSide::Rust => ( + raw.manifest + .artifact + .tree_sha256 + .clone() + .context("validated Rust tree SHA missing")?, + None, + raw.manifest.artifact.packet_count, + ), + }; + Ok(SourceLineage { + manifest_path, + manifest_sha256: raw.manifest_sha256.clone(), + raw_artifact_sha256, + raw_artifact_size, + raw_packet_count, + harness_repo_head: raw.manifest.harness_repo_head.clone(), + source_repo_head: raw.manifest.source_repo_head.clone(), + harness_worktree_clean: raw.manifest.harness_worktree_clean, + harness_worktree_state_sha256: raw.manifest.harness_worktree_state_sha256.clone(), + source_worktree_dirty: raw.manifest.source_worktree_dirty, + source_worktree_state_sha256: raw.manifest.source_worktree_state_sha256.clone(), + worktree_state_algorithm: raw.manifest.worktree_state_algorithm.clone(), + expected_exec_path: raw.manifest.expected_exec_path.clone(), + expected_exec_sha256: raw.manifest.expected_exec_sha256.clone(), + source_exec_path: raw.manifest.source_exec_path.clone(), + source_exec_sha256: raw.manifest.source_exec_sha256.clone(), + live_exec_path: raw.manifest.live_exec_path.clone(), + live_exec_sha256: raw.manifest.live_exec_sha256.clone(), + executable_pin_enforced: raw.manifest.executable_pin_enforced, + pm2_entry_pid: raw.manifest.pm2_entry_pid, + pm2_exec_path: raw.manifest.pm2_exec_path.clone(), + pm2_exec_sha256: raw.manifest.pm2_exec_sha256.clone(), + listener_runtime_pid: raw.manifest.listener_runtime_pid, + listener_relationship_verified: raw.manifest.listener_relationship_verified, + restart_count: raw.manifest.restart_count, + effective_config_path: raw.manifest.effective_config_path.clone(), + effective_config_redacted_sha256: raw.manifest.effective_config_redacted_sha256.clone(), + effective_config_algorithm: raw.manifest.effective_config_algorithm.clone(), + pm2_entry_starttime: raw.manifest.pm2_entry_starttime, + pm2_profile_redacted_sha256: raw.manifest.pm2_profile_redacted_sha256.clone(), + listener_runtime_starttime: raw.manifest.listener_runtime_starttime, + runtime_cleanup_verified: raw.manifest.runtime_cleanup_verified, + normal_runtime_restored: raw.manifest.normal_runtime_restored, + fixture_guard: raw.manifest.fixture_guard.clone(), + bot_report: raw.manifest.bot_report.clone(), + retained_bot_report_path: raw.manifest.bot_report.as_ref().map(|_| { + format!( + "{RAW_PROVENANCE_DIR}/{}", + match raw.manifest.side { + RawSide::Cpp => CPP_BOT_REPORT_FILE, + RawSide::Rust => RUST_BOT_REPORT_FILE, + } + ) + }), + }) +} + +/// Verify the schema and every retained source/output hash for a required +/// flow. The raw captures themselves stay gitignored; their exact manifests +/// are committed and cross-bound to the hashes verified during import. +pub fn verify_required_lineage( + flow: &str, + flow_dir: &Path, + expected_selection: &ImportSelection, +) -> Result<()> { + let path = flow_dir.join(LINEAGE_FILE); + let bytes = read_regular_file(&path) + .with_context(|| format!("reading required lineage {}", path.display()))?; + let lineage: DerivedLineage = serde_json::from_slice(&bytes) + .with_context(|| format!("parsing required lineage {}", path.display()))?; + ensure!( + lineage.version == LINEAGE_VERSION, + "required lineage has unsupported version {}", + lineage.version + ); + ensure!( + lineage.flow == flow, + "required lineage flow does not match {flow:?}" + ); + ensure!(lineage.completed, "required lineage completed must be true"); + ensure!( + lineage.selection.strict, + "required lineage import was not strict" + ); + ensure!( + lineage.selection == *expected_selection, + "required lineage selection does not match the reviewed import contract" + ); + + verify_retained_source(flow, flow_dir, RawSide::Cpp, &lineage.sources.cpp)?; + verify_retained_source(flow, flow_dir, RawSide::Rust, &lineage.sources.rust)?; + ensure!( + lineage.sources.cpp.harness_repo_head == lineage.sources.rust.harness_repo_head + && lineage.sources.cpp.harness_worktree_state_sha256 + == lineage.sources.rust.harness_worktree_state_sha256 + && lineage.sources.cpp.worktree_state_algorithm + == lineage.sources.rust.worktree_state_algorithm, + "required lineage C++/Rust harness identities differ" + ); + if flow == "loot-single-item-claim" { + ensure!( + lineage.sources.cpp.fixture_guard == lineage.sources.rust.fixture_guard, + "required lineage C++/Rust fixture identities differ" + ); + let cpp_bot = lineage + .sources + .cpp + .bot_report + .as_ref() + .context("required lineage C++ source is missing canonical bot report identity")?; + let rust_bot = lineage + .sources + .rust + .bot_report + .as_ref() + .context("required lineage Rust source is missing canonical bot report identity")?; + ensure!( + cpp_bot.contract == rust_bot.contract + && cpp_bot.exec_path == rust_bot.exec_path + && cpp_bot.exec_sha256 == rust_bot.exec_sha256 + && cpp_bot.account == rust_bot.account + && cpp_bot.account_id == rust_bot.account_id + && cpp_bot.character_guid == rust_bot.character_guid, + "required lineage C++/Rust bot identities differ" + ); + } + verify_file_lineage(flow_dir, &lineage.outputs.cpp_pkt, "cpp.pkt")?; + verify_file_lineage( + flow_dir, + &lineage.outputs.expected_divergences, + "expected-divergences.json", + )?; + + ensure!( + lineage.outputs.rust.path == "rust", + "Rust output path must be rust" + ); + let rust_digest = digest_tree(&flow_dir.join("rust"), None)?; + ensure!( + rust_digest.sha256 == lineage.outputs.rust.tree_sha256, + "derived Rust output tree SHA-256 does not match lineage" + ); + ensure!( + rust_digest.file_count == lineage.outputs.rust.file_count, + "derived Rust output file count does not match lineage" + ); + ensure!( + rust_digest.packet_count == lineage.outputs.rust.packet_count, + "derived Rust output packet count does not match lineage" + ); + Ok(()) +} + +fn verify_retained_source( + flow: &str, + flow_dir: &Path, + side: RawSide, + source: &SourceLineage, +) -> Result<()> { + let expected_path = match side { + RawSide::Cpp => format!("{RAW_PROVENANCE_DIR}/{CPP_RAW_MANIFEST_FILE}"), + RawSide::Rust => format!("{RAW_PROVENANCE_DIR}/{RUST_RAW_MANIFEST_FILE}"), + }; + ensure!( + source.manifest_path == expected_path, + "retained {side:?} manifest path is not canonical" + ); + validate_sha256(&source.manifest_sha256, "retained raw manifest SHA-256")?; + validate_sha256(&source.raw_artifact_sha256, "retained raw artifact SHA-256")?; + validate_sha256( + &source.expected_exec_sha256, + "retained expected executable SHA-256", + )?; + validate_sha256( + &source.source_exec_sha256, + "retained source executable SHA-256", + )?; + validate_sha256(&source.live_exec_sha256, "retained live executable SHA-256")?; + validate_sha256( + &source.harness_worktree_state_sha256, + "retained harness worktree state SHA-256", + )?; + validate_sha256( + &source.source_worktree_state_sha256, + "retained source worktree state SHA-256", + )?; + validate_sha256(&source.pm2_exec_sha256, "retained PM2 executable SHA-256")?; + validate_sha256( + &source.pm2_profile_redacted_sha256, + "retained PM2 profile SHA-256", + )?; + validate_sha256( + &source.effective_config_redacted_sha256, + "retained effective config SHA-256", + )?; + ensure!( + source.executable_pin_enforced, + "required raw executable was not pinned" + ); + + let path = flow_dir.join(&source.manifest_path); + let raw = read_and_validate_raw_manifest(&path, flow, side, true)?; + ensure!( + raw.manifest_sha256 == source.manifest_sha256, + "retained {side:?} raw manifest SHA-256 does not match lineage" + ); + ensure!( + raw.manifest.harness_repo_head == source.harness_repo_head + && raw.manifest.source_repo_head == source.source_repo_head + && raw.manifest.harness_worktree_clean == source.harness_worktree_clean + && raw.manifest.harness_worktree_state_sha256 == source.harness_worktree_state_sha256 + && raw.manifest.source_worktree_dirty == source.source_worktree_dirty + && raw.manifest.source_worktree_state_sha256 == source.source_worktree_state_sha256 + && raw.manifest.worktree_state_algorithm == source.worktree_state_algorithm + && raw.manifest.expected_exec_path == source.expected_exec_path + && raw.manifest.expected_exec_sha256 == source.expected_exec_sha256 + && raw.manifest.source_exec_path == source.source_exec_path + && raw.manifest.source_exec_sha256 == source.source_exec_sha256 + && raw.manifest.live_exec_path == source.live_exec_path + && raw.manifest.live_exec_sha256 == source.live_exec_sha256 + && raw.manifest.executable_pin_enforced == source.executable_pin_enforced + && raw.manifest.pm2_entry_pid == source.pm2_entry_pid + && raw.manifest.pm2_entry_starttime == source.pm2_entry_starttime + && raw.manifest.pm2_exec_path == source.pm2_exec_path + && raw.manifest.pm2_exec_sha256 == source.pm2_exec_sha256 + && raw.manifest.pm2_profile_redacted_sha256 == source.pm2_profile_redacted_sha256 + && raw.manifest.listener_runtime_pid == source.listener_runtime_pid + && raw.manifest.listener_runtime_starttime == source.listener_runtime_starttime + && raw.manifest.listener_relationship_verified == source.listener_relationship_verified + && raw.manifest.restart_count == source.restart_count + && raw.manifest.effective_config_path == source.effective_config_path + && raw.manifest.effective_config_redacted_sha256 + == source.effective_config_redacted_sha256 + && raw.manifest.effective_config_algorithm == source.effective_config_algorithm + && raw.manifest.runtime_cleanup_verified == source.runtime_cleanup_verified + && raw.manifest.normal_runtime_restored == source.normal_runtime_restored + && raw.manifest.fixture_guard == source.fixture_guard + && raw.manifest.bot_report == source.bot_report, + "retained {side:?} source/process/config provenance does not match lineage" + ); + match (&raw.manifest.bot_report, &source.retained_bot_report_path) { + (Some(evidence), Some(relative)) => { + let canonical = match side { + RawSide::Cpp => format!("{RAW_PROVENANCE_DIR}/{CPP_BOT_REPORT_FILE}"), + RawSide::Rust => format!("{RAW_PROVENANCE_DIR}/{RUST_BOT_REPORT_FILE}"), + }; + ensure!( + relative == &canonical, + "retained {side:?} bot report path is not canonical" + ); + let bytes = read_regular_file(&flow_dir.join(relative))?; + ensure!( + sha256_bytes(&bytes) == evidence.report_sha256, + "retained {side:?} bot report SHA-256 does not match manifest" + ); + validate_bot_report_json(&bytes, evidence)?; + } + (None, None) => {} + _ => bail!("retained {side:?} bot report presence does not match manifest"), + } + match side { + RawSide::Cpp => { + ensure!( + raw.manifest.artifact.sha256.as_deref() + == Some(source.raw_artifact_sha256.as_str()), + "retained C++ raw artifact SHA-256 does not match lineage" + ); + ensure!( + raw.manifest.artifact.size == source.raw_artifact_size, + "retained C++ raw artifact size does not match lineage" + ); + ensure!( + source.raw_packet_count.is_none(), + "C++ lineage has a packet count" + ); + } + RawSide::Rust => { + ensure!( + raw.manifest.artifact.tree_sha256.as_deref() + == Some(source.raw_artifact_sha256.as_str()), + "retained Rust raw tree SHA-256 does not match lineage" + ); + ensure!( + raw.manifest.artifact.packet_count == source.raw_packet_count, + "retained Rust raw packet count does not match lineage" + ); + ensure!( + source.raw_artifact_size.is_none(), + "Rust lineage has an artifact size" + ); + } + } + Ok(()) +} + +fn file_lineage(root: &Path, relative: &str) -> Result { + let bytes = read_regular_file(&root.join(relative))?; + Ok(FileLineage { + path: relative.to_string(), + size: u64::try_from(bytes.len()).context("derived file size does not fit u64")?, + sha256: sha256_bytes(&bytes), + }) +} + +fn verify_file_lineage(root: &Path, expected: &FileLineage, canonical: &str) -> Result<()> { + ensure!( + expected.path == canonical, + "derived output path must be {canonical}" + ); + validate_sha256(&expected.sha256, "derived output SHA-256")?; + let actual = file_lineage(root, canonical)?; + ensure!( + actual == *expected, + "derived {canonical} size or SHA-256 does not match lineage" + ); + Ok(()) +} + +fn validate_sha256(value: &str, label: &str) -> Result<()> { + ensure!( + value.len() == SHA256_HEX_LEN + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')), + "{label} must be exactly 64 lowercase hexadecimal characters" + ); + Ok(()) +} + +fn valid_git_oid(value: &str) -> bool { + matches!(value.len(), 40 | 64) + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) +} + +fn validate_utc_timestamp(value: &str) -> Result<()> { + let bytes = value.as_bytes(); + ensure!( + bytes.len() == 20 + && bytes[4] == b'-' + && bytes[7] == b'-' + && bytes[10] == b'T' + && bytes[13] == b':' + && bytes[16] == b':' + && bytes[19] == b'Z' + && bytes + .iter() + .enumerate() + .all(|(index, byte)| matches!(index, 4 | 7 | 10 | 13 | 16 | 19) + || byte.is_ascii_digit()), + "created_at must be canonical UTC RFC3339 (YYYY-MM-DDTHH:MM:SSZ)" + ); + let number = |start: usize, end: usize| -> Result { + std::str::from_utf8(&bytes[start..end])? + .parse::() + .map_err(Into::into) + }; + let year = number(0, 4)?; + let month = number(5, 7)?; + let day = number(8, 10)?; + let hour = number(11, 13)?; + let minute = number(14, 16)?; + let second = number(17, 19)?; + ensure!(year >= 1970, "created_at year is before 1970"); + ensure!((1..=12).contains(&month), "created_at month is invalid"); + let leap = year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400)); + let days = match month { + 2 if leap => 29, + 2 => 28, + 4 | 6 | 9 | 11 => 30, + _ => 31, + }; + ensure!((1..=days).contains(&day), "created_at day is invalid"); + ensure!(hour < 24, "created_at hour is invalid"); + ensure!(minute < 60, "created_at minute is invalid"); + ensure!(second < 60, "created_at second is invalid"); + Ok(()) +} + +fn sha256_bytes(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn read_regular_file(path: &Path) -> Result> { + let metadata = + fs::symlink_metadata(path).with_context(|| format!("inspecting {}", path.display()))?; + ensure!( + metadata.file_type().is_file() && !metadata.file_type().is_symlink(), + "{} is not a regular non-symlink file", + path.display() + ); + let mut file = open_read_no_follow(path)?; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes) + .with_context(|| format!("reading {}", path.display()))?; + Ok(bytes) +} + +#[cfg(unix)] +fn open_read_no_follow(path: &Path) -> Result { + use std::os::unix::fs::OpenOptionsExt as _; + OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW) + .open(path) + .with_context(|| format!("opening {} without following symlinks", path.display())) +} + +#[cfg(not(unix))] +fn open_read_no_follow(path: &Path) -> Result { + File::open(path).with_context(|| format!("opening {}", path.display())) +} + +fn digest_tree(root: &Path, excluded_manifest: Option<&Path>) -> Result { + let root_meta = fs::symlink_metadata(root) + .with_context(|| format!("inspecting tree {}", root.display()))?; + ensure!( + root_meta.file_type().is_dir() && !root_meta.file_type().is_symlink(), + "{} is not a non-symlink directory", + root.display() + ); + let excluded = excluded_manifest.and_then(|path| path.canonicalize().ok()); + let mut files = BTreeMap::, PathBuf>::new(); + collect_tree_files(root, root, excluded.as_deref(), &mut files)?; + ensure!( + !files.is_empty(), + "{} contains no capture files", + root.display() + ); + + let mut hasher = Sha256::new(); + let mut packet_count = 0_u64; + for (relative, path) in &files { + let bytes = read_regular_file(path)?; + hasher.update(relative); + hasher.update([0]); + hasher.update(sha256_bytes(&bytes).as_bytes()); + hasher.update([0]); + if path.extension().and_then(|extension| extension.to_str()) == Some("meta") { + packet_count = packet_count + .checked_add(1) + .context("packet count overflow")?; + } + } + Ok(TreeDigest { + sha256: format!("{:x}", hasher.finalize()), + file_count: u64::try_from(files.len()).context("tree file count does not fit u64")?, + packet_count, + }) +} + +fn collect_tree_files( + root: &Path, + directory: &Path, + excluded: Option<&Path>, + files: &mut BTreeMap, PathBuf>, +) -> Result<()> { + let entries = fs::read_dir(directory) + .with_context(|| format!("reading directory {}", directory.display()))?; + for entry in entries { + let entry = entry?; + let path = entry.path(); + let metadata = fs::symlink_metadata(&path)?; + ensure!( + !metadata.file_type().is_symlink(), + "capture tree contains symlink {}", + path.display() + ); + if metadata.file_type().is_dir() { + collect_tree_files(root, &path, excluded, files)?; + } else if metadata.file_type().is_file() { + if path.file_name().and_then(|name| name.to_str()) == Some(RUST_RAW_MANIFEST_FILE) { + if excluded.is_some_and(|excluded| { + path.canonicalize() + .is_ok_and(|canonical| canonical == excluded) + }) { + continue; + } + bail!( + "capture tree contains unexpected or nested {RUST_RAW_MANIFEST_FILE} at {}", + path.display() + ); + } + if excluded.is_some_and(|excluded| { + path.canonicalize() + .is_ok_and(|canonical| canonical == excluded) + }) { + continue; + } + let relative = relative_path_bytes(root, &path)?; + ensure!( + files.insert(relative, path).is_none(), + "capture tree contains duplicate path bytes" + ); + } else { + bail!("capture tree contains unsupported entry {}", path.display()); + } + } + Ok(()) +} + +#[cfg(unix)] +fn relative_path_bytes(root: &Path, path: &Path) -> Result> { + use std::os::unix::ffi::OsStrExt as _; + Ok(path + .strip_prefix(root) + .with_context(|| format!("{} is outside {}", path.display(), root.display()))? + .as_os_str() + .as_bytes() + .to_vec()) +} + +#[cfg(not(unix))] +fn relative_path_bytes(root: &Path, path: &Path) -> Result> { + Ok(path + .strip_prefix(root) + .with_context(|| format!("{} is outside {}", path.display(), root.display()))? + .to_string_lossy() + .replace('\\', "/") + .into_bytes()) +} + +fn write_synced_file(path: &Path, bytes: &[u8]) -> Result<()> { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .with_context(|| format!("creating {}", path.display()))?; + file.write_all(bytes) + .with_context(|| format!("writing {}", path.display()))?; + file.sync_all() + .with_context(|| format!("syncing {}", path.display()))?; + Ok(()) +} + +fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> { + let parent = path.parent().context("atomic output has no parent")?; + let name = path + .file_name() + .and_then(|name| name.to_str()) + .context("atomic output name is not UTF-8")?; + for attempt in 0..100_u64 { + let temp = parent.join(format!( + ".{name}.partial.{}.{}.{}", + std::process::id(), + STAGING_COUNTER.fetch_add(1, Ordering::Relaxed), + attempt + )); + match write_synced_file(&temp, bytes) { + Ok(()) => { + if let Err(error) = fs::rename(&temp, path) { + let _ = fs::remove_file(&temp); + return Err(error).with_context(|| format!("publishing {}", path.display())); + } + sync_directory(parent)?; + return Ok(()); + } + Err(error) + if error + .downcast_ref::() + .is_some_and(|io| io.kind() == std::io::ErrorKind::AlreadyExists) => {} + Err(error) => return Err(error), + } + } + bail!( + "could not allocate an atomic staging file for {}", + path.display() + ) +} + +fn sync_directory(path: &Path) -> Result<()> { + File::open(path) + .with_context(|| format!("opening directory {} for sync", path.display()))? + .sync_all() + .with_context(|| format!("syncing directory {}", path.display())) +} + +/// A complete flow prepared outside its published path. Dropping this value +/// before [`AtomicFlowImport::publish`] is equivalent to an interrupted import: +/// the old flow remains byte-for-byte visible and the staging tree is removed. +pub struct AtomicFlowImport { + root: PathBuf, + target: PathBuf, + staging: PathBuf, + target_existed_at_prepare: bool, + published: bool, +} + +impl AtomicFlowImport { + /// Prepare a private complete-tree staging directory, copying only the + /// flow's hand-reviewed metadata from the previous generation. + pub fn prepare(root: &Path, flow: &str) -> Result { + fs::create_dir_all(root) + .with_context(|| format!("creating flow root {}", root.display()))?; + let target = root.join(flow); + let target_existed_at_prepare = match fs::symlink_metadata(&target) { + Ok(metadata) => { + ensure!( + metadata.file_type().is_dir() && !metadata.file_type().is_symlink(), + "published flow {} is not a non-symlink directory", + target.display() + ); + true + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, + Err(error) => return Err(error).context("inspecting published flow target"), + }; + let staging = allocate_staging_directory(root, flow)?; + let mut transaction = Self { + root: root.to_path_buf(), + target, + staging, + target_existed_at_prepare, + published: false, + }; + if transaction.target_existed_at_prepare + && let Err(error) = copy_reviewed_metadata(&transaction.target, &transaction.staging) + { + let _ = fs::remove_dir_all(&transaction.staging); + transaction.published = true; + return Err(error); + } + Ok(transaction) + } + + /// Directory into which all derived artifacts and the lineage marker must + /// be written and validated before publication. + #[must_use] + pub fn staging_dir(&self) -> &Path { + &self.staging + } + + /// Atomically publish the entire complete flow. Existing flows use Linux + /// `RENAME_EXCHANGE`, so a process death exposes either the old complete + /// tree or the new complete tree, never a mixture of their files. + pub fn publish(mut self) -> Result<()> { + sync_tree(&self.staging)?; + if self.target_existed_at_prepare { + atomic_exchange_directories(&self.staging, &self.target)?; + self.published = true; + sync_directory(&self.root)?; + if let Err(error) = fs::remove_dir_all(&self.staging) { + eprintln!( + "capture-diff: warning: imported flow is complete, but old staging tree {} could not be removed: {error}", + self.staging.display() + ); + } else { + sync_directory(&self.root)?; + } + } else { + atomic_publish_new_directory(&self.staging, &self.target)?; + self.published = true; + sync_directory(&self.root)?; + } + Ok(()) + } +} + +impl Drop for AtomicFlowImport { + fn drop(&mut self) { + if !self.published { + let _ = fs::remove_dir_all(&self.staging); + } + } +} + +fn allocate_staging_directory(root: &Path, flow: &str) -> Result { + for _ in 0..100_u64 { + let candidate = root.join(format!( + ".{flow}.import-partial.{}.{}", + std::process::id(), + STAGING_COUNTER.fetch_add(1, Ordering::Relaxed) + )); + match fs::create_dir(&candidate) { + Ok(()) => return Ok(candidate), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => { + return Err(error) + .with_context(|| format!("creating staging flow {}", candidate.display())); + } + } + } + bail!("could not allocate a staging directory for flow {flow:?}") +} + +fn is_generated_entry(name: &str) -> bool { + matches!( + name, + "cpp.pkt" | "rust" | "expected-divergences.json" | LINEAGE_FILE | RAW_PROVENANCE_DIR + ) +} + +fn copy_reviewed_metadata(source: &Path, destination: &Path) -> Result<()> { + for entry in fs::read_dir(source)? { + let entry = entry?; + let name = entry + .file_name() + .into_string() + .map_err(|_| anyhow::anyhow!("flow metadata filename is not UTF-8"))?; + if is_generated_entry(&name) { + continue; + } + ensure!( + matches!( + name.as_str(), + "README.md" | "flow.json" | "requirement.json" + ), + "flow contains unknown non-generated entry {name:?}; import copies only README.md, flow.json, and requirement.json" + ); + copy_tree_entry(&entry.path(), &destination.join(name))?; + } + Ok(()) +} + +fn copy_tree_entry(source: &Path, destination: &Path) -> Result<()> { + let metadata = fs::symlink_metadata(source)?; + ensure!( + !metadata.file_type().is_symlink(), + "flow metadata contains symlink {}", + source.display() + ); + if metadata.file_type().is_file() { + fs::copy(source, destination).with_context(|| { + format!( + "copying flow metadata {} to {}", + source.display(), + destination.display() + ) + })?; + Ok(()) + } else { + bail!( + "reviewed flow metadata must be a regular file: {}", + source.display() + ) + } +} + +fn sync_tree(root: &Path) -> Result<()> { + let metadata = fs::symlink_metadata(root)?; + ensure!( + metadata.file_type().is_dir(), + "{} is not a directory", + root.display() + ); + for entry in fs::read_dir(root)? { + let entry = entry?; + let path = entry.path(); + let metadata = fs::symlink_metadata(&path)?; + ensure!( + !metadata.file_type().is_symlink(), + "staging tree contains symlink {}", + path.display() + ); + if metadata.file_type().is_dir() { + sync_tree(&path)?; + } else if metadata.file_type().is_file() { + File::open(&path)?.sync_all()?; + } else { + bail!("staging tree contains unsupported entry {}", path.display()); + } + } + sync_directory(root) +} + +#[cfg(target_os = "linux")] +#[allow(unsafe_code)] +fn atomic_exchange_directories(left: &Path, right: &Path) -> Result<()> { + use std::os::unix::ffi::OsStrExt as _; + + let left = CString::new(left.as_os_str().as_bytes()).context("staging path contains NUL")?; + let right = CString::new(right.as_os_str().as_bytes()).context("target path contains NUL")?; + // SAFETY: both C strings remain alive for the call, contain terminating + // NUL bytes supplied by `CString`, and `renameat2` does not retain them. + let result = unsafe { + libc::renameat2( + libc::AT_FDCWD, + left.as_ptr(), + libc::AT_FDCWD, + right.as_ptr(), + libc::RENAME_EXCHANGE, + ) + }; + if result == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()).with_context(|| { + format!( + "atomically exchanging staged flow {} with {}", + left.to_string_lossy(), + right.to_string_lossy() + ) + }) + } +} + +#[cfg(target_os = "linux")] +#[allow(unsafe_code)] +fn atomic_publish_new_directory(source: &Path, target: &Path) -> Result<()> { + use std::os::unix::ffi::OsStrExt as _; + + let source = + CString::new(source.as_os_str().as_bytes()).context("staging path contains NUL")?; + let target = CString::new(target.as_os_str().as_bytes()).context("target path contains NUL")?; + // SAFETY: both C strings remain alive and NUL-terminated for renameat2; + // RENAME_NOREPLACE makes a concurrent target creation fail atomically. + let result = unsafe { + libc::renameat2( + libc::AT_FDCWD, + source.as_ptr(), + libc::AT_FDCWD, + target.as_ptr(), + libc::RENAME_NOREPLACE, + ) + }; + if result == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()).with_context(|| { + format!( + "atomically publishing staged flow {} as new target {} without replacement", + source.to_string_lossy(), + target.to_string_lossy() + ) + }) + } +} + +#[cfg(not(target_os = "linux"))] +fn atomic_exchange_directories(_left: &Path, _right: &Path) -> Result<()> { + bail!("replacing an existing flow atomically requires Linux renameat2(RENAME_EXCHANGE)") +} + +#[cfg(not(target_os = "linux"))] +fn atomic_publish_new_directory(_source: &Path, _target: &Path) -> Result<()> { + bail!("publishing a new flow without replacement requires Linux renameat2(RENAME_NOREPLACE)") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_root(label: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!( + "capture-diff-lineage-{label}-{}-{}", + std::process::id(), + STAGING_COUNTER.fetch_add(1, Ordering::Relaxed) + )); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(&root).unwrap(); + root + } + + fn fake_oid() -> String { + "a".repeat(40) + } + + fn make_raw_pair(root: &Path, flow: &str) -> (PathBuf, PathBuf, PathBuf, PathBuf) { + let raw = root.join("raw"); + let rust = raw.join("rust"); + fs::create_dir_all(&rust).unwrap(); + let cpp = raw.join("cpp.pkt"); + fs::write(&cpp, b"raw-cpp").unwrap(); + fs::write( + rust.join("one.meta"), + b"direction=c2s\nseq=0\nopcode=0x0001\n", + ) + .unwrap(); + fs::write(rust.join("one.bin"), [1_u8, 0]).unwrap(); + let cpp_manifest = raw.join(CPP_RAW_MANIFEST_FILE); + let rust_manifest = rust.join(RUST_RAW_MANIFEST_FILE); + let rust_digest = digest_tree(&rust, Some(&rust_manifest)).unwrap(); + let cpp_json = serde_json::json!({ + "version": 3, + "flow": flow, + "side": "cpp", + "completed": true, + "created_at": "2026-07-19T00:00:00Z", + "harness_repo_head": fake_oid(), + "source_repo_head": "d".repeat(40), + "harness_worktree_clean": true, + "harness_worktree_state_sha256": "1".repeat(64), + "source_worktree_dirty": true, + "source_worktree_state_sha256": "2".repeat(64), + "worktree_state_algorithm": "git-head-path-mode-content-sha256-v1", + "expected_exec_path": "/opt/trinity/worldserver", + "expected_exec_sha256": "b".repeat(64), + "source_exec_path": "/opt/trinity/worldserver", + "source_exec_sha256": "b".repeat(64), + "live_exec_path": "/opt/trinity/worldserver", + "live_exec_sha256": "b".repeat(64), + "executable_pin_enforced": true, + "pm2_entry_pid": 122, + "pm2_entry_starttime": 1001, + "pm2_exec_path": "/opt/trinity/worldserver-wrapper.sh", + "pm2_exec_sha256": "3".repeat(64), + "pm2_profile_redacted_sha256": "5".repeat(64), + "listener_runtime_pid": 123, + "listener_runtime_starttime": 1002, + "listener_relationship_verified": true, + "restart_count": 2, + "effective_config_path": "/etc/trinity/worldserver.conf", + "effective_config_redacted_sha256": "e".repeat(64), + "effective_config_algorithm": "capture-relevant-redacted-v1", + "runtime_cleanup_verified": true, + "normal_runtime_restored": true, + "artifact": { + "path": "cpp.pkt", + "size": 7, + "sha256": sha256_bytes(b"raw-cpp") + } + }); + fs::write(&cpp_manifest, serde_json::to_vec_pretty(&cpp_json).unwrap()).unwrap(); + let rust_json = serde_json::json!({ + "version": 3, + "flow": flow, + "side": "rust", + "completed": true, + "created_at": "2026-07-19T00:00:01Z", + "harness_repo_head": fake_oid(), + "source_repo_head": fake_oid(), + "harness_worktree_clean": true, + "harness_worktree_state_sha256": "1".repeat(64), + "source_worktree_dirty": false, + "source_worktree_state_sha256": "1".repeat(64), + "worktree_state_algorithm": "git-head-path-mode-content-sha256-v1", + "expected_exec_path": "/opt/rustycore/world-server", + "expected_exec_sha256": "c".repeat(64), + "source_exec_path": "/opt/rustycore/world-server", + "source_exec_sha256": "c".repeat(64), + "live_exec_path": "/opt/rustycore/world-server", + "live_exec_sha256": "c".repeat(64), + "executable_pin_enforced": true, + "pm2_entry_pid": 456, + "pm2_entry_starttime": 2001, + "pm2_exec_path": "/opt/rustycore/world-server", + "pm2_exec_sha256": "c".repeat(64), + "pm2_profile_redacted_sha256": "6".repeat(64), + "listener_runtime_pid": 456, + "listener_runtime_starttime": 2001, + "listener_relationship_verified": true, + "restart_count": 3, + "effective_config_path": "/etc/rustycore/worldserver.conf", + "effective_config_redacted_sha256": "f".repeat(64), + "effective_config_algorithm": "capture-relevant-redacted-v1", + "runtime_cleanup_verified": true, + "normal_runtime_restored": true, + "artifact": { + "path": "rust", + "packet_count": rust_digest.packet_count, + "tree_sha256": rust_digest.sha256 + } + }); + fs::write( + &rust_manifest, + serde_json::to_vec_pretty(&rust_json).unwrap(), + ) + .unwrap(); + if flow == "loot-single-item-claim" { + let fixture = serde_json::json!({ + "enabled": true, + "contract": "loot-single-item-claim-fixture-v1", + "account": "TESTBOT2@bot.local", + "account_id": 9, + "character_guid": 15, + "peer_account": "TESTBOT3@bot.local", + "peer_account_id": 10, + "peer_character_guid": 16, + "creature_entry": 21779, + "creature_spawn_guid": 1117, + "item_entry": 30712, + "cleanup_verified": true + }); + let report_json = serde_json::json!({ + "loot_item_capture": true, + "loot_race_smoke": false, + "results": [{ + "account": "TESTBOT2@bot.local", + "account_id": 9, + "character_guid": 15, + "world_auth": true, + "enum_characters": true, + "player_login_verified": true, + "loot_race_smoke": true, + "loot_race_smoke_passed": true, + "loot_race_target_entry": 21779, + "loot_race_target_spawn_guid": 1117, + "loot_race_target_discovered": true, + "loot_race_loot_opened": true, + "loot_race_item_push_seen": true, + "loot_race_loot_removed_seen": true, + "loot_race_loot_coins": 0, + "loot_race_coin_removed_seen": false, + "loot_race_db_item_total": 1, + "loot_race_db_money_delta": 0, + "loot_race_relog_verified": true, + "loot_race_failure": null + }] + }); + let report_bytes = serde_json::to_vec_pretty(&report_json).unwrap(); + let cpp_report = raw.join("cpp-report.json"); + let rust_report = raw.join("rust-report.json"); + fs::write(&cpp_report, &report_bytes).unwrap(); + fs::write(&rust_report, &report_bytes).unwrap(); + + for (manifest_path, report_path) in + [(&cpp_manifest, cpp_report), (&rust_manifest, rust_report)] + { + let mut manifest: serde_json::Value = + serde_json::from_slice(&fs::read(manifest_path).unwrap()).unwrap(); + manifest["fixture_guard"] = fixture.clone(); + manifest["bot_report"] = serde_json::json!({ + "contract": "wow-test-bot-loot-item-capture-report-v1", + "exec_path": "/opt/rustycore/wow-test-bot", + "exec_sha256": "7".repeat(64), + "report_path": report_path.to_string_lossy(), + "report_sha256": sha256_bytes(&report_bytes), + "account": "TESTBOT2@bot.local", + "account_id": 9, + "character_guid": 15, + "report_validated": true + }); + fs::write(manifest_path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap(); + } + } + (cpp, cpp_manifest, rust, rust_manifest) + } + + fn make_derived_flow(root: &Path, flow: &str, raw: &ValidatedRawPair) -> PathBuf { + let flow_dir = root.join(flow); + fs::create_dir_all(flow_dir.join("rust")).unwrap(); + fs::write(flow_dir.join("cpp.pkt"), b"filtered-cpp").unwrap(); + fs::write(flow_dir.join("rust/one.meta"), b"derived-meta").unwrap(); + fs::write(flow_dir.join("rust/one.bin"), b"derived-bin").unwrap(); + fs::write(flow_dir.join("expected-divergences.json"), b"[]").unwrap(); + write_derived_lineage( + flow, + &flow_dir, + raw, + ImportSelection::new(vec![Direction::S2C, Direction::C2S], None, None, &[], true), + ) + .unwrap(); + flow_dir + } + + fn required_selection() -> ImportSelection { + ImportSelection::new(vec![Direction::S2C, Direction::C2S], None, None, &[], true) + } + + #[test] + fn raw_manifest_or_artifact_tamper_is_rejected() { + let root = test_root("raw-tamper"); + let flow = "required-flow"; + let (cpp, cpp_manifest, rust, rust_manifest) = make_raw_pair(&root, flow); + validate_raw_pair(flow, &cpp, &cpp_manifest, &rust, &rust_manifest, true).unwrap(); + + fs::write(&cpp, b"tampered").unwrap(); + let error = validate_raw_pair(flow, &cpp, &cpp_manifest, &rust, &rust_manifest, true) + .expect_err("raw artifact tamper must fail"); + assert!(error.to_string().contains("size") || error.to_string().contains("SHA-256")); + fs::write(&cpp, b"raw-cpp").unwrap(); + + let mut json: serde_json::Value = + serde_json::from_slice(&fs::read(&rust_manifest).unwrap()).unwrap(); + json["artifact"]["tree_sha256"] = serde_json::Value::String("d".repeat(64)); + fs::write(&rust_manifest, serde_json::to_vec_pretty(&json).unwrap()).unwrap(); + let error = validate_raw_pair(flow, &cpp, &cpp_manifest, &rust, &rust_manifest, true) + .expect_err("raw manifest hash tamper must fail"); + assert!(error.to_string().contains("tree SHA-256")); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn raw_manifest_requires_complete_consistent_process_provenance() { + let root = test_root("raw-provenance"); + let flow = "required-flow"; + let (cpp, cpp_manifest, rust, rust_manifest) = make_raw_pair(&root, flow); + let original = fs::read(&cpp_manifest).unwrap(); + + let mut json: serde_json::Value = serde_json::from_slice(&original).unwrap(); + json.as_object_mut().unwrap().remove("source_repo_head"); + fs::write(&cpp_manifest, serde_json::to_vec_pretty(&json).unwrap()).unwrap(); + let error = validate_raw_pair(flow, &cpp, &cpp_manifest, &rust, &rust_manifest, true) + .expect_err("missing source HEAD must fail schema validation"); + assert!(format!("{error:#}").contains("parsing raw manifest")); + + let mut json: serde_json::Value = serde_json::from_slice(&original).unwrap(); + json["live_exec_sha256"] = serde_json::Value::String("9".repeat(64)); + fs::write(&cpp_manifest, serde_json::to_vec_pretty(&json).unwrap()).unwrap(); + let error = validate_raw_pair(flow, &cpp, &cpp_manifest, &rust, &rust_manifest, true) + .expect_err("source/live executable mismatch must fail"); + assert!(format!("{error:#}").contains("expected/source/live executable SHA-256")); + + let mut json: serde_json::Value = serde_json::from_slice(&original).unwrap(); + json["harness_worktree_clean"] = serde_json::Value::Bool(false); + fs::write(&cpp_manifest, serde_json::to_vec_pretty(&json).unwrap()).unwrap(); + let error = validate_raw_pair(flow, &cpp, &cpp_manifest, &rust, &rust_manifest, true) + .expect_err("dirty capture harness must fail"); + assert!(format!("{error:#}").contains("harness worktree must be clean")); + + let mut json: serde_json::Value = serde_json::from_slice(&original).unwrap(); + json.as_object_mut().unwrap().remove("pm2_exec_sha256"); + fs::write(&cpp_manifest, serde_json::to_vec_pretty(&json).unwrap()).unwrap(); + let error = validate_raw_pair(flow, &cpp, &cpp_manifest, &rust, &rust_manifest, true) + .expect_err("missing PM2 entrypoint hash must fail schema validation"); + assert!(format!("{error:#}").contains("parsing raw manifest")); + + let mut json: serde_json::Value = serde_json::from_slice(&original).unwrap(); + json["created_at"] = serde_json::Value::String("yesterday".to_string()); + fs::write(&cpp_manifest, serde_json::to_vec_pretty(&json).unwrap()).unwrap(); + let error = validate_raw_pair(flow, &cpp, &cpp_manifest, &rust, &rust_manifest, true) + .expect_err("non-RFC3339 timestamp must fail"); + assert!(format!("{error:#}").contains("RFC3339")); + + fs::write(&cpp_manifest, &original).unwrap(); + let mut rust_json: serde_json::Value = + serde_json::from_slice(&fs::read(&rust_manifest).unwrap()).unwrap(); + rust_json["source_worktree_dirty"] = serde_json::Value::Bool(true); + fs::write( + &rust_manifest, + serde_json::to_vec_pretty(&rust_json).unwrap(), + ) + .unwrap(); + let error = validate_raw_pair(flow, &cpp, &cpp_manifest, &rust, &rust_manifest, true) + .expect_err("dirty Rust source worktree must fail"); + assert!(format!("{error:#}").contains("same clean state")); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn nested_raw_manifest_is_rejected_instead_of_excluded_from_tree_hash() { + let root = test_root("nested-raw-manifest"); + let flow = "required-flow"; + let (cpp, cpp_manifest, rust, rust_manifest) = make_raw_pair(&root, flow); + let nested = rust.join("nested"); + fs::create_dir(&nested).unwrap(); + fs::write(nested.join(RUST_RAW_MANIFEST_FILE), b"{}").unwrap(); + + let error = validate_raw_pair(flow, &cpp, &cpp_manifest, &rust, &rust_manifest, true) + .expect_err("nested raw manifest must fail rather than disappear from hashing"); + assert!(format!("{error:#}").contains("unexpected or nested")); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn missing_raw_or_derived_manifest_is_rejected() { + let root = test_root("missing-manifest"); + let flow = "required-flow"; + let (cpp, cpp_manifest, rust, rust_manifest) = make_raw_pair(&root, flow); + let raw = + validate_raw_pair(flow, &cpp, &cpp_manifest, &rust, &rust_manifest, true).unwrap(); + + fs::remove_file(&cpp_manifest).unwrap(); + let error = validate_raw_pair(flow, &cpp, &cpp_manifest, &rust, &rust_manifest, true) + .expect_err("missing C++ raw manifest must fail"); + assert!(error.to_string().contains("C++ raw manifest")); + + let flow_dir = make_derived_flow(&root, flow, &raw); + fs::remove_file(flow_dir.join(LINEAGE_FILE)).unwrap(); + let error = verify_required_lineage(flow, &flow_dir, &required_selection()) + .expect_err("missing derived lineage must fail"); + assert!(error.to_string().contains("reading required lineage")); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn verify_rejects_retained_manifest_and_output_tamper() { + let root = test_root("derived-tamper"); + let flow = "required-flow"; + let (cpp, cpp_manifest, rust, rust_manifest) = make_raw_pair(&root, flow); + let raw = + validate_raw_pair(flow, &cpp, &cpp_manifest, &rust, &rust_manifest, true).unwrap(); + let flow_dir = make_derived_flow(&root, flow, &raw); + verify_required_lineage(flow, &flow_dir, &required_selection()).unwrap(); + + fs::write(flow_dir.join("rust/one.bin"), b"tampered-output").unwrap(); + let error = verify_required_lineage(flow, &flow_dir, &required_selection()) + .expect_err("derived output tamper must fail"); + assert!(error.to_string().contains("tree SHA-256")); + fs::write(flow_dir.join("rust/one.bin"), b"derived-bin").unwrap(); + + let retained = flow_dir + .join(RAW_PROVENANCE_DIR) + .join(CPP_RAW_MANIFEST_FILE); + let mut json: serde_json::Value = + serde_json::from_slice(&fs::read(&retained).unwrap()).unwrap(); + json["artifact"]["sha256"] = serde_json::Value::String("e".repeat(64)); + fs::write(&retained, serde_json::to_vec_pretty(&json).unwrap()).unwrap(); + let error = verify_required_lineage(flow, &flow_dir, &required_selection()) + .expect_err("retained raw manifest tamper must fail"); + assert!(error.to_string().contains("raw manifest SHA-256")); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn verify_rejects_lineage_schema_and_hash_tamper() { + let root = test_root("lineage-tamper"); + let flow = "required-flow"; + let (cpp, cpp_manifest, rust, rust_manifest) = make_raw_pair(&root, flow); + let raw = + validate_raw_pair(flow, &cpp, &cpp_manifest, &rust, &rust_manifest, true).unwrap(); + let flow_dir = make_derived_flow(&root, flow, &raw); + + let path = flow_dir.join(LINEAGE_FILE); + let original = fs::read(&path).unwrap(); + let mut json: serde_json::Value = serde_json::from_slice(&original).unwrap(); + json["outputs"]["cpp_pkt"]["sha256"] = serde_json::Value::String("f".repeat(64)); + fs::write(&path, serde_json::to_vec_pretty(&json).unwrap()).unwrap(); + let error = verify_required_lineage(flow, &flow_dir, &required_selection()) + .expect_err("lineage output hash tamper must fail"); + assert!(error.to_string().contains("cpp.pkt")); + + let mut json: serde_json::Value = serde_json::from_slice(&original).unwrap(); + json["selection"]["ignored_opcodes"] = serde_json::json!([{ + "direction": "s2c", + "opcode": 11732 + }]); + fs::write(&path, serde_json::to_vec_pretty(&json).unwrap()).unwrap(); + let error = verify_required_lineage(flow, &flow_dir, &required_selection()) + .expect_err("an extra derived-flow filter must fail the reviewed contract"); + assert!(error.to_string().contains("reviewed import contract")); + + let mut json: serde_json::Value = serde_json::from_slice(&original).unwrap(); + json["unexpected"] = serde_json::Value::Bool(true); + fs::write(&path, serde_json::to_vec_pretty(&json).unwrap()).unwrap(); + let error = verify_required_lineage(flow, &flow_dir, &required_selection()) + .expect_err("unknown lineage fields must fail schema validation"); + assert!(error.to_string().contains("parsing required lineage")); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn interrupted_import_before_exchange_leaves_old_flow_untouched() { + let root = test_root("interrupted-import"); + let target = root.join("required-flow"); + fs::create_dir_all(&target).unwrap(); + fs::write(target.join("README.md"), b"reviewed metadata").unwrap(); + fs::write(target.join("cpp.pkt"), b"old-complete-flow").unwrap(); + + let staging_path; + { + let transaction = AtomicFlowImport::prepare(&root, "required-flow").unwrap(); + staging_path = transaction.staging_dir().to_path_buf(); + fs::write( + transaction.staging_dir().join("cpp.pkt"), + b"new-partial-flow", + ) + .unwrap(); + // A signal/error before publish drops the transaction here. + } + + assert_eq!( + fs::read(target.join("cpp.pkt")).unwrap(), + b"old-complete-flow" + ); + assert_eq!( + fs::read(target.join("README.md")).unwrap(), + b"reviewed metadata" + ); + assert!(!staging_path.exists()); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn import_rejects_unknown_existing_metadata_instead_of_carrying_it_forward() { + let root = test_root("unknown-metadata"); + let target = root.join("required-flow"); + fs::create_dir_all(&target).unwrap(); + fs::write(target.join("README.md"), b"reviewed").unwrap(); + fs::write(target.join("unreviewed.secret"), b"must not propagate").unwrap(); + + let error = AtomicFlowImport::prepare(&root, "required-flow") + .err() + .expect("unknown metadata must fail closed"); + assert!(error.to_string().contains("unknown non-generated entry")); + assert_eq!( + fs::read(target.join("unreviewed.secret")).unwrap(), + b"must not propagate" + ); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn complete_existing_flow_is_exchanged_as_one_generation() { + let root = test_root("atomic-exchange"); + let target = root.join("required-flow"); + fs::create_dir_all(&target).unwrap(); + fs::write(target.join("README.md"), b"reviewed metadata").unwrap(); + fs::write(target.join("cpp.pkt"), b"old").unwrap(); + + let transaction = AtomicFlowImport::prepare(&root, "required-flow").unwrap(); + fs::write(transaction.staging_dir().join("cpp.pkt"), b"new").unwrap(); + fs::write(transaction.staging_dir().join(LINEAGE_FILE), b"complete").unwrap(); + transaction.publish().unwrap(); + + assert_eq!(fs::read(target.join("cpp.pkt")).unwrap(), b"new"); + assert_eq!(fs::read(target.join(LINEAGE_FILE)).unwrap(), b"complete"); + assert_eq!( + fs::read(target.join("README.md")).unwrap(), + b"reviewed metadata" + ); + assert!(fs::read_dir(&root).unwrap().all(|entry| { + !entry + .unwrap() + .file_name() + .to_string_lossy() + .contains("partial") + })); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn loot_raw_pair_requires_canonical_guard_bot_report_and_cross_side_identity() { + let root = test_root("loot-identity"); + let flow = "loot-single-item-claim"; + let (cpp, cpp_manifest, rust, rust_manifest) = make_raw_pair(&root, flow); + validate_raw_pair(flow, &cpp, &cpp_manifest, &rust, &rust_manifest, true).unwrap(); + + let original = fs::read(&rust_manifest).unwrap(); + let mut json: serde_json::Value = serde_json::from_slice(&original).unwrap(); + json["fixture_guard"]["enabled"] = serde_json::Value::Bool(false); + fs::write(&rust_manifest, serde_json::to_vec_pretty(&json).unwrap()).unwrap(); + let error = validate_raw_pair(flow, &cpp, &cpp_manifest, &rust, &rust_manifest, true) + .expect_err("disabled fixture guard must fail"); + assert!(format!("{error:#}").contains("fixture_guard.enabled")); + + let mut json: serde_json::Value = serde_json::from_slice(&original).unwrap(); + json["bot_report"]["exec_sha256"] = serde_json::Value::String("8".repeat(64)); + fs::write(&rust_manifest, serde_json::to_vec_pretty(&json).unwrap()).unwrap(); + let error = validate_raw_pair(flow, &cpp, &cpp_manifest, &rust, &rust_manifest, true) + .expect_err("different bot binary identity must fail"); + assert!(format!("{error:#}").contains("different canonical bot identities")); + + fs::write(&rust_manifest, &original).unwrap(); + let report_path = serde_json::from_slice::(&original).unwrap() + ["bot_report"]["report_path"] + .as_str() + .unwrap() + .to_string(); + fs::write(&report_path, b"{}").unwrap(); + let error = validate_raw_pair(flow, &cpp, &cpp_manifest, &rust, &rust_manifest, true) + .expect_err("bot report tamper must fail"); + assert!(format!("{error:#}").contains("bot report SHA-256")); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn derived_loot_lineage_retains_and_revalidates_bot_reports() { + let root = test_root("loot-report-retention"); + let flow = "loot-single-item-claim"; + let (cpp, cpp_manifest, rust, rust_manifest) = make_raw_pair(&root, flow); + let raw = + validate_raw_pair(flow, &cpp, &cpp_manifest, &rust, &rust_manifest, true).unwrap(); + let flow_dir = make_derived_flow(&root, flow, &raw); + verify_required_lineage(flow, &flow_dir, &required_selection()).unwrap(); + + fs::write( + flow_dir.join(RAW_PROVENANCE_DIR).join(RUST_BOT_REPORT_FILE), + b"{}", + ) + .unwrap(); + let error = verify_required_lineage(flow, &flow_dir, &required_selection()) + .expect_err("retained bot report tamper must fail"); + assert!(error.to_string().contains("bot report SHA-256")); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn new_flow_publication_is_atomic_noreplace_under_target_race() { + let root = test_root("atomic-noreplace"); + let transaction = AtomicFlowImport::prepare(&root, "new-flow").unwrap(); + fs::write(transaction.staging_dir().join("cpp.pkt"), b"candidate").unwrap(); + + let target = root.join("new-flow"); + fs::create_dir(&target).unwrap(); + fs::write(target.join("sentinel"), b"concurrent owner").unwrap(); + let error = transaction + .publish() + .expect_err("concurrent target must never be replaced"); + assert!(format!("{error:#}").contains("without replacement")); + assert_eq!( + fs::read(target.join("sentinel")).unwrap(), + b"concurrent owner" + ); + assert!(!target.join("cpp.pkt").exists()); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/crates/capture-diff/src/main.rs b/crates/capture-diff/src/main.rs index 3e8f3ba46..95ac867e2 100644 --- a/crates/capture-diff/src/main.rs +++ b/crates/capture-diff/src/main.rs @@ -8,19 +8,21 @@ //! capture-diff diff --cpp A.pkt --rust DIR [...] # ad-hoc, no flow //! capture-diff show # list a capture //! capture-diff list # list known flows +//! capture-diff verify-required # require clean real artifacts //! capture-diff update-baseline [--rust DIR] # rewrite baseline //! ``` // Product names (RustyCore, TrinityCore) appear throughout the docs as prose. #![allow(clippy::doc_markdown)] +use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::process::ExitCode; use anyhow::{Context, Result, bail}; use capture_diff::diff::{self, BaselineDelta, DivergenceSignature}; -use capture_diff::{Capture, DiffReport, Direction, PacketBoundary, flow, pkt, rustdump}; +use capture_diff::{Capture, DiffReport, Direction, PacketBoundary, flow, lineage, pkt, rustdump}; fn main() -> ExitCode { match run() { @@ -45,12 +47,15 @@ fn run() -> Result { "show" => cmd_show(&rest), "list" => cmd_list(), "import" => cmd_import(&rest), + "verify-required" => cmd_verify_required(&rest), "update-baseline" => cmd_update_baseline(&rest), "-h" | "--help" | "help" => { print_usage(); Ok(ExitCode::SUCCESS) } - other => bail!("unknown command '{other}' (try: diff, show, list, update-baseline)"), + other => bail!( + "unknown command '{other}' (try: diff, show, list, verify-required, update-baseline)" + ), } } @@ -58,7 +63,9 @@ fn run() -> Result { struct Opts { positional: Option, cpp: Option, + cpp_manifest: Option, rust: Option, + rust_manifest: Option, direction: Option>, baseline: Option, from_opcode: Option, @@ -72,7 +79,9 @@ fn parse_opts(args: &[String]) -> Result { let mut opts = Opts { positional: None, cpp: None, + cpp_manifest: None, rust: None, + rust_manifest: None, direction: None, baseline: None, from_opcode: None, @@ -85,7 +94,13 @@ fn parse_opts(args: &[String]) -> Result { while let Some(arg) = it.next() { match arg.as_str() { "--cpp" => opts.cpp = Some(PathBuf::from(next(&mut it, "--cpp")?)), + "--cpp-manifest" => { + opts.cpp_manifest = Some(PathBuf::from(next(&mut it, "--cpp-manifest")?)); + } "--rust" => opts.rust = Some(PathBuf::from(next(&mut it, "--rust")?)), + "--rust-manifest" => { + opts.rust_manifest = Some(PathBuf::from(next(&mut it, "--rust-manifest")?)); + } "--baseline" => opts.baseline = Some(PathBuf::from(next(&mut it, "--baseline")?)), "--from-opcode" => { let raw = next(&mut it, "--from-opcode")?; @@ -169,6 +184,10 @@ const APPROVED_AMBIENT_IGNORES: &[PacketBoundary] = &[ direction: Some(Direction::S2C), opcode: 0x2DD2, // SMSG_TIME_SYNC_REQUEST }, + PacketBoundary { + direction: Some(Direction::C2S), + opcode: 0x3A3D, // CMSG_TIME_SYNC_RESPONSE paired with the request above + }, PacketBoundary { direction: Some(Direction::S2C), opcode: 0x2DD4, // SMSG_ON_MONSTER_MOVE @@ -183,6 +202,19 @@ fn boundaries_overlap(left: PacketBoundary, right: PacketBoundary) -> bool { } fn validate_ignored_opcodes(opts: &Opts) -> Result<()> { + let ignores_time_request = opts.ignored_opcodes.contains(&PacketBoundary { + direction: Some(Direction::S2C), + opcode: 0x2DD2, + }); + let ignores_time_response = opts.ignored_opcodes.contains(&PacketBoundary { + direction: Some(Direction::C2S), + opcode: 0x3A3D, + }); + if ignores_time_request != ignores_time_response { + bail!( + "time-sync ambient traffic may be ignored only as the reviewed s2c:0x2DD2 + c2s:0x3A3D request/response pair" + ); + } for ignored in &opts.ignored_opcodes { if opts .from_opcode @@ -195,10 +227,15 @@ fn validate_ignored_opcodes(opts: &Opts) -> Result<()> { } if !APPROVED_AMBIENT_IGNORES.contains(ignored) { bail!( - "--ignore-opcode {ignored} is not approved ambient traffic; reviewed filters are s2c:0x2DD2 and s2c:0x2DD4" + "--ignore-opcode {ignored} is not approved ambient traffic; reviewed filters are s2c:0x2DD2, c2s:0x3A3D, and s2c:0x2DD4" ); } } + for (index, ignored) in opts.ignored_opcodes.iter().enumerate() { + if opts.ignored_opcodes[..index].contains(ignored) { + bail!("--ignore-opcode {ignored} is duplicated"); + } + } Ok(()) } @@ -231,22 +268,299 @@ fn apply_ignored_opcodes(mut capture: Capture, opts: &Opts) -> Capture { capture } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +struct AmbientIgnoreCounts { + time_sync_request: usize, + time_sync_response: usize, + monster_move: usize, +} + +fn read_le_u32(bytes: &[u8]) -> u32 { + u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) +} + +fn monster_take<'a>(body: &'a [u8], cursor: &mut usize, len: usize) -> Option<&'a [u8]> { + let end = cursor.checked_add(len)?; + let bytes = body.get(*cursor..end)?; + *cursor = end; + Some(bytes) +} + +fn monster_read_u32(body: &[u8], cursor: &mut usize) -> Option { + let bytes: [u8; 4] = monster_take(body, cursor, 4)?.try_into().ok()?; + Some(u32::from_le_bytes(bytes)) +} + +fn monster_read_f32(body: &[u8], cursor: &mut usize) -> Option<()> { + let value = f32::from_bits(monster_read_u32(body, cursor)?); + value.is_finite().then_some(()) +} + +fn monster_read_packed_guid(body: &[u8], cursor: &mut usize) -> Option { + let low_mask = *monster_take(body, cursor, 1)?.first()?; + let high_mask = *monster_take(body, cursor, 1)?.first()?; + let encoded_len = (low_mask.count_ones() + high_mask.count_ones()) as usize; + let encoded = monster_take(body, cursor, encoded_len)?; + // A set mask bit encoding zero is non-canonical; empty masks are valid for + // optional GUID fields such as an absent transport. + encoded.iter().all(|byte| *byte != 0).then_some(())?; + Some(low_mask != 0 || high_mask != 0) +} + +/// Parse the exact C++ `MonsterMove::Write` / `MovementMonsterSpline` layout +/// far enough to prove that an ignored `SMSG_ON_MONSTER_MOVE` is one complete, +/// canonical movement packet rather than arbitrary bytes under an allowlisted +/// opcode. Counts and optional sections are consumed to the exact body end. +fn valid_monster_move_body(body: &[u8]) -> bool { + let mut cursor = 0usize; + if monster_read_packed_guid(body, &mut cursor) != Some(true) { + return false; + } + for _ in 0..3 { + if monster_read_f32(body, &mut cursor).is_none() { + return false; + } + } + if monster_read_u32(body, &mut cursor).is_none() { + return false; + } + for _ in 0..3 { + if monster_read_f32(body, &mut cursor).is_none() { + return false; + } + } + // CrzTeleport(1) + StopDistanceTolerance(3) are MSB-first and flushed; + // the low nibble is canonical zero padding. + let Some(spline_bits) = monster_take(body, &mut cursor, 1) + .and_then(|b| b.first()) + .copied() + else { + return false; + }; + if spline_bits & 0x0F != 0 || monster_take(body, &mut cursor, 17).is_none() { + return false; + } + if monster_read_packed_guid(body, &mut cursor).is_none() + || monster_take(body, &mut cursor, 1).is_none() + { + return false; + } + let Some(bits) = monster_take(body, &mut cursor, 5) else { + return false; + }; + let header = bits + .iter() + .fold(0u64, |value, byte| (value << 8) | u64::from(*byte)); + let face = ((header >> 38) & 0x03) as u8; + let point_count = ((header >> 22) & 0xFFFF) as usize; + let packed_delta_count = ((header >> 4) & 0xFFFF) as usize; + let has_filter = header & (1 << 3) != 0; + let has_spell_extra = header & (1 << 2) != 0; + let has_jump_extra = header & (1 << 1) != 0; + let has_anim_transition = header & 1 != 0; + + if has_filter { + let Some(filter_count) = monster_read_u32(body, &mut cursor).map(|v| v as usize) else { + return false; + }; + if monster_read_f32(body, &mut cursor).is_none() + || monster_take(body, &mut cursor, 2).is_none() + || monster_read_f32(body, &mut cursor).is_none() + || monster_take(body, &mut cursor, 2).is_none() + || monster_take(body, &mut cursor, filter_count.saturating_mul(4)).is_none() + { + return false; + } + let Some(filter_flags) = monster_take(body, &mut cursor, 1) + .and_then(|bytes| bytes.first()) + .copied() + else { + return false; + }; + if filter_flags & 0x3F != 0 { + return false; + } + } + + let face_ok = match face { + 0 => true, + 1 => (0..3).all(|_| monster_read_f32(body, &mut cursor).is_some()), + 2 => { + monster_read_f32(body, &mut cursor).is_some() + && monster_read_packed_guid(body, &mut cursor).is_some() + } + 3 => monster_read_f32(body, &mut cursor).is_some(), + _ => false, + }; + if !face_ok { + return false; + } + for _ in 0..point_count.saturating_mul(3) { + if monster_read_f32(body, &mut cursor).is_none() { + return false; + } + } + if monster_take(body, &mut cursor, packed_delta_count.saturating_mul(4)).is_none() { + return false; + } + if has_spell_extra + && (monster_read_packed_guid(body, &mut cursor).is_none() + || monster_take(body, &mut cursor, 12).is_none() + || monster_read_f32(body, &mut cursor).is_none()) + { + return false; + } + if has_jump_extra + && (monster_read_f32(body, &mut cursor).is_none() + || monster_take(body, &mut cursor, 8).is_none()) + { + return false; + } + if has_anim_transition && monster_take(body, &mut cursor, 13).is_none() { + return false; + } + cursor == body.len() +} + +fn validate_ambient_ignore_evidence(capture: &Capture, opts: &Opts) -> Result { + let ignores_time = opts.ignored_opcodes.contains(&PacketBoundary { + direction: Some(Direction::S2C), + opcode: 0x2DD2, + }); + let ignores_movement = opts.ignored_opcodes.contains(&PacketBoundary { + direction: Some(Direction::S2C), + opcode: 0x2DD4, + }); + let mut counts = AmbientIgnoreCounts::default(); + let mut pending_time_sync = BTreeMap::<(u32, u32), usize>::new(); + let mut completed_time_sync = BTreeMap::<(u32, u32), usize>::new(); + + for (index, packet) in capture.packets.iter().enumerate() { + match (packet.direction, packet.opcode) { + (Direction::S2C, 0x2DD2) if ignores_time => { + if packet.connection_id != 1 { + bail!( + "{} packet {index} time-sync request is on connection {}, expected instance connection 1", + capture.source, + packet.connection_id + ); + } + if packet.body.len() != 4 { + bail!( + "{} packet {index} time-sync request has malformed {}-byte body, expected 4", + capture.source, + packet.body.len() + ); + } + let key = (packet.connection_id, read_le_u32(&packet.body)); + if pending_time_sync.contains_key(&key) || completed_time_sync.contains_key(&key) { + bail!( + "{} packet {index} duplicates time-sync request sequence {} on connection {}", + capture.source, + key.1, + key.0 + ); + } + pending_time_sync.insert(key, index); + counts.time_sync_request += 1; + } + (Direction::C2S, 0x3A3D) if ignores_time => { + if packet.connection_id != 1 { + bail!( + "{} packet {index} time-sync response is on connection {}, expected instance connection 1", + capture.source, + packet.connection_id + ); + } + if packet.body.len() != 8 { + bail!( + "{} packet {index} time-sync response has malformed {}-byte body, expected 8", + capture.source, + packet.body.len() + ); + } + let key = (packet.connection_id, read_le_u32(&packet.body)); + let Some(request_index) = pending_time_sync.remove(&key) else { + bail!( + "{} packet {index} has orphan or duplicate time-sync response sequence {} on connection {}", + capture.source, + key.1, + key.0 + ); + }; + completed_time_sync.insert(key, request_index); + counts.time_sync_response += 1; + } + (Direction::S2C, 0x2DD4) if ignores_movement => { + if packet.connection_id != 1 { + bail!( + "{} packet {index} monster movement is on connection {}, expected instance connection 1", + capture.source, + packet.connection_id + ); + } + if !valid_monster_move_body(&packet.body) { + bail!( + "{} packet {index} has malformed monster-movement body ({} bytes)", + capture.source, + packet.body.len() + ); + } + counts.monster_move += 1; + } + _ => {} + } + } + + if let Some(((connection_id, sequence), request_index)) = pending_time_sync.into_iter().next() { + bail!( + "{} packet {request_index} has no matching time-sync response for sequence {sequence} on connection {connection_id}", + capture.source + ); + } + Ok(counts) +} + +#[cfg(test)] fn apply_capture_selection(capture: Capture, opts: &Opts) -> Result { validate_ignored_opcodes(opts)?; - Ok(apply_ignored_opcodes( - apply_capture_boundaries(capture, opts)?, - opts, + let bounded = apply_capture_boundaries(capture, opts)?; + validate_ambient_ignore_evidence(&bounded, opts)?; + Ok(apply_ignored_opcodes(bounded, opts)) +} + +fn apply_capture_pair_selection( + cpp: Capture, + rust: Capture, + opts: &Opts, +) -> Result<(Capture, Capture)> { + validate_ignored_opcodes(opts)?; + let cpp = apply_capture_boundaries(cpp, opts)?; + let rust = apply_capture_boundaries(rust, opts)?; + let cpp_counts = validate_ambient_ignore_evidence(&cpp, opts)?; + let rust_counts = validate_ambient_ignore_evidence(&rust, opts)?; + if cpp_counts != rust_counts { + bail!( + "ambient ignore evidence count mismatch: C++ {cpp_counts:?}, Rust {rust_counts:?}; refusing asymmetric deletion" + ); + } + Ok(( + apply_ignored_opcodes(cpp, opts), + apply_ignored_opcodes(rust, opts), )) } fn cmd_diff(args: &[String]) -> Result { let opts = parse_opts(args)?; + if opts.cpp_manifest.is_some() || opts.rust_manifest.is_some() { + bail!("raw manifest flags are accepted only by import"); + } // Resolve cpp/rust/directions/baseline either from a flow or explicit paths. let (cpp_path, rust_path, directions, baseline) = resolve_sources(&opts)?; - let cpp = apply_capture_selection(load_capture(&cpp_path)?, &opts)?; - let rust = apply_capture_selection(load_capture(&rust_path)?, &opts)?; + let (cpp, rust) = + apply_capture_pair_selection(load_capture(&cpp_path)?, load_capture(&rust_path)?, &opts)?; let report = DiffReport::compute(&cpp, &rust, &directions); if opts.json { @@ -328,17 +642,83 @@ fn cmd_show(args: &[String]) -> Result { #[allow(clippy::unnecessary_wraps)] fn cmd_list() -> Result { let flows = flow::list_flows(); - if flows.is_empty() { + let requirements = flow::list_requirements(); + if flows.is_empty() && requirements.is_empty() { println!("no flows found under {}", flow::flows_root().display()); - } else { + return Ok(ExitCode::SUCCESS); + } + if !flows.is_empty() { println!("known flows:"); - for name in flows { - match flow::load_flow(&name) { + for name in &flows { + match flow::load_flow(name) { Ok(f) => println!(" {name:<16} {}", f.description), Err(_) => println!(" {name}"), } } } + if !requirements.is_empty() { + println!("required flows:"); + for name in requirements { + match flow::load_requirement(&name) { + Ok(requirement) => println!( + " {name:<24} {:?} ({})", + requirement.status, requirement.issue + ), + Err(_) => println!(" {name:<24} INVALID"), + } + } + } + Ok(ExitCode::SUCCESS) +} + +/// Enforce a milestone's capture contract, not merely its accepted-divergence +/// baseline. A required flow must have real artifacts, an empty baseline, the +/// reviewed packet topology/order, and a fully clean C++↔Rust diff. +fn cmd_verify_required(args: &[String]) -> Result { + if args.len() != 1 { + bail!("usage: capture-diff verify-required "); + } + let name = &args[0]; + let requirement = flow::load_requirement(name)?; + requirement.require_ready()?; + + let pinned = flow::load_flow(name)?; + if pinned.directions != requirement.directions { + bail!( + "required flow '{name}' directions {:?} do not match flow.json {:?}", + requirement.directions, + pinned.directions + ); + } + let reviewed_selection = lineage_selection_for_requirement(&requirement); + lineage::verify_required_lineage(name, &requirement.directory, &reviewed_selection)?; + + let cpp = load_capture(&pinned.golden_pkt)?; + let rust = load_capture(&pinned.reference_rust)?; + requirement.validate_capture(&cpp)?; + requirement.validate_capture(&rust)?; + + let expected_text = std::fs::read_to_string(&pinned.expected) + .with_context(|| format!("reading baseline {}", pinned.expected.display()))?; + let expected: Vec = serde_json::from_str(&expected_text) + .with_context(|| format!("parsing baseline {}", pinned.expected.display()))?; + if !expected.is_empty() { + bail!( + "required flow '{name}' pins {} accepted divergence(s); a milestone capture must be clean", + expected.len() + ); + } + + let report = DiffReport::compute(&cpp, &rust, &requirement.directions); + if !report.is_clean() { + print!("{}", report.render_text()); + return Ok(ExitCode::FAILURE); + } + + println!( + "required flow '{name}' CLEAN: {} matched packet(s), exact topology/order and correlated payload semantics present", + report.counts.matched + ); Ok(ExitCode::SUCCESS) } @@ -347,7 +727,7 @@ fn cmd_list() -> Result { fn cmd_import(args: &[String]) -> Result { let opts = parse_opts(args)?; let name = opts.positional.as_deref().context( - "usage: capture-diff import --cpp --rust [--from-opcode c2s:0xNNNN --until-opcode s2c:0xNNNN] [--ignore-opcode s2c:0xNNNN]", + "usage: capture-diff import --cpp --rust [--cpp-manifest ] [--rust-manifest ] [--from-opcode c2s:0xNNNN --until-opcode s2c:0xNNNN] [--ignore-opcode s2c:0xNNNN]", )?; flow::validate_flow_name(name)?; let cpp_path = opts.cpp.clone().context("import requires --cpp ")?; @@ -355,14 +735,40 @@ fn cmd_import(args: &[String]) -> Result { .rust .clone() .context("import requires --rust ")?; + let cpp_manifest_path = opts.cpp_manifest.clone().unwrap_or_else(|| { + cpp_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join("cpp.capture-manifest.json") + }); + let rust_manifest_path = opts + .rust_manifest + .clone() + .unwrap_or_else(|| rust_path.join("rust.capture-manifest.json")); + let requirement_path = flow::flows_root().join(name).join("requirement.json"); + let is_required = requirement_path.is_file(); + if is_required && !opts.strict { + bail!("required flow '{name}' can be imported only with --strict"); + } + let raw = lineage::validate_raw_pair( + name, + &cpp_path, + &cpp_manifest_path, + &rust_path, + &rust_manifest_path, + is_required, + )?; - let cpp = apply_capture_selection(load_capture(&cpp_path)?, &opts)?; - let rust = apply_capture_selection(load_capture(&rust_path)?, &opts)?; + let (cpp, rust) = + apply_capture_pair_selection(load_capture(&cpp_path)?, load_capture(&rust_path)?, &opts)?; let directions = opts .direction .clone() .unwrap_or_else(|| vec![Direction::S2C, Direction::C2S]); + if opts.strict { + validate_required_import(name, &directions, &opts, &cpp, &rust)?; + } let report = DiffReport::compute(&cpp, &rust, &directions); if opts.strict && !report.is_clean() { print!("{}", report.render_text()); @@ -372,34 +778,111 @@ fn cmd_import(args: &[String]) -> Result { return Ok(ExitCode::FAILURE); } - let dir = flow::flows_root().join(name); - std::fs::create_dir_all(&dir)?; + let transaction = lineage::AtomicFlowImport::prepare(&flow::flows_root(), name)?; + let dir = transaction.staging_dir(); std::fs::write(dir.join("cpp.pkt"), pkt::write_pkt_bytes(&cpp)) - .with_context(|| format!("writing {}/cpp.pkt", dir.display()))?; + .with_context(|| format!("writing staged {}/cpp.pkt", dir.display()))?; let rust_dir = dir.join("rust"); - if rust_dir.exists() { - std::fs::remove_dir_all(&rust_dir)?; - } rustdump::write_rust_dump(&rust_dir, &rust)?; std::fs::write( dir.join("expected-divergences.json"), serde_json::to_string_pretty(&report.signatures())?, )?; + let raw_after_import = lineage::validate_raw_pair( + name, + &cpp_path, + &cpp_manifest_path, + &rust_path, + &rust_manifest_path, + is_required, + )?; + if raw != raw_after_import { + bail!("raw capture manifests or artifacts changed while import was running"); + } + let selection = lineage::ImportSelection::new( + directions.clone(), + opts.from_opcode, + opts.until_opcode, + &opts.ignored_opcodes, + opts.strict, + ); + lineage::write_derived_lineage(name, dir, &raw, selection)?; + if is_required { + let requirement = flow::load_requirement(name)?; + lineage::verify_required_lineage( + name, + dir, + &lineage_selection_for_requirement(&requirement), + )?; + } + transaction.publish()?; + let published_dir = flow::flows_root().join(name); println!( "imported flow '{name}': cpp={} rust={} packets, {} divergence(s) -> {}", cpp.packets.len(), rust.packets.len(), report.signatures().len(), - dir.display() + published_dir.display() ); print!("{}", report.render_text()); Ok(ExitCode::SUCCESS) } +/// A strict import for a milestone flow must satisfy its reviewed capture +/// contract before any fixture is written. `AwaitingRealCaptures` is accepted +/// here deliberately: importing the real pair is the operation that allows the +/// manifest to be reviewed and promoted to `ready` afterwards. +fn validate_required_import( + name: &str, + directions: &[Direction], + opts: &Opts, + cpp: &Capture, + rust: &Capture, +) -> Result<()> { + let requirement_path = flow::flows_root().join(name).join("requirement.json"); + if !requirement_path.is_file() { + return Ok(()); + } + + let requirement = flow::load_requirement(name)?; + requirement.validate_import_selection( + directions, + opts.from_opcode, + opts.until_opcode, + &opts.ignored_opcodes, + opts.strict, + )?; + requirement.validate_capture(cpp)?; + requirement.validate_capture(rust)?; + Ok(()) +} + +fn lineage_selection_for_requirement( + requirement: &flow::FlowRequirement, +) -> lineage::ImportSelection { + let ignored = requirement + .import_selection + .ignored_opcodes + .iter() + .copied() + .map(PacketBoundary::from) + .collect::>(); + lineage::ImportSelection::new( + requirement.directions.clone(), + Some(requirement.import_selection.from_opcode.into()), + Some(requirement.import_selection.until_opcode.into()), + &ignored, + true, + ) +} + fn cmd_update_baseline(args: &[String]) -> Result { let opts = parse_opts(args)?; + if opts.cpp_manifest.is_some() || opts.rust_manifest.is_some() { + bail!("raw manifest flags are accepted only by import"); + } if opts.from_opcode.is_some() || opts.until_opcode.is_some() || !opts.ignored_opcodes.is_empty() { bail!( @@ -481,12 +964,15 @@ fn print_usage() { \x20 capture-diff diff --cpp A.pkt --rust DIR [...]\n\ \x20 capture-diff show \n\ \x20 capture-diff list\n\ - \x20 capture-diff import --cpp PKT --rust DIR [--from-opcode c2s:0xNNNN] [--until-opcode s2c:0xNNNN] [--ignore-opcode s2c:0xNNNN] [--strict]\n\ + \x20 capture-diff verify-required \n\ + \x20 capture-diff import --cpp PKT --rust DIR [--cpp-manifest JSON] [--rust-manifest JSON] [--from-opcode c2s:0xNNNN] [--until-opcode s2c:0xNNNN] [--ignore-opcode s2c:0xNNNN] [--strict]\n\ \x20 capture-diff update-baseline [--rust DIR]\n\ \n\ A flow resolves its golden C++ capture, reference Rust dump, and accepted-divergence\n\ baseline from crates/capture-diff/flows//. --strict exits non-zero when the diff\n\ - deviates from that baseline (the milestone regression gate)." + deviates from that baseline. verify-required additionally requires an operator-marked\n\ + ready manifest and refuses missing fixtures, accepted divergences, or a capture that\n\ + violates its reviewed wire shape." ); } @@ -503,6 +989,170 @@ mod tests { } } + fn routed_packet( + direction: Direction, + connection_id: u32, + opcode: u16, + ) -> capture_diff::CapturedPacket { + capture_diff::CapturedPacket { + direction, + connection_id, + opcode, + body: Vec::new(), + } + } + + fn ambient_packet( + direction: Direction, + connection_id: u32, + opcode: u16, + body: Vec, + ) -> capture_diff::CapturedPacket { + capture_diff::CapturedPacket { + direction, + connection_id, + opcode, + body, + } + } + + fn minimal_monster_move_body() -> Vec { + let mut body = vec![0x01, 0x00, 0x01]; // canonical non-empty mover GUID + body.extend_from_slice(&[0; 12]); // current XYZ + body.extend_from_slice(&[0; 4]); // spline id + body.extend_from_slice(&[0; 12]); // destination XYZ + body.push(0); // CrzTeleport + tolerance + zero padding + body.extend_from_slice(&[0; 17]); // flags/elapsed/time/fade/mode + body.extend_from_slice(&[0, 0]); // empty transport GUID + body.push(0xFF); // vehicle seat + body.extend_from_slice(&[0; 5]); // normal face, zero path/options + assert!(valid_monster_move_body(&body)); + body + } + + fn valid_required_loot_capture(source: &str) -> Capture { + let pinned = flow::load_flow("loot-single-item-claim").expect("committed loot flow"); + let mut capture = load_capture(&pinned.reference_rust).expect("committed Rust fixture"); + capture.source = source.to_string(); + capture + } + + fn reviewed_required_import_opts() -> Opts { + parse_opts(&[ + "loot-single-item-claim".into(), + "--from-opcode".into(), + "c2s:0x3211".into(), + "--until-opcode".into(), + "c2s:0x3768".into(), + "--ignore-opcode".into(), + "s2c:0x2DD2".into(), + "--ignore-opcode".into(), + "c2s:0x3A3D".into(), + "--ignore-opcode".into(), + "s2c:0x2DD4".into(), + "--direction".into(), + "both".into(), + "--strict".into(), + ]) + .unwrap() + } + + #[test] + fn strict_import_accepts_awaiting_required_contract_with_valid_shape() { + let cpp = valid_required_loot_capture("cpp"); + let rust = valid_required_loot_capture("rust"); + let opts = reviewed_required_import_opts(); + + validate_required_import( + "loot-single-item-claim", + &[Direction::S2C, Direction::C2S], + &opts, + &cpp, + &rust, + ) + .unwrap(); + } + + #[test] + fn strict_import_rejects_required_contract_direction_mismatch() { + let cpp = valid_required_loot_capture("cpp"); + let rust = valid_required_loot_capture("rust"); + let opts = reviewed_required_import_opts(); + + let error = validate_required_import( + "loot-single-item-claim", + &[Direction::S2C], + &opts, + &cpp, + &rust, + ) + .expect_err("required flow directions must be exact"); + + assert!(error.to_string().contains("reviewed contract")); + } + + #[test] + fn strict_import_rejects_required_contract_wrong_route() { + let cpp = valid_required_loot_capture("cpp"); + let mut rust = valid_required_loot_capture("rust"); + let opts = reviewed_required_import_opts(); + rust.packets[2].connection_id = 0; + + let error = validate_required_import( + "loot-single-item-claim", + &[Direction::S2C, Direction::C2S], + &opts, + &cpp, + &rust, + ) + .expect_err("required flow route must be exact"); + + let message = error.to_string(); + assert!(message.contains("packet 2")); + assert!(message.contains("s2c conn=1 0x2615")); + } + + #[test] + fn strict_import_rejects_required_contract_wrong_boundary() { + let mut cpp = valid_required_loot_capture("cpp"); + let rust = valid_required_loot_capture("rust"); + let opts = reviewed_required_import_opts(); + cpp.packets + .insert(0, routed_packet(Direction::S2C, 1, 0x2DD4)); + + let error = validate_required_import( + "loot-single-item-claim", + &[Direction::S2C, Direction::C2S], + &opts, + &cpp, + &rust, + ) + .expect_err("required flow boundary must be exact"); + + assert!(error.to_string().contains("required boundary")); + } + + #[test] + fn strict_import_rejects_an_extra_approved_ignore_for_required_flow() { + let cpp = valid_required_loot_capture("cpp"); + let rust = valid_required_loot_capture("rust"); + let mut opts = reviewed_required_import_opts(); + opts.ignored_opcodes.push(PacketBoundary { + direction: Some(Direction::S2C), + opcode: 0x2DD4, + }); + + let error = validate_required_import( + "loot-single-item-claim", + &[Direction::S2C, Direction::C2S], + &opts, + &cpp, + &rust, + ) + .expect_err("an extra filter must not be able to hide required-flow traffic"); + assert!(error.to_string().contains("reviewed contract")); + } + #[test] fn parses_directional_packet_boundary() { assert_eq!( @@ -555,7 +1205,7 @@ mod tests { vec![ packet(Direction::S2C, 0x256D), packet(Direction::C2S, 0x318C), - packet(Direction::S2C, 0x2DD4), + ambient_packet(Direction::S2C, 1, 0x2DD4, minimal_monster_move_body()), packet(Direction::S2C, 0x271C), packet(Direction::C2S, 0x3768), packet(Direction::S2C, 0x304E), @@ -577,6 +1227,50 @@ mod tests { ); } + #[test] + fn import_can_remove_only_the_reviewed_time_sync_request_response_pair() { + let opts = parse_opts(&[ + "loot-single-item-claim".into(), + "--from-opcode".into(), + "c2s:0x3211".into(), + "--until-opcode".into(), + "c2s:0x3768".into(), + "--ignore-opcode".into(), + "s2c:0x2DD2".into(), + "--ignore-opcode".into(), + "c2s:0x3A3D".into(), + ]) + .unwrap(); + let capture = Capture::new( + "loot-window", + vec![ + packet(Direction::C2S, 0x3211), + ambient_packet(Direction::S2C, 1, 0x2DD2, 7_u32.to_le_bytes().to_vec()), + ambient_packet(Direction::C2S, 1, 0x3A3D, { + let mut body = 7_u32.to_le_bytes().to_vec(); + body.extend_from_slice(&1234_u32.to_le_bytes()); + body + }), + packet(Direction::S2C, 0x2615), + packet(Direction::C2S, 0x3768), + ], + ); + + let selected = apply_capture_selection(capture, &opts).unwrap(); + assert_eq!( + selected + .packets + .iter() + .map(|packet| (packet.direction, packet.opcode)) + .collect::>(), + vec![ + (Direction::C2S, 0x3211), + (Direction::S2C, 0x2615), + (Direction::C2S, 0x3768), + ] + ); + } + #[test] fn ignored_opcode_requires_an_explicit_direction() { let error = parse_opts(&[ @@ -623,6 +1317,140 @@ mod tests { ); } + #[test] + fn ignored_time_sync_requires_a_well_formed_matched_pair_on_instance_socket() { + let opts = parse_opts(&[ + "loot-single-item-claim".into(), + "--ignore-opcode".into(), + "s2c:0x2DD2".into(), + "--ignore-opcode".into(), + "c2s:0x3A3D".into(), + ]) + .unwrap(); + let request = ambient_packet(Direction::S2C, 1, 0x2DD2, 9_u32.to_le_bytes().to_vec()); + let response = ambient_packet(Direction::C2S, 1, 0x3A3D, { + let mut body = 9_u32.to_le_bytes().to_vec(); + body.extend_from_slice(&44_u32.to_le_bytes()); + body + }); + assert!( + apply_capture_selection( + Capture::new("valid", vec![request.clone(), response.clone()]), + &opts + ) + .unwrap() + .packets + .is_empty() + ); + + let mut wrong_socket = request.clone(); + wrong_socket.connection_id = 0; + let error = apply_capture_selection( + Capture::new("wrong socket", vec![wrong_socket, response.clone()]), + &opts, + ) + .expect_err("wrong socket must fail"); + assert!(error.to_string().contains("expected instance connection 1")); + + let error = apply_capture_selection(Capture::new("orphan", vec![response.clone()]), &opts) + .expect_err("orphan response must fail"); + assert!(error.to_string().contains("orphan or duplicate")); + + let error = apply_capture_selection( + Capture::new( + "duplicate", + vec![request.clone(), request.clone(), response], + ), + &opts, + ) + .expect_err("duplicate request must fail"); + assert!(error.to_string().contains("duplicates time-sync request")); + + let malformed = ambient_packet(Direction::S2C, 1, 0x2DD2, vec![9, 0, 0]); + let error = apply_capture_selection(Capture::new("malformed", vec![malformed]), &opts) + .expect_err("malformed request must fail"); + assert!(error.to_string().contains("malformed 3-byte body")); + } + + #[test] + fn paired_selection_rejects_asymmetric_ambient_counts() { + let opts = parse_opts(&[ + "stand-state".into(), + "--ignore-opcode".into(), + "s2c:0x2DD4".into(), + ]) + .unwrap(); + let body = minimal_monster_move_body(); + let cpp = Capture::new("cpp", vec![ambient_packet(Direction::S2C, 1, 0x2DD4, body)]); + let rust = Capture::new("rust", Vec::new()); + let error = apply_capture_pair_selection(cpp, rust, &opts) + .expect_err("different ignore cardinality must fail"); + assert!(error.to_string().contains("count mismatch")); + } + + #[test] + fn ignored_monster_move_requires_instance_route_and_structural_body() { + let opts = parse_opts(&[ + "stand-state".into(), + "--ignore-opcode".into(), + "s2c:0x2DD4".into(), + ]) + .unwrap(); + let malformed = ambient_packet(Direction::S2C, 1, 0x2DD4, vec![1; 12]); + let error = apply_capture_selection(Capture::new("malformed", vec![malformed]), &opts) + .expect_err("truncated movement must fail"); + assert!( + error + .to_string() + .contains("malformed monster-movement body") + ); + + let valid_body = minimal_monster_move_body(); + let wrong_route = ambient_packet(Direction::S2C, 0, 0x2DD4, valid_body); + let error = apply_capture_selection(Capture::new("wrong route", vec![wrong_route]), &opts) + .expect_err("realm-routed movement must fail"); + assert!(error.to_string().contains("expected instance connection 1")); + + let mut trailing = minimal_monster_move_body(); + trailing.push(0); + let error = apply_capture_selection( + Capture::new( + "trailing bytes", + vec![ambient_packet(Direction::S2C, 1, 0x2DD4, trailing)], + ), + &opts, + ) + .expect_err("trailing movement bytes must fail"); + assert!(error.to_string().contains("malformed monster-movement")); + } + + #[test] + fn time_sync_ignore_cannot_be_declared_one_sided_or_duplicated() { + let one_sided = + parse_opts(&["flow".into(), "--ignore-opcode".into(), "s2c:0x2DD2".into()]).unwrap(); + assert!( + apply_capture_selection(Capture::new("capture", Vec::new()), &one_sided) + .unwrap_err() + .to_string() + .contains("request/response pair") + ); + + let duplicate = parse_opts(&[ + "flow".into(), + "--ignore-opcode".into(), + "s2c:0x2DD4".into(), + "--ignore-opcode".into(), + "s2c:0x2DD4".into(), + ]) + .unwrap(); + assert!( + apply_capture_selection(Capture::new("capture", Vec::new()), &duplicate) + .unwrap_err() + .to_string() + .contains("duplicated") + ); + } + #[test] fn shared_boundaries_reject_start_without_end() { let opts = parse_opts(&[ diff --git a/crates/capture-diff/src/pkt.rs b/crates/capture-diff/src/pkt.rs index c12280988..54c31efab 100644 --- a/crates/capture-diff/src/pkt.rs +++ b/crates/capture-diff/src/pkt.rs @@ -139,11 +139,17 @@ pub fn parse_pkt_bytes(bytes: &[u8]) -> Result { bail!("PKT packet Length {length} < 4 (no room for opcode)"); } let opcode = cur.u32()?; + let opcode = u16::try_from(opcode).with_context(|| { + format!( + "PKT opcode 0x{opcode:08X} exceeds the 16-bit world opcode space at offset {}", + cur.pos.saturating_sub(4) + ) + })?; let body = cur.take(length - 4)?.to_vec(); packets.push(CapturedPacket { direction, connection_id, - opcode: opcode as u16, + opcode, body, }); } diff --git a/crates/capture-diff/src/rustdump.rs b/crates/capture-diff/src/rustdump.rs index 8eef31fe9..a80114773 100644 --- a/crates/capture-diff/src/rustdump.rs +++ b/crates/capture-diff/src/rustdump.rs @@ -8,16 +8,17 @@ //! packet bytes **including** the 2-byte little-endian opcode prefix. //! - `...meta` — `key=value` lines: `direction`, `addr`, `seq`, `counter`, //! `connection_id`, `opcode=0x....`, `name`, `len`. `len` is the full `.bin` -//! length (opcode prefix + body); the parser ignores it and derives the body -//! from `bin[2..]`. Legacy metadata without `connection_id` defaults to the -//! realm connection (`0`). +//! length (opcode prefix + body). All fields and the canonical filename are +//! checked; incomplete legacy sidecars are deliberately rejected because a +//! required capture is acceptance evidence, not a best-effort log import. //! `direction` is `c2s`/`s2c`, or `c2s-unencrypted`/`s2c-unencrypted` for the //! pre-encryption handshake packets — both normalize via [`Direction::from_tag`]. //! //! Packets are ordered by the global monotonic `seq` (c2s and s2c counters reset //! independently, so `seq` — not `counter` — is the cross-direction order). -use std::path::Path; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; @@ -28,85 +29,218 @@ struct Meta { direction: Direction, connection_id: u32, seq: u64, + len: usize, opcode: u16, - bin_path: std::path::PathBuf, + bin_path: PathBuf, } -fn parse_meta(path: &Path) -> Result { +const META_KEYS: [&str; 8] = [ + "direction", + "connection_id", + "addr", + "seq", + "counter", + "opcode", + "name", + "len", +]; + +fn parse_decimal(path: &Path, key: &str, value: &str) -> Result +where + T: std::str::FromStr, + T::Err: std::fmt::Display, +{ + value.parse::().map_err(|error| { + anyhow::anyhow!( + "meta {} has invalid {key} {value:?}: {error}", + path.display() + ) + }) +} + +fn parse_meta(path: &Path, expected_stem: &str) -> Result { let text = std::fs::read_to_string(path) .with_context(|| format!("reading meta {}", path.display()))?; - let mut direction = None; - let mut connection_id = None; - let mut seq = None; - let mut opcode = None; + let mut fields = BTreeMap::new(); for line in text.lines() { let Some((key, value)) = line.split_once('=') else { - continue; + bail!("meta {} has malformed line {line:?}", path.display()); }; - match key.trim() { - "direction" => direction = Direction::from_tag(value), - "connection_id" => { - connection_id = Some(value.trim().parse::().with_context(|| { - format!( - "meta {} has invalid connection_id {:?}", - path.display(), - value.trim() - ) - })?); - } - "seq" => seq = value.trim().parse::().ok(), - "opcode" => { - let v = value - .trim() - .trim_start_matches("0x") - .trim_start_matches("0X"); - opcode = u16::from_str_radix(v, 16).ok(); - } - _ => {} + if !META_KEYS.contains(&key) { + bail!("meta {} has unknown field {key:?}", path.display()); + } + if value.trim() != value || value.is_empty() { + bail!( + "meta {} has empty or non-canonical value for {key}", + path.display() + ); + } + if fields.insert(key, value).is_some() { + bail!("meta {} repeats field {key:?}", path.display()); + } + } + + for key in META_KEYS { + if !fields.contains_key(key) { + bail!("meta {} is missing field {key:?}", path.display()); } } - let direction = - direction.with_context(|| format!("meta {} missing/invalid direction", path.display()))?; - let seq = seq.with_context(|| format!("meta {} missing seq", path.display()))?; - let opcode = opcode.with_context(|| format!("meta {} missing opcode", path.display()))?; + + let direction_tag = fields["direction"]; + if !matches!( + direction_tag, + "c2s" | "s2c" | "c2s-unencrypted" | "s2c-unencrypted" + ) { + bail!("meta {} has invalid direction", path.display()); + } + let direction = Direction::from_tag(direction_tag) + .with_context(|| format!("meta {} has invalid direction", path.display()))?; + fields["addr"] + .parse::() + .with_context(|| { + format!( + "meta {} has invalid socket address {:?}", + path.display(), + fields["addr"] + ) + })?; + let connection_id = parse_decimal::(path, "connection_id", fields["connection_id"])?; + if connection_id > 1 { + bail!( + "meta {} has unsupported world connection_id {connection_id}", + path.display() + ); + } + let seq = parse_decimal::(path, "seq", fields["seq"])?; + let counter = parse_decimal::(path, "counter", fields["counter"])?; + let len = parse_decimal::(path, "len", fields["len"])?; + if len < 2 { + bail!( + "meta {} declares len {len}, smaller than opcode", + path.display() + ); + } + let opcode_text = fields["opcode"]; + let Some(hex) = opcode_text.strip_prefix("0x") else { + bail!("meta {} has non-canonical opcode", path.display()); + }; + let opcode = u16::from_str_radix(hex, 16) + .with_context(|| format!("meta {} has invalid opcode", path.display()))?; + if opcode_text != format!("0x{opcode:04X}") { + bail!("meta {} has non-canonical opcode", path.display()); + } + let name = fields["name"]; + if !name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + { + bail!("meta {} has unsafe packet name {name:?}", path.display()); + } + let canonical_stem = + format!("rust-{direction_tag}-{seq:08}-counter{counter}-0x{opcode:04X}-{name}-len{len}"); + if expected_stem != canonical_stem { + bail!( + "meta {} filename stem disagrees with its metadata; expected {canonical_stem:?}", + path.display() + ); + } let bin_path = path.with_extension("bin"); Ok(Meta { direction, - connection_id: connection_id.unwrap_or(0), + connection_id, seq, + len, opcode, bin_path, }) } +fn require_plain_directory(path: &Path) -> Result<()> { + let metadata = std::fs::symlink_metadata(path) + .with_context(|| format!("inspecting rust dump path {}", path.display()))?; + if metadata.file_type().is_symlink() || !metadata.file_type().is_dir() { + bail!( + "rust dump path is not a regular directory: {}", + path.display() + ); + } + Ok(()) +} + /// Parse a RustyCore packet dump directory into a normalized [`Capture`]. pub fn parse_rust_dump(dir: &Path) -> Result { - if !dir.is_dir() { - bail!("rust dump path is not a directory: {}", dir.display()); - } + require_plain_directory(dir)?; - let mut metas = Vec::new(); + let mut meta_paths = BTreeMap::::new(); + let mut bin_paths = BTreeMap::::new(); for entry in std::fs::read_dir(dir).with_context(|| format!("reading dump dir {}", dir.display()))? { let entry = entry?; let path = entry.path(); - if path.extension().and_then(|e| e.to_str()) == Some("meta") { - metas.push(parse_meta(&path)?); + let metadata = std::fs::symlink_metadata(&path) + .with_context(|| format!("inspecting dump entry {}", path.display()))?; + if metadata.file_type().is_symlink() || !metadata.file_type().is_file() { + bail!( + "rust dump entry is not a regular non-symlink file: {}", + path.display() + ); + } + let file_name = entry + .file_name() + .into_string() + .map_err(|_| anyhow::anyhow!("rust dump contains a non-UTF-8 filename"))?; + if file_name == "rust.capture-manifest.json" { + continue; + } + let (stem, paths) = if let Some(stem) = file_name.strip_suffix(".meta") { + (stem, &mut meta_paths) + } else if let Some(stem) = file_name.strip_suffix(".bin") { + (stem, &mut bin_paths) + } else { + bail!("unexpected file in rust dump: {}", path.display()); + }; + if stem.is_empty() || paths.insert(stem.to_string(), path.clone()).is_some() { + bail!("duplicate or empty packet stem in rust dump: {file_name}"); } } - if metas.is_empty() { + if meta_paths.is_empty() { bail!("no .meta files found in dump dir {}", dir.display()); } + let meta_stems = meta_paths.keys().cloned().collect::>(); + let bin_stems = bin_paths.keys().cloned().collect::>(); + if meta_stems != bin_stems { + let orphan_meta = meta_stems + .difference(&bin_stems) + .cloned() + .collect::>(); + let orphan_bin = bin_stems + .difference(&meta_stems) + .cloned() + .collect::>(); + bail!( + "rust dump has unpaired packet files (orphan .meta: {orphan_meta:?}; orphan .bin: {orphan_bin:?})" + ); + } + + let mut metas = meta_paths + .iter() + .map(|(stem, path)| parse_meta(path, stem)) + .collect::>>()?; + // Global seq is the canonical cross-direction wire order. metas.sort_by_key(|m| m.seq); for pair in metas.windows(2) { - if pair[0].seq == pair[1].seq { + let Some(expected_next) = pair[0].seq.checked_add(1) else { + bail!("packet dump seq overflow after {}", pair[0].seq); + }; + if pair[1].seq != expected_next { bail!( - "duplicate packet dump seq {} in {} and {}; the world process may have restarted during capture", + "non-contiguous packet dump seq {} then {} in {} and {}; the world process may have restarted or files are missing", pair[0].seq, + pair[1].seq, pair[0].bin_path.display(), pair[1].bin_path.display(), ); @@ -117,6 +251,14 @@ pub fn parse_rust_dump(dir: &Path) -> Result { for meta in metas { let bin = std::fs::read(&meta.bin_path) .with_context(|| format!("reading dump body {}", meta.bin_path.display()))?; + if bin.len() != meta.len { + bail!( + "dump body {} has {} bytes but metadata declares {}", + meta.bin_path.display(), + bin.len(), + meta.len + ); + } if bin.len() < 2 { bail!( "dump body {} too short ({} bytes) to hold an opcode", diff --git a/crates/capture-diff/src/semantic.rs b/crates/capture-diff/src/semantic.rs index d85871b9a..ecb2854f6 100644 --- a/crates/capture-diff/src/semantic.rs +++ b/crates/capture-diff/src/semantic.rs @@ -1,23 +1,68 @@ //! Narrow semantic packet comparators for fields that are intentionally -//! runtime-allocated and therefore cannot be compared byte-for-byte. +//! runtime-allocated, or for a specifically proven accumulated update-mask +//! artifact, and therefore cannot be compared byte-for-byte. //! //! Keep this module deliberately small. A semantic comparator is allowed to //! omit only a field whose value cannot be made stable across equivalent C++ -//! and Rust runs; every other decoded bit remains part of the comparison. +//! and Rust runs, or one exact empty mask fragment whose cadence was reproduced +//! independently; every other decoded bit remains part of the comparison. use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use crate::model::Direction; +use crate::model::{Capture, Direction}; + +/// `CMSG_LOOT_ITEM` in the 3.4.3 opcode table. +pub const CMSG_LOOT_ITEM: u16 = 0x3211; /// `SMSG_LOG_XP_GAIN` in the 3.4.3 opcode table. pub const SMSG_LOG_XP_GAIN: u16 = 0x26E5; +/// `SMSG_LOOT_REMOVED` in the 3.4.3 opcode table. +pub const SMSG_LOOT_REMOVED: u16 = 0x2615; + +/// `SMSG_ITEM_PUSH_RESULT` in the 3.4.3 opcode table. +pub const SMSG_ITEM_PUSH_RESULT: u16 = 0x2623; + +/// `SMSG_UPDATE_OBJECT` in the 3.4.3 opcode table. +pub const SMSG_UPDATE_OBJECT: u16 = 0x27CB; + +/// `CMSG_PING` used as the deterministic end fence of the issue-#106 flow. +pub const CMSG_PING: u16 = 0x3768; + const OBJECT_GUID_COUNTER_MASK: u64 = 0x0000_00FF_FFFF_FFFF; const HIGH_GUID_CREATURE: u8 = 8; +const HIGH_GUID_ITEM: u8 = 3; +const HIGH_GUID_LOOT_OBJECT: u8 = 15; +const HIGH_GUID_PLAYER: u8 = 2; +const GLOBAL_GUID_RESERVED_HIGH_BITS_MASK: u64 = (1_u64 << 42) - 1; const XP_GAIN_REASON_KILL: u8 = 0; +const VALUES_TYPE_UNIT: u32 = 1 << 5; +const VALUES_TYPE_ACTIVE_PLAYER: u32 = 1 << 7; +const UNIT_POWER_PARENT_BLOCKS_MASK: u8 = 1 << 3; +const UNIT_POWER_PARENT_BLOCK_3: u32 = 1 << 20; +const ISSUE_106_CAPTURE_PLAYER_LOW: u64 = 15; +const ISSUE_106_CAPTURE_PLAYER_HIGH: u64 = 0x0800_0400_0000_0000; +const ISSUE_106_CAPTURE_ITEM_HIGH: u64 = 0x0C00_0400_0000_0000; +const ISSUE_106_CREATURE_IDENTITY: StableObjectGuid = StableObjectGuid { + high_type: HIGH_GUID_CREATURE, + realm_id: 1, + map_id: 530, + entry: 21_779, + subtype: 0, + server_id: 0, +}; +const ISSUE_106_ITEM_ENTRY: i32 = 30_712; +// Doctor Maleficus' key is stored in the first keyring slot in the restored +// issue-#106 fixture. Pinning the observed C++ destination keeps a bank, +// equipment, or generic inventory update from satisfying this capture. +const ISSUE_106_ITEM_SLOT: i32 = 106; +const ISSUE_106_ITEM_DYNAMIC_FLAGS: u32 = 0x0020_0001; +const ISSUE_106_ITEM_CREATE_ZERO_TAIL_LEN: usize = 220; +const ISSUE_106_PING_BODY: [u8; 8] = [b'T', b'O', b'O', b'L', 0, 0, 0, 0]; -/// Stable identity fields of the victim ObjectGuid in `SMSG_LOG_XP_GAIN`. +/// Stable identity fields of a world-object `ObjectGuid` whose map-runtime +/// counter has one narrowly reviewed normalization. /// /// TrinityCore creates world-object GUIDs as: /// @@ -50,6 +95,47 @@ pub struct LogXpGainBody { pub group_bonus_bits: u32, } +/// Exact identity of a packed 128-bit `ObjectGuid` whose runtime component is +/// part of the reviewed wire contract. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExactObjectGuid { + pub low: u64, + pub high: u64, +} + +/// Stable semantic representation of a 3.4.3 `SMSG_LOOT_REMOVED` body. +/// +/// Only the lower 40-bit map-runtime counter of a Creature `owner` is absent. +/// The complete loot-object GUID (including its own counter) and list id remain +/// exact so this comparator cannot hide allocation or slot divergences. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct LootRemovedBody { + pub owner: StableObjectGuid, + pub loot_obj: ExactObjectGuid, + pub loot_list_id: u8, +} + +/// One exact `ActivePlayerData::InvSlots` value in a normalized player VALUES +/// update. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct InvSlotValue { + pub slot: u16, + pub item: ExactObjectGuid, +} + +/// Stable representation of the one-player, one-block VALUES update observed +/// after the issue-#106 item claim. +/// +/// C++ carried an empty UnitData parent bit 116 while Rust did not. That one +/// cadence-only mask is absent here; map, player and the complete ActivePlayer +/// InvSlots update remain exact. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UpdateObjectInvSlotsBody { + pub map_id: u16, + pub player: ExactObjectGuid, + pub inv_slots: Vec, +} + /// One side of a semantic comparison. A malformed body remains an explicit /// divergence even when both sides happen to contain the same malformed bytes. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -57,16 +143,24 @@ pub struct SemanticBodySide { #[serde(skip_serializing_if = "Option::is_none", default)] pub log_xp_gain: Option, #[serde(skip_serializing_if = "Option::is_none", default)] + pub loot_removed: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub update_object_inv_slots: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] pub decode_error: Option, /// Strict identity for a raw body that is not eligible for runtime-counter - /// normalization. Only a valid creature-kill body with a nonzero counter - /// omits this digest; invalid and valid non-kill sides retain it. + /// normalization. Only a valid reviewed Creature shape with a nonzero + /// counter omits this digest; invalid and valid non-eligible sides retain + /// it. #[serde(skip_serializing_if = "Option::is_none", default)] pub raw_body_sha256: Option, } impl SemanticBodySide { - fn from_decoded(decoded: Result, raw_body: &[u8]) -> Self { + fn from_decoded_log_xp_gain( + decoded: Result, + raw_body: &[u8], + ) -> Self { match decoded { Ok(decoded) if decoded.body.reason == XP_GAIN_REASON_KILL @@ -75,6 +169,8 @@ impl SemanticBodySide { { Self { log_xp_gain: Some(decoded.body), + loot_removed: None, + update_object_inv_slots: None, decode_error: Some( "kill XP creature victim has a zero runtime GUID counter".to_string(), ), @@ -83,11 +179,76 @@ impl SemanticBodySide { } Ok(decoded) => Self { log_xp_gain: Some(decoded.body), + loot_removed: None, + update_object_inv_slots: None, decode_error: None, raw_body_sha256: (!decoded.is_creature_kill()).then(|| raw_body_sha256(raw_body)), }, Err(error) => Self { log_xp_gain: None, + loot_removed: None, + update_object_inv_slots: None, + decode_error: Some(error), + raw_body_sha256: Some(raw_body_sha256(raw_body)), + }, + } + } + + fn from_decoded_loot_removed( + decoded: Result, + raw_body: &[u8], + ) -> Self { + match decoded { + Ok(decoded) => { + let shape_error = decoded.issue_106_shape_error(); + let reviewed_shape = decoded.is_issue_106_reviewed_shape(); + Self { + log_xp_gain: None, + loot_removed: Some(decoded.body), + update_object_inv_slots: None, + decode_error: shape_error, + // Only the exact issue-#106 Doctor/LootObject/list shape may + // omit the Creature runtime counter. A different Creature + // remains byte-strict even if its stable high fields happen + // to match another packet. + raw_body_sha256: (!reviewed_shape).then(|| raw_body_sha256(raw_body)), + } + } + Err(error) => Self { + log_xp_gain: None, + loot_removed: None, + update_object_inv_slots: None, + decode_error: Some(error), + raw_body_sha256: Some(raw_body_sha256(raw_body)), + }, + } + } + + fn from_update_object_inv_slots_decode( + decoded: UpdateObjectInvSlotsDecode, + raw_body: &[u8], + ) -> Self { + match decoded { + UpdateObjectInvSlotsDecode::Candidate(decoded) => Self { + log_xp_gain: None, + loot_removed: None, + update_object_inv_slots: Some(decoded.body), + decode_error: None, + raw_body_sha256: None, + }, + UpdateObjectInvSlotsDecode::NotEligible(reason) => Self { + log_xp_gain: None, + loot_removed: None, + update_object_inv_slots: None, + decode_error: Some(format!( + "not the reviewed single-player InvSlots VALUES shape: {reason}" + )), + raw_body_sha256: Some(raw_body_sha256(raw_body)), + }, + UpdateObjectInvSlotsDecode::Malformed(error) => Self { + log_xp_gain: None, + loot_removed: None, + update_object_inv_slots: None, decode_error: Some(error), raw_body_sha256: Some(raw_body_sha256(raw_body)), }, @@ -115,6 +276,8 @@ impl SemanticBodyDiff { self.cpp.decode_error.is_none() && self.rust.decode_error.is_none() && self.cpp.log_xp_gain == self.rust.log_xp_gain + && self.cpp.loot_removed == self.rust.loot_removed + && self.cpp.update_object_inv_slots == self.rust.update_object_inv_slots && self.cpp.raw_body_sha256 == self.rust.raw_body_sha256 } @@ -128,57 +291,150 @@ impl SemanticBodyDiff { return format!("Rust decode error: {error}"); } - let Some(cpp) = self.cpp.log_xp_gain else { - return "C++ semantic body is missing".to_string(); - }; - let Some(rust) = self.rust.log_xp_gain else { - return "Rust semantic body is missing".to_string(); - }; - - let mut fields = Vec::new(); - if cpp.victim.high_type != rust.victim.high_type { - fields.push("victim.high_type"); - } - if cpp.victim.realm_id != rust.victim.realm_id { - fields.push("victim.realm_id"); + if let (Some(cpp), Some(rust)) = (self.cpp.log_xp_gain, self.rust.log_xp_gain) { + return mismatch_log_xp_gain(cpp, rust); } - if cpp.victim.map_id != rust.victim.map_id { - fields.push("victim.map_id"); - } - if cpp.victim.entry != rust.victim.entry { - fields.push("victim.entry"); - } - if cpp.victim.subtype != rust.victim.subtype { - fields.push("victim.subtype"); - } - if cpp.victim.server_id != rust.victim.server_id { - fields.push("victim.server_id"); - } - if cpp.original != rust.original { - fields.push("original"); - } - if cpp.reason != rust.reason { - fields.push("reason"); - } - if cpp.amount != rust.amount { - fields.push("amount"); + + if let (Some(cpp), Some(rust)) = (self.cpp.loot_removed, self.rust.loot_removed) { + return mismatch_loot_removed(cpp, rust, &self.cpp, &self.rust); } - if cpp.group_bonus_bits != rust.group_bonus_bits { - fields.push("group_bonus"); + + if let (Some(cpp), Some(rust)) = ( + self.cpp.update_object_inv_slots.as_ref(), + self.rust.update_object_inv_slots.as_ref(), + ) { + return mismatch_update_object_inv_slots(cpp, rust); } - if fields.is_empty() { + "semantic body shape differs or is missing".to_string() + } +} + +fn mismatch_log_xp_gain(cpp: LogXpGainBody, rust: LogXpGainBody) -> String { + let mut fields = Vec::new(); + if cpp.victim.high_type != rust.victim.high_type { + fields.push("victim.high_type"); + } + if cpp.victim.realm_id != rust.victim.realm_id { + fields.push("victim.realm_id"); + } + if cpp.victim.map_id != rust.victim.map_id { + fields.push("victim.map_id"); + } + if cpp.victim.entry != rust.victim.entry { + fields.push("victim.entry"); + } + if cpp.victim.subtype != rust.victim.subtype { + fields.push("victim.subtype"); + } + if cpp.victim.server_id != rust.victim.server_id { + fields.push("victim.server_id"); + } + if cpp.original != rust.original { + fields.push("original"); + } + if cpp.reason != rust.reason { + fields.push("reason"); + } + if cpp.amount != rust.amount { + fields.push("amount"); + } + if cpp.group_bonus_bits != rust.group_bonus_bits { + fields.push("group_bonus"); + } + + if fields.is_empty() { + "semantic values are equal".to_string() + } else { + format!("mismatched field(s): {}", fields.join(", ")) + } +} + +fn mismatch_loot_removed( + cpp: LootRemovedBody, + rust: LootRemovedBody, + cpp_side: &SemanticBodySide, + rust_side: &SemanticBodySide, +) -> String { + let mut fields = Vec::new(); + if cpp.owner.high_type != rust.owner.high_type { + fields.push("owner.high_type"); + } + if cpp.owner.realm_id != rust.owner.realm_id { + fields.push("owner.realm_id"); + } + if cpp.owner.map_id != rust.owner.map_id { + fields.push("owner.map_id"); + } + if cpp.owner.entry != rust.owner.entry { + fields.push("owner.entry"); + } + if cpp.owner.subtype != rust.owner.subtype { + fields.push("owner.subtype"); + } + if cpp.owner.server_id != rust.owner.server_id { + fields.push("owner.server_id"); + } + if cpp.loot_obj.low != rust.loot_obj.low { + fields.push("loot_obj.low"); + } + if cpp.loot_obj.high != rust.loot_obj.high { + fields.push("loot_obj.high"); + } + if cpp.loot_list_id != rust.loot_list_id { + fields.push("loot_list_id"); + } + + if fields.is_empty() { + if cpp_side.raw_body_sha256 == rust_side.raw_body_sha256 { "semantic values are equal".to_string() } else { - format!("mismatched field(s): {}", fields.join(", ")) + "raw body identity differs outside the normalized Creature-owner shape".to_string() } + } else { + format!("mismatched field(s): {}", fields.join(", ")) + } +} + +fn mismatch_update_object_inv_slots( + cpp: &UpdateObjectInvSlotsBody, + rust: &UpdateObjectInvSlotsBody, +) -> String { + let mut fields = Vec::new(); + if cpp.map_id != rust.map_id { + fields.push("map_id"); + } + if cpp.player.low != rust.player.low || cpp.player.high != rust.player.high { + fields.push("player"); + } + if cpp.inv_slots.len() == rust.inv_slots.len() { + for (cpp_slot, rust_slot) in cpp.inv_slots.iter().zip(&rust.inv_slots) { + if cpp_slot.slot != rust_slot.slot { + fields.push("inv_slots.slot"); + } + if cpp_slot.item != rust_slot.item { + fields.push("inv_slots.item"); + } + } + } else { + fields.push("inv_slots.length"); + } + + fields.sort_unstable(); + fields.dedup(); + if fields.is_empty() { + "semantic values are equal".to_string() + } else { + format!("mismatched field(s): {}", fields.join(", ")) } } /// Compare packet bodies semantically when a reviewed narrow comparator exists. /// /// Routing is not normalized here: [`crate::diff::DiffReport`] still compares -/// `connection_id`, so `SMSG_LOG_XP_GAIN` must use the realm socket like C++. +/// `connection_id`, so `SMSG_LOG_XP_GAIN` must use the realm socket and +/// `SMSG_LOOT_REMOVED` / the reviewed `SMSG_UPDATE_OBJECT` must use the +/// instance socket like C++. #[must_use] pub fn compare_packet_bodies( direction: Direction, @@ -186,10 +442,19 @@ pub fn compare_packet_bodies( cpp: &[u8], rust: &[u8], ) -> Option { - if direction != Direction::S2C || opcode != SMSG_LOG_XP_GAIN { + if direction != Direction::S2C { return None; } + match opcode { + SMSG_LOG_XP_GAIN => compare_log_xp_gain_bodies(cpp, rust), + SMSG_LOOT_REMOVED => compare_loot_removed_bodies(cpp, rust), + SMSG_UPDATE_OBJECT => compare_update_object_inv_slots_bodies(cpp, rust), + _ => None, + } +} + +fn compare_log_xp_gain_bodies(cpp: &[u8], rust: &[u8]) -> Option { let cpp_decoded = decode_log_xp_gain_body_with_counter(cpp); let rust_decoded = decode_log_xp_gain_body_with_counter(rust); @@ -212,8 +477,105 @@ pub fn compare_packet_bodies( Some(SemanticBodyDiff { comparator: "smsg_log_xp_gain_without_runtime_guid_counter".to_string(), - cpp: SemanticBodySide::from_decoded(cpp_decoded, cpp), - rust: SemanticBodySide::from_decoded(rust_decoded, rust), + cpp: SemanticBodySide::from_decoded_log_xp_gain(cpp_decoded, cpp), + rust: SemanticBodySide::from_decoded_log_xp_gain(rust_decoded, rust), + }) +} + +fn compare_loot_removed_bodies(cpp: &[u8], rust: &[u8]) -> Option { + let cpp_decoded = decode_loot_removed_body_with_counter(cpp); + let rust_decoded = decode_loot_removed_body_with_counter(rust); + + // Normalize only the exact Doctor/LootObject/list shape proven by the + // paired issue-#106 capture. A generic Creature predicate would conflate + // two same-entry world objects because the omitted counter is their only + // per-instance wire identity. The bot's fixture preflight additionally + // proves that this entry has one SQL spawn on map 530. + let cpp_has_reviewed_owner = cpp_decoded + .as_ref() + .is_ok_and(DecodedLootRemovedBody::has_issue_106_owner_identity); + let rust_has_reviewed_owner = rust_decoded + .as_ref() + .is_ok_and(DecodedLootRemovedBody::has_issue_106_owner_identity); + if cpp_decoded.is_ok() + && rust_decoded.is_ok() + && !cpp_has_reviewed_owner + && !rust_has_reviewed_owner + { + return None; + } + + Some(SemanticBodyDiff { + comparator: "smsg_loot_removed_without_creature_owner_runtime_guid_counter".to_string(), + cpp: SemanticBodySide::from_decoded_loot_removed(cpp_decoded, cpp), + rust: SemanticBodySide::from_decoded_loot_removed(rust_decoded, rust), + }) +} + +fn compare_update_object_inv_slots_bodies(cpp: &[u8], rust: &[u8]) -> Option { + let cpp_decoded = decode_update_object_inv_slots_candidate(cpp); + let rust_decoded = decode_update_object_inv_slots_candidate(rust); + + let cpp_candidate = cpp_decoded.candidate(); + let rust_candidate = rust_decoded.candidate(); + match (cpp_candidate, rust_candidate) { + // This is the only accepted asymmetry: C++ accumulated UnitData parent + // bit 116 with no child or payload; Rust emitted only the identical + // ActivePlayer InvSlots delta. + (Some(cpp), Some(rust)) + if cpp.has_empty_unit_power_parent && !rust.has_empty_unit_power_parent => {} + // Identical orientation has no normalization to perform. Leave these + // UpdateObject bodies under ordinary byte comparison. + (Some(cpp), Some(rust)) + if cpp.has_empty_unit_power_parent == rust.has_empty_unit_power_parent => + { + return None; + } + (None, None) + if matches!(cpp_decoded, UpdateObjectInvSlotsDecode::NotEligible(_)) + && matches!(rust_decoded, UpdateObjectInvSlotsDecode::NotEligible(_)) => + { + return None; + } + // A non-candidate packet paired with a malformed candidate-shaped + // packet is not evidence that this narrowly reviewed asymmetry is in + // play. Keep the ordinary raw-body diff in either orientation. Two + // malformed candidate-shaped packets remain fail-closed below. + (None, None) + if matches!(cpp_decoded, UpdateObjectInvSlotsDecode::NotEligible(_)) + && matches!(rust_decoded, UpdateObjectInvSlotsDecode::Malformed(_)) => + { + return None; + } + (None, None) + if matches!(cpp_decoded, UpdateObjectInvSlotsDecode::Malformed(_)) + && matches!(rust_decoded, UpdateObjectInvSlotsDecode::NotEligible(_)) => + { + return None; + } + _ => {} + } + + let reverse_orientation = matches!( + (cpp_candidate, rust_candidate), + (Some(cpp), Some(rust)) + if !cpp.has_empty_unit_power_parent && rust.has_empty_unit_power_parent + ); + let mut cpp_side = SemanticBodySide::from_update_object_inv_slots_decode(cpp_decoded, cpp); + let mut rust_side = SemanticBodySide::from_update_object_inv_slots_decode(rust_decoded, rust); + if reverse_orientation { + let error = "empty UnitData Power parent appears only on Rust; normalization is C++-only" + .to_string(); + cpp_side.decode_error = Some(error.clone()); + cpp_side.raw_body_sha256 = Some(raw_body_sha256(cpp)); + rust_side.decode_error = Some(error); + rust_side.raw_body_sha256 = Some(raw_body_sha256(rust)); + } + + Some(SemanticBodyDiff { + comparator: "smsg_update_object_without_cpp_empty_unit_power_parent".to_string(), + cpp: cpp_side, + rust: rust_side, }) } @@ -223,12 +585,830 @@ struct DecodedLogXpGainBody { runtime_counter: u64, } +#[derive(Debug, Clone, Copy)] +struct DecodedLootRemovedBody { + body: LootRemovedBody, + owner_runtime_counter: u64, +} + +#[derive(Debug, Clone)] +struct DecodedUpdateObjectInvSlotsBody { + body: UpdateObjectInvSlotsBody, + has_empty_unit_power_parent: bool, +} + +#[derive(Debug)] +enum UpdateObjectInvSlotsDecode { + Candidate(DecodedUpdateObjectInvSlotsBody), + NotEligible(&'static str), + Malformed(String), +} + +impl UpdateObjectInvSlotsDecode { + fn candidate(&self) -> Option<&DecodedUpdateObjectInvSlotsBody> { + match self { + Self::Candidate(decoded) => Some(decoded), + Self::NotEligible(_) | Self::Malformed(_) => None, + } + } +} + +enum UpdateObjectInvSlotsFailure { + NotEligible(&'static str), + Malformed(String), +} + +impl DecodedLootRemovedBody { + fn has_issue_106_owner_identity(&self) -> bool { + self.body.owner == ISSUE_106_CREATURE_IDENTITY + } + + fn issue_106_shape_error(&self) -> Option { + if !self.has_issue_106_owner_identity() { + return None; + } + if self.owner_runtime_counter == 0 { + return Some( + "loot-removed issue-#106 Creature owner has a zero runtime GUID counter" + .to_string(), + ); + } + + if let Some(error) = issue_106_loot_object_error(self.body.loot_obj) { + return Some(error); + } + if self.body.loot_list_id != 0 { + return Some("loot_list_id is not the reviewed deterministic slot 0".to_string()); + } + None + } + + fn is_issue_106_reviewed_shape(&self) -> bool { + self.has_issue_106_owner_identity() && self.issue_106_shape_error().is_none() + } +} + +fn issue_106_loot_object_error(loot_obj: ExactObjectGuid) -> Option { + let stable = stable_object_guid(loot_obj.low, loot_obj.high); + if stable.high_type != HIGH_GUID_LOOT_OBJECT + || stable.realm_id != ISSUE_106_CREATURE_IDENTITY.realm_id + || stable.map_id != ISSUE_106_CREATURE_IDENTITY.map_id + || stable.entry != 0 + || stable.subtype != 0 + || stable.server_id != 0 + { + return Some("loot_obj.high is not the reviewed map-530 LootObject identity".to_string()); + } + if loot_obj.low & OBJECT_GUID_COUNTER_MASK == 0 { + return Some("loot_obj.low has a zero runtime GUID counter".to_string()); + } + None +} + impl DecodedLogXpGainBody { fn is_creature_kill(&self) -> bool { self.body.reason == XP_GAIN_REASON_KILL && self.body.victim.high_type == HIGH_GUID_CREATURE } } +fn decode_update_object_inv_slots_candidate(body: &[u8]) -> UpdateObjectInvSlotsDecode { + match decode_update_object_inv_slots_candidate_inner(body) { + Ok(decoded) => UpdateObjectInvSlotsDecode::Candidate(decoded), + Err(UpdateObjectInvSlotsFailure::NotEligible(reason)) => { + UpdateObjectInvSlotsDecode::NotEligible(reason) + } + Err(UpdateObjectInvSlotsFailure::Malformed(error)) => { + UpdateObjectInvSlotsDecode::Malformed(error) + } + } +} + +fn decode_update_object_inv_slots_candidate_inner( + body: &[u8], +) -> Result { + let mut cursor = 0usize; + let num_updates = read_u32(body, &mut cursor, "NumObjUpdates").map_err(|_| { + UpdateObjectInvSlotsFailure::NotEligible("body is too short for NumObjUpdates") + })?; + if num_updates != 1 { + return Err(UpdateObjectInvSlotsFailure::NotEligible( + "NumObjUpdates is not exactly one", + )); + } + + let map_id = + read_u16(body, &mut cursor, "MapID").map_err(UpdateObjectInvSlotsFailure::Malformed)?; + let destroy_or_out_of_range = read_u8(body, &mut cursor, "HasDestroyOrOutOfRange byte") + .map_err(UpdateObjectInvSlotsFailure::Malformed)?; + if destroy_or_out_of_range == 0x80 { + return Err(UpdateObjectInvSlotsFailure::NotEligible( + "packet carries destroy or out-of-range GUIDs", + )); + } + if destroy_or_out_of_range != 0 { + return Err(UpdateObjectInvSlotsFailure::Malformed(format!( + "HasDestroyOrOutOfRange byte has non-canonical padding bits: 0x{destroy_or_out_of_range:02X}" + ))); + } + + let declared_blocks_len = read_u32(body, &mut cursor, "update blocks length") + .map_err(UpdateObjectInvSlotsFailure::Malformed)? as usize; + let actual_blocks_len = body.len().saturating_sub(cursor); + if declared_blocks_len != actual_blocks_len { + return Err(UpdateObjectInvSlotsFailure::Malformed(format!( + "update blocks length declares {declared_blocks_len} bytes but {actual_blocks_len} remain" + ))); + } + let blocks = &body[cursor..]; + let mut block_cursor = 0usize; + let update_type = read_u8(blocks, &mut block_cursor, "UpdateType") + .map_err(UpdateObjectInvSlotsFailure::Malformed)?; + if update_type != 0 { + return Err(UpdateObjectInvSlotsFailure::NotEligible( + "the sole update block is not UpdateType::Values", + )); + } + + let (player_low, player_high) = read_packed_guid(blocks, &mut block_cursor, "Player") + .map_err(UpdateObjectInvSlotsFailure::Malformed)?; + if ((player_high >> 58) & 0x3F) as u8 != HIGH_GUID_PLAYER { + return Err(UpdateObjectInvSlotsFailure::NotEligible( + "the VALUES owner is not a Player GUID", + )); + } + if player_low == 0 { + return Err(UpdateObjectInvSlotsFailure::Malformed( + "Player GUID has a zero low identity".to_string(), + )); + } + if player_high & GLOBAL_GUID_RESERVED_HIGH_BITS_MASK != 0 { + return Err(UpdateObjectInvSlotsFailure::Malformed( + "Player GUID sets reserved high-word bits 0..41".to_string(), + )); + } + + let declared_values_len = read_u32(blocks, &mut block_cursor, "values length") + .map_err(UpdateObjectInvSlotsFailure::Malformed)? as usize; + let actual_values_len = blocks.len().saturating_sub(block_cursor); + if declared_values_len != actual_values_len { + return Err(UpdateObjectInvSlotsFailure::Malformed(format!( + "values length declares {declared_values_len} bytes but {actual_values_len} remain" + ))); + } + let values = &blocks[block_cursor..]; + let mut values_cursor = 0usize; + let has_empty_unit_power_parent = + decode_reviewed_changed_object_type_mask(values, &mut values_cursor)?; + + if has_empty_unit_power_parent { + decode_empty_unit_power_parent(values, &mut values_cursor)?; + } + + let inv_slots = decode_active_player_inv_slots(values, &mut values_cursor) + .map_err(UpdateObjectInvSlotsFailure::Malformed)?; + if values_cursor != values.len() { + return Err(UpdateObjectInvSlotsFailure::Malformed(format!( + "trailing bytes after ActivePlayer InvSlots: decoded {values_cursor} of {} bytes", + values.len() + ))); + } + + Ok(DecodedUpdateObjectInvSlotsBody { + body: UpdateObjectInvSlotsBody { + map_id, + player: ExactObjectGuid { + low: player_low, + high: player_high, + }, + inv_slots, + }, + has_empty_unit_power_parent, + }) +} + +fn decode_reviewed_changed_object_type_mask( + values: &[u8], + cursor: &mut usize, +) -> Result { + let changed_object_type_mask = read_u32(values, cursor, "ChangedObjectTypeMask") + .map_err(UpdateObjectInvSlotsFailure::Malformed)?; + match changed_object_type_mask { + VALUES_TYPE_ACTIVE_PLAYER => Ok(false), + mask if mask == VALUES_TYPE_UNIT | VALUES_TYPE_ACTIVE_PLAYER => Ok(true), + _ => Err(UpdateObjectInvSlotsFailure::NotEligible( + "ChangedObjectTypeMask is not ActivePlayer-only or Unit+ActivePlayer", + )), + } +} + +fn decode_empty_unit_power_parent( + values: &[u8], + cursor: &mut usize, +) -> Result<(), UpdateObjectInvSlotsFailure> { + let blocks_mask = read_u8(values, cursor, "UnitData blocks mask") + .map_err(UpdateObjectInvSlotsFailure::Malformed)?; + if blocks_mask != UNIT_POWER_PARENT_BLOCKS_MASK { + return Err(UpdateObjectInvSlotsFailure::Malformed(format!( + "UnitData blocks mask is 0x{blocks_mask:02X}, expected only block 3" + ))); + } + + let block_3 = read_u32_be(values, cursor, "UnitData block 3") + .map_err(UpdateObjectInvSlotsFailure::Malformed)?; + if block_3 != UNIT_POWER_PARENT_BLOCK_3 { + return Err(UpdateObjectInvSlotsFailure::Malformed(format!( + "UnitData block 3 is 0x{block_3:08X}, expected only parent bit 116" + ))); + } + Ok(()) +} + +fn decode_active_player_inv_slots( + values: &[u8], + cursor: &mut usize, +) -> Result, String> { + let blocks_group_0 = read_u32(values, cursor, "ActivePlayer blocks group 0")?; + let blocks_group_1 = read_u16_be(values, cursor, "ActivePlayer blocks group 1")?; + if blocks_group_1 != 0 { + return Err(format!( + "ActivePlayer blocks group 1 is 0x{blocks_group_1:04X}; InvSlots uses only blocks 3-8" + )); + } + + let mut blocks = [0u32; 32]; + for (index, block) in blocks.iter_mut().enumerate() { + if blocks_group_0 & (1 << index) != 0 { + *block = read_u32_be(values, cursor, "ActivePlayer block")?; + if *block == 0 { + return Err(format!("ActivePlayer group mask names empty block {index}")); + } + } + } + + let mut has_inv_slots_parent = false; + let mut changed_slots = Vec::new(); + for (block_index, block) in blocks.iter().copied().enumerate() { + for bit in 0..32u32 { + if block & (1 << bit) == 0 { + continue; + } + let field = block_index as u32 * 32 + bit; + match field { + 124 => has_inv_slots_parent = true, + 125..=265 => changed_slots.push((field - 125) as u16), + _ => { + return Err(format!( + "ActivePlayer mask contains non-InvSlots field {field}" + )); + } + } + } + } + if !has_inv_slots_parent { + return Err("ActivePlayer InvSlots parent bit 124 is absent".to_string()); + } + if changed_slots.len() != 1 { + return Err(format!( + "reviewed loot update requires exactly one InvSlots child, found {}", + changed_slots.len() + )); + } + + let mut inv_slots = Vec::with_capacity(1); + for slot in changed_slots { + let (item_low, item_high) = read_packed_guid(values, cursor, "InvSlots item")?; + if ((item_high >> 58) & 0x3F) as u8 != HIGH_GUID_ITEM || item_low == 0 { + return Err(format!("InvSlots[{slot}] is not a non-empty Item GUID")); + } + if item_high & GLOBAL_GUID_RESERVED_HIGH_BITS_MASK != 0 { + return Err(format!( + "InvSlots[{slot}] Item GUID sets reserved high-word bits 0..41" + )); + } + inv_slots.push(InvSlotValue { + slot, + item: ExactObjectGuid { + low: item_low, + high: item_high, + }, + }); + } + Ok(inv_slots) +} + +#[derive(Debug, Clone, Copy)] +struct DecodedSingleLootItemRequest { + loot_obj: ExactObjectGuid, + loot_list_id: u8, +} + +#[derive(Debug, Clone, Copy)] +struct DecodedIssue106ItemPushResult { + player: ExactObjectGuid, + slot_in_bag: i32, + quantity: i32, + item_guid: ExactObjectGuid, + item_entry: i32, +} + +#[derive(Debug, Clone, Copy)] +struct DecodedIssue106ItemCreate { + map_id: u16, + item_guid: ExactObjectGuid, + owner: ExactObjectGuid, + contained_in: ExactObjectGuid, + item_entry: i32, + stack_count: u32, +} + +/// Validate the complete, correlated payload contract of the issue-#106 +/// single-item capture. +/// +/// Opcode equality alone is insufficient evidence: an unrelated item push or +/// two identically malformed packets could otherwise compare clean. This pins +/// the one-request count, deterministic fixture identities, request/removal +/// LootObject and list id, recipient, quantity, awarded Item GUID, inventory +/// item CreateObject, slot update, and fixed ping fence on each side +/// independently. +pub fn validate_loot_single_item_claim_capture(capture: &Capture) -> Result<(), String> { + validate_issue_106_capture_topology(capture)?; + + let request = decode_single_loot_item_request(&capture.packets[0].body)?; + let created = decode_issue_106_item_create(&capture.packets[1].body)?; + let removed = decode_loot_removed_body_with_counter(&capture.packets[2].body)?; + validate_issue_106_request_removal(request, removed)?; + + let pushed = decode_issue_106_item_push_result(&capture.packets[3].body)?; + validate_issue_106_created_grant(created, pushed)?; + validate_issue_106_inventory_update(&capture.packets[4].body, pushed)?; + + if capture.packets[5].body != ISSUE_106_PING_BODY { + return Err(format!( + "CMSG_PING fence body is {:02X?}, expected fixed TOOL/zero-latency body {:02X?}", + capture.packets[5].body, ISSUE_106_PING_BODY + )); + } + Ok(()) +} + +fn validate_issue_106_capture_topology(capture: &Capture) -> Result<(), String> { + const EXPECTED: [(Direction, u32, u16); 6] = [ + (Direction::C2S, 1, CMSG_LOOT_ITEM), + (Direction::S2C, 1, SMSG_UPDATE_OBJECT), + (Direction::S2C, 1, SMSG_LOOT_REMOVED), + (Direction::S2C, 0, SMSG_ITEM_PUSH_RESULT), + (Direction::S2C, 1, SMSG_UPDATE_OBJECT), + (Direction::C2S, 1, CMSG_PING), + ]; + if capture.packets.len() != EXPECTED.len() { + return Err(format!( + "{} contains {} packet(s); the semantic loot-claim contract requires exactly {}", + capture.source, + capture.packets.len(), + EXPECTED.len() + )); + } + for (index, (packet, (direction, connection_id, opcode))) in + capture.packets.iter().zip(EXPECTED).enumerate() + { + if packet.direction != direction + || packet.connection_id != connection_id + || packet.opcode != opcode + { + return Err(format!( + "{} packet {index} is {} conn={} 0x{:04X}; semantic contract requires {} conn={} 0x{opcode:04X}", + capture.source, + packet.direction, + packet.connection_id, + packet.opcode, + direction, + connection_id + )); + } + } + Ok(()) +} + +fn validate_issue_106_request_removal( + request: DecodedSingleLootItemRequest, + removed: DecodedLootRemovedBody, +) -> Result<(), String> { + if !removed.is_issue_106_reviewed_shape() { + return Err(removed.issue_106_shape_error().unwrap_or_else(|| { + format!( + "loot-removed owner {:?} is not the reviewed issue-#106 Doctor identity", + removed.body.owner + ) + })); + } + if request.loot_obj != removed.body.loot_obj { + return Err(format!( + "CMSG_LOOT_ITEM LootObj {:?} does not match SMSG_LOOT_REMOVED {:?}", + request.loot_obj, removed.body.loot_obj + )); + } + if request.loot_list_id != removed.body.loot_list_id { + return Err(format!( + "CMSG_LOOT_ITEM LootListID {} does not match SMSG_LOOT_REMOVED {}", + request.loot_list_id, removed.body.loot_list_id + )); + } + Ok(()) +} + +fn validate_issue_106_created_grant( + created: DecodedIssue106ItemCreate, + pushed: DecodedIssue106ItemPushResult, +) -> Result<(), String> { + if created.map_id != ISSUE_106_CREATURE_IDENTITY.map_id { + return Err(format!( + "item CreateObject map {} does not match loot owner map {}", + created.map_id, ISSUE_106_CREATURE_IDENTITY.map_id + )); + } + if created.owner != pushed.player || created.contained_in != pushed.player { + return Err(format!( + "item CreateObject owner/contained {:?}/{:?} does not match ItemPushResult player {:?}", + created.owner, created.contained_in, pushed.player + )); + } + if created.item_guid != pushed.item_guid { + return Err(format!( + "item CreateObject GUID {:?} does not match ItemPushResult {:?}", + created.item_guid, pushed.item_guid + )); + } + if created.item_entry != pushed.item_entry { + return Err(format!( + "item CreateObject entry {} does not match ItemPushResult {}", + created.item_entry, pushed.item_entry + )); + } + if created.stack_count != pushed.quantity as u32 { + return Err(format!( + "item CreateObject stack {} does not match ItemPushResult quantity {}", + created.stack_count, pushed.quantity + )); + } + if pushed.quantity != 1 || pushed.item_entry != ISSUE_106_ITEM_ENTRY { + return Err(format!( + "ItemPushResult is item {}/quantity {}, expected fixture item {ISSUE_106_ITEM_ENTRY}/quantity 1", + pushed.item_entry, pushed.quantity + )); + } + Ok(()) +} + +fn validate_issue_106_inventory_update( + body: &[u8], + pushed: DecodedIssue106ItemPushResult, +) -> Result<(), String> { + let inventory_update = match decode_update_object_inv_slots_candidate(body) { + UpdateObjectInvSlotsDecode::Candidate(decoded) => decoded, + UpdateObjectInvSlotsDecode::NotEligible(reason) => { + return Err(format!( + "post-claim SMSG_UPDATE_OBJECT is not the reviewed one-slot InvSlots update: {reason}" + )); + } + UpdateObjectInvSlotsDecode::Malformed(error) => { + return Err(format!( + "post-claim SMSG_UPDATE_OBJECT is malformed: {error}" + )); + } + }; + if inventory_update.body.map_id != ISSUE_106_CREATURE_IDENTITY.map_id { + return Err(format!( + "post-claim map {} does not match loot owner map {}", + inventory_update.body.map_id, ISSUE_106_CREATURE_IDENTITY.map_id + )); + } + if inventory_update.body.player != pushed.player { + return Err(format!( + "ItemPushResult player {:?} does not match InvSlots player {:?}", + pushed.player, inventory_update.body.player + )); + } + let [slot] = inventory_update.body.inv_slots.as_slice() else { + return Err(format!( + "post-claim InvSlots update contains {} child values, expected one", + inventory_update.body.inv_slots.len() + )); + }; + if i32::from(slot.slot) != pushed.slot_in_bag { + return Err(format!( + "ItemPushResult SlotInBag {} does not match InvSlots child {}", + pushed.slot_in_bag, slot.slot + )); + } + if slot.item != pushed.item_guid { + return Err(format!( + "ItemPushResult ItemGUID {:?} does not match InvSlots item {:?}", + pushed.item_guid, slot.item + )); + } + Ok(()) +} + +/// Decode only the deterministic one-item CreateObject shape captured for +/// issue #106. +/// +/// C++ anchors: +/// +/// - `UpdateData::BuildPacket`: one update, map, destroy bit, data length; +/// - `Object::BuildCreateUpdateBlockForPlayer`: type, GUID, TypeID, movement, +/// values; +/// - `Item::BuildValuesCreate`: value length and Owner visibility flag; +/// - `ObjectData::WriteCreate` / `ItemData::WriteCreate`: entry, owner, +/// contained-in GUID and StackCount in that order. +fn decode_issue_106_item_create(body: &[u8]) -> Result { + let mut cursor = 0usize; + let num_updates = read_u32(body, &mut cursor, "NumObjUpdates")?; + if num_updates != 1 { + return Err(format!( + "item CreateObject contains {num_updates} object updates, expected exactly one" + )); + } + let map_id = read_u16(body, &mut cursor, "MapID")?; + let destroy_or_out_of_range = read_u8(body, &mut cursor, "HasDestroyOrOutOfRange byte")?; + if destroy_or_out_of_range != 0 { + return Err(format!( + "item CreateObject HasDestroyOrOutOfRange byte is 0x{destroy_or_out_of_range:02X}, expected canonical false" + )); + } + + let declared_blocks_len = read_u32(body, &mut cursor, "update blocks length")? as usize; + let actual_blocks_len = body.len().saturating_sub(cursor); + if declared_blocks_len != actual_blocks_len { + return Err(format!( + "item CreateObject update blocks length declares {declared_blocks_len} bytes but {actual_blocks_len} remain" + )); + } + + let update_type = read_u8(body, &mut cursor, "UpdateType")?; + if update_type != 1 { + return Err(format!( + "item CreateObject UpdateType is {update_type}, expected CreateObject (1)" + )); + } + let (item_low, item_high) = read_packed_guid(body, &mut cursor, "created Item")?; + let item_guid = ExactObjectGuid { + low: item_low, + high: item_high, + }; + if let Some(error) = issue_106_item_guid_error(item_guid) { + return Err(format!("item CreateObject {error}")); + } + + let type_id = read_u8(body, &mut cursor, "TypeID")?; + if type_id != 1 { + return Err(format!( + "item CreateObject TypeID is {type_id}, expected Item (1)" + )); + } + let create_bits: [u8; 3] = read_array(body, &mut cursor, "CreateObjectBits")?; + if create_bits != [0; 3] { + return Err(format!( + "item CreateObject flags are {create_bits:02X?}, expected all 18 Item flags clear" + )); + } + let pause_times = read_i32(body, &mut cursor, "PauseTimes count")?; + if pause_times != 0 { + return Err(format!( + "item CreateObject PauseTimes count is {pause_times}, expected zero" + )); + } + + let declared_values_len = read_u32(body, &mut cursor, "values length")? as usize; + let actual_values_len = body.len().saturating_sub(cursor); + if declared_values_len != actual_values_len { + return Err(format!( + "item CreateObject values length declares {declared_values_len} bytes but {actual_values_len} remain" + )); + } + decode_issue_106_item_create_values(&body[cursor..], map_id, item_guid) +} + +fn decode_issue_106_item_create_values( + values: &[u8], + map_id: u16, + item_guid: ExactObjectGuid, +) -> Result { + let mut values_cursor = 0usize; + let visibility_flags = read_u8(values, &mut values_cursor, "UpdateFieldFlags")?; + let item_entry = read_i32(values, &mut values_cursor, "ObjectData.EntryID")?; + let object_dynamic_flags = read_u32(values, &mut values_cursor, "ObjectData.DynamicFlags")?; + let object_scale_bits = read_u32(values, &mut values_cursor, "ObjectData.Scale")?; + if visibility_flags != 0x01 + || object_dynamic_flags != 0 + || object_scale_bits != 1.0_f32.to_bits() + { + return Err(format!( + "item CreateObject ObjectData is not the reviewed owner-visible shape: flags=0x{visibility_flags:02X} dynamic=0x{object_dynamic_flags:08X} scale_bits=0x{object_scale_bits:08X}" + )); + } + if item_entry != ISSUE_106_ITEM_ENTRY { + return Err(format!( + "item CreateObject entry is {item_entry}, expected fixture item {ISSUE_106_ITEM_ENTRY}" + )); + } + + let (owner_low, owner_high) = read_packed_guid(values, &mut values_cursor, "ItemData.Owner")?; + let owner = ExactObjectGuid { + low: owner_low, + high: owner_high, + }; + let (contained_low, contained_high) = + read_packed_guid(values, &mut values_cursor, "ItemData.ContainedIn")?; + let contained_in = ExactObjectGuid { + low: contained_low, + high: contained_high, + }; + let creator = read_packed_guid(values, &mut values_cursor, "ItemData.Creator")?; + let gift_creator = read_packed_guid(values, &mut values_cursor, "ItemData.GiftCreator")?; + let expected_player = ExactObjectGuid { + low: ISSUE_106_CAPTURE_PLAYER_LOW, + high: ISSUE_106_CAPTURE_PLAYER_HIGH, + }; + if owner != expected_player || contained_in != expected_player { + return Err(format!( + "item CreateObject Owner/ContainedIn is {owner:?}/{contained_in:?}, expected capture player {expected_player:?}" + )); + } + if creator != (0, 0) || gift_creator != (0, 0) { + return Err(format!( + "item CreateObject Creator/GiftCreator is {creator:?}/{gift_creator:?}, expected empty GUIDs" + )); + } + + let stack_count = read_u32(values, &mut values_cursor, "ItemData.StackCount")?; + let expiration = read_u32(values, &mut values_cursor, "ItemData.Expiration")?; + let mut spell_charges = [0_i32; 5]; + for charge in &mut spell_charges { + *charge = read_i32(values, &mut values_cursor, "ItemData.SpellCharges")?; + } + let item_dynamic_flags = read_u32(values, &mut values_cursor, "ItemData.DynamicFlags")?; + if stack_count != 1 + || expiration != 0 + || spell_charges != [0; 5] + || item_dynamic_flags != ISSUE_106_ITEM_DYNAMIC_FLAGS + { + return Err(format!( + "item CreateObject ItemData is not the deterministic one-key shape: stack={stack_count} expiration={expiration} charges={spell_charges:?} dynamic=0x{item_dynamic_flags:08X}" + )); + } + + let zero_tail = &values[values_cursor..]; + if zero_tail.len() != ISSUE_106_ITEM_CREATE_ZERO_TAIL_LEN + || zero_tail.iter().any(|byte| *byte != 0) + { + return Err(format!( + "item CreateObject deterministic ItemData tail has length {} and {} nonzero byte(s), expected {} zero bytes", + zero_tail.len(), + zero_tail.iter().filter(|byte| **byte != 0).count(), + ISSUE_106_ITEM_CREATE_ZERO_TAIL_LEN + )); + } + + Ok(DecodedIssue106ItemCreate { + map_id, + item_guid, + owner, + contained_in, + item_entry, + stack_count, + }) +} + +fn decode_single_loot_item_request(body: &[u8]) -> Result { + let mut cursor = 0usize; + let count = read_u32(body, &mut cursor, "Loot request count")?; + if count != 1 { + return Err(format!( + "CMSG_LOOT_ITEM contains {count} request(s), expected exactly one" + )); + } + let (loot_obj_low, loot_obj_high) = read_packed_guid(body, &mut cursor, "Loot Object")?; + let loot_obj = ExactObjectGuid { + low: loot_obj_low, + high: loot_obj_high, + }; + if let Some(error) = issue_106_loot_object_error(loot_obj) { + return Err(format!("CMSG_LOOT_ITEM {error}")); + } + let loot_list_id = read_u8(body, &mut cursor, "LootListID")?; + if loot_list_id != 0 { + return Err(format!( + "CMSG_LOOT_ITEM LootListID is {loot_list_id}, expected deterministic slot 0" + )); + } + let soft_interact = read_u8(body, &mut cursor, "IsSoftInteract bit byte")?; + if soft_interact != 0 { + return Err(format!( + "CMSG_LOOT_ITEM IsSoftInteract/padding byte is 0x{soft_interact:02X}, expected 0" + )); + } + if cursor != body.len() { + return Err(format!( + "trailing bytes after CMSG_LOOT_ITEM: decoded {cursor} of {} bytes", + body.len() + )); + } + Ok(DecodedSingleLootItemRequest { + loot_obj, + loot_list_id, + }) +} + +fn decode_issue_106_item_push_result(body: &[u8]) -> Result { + let mut cursor = 0usize; + let (player_low, player_high) = read_packed_guid(body, &mut cursor, "PlayerGUID")?; + let player = ExactObjectGuid { + low: player_low, + high: player_high, + }; + if player.low != ISSUE_106_CAPTURE_PLAYER_LOW || player.high != ISSUE_106_CAPTURE_PLAYER_HIGH { + return Err(format!( + "ItemPushResult PlayerGUID is {player:?}, expected capture character low={ISSUE_106_CAPTURE_PLAYER_LOW} high=0x{ISSUE_106_CAPTURE_PLAYER_HIGH:016X}" + )); + } + + let slot = read_u8(body, &mut cursor, "Slot")?; + let slot_in_bag = read_i32(body, &mut cursor, "SlotInBag")?; + let quest_log_item_id = read_i32(body, &mut cursor, "QuestLogItemID")?; + let quantity = read_i32(body, &mut cursor, "Quantity")?; + let quantity_in_inventory = read_i32(body, &mut cursor, "QuantityInInventory")?; + let dungeon_encounter_id = read_i32(body, &mut cursor, "DungeonEncounterID")?; + let battle_pet_species_id = read_i32(body, &mut cursor, "BattlePetSpeciesID")?; + let battle_pet_breed_id = read_i32(body, &mut cursor, "BattlePetBreedID")?; + let battle_pet_breed_quality = read_u32(body, &mut cursor, "BattlePetBreedQuality")?; + let battle_pet_level = read_i32(body, &mut cursor, "BattlePetLevel")?; + let (item_low, item_high) = read_packed_guid(body, &mut cursor, "ItemGUID")?; + let item_guid = ExactObjectGuid { + low: item_low, + high: item_high, + }; + if let Some(error) = issue_106_item_guid_error(item_guid) { + return Err(format!("ItemPushResult {error}")); + } + + let result_flags = read_u8(body, &mut cursor, "ItemPushResult bit flags")?; + if result_flags != 0x08 { + return Err(format!( + "ItemPushResult flags are 0x{result_flags:02X}, expected non-created/non-pushed normal-display loot flags 0x08" + )); + } + let item_entry = read_i32(body, &mut cursor, "ItemInstance.ItemID")?; + let random_properties_seed = read_i32(body, &mut cursor, "ItemInstance.RandomPropertiesSeed")?; + let random_properties_id = read_i32(body, &mut cursor, "ItemInstance.RandomPropertiesID")?; + let item_bonus_bits = read_u8(body, &mut cursor, "ItemInstance ItemBonus bit byte")?; + let modifications_bits = read_u8(body, &mut cursor, "ItemInstance modifications bit byte")?; + if slot != u8::MAX + || slot_in_bag != ISSUE_106_ITEM_SLOT + || quest_log_item_id != 0 + || quantity != 1 + || quantity_in_inventory != 1 + || dungeon_encounter_id != 0 + || battle_pet_species_id != 0 + || battle_pet_breed_id != 0 + || battle_pet_breed_quality != 0 + || battle_pet_level != 0 + || random_properties_seed != 0 + || random_properties_id != 0 + || item_bonus_bits != 0 + || modifications_bits != 0 + { + return Err(format!( + "ItemPushResult is not the deterministic one-item fixture shape: slot={slot} slot_in_bag={slot_in_bag} quest={quest_log_item_id} quantity={quantity}/{quantity_in_inventory} encounter={dungeon_encounter_id} battle_pet={battle_pet_species_id}/{battle_pet_breed_id}/{battle_pet_breed_quality}/{battle_pet_level} random={random_properties_seed}/{random_properties_id} bonus_bits=0x{item_bonus_bits:02X} mod_bits=0x{modifications_bits:02X}" + )); + } + if cursor != body.len() { + return Err(format!( + "trailing bytes after ItemPushResult ItemInstance: decoded {cursor} of {} bytes", + body.len() + )); + } + + Ok(DecodedIssue106ItemPushResult { + player, + slot_in_bag, + quantity, + item_guid, + item_entry, + }) +} + +fn issue_106_item_guid_error(item_guid: ExactObjectGuid) -> Option { + if item_guid.low == 0 + || item_guid.low & !OBJECT_GUID_COUNTER_MASK != 0 + || item_guid.high != ISSUE_106_CAPTURE_ITEM_HIGH + { + return Some(format!( + "ItemGUID {item_guid:?} is not the canonical non-empty realm-1 Item GUID with server id 0" + )); + } + None +} + /// Decode the opcode-less body emitted by C++ /// `WorldPackets::Character::LogXPGain::Write`. /// @@ -244,6 +1424,45 @@ pub fn decode_log_xp_gain_body(body: &[u8]) -> Result { decode_log_xp_gain_body_with_counter(body).map(|decoded| decoded.body) } +/// Decode the opcode-less body emitted by C++ +/// `WorldPackets::Loot::LootRemoved::Write`. +/// +/// Source anchors: +/// +/// - `LootPackets.cpp`: packed Owner, packed LootObj, LootListID; +/// - `ObjectGuid.cpp::operator<<`: low mask, high mask, packed low bytes, +/// packed high bytes; +/// - `ObjectGuidFactory::CreateWorldObject`: lower 40 low-word bits are the +/// map-runtime counter. +pub fn decode_loot_removed_body(body: &[u8]) -> Result { + decode_loot_removed_body_with_counter(body).map(|decoded| decoded.body) +} + +fn decode_loot_removed_body_with_counter(body: &[u8]) -> Result { + let mut cursor = 0usize; + let (owner_low, owner_high) = read_packed_guid(body, &mut cursor, "Owner")?; + let (loot_obj_low, loot_obj_high) = read_packed_guid(body, &mut cursor, "LootObj")?; + let loot_list_id = read_u8(body, &mut cursor, "LootListID")?; + if cursor != body.len() { + return Err(format!( + "trailing bytes after LootListID: decoded {cursor} of {} bytes", + body.len() + )); + } + + Ok(DecodedLootRemovedBody { + body: LootRemovedBody { + owner: stable_object_guid(owner_low, owner_high), + loot_obj: ExactObjectGuid { + low: loot_obj_low, + high: loot_obj_high, + }, + loot_list_id, + }, + owner_runtime_counter: owner_low & OBJECT_GUID_COUNTER_MASK, + }) +} + fn decode_log_xp_gain_body_with_counter(body: &[u8]) -> Result { let mut cursor = 0usize; let low_mask = read_u8(body, &mut cursor, "Victim low mask")?; @@ -264,17 +1483,9 @@ fn decode_log_xp_gain_body_with_counter(body: &[u8]) -> Result> 58) & 0x3F) as u8, - realm_id: ((high >> 42) & 0xFFFF) as u16, - map_id: ((high >> 29) & 0x1FFF) as u16, - entry: ((high >> 6) & 0x7F_FFFF) as u32, - subtype: (high & 0x3F) as u8, - server_id: ((stable_low >> 40) & 0xFF_FFFF) as u32, - }, + victim: stable_object_guid(low, high), original, reason, amount, @@ -284,6 +1495,28 @@ fn decode_log_xp_gain_body_with_counter(body: &[u8]) -> Result StableObjectGuid { + let stable_low = low & !OBJECT_GUID_COUNTER_MASK; + StableObjectGuid { + high_type: ((high >> 58) & 0x3F) as u8, + // Keeping all 16 bits between map and high type retains the three + // reserved bits as part of strict identity for non-canonical inputs. + realm_id: ((high >> 42) & 0xFFFF) as u16, + map_id: ((high >> 29) & 0x1FFF) as u16, + entry: ((high >> 6) & 0x7F_FFFF) as u32, + subtype: (high & 0x3F) as u8, + server_id: ((stable_low >> 40) & 0xFF_FFFF) as u32, + } +} + +fn read_packed_guid(body: &[u8], cursor: &mut usize, field: &str) -> Result<(u64, u64), String> { + let low_mask = read_u8(body, cursor, &format!("{field} low mask"))?; + let high_mask = read_u8(body, cursor, &format!("{field} high mask"))?; + let low = read_packed_u64(body, cursor, low_mask, &format!("{field} low word"))?; + let high = read_packed_u64(body, cursor, high_mask, &format!("{field} high word"))?; + Ok((low, high)) +} + fn read_packed_u64(body: &[u8], cursor: &mut usize, mask: u8, field: &str) -> Result { let mut bytes = [0u8; 8]; for (index, byte) in bytes.iter_mut().enumerate() { @@ -311,10 +1544,22 @@ fn read_i32(body: &[u8], cursor: &mut usize, field: &str) -> Result Ok(i32::from_le_bytes(read_array(body, cursor, field)?)) } +fn read_u16(body: &[u8], cursor: &mut usize, field: &str) -> Result { + Ok(u16::from_le_bytes(read_array(body, cursor, field)?)) +} + +fn read_u16_be(body: &[u8], cursor: &mut usize, field: &str) -> Result { + Ok(u16::from_be_bytes(read_array(body, cursor, field)?)) +} + fn read_u32(body: &[u8], cursor: &mut usize, field: &str) -> Result { Ok(u32::from_le_bytes(read_array(body, cursor, field)?)) } +fn read_u32_be(body: &[u8], cursor: &mut usize, field: &str) -> Result { + Ok(u32::from_be_bytes(read_array(body, cursor, field)?)) +} + fn read_array( body: &[u8], cursor: &mut usize, diff --git a/crates/capture-diff/tests/diff_engine.rs b/crates/capture-diff/tests/diff_engine.rs index 494812c60..6a8b79a0d 100644 --- a/crates/capture-diff/tests/diff_engine.rs +++ b/crates/capture-diff/tests/diff_engine.rs @@ -30,6 +30,33 @@ fn c2s(opcode: u16, body: &[u8]) -> CapturedPacket { const ALL: &[Direction] = &[Direction::S2C, Direction::C2S]; +#[allow(clippy::too_many_arguments)] +fn write_dump_record( + dir: &Path, + direction: &str, + seq: u64, + counter: u64, + connection_id: &str, + opcode: u16, + name: &str, + body: &[u8], +) -> String { + std::fs::create_dir_all(dir).unwrap(); + let len = body.len() + 2; + let stem = format!("rust-{direction}-{seq:08}-counter{counter}-0x{opcode:04X}-{name}-len{len}"); + let mut bin = opcode.to_le_bytes().to_vec(); + bin.extend_from_slice(body); + std::fs::write(dir.join(format!("{stem}.bin")), bin).unwrap(); + std::fs::write( + dir.join(format!("{stem}.meta")), + format!( + "direction={direction}\nconnection_id={connection_id}\naddr=127.0.0.1:0\nseq={seq}\ncounter={counter}\nopcode=0x{opcode:04X}\nname={name}\nlen={len}\n" + ), + ) + .unwrap(); + stem +} + #[test] fn identical_captures_are_clean() { let pkts = vec![s2c(0x0001, &[1, 2, 3]), s2c(0x0002, &[4, 5])]; @@ -273,6 +300,16 @@ fn pkt_rejects_wrong_version() { assert!(err.to_string().contains("version"), "got: {err}"); } +#[test] +fn pkt_rejects_opcode_wider_than_world_opcode() { + let cap = Capture::new("wide", vec![s2c(0x1234, &[])]); + let mut bytes = pkt::write_pkt_bytes(&cap); + // PKT 3.1 log header (66) + packet header (20) + optional address (20). + bytes[106..110].copy_from_slice(&0x0001_0000_u32.to_le_bytes()); + let error = pkt::parse_pkt_bytes(&bytes).expect_err("wide opcode must fail closed"); + assert!(error.to_string().contains("16-bit world opcode space")); +} + #[test] fn rust_dump_round_trips() { let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join("rust_dump_round_trips"); @@ -328,18 +365,17 @@ fn rust_dump_accepts_unencrypted_handshake_tags() { // parser MUST accept them — a real login dump always contains them. let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join("unencrypted_tags"); let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); // SMSG_AUTH_CHALLENGE-ish: opcode 0x256D, body [9], dumped pre-encryption. - std::fs::write( - dir.join("rust-s2c-unencrypted-00000000-counter0-0x256D-x-len3.bin"), - [0x6D, 0x25, 0x09], - ) - .unwrap(); - std::fs::write( - dir.join("rust-s2c-unencrypted-00000000-counter0-0x256D-x-len3.meta"), - "direction=s2c-unencrypted\nseq=0\nopcode=0x256D\nlen=3\n", - ) - .unwrap(); + write_dump_record( + &dir, + "s2c-unencrypted", + 0, + 0, + "0", + 0x256D, + "AuthResponse", + &[9], + ); let cap = rustdump::parse_rust_dump(&dir).expect("must parse -unencrypted tags"); assert_eq!(cap.packets.len(), 1); assert_eq!(cap.packets[0].direction, Direction::S2C); @@ -352,17 +388,7 @@ fn rust_dump_accepts_unencrypted_handshake_tags() { fn rust_dump_rejects_invalid_explicit_connection_id() { let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join("invalid_connection_id"); let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write( - dir.join("rust-s2c-00000000-counter0-0x256D-x-len3.bin"), - [0x6D, 0x25, 0x09], - ) - .unwrap(); - std::fs::write( - dir.join("rust-s2c-00000000-counter0-0x256D-x-len3.meta"), - "direction=s2c\nconnection_id=instance\nseq=0\nopcode=0x256D\nlen=3\n", - ) - .unwrap(); + write_dump_record(&dir, "s2c", 0, 0, "instance", 0x256D, "AuthResponse", &[9]); let error = rustdump::parse_rust_dump(&dir).unwrap_err(); assert!(error.to_string().contains("invalid connection_id")); @@ -372,25 +398,134 @@ fn rust_dump_rejects_invalid_explicit_connection_id() { fn rust_dump_rejects_duplicate_global_sequence_after_process_restart() { let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join("duplicate_global_sequence"); let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - - for (name, opcode) in [("first", 0x271C_u16), ("second", 0x27CB_u16)] { - let stem = format!("rust-s2c-{name}-0x{opcode:04X}"); - let mut bin = opcode.to_le_bytes().to_vec(); - bin.push(0); - std::fs::write(dir.join(format!("{stem}.bin")), bin).unwrap(); - std::fs::write( - dir.join(format!("{stem}.meta")), - format!("direction=s2c\nconnection_id=1\nseq=7\nopcode=0x{opcode:04X}\nlen=3\n"), - ) - .unwrap(); - } + write_dump_record(&dir, "s2c", 7, 0, "1", 0x271C, "StandStateUpdate", &[0]); + write_dump_record(&dir, "s2c", 7, 1, "1", 0x27CB, "UpdateObject", &[0]); let error = rustdump::parse_rust_dump(&dir).expect_err("duplicate seq must fail closed"); - assert!(error.to_string().contains("duplicate packet dump seq 7")); + assert!( + error + .to_string() + .contains("non-contiguous packet dump seq 7 then 7") + ); assert!(error.to_string().contains("may have restarted")); } +#[test] +fn rust_dump_requires_contiguous_sequence_but_not_zero_origin() { + let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join("sequence_contract"); + let _ = std::fs::remove_dir_all(&dir); + write_dump_record(&dir, "c2s", 41, 5, "1", 0x3211, "LootItem", &[0]); + write_dump_record(&dir, "s2c", 42, 9, "0", 0x2615, "LootRemoved", &[0]); + assert_eq!(rustdump::parse_rust_dump(&dir).unwrap().packets.len(), 2); + + let stem = write_dump_record(&dir, "s2c", 44, 10, "1", 0x27CB, "UpdateObject", &[0]); + let error = rustdump::parse_rust_dump(&dir).expect_err("sequence gap must fail"); + assert!(error.to_string().contains("seq 42 then 44")); + std::fs::remove_file(dir.join(format!("{stem}.meta"))).unwrap(); + std::fs::remove_file(dir.join(format!("{stem}.bin"))).unwrap(); +} + +#[test] +fn rust_dump_rejects_orphans_extras_subdirectories_and_symlinks() { + let root = Path::new(env!("CARGO_TARGET_TMPDIR")).join("flat_inventory_contract"); + let reset = || { + let _ = std::fs::remove_dir_all(&root); + write_dump_record(&root, "c2s", 0, 0, "1", 0x3211, "LootItem", &[0]) + }; + + let stem = reset(); + std::fs::remove_file(root.join(format!("{stem}.bin"))).unwrap(); + assert!( + rustdump::parse_rust_dump(&root) + .unwrap_err() + .to_string() + .contains("orphan .meta") + ); + + reset(); + std::fs::write(root.join("notes.txt"), b"not evidence").unwrap(); + assert!( + rustdump::parse_rust_dump(&root) + .unwrap_err() + .to_string() + .contains("unexpected file") + ); + + reset(); + std::fs::create_dir(root.join("nested")).unwrap(); + assert!( + rustdump::parse_rust_dump(&root) + .unwrap_err() + .to_string() + .contains("not a regular") + ); + + reset(); + #[cfg(unix)] + { + std::os::unix::fs::symlink(root.join(format!("{stem}.bin")), root.join("linked.bin")) + .unwrap(); + assert!( + rustdump::parse_rust_dump(&root) + .unwrap_err() + .to_string() + .contains("non-symlink") + ); + } +} + +#[test] +fn rust_dump_binds_metadata_filename_and_binary_length() { + let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join("metadata_binding_contract"); + let _ = std::fs::remove_dir_all(&dir); + let stem = write_dump_record(&dir, "c2s", 0, 0, "1", 0x3211, "LootItem", &[1, 2]); + let meta = dir.join(format!("{stem}.meta")); + let original = std::fs::read_to_string(&meta).unwrap(); + + std::fs::write(&meta, original.replace("len=4", "len=5")).unwrap(); + let error = rustdump::parse_rust_dump(&dir).expect_err("filename/meta mismatch must fail"); + assert!(error.to_string().contains("filename stem disagrees")); + + std::fs::write(&meta, &original).unwrap(); + let bin = dir.join(format!("{stem}.bin")); + std::fs::write(&bin, [0x11, 0x32, 1]).unwrap(); + let error = rustdump::parse_rust_dump(&dir).expect_err("body length mismatch must fail"); + assert!(error.to_string().contains("metadata declares 4")); + + std::fs::write(&bin, [0x11, 0x32, 1, 2]).unwrap(); + std::fs::write(&meta, format!("{original}unknown=value\n")).unwrap(); + assert!( + rustdump::parse_rust_dump(&dir) + .unwrap_err() + .to_string() + .contains("unknown field") + ); + + std::fs::write( + &meta, + original.replace("addr=127.0.0.1:0", "addr=not-a-socket"), + ) + .unwrap(); + assert!( + rustdump::parse_rust_dump(&dir) + .unwrap_err() + .to_string() + .contains("invalid socket address") + ); + + std::fs::write( + &meta, + original.replace("direction=c2s", "direction=c2s-unencrypted-unencrypted"), + ) + .unwrap(); + assert!( + rustdump::parse_rust_dump(&dir) + .unwrap_err() + .to_string() + .contains("invalid direction") + ); +} + #[test] fn baseline_delta_is_count_aware() { use capture_diff::DivergenceSignature; diff --git a/crates/capture-diff/tests/loot_removed_semantic.rs b/crates/capture-diff/tests/loot_removed_semantic.rs new file mode 100644 index 000000000..f49211e2d --- /dev/null +++ b/crates/capture-diff/tests/loot_removed_semantic.rs @@ -0,0 +1,431 @@ +//! Focused tests for the issue-#106 `SMSG_LOOT_REMOVED` normalization. +//! +//! Paired real C++/Rust captures proved that the same creature spawn receives +//! a different map-runtime GUID counter in the two independently started +//! servers. The comparator may omit exactly that lower 40-bit owner counter; +//! every other GUID bit, the complete loot-object GUID, list id, body shape, +//! canonical packed encoding, opcode, direction, and routing remain strict. + +use capture_diff::diff::DiffReport; +use capture_diff::model::{Capture, CapturedPacket, Direction}; +use capture_diff::semantic::{SMSG_LOOT_REMOVED, decode_loot_removed_body}; + +const REAL_CPP_BODY: [u8; 19] = [ + 0x03, 0xBF, 0x0C, 0x01, 0xC0, 0x44, 0x15, 0x40, 0x42, 0x04, 0x20, 0x01, 0xB8, 0x01, 0x40, 0x42, + 0x04, 0x3C, 0x00, +]; +const REAL_RUST_BODY: [u8; 18] = [ + 0x01, 0xBF, 0x01, 0xC0, 0x44, 0x15, 0x40, 0x42, 0x04, 0x20, 0x01, 0xB8, 0x01, 0x40, 0x42, 0x04, + 0x3C, 0x00, +]; + +#[derive(Clone, Copy)] +struct GuidFields { + high_type: u8, + realm_id: u16, + map_id: u16, + entry: u32, + subtype: u8, + server_id: u32, + counter: u64, +} + +impl GuidFields { + fn doctor(counter: u64) -> Self { + Self { + high_type: 8, // HighGuid::Creature + realm_id: 1, + map_id: 530, + entry: 21_779, + subtype: 0, + server_id: 0, + counter, + } + } + + fn loot_object(counter: u64) -> Self { + Self { + high_type: 15, // HighGuid::LootObject + realm_id: 1, + map_id: 530, + entry: 0, + subtype: 0, + server_id: 0, + counter, + } + } + + fn words(self) -> (u64, u64) { + let high = (u64::from(self.high_type) << 58) + | (u64::from(self.realm_id) << 42) + | (u64::from(self.map_id) << 29) + | (u64::from(self.entry) << 6) + | u64::from(self.subtype); + let low = (u64::from(self.server_id) << 40) | self.counter; + (low, high) + } +} + +fn packed_guid(low: u64, high: u64) -> Vec { + let low_bytes = low.to_le_bytes(); + let high_bytes = high.to_le_bytes(); + let mut low_mask = 0u8; + let mut high_mask = 0u8; + for (index, byte) in low_bytes.iter().enumerate() { + if *byte != 0 { + low_mask |= 1 << index; + } + } + for (index, byte) in high_bytes.iter().enumerate() { + if *byte != 0 { + high_mask |= 1 << index; + } + } + + let mut body = vec![low_mask, high_mask]; + body.extend( + low_bytes + .iter() + .enumerate() + .filter_map(|(index, byte)| (low_mask & (1 << index) != 0).then_some(*byte)), + ); + body.extend( + high_bytes + .iter() + .enumerate() + .filter_map(|(index, byte)| (high_mask & (1 << index) != 0).then_some(*byte)), + ); + body +} + +fn loot_removed_body(owner: GuidFields, loot_obj: GuidFields, loot_list_id: u8) -> Vec { + let (owner_low, owner_high) = owner.words(); + let (loot_low, loot_high) = loot_obj.words(); + let mut body = packed_guid(owner_low, owner_high); + body.extend(packed_guid(loot_low, loot_high)); + body.push(loot_list_id); + body +} + +fn packet(direction: Direction, connection_id: u32, opcode: u16, body: Vec) -> CapturedPacket { + CapturedPacket { + direction, + connection_id, + opcode, + body, + } +} + +fn report(cpp: CapturedPacket, rust: CapturedPacket, direction: Direction) -> DiffReport { + DiffReport::compute( + &Capture::new("cpp", vec![cpp]), + &Capture::new("rust", vec![rust]), + &[direction], + ) +} + +fn s2c_report(cpp_body: Vec, rust_body: Vec) -> DiffReport { + report( + packet(Direction::S2C, 1, SMSG_LOOT_REMOVED, cpp_body), + packet(Direction::S2C, 1, SMSG_LOOT_REMOVED, rust_body), + Direction::S2C, + ) +} + +#[test] +fn decoder_pins_real_cpp_loot_removed_bytes_and_field_order() { + let decoded = decode_loot_removed_body(&REAL_CPP_BODY).expect("decode real C++ body"); + assert_eq!(decoded.owner.high_type, 8); + assert_eq!(decoded.owner.realm_id, 1); + assert_eq!(decoded.owner.map_id, 530); + assert_eq!(decoded.owner.entry, 21_779); + assert_eq!(decoded.owner.subtype, 0); + assert_eq!(decoded.owner.server_id, 0); + assert_eq!(decoded.loot_obj.low, 1); + assert_eq!(decoded.loot_obj.high, 0x3C00_0442_4000_0000); + assert_eq!(decoded.loot_list_id, 0); + + let rust = decode_loot_removed_body(&REAL_RUST_BODY).expect("decode real Rust body"); + assert_eq!(decoded, rust, "only the omitted owner counter may differ"); +} + +#[test] +fn real_paired_bodies_ignore_only_creature_owner_runtime_counter() { + let report = s2c_report(REAL_CPP_BODY.to_vec(), REAL_RUST_BODY.to_vec()); + + assert!(report.is_clean(), "{}", report.render_text()); + assert_eq!(report.counts.matched, 1); + let body = report.ops[0].body.as_ref().expect("matched body"); + assert_ne!(body.cpp_len, body.rust_len); + assert_eq!(body.first_diff_offset, None); + let semantic = body.semantic.as_ref().expect("semantic comparator"); + assert_eq!( + semantic.comparator, + "smsg_loot_removed_without_creature_owner_runtime_guid_counter" + ); + assert_eq!(semantic.cpp.raw_body_sha256, None); + assert_eq!(semantic.rust.raw_body_sha256, None); +} + +#[test] +fn loot_removed_compares_every_stable_owner_component() { + let cpp = GuidFields::doctor(268); + let variants = [ + ( + GuidFields { + high_type: 9, + ..cpp + }, + "owner.high_type", + ), + (GuidFields { realm_id: 2, ..cpp }, "owner.realm_id"), + (GuidFields { map_id: 571, ..cpp }, "owner.map_id"), + ( + GuidFields { + entry: 21_780, + ..cpp + }, + "owner.entry", + ), + (GuidFields { subtype: 1, ..cpp }, "owner.subtype"), + ( + GuidFields { + server_id: 1, + ..cpp + }, + "owner.server_id", + ), + ]; + + for (changed, expected_field) in variants { + let report = s2c_report( + loot_removed_body(cpp, GuidFields::loot_object(1), 0), + loot_removed_body(changed, GuidFields::loot_object(1), 0), + ); + assert!(!report.is_clean(), "accepted {expected_field} mismatch"); + let semantic = report.ops[0] + .body + .as_ref() + .and_then(|body| body.semantic.as_ref()) + .expect("semantic mismatch"); + assert!( + semantic.mismatch_summary().contains(expected_field), + "unexpected summary: {}", + semantic.mismatch_summary() + ); + } +} + +#[test] +fn loot_removed_keeps_complete_loot_object_and_list_id_strict() { + let owner_cpp = GuidFields::doctor(268); + let owner_rust = GuidFields::doctor(1); + let loot_obj = GuidFields::loot_object(1); + let variants = [ + ( + GuidFields { + counter: 2, + ..loot_obj + }, + 0, + "loot_obj.low", + ), + ( + GuidFields { + map_id: 571, + ..loot_obj + }, + 0, + "loot_obj.high", + ), + (loot_obj, 1, "loot_list_id"), + ]; + + for (changed_loot_obj, changed_list_id, expected_field) in variants { + let report = s2c_report( + loot_removed_body(owner_cpp, loot_obj, 0), + loot_removed_body(owner_rust, changed_loot_obj, changed_list_id), + ); + assert!(!report.is_clean(), "accepted {expected_field} mismatch"); + let semantic = report.ops[0] + .body + .as_ref() + .and_then(|body| body.semantic.as_ref()) + .expect("semantic mismatch"); + assert!(semantic.mismatch_summary().contains(expected_field)); + } +} + +#[test] +fn creature_owner_requires_nonzero_runtime_counter_on_both_sides() { + for (cpp_counter, rust_counter) in [(0, 1), (1, 0), (0, 0)] { + let report = s2c_report( + loot_removed_body( + GuidFields::doctor(cpp_counter), + GuidFields::loot_object(1), + 0, + ), + loot_removed_body( + GuidFields::doctor(rust_counter), + GuidFields::loot_object(1), + 0, + ), + ); + assert!( + !report.is_clean(), + "accepted owner counters {cpp_counter}/{rust_counter}" + ); + assert!(report.render_text().contains("zero runtime GUID counter")); + } +} + +#[test] +fn malformed_noncanonical_and_trailing_bodies_never_compare_clean() { + let mut noncanonical = REAL_RUST_BODY.to_vec(); + noncanonical[2] = 0; // Owner low mask promises a byte that must be nonzero. + let mut trailing = REAL_RUST_BODY.to_vec(); + trailing.push(0xAA); + + for malformed in [vec![0x01], noncanonical, trailing] { + let report = s2c_report(malformed.clone(), malformed); + assert!(!report.is_clean(), "accepted malformed body"); + assert_eq!(report.counts.body_mismatches, 1); + let semantic = report.ops[0] + .body + .as_ref() + .and_then(|body| body.semantic.as_ref()) + .expect("malformed body must select fail-closed comparator"); + assert!(semantic.cpp.decode_error.is_some()); + assert!(semantic.rust.decode_error.is_some()); + assert!(semantic.cpp.raw_body_sha256.is_some()); + assert!(semantic.rust.raw_body_sha256.is_some()); + } +} + +#[test] +fn two_non_creature_owners_keep_raw_counter_comparison() { + let cpp_owner = GuidFields { + high_type: 11, // HighGuid::GameObject + ..GuidFields::doctor(1) + }; + let rust_owner = GuidFields { + counter: 2, + ..cpp_owner + }; + let report = s2c_report( + loot_removed_body(cpp_owner, GuidFields::loot_object(1), 0), + loot_removed_body(rust_owner, GuidFields::loot_object(1), 0), + ); + + assert!(!report.is_clean()); + assert_eq!(report.counts.body_mismatches, 1); + assert!(report.ops[0].body.as_ref().unwrap().semantic.is_none()); +} + +#[test] +fn another_creature_identity_keeps_its_instance_counter_strict() { + let first = GuidFields { + entry: 21_780, + counter: 1, + ..GuidFields::doctor(1) + }; + let second = GuidFields { + counter: 2, + ..first + }; + let report = s2c_report( + loot_removed_body(first, GuidFields::loot_object(1), 0), + loot_removed_body(second, GuidFields::loot_object(1), 0), + ); + + assert!(!report.is_clean()); + assert_eq!(report.counts.body_mismatches, 1); + assert!( + report.ops[0].body.as_ref().unwrap().semantic.is_none(), + "only the exact unique issue-#106 Doctor fixture may omit its instance counter" + ); +} + +#[test] +fn no_other_opcode_or_direction_receives_loot_owner_normalization() { + let cpp = REAL_CPP_BODY.to_vec(); + let rust = REAL_RUST_BODY.to_vec(); + + let wrong_opcode = report( + packet(Direction::S2C, 1, SMSG_LOOT_REMOVED - 1, cpp.clone()), + packet(Direction::S2C, 1, SMSG_LOOT_REMOVED - 1, rust.clone()), + Direction::S2C, + ); + assert!(!wrong_opcode.is_clean()); + assert!( + wrong_opcode.ops[0] + .body + .as_ref() + .unwrap() + .semantic + .is_none() + ); + + let wrong_direction = report( + packet(Direction::C2S, 1, SMSG_LOOT_REMOVED, cpp), + packet(Direction::C2S, 1, SMSG_LOOT_REMOVED, rust), + Direction::C2S, + ); + assert!(!wrong_direction.is_clean()); + assert!( + wrong_direction.ops[0] + .body + .as_ref() + .unwrap() + .semantic + .is_none() + ); +} + +#[test] +fn loot_removed_still_requires_cpp_instance_socket_routing() { + let report = report( + packet(Direction::S2C, 1, SMSG_LOOT_REMOVED, REAL_CPP_BODY.to_vec()), + packet( + Direction::S2C, + 0, + SMSG_LOOT_REMOVED, + REAL_RUST_BODY.to_vec(), + ), + Direction::S2C, + ); + + assert!(!report.is_clean()); + assert_eq!(report.counts.body_mismatches, 0); + assert_eq!(report.counts.connection_mismatches, 1); +} + +#[test] +fn semantic_baseline_identity_excludes_only_owner_counter_dependent_lengths() { + let expected = s2c_report( + loot_removed_body(GuidFields::doctor(1), GuidFields::loot_object(1), 0), + loot_removed_body(GuidFields::doctor(2), GuidFields::loot_object(2), 0), + ); + let current = s2c_report( + loot_removed_body( + GuidFields::doctor(0x01_0203_0405), + GuidFields::loot_object(1), + 0, + ), + loot_removed_body( + GuidFields::doctor(0x05_0403_0201), + GuidFields::loot_object(2), + 0, + ), + ); + + assert_ne!( + expected.ops[0].body.as_ref().unwrap().cpp_len, + current.ops[0].body.as_ref().unwrap().cpp_len + ); + assert_eq!(expected.signatures(), current.signatures()); + let signature = &expected.signatures()[0]; + assert_eq!(signature.cpp_body_len, None); + assert_eq!(signature.rust_body_len, None); + assert_eq!(signature.first_diff_offset, None); +} diff --git a/crates/capture-diff/tests/loot_single_item_claim_contract.rs b/crates/capture-diff/tests/loot_single_item_claim_contract.rs new file mode 100644 index 000000000..31a162e45 --- /dev/null +++ b/crates/capture-diff/tests/loot_single_item_claim_contract.rs @@ -0,0 +1,234 @@ +//! Fail-closed tests for the complete issue-#106 required-flow contract. + +use capture_diff::flow::{load_flow, load_requirement}; +use capture_diff::{Capture, Direction, pkt, rustdump}; + +fn committed_pair() -> (Capture, Capture) { + let flow = load_flow("loot-single-item-claim").expect("committed loot flow"); + let cpp = pkt::parse_pkt_file(&flow.golden_pkt).expect("C++ fixture"); + let rust = rustdump::parse_rust_dump(&flow.reference_rust).expect("Rust fixture"); + (cpp, rust) +} + +fn packet_index(capture: &Capture, direction: Direction, opcode: u16) -> usize { + capture + .packets + .iter() + .position(|packet| packet.direction == direction && packet.opcode == opcode) + .expect("required packet") +} + +#[test] +fn committed_pair_satisfies_correlated_single_claim_semantics() { + let requirement = load_requirement("loot-single-item-claim").expect("required contract"); + let (cpp, rust) = committed_pair(); + + requirement.validate_capture(&cpp).expect("C++ evidence"); + requirement.validate_capture(&rust).expect("Rust evidence"); +} + +#[test] +fn extra_item_push_on_wrong_socket_cannot_hide_behind_exact_anchor_count() { + let requirement = load_requirement("loot-single-item-claim").expect("required contract"); + let (_, mut rust) = committed_pair(); + let push_index = packet_index(&rust, Direction::S2C, 0x2623); + let mut duplicate = rust.packets[push_index].clone(); + duplicate.connection_id = 1; + rust.packets.insert(push_index + 1, duplicate); + + let error = requirement + .validate_capture(&rust) + .expect_err("wrong-route duplicate must invalidate evidence"); + assert!(error.to_string().contains("contains 7 packet(s)")); +} + +#[test] +fn loot_request_must_contain_one_correlated_object_and_list() { + let requirement = load_requirement("loot-single-item-claim").expect("required contract"); + let (_, rust) = committed_pair(); + let request_index = packet_index(&rust, Direction::C2S, 0x3211); + + let mut multiple = rust.clone(); + multiple.packets[request_index].body[..4].copy_from_slice(&2_u32.to_le_bytes()); + let error = requirement + .validate_capture(&multiple) + .expect_err("Count=2 must fail"); + assert!(error.to_string().contains("contains 2 request(s)")); + + let mut other_object = rust.clone(); + // Count (4), packed low/high masks (2), then the LootObject low counter. + other_object.packets[request_index].body[6] = 2; + let error = requirement + .validate_capture(&other_object) + .expect_err("request/removal LootObj mismatch must fail"); + assert!( + error + .to_string() + .contains("does not match SMSG_LOOT_REMOVED") + ); + + let mut other_list = rust; + other_list.packets[request_index].body[11] = 1; + let error = requirement + .validate_capture(&other_list) + .expect_err("a different LootListID must fail"); + assert!(error.to_string().contains("LootListID is 1")); +} + +#[test] +fn item_create_is_one_owned_item_and_correlates_with_the_grant() { + let requirement = load_requirement("loot-single-item-claim").expect("required contract"); + let (_, rust) = committed_pair(); + let create_index = rust + .packets + .iter() + .enumerate() + .find_map(|(index, packet)| { + (packet.direction == Direction::S2C && packet.opcode == 0x27CB).then_some(index) + }) + .expect("item CreateObject packet"); + + let mut two_updates = rust.clone(); + two_updates.packets[create_index].body[..4].copy_from_slice(&2_u32.to_le_bytes()); + let error = requirement + .validate_capture(&two_updates) + .expect_err("a multi-block create packet must fail"); + assert!(error.to_string().contains("2 object updates")); + + let mut values_instead_of_create = rust.clone(); + values_instead_of_create.packets[create_index].body[11] = 0; + let error = requirement + .validate_capture(&values_instead_of_create) + .expect_err("a non-CreateObject update must fail"); + assert!(error.to_string().contains("expected CreateObject (1)")); + + let mut non_item = rust.clone(); + non_item.packets[create_index].body[19] = 5; + let error = requirement + .validate_capture(&non_item) + .expect_err("a Unit CreateObject must fail"); + assert!(error.to_string().contains("expected Item (1)")); + + let mut other_item_guid = rust.clone(); + // Top-level fields (11), UpdateType (1), packed GUID masks (2), then item low. + other_item_guid.packets[create_index].body[14] ^= 1; + let error = requirement + .validate_capture(&other_item_guid) + .expect_err("created and granted Item GUIDs must correlate"); + assert!(error.to_string().contains("does not match ItemPushResult")); + + let mut other_entry = rust.clone(); + other_entry.packets[create_index].body[32..36].copy_from_slice(&30_713_i32.to_le_bytes()); + let error = requirement + .validate_capture(&other_entry) + .expect_err("a foreign created item entry must fail"); + assert!(error.to_string().contains("expected fixture item 30712")); + + let mut other_owner = rust.clone(); + // Values start at 31; Owner masks start at 44 and its low byte is 46. + other_owner.packets[create_index].body[46] = 16; + let error = requirement + .validate_capture(&other_owner) + .expect_err("a foreign item owner must fail"); + assert!(error.to_string().contains("expected capture player")); + + let mut other_container = rust.clone(); + // ContainedIn immediately follows the five-byte Owner packed GUID; its + // first nonzero low byte is body byte 51. + other_container.packets[create_index].body[51] = 16; + let error = requirement + .validate_capture(&other_container) + .expect_err("a foreign containing player must fail"); + assert!(error.to_string().contains("expected capture player")); + + let mut stack_two = rust; + stack_two.packets[create_index].body[58..62].copy_from_slice(&2_u32.to_le_bytes()); + let error = requirement + .validate_capture(&stack_two) + .expect_err("StackCount=2 must fail the single-item contract"); + assert!(error.to_string().contains("stack=2")); +} + +#[test] +fn item_push_recipient_quantity_item_and_inventory_value_are_correlated() { + let requirement = load_requirement("loot-single-item-claim").expect("required contract"); + let (_, rust) = committed_pair(); + let push_index = packet_index(&rust, Direction::S2C, 0x2623); + + let mut other_player = rust.clone(); + // Packed PlayerGUID low byte follows its two masks. + other_player.packets[push_index].body[2] = 16; + let error = requirement + .validate_capture(&other_player) + .expect_err("wrong recipient must fail"); + assert!(error.to_string().contains("PlayerGUID")); + + let mut quantity_two = rust.clone(); + quantity_two.packets[push_index].body[14..18].copy_from_slice(&2_i32.to_le_bytes()); + let error = requirement + .validate_capture(&quantity_two) + .expect_err("Quantity=2 must fail"); + assert!(error.to_string().contains("quantity=2/1")); + + let mut other_slot = rust.clone(); + // Packed PlayerGUID occupies five bytes; Slot is byte 5 and SlotInBag is + // bytes 6..10. The restored Doctor-key fixture uses keyring slot 106. + other_slot.packets[push_index].body[6..10].copy_from_slice(&35_i32.to_le_bytes()); + let error = requirement + .validate_capture(&other_slot) + .expect_err("a backpack/bank/equipment destination must not satisfy this fixture"); + assert!(error.to_string().contains("slot_in_bag=35")); + + let mut other_item_guid = rust.clone(); + // ItemGUID masks start at 42; byte 44 is its first nonzero low byte. + other_item_guid.packets[push_index].body[44] ^= 1; + let error = requirement + .validate_capture(&other_item_guid) + .expect_err("CreateObject/ItemPush/InvSlots ItemGUID mismatch must fail"); + assert!(error.to_string().contains("does not match ItemPushResult")); + + let mut other_item_entry = rust; + other_item_entry.packets[push_index].body[50..54].copy_from_slice(&30_713_i32.to_le_bytes()); + let error = requirement + .validate_capture(&other_item_entry) + .expect_err("wrong ItemInstance entry must fail"); + assert!( + error + .to_string() + .contains("item CreateObject entry 30712 does not match ItemPushResult 30713") + ); +} + +#[test] +fn inventory_update_must_name_the_same_awarded_item() { + let requirement = load_requirement("loot-single-item-claim").expect("required contract"); + let (_, mut rust) = committed_pair(); + let inventory_index = rust + .packets + .iter() + .enumerate() + .rfind(|(_, packet)| packet.direction == Direction::S2C && packet.opcode == 0x27CB) + .map(|(index, _)| index) + .expect("post-claim InvSlots packet"); + // The InvSlots packed Item masks are bytes 39/40 and its first low byte is + // 41 in the reviewed 46-byte Rust body. + rust.packets[inventory_index].body[41] ^= 1; + + let error = requirement + .validate_capture(&rust) + .expect_err("InvSlots must contain the ItemPush/CreateObject GUID"); + assert!(error.to_string().contains("does not match InvSlots item")); +} + +#[test] +fn deterministic_ping_payload_is_part_of_the_claim_boundary() { + let requirement = load_requirement("loot-single-item-claim").expect("required contract"); + let (_, mut rust) = committed_pair(); + let ping_index = packet_index(&rust, Direction::C2S, 0x3768); + rust.packets[ping_index].body[0] ^= 1; + + let error = requirement + .validate_capture(&rust) + .expect_err("a different fence serial must fail"); + assert!(error.to_string().contains("fixed TOOL/zero-latency")); +} diff --git a/crates/capture-diff/tests/update_object_empty_unit_parent_semantic.rs b/crates/capture-diff/tests/update_object_empty_unit_parent_semantic.rs new file mode 100644 index 000000000..17db5d521 --- /dev/null +++ b/crates/capture-diff/tests/update_object_empty_unit_parent_semantic.rs @@ -0,0 +1,501 @@ +//! Fail-closed tests for the issue-#106 `SMSG_UPDATE_OBJECT` cadence filter. +//! +//! Two independent C++ captures emitted the same 51-byte one-player VALUES +//! body: UnitData contained only parent bit 116 (`Power`) with no child and no +//! payload, followed by one ActivePlayer InvSlots value. Rust emitted the same +//! update without that empty parent in 46 bytes. The comparator may remove only +//! that C++-side five-byte mask fragment; this is capture cadence noise, not +//! evidence that power-regeneration gameplay is equivalent. + +use capture_diff::diff::DiffReport; +use capture_diff::model::{Capture, CapturedPacket, Direction}; +use capture_diff::semantic::SMSG_UPDATE_OBJECT; + +const REAL_CPP_BODY: [u8; 51] = [ + 0x01, 0x00, 0x00, 0x00, 0x12, 0x02, 0x00, 0x28, 0x00, 0x00, 0x00, 0x00, 0x01, 0xA0, 0x0F, 0x04, + 0x08, 0x1E, 0x00, 0x00, 0x00, 0xA0, 0x00, 0x00, 0x00, 0x08, 0x00, 0x10, 0x00, 0x00, 0x88, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x07, 0xA0, 0xFA, 0x84, + 0x1E, 0x04, 0x0C, +]; +const REAL_RUST_BODY: [u8; 46] = [ + 0x01, 0x00, 0x00, 0x00, 0x12, 0x02, 0x00, 0x23, 0x00, 0x00, 0x00, 0x00, 0x01, 0xA0, 0x0F, 0x04, + 0x08, 0x19, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x88, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x07, 0xA0, 0xFA, 0x84, 0x1E, 0x04, 0x0C, +]; + +fn packet(direction: Direction, connection_id: u32, opcode: u16, body: Vec) -> CapturedPacket { + CapturedPacket { + direction, + connection_id, + opcode, + body, + } +} + +fn report(cpp: CapturedPacket, rust: CapturedPacket, direction: Direction) -> DiffReport { + DiffReport::compute( + &Capture::new("cpp", vec![cpp]), + &Capture::new("rust", vec![rust]), + &[direction], + ) +} + +fn s2c_report(cpp: Vec, rust: Vec) -> DiffReport { + report( + packet(Direction::S2C, 1, SMSG_UPDATE_OBJECT, cpp), + packet(Direction::S2C, 1, SMSG_UPDATE_OBJECT, rust), + Direction::S2C, + ) +} + +fn semantic(report: &DiffReport) -> &capture_diff::SemanticBodyDiff { + report.ops[0] + .body + .as_ref() + .and_then(|body| body.semantic.as_ref()) + .expect("reviewed UpdateObject comparator") +} + +fn set_u32_le(body: &mut [u8], offset: usize, value: u32) { + body[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); +} + +#[test] +fn real_51_and_46_byte_bodies_remove_only_cpp_empty_unit_power_parent() { + let report = s2c_report(REAL_CPP_BODY.to_vec(), REAL_RUST_BODY.to_vec()); + + assert!(report.is_clean(), "{}", report.render_text()); + assert_eq!(report.counts.matched, 1); + let body = report.ops[0].body.as_ref().expect("body comparison"); + assert_eq!(body.cpp_len, 51); + assert_eq!(body.rust_len, 46); + assert_eq!(body.first_diff_offset, None); + let semantic = semantic(&report); + assert_eq!( + semantic.comparator, + "smsg_update_object_without_cpp_empty_unit_power_parent" + ); + assert_eq!(semantic.cpp.raw_body_sha256, None); + assert_eq!(semantic.rust.raw_body_sha256, None); + + let cpp = semantic + .cpp + .update_object_inv_slots + .as_ref() + .expect("decoded C++ update"); + let rust = semantic + .rust + .update_object_inv_slots + .as_ref() + .expect("decoded Rust update"); + assert_eq!(cpp, rust); + assert_eq!(cpp.map_id, 530); + assert_eq!(cpp.player.low, 15); + assert_eq!(cpp.player.high, 0x0800_0400_0000_0000); + assert_eq!(cpp.inv_slots.len(), 1); + assert_eq!(cpp.inv_slots[0].slot, 106); + assert_eq!(cpp.inv_slots[0].item.low, 0x001E_84FA); + assert_eq!(cpp.inv_slots[0].item.high, 0x0C00_0400_0000_0000); +} + +#[test] +fn map_and_player_identity_remain_strict() { + let mut changed_map = REAL_RUST_BODY.to_vec(); + changed_map[4] = 0x13; + let map_report = s2c_report(REAL_CPP_BODY.to_vec(), changed_map); + assert!(!map_report.is_clean()); + assert!(semantic(&map_report).mismatch_summary().contains("map_id")); + + let mut changed_player = REAL_RUST_BODY.to_vec(); + changed_player[14] = 0x10; + let player_report = s2c_report(REAL_CPP_BODY.to_vec(), changed_player); + assert!(!player_report.is_clean()); + assert!( + semantic(&player_report) + .mismatch_summary() + .contains("player") + ); +} + +#[test] +fn inv_slot_and_item_value_remain_strict() { + let mut changed_slot = REAL_RUST_BODY.to_vec(); + // ActivePlayer block 7: field 231 / slot 106 -> field 232 / slot 107. + changed_slot[37] = 0x01; + changed_slot[38] = 0x00; + let slot_report = s2c_report(REAL_CPP_BODY.to_vec(), changed_slot); + assert!(!slot_report.is_clean()); + assert!( + semantic(&slot_report) + .mismatch_summary() + .contains("inv_slots.slot") + ); + + let mut changed_item = REAL_RUST_BODY.to_vec(); + changed_item[41] = 0xFB; + let item_report = s2c_report(REAL_CPP_BODY.to_vec(), changed_item); + assert!(!item_report.is_clean()); + assert!( + semantic(&item_report) + .mismatch_summary() + .contains("inv_slots.item") + ); +} + +#[test] +fn destroy_out_of_range_and_noncanonical_outer_bit_padding_fail() { + let mut destroy = REAL_RUST_BODY.to_vec(); + destroy[6] = 0x80; + let report = s2c_report(REAL_CPP_BODY.to_vec(), destroy); + assert!(!report.is_clean()); + assert!(semantic(&report).rust.decode_error.is_some()); + + let mut padding = REAL_RUST_BODY.to_vec(); + padding[6] = 0x01; + let report = s2c_report(REAL_CPP_BODY.to_vec(), padding); + assert!(!report.is_clean()); + assert!( + semantic(&report) + .rust + .decode_error + .as_deref() + .unwrap() + .contains("padding") + ); +} + +#[test] +fn another_object_type_parent_or_active_mask_fails() { + let mut another_type = REAL_RUST_BODY.to_vec(); + another_type[21] = 0x81; + let report = s2c_report(REAL_CPP_BODY.to_vec(), another_type); + assert!(!report.is_clean()); + assert!(semantic(&report).rust.decode_error.is_some()); + + // Add ActivePlayer block 0 / field 0 alongside the real InvSlots blocks. + let mut another_active_mask = REAL_RUST_BODY.to_vec(); + another_active_mask[25] = 0x89; + another_active_mask.splice(31..31, [0x00, 0x00, 0x00, 0x01]); + set_u32_le(&mut another_active_mask, 7, 39); + set_u32_le(&mut another_active_mask, 17, 29); + let report = s2c_report(REAL_CPP_BODY.to_vec(), another_active_mask); + assert!(!report.is_clean()); + assert!( + semantic(&report) + .rust + .decode_error + .as_deref() + .unwrap() + .contains("non-InvSlots") + ); +} + +#[test] +fn any_unit_child_other_parent_or_payload_fails() { + let mut another_parent = REAL_CPP_BODY.to_vec(); + another_parent[27] = 0x08; + let report = s2c_report(another_parent, REAL_RUST_BODY.to_vec()); + assert!(!report.is_clean()); + assert!( + semantic(&report) + .cpp + .decode_error + .as_deref() + .unwrap() + .contains("parent bit 116") + ); + + // Add child bit 117 and a four-byte field payload before ActivePlayer. + let mut child_and_payload = REAL_CPP_BODY.to_vec(); + child_and_payload[27] = 0x30; + child_and_payload.splice(30..30, [0x00, 0x00, 0x00, 0x00]); + set_u32_le(&mut child_and_payload, 7, 44); + set_u32_le(&mut child_and_payload, 17, 34); + let report = s2c_report(child_and_payload, REAL_RUST_BODY.to_vec()); + assert!(!report.is_clean()); + assert!( + semantic(&report) + .cpp + .decode_error + .as_deref() + .unwrap() + .contains("parent bit 116") + ); + + // Even payload bytes without a child mask cannot be swallowed. + let mut payload_only = REAL_CPP_BODY.to_vec(); + payload_only.splice(30..30, [0xAA, 0xBB, 0xCC, 0xDD]); + set_u32_le(&mut payload_only, 7, 44); + set_u32_le(&mut payload_only, 17, 34); + let report = s2c_report(payload_only, REAL_RUST_BODY.to_vec()); + assert!(!report.is_clean()); + assert!(semantic(&report).cpp.decode_error.is_some()); +} + +#[test] +fn length_truncation_trailing_bytes_and_packed_guid_noncanonicality_fail() { + let mut bad_outer_len = REAL_RUST_BODY.to_vec(); + set_u32_le(&mut bad_outer_len, 7, 34); + let report = s2c_report(REAL_CPP_BODY.to_vec(), bad_outer_len); + assert!(!report.is_clean()); + assert!(semantic(&report).rust.decode_error.is_some()); + + let mut bad_values_len = REAL_RUST_BODY.to_vec(); + set_u32_le(&mut bad_values_len, 17, 24); + let report = s2c_report(REAL_CPP_BODY.to_vec(), bad_values_len); + assert!(!report.is_clean()); + assert!(semantic(&report).rust.decode_error.is_some()); + + let mut truncated = REAL_RUST_BODY.to_vec(); + truncated.pop(); + let report = s2c_report(REAL_CPP_BODY.to_vec(), truncated); + assert!(!report.is_clean()); + assert!(semantic(&report).rust.decode_error.is_some()); + + let mut trailing = REAL_RUST_BODY.to_vec(); + trailing.push(0xAA); + let report = s2c_report(REAL_CPP_BODY.to_vec(), trailing); + assert!(!report.is_clean()); + assert!(semantic(&report).rust.decode_error.is_some()); + + let mut bad_player_packing = REAL_RUST_BODY.to_vec(); + bad_player_packing[14] = 0; + let report = s2c_report(REAL_CPP_BODY.to_vec(), bad_player_packing); + assert!(!report.is_clean()); + assert!( + semantic(&report) + .rust + .decode_error + .as_deref() + .unwrap() + .contains("non-canonical") + ); + + let mut bad_item_packing = REAL_RUST_BODY.to_vec(); + bad_item_packing[41] = 0; + let report = s2c_report(REAL_CPP_BODY.to_vec(), bad_item_packing); + assert!(!report.is_clean()); + assert!( + semantic(&report) + .rust + .decode_error + .as_deref() + .unwrap() + .contains("non-canonical") + ); +} + +#[test] +fn player_and_item_global_guid_reserved_high_bits_fail_without_length_changes() { + // ObjectGuidFactory::CreatePlayer uses only HighGuid bits 58..63 and realm + // bits 42..57. Changing byte 5 from 0x04 to 0x05 sets forbidden bit 40 + // while preserving the packed length and HighGuid::Player. + let mut cpp_bad_player = REAL_CPP_BODY.to_vec(); + cpp_bad_player[15] = 0x05; + let mut rust_bad_player = REAL_RUST_BODY.to_vec(); + rust_bad_player[15] = 0x05; + let report = s2c_report(cpp_bad_player, rust_bad_player); + assert!(!report.is_clean()); + assert!( + semantic(&report) + .cpp + .decode_error + .as_deref() + .unwrap() + .contains("reserved high-word bits 0..41") + ); + assert!( + semantic(&report) + .rust + .decode_error + .as_deref() + .unwrap() + .contains("reserved high-word bits 0..41") + ); + + // ObjectGuidFactory::CreateItem has the same high-word layout. These are + // the matching high-byte positions in the literal 51/46-byte bodies. + let mut cpp_bad_item = REAL_CPP_BODY.to_vec(); + cpp_bad_item[49] = 0x05; + let mut rust_bad_item = REAL_RUST_BODY.to_vec(); + rust_bad_item[44] = 0x05; + let report = s2c_report(cpp_bad_item, rust_bad_item); + assert!(!report.is_clean()); + assert!( + semantic(&report) + .cpp + .decode_error + .as_deref() + .unwrap() + .contains("reserved high-word bits 0..41") + ); + assert!( + semantic(&report) + .rust + .decode_error + .as_deref() + .unwrap() + .contains("reserved high-word bits 0..41") + ); +} + +#[test] +fn identical_malformed_candidate_is_never_capture_clean() { + let mut malformed = REAL_RUST_BODY.to_vec(); + malformed[14] = 0; + let report = s2c_report(malformed.clone(), malformed); + + assert!(!report.is_clean()); + assert_eq!(report.counts.body_mismatches, 1); + assert!(semantic(&report).cpp.decode_error.is_some()); + assert!(semantic(&report).rust.decode_error.is_some()); + assert!(semantic(&report).cpp.raw_body_sha256.is_some()); + assert!(semantic(&report).rust.raw_body_sha256.is_some()); +} + +#[test] +fn mixed_not_eligible_and_malformed_shapes_keep_raw_comparison_in_both_orientations() { + let mut not_eligible = REAL_RUST_BODY.to_vec(); + set_u32_le(&mut not_eligible, 0, 2); + let mut malformed = REAL_RUST_BODY.to_vec(); + malformed[14] = 0; + + for report in [ + s2c_report(not_eligible.clone(), malformed.clone()), + s2c_report(malformed.clone(), not_eligible.clone()), + ] { + assert!(!report.is_clean()); + assert_eq!(report.counts.body_mismatches, 1); + let body = report.ops[0].body.as_ref().expect("raw body comparison"); + assert!(body.semantic.is_none()); + assert!(body.first_diff_offset.is_some()); + } +} + +#[test] +fn cpp_only_orientation_is_mandatory() { + let report = s2c_report(REAL_RUST_BODY.to_vec(), REAL_CPP_BODY.to_vec()); + + assert!(!report.is_clean()); + assert!(semantic(&report).mismatch_summary().contains("C++-only")); +} + +#[test] +fn create_object_and_other_update_object_shapes_receive_no_normalization() { + let mut cpp_create = REAL_CPP_BODY.to_vec(); + let mut rust_create = REAL_RUST_BODY.to_vec(); + cpp_create[11] = 1; + rust_create[11] = 1; + let report = s2c_report(cpp_create, rust_create); + assert!(!report.is_clean()); + assert!(report.ops[0].body.as_ref().unwrap().semantic.is_none()); + + let mut cpp_many = REAL_CPP_BODY.to_vec(); + let mut rust_many = REAL_RUST_BODY.to_vec(); + set_u32_le(&mut cpp_many, 0, 2); + set_u32_le(&mut rust_many, 0, 2); + let report = s2c_report(cpp_many, rust_many); + assert!(!report.is_clean()); + assert!(report.ops[0].body.as_ref().unwrap().semantic.is_none()); +} + +#[test] +fn same_side_shape_receives_no_normalization() { + let mut changed = REAL_CPP_BODY.to_vec(); + changed[14] = 0x10; + let report = s2c_report(REAL_CPP_BODY.to_vec(), changed); + + assert!(!report.is_clean()); + assert!(report.ops[0].body.as_ref().unwrap().semantic.is_none()); +} + +#[test] +fn opcode_direction_and_instance_routing_remain_strict() { + let wrong_opcode = report( + packet( + Direction::S2C, + 1, + SMSG_UPDATE_OBJECT - 1, + REAL_CPP_BODY.to_vec(), + ), + packet( + Direction::S2C, + 1, + SMSG_UPDATE_OBJECT - 1, + REAL_RUST_BODY.to_vec(), + ), + Direction::S2C, + ); + assert!(!wrong_opcode.is_clean()); + assert!( + wrong_opcode.ops[0] + .body + .as_ref() + .unwrap() + .semantic + .is_none() + ); + + let wrong_direction = report( + packet( + Direction::C2S, + 1, + SMSG_UPDATE_OBJECT, + REAL_CPP_BODY.to_vec(), + ), + packet( + Direction::C2S, + 1, + SMSG_UPDATE_OBJECT, + REAL_RUST_BODY.to_vec(), + ), + Direction::C2S, + ); + assert!(!wrong_direction.is_clean()); + assert!( + wrong_direction.ops[0] + .body + .as_ref() + .unwrap() + .semantic + .is_none() + ); + + let wrong_route = report( + packet( + Direction::S2C, + 1, + SMSG_UPDATE_OBJECT, + REAL_CPP_BODY.to_vec(), + ), + packet( + Direction::S2C, + 0, + SMSG_UPDATE_OBJECT, + REAL_RUST_BODY.to_vec(), + ), + Direction::S2C, + ); + assert!(!wrong_route.is_clean()); + assert_eq!(wrong_route.counts.body_mismatches, 0); + assert_eq!(wrong_route.counts.connection_mismatches, 1); +} + +#[test] +fn semantic_mismatch_baseline_keeps_all_stable_values() { + let mut changed_slot = REAL_RUST_BODY.to_vec(); + changed_slot[37] = 0x01; + changed_slot[38] = 0x00; + let slot_signature = s2c_report(REAL_CPP_BODY.to_vec(), changed_slot).signatures(); + + let mut changed_item = REAL_RUST_BODY.to_vec(); + changed_item[41] = 0xFB; + let item_signature = s2c_report(REAL_CPP_BODY.to_vec(), changed_item).signatures(); + + assert_eq!(slot_signature.len(), 1); + assert_eq!(item_signature.len(), 1); + assert_ne!(slot_signature, item_signature); + assert_eq!(slot_signature[0].cpp_body_len, None); + assert_eq!(slot_signature[0].rust_body_len, None); + assert_eq!(slot_signature[0].first_diff_offset, None); +} diff --git a/crates/world-server/src/creature_loaded_grid.rs b/crates/world-server/src/creature_loaded_grid.rs index ce4dc27b9..3a139a236 100644 --- a/crates/world-server/src/creature_loaded_grid.rs +++ b/crates/world-server/src/creature_loaded_grid.rs @@ -74,6 +74,10 @@ pub struct ResolvedCreatureTemplateLikeCpp { pub static_flags: [u32; 8], pub creature_type: u32, pub type_flags: u32, + pub loot_id: u32, + pub skin_loot_id: u32, + pub gold_min: u32, + pub gold_max: u32, pub movement_type: MovementGeneratorType, pub ground_movement_type: u8, pub swim_allowed: bool, @@ -421,6 +425,10 @@ pub fn build_loaded_grid_creature_inputs_from_db_like_cpp( static_flags: difficulty.static_flags, creature_type: template.creature_type, type_flags: difficulty.type_flags, + loot_id: difficulty.loot_id, + skin_loot_id: difficulty.skin_loot_id, + gold_min: difficulty.gold_min, + gold_max: difficulty.gold_max, movement_type, ground_movement_type: runtime_row.ground_movement_type, swim_allowed: runtime_row.swim_allowed, @@ -583,6 +591,10 @@ fn template_lifecycle_record( static_flags: template.static_flags, creature_type: template.creature_type, type_flags: template.type_flags, + loot_id: template.loot_id, + skin_loot_id: template.skin_loot_id, + gold_min: template.gold_min, + gold_max: template.gold_max, movement_type: template.movement_type, ground_movement_type: template.ground_movement_type, swim_allowed: template.swim_allowed, @@ -694,6 +706,10 @@ mod tests { static_flags: [0; 8], creature_type: 0, type_flags: 0x20, + loot_id: 7_001, + skin_loot_id: 7_002, + gold_min: 17, + gold_max: 29, movement_type: MovementGeneratorType::Idle, ground_movement_type: wow_constants::CreatureGroundMovementType::Run as u8, swim_allowed: true, @@ -904,6 +920,17 @@ mod tests { fn db_backed_difficulty_store_with_static_flags( entry: u32, static_flags: [u32; 8], + ) -> CreatureDifficultyStoreLikeCpp { + db_backed_difficulty_store_with_static_flags_and_loot(entry, static_flags, 0, 0, 0, 0) + } + + fn db_backed_difficulty_store_with_static_flags_and_loot( + entry: u32, + static_flags: [u32; 8], + loot_id: u32, + skin_loot_id: u32, + gold_min: u32, + gold_max: u32, ) -> CreatureDifficultyStoreLikeCpp { CreatureDifficultyStoreLikeCpp::from_records( [wow_data::CreatureDifficultyRecordLikeCpp { @@ -919,14 +946,48 @@ mod tests { creature_difficulty_id: 0, type_flags: 0x55, type_flags2: 0, - loot_id: 0, + loot_id, + pickpocket_loot_id: 0, + skin_loot_id, + gold_min, + gold_max, + static_flags, + }], + |_| 1.0, + ) + } + + fn db_backed_fallback_difficulty_store_with_loot( + entry: u32, + static_flags: [u32; 8], + loot_id: u32, + skin_loot_id: u32, + gold_min: u32, + gold_max: u32, + ) -> CreatureDifficultyStoreLikeCpp { + CreatureDifficultyStoreLikeCpp::from_records_with_difficulty_fallbacks( + [wow_data::CreatureDifficultyRecordLikeCpp { + entry, + difficulty_id: 0, + min_level: 18, + max_level: 20, + health_scaling_expansion: -1, + health_modifier: 2.0, + mana_modifier: 3.0, + armor_modifier: 1.0, + damage_modifier: 4.0, + creature_difficulty_id: 0, + type_flags: 0x55, + type_flags2: 0, + loot_id, pickpocket_loot_id: 0, - skin_loot_id: 0, - gold_min: 0, - gold_max: 0, + skin_loot_id, + gold_min, + gold_max, static_flags, }], |_| 1.0, + [(2, 0)], ) } @@ -1042,7 +1103,14 @@ mod tests { &spawn, &runtime_row, &db_backed_template_store(entry), - &db_backed_difficulty_store_with_static_flags(entry, static_flags), + &db_backed_fallback_difficulty_store_with_loot( + entry, + static_flags, + 21_779, + 21_780, + 13, + 31, + ), &db_backed_base_stats_store(), &CreatureClassificationHealthRatesLikeCpp::default(), &display_store, @@ -1080,6 +1148,10 @@ mod tests { "C++ ObjectMgr strips disallowed creature `unit_flags3` SQL bits before UpdateEntry" ); assert_eq!(template.static_flags[0], static_flags[0]); + assert_eq!(template.loot_id, 21_779); + assert_eq!(template.skin_loot_id, 21_780); + assert_eq!((template.gold_min, template.gold_max), (13, 31)); + assert_eq!(template.difficulty_id, 2); assert_eq!(template.display_id, 999); assert_eq!( template.flight_movement_type, @@ -1122,6 +1194,31 @@ mod tests { assert_eq!(runtime.stats.mana, 150); assert_eq!(runtime.stats.min_damage, 20.0); assert_eq!(runtime.stats.max_damage, 30.0); + + let (default_loot_template, _, _) = build_loaded_grid_creature_inputs_from_db_like_cpp( + &spawn, + &runtime_row, + &db_backed_template_store(entry), + &CreatureDifficultyStoreLikeCpp::default(), + &db_backed_base_stats_store(), + &CreatureClassificationHealthRatesLikeCpp::default(), + &display_store, + &model_store, + &model_info_store, + None, + &CreatureAddonStoreLikeCpp::default(), + 2, + 9, + 123, + true, + None, + &mut random, + ) + .expect("missing difficulty rows should use C++'s zero-valued fallback record"); + assert_eq!(default_loot_template.loot_id, 0); + assert_eq!(default_loot_template.skin_loot_id, 0); + assert_eq!(default_loot_template.gold_min, 0); + assert_eq!(default_loot_template.gold_max, 0); } #[test] @@ -2114,6 +2211,66 @@ mod tests { ); } + #[test] + fn loaded_grid_creature_lifecycle_resolver_applies_difficulty_loot_metadata_like_cpp() { + let entry = 12_345; + let resolver = CreatureLoadedGridLifecycleResolverLikeCpp::new( + [template(entry)], + [spawn(55, entry, true)], + [selection(entry)], + ); + + let resolved = resolver + .resolve_loaded_grid_creature_like_cpp(55, map_creature_guid(entry, 571, 55)) + .expect("resolver should carry selected difficulty loot metadata"); + + assert_eq!(resolved.lifecycle_record.create.template.loot_id, 7_001); + assert_eq!( + resolved.lifecycle_record.create.template.skin_loot_id, + 7_002 + ); + assert_eq!( + ( + resolved.lifecycle_record.create.template.gold_min, + resolved.lifecycle_record.create.template.gold_max, + ), + (17, 29) + ); + assert_eq!(resolved.creature.ai_ownership().loot_id, 7_001); + assert_eq!(resolved.creature.ai_ownership().skin_loot_id, 7_002); + assert_eq!( + ( + resolved.creature.ai_ownership().gold_min, + resolved.creature.ai_ownership().gold_max, + ), + (17, 29) + ); + } + + #[test] + fn loaded_grid_creature_lifecycle_resolver_preserves_absent_loot_metadata_like_cpp() { + let entry = 12_345; + let mut no_loot_template = template(entry); + no_loot_template.loot_id = 0; + no_loot_template.skin_loot_id = 0; + no_loot_template.gold_min = 0; + no_loot_template.gold_max = 0; + let resolver = CreatureLoadedGridLifecycleResolverLikeCpp::new( + [no_loot_template], + [spawn(55, entry, true)], + [selection(entry)], + ); + + let resolved = resolver + .resolve_loaded_grid_creature_like_cpp(55, map_creature_guid(entry, 571, 55)) + .expect("zero difficulty loot metadata remains a valid no-loot creature"); + + assert_eq!(resolved.creature.ai_ownership().loot_id, 0); + assert_eq!(resolved.creature.ai_ownership().skin_loot_id, 0); + assert_eq!(resolved.creature.ai_ownership().gold_min, 0); + assert_eq!(resolved.creature.ai_ownership().gold_max, 0); + } + #[test] fn loaded_grid_creature_lifecycle_resolver_applies_sparring_health_pct_like_cpp() { let entry = 12_345; diff --git a/crates/world-server/src/main.rs b/crates/world-server/src/main.rs index 1f58428d1..b9a3dab7d 100644 --- a/crates/world-server/src/main.rs +++ b/crates/world-server/src/main.rs @@ -33,9 +33,9 @@ use wow_core::{ use wow_database::{ CharStatements, CharacterDatabase, DATABASE_CHARACTER_LIKE_CPP, DATABASE_HOTFIX_LIKE_CPP, DATABASE_LOGIN_LIKE_CPP, DATABASE_MASK_ALL_LIKE_CPP, DATABASE_WORLD_LIKE_CPP, HotfixDatabase, - LoginDatabase, LoginStatements, PreparedStatement, SqlParam, SqlResult, SqlTransaction, - StatementDef, WorldDatabase, WorldStatements, escape_string_like_cpp, - warn_about_sync_queries_scope_like_cpp, + ItemGuidAllocatorAdvisoryLockLikeCpp, LoginDatabase, LoginStatements, PreparedStatement, + SqlParam, SqlResult, SqlTransaction, StatementDef, WorldDatabase, WorldStatements, + escape_string_like_cpp, warn_about_sync_queries_scope_like_cpp, }; use wow_instances::{InstanceLockMgr, MapDb2Entries, ResetSchedule}; use wow_loot::{ @@ -81,6 +81,42 @@ const WORLD_CONFIG_CANDIDATES: &[&str] = &[ "WorldServer.conf", "WorldServer.conf.dist", ]; + +fn next_item_guid_allocator_start_like_cpp(max_persisted_guid: Option) -> Result { + let next = max_persisted_guid + .unwrap_or(0) + .checked_add(1) + .context("item_instance GUID counter overflow")?; + let next = i64::try_from(next) + .context("item_instance GUID counter exceeds the supported integer range")?; + let generator_limit = ObjectGuid::max_counter(HighGuid::Item) - 1; + if next >= generator_limit { + bail!( + "item_instance GUID allocator start {next} is outside HighGuid::Item generator range (must be below {generator_limit})" + ); + } + Ok(next) +} + +const ITEM_GUID_DANGLING_REFERENCE_CLEANUP_STATEMENTS_LIKE_CPP: [CharStatements; 4] = [ + CharStatements::DEL_INVALID_CHAR_INVENTORY_ITEM_GUIDS, + CharStatements::DEL_INVALID_MAIL_ITEM_GUIDS, + CharStatements::DEL_INVALID_AUCTION_ITEM_GUIDS, + CharStatements::DEL_INVALID_GUILD_BANK_ITEM_GUIDS, +]; + +fn item_guid_reference_cleanup_transaction_like_cpp( + char_db: &CharacterDatabase, + next_item_guid: u64, +) -> SqlTransaction { + let mut transaction = SqlTransaction::new(); + for statement_id in ITEM_GUID_DANGLING_REFERENCE_CLEANUP_STATEMENTS_LIKE_CPP { + let mut statement = char_db.prepare(statement_id); + statement.set_u64(0, next_item_guid); + transaction.append(statement); + } + transaction +} const WORLD_CONFIG_DIR: &str = "worldserver.conf.d"; const RUSTYCORE_LEGACY_CREATURE_GLOBAL_RUNTIME_CONFIG: &str = "RustyCore.LegacyCreatureGlobalRuntime"; @@ -1336,6 +1372,50 @@ async fn main() -> Result { let guid_generator = Arc::new(ObjectGuidGenerator::new(HighGuid::Player, max_guid)); info!("GUID generator initialized, next counter: {max_guid}"); + // A process-local atomic generator is safe only while one world-server can + // allocate for this character database. Hold a connection-scoped MySQL + // advisory lock for the complete server lifetime, failing startup if a + // rolling/duplicate process already owns that allocation domain. + let mut item_guid_allocator_advisory_lock = + ItemGuidAllocatorAdvisoryLockLikeCpp::acquire_like_cpp(char_db.pool()) + .await + .context("failed to acquire the character DB item GUID allocator lock")?; + + // C++ `ObjectMgr::SetHighestGuids` initializes one process-wide item + // generator from `MAX(item_instance.guid) + 1`. Sharing the atomic Rust + // mirror across every session prevents concurrent loot grants from + // selecting the same database GUID. + let next_item_guid = { + let stmt = char_db.prepare(CharStatements::SEL_MAX_ITEM_GUID); + match char_db.query(&stmt).await { + Ok(result) => { + if result.is_empty() || result.is_null(0) { + next_item_guid_allocator_start_like_cpp(None)? + } else { + let max_val: u64 = result + .try_read(0) + .context("failed to decode MAX(item_instance.guid)")?; + next_item_guid_allocator_start_like_cpp(Some(max_val))? + } + } + Err(error) => { + return Err(error) + .context("failed to initialize item GUID allocator from item_instance"); + } + } + }; + let next_item_guid_u64 = u64::try_from(next_item_guid) + .context("item GUID allocator start must be a positive database counter")?; + char_db + .commit_transaction(item_guid_reference_cleanup_transaction_like_cpp( + &char_db, + next_item_guid_u64, + )) + .await + .context("failed to clean dangling item GUID references before allocator publication")?; + let item_guid_generator = Arc::new(ObjectGuidGenerator::new(HighGuid::Item, next_item_guid)); + info!("Item GUID generator initialized, next counter: {next_item_guid}"); + let char_db = Arc::new(char_db); // Load Item.db2 for inventory_type lookups (replaces item_type_cache table) @@ -5019,6 +5099,7 @@ async fn main() -> Result { login_db: Some(Arc::clone(&login_db)), world_db: Some(Arc::clone(&world_db)), guid_generator: Some(Arc::clone(&guid_generator)), + item_guid_generator: Some(Arc::clone(&item_guid_generator)), instance_lock_mgr: Some(Arc::clone(&instance_lock_mgr)), bank_bag_slot_prices_store: Some(Arc::clone(&bank_bag_slot_prices_store)), currency_types_store: Some(Arc::clone(¤cy_types_store)), @@ -5473,7 +5554,7 @@ async fn main() -> Result { realm_addr, lookup, resources, - move |account, pkt_rx, send_tx, res| { + move |account, pkt_rx, send_tx, send_write_fence_like_cpp, res| { let mgr = Arc::clone(&mgr); let smap = Arc::clone(&smap); let canonical_map = Arc::clone(&canonical_map); @@ -5488,6 +5569,7 @@ async fn main() -> Result { account, pkt_rx, send_tx, + send_write_fence_like_cpp, res, mgr, smap, @@ -5723,6 +5805,18 @@ async fn main() -> Result { } } } + result = item_guid_allocator_advisory_lock.wait_until_lost_like_cpp() => { + world_runtime_state.stop_now_like_cpp(ERROR_EXIT_CODE_LIKE_CPP); + match result { + Ok(()) => tracing::error!( + "Item GUID allocator advisory-lock monitor stopped unexpectedly" + ), + Err(error) => tracing::error!( + %error, + "Item GUID allocator advisory lock was lost; stopping before another GUID allocation" + ), + } + } } // Close registration under the same mutex used by `try_register`. An @@ -5870,6 +5964,11 @@ async fn main() -> Result { tracing::error!("Failed to mark realm {realm_id} offline: {e}"); } + if let Err(error) = item_guid_allocator_advisory_lock.release_like_cpp().await { + world_runtime_state.stop_now_like_cpp(ERROR_EXIT_CODE_LIKE_CPP); + tracing::error!(%error, "Failed to release item GUID allocator advisory lock"); + } + info!( exit_code = world_runtime_state.get_exit_code_like_cpp(), "World server stopped." @@ -12358,6 +12457,7 @@ async fn create_session( account: AccountInfo, pkt_rx: flume::Receiver, send_tx: flume::Sender>, + send_write_fence_like_cpp: wow_network::SocketWriteFenceLikeCpp, resources: Arc, session_mgr: Arc, shared_map: SharedMapManager, @@ -12403,6 +12503,7 @@ async fn create_session( pkt_rx, send_tx, ); + session.set_send_write_fence_like_cpp(send_write_fence_like_cpp); let Some((active_session_id, session_cancellation)) = active_session_registry.try_register(account.id, session.session_command_tx()) else { @@ -12432,6 +12533,9 @@ async fn create_session( if let Some(ref generator) = resources.guid_generator { session.set_guid_generator(Arc::clone(generator)); } + if let Some(ref generator) = resources.item_guid_generator { + session.set_item_guid_generator_like_cpp(Arc::clone(generator)); + } if let Some(ref mgr) = resources.instance_lock_mgr { session.set_instance_lock_mgr(Arc::clone(mgr)); } @@ -14226,9 +14330,10 @@ mod tests { GameEventLiveUpdateActionLikeCpp, GameEventLiveUpdateSideEffectSummaryLikeCpp, GameEventQuestCompleteConditionSaveDbStatementKindLikeCpp, GameEventWorldEventStateDbOperationKindLikeCpp, GameEventWorldEventStateDbOperationLikeCpp, - GameEventWorldEventStateDbStatementKindLikeCpp, LoadedGridCreatureRespawnCachesLikeCpp, - PersistedRespawnLoadReportLikeCpp, PersistedRespawnRowLikeCpp, - PersistedRespawnTimesLikeCpp, REQUIRED_TDB_CACHE_ID_LIKE_CPP, + GameEventWorldEventStateDbStatementKindLikeCpp, + ITEM_GUID_DANGLING_REFERENCE_CLEANUP_STATEMENTS_LIKE_CPP, + LoadedGridCreatureRespawnCachesLikeCpp, PersistedRespawnLoadReportLikeCpp, + PersistedRespawnRowLikeCpp, PersistedRespawnTimesLikeCpp, REQUIRED_TDB_CACHE_ID_LIKE_CPP, REQUIRED_TDB_VERSION_LIKE_CPP, RESTART_EXIT_CODE_LIKE_CPP, RespawnDbDeleteQueueOutcomeLikeCpp, RespawnDbRetryQueueLikeCpp, RespawnDbSaveQueueOutcomeLikeCpp, RespawnDbSubmitErrorLikeCpp, @@ -14273,12 +14378,13 @@ mod tests { materialize_game_event_world_event_state_db_bridge_like_cpp, max_core_stuck_time_ms_like_cpp, max_core_stuck_time_secs_like_cpp, min_world_update_time_ms_like_cpp, mmap_runtime_config_like_cpp, - normalize_realm_security_level_like_cpp, normalize_realm_type_like_cpp, - normalized_realm_name_like_cpp, persisted_respawn_info_from_row_like_cpp, - process_exit_code_like_cpp, queue_respawn_db_delete_like_cpp, - queue_respawn_db_save_like_cpp, realm_id_like_cpp, realm_list_entry_from_row_like_cpp, - repair_cost_rate_like_cpp, reputation_rates_like_cpp, reset_schedule_like_cpp, - respawn_db_retry_delay, run_legacy_creature_lifecycle_tick_and_refresh_once_like_cpp, + next_item_guid_allocator_start_like_cpp, normalize_realm_security_level_like_cpp, + normalize_realm_type_like_cpp, normalized_realm_name_like_cpp, + persisted_respawn_info_from_row_like_cpp, process_exit_code_like_cpp, + queue_respawn_db_delete_like_cpp, queue_respawn_db_save_like_cpp, realm_id_like_cpp, + realm_list_entry_from_row_like_cpp, repair_cost_rate_like_cpp, reputation_rates_like_cpp, + reset_schedule_like_cpp, respawn_db_retry_delay, + run_legacy_creature_lifecycle_tick_and_refresh_once_like_cpp, run_legacy_creature_melee_tick_and_deliver_once_like_cpp, run_legacy_creature_movement_tick_and_deliver_once_like_cpp, run_legacy_creature_runtime_tick_and_deliver_once_like_cpp, @@ -14300,7 +14406,7 @@ mod tests { use std::sync::{Arc, Mutex, RwLock}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use wow_constants::{ConditionSourceType, ConditionType}; - use wow_core::{ObjectGuid, Position, guid::HighGuid}; + use wow_core::{ObjectGuid, ObjectGuidGenerator, Position, guid::HighGuid}; use wow_data::{Condition, ConditionEntriesByTypeStore}; use wow_database::{ CharStatements, DATABASE_CHARACTER_LIKE_CPP, DATABASE_HOTFIX_LIKE_CPP, @@ -14347,6 +14453,44 @@ mod tests { })) } + #[test] + fn item_guid_allocator_start_is_max_plus_one_and_fails_before_generator_panic_like_cpp() { + assert_eq!(next_item_guid_allocator_start_like_cpp(None).unwrap(), 1); + let start = next_item_guid_allocator_start_like_cpp(Some(41)).unwrap(); + assert_eq!(start, 42); + let generator = ObjectGuidGenerator::new(HighGuid::Item, start); + assert_eq!( + generator.generate(), + 42, + "fetch_add returns the configured MAX+1 start before advancing" + ); + + let generator_limit = ObjectGuid::max_counter(HighGuid::Item) - 1; + assert_eq!( + next_item_guid_allocator_start_like_cpp(Some((generator_limit - 2) as u64)).unwrap(), + generator_limit - 1 + ); + assert!( + next_item_guid_allocator_start_like_cpp(Some((generator_limit - 1) as u64)).is_err(), + "startup must reject the value that ObjectGuidGenerator::generate would panic on" + ); + assert!(next_item_guid_allocator_start_like_cpp(Some(u64::MAX)).is_err()); + } + + #[test] + fn item_guid_allocator_cleans_every_cpp_dangling_reference_table_before_publication() { + let sql = ITEM_GUID_DANGLING_REFERENCE_CLEANUP_STATEMENTS_LIKE_CPP + .map(|statement| statement.sql()); + assert_eq!(sql.len(), 4); + assert!(sql[0].contains("character_inventory")); + assert!(sql[1].contains("mail_items")); + assert!(sql[2].contains("auctionhouse")); + assert!(sql[3].contains("guild_bank_item")); + for statement in sql { + assert!(statement.contains(">= ?")); + } + } + #[test] fn target_icon_raw_from_db_bytes_preserves_cpp_binary_guid_shape() { assert_eq!(target_icon_raw_from_db_bytes_like_cpp(&[]), [0u8; 16]); @@ -14376,6 +14520,7 @@ mod tests { is_in_world: true, send_tx, command_tx, + durable_loot_money_tracker_like_cpp: Default::default(), active_loot_rolls: Vec::new(), pass_on_group_loot: false, enchanting_skill: 0, @@ -20981,6 +21126,7 @@ mmap.enablePathFinding = 0 is_in_world: true, send_tx, command_tx, + durable_loot_money_tracker_like_cpp: Default::default(), active_loot_rolls: Vec::new(), pass_on_group_loot: false, enchanting_skill: 0, diff --git a/crates/wow-database/src/lib.rs b/crates/wow-database/src/lib.rs index 8d7393345..b1e7b1d42 100644 --- a/crates/wow-database/src/lib.rs +++ b/crates/wow-database/src/lib.rs @@ -65,7 +65,10 @@ pub use statements::{ CharStatements, HOTFIX_STATEMENT_STRATEGY_LIKE_CPP, HotfixStatementStrategyLikeCpp, HotfixStatements, LoginStatements, StatementDef, WorldStatements, }; -pub use transaction::SqlTransaction; +pub use transaction::{ + ItemGuidAllocatorAdvisoryLockLikeCpp, SqlTransaction, SqlTransactionCommitError, + is_database_deadlock_like_cpp, retry_deadlocked_operation_like_cpp, +}; /// Type aliases for each database connection. pub type LoginDatabase = Database; diff --git a/crates/wow-database/src/statements/character.rs b/crates/wow-database/src/statements/character.rs index 990624af8..c90cf7122 100644 --- a/crates/wow-database/src/statements/character.rs +++ b/crates/wow-database/src/statements/character.rs @@ -1179,6 +1179,8 @@ pub enum CharStatements { /// UPDATE characters SET money = ? WHERE guid = ? UPD_CHAR_MONEY, + /// SELECT money FROM characters WHERE guid = ? FOR UPDATE + SEL_CHAR_MONEY_FOR_UPDATE, /// C++ `CHAR_UPD_CHARACTER` persists this field immediately before powers. /// UPDATE characters SET health = ? WHERE guid = ? UPD_CHAR_HEALTH, @@ -1208,12 +1210,17 @@ pub enum CharStatements { /// SELECT MAX(guid) FROM item_instance SEL_MAX_ITEM_GUID, + /// C++ ObjectMgr::SetHighestGuids startup cleanup. + DEL_INVALID_CHAR_INVENTORY_ITEM_GUIDS, + DEL_INVALID_MAIL_ITEM_GUIDS, + DEL_INVALID_AUCTION_ITEM_GUIDS, + DEL_INVALID_GUILD_BANK_ITEM_GUIDS, /// INSERT INTO item_instance (guid, itemEntry, owner_guid, count, durability, enchantments, charges) /// VALUES (?, ?, ?, ?, ?, '', '') INS_ITEM_INSTANCE, - /// INSERT INTO item_instance preserving generated loot random property/context metadata. + /// INSERT INTO item_instance preserving generated loot flags/random/context metadata. INS_ITEM_INSTANCE_WITH_RANDOM_CONTEXT, /// INSERT INTO item_instance with the C++ Item::CloneItem persisted field subset. @@ -1671,6 +1678,8 @@ pub enum CharStatements { /// SELECT money FROM item_loot_money WHERE container_id = ? SEL_ITEMCONTAINER_MONEY, + /// SELECT money FROM item_loot_money WHERE container_id = ? FOR UPDATE + SEL_ITEMCONTAINER_MONEY_FOR_UPDATE, /// INSERT INTO item_loot_money (container_id, money) VALUES (?, ?) INS_ITEMCONTAINER_MONEY, @@ -2778,6 +2787,9 @@ impl StatementDef for CharStatements { Self::UPD_CHAR_XP => "UPDATE characters SET xp = ? WHERE guid = ?", Self::UPD_CHAR_LEVEL => "UPDATE characters SET level = ?, xp = ? WHERE guid = ?", Self::UPD_CHAR_MONEY => "UPDATE characters SET money = ? WHERE guid = ?", + Self::SEL_CHAR_MONEY_FOR_UPDATE => { + "SELECT money FROM characters WHERE guid = ? FOR UPDATE" + } Self::UPD_CHAR_HEALTH => "UPDATE characters SET health = ? WHERE guid = ?", Self::UPD_CHAR_POWERS => { "UPDATE characters SET power1 = ?, power2 = ?, power3 = ?, power4 = ?, power5 = ?, power6 = ?, power7 = ?, power8 = ?, power9 = ?, power10 = ? WHERE guid = ?" @@ -2798,6 +2810,16 @@ impl StatementDef for CharStatements { "UPDATE characters SET exploredZones = ? WHERE guid = ?" } Self::SEL_MAX_ITEM_GUID => "SELECT MAX(guid) FROM item_instance", + Self::DEL_INVALID_CHAR_INVENTORY_ITEM_GUIDS => { + "DELETE FROM character_inventory WHERE item >= ?" + } + Self::DEL_INVALID_MAIL_ITEM_GUIDS => "DELETE FROM mail_items WHERE item_guid >= ?", + Self::DEL_INVALID_AUCTION_ITEM_GUIDS => { + "DELETE a, ab, ai FROM auctionhouse a LEFT JOIN auction_bidders ab ON ab.auctionId = a.id LEFT JOIN auction_items ai ON ai.auctionId = a.id WHERE ai.itemGuid >= ?" + } + Self::DEL_INVALID_GUILD_BANK_ITEM_GUIDS => { + "DELETE FROM guild_bank_item WHERE item_guid >= ?" + } Self::INS_ITEM_INSTANCE => { "INSERT INTO item_instance \ (guid, itemEntry, owner_guid, creatorGuid, giftCreatorGuid, count, \ @@ -2810,7 +2832,7 @@ impl StatementDef for CharStatements { (guid, itemEntry, owner_guid, creatorGuid, giftCreatorGuid, count, \ durability, enchantments, charges, flags, randomPropertiesId, \ randomPropertiesSeed, context) \ - VALUES (?, ?, ?, 0, 0, ?, ?, '', '', 0, ?, ?, ?)" + VALUES (?, ?, ?, 0, 0, ?, ?, '', '', ?, ?, ?, ?)" } Self::INS_ITEM_INSTANCE_CLONE => { "INSERT INTO item_instance \ @@ -3177,6 +3199,9 @@ impl StatementDef for CharStatements { Self::SEL_ITEMCONTAINER_MONEY => { "SELECT money FROM item_loot_money WHERE container_id = ? LIMIT 1" } + Self::SEL_ITEMCONTAINER_MONEY_FOR_UPDATE => { + "SELECT money FROM item_loot_money WHERE container_id = ? FOR UPDATE" + } Self::INS_ITEMCONTAINER_MONEY => { "INSERT INTO item_loot_money (container_id, money) VALUES (?, ?)" } @@ -6114,7 +6139,7 @@ mod tests { .sql() .matches('?') .count(), - 8 + 9 ); assert_eq!( CharStatements::INS_ITEM_INSTANCE_CLONE diff --git a/crates/wow-database/src/transaction.rs b/crates/wow-database/src/transaction.rs index 1331138e8..9ad4614a8 100644 --- a/crates/wow-database/src/transaction.rs +++ b/crates/wow-database/src/transaction.rs @@ -2,25 +2,295 @@ use crate::error::DatabaseError; use crate::params::{PreparedStatement, SqlParam}; -use sqlx::MySqlPool; +use sqlx::{MySql, MySqlPool, pool::PoolConnection}; +use std::future::Future; use std::sync::LazyLock; use std::time::{Duration, Instant}; -use tokio::sync::Mutex; +use tokio::{ + sync::{Mutex, oneshot, watch}, + task::JoinHandle, + time::MissedTickBehavior, +}; const DEADLOCK_MAX_RETRY_TIME_LIKE_CPP: Duration = Duration::from_secs(60); static DEADLOCK_RETRY_LOCK_LIKE_CPP: LazyLock> = LazyLock::new(|| Mutex::new(())); +const ITEM_GUID_ALLOCATOR_LOCK_PREFIX_LIKE_CPP: &str = "rustycore:item-guid:"; +const ITEM_GUID_ALLOCATOR_LOCK_VERIFY_INTERVAL_LIKE_CPP: Duration = Duration::from_secs(30); + +/// Retry an operation whose caller can distinguish MySQL deadlock 1213 from +/// all other failures. This extends TrinityCore's serialized 60-second retry +/// discipline to transactions that perform dynamic `SELECT ... FOR UPDATE` +/// reads and therefore cannot be represented by [`SqlTransaction`]. +pub async fn retry_deadlocked_operation_like_cpp( + mut operation: F, + is_deadlock: IsDeadlock, +) -> Result +where + F: FnMut() -> Fut, + Fut: Future>, + IsDeadlock: Fn(&E) -> bool, +{ + let mut result = operation().await; + if result + .as_ref() + .err() + .is_none_or(|error| !is_deadlock(error)) + { + return result; + } + + let _deadlock_guard = DEADLOCK_RETRY_LOCK_LIKE_CPP.lock().await; + let start = Instant::now(); + loop { + if start.elapsed() > DEADLOCK_MAX_RETRY_TIME_LIKE_CPP { + tracing::error!( + target: "sql.sql", + "Fatal deadlocked SQL operation, it will not be retried anymore" + ); + return result; + } + + result = operation().await; + if result + .as_ref() + .err() + .is_none_or(|error| !is_deadlock(error)) + { + return result; + } + tracing::warn!( + target: "sql.sql", + loop_timer_ms = start.elapsed().as_millis(), + "Deadlocked SQL operation, retrying" + ); + } +} + +/// Dedicated MySQL connection holding the process-lifetime item GUID allocator +/// lock. `close_on_drop` is set after acquisition so early-return and panic +/// paths close the connection instead of returning a still-locked connection +/// to the pool. +#[derive(Debug)] +pub struct ItemGuidAllocatorAdvisoryLockLikeCpp { + stop_tx: Option>, + loss_rx: watch::Receiver>, + monitor_handle: Option>>, +} + +impl ItemGuidAllocatorAdvisoryLockLikeCpp { + pub async fn acquire_like_cpp(pool: &MySqlPool) -> Result { + let mut connection = pool.acquire().await.map_err(DatabaseError::from)?; + // From this point onward even an ambiguous GET_LOCK response must not + // return a potentially locked connection to the pool. + connection.close_on_drop(); + let database_name = sqlx::query_scalar::<_, Option>("SELECT DATABASE()") + .fetch_one(&mut *connection) + .await + .map_err(DatabaseError::from)? + .ok_or_else(|| { + DatabaseError::Transaction( + "item GUID allocator lock requires a selected character database".to_string(), + ) + })?; + let lock_name = item_guid_allocator_lock_name_like_cpp(&database_name); + let acquired = sqlx::query_scalar::<_, Option>("SELECT GET_LOCK(?, 0)") + .bind(&lock_name) + .fetch_one(&mut *connection) + .await + .map_err(DatabaseError::from)?; + if acquired != Some(1) { + return Err(DatabaseError::Transaction(format!( + "another world-server owns the item GUID allocator lock for character database {database_name}" + ))); + } + let (stop_tx, stop_rx) = oneshot::channel(); + let (loss_tx, loss_rx) = watch::channel(None); + let monitor_handle = tokio::spawn(run_item_guid_allocator_lock_monitor_like_cpp( + connection, lock_name, stop_rx, loss_tx, + )); + Ok(Self { + stop_tx: Some(stop_tx), + loss_rx, + monitor_handle: Some(monitor_handle), + }) + } + + /// Wait until the dedicated MySQL session no longer demonstrably owns the + /// lock. A world process must treat this as fatal because its process-local + /// allocator is no longer exclusive after that instant. + pub async fn wait_until_lost_like_cpp(&mut self) -> Result<(), DatabaseError> { + loop { + if let Some(message) = self.loss_rx.borrow().clone() { + return Err(DatabaseError::Transaction(message)); + } + if self.loss_rx.changed().await.is_err() { + return Err(DatabaseError::Transaction( + "item GUID allocator advisory-lock monitor stopped unexpectedly".to_string(), + )); + } + } + } + + /// Release explicitly during orderly shutdown. If the query fails, the + /// connection's close-on-drop fallback still releases this session lock. + pub async fn release_like_cpp(mut self) -> Result<(), DatabaseError> { + if let Some(stop_tx) = self.stop_tx.take() { + let _ = stop_tx.send(()); + } + let Some(monitor_handle) = self.monitor_handle.take() else { + return Ok(()); + }; + monitor_handle.await.map_err(|error| { + DatabaseError::Transaction(format!( + "item GUID allocator advisory-lock monitor task failed: {error}" + )) + })? + } + + #[cfg(test)] + pub(crate) fn lock_name_for_test(database_name: &str) -> String { + item_guid_allocator_lock_name_like_cpp(database_name) + } +} + +async fn run_item_guid_allocator_lock_monitor_like_cpp( + mut connection: PoolConnection, + lock_name: String, + mut stop_rx: oneshot::Receiver<()>, + loss_tx: watch::Sender>, +) -> Result<(), DatabaseError> { + let mut verify_interval = + tokio::time::interval(ITEM_GUID_ALLOCATOR_LOCK_VERIFY_INTERVAL_LIKE_CPP); + verify_interval.set_missed_tick_behavior(MissedTickBehavior::Skip); + + loop { + tokio::select! { + biased; + _ = &mut stop_rx => { + return release_item_guid_allocator_lock_like_cpp(&mut connection, &lock_name).await; + } + _ = verify_interval.tick() => { + let ownership = sqlx::query_scalar::<_, Option>( + "SELECT IS_USED_LOCK(?) = CONNECTION_ID()", + ) + .bind(&lock_name) + .fetch_one(&mut *connection) + .await; + match ownership { + Ok(Some(1)) => {} + Ok(_) => { + let message = format!( + "dedicated MySQL session lost item GUID allocator advisory lock {lock_name}" + ); + let _ = loss_tx.send(Some(message.clone())); + return Err(DatabaseError::Transaction(message)); + } + Err(error) => { + let error = DatabaseError::from(error); + let message = format!( + "could not verify item GUID allocator advisory lock {lock_name}: {error}" + ); + let _ = loss_tx.send(Some(message)); + return Err(error); + } + } + } + } + } +} + +async fn release_item_guid_allocator_lock_like_cpp( + connection: &mut PoolConnection, + lock_name: &str, +) -> Result<(), DatabaseError> { + let released = sqlx::query_scalar::<_, Option>("SELECT RELEASE_LOCK(?)") + .bind(lock_name) + .fetch_one(&mut **connection) + .await + .map_err(DatabaseError::from)?; + if released != Some(1) { + return Err(DatabaseError::Transaction(format!( + "item GUID allocator advisory lock {lock_name} was not owned at shutdown" + ))); + } + // The monitor owns this dedicated close-on-drop connection. Returning + // drops it immediately rather than putting it back into the shared pool. + Ok(()) +} + +fn item_guid_allocator_lock_name_like_cpp(database_name: &str) -> String { + // Stable FNV-1a avoids exposing a possibly sensitive database name while + // keeping independent character databases in independent lock domains. + let mut hash = 0xcbf2_9ce4_8422_2325u64; + for byte in database_name.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + format!("{ITEM_GUID_ALLOCATOR_LOCK_PREFIX_LIKE_CPP}{hash:016x}") +} + +/// Whether a failed transaction is known to have rolled back or crossed the +/// point where MySQL may have committed before the connection lost the reply. +/// Consume-and-grant callers must never reopen a claim on the latter result. +#[derive(Debug)] +pub enum SqlTransactionCommitError { + DefinitelyRolledBack(DatabaseError), + CommitOutcomeUnknown(DatabaseError), +} + +impl SqlTransactionCommitError { + #[must_use] + pub fn is_commit_outcome_unknown_like_cpp(&self) -> bool { + matches!(self, Self::CommitOutcomeUnknown(_)) + } + + #[must_use] + pub fn into_database_error(self) -> DatabaseError { + match self { + Self::DefinitelyRolledBack(error) | Self::CommitOutcomeUnknown(error) => error, + } + } +} + +impl std::fmt::Display for SqlTransactionCommitError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::DefinitelyRolledBack(error) => { + write!(formatter, "transaction rolled back: {error}") + } + Self::CommitOutcomeUnknown(error) => { + write!(formatter, "transaction COMMIT outcome is unknown: {error}") + } + } + } +} + +impl std::error::Error for SqlTransactionCommitError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(match self { + Self::DefinitelyRolledBack(error) | Self::CommitOutcomeUnknown(error) => error, + }) + } +} + /// A batch of SQL statements to be executed atomically within a transaction. /// /// Matches the TC `TransactionBase` / `Transaction` pattern: collect /// prepared statements or raw SQL strings, then commit them all at once. #[derive(Debug, Default)] pub struct SqlTransaction { - statements: Vec, + statements: Vec, cleaned_up_like_cpp: bool, } +#[derive(Debug)] +struct TransactionStatement { + statement: PreparedStatement, + expected_rows_affected: Option, +} + impl SqlTransaction { /// Create a new empty transaction batch. pub fn new() -> Self { @@ -32,7 +302,23 @@ impl SqlTransaction { /// Append a prepared statement to this transaction. pub fn append(&mut self, stmt: PreparedStatement) { - self.statements.push(stmt); + self.statements.push(TransactionStatement { + statement: stmt, + expected_rows_affected: None, + }); + } + + /// Append a prepared statement whose affected-row count is part of the + /// transaction's correctness contract. + /// + /// A mismatch aborts the transaction before `COMMIT`. Consume-and-grant + /// operations must not treat an `UPDATE` or `DELETE` that matched no + /// durable row as a durable gameplay success. + pub fn append_expect_rows_affected(&mut self, stmt: PreparedStatement, expected: u64) { + self.statements.push(TransactionStatement { + statement: stmt, + expected_rows_affected: Some(expected), + }); } /// Append a raw SQL statement like TC `TransactionBase::Append(char const*)`. @@ -40,8 +326,7 @@ impl SqlTransaction { /// Prefer prepared statements for user input. This is for C++ parity with /// existing raw-SQL transaction call sites and test fixtures. pub fn append_raw_sql_like_cpp(&mut self, sql: impl Into) { - self.statements - .push(PreparedStatement::raw_sql_like_cpp(sql)); + self.append(PreparedStatement::raw_sql_like_cpp(sql)); } /// Clear queued statements once, mirroring TC `TransactionBase::Cleanup`. @@ -76,13 +361,25 @@ impl SqlTransaction { /// TrinityCore's `TransactionTask::_deadlockLock` and /// `DEADLOCK_MAX_RETRY_TIME_MS`. pub async fn commit(self, pool: &MySqlPool) -> Result<(), DatabaseError> { + self.commit_with_outcome_like_cpp(pool) + .await + .map_err(SqlTransactionCommitError::into_database_error) + } + + /// Commit while preserving the ambiguity of a transport error returned by + /// `COMMIT`. Query/validation failures and MySQL deadlock 1213 are definite + /// rollbacks; a non-deadlock COMMIT error must be reconciled by the caller. + pub async fn commit_with_outcome_like_cpp( + self, + pool: &MySqlPool, + ) -> Result<(), SqlTransactionCommitError> { if self.statements.is_empty() { return Ok(()); } let result = self.try_commit(pool).await; - if !is_deadlock_like_cpp(&result) { + if !is_outcome_deadlock_like_cpp(&result) { return result; } @@ -102,6 +399,9 @@ impl SqlTransaction { if retry.is_ok() { return retry; } + if !is_outcome_deadlock_like_cpp(&retry) { + return retry; + } tracing::warn!( target: "sql.sql", @@ -132,32 +432,85 @@ impl SqlTransaction { #[cfg(test)] pub(crate) fn sqls_for_test(&self) -> Vec<&str> { - self.statements.iter().map(PreparedStatement::sql).collect() + self.statements + .iter() + .map(|statement| statement.statement.sql()) + .collect() + } + + #[cfg(test)] + pub(crate) fn expected_rows_for_test(&self) -> Vec> { + self.statements + .iter() + .map(|statement| statement.expected_rows_affected) + .collect() } - async fn try_commit(&self, pool: &MySqlPool) -> Result<(), DatabaseError> { + async fn try_commit(&self, pool: &MySqlPool) -> Result<(), SqlTransactionCommitError> { self.try_commit_inner(pool).await } - async fn try_commit_inner(&self, pool: &MySqlPool) -> Result<(), DatabaseError> { - let mut tx = pool.begin().await?; + async fn try_commit_inner(&self, pool: &MySqlPool) -> Result<(), SqlTransactionCommitError> { + let mut tx = pool + .begin() + .await + .map_err(DatabaseError::from) + .map_err(SqlTransactionCommitError::DefinitelyRolledBack)?; - for stmt in &self.statements { + for (statement_index, transaction_statement) in self.statements.iter().enumerate() { + let stmt = &transaction_statement.statement; let mut query = sqlx::query(stmt.sql()); for param in stmt.params() { query = bind_param(query, param); } - query.execute(&mut *tx).await?; + let result = query + .execute(&mut *tx) + .await + .map_err(DatabaseError::from) + .map_err(SqlTransactionCommitError::DefinitelyRolledBack)?; + if let Some(expected) = transaction_statement.expected_rows_affected { + validate_rows_affected(statement_index, expected, result.rows_affected()) + .map_err(SqlTransactionCommitError::DefinitelyRolledBack)?; + } } - tx.commit().await?; + if let Err(error) = tx.commit().await { + let error = DatabaseError::from(error); + if is_database_deadlock_like_cpp(&error) { + return Err(SqlTransactionCommitError::DefinitelyRolledBack(error)); + } + return Err(SqlTransactionCommitError::CommitOutcomeUnknown(error)); + } Ok(()) } } -fn is_deadlock_like_cpp(result: &Result<(), DatabaseError>) -> bool { +fn validate_rows_affected( + statement_index: usize, + expected: u64, + actual: u64, +) -> Result<(), DatabaseError> { + if actual == expected { + return Ok(()); + } + + Err(DatabaseError::Transaction(format!( + "statement {statement_index} affected {actual} rows; expected exactly {expected}" + ))) +} + +fn is_outcome_deadlock_like_cpp(result: &Result<(), SqlTransactionCommitError>) -> bool { match result { - Err(DatabaseError::Query(sqlx::Error::Database(db_err))) => { + Err(SqlTransactionCommitError::DefinitelyRolledBack(error)) => { + is_database_deadlock_like_cpp(error) + } + Err(SqlTransactionCommitError::CommitOutcomeUnknown(_)) | Ok(()) => false, + } +} + +pub fn is_database_deadlock_like_cpp(error: &DatabaseError) -> bool { + match error { + DatabaseError::Query(sqlx::Error::Database(db_err)) => { db_err.code().as_deref() == Some("1213") || db_err.message().contains("Deadlock") || db_err.message().contains("deadlock") @@ -191,7 +544,12 @@ pub(crate) fn bind_param<'q>( #[cfg(test)] mod tests { - use super::SqlTransaction; + use super::{ + ItemGuidAllocatorAdvisoryLockLikeCpp, SqlTransaction, SqlTransactionCommitError, + retry_deadlocked_operation_like_cpp, validate_rows_affected, + }; + use crate::{DatabaseError, PreparedStatement}; + use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::Duration; use tokio::sync::oneshot; @@ -214,6 +572,25 @@ mod tests { release_tx.send(()).unwrap(); holder.await.unwrap(); assert!(SqlTransaction::deadlock_retry_lock_probe_for_test().await); + + let attempts = AtomicUsize::new(0); + let value = retry_deadlocked_operation_like_cpp( + || { + let attempt = attempts.fetch_add(1, Ordering::Relaxed); + async move { + if attempt < 2 { + Err(DatabaseError::Transaction("synthetic deadlock".to_string())) + } else { + Ok(7u8) + } + } + }, + |error| error.to_string().contains("deadlock"), + ) + .await + .unwrap(); + assert_eq!(value, 7); + assert_eq!(attempts.load(Ordering::Relaxed), 3); } #[test] @@ -245,4 +622,58 @@ mod tests { tx.cleanup_like_cpp(); assert_eq!(tx.len(), 1); } + + #[test] + fn transaction_tracks_affected_row_contracts_fail_closed() { + let mut tx = SqlTransaction::new(); + tx.append(PreparedStatement::raw_sql_like_cpp( + "UPDATE item_instance SET count = 2 WHERE guid = 1", + )); + tx.append_expect_rows_affected( + PreparedStatement::raw_sql_like_cpp( + "DELETE FROM item_loot_items WHERE container_id = 1", + ), + 1, + ); + + assert_eq!(tx.expected_rows_for_test(), vec![None, Some(1)]); + assert!(validate_rows_affected(1, 1, 1).is_ok()); + let error = validate_rows_affected(1, 1, 0).unwrap_err(); + assert!(matches!(error, DatabaseError::Transaction(_))); + assert_eq!( + error.to_string(), + "transaction failed: statement 1 affected 0 rows; expected exactly 1" + ); + } + + #[test] + fn item_guid_allocator_lock_domain_is_stable_private_and_database_scoped() { + let first = ItemGuidAllocatorAdvisoryLockLikeCpp::lock_name_for_test("characters"); + let same = ItemGuidAllocatorAdvisoryLockLikeCpp::lock_name_for_test("characters"); + let other = ItemGuidAllocatorAdvisoryLockLikeCpp::lock_name_for_test("characters_qa"); + + assert_eq!(first, same); + assert_ne!(first, other); + assert!(first.starts_with("rustycore:item-guid:")); + assert!(!first.contains("characters")); + assert!( + first.len() <= 64, + "MySQL named locks are limited to 64 bytes" + ); + } + + #[test] + fn commit_outcome_unknown_remains_distinct_from_definite_rollback() { + let unknown = SqlTransactionCommitError::CommitOutcomeUnknown(DatabaseError::Transaction( + "connection lost after COMMIT".to_string(), + )); + let rollback = SqlTransactionCommitError::DefinitelyRolledBack(DatabaseError::Transaction( + "statement failed before COMMIT".to_string(), + )); + + assert!(unknown.is_commit_outcome_unknown_like_cpp()); + assert!(!rollback.is_commit_outcome_unknown_like_cpp()); + assert!(unknown.to_string().contains("outcome is unknown")); + assert!(rollback.to_string().contains("rolled back")); + } } diff --git a/crates/wow-entities/Cargo.toml b/crates/wow-entities/Cargo.toml index d28a9fb12..ccd3d82fa 100644 --- a/crates/wow-entities/Cargo.toml +++ b/crates/wow-entities/Cargo.toml @@ -10,6 +10,7 @@ license.workspace = true bitflags = { workspace = true } wow-core = { workspace = true } wow-constants = { workspace = true } +wow-loot = { workspace = true } [lints] workspace = true diff --git a/crates/wow-entities/src/creature.rs b/crates/wow-entities/src/creature.rs index eeaf71c2e..b7fa87d24 100644 --- a/crates/wow-entities/src/creature.rs +++ b/crates/wow-entities/src/creature.rs @@ -11,6 +11,10 @@ use wow_constants::{ UnitPvpFlags, UnitStandStateType, UnitState, WeaponAttackType, movement::MovementFlag, }; use wow_core::{ObjectGuid, Position}; +use wow_loot::{ + CreatureLoot, LootInstallOutcome, OwnedLootAuthority, OwnedLootAuthorityStamp, + OwnedLootSnapshot, +}; use crate::{ BASE_MAXDAMAGE, BASE_MINDAMAGE, MoveFallPlan, MovementGeneratorKind, @@ -274,6 +278,10 @@ pub struct CreatureTemplateLifecycleRecord { pub static_flags: [u32; 8], pub creature_type: u32, pub type_flags: u32, + pub loot_id: u32, + pub skin_loot_id: u32, + pub gold_min: u32, + pub gold_max: u32, pub movement_type: MovementGeneratorType, pub ground_movement_type: u8, pub swim_allowed: bool, @@ -870,6 +878,10 @@ impl CreatureOwnedLoot { } } +fn creature_owned_loot_from_snapshot(snapshot: &OwnedLootSnapshot) -> CreatureOwnedLoot { + CreatureOwnedLoot::new(snapshot.loot.coins, u32::from(snapshot.loot.unlooted_count)) +} + #[derive(Debug, Clone, PartialEq)] pub struct Creature { unit: Unit, @@ -926,6 +938,13 @@ pub struct Creature { attack_reputation_faction_id: Option, is_contested_guard_faction: bool, spell_focus: CreatureSpellFocusStateLikeCpp, + /// Monotonic identity for the creature loot-producing lifetime. Async + /// `Unit::Kill` generation captures this value and may install its pools + /// only while the same death lifetime is still current. Corpse removal + /// and respawn advance it, preventing an old generator from winning an + /// ABA race against a later incarnation of the same spawn GUID. + loot_lifecycle_revision: u64, + loot_authority: OwnedLootAuthority, shared_loot: Option, personal_loot: HashMap, } @@ -995,6 +1014,8 @@ impl Creature { attack_reputation_faction_id: None, is_contested_guard_faction: false, spell_focus: CreatureSpellFocusStateLikeCpp::default(), + loot_lifecycle_revision: 0, + loot_authority: OwnedLootAuthority::new(), shared_loot: None, personal_loot: HashMap::new(), } @@ -1156,6 +1177,15 @@ impl Creature { self.ai_ownership.unit_flags3 = template.unit_flags3; self.ai_ownership.min_damage = record.stats.min_damage.max(0.0) as u32; self.ai_ownership.max_damage = record.stats.max_damage.max(0.0) as u32; + // C++ `Creature::GetLootId` first honors an object-local `m_lootId` override and then + // reads the selected `CreatureDifficulty::LootID`; death/skinning generation reads + // GoldMin/GoldMax/SkinLootID from that same difficulty (`Creature.cpp:1317-1323`, + // `Unit.cpp:10545-10575,10675`). Keep those values on the runtime creature instead of + // falling back to `CreatureAiOwnershipState`'s zero defaults. + self.ai_ownership.loot_id = template.loot_id; + self.ai_ownership.skin_loot_id = template.skin_loot_id; + self.ai_ownership.gold_min = template.gold_min; + self.ai_ownership.gold_max = template.gold_max; self.unit .set_npc_flags_like_cpp(self.ai_ownership.npc_flags); self.unit @@ -1805,6 +1835,12 @@ impl Creature { } pub fn reset_ai_combat(&mut self, now_ms: u64) { + if self.ai_ownership.state == CreatureAiState::Dead + || self.unit.is_dead() + || self.unit.data().health == 0 + { + self.advance_loot_lifecycle_revision_like_cpp(); + } self.ai_ownership.state = CreatureAiState::Returning; self.ai_ownership.combat_target = None; self.ai_ownership.move_target = Some(self.ai_ownership.home_position); @@ -1878,6 +1914,7 @@ impl Creature { game_time_secs.saturating_add(MAX_AGGRO_RESET_TIME_SECS_LIKE_CPP); } if remaining == 0 { + self.advance_loot_lifecycle_revision_like_cpp(); self.ai_ownership.state = CreatureAiState::Dead; self.ai_ownership.combat_target = None; self.ai_ownership.move_target = None; @@ -1895,6 +1932,9 @@ impl Creature { } pub fn mark_ai_dead_at_game_time_like_cpp(&mut self, now_ms: u64, game_time_secs: i64) { + if self.ai_ownership.state != CreatureAiState::Dead { + self.advance_loot_lifecycle_revision_like_cpp(); + } self.ai_ownership.state = CreatureAiState::Dead; self.ai_ownership.combat_target = None; self.ai_ownership.move_target = None; @@ -2788,6 +2828,17 @@ impl Creature { &self.runtime_state } + pub const fn loot_lifecycle_revision_like_cpp(&self) -> u64 { + self.loot_lifecycle_revision + } + + fn advance_loot_lifecycle_revision_like_cpp(&mut self) -> u64 { + // Never wrap to an earlier lifetime: even an unreachable counter + // exhaustion must remain fail-closed for async generation tokens. + self.loot_lifecycle_revision = self.loot_lifecycle_revision.saturating_add(1).max(1); + self.loot_lifecycle_revision + } + pub fn runtime_state_mut(&mut self) -> &mut CreatureRuntimeState { &mut self.runtime_state } @@ -2800,6 +2851,128 @@ impl Creature { self.runtime_state.has_loot_recipient } + pub const fn loot_authority_like_cpp(&self) -> &OwnedLootAuthority { + &self.loot_authority + } + + /// Bind this runtime mirror to the same C++ `Creature::loot` authority as + /// another coexisting map model. + pub fn rebind_loot_authority_like_cpp(&mut self, authority: OwnedLootAuthority) -> bool { + if self.loot_authority.shares_storage_like_cpp(&authority) { + self.sync_loot_summaries_from_authority_like_cpp(); + return false; + } + + // Invalidate leases retained against a displaced mirror before the + // replacement authority can serve another claim for the same C++ + // object. + self.loot_authority.detach_like_cpp(); + self.loot_authority = authority; + self.sync_loot_summaries_from_authority_like_cpp(); + true + } + + /// Rebinds only if this mirror still owns the authority observed by the + /// caller. This is the object-local compare/exchange boundary used when + /// the legacy and canonical map models are reconciled without holding + /// both map locks at once. + pub fn rebind_loot_authority_if_current_like_cpp( + &mut self, + expected: &OwnedLootAuthority, + expected_stamp: OwnedLootAuthorityStamp, + authority: OwnedLootAuthority, + ) -> Option { + if !self.loot_authority.shares_storage_like_cpp(expected) { + return None; + } + + if self.loot_authority.stamp_like_cpp() != expected_stamp { + return None; + } + + if self.loot_authority.shares_storage_like_cpp(&authority) { + self.sync_loot_summaries_from_authority_like_cpp(); + return Some(false); + } + + if !self.loot_authority.detach_if_stamp_like_cpp(expected_stamp) { + return None; + } + + self.loot_authority = authority; + self.sync_loot_summaries_from_authority_like_cpp(); + Some(true) + } + + /// Assigns authority identity to a temporary whole-entity snapshot. + /// + /// The snapshot may share its old `Arc` with a live legacy entity, so it + /// must not detach that allocation. Only the later CAS against the actual + /// owning entity is allowed to invalidate a displaced authority. + pub fn adopt_loot_authority_for_snapshot_like_cpp(&mut self, authority: OwnedLootAuthority) { + self.loot_authority = authority; + self.sync_loot_summaries_from_authority_like_cpp(); + } + + pub fn share_loot_authority_like_cpp(&mut self, authority: OwnedLootAuthority) { + self.rebind_loot_authority_like_cpp(authority); + } + + pub fn initialize_loot_authority_like_cpp( + &mut self, + shared: Option, + personal: HashMap, + ) -> LootInstallOutcome { + let outcome = self.loot_authority.initialize_like_cpp(shared, personal); + self.sync_loot_summaries_from_authority_like_cpp(); + outcome + } + + pub fn initialize_shared_loot_authority_like_cpp( + &mut self, + loot: CreatureLoot, + ) -> LootInstallOutcome { + let outcome = self.loot_authority.initialize_shared_like_cpp(loot); + self.sync_loot_summaries_from_authority_like_cpp(); + outcome + } + + pub fn upsert_personal_loot_authority_like_cpp( + &mut self, + player: ObjectGuid, + loot: CreatureLoot, + replace: bool, + ) -> LootInstallOutcome { + let outcome = self + .loot_authority + .upsert_personal_like_cpp(player, loot, replace); + self.sync_loot_summaries_from_authority_like_cpp(); + outcome + } + + pub fn replace_loot_authority_like_cpp( + &mut self, + shared: Option, + personal: HashMap, + ) -> u64 { + let generation = self.loot_authority.replace_like_cpp(shared, personal); + self.sync_loot_summaries_from_authority_like_cpp(); + generation + } + + pub fn sync_loot_summaries_from_authority_like_cpp(&mut self) { + self.shared_loot = self + .loot_authority + .shared_snapshot_like_cpp() + .map(|snapshot| creature_owned_loot_from_snapshot(&snapshot)); + self.personal_loot = self + .loot_authority + .personal_snapshots_like_cpp() + .into_iter() + .map(|(player, snapshot)| (player, creature_owned_loot_from_snapshot(&snapshot))) + .collect(); + } + pub const fn shared_loot_like_cpp(&self) -> Option<&CreatureOwnedLoot> { self.shared_loot.as_ref() } @@ -2837,11 +3010,17 @@ impl Creature { } pub fn clear_loot_like_cpp(&mut self) { + self.advance_loot_lifecycle_revision_like_cpp(); + self.loot_authority.retire_like_cpp(); self.shared_loot = None; self.personal_loot.clear(); } pub fn is_fully_looted_like_cpp(&self) -> bool { + if !self.loot_authority.is_pristine_like_cpp() { + return self.loot_authority.is_fully_looted_like_cpp(); + } + if self .shared_loot .as_ref() @@ -2947,6 +3126,9 @@ impl Creature { match state { DeathState::JustDied => { + if self.ai_ownership.state != CreatureAiState::Dead { + self.advance_loot_lifecycle_revision_like_cpp(); + } let needs_falling = death_fall.filter(|context| { (self.is_flying_like_cpp() || self.is_hovering_like_cpp()) && !context.is_underwater @@ -3046,6 +3228,7 @@ impl Creature { self.unit.set_death_state(DeathState::Corpse); } DeathState::JustRespawned => { + self.advance_loot_lifecycle_revision_like_cpp(); let motion_initialize_outcome = self.aim_initialize_like_cpp(); let is_pet = self.unit.world().object().guid().is_pet(); if is_pet { @@ -3203,6 +3386,11 @@ impl Creature { return plan; } + // C++ drops the corpse's object-owned Loot during removal (directly + // in compatibility mode and via object destruction otherwise). Rust + // leases can outlive the map reference, so retire it at the lifecycle + // boundary before an in-flight async claim can commit. + self.clear_loot_like_cpp(); self.runtime_state.remove_corpse_requested = true; self.runtime_state.corpse_removed_count = self.runtime_state.corpse_removed_count.saturating_add(1); @@ -3546,6 +3734,44 @@ mod tests { } } + fn owned_loot_fixture_like_cpp( + coins: u32, + unlooted_count: u8, + allowed_looters: Vec, + ) -> CreatureLoot { + CreatureLoot { + loot_guid: ObjectGuid::EMPTY, + coins, + unlooted_count, + loot_type: 1, + dungeon_encounter_id: 0, + loot_method: 0, + loot_master: ObjectGuid::EMPTY, + round_robin_player: ObjectGuid::EMPTY, + player_ffa_items: Vec::new(), + players_looting: Vec::new(), + allowed_looters, + items: Vec::new(), + looted_by_player: false, + } + } + + fn poll_immediately_ready(future: F) -> F::Output { + struct NoopWake; + + impl std::task::Wake for NoopWake { + fn wake(self: std::sync::Arc) {} + } + + let waker = std::task::Waker::from(std::sync::Arc::new(NoopWake)); + let mut context = std::task::Context::from_waker(&waker); + let mut future = std::pin::pin!(future); + match future.as_mut().poll(&mut context) { + std::task::Poll::Ready(output) => output, + std::task::Poll::Pending => panic!("expected the uncontended claim to be ready"), + } + } + #[test] fn creature_search_formation_like_cpp_requests_only_with_spawn_and_info() { let mut creature = Creature::new(false); @@ -4490,6 +4716,10 @@ mod tests { static_flags: [0; 8], creature_type: 9, type_flags: 0x20, + loot_id: 7_001, + skin_loot_id: 7_002, + gold_min: 17, + gold_max: 29, movement_type: MovementGeneratorType::Idle, ground_movement_type: CreatureGroundMovementType::Run as u8, swim_allowed: true, @@ -5895,6 +6125,109 @@ mod tests { assert!(!CreatureOwnedLoot::new(1, 1).is_looted_like_cpp()); } + #[test] + fn creature_owns_full_loot_authority_and_clear_retires_it() { + let mut creature = Creature::new(false); + let full_loot = CreatureLoot { + loot_guid: ObjectGuid::EMPTY, + coins: 17, + unlooted_count: 2, + loot_type: 1, + dungeon_encounter_id: 0, + loot_method: 0, + loot_master: ObjectGuid::EMPTY, + round_robin_player: ObjectGuid::EMPTY, + player_ffa_items: Vec::new(), + players_looting: Vec::new(), + allowed_looters: Vec::new(), + items: Vec::new(), + looted_by_player: false, + }; + creature.initialize_shared_loot_authority_like_cpp(full_loot); + + assert_eq!( + creature.shared_loot_like_cpp(), + Some(&CreatureOwnedLoot::new(17, 2)) + ); + assert!(!creature.loot_authority_like_cpp().is_retired_like_cpp()); + creature.clear_loot_like_cpp(); + assert!(creature.loot_authority_like_cpp().is_retired_like_cpp()); + assert!( + creature + .loot_authority_like_cpp() + .shared_snapshot_like_cpp() + .is_none() + ); + } + + #[test] + fn creature_rebind_retires_displaced_authority_and_its_lease() { + let player = ObjectGuid::create_player(1, 77); + let displaced = OwnedLootAuthority::new(); + displaced.replace_like_cpp( + Some(owned_loot_fixture_like_cpp(17, 0, vec![player])), + HashMap::new(), + ); + let mut creature = Creature::new(false); + assert!(creature.rebind_loot_authority_like_cpp(displaced.clone())); + let lease = poll_immediately_ready(displaced.reserve_money_like_cpp(player)) + .expect("the allowed player reserves the displaced authority"); + + let replacement = OwnedLootAuthority::new(); + replacement.replace_like_cpp( + Some(owned_loot_fixture_like_cpp(23, 0, vec![player])), + HashMap::new(), + ); + assert!(creature.rebind_loot_authority_like_cpp(replacement.clone())); + + assert!(displaced.is_retired_like_cpp()); + assert!( + creature + .loot_authority_like_cpp() + .shares_storage_like_cpp(&replacement) + ); + assert_eq!( + lease.commit_like_cpp(), + Err(wow_loot::LootClaimCommitError::StaleGeneration), + "a lease against the displaced Arc must not commit after rebind" + ); + assert_eq!( + replacement.shared_snapshot_like_cpp().unwrap().loot.coins, + 23 + ); + } + + #[test] + fn creature_fully_looted_reads_active_authority_without_summary_refresh() { + let authority = OwnedLootAuthority::new(); + authority.replace_like_cpp( + Some(owned_loot_fixture_like_cpp(17, 0, Vec::new())), + HashMap::new(), + ); + let mut creature = Creature::new(false); + creature.rebind_loot_authority_like_cpp(authority.clone()); + assert_eq!( + creature.shared_loot_like_cpp(), + Some(&CreatureOwnedLoot::new(17, 0)) + ); + assert!(!creature.is_fully_looted_like_cpp()); + + authority.replace_like_cpp( + Some(owned_loot_fixture_like_cpp(0, 0, Vec::new())), + HashMap::new(), + ); + + assert_eq!( + creature.shared_loot_like_cpp(), + Some(&CreatureOwnedLoot::new(17, 0)), + "the compatibility summary remains deliberately stale" + ); + assert!( + creature.is_fully_looted_like_cpp(), + "lifecycle decisions must read the active object-owned authority" + ); + } + #[test] fn creature_is_fully_looted_checks_shared_and_personal_loot_like_cpp() { let looted_player = ObjectGuid::create_player(1, 7); @@ -6167,6 +6500,23 @@ mod tests { assert!(non_compat_plan.contains(CreatureRuntimeAction::RequestObjectRemove)); } + #[test] + fn creature_corpse_removal_retires_arc_held_loot_authority() { + let mut creature = Creature::new(false); + creature.replace_loot_authority_like_cpp(None, HashMap::new()); + let authority = creature.loot_authority_like_cpp().clone(); + creature.unit_mut().set_death_state(DeathState::Corpse); + assert!(!authority.is_retired_like_cpp()); + + let plan = creature.remove_corpse_runtime(20_000, true, false); + + assert!(plan.contains(CreatureRuntimeAction::RemoveLoot)); + assert!( + authority.is_retired_like_cpp(), + "corpse removal must invalidate claims that outlive the map object" + ); + } + #[test] fn creature_runtime_all_loot_removed_updates_corpse_and_respawn_like_trinity() { let now = 1_000; diff --git a/crates/wow-entities/src/game_object.rs b/crates/wow-entities/src/game_object.rs index fcde3b541..9d099fcfb 100644 --- a/crates/wow-entities/src/game_object.rs +++ b/crates/wow-entities/src/game_object.rs @@ -2,6 +2,10 @@ use std::collections::{HashMap, HashSet}; use wow_constants::{TypeId, TypeMask}; use wow_core::{ObjectGuid, Position}; +use wow_loot::{ + CreatureLoot, LootInstallOutcome, OwnedLootAuthority, OwnedLootAuthorityLifecycle, + OwnedLootAuthorityStamp, OwnedLootSnapshot, +}; use crate::{ CreateObjectFlags, MapBindingError, ObjectChangedFields, ObjectDataUpdate, UpdateMask, @@ -212,6 +216,10 @@ impl GameObjectOwnedLoot { } } +fn game_object_owned_loot_from_snapshot(snapshot: &OwnedLootSnapshot) -> GameObjectOwnedLoot { + GameObjectOwnedLoot::new(snapshot.loot.coins, u32::from(snapshot.loot.unlooted_count)) +} + impl GameObjectLootSource { pub const fn is_empty(&self) -> bool { self.loot_id == 0 && self.personal_loot_id == 0 && self.push_loot_id == 0 @@ -229,8 +237,16 @@ impl GameObjectLootSource { self.open_loot_id_like_cpp() != 0 } + /// C++ stores every `chestPersonalLoot` result in `m_personalLoot` when + /// there is no shared `GetLootId()` result. `DungeonEncounter` only + /// chooses how the personal pools are generated; it does not decide + /// whether the loot is personal (`GameObject.cpp:2584-2613`). + pub const fn uses_personal_loot_like_cpp(&self) -> bool { + self.loot_id == 0 && self.personal_loot_id != 0 + } + pub const fn is_personal_encounter_loot_like_cpp(&self) -> bool { - self.loot_id == 0 && self.personal_loot_id != 0 && self.dungeon_encounter_id != 0 + self.uses_personal_loot_like_cpp() && self.dungeon_encounter_id != 0 } pub const fn should_autostore_push_loot_like_cpp(&self) -> bool { @@ -1005,6 +1021,12 @@ pub struct GameObject { restock_time: i64, loot_state: LootState, loot_state_unit_guid: ObjectGuid, + /// Monotonic identity for one C++ `GameObject::loot` lifetime. `ClearLoot` + /// advances it before a restock can generate another pool, so async work + /// captured for the previous use cannot install into the next use of the + /// same spawn GUID. + loot_lifecycle_revision: u64, + loot_authority: OwnedLootAuthority, shared_loot: Option, personal_loot: HashMap, unique_users: HashSet, @@ -1098,6 +1120,8 @@ impl GameObject { restock_time: 0, loot_state: LootState::NotReady, loot_state_unit_guid: ObjectGuid::EMPTY, + loot_lifecycle_revision: 0, + loot_authority: OwnedLootAuthority::new(), shared_loot: None, personal_loot: HashMap::new(), unique_users: HashSet::new(), @@ -1493,6 +1517,17 @@ impl GameObject { self.loot_state_unit_guid } + pub const fn loot_lifecycle_revision_like_cpp(&self) -> u64 { + self.loot_lifecycle_revision + } + + fn advance_loot_lifecycle_revision_like_cpp(&mut self) -> u64 { + // A restock identity may stop advancing only at exhaustion; it must + // never wrap and become equal to a stale async observation. + self.loot_lifecycle_revision = self.loot_lifecycle_revision.saturating_add(1).max(1); + self.loot_lifecycle_revision + } + pub fn set_loot_state(&mut self, state: LootState, unit: Option) { self.loot_state = state; self.loot_state_unit_guid = unit.unwrap_or(ObjectGuid::EMPTY); @@ -1612,7 +1647,247 @@ impl GameObject { self.use_times = 0; } + pub const fn loot_authority_like_cpp(&self) -> &OwnedLootAuthority { + &self.loot_authority + } + + pub fn rebind_loot_authority_like_cpp(&mut self, authority: OwnedLootAuthority) -> bool { + if self.loot_authority.shares_storage_like_cpp(&authority) { + self.sync_loot_summaries_from_authority_like_cpp(); + return false; + } + + self.loot_authority.detach_like_cpp(); + self.loot_authority = authority; + self.sync_loot_summaries_from_authority_like_cpp(); + true + } + + /// Compare/exchange variant for callers that cannot keep the canonical + /// map lock between observing and rebinding this object. + pub fn rebind_loot_authority_if_current_like_cpp( + &mut self, + expected: &OwnedLootAuthority, + expected_stamp: OwnedLootAuthorityStamp, + authority: OwnedLootAuthority, + ) -> Option { + if !self.loot_authority.shares_storage_like_cpp(expected) { + return None; + } + + if self.loot_authority.stamp_like_cpp() != expected_stamp { + return None; + } + + if self.loot_authority.shares_storage_like_cpp(&authority) { + self.sync_loot_summaries_from_authority_like_cpp(); + return Some(false); + } + + if !self.loot_authority.detach_if_stamp_like_cpp(expected_stamp) { + return None; + } + + self.loot_authority = authority; + self.sync_loot_summaries_from_authority_like_cpp(); + Some(true) + } + + /// Non-owning snapshot adoption counterpart to the entity-local CAS. + pub fn adopt_loot_authority_for_snapshot_like_cpp(&mut self, authority: OwnedLootAuthority) { + self.loot_authority = authority; + self.sync_loot_summaries_from_authority_like_cpp(); + } + + pub fn share_loot_authority_like_cpp(&mut self, authority: OwnedLootAuthority) { + self.rebind_loot_authority_like_cpp(authority); + } + + pub fn initialize_loot_authority_like_cpp( + &mut self, + shared: Option, + personal: HashMap, + ) -> LootInstallOutcome { + let outcome = self.loot_authority.initialize_like_cpp(shared, personal); + self.sync_loot_summaries_from_authority_like_cpp(); + outcome + } + + pub fn initialize_shared_loot_authority_like_cpp( + &mut self, + loot: CreatureLoot, + ) -> LootInstallOutcome { + let outcome = self.loot_authority.initialize_shared_like_cpp(loot); + self.sync_loot_summaries_from_authority_like_cpp(); + outcome + } + + pub fn upsert_personal_loot_authority_like_cpp( + &mut self, + player: ObjectGuid, + loot: CreatureLoot, + replace: bool, + ) -> LootInstallOutcome { + let outcome = self + .loot_authority + .upsert_personal_like_cpp(player, loot, replace); + self.sync_loot_summaries_from_authority_like_cpp(); + outcome + } + + /// Installs the complete shared/personal pool topology only while this is + /// still the exact `ClearLoot`/restock lifetime observed before async + /// template generation. + /// + /// C++ creates chest loot synchronously from `GameObject::Use`, on the map + /// thread (`GameObject.cpp:2559-2575`). Rust releases the map lock for DB + /// work, so identity, object generation, and the entity-local lifecycle + /// revision form the equivalent compare/exchange boundary. If another + /// opener already installed this same lifecycle, its first-writer result is + /// accepted without replacing it. + pub fn install_loot_authority_if_lifecycle_like_cpp( + &mut self, + expected_authority: &OwnedLootAuthority, + expected_object_generation: u64, + expected_lifecycle_revision: u64, + shared: Option, + personal: HashMap, + ) -> bool { + if self.loot_lifecycle_revision != expected_lifecycle_revision + || self.loot_state == LootState::JustDeactivated + || !self + .loot_authority + .shares_storage_like_cpp(expected_authority) + { + return false; + } + + let stamp = self.loot_authority.stamp_like_cpp(); + let installed = match stamp.lifecycle { + OwnedLootAuthorityLifecycle::Pristine + if expected_object_generation == 0 && stamp.object_generation == 0 => + { + self.loot_authority + .initialize_pristine_like_cpp(shared, personal) + .installed() + } + OwnedLootAuthorityLifecycle::Retired + if stamp.object_generation == expected_object_generation => + { + self.loot_authority + .replace_retired_generation_like_cpp( + expected_object_generation, + shared, + personal, + ) + .is_some() + } + OwnedLootAuthorityLifecycle::Active + if stamp.object_generation == expected_object_generation.wrapping_add(1).max(1) => + { + // A concurrent opener won the same map-object lifecycle. C++ + // keeps the first `m_loot`; do not replace its generated pools. + true + } + _ => false, + }; + + if installed { + self.sync_loot_summaries_from_authority_like_cpp(); + } + installed + } + + /// Installs one personal pool only while the object is still in the exact + /// `ClearLoot`/restock lifetime observed before async template generation. + /// + /// A retired non-pristine authority is a valid new generation only when + /// its object generation still matches the tombstone captured by the + /// caller. Once another player has started that same lifecycle, additional + /// personal pools may join the active authority without replacing it. + pub fn install_personal_loot_if_lifecycle_like_cpp( + &mut self, + expected_authority: &OwnedLootAuthority, + expected_object_generation: u64, + expected_lifecycle_revision: u64, + player: ObjectGuid, + loot: CreatureLoot, + replace: bool, + ) -> bool { + if self.loot_lifecycle_revision != expected_lifecycle_revision + || self.loot_state == LootState::JustDeactivated + || !self + .loot_authority + .shares_storage_like_cpp(expected_authority) + { + return false; + } + + let current_generation = self.loot_authority.generation_like_cpp(); + let installed = if self.loot_authority.is_retired_like_cpp() && current_generation != 0 { + if current_generation != expected_object_generation { + return false; + } + self.loot_authority + .replace_retired_generation_like_cpp( + expected_object_generation, + None, + HashMap::from([(player, loot)]), + ) + .is_some() + } else { + if current_generation < expected_object_generation { + return false; + } + let outcome = self + .loot_authority + .upsert_personal_like_cpp(player, loot, replace); + outcome.installed() + || self + .loot_authority + .snapshot_for_player_like_cpp(player) + .is_some() + }; + + self.sync_loot_summaries_from_authority_like_cpp(); + installed + } + + pub fn replace_loot_authority_like_cpp( + &mut self, + shared: Option, + personal: HashMap, + ) -> u64 { + let generation = self.loot_authority.replace_like_cpp(shared, personal); + self.sync_loot_summaries_from_authority_like_cpp(); + generation + } + + pub fn sync_loot_summaries_from_authority_like_cpp(&mut self) { + self.shared_loot = self + .loot_authority + .shared_snapshot_like_cpp() + .map(|snapshot| game_object_owned_loot_from_snapshot(&snapshot)); + self.personal_loot = self + .loot_authority + .personal_snapshots_like_cpp() + .into_iter() + .map(|(player, snapshot)| (player, game_object_owned_loot_from_snapshot(&snapshot))) + .collect(); + } + + /// Invalidates every outstanding async claim without eagerly applying + /// `ClearLoot` side effects to the still map-resident object. C++ + /// `GameObject::Delete` queues physical removal after setting + /// `GO_NOT_READY`; its loot members remain observable until destruction. + pub fn retire_loot_authority_like_cpp(&mut self) { + self.advance_loot_lifecycle_revision_like_cpp(); + self.loot_authority.retire_like_cpp(); + } + pub fn clear_loot_like_cpp(&mut self) { + self.advance_loot_lifecycle_revision_like_cpp(); + self.loot_authority.retire_like_cpp(); self.shared_loot = None; self.personal_loot.clear(); self.unique_users.clear(); @@ -1620,6 +1895,10 @@ impl GameObject { } pub fn is_fully_looted_like_cpp(&self) -> bool { + if !self.loot_authority.is_pristine_like_cpp() { + return self.loot_authority.is_fully_looted_like_cpp(); + } + if self .shared_loot .as_ref() @@ -2009,6 +2288,24 @@ mod tests { use super::*; use wow_core::guid::HighGuid; + fn owned_loot_fixture_like_cpp(coins: u32, unlooted_count: u8) -> CreatureLoot { + CreatureLoot { + loot_guid: ObjectGuid::EMPTY, + coins, + unlooted_count, + loot_type: 1, + dungeon_encounter_id: 0, + loot_method: 0, + loot_master: ObjectGuid::EMPTY, + round_robin_player: ObjectGuid::EMPTY, + player_ffa_items: Vec::new(), + players_looting: Vec::new(), + allowed_looters: Vec::new(), + items: Vec::new(), + looted_by_player: false, + } + } + #[test] fn gameobject_constructor_matches_cpp_base_state() { let go = GameObject::new(); @@ -3312,6 +3609,7 @@ mod tests { assert!(!source.is_empty()); assert_eq!(source.open_loot_id_like_cpp(), 10); assert!(source.has_open_loot_like_cpp()); + assert!(!source.uses_personal_loot_like_cpp()); assert!(!source.is_personal_encounter_loot_like_cpp()); assert!(!source.should_autostore_push_loot_like_cpp()); @@ -3329,6 +3627,7 @@ mod tests { .expect("chest templates expose a chest loot source"); assert!(!push_source.is_empty()); assert!(!push_source.has_open_loot_like_cpp()); + assert!(!push_source.uses_personal_loot_like_cpp()); assert!(!push_source.is_personal_encounter_loot_like_cpp()); assert!(push_source.should_autostore_push_loot_like_cpp()); @@ -3338,7 +3637,16 @@ mod tests { .expect("chest templates expose a chest loot source"); assert_eq!(personal_encounter_source.open_loot_id_like_cpp(), 25); assert!(personal_encounter_source.has_open_loot_like_cpp()); + assert!(personal_encounter_source.uses_personal_loot_like_cpp()); assert!(personal_encounter_source.is_personal_encounter_loot_like_cpp()); + + data[GAMEOBJECT_DATA_CHEST_DUNGEON_ENCOUNTER] = 0; + let personal_non_encounter_source = + GameObjectTemplateData::new(GAMEOBJECT_TYPE_CHEST, data) + .chest_loot_source_like_cpp() + .expect("chest templates expose a chest loot source"); + assert!(personal_non_encounter_source.uses_personal_loot_like_cpp()); + assert!(!personal_non_encounter_source.is_personal_encounter_loot_like_cpp()); assert_eq!( GameObjectTemplateData::new(GAMEOBJECT_TYPE_FISHING_HOLE, data) .chest_loot_source_like_cpp(), @@ -3478,6 +3786,252 @@ mod tests { assert!(!GameObjectOwnedLoot::new(1, 1).is_looted_like_cpp()); } + #[test] + fn gameobject_personal_authority_updates_summary_and_clear_retires_it() { + let player = ObjectGuid::create_player(1, 77); + let mut gameobject = GameObject::new(); + let full_loot = CreatureLoot { + loot_guid: ObjectGuid::EMPTY, + coins: 23, + unlooted_count: 1, + loot_type: 9, + dungeon_encounter_id: 0, + loot_method: 5, + loot_master: ObjectGuid::EMPTY, + round_robin_player: ObjectGuid::EMPTY, + player_ffa_items: Vec::new(), + players_looting: Vec::new(), + allowed_looters: vec![player], + items: Vec::new(), + looted_by_player: false, + }; + gameobject.upsert_personal_loot_authority_like_cpp(player, full_loot, false); + + assert_eq!( + gameobject.personal_loot_like_cpp(player), + Some(&GameObjectOwnedLoot::new(23, 1)) + ); + assert!( + gameobject + .loot_authority_like_cpp() + .snapshot_for_player_like_cpp(player) + .is_some() + ); + gameobject.clear_loot_like_cpp(); + assert!(gameobject.loot_authority_like_cpp().is_retired_like_cpp()); + assert_eq!(gameobject.personal_loot_count_like_cpp(), 0); + } + + #[test] + fn clear_loot_restock_starts_second_personal_generation_for_same_gameobject() { + let player = ObjectGuid::create_player(1, 78); + let mut gameobject = GameObject::new(); + gameobject.set_loot_state(LootState::Activated, Some(player)); + gameobject.upsert_personal_loot_authority_like_cpp( + player, + owned_loot_fixture_like_cpp(7, 0), + false, + ); + let first_generation = gameobject.loot_authority_like_cpp().generation_like_cpp(); + + gameobject.clear_loot_like_cpp(); + gameobject.set_loot_state(LootState::Ready, None); + let authority = gameobject.loot_authority_like_cpp().clone(); + let tombstone_generation = authority.generation_like_cpp(); + let lifecycle_revision = gameobject.loot_lifecycle_revision_like_cpp(); + + assert!(gameobject.install_personal_loot_if_lifecycle_like_cpp( + &authority, + tombstone_generation, + lifecycle_revision, + player, + owned_loot_fixture_like_cpp(19, 0), + false, + )); + let second = gameobject + .loot_authority_like_cpp() + .snapshot_for_player_like_cpp(player) + .expect("restocked personal pool"); + assert_eq!(second.loot.coins, 19); + assert!(gameobject.loot_authority_like_cpp().generation_like_cpp() > first_generation); + assert!(!gameobject.loot_authority_like_cpp().is_retired_like_cpp()); + } + + #[test] + fn stale_personal_generator_cannot_cross_gameobject_clear_loot_lifecycle() { + let player = ObjectGuid::create_player(1, 79); + let mut gameobject = GameObject::new(); + gameobject.set_loot_state(LootState::Activated, Some(player)); + gameobject.upsert_personal_loot_authority_like_cpp( + player, + owned_loot_fixture_like_cpp(3, 0), + false, + ); + let stale_authority = gameobject.loot_authority_like_cpp().clone(); + let stale_generation = stale_authority.generation_like_cpp(); + let stale_lifecycle_revision = gameobject.loot_lifecycle_revision_like_cpp(); + + gameobject.clear_loot_like_cpp(); + gameobject.set_loot_state(LootState::Ready, None); + + assert!(!gameobject.install_personal_loot_if_lifecycle_like_cpp( + &stale_authority, + stale_generation, + stale_lifecycle_revision, + player, + owned_loot_fixture_like_cpp(99, 0), + false, + )); + assert!(gameobject.loot_authority_like_cpp().is_retired_like_cpp()); + assert!( + gameobject + .loot_authority_like_cpp() + .snapshot_for_player_like_cpp(player) + .is_none() + ); + } + + #[test] + fn clear_loot_restock_installs_shared_generation_once_for_same_lifecycle() { + let player = ObjectGuid::create_player(1, 80); + let mut gameobject = GameObject::new(); + gameobject.set_loot_state(LootState::Activated, Some(player)); + gameobject.initialize_shared_loot_authority_like_cpp(owned_loot_fixture_like_cpp(7, 0)); + + gameobject.clear_loot_like_cpp(); + gameobject.set_loot_state(LootState::Ready, None); + let authority = gameobject.loot_authority_like_cpp().clone(); + let tombstone_generation = authority.generation_like_cpp(); + let lifecycle_revision = gameobject.loot_lifecycle_revision_like_cpp(); + + assert!(gameobject.install_loot_authority_if_lifecycle_like_cpp( + &authority, + tombstone_generation, + lifecycle_revision, + Some(owned_loot_fixture_like_cpp(19, 0)), + HashMap::new(), + )); + let installed_generation = authority.generation_like_cpp(); + assert_eq!( + authority + .shared_snapshot_like_cpp() + .expect("restocked shared pool") + .loot + .coins, + 19 + ); + + assert!(gameobject.install_loot_authority_if_lifecycle_like_cpp( + &authority, + tombstone_generation, + lifecycle_revision, + Some(owned_loot_fixture_like_cpp(99, 0)), + HashMap::new(), + )); + assert_eq!(authority.generation_like_cpp(), installed_generation); + assert_eq!( + authority + .shared_snapshot_like_cpp() + .expect("the first shared install remains authoritative") + .loot + .coins, + 19, + "a concurrent generator for the same lifecycle must not replace the first pool" + ); + } + + #[test] + fn stale_shared_generator_cannot_cross_second_clear_of_retired_gameobject_lifecycle() { + let player = ObjectGuid::create_player(1, 81); + let mut gameobject = GameObject::new(); + gameobject.set_loot_state(LootState::Activated, Some(player)); + gameobject.initialize_shared_loot_authority_like_cpp(owned_loot_fixture_like_cpp(3, 0)); + + gameobject.clear_loot_like_cpp(); + gameobject.set_loot_state(LootState::Ready, None); + let stale_authority = gameobject.loot_authority_like_cpp().clone(); + let stale_generation = stale_authority.generation_like_cpp(); + let stale_lifecycle_revision = gameobject.loot_lifecycle_revision_like_cpp(); + + // `OwnedLootAuthority::retire_like_cpp` is intentionally idempotent for + // an existing tombstone. The GameObject revision must still reject the + // async generator captured before this second C++ `ClearLoot`. + gameobject.clear_loot_like_cpp(); + gameobject.set_loot_state(LootState::Ready, None); + assert_eq!(stale_authority.generation_like_cpp(), stale_generation); + assert!( + gameobject.loot_lifecycle_revision_like_cpp() > stale_lifecycle_revision, + "the entity-local lifetime advances even when the authority tombstone does not" + ); + + assert!(!gameobject.install_loot_authority_if_lifecycle_like_cpp( + &stale_authority, + stale_generation, + stale_lifecycle_revision, + Some(owned_loot_fixture_like_cpp(99, 0)), + HashMap::new(), + )); + assert!(gameobject.loot_authority_like_cpp().is_retired_like_cpp()); + assert!( + gameobject + .loot_authority_like_cpp() + .shared_snapshot_like_cpp() + .is_none() + ); + assert_eq!(gameobject.shared_loot_like_cpp(), None); + } + + #[test] + fn shared_generator_cannot_install_after_gameobject_authority_rebind() { + let player = ObjectGuid::create_player(1, 82); + let mut gameobject = GameObject::new(); + gameobject.set_loot_state(LootState::Activated, Some(player)); + gameobject.initialize_shared_loot_authority_like_cpp(owned_loot_fixture_like_cpp(5, 0)); + gameobject.clear_loot_like_cpp(); + gameobject.set_loot_state(LootState::Ready, None); + + let stale_authority = gameobject.loot_authority_like_cpp().clone(); + let stale_generation = stale_authority.generation_like_cpp(); + let lifecycle_revision = gameobject.loot_lifecycle_revision_like_cpp(); + let replacement = OwnedLootAuthority::new(); + assert!(gameobject.rebind_loot_authority_like_cpp(replacement.clone())); + + assert!(!gameobject.install_loot_authority_if_lifecycle_like_cpp( + &stale_authority, + stale_generation, + lifecycle_revision, + Some(owned_loot_fixture_like_cpp(77, 0)), + HashMap::new(), + )); + assert!(replacement.is_pristine_like_cpp()); + assert!(replacement.shared_snapshot_like_cpp().is_none()); + } + + #[test] + fn gameobject_fully_looted_reads_active_authority_without_summary_refresh() { + let authority = OwnedLootAuthority::new(); + authority.replace_like_cpp(Some(owned_loot_fixture_like_cpp(31, 0)), HashMap::new()); + let mut gameobject = GameObject::new(); + gameobject.rebind_loot_authority_like_cpp(authority.clone()); + assert_eq!( + gameobject.shared_loot_like_cpp(), + Some(&GameObjectOwnedLoot::new(31, 0)) + ); + assert!(!gameobject.is_fully_looted_like_cpp()); + + authority.replace_like_cpp(Some(owned_loot_fixture_like_cpp(0, 0)), HashMap::new()); + + assert_eq!( + gameobject.shared_loot_like_cpp(), + Some(&GameObjectOwnedLoot::new(31, 0)), + "the compatibility summary remains deliberately stale" + ); + assert!( + gameobject.is_fully_looted_like_cpp(), + "lifecycle decisions must read the active object-owned authority" + ); + } + #[test] fn gameobject_is_fully_looted_checks_shared_and_personal_loot_like_cpp() { let mut go = GameObject::new(); diff --git a/crates/wow-loot/Cargo.toml b/crates/wow-loot/Cargo.toml index 25257157f..0d798d7c6 100644 --- a/crates/wow-loot/Cargo.toml +++ b/crates/wow-loot/Cargo.toml @@ -9,3 +9,4 @@ license.workspace = true wow-core = { workspace = true } wow-constants = { workspace = true } rand = { workspace = true } +tokio = { workspace = true } diff --git a/crates/wow-loot/src/authority.rs b/crates/wow-loot/src/authority.rs new file mode 100644 index 000000000..ae97756d9 --- /dev/null +++ b/crates/wow-loot/src/authority.rs @@ -0,0 +1,3425 @@ +// Copyright (c) 2026 alseif0x +// RustyCore — WoW WotLK 3.4.3 server in Rust +// Based on TrinityCore protocol research (https://github.com/TrinityCore/TrinityCore) +// Licensed under GPL v3 — https://www.gnu.org/licenses/gpl-3.0.html + +//! Map-object-owned runtime loot and cancellation-safe claims. +//! +//! TrinityCore owns one shared `Loot` plus an optional GUID-keyed personal-loot map on +//! `Creature` and `GameObject`. `GetLootForPlayer` uses shared loot only while the personal +//! map is empty (`Creature.cpp:1377-1386`, `GameObject.cpp:3898-3907`). The C++ world-session +//! scheduler serializes loot handlers globally. Rust sessions run concurrently, so this module +//! preserves the same single-owner result with short synchronous critical sections and async +//! waiters. No authority lock is held across an `.await`. + +use std::collections::HashMap; +use std::fmt; +use std::sync::atomic::{AtomicU8, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard}; + +use tokio::sync::watch; +use wow_core::ObjectGuid; + +use crate::LOOT_SLOT_TYPE_OWNER_LIKE_CPP; + +/// Server-side loot state for one loot object. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CreatureLoot { + pub loot_guid: ObjectGuid, + pub coins: u32, + pub unlooted_count: u8, + pub loot_type: u8, + pub dungeon_encounter_id: u32, + pub loot_method: u8, + pub loot_master: ObjectGuid, + pub round_robin_player: ObjectGuid, + pub player_ffa_items: Vec<(ObjectGuid, Vec)>, + pub players_looting: Vec, + pub allowed_looters: Vec, + pub items: Vec, + pub looted_by_player: bool, +} + +impl CreatureLoot { + #[must_use] + pub const fn is_looted_like_cpp(&self) -> bool { + self.coins == 0 && self.unlooted_count == 0 + } + + #[must_use] + pub fn item_like_cpp(&self, loot_list_id: u8) -> Option<&LootEntry> { + self.items + .iter() + .find(|entry| entry.loot_list_id == loot_list_id) + } + + #[must_use] + pub fn player_has_unlooted_ffa_item_like_cpp( + &self, + player: ObjectGuid, + loot_list_id: u8, + ) -> bool { + self.player_ffa_items + .iter() + .find(|(looter, _)| *looter == player) + .is_some_and(|(_, items)| { + items + .iter() + .any(|item| item.loot_list_id == loot_list_id && !item.is_looted) + }) + } + + #[must_use] + pub fn item_is_looted_for_player_like_cpp(&self, loot_list_id: u8, player: ObjectGuid) -> bool { + self.item_like_cpp(loot_list_id) + .is_none_or(|entry| entry.is_looted_for_player_like_cpp(player)) + } + + fn mark_item_looted_for_player_like_cpp( + &mut self, + loot_list_id: u8, + player: ObjectGuid, + ) -> bool { + let Some(index) = self + .items + .iter() + .position(|entry| entry.loot_list_id == loot_list_id) + else { + return false; + }; + + if self.items[index].is_looted_for_player_like_cpp(player) { + return false; + } + + let free_for_all = self.items[index].flags.freeforall; + self.items[index].mark_looted_for_player_like_cpp(player); + if free_for_all + && let Some((_, items)) = self + .player_ffa_items + .iter_mut() + .find(|(looter, _)| *looter == player) + && let Some(item) = items + .iter_mut() + .find(|item| item.loot_list_id == loot_list_id) + { + item.is_looted = true; + } + + self.unlooted_count = self.unlooted_count.saturating_sub(1); + true + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NotNormalLootItem { + pub loot_list_id: u8, + pub is_looted: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LootEntry { + pub loot_list_id: u8, + pub item_id: u32, + pub quantity: u32, + pub random_properties_id: i32, + pub random_properties_seed: i32, + pub item_context: u8, + pub flags: LootEntryFlags, + pub allowed_looters: Vec, + pub roll_winner: ObjectGuid, + pub ffa_looted_by: Vec, + pub taken: bool, +} + +impl LootEntry { + #[must_use] + pub const fn free_for_all_ui_type_like_cpp(&self) -> u8 { + LOOT_SLOT_TYPE_OWNER_LIKE_CPP + } + + #[must_use] + pub const fn is_over_threshold_like_cpp(&self) -> bool { + !self.flags.under_threshold && !self.flags.freeforall + } + + #[must_use] + pub fn visible_in_represented_free_for_all_view_like_cpp(&self, player: ObjectGuid) -> bool { + !self.is_looted_for_player_like_cpp(player) && self.has_allowed_looter_like_cpp(player) + } + + pub fn add_allowed_looter_like_cpp(&mut self, player: ObjectGuid) { + if !player.is_empty() && !self.allowed_looters.contains(&player) { + self.allowed_looters.push(player); + } + } + + #[must_use] + pub fn has_allowed_looter_like_cpp(&self, player: ObjectGuid) -> bool { + self.allowed_looters.contains(&player) + } + + #[must_use] + pub fn roll_winner_allows_like_cpp(&self, player: ObjectGuid) -> bool { + self.roll_winner.is_empty() || self.roll_winner == player + } + + #[must_use] + pub fn is_looted_for_player_like_cpp(&self, player: ObjectGuid) -> bool { + if self.flags.freeforall { + self.ffa_looted_by.contains(&player) + } else { + self.taken + } + } + + pub fn mark_looted_for_player_like_cpp(&mut self, player: ObjectGuid) { + if self.flags.freeforall { + if !player.is_empty() && !self.ffa_looted_by.contains(&player) { + self.ffa_looted_by.push(player); + } + } else { + self.taken = true; + } + } + + #[must_use] + pub fn fully_looted_like_cpp(&self) -> bool { + if self.flags.freeforall { + !self.allowed_looters.is_empty() + && self + .allowed_looters + .iter() + .all(|player| self.ffa_looted_by.contains(player)) + } else { + self.taken + } + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct LootEntryFlags { + pub follow_loot_rules: bool, + pub freeforall: bool, + pub blocked: bool, + pub counted: bool, + pub under_threshold: bool, + pub needs_quest: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum OwnedLootScope { + Shared, + Personal(ObjectGuid), +} + +/// Observable lifetime state of one object-owned loot authority. +/// +/// Keeping this classification behind one mutex acquisition matters during +/// mirror reconciliation: a retired authority from an older object lifetime +/// must not be treated like a newly constructed authority merely because both +/// currently expose no loot pools. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OwnedLootAuthorityLifecycle { + Pristine, + Active, + Retired, + /// Conflicting live mirrors were observed for one C++ object. This + /// attached tombstone is terminal until the object is destroyed. + Quarantined, + /// This allocation was displaced from its owning entity mirror. It may + /// still be held by an async task, but can never own loot again. + Detached, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OwnedLootAuthorityStamp { + pub lifecycle: OwnedLootAuthorityLifecycle, + pub object_generation: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OwnedLootSnapshot { + pub generation: u64, + pub scope: OwnedLootScope, + pub loot: CreatureLoot, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LootInstallOutcome { + Installed { generation: u64 }, + AlreadyInitialized { generation: u64 }, +} + +impl LootInstallOutcome { + #[must_use] + pub const fn generation(self) -> u64 { + match self { + Self::Installed { generation } | Self::AlreadyInitialized { generation } => generation, + } + } + + #[must_use] + pub const fn installed(self) -> bool { + matches!(self, Self::Installed { .. }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LootViewerOpenOutcome { + pub generation: u64, + pub scope: OwnedLootScope, + pub inserted: bool, + pub first_viewer: bool, +} + +/// Coherent close-time view of one selected `Loot` and its whole C++ owner. +/// +/// `Loot::isLooted()` controls the releasing player's branch, while +/// `Creature::IsFullyLooted()` / `GameObject::IsFullyLooted()` inspect every +/// shared and personal pool before a global lifecycle transition. Keeping both +/// observations under the authority mutex prevents mixing different pool +/// states when concurrent sessions release personal loot. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LootViewerCloseOutcome { + pub snapshot: OwnedLootSnapshot, + pub removed: bool, + pub whole_object_fully_looted: bool, + /// Whole-owner generation observed with the selected pool. Lifecycle + /// callers must revalidate this together with `lifecycle_revision` before + /// mutating the map object. + pub object_generation: u64, + /// Advances whenever an install, replacement, or personal-pool upsert can + /// make a previously complete owner incomplete again. + pub lifecycle_revision: u64, + /// C++ `Creature::AllLootRemovedFromCorpse` scans the shared pool and every + /// personal pool rather than inferring skinning from the releasing pool. + pub whole_object_fully_skinned: bool, +} + +/// Whole-owner completion observed without requiring an open client view. +/// Detached durable workers use this after COMMIT when the player already +/// released the window while the claim was still `Persisting`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LootFullyLootedLifecycleObservation { + pub object_generation: u64, + pub lifecycle_revision: u64, + pub whole_object_fully_skinned: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LootRoundRobinReleaseOutcome { + pub snapshot: OwnedLootSnapshot, + pub cleared: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct LootItemClaimKey { + pub scope: OwnedLootScope, + pub loot_list_id: u8, + /// `Some(player)` for FFA items; `None` for globally unique items. + pub claimant: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LootClaimPayload { + Item(LootEntry), + Money(u32), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LootClaimError { + Retired, + StaleGeneration, + NoLootForPlayer, + /// The selected session could not enqueue its opening response. Rust + /// must not retain a C++ `PlayersLooting`/`_wasOpened` transition for a + /// window the client never had a chance to observe. + ResponseEnqueueFailed, + ItemNotFound, + ItemAlreadyLooted, + PlayerNotAllowed, + ItemBlocked, + WrongRollWinner, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LootClaimCommitError { + RolledBack, + StaleGeneration, + StateChanged, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +enum ReservationKey { + Item(LootItemClaimKey), + Money(OwnedLootScope), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct AuthorityState { + /// Whole-object lifecycle counter retained for first-install/retire + /// arbitration. Claims use the selected pool's epoch below. + generation: u64, + retired: bool, + detached: bool, + quarantined: bool, + shared: Option, + personal: HashMap, + /// Unique identity of each concrete C++ `Loot` pool. Replacing one + /// personal pool advances only that scope and leaves peer claims valid. + scope_epochs: HashMap, + next_scope_epoch: u64, + lifecycle_revision: u64, + reservations: HashMap, + /// Claims that crossed the durable-persistence boundary. Lifecycle close + /// may reject every new claim, but must retain these reservations until + /// their SQL result commits or rolls back. + persisting: HashMap, + next_token: u64, +} + +impl Default for AuthorityState { + fn default() -> Self { + Self { + generation: 0, + retired: true, + detached: false, + quarantined: false, + shared: None, + personal: HashMap::new(), + scope_epochs: HashMap::new(), + next_scope_epoch: 1, + lifecycle_revision: 0, + reservations: HashMap::new(), + persisting: HashMap::new(), + next_token: 1, + } + } +} + +fn retain_only_persisting_reservations(state: &mut AuthorityState) { + let persisting = &state.persisting; + state + .reservations + .retain(|key, token| persisting.get(key) == Some(token)); +} + +fn finalize_closed_state_if_drained(state: &mut AuthorityState) { + if !state.retired || !state.persisting.is_empty() { + return; + } + state.shared = None; + state.personal.clear(); + state.scope_epochs.clear(); + state.reservations.clear(); +} + +fn scope_has_persisting_claim(state: &AuthorityState, scope: OwnedLootScope) -> bool { + state + .persisting + .keys() + .any(|key| reservation_scope(*key) == scope) +} + +struct OwnedLootAuthorityInner { + state: Mutex, + changed: watch::Sender, +} + +/// Shared, object-owned authority for one creature or game-object loot lifetime. +#[derive(Clone)] +pub struct OwnedLootAuthority { + inner: Arc, +} + +impl Default for OwnedLootAuthority { + fn default() -> Self { + Self::new() + } +} + +impl fmt::Debug for OwnedLootAuthority { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("OwnedLootAuthority") + .field("state", &self.state_snapshot()) + .finish() + } +} + +impl PartialEq for OwnedLootAuthority { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.inner, &other.inner) || self.state_snapshot() == other.state_snapshot() + } +} + +impl Eq for OwnedLootAuthority {} + +impl OwnedLootAuthority { + #[must_use] + pub fn new() -> Self { + let (changed, _) = watch::channel(0); + Self { + inner: Arc::new(OwnedLootAuthorityInner { + state: Mutex::new(AuthorityState::default()), + changed, + }), + } + } + + /// Whether both handles address the same object-owned loot state. + /// + /// State equality is insufficient here: two independently allocated + /// authorities can contain identical loot while still allowing separate + /// claims. Runtime mirror reconciliation must compare the backing `Arc`. + #[must_use] + pub fn shares_storage_like_cpp(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.inner, &other.inner) + } + + /// A newly constructed authority that has never owned a loot lifetime. + #[must_use] + pub fn is_pristine_like_cpp(&self) -> bool { + self.lifecycle_like_cpp() == OwnedLootAuthorityLifecycle::Pristine + } + + /// Classifies this backing allocation without racing two separate state + /// reads. `Pristine` means it has never owned a C++ `Loot` lifetime; + /// `Retired` means an earlier lifetime was explicitly invalidated. + #[must_use] + pub fn lifecycle_like_cpp(&self) -> OwnedLootAuthorityLifecycle { + let state = self.lock_state(); + if state.detached { + OwnedLootAuthorityLifecycle::Detached + } else if state.quarantined { + OwnedLootAuthorityLifecycle::Quarantined + } else if !state.retired { + OwnedLootAuthorityLifecycle::Active + } else if state.generation == 0 { + OwnedLootAuthorityLifecycle::Pristine + } else { + OwnedLootAuthorityLifecycle::Retired + } + } + + #[must_use] + pub fn stamp_like_cpp(&self) -> OwnedLootAuthorityStamp { + let state = self.lock_state(); + let lifecycle = if state.detached { + OwnedLootAuthorityLifecycle::Detached + } else if state.quarantined { + OwnedLootAuthorityLifecycle::Quarantined + } else if !state.retired { + OwnedLootAuthorityLifecycle::Active + } else if state.generation == 0 { + OwnedLootAuthorityLifecycle::Pristine + } else { + OwnedLootAuthorityLifecycle::Retired + }; + OwnedLootAuthorityStamp { + lifecycle, + object_generation: state.generation, + } + } + + /// Creates an attached fail-closed tombstone for mirror conflicts. Unlike + /// a pristine authority, this cannot be initialized through the legacy + /// first-generation bridge. + #[must_use] + pub fn new_retired_tombstone_like_cpp() -> Self { + let authority = Self::new(); + { + let mut state = authority.lock_state(); + state.generation = 1; + state.quarantined = true; + } + authority + } + + /// Permanently invalidates a displaced backing allocation. Retiring alone + /// is intentionally reversible for respawn/restock; detaching is not. + pub fn detach_like_cpp(&self) -> u64 { + let generation = { + let mut state = self.lock_state(); + if state.detached { + return state.generation; + } + state.generation = state.generation.wrapping_add(1).max(1); + state.retired = true; + state.detached = true; + state.quarantined = false; + retain_only_persisting_reservations(&mut state); + finalize_closed_state_if_drained(&mut state); + state.generation + }; + self.notify_changed(); + generation + } + + /// Conditional detach used by entity-local compare/exchange. A stale + /// observer cannot detach a replacement generation on the same `Arc`. + pub fn detach_if_stamp_like_cpp(&self, expected: OwnedLootAuthorityStamp) -> bool { + let detached = { + let mut state = self.lock_state(); + let current_lifecycle = if state.detached { + OwnedLootAuthorityLifecycle::Detached + } else if state.quarantined { + OwnedLootAuthorityLifecycle::Quarantined + } else if !state.retired { + OwnedLootAuthorityLifecycle::Active + } else if state.generation == 0 { + OwnedLootAuthorityLifecycle::Pristine + } else { + OwnedLootAuthorityLifecycle::Retired + }; + if current_lifecycle != expected.lifecycle + || state.generation != expected.object_generation + { + return false; + } + if state.detached { + return true; + } + state.generation = state.generation.wrapping_add(1).max(1); + state.retired = true; + state.detached = true; + state.quarantined = false; + retain_only_persisting_reservations(&mut state); + finalize_closed_state_if_drained(&mut state); + true + }; + if detached { + self.notify_changed(); + } + detached + } + + /// Reads lifecycle decisions directly from the authoritative pools. + #[must_use] + pub fn is_fully_looted_like_cpp(&self) -> bool { + let state = self.lock_state(); + state.retired || active_loot_pools_fully_looted_like_cpp(&state) + } + + /// Waits until every claim that crossed its durable boundary has resolved. + /// The authority mutex is never held across the await. Disconnect/logout + /// uses this before C++ `DoLootReleaseAll`, so a cancelled packet waiter + /// cannot leave a post-commit owner without its release lifecycle. + pub async fn wait_for_persisting_claims_like_cpp(&self) { + let mut changed = self.inner.changed.subscribe(); + loop { + if self.lock_state().persisting.is_empty() { + return; + } + if changed.changed().await.is_err() { + return; + } + } + } + + /// Runs one map-object lifecycle mutation only while the exact whole-owner + /// generation and pool topology observed by `close_viewer...` are still + /// current and fully looted. Callers must acquire the owning map lock first; + /// the callback must not re-enter this authority. + pub fn with_fully_looted_lifecycle_observation_like_cpp( + &self, + expected_object_generation: u64, + expected_lifecycle_revision: u64, + apply_before_unlock: impl FnOnce() -> R, + ) -> Option { + let state = self.lock_state(); + if state.retired + || state.generation != expected_object_generation + || state.lifecycle_revision != expected_lifecycle_revision + || !active_loot_pools_fully_looted_like_cpp(&state) + { + return None; + } + Some(apply_before_unlock()) + } + + /// Captures the exact fully-looted generation for lifecycle work that is + /// triggered after a detached durable claim commits. Unlike + /// `close_viewer_if_generation_like_cpp`, this does not require or mutate + /// `players_looting`. + #[must_use] + pub fn fully_looted_lifecycle_observation_like_cpp( + &self, + ) -> Option { + let state = self.lock_state(); + if state.retired || !active_loot_pools_fully_looted_like_cpp(&state) { + return None; + } + Some(LootFullyLootedLifecycleObservation { + object_generation: state.generation, + lifecycle_revision: state.lifecycle_revision, + whole_object_fully_skinned: active_loot_pools_fully_skinned_like_cpp(&state), + }) + } + + /// Installs the first loot generation, or returns the active generation unchanged. + pub fn initialize_like_cpp( + &self, + shared: Option, + personal: HashMap, + ) -> LootInstallOutcome { + self.initialize_pristine_like_cpp(shared, personal) + } + + /// Bridges only a newly constructed object whose authority has never + /// owned loot. The pristine check and installation share one lock, so a + /// concurrent retirement cannot be followed by resurrection of a stale + /// session cache. + pub fn initialize_pristine_like_cpp( + &self, + shared: Option, + personal: HashMap, + ) -> LootInstallOutcome { + let outcome = { + let mut state = self.lock_state(); + if state.detached + || state.quarantined + || !state.persisting.is_empty() + || !state.retired + || state.generation != 0 + { + LootInstallOutcome::AlreadyInitialized { + generation: any_scope_epoch(&state).unwrap_or(0), + } + } else { + state.generation = 1; + state.retired = false; + state.shared = shared; + state.personal = personal; + state.reservations.clear(); + state.persisting.clear(); + let epoch = next_scope_epoch(&mut state); + install_all_scope_epochs(&mut state, epoch); + bump_lifecycle_revision(&mut state); + LootInstallOutcome::Installed { generation: epoch } + } + }; + if outcome.installed() { + self.notify_changed(); + } + outcome + } + + /// Starts an explicitly generated new object lifetime only if the caller + /// still observes the same retired lifetime it inspected before any async + /// loot-template/database work. + pub fn replace_retired_generation_like_cpp( + &self, + expected_object_generation: u64, + shared: Option, + personal: HashMap, + ) -> Option { + let epoch = { + let mut state = self.lock_state(); + if state.detached + || state.quarantined + || !state.persisting.is_empty() + || !state.retired + || state.generation != expected_object_generation + { + return None; + } + state.generation = state.generation.wrapping_add(1).max(1); + state.retired = false; + state.shared = shared; + state.personal = personal; + state.reservations.clear(); + state.persisting.clear(); + let epoch = next_scope_epoch(&mut state); + install_all_scope_epochs(&mut state, epoch); + bump_lifecycle_revision(&mut state); + epoch + }; + self.notify_changed(); + Some(epoch) + } + + /// Replaces all pools and starts a new generation. + pub fn replace_like_cpp( + &self, + shared: Option, + personal: HashMap, + ) -> u64 { + let epoch = { + let mut state = self.lock_state(); + if state.detached || state.quarantined || !state.persisting.is_empty() { + return 0; + } + state.generation = state.generation.wrapping_add(1).max(1); + state.retired = false; + state.shared = shared; + state.personal = personal; + state.reservations.clear(); + state.persisting.clear(); + let epoch = next_scope_epoch(&mut state); + install_all_scope_epochs(&mut state, epoch); + bump_lifecycle_revision(&mut state); + epoch + }; + self.notify_changed(); + epoch + } + + /// Installs shared loot without replacing already-installed personal pools. + pub fn initialize_shared_like_cpp(&self, loot: CreatureLoot) -> LootInstallOutcome { + let (outcome, changed) = { + let mut state = self.lock_state(); + if state.detached + || state.quarantined + || !state.persisting.is_empty() + || (state.retired && state.generation != 0) + { + ( + LootInstallOutcome::AlreadyInitialized { generation: 0 }, + false, + ) + } else if !state.retired && state.shared.is_some() { + ( + LootInstallOutcome::AlreadyInitialized { + generation: scope_epoch(&state, OwnedLootScope::Shared).unwrap_or(0), + }, + false, + ) + } else { + if state.retired { + state.generation = state.generation.wrapping_add(1).max(1); + state.retired = false; + state.personal.clear(); + state.scope_epochs.clear(); + state.reservations.clear(); + state.persisting.clear(); + } + let epoch = next_scope_epoch(&mut state); + state.shared = Some(loot); + state.scope_epochs.insert(OwnedLootScope::Shared, epoch); + bump_lifecycle_revision(&mut state); + (LootInstallOutcome::Installed { generation: epoch }, true) + } + }; + if changed { + self.notify_changed(); + } + outcome + } + + /// Adds a personal pool to the active generation. + /// + /// With `replace == false`, an existing pool is first-writer-wins. Replacing an existing + /// pool invalidates only that player's leases; independent personal pools keep their claims. + pub fn upsert_personal_like_cpp( + &self, + player: ObjectGuid, + loot: CreatureLoot, + replace: bool, + ) -> LootInstallOutcome { + let (outcome, changed) = { + let mut state = self.lock_state(); + if state.detached || state.quarantined || (state.retired && state.generation != 0) { + ( + LootInstallOutcome::AlreadyInitialized { generation: 0 }, + false, + ) + } else if scope_has_persisting_claim(&state, OwnedLootScope::Personal(player)) + || (state.personal.is_empty() + && state.shared.is_some() + && !state.persisting.is_empty()) + { + ( + LootInstallOutcome::AlreadyInitialized { + generation: scope_epoch(&state, OwnedLootScope::Personal(player)) + .or_else(|| scope_epoch(&state, OwnedLootScope::Shared)) + .unwrap_or(0), + }, + false, + ) + } else if !state.retired && state.personal.contains_key(&player) && !replace { + ( + LootInstallOutcome::AlreadyInitialized { + generation: scope_epoch(&state, OwnedLootScope::Personal(player)) + .unwrap_or(0), + }, + false, + ) + } else { + if state.retired { + state.generation = state.generation.wrapping_add(1).max(1); + state.retired = false; + state.shared = None; + state.scope_epochs.clear(); + state.reservations.clear(); + state.persisting.clear(); + } else if state.personal.is_empty() && state.shared.is_some() { + // Switching the C++ owner from shared selection to a + // personal-loot map changes the scope for every player. + state.generation = state.generation.wrapping_add(1).max(1); + state.scope_epochs.remove(&OwnedLootScope::Shared); + state.reservations.clear(); + } else if state.personal.contains_key(&player) { + // Replacing P1's independent Loot must not invalidate an + // in-flight P2 claim. Token removal makes only P1's old + // leases stale without advancing the object generation. + state.reservations.retain(|key, _| match key { + ReservationKey::Item(item) => { + item.scope != OwnedLootScope::Personal(player) + } + ReservationKey::Money(scope) => *scope != OwnedLootScope::Personal(player), + }); + } + let epoch = next_scope_epoch(&mut state); + state.personal.insert(player, loot); + state + .scope_epochs + .insert(OwnedLootScope::Personal(player), epoch); + bump_lifecycle_revision(&mut state); + (LootInstallOutcome::Installed { generation: epoch }, true) + } + }; + if changed { + self.notify_changed(); + } + outcome + } + + /// Invalidates all leases and removes every pool. Retiring a pristine + /// allocation advances it to a real tombstone so an async first-generation + /// installer captured before the clear cannot install afterwards. + pub fn retire_like_cpp(&self) -> u64 { + let (generation, changed) = { + let mut state = self.lock_state(); + if state.retired && state.generation != 0 { + (state.generation, false) + } else { + state.generation = state.generation.wrapping_add(1).max(1); + state.retired = true; + retain_only_persisting_reservations(&mut state); + finalize_closed_state_if_drained(&mut state); + (state.generation, true) + } + }; + if changed { + self.notify_changed(); + } + generation + } + + #[must_use] + pub fn generation_like_cpp(&self) -> u64 { + self.lock_state().generation + } + + #[must_use] + pub fn scope_generation_like_cpp(&self, scope: OwnedLootScope) -> Option { + let state = self.lock_state(); + (!state.retired) + .then(|| scope_epoch(&state, scope)) + .flatten() + } + + #[must_use] + pub fn is_retired_like_cpp(&self) -> bool { + self.lock_state().retired + } + + #[must_use] + pub fn snapshot_for_player_like_cpp(&self, player: ObjectGuid) -> Option { + let state = self.lock_state(); + let scope = selected_scope_like_cpp(&state, player)?; + snapshot_for_scope(&state, scope) + } + + #[must_use] + pub fn shared_snapshot_like_cpp(&self) -> Option { + let state = self.lock_state(); + snapshot_for_scope(&state, OwnedLootScope::Shared) + } + + #[must_use] + pub fn personal_snapshot_like_cpp(&self, player: ObjectGuid) -> Option { + let state = self.lock_state(); + snapshot_for_scope(&state, OwnedLootScope::Personal(player)) + } + + #[must_use] + pub fn personal_snapshots_like_cpp(&self) -> HashMap { + let state = self.lock_state(); + if state.retired { + return HashMap::new(); + } + state + .personal + .iter() + .filter_map(|(player, loot)| { + let scope = OwnedLootScope::Personal(*player); + Some(( + *player, + OwnedLootSnapshot { + generation: scope_epoch(&state, scope)?, + scope, + loot: loot.clone(), + }, + )) + }) + .collect() + } + + pub fn add_viewer_like_cpp( + &self, + player: ObjectGuid, + ) -> Result { + self.open_view_with_snapshot_like_cpp(player, |_, _| ()) + .map(|(outcome, ())| outcome) + } + + /// Registers a viewer, snapshots the exact pool, and executes one + /// synchronous response-enqueue callback before claims may commit again. + /// + /// The callback must not re-enter this authority. This explicit critical + /// section replaces C++'s globally serialized world-session scheduler: a + /// client cannot receive a stale `LootResponse` while being omitted from + /// the corresponding removal fanout. + pub fn open_view_with_snapshot_like_cpp( + &self, + player: ObjectGuid, + observe_before_unlock: impl FnOnce(&OwnedLootSnapshot, &LootViewerOpenOutcome) -> R, + ) -> Result<(LootViewerOpenOutcome, R), LootClaimError> { + self.try_open_view_with_snapshot_like_cpp(player, |snapshot, outcome| { + Some(observe_before_unlock(snapshot, outcome)) + }) + } + + /// Transactionally opens one client view and attempts its response + /// publication while the authority remains locked. + /// + /// C++ writes `LootResponse` before `Loot::OnLootOpened` and processes the + /// thread-unsafe loot handlers serially (`Player.cpp:8747-8773`, + /// `Opcodes.cpp:587-590`). Rust sessions are concurrent, so the enqueue + /// must share this critical section with `players_looting`: a successful + /// enqueue is ordered before any claim-removal fanout. If the nonblocking + /// callback rejects publication (for example, a full/disconnected socket + /// queue), both tentative open mutations are rolled back before unlock. + /// + /// The callback must not re-enter this authority or perform blocking work. + pub fn try_open_view_with_snapshot_like_cpp( + &self, + player: ObjectGuid, + try_observe_before_unlock: impl FnOnce(&OwnedLootSnapshot, &LootViewerOpenOutcome) -> Option, + ) -> Result<(LootViewerOpenOutcome, R), LootClaimError> { + let (outcome, observed) = { + let mut state = self.lock_state(); + if state.retired { + return Err(LootClaimError::Retired); + } + let scope = + selected_scope_like_cpp(&state, player).ok_or(LootClaimError::NoLootForPlayer)?; + let generation = scope_epoch(&state, scope).ok_or(LootClaimError::StaleGeneration)?; + let loot = + loot_for_scope_mut(&mut state, scope).ok_or(LootClaimError::NoLootForPlayer)?; + let first_viewer = !loot.looted_by_player; + if first_viewer { + loot.looted_by_player = true; + } + let inserted = if loot.players_looting.contains(&player) { + false + } else { + loot.players_looting.push(player); + true + }; + let outcome = LootViewerOpenOutcome { + generation, + scope, + inserted, + first_viewer, + }; + let snapshot = OwnedLootSnapshot { + generation, + scope, + loot: loot.clone(), + }; + let Some(observed) = try_observe_before_unlock(&snapshot, &outcome) else { + // No other authority mutation can interleave while the + // callback runs. Restore exactly the fields tentatively + // changed by this open and fail closed. + let loot = loot_for_scope_mut(&mut state, scope) + .expect("selected loot scope remains present under its authority lock"); + if inserted { + loot.players_looting.retain(|viewer| *viewer != player); + } + if first_viewer { + loot.looted_by_player = false; + } + return Err(LootClaimError::ResponseEnqueueFailed); + }; + (outcome, observed) + }; + if outcome.inserted || outcome.first_viewer { + self.notify_changed(); + } + Ok((outcome, observed)) + } + + pub fn remove_viewer_like_cpp(&self, player: ObjectGuid) -> bool { + let removed = { + let mut state = self.lock_state(); + let Some(scope) = selected_scope_like_cpp(&state, player) else { + return false; + }; + let Some(loot) = loot_for_scope_mut(&mut state, scope) else { + return false; + }; + let old_len = loot.players_looting.len(); + loot.players_looting.retain(|viewer| *viewer != player); + old_len != loot.players_looting.len() + }; + if removed { + self.notify_changed(); + } + removed + } + + /// Removes a viewer only from the exact object lifetime that the session + /// opened. `None` means the authority was retired or replaced before the + /// release reached it; callers must then avoid touching replacement + /// lifecycle state. + pub fn remove_viewer_if_generation_like_cpp( + &self, + expected_generation: u64, + player: ObjectGuid, + ) -> Option { + let removed = { + let mut state = self.lock_state(); + if state.retired { + return None; + } + let scope = selected_scope_like_cpp(&state, player)?; + if scope_epoch(&state, scope) != Some(expected_generation) { + return None; + } + let loot = loot_for_scope_mut(&mut state, scope)?; + let old_len = loot.players_looting.len(); + loot.players_looting.retain(|viewer| *viewer != player); + old_len != loot.players_looting.len() + }; + if removed { + self.notify_changed(); + } + Some(removed) + } + + /// Closes one exact viewer generation and observes both the selected pool + /// and whole-object completion in the same critical section. + /// + /// C++ `WorldSession::DoLootRelease` first tests the selected + /// `Loot::isLooted()`, then separately gates global creature/gameobject + /// lifecycle changes on `IsFullyLooted()`, which walks every personal pool. + pub fn close_viewer_if_generation_like_cpp( + &self, + expected_generation: u64, + player: ObjectGuid, + ) -> Option { + let outcome = { + let mut state = self.lock_state(); + if state.retired { + return None; + } + let scope = selected_scope_like_cpp(&state, player)?; + if scope_epoch(&state, scope) != Some(expected_generation) { + return None; + } + let (removed, snapshot) = { + let loot = loot_for_scope_mut(&mut state, scope)?; + let old_len = loot.players_looting.len(); + loot.players_looting.retain(|viewer| *viewer != player); + ( + old_len != loot.players_looting.len(), + OwnedLootSnapshot { + generation: expected_generation, + scope, + loot: loot.clone(), + }, + ) + }; + LootViewerCloseOutcome { + snapshot, + removed, + whole_object_fully_looted: active_loot_pools_fully_looted_like_cpp(&state), + object_generation: state.generation, + lifecycle_revision: state.lifecycle_revision, + whole_object_fully_skinned: active_loot_pools_fully_skinned_like_cpp(&state), + } + }; + if outcome.removed { + self.notify_changed(); + } + Some(outcome) + } + + /// Clears C++ `Loot::roundRobinPlayer` on the exact selected pool that the + /// session opened. A stale release cannot mutate a replacement pool. + pub fn clear_round_robin_if_generation_like_cpp( + &self, + expected_generation: u64, + player: ObjectGuid, + ) -> Option { + let outcome = { + let mut state = self.lock_state(); + if state.retired { + return None; + } + let scope = selected_scope_like_cpp(&state, player)?; + if scope_epoch(&state, scope) != Some(expected_generation) { + return None; + } + let loot = loot_for_scope_mut(&mut state, scope)?; + let cleared = loot.round_robin_player == player; + if cleared { + loot.round_robin_player = ObjectGuid::EMPTY; + } + LootRoundRobinReleaseOutcome { + snapshot: OwnedLootSnapshot { + generation: expected_generation, + scope, + loot: loot.clone(), + }, + cleared, + } + }; + if outcome.cleared { + self.notify_changed(); + } + Some(outcome) + } + + #[must_use] + pub fn viewers_for_player_like_cpp(&self, player: ObjectGuid) -> Vec { + self.snapshot_for_player_like_cpp(player) + .map_or_else(Vec::new, |snapshot| snapshot.loot.players_looting) + } + + /// Publishes the final group-roll state on the object-owned item. + /// + /// C++ mutates the same `LootItem` that later reaches `StoreLootItem`: a + /// one-candidate roll becomes under-threshold/unblocked, while a completed + /// roll becomes unblocked and records its winner. Session-local packet + /// views must therefore not be the owner of these fields. + pub fn finish_item_roll_like_cpp( + &self, + player: ObjectGuid, + expected_generation: u64, + loot_list_id: u8, + under_threshold: bool, + winner: Option, + ) -> Result { + let changed = { + let mut state = self.lock_state(); + if state.retired { + return Err(LootClaimError::Retired); + } + let scope = + selected_scope_like_cpp(&state, player).ok_or(LootClaimError::NoLootForPlayer)?; + if scope_epoch(&state, scope) != Some(expected_generation) { + return Err(LootClaimError::StaleGeneration); + } + let loot = + loot_for_scope_mut(&mut state, scope).ok_or(LootClaimError::NoLootForPlayer)?; + let entry = loot + .items + .iter_mut() + .find(|entry| entry.loot_list_id == loot_list_id) + .ok_or(LootClaimError::ItemNotFound)?; + let winner = winner.unwrap_or(ObjectGuid::EMPTY); + let changed = entry.flags.blocked + || entry.flags.under_threshold != under_threshold + || entry.roll_winner != winner; + entry.flags.blocked = false; + entry.flags.under_threshold = under_threshold; + entry.roll_winner = winner; + changed + }; + if changed { + self.notify_changed(); + } + Ok(changed) + } + + /// Publishes a winning roll and acquires its item claim in the same + /// authority critical section. C++ gets this serialization from the world + /// update thread; Rust session tasks need an explicit atomic boundary so a + /// respawn cannot land between `LootRoll::Finish` and winner storage. + pub fn finish_item_roll_and_reserve_award_like_cpp( + &self, + scope_player: ObjectGuid, + expected_generation: u64, + loot_list_id: u8, + winner: ObjectGuid, + ) -> Result { + let acquired = { + let mut state = self.lock_state(); + if state.retired { + return Err(LootClaimError::Retired); + } + let scope = selected_scope_like_cpp(&state, scope_player) + .ok_or(LootClaimError::NoLootForPlayer)?; + if selected_scope_like_cpp(&state, winner) != Some(scope) { + return Err(LootClaimError::NoLootForPlayer); + } + if scope_epoch(&state, scope) != Some(expected_generation) { + return Err(LootClaimError::StaleGeneration); + } + + let previous = { + let loot = + loot_for_scope_mut(&mut state, scope).ok_or(LootClaimError::NoLootForPlayer)?; + let entry = loot + .items + .iter_mut() + .find(|entry| entry.loot_list_id == loot_list_id) + .ok_or(LootClaimError::ItemNotFound)?; + let previous = ( + entry.flags.blocked, + entry.flags.under_threshold, + entry.roll_winner, + ); + entry.flags.blocked = false; + entry.flags.under_threshold = false; + entry.roll_winner = winner; + previous + }; + + match reserve_item_once( + &mut state, + winner, + loot_list_id, + LootItemClaimMode::Award, + Some(expected_generation), + ) { + ReserveAttempt::Acquired { + generation, + token, + key, + payload, + } => Ok((generation, token, key, payload)), + ReserveAttempt::Wait | ReserveAttempt::Rejected(_) => { + if let Some(entry) = loot_for_scope_mut(&mut state, scope).and_then(|loot| { + loot.items + .iter_mut() + .find(|entry| entry.loot_list_id == loot_list_id) + }) { + entry.flags.blocked = previous.0; + entry.flags.under_threshold = previous.1; + entry.roll_winner = previous.2; + } + Err(LootClaimError::ItemAlreadyLooted) + } + } + }?; + + self.notify_changed(); + Ok(LootClaimLease::new( + self.clone(), + acquired.0, + acquired.1, + acquired.2, + winner, + acquired.3, + )) + } + + /// Waits until the selected slot is unreserved, then reserves it atomically. + pub async fn reserve_item_like_cpp( + &self, + player: ObjectGuid, + loot_list_id: u8, + ) -> Result { + self.reserve_item_with_mode_like_cpp(player, loot_list_id, LootItemClaimMode::Direct, None) + .await + } + + pub async fn reserve_item_for_generation_like_cpp( + &self, + player: ObjectGuid, + loot_list_id: u8, + expected_generation: u64, + ) -> Result { + self.reserve_item_with_mode_like_cpp( + player, + loot_list_id, + LootItemClaimMode::Direct, + Some(expected_generation), + ) + .await + } + + /// Uses the same atomic claim boundary for master-loot and completed-roll awards. + /// Award paths may consume a group-roll-blocked slot, but still enforce the selected winner. + pub async fn reserve_item_for_award_like_cpp( + &self, + player: ObjectGuid, + loot_list_id: u8, + ) -> Result { + self.reserve_item_with_mode_like_cpp(player, loot_list_id, LootItemClaimMode::Award, None) + .await + } + + pub async fn reserve_item_for_award_generation_like_cpp( + &self, + player: ObjectGuid, + loot_list_id: u8, + expected_generation: u64, + ) -> Result { + self.reserve_item_with_mode_like_cpp( + player, + loot_list_id, + LootItemClaimMode::Award, + Some(expected_generation), + ) + .await + } + + async fn reserve_item_with_mode_like_cpp( + &self, + player: ObjectGuid, + loot_list_id: u8, + mode: LootItemClaimMode, + expected_generation: Option, + ) -> Result { + let mut changed = self.inner.changed.subscribe(); + loop { + let attempt = { + let mut state = self.lock_state(); + reserve_item_once(&mut state, player, loot_list_id, mode, expected_generation) + }; + match attempt { + ReserveAttempt::Acquired { + generation, + token, + key, + payload, + } => { + return Ok(LootClaimLease::new( + self.clone(), + generation, + token, + key, + player, + payload, + )); + } + ReserveAttempt::Wait => { + // The sender is owned by the authority, so closure only occurs when the + // authority itself is gone (which this method's `&self` prevents). + let _ = changed.changed().await; + } + ReserveAttempt::Rejected(error) => return Err(error), + } + } + } + + /// Waits until the selected money pool is unreserved, then reserves it atomically. + pub async fn reserve_money_like_cpp( + &self, + player: ObjectGuid, + ) -> Result { + self.reserve_money_for_optional_generation_like_cpp(player, None) + .await + } + + pub async fn reserve_money_for_generation_like_cpp( + &self, + player: ObjectGuid, + expected_generation: u64, + ) -> Result { + self.reserve_money_for_optional_generation_like_cpp(player, Some(expected_generation)) + .await + } + + async fn reserve_money_for_optional_generation_like_cpp( + &self, + player: ObjectGuid, + expected_generation: Option, + ) -> Result { + let mut changed = self.inner.changed.subscribe(); + loop { + let attempt = { + let mut state = self.lock_state(); + reserve_money_once(&mut state, player, expected_generation) + }; + match attempt { + ReserveAttempt::Acquired { + generation, + token, + key, + payload, + } => { + return Ok(LootClaimLease::new( + self.clone(), + generation, + token, + key, + player, + payload, + )); + } + ReserveAttempt::Wait => { + let _ = changed.changed().await; + } + ReserveAttempt::Rejected(error) => return Err(error), + } + } + } + + fn lock_state(&self) -> MutexGuard<'_, AuthorityState> { + self.inner + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn state_snapshot(&self) -> AuthorityState { + self.lock_state().clone() + } + + fn notify_changed(&self) { + self.inner + .changed + .send_modify(|version| *version = version.wrapping_add(1)); + } + + fn begin_claim_persistence( + &self, + lease: &LootClaimLeaseInner, + ) -> Result<(), LootClaimCommitError> { + let mut state = self.lock_state(); + // The status check belongs under the same authority lock as the + // reservation transition. Otherwise a cloned lease can commit after + // an optimistic check and before this lock is acquired, letting a + // stale persistence begin overwrite a terminal status (ABA). + match lease.status.load(Ordering::Acquire) { + LEASE_COMMITTED => return Err(LootClaimCommitError::StateChanged), + LEASE_ROLLED_BACK => return Err(LootClaimCommitError::RolledBack), + LEASE_ACTIVE => {} + _ => return Err(LootClaimCommitError::StateChanged), + } + if state.persisting.get(&lease.key) == Some(&lease.token) { + return Err(LootClaimCommitError::StateChanged); + } + if state.retired + || scope_epoch(&state, reservation_scope(lease.key)) != Some(lease.generation) + { + lease.status.store(LEASE_ROLLED_BACK, Ordering::Release); + return Err(LootClaimCommitError::StaleGeneration); + } + if state.reservations.get(&lease.key) != Some(&lease.token) { + lease.status.store(LEASE_ROLLED_BACK, Ordering::Release); + return Err(LootClaimCommitError::StateChanged); + } + state.persisting.insert(lease.key, lease.token); + Ok(()) + } + + fn abort_claim_persistence(&self, lease: &LootClaimLeaseInner) -> bool { + let aborted = { + let mut state = self.lock_state(); + if lease.status.load(Ordering::Acquire) != LEASE_ACTIVE + || state.persisting.get(&lease.key) != Some(&lease.token) + { + return false; + } + state.persisting.remove(&lease.key); + state.reservations.remove(&lease.key); + lease.status.store(LEASE_ROLLED_BACK, Ordering::Release); + finalize_closed_state_if_drained(&mut state); + true + }; + if aborted { + self.notify_changed(); + } + aborted + } + + fn quarantine_claim_persistence_commit_unknown(&self, lease: &LootClaimLeaseInner) -> bool { + let quarantined = { + let mut state = self.lock_state(); + if lease.status.load(Ordering::Acquire) != LEASE_ACTIVE + || state.persisting.get(&lease.key) != Some(&lease.token) + { + return false; + } + + // A transport error after COMMIT was submitted cannot prove + // whether SQL applied the durable side effect. Remove the local + // reservation so persistence waiters drain, but never make it + // claimable again: the whole authority becomes an attached, + // retired quarantine until DB reconciliation decides the result. + state.persisting.remove(&lease.key); + state.reservations.remove(&lease.key); + lease.status.store(LEASE_QUARANTINED, Ordering::Release); + state.retired = true; + state.quarantined = true; + finalize_closed_state_if_drained(&mut state); + true + }; + if quarantined { + self.notify_changed(); + } + quarantined + } + + fn commit_claim( + &self, + lease: &LootClaimLeaseInner, + persistence_owner: bool, + ) -> Result { + self.commit_claim_with_snapshot(lease, persistence_owner) + .map(|outcome| outcome.first_commit) + } + + fn commit_claim_with_snapshot( + &self, + lease: &LootClaimLeaseInner, + persistence_owner: bool, + ) -> Result { + if lease.status.load(Ordering::Acquire) == LEASE_COMMITTED { + return Ok(LootClaimCommitOutcome { + first_commit: false, + snapshot: None, + }); + } + if lease.status.load(Ordering::Acquire) == LEASE_QUARANTINED { + return Err(LootClaimCommitError::StateChanged); + } + + let result = { + let mut state = self.lock_state(); + match lease.status.load(Ordering::Acquire) { + LEASE_COMMITTED => { + return Ok(LootClaimCommitOutcome { + first_commit: false, + snapshot: None, + }); + } + LEASE_ROLLED_BACK => return Err(LootClaimCommitError::RolledBack), + LEASE_QUARANTINED => return Err(LootClaimCommitError::StateChanged), + _ => {} + } + + let scope = reservation_scope(lease.key); + let persistence_protected = state.persisting.get(&lease.key) == Some(&lease.token); + if persistence_protected && !persistence_owner { + return Err(LootClaimCommitError::StateChanged); + } + if (state.retired && !persistence_protected) + || scope_epoch(&state, scope) != Some(lease.generation) + { + lease.status.store(LEASE_ROLLED_BACK, Ordering::Release); + if persistence_protected { + state.persisting.remove(&lease.key); + finalize_closed_state_if_drained(&mut state); + } + Err(LootClaimCommitError::StaleGeneration) + } else if state.reservations.get(&lease.key) != Some(&lease.token) { + lease.status.store(LEASE_ROLLED_BACK, Ordering::Release); + if persistence_protected { + state.persisting.remove(&lease.key); + finalize_closed_state_if_drained(&mut state); + } + Err(LootClaimCommitError::StateChanged) + } else { + let committed = match lease.key { + ReservationKey::Item(key) => loot_for_scope_mut(&mut state, key.scope) + .is_some_and(|loot| { + loot.mark_item_looted_for_player_like_cpp( + key.loot_list_id, + lease.player, + ) + }), + ReservationKey::Money(scope) => loot_for_scope_mut(&mut state, scope) + .is_some_and(|loot| { + loot.coins = 0; + true + }), + }; + + // Capture the exact post-mutation pool while still holding + // the authority mutex. This is the C++ serialization point + // used by money fanout: a viewer captured here necessarily + // opened before coins became zero; a later opener observes + // zero directly and must not receive a spurious removal. + let committed_snapshot = committed.then(|| { + scope_epoch(&state, scope).and_then(|generation| { + loot_for_scope(&state, scope) + .cloned() + .map(|loot| OwnedLootSnapshot { + generation, + scope, + loot, + }) + }) + }); + + state.reservations.remove(&lease.key); + state.persisting.remove(&lease.key); + if committed { + lease.status.store(LEASE_COMMITTED, Ordering::Release); + finalize_closed_state_if_drained(&mut state); + Ok(LootClaimCommitOutcome { + first_commit: true, + snapshot: committed_snapshot.flatten(), + }) + } else { + lease.status.store(LEASE_ROLLED_BACK, Ordering::Release); + finalize_closed_state_if_drained(&mut state); + Err(LootClaimCommitError::StateChanged) + } + } + }; + self.notify_changed(); + result + } + + fn rollback_claim(&self, lease: &LootClaimLeaseInner) -> bool { + let removed = { + let mut state = self.lock_state(); + if lease.status.load(Ordering::Acquire) != LEASE_ACTIVE { + return false; + } + if state.persisting.get(&lease.key) == Some(&lease.token) { + return false; + } + let removed = scope_epoch(&state, reservation_scope(lease.key)) + == Some(lease.generation) + && state.reservations.get(&lease.key) == Some(&lease.token) + && state.reservations.remove(&lease.key).is_some(); + if state.persisting.get(&lease.key) == Some(&lease.token) { + state.persisting.remove(&lease.key); + } + lease.status.store(LEASE_ROLLED_BACK, Ordering::Release); + finalize_closed_state_if_drained(&mut state); + removed + }; + if removed { + self.notify_changed(); + } + removed + } +} + +enum ReserveAttempt { + Acquired { + generation: u64, + token: u64, + key: ReservationKey, + payload: LootClaimPayload, + }, + Wait, + Rejected(LootClaimError), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LootItemClaimMode { + Direct, + Award, +} + +fn reserve_item_once( + state: &mut AuthorityState, + player: ObjectGuid, + loot_list_id: u8, + mode: LootItemClaimMode, + expected_generation: Option, +) -> ReserveAttempt { + if state.retired { + return ReserveAttempt::Rejected(LootClaimError::Retired); + } + let Some(scope) = selected_scope_like_cpp(state, player) else { + return ReserveAttempt::Rejected(LootClaimError::NoLootForPlayer); + }; + let Some(generation) = scope_epoch(state, scope) else { + return ReserveAttempt::Rejected(LootClaimError::StaleGeneration); + }; + if expected_generation.is_some_and(|expected| expected != generation) { + return ReserveAttempt::Rejected(LootClaimError::StaleGeneration); + } + let Some(loot) = loot_for_scope(state, scope) else { + return ReserveAttempt::Rejected(LootClaimError::NoLootForPlayer); + }; + let Some(entry) = loot.item_like_cpp(loot_list_id).cloned() else { + return ReserveAttempt::Rejected(LootClaimError::ItemNotFound); + }; + if entry.is_looted_for_player_like_cpp(player) { + return ReserveAttempt::Rejected(LootClaimError::ItemAlreadyLooted); + } + if !entry.has_allowed_looter_like_cpp(player) { + return ReserveAttempt::Rejected(LootClaimError::PlayerNotAllowed); + } + if mode == LootItemClaimMode::Direct && entry.flags.blocked { + return ReserveAttempt::Rejected(LootClaimError::ItemBlocked); + } + if !entry.roll_winner_allows_like_cpp(player) { + return ReserveAttempt::Rejected(LootClaimError::WrongRollWinner); + } + + let key = ReservationKey::Item(LootItemClaimKey { + scope, + loot_list_id, + claimant: entry.flags.freeforall.then_some(player), + }); + if state.reservations.contains_key(&key) { + return ReserveAttempt::Wait; + } + + let token = next_token(state); + state.reservations.insert(key, token); + ReserveAttempt::Acquired { + generation, + token, + key, + payload: LootClaimPayload::Item(entry), + } +} + +fn reserve_money_once( + state: &mut AuthorityState, + player: ObjectGuid, + expected_generation: Option, +) -> ReserveAttempt { + if state.retired { + return ReserveAttempt::Rejected(LootClaimError::Retired); + } + let Some(scope) = selected_scope_like_cpp(state, player) else { + return ReserveAttempt::Rejected(LootClaimError::NoLootForPlayer); + }; + let Some(generation) = scope_epoch(state, scope) else { + return ReserveAttempt::Rejected(LootClaimError::StaleGeneration); + }; + if expected_generation.is_some_and(|expected| expected != generation) { + return ReserveAttempt::Rejected(LootClaimError::StaleGeneration); + } + let Some(loot) = loot_for_scope(state, scope) else { + return ReserveAttempt::Rejected(LootClaimError::NoLootForPlayer); + }; + if !loot.allowed_looters.contains(&player) { + return ReserveAttempt::Rejected(LootClaimError::PlayerNotAllowed); + } + let key = ReservationKey::Money(scope); + if state.reservations.contains_key(&key) { + return ReserveAttempt::Wait; + } + + let payload = LootClaimPayload::Money(loot.coins); + let token = next_token(state); + state.reservations.insert(key, token); + ReserveAttempt::Acquired { + generation, + token, + key, + payload, + } +} + +fn next_token(state: &mut AuthorityState) -> u64 { + let token = state.next_token; + state.next_token = state.next_token.wrapping_add(1).max(1); + token +} + +fn next_scope_epoch(state: &mut AuthorityState) -> u64 { + let epoch = state.next_scope_epoch; + state.next_scope_epoch = state.next_scope_epoch.wrapping_add(1).max(1); + epoch +} + +fn bump_lifecycle_revision(state: &mut AuthorityState) { + // A retired authority must never wrap back to a lifecycle token retained + // by a detached worker. + state.lifecycle_revision = state.lifecycle_revision.saturating_add(1).max(1); +} + +fn install_all_scope_epochs(state: &mut AuthorityState, epoch: u64) { + state.scope_epochs.clear(); + if state.shared.is_some() { + state.scope_epochs.insert(OwnedLootScope::Shared, epoch); + } + let players = state.personal.keys().copied().collect::>(); + for player in players { + state + .scope_epochs + .insert(OwnedLootScope::Personal(player), epoch); + } +} + +fn any_scope_epoch(state: &AuthorityState) -> Option { + state.scope_epochs.values().copied().min() +} + +fn scope_epoch(state: &AuthorityState, scope: OwnedLootScope) -> Option { + state.scope_epochs.get(&scope).copied() +} + +const fn reservation_scope(key: ReservationKey) -> OwnedLootScope { + match key { + ReservationKey::Item(item) => item.scope, + ReservationKey::Money(scope) => scope, + } +} + +fn selected_scope_like_cpp(state: &AuthorityState, player: ObjectGuid) -> Option { + if state.retired { + None + } else if state.personal.is_empty() { + state.shared.as_ref().map(|_| OwnedLootScope::Shared) + } else { + state + .personal + .contains_key(&player) + .then_some(OwnedLootScope::Personal(player)) + } +} + +fn active_loot_pools_fully_looted_like_cpp(state: &AuthorityState) -> bool { + state + .shared + .as_ref() + .is_none_or(CreatureLoot::is_looted_like_cpp) + && state + .personal + .values() + .all(CreatureLoot::is_looted_like_cpp) +} + +fn active_loot_pools_fully_skinned_like_cpp(state: &AuthorityState) -> bool { + let skinning = wow_constants::LootType::Skinning as u8; + if state + .shared + .as_ref() + .is_some_and(|loot| loot.loot_type == skinning && loot.is_looted_like_cpp()) + { + return true; + } + + let mut has_personal_skinning_loot = false; + for loot in state.personal.values() { + if loot.loot_type != skinning { + continue; + } + if !loot.is_looted_like_cpp() { + return false; + } + has_personal_skinning_loot = true; + } + has_personal_skinning_loot +} + +fn loot_for_scope(state: &AuthorityState, scope: OwnedLootScope) -> Option<&CreatureLoot> { + match scope { + OwnedLootScope::Shared => state.shared.as_ref(), + OwnedLootScope::Personal(player) => state.personal.get(&player), + } +} + +fn loot_for_scope_mut( + state: &mut AuthorityState, + scope: OwnedLootScope, +) -> Option<&mut CreatureLoot> { + match scope { + OwnedLootScope::Shared => state.shared.as_mut(), + OwnedLootScope::Personal(player) => state.personal.get_mut(&player), + } +} + +fn snapshot_for_scope(state: &AuthorityState, scope: OwnedLootScope) -> Option { + if state.retired { + return None; + } + let generation = scope_epoch(state, scope)?; + loot_for_scope(state, scope) + .cloned() + .map(|loot| OwnedLootSnapshot { + generation, + scope, + loot, + }) +} + +const LEASE_ACTIVE: u8 = 0; +const LEASE_COMMITTED: u8 = 1; +const LEASE_ROLLED_BACK: u8 = 2; +const LEASE_QUARANTINED: u8 = 3; + +struct LootClaimCommitOutcome { + first_commit: bool, + snapshot: Option, +} + +struct LootClaimLeaseInner { + authority: OwnedLootAuthority, + generation: u64, + token: u64, + key: ReservationKey, + player: ObjectGuid, + payload: LootClaimPayload, + status: AtomicU8, +} + +impl Drop for LootClaimLeaseInner { + fn drop(&mut self) { + let authority = self.authority.clone(); + authority.rollback_claim(self); + } +} + +/// A cloneable claim lease. Only dropping the final clone rolls an active claim back. +#[derive(Clone)] +pub struct LootClaimLease { + inner: Arc, +} + +/// RAII owner of the durable phase of one claim. Dropping it before a +/// successful commit is the only operation allowed to reopen a persisting +/// reservation; ordinary clones cannot roll it back concurrently. +pub struct LootClaimPersistenceGuard { + claim: LootClaimLease, + resolved: bool, +} + +impl fmt::Debug for LootClaimPersistenceGuard { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("LootClaimPersistenceGuard") + .field("claim", &self.claim) + .field("resolved", &self.resolved) + .finish() + } +} + +impl LootClaimPersistenceGuard { + pub fn commit_like_cpp(&mut self) -> Result { + self.commit_with_snapshot_like_cpp() + .map(|(first_commit, _)| first_commit) + } + + /// Commits and captures the post-mutation pool under the same authority + /// mutex. Consumers use this to distinguish viewers that opened before a + /// money transition from those that opened afterwards and already saw + /// zero in their response. + pub fn commit_with_snapshot_like_cpp( + &mut self, + ) -> Result<(bool, Option), LootClaimCommitError> { + let result = self + .claim + .inner + .authority + .commit_claim_with_snapshot(&self.claim.inner, true); + if result.is_ok() { + self.resolved = true; + } + result.map(|outcome| (outcome.first_commit, outcome.snapshot)) + } + + /// Terminal fail-closed outcome for an indeterminate database COMMIT. + /// The pending claim is removed so shutdown waits can drain, but neither + /// this lease nor its authority can be reserved or reinitialized until an + /// external durable reconciliation resolves the ambiguity. + #[must_use] + pub fn quarantine_commit_unknown_like_cpp(&mut self) -> bool { + let quarantined = self + .claim + .inner + .authority + .quarantine_claim_persistence_commit_unknown(&self.claim.inner); + if quarantined { + self.resolved = true; + } + quarantined + } +} + +impl Drop for LootClaimPersistenceGuard { + fn drop(&mut self) { + if !self.resolved { + self.claim + .inner + .authority + .abort_claim_persistence(&self.claim.inner); + } + } +} + +impl fmt::Debug for LootClaimLease { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("LootClaimLease") + .field("generation", &self.inner.generation) + .field("player", &self.inner.player) + .field("payload", &self.inner.payload) + .field("status", &self.inner.status.load(Ordering::Acquire)) + .finish() + } +} + +impl LootClaimLease { + fn new( + authority: OwnedLootAuthority, + generation: u64, + token: u64, + key: ReservationKey, + player: ObjectGuid, + payload: LootClaimPayload, + ) -> Self { + Self { + inner: Arc::new(LootClaimLeaseInner { + authority, + generation, + token, + key, + player, + payload, + status: AtomicU8::new(LEASE_ACTIVE), + }), + } + } + + #[must_use] + pub fn generation_like_cpp(&self) -> u64 { + self.inner.generation + } + + /// Whether this lease was reserved from the same object-owned authority. + /// Epochs restart in independently allocated authorities, so comparing the + /// numeric generation alone is vulnerable to an ABA across respawns. + #[must_use] + pub fn shares_authority_like_cpp(&self, authority: &OwnedLootAuthority) -> bool { + self.inner.authority.shares_storage_like_cpp(authority) + } + + #[must_use] + pub fn player_like_cpp(&self) -> ObjectGuid { + self.inner.player + } + + #[must_use] + pub fn payload_like_cpp(&self) -> &LootClaimPayload { + &self.inner.payload + } + + /// Protects this reservation across the following durable database + /// operation. Once this succeeds, lifecycle retirement rejects new claims + /// but keeps this exact lease commit-capable until SQL resolves. The raw + /// transition is intentionally not exposed: this RAII guard guarantees + /// cancellation and early-return paths release the reservation. + pub fn begin_persistence_guard_like_cpp( + &self, + ) -> Result { + self.inner.authority.begin_claim_persistence(&self.inner)?; + Ok(LootClaimPersistenceGuard { + claim: self.clone(), + resolved: false, + }) + } + + /// Applies the claim exactly once. Later commits from clones are successful no-ops. + pub fn commit_like_cpp(&self) -> Result { + self.inner.authority.commit_claim(&self.inner, false) + } + + /// Applies the claim and captures the exact post-mutation pool while the + /// authority mutex is still held. Durable fanout must use this cut rather + /// than sampling the authority after commit, when a later viewer may have + /// opened a window that already reflects the consumed item or money. + pub fn commit_with_snapshot_like_cpp( + &self, + ) -> Result<(bool, Option), LootClaimCommitError> { + self.inner + .authority + .commit_claim_with_snapshot(&self.inner, false) + .map(|outcome| (outcome.first_commit, outcome.snapshot)) + } + + /// Releases an active claim. Later rollback calls are no-ops. + pub fn rollback_like_cpp(&self) -> bool { + self.inner.authority.rollback_claim(&self.inner) + } + + #[must_use] + pub fn is_committed_like_cpp(&self) -> bool { + self.inner.status.load(Ordering::Acquire) == LEASE_COMMITTED + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::sync::Arc; + use std::time::Duration; + + use tokio::sync::Barrier; + use wow_core::ObjectGuid; + + use super::{ + CreatureLoot, LootClaimCommitError, LootClaimError, LootClaimPayload, LootEntry, + LootEntryFlags, LootInstallOutcome, LootItemClaimMode, NotNormalLootItem, + OwnedLootAuthority, OwnedLootAuthorityLifecycle, OwnedLootScope, ReserveAttempt, + reserve_item_once, + }; + + fn player(counter: i64) -> ObjectGuid { + ObjectGuid::create_player(1, counter) + } + + fn owner(counter: u32) -> ObjectGuid { + ObjectGuid::create_creature_like_cpp(1, 1, counter, i64::from(counter)) + } + + fn entry(list_id: u8, free_for_all: bool, allowed: Vec) -> LootEntry { + LootEntry { + loot_list_id: list_id, + item_id: 1000 + u32::from(list_id), + quantity: 1, + random_properties_id: 0, + random_properties_seed: 0, + item_context: 0, + flags: LootEntryFlags { + freeforall: free_for_all, + counted: true, + ..LootEntryFlags::default() + }, + allowed_looters: allowed, + roll_winner: ObjectGuid::EMPTY, + ffa_looted_by: Vec::new(), + taken: false, + } + } + + fn loot(owner_guid: ObjectGuid, coins: u32, items: Vec) -> CreatureLoot { + let unlooted_count = items.len() as u8; + CreatureLoot { + loot_guid: owner_guid, + coins, + unlooted_count, + loot_type: 1, + dungeon_encounter_id: 0, + loot_method: 0, + loot_master: ObjectGuid::EMPTY, + round_robin_player: ObjectGuid::EMPTY, + player_ffa_items: Vec::new(), + players_looting: Vec::new(), + allowed_looters: Vec::new(), + items, + looted_by_player: false, + } + } + + fn money_loot( + owner_guid: ObjectGuid, + coins: u32, + allowed_looters: Vec, + ) -> CreatureLoot { + let mut loot = loot(owner_guid, coins, Vec::new()); + loot.allowed_looters = allowed_looters; + loot + } + + #[test] + fn equal_authority_state_does_not_imply_shared_storage_like_cpp() { + let first = OwnedLootAuthority::new(); + let second = OwnedLootAuthority::new(); + let shared_loot = loot(owner(1), 17, Vec::new()); + + first.replace_like_cpp(Some(shared_loot.clone()), HashMap::new()); + second.replace_like_cpp(Some(shared_loot), HashMap::new()); + + assert_eq!(first, second, "the independent authority states match"); + assert!( + !first.shares_storage_like_cpp(&second), + "equal state must not hide two independently claimable Arc owners" + ); + assert!(first.shares_storage_like_cpp(&first.clone())); + } + + #[test] + fn personal_map_suppresses_shared_pool_like_cpp() { + let authority = OwnedLootAuthority::new(); + let first = player(1); + let second = player(2); + let mut personal = HashMap::new(); + personal.insert(first, loot(owner(2), 9, Vec::new())); + authority.replace_like_cpp(Some(loot(owner(1), 5, Vec::new())), personal); + + let first_snapshot = authority + .snapshot_for_player_like_cpp(first) + .expect("personal owner has loot"); + assert_eq!(first_snapshot.scope, OwnedLootScope::Personal(first)); + assert_eq!(first_snapshot.loot.coins, 9); + assert!(authority.snapshot_for_player_like_cpp(second).is_none()); + assert_eq!(authority.shared_snapshot_like_cpp().unwrap().loot.coins, 5); + } + + #[test] + fn initialize_is_first_writer_wins_and_retire_invalidates_generation() { + let authority = OwnedLootAuthority::new(); + let first = + authority.initialize_like_cpp(Some(loot(owner(1), 5, Vec::new())), HashMap::new()); + let second = + authority.initialize_like_cpp(Some(loot(owner(1), 9, Vec::new())), HashMap::new()); + assert!(matches!(first, LootInstallOutcome::Installed { .. })); + assert_eq!(first.generation(), second.generation()); + assert!(!second.installed()); + assert_eq!(authority.shared_snapshot_like_cpp().unwrap().loot.coins, 5); + + let retired_generation = authority.retire_like_cpp(); + assert!(retired_generation > first.generation()); + assert!(authority.shared_snapshot_like_cpp().is_none()); + assert_eq!(authority.retire_like_cpp(), retired_generation); + } + + #[tokio::test] + async fn normal_item_has_one_winner_and_waiter_observes_commit() { + let authority = OwnedLootAuthority::new(); + let first = player(1); + let second = player(2); + authority.replace_like_cpp( + Some(loot( + owner(1), + 0, + vec![entry(1, false, vec![first, second])], + )), + HashMap::new(), + ); + + let first_lease = authority.reserve_item_like_cpp(first, 1).await.unwrap(); + let waiting_authority = authority.clone(); + let waiter = + tokio::spawn(async move { waiting_authority.reserve_item_like_cpp(second, 1).await }); + tokio::task::yield_now().await; + first_lease.commit_like_cpp().unwrap(); + + assert_eq!( + waiter.await.unwrap().unwrap_err(), + LootClaimError::ItemAlreadyLooted + ); + assert!(authority.shared_snapshot_like_cpp().unwrap().loot.items[0].taken); + } + + #[tokio::test] + async fn item_claim_requires_allowed_looter_and_direct_policy_like_cpp() { + let authority = OwnedLootAuthority::new(); + let looter = player(1); + authority.replace_like_cpp( + Some(loot(owner(1), 0, vec![entry(1, false, Vec::new())])), + HashMap::new(), + ); + assert_eq!( + authority + .reserve_item_like_cpp(looter, 1) + .await + .unwrap_err(), + LootClaimError::PlayerNotAllowed + ); + + let mut blocked = entry(1, false, vec![looter]); + blocked.flags.blocked = true; + authority.replace_like_cpp(Some(loot(owner(1), 0, vec![blocked])), HashMap::new()); + assert_eq!( + authority + .reserve_item_like_cpp(looter, 1) + .await + .unwrap_err(), + LootClaimError::ItemBlocked + ); + let award = authority + .reserve_item_for_award_like_cpp(looter, 1) + .await + .expect("completed award may claim the blocked slot"); + assert!(award.commit_like_cpp().unwrap()); + assert!(!award.commit_like_cpp().unwrap(), "commit is idempotent"); + + let other = player(2); + let mut won = entry(2, false, vec![looter, other]); + won.roll_winner = other; + authority.replace_like_cpp(Some(loot(owner(1), 0, vec![won])), HashMap::new()); + assert_eq!( + authority + .reserve_item_for_award_like_cpp(looter, 2) + .await + .unwrap_err(), + LootClaimError::WrongRollWinner + ); + authority + .reserve_item_for_award_like_cpp(other, 2) + .await + .unwrap() + .commit_like_cpp() + .unwrap(); + } + + #[tokio::test] + async fn finished_roll_state_is_published_before_later_direct_claims() { + let authority = OwnedLootAuthority::new(); + let first = player(1); + let second = player(2); + let mut rolled = entry(1, false, vec![first, second]); + rolled.flags.blocked = true; + authority.replace_like_cpp(Some(loot(owner(1), 0, vec![rolled])), HashMap::new()); + let roll_generation = authority.shared_snapshot_like_cpp().unwrap().generation; + + assert!( + authority + .finish_item_roll_like_cpp(first, roll_generation, 1, false, Some(second)) + .unwrap() + ); + assert_eq!( + authority.reserve_item_like_cpp(first, 1).await.unwrap_err(), + LootClaimError::WrongRollWinner + ); + authority + .reserve_item_like_cpp(second, 1) + .await + .unwrap() + .commit_like_cpp() + .unwrap(); + + let mut single_candidate = entry(2, false, vec![first]); + single_candidate.flags.blocked = true; + authority.replace_like_cpp( + Some(loot(owner(1), 0, vec![single_candidate])), + HashMap::new(), + ); + let roll_generation = authority.shared_snapshot_like_cpp().unwrap().generation; + authority + .finish_item_roll_like_cpp(first, roll_generation, 2, true, None) + .unwrap(); + let snapshot = authority.shared_snapshot_like_cpp().unwrap(); + assert!(snapshot.loot.items[0].flags.under_threshold); + assert!(!snapshot.loot.items[0].flags.blocked); + authority + .reserve_item_like_cpp(first, 2) + .await + .unwrap() + .commit_like_cpp() + .unwrap(); + } + + #[tokio::test] + async fn final_clone_drop_rolls_back_and_wakes_waiter() { + let authority = OwnedLootAuthority::new(); + let looter = player(1); + authority.replace_like_cpp( + Some(loot(owner(1), 0, vec![entry(1, false, vec![looter])])), + HashMap::new(), + ); + + let lease = authority.reserve_item_like_cpp(looter, 1).await.unwrap(); + let clone = lease.clone(); + let waiting_authority = authority.clone(); + let waiter = + tokio::spawn(async move { waiting_authority.reserve_item_like_cpp(looter, 1).await }); + drop(lease); + tokio::task::yield_now().await; + assert!( + !waiter.is_finished(), + "one clone still owns the reservation" + ); + drop(clone); + + let retry = waiter.await.unwrap().unwrap(); + assert!(matches!( + retry.payload_like_cpp(), + LootClaimPayload::Item(_) + )); + retry.commit_like_cpp().unwrap(); + } + + #[tokio::test] + async fn change_between_busy_check_and_wait_poll_is_not_lost() { + use std::time::Duration; + + let authority = OwnedLootAuthority::new(); + let looter = player(1); + authority.replace_like_cpp( + Some(loot(owner(1), 0, vec![entry(1, false, vec![looter])])), + HashMap::new(), + ); + let first = authority.reserve_item_like_cpp(looter, 1).await.unwrap(); + let mut changed = authority.inner.changed.subscribe(); + let busy = { + let mut state = authority.lock_state(); + reserve_item_once(&mut state, looter, 1, LootItemClaimMode::Direct, None) + }; + assert!(matches!(busy, ReserveAttempt::Wait)); + + // This transition is deliberately between the locked Busy result and the first poll of + // `changed()`. A watch version remembers it; Notify::notify_waiters would lose it. + first.rollback_like_cpp(); + tokio::time::timeout(Duration::from_secs(1), changed.changed()) + .await + .expect("reservation wake must not be lost") + .unwrap(); + let retry = { + let mut state = authority.lock_state(); + reserve_item_once(&mut state, looter, 1, LootItemClaimMode::Direct, None) + }; + assert!(matches!(retry, ReserveAttempt::Acquired { .. })); + authority.retire_like_cpp(); + } + + #[tokio::test] + async fn ffa_item_is_reserved_and_consumed_once_per_player() { + let authority = OwnedLootAuthority::new(); + let first = player(1); + let second = player(2); + let mut shared = loot(owner(1), 0, vec![entry(1, true, vec![first, second])]); + shared.unlooted_count = 2; + shared.player_ffa_items = vec![ + ( + first, + vec![NotNormalLootItem { + loot_list_id: 1, + is_looted: false, + }], + ), + ( + second, + vec![NotNormalLootItem { + loot_list_id: 1, + is_looted: false, + }], + ), + ]; + authority.replace_like_cpp(Some(shared), HashMap::new()); + + let barrier = Arc::new(Barrier::new(3)); + let mut tasks = Vec::new(); + for looter in [first, second] { + let authority = authority.clone(); + let barrier = barrier.clone(); + tasks.push(tokio::spawn(async move { + barrier.wait().await; + let lease = authority.reserve_item_like_cpp(looter, 1).await.unwrap(); + lease.commit_like_cpp().unwrap(); + })); + } + barrier.wait().await; + for task in tasks { + task.await.unwrap(); + } + + let shared = authority.shared_snapshot_like_cpp().unwrap().loot; + assert_eq!(shared.unlooted_count, 0); + assert!(shared.items[0].fully_looted_like_cpp()); + assert_eq!( + authority.reserve_item_like_cpp(first, 1).await.unwrap_err(), + LootClaimError::ItemAlreadyLooted + ); + assert_eq!( + authority + .reserve_item_like_cpp(second, 1) + .await + .unwrap_err(), + LootClaimError::ItemAlreadyLooted + ); + } + + #[tokio::test] + async fn personal_claims_and_ae_owners_are_independent() { + let first = player(1); + let second = player(2); + let authority = OwnedLootAuthority::new(); + let mut personal = HashMap::new(); + personal.insert(first, loot(owner(1), 0, vec![entry(1, false, vec![first])])); + personal.insert( + second, + loot(owner(1), 0, vec![entry(1, false, vec![second])]), + ); + authority.replace_like_cpp(None, personal); + + let first_lease = authority.reserve_item_like_cpp(first, 1).await.unwrap(); + let second_lease = authority.reserve_item_like_cpp(second, 1).await.unwrap(); + first_lease.commit_like_cpp().unwrap(); + second_lease.commit_like_cpp().unwrap(); + + let first_ae = OwnedLootAuthority::new(); + let second_ae = OwnedLootAuthority::new(); + first_ae.replace_like_cpp(Some(money_loot(owner(10), 7, vec![first])), HashMap::new()); + second_ae.replace_like_cpp( + Some(money_loot(owner(11), 11, vec![second])), + HashMap::new(), + ); + first_ae + .reserve_money_like_cpp(first) + .await + .unwrap() + .commit_like_cpp() + .unwrap(); + assert_eq!(first_ae.shared_snapshot_like_cpp().unwrap().loot.coins, 0); + assert_eq!(second_ae.shared_snapshot_like_cpp().unwrap().loot.coins, 11); + } + + #[tokio::test] + async fn stale_lease_cannot_touch_replacement_generation() { + let authority = OwnedLootAuthority::new(); + let looter = player(1); + authority.replace_like_cpp(Some(money_loot(owner(1), 5, vec![looter])), HashMap::new()); + let stale = authority.reserve_money_like_cpp(looter).await.unwrap(); + + let retired_generation = authority.retire_like_cpp(); + authority + .replace_retired_generation_like_cpp( + retired_generation, + Some(money_loot(owner(1), 9, vec![looter])), + HashMap::new(), + ) + .unwrap(); + assert!(stale.commit_like_cpp().is_err()); + drop(stale); + assert_eq!(authority.shared_snapshot_like_cpp().unwrap().loot.coins, 9); + } + + #[tokio::test] + async fn allowed_player_can_reserve_and_commit_money_like_cpp() { + let authority = OwnedLootAuthority::new(); + let looter = player(1); + authority.replace_like_cpp(Some(money_loot(owner(1), 17, vec![looter])), HashMap::new()); + + let claim = authority.reserve_money_like_cpp(looter).await.unwrap(); + assert_eq!(claim.payload_like_cpp(), &LootClaimPayload::Money(17)); + assert!(claim.commit_like_cpp().unwrap()); + assert_eq!(authority.shared_snapshot_like_cpp().unwrap().loot.coins, 0); + } + + #[tokio::test] + async fn money_claim_rejects_player_not_in_allowed_looters_like_cpp() { + let authority = OwnedLootAuthority::new(); + let allowed = player(1); + let denied = player(2); + authority.replace_like_cpp( + Some(money_loot(owner(1), 19, vec![allowed])), + HashMap::new(), + ); + + assert_eq!( + authority.reserve_money_like_cpp(denied).await.unwrap_err(), + LootClaimError::PlayerNotAllowed + ); + assert_eq!(authority.shared_snapshot_like_cpp().unwrap().loot.coins, 19); + } + + #[tokio::test] + async fn stale_player_cannot_claim_replacement_generation_money_like_cpp() { + let authority = OwnedLootAuthority::new(); + let stale_looter = player(1); + let current_looter = player(2); + authority.replace_like_cpp( + Some(money_loot(owner(1), 5, vec![stale_looter])), + HashMap::new(), + ); + let stale_generation = authority.generation_like_cpp(); + + let retired_generation = authority.retire_like_cpp(); + authority + .replace_retired_generation_like_cpp( + retired_generation, + Some(money_loot(owner(1), 23, vec![current_looter])), + HashMap::new(), + ) + .unwrap(); + assert!(authority.generation_like_cpp() > stale_generation); + assert_eq!( + authority + .reserve_money_like_cpp(stale_looter) + .await + .unwrap_err(), + LootClaimError::PlayerNotAllowed + ); + assert_eq!(authority.shared_snapshot_like_cpp().unwrap().loot.coins, 23); + + let current_claim = authority + .reserve_money_like_cpp(current_looter) + .await + .unwrap(); + assert!(current_claim.commit_like_cpp().unwrap()); + assert_eq!(authority.shared_snapshot_like_cpp().unwrap().loot.coins, 0); + } + + #[tokio::test] + async fn money_claim_rolls_back_and_later_cpp_request_observes_zero() { + let authority = OwnedLootAuthority::new(); + let first = player(1); + let second = player(2); + authority.replace_like_cpp( + Some(money_loot(owner(1), 25, vec![first, second])), + HashMap::new(), + ); + + let abandoned = authority.reserve_money_like_cpp(first).await.unwrap(); + assert!(abandoned.rollback_like_cpp()); + let winner = authority.reserve_money_like_cpp(second).await.unwrap(); + assert_eq!(winner.payload_like_cpp(), &LootClaimPayload::Money(25)); + assert!(winner.commit_like_cpp().unwrap()); + assert!(!winner.commit_like_cpp().unwrap()); + let zero = authority.reserve_money_like_cpp(first).await.unwrap(); + assert_eq!(zero.payload_like_cpp(), &LootClaimPayload::Money(0)); + assert!(zero.commit_like_cpp().unwrap()); + } + + #[tokio::test] + async fn concurrent_money_waiter_observes_the_single_committed_winner() { + let authority = OwnedLootAuthority::new(); + let first = player(1); + let second = player(2); + authority.replace_like_cpp( + Some(money_loot(owner(1), 31, vec![first, second])), + HashMap::new(), + ); + + let winner = authority.reserve_money_like_cpp(first).await.unwrap(); + let waiting_authority = authority.clone(); + let waiter = + tokio::spawn(async move { waiting_authority.reserve_money_like_cpp(second).await }); + tokio::task::yield_now().await; + assert!(winner.commit_like_cpp().unwrap()); + let zero = waiter.await.unwrap().unwrap(); + assert_eq!(zero.payload_like_cpp(), &LootClaimPayload::Money(0)); + assert!(zero.commit_like_cpp().unwrap()); + assert_eq!(authority.shared_snapshot_like_cpp().unwrap().loot.coins, 0); + } + + #[test] + fn personal_pools_can_be_installed_incrementally_without_replacing_peers() { + let authority = OwnedLootAuthority::new(); + let first = player(1); + let second = player(2); + authority.upsert_personal_like_cpp(first, loot(owner(1), 7, Vec::new()), false); + let generation = authority.generation_like_cpp(); + authority.upsert_personal_like_cpp(second, loot(owner(1), 11, Vec::new()), false); + + assert_eq!(authority.generation_like_cpp(), generation); + assert_eq!( + authority + .personal_snapshot_like_cpp(first) + .unwrap() + .loot + .coins, + 7 + ); + assert_eq!( + authority + .personal_snapshot_like_cpp(second) + .unwrap() + .loot + .coins, + 11 + ); + let duplicate = + authority.upsert_personal_like_cpp(first, loot(owner(1), 99, Vec::new()), false); + assert!(!duplicate.installed()); + assert_eq!( + authority + .personal_snapshot_like_cpp(first) + .unwrap() + .loot + .coins, + 7 + ); + } + + #[tokio::test] + async fn replacing_one_personal_pool_does_not_stale_another_players_claim() { + let authority = OwnedLootAuthority::new(); + let first = player(1); + let second = player(2); + let mut first_loot = loot(owner(1), 0, vec![entry(1, false, vec![first])]); + first_loot.allowed_looters = vec![first]; + authority.upsert_personal_like_cpp(first, first_loot, false); + let mut second_loot = loot(owner(1), 0, vec![entry(1, false, vec![second])]); + second_loot.allowed_looters = vec![second]; + authority.upsert_personal_like_cpp(second, second_loot, false); + + let stale_first_item = authority.reserve_item_like_cpp(first, 1).await.unwrap(); + let stale_first_money = authority.reserve_money_like_cpp(first).await.unwrap(); + let second_claim = authority.reserve_item_like_cpp(second, 1).await.unwrap(); + let first_epoch = authority + .personal_snapshot_like_cpp(first) + .unwrap() + .generation; + let second_epoch = authority + .personal_snapshot_like_cpp(second) + .unwrap() + .generation; + let generation = authority.generation_like_cpp(); + let mut replacement = loot(owner(1), 9, vec![entry(2, false, vec![first])]); + replacement.allowed_looters = vec![first]; + authority.upsert_personal_like_cpp(first, replacement, true); + + assert_eq!(authority.generation_like_cpp(), generation); + assert_ne!( + authority + .personal_snapshot_like_cpp(first) + .unwrap() + .generation, + first_epoch + ); + assert_eq!( + authority + .personal_snapshot_like_cpp(second) + .unwrap() + .generation, + second_epoch + ); + assert_eq!( + stale_first_item.commit_like_cpp(), + Err(LootClaimCommitError::StaleGeneration) + ); + assert_eq!( + stale_first_money.commit_like_cpp(), + Err(LootClaimCommitError::StaleGeneration) + ); + assert!(second_claim.commit_like_cpp().unwrap()); + assert!( + authority + .personal_snapshot_like_cpp(second) + .unwrap() + .loot + .items[0] + .taken + ); + assert_eq!( + authority + .personal_snapshot_like_cpp(first) + .unwrap() + .loot + .coins, + 9 + ); + assert!( + authority + .reserve_item_like_cpp(first, 2) + .await + .unwrap() + .commit_like_cpp() + .unwrap() + ); + assert!( + authority + .reserve_money_like_cpp(first) + .await + .unwrap() + .commit_like_cpp() + .unwrap() + ); + } + + #[tokio::test] + async fn stale_personal_waiter_rejects_replacement_epoch_without_reserving_it() { + let authority = OwnedLootAuthority::new(); + let looter = player(1); + authority.upsert_personal_like_cpp( + looter, + loot(owner(1), 0, vec![entry(1, false, vec![looter])]), + false, + ); + let old_epoch = authority + .personal_snapshot_like_cpp(looter) + .unwrap() + .generation; + let held = authority.reserve_item_like_cpp(looter, 1).await.unwrap(); + let waiting_authority = authority.clone(); + let waiter = tokio::spawn(async move { + waiting_authority + .reserve_item_for_generation_like_cpp(looter, 1, old_epoch) + .await + }); + tokio::task::yield_now().await; + + authority.upsert_personal_like_cpp( + looter, + loot(owner(1), 0, vec![entry(1, false, vec![looter])]), + true, + ); + assert_eq!( + waiter.await.unwrap().unwrap_err(), + LootClaimError::StaleGeneration + ); + drop(held); + assert!( + authority + .reserve_item_like_cpp(looter, 1) + .await + .unwrap() + .commit_like_cpp() + .unwrap() + ); + } + + #[test] + fn pristine_bridge_cannot_resurrect_a_retired_nonzero_generation() { + let authority = OwnedLootAuthority::new(); + let looter = player(1); + let first = authority.initialize_pristine_like_cpp( + Some(money_loot(owner(1), 5, vec![looter])), + HashMap::new(), + ); + assert!(first.installed()); + authority.retire_like_cpp(); + + let stale = authority.initialize_pristine_like_cpp( + Some(money_loot(owner(1), 99, vec![looter])), + HashMap::new(), + ); + assert!(!stale.installed()); + assert!( + !authority + .initialize_like_cpp(Some(money_loot(owner(1), 98, vec![looter])), HashMap::new(),) + .installed() + ); + assert!( + !authority + .initialize_shared_like_cpp(money_loot(owner(1), 97, vec![looter])) + .installed() + ); + assert!( + !authority + .upsert_personal_like_cpp(looter, money_loot(owner(1), 96, vec![looter]), true,) + .installed() + ); + assert!(authority.is_retired_like_cpp()); + assert!(authority.shared_snapshot_like_cpp().is_none()); + } + + #[test] + fn viewer_first_open_is_atomic_and_retire_clears_it() { + let authority = OwnedLootAuthority::new(); + let first = player(1); + let second = player(2); + authority.replace_like_cpp(Some(loot(owner(1), 1, Vec::new())), HashMap::new()); + + let first_open = authority.add_viewer_like_cpp(first).unwrap(); + let duplicate = authority.add_viewer_like_cpp(first).unwrap(); + let second_open = authority.add_viewer_like_cpp(second).unwrap(); + assert!(first_open.first_viewer); + assert!(!duplicate.inserted); + assert!(!second_open.first_viewer); + assert_eq!( + authority.viewers_for_player_like_cpp(first), + vec![first, second] + ); + assert!(authority.remove_viewer_like_cpp(first)); + assert!(!authority.remove_viewer_like_cpp(first)); + assert!(authority.remove_viewer_like_cpp(second)); + let reopened = authority.add_viewer_like_cpp(first).unwrap(); + assert!(reopened.inserted); + assert!( + !reopened.first_viewer, + "C++ Loot::_wasOpened survives an empty active-looter set" + ); + authority.retire_like_cpp(); + assert!(authority.viewers_for_player_like_cpp(second).is_empty()); + } + + #[test] + fn rejected_view_response_rolls_back_viewer_and_first_open_like_cpp() { + let authority = OwnedLootAuthority::new(); + let viewer = player(1); + authority.replace_like_cpp( + Some(loot(owner(1), 0, vec![entry(1, false, vec![viewer])])), + HashMap::new(), + ); + + assert_eq!( + authority.try_open_view_with_snapshot_like_cpp(viewer, |_, _| None::<()>), + Err(LootClaimError::ResponseEnqueueFailed) + ); + let rejected = authority.shared_snapshot_like_cpp().unwrap(); + assert!(rejected.loot.players_looting.is_empty()); + assert!(!rejected.loot.looted_by_player); + + let retry = authority.add_viewer_like_cpp(viewer).unwrap(); + assert!(retry.inserted); + assert!( + retry.first_viewer, + "a response the client never observed must not consume C++ Loot::_wasOpened" + ); + } + + #[test] + fn exact_generation_release_removes_only_that_viewer() { + let authority = OwnedLootAuthority::new(); + let first = player(1); + let second = player(2); + let generation = authority.replace_like_cpp( + Some(money_loot(owner(1), 17, vec![first, second])), + HashMap::new(), + ); + authority.add_viewer_like_cpp(first).unwrap(); + authority.add_viewer_like_cpp(second).unwrap(); + + assert_eq!( + authority.remove_viewer_if_generation_like_cpp(generation, first), + Some(true) + ); + assert_eq!(authority.viewers_for_player_like_cpp(second), vec![second]); + assert_eq!( + authority.remove_viewer_if_generation_like_cpp(generation, first), + Some(false) + ); + assert_eq!(authority.shared_snapshot_like_cpp().unwrap().loot.coins, 17); + } + + #[test] + fn retired_generation_release_cannot_touch_replacement_viewer_or_pool() { + let authority = OwnedLootAuthority::new(); + let viewer = player(1); + let old_generation = + authority.replace_like_cpp(Some(money_loot(owner(1), 7, vec![viewer])), HashMap::new()); + authority.add_viewer_like_cpp(viewer).unwrap(); + + authority.retire_like_cpp(); + let replacement_generation = authority + .replace_like_cpp(Some(money_loot(owner(1), 29, vec![viewer])), HashMap::new()); + authority.add_viewer_like_cpp(viewer).unwrap(); + + assert_ne!(replacement_generation, old_generation); + assert_eq!( + authority.remove_viewer_if_generation_like_cpp(old_generation, viewer), + None + ); + let replacement = authority.shared_snapshot_like_cpp().unwrap(); + assert_eq!(replacement.generation, replacement_generation); + assert_eq!(replacement.loot.coins, 29); + assert_eq!(replacement.loot.players_looting, vec![viewer]); + assert_eq!(replacement.loot.allowed_looters, vec![viewer]); + } + + #[tokio::test] + async fn persistence_guard_survives_retire_and_closes_after_commit_like_cpp() { + let authority = OwnedLootAuthority::new(); + let looter = player(1); + authority.replace_like_cpp( + Some(loot(owner(1), 0, vec![entry(1, false, vec![looter])])), + HashMap::new(), + ); + let claim = authority.reserve_item_like_cpp(looter, 1).await.unwrap(); + let mut persistence = claim.begin_persistence_guard_like_cpp().unwrap(); + + let retired_generation = authority.retire_like_cpp(); + assert_eq!( + authority.lifecycle_like_cpp(), + OwnedLootAuthorityLifecycle::Retired + ); + assert_eq!( + claim.commit_like_cpp(), + Err(LootClaimCommitError::StateChanged), + "only the durable-phase owner may resolve a protected reservation" + ); + assert!(!claim.rollback_like_cpp()); + assert!(persistence.commit_like_cpp().unwrap()); + + assert!(authority.is_retired_like_cpp()); + assert!(authority.shared_snapshot_like_cpp().is_none()); + assert!( + authority + .replace_retired_generation_like_cpp( + retired_generation, + Some(money_loot(owner(1), 9, vec![looter])), + HashMap::new(), + ) + .is_some(), + "the closed lifetime may respawn only after durable completion" + ); + } + + #[tokio::test] + async fn persistence_guard_survives_detach_but_detached_owner_never_reopens_like_cpp() { + let authority = OwnedLootAuthority::new(); + let looter = player(1); + authority.replace_like_cpp( + Some(loot(owner(1), 0, vec![entry(1, false, vec![looter])])), + HashMap::new(), + ); + let claim = authority.reserve_item_like_cpp(looter, 1).await.unwrap(); + let mut persistence = claim.begin_persistence_guard_like_cpp().unwrap(); + + let detached_generation = authority.detach_like_cpp(); + assert_eq!( + authority.lifecycle_like_cpp(), + OwnedLootAuthorityLifecycle::Detached + ); + assert!(persistence.commit_like_cpp().unwrap()); + assert_eq!( + authority + .replace_like_cpp(Some(money_loot(owner(1), 9, vec![looter])), HashMap::new(),), + 0 + ); + assert!( + authority + .replace_retired_generation_like_cpp( + detached_generation, + Some(money_loot(owner(1), 9, vec![looter])), + HashMap::new(), + ) + .is_none() + ); + } + + #[tokio::test] + async fn persistence_guard_is_unique_and_external_clones_cannot_resolve_it_like_cpp() { + let authority = OwnedLootAuthority::new(); + let looter = player(1); + authority.replace_like_cpp(Some(money_loot(owner(1), 17, vec![looter])), HashMap::new()); + let claim = authority.reserve_money_like_cpp(looter).await.unwrap(); + let clone = claim.clone(); + let mut persistence = claim.begin_persistence_guard_like_cpp().unwrap(); + + assert_eq!( + clone.begin_persistence_guard_like_cpp().unwrap_err(), + LootClaimCommitError::StateChanged + ); + assert_eq!( + clone.commit_like_cpp(), + Err(LootClaimCommitError::StateChanged) + ); + assert!(!clone.rollback_like_cpp()); + assert!(persistence.commit_like_cpp().unwrap()); + assert_eq!( + claim.begin_persistence_guard_like_cpp().unwrap_err(), + LootClaimCommitError::StateChanged + ); + } + + #[tokio::test] + async fn dropped_persistence_guard_reopens_claim_after_failure_like_cpp() { + let authority = OwnedLootAuthority::new(); + let looter = player(1); + authority.replace_like_cpp( + Some(loot(owner(1), 0, vec![entry(1, false, vec![looter])])), + HashMap::new(), + ); + let failed = authority.reserve_item_like_cpp(looter, 1).await.unwrap(); + let persistence = failed.begin_persistence_guard_like_cpp().unwrap(); + drop(persistence); + + assert_eq!( + failed.commit_like_cpp(), + Err(LootClaimCommitError::RolledBack) + ); + let retry = authority.reserve_item_like_cpp(looter, 1).await.unwrap(); + assert!(retry.commit_like_cpp().unwrap()); + } + + #[tokio::test] + async fn persistence_commit_snapshot_excludes_viewers_opened_after_money_transition_like_cpp() { + let authority = OwnedLootAuthority::new(); + let first = player(1); + let late = player(2); + authority.replace_like_cpp( + Some(money_loot(owner(1), 17, vec![first, late])), + HashMap::new(), + ); + authority.add_viewer_like_cpp(first).unwrap(); + + let claim = authority.reserve_money_like_cpp(first).await.unwrap(); + let mut persistence = claim.begin_persistence_guard_like_cpp().unwrap(); + let (first_commit, committed_snapshot) = + persistence.commit_with_snapshot_like_cpp().unwrap(); + assert!(first_commit); + assert_eq!( + committed_snapshot.unwrap().loot.players_looting, + vec![first], + "the durable fanout snapshot is captured at the same serialized transition as coins=0" + ); + + authority.add_viewer_like_cpp(late).unwrap(); + assert_eq!( + authority + .shared_snapshot_like_cpp() + .unwrap() + .loot + .players_looting, + vec![first, late], + "a later opener observes zero directly and is not retroactively part of the commit fanout" + ); + } + + #[tokio::test] + async fn persistence_commit_snapshot_excludes_viewers_opened_after_item_transition_like_cpp() { + let authority = OwnedLootAuthority::new(); + let first = player(1); + let late = player(2); + authority.replace_like_cpp( + Some(loot(owner(1), 0, vec![entry(1, false, vec![first, late])])), + HashMap::new(), + ); + authority.add_viewer_like_cpp(first).unwrap(); + + let claim = authority.reserve_item_like_cpp(first, 1).await.unwrap(); + let mut persistence = claim.begin_persistence_guard_like_cpp().unwrap(); + let (first_commit, committed_snapshot) = + persistence.commit_with_snapshot_like_cpp().unwrap(); + assert!(first_commit); + let committed_snapshot = committed_snapshot.unwrap(); + assert_eq!(committed_snapshot.loot.players_looting, vec![first]); + assert!(committed_snapshot.loot.items[0].taken); + + let (_, late_snapshot) = authority + .open_view_with_snapshot_like_cpp(late, |snapshot, _| snapshot.clone()) + .unwrap(); + assert_eq!(late_snapshot.loot.players_looting, vec![first, late]); + assert!(late_snapshot.loot.items[0].taken); + assert_eq!( + committed_snapshot.loot.players_looting, + vec![first], + "a viewer that first observes the consumed item is outside the commit fanout cut" + ); + } + + #[tokio::test] + async fn one_personal_persistence_does_not_block_peer_scope_like_cpp() { + let authority = OwnedLootAuthority::new(); + let first = player(1); + let second = player(2); + authority.upsert_personal_like_cpp( + first, + loot(owner(1), 0, vec![entry(1, false, vec![first])]), + false, + ); + let first_claim = authority.reserve_item_like_cpp(first, 1).await.unwrap(); + let mut first_persistence = first_claim.begin_persistence_guard_like_cpp().unwrap(); + + assert!( + authority + .upsert_personal_like_cpp( + second, + loot(owner(1), 0, vec![entry(1, false, vec![second])]), + false, + ) + .installed(), + "P1 durable work must not serialize an independent P2 pool" + ); + let second_claim = authority.reserve_item_like_cpp(second, 1).await.unwrap(); + assert!(second_claim.commit_like_cpp().unwrap()); + + assert!( + !authority + .upsert_personal_like_cpp( + first, + loot(owner(1), 0, vec![entry(2, false, vec![first])]), + true, + ) + .installed(), + "the exact persisting P1 pool cannot be replaced" + ); + assert!(first_persistence.commit_like_cpp().unwrap()); + } + + #[test] + fn lifecycle_observation_is_invalidated_by_late_personal_upsert_like_cpp() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let authority = OwnedLootAuthority::new(); + let first = player(1); + let late = player(2); + authority.replace_like_cpp(Some(loot(owner(1), 0, vec![])), HashMap::new()); + authority.add_viewer_like_cpp(first).unwrap(); + let generation = authority + .snapshot_for_player_like_cpp(first) + .unwrap() + .generation; + let close = authority + .close_viewer_if_generation_like_cpp(generation, first) + .unwrap(); + assert!(close.whole_object_fully_looted); + + let applications = AtomicUsize::new(0); + assert!( + authority + .with_fully_looted_lifecycle_observation_like_cpp( + close.object_generation, + close.lifecycle_revision, + || applications.fetch_add(1, Ordering::SeqCst), + ) + .is_some() + ); + assert!( + authority + .upsert_personal_like_cpp( + late, + loot(owner(1), 0, vec![entry(1, false, vec![late])]), + false, + ) + .installed() + ); + assert!( + authority + .with_fully_looted_lifecycle_observation_like_cpp( + close.object_generation, + close.lifecycle_revision, + || applications.fetch_add(1, Ordering::SeqCst), + ) + .is_none(), + "an upsert after close must invalidate the pre-upsert lifecycle observation" + ); + assert_eq!(applications.load(Ordering::SeqCst), 1); + } + + #[test] + fn round_robin_clear_is_generation_guarded_and_returns_authoritative_snapshot_like_cpp() { + let authority = OwnedLootAuthority::new(); + let first = player(1); + let second = player(2); + let mut shared = loot(owner(1), 0, vec![entry(1, false, vec![first, second])]); + shared.round_robin_player = first; + authority.replace_like_cpp(Some(shared), HashMap::new()); + let generation = authority + .snapshot_for_player_like_cpp(first) + .unwrap() + .generation; + + let cleared = authority + .clear_round_robin_if_generation_like_cpp(generation, first) + .unwrap(); + assert!(cleared.cleared); + assert!(cleared.snapshot.loot.round_robin_player.is_empty()); + + let mut replacement = loot(owner(1), 0, vec![entry(1, false, vec![first, second])]); + replacement.round_robin_player = second; + authority.replace_like_cpp(Some(replacement), HashMap::new()); + assert!( + authority + .clear_round_robin_if_generation_like_cpp(generation, second) + .is_none(), + "a stale release cannot clear round robin on a replacement pool" + ); + assert_eq!( + authority + .snapshot_for_player_like_cpp(second) + .unwrap() + .loot + .round_robin_player, + second + ); + } + + #[tokio::test] + async fn wait_for_persisting_claims_observes_commit_and_failure_boundaries_like_cpp() { + let authority = OwnedLootAuthority::new(); + let looter = player(1); + authority.replace_like_cpp( + Some(loot(owner(1), 0, vec![entry(1, false, vec![looter])])), + HashMap::new(), + ); + let claim = authority.reserve_item_like_cpp(looter, 1).await.unwrap(); + let mut persistence = claim.begin_persistence_guard_like_cpp().unwrap(); + let waiting = authority.clone(); + let waiter = tokio::spawn(async move { + waiting.wait_for_persisting_claims_like_cpp().await; + }); + tokio::task::yield_now().await; + assert!(!waiter.is_finished()); + assert!(persistence.commit_like_cpp().unwrap()); + waiter.await.unwrap(); + + authority.replace_like_cpp( + Some(loot(owner(1), 0, vec![entry(2, false, vec![looter])])), + HashMap::new(), + ); + let failed = authority.reserve_item_like_cpp(looter, 2).await.unwrap(); + let persistence = failed.begin_persistence_guard_like_cpp().unwrap(); + drop(persistence); + authority.wait_for_persisting_claims_like_cpp().await; + assert!(authority.reserve_item_like_cpp(looter, 2).await.is_ok()); + } + + #[tokio::test] + async fn commit_unknown_quarantine_is_terminal_fail_closed_and_drains_waiters_like_cpp() { + let authority = OwnedLootAuthority::new(); + let looter = player(1); + let original = loot(owner(1), 0, vec![entry(1, false, vec![looter])]); + authority.replace_like_cpp(Some(original.clone()), HashMap::new()); + let claim = authority.reserve_item_like_cpp(looter, 1).await.unwrap(); + let mut persistence = claim.begin_persistence_guard_like_cpp().unwrap(); + let waiting = authority.clone(); + let waiter = tokio::spawn(async move { + waiting.wait_for_persisting_claims_like_cpp().await; + }); + tokio::task::yield_now().await; + assert!(!waiter.is_finished()); + + assert!(persistence.quarantine_commit_unknown_like_cpp()); + drop(persistence); + tokio::time::timeout(Duration::from_secs(1), waiter) + .await + .expect("quarantine removes the persisting token") + .unwrap(); + + assert_eq!( + authority.lifecycle_like_cpp(), + OwnedLootAuthorityLifecycle::Quarantined + ); + assert_eq!( + claim.commit_like_cpp(), + Err(LootClaimCommitError::StateChanged) + ); + assert!(matches!( + authority.reserve_item_like_cpp(looter, 1).await, + Err(LootClaimError::Retired) + )); + assert_eq!( + authority.replace_like_cpp(Some(original.clone()), HashMap::new()), + 0 + ); + assert!( + !authority + .initialize_pristine_like_cpp(Some(original), HashMap::new()) + .installed() + ); + } + + #[tokio::test] + async fn whole_object_skinned_requires_every_active_skinning_pool_like_cpp() { + let authority = OwnedLootAuthority::new(); + let first = player(1); + let second = player(2); + let mut first_pool = loot(owner(1), 0, vec![]); + first_pool.loot_type = wow_constants::LootType::Skinning as u8; + let mut second_pool = loot(owner(1), 0, vec![entry(1, false, vec![second])]); + second_pool.loot_type = wow_constants::LootType::Skinning as u8; + authority.replace_like_cpp( + None, + [(first, first_pool), (second, second_pool)] + .into_iter() + .collect(), + ); + authority.add_viewer_like_cpp(first).unwrap(); + let first_generation = authority + .snapshot_for_player_like_cpp(first) + .unwrap() + .generation; + let first_close = authority + .close_viewer_if_generation_like_cpp(first_generation, first) + .unwrap(); + assert!(first_close.snapshot.loot.is_looted_like_cpp()); + assert!(!first_close.whole_object_fully_looted); + assert!(!first_close.whole_object_fully_skinned); + + authority + .reserve_item_like_cpp(second, 1) + .await + .unwrap() + .commit_like_cpp() + .unwrap(); + authority.add_viewer_like_cpp(second).unwrap(); + let second_generation = authority + .snapshot_for_player_like_cpp(second) + .unwrap() + .generation; + let second_close = authority + .close_viewer_if_generation_like_cpp(second_generation, second) + .unwrap(); + assert!(second_close.whole_object_fully_looted); + assert!(second_close.whole_object_fully_skinned); + } +} diff --git a/crates/wow-loot/src/lib.rs b/crates/wow-loot/src/lib.rs index 79f810c4d..3c2f8fed2 100644 --- a/crates/wow-loot/src/lib.rs +++ b/crates/wow-loot/src/lib.rs @@ -15,6 +15,16 @@ use std::collections::{HashMap, HashSet}; use rand::Rng; use wow_core::ObjectGuid; +mod authority; + +pub use authority::{ + CreatureLoot, LootClaimCommitError, LootClaimError, LootClaimLease, LootClaimPayload, + LootClaimPersistenceGuard, LootEntry, LootEntryFlags, LootInstallOutcome, LootItemClaimKey, + LootRoundRobinReleaseOutcome, LootViewerCloseOutcome, LootViewerOpenOutcome, NotNormalLootItem, + OwnedLootAuthority, OwnedLootAuthorityLifecycle, OwnedLootAuthorityStamp, OwnedLootScope, + OwnedLootSnapshot, +}; + const MIN_NON_ZERO_LOOT_CHANCE_LIKE_CPP: f32 = 0.000001; pub const MAX_NR_LOOT_ITEMS_LIKE_CPP: usize = 18; pub const LOOT_METHOD_FREE_FOR_ALL_LIKE_CPP: u8 = 0; @@ -93,7 +103,7 @@ pub struct LootStoreItemContext { pub item: LootStoreItem, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq)] pub struct GeneratedLootItem { pub item_id: u32, pub count: u32, @@ -101,6 +111,7 @@ pub struct GeneratedLootItem { pub random_properties_id: i32, pub random_properties_seed: i32, pub context: u8, + pub store_item_context: LootStoreItemContext, pub free_for_all: bool, pub follow_loot_rules: bool, pub needs_quest: bool, @@ -110,7 +121,7 @@ pub struct GeneratedLootItem { pub is_counted: bool, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq)] pub struct GeneratedPersonalLootItem { pub looter: ObjectGuid, pub item: GeneratedLootItem, @@ -1468,14 +1479,18 @@ impl LootTemplate { return; } } - } else if item_allowed(LootStoreItemContext { - store_kind, - entry, - item: *item, - }) { + } else { + let store_item_context = LootStoreItemContext { + store_kind, + entry, + item: *item, + }; + if !item_allowed(store_item_context) { + continue; + } add_generated_loot_item_like_cpp( generated, - *item, + store_item_context, options.item_context, rng, item_template, @@ -1617,7 +1632,11 @@ impl LootTemplate { add_generated_personal_loot_item_like_cpp( generated, chosen_looter, - *item, + LootStoreItemContext { + store_kind, + entry, + item: *item, + }, options.item_context, rng, item_template, @@ -1656,6 +1675,8 @@ impl LootTemplate { random_properties, options.item_context, chosen_looter, + store_kind, + entry, ); } } @@ -1969,7 +1990,11 @@ impl LootGroup { { add_generated_loot_item_like_cpp( generated, - item, + LootStoreItemContext { + store_kind, + entry, + item, + }, item_context, rng, item_template, @@ -2012,18 +2037,24 @@ impl LootGroup { random_properties: &mut FRandom, item_context: u8, looter: ObjectGuid, + store_kind: LootStoreKind, + entry: u32, ) where R: Rng + ?Sized, FTemplate: FnMut(u32) -> Option, FRandom: FnMut(u32, &mut R) -> LootItemRandomProperties, { if let Some(item) = - self.roll_with_context_like_cpp(loot_mode, rng, |_| true, LootStoreKind::Creature, 0) + self.roll_with_context_like_cpp(loot_mode, rng, |_| true, store_kind, entry) { add_generated_personal_loot_item_like_cpp( generated, looter, - item, + LootStoreItemContext { + store_kind, + entry, + item, + }, item_context, rng, item_template, @@ -2035,7 +2066,7 @@ impl LootGroup { fn add_generated_loot_item_like_cpp( generated: &mut Vec, - item: LootStoreItem, + store_item_context: LootStoreItemContext, item_context: u8, rng: &mut R, item_template: &mut FTemplate, @@ -2045,6 +2076,7 @@ fn add_generated_loot_item_like_cpp( FTemplate: FnMut(u32) -> Option, FRandom: FnMut(u32, &mut R) -> LootItemRandomProperties, { + let item = store_item_context.item; let Some(metadata) = item_template(item.item_id) else { return; }; @@ -2070,6 +2102,7 @@ fn add_generated_loot_item_like_cpp( random_properties_id: random_properties.id, random_properties_seed: random_properties.seed, context: item_context, + store_item_context, free_for_all: metadata.has_multi_drop_flag, follow_loot_rules: !item.needs_quest || metadata.has_follow_loot_rules_flag, needs_quest: item.needs_quest, @@ -2085,7 +2118,7 @@ fn add_generated_loot_item_like_cpp( fn add_generated_personal_loot_item_like_cpp( generated: &mut Vec, looter: ObjectGuid, - item: LootStoreItem, + store_item_context: LootStoreItemContext, item_context: u8, rng: &mut R, item_template: &mut FTemplate, @@ -2095,6 +2128,7 @@ fn add_generated_personal_loot_item_like_cpp( FTemplate: FnMut(u32) -> Option, FRandom: FnMut(u32, &mut R) -> LootItemRandomProperties, { + let item = store_item_context.item; let Some(metadata) = item_template(item.item_id) else { return; }; @@ -2122,6 +2156,7 @@ fn add_generated_personal_loot_item_like_cpp( random_properties_id: random_properties.id, random_properties_seed: random_properties.seed, context: item_context, + store_item_context, free_for_all: metadata.has_multi_drop_flag, follow_loot_rules: !item.needs_quest || metadata.has_follow_loot_rules_flag, needs_quest: item.needs_quest, @@ -2175,13 +2210,13 @@ mod tests { LOOT_SLOT_TYPE_ROLL_ONGOING_LIKE_CPP, LootConditionId, LootConditionLinkReport, LootConditionReferenceUseLikeCpp, LootConditionRowLikeCpp, LootFillError, LootFillOptions, LootItemRandomProperties, LootItemTemplateMetadata, LootReferenceCheckReport, - LootReferenceUse, LootStore, LootStoreItem, LootStoreKind, LootStoreLoadError, LootStores, - LootTemplate, LootTemplateRow, MissingLootConditionItemTemplate, - MissingLootConditionTemplate, MissingLootConditionTemplateItem, - check_loot_condition_links_like_cpp, check_loot_condition_references_like_cpp, - check_loot_references_like_cpp, condition_compare_values_like_cpp, - generate_money_loot_with_rate_like_cpp, loot_condition_reference_ids_like_cpp, - loot_condition_reference_self_references_like_cpp, + LootReferenceUse, LootStore, LootStoreItem, LootStoreItemContext, LootStoreKind, + LootStoreLoadError, LootStores, LootTemplate, LootTemplateRow, + MissingLootConditionItemTemplate, MissingLootConditionTemplate, + MissingLootConditionTemplateItem, check_loot_condition_links_like_cpp, + check_loot_condition_references_like_cpp, check_loot_references_like_cpp, + condition_compare_values_like_cpp, generate_money_loot_with_rate_like_cpp, + loot_condition_reference_ids_like_cpp, loot_condition_reference_self_references_like_cpp, loot_condition_row_is_loadable_without_external_stores_like_cpp, loot_condition_row_normalize_without_external_stores_like_cpp, loot_conditions_allow_player_like_cpp_representable, @@ -2253,7 +2288,14 @@ mod tests { ) } - fn generated_item(item_id: u32, count: u32, loot_list_id: u32) -> GeneratedLootItem { + fn generated_item( + store_item: LootStoreItem, + count: u32, + loot_list_id: u32, + store_kind: LootStoreKind, + entry: u32, + ) -> GeneratedLootItem { + let item_id = store_item.item_id; GeneratedLootItem { item_id, count, @@ -2261,6 +2303,11 @@ mod tests { random_properties_id: item_id as i32 + 1000, random_properties_seed: item_id as i32 + 2000, context: 0, + store_item_context: LootStoreItemContext { + store_kind, + entry, + item: store_item, + }, free_for_all: false, follow_loot_rules: true, needs_quest: false, @@ -3001,9 +3048,9 @@ mod tests { assert_eq!( generated, vec![ - generated_item(25, 1, 0), - generated_item(27, 1, 1), - generated_item(26, 1, 2), + generated_item(item(25, 0, 100.0, 0), 1, 0, LootStoreKind::Creature, 100,), + generated_item(item(27, 0, 100.0, 0), 1, 1, LootStoreKind::Reference, 700,), + generated_item(item(26, 0, 0.0, 1), 1, 2, LootStoreKind::Creature, 100,), ] ); } @@ -3176,7 +3223,16 @@ mod tests { ) .unwrap(); - assert_eq!(generated, vec![generated_item(27, 1, 0)]); + assert_eq!( + generated, + vec![generated_item( + item(27, 0, 100.0, 0), + 1, + 0, + LootStoreKind::Reference, + 900 + )] + ); assert_eq!(seen, vec![(LootStoreKind::Reference, 900, 27)]); assert_eq!( stores[&LootStoreKind::Creature].condition_ids_for_fill_like_cpp( @@ -3292,6 +3348,14 @@ mod tests { random_properties_id: 4242, random_properties_seed: 2424, context: 4, + store_item_context: LootStoreItemContext { + store_kind: LootStoreKind::Item, + entry: 600, + item: LootStoreItem { + needs_quest: true, + ..item(25, 0, 100.0, 0) + }, + }, free_for_all: true, follow_loot_rules: true, needs_quest: true, diff --git a/crates/wow-map/Cargo.toml b/crates/wow-map/Cargo.toml index 3ed62440d..e9279cab8 100644 --- a/crates/wow-map/Cargo.toml +++ b/crates/wow-map/Cargo.toml @@ -17,3 +17,4 @@ rand = { workspace = true } [dev-dependencies] wow-constants = { workspace = true } +wow-loot = { workspace = true } diff --git a/crates/wow-map/src/map.rs b/crates/wow-map/src/map.rs index 0dc57c6a9..f5e17c722 100644 --- a/crates/wow-map/src/map.rs +++ b/crates/wow-map/src/map.rs @@ -8701,8 +8701,11 @@ where ) -> Result, MapObjectStoreError> { self.validate_map_object(record.object())?; let guid = record.object().guid(); - let previous = self.map_objects.remove(&guid); - if let Some(previous_record) = previous.as_ref() { + let mut previous = self.map_objects.remove(&guid); + if let Some(previous_record) = previous.as_mut() { + if !typed_loot_authorities_share_storage_like_cpp(previous_record, &record) { + detach_typed_loot_authority_like_cpp(previous_record); + } self.unindex_map_object_record_by_spawn_id_like_cpp(previous_record); } self.index_map_object_record_by_spawn_id_like_cpp(&record); @@ -10721,6 +10724,12 @@ where .get_mut(&guid) .and_then(MapObjectRecord::game_object_mut) { + // `GameObject::Delete` queues physical removal without calling + // `ClearLoot`. Terminally detach only the async authority here: + // this prevents both Arc-held claims and an async generator from + // reactivating the deleted lifetime while preserving C++'s + // interim object fields until remove-list drain. + game_object.loot_authority_like_cpp().detach_like_cpp(); game_object.set_loot_state(LootState::NotReady, None); } let remove_from_owner = self.gameobject_remove_from_owner_like_cpp(guid); @@ -10883,6 +10892,7 @@ where .get_mut(&guid) .and_then(MapObjectRecord::game_object_mut) { + game_object.loot_authority_like_cpp().detach_like_cpp(); game_object.set_loot_state(LootState::NotReady, None); } let remove_from_owner = self.gameobject_remove_from_owner_like_cpp(guid); @@ -11337,9 +11347,17 @@ where }) .ok_or(RemoveFromMapError::ObjectNotFound { guid })?; let remove_from_active = was_active.then(|| self.remove_from_active_like_cpp(guid)); - let record = self + let mut record = self .remove_map_object(guid) .ok_or(RemoveFromMapError::ObjectNotFound { guid })?; + // Rust's non-delete outcome retains only the erased + // `WorldObject`; `MapObjectRecord::into_object` still destroys the + // typed Creature/GameObject that owns its Loot. Until this API can + // return the full typed record like C++ retains the object pointer, + // both paths must terminally detach that otherwise orphaned + // authority. A stale lease may finish only if it already crossed + // the protected durable boundary. + detach_typed_loot_authority_like_cpp(&mut record); let was_world_object_like_cpp = map_record_is_world_object_like_cpp(&record); let mut object = record.into_object(); let was_in_world = remove_from_map_was_in_world; @@ -14781,6 +14799,55 @@ where } } +/// Invalidates async claims before a terminal typed object lifetime is dropped. +/// +/// C++ destroys `Creature::loot` / `GameObject::loot` together with the typed +/// object. Rust leases can retain the shared authority after that drop, so the +/// backing allocation must become permanently detached first. +fn detach_typed_loot_authority_like_cpp(record: &mut MapObjectRecord) { + match record.kind() { + AccessorObjectKind::Creature => { + if let Some(creature) = record.creature_mut() { + creature.loot_authority_like_cpp().detach_like_cpp(); + } + } + AccessorObjectKind::GameObject => { + if let Some(game_object) = record.game_object_mut() { + game_object.loot_authority_like_cpp().detach_like_cpp(); + } + } + _ => {} + } +} + +/// A same-GUID whole-entity refresh may replace the record while deliberately +/// retaining the exact object lifetime. Do not detach the shared authority in +/// that case; only a distinct backing allocation is displaced terminally. +fn typed_loot_authorities_share_storage_like_cpp( + previous: &MapObjectRecord, + replacement: &MapObjectRecord, +) -> bool { + match (previous.kind(), replacement.kind()) { + (AccessorObjectKind::Creature, AccessorObjectKind::Creature) => previous + .creature() + .zip(replacement.creature()) + .is_some_and(|(previous, replacement)| { + previous + .loot_authority_like_cpp() + .shares_storage_like_cpp(replacement.loot_authority_like_cpp()) + }), + (AccessorObjectKind::GameObject, AccessorObjectKind::GameObject) => previous + .game_object() + .zip(replacement.game_object()) + .is_some_and(|(previous, replacement)| { + previous + .loot_authority_like_cpp() + .shares_storage_like_cpp(replacement.loot_authority_like_cpp()) + }), + _ => false, + } +} + impl GridUnloadEntityStore for Map { fn creature_mut(&mut self, guid: ObjectGuid) -> Option<&mut Creature> { self.map_objects @@ -15012,6 +15079,7 @@ mod tests { VehicleAccessory, VehicleSeatAddon, VehicleSeatInfo, VehicleSpellImmunity, VehicleSpellImmunityKind, }; + use wow_loot::{CreatureLoot, LootClaimCommitError, OwnedLootAuthorityLifecycle}; const GO_FLAG_MAP_OBJECT: u32 = 0x0010_0000; @@ -18881,6 +18949,44 @@ mod tests { gameobject } + fn money_loot_for_player_like_cpp( + loot_guid: ObjectGuid, + coins: u32, + player: ObjectGuid, + ) -> CreatureLoot { + CreatureLoot { + loot_guid, + coins, + unlooted_count: 0, + loot_type: 1, + dungeon_encounter_id: 0, + loot_method: 0, + loot_master: ObjectGuid::EMPTY, + round_robin_player: ObjectGuid::EMPTY, + player_ffa_items: Vec::new(), + players_looting: Vec::new(), + allowed_looters: vec![player], + items: Vec::new(), + looted_by_player: false, + } + } + + fn poll_immediately_ready(future: F) -> F::Output { + struct NoopWake; + + impl std::task::Wake for NoopWake { + fn wake(self: std::sync::Arc) {} + } + + let waker = std::task::Waker::from(std::sync::Arc::new(NoopWake)); + let mut context = std::task::Context::from_waker(&waker); + let mut future = std::pin::pin!(future); + match future.as_mut().poll(&mut context) { + std::task::Poll::Ready(output) => output, + std::task::Poll::Pending => panic!("expected the uncontended claim to be ready"), + } + } + fn summon_gameobject_template_like_cpp( entry: u32, go_type: u32, @@ -25350,6 +25456,360 @@ mod tests { assert_eq!(object.current_cell(), None); } + #[test] + fn remove_from_map_delete_detaches_creature_loot_authority_before_typed_drop() { + let mut map = test_map(); + let player = ObjectGuid::create_player(1, 484_101); + let mut creature = test_creature_for_spawn(484_101, 4_841_010, true); + let guid = creature.guid(); + assert!( + creature + .initialize_shared_loot_authority_like_cpp(money_loot_for_player_like_cpp( + guid, 17, player, + )) + .installed() + ); + let retained_authority = creature.loot_authority_like_cpp().clone(); + let lease = poll_immediately_ready(retained_authority.reserve_money_like_cpp(player)) + .expect("the live Creature authority must grant the uncontended lease"); + creature + .unit_mut() + .world_mut() + .object_mut() + .remove_from_world(); + map.add_map_object_record_to_map_like_cpp(MapObjectRecord::new_creature(creature).unwrap()) + .unwrap(); + + let removed = map.remove_from_map_like_cpp(guid, true).unwrap(); + + assert!(removed.object.is_none()); + assert_eq!( + retained_authority.lifecycle_like_cpp(), + OwnedLootAuthorityLifecycle::Detached + ); + assert!(matches!( + lease.commit_like_cpp(), + Err(LootClaimCommitError::StaleGeneration | LootClaimCommitError::RolledBack) + )); + } + + #[test] + fn remove_from_map_nondelete_detaches_orphaned_typed_loot_authority() { + let mut map = test_map(); + let player = ObjectGuid::create_player(1, 484_109); + let mut creature = test_creature_for_spawn(484_109, 4_841_090, true); + let guid = creature.guid(); + assert!( + creature + .initialize_shared_loot_authority_like_cpp(money_loot_for_player_like_cpp( + guid, 17, player, + )) + .installed() + ); + let retained_authority = creature.loot_authority_like_cpp().clone(); + let lease = poll_immediately_ready(retained_authority.reserve_money_like_cpp(player)) + .expect("the live typed Creature owns the reservation"); + creature + .unit_mut() + .world_mut() + .object_mut() + .remove_from_world(); + map.add_map_object_record_to_map_like_cpp(MapObjectRecord::new_creature(creature).unwrap()) + .unwrap(); + + let removed = map.remove_from_map_like_cpp(guid, false).unwrap(); + + assert!( + removed.object.is_some(), + "the erased WorldObject remains available to the non-delete caller" + ); + assert_eq!( + retained_authority.lifecycle_like_cpp(), + OwnedLootAuthorityLifecycle::Detached, + "the returned erased object cannot preserve the destroyed typed loot owner" + ); + assert!(matches!( + lease.commit_like_cpp(), + Err(LootClaimCommitError::StaleGeneration | LootClaimCommitError::RolledBack) + )); + } + + #[test] + fn remove_from_map_delete_detaches_gameobject_loot_authority_before_typed_drop() { + let mut map = test_map(); + let player = ObjectGuid::create_player(1, 484_102); + let mut gameobject = test_gameobject_for_spawn(484_102, 4_841_020); + let guid = gameobject.world().guid(); + assert!( + gameobject + .initialize_shared_loot_authority_like_cpp(money_loot_for_player_like_cpp( + guid, 19, player, + )) + .installed() + ); + let retained_authority = gameobject.loot_authority_like_cpp().clone(); + let lease = poll_immediately_ready(retained_authority.reserve_money_like_cpp(player)) + .expect("the live GameObject authority must grant the uncontended lease"); + gameobject.world_mut().object_mut().remove_from_world(); + map.add_map_object_record_to_map_like_cpp( + MapObjectRecord::new_game_object(gameobject).unwrap(), + ) + .unwrap(); + + let removed = map.remove_from_map_like_cpp(guid, true).unwrap(); + + assert!(removed.object.is_none()); + assert_eq!( + retained_authority.lifecycle_like_cpp(), + OwnedLootAuthorityLifecycle::Detached + ); + assert!(matches!( + lease.commit_like_cpp(), + Err(LootClaimCommitError::StaleGeneration | LootClaimCommitError::RolledBack) + )); + } + + #[test] + fn insert_map_object_record_detaches_displaced_creature_authority_for_same_guid() { + let mut map = test_map(); + let player = ObjectGuid::create_player(1, 484_103); + let mut displaced_creature = test_creature_for_spawn(484_103, 4_841_030, true); + let guid = displaced_creature.guid(); + assert!( + displaced_creature + .initialize_shared_loot_authority_like_cpp(money_loot_for_player_like_cpp( + guid, 23, player, + )) + .installed() + ); + let displaced_authority = displaced_creature.loot_authority_like_cpp().clone(); + map.insert_map_object_record(MapObjectRecord::new_creature(displaced_creature).unwrap()) + .unwrap(); + let lease = poll_immediately_ready(displaced_authority.reserve_money_like_cpp(player)) + .expect("the displaced Creature authority must grant the uncontended lease"); + + let mut replacement = test_creature_for_spawn(484_104, 4_841_030, true); + assert!( + replacement + .initialize_shared_loot_authority_like_cpp(money_loot_for_player_like_cpp( + guid, 29, player, + )) + .installed() + ); + let replacement_authority = replacement.loot_authority_like_cpp().clone(); + let displaced = map + .insert_map_object_record(MapObjectRecord::new_creature(replacement).unwrap()) + .unwrap() + .expect("same-GUID insert must return the displaced Creature record"); + + assert_eq!( + displaced_authority.lifecycle_like_cpp(), + OwnedLootAuthorityLifecycle::Detached + ); + assert!( + displaced + .creature() + .unwrap() + .loot_authority_like_cpp() + .shares_storage_like_cpp(&displaced_authority) + ); + assert!( + map.map_object_record(guid) + .and_then(MapObjectRecord::creature) + .unwrap() + .loot_authority_like_cpp() + .shares_storage_like_cpp(&replacement_authority) + ); + assert_eq!( + replacement_authority.lifecycle_like_cpp(), + OwnedLootAuthorityLifecycle::Active + ); + assert!(matches!( + lease.commit_like_cpp(), + Err(LootClaimCommitError::StaleGeneration | LootClaimCommitError::RolledBack) + )); + } + + #[test] + fn insert_map_object_record_detaches_displaced_gameobject_authority_for_same_guid() { + let mut map = test_map(); + let player = ObjectGuid::create_player(1, 484_104); + let mut displaced_gameobject = test_gameobject_for_spawn(484_105, 4_841_040); + let guid = displaced_gameobject.world().guid(); + assert!( + displaced_gameobject + .initialize_shared_loot_authority_like_cpp(money_loot_for_player_like_cpp( + guid, 31, player, + )) + .installed() + ); + let displaced_authority = displaced_gameobject.loot_authority_like_cpp().clone(); + map.insert_map_object_record( + MapObjectRecord::new_game_object(displaced_gameobject).unwrap(), + ) + .unwrap(); + let lease = poll_immediately_ready(displaced_authority.reserve_money_like_cpp(player)) + .expect("the displaced GameObject authority must grant the uncontended lease"); + + let mut replacement = test_gameobject_for_spawn(484_106, 4_841_040); + assert!( + replacement + .initialize_shared_loot_authority_like_cpp(money_loot_for_player_like_cpp( + guid, 37, player, + )) + .installed() + ); + let replacement_authority = replacement.loot_authority_like_cpp().clone(); + let displaced = map + .insert_map_object_record(MapObjectRecord::new_game_object(replacement).unwrap()) + .unwrap() + .expect("same-GUID insert must return the displaced GameObject record"); + + assert_eq!( + displaced_authority.lifecycle_like_cpp(), + OwnedLootAuthorityLifecycle::Detached + ); + assert!( + displaced + .game_object() + .unwrap() + .loot_authority_like_cpp() + .shares_storage_like_cpp(&displaced_authority) + ); + assert!( + map.map_object_record(guid) + .and_then(MapObjectRecord::game_object) + .unwrap() + .loot_authority_like_cpp() + .shares_storage_like_cpp(&replacement_authority) + ); + assert_eq!( + replacement_authority.lifecycle_like_cpp(), + OwnedLootAuthorityLifecycle::Active + ); + assert!(matches!( + lease.commit_like_cpp(), + Err(LootClaimCommitError::StaleGeneration | LootClaimCommitError::RolledBack) + )); + } + + #[test] + fn insert_map_object_record_preserves_shared_typed_authority_for_same_guid_refresh() { + let player = ObjectGuid::create_player(1, 484_105); + + let mut creature_map = test_map(); + let mut creature = test_creature_for_spawn(484_107, 4_841_050, true); + let creature_guid = creature.guid(); + assert!( + creature + .initialize_shared_loot_authority_like_cpp(money_loot_for_player_like_cpp( + creature_guid, + 41, + player, + )) + .installed() + ); + let creature_record = MapObjectRecord::new_creature(creature).unwrap(); + let creature_refresh = creature_record.clone(); + let creature_authority = creature_record + .creature() + .unwrap() + .loot_authority_like_cpp() + .clone(); + creature_map + .insert_map_object_record(creature_record) + .unwrap(); + let creature_lease = + poll_immediately_ready(creature_authority.reserve_money_like_cpp(player)) + .expect("the shared Creature authority must grant the uncontended lease"); + + let displaced_creature = creature_map + .insert_map_object_record(creature_refresh) + .unwrap() + .expect("same-GUID refresh must return the prior Creature record"); + + assert!( + displaced_creature + .creature() + .unwrap() + .loot_authority_like_cpp() + .shares_storage_like_cpp(&creature_authority) + ); + assert_eq!( + creature_authority.lifecycle_like_cpp(), + OwnedLootAuthorityLifecycle::Active + ); + assert_eq!(creature_lease.commit_like_cpp(), Ok(true)); + assert_eq!( + creature_map + .map_object_record(creature_guid) + .and_then(MapObjectRecord::creature) + .unwrap() + .loot_authority_like_cpp() + .shared_snapshot_like_cpp() + .unwrap() + .loot + .coins, + 0 + ); + + let mut gameobject_map = test_map(); + let mut gameobject = test_gameobject_for_spawn(484_108, 4_841_060); + let gameobject_guid = gameobject.world().guid(); + assert!( + gameobject + .initialize_shared_loot_authority_like_cpp(money_loot_for_player_like_cpp( + gameobject_guid, + 43, + player, + )) + .installed() + ); + let gameobject_record = MapObjectRecord::new_game_object(gameobject).unwrap(); + let gameobject_refresh = gameobject_record.clone(); + let gameobject_authority = gameobject_record + .game_object() + .unwrap() + .loot_authority_like_cpp() + .clone(); + gameobject_map + .insert_map_object_record(gameobject_record) + .unwrap(); + let gameobject_lease = + poll_immediately_ready(gameobject_authority.reserve_money_like_cpp(player)) + .expect("the shared GameObject authority must grant the uncontended lease"); + + let displaced_gameobject = gameobject_map + .insert_map_object_record(gameobject_refresh) + .unwrap() + .expect("same-GUID refresh must return the prior GameObject record"); + + assert!( + displaced_gameobject + .game_object() + .unwrap() + .loot_authority_like_cpp() + .shares_storage_like_cpp(&gameobject_authority) + ); + assert_eq!( + gameobject_authority.lifecycle_like_cpp(), + OwnedLootAuthorityLifecycle::Active + ); + assert_eq!(gameobject_lease.commit_like_cpp(), Ok(true)); + assert_eq!( + gameobject_map + .map_object_record(gameobject_guid) + .and_then(MapObjectRecord::game_object) + .unwrap() + .loot_authority_like_cpp() + .shared_snapshot_like_cpp() + .unwrap() + .loot + .coins, + 0 + ); + } + #[test] fn remove_from_map_like_cpp_unregisters_personal_phase_tracker_from_object_owner_like_cpp() { let mut map = test_map(); @@ -30885,6 +31345,7 @@ mod tests { guid(HighGuid::Player, 4590291), GameObjectOwnedLoot::new(6, 1), ); + let loot_authority = owner.loot_authority_like_cpp().clone(); assert!(owner.add_unique_use_like_cpp(guid(HighGuid::Player, 4590292))); assert!(owner.schedule_despawn_or_unsummon_like_cpp(1, 0)); @@ -30913,6 +31374,7 @@ mod tests { assert_eq!(owner_after.personal_loot_count_like_cpp(), 1); assert_eq!(owner_after.unique_user_count_like_cpp(), 1); assert_eq!(owner_after.use_times(), 1); + assert!(loot_authority.is_retired_like_cpp()); assert!(map.map_object_record(trap_guid).is_some()); } @@ -31210,6 +31672,9 @@ mod tests { let guid = gameobject.world().guid(); gameobject.set_respawn_compatibility_mode(false); gameobject.set_represented_gameobject_data_present_like_cpp(true); + gameobject.replace_loot_authority_like_cpp(None, HashMap::new()); + let loot_authority = gameobject.loot_authority_like_cpp().clone(); + assert!(!loot_authority.is_retired_like_cpp()); map.add_map_object_record_to_map_like_cpp( MapObjectRecord::new_game_object(gameobject).unwrap(), ) @@ -31236,6 +31701,10 @@ mod tests { ); assert_eq!(map.objects_to_remove_count_like_cpp(), 1); assert_eq!(map.pool_data_like_cpp().get_spawned_objects_like_cpp(56), 0); + assert!( + loot_authority.is_retired_like_cpp(), + "queued GameObject deletion must invalidate Arc-held loot claims" + ); } #[test] diff --git a/crates/wow-network/src/accept.rs b/crates/wow-network/src/accept.rs index 21f895145..998945aca 100644 --- a/crates/wow-network/src/accept.rs +++ b/crates/wow-network/src/accept.rs @@ -20,7 +20,8 @@ use crate::group_registry::{GroupRegistry, PendingInvites}; use crate::player_registry::{GameEventQuestCompleteCommandLikeCpp, PlayerRegistry}; use crate::session_mgr::{InstanceLink, SessionManager}; use crate::world_socket::{ - AccountInfo, AccountLookup, WorldSocket, WorldSocketError, sign_enable_encryption, + AccountInfo, AccountLookup, SocketWriteFenceLikeCpp, WorldSocket, WorldSocketError, + sign_enable_encryption, }; /// C++ `World::rate_values` subset used by loot generation. @@ -191,6 +192,10 @@ pub struct SessionResources { pub login_db: Option>, pub world_db: Option>, pub guid_generator: Option>, + /// Process-wide C++ `sObjectMgr->GetGenerator()` mirror. + /// Every session must share this allocator so concurrent item creation + /// cannot select the same `item_instance.guid`. + pub item_guid_generator: Option>, pub instance_lock_mgr: Option>>, pub bank_bag_slot_prices_store: Option>, pub currency_types_store: Option>, @@ -488,6 +493,7 @@ pub struct SessionResources { /// - `AccountInfo` from the auth handshake /// - `packet_rx` — channel to receive packets from the socket /// - `send_tx` — channel to send responses back through the socket +/// - `send_write_fence` — FIFO completion fence paired with `send_tx` /// - `SessionResources` — shared resources for the session /// /// The callback should create a WorldSession and return a future that runs @@ -505,6 +511,7 @@ where AccountInfo, flume::Receiver, flume::Sender>, + SocketWriteFenceLikeCpp, Arc, ) -> Fut + Send @@ -573,7 +580,7 @@ where }; // Phase 3: Create session channels - let (pkt_rx, send_tx) = socket.create_session_channels(); + let (pkt_rx, send_tx, send_write_fence_like_cpp) = socket.create_session_channels(); // Phase 4: Split socket into read/write halves let pong_tx = send_tx.clone(); @@ -590,7 +597,13 @@ where }); // Phase 6: Spawn session update loop - let session_future = callback(account_info, pkt_rx, send_tx, res); + let session_future = callback( + account_info, + pkt_rx, + send_tx, + send_write_fence_like_cpp, + res, + ); tokio::spawn(session_future); // Phase 7: Run the encrypted read loop (blocks until disconnect) @@ -834,6 +847,7 @@ async fn handle_instance_connection( // send_rx_for_socket → instance writer reads from here let instance_link = InstanceLink { send_tx: send_tx.clone(), + send_write_fence_like_cpp: Some(socket.send_write_fence_like_cpp()), pkt_rx: Some(pkt_rx), }; diff --git a/crates/wow-network/src/group_registry.rs b/crates/wow-network/src/group_registry.rs index b1e3843ef..21a81633b 100644 --- a/crates/wow-network/src/group_registry.rs +++ b/crates/wow-network/src/group_registry.rs @@ -282,7 +282,8 @@ pub struct GroupInfo { pub members: Vec, /// C++ `Group::m_memberSlots` represented metadata. pub member_slots: Vec, - /// 0=FreeForAll, 1=RoundRobin, 2=MasterLoot, 3=GroupLoot, 4=NeedBeforeGreed + /// C++ `LootMethod` (`Loot.h`): 0=FreeForAll, 1=RoundRobin, + /// 2=MasterLoot, 3=GroupLoot, 4=NeedBeforeGreed, 5=PersonalLoot. pub loot_method: u8, pub looter_guid: ObjectGuid, pub loot_threshold: u8, diff --git a/crates/wow-network/src/lib.rs b/crates/wow-network/src/lib.rs index de953b6a0..727441f77 100644 --- a/crates/wow-network/src/lib.rs +++ b/crates/wow-network/src/lib.rs @@ -34,14 +34,22 @@ pub use group_registry::{ register_group_db_store_id_like_cpp, tick_all_group_ready_checks_like_cpp, }; pub use player_registry::{ - ApplyCreatureMeleeDamageLikeCppCommand, CreatureAttackStartLikeCppCommand, - GameEventQuestCompleteClientOutcomeLikeCpp, GameEventQuestCompleteCommandLikeCpp, - GameEventQuestCompleteResponseLikeCpp, KickLikeCppCommand, LootRollStoreWinnerCommand, - LootRollVoteCommand, MasterLootGiveCommand, MasterLootGiveResult, PlayerBroadcastInfo, - PlayerRegistry, RefreshVisibleWorldCreaturesLikeCppCommand, ResetSeasonalQuestStatusCommand, + ApplyCreatureMeleeDamageLikeCppCommand, ApplyLootMoneyLikeCppCommand, + ApplyLootMoneyResultLikeCpp, CreatureAttackStartLikeCppCommand, + DurableLootMoneyAdmissionClosedLikeCpp, DurableLootMoneyCompletionLikeCpp, + DurableLootMoneyPersistenceGuardLikeCpp, DurableLootMoneyPersistenceTrackerLikeCpp, + DurableLootMoneySaveFenceLikeCpp, GameEventQuestCompleteClientOutcomeLikeCpp, + GameEventQuestCompleteCommandLikeCpp, GameEventQuestCompleteResponseLikeCpp, + KickLikeCppCommand, LootRollCommandIdentityLikeCpp, LootRollStoreWinnerCommand, + LootRollVoteCommand, MasterLootGiveCommand, MasterLootGiveResult, + NotifyLootMoneyRemovedLikeCppCommand, PlayerBroadcastInfo, PlayerRegistry, + RefreshVisibleWorldCreaturesLikeCppCommand, ResetSeasonalQuestStatusCommand, SendAddonIfRegisteredLikeCppCommand, SendIfVisibleLikeCppCommand, - SendPartyUpdateLikeCppCommand, SendVisibleObjectValuesUpdateCommand, SessionCommand, - WorldSessionShutdownFlushLikeCppCommand, WorldSessionShutdownFlushResultLikeCpp, + SendPartyUpdateLikeCppCommand, SendRealmPacketLikeCppCommand, + SendVisibleObjectValuesUpdateCommand, SessionCommand, WorldSessionShutdownFlushLikeCppCommand, + WorldSessionShutdownFlushResultLikeCpp, }; pub use session_mgr::{InstanceLink, SessionManager}; -pub use world_socket::{AccountInfo, SocketReader, SocketWriter, WorldSocket, WorldSocketError}; +pub use world_socket::{ + AccountInfo, SocketReader, SocketWriteFenceLikeCpp, SocketWriter, WorldSocket, WorldSocketError, +}; diff --git a/crates/wow-network/src/player_registry.rs b/crates/wow-network/src/player_registry.rs index 7e8766f28..bc5351462 100644 --- a/crates/wow-network/src/player_registry.rs +++ b/crates/wow-network/src/player_registry.rs @@ -11,8 +11,13 @@ use dashmap::DashMap; use std::collections::{HashMap, HashSet}; +use std::sync::{ + Arc, Mutex, + atomic::{AtomicBool, AtomicU64}, +}; use std::time::Instant; use wow_core::{ObjectGuid, Position}; +use wow_loot::{LootClaimLease, OwnedLootAuthority}; use wow_packet::packets::loot::LootEntry; use wow_packet::packets::party::{ PartyMemberAuraState, PartyMemberPetStats, PartyMemberPhaseStates, PartyUpdate, @@ -24,6 +29,8 @@ pub enum SessionCommand { WorldSessionShutdownFlushLikeCpp(WorldSessionShutdownFlushLikeCppCommand), ApplyCreatureMeleeDamageLikeCpp(ApplyCreatureMeleeDamageLikeCppCommand), CreatureAttackStartLikeCpp(CreatureAttackStartLikeCppCommand), + ApplyLootMoneyLikeCpp(ApplyLootMoneyLikeCppCommand), + NotifyLootMoneyRemovedLikeCpp(NotifyLootMoneyRemovedLikeCppCommand), MasterLootGive(MasterLootGiveCommand), LootRollStoreWinner(LootRollStoreWinnerCommand), LootRollVote(LootRollVoteCommand), @@ -41,6 +48,13 @@ pub enum SessionCommand { /// Deliver `PartyUpdate` from the receiver's own session so C++ /// `Player::NextGroupUpdateSequenceNumber` is consumed per player. SendPartyUpdateLikeCpp(SendPartyUpdateLikeCppCommand), + /// Deliver an already-serialized packet on the receiver's realm socket. + /// + /// C++ assigns party-control packets such as `SMSG_PARTY_INVITE` to + /// `CONNECTION_TYPE_REALM`. The shared player registry intentionally owns + /// only the primary (instance after ConnectTo) packet sender, so remote + /// realm delivery must be executed by the target session itself. + SendRealmPacketLikeCpp(SendRealmPacketLikeCppCommand), /// Apply C++ `Group::Disband`/`Group::RemoveMember` session-local cleanup /// for a connected remote member. ApplyGroupRemovalLikeCpp(ApplyGroupRemovalLikeCppCommand), @@ -121,10 +135,21 @@ pub struct WorldSessionShutdownFlushResultLikeCpp { /// Payload for C++ `Group::SendUpdateToPlayer`. #[derive(Clone, Debug)] pub struct SendPartyUpdateLikeCppCommand { + /// Character that owned the registry entry when the command was queued. + /// A `WorldSession` survives character logout, so the receiver must reject + /// a delayed update after that session selects another character. + pub recipient: ObjectGuid, pub party_update: PartyUpdate, pub member_full_state_packets: Vec>, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SendRealmPacketLikeCppCommand { + /// Character that owned the registry entry when the packet was queued. + pub recipient: ObjectGuid, + pub packet_bytes: Vec, +} + /// Payload for [`SessionCommand::ApplyGroupRemovalLikeCpp`]. /// /// C++ `Group::RemoveMember` and `Group::Disband` mutate the connected @@ -421,6 +446,9 @@ pub struct MasterLootGiveCommand { pub loot_list_id: u8, pub dungeon_encounter_id: u32, pub entry: LootEntry, + /// Cloneable claim ownership. If the requester times out, the command's + /// clone keeps the slot reserved until this target commits or drops it. + pub claim: Option, pub result_tx: flume::Sender, } @@ -437,10 +465,388 @@ pub struct LootRollStoreWinnerCommand { pub loot_obj: ObjectGuid, pub loot_list_id: u8, pub dungeon_encounter_id: u32, - pub entry: LootEntry, + /// One normal roll award or the complete generated disenchant result. + /// + /// C++ `LootRoll::Finish` generates every disenchant material before it + /// starts storing them. Keeping the generated result in one command lets + /// the target session preflight and persist the complete result in one + /// transaction instead of acknowledging one material at a time. + pub entries: Vec, + pub is_disenchant: bool, + /// See [`MasterLootGiveCommand::claim`]. + pub claim: Option, pub result_tx: flume::Sender, } +/// Session-local application of one already-durable C++ shared-loot payout. +/// +/// The source-side detached persistence worker creates this command only after +/// the complete group transaction and the object-owned money claim have both +/// committed. No target acknowledgement participates in the persistence +/// decision, so two sessions looting concurrently cannot wait on each other's +/// command loops. +#[derive(Clone, Debug)] +pub struct ApplyLootMoneyLikeCppCommand { + pub recipient: ObjectGuid, + pub loot_owner: ObjectGuid, + pub loot_obj: ObjectGuid, + /// C++ share advertised by `SMSG_LOOT_MONEY_NOTIFY`. + pub amount: u64, + /// Delta that the locked character row actually accepted. This is shared + /// with the detached worker so command delivery order cannot make runtime + /// gold disagree with the durable cap decision. + pub durable_applied_amount: Arc, + /// Target character's directly shared persistence fence. The detached + /// worker registers it before opening SQL; no command acknowledgement is + /// part of the transaction decision. + pub durable_persistence_tracker: Arc, + pub sole_looter: bool, + /// Exact backing allocation whose scope epoch is recorded below. Epochs + /// restart in a newly allocated authority, so the number alone is not an + /// object-lifetime identity. + pub authority: OwnedLootAuthority, + pub authority_generation: u64, + /// True only if the object-owned claim committed for the recorded generation. + /// SQL may already be durable when lifecycle replacement makes this false; + /// the payout still applies, but must not touch the replacement loot pool. + pub authority_committed: Arc, + /// This recipient was also viewing the pool, so its own session emits + /// `CoinRemoved` immediately before `LootMoneyNotify`. + pub send_coin_removed: Arc, + /// The source handler may apply its own share immediately after awaiting + /// the detached worker while the same command remains queued as the + /// cancellation fallback. This gate makes those two paths exact-once. + pub applied: Arc, + /// Packet/criteria publication can occur after a save fence has already + /// reconciled the durable delta. Keep its exact-once gate separate from + /// the balance mutation gate. + pub published: Arc, +} + +/// Durable result of one character-row money mutation. +/// +/// The detached SQL worker records this before publishing its session command. +/// Both paths share [`Self::applied`], so logout reconciliation and normal +/// command delivery cannot apply the same durable delta twice. +#[derive(Clone, Debug)] +pub struct DurableLootMoneyCompletionLikeCpp { + pub durable_money_before: u64, + pub durable_money_after: u64, + pub durable_applied_amount: u64, + pub applied: Arc, +} + +#[derive(Debug, Default)] +struct DurableLootMoneyPersistenceStateLikeCpp { + in_flight: usize, + completions: Vec, + indeterminate: bool, + admission_closed: bool, + permanently_closed: bool, + active_save_fences: usize, +} + +/// Per-character fence for detached loot-money transactions. +/// +/// A tracker is published in [`PlayerBroadcastInfo`] and registered directly +/// by a source session before it opens a transaction for every recipient. This +/// avoids command acknowledgements (and their A↔B deadlocks), while allowing a +/// target session to wait for and reconcile durable completions before an +/// absolute `Player::SaveToDB` money write. +#[derive(Debug)] +pub struct DurableLootMoneyPersistenceTrackerLikeCpp { + state: Mutex, + changed: tokio::sync::watch::Sender, + money_mutation_serial: Arc>, +} + +impl Default for DurableLootMoneyPersistenceTrackerLikeCpp { + fn default() -> Self { + let (changed, _) = tokio::sync::watch::channel(0); + Self { + state: Mutex::new(DurableLootMoneyPersistenceStateLikeCpp::default()), + changed, + money_mutation_serial: Arc::new(tokio::sync::Mutex::new(())), + } + } +} + +impl DurableLootMoneyPersistenceTrackerLikeCpp { + /// Serialize DB money mutations for this character across stored-item and + /// group payouts. Multi-recipient workers acquire these locks in sorted + /// GUID order before taking the matching character-row locks. + pub async fn lock_money_mutation_like_cpp(&self) -> tokio::sync::OwnedMutexGuard<()> { + Arc::clone(&self.money_mutation_serial).lock_owned().await + } + + #[must_use] + pub fn begin_like_cpp( + self: &Arc, + ) -> Result + { + let mut state = self + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if state.admission_closed { + return Err(DurableLootMoneyAdmissionClosedLikeCpp); + } + state.in_flight += 1; + drop(state); + let _ = self.changed.send_modify(|generation| { + *generation = generation.wrapping_add(1); + }); + Ok(DurableLootMoneyPersistenceGuardLikeCpp { + tracker: Arc::clone(self), + resolved: false, + }) + } + + /// Close admission before observing `in_flight` and keep it closed across + /// snapshot plus SQL commit. Either a source registers first and the save + /// waits, or the save closes first and the source fails before BEGIN. + #[must_use] + pub fn close_admission_for_save_like_cpp(self: &Arc) -> DurableLootMoneySaveFenceLikeCpp { + let mut state = self + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + state.active_save_fences = state.active_save_fences.saturating_add(1); + state.admission_closed = true; + DurableLootMoneySaveFenceLikeCpp { + tracker: Arc::clone(self), + } + } + + /// Logout closes the old registry-published tracker permanently. A source + /// that cloned it before unregister cannot mutate the character after its + /// final save. + pub fn close_admission_permanently_like_cpp(&self) { + let mut state = self + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + state.admission_closed = true; + state.permanently_closed = true; + } + + pub async fn wait_until_idle_like_cpp(&self) { + let mut changed = self.changed.subscribe(); + loop { + if self + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .in_flight + == 0 + { + return; + } + if changed.changed().await.is_err() { + return; + } + } + } + + /// Returns every completion whose shared exact-once gate is still open. + /// Applied entries are pruned only after their CAS is observable. + #[must_use] + pub fn pending_completions_like_cpp(&self) -> Vec { + let mut state = self + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + state.completions.retain(|completion| { + !completion + .applied + .load(std::sync::atomic::Ordering::Acquire) + }); + state.completions.clone() + } + + #[must_use] + pub fn is_indeterminate_like_cpp(&self) -> bool { + self.state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .indeterminate + } + + pub fn mark_indeterminate_like_cpp(&self) { + { + let mut state = self + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + state.indeterminate = true; + state.admission_closed = true; + state.permanently_closed = true; + } + let _ = self.changed.send_modify(|generation| { + *generation = generation.wrapping_add(1); + }); + } + + fn finish_like_cpp( + &self, + completion: Option, + indeterminate: bool, + ) { + { + let mut state = self + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + debug_assert!(state.in_flight != 0); + state.in_flight = state.in_flight.saturating_sub(1); + if let Some(completion) = completion { + state.completions.push(completion); + } + state.indeterminate |= indeterminate; + if indeterminate { + state.admission_closed = true; + state.permanently_closed = true; + } + } + let _ = self.changed.send_modify(|generation| { + *generation = generation.wrapping_add(1); + }); + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DurableLootMoneyAdmissionClosedLikeCpp; + +impl std::fmt::Display for DurableLootMoneyAdmissionClosedLikeCpp { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("character money persistence admission is closed") + } +} + +impl std::error::Error for DurableLootMoneyAdmissionClosedLikeCpp {} + +#[derive(Debug)] +pub struct DurableLootMoneySaveFenceLikeCpp { + tracker: Arc, +} + +impl Drop for DurableLootMoneySaveFenceLikeCpp { + fn drop(&mut self) { + let mut state = self + .tracker + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + debug_assert!(state.active_save_fences != 0); + state.active_save_fences = state.active_save_fences.saturating_sub(1); + if state.active_save_fences == 0 && !state.permanently_closed { + state.admission_closed = false; + } + } +} + +/// RAII registration for one recipient's durable money mutation. +#[derive(Debug)] +pub struct DurableLootMoneyPersistenceGuardLikeCpp { + tracker: Arc, + resolved: bool, +} + +impl DurableLootMoneyPersistenceGuardLikeCpp { + pub fn commit_like_cpp(&mut self, completion: DurableLootMoneyCompletionLikeCpp) { + if self.resolved { + return; + } + self.resolved = true; + self.tracker.finish_like_cpp(Some(completion), false); + } + + /// A COMMIT was attempted but its outcome could not be reconciled. The + /// target must skip absolute money saves until it disconnects/reloads. + pub fn mark_indeterminate_like_cpp(&mut self) { + if self.resolved { + return; + } + self.resolved = true; + self.tracker.finish_like_cpp(None, true); + } +} + +impl Drop for DurableLootMoneyPersistenceGuardLikeCpp { + fn drop(&mut self) { + if !self.resolved { + self.tracker.finish_like_cpp(None, false); + self.resolved = true; + } + } +} + +/// Durable `Loot::NotifyMoneyRemoved` delivery for an active viewer that is +/// not itself a payout recipient. +#[derive(Clone, Debug)] +pub struct NotifyLootMoneyRemovedLikeCppCommand { + pub recipient: ObjectGuid, + pub loot_owner: ObjectGuid, + pub loot_obj: ObjectGuid, + pub authority: OwnedLootAuthority, + pub authority_generation: u64, + pub authority_committed: Arc, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ApplyLootMoneyResultLikeCpp { + Applied, + PersistenceFailed, + TargetMismatch, +} + +#[derive(Clone, Debug)] +pub struct LootRollCommandIdentityLikeCpp { + loot_obj: ObjectGuid, + loot_list_id: u8, + authority: OwnedLootAuthority, + authority_generation: u64, + /// Pointer-identity equivalent of the exact C++ `LootRoll*` registered in + /// `Player::m_lootRolls` (`Player.cpp::GetLootRoll` / `RemoveLootRoll` and + /// `Loot.cpp::LootRoll::~LootRoll`). A replacement roll may reuse both + /// packet key and loot generation, so those values alone cannot reject a + /// queued old vote. + roll_instance: Arc<()>, +} + +impl LootRollCommandIdentityLikeCpp { + #[must_use] + pub fn new_like_cpp( + loot_obj: ObjectGuid, + loot_list_id: u8, + authority: OwnedLootAuthority, + authority_generation: u64, + ) -> Self { + Self { + loot_obj, + loot_list_id, + authority, + authority_generation, + roll_instance: Arc::new(()), + } + } + + #[must_use] + pub fn matches_key_like_cpp(&self, loot_obj: ObjectGuid, loot_list_id: u8) -> bool { + self.loot_obj == loot_obj && self.loot_list_id == loot_list_id + } + + /// Mirrors the lifetime identity of the C++ `LootRoll*`, while also + /// fail-closing if an authority allocation or generation was replaced. + #[must_use] + pub fn is_exact_roll_like_cpp(&self, other: &Self) -> bool { + self.matches_key_like_cpp(other.loot_obj, other.loot_list_id) + && self.authority_generation == other.authority_generation + && self.authority.shares_storage_like_cpp(&other.authority) + && Arc::ptr_eq(&self.roll_instance, &other.roll_instance) + } +} + #[derive(Clone, Debug)] pub struct LootRollVoteCommand { pub voter_guid: ObjectGuid, @@ -448,6 +854,9 @@ pub struct LootRollVoteCommand { pub loot_list_id: u8, pub roll_type: u8, pub pass_on_group_loot: bool, + /// Immutable enqueue-time identity of the exact roll instance. C++ queues + /// no cross-session surrogate; its player retains the exact `LootRoll*`. + pub roll_identity: LootRollCommandIdentityLikeCpp, } /// Information stored for each active player session. @@ -472,8 +881,12 @@ pub struct PlayerBroadcastInfo { pub send_tx: flume::Sender>, /// Channel used for C++-style cross-session state mutations. pub command_tx: flume::Sender, - /// Represented pending loot-roll keys owned by this session. - pub active_loot_rolls: Vec<(ObjectGuid, u8)>, + /// Per-character durable loot-money fence used by remote source sessions. + pub durable_loot_money_tracker_like_cpp: Arc, + /// Exact represented pending loot-roll identities owned by this session. + /// The packet key may be reused, so cross-session routing must clone this + /// identity into the queued command rather than publishing keys alone. + pub active_loot_rolls: Vec, /// Current `Player::GetPassOnGroupLoot()` state for group/NBG roll startup. pub pass_on_group_loot: bool, /// Represented `Player::GetSkillValue(SKILL_ENCHANTING)` used by group-roll disenchant masks. @@ -631,6 +1044,7 @@ mod tests { is_in_world: true, send_tx, command_tx, + durable_loot_money_tracker_like_cpp: Default::default(), active_loot_rolls: Vec::new(), pass_on_group_loot: false, enchanting_skill: 0, @@ -784,4 +1198,96 @@ mod tests { assert_eq!(cmd.map_id, 571); assert_eq!(cmd.instance_id, 4); } + + #[tokio::test] + async fn durable_money_save_fence_closes_admission_then_waits_for_prior_worker() { + let tracker = Arc::new(DurableLootMoneyPersistenceTrackerLikeCpp::default()); + let worker = tracker.begin_like_cpp().unwrap(); + let save_fence = tracker.close_admission_for_save_like_cpp(); + + assert!(tracker.begin_like_cpp().is_err()); + let wait_tracker = Arc::clone(&tracker); + let waiter = tokio::spawn(async move { + wait_tracker.wait_until_idle_like_cpp().await; + }); + tokio::task::yield_now().await; + assert!(!waiter.is_finished()); + + drop(worker); + tokio::time::timeout(std::time::Duration::from_secs(1), waiter) + .await + .expect("prior durable worker must release the save wait") + .unwrap(); + assert!(tracker.begin_like_cpp().is_err()); + + drop(save_fence); + assert!(tracker.begin_like_cpp().is_ok()); + } + + #[test] + fn durable_money_permanent_logout_fence_never_reopens() { + let tracker = Arc::new(DurableLootMoneyPersistenceTrackerLikeCpp::default()); + let save_fence = tracker.close_admission_for_save_like_cpp(); + tracker.close_admission_permanently_like_cpp(); + drop(save_fence); + + assert!(tracker.begin_like_cpp().is_err()); + } + + #[test] + fn overlapping_durable_money_save_fences_reopen_only_after_last_drop() { + let tracker = Arc::new(DurableLootMoneyPersistenceTrackerLikeCpp::default()); + let first = tracker.close_admission_for_save_like_cpp(); + let second = tracker.close_admission_for_save_like_cpp(); + + drop(first); + assert!( + tracker.begin_like_cpp().is_err(), + "dropping the first fence must not reopen admission under the second" + ); + drop(second); + assert!(tracker.begin_like_cpp().is_ok()); + } + + #[test] + fn durable_money_completion_uses_one_shared_exact_once_gate() { + let tracker = Arc::new(DurableLootMoneyPersistenceTrackerLikeCpp::default()); + let mut worker = tracker.begin_like_cpp().unwrap(); + let applied = Arc::new(AtomicBool::new(false)); + worker.commit_like_cpp(DurableLootMoneyCompletionLikeCpp { + durable_money_before: 40, + durable_money_after: 47, + durable_applied_amount: 7, + applied: Arc::clone(&applied), + }); + + let pending = tracker.pending_completions_like_cpp(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].durable_money_before, 40); + assert_eq!(pending[0].durable_money_after, 47); + assert_eq!(pending[0].durable_applied_amount, 7); + assert!( + pending[0] + .applied + .compare_exchange( + false, + true, + std::sync::atomic::Ordering::AcqRel, + std::sync::atomic::Ordering::Acquire, + ) + .is_ok() + ); + assert!(tracker.pending_completions_like_cpp().is_empty()); + } + + #[test] + fn indeterminate_money_outcome_permanently_closes_admission_until_relogin() { + let tracker = Arc::new(DurableLootMoneyPersistenceTrackerLikeCpp::default()); + let fence = tracker.close_admission_for_save_like_cpp(); + tracker.mark_indeterminate_like_cpp(); + drop(fence); + + assert!(tracker.is_indeterminate_like_cpp()); + assert!(tracker.begin_like_cpp().is_err()); + } } diff --git a/crates/wow-network/src/session_mgr.rs b/crates/wow-network/src/session_mgr.rs index 0147acd1d..185e5b90d 100644 --- a/crates/wow-network/src/session_mgr.rs +++ b/crates/wow-network/src/session_mgr.rs @@ -27,6 +27,9 @@ struct PendingEntry { pub struct InstanceLink { /// New send channel — session writes to this, instance socket reads from it. pub send_tx: flume::Sender>, + /// Write fence paired with `send_tx`. `None` is accepted only by local + /// fallback/tests that keep using the already-installed physical socket. + pub send_write_fence_like_cpp: Option, /// Packet receiver — session reads decoded packets from the instance socket here. /// `None` in fallback mode (direct login on realm socket — keep existing packet_rx). pub pkt_rx: Option>, diff --git a/crates/wow-network/src/world_socket.rs b/crates/wow-network/src/world_socket.rs index 1d515009c..5cadc777a 100644 --- a/crates/wow-network/src/world_socket.rs +++ b/crates/wow-network/src/world_socket.rs @@ -17,13 +17,14 @@ //! 7. Client sends `EnterEncryptedModeAck` //! 8. All subsequent packets are AES-128-GCM encrypted +use std::collections::HashMap; use std::fs; use std::future::Future; use std::net::SocketAddr; use std::path::Path; use std::pin::Pin; -use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use bytes::BytesMut; @@ -78,6 +79,113 @@ const INSTANCE_CONNECTION_ID_LIKE_CPP: u32 = 1; static PACKET_DUMP_SEQUENCE: AtomicU64 = AtomicU64::new(1); +// Internal transport marker. A real server packet can never use NULL_OPCODE, +// so this byte sequence cannot collide with valid packet bytes. +const WRITE_FENCE_PREFIX_LIKE_CPP: [u8; 8] = [0, 0, b'R', b'C', b'F', b'E', b'N', b'C']; + +#[derive(Default)] +struct SocketWriteFenceStateLikeCpp { + next_id: AtomicU64, + pending: Mutex>>, +} + +/// Cancellation guard for one pending marker acknowledgement. +/// +/// A session handler can be dropped while `send_async` is waiting for bounded +/// channel capacity. In that case no marker will ever reach the writer, so +/// the pending sender must be removed by `Drop` rather than by an async tail. +struct PendingSocketWriteFenceLikeCpp { + state: Arc, + id: u64, +} + +impl Drop for PendingSocketWriteFenceLikeCpp { + fn drop(&mut self) { + self.state + .pending + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .remove(&self.id); + } +} + +/// FIFO write fence for one physical world socket. +/// +/// TrinityCore records/sends `SendDirectMessage` synchronously, while RustyCore +/// has independent realm and instance writer tasks. A fence marker is queued +/// after a realm packet and acknowledged by that socket's writer only after it +/// has fully written every earlier packet. This lets a caller defer an instance +/// update without relying on scheduler timing or `flume::Sender::is_empty()`. +#[derive(Clone, Default)] +pub struct SocketWriteFenceLikeCpp { + state: Arc, +} + +impl SocketWriteFenceLikeCpp { + fn marker_like_cpp(id: u64) -> Vec { + let mut marker = Vec::with_capacity(16); + marker.extend_from_slice(&WRITE_FENCE_PREFIX_LIKE_CPP); + marker.extend_from_slice(&id.to_le_bytes()); + marker + } + + fn marker_id_like_cpp(data: &[u8]) -> Option { + if data.len() != 16 || data[..8] != WRITE_FENCE_PREFIX_LIKE_CPP { + return None; + } + Some(u64::from_le_bytes(data[8..16].try_into().ok()?)) + } + + fn acknowledge_marker_like_cpp(&self, data: &[u8]) -> bool { + let Some(id) = Self::marker_id_like_cpp(data) else { + return false; + }; + let acknowledgement = self + .state + .pending + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .remove(&id); + if let Some(acknowledgement) = acknowledgement { + let _ = acknowledgement.send(()); + } + true + } + + /// Wait until this socket's writer has written every packet queued before + /// the fence. Returns false if the channel or writer cannot acknowledge the + /// bounded fence; callers must keep durable gameplay state committed. + pub async fn wait_for_prior_packets_written_like_cpp( + &self, + send_tx: &flume::Sender>, + timeout: Duration, + ) -> bool { + let id = self.state.next_id.fetch_add(1, Ordering::Relaxed); + let (acknowledgement_tx, acknowledgement_rx) = tokio::sync::oneshot::channel(); + self.state + .pending + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert(id, acknowledgement_tx); + let _pending = PendingSocketWriteFenceLikeCpp { + state: self.state.clone(), + id, + }; + + if !matches!( + tokio::time::timeout(timeout, send_tx.send_async(Self::marker_like_cpp(id))).await, + Ok(Ok(())) + ) { + return false; + } + + match tokio::time::timeout(timeout, acknowledgement_rx).await { + Ok(Ok(())) => true, + Ok(Err(_)) | Err(_) => false, + } + } +} + // Build-specific auth seeds are loaded from the `build_info` DB table at startup // and stored in SessionResources. They are passed to AccountInfo during lookup. @@ -272,6 +380,8 @@ pub struct WorldSocket { // Outbound channel — receives serialized bytes from WorldSession send_rx: Option>>, + // Sidecar state for internal FIFO markers acknowledged by SocketWriter. + send_write_fence_like_cpp: SocketWriteFenceLikeCpp, // Persistent compression stream for direct encrypted sends. compressor: compression::PacketCompressor, @@ -317,6 +427,7 @@ impl WorldSocket { session_key: None, session_tx: None, send_rx: None, + send_write_fence_like_cpp: SocketWriteFenceLikeCpp::default(), compressor: compression::PacketCompressor::new(), state: SocketState::Uninitialized, account_info: None, @@ -629,6 +740,11 @@ impl WorldSocket { self.send_rx = Some(rx); } + /// Clone the write fence paired with this physical socket's send channel. + pub fn send_write_fence_like_cpp(&self) -> SocketWriteFenceLikeCpp { + self.send_write_fence_like_cpp.clone() + } + // ── Packet I/O ──────────────────────────────────────────────── /// Send a server packet WITHOUT encryption (used during handshake). @@ -852,18 +968,23 @@ impl WorldSocket { /// Set up session channels and return the receivers/sender for session creation. /// - /// Returns `(packet_rx, send_tx)` — the session reads packets from `packet_rx` - /// and writes responses via `send_tx`. + /// Returns `(packet_rx, send_tx, write_fence)` — the session reads packets + /// from `packet_rx`, writes responses via `send_tx`, and can fence that + /// physical writer when cross-connection C++ ordering requires it. pub fn create_session_channels( &mut self, - ) -> (flume::Receiver, flume::Sender>) { + ) -> ( + flume::Receiver, + flume::Sender>, + SocketWriteFenceLikeCpp, + ) { let (pkt_tx, pkt_rx) = flume::bounded(256); let (send_tx, send_rx) = flume::bounded(256); self.session_tx = Some(pkt_tx); self.send_rx = Some(send_rx); - (pkt_rx, send_tx) + (pkt_rx, send_tx, self.send_write_fence_like_cpp()) } /// Run the encrypted read loop, forwarding packets to the session channel. @@ -992,6 +1113,7 @@ impl WorldSocket { writer: write_half, crypt: WorldCrypt::new_with_server_counter(&encrypt_key, self.unencrypted_packets_sent), send_rx, + send_write_fence_like_cpp: self.send_write_fence_like_cpp, addr: self.addr, connection_id: self.connection_id, compressor: compression::PacketCompressor::new(), @@ -1156,6 +1278,7 @@ pub struct SocketWriter { writer: tokio::net::tcp::OwnedWriteHalf, crypt: WorldCrypt, send_rx: flume::Receiver>, + send_write_fence_like_cpp: SocketWriteFenceLikeCpp, addr: SocketAddr, connection_id: u32, compressor: compression::PacketCompressor, @@ -1175,6 +1298,13 @@ impl SocketWriter { } }; + if self + .send_write_fence_like_cpp + .acknowledge_marker_like_cpp(&data) + { + continue; + } + self.write_encrypted(&data).await?; } } @@ -1378,6 +1508,132 @@ fn should_compress_server_packet_like_cpp(data: &[u8]) -> bool { mod tests { use super::*; + #[tokio::test] + async fn write_fence_acknowledges_only_after_prior_fifo_packet_like_cpp() { + let fence = SocketWriteFenceLikeCpp::default(); + let (send_tx, send_rx) = flume::bounded::>(4); + let prior_packet = vec![0x23, 0x26, 0xAA]; + send_tx.send(prior_packet.clone()).unwrap(); + + let wait_fence = { + let fence = fence.clone(); + let send_tx = send_tx.clone(); + tokio::spawn(async move { + fence + .wait_for_prior_packets_written_like_cpp(&send_tx, Duration::from_millis(250)) + .await + }) + }; + + assert_eq!(send_rx.recv_async().await.unwrap(), prior_packet); + let marker = send_rx.recv_async().await.unwrap(); + assert!(SocketWriteFenceLikeCpp::marker_id_like_cpp(&marker).is_some()); + assert!(!wait_fence.is_finished()); + assert!(fence.acknowledge_marker_like_cpp(&marker)); + assert!(wait_fence.await.unwrap()); + } + + #[tokio::test] + async fn cancelled_write_fence_removes_pending_acknowledgement_like_cpp() { + let fence = SocketWriteFenceLikeCpp::default(); + let (send_tx, _send_rx) = flume::bounded::>(1); + send_tx.send(vec![0x23, 0x26, 0xAA]).unwrap(); + + let wait_fence = { + let fence = fence.clone(); + let send_tx = send_tx.clone(); + tokio::spawn(async move { + fence + .wait_for_prior_packets_written_like_cpp(&send_tx, Duration::from_secs(30)) + .await + }) + }; + + for _ in 0..100 { + if !fence + .state + .pending + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .is_empty() + { + break; + } + tokio::task::yield_now().await; + } + assert_eq!( + fence + .state + .pending + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .len(), + 1 + ); + + wait_fence.abort(); + assert!(wait_fence.await.unwrap_err().is_cancelled()); + assert!( + fence + .state + .pending + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .is_empty() + ); + } + + #[tokio::test] + async fn cancelled_after_marker_dequeued_still_consumes_marker_without_null_opcode_write_like_cpp() + { + let fence = SocketWriteFenceLikeCpp::default(); + let (send_tx, send_rx) = flume::bounded::>(1); + let wait_fence = { + let fence = fence.clone(); + let send_tx = send_tx.clone(); + tokio::spawn(async move { + fence + .wait_for_prior_packets_written_like_cpp(&send_tx, Duration::from_secs(30)) + .await + }) + }; + + // The writer has already dequeued the internal marker. Cancellation + // now removes only the pending oneshot; it cannot remove this byte + // sequence from the writer's local variable. + let marker = send_rx.recv_async().await.unwrap(); + assert!(SocketWriteFenceLikeCpp::marker_id_like_cpp(&marker).is_some()); + assert_eq!( + fence + .state + .pending + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .len(), + 1 + ); + wait_fence.abort(); + assert!(wait_fence.await.unwrap_err().is_cancelled()); + assert!( + fence + .state + .pending + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .is_empty() + ); + + // This is the exact first branch in `SocketWriter::run`: marker + // recognition is independent of whether an acknowledgement sender is + // still pending, so the bytes are consumed by `continue` and can + // never reach `write_encrypted` as NULL_OPCODE. + let mut bytes_passed_to_writer = Vec::new(); + if !fence.acknowledge_marker_like_cpp(&marker) { + bytes_passed_to_writer.push(marker); + } + assert!(bytes_passed_to_writer.is_empty()); + } + #[test] fn hex_conversion() { let bytes = hex_to_bytes("DEADBEEF"); diff --git a/crates/wow-packet/Cargo.toml b/crates/wow-packet/Cargo.toml index 9b8d84041..d124ac5cc 100644 --- a/crates/wow-packet/Cargo.toml +++ b/crates/wow-packet/Cargo.toml @@ -13,4 +13,5 @@ tracing = { workspace = true } wow-core = { workspace = true } wow-constants = { workspace = true } wow-movement = { workspace = true } +wow-loot = { workspace = true } thiserror = { workspace = true } diff --git a/crates/wow-packet/src/packets/loot.rs b/crates/wow-packet/src/packets/loot.rs index dc8172770..07ec613fa 100644 --- a/crates/wow-packet/src/packets/loot.rs +++ b/crates/wow-packet/src/packets/loot.rs @@ -7,6 +7,7 @@ use wow_constants::{ClientOpcodes, ServerOpcodes}; use wow_core::ObjectGuid; +pub use wow_loot::{CreatureLoot, LootEntry, LootEntryFlags, NotNormalLootItem}; use crate::packets::item::ItemInstance; use crate::world_packet::{PacketError, WorldPacket}; @@ -25,6 +26,7 @@ pub const LOOT_RESPONSE_DEFAULT_FAILURE_REASON_LIKE_CPP: u8 = LOOT_ERROR_NO_LOOT pub const LOOT_TYPE_NONE_LIKE_CPP: u8 = 0; pub const LOOT_TYPE_CORPSE_LIKE_CPP: u8 = 1; +pub const LOOT_TYPE_PICKPOCKETING_LIKE_CPP: u8 = 2; pub const LOOT_TYPE_FISHING_LIKE_CPP: u8 = 3; pub const LOOT_TYPE_DISENCHANTING_LIKE_CPP: u8 = 4; pub const LOOT_TYPE_ITEM_LIKE_CPP: u8 = 5; @@ -582,115 +584,8 @@ impl ServerPacket for MasterLootCandidateList { // ── In-memory loot tracking ────────────────────────────────────── -/// Server-side loot state for one dead creature. -#[derive(Debug, Clone)] -pub struct CreatureLoot { - pub loot_guid: ObjectGuid, - pub coins: u32, - pub unlooted_count: u8, - pub loot_type: u8, - pub dungeon_encounter_id: u32, - pub loot_method: u8, - pub loot_master: ObjectGuid, - pub round_robin_player: ObjectGuid, - pub player_ffa_items: Vec<(ObjectGuid, Vec)>, - pub players_looting: Vec, - pub allowed_looters: Vec, - pub items: Vec, - pub looted_by_player: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct NotNormalLootItem { - pub loot_list_id: u8, - pub is_looted: bool, -} - -#[derive(Debug, Clone)] -pub struct LootEntry { - pub loot_list_id: u8, - pub item_id: u32, - pub quantity: u32, - pub random_properties_id: i32, - pub random_properties_seed: i32, - pub item_context: u8, - pub flags: LootEntryFlags, - pub allowed_looters: Vec, - pub roll_winner: ObjectGuid, - pub ffa_looted_by: Vec, - pub taken: bool, -} - pub const LOOT_SLOT_TYPE_OWNER_LIKE_CPP: u8 = 4; -impl LootEntry { - pub fn free_for_all_ui_type_like_cpp(&self) -> u8 { - LOOT_SLOT_TYPE_OWNER_LIKE_CPP - } - - pub fn is_over_threshold_like_cpp(&self) -> bool { - !self.flags.under_threshold && !self.flags.freeforall - } - - pub fn visible_in_represented_free_for_all_view_like_cpp(&self, player: ObjectGuid) -> bool { - !self.is_looted_for_player_like_cpp(player) && self.has_allowed_looter_like_cpp(player) - } - - pub fn add_allowed_looter_like_cpp(&mut self, player: ObjectGuid) { - if !player.is_empty() && !self.allowed_looters.contains(&player) { - self.allowed_looters.push(player); - } - } - - pub fn has_allowed_looter_like_cpp(&self, player: ObjectGuid) -> bool { - self.allowed_looters.contains(&player) - } - - pub fn roll_winner_allows_like_cpp(&self, player: ObjectGuid) -> bool { - self.roll_winner.is_empty() || self.roll_winner == player - } - - pub fn is_looted_for_player_like_cpp(&self, player: ObjectGuid) -> bool { - if self.flags.freeforall { - self.ffa_looted_by.contains(&player) - } else { - self.taken - } - } - - pub fn mark_looted_for_player_like_cpp(&mut self, player: ObjectGuid) { - if self.flags.freeforall { - if !player.is_empty() && !self.ffa_looted_by.contains(&player) { - self.ffa_looted_by.push(player); - } - } else { - self.taken = true; - } - } - - pub fn fully_looted_like_cpp(&self) -> bool { - if self.flags.freeforall { - !self.allowed_looters.is_empty() - && self - .allowed_looters - .iter() - .all(|player| self.ffa_looted_by.contains(player)) - } else { - self.taken - } - } -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub struct LootEntryFlags { - pub follow_loot_rules: bool, - pub freeforall: bool, - pub blocked: bool, - pub counted: bool, - pub under_threshold: bool, - pub needs_quest: bool, -} - #[cfg(test)] mod tests { use crate::{ClientPacket, ServerPacket}; diff --git a/crates/wow-packet/src/packets/update.rs b/crates/wow-packet/src/packets/update.rs index a07e8886a..6f6f1e896 100644 --- a/crates/wow-packet/src/packets/update.rs +++ b/crates/wow-packet/src/packets/update.rs @@ -3120,8 +3120,13 @@ pub enum UpdateBlock { guid: ObjectGuid, create_data: ItemCreateData, }, - /// VALUES update for an item: currently only StackCount is needed by direct inventory stores. - ItemValuesUpdate { guid: ObjectGuid, stack_count: u32 }, + /// VALUES update for an item store. `dynamic_flags` is present when the + /// same C++ `_StoreItem` call both grows a stack and binds it. + ItemValuesUpdate { + guid: ObjectGuid, + stack_count: u32, + dynamic_flags: Option, + }, /// VALUES update for a player: only changed InvSlots, VisibleItems, VirtualItems. PlayerValuesUpdate { guid: ObjectGuid, @@ -3532,9 +3537,13 @@ impl UpdateObject { create_data.bounds_radius_2d )); } - UpdateBlock::ItemValuesUpdate { guid, stack_count } => { + UpdateBlock::ItemValuesUpdate { + guid, + stack_count, + dynamic_flags, + } => { lines.push(format!( - "#{index:03} item_values guid={guid:?} stack_count={stack_count}" + "#{index:03} item_values guid={guid:?} stack_count={stack_count} dynamic_flags={dynamic_flags:?}" )); } UpdateBlock::PlayerValuesUpdate { @@ -4373,13 +4382,31 @@ impl UpdateObject { /// Each item gets its own block. Sent BEFORE the player CREATE packet /// so the client has item objects when it processes InvSlots. pub fn create_items(items: Vec, map_id: u16) -> Self { + Self::create_items_with_update_type(items, map_id, UpdateType::CreateObject2) + } + + /// Create inventory item blocks from C++ `Player::_StoreItem`. + /// + /// `_StoreItem` calls `Item::AddToWorld` directly and then + /// `SendUpdateToPlayer`; unlike `Map::AddToMap`, that path never raises + /// `Object::m_isNewObject`, so `BuildCreateUpdateBlockForPlayer` writes + /// `CreateObject` rather than `CreateObject2`. + pub fn create_stored_items(items: Vec, map_id: u16) -> Self { + Self::create_items_with_update_type(items, map_id, UpdateType::CreateObject) + } + + fn create_items_with_update_type( + items: Vec, + map_id: u16, + update_type: UpdateType, + ) -> Self { let num = items.len() as u32; let blocks = items .into_iter() .map(|data| { let guid = data.item_guid; UpdateBlock::CreateItem { - update_type: UpdateType::CreateObject2, + update_type, guid, create_data: data, } @@ -4402,7 +4429,32 @@ impl UpdateObject { num_updates: 1, destroy_guids: Vec::new(), out_of_range_guids: Vec::new(), - blocks: vec![UpdateBlock::ItemValuesUpdate { guid, stack_count }], + blocks: vec![UpdateBlock::ItemValuesUpdate { + guid, + stack_count, + dynamic_flags: None, + }], + } + } + + /// Create the single ItemData VALUES update emitted by C++ `_StoreItem` + /// when an existing stack changes both count and binding flags. + pub fn item_stack_count_and_flags_update( + guid: ObjectGuid, + map_id: u16, + stack_count: u32, + dynamic_flags: u32, + ) -> Self { + Self { + map_id, + num_updates: 1, + destroy_guids: Vec::new(), + out_of_range_guids: Vec::new(), + blocks: vec![UpdateBlock::ItemValuesUpdate { + guid, + stack_count, + dynamic_flags: Some(dynamic_flags), + }], } } } @@ -4550,8 +4602,17 @@ impl ServerPacket for UpdateObject { } => { write_item_create_block(&mut blocks_buf, *update_type, guid, create_data); } - UpdateBlock::ItemValuesUpdate { guid, stack_count } => { - write_item_values_update_block(&mut blocks_buf, guid, *stack_count); + UpdateBlock::ItemValuesUpdate { + guid, + stack_count, + dynamic_flags, + } => { + write_item_values_update_block( + &mut blocks_buf, + guid, + *stack_count, + *dynamic_flags, + ); } UpdateBlock::PlayerValuesUpdate { guid, @@ -5537,13 +5598,19 @@ fn write_item_create_block( // ── VALUES update (UpdateType::Values) ───────────────────────────── -/// Write an ItemData VALUES update containing StackCount only. +/// Write an ItemData VALUES update containing StackCount and, when the store +/// binds an existing stack, DynamicFlags in the same update block. /// /// C++ refs: /// - `Item::SetCount` /// - `Object::BuildValuesUpdate` /// - `UF::ItemData::WriteUpdate` -fn write_item_values_update_block(buf: &mut WorldPacket, guid: &ObjectGuid, stack_count: u32) { +fn write_item_values_update_block( + buf: &mut WorldPacket, + guid: &ObjectGuid, + stack_count: u32, + dynamic_flags: Option, +) { buf.write_uint8(UpdateType::Values as u8); buf.write_packed_guid(guid); @@ -5551,11 +5618,19 @@ fn write_item_values_update_block(buf: &mut WorldPacket, guid: &ObjectGuid, stac val_buf.write_uint32(1 << 1); // TypeId::Item // ItemData has 43 bits: two 32-bit field blocks and a 2-bit blocks mask. - // Parent bit 0 and StackCount bit 7 are set for a count-only update. + // Parent bit 0 and StackCount bit 7 are always set. DynamicFlags bit 9 + // joins the same mask when `_StoreItem` binds the destination stack. val_buf.write_bits(0x01, 2); - val_buf.write_bits((1 << 0) | (1 << 7), 32); + let mut item_mask = (1 << 0) | (1 << 7); + if dynamic_flags.is_some() { + item_mask |= 1 << 9; + } + val_buf.write_bits(item_mask, 32); val_buf.flush_bits(); val_buf.write_int32(stack_count as i32); + if let Some(dynamic_flags) = dynamic_flags { + val_buf.write_uint32(dynamic_flags); + } let val_data = val_buf.into_data(); buf.write_uint32(val_data.len() as u32); @@ -9799,6 +9874,37 @@ mod tests { assert!(bytes.windows(2).any(|window| window == 77u16.to_le_bytes())); } + #[test] + fn stored_item_create_uses_cpp_non_map_create_type() { + let item_guid = ObjectGuid::create_item(1, 900); + let owner_guid = ObjectGuid::create_player(1, 42); + let packet = UpdateObject::create_stored_items( + vec![ItemCreateData { + item_guid, + entry_id: 700, + owner_guid, + contained_in: owner_guid, + stack_count: 1, + dynamic_flags: 0, + durability: 0, + max_durability: 0, + random_properties_seed: 0, + random_properties_id: 0, + enchantments: [ItemEnchantmentValuesUpdate::default(); 13], + gems: Vec::new(), + context: 0, + container_slots: 0, + container_item_guids: [ObjectGuid::EMPTY; 36], + }], + 0, + ); + + let UpdateBlock::CreateItem { update_type, .. } = packet.blocks[0] else { + panic!("stored item packet must contain an item create block"); + }; + assert_eq!(update_type, UpdateType::CreateObject); + } + #[test] fn container_create_serializes_cpp_container_data_after_item_data() { let item_guid = ObjectGuid::create_item(1, 900); @@ -9852,6 +9958,32 @@ mod tests { assert!(bytes.windows(4).any(|window| window == 19i32.to_le_bytes())); } + #[test] + fn bound_existing_stack_serializes_count_and_flags_in_one_values_update() { + let item_guid = ObjectGuid::create_item(1, 901); + let dynamic_flags = 0x0000_0001; + let pkt = UpdateObject::item_stack_count_and_flags_update(item_guid, 0, 19, dynamic_flags); + + assert_eq!(pkt.num_updates, 1); + assert_eq!(pkt.blocks.len(), 1); + assert!(matches!( + pkt.blocks.as_slice(), + [UpdateBlock::ItemValuesUpdate { + guid, + stack_count: 19, + dynamic_flags: Some(flags), + }] if *guid == item_guid && *flags == dynamic_flags + )); + + let bytes = pkt.to_bytes(); + assert!(bytes.windows(4).any(|window| window == 19i32.to_le_bytes())); + assert!( + bytes + .windows(4) + .any(|window| window == dynamic_flags.to_le_bytes()) + ); + } + #[test] fn movement_block_default_speeds() { let mv = MovementBlock::default(); diff --git a/crates/wow-world/src/handlers/character.rs b/crates/wow-world/src/handlers/character.rs index 6136f75d0..e50495186 100644 --- a/crates/wow-world/src/handlers/character.rs +++ b/crates/wow-world/src/handlers/character.rs @@ -71,7 +71,8 @@ use crate::session::{ CharacterPetSpellRowLikeCpp, CharacterPetStableRowLikeCpp, REST_STATE_NORMAL_LIKE_CPP, REST_STATE_RAF_LINKED_LIKE_CPP, RepresentedAlterAppearanceLikeCpp, RepresentedBankItemMoveLikeCpp, RepresentedConfirmBarbersChoiceLikeCpp, - RepresentedGameObjectUseState, RepresentedHomebindLikeCpp, SpellCastMetadata, + RepresentedGameObjectUseState, RepresentedHomebindLikeCpp, + RepresentedQuestObjectiveProgressEventLikeCpp, SpellCastMetadata, }; // ── Handler registration ──────────────────────────────────────────── @@ -2069,6 +2070,21 @@ fn player_money_gain_like_cpp(current_money: u64, amount: u64) -> Option { } } +const UPD_CHARACTER_MONEY_AND_BANK_SLOTS_LIKE_CPP: &str = + "UPDATE characters SET money = ?, bankSlots = ? WHERE guid = ?"; + +fn bank_slot_purchase_update_statement_like_cpp( + player_guid: ObjectGuid, + new_money: u64, + new_bank_slot_count: u8, +) -> PreparedStatement { + let mut statement = PreparedStatement::new(UPD_CHARACTER_MONEY_AND_BANK_SLOTS_LIKE_CPP); + statement.set_u64(0, new_money); + statement.set_u8(1, new_bank_slot_count); + statement.set_u64(2, player_guid.counter() as u64); + statement +} + fn active_known_spell_for_send_like_cpp(spell_id: u32, active: u8, disabled: u8) -> Option { if spell_id > 0 && active != 0 && disabled == 0 { i32::try_from(spell_id).ok() @@ -4373,6 +4389,11 @@ impl WorldSession { // Complete logout immediately self.logout_time = None; + if let Some(player_guid) = self.player_guid() { + self.wait_for_active_loot_persistence_like_cpp().await; + self.do_loot_release_all_like_cpp(player_guid).await; + } + // Trinity clears buyback slots before SaveToDB; persisted buyback items must not survive logout. self.clear_buyback_on_logout().await; self.save_current_player_to_db_like_cpp().await; @@ -4382,10 +4403,6 @@ impl WorldSession { self.save_account_item_appearances_like_cpp().await; self.save_account_transmog_illusions_like_cpp().await; - if let Some(player_guid) = self.player_guid() { - self.close_active_loot_windows_like_cpp(player_guid); - } - // Mark character offline in DB self.mark_character_offline().await; @@ -6848,6 +6865,7 @@ impl WorldSession { let (tx, rx) = tokio::sync::oneshot::channel(); let link = wow_network::session_mgr::InstanceLink { send_tx: self.send_tx().clone(), + send_write_fence_like_cpp: None, pkt_rx: None, // None = keep using realm socket's packet_rx }; let _ = tx.send(link); @@ -10691,9 +10709,14 @@ impl WorldSession { /// CMSG_BUY_BANK_SLOT — player buys the next personal bank bag slot. /// - /// C++ ref: `WorldSession::HandleBuyBankSlotOpcode`. + /// C++ ref: `WorldSession::HandleBuyBankSlotOpcode`. C++ mutates the bank + /// slot count and money in one serialized session turn; its later + /// `Player::SaveToDB`/`SaveInventoryAndGoldToDB` persists both values from + /// that coherent state. Rust must cross SQL here because detached group + /// payouts use the character money row as their durable cap authority, so + /// persist both fields atomically before publishing either runtime field. pub async fn handle_buy_bank_slot(&mut self, buy: BuyBankSlot) { - let Some(_player_guid) = self.player_guid() else { + let Some(player_guid) = self.player_guid() else { return; }; let Some(_banker) = @@ -10717,6 +10740,30 @@ impl WorldSession { return; }; + #[cfg(test)] + let test_commit_result = self.loot_money_persistence_test_result_for_worker_like_cpp(); + #[cfg(not(test))] + let test_commit_result: Option = None; + + let char_db = if test_commit_result.is_some() { + None + } else { + let Some(char_db) = self.char_db().map(Arc::clone) else { + return; + }; + Some(char_db) + }; + + // Close payout admission before reading money. A previously admitted + // payout either completes and is reconciled first, or this purchase + // closes first and the payout retries its still-available pool. + let Some(money_persistence) = self + .begin_exclusive_player_money_persistence_like_cpp() + .await + else { + return; + }; + let old_money = self.player_gold_like_cpp(); if old_money < u64::from(price) { debug!( @@ -10731,20 +10778,64 @@ impl WorldSession { let new_count = u8::try_from(next_slot).unwrap_or(u8::MAX); let new_money = old_money - u64::from(price); + + let mut transaction = SqlTransaction::new(); + transaction.append_expect_rows_affected( + bank_slot_purchase_update_statement_like_cpp(player_guid, new_money, new_count), + 1, + ); + let money_persistence = if let Some(success) = test_commit_result { + if !success { + return; + } + money_persistence + } else { + let Some(money_persistence) = self + .commit_exclusive_player_money_transaction_like_cpp( + money_persistence, + char_db + .as_ref() + .expect("production bank-slot purchase retains its database"), + transaction, + old_money, + new_money, + "bank-slot purchase", + ) + .await + else { + return; + }; + money_persistence + }; + + // Publish both runtime fields only after the combined SQL COMMIT. Set + // the values synchronously while admission is still closed, then drop + // the fence before criteria processing can re-enter persistence. + self.set_player_gold_like_cpp(new_money); + // This helper builds a clean snapshot from current runtime and then + // marks the requested count dirty. Emit it while runtime still holds + // the old count, after the durable COMMIT but before storing the new + // count locally. self.send_player_bank_bag_slots_update_like_cpp(new_count); - if let Some(player_guid) = self.player_guid() { - self.send_packet(&UpdateObject::player_money_update( - player_guid, - self.player_map_id_like_cpp(), - new_money, - None, - )); - } self.set_player_bank_bag_slot_count_like_cpp(new_count); - self.apply_player_money_change_like_cpp(old_money, new_money) - .await; + self.enqueue_represented_quest_objective_progress_like_cpp( + RepresentedQuestObjectiveProgressEventLikeCpp::MoneyChanged { + old_money, + new_money, + }, + ); self.sync_object_accessor_player(); self.sync_player_registry_state_like_cpp(); + drop(money_persistence); + + self.send_packet(&UpdateObject::player_money_update( + player_guid, + self.player_map_id_like_cpp(), + new_money, + None, + )); + self.drain_represented_quest_objective_progress_like_cpp() + .await; } /// CMSG_CHANGE_BANK_BAG_SLOT_FLAG — player toggles an ActivePlayer bank bag flag. @@ -11675,7 +11766,6 @@ impl WorldSession { Some(g) => g, None => return, }; - let realm_id = self.realm_id(); let map_id = self.player_map_id_like_cpp(); let vendor_slot = match vendor_buy_muid_to_cpp_slot(buy.muid) { Some(slot) => slot, @@ -12100,20 +12190,35 @@ impl WorldSession { return; } - let needs_new_items = store_dest.iter().any(|dest| { - let slot = (dest.pos & 0x00FF) as u8; - !self.inventory_items_like_cpp().contains_key(&slot) - }); - let mut next_item_guid = if needs_new_items { - let max_guid_stmt = char_db.prepare(CharStatements::SEL_MAX_ITEM_GUID); - match char_db.query(&max_guid_stmt).await { - Ok(r) => r.try_read::(0).unwrap_or(0) + 1, - Err(_) => 1, - } - } else { - 0 + let new_item_count = store_dest + .iter() + .filter(|dest| { + let slot = (dest.pos & 0x00FF) as u8; + !self.inventory_items_like_cpp().contains_key(&slot) + }) + .count(); + let Some(allocated_new_item_guids) = + self.allocate_item_instance_guids_like_cpp(new_item_count) + else { + warn!( + count = new_item_count, + "BuyItem: process-wide item GUID allocator is unavailable" + ); + self.send_buy_error( + BuyResult::CantFindItem, + Some(buy.vendor_guid), + buy.muid as u32, + ); + return; }; + let mut allocated_new_item_guids = allocated_new_item_guids.into_iter(); + let Some(money_persistence) = self + .begin_exclusive_player_money_persistence_like_cpp() + .await + else { + return; + }; let mut tx = SqlTransaction::new(); let old_gold = self.player_gold_like_cpp(); let new_gold = old_gold.saturating_sub(buy_price); @@ -12155,9 +12260,15 @@ impl WorldSession { tx.append(upd_count); existing_updates.push((slot, inv_item.guid, new_count)); } else { - let db_guid = next_item_guid; - next_item_guid += 1; - let item_guid = ObjectGuid::create_item(realm_id, db_guid as i64); + let Some((db_guid, item_guid)) = allocated_new_item_guids.next() else { + warn!("BuyItem: preallocated item GUID count did not match store plan"); + self.send_buy_error( + BuyResult::CantFindItem, + Some(buy.vendor_guid), + buy.muid as u32, + ); + return; + }; let mut ins_item = char_db.prepare(CharStatements::INS_ITEM_INSTANCE); ins_item.set_u64(0, db_guid); @@ -12220,35 +12331,33 @@ impl WorldSession { } self.append_player_currency_save_statements(&mut tx, player_guid.counter() as u64); - if let Err(e) = char_db.commit_transaction(tx).await { + let Some(money_persistence) = self + .commit_exclusive_player_money_transaction_like_cpp( + money_persistence, + char_db.as_ref(), + tx, + old_gold, + new_gold, + "vendor item purchase", + ) + .await + else { self.set_player_currencies_like_cpp(currency_snapshot); - warn!("BuyItem: store transaction failed: {e}"); + warn!("BuyItem: store transaction did not commit"); self.send_buy_error( BuyResult::CantFindItem, Some(buy.vendor_guid), buy.muid as u32, ); return; - } + }; - self.apply_player_money_change_like_cpp(old_gold, new_gold) - .await; + // The SQL transaction also owns the turn-ins, returned inventory + // stacks, currencies and refund metadata. Publish all of that runtime + // state synchronously before reopening payout admission; cancelling + // the handler after COMMIT must not leave runtime at the pre-buy state. + self.stage_player_money_change_like_cpp(old_gold, new_gold); self.apply_item_turnin_changes(player_guid, map_id, &item_turnin_changes); - for &(currency_id, amount) in &extended_cost_currency_costs { - let Some(quantity) = i32::try_from(self.player_currency_quantity(currency_id)).ok() - else { - continue; - }; - let Some(amount) = i32::try_from(amount).ok() else { - continue; - }; - self.send_packet(&SetCurrency::vendor_loss( - currency_id as i32, - quantity, - amount, - )); - } - for &(_, item_guid, new_count) in &existing_updates { self.update_inventory_item_object_like_cpp(item_guid, |item| { item.set_count(new_count); @@ -12291,15 +12400,6 @@ impl WorldSession { .iter() .map(|&(slot, _, item_guid, _)| (slot, item_guid)) .collect(); - - info!( - "BuyItem: player {:?} bought item {} across {} destination(s) for {} copper (remaining: {})", - player_guid, - buy.item_id, - store_dest.len(), - buy_price, - self.player_gold_like_cpp() - ); let new_quantity = if vendor_item.max_count == 0 { -1 } else { @@ -12312,7 +12412,33 @@ impl WorldSession { quantity, ) as i32 }; + drop(money_persistence); + + self.drain_represented_quest_objective_progress_like_cpp() + .await; + for &(currency_id, amount) in &extended_cost_currency_costs { + let Some(quantity) = i32::try_from(self.player_currency_quantity(currency_id)).ok() + else { + continue; + }; + let Some(amount) = i32::try_from(amount).ok() else { + continue; + }; + self.send_packet(&SetCurrency::vendor_loss( + currency_id as i32, + quantity, + amount, + )); + } + info!( + "BuyItem: player {:?} bought item {} across {} destination(s) for {} copper (remaining: {})", + player_guid, + buy.item_id, + store_dest.len(), + buy_price, + self.player_gold_like_cpp() + ); // ── Send BuySucceeded ── self.send_packet(&BuySucceeded { vendor_guid: buy.vendor_guid, @@ -12444,6 +12570,12 @@ impl WorldSession { Some(db) => Arc::clone(db), None => return, }; + let Some(money_persistence) = self + .begin_exclusive_player_money_persistence_like_cpp() + .await + else { + return; + }; let mut tx = SqlTransaction::new(); let old_gold = self.player_gold_like_cpp(); let new_gold = old_gold.saturating_sub(price); @@ -12520,14 +12652,26 @@ impl WorldSession { tx.append(del_item); } - if let Err(e) = char_db.commit_transaction(tx).await { - warn!("BuyBackItem: transaction failed: {e}"); + let Some(money_persistence) = self + .commit_exclusive_player_money_transaction_like_cpp( + money_persistence, + char_db.as_ref(), + tx, + old_gold, + new_gold, + "vendor buyback purchase", + ) + .await + else { + warn!("BuyBackItem: transaction did not commit"); self.send_buy_error(BuyResult::CantFindItem, Some(buyback.vendor_guid), 0); return; - } + }; - self.apply_player_money_change_like_cpp(old_gold, new_gold) - .await; + // The same COMMIT moved the buyback item (or merged/deleted it) and + // charged the player. Mirror that entire durable state before the + // guard can reopen admission or this future can be cancelled. + self.stage_player_money_change_like_cpp(old_gold, new_gold); self.remove_buyback_item_like_cpp(buyback_slot); self.clear_buyback_slot_metadata_like_cpp(buyback_slot); if self @@ -12563,6 +12707,10 @@ impl WorldSession { self.remove_inventory_item_object(buyback_item.guid); } self.sync_object_accessor_player(); + drop(money_persistence); + + self.drain_represented_quest_objective_progress_like_cpp() + .await; for &(_, item_guid, new_count) in &existing_updates { self.send_packet(&UpdateObject::item_stack_count_update( @@ -12713,6 +12861,12 @@ impl WorldSession { } let money = sell_price.saturating_mul(u64::from(sold_count)); + let Some(money_persistence) = self + .begin_exclusive_player_money_persistence_like_cpp() + .await + else { + return; + }; let old_gold = self.player_gold_like_cpp(); let Some(new_gold) = player_money_gain_like_cpp(old_gold, money) else { self.send_sell_error( @@ -12761,12 +12915,18 @@ impl WorldSession { upd_count.set_u64(1, item.db_guid); tx.append(upd_count); - let max_guid_stmt = char_db.prepare(CharStatements::SEL_MAX_ITEM_GUID); - let new_db_guid = match char_db.query(&max_guid_stmt).await { - Ok(r) => r.try_read::(0).unwrap_or(0) + 1, - Err(_) => 1, + let Some((new_db_guid, new_item_guid)) = self + .allocate_item_instance_guids_like_cpp(1) + .and_then(|mut allocated| allocated.pop()) + else { + warn!("SellItem: process-wide item GUID allocator is unavailable"); + self.send_sell_error( + SellResult::CantSellItem, + Some(sell.vendor_guid), + sell.item_guid, + ); + return; }; - let new_item_guid = ObjectGuid::create_item(self.realm_id(), new_db_guid as i64); let cloned_item = runtime_item.clone_item_for_store(new_item_guid, Some(player_guid), amount); let cloned_data = cloned_item.data(); @@ -12809,18 +12969,30 @@ impl WorldSession { upd_money.set_u64(1, player_guid.counter() as u64); tx.append(upd_money); - if let Err(e) = char_db.commit_transaction(tx).await { - warn!("SellItem: transaction failed: {e}"); + let Some(money_persistence) = self + .commit_exclusive_player_money_transaction_like_cpp( + money_persistence, + char_db.as_ref(), + tx, + old_gold, + new_gold, + "vendor item sale", + ) + .await + else { + warn!("SellItem: transaction did not commit"); self.send_sell_error( SellResult::CantSellItem, Some(sell.vendor_guid), sell.item_guid, ); return; - } + }; - self.apply_player_money_change_like_cpp(old_gold, new_gold) - .await; + // C++ mutates money, inventory and buyback state as one in-memory + // operation. Our durable-first adaptation must publish the same whole + // state before reopening admission or reaching a cancellation point. + self.stage_player_money_change_like_cpp(old_gold, new_gold); if let Some(old_buyback) = old_buyback { self.remove_buyback_item_like_cpp(buyback_slot); self.remove_inventory_item_object(old_buyback.guid); @@ -12865,6 +13037,10 @@ impl WorldSession { self.set_inventory_item_object_slot(item.guid, buyback_slot); } self.sync_object_accessor_player(); + drop(money_persistence); + + self.drain_represented_quest_objective_progress_like_cpp() + .await; info!( "SellItem: player {:?} sold {}x item {} from slot {} for {} copper (total: {})", @@ -13211,6 +13387,12 @@ impl WorldSession { } } + let Some(money_persistence) = self + .begin_exclusive_player_money_persistence_like_cpp() + .await + else { + return; + }; let mut tx = SqlTransaction::new(); let mut del_refund = char_db.prepare(CharStatements::DEL_ITEM_REFUND_INSTANCE); del_refund.set_u64(0, refund_inv_item.db_guid); @@ -13241,20 +13423,24 @@ impl WorldSession { tx.append(upd_count); } - let realm_id = self.realm_id(); let mut created_new_stacks = Vec::new(); if !planned_new_stacks.is_empty() { - let max_guid_stmt = char_db.prepare(CharStatements::SEL_MAX_ITEM_GUID); - let mut next_item_guid = match char_db.query(&max_guid_stmt).await { - Ok(r) => r.try_read::(0).unwrap_or(0) + 1, - Err(_) => 1, + let Some(allocated_guids) = + self.allocate_item_instance_guids_like_cpp(planned_new_stacks.len()) + else { + warn!( + count = planned_new_stacks.len(), + "ItemPurchaseRefund: process-wide item GUID allocator is unavailable" + ); + self.send_packet(&ItemPurchaseRefundResult { + item_guid: refund.item_guid, + result: REFUND_RESULT_ERR_GENERIC, + contents: Some(contents), + }); + return; }; - for stack in &planned_new_stacks { - let db_guid = next_item_guid; - next_item_guid += 1; - let item_guid = ObjectGuid::create_item(realm_id, db_guid as i64); - + for (stack, (db_guid, item_guid)) in planned_new_stacks.iter().zip(allocated_guids) { let mut ins_item = char_db.prepare(CharStatements::INS_ITEM_INSTANCE); ins_item.set_u64(0, db_guid); ins_item.set_u32(1, stack.entry_id); @@ -13292,22 +13478,31 @@ impl WorldSession { } self.append_player_currency_save_statements(&mut tx, player_guid.counter() as u64); - if let Err(e) = char_db.commit_transaction(tx).await { + let Some(money_persistence) = self + .commit_exclusive_player_money_transaction_like_cpp( + money_persistence, + char_db.as_ref(), + tx, + old_money, + new_gold, + "vendor item purchase refund", + ) + .await + else { self.set_player_currencies_like_cpp(currency_snapshot); - warn!("ItemPurchaseRefund: refund transaction failed: {e}"); + warn!("ItemPurchaseRefund: refund transaction did not commit"); self.send_packet(&ItemPurchaseRefundResult { item_guid: refund.item_guid, result: REFUND_RESULT_ERR_GENERIC, contents: Some(contents), }); return; - } + }; - self.apply_player_money_change_like_cpp(old_money, new_gold) - .await; - if money_overflow { - self.send_equip_error(InventoryResult::TooMuchGold, None, None, 0, 0); - } + // Refund COMMIT covers money, currencies, destruction of the refunded + // item, and every restored stack. Publish the corresponding runtime + // inventory before the guard opens or an await permits cancellation. + self.stage_player_money_change_like_cpp(old_money, new_gold); self.remove_inventory_item_like_cpp(refund_slot); self.remove_inventory_item_object(refund.item_guid); @@ -13339,6 +13534,13 @@ impl WorldSession { self.insert_inventory_item_object(item_object); } self.sync_object_accessor_player(); + drop(money_persistence); + + self.drain_represented_quest_objective_progress_like_cpp() + .await; + if money_overflow { + self.send_equip_error(InventoryResult::TooMuchGold, None, None, 0, 0); + } self.send_packet(&ItemPurchaseRefundResult { item_guid: refund.item_guid, @@ -16342,6 +16544,7 @@ mod tests { RepresentedTaxiFlightNodeLikeCpp, }; use wow_constants::{ItemClass, ServerOpcodes}; + use wow_core::ObjectGuidGenerator; use wow_data::character_progression::{ ChrClassesEntry, ChrClassesStore, ChrRacesEntry, ChrRacesStore, }; @@ -16957,21 +17160,23 @@ mod tests { ) -> (WorldSession, flume::Receiver>) { let (_pkt_tx, pkt_rx) = flume::bounded::(1); let (send_tx, send_rx) = flume::bounded::>(capacity); - ( - WorldSession::new( - 1, - "TestAccount".into(), - 0, - 2, - 9, - 54261, - vec![0u8; 40], - "esES".into(), - pkt_rx, - send_tx, - ), - send_rx, - ) + let mut session = WorldSession::new( + 1, + "TestAccount".into(), + 0, + 2, + 9, + 54261, + vec![0u8; 40], + "esES".into(), + pkt_rx, + send_tx, + ); + session.set_item_guid_generator_like_cpp(Arc::new(ObjectGuidGenerator::new( + HighGuid::Item, + 1, + ))); + (session, send_rx) } fn run_login_grid_cleanup_test(test: impl FnOnce() + Send + 'static) { @@ -20141,11 +20346,125 @@ mod tests { assert!(send_rx.try_recv().is_err()); } + #[test] + fn committed_money_callers_publish_all_runtime_state_before_reopening_admission() { + fn assert_publication_segment( + source: &str, + operation: &str, + publication_start: &str, + required_runtime_publications: &[&str], + ) { + let operation_marker = format!("\"{operation}\""); + let operation_offset = source + .find(&operation_marker) + .unwrap_or_else(|| panic!("missing operation marker {operation_marker}")); + let after_operation = &source[operation_offset..]; + let publication_offset = after_operation.find(publication_start).unwrap_or_else(|| { + panic!("{operation}: missing publication start {publication_start}") + }); + let after_publication = &after_operation[publication_offset..]; + let drop_offset = after_publication + .find("drop(money_persistence);") + .unwrap_or_else(|| panic!("{operation}: missing money guard drop")); + let publication = &after_publication[..drop_offset]; + + assert!( + !publication.contains(".await"), + "{operation}: an await reintroduced a post-COMMIT cancellation point before runtime publication" + ); + for required in required_runtime_publications { + assert!( + publication.contains(required), + "{operation}: runtime publication `{required}` must precede the money guard drop" + ); + } + } + + let character = include_str!("character.rs"); + let trainer = include_str!("trainer.rs"); + let session = include_str!("../session.rs"); + + assert_publication_segment( + trainer, + "trainer spell purchase", + "self.stage_player_money_change_like_cpp", + &[ + "self.learn_known_spell_like_cpp(spell_id);", + "self.sync_object_accessor_player();", + "self.sync_player_registry_state_like_cpp();", + ], + ); + assert_publication_segment( + character, + "bank-slot purchase", + "self.set_player_gold_like_cpp(new_money);", + &[ + "self.set_player_bank_bag_slot_count_like_cpp(new_count);", + "self.sync_object_accessor_player();", + "self.sync_player_registry_state_like_cpp();", + ], + ); + assert_publication_segment( + character, + "vendor item purchase", + "self.stage_player_money_change_like_cpp", + &[ + "self.apply_item_turnin_changes", + "self.insert_inventory_item_like_cpp", + "self.update_vendor_item_current_count", + "self.sync_object_accessor_player();", + ], + ); + assert_publication_segment( + character, + "vendor buyback purchase", + "self.stage_player_money_change_like_cpp", + &[ + "self.remove_buyback_item_like_cpp", + "self.insert_inventory_item_like_cpp", + "self.sync_object_accessor_player();", + ], + ); + assert_publication_segment( + character, + "vendor item sale", + "self.stage_player_money_change_like_cpp", + &[ + "self.set_buyback_slot_metadata_like_cpp", + "self.insert_buyback_item_like_cpp", + "self.sync_object_accessor_player();", + ], + ); + assert_publication_segment( + character, + "vendor item purchase refund", + "self.stage_player_money_change_like_cpp", + &[ + "self.remove_inventory_item_like_cpp(refund_slot);", + "self.insert_inventory_item_like_cpp", + "self.sync_object_accessor_player();", + ], + ); + assert_publication_segment( + session, + "single item durability repair", + "self.stage_player_money_change_like_cpp", + &["self.apply_inventory_item_durability_repair_runtime_like_cpp(item_guid)"], + ); + assert_publication_segment( + session, + "all-items durability repair", + "self.stage_player_money_change_like_cpp", + &["self.apply_inventory_item_durability_repair_runtime_like_cpp(item_guid)"], + ); + } + #[tokio::test] async fn buy_bank_slot_buys_next_slot_and_spends_money_like_cpp() { let (mut session, send_rx, canonical) = make_bank_slot_session(4); let banker = ObjectGuid::create_world_object(HighGuid::Creature, 0, 1, 571, 0, 2456, 1); insert_banker_creature(&canonical, banker, NPCFlags1::BANKER.bits()); + session.set_loot_money_persistence_test_result_like_cpp(true); session .handle_buy_bank_slot(BuyBankSlot { guid: banker }) @@ -20161,6 +20480,52 @@ mod tests { assert!(send_rx.try_recv().is_err()); } + #[tokio::test] + async fn buy_bank_slot_definite_rollback_keeps_runtime_and_packets_unchanged_like_cpp() { + let (mut session, send_rx, canonical) = make_bank_slot_session(4); + let banker = ObjectGuid::create_world_object(HighGuid::Creature, 0, 1, 571, 0, 2456, 4); + insert_banker_creature(&canonical, banker, NPCFlags1::BANKER.bits()); + session.set_loot_money_persistence_test_result_like_cpp(false); + + session + .handle_buy_bank_slot(BuyBankSlot { guid: banker }) + .await; + + assert_eq!(session.player_bank_bag_slot_count_like_cpp(), 0); + assert_eq!(session.player_gold_like_cpp(), 150); + assert!(send_rx.try_recv().is_err()); + assert!( + session + .durable_loot_money_persistence_tracker_like_cpp() + .begin_like_cpp() + .is_ok(), + "a definite rollback must reopen payout admission" + ); + } + + #[test] + fn bank_slot_purchase_persists_money_and_slot_in_one_checked_statement_like_cpp() { + let player_guid = ObjectGuid::create_player(1, 42); + let statement = bank_slot_purchase_update_statement_like_cpp(player_guid, 12_345, 3); + + assert_eq!( + statement.sql(), + "UPDATE characters SET money = ?, bankSlots = ? WHERE guid = ?" + ); + assert!(matches!( + statement.params()[0], + wow_database::SqlParam::U64(12_345) + )); + assert!(matches!( + statement.params()[1], + wow_database::SqlParam::U8(3) + )); + assert!(matches!( + statement.params()[2], + wow_database::SqlParam::U64(42) + )); + } + #[tokio::test] async fn buy_bank_slot_rejects_non_banker_like_cpp() { let (mut session, send_rx, canonical) = make_bank_slot_session(1); @@ -22434,7 +22799,10 @@ mod tests { wow_constants::ServerOpcodes::LogoutComplete as u16 ); assert!(!session.is_active_loot_guid(loot_guid)); - assert!(session.loot_table.contains_key(&loot_guid)); + assert!( + !session.loot_table.contains_key(&loot_guid), + "full logout release retires the session packet-cache copy like C++" + ); } #[test] diff --git a/crates/wow-world/src/handlers/chat.rs b/crates/wow-world/src/handlers/chat.rs index d77abf13a..98a13d55c 100644 --- a/crates/wow-world/src/handlers/chat.rs +++ b/crates/wow-world/src/handlers/chat.rs @@ -1930,6 +1930,7 @@ mod tests { is_in_world: true, send_tx, command_tx, + durable_loot_money_tracker_like_cpp: Default::default(), active_loot_rolls: Vec::new(), pass_on_group_loot: false, enchanting_skill: 0, diff --git a/crates/wow-world/src/handlers/group.rs b/crates/wow-world/src/handlers/group.rs index 7ecf81e4f..fd5c7d92a 100644 --- a/crates/wow-world/src/handlers/group.rs +++ b/crates/wow-world/src/handlers/group.rs @@ -6,6 +6,7 @@ //! Handlers for Group/Party opcodes: PartyInvite, PartyInviteResponse, LeaveGroup. use rand::Rng; +use std::time::Duration; use tracing::{info, warn}; use wow_constants::ClientOpcodes; use wow_core::{ObjectGuid, guid::HighGuid}; @@ -19,7 +20,8 @@ use wow_network::{ GROUP_ASSIGN_MAINASSIST_LIKE_CPP, GROUP_ASSIGN_MAINTANK_LIKE_CPP, GroupInfo, GroupRegistry, MEMBER_FLAG_ASSISTANT_LIKE_CPP, MEMBER_FLAG_MAINASSIST_LIKE_CPP, MEMBER_FLAG_MAINTANK_LIKE_CPP, PendingInvites, PlayerRegistry, ReadyCheckEventLikeCpp, SendPartyUpdateLikeCppCommand, - SessionCommand, free_group_db_store_id_like_cpp, register_group_db_store_id_like_cpp, + SendRealmPacketLikeCppCommand, SessionCommand, free_group_db_store_id_like_cpp, + register_group_db_store_id_like_cpp, }; use wow_packet::packets::misc::{RandomRoll, RandomRollClient}; use wow_packet::packets::party::{ @@ -39,6 +41,7 @@ use crate::session::{WorldSession, player_team_for_race_cpp}; const SOCIAL_FLAG_FRIEND_LIKE_CPP: u32 = 0x01; const SOCIAL_FLAG_IGNORED_LIKE_CPP: u32 = 0x02; +const PARTY_REALM_COMMAND_TIMEOUT_LIKE_CPP: Duration = Duration::from_millis(250); // ── canonical group lookup ──────────────────────────────────────────────────── @@ -551,28 +554,32 @@ fn send_party_update(group: &GroupInfo, registry: &PlayerRegistry, _vra: u32) { } let command = SendPartyUpdateLikeCppCommand { + recipient: member_guid, party_update: update, member_full_state_packets, }; + #[cfg(not(test))] if member_entry .command_tx - .try_send(SessionCommand::SendPartyUpdateLikeCpp(command.clone())) + .try_send(SessionCommand::SendPartyUpdateLikeCpp(command)) .is_err() { - #[cfg(test)] - { - let mut update = command.party_update; - update.sequence_num = group.sequence_num as i32; - let _ = member_entry.send_tx.try_send(update.to_bytes()); - for packet in command.member_full_state_packets { - let _ = member_entry.send_tx.try_send(packet); - } - } + warn!(member = %member_guid, "failed to queue party update for remote session"); + } + #[cfg(test)] + { + member_entry + .command_tx + .send_timeout( + SessionCommand::SendPartyUpdateLikeCpp(command), + Duration::from_secs(1), + ) + .expect("test session dispatcher accepts PartyUpdate command"); } } } -fn send_group_new_leader_like_cpp( +async fn send_group_new_leader_like_cpp( group: &GroupInfo, registry: &PlayerRegistry, new_leader_name: &str, @@ -583,11 +590,18 @@ fn send_group_new_leader_like_cpp( } .to_bytes(); - for &member_guid in &group.members { - let Some(member_entry) = registry.get(&member_guid) else { - continue; - }; - let _ = member_entry.send_tx.try_send(packet.clone()); + let recipients: Vec<_> = group + .members + .iter() + .filter_map(|member_guid| { + registry + .get(member_guid) + .map(|entry| (*member_guid, entry.command_tx.clone())) + }) + .collect(); + for (member_guid, command_tx) in recipients { + let _ = + send_realm_packet_to_player_like_cpp(member_guid, &command_tx, packet.clone()).await; } } @@ -815,7 +829,7 @@ fn send_party_uninvite_result_like_cpp( result: u8, result_guid: ObjectGuid, ) { - session.send_packet(&PartyCommandResult { + session.send_packet_realm(&PartyCommandResult { name: String::new(), command: 1, // C++ PARTY_OP_UNINVITE result, @@ -824,6 +838,47 @@ fn send_party_uninvite_result_like_cpp( }); } +/// Queue a realm-routed packet on the target's owning session. +/// +/// The registry's `send_tx` is the primary socket and becomes INSTANCE after +/// ConnectTo. Legacy C++ routes party-control packets through REALM +/// (`Opcodes.cpp:1826-1832`), so cross-session delivery must not use that +/// primary sender directly. +async fn send_realm_packet_to_player_like_cpp( + recipient: ObjectGuid, + command_tx: &flume::Sender, + packet_bytes: Vec, +) -> bool { + let command = SessionCommand::SendRealmPacketLikeCpp(SendRealmPacketLikeCppCommand { + recipient, + packet_bytes, + }); + match tokio::time::timeout( + PARTY_REALM_COMMAND_TIMEOUT_LIKE_CPP, + command_tx.send_async(command), + ) + .await + { + Ok(Ok(())) => { + // Test fixtures run a real command dispatcher on another task/thread. + // Yielding here lets that receiver perform the same session-local + // routing before packet assertions without adding a wrong-socket + // production fallback. + #[cfg(test)] + tokio::task::yield_now().await; + true + } + Ok(Err(error)) => { + warn!(recipient = %recipient, %error, "realm-routed party command channel closed"); + false + } + Err(_) => { + warn!(recipient = %recipient, "timed out queueing realm-routed party packet"); + false + } + } +} + fn role_changed_inform_like_cpp( party_index: u8, from: ObjectGuid, @@ -885,19 +940,42 @@ fn raid_markers_changed_like_cpp(group: &GroupInfo) -> Vec { .to_bytes() } -fn queue_visible_gameobjects_or_spellclicks_refresh_like_cpp( +async fn queue_visible_gameobjects_or_spellclicks_refresh_like_cpp( group: &GroupInfo, registry: &PlayerRegistry, local_guid: ObjectGuid, ) { - for &member_guid in &group.members { - if member_guid == local_guid { - continue; - } - if let Some(member) = registry.get(&member_guid) { - let _ = member.command_tx.try_send( + let recipients: Vec<_> = group + .members + .iter() + .copied() + .filter(|member_guid| *member_guid != local_guid) + .filter_map(|member_guid| { + registry + .get(&member_guid) + .map(|member| (member_guid, member.command_tx.clone())) + }) + .collect(); + + for (member_guid, command_tx) in recipients { + match tokio::time::timeout( + PARTY_REALM_COMMAND_TIMEOUT_LIKE_CPP, + command_tx.send_async( wow_network::SessionCommand::RefreshVisibleGameobjectsOrSpellClicksLikeCpp, - ); + ), + ) + .await + { + Ok(Ok(())) => {} + Ok(Err(error)) => warn!( + member = %member_guid, + %error, + "visible gameobject refresh command channel closed" + ), + Err(_) => warn!( + member = %member_guid, + "timed out queueing visible gameobject refresh command" + ), } } } @@ -1005,7 +1083,8 @@ fn group_lfg_data_delete_statement_like_cpp(db_store_id: u32) -> PreparedStateme impl WorldSession { /// CMSG_PARTY_INVITE (0x3604) /// - /// Parse layout (C# reference): + /// Parse layout from C++ `WorldPackets::Party::PartyInviteClient::Read` + /// (`PartyPackets.cpp`): /// HasBit() → has_party_index /// ResetBitPos() /// ReadBits(9) → name_len @@ -1068,7 +1147,7 @@ impl WorldSession { macro_rules! send_result { ($result:expr) => { - self.send_packet(&PartyCommandResult { + self.send_packet_realm(&PartyCommandResult { name: target_name.clone(), command: 0, // Invite result: $result, @@ -1192,7 +1271,10 @@ impl WorldSession { if current_group_guid_like_cpp(group_reg, None, real_target_guid, party_index).is_some() { send_result!(party_result::ALREADY_IN_GROUP); - if let Some(target_entry) = registry.get(&real_target_guid) { + if let Some(target_command_tx) = registry + .get(&real_target_guid) + .map(|entry| entry.command_tx.clone()) + { let invite = PartyInviteServer { can_accept: false, proposed_roles: proposed_roles as u8, @@ -1207,7 +1289,12 @@ impl WorldSession { realm_name: realm_name.clone(), realm_name_normalized: realm_name_normalized.clone(), }; - let _ = target_entry.send_tx.send(invite.to_bytes()); + let _ = send_realm_packet_to_player_like_cpp( + real_target_guid, + &target_command_tx, + invite.to_bytes(), + ) + .await; } return; } @@ -1246,26 +1333,34 @@ impl WorldSession { pending.insert(real_target_guid, invite); // 7. Send invite dialog to the target. - if let Some(target_entry) = registry.get(&real_target_guid) { - let invite = PartyInviteServer { - can_accept: true, - proposed_roles: proposed_roles as u8, - inviter_name: inviter_name.clone(), - inviter_guid: my_guid, - inviter_bnet_account_guid: ObjectGuid::create_global( - HighGuid::WowAccount, - 0, - self.account_id as i64, - ), - virtual_realm_address: vra, - realm_name, - realm_name_normalized, - }; - let _ = target_entry.send_tx.send(invite.to_bytes()); + let invite_packet = PartyInviteServer { + can_accept: true, + proposed_roles: proposed_roles as u8, + inviter_name: inviter_name.clone(), + inviter_guid: my_guid, + inviter_bnet_account_guid: ObjectGuid::create_global( + HighGuid::WowAccount, + 0, + self.account_id as i64, + ), + virtual_realm_address: vra, + realm_name, + realm_name_normalized, + }; + if !send_realm_packet_to_player_like_cpp( + real_target_guid, + &target_snapshot.command_tx, + invite_packet.to_bytes(), + ) + .await + { + remove_pending_invite_like_cpp(pending, real_target_guid, invite); + send_result!(party_result::BAD_PLAYER_NAME); + return; } // 7. Confirm back to self. - self.send_packet(&PartyCommandResult { + self.send_packet_realm(&PartyCommandResult { name: target_name, command: 0, result: party_result::OK, @@ -1331,13 +1426,18 @@ impl WorldSession { // 2. Declined? if !accept { - let leader_send_tx = registry + let leader_command_tx = registry .get(&invite.leader_guid) - .map(|leader| leader.send_tx.clone()); + .map(|leader| leader.command_tx.clone()); remove_pending_invite_like_cpp(&pending, my_guid, invite); - if let Some(leader_send_tx) = leader_send_tx { + if let Some(leader_command_tx) = leader_command_tx { let decline = GroupDecline { name: my_name }; - let _ = leader_send_tx.send(decline.to_bytes()); + let _ = send_realm_packet_to_player_like_cpp( + invite.leader_guid, + &leader_command_tx, + decline.to_bytes(), + ) + .await; } return; } @@ -1361,7 +1461,7 @@ impl WorldSession { let group_guid = if let Some(gid) = invite.group_guid { if let Some(mut g) = group_reg.get_mut(&gid) { if g.is_full_like_cpp() { - self.send_packet(&PartyCommandResult { + self.send_packet_realm(&PartyCommandResult { name: String::new(), command: 0, result: party_result::GROUP_FULL, @@ -1643,7 +1743,7 @@ impl WorldSession { ); self.sync_player_registry_state_like_cpp(); let _ = self.update_visible_gameobjects_or_spell_clicks_like_cpp(); - self.send_packet(&wow_packet::packets::party::GroupDestroyed); + self.send_packet_realm(&wow_packet::packets::party::GroupDestroyed); return; } @@ -1696,7 +1796,7 @@ impl WorldSession { }; if self.player_in_represented_battleground_like_cpp() { - self.send_packet(&PartyCommandResult { + self.send_packet_realm(&PartyCommandResult { name: String::new(), command: 0, result: party_result::INVITE_RESTRICTED, @@ -1713,7 +1813,7 @@ impl WorldSession { (pending_invites.as_ref(), pending_invite) { if invite.leader_guid == my_guid { - self.send_packet(&PartyCommandResult { + self.send_packet_realm(&PartyCommandResult { name: player_name, command: 2, result: party_result::OK, @@ -1727,7 +1827,7 @@ impl WorldSession { } let gid = real_group_guid.expect("checked above"); - self.send_packet(&PartyCommandResult { + self.send_packet_realm(&PartyCommandResult { name: player_name, command: 2, result: party_result::OK, @@ -1823,7 +1923,7 @@ impl WorldSession { ); self.sync_player_registry_state_like_cpp(); let _ = self.update_visible_gameobjects_or_spell_clicks_like_cpp(); - self.send_packet(&GroupUninvite); + self.send_packet_realm(&GroupUninvite); return; } @@ -1841,7 +1941,7 @@ impl WorldSession { ); self.sync_player_registry_state_like_cpp(); let _ = self.update_visible_gameobjects_or_spell_clicks_like_cpp(); - self.send_packet(&GroupUninvite); + self.send_packet_realm(&GroupUninvite); } /// CMSG_CONVERT_RAID. @@ -1885,7 +1985,7 @@ impl WorldSession { return; } - self.send_packet(&PartyCommandResult { + self.send_packet_realm(&PartyCommandResult { name: String::new(), command: 0, result: party_result::OK, @@ -1927,7 +2027,8 @@ impl WorldSession { if let Some(group) = group_reg.get(&group_guid) { send_party_update(&group, ®istry, vra); - queue_visible_gameobjects_or_spellclicks_refresh_like_cpp(&group, ®istry, my_guid); + queue_visible_gameobjects_or_spellclicks_refresh_like_cpp(&group, ®istry, my_guid) + .await; } let _ = self.update_visible_gameobjects_or_spell_clicks_like_cpp(); } @@ -2209,8 +2310,8 @@ impl WorldSession { } } - if let Some(group) = group_reg.get(&group_guid) { - send_group_new_leader_like_cpp(&group, ®istry, &target_name); + if let Some(group) = group_reg.get(&group_guid).map(|group| group.clone()) { + send_group_new_leader_like_cpp(&group, ®istry, &target_name).await; send_party_update(&group, ®istry, vra); } } @@ -2903,7 +3004,7 @@ impl WorldSession { let registry = self.player_registry().map(std::sync::Arc::clone); let state = party_member_full_state_like_cpp(request.target_guid, registry.as_deref()); - self.send_packet(&state); + self.send_packet_realm(&state); } /// CMSG_INITIATE_ROLE_POLL. @@ -3171,11 +3272,12 @@ mod tests { group_member_delete_statement_like_cpp, group_member_flag_update_statement_like_cpp, group_member_insert_statement_like_cpp, group_member_subgroup_update_statement_like_cpp, group_type_update_statement_like_cpp, party_invite_social_friend_match_like_cpp, - party_invite_social_ignore_match_like_cpp, party_player_info_like_cpp, send_party_update, - send_ready_check_events_like_cpp, sender_can_start_ready_check_like_cpp, + party_invite_social_ignore_match_like_cpp, party_player_info_like_cpp, + send_group_new_leader_like_cpp, send_party_update, send_ready_check_events_like_cpp, + sender_can_start_ready_check_like_cpp, }; use flume::bounded; - use std::sync::Arc; + use std::{sync::Arc, time::Duration}; use wow_constants::{ClientOpcodes, ServerOpcodes}; use wow_core::{ObjectGuid, Position, guid::HighGuid}; use wow_database::{CharStatements, SqlParam, StatementDef}; @@ -3186,15 +3288,60 @@ mod tests { PendingInvites, PlayerBroadcastInfo, PlayerRegistry, ReadyCheckEventLikeCpp, SessionCommand, }; - use wow_packet::{WorldPacket, packets::party::party_result}; + use wow_packet::{ServerPacket, WorldPacket, packets::party::party_result}; use crate::session::WorldSession; + fn test_session_command_dispatcher( + guid: ObjectGuid, + send_tx: flume::Sender>, + ) -> ( + flume::Sender, + flume::Receiver, + ) { + let (command_tx, command_rx) = flume::bounded(0); + let (observed_tx, observed_rx) = flume::unbounded(); + std::thread::spawn(move || { + let mut party_sequences = std::collections::HashMap::::new(); + while let Ok(command) = command_rx.recv() { + match command { + SessionCommand::SendRealmPacketLikeCpp(command) + if command.recipient == guid => + { + let _ = send_tx.send(command.packet_bytes); + } + SessionCommand::SendPartyUpdateLikeCpp(mut command) + if command.recipient == guid => + { + let sequence = party_sequences + .entry(command.party_update.party_index) + .or_default(); + *sequence += 1; + command.party_update.sequence_num = *sequence; + let _ = send_tx.send(command.party_update.to_bytes()); + for packet in command.member_full_state_packets { + let _ = send_tx.send(packet); + } + } + command => { + let _ = observed_tx.send(command); + } + } + } + }); + (command_tx, observed_rx) + } + fn broadcast_info(guid: ObjectGuid, send_tx: flume::Sender>) -> PlayerBroadcastInfo { - let (command_tx, _command_rx) = flume::bounded(0); + let (command_tx, _observed_rx) = test_session_command_dispatcher(guid, send_tx.clone()); broadcast_info_with_command_tx(guid, send_tx, command_tx) } + fn recv_dispatched_packet(rx: &flume::Receiver>, label: &str) -> Vec { + rx.recv_timeout(std::time::Duration::from_secs(1)) + .unwrap_or_else(|error| panic!("{label}: {error}")) + } + fn broadcast_info_with_command_tx( guid: ObjectGuid, send_tx: flume::Sender>, @@ -3209,6 +3356,7 @@ mod tests { is_in_world: true, send_tx, command_tx, + durable_loot_money_tracker_like_cpp: Default::default(), active_loot_rolls: Vec::new(), pass_on_group_loot: false, enchanting_skill: 0, @@ -3666,7 +3814,7 @@ mod tests { send_party_update(&group, ®istry, 0); - let sent = rx.try_recv().unwrap(); + let sent = recv_dispatched_packet(&rx, "raid PartyUpdate"); let mut pkt = WorldPacket::from_bytes(&sent); assert_eq!( pkt.read_uint16().unwrap(), @@ -3684,7 +3832,7 @@ mod tests { send_party_update(&group, ®istry, 0); - let sent = rx.try_recv().unwrap(); + let sent = recv_dispatched_packet(&rx, "non-master-loot PartyUpdate"); assert!( !sent .windows(master_bytes.len()) @@ -3703,7 +3851,7 @@ mod tests { send_party_update(&group, ®istry, 0); - let sent = rx.try_recv().unwrap(); + let sent = recv_dispatched_packet(&rx, "raid-group PartyUpdate"); let mut pkt = WorldPacket::from_bytes(&sent); assert_eq!( pkt.read_uint16().unwrap(), @@ -3931,7 +4079,7 @@ mod tests { pending_invites.get(&target).is_some(), "PartyIndex INSTANCE must not treat the full HOME group as the invite group" ); - let invite = target_rx.try_recv().expect("target invite packet"); + let invite = recv_dispatched_packet(&target_rx, "target invite packet"); assert_eq!( u16::from_le_bytes([invite[0], invite[1]]), ServerOpcodes::PartyInvite as u16 @@ -3968,7 +4116,7 @@ mod tests { .handle_party_invite(party_invite_packet(target, &target_name, None, 0x12)) .await; - let invite = target_rx.try_recv().expect("target invite packet"); + let invite = recv_dispatched_packet(&target_rx, "target invite packet"); let mut packet = WorldPacket::from_bytes(&invite); assert_eq!( packet.read_uint16().expect("opcode"), @@ -4012,6 +4160,128 @@ mod tests { assert!(packet.is_empty()); } + #[tokio::test] + async fn party_invite_and_result_route_through_realm_like_cpp() { + let (mut session, instance_rx) = make_session_with_send(); + let (realm_tx, realm_rx) = bounded(8); + session.install_realm_send_channel_for_test(realm_tx); + let inviter = ObjectGuid::create_player(1, 42); + let target = ObjectGuid::create_player(1, 77); + let target_name = format!("Player{}", target.low_value()); + session.set_player_guid(Some(inviter)); + session.set_loaded_player_name_like_cpp("Leader".to_string()); + + let player_registry = Arc::new(PlayerRegistry::default()); + let (target_instance_tx, target_instance_rx) = bounded(8); + let (target_command_tx, target_command_rx) = bounded(8); + player_registry.insert( + target, + broadcast_info_with_command_tx(target, target_instance_tx, target_command_tx), + ); + session.set_player_registry(player_registry); + session.set_group_registry( + Arc::new(GroupRegistry::default()), + Arc::new(PendingInvites::default()), + ); + + session + .handle_party_invite(party_invite_packet(target, &target_name, None, 0)) + .await; + + let SessionCommand::SendRealmPacketLikeCpp(command) = + target_command_rx.try_recv().expect("remote realm command") + else { + panic!("expected remote realm packet command"); + }; + assert_eq!(command.recipient, target); + assert_eq!( + u16::from_le_bytes([command.packet_bytes[0], command.packet_bytes[1]]), + ServerOpcodes::PartyInvite as u16 + ); + assert!(target_instance_rx.try_recv().is_err()); + + let result = realm_rx.try_recv().expect("realm PartyCommandResult"); + assert_eq!( + u16::from_le_bytes([result[0], result[1]]), + ServerOpcodes::PartyCommandResult as u16 + ); + assert!(instance_rx.try_recv().is_err()); + } + + #[tokio::test] + async fn party_invite_queue_timeout_rolls_back_pending_and_never_reports_ok_like_cpp() { + let (mut session, send_rx) = make_session_with_send(); + let inviter = ObjectGuid::create_player(1, 42); + let target = ObjectGuid::create_player(1, 77); + let target_name = format!("Player{}", target.low_value()); + session.set_player_guid(Some(inviter)); + session.set_loaded_player_name_like_cpp("Leader".to_string()); + + let player_registry = Arc::new(PlayerRegistry::default()); + let (target_send_tx, target_send_rx) = bounded(8); + // Keep the zero-capacity receiver alive without polling it. The + // production helper must time out rather than drop the command or use + // the target's primary/INSTANCE sender. + let (target_command_tx, _target_command_rx) = bounded(0); + player_registry.insert( + target, + broadcast_info_with_command_tx(target, target_send_tx, target_command_tx), + ); + let pending = Arc::new(PendingInvites::default()); + session.set_player_registry(player_registry); + session.set_group_registry(Arc::new(GroupRegistry::default()), Arc::clone(&pending)); + + session + .handle_party_invite(party_invite_packet(target, &target_name, None, 0)) + .await; + + assert_eq!( + party_command_result_code(&send_rx.try_recv().expect("failed invite result")), + party_result::BAD_PLAYER_NAME + ); + assert!(pending.get(&target).is_none()); + assert!(pending.get(&inviter).is_none()); + assert!(target_send_rx.try_recv().is_err()); + } + + #[tokio::test] + async fn group_new_leader_fanout_queues_realm_commands_like_cpp() { + let leader = ObjectGuid::create_player(1, 42); + let member = ObjectGuid::create_player(1, 77); + let mut group = GroupInfo::new(leader); + group.add_member(member); + let registry = PlayerRegistry::default(); + let (leader_instance_tx, leader_instance_rx) = bounded(4); + let (leader_command_tx, leader_command_rx) = bounded(4); + registry.insert( + leader, + broadcast_info_with_command_tx(leader, leader_instance_tx, leader_command_tx), + ); + let (member_instance_tx, member_instance_rx) = bounded(4); + let (member_command_tx, member_command_rx) = bounded(4); + registry.insert( + member, + broadcast_info_with_command_tx(member, member_instance_tx, member_command_tx), + ); + + send_group_new_leader_like_cpp(&group, ®istry, "NewLeader").await; + + for (expected, command_rx) in [(leader, leader_command_rx), (member, member_command_rx)] { + let SessionCommand::SendRealmPacketLikeCpp(command) = + command_rx.try_recv().expect("realm GroupNewLeader command") + else { + panic!("expected realm command"); + }; + assert_eq!(command.recipient, expected); + assert_eq!( + u16::from_le_bytes([command.packet_bytes[0], command.packet_bytes[1]]), + ServerOpcodes::GroupNewLeader as u16 + ); + } + assert!(leader_instance_rx.try_recv().is_err()); + assert!(member_instance_rx.try_recv().is_err()); + } + #[tokio::test] async fn party_invite_non_leader_rejects_not_leader_like_cpp() { let (mut session, send_rx) = make_session_with_send(); @@ -4079,9 +4349,10 @@ mod tests { party_command_result_code(&send_rx.try_recv().expect("party command result")), party_result::ALREADY_IN_GROUP ); - assert!(!party_invite_can_accept( - &target_rx.try_recv().expect("target failed invite packet") - )); + assert!(!party_invite_can_accept(&recv_dispatched_packet( + &target_rx, + "target failed invite packet" + ))); assert!(pending_invites.get(&target).is_none()); } @@ -4116,9 +4387,10 @@ mod tests { .await; assert!(pending_invites.get(&target).is_some()); - assert!(party_invite_can_accept( - &target_rx.try_recv().expect("target invite packet") - )); + assert!(party_invite_can_accept(&recv_dispatched_packet( + &target_rx, + "target invite packet" + ))); } #[tokio::test] @@ -4217,9 +4489,10 @@ mod tests { .await; assert!(pending_invites.get(&target).is_some()); - assert!(party_invite_can_accept( - &target_rx.try_recv().expect("target invite packet") - )); + assert!(party_invite_can_accept(&recv_dispatched_packet( + &target_rx, + "target invite packet" + ))); } #[tokio::test] @@ -4250,9 +4523,10 @@ mod tests { .await; assert!(pending_invites.get(&target).is_some()); - assert!(party_invite_can_accept( - &target_rx.try_recv().expect("target invite packet") - )); + assert!(party_invite_can_accept(&recv_dispatched_packet( + &target_rx, + "target invite packet" + ))); } #[tokio::test] @@ -4551,12 +4825,12 @@ mod tests { assert!(command.send_group_uninvite); assert!(command.refresh_visible_gameobjects_or_spellclicks); - let leader_update = leader_rx.try_recv().expect("leader party update"); + let leader_update = recv_dispatched_packet(&leader_rx, "leader party update"); assert_eq!( u16::from_le_bytes([leader_update[0], leader_update[1]]), ServerOpcodes::PartyUpdate as u16 ); - let remaining_update = remaining_rx.try_recv().expect("remaining party update"); + let remaining_update = recv_dispatched_packet(&remaining_rx, "remaining party update"); assert_eq!( u16::from_le_bytes([remaining_update[0], remaining_update[1]]), ServerOpcodes::PartyUpdate as u16 @@ -4672,8 +4946,8 @@ mod tests { send_party_update(&group, ®istry, 0); - let _party_update = leader_rx.try_recv().unwrap(); - let full_state = leader_rx.try_recv().unwrap(); + let _party_update = recv_dispatched_packet(&leader_rx, "leader PartyUpdate"); + let full_state = recv_dispatched_packet(&leader_rx, "leader PartyMemberFullState"); assert_eq!( u16::from_le_bytes([full_state[0], full_state[1]]), ServerOpcodes::PartyMemberFullState as u16 @@ -5301,6 +5575,26 @@ mod tests { assert!(target_rx.try_recv().is_err()); } + #[tokio::test] + async fn request_party_member_stats_routes_reply_through_realm_like_cpp() { + let (mut session, instance_rx) = make_session_with_send(); + let (realm_tx, realm_rx) = bounded(4); + session.install_realm_send_channel_for_test(realm_tx); + let target = ObjectGuid::create_player(1, 77); + session.set_player_registry(Arc::new(PlayerRegistry::default())); + + session + .handle_request_party_member_stats(request_party_member_stats_packet(target, None)) + .await; + + let packet = realm_rx.try_recv().expect("realm PartyMemberFullState"); + assert_eq!( + u16::from_le_bytes([packet[0], packet[1]]), + ServerOpcodes::PartyMemberFullState as u16 + ); + assert!(instance_rx.try_recv().is_err()); + } + #[tokio::test] async fn request_party_member_stats_online_replies_snapshot_without_fanout_like_cpp() { let (mut session, send_rx) = make_session_with_send(); @@ -5501,12 +5795,10 @@ mod tests { assert_eq!(pkt.read_uint8().unwrap(), 1); assert_eq!(pkt.read_uint8().unwrap(), 4); - let leader_update = leader_rx - .try_recv() - .expect("leader PartyUpdate after SetLfgRoles"); - let member_update = member_rx - .try_recv() - .expect("member PartyUpdate after SetLfgRoles"); + let leader_update = + recv_dispatched_packet(&leader_rx, "leader PartyUpdate after SetLfgRoles"); + let member_update = + recv_dispatched_packet(&member_rx, "member PartyUpdate after SetLfgRoles"); let mut leader_update_pkt = WorldPacket::from_bytes(&leader_update); let mut member_update_pkt = WorldPacket::from_bytes(&member_update); assert_eq!( @@ -5713,11 +6005,13 @@ mod tests { let player_registry = Arc::new(PlayerRegistry::default()); let (leader_tx, leader_rx) = bounded(8); let (member_tx, _member_rx) = bounded(8); - let (member_command_tx, member_command_rx) = bounded(8); + let (member_command_tx, member_command_rx) = + test_session_command_dispatcher(member, member_tx.clone()); player_registry.insert(leader, broadcast_info(leader, leader_tx)); - let mut member_info = broadcast_info(member, member_tx); - member_info.command_tx = member_command_tx; - player_registry.insert(member, member_info); + player_registry.insert( + member, + broadcast_info_with_command_tx(member, member_tx, member_command_tx), + ); session.set_player_guid(Some(leader)); session.group_guid = Some(group_guid); @@ -5731,26 +6025,20 @@ mod tests { .get(&group_guid) .is_some_and(|group| group.is_raid_group()) ); - let mut remote_refresh_queued = false; - while let Ok(command) = member_command_rx.try_recv() { - if matches!( - command, - SessionCommand::RefreshVisibleGameobjectsOrSpellClicksLikeCpp - ) { - remote_refresh_queued = true; - } - } - assert!( - remote_refresh_queued, - "remote member visible refresh command queued" - ); + let remote_refresh = member_command_rx + .recv_timeout(Duration::from_secs(1)) + .expect("remote member visible refresh command queued"); + assert!(matches!( + remote_refresh, + SessionCommand::RefreshVisibleGameobjectsOrSpellClicksLikeCpp + )); let command_result = send_rx.try_recv().expect("party command result"); assert_eq!( u16::from_le_bytes([command_result[0], command_result[1]]), ServerOpcodes::PartyCommandResult as u16 ); assert!(send_rx.try_recv().is_err()); - let party_update = leader_rx.try_recv().expect("leader party update"); + let party_update = recv_dispatched_packet(&leader_rx, "leader party update"); assert_eq!( u16::from_le_bytes([party_update[0], party_update[1]]), ServerOpcodes::PartyUpdate as u16 @@ -5822,12 +6110,12 @@ mod tests { let group = group_registry.get(&group_guid).unwrap(); assert_eq!(group.member_group_like_cpp(member), 2); assert!(group.has_free_slot_sub_group_like_cpp(0)); - let leader_update = leader_rx.try_recv().expect("leader party update"); + let leader_update = recv_dispatched_packet(&leader_rx, "leader party update"); assert_eq!( u16::from_le_bytes([leader_update[0], leader_update[1]]), ServerOpcodes::PartyUpdate as u16 ); - let member_update = member_rx.try_recv().expect("member party update"); + let member_update = recv_dispatched_packet(&member_rx, "member party update"); assert_eq!( u16::from_le_bytes([member_update[0], member_update[1]]), ServerOpcodes::PartyUpdate as u16 @@ -5938,12 +6226,12 @@ mod tests { & wow_network::MEMBER_FLAG_MAINTANK_LIKE_CPP, wow_network::MEMBER_FLAG_MAINTANK_LIKE_CPP ); - let leader_update = leader_rx.try_recv().expect("leader party update"); + let leader_update = recv_dispatched_packet(&leader_rx, "leader party update"); assert_eq!( u16::from_le_bytes([leader_update[0], leader_update[1]]), ServerOpcodes::PartyUpdate as u16 ); - let member_update = member_rx.try_recv().expect("member party update"); + let member_update = recv_dispatched_packet(&member_rx, "member party update"); assert_eq!( u16::from_le_bytes([member_update[0], member_update[1]]), ServerOpcodes::PartyUpdate as u16 @@ -6003,7 +6291,7 @@ mod tests { & wow_network::MEMBER_FLAG_MAINASSIST_LIKE_CPP, wow_network::MEMBER_FLAG_MAINASSIST_LIKE_CPP ); - assert!(leader_rx.try_recv().is_ok()); + let _ = recv_dispatched_packet(&leader_rx, "leader party update"); } #[tokio::test] @@ -6097,8 +6385,8 @@ mod tests { .flags, 0 ); - assert!(leader_rx.try_recv().is_ok()); - assert!(member_rx.try_recv().is_ok()); + let _ = recv_dispatched_packet(&leader_rx, "leader party update"); + let _ = recv_dispatched_packet(&member_rx, "member party update"); { let mut group = group_registry.get_mut(&group_guid).unwrap(); @@ -6129,8 +6417,8 @@ mod tests { & wow_network::MEMBER_FLAG_MAINTANK_LIKE_CPP, 0 ); - assert!(leader_rx.try_recv().is_ok()); - assert!(member_rx.try_recv().is_ok()); + let _ = recv_dispatched_packet(&leader_rx, "leader party update"); + let _ = recv_dispatched_packet(&member_rx, "member party update"); } #[tokio::test] @@ -6164,8 +6452,8 @@ mod tests { let group = group_registry.get(&group_guid).unwrap(); assert_eq!(group.sequence_num, sequence_before); assert_eq!(group.member_slot_like_cpp(member).unwrap().flags, 0); - assert!(leader_rx.try_recv().is_ok()); - assert!(member_rx.try_recv().is_ok()); + let _ = recv_dispatched_packet(&leader_rx, "leader party update"); + let _ = recv_dispatched_packet(&member_rx, "member party update"); } #[tokio::test] @@ -6207,12 +6495,12 @@ mod tests { wow_network::MEMBER_FLAG_ASSISTANT_LIKE_CPP ); } - let leader_update = leader_rx.try_recv().expect("leader party update"); + let leader_update = recv_dispatched_packet(&leader_rx, "leader party update"); assert_eq!( u16::from_le_bytes([leader_update[0], leader_update[1]]), ServerOpcodes::PartyUpdate as u16 ); - let member_update = member_rx.try_recv().expect("member party update"); + let member_update = recv_dispatched_packet(&member_rx, "member party update"); assert_eq!( u16::from_le_bytes([member_update[0], member_update[1]]), ServerOpcodes::PartyUpdate as u16 @@ -6258,8 +6546,8 @@ mod tests { 0 ); } - assert!(leader_rx.try_recv().is_ok()); - assert!(member_rx.try_recv().is_ok()); + let _ = recv_dispatched_packet(&leader_rx, "leader party update"); + let _ = recv_dispatched_packet(&member_rx, "member party update"); } #[tokio::test] @@ -6434,8 +6722,8 @@ mod tests { group_registry.get(&group_guid).unwrap().sequence_num, sequence_after_apply ); - assert!(leader_rx.try_recv().is_ok()); - assert!(member_rx.try_recv().is_ok()); + let _ = recv_dispatched_packet(&leader_rx, "leader party update"); + let _ = recv_dispatched_packet(&member_rx, "member party update"); } #[tokio::test] @@ -6476,12 +6764,12 @@ mod tests { & wow_network::MEMBER_FLAG_ASSISTANT_LIKE_CPP, wow_network::MEMBER_FLAG_ASSISTANT_LIKE_CPP ); - let leader_update = leader_rx.try_recv().expect("leader party update"); + let leader_update = recv_dispatched_packet(&leader_rx, "leader party update"); assert_eq!( u16::from_le_bytes([leader_update[0], leader_update[1]]), ServerOpcodes::PartyUpdate as u16 ); - let member_update = member_rx.try_recv().expect("member party update"); + let member_update = recv_dispatched_packet(&member_rx, "member party update"); assert_eq!( u16::from_le_bytes([member_update[0], member_update[1]]), ServerOpcodes::PartyUpdate as u16 @@ -6543,22 +6831,22 @@ mod tests { ); drop(group); - let leader_new_leader = leader_rx.try_recv().expect("leader new-leader packet"); + let leader_new_leader = recv_dispatched_packet(&leader_rx, "leader new-leader packet"); assert_eq!( u16::from_le_bytes([leader_new_leader[0], leader_new_leader[1]]), ServerOpcodes::GroupNewLeader as u16 ); - let leader_update = leader_rx.try_recv().expect("leader party update"); + let leader_update = recv_dispatched_packet(&leader_rx, "leader party update"); assert_eq!( u16::from_le_bytes([leader_update[0], leader_update[1]]), ServerOpcodes::PartyUpdate as u16 ); - let member_new_leader = member_rx.try_recv().expect("member new-leader packet"); + let member_new_leader = recv_dispatched_packet(&member_rx, "member new-leader packet"); assert_eq!( u16::from_le_bytes([member_new_leader[0], member_new_leader[1]]), ServerOpcodes::GroupNewLeader as u16 ); - let member_update = member_rx.try_recv().expect("member party update"); + let member_update = recv_dispatched_packet(&member_rx, "member party update"); assert_eq!( u16::from_le_bytes([member_update[0], member_update[1]]), ServerOpcodes::PartyUpdate as u16 @@ -6737,17 +7025,17 @@ mod tests { let group = group_registry.get(&group_guid).unwrap(); assert_eq!(group.member_group_like_cpp(first), 2); assert_eq!(group.member_group_like_cpp(second), 0); - let leader_update = leader_rx.try_recv().expect("leader party update"); + let leader_update = recv_dispatched_packet(&leader_rx, "leader party update"); assert_eq!( u16::from_le_bytes([leader_update[0], leader_update[1]]), ServerOpcodes::PartyUpdate as u16 ); - let first_update = first_rx.try_recv().expect("first member party update"); + let first_update = recv_dispatched_packet(&first_rx, "first member party update"); assert_eq!( u16::from_le_bytes([first_update[0], first_update[1]]), ServerOpcodes::PartyUpdate as u16 ); - let second_update = second_rx.try_recv().expect("second member party update"); + let second_update = recv_dispatched_packet(&second_rx, "second member party update"); assert_eq!( u16::from_le_bytes([second_update[0], second_update[1]]), ServerOpcodes::PartyUpdate as u16 @@ -6834,16 +7122,13 @@ mod tests { .member_group_like_cpp(second), 0 ); - let first_update = first_rx - .try_recv() - .expect("first update after assistant swap"); + let first_update = recv_dispatched_packet(&first_rx, "first update after assistant swap"); assert_eq!( u16::from_le_bytes([first_update[0], first_update[1]]), ServerOpcodes::PartyUpdate as u16 ); - let second_update = second_rx - .try_recv() - .expect("second update after assistant swap"); + let second_update = + recv_dispatched_packet(&second_rx, "second update after assistant swap"); assert_eq!( u16::from_le_bytes([second_update[0], second_update[1]]), ServerOpcodes::PartyUpdate as u16 diff --git a/crates/wow-world/src/handlers/loot.rs b/crates/wow-world/src/handlers/loot.rs index 5022a492e..83efcf418 100644 --- a/crates/wow-world/src/handlers/loot.rs +++ b/crates/wow-world/src/handlers/loot.rs @@ -5,10 +5,15 @@ //! Loot packet handlers — CMSG_LOOT_UNIT, CMSG_LOOT_ITEM, CMSG_LOOT_RELEASE. //! -//! Reference: C# Game/Handlers/LootHandler.cs +//! References: C++ `WorldSession::HandleLoot*`/`DoLootRelease` in +//! `src/server/game/Handlers/LootHandler.cpp` and `Loot` in +//! `src/server/game/Loot/Loot.cpp`. use std::collections::{HashMap, HashSet}; -use std::sync::Arc; +use std::sync::{ + Arc, + atomic::{AtomicBool, AtomicU64, Ordering}, +}; use std::time::{Duration, Instant}; use rand::{ @@ -19,13 +24,18 @@ use tokio::time::timeout; use tracing::{debug, info, warn}; use wow_constants::{ - ClientOpcodes, InventoryResult, InventoryType, ItemContext, ItemFlags, ItemFlags2, ItemQuality, + ClientOpcodes, InventoryResult, InventoryType, ItemContext, ItemFieldFlags, ItemFlags, + ItemFlags2, ItemQuality, }; use wow_core::{ObjectGuid, guid::HighGuid}; use wow_data::{ItemRandomEnchantmentTemplateEntry, ItemRandomPropertyTemplateEntry}; -use wow_database::{CharStatements, SqlTransaction, WorldStatements}; +use wow_database::{ + CharStatements, CharacterDatabase, DatabaseError, SqlTransaction, SqlTransactionCommitError, + StatementDef, WorldStatements, is_database_deadlock_like_cpp, + retry_deadlocked_operation_like_cpp, +}; use wow_entities::{ - AccessorObjectKind, CORPSE_DYNFLAG_LOOTABLE, CreatureOwnedLoot, GAMEOBJECT_TYPE_AREADAMAGE, + AccessorObjectKind, CORPSE_DYNFLAG_LOOTABLE, GAMEOBJECT_TYPE_AREADAMAGE, GAMEOBJECT_TYPE_BARBER_CHAIR, GAMEOBJECT_TYPE_BINDER, GAMEOBJECT_TYPE_CAMERA, GAMEOBJECT_TYPE_CHAIR, GAMEOBJECT_TYPE_CHEST, GAMEOBJECT_TYPE_DESTRUCTIBLE_BUILDING, GAMEOBJECT_TYPE_DOOR, GAMEOBJECT_TYPE_DUNGEON_DIFFICULTY, GAMEOBJECT_TYPE_FISHING_HOLE, @@ -33,16 +43,18 @@ use wow_entities::{ GAMEOBJECT_TYPE_GATHERING_NODE, GAMEOBJECT_TYPE_GOOBER, GAMEOBJECT_TYPE_GUILD_BANK, GAMEOBJECT_TYPE_MAILBOX, GAMEOBJECT_TYPE_MAP_OBJECT, GAMEOBJECT_TYPE_MINI_GAME, GAMEOBJECT_TYPE_QUESTGIVER, GAMEOBJECT_TYPE_TEXT, GO_DYNFLAG_LO_NO_INTERACT, - GameObjectLootSource, GameObjectOwnedLoot, GatheringNodeUseSource, GoState, - INVENTORY_DEFAULT_SIZE, INVENTORY_SLOT_BAG_0, INVENTORY_SLOT_ITEM_END, - INVENTORY_SLOT_ITEM_START, Item, ItemPosCount, LootState, make_item_pos, + GameObjectLootSource, GatheringNodeUseSource, GoState, INVENTORY_DEFAULT_SIZE, + INVENTORY_SLOT_BAG_0, INVENTORY_SLOT_ITEM_END, INVENTORY_SLOT_ITEM_START, Item, ItemPosCount, + LootState, MAX_MONEY_AMOUNT, is_bag_pos, make_item_pos, }; use wow_handler::{PacketHandlerEntry, PacketProcessing, SessionStatus}; use wow_loot::{ - GeneratedLootItem, LootConditionId, LootConditionRowLikeCpp, LootFillError, LootFillOptions, + GeneratedLootItem, LootClaimCommitError, LootClaimError, LootClaimLease, LootClaimPayload, + LootConditionId, LootConditionRowLikeCpp, LootFillError, LootFillOptions, LootItemRandomProperties, LootItemTemplateMetadata, LootStoreItem, LootStoreItemContext, - LootStoreKind, LootTemplate, condition_compare_values_like_cpp, - loot_condition_reference_ids_like_cpp, loot_condition_reference_self_references_like_cpp, + LootStoreKind, LootTemplate, OwnedLootAuthority, OwnedLootAuthorityLifecycle, OwnedLootScope, + OwnedLootSnapshot, condition_compare_values_like_cpp, loot_condition_reference_ids_like_cpp, + loot_condition_reference_self_references_like_cpp, loot_condition_row_normalize_without_external_stores_like_cpp, loot_conditions_allow_player_with_references_like_cpp_representable, loot_item_ui_type_for_player_like_cpp, @@ -61,7 +73,9 @@ use wow_network::player_registry::{ WorldSessionShutdownFlushResultLikeCpp, }; use wow_network::{ - LootRollStoreWinnerCommand, LootRollVoteCommand, MasterLootGiveCommand, MasterLootGiveResult, + ApplyLootMoneyLikeCppCommand, ApplyLootMoneyResultLikeCpp, KickLikeCppCommand, + LootRollCommandIdentityLikeCpp, LootRollStoreWinnerCommand, LootRollVoteCommand, + MasterLootGiveCommand, MasterLootGiveResult, NotifyLootMoneyRemovedLikeCppCommand, PlayerRegistry, SessionCommand, }; use wow_packet::packets::item::{ @@ -89,9 +103,12 @@ use crate::conditions::{ QUEST_STATUS_NONE_LIKE_CPP, QUEST_STATUS_REWARDED_LIKE_CPP, }; use crate::session::{ - InventoryItem, RepresentedGameObjectSpellCaster, RepresentedGameObjectUseEffect, + DurableItemLootCompletionLikeCpp, DurableItemLootPersistenceGuardLikeCpp, + DurableLootItemFanoutLikeCpp, InventoryItem, LootMoneyPersistenceErrorLikeCpp, + LootMoneyViewerFanoutLikeCpp, RepresentedGameObjectSpellCaster, RepresentedGameObjectUseEffect, RepresentedLootRollState, RepresentedLootRollVote, RepresentedQuestObjectiveProgressEventLikeCpp, SessionState, WorldSession, + loot_money_durable_outcome_like_cpp, }; const LOOT_METHOD_MASTER_LIKE_CPP: u8 = 2; @@ -128,6 +145,17 @@ const LOCK_KEY_SPELL_LIKE_CPP: u8 = 3; const SPELL_EFFECT_OPEN_LOCK_LIKE_CPP: u32 = 33; const REMOTE_MASTER_LOOT_COMMAND_TIMEOUT: Duration = Duration::from_millis(250); +#[derive(Clone)] +struct AuthoritativeLootReleaseLikeCpp { + authority: OwnedLootAuthority, + selected_generation: u64, + loot: CreatureLoot, + whole_object_fully_looted: bool, + whole_object_fully_skinned: bool, + object_generation: u64, + lifecycle_revision: u64, +} + // ── Handler registrations ───────────────────────────────────────── inventory::submit! { @@ -263,12 +291,11 @@ impl WorldSession { else { return; }; - if !self.active_loot_guid.is_empty() && !self.active_loot_guid.is_item() { + if self.has_active_non_item_loot_views_like_cpp() { self.do_loot_release_all_like_cpp(player_guid).await; } self.set_active_loot_guid(req.unit); - self.send_packet(&response); - self.represented_on_loot_opened_like_cpp(req.unit, player_guid); + self.represented_on_loot_opened_like_cpp(req.unit, player_guid, response); if !ae_owner_guids.is_empty() { self.send_packet(&AELootTargetsAck); @@ -279,8 +306,7 @@ impl WorldSession { .await { self.add_active_loot_view_owner_like_cpp(owner_guid); - self.send_packet(&response); - self.represented_on_loot_opened_like_cpp(owner_guid, player_guid); + self.represented_on_loot_opened_like_cpp(owner_guid, player_guid, response); self.send_packet(&AELootTargetsAck); } } @@ -339,10 +365,22 @@ impl WorldSession { let should_record_generation_effects = source.loot_id != 0 && !self.loot_table.contains_key(&gameobject_guid); + let allowed_looters = if source.is_personal_encounter_loot_like_cpp() { + Vec::new() + } else if source.uses_personal_loot_like_cpp() { + // C++ creates only `m_personalLoot[player]` for a personal chest + // without a DungeonEncounter; group loot rules never widen it. + vec![player_guid] + } else if source.use_group_loot_rules { + self.represented_group_looters_at_reward_distance_like_cpp(player_guid) + } else { + vec![player_guid] + }; self.ensure_represented_gameobject_chest_loot_like_cpp( gameobject_guid, player_guid, source, + &allowed_looters, ) .await; if should_record_generation_effects && self.loot_table.contains_key(&gameobject_guid) { @@ -354,21 +392,31 @@ impl WorldSession { ); } - if !source.is_personal_encounter_loot_like_cpp() - && let Some(loot) = self.loot_table.get_mut(&gameobject_guid) + if self + .sync_represented_gameobject_loot_to_canonical_like_cpp(gameobject_guid, player_guid) + .is_none() { - mark_loot_allowed_for_player_like_cpp(loot, player_guid); + self.loot_table.remove(&gameobject_guid); + return; } - self.sync_represented_gameobject_loot_to_canonical_like_cpp(gameobject_guid, player_guid); let Some(loot) = self.loot_table.get(&gameobject_guid) else { return; }; - if !self.represented_loot_can_be_opened_by_player_like_cpp( - gameobject_guid, - loot, - player_guid, - ) { + // C++ keeps and sends an empty non-encounter + // `m_personalLoot[player]`. Encounter generation instead discards + // empty pools in `GenerateDungeonEncounterPersonalLoot`, so only the + // former bypasses the generic item/money availability gate. + let empty_non_encounter_personal_pool = source.uses_personal_loot_like_cpp() + && !source.is_personal_encounter_loot_like_cpp() + && loot.allowed_looters.contains(&player_guid); + if !empty_non_encounter_personal_pool + && !self.represented_loot_can_be_opened_by_player_like_cpp( + gameobject_guid, + loot, + player_guid, + ) + { return; } @@ -390,12 +438,11 @@ impl WorldSession { ae_looting: false, }; - if !self.active_loot_guid.is_empty() && !self.active_loot_guid.is_item() { + if self.has_active_non_item_loot_views_like_cpp() { self.do_loot_release_all_like_cpp(player_guid).await; } self.set_active_loot_guid(gameobject_guid); - self.send_packet(&response); - self.represented_on_loot_opened_like_cpp(gameobject_guid, player_guid); + self.represented_on_loot_opened_like_cpp(gameobject_guid, player_guid, response); } pub(crate) async fn open_represented_fishing_hole_like_cpp( @@ -443,6 +490,11 @@ impl WorldSession { if !self.represented_gameobject_exists_for_loot_like_cpp(gameobject_guid) { return; } + let install_observation = + self.represented_gameobject_loot_install_observation_like_cpp(gameobject_guid); + if install_observation.is_none() && !represented_local_loot_fixture_allowed_like_cpp() { + return; + } let loot_type = if junk { LOOT_TYPE_FISHING_JUNK_LIKE_CPP @@ -467,10 +519,14 @@ impl WorldSession { Vec::new() }); + let Some(loot_guid) = self.next_represented_loot_object_guid_like_cpp(gameobject_guid) + else { + return; + }; self.loot_table.insert( gameobject_guid, CreatureLoot { - loot_guid: represented_loot_object_guid_like_cpp(gameobject_guid), + loot_guid, coins: 0, unlooted_count: 0, loot_type, @@ -489,7 +545,25 @@ impl WorldSession { if let Some(loot) = self.loot_table.get_mut(&gameobject_guid) { mark_loot_allowed_for_player_like_cpp(loot, player_guid); } - self.sync_represented_gameobject_loot_to_canonical_like_cpp(gameobject_guid, player_guid); + let upserted = self + .loot_table + .get(&gameobject_guid) + .cloned() + .and_then(|loot| { + install_observation.as_ref().and_then(|observation| { + self.upsert_represented_personal_gameobject_loot_authority_if_observed_like_cpp( + gameobject_guid, + player_guid, + loot, + false, + observation, + ) + }) + }); + if upserted.is_none() && !represented_local_loot_fixture_allowed_like_cpp() { + self.loot_table.remove(&gameobject_guid); + return; + } let Some(loot) = self.loot_table.get(&gameobject_guid) else { return; @@ -516,12 +590,11 @@ impl WorldSession { ae_looting: false, }; - if !self.active_loot_guid.is_empty() && !self.active_loot_guid.is_item() { + if self.has_active_non_item_loot_views_like_cpp() { self.do_loot_release_all_like_cpp(player_guid).await; } self.set_active_loot_guid(gameobject_guid); - self.send_packet(&response); - self.represented_on_loot_opened_like_cpp(gameobject_guid, player_guid); + self.represented_on_loot_opened_like_cpp(gameobject_guid, player_guid, response); } pub(crate) async fn open_represented_gathering_node_like_cpp( @@ -1003,12 +1076,39 @@ impl WorldSession { return; } + // Fishing holes replace this player's personal `Loot` in place. Close + // the old C++ view before the upsert so its release cannot detach or + // apply lifecycle state to the freshly generated pool. + if replace_existing && self.has_active_non_item_loot_views_like_cpp() { + self.do_loot_release_all_like_cpp(player_guid).await; + } + + // C++ serializes template generation and `ClearLoot` on the map + // thread. Rust awaits database-backed template generation, so retain + // the exact object lifetime and authority tombstone across that await. + let install_observation = + self.represented_gameobject_loot_install_observation_like_cpp(gameobject_guid); + if install_observation.is_none() && !represented_local_loot_fixture_allowed_like_cpp() { + return; + } + + if !replace_existing + && let Some(snapshot) = self + .represented_owned_loot_authority_like_cpp(gameobject_guid) + .and_then(|authority| authority.snapshot_for_player_like_cpp(player_guid)) + { + self.loot_table.insert(gameobject_guid, snapshot.loot); + self.represented_loot_cache_generations_like_cpp + .insert(gameobject_guid, snapshot.generation); + } + if replace_existing || !self.loot_table.contains_key(&gameobject_guid) { let items = self .generate_represented_gameobject_loot_items_for_store_like_cpp( loot_id, LootStoreKind::Gameobject, LOOT_MODE_DEFAULT_LIKE_CPP, + None, ) .await .unwrap_or_else(|| { @@ -1019,10 +1119,14 @@ impl WorldSession { ); Vec::new() }); + let Some(loot_guid) = self.next_represented_loot_object_guid_like_cpp(gameobject_guid) + else { + return; + }; self.loot_table.insert( gameobject_guid, CreatureLoot { - loot_guid: represented_loot_object_guid_like_cpp(gameobject_guid), + loot_guid, coins: 0, unlooted_count: 0, loot_type, @@ -1042,7 +1146,25 @@ impl WorldSession { if let Some(loot) = self.loot_table.get_mut(&gameobject_guid) { mark_loot_allowed_for_player_like_cpp(loot, player_guid); } - self.sync_represented_gameobject_loot_to_canonical_like_cpp(gameobject_guid, player_guid); + self.represented_personal_loot_owners + .insert(gameobject_guid); + if let Some(loot) = self.loot_table.get(&gameobject_guid).cloned() { + let upserted = install_observation.as_ref().and_then(|observation| { + self.upsert_represented_personal_gameobject_loot_authority_if_observed_like_cpp( + gameobject_guid, + player_guid, + loot, + replace_existing, + observation, + ) + }); + if upserted.is_none() && !represented_local_loot_fixture_allowed_like_cpp() { + self.loot_table.remove(&gameobject_guid); + self.represented_personal_loot_owners + .remove(&gameobject_guid); + return; + } + } let Some(loot) = self.loot_table.get(&gameobject_guid) else { return; @@ -1069,12 +1191,11 @@ impl WorldSession { ae_looting: false, }; - if !self.active_loot_guid.is_empty() && !self.active_loot_guid.is_item() { + if !replace_existing && self.has_active_non_item_loot_views_like_cpp() { self.do_loot_release_all_like_cpp(player_guid).await; } self.set_active_loot_guid(gameobject_guid); - self.send_packet(&response); - self.represented_on_loot_opened_like_cpp(gameobject_guid, player_guid); + self.represented_on_loot_opened_like_cpp(gameobject_guid, player_guid, response); } /// CMSG_LOOT_ITEM — player clicks to take a specific item from the loot. @@ -1093,7 +1214,6 @@ impl WorldSession { }; let mut taken_items: Vec<(ObjectGuid, ObjectGuid, u8, u32, u32, bool)> = Vec::new(); - let mut item_release: Vec = Vec::new(); let mut canonical_loot_sync: Vec = Vec::new(); for loot_req in &req.requests { @@ -1106,8 +1226,6 @@ impl WorldSession { continue; }; - self.ensure_represented_player_looting_like_cpp(owner_guid, player_guid); - if owner_guid.is_game_object() && !self.represented_gameobject_can_autostore_loot_item_like_cpp( owner_guid, @@ -1146,7 +1264,34 @@ impl WorldSession { } } - let Some((entry, dungeon_encounter_id)) = + let owned_authority = self + .prepare_owned_loot_authority_for_active_request_like_cpp(owner_guid, player_guid); + let authority = owned_authority + .as_ref() + .filter(|authority| { + authority + .snapshot_for_player_like_cpp(player_guid) + .is_some() + }) + .cloned(); + if authority.is_none() + && (owner_guid.is_creature_or_vehicle() || owner_guid.is_game_object()) + && (owned_authority.is_some() || !represented_local_loot_fixture_allowed_like_cpp()) + { + self.send_equip_error(InventoryResult::LootGone, None, None, 0, 0); + continue; + } + if let Some(authority) = authority.as_ref() { + if !self.represented_active_loot_generation_matches_like_cpp(owner_guid, authority) + { + self.send_equip_error(InventoryResult::LootGone, None, None, 0, 0); + continue; + } + let _ = self.reconcile_represented_loot_cache_like_cpp(owner_guid, player_guid); + } + self.ensure_represented_player_looting_like_cpp(owner_guid, player_guid); + + let Some((cached_entry, dungeon_encounter_id)) = self.loot_table.get(&owner_guid).and_then(|loot| { loot.items .iter() @@ -1166,36 +1311,108 @@ impl WorldSession { continue; }; - if !entry.has_allowed_looter_like_cpp(player_guid) { + if !cached_entry.has_allowed_looter_like_cpp(player_guid) { self.send_packet(&LootReleaseAll); continue; } - if entry.flags.blocked { + if cached_entry.flags.blocked { self.send_packet(&LootReleaseAll); continue; } - if !entry.roll_winner_allows_like_cpp(player_guid) { + if !cached_entry.roll_winner_allows_like_cpp(player_guid) { self.send_packet(&LootReleaseAll); continue; } - if !self - .store_direct_loot_item_like_cpp(&entry, dungeon_encounter_id) + let (entry, claim) = if let Some(authority) = authority { + let Some(expected_generation) = self + .active_loot_view_generations_like_cpp + .get(&owner_guid) + .copied() + else { + self.send_equip_error(InventoryResult::LootGone, None, None, 0, 0); + continue; + }; + let claim = match authority + .reserve_item_for_generation_like_cpp( + player_guid, + loot_req.loot_list_id, + expected_generation, + ) + .await + { + Ok(claim) => claim, + Err(_) => { + let _ = + self.reconcile_represented_loot_cache_like_cpp(owner_guid, player_guid); + self.send_equip_error(InventoryResult::LootGone, None, None, 0, 0); + continue; + } + }; + if !self + .represented_active_loot_claim_generation_matches_like_cpp(owner_guid, &claim) + { + claim.rollback_like_cpp(); + self.send_equip_error(InventoryResult::LootGone, None, None, 0, 0); + continue; + } + let LootClaimPayload::Item(entry) = claim.payload_like_cpp() else { + claim.rollback_like_cpp(); + self.send_equip_error(InventoryResult::LootGone, None, None, 0, 0); + continue; + }; + (entry.clone(), Some(claim)) + } else { + (cached_entry, None) + }; + + let stored = if let Some(claim) = claim.as_ref() { + self.store_claimed_direct_loot_item_from_owner_like_cpp( + &entry, + dungeon_encounter_id, + owner_guid, + loot_req.object, + claim, + ) .await - { + } else { + self.store_direct_loot_item_from_owner_like_cpp( + &entry, + dungeon_encounter_id, + owner_guid, + ) + .await + }; + if !stored { continue; } if owner_guid.is_item() { - self.delete_stored_item_loot_item_like_cpp( - owner_guid, - entry.item_id, - entry.quantity, - entry.loot_list_id, - ) - .await; + // The detached worker published this exact durable removal to + // the session tracker before its JoinHandle completed. Apply + // it here on the normal path; logout/disconnect and the + // session tick drain the same completion after cancellation. + self.apply_pending_durable_item_loot_completions_like_cpp() + .await; + debug!( + account = self.account_id, + item = entry.item_id, + quantity = entry.quantity, + "Looted item" + ); + continue; + } + + if claim.is_some() { + debug!( + account = self.account_id, + item = entry.item_id, + quantity = entry.quantity, + "Looted item" + ); + continue; } if let Some(loot) = self.loot_table.get_mut(&owner_guid) { @@ -1220,10 +1437,6 @@ impl WorldSession { )); canonical_loot_sync.push(owner_guid); } - - if owner_guid.is_item() && loot_is_looted_like_cpp(loot) { - item_release.push(owner_guid); - } } } @@ -1251,18 +1464,6 @@ impl WorldSession { "Looted item" ); } - - item_release.sort_by_key(|guid| guid.counter()); - item_release.dedup(); - for loot_guid in item_release { - self.loot_table.remove(&loot_guid); - self.clear_active_loot_guid_if(loot_guid); - self.send_packet(&SLootRelease { - loot_obj: loot_guid, - owner: player_guid, - }); - self.destroy_fully_looted_direct_item(loot_guid).await; - } } /// CMSG_LOOT_MONEY — player takes money from the current loot view. @@ -1301,9 +1502,11 @@ impl WorldSession { .into_iter() .filter_map(|loot_guid| { let loot = self.loot_table.get(&loot_guid)?; - if loot_guid.is_creature_or_vehicle() - && !loot.allowed_looters.contains(&player_guid) - { + // C++ only places loot in Player::GetAELootView after the + // player passed the source's loot-eligibility gate. Keep the + // same invariant at this represented boundary so a stale or + // forged local view cannot take another player's money. + if !loot.allowed_looters.contains(&player_guid) { return None; } Some(( @@ -1320,9 +1523,285 @@ impl WorldSession { let mut item_release: Vec = Vec::new(); let mut player_money_delta = 0u64; + let mut legacy_money_processed = false; + + for (loot_guid, loot_obj, money) in &money_by_loot { + let owned_authority = self + .prepare_owned_loot_authority_for_active_request_like_cpp(*loot_guid, player_guid); + let authority = owned_authority + .as_ref() + .filter(|authority| { + authority + .snapshot_for_player_like_cpp(player_guid) + .is_some() + }) + .cloned(); + if authority.is_none() + && (loot_guid.is_creature_or_vehicle() || loot_guid.is_game_object()) + && (owned_authority.is_some() || !represented_local_loot_fixture_allowed_like_cpp()) + { + debug!( + owner = ?loot_guid, + "world-object loot money has no shared authority; refusing session-local fallback" + ); + continue; + } + if let Some(authority) = authority { + if !self.represented_active_loot_generation_matches_like_cpp(*loot_guid, &authority) + { + debug!( + owner = ?loot_guid, + "delayed loot-money request does not belong to the active object generation" + ); + continue; + } + let _ = self.reconcile_represented_loot_cache_like_cpp(*loot_guid, player_guid); + self.ensure_represented_player_looting_like_cpp(*loot_guid, player_guid); + + let Some(expected_generation) = self + .active_loot_view_generations_like_cpp + .get(loot_guid) + .copied() + else { + continue; + }; + let claim = match authority + .reserve_money_for_generation_like_cpp(player_guid, expected_generation) + .await + { + Ok(claim) => claim, + Err(_) => { + let _ = + self.reconcile_represented_loot_cache_like_cpp(*loot_guid, player_guid); + continue; + } + }; + if !self + .represented_active_loot_claim_generation_matches_like_cpp(*loot_guid, &claim) + { + claim.rollback_like_cpp(); + continue; + } + let LootClaimPayload::Money(reserved_money) = claim.payload_like_cpp() else { + claim.rollback_like_cpp(); + continue; + }; + let authority_generation = claim.generation_like_cpp(); + let mut recipients = self.represented_loot_money_recipients_like_cpp(*loot_guid); + recipients.sort_unstable_by_key(|guid| (guid.high_value(), guid.low_value())); + recipients.dedup(); + if recipients.is_empty() { + recipients.push(player_guid); + } + let mut admitted = Vec::with_capacity(recipients.len()); + for recipient in recipients.iter().copied() { + let admission = if recipient == player_guid { + Some(( + self.session_command_tx(), + self.durable_loot_money_persistence_tracker_like_cpp(), + )) + } else { + self.player_registry() + .and_then(|registry| registry.get(&recipient)) + .map(|target| { + ( + target.command_tx.clone(), + Arc::clone(&target.durable_loot_money_tracker_like_cpp), + ) + }) + }; + let Some((command_tx, money_tracker)) = admission else { + admitted.clear(); + break; + }; + admitted.push((recipient, command_tx, money_tracker)); + } + + // Eligibility is chosen once, before persistence. If a + // connected eligible member cannot be admitted, retry the + // original pool instead of silently changing the divisor. + if admitted.len() != recipients.len() || admitted.is_empty() { + claim.rollback_like_cpp(); + let _ = self.reconcile_represented_loot_cache_like_cpp(*loot_guid, player_guid); + continue; + } + + let money_per_player = u64::from(*reserved_money) / recipients.len() as u64; + let sole_looter = recipients.len() <= 1; + let payouts = recipients + .iter() + .copied() + .map(|recipient| (recipient, money_per_player)) + .collect::>(); + let authority_committed = Arc::new(AtomicBool::new(false)); + let mut deliveries = Vec::with_capacity(admitted.len()); + let mut local_application = None; + for (recipient, command_tx, money_tracker) in admitted { + let application = ApplyLootMoneyLikeCppCommand { + recipient, + loot_owner: *loot_guid, + loot_obj: *loot_obj, + amount: money_per_player, + durable_applied_amount: Arc::new(AtomicU64::new(0)), + durable_persistence_tracker: money_tracker, + sole_looter, + authority: authority.clone(), + authority_generation, + authority_committed: Arc::clone(&authority_committed), + send_coin_removed: Arc::new(AtomicBool::new(false)), + applied: Arc::new(AtomicBool::new(false)), + published: Arc::new(AtomicBool::new(false)), + }; + if recipient == player_guid { + local_application = Some(application.clone()); + } + deliveries.push(( + command_tx, + SessionCommand::ApplyLootMoneyLikeCpp(application), + )); + } + + let current_map = self.player_map_id_like_cpp(); + let current_instance = self + .current_canonical_player_map_key_like_cpp() + .map(|key| key.instance_id) + .unwrap_or(0); + let viewer_fanout = LootMoneyViewerFanoutLikeCpp { + scope_player: player_guid, + source_player: player_guid, + source_command_tx: self.session_command_tx(), + player_registry: self.player_registry().cloned(), + map_id: current_map, + instance_id: current_instance, + loot_owner: *loot_guid, + loot_obj: *loot_obj, + authority: authority.clone(), + authority_generation, + payout_recipients: recipients.iter().copied().collect(), + }; + + let persistence = match self.spawn_group_loot_money_persistence_like_cpp( + payouts, + claim, + deliveries, + authority_committed, + viewer_fanout, + ) { + Ok(persistence) => persistence, + Err(error) => { + warn!( + owner = ?loot_guid, + recipients = recipients.len(), + amount = money_per_player, + %error, + "atomic loot-money fanout could not start; pool remains available" + ); + let _ = + self.reconcile_represented_loot_cache_like_cpp(*loot_guid, player_guid); + continue; + } + }; + + if let Err(error) = persistence.await.unwrap_or_else(|join_error| { + warn!( + owner = ?loot_guid, + ?join_error, + "atomic loot-money persistence worker terminated" + ); + Err(crate::session::LootMoneyPersistenceErrorLikeCpp::WorkerTerminated) + }) { + warn!( + owner = ?loot_guid, + recipients = recipients.len(), + amount = money_per_player, + %error, + "atomic loot-money fanout persistence failed; pool remains available" + ); + let _ = self.reconcile_represented_loot_cache_like_cpp(*loot_guid, player_guid); + continue; + } + if let Some(application) = local_application { + self.handle_apply_loot_money_like_cpp_command(application) + .await; + } + // The detached worker has already committed the authority and + // queued the durable runtime applications. Returning to the + // session loop lets this session drain its own command too. + continue; + } - for (loot_guid, _loot_obj, money) in &money_by_loot { self.ensure_represented_player_looting_like_cpp(*loot_guid, player_guid); + + if loot_guid.is_item() { + let cached_amount = u64::from(*money); + let Some((balance_applied, publication_applied, applied_delta, notified_amount)) = + self.persist_and_consume_stored_item_money_like_cpp(*loot_guid, cached_amount) + .await + else { + continue; + }; + let apply_balance = balance_applied + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok(); + let publish = publication_applied + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok(); + if !apply_balance && !publish { + continue; + } + + if apply_balance { + let old_money = self.player_gold_like_cpp(); + let new_money = old_money + .checked_add(applied_delta) + .filter(|money| *money <= MAX_MONEY_AMOUNT) + .unwrap_or(old_money); + self.set_player_gold_like_cpp(new_money); + if applied_delta != 0 { + self.enqueue_represented_quest_objective_progress_like_cpp( + RepresentedQuestObjectiveProgressEventLikeCpp::MoneyChanged { + old_money, + new_money, + }, + ); + } + } + if publish { + self.represented_notify_money_removed_like_cpp(*loot_guid); + self.send_packet(&LootMoneyNotify { + money: notified_amount, + money_mod: 0, + sole_looter: true, + }); + if let Some(loot) = self.loot_table.get_mut(loot_guid) { + loot.coins = 0; + if loot_is_looted_like_cpp(loot) { + item_release.push(*loot_guid); + } + } + } + if apply_balance || publish { + self.drain_represented_quest_objective_progress_like_cpp() + .await; + } + continue; + } + + // Every live Creature/Vehicle/GameObject source must use its + // object-owned authority, and stored Item money has the atomic + // character/source-row transaction above. The remaining local + // cache path exists only for pre-authority unit fixtures. Refuse + // it in production so an unknown future owner type cannot publish + // CoinRemoved or clear its pool before durable money succeeds. + if !represented_local_loot_fixture_allowed_like_cpp() { + debug!( + owner = ?loot_guid, + "non-authoritative loot-money fallback is disabled in production" + ); + continue; + } + + legacy_money_processed = true; self.represented_notify_money_removed_like_cpp(*loot_guid); let recipients = self.represented_loot_money_recipients_like_cpp(*loot_guid); @@ -1362,25 +1841,28 @@ impl WorldSession { } } - let old_money = self.player_gold_like_cpp(); - let new_money = old_money.saturating_add(player_money_delta); - if player_money_delta != 0 { - self.enqueue_represented_quest_objective_progress_like_cpp( - RepresentedQuestObjectiveProgressEventLikeCpp::MoneyChanged { - old_money, - new_money, - }, - ); - } - self.set_player_gold_like_cpp(new_money); - self.save_player_gold().await; - self.drain_represented_quest_objective_progress_like_cpp() - .await; - - for (loot_guid, _, _) in &money_by_loot { - if loot_guid.is_item() { - self.delete_stored_item_money_like_cpp(*loot_guid).await; + if legacy_money_processed { + if let Some((old_money, new_money)) = self + .mutate_and_persist_player_gold_exclusive_like_cpp(|old_money| { + crate::session::loot_money_durable_outcome_like_cpp( + old_money, + player_money_delta, + ) + .0 + }) + .await + { + if old_money != new_money { + self.enqueue_represented_quest_objective_progress_like_cpp( + RepresentedQuestObjectiveProgressEventLikeCpp::MoneyChanged { + old_money, + new_money, + }, + ); + } } + self.drain_represented_quest_objective_progress_like_cpp() + .await; } for loot_guid in item_release { @@ -1401,7 +1883,13 @@ impl WorldSession { return Vec::new(); }; - if !loot_guid.is_creature() { + let Some(loot) = self.loot_table.get(&loot_guid) else { + return vec![player_guid]; + }; + // C++ shares only LOOT_CORPSE. Pickpocket money is creature-owned but + // personal; vehicle corpses still share even though their HighGuid is + // not Creature (`LootHandler.cpp::HandleLootMoneyOpcode`). + if loot.loot_type != LOOT_TYPE_CORPSE_LIKE_CPP { return vec![player_guid]; } @@ -1418,10 +1906,14 @@ impl WorldSession { }; let source_position = self.player_position_like_cpp().unwrap_or_default(); + let source_instance_id = self + .current_canonical_player_map_key_like_cpp() + .map(|key| key.instance_id) + .unwrap_or(0); let mut recipients = Vec::new(); for member_guid in &group.members { - if !self.represented_loot_money_allowed_for_member_like_cpp(loot_guid, *member_guid) { + if !loot.allowed_looters.contains(member_guid) { continue; } @@ -1434,11 +1926,16 @@ impl WorldSession { continue; }; - if member.map_id != self.player_map_id_like_cpp() { + if !member.is_in_world + || member.map_id != self.player_map_id_like_cpp() + || member.instance_id != source_instance_id + { continue; } - if source_position.is_within_dist(&member.position, 74.0) { + if self.current_map_is_dungeon_like_cpp() + || source_position.is_within_dist(&member.position, 74.0) + { recipients.push(*member_guid); } } @@ -1467,19 +1964,369 @@ impl WorldSession { loot.coins } - fn represented_loot_unlooted_count_for_player_like_cpp( - loot: &CreatureLoot, - player_guid: ObjectGuid, - ) -> u32 { - loot.items - .iter() - .filter(|entry| { - entry.has_allowed_looter_like_cpp(player_guid) - && !loot_item_is_looted_for_player_like_cpp(loot, entry, player_guid) + /// Clone the object-owned authority while the map/entity lock is held, + /// then release that lock before any reservation can await. + fn represented_owned_loot_authority_like_cpp( + &mut self, + owner_guid: ObjectGuid, + ) -> Option { + if owner_guid.is_creature_or_vehicle() { + // The legacy and canonical maps deliberately use separate locks. + // Reconcile optimistically with object-local compare/exchange; + // blind rebinding can otherwise clobber a newer respawn between + // the read and write phases. + for _ in 0..8 { + let canonical_player_map_key = self.current_canonical_player_map_key_like_cpp(); + let map_key = canonical_player_map_key + .or_else(|| { + self.canonical_object_lookup_map_key_like_cpp(u32::from( + self.player_map_id_like_cpp(), + )) + }) + .unwrap_or_else(|| { + let (map_id, instance_id) = self.current_legacy_runtime_map_key_like_cpp(); + wow_map::MapKey::new(u32::from(map_id), instance_id) + }); + let map_key_still_valid = |session: &Self| { + session.loot_reconciliation_map_key_still_valid_like_cpp( + map_key, + canonical_player_map_key.is_some(), + ) + }; + let legacy = + self.read_legacy_creature_loot_authority_on_map_like_cpp(owner_guid, map_key); + let canonical = self + .read_canonical_creature_loot_authority_on_map_like_cpp(owner_guid, map_key); + let (legacy, canonical) = match (legacy, canonical) { + (Some(legacy), Some(canonical)) => (legacy, canonical), + (None, None) => return None, + (Some(authority), None) | (None, Some(authority)) => { + if !map_key_still_valid(self) { + continue; + } + return Some(authority); + } + }; + if !map_key_still_valid(self) { + continue; + } + + let legacy_stamp = legacy.stamp_like_cpp(); + let canonical_stamp = canonical.stamp_like_cpp(); + let selected = crate::session::reconcile_creature_loot_authority_mirrors_like_cpp( + &canonical, + canonical_stamp, + &legacy, + legacy_stamp, + ); + if !map_key_still_valid(self) { + continue; + } + if self + .rebind_canonical_creature_loot_authority_on_map_like_cpp( + owner_guid, + map_key, + &canonical, + canonical_stamp, + selected.clone(), + ) + .is_none() + { + continue; + } + if !map_key_still_valid(self) { + continue; + } + if self + .rebind_legacy_creature_loot_authority_on_map_like_cpp( + owner_guid, + map_key, + &legacy, + legacy_stamp, + selected.clone(), + ) + .is_none() + { + continue; + } + + if !map_key_still_valid(self) { + continue; + } + let converged_legacy = self + .read_legacy_creature_loot_authority_on_map_like_cpp(owner_guid, map_key) + .is_some_and(|authority| authority.shares_storage_like_cpp(&selected)); + let converged_canonical = self + .read_canonical_creature_loot_authority_on_map_like_cpp(owner_guid, map_key) + .is_some_and(|authority| authority.shares_storage_like_cpp(&selected)); + if converged_legacy && converged_canonical { + return Some(selected); + } + } + + // Continuous concurrent replacement is safer as a failed request + // than as an overwrite of the newest mirror. + return None; + } + + if owner_guid.is_game_object() { + let canonical_player_map_key = self.current_canonical_player_map_key_like_cpp(); + let map_key = canonical_player_map_key.or_else(|| { + self.canonical_object_lookup_map_key_like_cpp(u32::from( + self.player_map_id_like_cpp(), + )) + })?; + let authority = + self.read_canonical_gameobject_loot_authority_on_map_like_cpp(owner_guid, map_key)?; + let still_valid = self.loot_reconciliation_map_key_still_valid_like_cpp( + map_key, + canonical_player_map_key.is_some(), + ); + return still_valid.then_some(authority); + } + + None + } + + /// Bridge pre-authority represented fixtures (and the equivalent first + /// live generation) into the object-owned source of truth exactly once. + /// A retired non-zero generation is never reinstalled from session cache. + fn prepare_owned_loot_authority_for_active_request_like_cpp( + &mut self, + owner_guid: ObjectGuid, + scope_player: ObjectGuid, + ) -> Option { + let authority = self.represented_owned_loot_authority_like_cpp(owner_guid)?; + let can_install_first_generation = represented_local_loot_fixture_allowed_like_cpp() + && authority.is_retired_like_cpp() + && authority.generation_like_cpp() == 0 + && self.loot_table.contains_key(&owner_guid) + && (self.active_loot_view_owners.contains(&owner_guid) + || self.is_active_loot_guid(owner_guid)); + if !can_install_first_generation { + return Some(authority); + } + + if owner_guid.is_game_object() { + let _ = self + .sync_represented_gameobject_loot_to_canonical_like_cpp(owner_guid, scope_player); + } else if owner_guid.is_creature_or_vehicle() { + let _ = + self.sync_represented_creature_loot_to_canonical_like_cpp(owner_guid, scope_player); + } + + let authority = self.represented_owned_loot_authority_like_cpp(owner_guid)?; + if let Some(snapshot) = authority.snapshot_for_player_like_cpp(scope_player) { + self.active_loot_view_generations_like_cpp + .entry(owner_guid) + .or_insert(snapshot.generation); + self.active_loot_view_authorities_like_cpp + .entry(owner_guid) + .or_insert_with(|| authority.clone()); + } + Some(authority) + } + + /// Refresh the session-local window from the object-owned source of truth. + /// The local table remains a packet-building cache only. + fn reconcile_represented_loot_cache_like_cpp( + &mut self, + owner_guid: ObjectGuid, + player_guid: ObjectGuid, + ) -> bool { + let Some(authority) = self.represented_owned_loot_authority_like_cpp(owner_guid) else { + return false; + }; + let Some(snapshot) = authority.snapshot_for_player_like_cpp(player_guid) else { + self.discard_represented_personal_loot_cache_for_player_like_cpp( + owner_guid, + player_guid, + ); + return false; + }; + self.cache_represented_owned_loot_snapshot_like_cpp(owner_guid, player_guid, snapshot); + true + } + + /// Rebuild every session-local field derived from one authoritative + /// snapshot. In particular, a reopened personal creature view must restore + /// its personal-owner marker and per-player money mirror; restoring only + /// `loot_table` would make the same pool behave as shared loot. + fn cache_represented_owned_loot_snapshot_like_cpp( + &mut self, + owner_guid: ObjectGuid, + _requested_player_guid: ObjectGuid, + snapshot: OwnedLootSnapshot, + ) { + let OwnedLootSnapshot { + generation, + scope, + loot, + } = snapshot; + // One WorldSession caches exactly one selected pool for an owner. + // Generation scratch may have populated money entries for every + // encounter tapper, but those peer pools now live in the authority; + // retaining their session-local markers can misclassify a later + // shared snapshot as personal loot. + self.represented_personal_loot_money + .retain(|(owner, _), _| *owner != owner_guid); + self.represented_personal_loot_owners.remove(&owner_guid); + match scope { + OwnedLootScope::Personal(scope_player_guid) => { + self.represented_personal_loot_owners.insert(owner_guid); + self.represented_personal_loot_money + .insert((owner_guid, scope_player_guid), loot.coins); + } + OwnedLootScope::Shared => {} + } + self.loot_table.insert(owner_guid, loot); + self.represented_loot_cache_generations_like_cpp + .insert(owner_guid, generation); + } + + /// Drops only this session/player's packet-building mirror. The canonical + /// object-owned authority remains the source of truth and rehydrates a + /// later open. C++ has no session-owned `Loot` clone after a window closes. + fn discard_represented_personal_loot_cache_for_player_like_cpp( + &mut self, + owner_guid: ObjectGuid, + _player_guid: ObjectGuid, + ) { + self.loot_table.remove(&owner_guid); + self.represented_loot_cache_generations_like_cpp + .remove(&owner_guid); + self.represented_personal_loot_money + .retain(|(owner, _), _| *owner != owner_guid); + self.represented_personal_loot_owners.remove(&owner_guid); + } + + fn refresh_owned_loot_summary_like_cpp(&mut self, owner_guid: ObjectGuid) { + if owner_guid.is_creature_or_vehicle() { + if let Some(authority) = self.represented_owned_loot_authority_like_cpp(owner_guid) { + let _ = self.rebind_legacy_creature_loot_authority_like_cpp( + owner_guid, + &authority, + authority.stamp_like_cpp(), + authority.clone(), + ); + let authority_stamp = authority.stamp_like_cpp(); + let _ = self.rebind_canonical_creature_loot_authority_like_cpp( + owner_guid, + &authority, + authority_stamp, + authority.clone(), + ); + } + } else if owner_guid.is_game_object() { + if let Some(authority) = self.represented_owned_loot_authority_like_cpp(owner_guid) { + let _ = self.rebind_canonical_gameobject_loot_authority_like_cpp( + owner_guid, + &authority, + authority.stamp_like_cpp(), + authority.clone(), + ); + } + } + } + + fn represented_loot_authority_pools_like_cpp( + &mut self, + owner_guid: ObjectGuid, + player_guid: ObjectGuid, + loot: CreatureLoot, + personal: bool, + ) -> Option<(Option, HashMap)> { + if !personal { + return Some((Some(loot), HashMap::new())); + } + + let mut looters = loot.allowed_looters.clone(); + if looters.is_empty() && !player_guid.is_empty() { + looters.push(player_guid); + } + looters.sort_unstable_by_key(|guid| (guid.high_value(), guid.low_value())); + looters.dedup(); + + let mut personal_loot = HashMap::new(); + for (index, looter) in looters.into_iter().enumerate() { + let mut pool = loot.clone(); + if index != 0 { + pool.loot_guid = self.next_represented_loot_object_guid_like_cpp(owner_guid)?; + } + pool.coins = self + .represented_personal_loot_money + .get(&(owner_guid, looter)) + .copied() + .unwrap_or(0); + pool.allowed_looters = vec![looter]; + pool.players_looting.retain(|viewer| *viewer == looter); + pool.items.retain(|entry| { + entry.allowed_looters.is_empty() || entry.allowed_looters.contains(&looter) + }); + for entry in &mut pool.items { + entry.allowed_looters = vec![looter]; + } + rebuild_represented_personal_loot_counts_preserving_consumed_like_cpp(&mut pool); + personal_loot.insert(looter, pool); + } + + Some((None, personal_loot)) + } + + /// Runtime-facing allocator. Production has no fallback: the `cfg(test)` + /// branch exists only so older packet-cache fixtures can retain their + /// deterministic owner-derived identity while they are migrated to typed + /// canonical map objects. + fn next_represented_loot_object_guid_like_cpp( + &mut self, + owner_guid: ObjectGuid, + ) -> Option { + let canonical = self.next_canonical_loot_object_guid_like_cpp(owner_guid); + #[cfg(test)] + { + canonical.or_else(|| { + (!owner_guid.is_empty()).then(|| represented_loot_object_guid_like_cpp(owner_guid)) }) - .count() - .try_into() - .unwrap_or(u32::MAX) + } + #[cfg(not(test))] + { + canonical + } + } + + /// Mirrors `Loot::Loot(Map*)`: every concrete pool receives a fresh + /// map-owned `HighGuid::LootObject` low GUID. This strict helper always + /// fails closed when the owner's exact canonical map is unavailable. + fn next_canonical_loot_object_guid_like_cpp( + &mut self, + owner_guid: ObjectGuid, + ) -> Option { + (|| { + // Map 0 (Eastern Kingdoms) is a real map, not an unspecified + // sentinel. C++ allocates from the owner's exact `GetMap()`. + let owner_map_id = u32::from(owner_guid.map_id()); + let key = self.canonical_object_lookup_map_key_like_cpp(owner_map_id)?; + if key.map_id != owner_map_id { + return None; + } + let manager = self.canonical_map_manager.as_ref()?; + let mut manager = manager.lock().ok()?; + let map = manager.find_map_mut(key.map_id, key.instance_id)?.map_mut(); + let counter = map.generate_low_guid_like_cpp(HighGuid::LootObject).ok()?; + let map_id = u16::try_from(key.map_id).ok()?; + // C++ passes realm id 0 to ObjectGuidFactory, where + // `GetRealmIdForObjectGuid(0)` substitutes the active realm. + // Rust's factory is explicit, so pass the session realm here. + Some(ObjectGuid::create_world_object( + HighGuid::LootObject, + 0, + self.realm_id(), + map_id, + 0, + 0, + counter, + )) + })() } fn sync_represented_gameobject_loot_to_canonical_like_cpp( @@ -1487,44 +2334,147 @@ impl WorldSession { gameobject_guid: ObjectGuid, player_guid: ObjectGuid, ) -> Option<()> { + let Some(authority) = self.represented_owned_loot_authority_like_cpp(gameobject_guid) + else { + return (represented_local_loot_fixture_allowed_like_cpp() + && self.loot_table.contains_key(&gameobject_guid)) + .then_some(()); + }; let loot = self.loot_table.get(&gameobject_guid)?.clone(); let is_personal = self .represented_personal_loot_owners .contains(&gameobject_guid); - let personal_money = self.represented_personal_loot_money.clone(); + let (shared, personal) = self.represented_loot_authority_pools_like_cpp( + gameobject_guid, + player_guid, + loot, + is_personal, + )?; + let installed = authority + .initialize_pristine_like_cpp(shared, personal) + .installed(); + if !installed + && authority + .snapshot_for_player_like_cpp(player_guid) + .is_none() + { + self.loot_table.remove(&gameobject_guid); + self.represented_loot_cache_generations_like_cpp + .remove(&gameobject_guid); + return None; + } + self.refresh_owned_loot_summary_like_cpp(gameobject_guid); + let _ = self.reconcile_represented_loot_cache_like_cpp(gameobject_guid, player_guid); + Some(()) + } + + fn upsert_represented_personal_gameobject_loot_authority_like_cpp( + &mut self, + gameobject_guid: ObjectGuid, + player_guid: ObjectGuid, + loot: CreatureLoot, + replace: bool, + ) -> Option<()> { + let observation = + self.represented_gameobject_loot_install_observation_like_cpp(gameobject_guid)?; + self.upsert_represented_personal_gameobject_loot_authority_if_observed_like_cpp( + gameobject_guid, + player_guid, + loot, + replace, + &observation, + ) + } + + fn represented_gameobject_loot_install_observation_like_cpp( + &mut self, + gameobject_guid: ObjectGuid, + ) -> Option { + self.represented_gameobject_loot_install_observation_result_like_cpp(gameobject_guid)? + } + + /// Preserves the distinction between a missing canonical owner (`None`) + /// and an owner whose current lifecycle rejects generation (`Some(None)`). + /// Test-only packet fixtures may fall back only for the former. + fn represented_gameobject_loot_install_observation_result_like_cpp( + &mut self, + gameobject_guid: ObjectGuid, + ) -> Option> { self.mutate_canonical_gameobject_by_guid_like_cpp(gameobject_guid, |gameobject| { - if is_personal { - gameobject.clear_shared_loot_like_cpp(); - let mut represented_looters = loot.allowed_looters.clone(); - if represented_looters.is_empty() && !player_guid.is_empty() { - represented_looters.push(player_guid); - } - represented_looters - .sort_unstable_by_key(|guid| (guid.high_value(), guid.low_value())); - represented_looters.dedup(); - for looter_guid in represented_looters { - let gold = personal_money - .get(&(gameobject_guid, looter_guid)) - .copied() - .unwrap_or(0); - let unlooted_count = Self::represented_loot_unlooted_count_for_player_like_cpp( - &loot, - looter_guid, - ); - gameobject.set_personal_loot_like_cpp( - looter_guid, - GameObjectOwnedLoot::new(gold, unlooted_count), - ); + (gameobject.loot_state() != LootState::JustDeactivated).then(|| { + let authority = gameobject.loot_authority_like_cpp().clone(); + RepresentedGameObjectLootInstallObservationLikeCpp { + object_generation: authority.generation_like_cpp(), + authority, + loot_lifecycle_revision: gameobject.loot_lifecycle_revision_like_cpp(), } - } else { - gameobject.clear_personal_loot_like_cpp(); - gameobject.set_shared_loot_like_cpp(GameObjectOwnedLoot::new( - loot.coins, - u32::from(loot.unlooted_count), - )); - } + }) }) - .map(|_| ()) + } + + fn upsert_represented_personal_gameobject_loot_authority_if_observed_like_cpp( + &mut self, + gameobject_guid: ObjectGuid, + player_guid: ObjectGuid, + loot: CreatureLoot, + replace: bool, + observation: &RepresentedGameObjectLootInstallObservationLikeCpp, + ) -> Option<()> { + self.upsert_represented_personal_gameobject_loot_authority_if_observed_with_empty_policy_like_cpp( + gameobject_guid, + player_guid, + loot, + replace, + false, + observation, + ) + } + + fn upsert_represented_personal_gameobject_loot_authority_if_observed_with_empty_policy_like_cpp( + &mut self, + gameobject_guid: ObjectGuid, + player_guid: ObjectGuid, + loot: CreatureLoot, + replace: bool, + discard_empty_pool: bool, + observation: &RepresentedGameObjectLootInstallObservationLikeCpp, + ) -> Option<()> { + let (_, mut personal) = self.represented_loot_authority_pools_like_cpp( + gameobject_guid, + player_guid, + loot, + true, + )?; + let pool = personal.remove(&player_guid)?; + if discard_empty_pool && loot_is_looted_like_cpp(&pool) { + self.discard_represented_personal_loot_cache_for_player_like_cpp( + gameobject_guid, + player_guid, + ); + return None; + } + let installed = + self.mutate_canonical_gameobject_by_guid_like_cpp(gameobject_guid, move |gameobject| { + gameobject.install_personal_loot_if_lifecycle_like_cpp( + &observation.authority, + observation.object_generation, + observation.loot_lifecycle_revision, + player_guid, + pool, + replace, + ) + }); + if installed != Some(true) { + self.discard_represented_personal_loot_cache_for_player_like_cpp( + gameobject_guid, + player_guid, + ); + return None; + } + if !self.reconcile_represented_loot_cache_like_cpp(gameobject_guid, player_guid) { + return None; + } + Some(()) } fn sync_represented_creature_loot_to_canonical_like_cpp( @@ -1532,23 +2482,37 @@ impl WorldSession { creature_guid: ObjectGuid, _player_guid: ObjectGuid, ) -> Option<()> { + let Some(authority) = self.represented_owned_loot_authority_like_cpp(creature_guid) else { + return (represented_local_loot_fixture_allowed_like_cpp() + && self.loot_table.contains_key(&creature_guid)) + .then_some(()); + }; let loot = self.loot_table.get(&creature_guid)?.clone(); - let summary = CreatureOwnedLoot::new(loot.coins, u32::from(loot.unlooted_count)); - if self - .mutate_world_creature(creature_guid, |world_creature| { - world_creature.creature.clear_personal_loot_like_cpp(); - world_creature.creature.set_shared_loot_like_cpp(summary); - }) - .is_some() + let is_personal = self + .represented_personal_loot_owners + .contains(&creature_guid); + let (shared, personal) = self.represented_loot_authority_pools_like_cpp( + creature_guid, + _player_guid, + loot, + is_personal, + )?; + let installed = authority + .initialize_pristine_like_cpp(shared, personal) + .installed(); + if !installed + && authority + .snapshot_for_player_like_cpp(_player_guid) + .is_none() { - return Some(()); + self.loot_table.remove(&creature_guid); + self.represented_loot_cache_generations_like_cpp + .remove(&creature_guid); + return None; } - - self.mutate_canonical_creature_by_guid_like_cpp(creature_guid, |creature| { - creature.clear_personal_loot_like_cpp(); - creature.set_shared_loot_like_cpp(summary); - }) - .map(|_| ()) + self.refresh_owned_loot_summary_like_cpp(creature_guid); + let _ = self.reconcile_represented_loot_cache_like_cpp(creature_guid, _player_guid); + Some(()) } fn refresh_represented_loot_owner_canonical_summary_like_cpp( @@ -1560,8 +2524,13 @@ impl WorldSession { let _ = self .sync_represented_gameobject_loot_to_canonical_like_cpp(owner_guid, player_guid); } else if owner_guid.is_creature_or_vehicle() { - let _ = - self.sync_represented_creature_loot_to_canonical_like_cpp(owner_guid, player_guid); + if self + .sync_represented_creature_loot_to_canonical_like_cpp(owner_guid, player_guid) + .is_none() + { + self.loot_table.remove(&owner_guid); + return; + } } } @@ -1620,33 +2589,11 @@ impl WorldSession { loot_can_be_opened_by_player_like_cpp(loot, player_guid) } - fn represented_loot_money_allowed_for_member_like_cpp( - &self, - loot_guid: ObjectGuid, - member_guid: ObjectGuid, - ) -> bool { - let Some(loot) = self.loot_table.get(&loot_guid) else { - return false; - }; - - if loot - .items - .iter() - .all(|item| item.allowed_looters.is_empty()) - { - return true; - } - - loot.items - .iter() - .any(|item| item.allowed_looters.contains(&member_guid)) - } - /// CMSG_LOOT_RELEASE — player closes the loot window. /// - /// C# ref: `LootHandler.DoLootRelease` (creature branch): - /// if loot.IsLooted() && creature.IsFullyLooted() → RemoveDynamicFlag(Lootable) - /// → creature.AllLootRemovedFromCorpse() → sets `m_corpseRemoveTime = now + decay` + /// C++ `WorldSession::DoLootRelease` creature branch: + /// `loot->isLooted() && creature->IsFullyLooted()` removes the lootable + /// dynamic flag and calls `Creature::AllLootRemovedFromCorpse` for a corpse. pub async fn handle_loot_release(&mut self, mut pkt: wow_packet::WorldPacket) { let req = match LootRelease::read(&mut pkt) { Ok(r) => r, @@ -1708,21 +2655,29 @@ impl WorldSession { }; let roll_key = (roll.loot_obj, roll.loot_list_id); - let mut command_tx = None; + let instance_id = self + .current_canonical_player_map_key_like_cpp() + .map(|key| key.instance_id) + .unwrap_or(0); + let mut command_target = None; for owner in registry.iter() { if *owner.key() == player_guid { continue; } - if owner.map_id != self.player_map_id_like_cpp() { + if owner.map_id != self.player_map_id_like_cpp() || owner.instance_id != instance_id { continue; } - if owner.active_loot_rolls.contains(&roll_key) { - command_tx = Some(owner.command_tx.clone()); + if let Some(identity) = owner + .active_loot_rolls + .iter() + .find(|identity| identity.matches_key_like_cpp(roll_key.0, roll_key.1)) + { + command_target = Some((owner.command_tx.clone(), identity.clone())); break; } } - let Some(command_tx) = command_tx else { + let Some((command_tx, roll_identity)) = command_target else { return false; }; @@ -1733,6 +2688,7 @@ impl WorldSession { loot_list_id: roll.loot_list_id, roll_type: roll.roll_type, pass_on_group_loot: self.pass_on_group_loot, + roll_identity, })) .is_ok() } @@ -1756,14 +2712,23 @@ impl WorldSession { player_guid: ObjectGuid, pass_on_group_loot: bool, ) -> bool { - if pass_on_group_loot { - return false; - } - - let Some(owner_guid) = self.active_loot_owner_for_loot_object_like_cpp(roll.loot_obj) - else { + let roll_key = (roll.loot_obj, roll.loot_list_id); + let Some(roll_state) = self.represented_loot_rolls.get(&roll_key).cloned() else { return false; }; + if self + .represented_current_loot_roll_authority_like_cpp(&roll_state) + .is_none() + { + self.cancel_represented_loot_roll_generation_mismatch_like_cpp(roll_key, &roll_state); + return true; + } + + if pass_on_group_loot { + return false; + } + + let owner_guid = roll_state.owner_guid; let Some(loot) = self.loot_table.get(&owner_guid) else { return false; @@ -1837,6 +2802,53 @@ impl WorldSession { true } + /// A represented roll is scoped to one lifetime of the object-owned Loot. + /// + /// C++ destroys `LootRoll` together with its owning `Loot`. Rust keeps the + /// packet-facing roll state in the session, so a recycled object GUID must + /// not let that stale state unblock or award an item from a later lifetime. + fn represented_current_loot_roll_authority_like_cpp( + &mut self, + state: &RepresentedLootRollState, + ) -> Option { + let Some(authority) = self.represented_owned_loot_authority_like_cpp(state.owner_guid) + else { + return None; + }; + + if !authority.shares_storage_like_cpp(&state.authority) { + return None; + } + let player_guid = match state.authority_scope { + wow_loot::OwnedLootScope::Shared => self.player_guid()?, + wow_loot::OwnedLootScope::Personal(player_guid) => player_guid, + }; + authority + .snapshot_for_player_like_cpp(player_guid) + .is_some_and(|snapshot| { + snapshot.scope == state.authority_scope + && snapshot.generation == state.authority_generation + && snapshot.loot.loot_guid == state.loot_obj + }) + .then_some(authority) + } + + fn cancel_represented_loot_roll_generation_mismatch_like_cpp( + &mut self, + key: (ObjectGuid, u8), + state: &RepresentedLootRollState, + ) { + debug!( + owner = ?state.owner_guid, + loot_obj = ?state.loot_obj, + loot_list_id = state.loot_list_id, + authority_generation = state.authority_generation, + "represented loot roll cancelled after owner loot generation changed" + ); + self.represented_loot_rolls.remove(&key); + self.publish_represented_loot_roll_ownership_like_cpp(); + } + async fn finish_represented_loot_roll_like_cpp( &mut self, loot_obj: ObjectGuid, @@ -1845,15 +2857,60 @@ impl WorldSession { winner: Option<(ObjectGuid, RepresentedLootRollVote)>, finished_state: Option<&RepresentedLootRollState>, ) { - let Some(owner_guid) = self.active_loot_owner_for_loot_object_like_cpp(loot_obj) else { + let Some(state) = finished_state else { + return; + }; + let roll_key = (loot_obj, loot_list_id); + if state.loot_obj != loot_obj || state.loot_list_id != loot_list_id { + self.cancel_represented_loot_roll_generation_mismatch_like_cpp(roll_key, state); + return; + } + let Some(authority) = self.represented_current_loot_roll_authority_like_cpp(state) else { + self.cancel_represented_loot_roll_generation_mismatch_like_cpp(roll_key, state); return; }; + let owner_guid = state.owner_guid; let dungeon_encounter_id = self .loot_table .get(&owner_guid) .map(|loot| loot.dungeon_encounter_id as i32) .unwrap_or(0); + let winner_guid = winner.as_ref().map(|(guid, _)| *guid); + let scope_player = winner_guid + .or_else(|| self.player_guid()) + .unwrap_or(ObjectGuid::EMPTY); + let claim = if let Some(winner_guid) = winner_guid { + match authority.finish_item_roll_and_reserve_award_like_cpp( + scope_player, + state.authority_generation, + loot_list_id, + winner_guid, + ) { + Ok(claim) => Some(claim), + Err(_) => { + self.cancel_represented_loot_roll_generation_mismatch_like_cpp(roll_key, state); + return; + } + } + } else { + if authority + .finish_item_roll_like_cpp( + scope_player, + state.authority_generation, + loot_list_id, + false, + None, + ) + .is_err() + { + self.cancel_represented_loot_roll_generation_mismatch_like_cpp(roll_key, state); + return; + } + None + }; + let _ = self.reconcile_represented_loot_cache_like_cpp(owner_guid, scope_player); + if let Some(loot) = self.loot_table.get_mut(&owner_guid) { if let Some(loot_entry) = loot .items @@ -1928,6 +2985,7 @@ impl WorldSession { entry, winner_guid, winner_vote, + claim, ) .await; } @@ -2111,7 +3169,11 @@ impl WorldSession { let Some(player) = registry.get(&target) else { return; }; - if player.map_id != self.player_map_id_like_cpp() { + let instance_id = self + .current_canonical_player_map_key_like_cpp() + .map(|key| key.instance_id) + .unwrap_or(0); + if player.map_id != self.player_map_id_like_cpp() || player.instance_id != instance_id { return; } @@ -2129,6 +3191,10 @@ impl WorldSession { }; let bytes = packet.to_bytes(); + let instance_id = self + .current_canonical_player_map_key_like_cpp() + .map(|key| key.instance_id) + .unwrap_or(0); for looter in &entry.allowed_looters { if Some(*looter) == except { continue; @@ -2145,7 +3211,7 @@ impl WorldSession { let Some(player) = registry.get(looter) else { continue; }; - if player.map_id != self.player_map_id_like_cpp() { + if player.map_id != self.player_map_id_like_cpp() || player.instance_id != instance_id { continue; } @@ -2160,6 +3226,10 @@ impl WorldSession { except: Option, ) { let bytes = packet.to_bytes(); + let instance_id = self + .current_canonical_player_map_key_like_cpp() + .map(|key| key.instance_id) + .unwrap_or(0); for (player_guid, vote) in &state.voters { if vote.vote == ROLL_VOTE_NOT_VALID_LIKE_CPP { continue; @@ -2179,7 +3249,7 @@ impl WorldSession { let Some(player) = registry.get(player_guid) else { continue; }; - if player.map_id != self.player_map_id_like_cpp() { + if player.map_id != self.player_map_id_like_cpp() || player.instance_id != instance_id { continue; } @@ -2243,6 +3313,41 @@ impl WorldSession { return; } + let owned_authority = self + .prepare_owned_loot_authority_for_active_request_like_cpp(owner_guid, player_guid); + let authority = owned_authority + .as_ref() + .filter(|authority| { + authority + .snapshot_for_player_like_cpp(master_loot_item.target) + .is_some() + }) + .cloned(); + if authority.is_none() + && (owner_guid.is_creature_or_vehicle() || owner_guid.is_game_object()) + && (owned_authority.is_some() || !represented_local_loot_fixture_allowed_like_cpp()) + { + self.send_loot_error_like_cpp( + req.object, + owner_guid, + LOOT_ERROR_MASTER_OTHER_LIKE_CPP, + ); + return; + } + if let Some(authority) = authority.as_ref() { + if !self.represented_active_loot_generation_matches_like_cpp(owner_guid, authority) + { + self.send_loot_error_like_cpp( + req.object, + owner_guid, + LOOT_ERROR_MASTER_OTHER_LIKE_CPP, + ); + return; + } + let _ = self + .reconcile_represented_loot_cache_like_cpp(owner_guid, master_loot_item.target); + } + let Some(loot) = self.loot_table.get(&owner_guid) else { return; }; @@ -2286,22 +3391,88 @@ impl WorldSession { return; } - let entry = item.clone(); - if master_loot_item.target == player_guid { - if !self - .store_direct_loot_item_like_cpp(&entry, dungeon_encounter_id) + let mut entry = item.clone(); + let claim = if let Some(authority) = authority { + let Some(expected_generation) = self + .active_loot_view_generations_like_cpp + .get(&owner_guid) + .copied() + else { + self.send_loot_error_like_cpp( + req.object, + owner_guid, + LOOT_ERROR_MASTER_OTHER_LIKE_CPP, + ); + return; + }; + let claim = match authority + .reserve_item_for_award_generation_like_cpp( + master_loot_item.target, + req.loot_list_id, + expected_generation, + ) .await { + Ok(claim) => claim, + Err(_) => { + self.send_loot_error_like_cpp( + req.object, + owner_guid, + LOOT_ERROR_MASTER_OTHER_LIKE_CPP, + ); + return; + } + }; + if !self + .represented_active_loot_claim_generation_matches_like_cpp(owner_guid, &claim) + { + claim.rollback_like_cpp(); + self.send_loot_error_like_cpp( + req.object, + owner_guid, + LOOT_ERROR_MASTER_OTHER_LIKE_CPP, + ); return; } - self.mark_represented_master_loot_item_removed_like_cpp( - owner_guid, - req.object, - req.loot_list_id, - master_loot_item.target, - ); + if let LootClaimPayload::Item(reserved_entry) = claim.payload_like_cpp() { + entry = reserved_entry.clone(); + } + Some(claim) + } else { + None + }; + if master_loot_item.target == player_guid { + let stored = if let Some(claim) = claim.as_ref() { + self.store_claimed_direct_loot_item_from_owner_like_cpp( + &entry, + dungeon_encounter_id, + owner_guid, + req.object, + claim, + ) + .await + } else { + self.store_direct_loot_item_from_owner_like_cpp( + &entry, + dungeon_encounter_id, + owner_guid, + ) + .await + }; + if !stored { + return; + } + if claim.is_none() { + self.mark_represented_master_loot_item_removed_like_cpp( + owner_guid, + req.object, + req.loot_list_id, + master_loot_item.target, + ); + } current_session_assignments = current_session_assignments.saturating_add(1); } else { + let authoritative_claim = claim.is_some(); match self .request_represented_remote_master_loot_give_like_cpp( master_loot_item.target, @@ -2310,10 +3481,11 @@ impl WorldSession { req.loot_list_id, dungeon_encounter_id, entry, + claim, ) .await { - MasterLootGiveResult::Stored => { + MasterLootGiveResult::Stored if !authoritative_claim => { self.mark_represented_master_loot_item_removed_like_cpp( owner_guid, req.object, @@ -2321,6 +3493,7 @@ impl WorldSession { master_loot_item.target, ); } + MasterLootGiveResult::Stored => {} MasterLootGiveResult::StoreFailed(error) => { self.send_loot_error_like_cpp(req.object, owner_guid, error); return; @@ -2354,6 +3527,7 @@ impl WorldSession { loot_list_id: u8, dungeon_encounter_id: u32, entry: LootEntry, + claim: Option, ) -> MasterLootGiveResult { let Some(player_guid) = self.player_guid() else { return MasterLootGiveResult::TargetMismatch; @@ -2376,6 +3550,7 @@ impl WorldSession { loot_list_id, dungeon_encounter_id, entry, + claim, result_tx, }); @@ -2398,6 +3573,7 @@ impl WorldSession { entry: &LootEntry, winner_guid: ObjectGuid, winner_vote: RepresentedLootRollVote, + claim: Option, ) { let dungeon_encounter_id = self .loot_table @@ -2405,23 +3581,45 @@ impl WorldSession { .map(|loot| loot.dungeon_encounter_id) .unwrap_or(0); if winner_vote.vote == ROLL_VOTE_DISENCHANT_LIKE_CPP { + let reserved_entry = claim + .as_ref() + .and_then(|claim| match claim.payload_like_cpp() { + LootClaimPayload::Item(entry) => Some(entry), + LootClaimPayload::Money(_) => None, + }) + .unwrap_or(entry); if self .store_represented_disenchant_loot_winner_like_cpp( owner_guid, loot_obj, loot_list_id, - entry, + reserved_entry, winner_guid, dungeon_encounter_id, + claim.as_ref(), ) .await { - self.mark_represented_master_loot_item_removed_like_cpp( - owner_guid, - loot_obj, - loot_list_id, - winner_guid, - ); + if self.player_guid() == Some(winner_guid) { + if claim.is_none() { + self.mark_represented_master_loot_item_removed_like_cpp( + owner_guid, + loot_obj, + loot_list_id, + winner_guid, + ); + } + } else if claim.is_none() { + // Object-owned claims are committed and fanned out by the + // remote target session. The legacy cache-only fallback + // still has to be retired by the source session. + self.mark_represented_master_loot_item_removed_like_cpp( + owner_guid, + loot_obj, + loot_list_id, + winner_guid, + ); + } } return; } @@ -2440,23 +3638,45 @@ impl WorldSession { .cloned() }) .unwrap_or_else(|| entry.clone()); + if let Some(claim) = claim.as_ref() + && let LootClaimPayload::Item(reserved_entry) = claim.payload_like_cpp() + { + store_entry = reserved_entry.clone(); + } store_entry.roll_winner = winner_guid; if self.player_guid() == Some(winner_guid) { - if self - .store_direct_loot_item_like_cpp(&store_entry, dungeon_encounter_id) - .await - { - self.mark_represented_master_loot_item_removed_like_cpp( + let stored = if let Some(claim) = claim.as_ref() { + self.store_claimed_direct_loot_item_from_owner_like_cpp( + &store_entry, + dungeon_encounter_id, owner_guid, loot_obj, - loot_list_id, - winner_guid, - ); + claim, + ) + .await + } else { + self.store_direct_loot_item_from_owner_like_cpp( + &store_entry, + dungeon_encounter_id, + owner_guid, + ) + .await + }; + if stored { + if claim.is_none() { + self.mark_represented_master_loot_item_removed_like_cpp( + owner_guid, + loot_obj, + loot_list_id, + winner_guid, + ); + } } return; } + let authoritative_claim = claim.is_some(); match self .request_represented_remote_loot_roll_winner_store_like_cpp( winner_guid, @@ -2464,11 +3684,13 @@ impl WorldSession { loot_obj, loot_list_id, dungeon_encounter_id, - store_entry, + vec![store_entry], + false, + claim, ) .await { - MasterLootGiveResult::Stored => { + MasterLootGiveResult::Stored if !authoritative_claim => { self.mark_represented_master_loot_item_removed_like_cpp( owner_guid, loot_obj, @@ -2476,6 +3698,7 @@ impl WorldSession { winner_guid, ); } + MasterLootGiveResult::Stored => {} MasterLootGiveResult::StoreFailed(error) => { debug!( account = self.account_id, @@ -2506,6 +3729,7 @@ impl WorldSession { entry: &LootEntry, winner_guid: ObjectGuid, dungeon_encounter_id: u32, + claim: Option<&LootClaimLease>, ) -> bool { let Some(template) = self .item_stats_store() @@ -2533,55 +3757,58 @@ impl WorldSession { } if self.player_guid() == Some(winner_guid) { - for disenchant_entry in &disenchant_entries { - if !self - .store_direct_loot_item_like_cpp(disenchant_entry, dungeon_encounter_id) - .await - { - return false; - } - } - return true; + return self + .store_direct_disenchant_batch_like_cpp( + &disenchant_entries, + dungeon_encounter_id, + claim, + claim.map(|_| LootItemClaimCommitContextLikeCpp { + owner_guid, + loot_obj, + loot_list_id, + player_guid: winner_guid, + free_for_all: entry.flags.freeforall, + }), + ) + .await; } - for disenchant_entry in disenchant_entries { - match self - .request_represented_remote_loot_roll_winner_store_like_cpp( - winner_guid, - owner_guid, - loot_obj, + match self + .request_represented_remote_loot_roll_winner_store_like_cpp( + winner_guid, + owner_guid, + loot_obj, + loot_list_id, + dungeon_encounter_id, + disenchant_entries, + true, + claim.cloned(), + ) + .await + { + MasterLootGiveResult::Stored => true, + MasterLootGiveResult::StoreFailed(error) => { + debug!( + account = self.account_id, + winner = ?winner_guid, + loot_obj = ?loot_obj, loot_list_id, - dungeon_encounter_id, - disenchant_entry, - ) - .await - { - MasterLootGiveResult::Stored => {} - MasterLootGiveResult::StoreFailed(error) => { - debug!( - account = self.account_id, - winner = ?winner_guid, - loot_obj = ?loot_obj, - loot_list_id, - error, - "represented disenchant loot winner store failed in target session" - ); - return false; - } - MasterLootGiveResult::TargetMismatch => { - debug!( - account = self.account_id, - winner = ?winner_guid, - loot_obj = ?loot_obj, - loot_list_id, - "represented disenchant loot winner target was not connected" - ); - return false; - } + error, + "represented disenchant loot winner batch failed in target session" + ); + false + } + MasterLootGiveResult::TargetMismatch => { + debug!( + account = self.account_id, + winner = ?winner_guid, + loot_obj = ?loot_obj, + loot_list_id, + "represented disenchant loot winner target was not connected" + ); + false } } - - true } async fn generate_represented_disenchant_loot_template_entries_like_cpp( @@ -2814,7 +4041,9 @@ impl WorldSession { loot_obj: ObjectGuid, loot_list_id: u8, dungeon_encounter_id: u32, - entry: LootEntry, + entries: Vec, + is_disenchant: bool, + claim: Option, ) -> MasterLootGiveResult { let Some(registry) = self.player_registry() else { return MasterLootGiveResult::TargetMismatch; @@ -2832,7 +4061,9 @@ impl WorldSession { loot_obj, loot_list_id, dungeon_encounter_id, - entry, + entries, + is_disenchant, + claim, result_tx, }); @@ -2848,6 +4079,8 @@ impl WorldSession { } pub(crate) async fn process_represented_session_commands_like_cpp(&mut self) { + self.apply_pending_durable_item_loot_completions_like_cpp() + .await; let commands = self.drain_session_commands(); for command in commands { match command { @@ -2868,6 +4101,12 @@ impl WorldSession { SessionCommand::CreatureAttackStartLikeCpp(command) => { self.handle_creature_attack_start_like_cpp_command_like_cpp(command); } + SessionCommand::ApplyLootMoneyLikeCpp(command) => { + self.handle_apply_loot_money_like_cpp_command(command).await; + } + SessionCommand::NotifyLootMoneyRemovedLikeCpp(command) => { + self.handle_notify_loot_money_removed_like_cpp_command(command); + } SessionCommand::MasterLootGive(command) => { self.handle_represented_master_loot_give_command_like_cpp(command) .await; @@ -2911,6 +4150,13 @@ impl WorldSession { SessionCommand::SendRepeatableTurnInRequestItemsLikeCpp(command) => { self.handle_send_repeatable_turn_in_request_items_command_like_cpp(command); } + SessionCommand::SendRealmPacketLikeCpp(command) => { + if self.state() == SessionState::LoggedIn + && self.player_guid() == Some(command.recipient) + { + self.send_raw_packet_realm(&command.packet_bytes); + } + } SessionCommand::SendPartyUpdateLikeCpp(command) => { self.handle_send_party_update_command_like_cpp(command); } @@ -2971,10 +4217,10 @@ impl WorldSession { let _ = self.update_visible_gameobjects_or_spell_clicks_like_cpp(); } if command.send_group_destroyed { - self.send_packet(&wow_packet::packets::party::GroupDestroyed); + self.send_packet_realm(&wow_packet::packets::party::GroupDestroyed); } if command.send_group_uninvite { - self.send_packet(&wow_packet::packets::party::GroupUninvite); + self.send_packet_realm(&wow_packet::packets::party::GroupUninvite); } } @@ -2998,12 +4244,17 @@ impl WorldSession { if self.state() != SessionState::LoggedIn { return; } + if self.player_guid() != Some(command.recipient) { + return; + } command.party_update.sequence_num = self.next_group_update_sequence_number_like_cpp(command.party_update.party_index); - self.send_packet(&command.party_update); + // `SMSG_PARTY_UPDATE` and `SMSG_PARTY_MEMBER_FULL_STATE` are both + // CONNECTION_TYPE_REALM in legacy C++ Opcodes.cpp:1829/1832. + self.send_packet_realm(&command.party_update); for packet in command.member_full_state_packets { - let _ = self.send_tx().send(packet); + self.send_raw_packet_realm(&packet); } } @@ -3614,6 +4865,17 @@ impl WorldSession { &mut self, command: LootRollVoteCommand, ) { + let roll_key = (command.loot_obj, command.loot_list_id); + let Some(current_roll) = self.represented_loot_rolls.get(&roll_key) else { + return; + }; + if !Self::represented_loot_roll_vote_command_targets_identity_like_cpp( + &command, + ¤t_roll.command_identity, + ) { + return; + } + let roll = LootRoll { loot_obj: command.loot_obj, loot_list_id: command.loot_list_id, @@ -3629,11 +4891,162 @@ impl WorldSession { .await; } - async fn handle_represented_master_loot_give_command_like_cpp( + fn represented_loot_roll_vote_command_targets_identity_like_cpp( + command: &LootRollVoteCommand, + current_identity: &LootRollCommandIdentityLikeCpp, + ) -> bool { + current_identity.matches_key_like_cpp(command.loot_obj, command.loot_list_id) + && current_identity.is_exact_roll_like_cpp(&command.roll_identity) + } + + async fn handle_apply_loot_money_like_cpp_command( &mut self, - command: MasterLootGiveCommand, + command: ApplyLootMoneyLikeCppCommand, ) { - let Some(player_guid) = self.player_guid() else { + if self.player_guid() != Some(command.recipient) { + return; + } + let apply_money = !command.applied.swap(true, Ordering::SeqCst); + let publish = !command.published.swap(true, Ordering::SeqCst); + if !apply_money && !publish { + return; + } + + if publish + && command.send_coin_removed.load(Ordering::Acquire) + && command.authority_committed.load(Ordering::Acquire) + && self.represented_loot_money_command_targets_active_generation_like_cpp( + command.loot_owner, + &command.authority, + command.authority_generation, + ) + { + self.send_packet(&CoinRemoved { + loot_obj: command.loot_obj, + }); + self.refresh_owned_loot_summary_like_cpp(command.loot_owner); + if let Some(player_guid) = self.player_guid() { + let _ = + self.reconcile_represented_loot_cache_like_cpp(command.loot_owner, player_guid); + } + } + let durable_applied_amount = command.durable_applied_amount.load(Ordering::Acquire); + let _ = self + .apply_durable_represented_loot_money_payout_like_cpp( + command.amount, + durable_applied_amount, + command.sole_looter, + apply_money, + publish, + ) + .await; + } + + fn handle_notify_loot_money_removed_like_cpp_command( + &mut self, + command: NotifyLootMoneyRemovedLikeCppCommand, + ) { + if self.player_guid() != Some(command.recipient) + || !command.authority_committed.load(Ordering::Acquire) + || !self.represented_loot_money_command_targets_active_generation_like_cpp( + command.loot_owner, + &command.authority, + command.authority_generation, + ) + { + return; + } + + self.send_packet(&CoinRemoved { + loot_obj: command.loot_obj, + }); + self.refresh_owned_loot_summary_like_cpp(command.loot_owner); + if let Some(player_guid) = self.player_guid() { + let _ = self.reconcile_represented_loot_cache_like_cpp(command.loot_owner, player_guid); + } + } + + fn represented_loot_money_command_targets_active_generation_like_cpp( + &mut self, + owner_guid: ObjectGuid, + expected_authority: &OwnedLootAuthority, + authority_generation: u64, + ) -> bool { + if !self + .active_loot_view_authorities_like_cpp + .get(&owner_guid) + .is_some_and(|active| active.shares_storage_like_cpp(expected_authority)) + || !self + .active_loot_view_generations_like_cpp + .get(&owner_guid) + .is_some_and(|active| *active == authority_generation) + { + return false; + } + let Some(player_guid) = self.player_guid() else { + return false; + }; + self.represented_owned_loot_authority_like_cpp(owner_guid) + .is_some_and(|authority| { + authority.shares_storage_like_cpp(expected_authority) + && authority + .snapshot_for_player_like_cpp(player_guid) + .is_some_and(|snapshot| snapshot.generation == authority_generation) + }) + } + + async fn apply_durable_represented_loot_money_payout_like_cpp( + &mut self, + notified_amount: u64, + durable_applied_amount: u64, + sole_looter: bool, + apply_money: bool, + publish: bool, + ) -> ApplyLootMoneyResultLikeCpp { + if self.player_guid().is_none() { + return ApplyLootMoneyResultLikeCpp::TargetMismatch; + } + let old_money = self.player_gold_like_cpp(); + let new_money = if apply_money { + old_money + .checked_add(durable_applied_amount) + .filter(|money| *money <= MAX_MONEY_AMOUNT) + .unwrap_or(old_money) + } else { + old_money + }; + + if apply_money && durable_applied_amount != 0 { + self.enqueue_represented_quest_objective_progress_like_cpp( + RepresentedQuestObjectiveProgressEventLikeCpp::MoneyChanged { + old_money, + new_money, + }, + ); + } + if apply_money { + self.set_player_gold_like_cpp(new_money); + } + if publish { + self.send_packet(&LootMoneyNotify { + money: notified_amount, + money_mod: 0, + sole_looter, + }); + } + if apply_money || publish { + self.drain_represented_quest_objective_progress_like_cpp() + .await; + } + + ApplyLootMoneyResultLikeCpp::Applied + } + + async fn handle_represented_master_loot_give_command_like_cpp( + &mut self, + command: MasterLootGiveCommand, + ) { + let Some(player_guid) = self.player_guid() else { let _ = command.result_tx.send(MasterLootGiveResult::TargetMismatch); return; }; @@ -3658,10 +5071,24 @@ impl WorldSession { return; } - let result = if self - .store_direct_loot_item_like_cpp(&command.entry, command.dungeon_encounter_id) + let stored = if let Some(claim) = command.claim.as_ref() { + self.store_claimed_direct_loot_item_from_owner_like_cpp( + &command.entry, + command.dungeon_encounter_id, + command.loot_owner, + command.loot_obj, + claim, + ) .await - { + } else { + self.store_direct_loot_item_from_owner_like_cpp( + &command.entry, + command.dungeon_encounter_id, + command.loot_owner, + ) + .await + }; + let result = if stored { MasterLootGiveResult::Stored } else { MasterLootGiveResult::StoreFailed(LOOT_ERROR_MASTER_OTHER_LIKE_CPP) @@ -3689,9 +5116,13 @@ impl WorldSession { return; }; - if command.entry.allowed_looters.is_empty() - || !command.entry.allowed_looters.contains(&player_guid) - || !command.entry.roll_winner_allows_like_cpp(player_guid) + if command.entries.is_empty() + || (!command.is_disenchant && command.entries.len() != 1) + || command.entries.iter().any(|entry| { + entry.allowed_looters.is_empty() + || !entry.allowed_looters.contains(&player_guid) + || !entry.roll_winner_allows_like_cpp(player_guid) + }) { let _ = command.result_tx.send(MasterLootGiveResult::StoreFailed( LOOT_ERROR_MASTER_OTHER_LIKE_CPP, @@ -3699,21 +5130,57 @@ impl WorldSession { return; } - if let Some(error) = self.represented_master_loot_can_store_error_like_cpp( - player_guid, - command.entry.item_id, - command.entry.quantity, - ) { + if let Some(error) = command.entries.iter().find_map(|entry| { + self.represented_master_loot_can_store_error_like_cpp( + player_guid, + entry.item_id, + entry.quantity, + ) + }) { let _ = command .result_tx .send(MasterLootGiveResult::StoreFailed(error)); return; } - let result = if self - .store_direct_loot_item_like_cpp(&command.entry, command.dungeon_encounter_id) + let stored = if command.is_disenchant { + self.store_direct_disenchant_batch_like_cpp( + &command.entries, + command.dungeon_encounter_id, + command.claim.as_ref(), + command + .claim + .as_ref() + .map(|claim| LootItemClaimCommitContextLikeCpp { + owner_guid: command.loot_owner, + loot_obj: command.loot_obj, + loot_list_id: command.loot_list_id, + player_guid, + free_for_all: match claim.payload_like_cpp() { + LootClaimPayload::Item(entry) => entry.flags.freeforall, + LootClaimPayload::Money(_) => false, + }, + }), + ) .await - { + } else if let Some(claim) = command.claim.as_ref() { + self.store_claimed_direct_loot_item_from_owner_like_cpp( + &command.entries[0], + command.dungeon_encounter_id, + command.loot_owner, + command.loot_obj, + claim, + ) + .await + } else { + self.store_direct_loot_item_from_owner_like_cpp( + &command.entries[0], + command.dungeon_encounter_id, + command.loot_owner, + ) + .await + }; + let result = if stored { MasterLootGiveResult::Stored } else { MasterLootGiveResult::StoreFailed(LOOT_ERROR_MASTER_OTHER_LIKE_CPP) @@ -3731,6 +5198,333 @@ impl WorldSession { let _ = command.result_tx.send(result); } + fn prepare_durable_loot_item_fanout_like_cpp( + &mut self, + claim: &LootClaimLease, + context: LootItemClaimCommitContextLikeCpp, + ) -> Option { + let authority = self + .represented_owned_loot_authority_like_cpp(context.owner_guid) + .filter(|authority| claim.shares_authority_like_cpp(authority))?; + let precommit_snapshot = authority + .snapshot_for_player_like_cpp(context.player_guid) + .filter(|snapshot| { + snapshot.generation == claim.generation_like_cpp() + && snapshot.loot.loot_guid == context.loot_obj + && snapshot + .loot + .items + .iter() + .any(|entry| entry.loot_list_id == context.loot_list_id) + })?; + let instance_id = self + .current_canonical_player_map_key_like_cpp() + .map(|key| key.instance_id) + .unwrap_or(0); + Some(DurableLootItemFanoutLikeCpp { + owner_guid: context.owner_guid, + loot_obj: context.loot_obj, + loot_list_id: context.loot_list_id, + player_guid: context.player_guid, + free_for_all: context.free_for_all, + authority, + authority_generation: claim.generation_like_cpp(), + precommit_snapshot, + committed_snapshot: Arc::new(std::sync::OnceLock::new()), + source_send_tx: self.send_tx().clone(), + player_registry: self.player_registry().cloned(), + map_id: self.player_map_id_like_cpp(), + instance_id, + published: Arc::new(AtomicBool::new(false)), + }) + } + + fn publish_durable_loot_item_fanout_like_cpp( + &mut self, + route: &DurableLootItemFanoutLikeCpp, + ) -> bool { + let Some(committed_snapshot) = route.committed_snapshot.get().filter(|snapshot| { + snapshot.generation == route.authority_generation + && snapshot.loot.loot_guid == route.loot_obj + }) else { + // Never replace the serialization cut with a later authority + // sample. The latter may include a viewer that opened after the + // item commit and already received a response without this slot. + return false; + }; + if route + .published + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return true; + } + + // C++ serializes StoreLootItem before a later LootRelease and notifies + // synchronously. Preserve that already ordered cohort across Rust's + // SQL wait, then add only viewers captured by the item mutation. A + // post-COMMIT opener is excluded because it saw the removed slot. + let viewers = durable_loot_item_fanout_viewers_like_cpp( + &route.precommit_snapshot.loot.players_looting, + &committed_snapshot.loot.players_looting, + ); + let Some(entry) = committed_snapshot + .loot + .items + .iter() + .find(|entry| entry.loot_list_id == route.loot_list_id) + else { + return false; + }; + let allowed_looters = entry + .allowed_looters + .iter() + .copied() + .collect::>(); + let packet = LootRemoved { + owner: route.owner_guid, + loot_obj: route.loot_obj, + loot_list_id: route.loot_list_id, + }; + let bytes = packet.to_bytes(); + let mut stale_viewers = Vec::new(); + + if route.free_for_all { + let _ = route.source_send_tx.send(bytes); + } else { + for viewer in viewers { + if !allowed_looters.contains(&viewer) { + continue; + } + if viewer == route.player_guid { + let _ = route.source_send_tx.send(bytes.clone()); + continue; + } + let Some(registry) = route.player_registry.as_ref() else { + stale_viewers.push(viewer); + continue; + }; + let Some(player) = registry.get(&viewer) else { + stale_viewers.push(viewer); + continue; + }; + if player.map_id != route.map_id || player.instance_id != route.instance_id { + stale_viewers.push(viewer); + continue; + } + let _ = player.send_tx.send(bytes.clone()); + } + } + + for viewer in stale_viewers { + let _ = route + .authority + .remove_viewer_if_generation_like_cpp(route.authority_generation, viewer); + } + + self.refresh_owned_loot_summary_like_cpp(route.owner_guid); + if self.player_guid() == Some(route.player_guid) { + let _ = + self.reconcile_represented_loot_cache_like_cpp(route.owner_guid, route.player_guid); + } + self.finalize_unviewed_durable_loot_owner_like_cpp(route); + true + } + + fn finalize_unviewed_durable_loot_owner_like_cpp( + &mut self, + route: &DurableLootItemFanoutLikeCpp, + ) { + let same_view_still_open = self + .active_loot_view_authorities_like_cpp + .get(&route.owner_guid) + .is_some_and(|authority| authority.shares_storage_like_cpp(&route.authority)) + && self + .active_loot_view_generations_like_cpp + .get(&route.owner_guid) + .is_some_and(|generation| *generation == route.authority_generation); + if same_view_still_open { + return; + } + if !self + .represented_owned_loot_authority_like_cpp(route.owner_guid) + .is_some_and(|authority| authority.shares_storage_like_cpp(&route.authority)) + { + return; + } + let Some(observation) = route + .authority + .fully_looted_lifecycle_observation_like_cpp() + else { + return; + }; + let Some(snapshot) = route + .authority + .snapshot_for_player_like_cpp(route.player_guid) + .filter(|snapshot| snapshot.generation == route.authority_generation) + else { + return; + }; + + self.loot_table + .insert(route.owner_guid, snapshot.loot.clone()); + self.represented_loot_cache_generations_like_cpp + .insert(route.owner_guid, snapshot.generation); + + if route.owner_guid.is_game_object() { + let release = AuthoritativeLootReleaseLikeCpp { + authority: route.authority.clone(), + selected_generation: route.authority_generation, + loot: snapshot.loot, + whole_object_fully_looted: true, + whole_object_fully_skinned: observation.whole_object_fully_skinned, + object_generation: observation.object_generation, + lifecycle_revision: observation.lifecycle_revision, + }; + self.apply_represented_gameobject_loot_release_like_cpp( + route.owner_guid, + route.player_guid, + true, + true, + Some(&release), + ); + let _ = + self.queue_chest_gameobject_state_refresh_for_same_map_like_cpp(route.owner_guid); + self.hide_represented_gameobject_for_player_after_loot_release_like_cpp( + route.owner_guid, + ); + if self + .represented_gameobject_use_states + .get(&route.owner_guid) + .and_then(|state| state.go_type) + .map(u32::from) + == Some(GAMEOBJECT_TYPE_GATHERING_NODE) + { + self.send_gathering_node_loot_release_dynamic_flags_update_like_cpp( + route.owner_guid, + ); + } + self.loot_table.remove(&route.owner_guid); + return; + } + + if route.owner_guid.is_corpse() { + self.remove_canonical_corpse_lootable_dynamic_flag_like_cpp(route.owner_guid); + self.loot_table.remove(&route.owner_guid); + return; + } + + if !route.owner_guid.is_creature_or_vehicle() { + return; + } + + let corpse_decay_looted_rate = self.loot_drop_rates_like_cpp().corpse_decay_looted; + let whole_object_fully_skinned = observation.whole_object_fully_skinned; + let marked = self + .mutate_world_creature_if_fully_looted_observation_like_cpp( + route.owner_guid, + &route.authority, + observation.object_generation, + observation.lifecycle_revision, + |creature| { + creature.force_dynamic_flags_update_like_cpp(); + creature.remove_lootable_dynamic_flag_like_cpp(); + if creature.is_alive() { + return None; + } + let corpse_decay_secs = looted_corpse_decay_secs_like_cpp( + whole_object_fully_skinned, + creature.corpse_delay_secs_like_cpp(), + creature.ignore_corpse_decay_ratio_like_cpp(), + corpse_decay_looted_rate, + ); + creature + .all_loot_removed_from_corpse_like_cpp( + corpse_decay_looted_rate, + whole_object_fully_skinned, + ) + .then_some((creature.entry(), corpse_decay_secs)) + }, + ) + .flatten(); + self.loot_table.remove(&route.owner_guid); + if let Some((entry, corpse_decay_secs)) = marked { + info!( + "Creature {:?} (entry {}) fully looted after durable claim — despawning in {}s", + route.owner_guid, entry, corpse_decay_secs + ); + } + } + + fn commit_represented_loot_item_claim_like_cpp( + &mut self, + claim: &LootClaimLease, + context: LootItemClaimCommitContextLikeCpp, + fanout: Option<&DurableLootItemFanoutLikeCpp>, + ) -> bool { + if !claim.is_committed_like_cpp() { + match claim.commit_with_snapshot_like_cpp() { + Ok((_, Some(snapshot))) => { + if let Some(fanout) = fanout { + let _ = fanout.committed_snapshot.set(snapshot); + } + } + Ok((_, None)) => { + warn!( + owner = ?context.owner_guid, + loot_list_id = context.loot_list_id, + "durable loot item committed without an exact authority snapshot" + ); + return false; + } + Err(error) => { + warn!( + owner = ?context.owner_guid, + loot_list_id = context.loot_list_id, + ?error, + "durable loot item could not commit its object-owned claim" + ); + return false; + } + } + } + let Some(fanout) = fanout.filter(|fanout| { + fanout.owner_guid == context.owner_guid + && fanout.loot_obj == context.loot_obj + && fanout.loot_list_id == context.loot_list_id + && fanout.player_guid == context.player_guid + && fanout.free_for_all == context.free_for_all + && claim.shares_authority_like_cpp(&fanout.authority) + && claim.generation_like_cpp() == fanout.authority_generation + }) else { + warn!( + owner = ?context.owner_guid, + loot_list_id = context.loot_list_id, + "durable loot item committed without its retained fanout route" + ); + return false; + }; + self.publish_durable_loot_item_fanout_like_cpp(fanout) + } + + fn publish_persisted_loot_item_removal_like_cpp( + &mut self, + claim: Option<&LootClaimLease>, + context: Option, + fanout: Option<&DurableLootItemFanoutLikeCpp>, + ) -> bool { + match (claim, context, fanout) { + (None, None, None) => true, + (Some(claim), Some(context), fanout) => { + self.commit_represented_loot_item_claim_like_cpp(claim, context, fanout) + } + _ => { + warn!("durable loot item claim/context mismatch before removal publication"); + false + } + } + } + fn mark_represented_master_loot_item_removed_like_cpp( &mut self, owner_guid: ObjectGuid, @@ -3797,9 +5591,16 @@ impl WorldSession { return true; } + let instance_id = self + .current_canonical_player_map_key_like_cpp() + .map(|key| key.instance_id) + .unwrap_or(0); self.player_registry() .and_then(|registry| registry.get(&target)) - .is_some_and(|target_info| target_info.map_id == self.player_map_id_like_cpp()) + .is_some_and(|target_info| { + target_info.map_id == self.player_map_id_like_cpp() + && target_info.instance_id == instance_id + }) } fn represented_master_loot_target_eligible_like_cpp(&self, target: ObjectGuid) -> bool { @@ -3866,32 +5667,14 @@ impl WorldSession { if !creature.tappers.is_empty() && !creature.tappers.contains(&player_guid) { continue; } - let loot_owner_guid = creature.tappers.first().copied().unwrap_or(player_guid); - - self.ensure_represented_creature_loot_like_cpp( - owner_guid, - loot_owner_guid, - creature.level, - creature.entry, - creature.loot_id, - creature.gold_min, - creature.gold_max, - creature.dungeon_encounter_id, - ) - .await; - if let Some(loot) = self.loot_table.get_mut(&owner_guid) { - if creature.tappers.is_empty() { - if loot.allowed_looters.contains(&player_guid) { - mark_loot_allowed_for_player_like_cpp(loot, player_guid); - } - } else { - for tapper in &creature.tappers { - mark_loot_allowed_for_player_like_cpp(loot, *tapper); - } - } + // C++ `CMSG_LOOT_UNIT` only reads the Loot created by + // `Unit::Kill`; it never regenerates a corpse pool. Reconcile the + // active object-owned generation and fail closed if kill-time + // generation is absent or the corpse lifetime was retired. + if !self.reconcile_represented_loot_cache_like_cpp(owner_guid, player_guid) { + self.loot_table.remove(&owner_guid); + continue; } - let _ = - self.sync_represented_creature_loot_to_canonical_like_cpp(owner_guid, player_guid); if self.loot_table.get(&owner_guid).is_some_and(|loot| { self.represented_loot_can_be_opened_by_player_like_cpp( @@ -3917,31 +5700,13 @@ impl WorldSession { if !creature.tappers.is_empty() && !creature.tappers.contains(&player_guid) { return None; } - let loot_owner_guid = creature.tappers.first().copied().unwrap_or(player_guid); - self.ensure_represented_creature_loot_like_cpp( - owner_guid, - loot_owner_guid, - creature.level, - creature.entry, - creature.loot_id, - creature.gold_min, - creature.gold_max, - creature.dungeon_encounter_id, - ) - .await; - - if let Some(loot) = self.loot_table.get_mut(&owner_guid) { - if creature.tappers.is_empty() { - if loot.allowed_looters.contains(&player_guid) { - mark_loot_allowed_for_player_like_cpp(loot, player_guid); - } - } else { - for tapper in &creature.tappers { - mark_loot_allowed_for_player_like_cpp(loot, *tapper); - } - } + // `Player::isAllowedToLoot` reads `Creature::GetLootForPlayer`; the + // client request is not a generation trigger. A retired/missing + // authority therefore means there is no loot response. + if !self.reconcile_represented_loot_cache_like_cpp(owner_guid, player_guid) { + self.loot_table.remove(&owner_guid); + return None; } - let _ = self.sync_represented_creature_loot_to_canonical_like_cpp(owner_guid, player_guid); let loot = self.loot_table.get(&owner_guid)?; if !self.represented_loot_can_be_opened_by_player_like_cpp(owner_guid, loot, player_guid) { @@ -3973,6 +5738,16 @@ impl WorldSession { let Some(loot_owner_guid) = creature.tappers.first().copied() else { return; }; + let loot_scope_player_guid = if self.current_map_dungeon_state_like_cpp() == Some(false) { + let connected_tappers = + self.represented_connected_creature_tappers_like_cpp(&creature.tappers); + self.player_guid() + .filter(|player_guid| connected_tappers.contains(player_guid)) + .or_else(|| connected_tappers.first().copied()) + .unwrap_or(loot_owner_guid) + } else { + loot_owner_guid + }; self.ensure_represented_creature_loot_like_cpp( creature_guid, @@ -3983,33 +5758,118 @@ impl WorldSession { creature.gold_min, creature.gold_max, creature.dungeon_encounter_id, + &creature.tappers, + creature.loot_lifecycle_revision, ) .await; - - if let Some(loot) = self.loot_table.get_mut(&creature_guid) { - for tapper in creature.tappers { - mark_loot_allowed_for_player_like_cpp(loot, tapper); - } + if self + .sync_represented_creature_loot_to_canonical_like_cpp( + creature_guid, + loot_scope_player_guid, + ) + .is_none() + { + self.loot_table.remove(&creature_guid); } - let _ = self - .sync_represented_creature_loot_to_canonical_like_cpp(creature_guid, loot_owner_guid); } fn represented_on_loot_opened_like_cpp( &mut self, owner_guid: ObjectGuid, player_guid: ObjectGuid, + mut response: LootResponse, ) { - self.ensure_represented_player_looting_like_cpp(owner_guid, player_guid); + let authority = self + .prepare_owned_loot_authority_for_active_request_like_cpp(owner_guid, player_guid) + .filter(|authority| { + authority + .snapshot_for_player_like_cpp(player_guid) + .is_some() + }); + let authoritative_open = if let Some(authority) = authority.as_ref() { + match authority.try_open_view_with_snapshot_like_cpp( + player_guid, + |snapshot, outcome| { + // Enqueue the response while the authority mutex still + // excludes item/money commit. Any later commit therefore + // observes this viewer and its removal packet is ordered + // after the response on this session's send queue. + response.loot_obj = snapshot.loot.loot_guid; + response.acquire_reason = + loot_type_for_client_like_cpp(snapshot.loot.loot_type); + response.loot_method = snapshot.loot.loot_method; + response.coins = snapshot.loot.coins; + response.items = + represented_loot_response_items_like_cpp(&snapshot.loot, player_guid); + + // `flume::Sender::send` may wait indefinitely while this + // lock is held. Reject a saturated/disconnected socket + // queue immediately; the authority method rolls back its + // tentative viewer and first-open mutations before unlock. + if !self.try_send_packet(&response) { + return None; + } + + // Session mirrors become observable only after the client + // response was accepted by its ordered send queue. + self.loot_table.insert(owner_guid, snapshot.loot.clone()); + self.represented_loot_cache_generations_like_cpp + .insert(owner_guid, snapshot.generation); + self.active_loot_view_generations_like_cpp + .insert(owner_guid, outcome.generation); + self.active_loot_view_authorities_like_cpp + .insert(owner_guid, authority.clone()); + Some(()) + }, + ) { + Ok((outcome, ())) => Some(outcome), + Err(LootClaimError::ResponseEnqueueFailed) => { + // Do not attempt a blocking release on the same saturated + // queue. The client never observed this view, so dropping + // every local mirror is the closed state. + self.discard_represented_personal_loot_cache_for_player_like_cpp( + owner_guid, + player_guid, + ); + self.clear_active_loot_guid_if(owner_guid); + return; + } + Err(_) => None, + } + } else { + None + }; + if authoritative_open.is_none() { + if (owner_guid.is_creature_or_vehicle() || owner_guid.is_game_object()) + && !represented_local_loot_fixture_allowed_like_cpp() + { + self.close_stale_active_loot_view_like_cpp(owner_guid, player_guid); + return; + } + self.send_packet(&response); + self.ensure_represented_player_looting_like_cpp(owner_guid, player_guid); + } else if let Some(authority) = authority.as_ref() { + if !self + .active_loot_view_authorities_like_cpp + .get(&owner_guid) + .is_some_and(|opened| opened.shares_storage_like_cpp(authority)) + { + self.active_loot_view_authorities_like_cpp + .insert(owner_guid, authority.clone()); + } + } self.represented_notify_loot_list_like_cpp(owner_guid); - let first_open = match self.loot_table.get_mut(&owner_guid) { - Some(loot) if !loot.looted_by_player => { - loot.looted_by_player = true; - true - } - _ => false, + let first_open = match authoritative_open { + Some(outcome) => outcome.first_viewer, + None => match self.loot_table.get_mut(&owner_guid) { + Some(loot) if !loot.looted_by_player => { + loot.looted_by_player = true; + true + } + _ => false, + }, }; if !first_open { return; @@ -4020,7 +5880,6 @@ impl WorldSession { .get(&owner_guid) .map(|loot| loot.loot_method) .unwrap_or_default(); - match loot_method { LOOT_METHOD_GROUP_LIKE_CPP | LOOT_METHOD_NEED_BEFORE_GREED_LIKE_CPP => { self.represented_start_group_loot_rolls_on_first_open_like_cpp( @@ -4039,6 +5898,42 @@ impl WorldSession { } } + /// True only while a request still belongs to the exact object lifetime + /// whose loot window this session opened. + fn represented_active_loot_generation_matches_like_cpp( + &self, + owner_guid: ObjectGuid, + authority: &OwnedLootAuthority, + ) -> bool { + let Some(player_guid) = self.player_guid() else { + return false; + }; + let current_generation = authority + .snapshot_for_player_like_cpp(player_guid) + .map(|snapshot| snapshot.generation); + self.active_loot_view_authorities_like_cpp + .get(&owner_guid) + .is_some_and(|opened| opened.shares_storage_like_cpp(authority)) + && self + .active_loot_view_generations_like_cpp + .get(&owner_guid) + .is_some_and(|opened| Some(*opened) == current_generation) + } + + fn represented_active_loot_claim_generation_matches_like_cpp( + &self, + owner_guid: ObjectGuid, + claim: &LootClaimLease, + ) -> bool { + self.active_loot_view_authorities_like_cpp + .get(&owner_guid) + .is_some_and(|opened| claim.shares_authority_like_cpp(opened)) + && self + .active_loot_view_generations_like_cpp + .get(&owner_guid) + .is_some_and(|opened| *opened == claim.generation_like_cpp()) + } + fn represented_notify_loot_list_like_cpp(&self, owner_guid: ObjectGuid) { if self.group_guid.is_none() { return; @@ -4064,6 +5959,10 @@ impl WorldSession { .then_some(loot.round_robin_player), }; let bytes = packet.to_bytes(); + let instance_id = self + .current_canonical_player_map_key_like_cpp() + .map(|key| key.instance_id) + .unwrap_or(0); for allowed_looter in &loot.allowed_looters { if Some(*allowed_looter) == self.player_guid() { @@ -4077,7 +5976,7 @@ impl WorldSession { let Some(player) = registry.get(allowed_looter) else { continue; }; - if player.map_id != self.player_map_id_like_cpp() { + if player.map_id != self.player_map_id_like_cpp() || player.instance_id != instance_id { continue; } @@ -4102,9 +6001,34 @@ impl WorldSession { owner_guid: ObjectGuid, loot_list_id: u8, ) { - let Some(loot) = self.loot_table.get(&owner_guid) else { + let Some(loot) = self.loot_table.get(&owner_guid).cloned() else { return; }; + let snapshot = wow_loot::OwnedLootSnapshot { + generation: self + .represented_loot_cache_generations_like_cpp + .get(&owner_guid) + .copied() + .unwrap_or(0), + scope: wow_loot::OwnedLootScope::Shared, + loot, + }; + self.represented_notify_loot_item_removed_from_snapshot_like_cpp( + owner_guid, + None, + &snapshot, + loot_list_id, + ); + } + + fn represented_notify_loot_item_removed_from_snapshot_like_cpp( + &mut self, + owner_guid: ObjectGuid, + authority: Option<&OwnedLootAuthority>, + snapshot: &wow_loot::OwnedLootSnapshot, + loot_list_id: u8, + ) { + let loot = &snapshot.loot; let Some(entry) = loot .items .iter() @@ -4123,6 +6047,10 @@ impl WorldSession { let allowed_looters = entry.allowed_looters.clone(); let current_player = self.player_guid(); let current_map = self.player_map_id_like_cpp(); + let current_instance = self + .current_canonical_player_map_key_like_cpp() + .map(|key| key.instance_id) + .unwrap_or(0); let registry = self.player_registry().cloned(); let mut stale_looters = Vec::new(); @@ -4144,7 +6072,7 @@ impl WorldSession { stale_looters.push(*looter); continue; }; - if player.map_id != current_map { + if player.map_id != current_map || player.instance_id != current_instance { stale_looters.push(*looter); continue; } @@ -4158,9 +6086,20 @@ impl WorldSession { loot.players_looting .retain(|looter| !stale_looters.contains(looter)); } + if !stale_looters.is_empty() { + if let Some(authority) = authority { + for looter in stale_looters { + let _ = + authority.remove_viewer_if_generation_like_cpp(snapshot.generation, looter); + } + } + } } fn represented_notify_money_removed_like_cpp(&mut self, owner_guid: ObjectGuid) { + if let Some(player_guid) = self.player_guid() { + let _ = self.reconcile_represented_loot_cache_like_cpp(owner_guid, player_guid); + } let Some(loot) = self.loot_table.get(&owner_guid) else { return; }; @@ -4172,6 +6111,10 @@ impl WorldSession { let players_looting = loot.players_looting.clone(); let current_player = self.player_guid(); let current_map = self.player_map_id_like_cpp(); + let current_instance = self + .current_canonical_player_map_key_like_cpp() + .map(|key| key.instance_id) + .unwrap_or(0); let registry = self.player_registry().cloned(); let mut stale_looters = Vec::new(); @@ -4189,7 +6132,7 @@ impl WorldSession { stale_looters.push(*looter); continue; }; - if player.map_id != current_map { + if player.map_id != current_map || player.instance_id != current_instance { stale_looters.push(*looter); continue; } @@ -4203,6 +6146,16 @@ impl WorldSession { loot.players_looting .retain(|looter| !stale_looters.contains(looter)); } + if !stale_looters.is_empty() + && let Some(authority) = self.represented_owned_loot_authority_like_cpp(owner_guid) + { + for looter in stale_looters { + authority.remove_viewer_like_cpp(looter); + } + if let Some(player_guid) = current_player { + let _ = self.reconcile_represented_loot_cache_like_cpp(owner_guid, player_guid); + } + } } fn represented_start_group_loot_rolls_on_first_open_like_cpp( @@ -4210,11 +6163,24 @@ impl WorldSession { owner_guid: ObjectGuid, player_guid: ObjectGuid, ) { + let Some(authority) = self.represented_owned_loot_authority_like_cpp(owner_guid) else { + return; + }; + let Some(authority_snapshot) = authority.snapshot_for_player_like_cpp(player_guid) else { + return; + }; + let authority_generation = authority_snapshot.generation; + let authority_scope = authority_snapshot.scope; let current_map_id = self.player_map_id_like_cpp(); + let current_instance_id = self + .current_canonical_player_map_key_like_cpp() + .map(|key| key.instance_id) + .unwrap_or(0); let player_registry = self.player_registry().cloned(); let mut packets = Vec::new(); let mut auto_pass_packets = Vec::new(); let mut pending_rolls = Vec::new(); + let mut unblocked_without_roll = Vec::new(); let item_flags2_by_item_id: HashMap, Option)> = self .loot_table .get(&owner_guid) @@ -4246,12 +6212,13 @@ impl WorldSession { entry, player_guid, current_map_id, + current_instance_id, player_registry.as_deref(), ); - if eligible_looters.len() <= 1 { entry.flags.under_threshold = true; entry.flags.blocked = false; + unblocked_without_roll.push(entry.loot_list_id); continue; } @@ -4268,7 +6235,10 @@ impl WorldSession { .as_deref() .and_then(|registry| registry.get(looter)) { - Some(player) if player.map_id == current_map_id => { + Some(player) + if player.map_id == current_map_id + && player.instance_id == current_instance_id => + { if player.pass_on_group_loot { ROLL_VOTE_PASS_LIKE_CPP } else { @@ -4286,9 +6256,20 @@ impl WorldSession { }, ); } + let command_identity = LootRollCommandIdentityLikeCpp::new_like_cpp( + loot.loot_guid, + entry.loot_list_id, + authority.clone(), + authority_generation, + ); let state = RepresentedLootRollState { + owner_guid, loot_obj: loot.loot_guid, loot_list_id: entry.loot_list_id, + authority: authority.clone(), + authority_generation, + authority_scope, + command_identity, end_time: Instant::now() + Duration::from_millis(u64::from(LOOT_ROLL_TIMEOUT_MS_LIKE_CPP)), voters, @@ -4354,6 +6335,21 @@ impl WorldSession { } } + if !unblocked_without_roll.is_empty() + && let Some(authority) = self.represented_owned_loot_authority_like_cpp(owner_guid) + { + for loot_list_id in unblocked_without_roll { + let _ = authority.finish_item_roll_like_cpp( + player_guid, + authority_generation, + loot_list_id, + true, + None, + ); + } + let _ = self.reconcile_represented_loot_cache_like_cpp(owner_guid, player_guid); + } + for roll in pending_rolls { self.represented_loot_rolls .insert((roll.loot_obj, roll.loot_list_id), roll); @@ -4372,7 +6368,9 @@ impl WorldSession { let Some(player) = registry.get(&looter) else { continue; }; - if player.map_id != self.player_map_id_like_cpp() { + if player.map_id != self.player_map_id_like_cpp() + || player.instance_id != current_instance_id + { continue; } @@ -4397,20 +6395,17 @@ impl WorldSession { info.active_loot_rolls = self .represented_loot_rolls - .keys() - .map(|key| (key.0, key.1)) + .values() + .map(|state| state.command_identity.clone()) .collect(); } pub(crate) async fn tick_represented_loot_rolls_like_cpp(&mut self) { let now = Instant::now(); - let expired: Vec<(ObjectGuid, u8)> = self - .represented_loot_rolls - .iter() - .filter_map(|(key, state)| (state.end_time <= now).then_some(*key)) - .collect(); + let roll_keys: Vec<(ObjectGuid, u8)> = + self.represented_loot_rolls.keys().copied().collect(); - for (loot_obj, loot_list_id) in expired { + for (loot_obj, loot_list_id) in roll_keys { let Some(state) = self .represented_loot_rolls .get(&(loot_obj, loot_list_id)) @@ -4418,12 +6413,21 @@ impl WorldSession { else { continue; }; - let Some(owner_guid) = self.active_loot_owner_for_loot_object_like_cpp(loot_obj) else { - self.represented_loot_rolls - .remove(&(loot_obj, loot_list_id)); - self.publish_represented_loot_roll_ownership_like_cpp(); + if self + .represented_current_loot_roll_authority_like_cpp(&state) + .is_none() + { + self.cancel_represented_loot_roll_generation_mismatch_like_cpp( + (loot_obj, loot_list_id), + &state, + ); continue; - }; + } + if state.end_time > now { + continue; + } + + let owner_guid = state.owner_guid; let Some(entry) = self.loot_table.get(&owner_guid).and_then(|loot| { loot.items .iter() @@ -4508,6 +6512,57 @@ impl WorldSession { }) } + /// Install kill-time pools only while the exact creature death lifetime + /// observed before async template generation is still current. C++ runs + /// `Unit::Kill` and loot creation on one map thread; this lock-scoped CAS + /// is the Rust equivalent and prevents corpse-removal/respawn ABA. + fn install_represented_creature_kill_loot_if_current_like_cpp( + &mut self, + creature_guid: ObjectGuid, + expected_authority: &OwnedLootAuthority, + expected_object_generation: u64, + expected_loot_lifecycle_revision: u64, + shared: Option, + personal: HashMap, + ) -> bool { + let expected_authority = expected_authority.clone(); + self.mutate_world_creature(creature_guid, move |world_creature| { + if world_creature.is_alive() + || world_creature.creature.loot_lifecycle_revision_like_cpp() + != expected_loot_lifecycle_revision + || !world_creature + .creature + .loot_authority_like_cpp() + .shares_storage_like_cpp(&expected_authority) + || !expected_authority.is_retired_like_cpp() + || expected_authority.generation_like_cpp() != expected_object_generation + { + return false; + } + + let installed = if expected_object_generation == 0 { + expected_authority + .initialize_pristine_like_cpp(shared, personal) + .installed() + } else { + expected_authority + .replace_retired_generation_like_cpp( + expected_object_generation, + shared, + personal, + ) + .is_some() + }; + if installed { + world_creature + .creature + .sync_loot_summaries_from_authority_like_cpp(); + } + installed + }) + .unwrap_or(false) + } + async fn ensure_represented_creature_loot_like_cpp( &mut self, creature_guid: ObjectGuid, @@ -4518,9 +6573,197 @@ impl WorldSession { gold_min: u32, gold_max: u32, dungeon_encounter_id: u32, + allowed_looters: &[ObjectGuid], + expected_loot_lifecycle_revision: u64, ) { + let authority = self.represented_owned_loot_authority_like_cpp(creature_guid); + if authority.is_none() && !represented_local_loot_fixture_allowed_like_cpp() { + self.loot_table.remove(&creature_guid); + return; + } + let mut retired_object_generation = None; + if let Some(authority) = authority.as_ref() { + #[cfg(test)] + if authority.is_pristine_like_cpp() && self.loot_table.contains_key(&creature_guid) { + if !self + .represented_personal_loot_owners + .contains(&creature_guid) + && let Some(loot) = self.loot_table.get_mut(&creature_guid) + { + prepare_represented_shared_creature_loot_generation_like_cpp( + loot, + allowed_looters, + ); + } + if self + .sync_represented_creature_loot_to_canonical_like_cpp( + creature_guid, + loot_owner_guid, + ) + .is_some() + { + // Legacy packet fixtures pre-populate the former session + // cache. Install that value once into the typed object-owned + // authority instead of silently replacing it with generated + // empty loot. This branch does not exist in production. + return; + } + } + let snapshot = self + .player_guid() + .and_then(|player_guid| authority.snapshot_for_player_like_cpp(player_guid)) + .or_else(|| authority.snapshot_for_player_like_cpp(loot_owner_guid)); + if let Some(snapshot) = snapshot { + let cache_player = match snapshot.scope { + OwnedLootScope::Personal(player_guid) => player_guid, + OwnedLootScope::Shared => loot_owner_guid, + }; + self.cache_represented_owned_loot_snapshot_like_cpp( + creature_guid, + cache_player, + snapshot, + ); + return; + } + if !authority.is_retired_like_cpp() { + self.loot_table.remove(&creature_guid); + return; + } + retired_object_generation = Some(authority.generation_like_cpp()); + self.loot_table.remove(&creature_guid); + self.represented_loot_cache_generations_like_cpp + .remove(&creature_guid); + } + + let map_is_dungeon = self.current_map_dungeon_state_like_cpp(); + let connected_tappers = + self.represented_connected_creature_tappers_like_cpp(allowed_looters); + + // C++ `Unit::Kill` has three distinct ownership shapes: + // - overworld: one independently generated personal pool per tapper; + // - dungeon encounter/boss: one independent, lockout-filtered pool per + // tapper (`GenerateDungeonEncounterPersonalLoot`); + // - dungeon trash: exactly one personal pool, keyed by the group's + // selected looter (or the first tapper without a group). + if map_is_dungeon == Some(false) + || (map_is_dungeon == Some(true) && dungeon_encounter_id != 0) + { + let personal_tappers = connected_tappers + .into_iter() + .filter(|tapper| { + dungeon_encounter_id == 0 + || !self + .represented_locked_dungeon_encounters + .contains(&(*tapper, dungeon_encounter_id)) + }) + .collect::>(); + if personal_tappers.is_empty() { + self.loot_table.remove(&creature_guid); + return; + } + + let Some(personal) = self + .generate_represented_creature_personal_loot_like_cpp( + creature_guid, + level, + entry, + loot_id, + gold_min, + gold_max, + dungeon_encounter_id, + &personal_tappers, + ) + .await + else { + return; + }; + let cache_player = self + .player_guid() + .filter(|player_guid| personal.contains_key(player_guid)) + .unwrap_or(personal_tappers[0]); + + if let (Some(authority), Some(expected_generation)) = + (authority.as_ref(), retired_object_generation) + { + if self.install_represented_creature_kill_loot_if_current_like_cpp( + creature_guid, + authority, + expected_generation, + expected_loot_lifecycle_revision, + None, + personal, + ) { + let _ = + self.reconcile_represented_loot_cache_like_cpp(creature_guid, cache_player); + } else { + self.loot_table.remove(&creature_guid); + } + } else if represented_local_loot_fixture_allowed_like_cpp() + && let Some(pool) = personal.get(&cache_player).cloned() + { + self.loot_table.insert(creature_guid, pool); + } + return; + } + + if map_is_dungeon == Some(true) { + if connected_tappers.is_empty() { + self.loot_table.remove(&creature_guid); + return; + } + let selected_looter = + self.represented_dungeon_trash_looter_like_cpp(&connected_tappers); + let Some(personal) = self + .generate_represented_creature_personal_loot_like_cpp( + creature_guid, + level, + entry, + loot_id, + gold_min, + gold_max, + 0, + &[selected_looter], + ) + .await + else { + return; + }; + let has_loot = personal + .get(&selected_looter) + .is_some_and(|loot| !loot_is_looted_like_cpp(loot)); + + if let (Some(authority), Some(expected_generation)) = + (authority.as_ref(), retired_object_generation) + { + if self.install_represented_creature_kill_loot_if_current_like_cpp( + creature_guid, + authority, + expected_generation, + expected_loot_lifecycle_revision, + None, + personal, + ) { + let _ = self + .reconcile_represented_loot_cache_like_cpp(creature_guid, selected_looter); + if has_loot { + self.advance_represented_dungeon_trash_looter_like_cpp(&connected_tappers); + } + } else { + self.loot_table.remove(&creature_guid); + } + } else if represented_local_loot_fixture_allowed_like_cpp() + && let Some(pool) = personal.get(&selected_looter).cloned() + { + self.loot_table.insert(creature_guid, pool); + } + return; + } + + // Missing Map.db2 metadata is not proof of either overworld or + // dungeon. Preserve the represented shared fallback for legacy test + // fixtures, but still bind its async install to the exact death token. if !self.loot_table.contains_key(&creature_guid) { - let loot = self + let Some(mut loot) = self .generate_represented_creature_loot_like_cpp( creature_guid, loot_owner_guid, @@ -4531,8 +6774,33 @@ impl WorldSession { gold_max, dungeon_encounter_id, ) - .await; - self.loot_table.insert(creature_guid, loot); + .await + else { + return; + }; + prepare_represented_shared_creature_loot_generation_like_cpp( + &mut loot, + allowed_looters, + ); + if let (Some(authority), Some(expected_generation)) = + (authority.as_ref(), retired_object_generation) + { + if self.install_represented_creature_kill_loot_if_current_like_cpp( + creature_guid, + authority, + expected_generation, + expected_loot_lifecycle_revision, + Some(loot), + HashMap::new(), + ) { + let _ = self + .reconcile_represented_loot_cache_like_cpp(creature_guid, loot_owner_guid); + } else { + self.loot_table.remove(&creature_guid); + } + } else if represented_local_loot_fixture_allowed_like_cpp() { + self.loot_table.insert(creature_guid, loot); + } } } @@ -4541,47 +6809,232 @@ impl WorldSession { gameobject_guid: ObjectGuid, player_guid: ObjectGuid, source: GameObjectLootSource, + allowed_looters: &[ObjectGuid], ) { - if !self.loot_table.contains_key(&gameobject_guid) { - let loot = self - .generate_represented_gameobject_chest_loot_like_cpp( - gameobject_guid, - player_guid, - source, - ) - .await; - self.loot_table.insert(gameobject_guid, loot); - } - } - - async fn generate_represented_gameobject_chest_loot_like_cpp( - &mut self, - gameobject_guid: ObjectGuid, - player_guid: ObjectGuid, - source: GameObjectLootSource, - ) -> CreatureLoot { + // C++ creates `m_loot` synchronously in `GameObject::Use` + // (`GameObject.cpp:2559-2575`). Capture the exact map-owned lifetime + // before async template work, then revalidate it under the map lock at + // install time so `ClearLoot`/restock cannot be crossed. + let install_observation = match self + .represented_gameobject_loot_install_observation_result_like_cpp(gameobject_guid) + { + Some(Some(observation)) => Some(observation), + Some(None) => { + self.loot_table.remove(&gameobject_guid); + self.represented_loot_cache_generations_like_cpp + .remove(&gameobject_guid); + return; + } + None if !represented_local_loot_fixture_allowed_like_cpp() => { + self.loot_table.remove(&gameobject_guid); + self.represented_loot_cache_generations_like_cpp + .remove(&gameobject_guid); + return; + } + None => None, + }; + let authority = install_observation + .as_ref() + .map(|observation| observation.authority.clone()); + let mut install_single_personal_pool = false; + if let Some(authority) = authority.as_ref() { + #[cfg(test)] + if authority.is_pristine_like_cpp() && self.loot_table.contains_key(&gameobject_guid) { + if !self + .represented_personal_loot_owners + .contains(&gameobject_guid) + && let Some(loot) = self.loot_table.get_mut(&gameobject_guid) + { + prepare_represented_shared_loot_generation_like_cpp(loot, allowed_looters); + } + if self + .sync_represented_gameobject_loot_to_canonical_like_cpp( + gameobject_guid, + player_guid, + ) + .is_some() + { + // Test-only bridge for legacy pre-authority packet fixtures; + // live gameobjects still require their canonical map owner. + return; + } + } + if let Some(snapshot) = authority.snapshot_for_player_like_cpp(player_guid) { + self.cache_represented_owned_loot_snapshot_like_cpp( + gameobject_guid, + player_guid, + snapshot, + ); + return; + } + let active_authority = + authority.stamp_like_cpp().lifecycle == OwnedLootAuthorityLifecycle::Active; + let can_add_personal_pool = active_authority + && source.uses_personal_loot_like_cpp() + && !source.is_personal_encounter_loot_like_cpp(); + if can_add_personal_pool { + // The non-encounter C++ branch adds exactly this opener's + // `m_personalLoot[player]` pool. Encounter loot is different: + // it regenerates one topology from GameObject::GetTapList and + // assigns the whole map. Rust does not yet have that canonical + // script-owned tap list, so an encounter opener absent from + // the installed topology must fail closed rather than receive + // a fabricated singleton pool. + install_single_personal_pool = true; + } + if !authority.is_retired_like_cpp() && !can_add_personal_pool { + self.loot_table.remove(&gameobject_guid); + self.represented_loot_cache_generations_like_cpp + .remove(&gameobject_guid); + return; + } + self.loot_table.remove(&gameobject_guid); + self.represented_loot_cache_generations_like_cpp + .remove(&gameobject_guid); + } + + if !self.loot_table.contains_key(&gameobject_guid) { + let single_personal_looter = install_single_personal_pool.then_some([player_guid]); + let generation_allowed_looters = single_personal_looter + .as_ref() + .map_or(allowed_looters, |looters| looters.as_slice()); + let Some(mut loot) = self + .generate_represented_gameobject_chest_loot_like_cpp( + gameobject_guid, + player_guid, + source, + generation_allowed_looters, + ) + .await + else { + return; + }; + let personal = self + .represented_personal_loot_owners + .contains(&gameobject_guid); + if !personal { + prepare_represented_shared_loot_generation_like_cpp(&mut loot, allowed_looters); + } + if let Some(observation) = install_observation { + if source.uses_personal_loot_like_cpp() + && (!source.is_personal_encounter_loot_like_cpp() + || install_single_personal_pool) + { + if self + .upsert_represented_personal_gameobject_loot_authority_if_observed_with_empty_policy_like_cpp( + gameobject_guid, + player_guid, + loot, + false, + source.is_personal_encounter_loot_like_cpp(), + &observation, + ) + .is_none() + { + self.loot_table.remove(&gameobject_guid); + self.represented_loot_cache_generations_like_cpp + .remove(&gameobject_guid); + } + return; + } + let Some((shared, mut personal)) = self.represented_loot_authority_pools_like_cpp( + gameobject_guid, + player_guid, + loot, + personal, + ) else { + self.loot_table.remove(&gameobject_guid); + self.represented_loot_cache_generations_like_cpp + .remove(&gameobject_guid); + return; + }; + if source.is_personal_encounter_loot_like_cpp() { + // `GenerateDungeonEncounterPersonalLoot` drops each + // per-player `Loot` that is already empty after money, + // personal-template and not-normal processing + // (`LootMgr.cpp:933-941`). The non-encounter + // `chestPersonalLoot` branch deliberately keeps its empty + // `m_personalLoot[player]`, so this filter belongs only to + // the encounter topology. + personal.retain(|_, pool| !loot_is_looted_like_cpp(pool)); + self.represented_personal_loot_money + .retain(|(owner, player), _| { + *owner != gameobject_guid || personal.contains_key(player) + }); + if personal.is_empty() { + // C++ assigns an empty `m_personalLoot` map and sends no + // loot window. Keep the authority pristine/retired so + // a later `Use` may generate again instead of leaving + // an active owner with no selectable pool. + self.represented_personal_loot_owners + .remove(&gameobject_guid); + self.loot_table.remove(&gameobject_guid); + self.represented_loot_cache_generations_like_cpp + .remove(&gameobject_guid); + return; + } + } + let installed = self + .mutate_canonical_gameobject_by_guid_like_cpp( + gameobject_guid, + move |gameobject| { + gameobject.install_loot_authority_if_lifecycle_like_cpp( + &observation.authority, + observation.object_generation, + observation.loot_lifecycle_revision, + shared, + personal, + ) + }, + ) + .unwrap_or(false); + if !installed { + self.loot_table.remove(&gameobject_guid); + self.represented_loot_cache_generations_like_cpp + .remove(&gameobject_guid); + return; + } + let _ = + self.reconcile_represented_loot_cache_like_cpp(gameobject_guid, player_guid); + } else if represented_local_loot_fixture_allowed_like_cpp() { + self.loot_table.insert(gameobject_guid, loot); + } + } + } + + async fn generate_represented_gameobject_chest_loot_like_cpp( + &mut self, + gameobject_guid: ObjectGuid, + player_guid: ObjectGuid, + source: GameObjectLootSource, + allowed_looters: &[ObjectGuid], + ) -> Option { + let personal_loot = source.uses_personal_loot_like_cpp(); + let personal_encounter = source.is_personal_encounter_loot_like_cpp(); let (loot_method, loot_master, round_robin_player) = self .represented_gameobject_chest_group_state_like_cpp( - source.use_group_loot_rules, + source.use_group_loot_rules && !personal_loot, player_guid, ); let loot_id = source.open_loot_id_like_cpp(); - let personal_encounter = source.is_personal_encounter_loot_like_cpp(); let items = if personal_encounter { Vec::new() } else { - self.generate_represented_gameobject_loot_items_like_cpp(loot_id) - .await - .unwrap_or_else(|| { - if loot_id != 0 { - debug!( - loot_id, - gameobject = ?gameobject_guid, - "gameobject loot template unavailable for represented chest" - ); - } - Vec::new() - }) + self.generate_represented_shared_gameobject_loot_items_like_cpp( + loot_id, + allowed_looters, + ) + .await + .unwrap_or_else(|| { + if loot_id != 0 { + debug!( + loot_id, + gameobject = ?gameobject_guid, + "gameobject loot template unavailable for represented chest" + ); + } + Vec::new() + }) }; let (min_money, max_money) = self .load_gameobject_template_addon_money_loot_like_cpp(gameobject_guid.entry()) @@ -4592,8 +7045,9 @@ impl WorldSession { self.loot_drop_rates_like_cpp().money, ); + let loot_guid = self.next_represented_loot_object_guid_like_cpp(gameobject_guid)?; let mut loot = CreatureLoot { - loot_guid: represented_loot_object_guid_like_cpp(gameobject_guid), + loot_guid, coins, unlooted_count: 0, loot_type: LOOT_TYPE_CHEST_LIKE_CPP, @@ -4608,18 +7062,36 @@ impl WorldSession { looted_by_player: false, }; - if personal_encounter { + if personal_loot { loot.coins = 0; self.represented_personal_loot_owners .insert(gameobject_guid); self.represented_personal_loot_money .retain(|(owner, _), _| *owner != gameobject_guid); - let represented_tappers = self - .represented_gameobject_personal_encounter_tappers_like_cpp( + let represented_tappers = if personal_encounter && !allowed_looters.is_empty() { + let mut tappers = allowed_looters + .iter() + .copied() + .filter(|guid| { + guid.is_player() + && self.represented_player_is_unlocked_for_dungeon_encounter_like_cpp( + *guid, + source.dungeon_encounter_id, + ) + }) + .collect::>(); + tappers.sort_unstable_by_key(|guid| (guid.high_value(), guid.low_value())); + tappers.dedup(); + tappers + } else if personal_encounter { + self.represented_gameobject_personal_encounter_tappers_like_cpp( gameobject_guid, player_guid, source.dungeon_encounter_id, - ); + ) + } else { + vec![player_guid] + }; for tapper in &represented_tappers { if !loot.allowed_looters.contains(tapper) { loot.allowed_looters.push(*tapper); @@ -4632,22 +7104,24 @@ impl WorldSession { self.represented_personal_loot_money .insert((gameobject_guid, *tapper), tapper_money); } - loot.items = self - .generate_represented_gameobject_personal_loot_items_like_cpp( - loot_id, - &represented_tappers, - ) - .await - .unwrap_or_else(|| { - if loot_id != 0 { - debug!( - loot_id, - gameobject = ?gameobject_guid, - "gameobject personal loot template unavailable for represented chest" - ); - } - Vec::new() - }); + if personal_encounter { + loot.items = self + .generate_represented_gameobject_personal_loot_items_like_cpp( + loot_id, + &represented_tappers, + ) + .await + .unwrap_or_else(|| { + if loot_id != 0 { + debug!( + loot_id, + gameobject = ?gameobject_guid, + "gameobject personal loot template unavailable for represented chest" + ); + } + Vec::new() + }); + } rebuild_represented_personal_loot_counts_like_cpp(&mut loot); if represented_tappers.is_empty() { self.represented_personal_loot_owners @@ -4655,7 +7129,7 @@ impl WorldSession { } } - loot + Some(loot) } fn represented_gameobject_personal_encounter_tappers_like_cpp( @@ -4717,7 +7191,7 @@ impl WorldSession { fn represented_gameobject_chest_group_state_like_cpp( &self, use_group_loot_rules: bool, - player_guid: ObjectGuid, + _player_guid: ObjectGuid, ) -> (u8, ObjectGuid, ObjectGuid) { if !use_group_loot_rules { return (0, ObjectGuid::EMPTY, ObjectGuid::EMPTY); @@ -4732,7 +7206,63 @@ impl WorldSession { return (0, ObjectGuid::EMPTY, ObjectGuid::EMPTY); }; - (group.loot_method, group.master_looter_guid, player_guid) + // C++ `Loot::FillLoot` assigns round robin only for `LOOT_CORPSE`. + ( + group.loot_method, + group.master_looter_guid, + ObjectGuid::EMPTY, + ) + } + + /// C++ `Loot::FillLoot` calls `FillNotNormalLootFor` for every connected + /// group member at reward distance from the opening player before the + /// chest's shared `Loot` becomes visible. + fn represented_group_looters_at_reward_distance_like_cpp( + &self, + player_guid: ObjectGuid, + ) -> Vec { + let Some(group_guid) = self.group_guid else { + return vec![player_guid]; + }; + let Some(group_registry) = self.group_registry() else { + return vec![player_guid]; + }; + let Some(group) = group_registry.get(&group_guid) else { + return vec![player_guid]; + }; + let source_position = self.player_position_like_cpp().unwrap_or_default(); + let map_id = self.player_map_id_like_cpp(); + let instance_id = self + .current_canonical_player_map_key_like_cpp() + .map(|key| key.instance_id) + .unwrap_or(0); + let registry = self.player_registry(); + let mut looters = Vec::new(); + + for member_guid in &group.members { + if *member_guid == player_guid { + looters.push(*member_guid); + continue; + } + let Some(member) = registry.and_then(|registry| registry.get(member_guid)) else { + continue; + }; + if member.is_in_world + && member.map_id == map_id + && member.instance_id == instance_id + && (self.current_map_is_dungeon_like_cpp() + || source_position.is_within_dist(&member.position, 74.0)) + { + looters.push(*member_guid); + } + } + + if looters.is_empty() { + looters.push(player_guid); + } + looters.sort_unstable_by_key(|guid| (guid.high_value(), guid.low_value())); + looters.dedup(); + looters } async fn generate_represented_gameobject_loot_items_like_cpp( @@ -4743,6 +7273,21 @@ impl WorldSession { loot_id, LootStoreKind::Gameobject, LOOT_MODE_DEFAULT_LIKE_CPP, + None, + ) + .await + } + + async fn generate_represented_shared_gameobject_loot_items_like_cpp( + &mut self, + loot_id: u32, + allowed_looters: &[ObjectGuid], + ) -> Option> { + self.generate_represented_gameobject_loot_items_for_store_like_cpp( + loot_id, + LootStoreKind::Gameobject, + LOOT_MODE_DEFAULT_LIKE_CPP, + Some(allowed_looters), ) .await } @@ -4752,6 +7297,7 @@ impl WorldSession { loot_id: u32, store_kind: LootStoreKind, loot_mode: u16, + shared_allowed_looters: Option<&[ObjectGuid]>, ) -> Option> { if loot_id == 0 { return Some(Vec::new()); @@ -4773,6 +7319,7 @@ impl WorldSession { condition_ids.iter().map(|id| id.source_entry), ) .await; + let defer_eligibility_until_after_roll = shared_allowed_looters.is_some(); let generated = { match store.fill_loot_with_context_like_cpp( loot_id, @@ -4795,12 +7342,13 @@ impl WorldSession { }, |item| self.item_drop_rate_like_cpp(item.item_id), |context| { - self.represented_creature_loot_item_allowed_like_cpp( - context, - &condition_rows, - &condition_references, - &addon_metadata, - ) + defer_eligibility_until_after_roll + || self.represented_creature_loot_item_allowed_like_cpp( + context, + &condition_rows, + &condition_references, + &addon_metadata, + ) }, |item_id, rng| { let random_properties = @@ -4824,7 +7372,24 @@ impl WorldSession { .get(&item.item_id) .copied() .unwrap_or_default(); - generated_creature_loot_item_to_entry_like_cpp(item, metadata) + if let Some(allowed_looters) = shared_allowed_looters { + generated_shared_gameobject_loot_item_to_entry_like_cpp( + item, + metadata, + allowed_looters, + |context, looter| { + self.represented_creature_loot_item_allowed_for_player_like_cpp( + context, + looter, + &condition_rows, + &condition_references, + &addon_metadata, + ) + }, + ) + } else { + generated_creature_loot_item_to_entry_like_cpp(item, metadata) + } }) .collect(), ) @@ -4842,6 +7407,7 @@ impl WorldSession { current_area_id, LootStoreKind::Fishing, loot_mode, + None, ) .await?; if !items.is_empty() { @@ -4861,6 +7427,7 @@ impl WorldSession { 1, LootStoreKind::Fishing, loot_mode, + None, ) .await } @@ -5027,7 +7594,7 @@ impl WorldSession { gold_min: u32, gold_max: u32, dungeon_encounter_id: u32, - ) -> CreatureLoot { + ) -> Option { let (loot_method, loot_master, round_robin_player) = self.represented_creature_loot_group_state_like_cpp(loot_owner_guid); let coins = self.represented_money_loot_with_rate_like_cpp( @@ -5049,8 +7616,9 @@ impl WorldSession { Vec::new() }); - CreatureLoot { - loot_guid: represented_loot_object_guid_like_cpp(creature_guid), + let loot_guid = self.next_represented_loot_object_guid_like_cpp(creature_guid)?; + Some(CreatureLoot { + loot_guid, coins, unlooted_count: 0, loot_type: LOOT_TYPE_CORPSE_LIKE_CPP, @@ -5063,7 +7631,131 @@ impl WorldSession { allowed_looters: Vec::new(), items, looted_by_player: false, + }) + } + + /// C++ `Unit::Kill` first resolves every tap-list GUID through + /// `ObjectAccessor::GetPlayer(*creature, guid)`. Only connected players in + /// the creature's exact map instance receive an overworld personal pool. + fn represented_connected_creature_tappers_like_cpp( + &self, + tappers: &[ObjectGuid], + ) -> Vec { + let current_player = self.player_guid(); + let map_id = self.player_map_id_like_cpp(); + let instance_id = self + .current_canonical_player_map_key_like_cpp() + .map(|key| key.instance_id) + .unwrap_or(0); + let registry = self.player_registry(); + let mut connected = tappers + .iter() + .copied() + .filter(|tapper| { + if !tapper.is_player() { + return false; + } + if Some(*tapper) == current_player { + return true; + } + registry + .and_then(|registry| registry.get(tapper)) + .is_some_and(|player| { + player.is_in_world + && player.map_id == map_id + && player.instance_id == instance_id + }) + }) + .collect::>(); + connected.sort_unstable_by_key(|guid| (guid.high_value(), guid.low_value())); + connected.dedup(); + connected + } + + fn represented_dungeon_trash_looter_like_cpp( + &self, + connected_tappers: &[ObjectGuid], + ) -> ObjectGuid { + let selected = + if let (Some(group_guid), Some(registry)) = (self.group_guid, self.group_registry()) { + registry + .get(&group_guid) + .map(|group| group.looter_guid_like_cpp()) + .filter(|looter| connected_tappers.contains(looter)) + } else { + None + }; + selected.unwrap_or(connected_tappers[0]) + } + + fn advance_represented_dungeon_trash_looter_like_cpp(&self, connected_tappers: &[ObjectGuid]) { + let (Some(group_guid), Some(registry)) = (self.group_guid, self.group_registry()) else { + return; + }; + if let Some(mut group) = registry.get_mut(&group_guid) { + let _ = group.update_looter_guid_like_cpp(connected_tappers.iter().copied(), false); + } + } + + /// Generate one independently rolled C++ personal `Loot` per supplied + /// player. The caller chooses the ownership set for overworld tappers, + /// encounter-eligible dungeon tappers, or the single dungeon-trash + /// selected looter. Every pool is constructed without a Group and remains + /// an object-owned per-view source of truth. + #[allow(clippy::too_many_arguments)] + async fn generate_represented_creature_personal_loot_like_cpp( + &mut self, + creature_guid: ObjectGuid, + _level: u8, + entry: u32, + loot_id: u32, + gold_min: u32, + gold_max: u32, + dungeon_encounter_id: u32, + tappers: &[ObjectGuid], + ) -> Option> { + let mut personal = HashMap::with_capacity(tappers.len()); + for tapper in tappers { + let coins = self.represented_money_loot_with_rate_like_cpp( + gold_min, + gold_max, + self.loot_drop_rates_like_cpp().money, + ); + let items = self + .generate_represented_creature_loot_items_for_player_like_cpp(loot_id, *tapper) + .await + .unwrap_or_else(|| { + if loot_id != 0 { + debug!( + entry, + loot_id, + tapper = ?tapper, + "creature personal loot template unavailable for represented overworld corpse" + ); + } + Vec::new() + }); + let loot_guid = self.next_represented_loot_object_guid_like_cpp(creature_guid)?; + let mut loot = CreatureLoot { + loot_guid, + coins, + unlooted_count: 0, + loot_type: LOOT_TYPE_CORPSE_LIKE_CPP, + dungeon_encounter_id, + loot_method: 0, + loot_master: ObjectGuid::EMPTY, + round_robin_player: ObjectGuid::EMPTY, + player_ffa_items: Vec::new(), + players_looting: Vec::new(), + allowed_looters: Vec::new(), + items, + looted_by_player: false, + }; + mark_loot_allowed_for_player_like_cpp(&mut loot, *tapper); + rebuild_represented_personal_loot_counts_preserving_consumed_like_cpp(&mut loot); + personal.insert(*tapper, loot); } + Some(personal) } fn represented_creature_loot_group_state_like_cpp( @@ -5087,9 +7779,19 @@ impl WorldSession { &mut self, loot_id: u32, ) -> Option> { - if loot_id == 0 { - return Some(Vec::new()); - } + let player_guid = self.player_guid().unwrap_or(ObjectGuid::EMPTY); + self.generate_represented_creature_loot_items_for_player_like_cpp(loot_id, player_guid) + .await + } + + async fn generate_represented_creature_loot_items_for_player_like_cpp( + &mut self, + loot_id: u32, + player_guid: ObjectGuid, + ) -> Option> { + if loot_id == 0 { + return Some(Vec::new()); + } let mut rng = self.represented_runtime_subrng_like_cpp(); let stores = self.loot_stores()?; @@ -5132,8 +7834,9 @@ impl WorldSession { }, |item| self.item_drop_rate_like_cpp(item.item_id), |context| { - self.represented_creature_loot_item_allowed_like_cpp( + self.represented_creature_loot_item_allowed_for_player_like_cpp( context, + player_guid, &condition_rows, &condition_references, &addon_metadata, @@ -5878,21 +8581,21 @@ impl WorldSession { guid: ObjectGuid, allowed: &[AccessorObjectKind], ) -> Option { + let map_key = self + .canonical_object_lookup_map_key_like_cpp(u32::from(self.player_map_id_like_cpp()))?; let manager = self.canonical_map_manager.as_ref()?; let manager = manager.lock().ok()?; - let map = manager - .find_map(u32::from(self.player_map_id_like_cpp()), 0)? - .map(); + let map = manager.find_map(map_key.map_id, map_key.instance_id)?.map(); map.map_object_by_kind(guid, allowed) .map(|object| object.position()) } fn canonical_gameobject_owner_for_loot_like_cpp(&self, guid: ObjectGuid) -> Option { + let map_key = self + .canonical_object_lookup_map_key_like_cpp(u32::from(self.player_map_id_like_cpp()))?; let manager = self.canonical_map_manager.as_ref()?; let manager = manager.lock().ok()?; - let map = manager - .find_map(u32::from(self.player_map_id_like_cpp()), 0)? - .map(); + let map = manager.find_map(map_key.map_id, map_key.instance_id)?.map(); let owner_guid = map.get_typed_game_object(guid)?.owner_guid(); (!owner_guid.is_empty()).then_some(owner_guid) } @@ -5901,20 +8604,18 @@ impl WorldSession { &mut self, corpse_guid: ObjectGuid, ) -> bool { - let map_id = u32::from(self.player_map_id_like_cpp()); + let Some(map_key) = + self.canonical_object_lookup_map_key_like_cpp(u32::from(self.player_map_id_like_cpp())) + else { + return false; + }; let Some(manager) = self.canonical_map_manager.as_ref().cloned() else { return false; }; let Ok(mut manager) = manager.lock() else { return false; }; - let mut instance_id = None; - manager.do_for_all_maps_with_map_id(map_id, |managed| { - if instance_id.is_none() && managed.map().get_typed_corpse(corpse_guid).is_some() { - instance_id = Some(managed.instance_id()); - } - }); - let Some(map) = manager.find_map_mut(map_id, instance_id.unwrap_or(0)) else { + let Some(map) = manager.find_map_mut(map_key.map_id, map_key.instance_id) else { return false; }; let Some(corpse) = map.map_mut().get_typed_corpse_mut(corpse_guid) else { @@ -5939,6 +8640,7 @@ impl WorldSession { gold_max: creature.gold_max(), dungeon_encounter_id: creature.dungeon_encounter_id(), tappers: creature.creature.tap_list().to_vec(), + loot_lifecycle_revision: creature.creature.loot_lifecycle_revision_like_cpp(), }) } @@ -6108,7 +8810,9 @@ impl WorldSession { &mut self, guid: ObjectGuid, player_guid: ObjectGuid, - fully_looted: bool, + selected_pool_looted: bool, + mut whole_object_fully_looted: bool, + authoritative_release: Option<&AuthoritativeLootReleaseLikeCpp>, ) { let go_type = self .represented_gameobject_use_states @@ -6132,13 +8836,50 @@ impl WorldSession { .represented_gameobject_use_states .get(&guid) .and_then(|state| state.fishing_hole_max_opens); - let canonical_fishing_hole_use_count_after_release = (go_type - == Some(GAMEOBJECT_TYPE_FISHING_HOLE)) - .then(|| self.add_use_and_get_canonical_gameobject_use_count_like_cpp(guid)) - .flatten(); + let canonical_fishing_hole_release = (go_type == Some(GAMEOBJECT_TYPE_FISHING_HOLE)) + .then(|| { + self.release_canonical_fishing_hole_like_cpp( + guid, + represented_fishing_hole_max_opens, + ) + }) + .flatten(); + let canonical_fishing_hole_use_count_after_release = canonical_fishing_hole_release + .as_ref() + .map(|(use_count, _, _)| *use_count); + + let guarded_global_transition_attempted = selected_pool_looted + && whole_object_fully_looted + && !matches!( + go_type, + Some(GAMEOBJECT_TYPE_FISHING_NODE) + | Some(GAMEOBJECT_TYPE_FISHING_HOLE) + | Some(GAMEOBJECT_TYPE_GATHERING_NODE) + ) + && authoritative_release.is_some(); + let guarded_global_transition = authoritative_release + .filter(|_| guarded_global_transition_attempted) + .and_then(|release| { + self.set_canonical_gameobject_loot_state_if_fully_looted_observation_like_cpp( + guid, + &release.authority, + release.object_generation, + release.lifecycle_revision, + LootState::JustDeactivated, + None, + represented_chest_restock_time_secs, + false, + ) + }); + if guarded_global_transition_attempted && guarded_global_transition.is_none() { + // An upsert/install/replacement won the serialization point after + // close. Its new pool must keep the object globally active. + whole_object_fully_looted = false; + } let canonical_loot_state_request = match go_type { Some(GAMEOBJECT_TYPE_FISHING_NODE) => Some((LootState::JustDeactivated, None, false)), + Some(GAMEOBJECT_TYPE_FISHING_HOLE) if canonical_fishing_hole_release.is_some() => None, Some(GAMEOBJECT_TYPE_FISHING_HOLE) => { let use_count_after_release = canonical_fishing_hole_use_count_after_release .unwrap_or(represented_personal_loot_uses_after_release); @@ -6151,11 +8892,15 @@ impl WorldSession { }; Some((state, None, false)) } - Some(GAMEOBJECT_TYPE_GATHERING_NODE) if fully_looted => None, - _ if fully_looted => Some((LootState::JustDeactivated, None, false)), + Some(GAMEOBJECT_TYPE_GATHERING_NODE) if selected_pool_looted => None, + _ if guarded_global_transition_attempted => None, + _ if selected_pool_looted && whole_object_fully_looted => { + Some((LootState::JustDeactivated, None, false)) + } + _ if selected_pool_looted => None, _ => Some((LootState::Activated, Some(player_guid), true)), }; - let canonical_loot_state_updated = canonical_loot_state_request.is_some_and( + let requested_loot_state_outcome = canonical_loot_state_request.and_then( |(loot_state, unit_guid, shared_loot_is_changed_like_cpp)| { self.set_canonical_gameobject_loot_state_like_cpp( guid, @@ -6164,18 +8909,33 @@ impl WorldSession { represented_chest_restock_time_secs, shared_loot_is_changed_like_cpp, ) - .is_some_and(|outcome| { - outcome.status == wow_map::map::GameObjectSetLootStateStatusLikeCpp::Updated - }) }, ); + let canonical_applied_loot_state = if guarded_global_transition.is_some() { + Some((LootState::JustDeactivated, None)) + } else if let Some((_, state, _)) = canonical_fishing_hole_release.as_ref() { + Some((*state, None)) + } else { + canonical_loot_state_request.map(|(state, unit_guid, _)| (state, unit_guid)) + }; + let canonical_loot_state_updated = guarded_global_transition + .as_ref() + .or_else(|| { + canonical_fishing_hole_release + .as_ref() + .map(|(_, _, outcome)| outcome) + }) + .or(requested_loot_state_outcome.as_ref()) + .is_some_and(|outcome| { + outcome.status == wow_map::map::GameObjectSetLootStateStatusLikeCpp::Updated + }); let state = self .represented_gameobject_use_states .entry(guid) .or_default(); if canonical_loot_state_updated { - if let Some((loot_state, unit_guid, _)) = canonical_loot_state_request { + if let Some((loot_state, unit_guid)) = canonical_applied_loot_state { state.loot_state = Some(loot_state); state.loot_state_unit_guid = unit_guid.unwrap_or(ObjectGuid::EMPTY); if loot_state == LootState::Activated @@ -6209,9 +8969,10 @@ impl WorldSession { }; state.loot_state_unit_guid = ObjectGuid::EMPTY; } - Some(GAMEOBJECT_TYPE_GATHERING_NODE) if fully_looted => {} + Some(GAMEOBJECT_TYPE_GATHERING_NODE) if selected_pool_looted => {} Some(GAMEOBJECT_TYPE_CHEST) - if fully_looted + if selected_pool_looted + && whole_object_fully_looted && state.chest_consumable == Some(false) && state .chest_personal_loot_id @@ -6226,10 +8987,11 @@ impl WorldSession { state.chest_restock_until = Some(Instant::now() + Duration::from_secs(u64::from(restock_secs))); } - _ if fully_looted => { + _ if selected_pool_looted && whole_object_fully_looted => { state.loot_state = Some(LootState::JustDeactivated); state.loot_state_unit_guid = ObjectGuid::EMPTY; } + _ if selected_pool_looted => {} _ => { state.loot_state = Some(LootState::Activated); state.loot_state_unit_guid = player_guid; @@ -6251,10 +9013,11 @@ impl WorldSession { state.personal_loot_uses = canonical_fishing_hole_use_count_after_release .unwrap_or(represented_personal_loot_uses_after_release); } - if go_type == Some(GAMEOBJECT_TYPE_GATHERING_NODE) { + if go_type == Some(GAMEOBJECT_TYPE_GATHERING_NODE) && selected_pool_looted { state.go_state = Some(GoState::Active); } if go_type == Some(GAMEOBJECT_TYPE_CHEST) + && selected_pool_looted && state.chest_consumable == Some(false) && state .chest_personal_loot_id @@ -6374,26 +9137,231 @@ impl WorldSession { } } - pub(crate) fn close_active_loot_windows_like_cpp(&mut self, player_guid: ObjectGuid) { - let mut active_owners: Vec = - self.active_loot_view_owners.iter().copied().collect(); - if active_owners.is_empty() && !self.active_loot_guid.is_empty() { - active_owners.push(self.active_loot_guid); + /// Publishes successful durable loot transactions that outlived their + /// packet waiter. This replays committed Item-owned money or item grants + /// into runtime state before disconnect can persist a stale snapshot. C++ + /// auto-releases only when `Loot::isLooted()` (zero coins and no visible + /// items) and the owner GUID is an Item. + pub(crate) async fn apply_pending_durable_item_loot_completions_like_cpp(&mut self) { + self.apply_pending_durable_item_loot_completions_with_objective_drain_like_cpp(true) + .await; + } + + pub(crate) async fn apply_pending_durable_item_loot_completions_with_objective_drain_like_cpp( + &mut self, + drain_money_objectives: bool, + ) { + let completions = self.take_durable_item_loot_completions_like_cpp(); + for completion in completions { + if let Some(fanout) = completion.item_fanout.as_ref() { + let _ = self.publish_durable_loot_item_fanout_like_cpp(fanout); + } + let requires_runtime_recovery = + !completion.runtime_inventory_applied.load(Ordering::Acquire); + let targets_current_player = self.player_guid() == Some(completion.player_guid); + + if let Some(applied_delta) = completion.durable_item_money_applied_amount { + let apply_balance = targets_current_player + && completion + .durable_item_money_balance_applied + .as_ref() + .is_some_and(|applied| { + applied + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + }); + let publish = targets_current_player + && completion + .runtime_inventory_applied + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok(); + if apply_balance { + let old_money = self.player_gold_like_cpp(); + let new_money = old_money + .checked_add(applied_delta) + .filter(|money| *money <= MAX_MONEY_AMOUNT) + .unwrap_or(old_money); + self.set_player_gold_like_cpp(new_money); + if old_money != new_money { + self.enqueue_represented_quest_objective_progress_like_cpp( + RepresentedQuestObjectiveProgressEventLikeCpp::MoneyChanged { + old_money, + new_money, + }, + ); + } + } + if publish { + self.represented_notify_money_removed_like_cpp(completion.owner_guid); + self.send_packet(&LootMoneyNotify { + money: completion + .durable_item_money_notified_amount + .expect("durable Item money completion retains notification amount"), + money_mod: 0, + sole_looter: true, + }); + + let fully_looted = + self.loot_table + .get_mut(&completion.owner_guid) + .is_some_and(|loot| { + loot.coins = 0; + loot_is_looted_like_cpp(loot) + }); + if fully_looted { + // Source Item destruction remains owned by the normal + // C++ release phase; the completion only makes the + // already-durable money mutation visible first. + self.do_loot_release_owner_like_cpp( + completion.owner_guid, + completion.player_guid, + ) + .await; + } + } + if drain_money_objectives && (apply_balance || publish) { + self.drain_represented_quest_objective_progress_like_cpp() + .await; + } + continue; + } + + if targets_current_player && completion.item_owner_auto_release { + debug_assert!(completion.owner_guid.is_item()); + let removal = self + .loot_table + .get_mut(&completion.owner_guid) + .and_then(|loot| { + let entry = loot + .items + .iter() + .find(|entry| entry.loot_list_id == completion.loot_list_id)?; + let free_for_all = entry.flags.freeforall; + let newly_removed = !loot_item_is_looted_for_player_like_cpp( + loot, + entry, + completion.player_guid, + ); + let loot_obj = loot.loot_guid; + mark_loot_item_looted_for_player_like_cpp( + loot, + completion.loot_list_id, + completion.player_guid, + ); + Some(( + loot_obj, + free_for_all, + newly_removed, + loot_is_looted_like_cpp(loot), + )) + }); + + if let Some((loot_obj, free_for_all, newly_removed, fully_looted)) = removal { + if newly_removed { + if free_for_all { + self.send_packet(&LootRemoved { + owner: completion.owner_guid, + loot_obj, + loot_list_id: completion.loot_list_id, + }); + } else { + self.represented_notify_loot_item_removed_like_cpp( + completion.owner_guid, + completion.loot_list_id, + ); + } + } + + if fully_looted { + self.do_loot_release_owner_like_cpp( + completion.owner_guid, + completion.player_guid, + ) + .await; + } + } + } else if targets_current_player && requires_runtime_recovery { + // The detached worker already committed the authority. Refresh + // the packet cache so disconnect's DoLootReleaseAll observes + // the consumed claim rather than the pre-commit session copy. + self.refresh_owned_loot_summary_like_cpp(completion.owner_guid); + let _ = self.reconcile_represented_loot_cache_like_cpp( + completion.owner_guid, + completion.player_guid, + ); + } + + if requires_runtime_recovery { + // SQL committed after the packet waiter disappeared, before + // its synchronous runtime inventory publication. Do not let + // the player operate on a stale slot; the persisted grant and + // source consumption are reconstructed on the next login. + self.kick("durable loot item completed after handler cancellation; relog required"); + } } - active_owners.sort_by_key(|guid| (guid.high_value(), guid.low_value())); + } - for owner_guid in active_owners { - if let Some(loot) = self.loot_table.get_mut(&owner_guid) { - loot.players_looting.retain(|looter| *looter != player_guid); + pub(crate) async fn wait_for_active_loot_persistence_like_cpp(&mut self) { + let mut authorities = Vec::::new(); + for authority in self.active_loot_view_authorities_like_cpp.values() { + if authorities + .iter() + .any(|existing| existing.shares_storage_like_cpp(authority)) + { + continue; } - self.send_packet(&SLootRelease { - loot_obj: owner_guid, - owner: player_guid, - }); - self.clear_active_loot_guid_if(owner_guid); + authorities.push(authority.clone()); + } + for authority in authorities { + authority.wait_for_persisting_claims_like_cpp().await; + } + self.wait_for_durable_item_loot_persistence_like_cpp().await; + self.apply_pending_durable_item_loot_completions_like_cpp() + .await; + } + + /// Mirrors the observable side of C++ `Loot::~Loot`: once the exact + /// object-owned allocation behind an open view is retired, detached, or + /// replaced, the next session tick releases that stale client window. + /// Each session owns its socket, so global object destruction is fanned + /// out cooperatively without holding a map lock across network work. + pub(crate) fn close_retired_active_loot_windows_like_cpp(&mut self, player_guid: ObjectGuid) { + let mut stale_owners = self + .active_loot_view_authorities_like_cpp + .iter() + .filter_map(|(owner_guid, authority)| { + let generation = self + .active_loot_view_generations_like_cpp + .get(owner_guid) + .copied(); + let still_open = generation.is_some_and(|generation| { + authority + .snapshot_for_player_like_cpp(player_guid) + .is_some_and(|snapshot| snapshot.generation == generation) + }); + (!still_open).then_some(*owner_guid) + }) + .collect::>(); + stale_owners.sort_unstable_by_key(|guid| (guid.high_value(), guid.low_value())); + + for owner_guid in stale_owners { + self.close_stale_active_loot_view_like_cpp(owner_guid, player_guid); } } + fn close_stale_active_loot_view_like_cpp( + &mut self, + owner_guid: ObjectGuid, + player_guid: ObjectGuid, + ) { + self.discard_represented_personal_loot_cache_for_player_like_cpp(owner_guid, player_guid); + self.send_packet(&SLootRelease { + loot_obj: owner_guid, + owner: player_guid, + }); + self.clear_active_loot_guid_if(owner_guid); + } + async fn do_loot_release_owner_like_cpp( &mut self, owner_guid: ObjectGuid, @@ -6405,29 +9373,79 @@ impl WorldSession { return false; } - // Check if loot is fully taken (all items picked up). - // Coins are auto-consumed when the loot window opens (sent in LootResponse), - // so we only check items here. - // C# ref: `loot.IsLooted()` → no more non-taken items. - let Some(loot) = self.loot_table.get(&owner_guid) else { + let authoritative_release = if let Some(authority) = + self.prepare_owned_loot_authority_for_active_request_like_cpp(owner_guid, player_guid) + { + if !self + .active_loot_view_authorities_like_cpp + .get(&owner_guid) + .is_some_and(|opened| opened.shares_storage_like_cpp(&authority)) + { + self.close_stale_active_loot_view_like_cpp(owner_guid, player_guid); + return true; + } + let Some(active_generation) = self + .active_loot_view_generations_like_cpp + .get(&owner_guid) + .copied() + else { + self.close_stale_active_loot_view_like_cpp(owner_guid, player_guid); + return true; + }; + let Some(close) = + authority.close_viewer_if_generation_like_cpp(active_generation, player_guid) + else { + self.close_stale_active_loot_view_like_cpp(owner_guid, player_guid); + return true; + }; + Some(AuthoritativeLootReleaseLikeCpp { + authority, + selected_generation: active_generation, + loot: close.snapshot.loot, + whole_object_fully_looted: close.whole_object_fully_looted, + whole_object_fully_skinned: close.whole_object_fully_skinned, + object_generation: close.object_generation, + lifecycle_revision: close.lifecycle_revision, + }) + } else { + None + }; + + if authoritative_release.is_none() + && (owner_guid.is_creature_or_vehicle() || owner_guid.is_game_object()) + && !represented_local_loot_fixture_allowed_like_cpp() + { + self.close_stale_active_loot_view_like_cpp(owner_guid, player_guid); + return true; + } + + // C++ `Loot::isLooted()` requires both zero gold and zero remaining + // player-visible item count. + let Some(loot) = authoritative_release + .as_ref() + .map(|release| &release.loot) + .or_else(|| self.loot_table.get(&owner_guid)) + else { return false; }; - let represented_fully_looted = loot_is_looted_like_cpp(loot); + let selected_pool_looted = loot_is_looted_like_cpp(loot); let represented_loot_type = loot.loot_type; - let fully_looted = if owner_guid.is_game_object() { + let whole_object_fully_looted = if let Some(release) = authoritative_release.as_ref() { + release.whole_object_fully_looted + } else if owner_guid.is_game_object() { self.canonical_gameobject_fully_looted_after_represented_sync_like_cpp( owner_guid, player_guid, - represented_fully_looted, + selected_pool_looted, ) } else if owner_guid.is_creature_or_vehicle() { self.canonical_creature_fully_looted_after_represented_sync_like_cpp( owner_guid, player_guid, - represented_fully_looted, + selected_pool_looted, ) } else { - represented_fully_looted + selected_pool_looted }; if let Some(loot) = self.loot_table.get_mut(&owner_guid) { @@ -6446,57 +9464,135 @@ impl WorldSession { if !self .represented_gameobject_can_autostore_loot_item_like_cpp(owner_guid, player_guid) { + if authoritative_release.is_some() { + self.discard_represented_personal_loot_cache_for_player_like_cpp( + owner_guid, + player_guid, + ); + } return true; } self.apply_represented_gameobject_loot_release_like_cpp( owner_guid, player_guid, - fully_looted, + selected_pool_looted, + whole_object_fully_looted, + authoritative_release.as_ref(), ); let _ = self.queue_chest_gameobject_state_refresh_for_same_map_like_cpp(owner_guid); - if !fully_looted { - return true; - } - - self.hide_represented_gameobject_for_player_after_loot_release_like_cpp(owner_guid); let go_type = self .represented_gameobject_use_states .get(&owner_guid) .and_then(|state| state.go_type) .map(u32::from); + let selected_release_branch = selected_pool_looted + || matches!( + go_type, + Some(GAMEOBJECT_TYPE_FISHING_NODE) | Some(GAMEOBJECT_TYPE_FISHING_HOLE) + ); + if !selected_release_branch { + if authoritative_release.is_some() { + self.discard_represented_personal_loot_cache_for_player_like_cpp( + owner_guid, + player_guid, + ); + } + return true; + } + + self.hide_represented_gameobject_for_player_after_loot_release_like_cpp(owner_guid); if go_type == Some(GAMEOBJECT_TYPE_GATHERING_NODE) { self.send_gathering_node_loot_release_dynamic_flags_update_like_cpp(owner_guid); } + if authoritative_release.is_some() { + self.discard_represented_personal_loot_cache_for_player_like_cpp( + owner_guid, + player_guid, + ); + } else { + self.loot_table.remove(&owner_guid); + } + return true; + } + + if owner_guid.is_item() + && matches!( + represented_loot_type, + LOOT_TYPE_PROSPECTING_LIKE_CPP | LOOT_TYPE_MILLING_LIKE_CPP + ) + { + // C++ always clears the generated Loot and consumes at most five + // source items for prospecting/milling, even if the window closes + // before every generated entry was taken. + self.clear_active_loot_guid_if(owner_guid); self.loot_table.remove(&owner_guid); + self.update_inventory_item_object_like_cpp(owner_guid, |item| { + item.set_loot_generated(false); + }); + self.destroy_direct_item_count_after_loot_release_like_cpp(owner_guid, Some(5)) + .await; return true; } - if owner_guid.is_item() && !fully_looted { + if owner_guid.is_item() && !selected_pool_looted { self.clear_active_loot_guid_if(owner_guid); + let item_has_loot_flag = self + .inventory_items_like_cpp() + .values() + .find(|item| item.guid == owner_guid) + .and_then(|item| self.item_template_flags(item.entry_id)) + .map(|flags| flags.contains(wow_constants::ItemFlags::HAS_LOOT)); + if item_has_loot_flag == Some(false) { + self.destroy_fully_looted_direct_item(owner_guid).await; + } return true; } self.clear_active_loot_guid_if(owner_guid); - if !fully_looted { - let round_robin_released = self.loot_table.get_mut(&owner_guid).is_some_and(|loot| { - if loot.round_robin_player == player_guid { - loot.round_robin_player = ObjectGuid::EMPTY; - true - } else { - false - } - }); + if !selected_pool_looted { + let round_robin_released = if let Some(release) = authoritative_release.as_ref() { + release + .authority + .clear_round_robin_if_generation_like_cpp( + release.selected_generation, + player_guid, + ) + .is_some_and(|outcome| { + self.loot_table.insert(owner_guid, outcome.snapshot.loot); + outcome.cleared + }) + } else { + self.loot_table.get_mut(&owner_guid).is_some_and(|loot| { + if loot.round_robin_player == player_guid { + loot.round_robin_player = ObjectGuid::EMPTY; + true + } else { + false + } + }) + }; if round_robin_released { self.represented_notify_loot_list_like_cpp(owner_guid); } + if owner_guid.is_creature_or_vehicle() { + let _ = self.mutate_world_creature(owner_guid, |creature| { + creature.force_dynamic_flags_update_like_cpp(); + }); + } + if authoritative_release.is_some() { + self.discard_represented_personal_loot_cache_for_player_like_cpp( + owner_guid, + player_guid, + ); + } return true; } // Remove loot entry from memory once the represented loot is consumed. self.loot_table.remove(&owner_guid); - if owner_guid.is_item() && fully_looted { + if owner_guid.is_item() && selected_pool_looted { self.destroy_fully_looted_direct_item(owner_guid).await; return true; } @@ -6506,34 +9602,64 @@ impl WorldSession { return true; } + // C++ forces the viewer-dependent DynamicFlags field after every + // creature release, including a selected personal pool that completed + // while another pool remains. + let _ = self.mutate_world_creature(owner_guid, |creature| { + creature.force_dynamic_flags_update_like_cpp(); + }); + + if !whole_object_fully_looted { + if authoritative_release.is_some() { + self.discard_represented_personal_loot_cache_for_player_like_cpp( + owner_guid, + player_guid, + ); + } + return true; + } + let corpse_decay_looted_rate = self.loot_drop_rates_like_cpp().corpse_decay_looted; // Start corpse despawn timer if fully looted. - let marked = self - .mutate_world_creature(owner_guid, |creature| { - creature.remove_lootable_dynamic_flag_like_cpp(); - if !creature.is_alive() { - let is_fully_skinned = represented_loot_type == LOOT_TYPE_SKINNING_LIKE_CPP; - let corpse_decay_secs = looted_corpse_decay_secs_like_cpp( - is_fully_skinned, - creature.corpse_delay_secs_like_cpp(), - creature.ignore_corpse_decay_ratio_like_cpp(), - corpse_decay_looted_rate, - ); - if !creature.all_loot_removed_from_corpse_like_cpp( - corpse_decay_looted_rate, - is_fully_skinned, - ) { - // C++ returns without resetting an already-expired - // corpse. The lifecycle mirror must remain expired too. - return None; - } - Some((creature.entry(), corpse_decay_secs)) - } else { - None + let whole_object_fully_skinned = authoritative_release.as_ref().map_or( + represented_loot_type == LOOT_TYPE_SKINNING_LIKE_CPP, + |release| release.whole_object_fully_skinned, + ); + let apply_lifecycle = |creature: &mut crate::map_manager::WorldCreature| { + creature.remove_lootable_dynamic_flag_like_cpp(); + if !creature.is_alive() { + let corpse_decay_secs = looted_corpse_decay_secs_like_cpp( + whole_object_fully_skinned, + creature.corpse_delay_secs_like_cpp(), + creature.ignore_corpse_decay_ratio_like_cpp(), + corpse_decay_looted_rate, + ); + if !creature.all_loot_removed_from_corpse_like_cpp( + corpse_decay_looted_rate, + whole_object_fully_skinned, + ) { + // C++ returns without resetting an already-expired + // corpse. The lifecycle mirror must remain expired too. + return None; } - }) - .flatten(); + Some((creature.entry(), corpse_decay_secs)) + } else { + None + } + }; + let marked = if let Some(release) = authoritative_release.as_ref() { + self.mutate_world_creature_if_fully_looted_observation_like_cpp( + owner_guid, + &release.authority, + release.object_generation, + release.lifecycle_revision, + apply_lifecycle, + ) + } else { + self.mutate_world_creature(owner_guid, apply_lifecycle) + } + .flatten(); if let Some((entry, corpse_decay_secs)) = marked { info!( @@ -6542,251 +9668,652 @@ impl WorldSession { ); } - true - } - - async fn delete_stored_item_money_like_cpp(&self, item_guid: ObjectGuid) { - let Some(char_db) = self.char_db().map(Arc::clone) else { - return; - }; - - let mut stmt = char_db.prepare(CharStatements::DEL_ITEMCONTAINER_MONEY); - stmt.set_u64(0, item_guid.counter() as u64); - if let Err(e) = char_db.execute(&stmt).await { - warn!( - item_guid = item_guid.counter(), - error = %e, - "failed to delete stored item loot money" + if authoritative_release.is_some() { + self.discard_represented_personal_loot_cache_for_player_like_cpp( + owner_guid, + player_guid, ); } + + true } - async fn delete_stored_item_loot_item_like_cpp( + async fn persist_and_consume_stored_item_money_like_cpp( &self, item_guid: ObjectGuid, - item_id: u32, - count: u32, - loot_list_id: u8, - ) { - let Some(char_db) = self.char_db().map(Arc::clone) else { - return; - }; - - let mut stmt = char_db.prepare(CharStatements::DEL_ITEMCONTAINER_ITEM); - stmt.set_u64(0, item_guid.counter() as u64); - stmt.set_u32(1, item_id); - stmt.set_u32(2, count); - stmt.set_u32(3, u32::from(loot_list_id)); - if let Err(e) = char_db.execute(&stmt).await { - warn!( - item_guid = item_guid.counter(), - item_id, - count, - loot_list_id, - error = %e, - "failed to delete stored item loot row" - ); + cached_notified_amount: u64, + ) -> Option<(Arc, Arc, u64, u64)> { + let (worker, balance_applied, publication_applied) = self + .spawn_stored_item_money_persistence_worker_like_cpp( + item_guid, + cached_notified_amount, + )?; + match worker.await { + Ok(Ok((applied_delta, notified_amount))) => Some(( + balance_applied, + publication_applied, + applied_delta, + notified_amount, + )), + Ok(Err(error)) => { + warn!( + item_guid = item_guid.counter(), + ?error, + "failed to atomically persist and consume stored item loot money" + ); + None + } + Err(error) => { + warn!( + item_guid = item_guid.counter(), + ?error, + "stored item loot-money persistence worker terminated" + ); + None + } } } - async fn store_direct_loot_item_like_cpp( + fn spawn_stored_item_money_persistence_worker_like_cpp( + &self, + item_guid: ObjectGuid, + cached_notified_amount: u64, + ) -> Option<( + tokio::task::JoinHandle>, + Arc, + Arc, + )> { + let Some(player_guid) = self.player_guid() else { + return None; + }; + let test_result = self.loot_money_persistence_test_result_for_worker_like_cpp(); + let char_db = if test_result.is_some() { + None + } else { + Some(self.char_db().map(Arc::clone)?) + }; + let test_current_money = self.player_gold_like_cpp(); + let balance_applied = Arc::new(AtomicBool::new(false)); + let publication_applied = Arc::new(AtomicBool::new(false)); + let mut item_persistence_guard = self.begin_durable_item_loot_persistence_like_cpp(); + let money_persistence_tracker = self.durable_loot_money_persistence_tracker_like_cpp(); + let mut money_persistence_guard = money_persistence_tracker.begin_like_cpp().ok()?; + let command_tx = self.session_command_tx(); + let worker_balance_applied = Arc::clone(&balance_applied); + let worker_publication_applied = Arc::clone(&publication_applied); + let worker = tokio::spawn(async move { + let _money_mutation_lock = money_persistence_tracker + .lock_money_mutation_like_cpp() + .await; + let (before, after, applied_delta, notified_amount) = if let Some(success) = test_result + { + tokio::task::yield_now().await; + if !success { + return Err(LootMoneyPersistenceErrorLikeCpp::MissingCharacterDatabase); + } + let (after, applied_delta) = + loot_money_durable_outcome_like_cpp(test_current_money, cached_notified_amount); + ( + test_current_money, + after, + applied_delta, + cached_notified_amount, + ) + } else { + let char_db = char_db.expect("production stored-money worker has a database"); + let attempt = retry_deadlocked_operation_like_cpp( + || { + attempt_stored_item_money_transaction_like_cpp( + char_db.as_ref(), + player_guid, + item_guid, + cached_notified_amount, + ) + }, + stored_item_money_attempt_is_deadlock_like_cpp, + ) + .await; + let outcome = match attempt { + Ok(outcome) => outcome, + Err(StoredItemMoneyAttemptErrorLikeCpp::DefinitelyRolledBack(error)) => { + return Err(error); + } + Err(StoredItemMoneyAttemptErrorLikeCpp::CommitOutcomeUnknown { + error, + outcome, + }) => match reconcile_stored_item_money_commit_like_cpp( + char_db.as_ref(), + player_guid, + item_guid, + outcome, + ) + .await + { + Ok(StoredItemMoneyCommitReconciliationLikeCpp::Committed) => outcome, + Ok(StoredItemMoneyCommitReconciliationLikeCpp::RolledBack) => { + return Err(LootMoneyPersistenceErrorLikeCpp::Database( + DatabaseError::Transaction( + "stored Item money COMMIT was reconciled as rolled back" + .to_string(), + ), + )); + } + Ok(StoredItemMoneyCommitReconciliationLikeCpp::Indeterminate) | Err(_) => { + money_persistence_guard.mark_indeterminate_like_cpp(); + queue_stored_item_money_indeterminate_kick_like_cpp(&command_tx); + return Err(LootMoneyPersistenceErrorLikeCpp::CommitOutcomeUnknown( + error, + )); + } + }, + }; + ( + outcome.before, + outcome.after, + outcome.applied_delta, + outcome.notified_amount, + ) + }; + + money_persistence_guard.commit_like_cpp( + wow_network::DurableLootMoneyCompletionLikeCpp { + durable_money_before: before, + durable_money_after: after, + durable_applied_amount: applied_delta, + applied: Arc::clone(&worker_balance_applied), + }, + ); + item_persistence_guard.mark_committed_like_cpp(DurableItemLootCompletionLikeCpp { + owner_guid: item_guid, + loot_list_id: 0, + player_guid, + item_owner_auto_release: false, + durable_item_money_applied_amount: Some(applied_delta), + durable_item_money_notified_amount: Some(notified_amount), + durable_item_money_balance_applied: Some(Arc::clone(&worker_balance_applied)), + item_fanout: None, + runtime_inventory_applied: Arc::clone(&worker_publication_applied), + }); + Ok((applied_delta, notified_amount)) + }); + Some((worker, balance_applied, publication_applied)) + } + + async fn store_direct_loot_item_like_cpp( &mut self, loot_entry: &LootEntry, dungeon_encounter_id: u32, ) -> bool { - let item_id = loot_entry.item_id; - let count = loot_entry.quantity; + self.store_direct_loot_item_with_source_like_cpp( + loot_entry, + dungeon_encounter_id, + None, + None, + None, + ) + .await + } + + /// Apply the item state established by C++ `Player::StoreNewItem` and + /// `_StoreItem` before the item is persisted or sent to the client. + fn apply_stored_new_item_flags_like_cpp(&self, item_id: u32, slot: u8, item: &mut Item) { + if let Some(template) = self.item_storage_template(item_id) { + item.set_bonding(template.bonding); + } + item.set_item_flag(ItemFieldFlags::NEW_ITEM); + item.bind_if_stored(is_bag_pos(make_item_pos(INVENTORY_SLOT_BAG_0, slot))); + } + + fn stored_new_item_dynamic_flags_like_cpp(&self, item_id: u32, slot: u8) -> u32 { + let mut item = Item::new(0); + self.apply_stored_new_item_flags_like_cpp(item_id, slot, &mut item); + item.item_flags_bits() + } + + /// C++ `_StoreItem` binds the destination object before incrementing an + /// existing stack. Unlike `StoreNewItem`, that historical object must not + /// acquire `ITEM_FIELD_FLAG_NEW_ITEM` merely because more items arrived. + fn stored_existing_item_dynamic_flags_like_cpp( + &self, + item_id: u32, + slot: u8, + existing: &Item, + ) -> u32 { + let mut planned = existing.clone(); + if let Some(template) = self.item_storage_template(item_id) { + planned.set_bonding(template.bonding); + } + planned.bind_if_stored(is_bag_pos(make_item_pos(INVENTORY_SLOT_BAG_0, slot))); + planned.item_flags_bits() + } + + /// Queue count and any binding transition in one transaction. Runtime + /// state is deliberately updated only after this transaction commits. + fn append_existing_loot_stack_persistence_like_cpp( + char_db: &CharacterDatabase, + transaction: &mut SqlTransaction, + db_guid: u64, + new_count: u32, + dynamic_flags: Option, + ) { + let mut update_count = char_db.prepare(CharStatements::UPD_ITEM_INSTANCE_COUNT); + update_count.set_u32(0, new_count); + update_count.set_u64(1, db_guid); + transaction.append_expect_rows_affected(update_count, 1); + + if let Some(dynamic_flags) = dynamic_flags { + let mut update_flags = char_db.prepare(CharStatements::UPD_ITEM_INSTANCE_FLAGS); + update_flags.set_u32(0, dynamic_flags); + update_flags.set_u64(1, db_guid); + transaction.append_expect_rows_affected(update_flags, 1); + } + } + + /// Persist the complete result of one group-roll disenchant as a single + /// durable award. + /// + /// C++ `LootRoll::Finish` first materializes a temporary + /// `LOOT_DISENCHANTING` loot and then calls `Loot::AutoStore`. The C++ + /// loop stores each generated material independently, which is unsafe for + /// Rust's concurrently shared object authority: a later failure could + /// reopen the original roll slot after an earlier material was durable. + /// This bounded divergence keeps C++ generation/inventory rules but plans + /// every material before creating one SQL transaction. The detached + /// transaction worker owns the original roll claim through COMMIT. + async fn store_direct_disenchant_batch_like_cpp( + &mut self, + loot_entries: &[LootEntry], + dungeon_encounter_id: u32, + claim: Option<&LootClaimLease>, + claim_commit_context: Option, + ) -> bool { let Some(player_guid) = self.player_guid() else { return false; }; - let Some(char_db) = self.char_db().map(Arc::clone) else { - return false; - }; - let Some((store_result, mut store_dest, _)) = - self.plan_store_new_direct_inventory_item(item_id, count) - else { - self.send_equip_error(InventoryResult::ItemNotFound, None, None, 0, 0); - return false; - }; - if store_result != InventoryResult::Ok { - self.send_equip_error(store_result, None, None, 0, 0); + if loot_entries.is_empty() + || loot_entries + .iter() + .any(|entry| entry.item_id == 0 || entry.quantity == 0) + { return false; } - - let store_random_properties = { - let mut rng = self.represented_runtime_subrng_like_cpp(); - self.generate_loot_store_random_properties_with_rng_like_cpp(item_id, &mut rng) + let durable_item_fanout = match (claim, claim_commit_context) { + (Some(claim), Some(context)) => { + let Some(fanout) = self.prepare_durable_loot_item_fanout_like_cpp(claim, context) + else { + return false; + }; + Some(fanout) + } + (None, None) => None, + _ => return false, }; - if store_dest.iter().any(|dest| { - let bag = (dest.pos >> 8) as u8; - let slot = (dest.pos & 0x00FF) as u8; - bag == u8::from(INVENTORY_SLOT_BAG_0) - && self - .inventory_items_like_cpp() - .get(&slot) - .is_some_and(|existing| { - self.inventory_item_objects_like_cpp() - .get(&existing.guid) - .is_some_and(|item| { - !loot_store_data_can_stack_with_item( - loot_entry, - store_random_properties, - item, - ) - }) - }) - }) { - let Some(compatible_dest) = self.plan_direct_loot_item_preserving_cpp_store_metadata( - loot_entry, - store_random_properties, + #[cfg(test)] + if let Some(grants) = self.loot_item_store_test_grants_like_cpp.clone() { + let success = self.loot_item_store_test_success_like_cpp; + let commit_gate = self.loot_item_store_test_commit_gate_like_cpp.clone(); + let grant_count = loot_entries.len(); + let runtime_inventory_applied = + claim_commit_context.map(|_| Arc::new(AtomicBool::new(false))); + let durable_item_completion = claim_commit_context + .zip(runtime_inventory_applied.as_ref().map(Arc::clone)) + .map(|(context, runtime_inventory_applied)| { + ( + self.begin_durable_item_loot_persistence_like_cpp(), + DurableItemLootCompletionLikeCpp { + owner_guid: context.owner_guid, + loot_list_id: context.loot_list_id, + player_guid: context.player_guid, + item_owner_auto_release: false, + durable_item_money_applied_amount: None, + durable_item_money_notified_amount: None, + durable_item_money_balance_applied: None, + item_fanout: durable_item_fanout.clone(), + runtime_inventory_applied, + }, + ) + }); + let Ok(persistence) = spawn_loot_claim_persistence_worker_like_cpp( + async move { + // Model the asynchronous commit boundary so cancellation + // regressions exercise the same ownership shape as SQL. + tokio::task::yield_now().await; + if let Some(gate) = commit_gate { + gate.notified().await; + } + if !success { + return Err(()); + } + grants.fetch_add(grant_count, Ordering::SeqCst); + Ok(()) + }, + claim.cloned(), + durable_item_completion, ) else { - self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); return false; }; - store_dest = compatible_dest; + if !matches!(persistence.await, Ok(Ok(()))) { + return false; + } + for (slot, entry) in loot_entries.iter().enumerate() { + self.send_loot_item_push_result( + player_guid, + ObjectGuid::EMPTY, + entry, + 0, + 0, + u8::try_from(slot).unwrap_or(0), + entry.quantity, + entry.quantity, + false, + dungeon_encounter_id, + ); + } + if !self.publish_persisted_loot_item_removal_like_cpp( + claim, + claim_commit_context, + durable_item_fanout.as_ref(), + ) { + return false; + } + if let Some(runtime_inventory_applied) = runtime_inventory_applied { + runtime_inventory_applied.store(true, Ordering::Release); + } + return true; } - let mut planned_existing_counts = Vec::<(u8, ObjectGuid, u64, u32, u32)>::new(); - let mut planned_new_stacks = Vec::::new(); + let Some(char_db) = self.char_db().map(Arc::clone) else { + return false; + }; - for dest in store_dest { - let bag = (dest.pos >> 8) as u8; - let slot = (dest.pos & 0x00FF) as u8; - if bag != u8::from(INVENTORY_SLOT_BAG_0) { + // `CanStoreNewItem`'s max-count checks must see all generated stacks + // of the same material, not each temporary LootItem in isolation. + let mut quantity_by_item = HashMap::::new(); + for entry in loot_entries { + let Some(total) = quantity_by_item + .get(&entry.item_id) + .copied() + .unwrap_or(0) + .checked_add(entry.quantity) + else { self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); return false; + }; + quantity_by_item.insert(entry.item_id, total); + } + for (item_id, count) in quantity_by_item { + let Some((store_result, _, _)) = + self.plan_store_new_direct_inventory_item(item_id, count) + else { + self.send_equip_error(InventoryResult::ItemNotFound, None, None, 0, 0); + return false; + }; + if store_result != InventoryResult::Ok { + self.send_equip_error(store_result, None, None, 0, 0); + return false; } + } + + let backpack_end = INVENTORY_SLOT_ITEM_START + .saturating_add(INVENTORY_DEFAULT_SIZE) + .min(INVENTORY_SLOT_ITEM_END); + let mut planned_existing_stacks = Vec::::new(); + let mut planned_new_stacks = Vec::::new(); + let mut planned_grants = Vec::::new(); + for loot_entry in loot_entries { + let random_properties = { + let mut rng = self.represented_runtime_subrng_like_cpp(); + self.generate_loot_store_random_properties_with_rng_like_cpp( + loot_entry.item_id, + &mut rng, + ) + }; let max_stack = self - .item_storage_template(item_id) + .item_storage_template(loot_entry.item_id) .map(|template| template.max_stack_size) .unwrap_or(1) .max(1); - - if let Some(existing) = self.inventory_items_like_cpp().get(&slot) { + let mut remaining = loot_entry.quantity; + let mut existing_pushes = Vec::new(); + let mut new_pushes = Vec::new(); + + // Existing backpack stacks are consumed first, matching the + // direct StoreNewItem path represented in this server. + for slot in INVENTORY_SLOT_ITEM_START..backpack_end { + if remaining == 0 { + break; + } + let Some(existing) = self.inventory_items_like_cpp().get(&slot) else { + continue; + }; + if existing.entry_id != loot_entry.item_id { + continue; + } let Some(existing_object) = self.inventory_item_objects_like_cpp().get(&existing.guid) else { self.send_equip_error(InventoryResult::ItemNotFound, None, None, 0, 0); return false; }; - let base_count = planned_existing_counts + if !loot_store_data_can_stack_with_item( + loot_entry, + random_properties, + existing_object, + ) { + continue; + } + + let current_count = planned_existing_stacks .iter() - .find(|(planned_slot, ..)| *planned_slot == slot) - .map(|(_, _, _, new_count, _)| *new_count) + .find(|planned| planned.slot == slot) + .map(|planned| planned.new_count) .unwrap_or_else(|| existing_object.count()); - let new_count = base_count.saturating_add(dest.count); - if existing.entry_id != item_id - || new_count > max_stack - || !loot_store_data_can_stack_with_item( - loot_entry, - store_random_properties, - existing_object, - ) - { - self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); - return false; + let added_count = max_stack.saturating_sub(current_count).min(remaining); + if added_count == 0 { + continue; } - if let Some(existing_plan) = planned_existing_counts + let new_count = current_count.saturating_add(added_count); + if let Some(planned) = planned_existing_stacks .iter_mut() - .find(|(planned_slot, ..)| *planned_slot == slot) + .find(|planned| planned.slot == slot) { - existing_plan.3 = new_count; - existing_plan.4 = existing_plan.4.saturating_add(dest.count); + planned.new_count = new_count; } else { - planned_existing_counts.push(( + let dynamic_flags = self.stored_existing_item_dynamic_flags_like_cpp( + loot_entry.item_id, slot, - existing.guid, - existing.db_guid, + existing_object, + ); + planned_existing_stacks.push(PlannedDisenchantExistingStack { + slot, + item_guid: existing.guid, + db_guid: existing.db_guid, new_count, - dest.count, - )); + dynamic_flags, + flags_changed: dynamic_flags != existing_object.item_flags_bits(), + }); } - continue; + existing_pushes.push(PlannedDisenchantExistingPush { + slot, + item_guid: existing.guid, + added_count, + new_count, + }); + remaining = remaining.saturating_sub(added_count); } - if let Some(stack) = planned_new_stacks - .iter_mut() - .find(|stack| stack.slot == slot) - { - if stack.entry_id == item_id - && stack.random_properties_id == store_random_properties.id - && stack.random_properties_seed == store_random_properties.seed - && stack.item_context == loot_entry.item_context - && stack.count.saturating_add(dest.count) <= max_stack + // A second generated LootItem for the same material may continue + // a new stack already planned earlier in this same transaction. + for (stack_index, stack) in planned_new_stacks.iter_mut().enumerate() { + if remaining == 0 { + break; + } + if stack.entry_id != loot_entry.item_id + || stack.random_properties_id != random_properties.id + || stack.random_properties_seed != random_properties.seed + || stack.item_context != loot_entry.item_context { - stack.count = stack.count.saturating_add(dest.count); continue; } - self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); - return false; + let added_count = max_stack.saturating_sub(stack.count).min(remaining); + if added_count == 0 { + continue; + } + stack.count = stack.count.saturating_add(added_count); + new_pushes.push(PlannedDisenchantNewPush { + stack_index, + added_count, + new_count: stack.count, + }); + remaining = remaining.saturating_sub(added_count); } - planned_new_stacks.push(PlannedLootNewStack { - slot, - entry_id: item_id, - count: dest.count, - max_durability: self.item_template_max_durability(item_id), - random_properties_id: store_random_properties.id, - random_properties_seed: store_random_properties.seed, - item_context: loot_entry.item_context, + while remaining > 0 { + let Some(slot) = (INVENTORY_SLOT_ITEM_START..backpack_end).find(|slot| { + !self.inventory_items_like_cpp().contains_key(slot) + && !planned_new_stacks.iter().any(|stack| stack.slot == *slot) + }) else { + self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); + return false; + }; + let count = remaining.min(max_stack); + let stack_index = planned_new_stacks.len(); + planned_new_stacks.push(PlannedLootNewStack { + slot, + entry_id: loot_entry.item_id, + count, + max_durability: self.item_template_max_durability(loot_entry.item_id), + dynamic_flags: self + .stored_new_item_dynamic_flags_like_cpp(loot_entry.item_id, slot), + random_properties_id: random_properties.id, + random_properties_seed: random_properties.seed, + item_context: loot_entry.item_context, + }); + new_pushes.push(PlannedDisenchantNewPush { + stack_index, + added_count: count, + new_count: count, + }); + remaining = remaining.saturating_sub(count); + } + + planned_grants.push(PlannedDisenchantGrant { + entry: loot_entry.clone(), + random_properties, + existing_pushes, + new_pushes, }); } - let mut tx = SqlTransaction::new(); - for &(_, _, db_guid, new_count, _) in &planned_existing_counts { - let mut upd_count = char_db.prepare(CharStatements::UPD_ITEM_INSTANCE_COUNT); - upd_count.set_u32(0, new_count); - upd_count.set_u64(1, db_guid); - tx.append(upd_count); + let mut transaction = SqlTransaction::new(); + for stack in &planned_existing_stacks { + Self::append_existing_loot_stack_persistence_like_cpp( + &char_db, + &mut transaction, + stack.db_guid, + stack.new_count, + stack.flags_changed.then_some(stack.dynamic_flags), + ); } - let realm_id = self.realm_id(); - let mut created_new_stacks = Vec::new(); + let mut created_new_stacks = Vec::with_capacity(planned_new_stacks.len()); if !planned_new_stacks.is_empty() { - let max_guid_stmt = char_db.prepare(CharStatements::SEL_MAX_ITEM_GUID); - let mut next_item_guid = match char_db.query(&max_guid_stmt).await { - Ok(r) => r.try_read::(0).unwrap_or(0) + 1, - Err(_) => 1, + let Some(allocated_guids) = + self.allocate_item_instance_guids_like_cpp(planned_new_stacks.len()) + else { + warn!( + count = planned_new_stacks.len(), + "disenchant item grant has no process-wide item GUID allocator" + ); + self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); + return false; }; - for stack in &planned_new_stacks { - let db_guid = next_item_guid; - next_item_guid += 1; - let item_guid = ObjectGuid::create_item(realm_id, db_guid as i64); - - let mut ins_item = + for (stack, (db_guid, item_guid)) in planned_new_stacks.iter().zip(allocated_guids) { + let mut insert_item = char_db.prepare(CharStatements::INS_ITEM_INSTANCE_WITH_RANDOM_CONTEXT); - ins_item.set_u64(0, db_guid); - ins_item.set_u32(1, stack.entry_id); - ins_item.set_u64(2, player_guid.counter() as u64); - ins_item.set_u32(3, stack.count); - ins_item.set_u32(4, stack.max_durability); - ins_item.set_i32(5, stack.random_properties_id); - ins_item.set_i32(6, stack.random_properties_seed); - ins_item.set_u8(7, stack.item_context); - tx.append(ins_item); - - let mut ins_inv = char_db.prepare(CharStatements::INS_CHAR_INVENTORY); - ins_inv.set_u64(0, player_guid.counter() as u64); - ins_inv.set_u8(1, stack.slot); - ins_inv.set_u64(2, db_guid); - tx.append(ins_inv); + insert_item.set_u64(0, db_guid); + insert_item.set_u32(1, stack.entry_id); + insert_item.set_u64(2, player_guid.counter() as u64); + insert_item.set_u32(3, stack.count); + insert_item.set_u32(4, stack.max_durability); + insert_item.set_u32(5, stack.dynamic_flags); + insert_item.set_i32(6, stack.random_properties_id); + insert_item.set_i32(7, stack.random_properties_seed); + insert_item.set_u8(8, stack.item_context); + transaction.append_expect_rows_affected(insert_item, 1); + + let mut insert_inventory = char_db.prepare(CharStatements::INS_CHAR_INVENTORY); + insert_inventory.set_u64(0, player_guid.counter() as u64); + insert_inventory.set_u8(1, stack.slot); + insert_inventory.set_u64(2, db_guid); + transaction.append_expect_rows_affected(insert_inventory, 1); created_new_stacks.push((stack.clone(), db_guid, item_guid)); } } - if let Err(e) = char_db.commit_transaction(tx).await { - warn!("LootItem: store transaction failed: {e}"); - self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); - return false; - } - - for &(_, item_guid, _, new_count, _) in &planned_existing_counts { - self.update_inventory_item_object_like_cpp(item_guid, |item| { - item.set_count(new_count); + let runtime_inventory_applied = + claim_commit_context.map(|_| Arc::new(AtomicBool::new(false))); + let durable_item_completion = claim_commit_context + .zip(runtime_inventory_applied.as_ref().map(Arc::clone)) + .map(|(context, runtime_inventory_applied)| { + ( + self.begin_durable_item_loot_persistence_like_cpp(), + DurableItemLootCompletionLikeCpp { + owner_guid: context.owner_guid, + loot_list_id: context.loot_list_id, + player_guid: context.player_guid, + item_owner_auto_release: false, + durable_item_money_applied_amount: None, + durable_item_money_notified_amount: None, + durable_item_money_balance_applied: None, + item_fanout: durable_item_fanout.clone(), + runtime_inventory_applied, + }, + ) + }); + let persistence_char_db = Arc::clone(&char_db); + let persistence = match spawn_sql_loot_claim_persistence_worker_like_cpp( + async move { + transaction + .commit_with_outcome_like_cpp(persistence_char_db.pool()) + .await + }, + claim.cloned(), + durable_item_completion, + self.session_command_tx(), + ) { + Ok(persistence) => persistence, + Err(error) => { + warn!(?error, "disenchant claim closed before persistence started"); + return false; + } + }; + match persistence.await { + Ok(Ok(())) => {} + Ok(Err(error)) => { + warn!(?error, "disenchant material batch transaction failed"); + self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); + return false; + } + Err(error) => { + warn!(?error, "disenchant material batch worker terminated"); + self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); + return false; + } + } + + for stack in &planned_existing_stacks { + self.update_inventory_item_object_like_cpp(stack.item_guid, |item| { + item.set_count(stack.new_count); + if stack.flags_changed { + item.replace_all_item_flags(ItemFieldFlags::from_bits_retain( + stack.dynamic_flags, + )); + } }); } @@ -6810,6 +10337,7 @@ impl WorldSession { loot_item_context(stack.item_context), stack.slot, ); + self.apply_stored_new_item_flags_like_cpp(stack.entry_id, stack.slot, &mut item_object); if stack.random_properties_id != 0 { item_object.set_random_properties_id(stack.random_properties_id); } @@ -6820,22 +10348,27 @@ impl WorldSession { self.insert_inventory_item_object(item_object); } self.sync_object_accessor_player(); + if let Some(runtime_inventory_applied) = runtime_inventory_applied { + runtime_inventory_applied.store(true, Ordering::Release); + } - let quest_log_item_id = self - .load_creature_item_template_addon_loot_metadata_like_cpp(item_id) - .await - .quest_log_item_id - .try_into() - .unwrap_or(0); - let mut changed_quest_ids = self - .apply_quest_source_item_added_non_bound_objective_progress_like_cpp( - item_id, - quest_log_item_id, - count, - ) - .await; - self.save_changed_represented_quest_statuses_like_cpp(&mut changed_quest_ids) - .await; + for grant in &planned_grants { + let quest_log_item_id = self + .load_creature_item_template_addon_loot_metadata_like_cpp(grant.entry.item_id) + .await + .quest_log_item_id + .try_into() + .unwrap_or(0); + let mut changed_quest_ids = self + .apply_quest_source_item_added_non_bound_objective_progress_like_cpp( + grant.entry.item_id, + quest_log_item_id, + grant.entry.quantity, + ) + .await; + self.save_changed_represented_quest_statuses_like_cpp(&mut changed_quest_ids) + .await; + } let map_id = self.player_map_id_like_cpp(); if !created_new_stacks.is_empty() { @@ -6847,7 +10380,7 @@ impl WorldSession { owner_guid: player_guid, contained_in: player_guid, stack_count: stack.count, - dynamic_flags: 0, + dynamic_flags: stack.dynamic_flags, durability: stack.max_durability, max_durability: stack.max_durability, random_properties_seed: stack.random_properties_seed, @@ -6859,2155 +10392,6661 @@ impl WorldSession { container_item_guids: [ObjectGuid::EMPTY; 36], }) .collect(); - self.send_packet(&UpdateObject::create_items(item_creates, map_id)); + self.send_packet(&UpdateObject::create_stored_items(item_creates, map_id)); + } + for stack in &planned_existing_stacks { + let update = if stack.flags_changed { + UpdateObject::item_stack_count_and_flags_update( + stack.item_guid, + map_id, + stack.new_count, + stack.dynamic_flags, + ) + } else { + UpdateObject::item_stack_count_update(stack.item_guid, map_id, stack.new_count) + }; + self.send_packet(&update); } - for &(slot, item_guid, _, new_count, added_count) in &planned_existing_counts { - self.send_packet(&UpdateObject::item_stack_count_update( - item_guid, map_id, new_count, - )); - self.send_loot_item_push_result( - player_guid, - item_guid, - loot_entry, - store_random_properties.id, - store_random_properties.seed, - slot, - added_count, - new_count, - false, - dungeon_encounter_id, + // C++ writes each material's item update on the instance connection + // before `SendNewItem` routes its push result to the realm connection. + if !self + .wait_for_instance_send_before_realm_send_like_cpp() + .await + { + let _ = self.publish_persisted_loot_item_removal_like_cpp( + claim, + claim_commit_context, + durable_item_fanout.as_ref(), ); + self.sync_player_registry_state_like_cpp(); + self.kick("loot socket ordering fence failed after durable disenchant claim"); + return true; } - for (stack, _, item_guid) in &created_new_stacks { - self.send_loot_item_push_result( - player_guid, - *item_guid, - loot_entry, - stack.random_properties_id, - stack.random_properties_seed, - stack.slot, - stack.count, - stack.count, - false, - dungeon_encounter_id, - ); + for grant in &planned_grants { + for push in &grant.existing_pushes { + self.send_loot_item_push_result( + player_guid, + push.item_guid, + &grant.entry, + grant.random_properties.id, + grant.random_properties.seed, + push.slot, + push.added_count, + push.new_count, + false, + dungeon_encounter_id, + ); + } + for push in &grant.new_pushes { + let (stack, _, item_guid) = &created_new_stacks[push.stack_index]; + self.send_loot_item_push_result( + player_guid, + *item_guid, + &grant.entry, + stack.random_properties_id, + stack.random_properties_seed, + stack.slot, + push.added_count, + push.new_count, + false, + dungeon_encounter_id, + ); + } + } + + // `Loot::AutoStore` completes every realm-routed `SendNewItem` before + // `LootRoll::Finish` emits the original slot removal on the instance + // connection. Do not publish the later instance packets without the + // writer acknowledgement; reconnect will reload the durable grant. + if !self + .wait_for_realm_send_before_instance_update_like_cpp() + .await + { + self.sync_player_registry_state_like_cpp(); + self.kick("loot socket ordering fence failed after durable disenchant claim"); + return true; + } + + // C++ `Loot::AutoStore` performs `StoreNewItem` and `SendNewItem` for + // every generated material. Only after `AutoStore` returns does + // `LootRoll::Finish` call `NotifyItemRemoved` for the original loot. + // SQL and the claim were already committed by the detached worker. + if !self.publish_persisted_loot_item_removal_like_cpp( + claim, + claim_commit_context, + durable_item_fanout.as_ref(), + ) { + return false; } if !created_new_stacks.is_empty() { - let changed_slots: Vec<_> = created_new_stacks + let changed_slots = created_new_stacks .iter() .map(|(stack, _, item_guid)| (stack.slot, *item_guid)) - .collect(); + .collect::>(); self.send_player_values_update_from_entity_bridge(&changed_slots, &[], &[], &[], None); } for update in &collection_updates { self.send_player_values_update_like_cpp(update); } - self.sync_player_registry_state_like_cpp(); true } - fn plan_direct_loot_item_preserving_cpp_store_metadata( - &self, + async fn store_direct_loot_item_from_owner_like_cpp( + &mut self, loot_entry: &LootEntry, - random_properties: LootStoreRandomProperties, - ) -> Option> { - let max_stack = self - .item_storage_template(loot_entry.item_id) - .map(|template| template.max_stack_size) - .unwrap_or(1) - .max(1); - let mut remaining = loot_entry.quantity; - let mut dest = Vec::new(); + dungeon_encounter_id: u32, + owner_guid: ObjectGuid, + ) -> bool { + self.store_direct_loot_item_with_source_like_cpp( + loot_entry, + dungeon_encounter_id, + owner_guid.is_item().then_some(owner_guid), + None, + None, + ) + .await + } - let mut existing_slots: Vec = self.inventory_items_like_cpp().keys().copied().collect(); - existing_slots.sort_unstable(); - for slot in existing_slots { - if remaining == 0 { - break; + async fn store_claimed_direct_loot_item_from_owner_like_cpp( + &mut self, + loot_entry: &LootEntry, + dungeon_encounter_id: u32, + owner_guid: ObjectGuid, + loot_obj: ObjectGuid, + claim: &LootClaimLease, + ) -> bool { + let Some(player_guid) = self.player_guid() else { + return false; + }; + self.store_direct_loot_item_with_source_like_cpp( + loot_entry, + dungeon_encounter_id, + owner_guid.is_item().then_some(owner_guid), + Some(claim), + Some(LootItemClaimCommitContextLikeCpp { + owner_guid, + loot_obj, + loot_list_id: loot_entry.loot_list_id, + player_guid, + free_for_all: loot_entry.flags.freeforall, + }), + ) + .await + } + + async fn store_direct_loot_item_with_source_like_cpp( + &mut self, + loot_entry: &LootEntry, + dungeon_encounter_id: u32, + stored_item_loot_source: Option, + claim: Option<&LootClaimLease>, + claim_commit_context: Option, + ) -> bool { + let item_id = loot_entry.item_id; + let count = loot_entry.quantity; + let Some(player_guid) = self.player_guid() else { + return false; + }; + let durable_item_fanout = match (claim, claim_commit_context) { + (Some(claim), Some(context)) => { + let Some(fanout) = self.prepare_durable_loot_item_fanout_like_cpp(claim, context) + else { + return false; + }; + Some(fanout) } - let Some(existing) = self.inventory_items_like_cpp().get(&slot) else { - continue; - }; - let Some(existing_object) = self.inventory_item_objects_like_cpp().get(&existing.guid) - else { - continue; + (None, None) => None, + _ => return false, + }; + #[cfg(test)] + if let Some(grants) = self.loot_item_store_test_grants_like_cpp.clone() { + let success = self.loot_item_store_test_success_like_cpp; + let commit_gate = self.loot_item_store_test_commit_gate_like_cpp.clone(); + let durable_completion_context = stored_item_loot_source + .map(|owner_guid| (owner_guid, loot_entry.loot_list_id, player_guid, true)) + .or_else(|| { + claim_commit_context.map(|context| { + ( + context.owner_guid, + context.loot_list_id, + context.player_guid, + false, + ) + }) + }); + let runtime_inventory_applied = + durable_completion_context.map(|_| Arc::new(AtomicBool::new(false))); + let durable_item_completion = durable_completion_context + .zip(runtime_inventory_applied.as_ref().map(Arc::clone)) + .map( + |( + (owner_guid, loot_list_id, player_guid, item_owner_auto_release), + runtime_inventory_applied, + )| { + ( + self.begin_durable_item_loot_persistence_like_cpp(), + DurableItemLootCompletionLikeCpp { + owner_guid, + loot_list_id, + player_guid, + item_owner_auto_release, + durable_item_money_applied_amount: None, + durable_item_money_notified_amount: None, + durable_item_money_balance_applied: None, + item_fanout: durable_item_fanout.clone(), + runtime_inventory_applied, + }, + ) + }, + ); + let Ok(worker) = spawn_loot_claim_persistence_worker_like_cpp( + async move { + tokio::task::yield_now().await; + if let Some(gate) = commit_gate { + gate.notified().await; + } + if !success { + return Err(()); + } + grants.fetch_add(1, Ordering::SeqCst); + Ok(()) + }, + claim.cloned(), + durable_item_completion, + ) else { + return false; }; - if existing.entry_id != loot_entry.item_id - || !loot_store_data_can_stack_with_item( - loot_entry, - random_properties, - existing_object, - ) - || existing_object.count() >= max_stack - { - continue; - } - let can_add = max_stack - .saturating_sub(existing_object.count()) - .min(remaining); - if can_add > 0 { - dest.push(ItemPosCount::new( - make_item_pos(INVENTORY_SLOT_BAG_0, slot), - can_add, - )); - remaining = remaining.saturating_sub(can_add); + if !matches!(worker.await, Ok(Ok(()))) { + return false; } - } - - let backpack_end = INVENTORY_SLOT_ITEM_START - .saturating_add(INVENTORY_DEFAULT_SIZE) - .min(INVENTORY_SLOT_ITEM_END); - for slot in INVENTORY_SLOT_ITEM_START..backpack_end { - if remaining == 0 { - break; + if !self.publish_persisted_loot_item_removal_like_cpp( + claim, + claim_commit_context, + durable_item_fanout.as_ref(), + ) { + return false; } - if self.inventory_items_like_cpp().contains_key(&slot) { - continue; + self.send_loot_item_push_result( + player_guid, + ObjectGuid::EMPTY, + loot_entry, + 0, + 0, + 0, + count, + count, + false, + dungeon_encounter_id, + ); + if let Some(runtime_inventory_applied) = runtime_inventory_applied { + runtime_inventory_applied.store(true, Ordering::Release); } - let quantity = max_stack.min(remaining); - dest.push(ItemPosCount::new( - make_item_pos(INVENTORY_SLOT_BAG_0, slot), - quantity, - )); - remaining = remaining.saturating_sub(quantity); + return true; + } + let Some(char_db) = self.char_db().map(Arc::clone) else { + return false; + }; + let Some((store_result, mut store_dest, _)) = + self.plan_store_new_direct_inventory_item(item_id, count) + else { + self.send_equip_error(InventoryResult::ItemNotFound, None, None, 0, 0); + return false; + }; + if store_result != InventoryResult::Ok { + self.send_equip_error(store_result, None, None, 0, 0); + return false; } - (remaining == 0).then_some(dest) - } + let store_random_properties = { + let mut rng = self.represented_runtime_subrng_like_cpp(); + self.generate_loot_store_random_properties_with_rng_like_cpp(item_id, &mut rng) + }; - fn send_loot_item_push_result( - &self, - player_guid: ObjectGuid, - item_guid: ObjectGuid, - loot_entry: &LootEntry, - random_properties_id: i32, - random_properties_seed: i32, - slot: u8, - quantity: u32, - quantity_in_inventory: u32, - created: bool, - dungeon_encounter_id: u32, - ) { - let is_encounter_loot = dungeon_encounter_id != 0; - self.send_packet(&ItemPushResult { - player_guid, - slot: u8::from(INVENTORY_SLOT_BAG_0), - slot_in_bag: i32::from(slot), - item: ItemInstance { - item_id: loot_entry.item_id as i32, - random_properties_seed, - random_properties_id, - item_bonus: None, - modifications: ItemModList { values: Vec::new() }, - }, - quest_log_item_id: 0, - quantity: quantity as i32, - quantity_in_inventory: quantity_in_inventory as i32, - dungeon_encounter_id: dungeon_encounter_id as i32, - battle_pet_species_id: 0, - battle_pet_breed_id: 0, - battle_pet_breed_quality: 0, - battle_pet_level: 0, - item_guid, - pushed: false, - display_text: if is_encounter_loot { - ItemPushResultDisplayType::EncounterLoot - } else { - ItemPushResultDisplayType::Normal - }, - created, - is_bonus_roll: false, - is_encounter_loot, - }); - } + if store_dest.iter().any(|dest| { + let bag = (dest.pos >> 8) as u8; + let slot = (dest.pos & 0x00FF) as u8; + bag == u8::from(INVENTORY_SLOT_BAG_0) + && self + .inventory_items_like_cpp() + .get(&slot) + .is_some_and(|existing| { + self.inventory_item_objects_like_cpp() + .get(&existing.guid) + .is_some_and(|item| { + !loot_store_data_can_stack_with_item( + loot_entry, + store_random_properties, + item, + ) + }) + }) + }) { + let Some(compatible_dest) = self.plan_direct_loot_item_preserving_cpp_store_metadata( + loot_entry, + store_random_properties, + ) else { + self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); + return false; + }; + store_dest = compatible_dest; + } - async fn destroy_fully_looted_direct_item(&mut self, item_guid: ObjectGuid) { - let player_guid = match self.player_guid() { - Some(guid) => guid, - None => return, - }; + let mut planned_existing_counts = Vec::::new(); + let mut planned_new_stacks = Vec::::new(); - let runtime_item = self - .inventory_item_objects_like_cpp() - .get(&item_guid) - .cloned(); - let (bag, slot) = match runtime_item.as_ref() { - Some(item) => (item.bag_slot(), item.slot()), - None => return, - }; + for dest in store_dest { + let bag = (dest.pos >> 8) as u8; + let slot = (dest.pos & 0x00FF) as u8; + if bag != u8::from(INVENTORY_SLOT_BAG_0) { + self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); + return false; + } - let Some(item) = self.get_inventory_item_by_pos(bag, slot) else { - return; - }; + let max_stack = self + .item_storage_template(item_id) + .map(|template| template.max_stack_size) + .unwrap_or(1) + .max(1); - let char_db = match self.char_db() { - Some(db) => Arc::clone(db), - None => return, - }; + if let Some(existing) = self.inventory_items_like_cpp().get(&slot) { + let Some(existing_object) = + self.inventory_item_objects_like_cpp().get(&existing.guid) + else { + self.send_equip_error(InventoryResult::ItemNotFound, None, None, 0, 0); + return false; + }; + let base_count = planned_existing_counts + .iter() + .find(|planned| planned.slot == slot) + .map(|planned| planned.new_count) + .unwrap_or_else(|| existing_object.count()); + let new_count = base_count.saturating_add(dest.count); + if existing.entry_id != item_id + || new_count > max_stack + || !loot_store_data_can_stack_with_item( + loot_entry, + store_random_properties, + existing_object, + ) + { + self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); + return false; + } + if let Some(existing_plan) = planned_existing_counts + .iter_mut() + .find(|planned| planned.slot == slot) + { + existing_plan.new_count = new_count; + existing_plan.added_count = + existing_plan.added_count.saturating_add(dest.count); + } else { + let dynamic_flags = self.stored_existing_item_dynamic_flags_like_cpp( + item_id, + slot, + existing_object, + ); + planned_existing_counts.push(PlannedDirectLootExistingStack { + slot, + item_guid: existing.guid, + db_guid: existing.db_guid, + new_count, + added_count: dest.count, + dynamic_flags, + flags_changed: dynamic_flags != existing_object.item_flags_bits(), + }); + } + continue; + } + + if let Some(stack) = planned_new_stacks + .iter_mut() + .find(|stack| stack.slot == slot) + { + if stack.entry_id == item_id + && stack.random_properties_id == store_random_properties.id + && stack.random_properties_seed == store_random_properties.seed + && stack.item_context == loot_entry.item_context + && stack.count.saturating_add(dest.count) <= max_stack + { + stack.count = stack.count.saturating_add(dest.count); + continue; + } + self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); + return false; + } + + planned_new_stacks.push(PlannedLootNewStack { + slot, + entry_id: item_id, + count: dest.count, + max_durability: self.item_template_max_durability(item_id), + dynamic_flags: self.stored_new_item_dynamic_flags_like_cpp(item_id, slot), + random_properties_id: store_random_properties.id, + random_properties_seed: store_random_properties.seed, + item_context: loot_entry.item_context, + }); + } let mut tx = SqlTransaction::new(); - let should_expire_refund = runtime_item - .as_ref() - .is_some_and(|item_object| item_object.is_refundable()); - if should_expire_refund { - let mut del_refund = char_db.prepare(CharStatements::DEL_ITEM_REFUND_INSTANCE); - del_refund.set_u64(0, item.db_guid); - tx.append(del_refund); + for stack in &planned_existing_counts { + Self::append_existing_loot_stack_persistence_like_cpp( + &char_db, + &mut tx, + stack.db_guid, + stack.new_count, + stack.flags_changed.then_some(stack.dynamic_flags), + ); } - let mut del_inv = char_db.prepare(CharStatements::DEL_CHAR_INVENTORY_ITEM); - del_inv.set_u64(0, player_guid.counter() as u64); - del_inv.set_u64(1, item.db_guid); - tx.append(del_inv); + let mut created_new_stacks = Vec::new(); + if !planned_new_stacks.is_empty() { + let Some(allocated_guids) = + self.allocate_item_instance_guids_like_cpp(planned_new_stacks.len()) + else { + warn!( + count = planned_new_stacks.len(), + "loot item grant has no process-wide item GUID allocator" + ); + self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); + return false; + }; - let mut del_item = char_db.prepare(CharStatements::DEL_ITEM_INSTANCE); - del_item.set_u64(0, item.db_guid); - tx.append(del_item); + for (stack, (db_guid, item_guid)) in planned_new_stacks.iter().zip(allocated_guids) { + let mut ins_item = + char_db.prepare(CharStatements::INS_ITEM_INSTANCE_WITH_RANDOM_CONTEXT); + ins_item.set_u64(0, db_guid); + ins_item.set_u32(1, stack.entry_id); + ins_item.set_u64(2, player_guid.counter() as u64); + ins_item.set_u32(3, stack.count); + ins_item.set_u32(4, stack.max_durability); + ins_item.set_u32(5, stack.dynamic_flags); + ins_item.set_i32(6, stack.random_properties_id); + ins_item.set_i32(7, stack.random_properties_seed); + ins_item.set_u8(8, stack.item_context); + tx.append_expect_rows_affected(ins_item, 1); - if let Err(e) = char_db.commit_transaction(tx).await { - warn!("LootRelease: delete fully looted item failed: {e}"); - return; - } + let mut ins_inv = char_db.prepare(CharStatements::INS_CHAR_INVENTORY); + ins_inv.set_u64(0, player_guid.counter() as u64); + ins_inv.set_u8(1, stack.slot); + ins_inv.set_u64(2, db_guid); + tx.append_expect_rows_affected(ins_inv, 1); - self.remove_fully_looted_runtime_item(bag, slot, item.guid); + created_new_stacks.push((stack.clone(), db_guid, item_guid)); + } + } - if should_expire_refund { - self.send_packet(&ItemExpirePurchaseRefund { - item_guid: item.guid, - }); + // Item-container loot is a move between two durable stores. Delete + // the source row in the same transaction that grants the destination + // stack so a crash cannot duplicate (grant-only) or lose (delete-only) + // the item. + if let Some(item_guid) = stored_item_loot_source { + let mut delete_source = char_db.prepare(CharStatements::DEL_ITEMCONTAINER_ITEM); + delete_source.set_u64(0, item_guid.counter() as u64); + delete_source.set_u32(1, item_id); + delete_source.set_u32(2, count); + delete_source.set_u32(3, u32::from(loot_entry.loot_list_id)); + tx.append_expect_rows_affected(delete_source, 1); } - // Player-values update and stat refresh only apply to top-level slots. - if bag == INVENTORY_SLOT_BAG_0 { - let mut visible_item_changes = Vec::new(); - let mut virtual_item_changes = Vec::new(); - if (slot as usize) < 19 { - visible_item_changes.push((slot, 0i32, 0u16, 0u16)); + let durable_claim = claim.cloned(); + let durable_completion_context = stored_item_loot_source + .map(|owner_guid| (owner_guid, loot_entry.loot_list_id, player_guid, true)) + .or_else(|| { + claim_commit_context.map(|context| { + ( + context.owner_guid, + context.loot_list_id, + context.player_guid, + false, + ) + }) + }); + let runtime_inventory_applied = + durable_completion_context.map(|_| Arc::new(AtomicBool::new(false))); + let durable_item_completion = durable_completion_context + .zip(runtime_inventory_applied.as_ref().map(Arc::clone)) + .map( + |( + (owner_guid, loot_list_id, player_guid, item_owner_auto_release), + runtime_inventory_applied, + )| { + ( + self.begin_durable_item_loot_persistence_like_cpp(), + DurableItemLootCompletionLikeCpp { + owner_guid, + loot_list_id, + player_guid, + item_owner_auto_release, + durable_item_money_applied_amount: None, + durable_item_money_notified_amount: None, + durable_item_money_balance_applied: None, + item_fanout: durable_item_fanout.clone(), + runtime_inventory_applied, + }, + ) + }, + ); + let persistence_char_db = Arc::clone(&char_db); + let persistence = match spawn_sql_loot_claim_persistence_worker_like_cpp( + async move { + tx.commit_with_outcome_like_cpp(persistence_char_db.pool()) + .await + }, + durable_claim, + durable_item_completion, + self.session_command_tx(), + ) { + Ok(persistence) => persistence, + Err(error) => { + warn!(?error, "LootItem: claim closed before persistence started"); + self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); + return false; } - if slot >= 15 && slot <= 17 { - virtual_item_changes.push((slot - 15, 0i32, 0u16, 0u16)); + }; + let persistence_result = match persistence.await { + Ok(result) => result, + Err(error) => { + warn!( + ?error, + "LootItem: detached store transaction worker terminated" + ); + self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); + return false; } + }; + if let Err(e) = persistence_result { + warn!("LootItem: store transaction failed: {e:?}"); + self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); + return false; + } - self.send_player_values_update_from_entity_bridge( - &[(slot, ObjectGuid::EMPTY)], - &visible_item_changes, - &virtual_item_changes, - &[], - None, - ); - - if slot < 19 { - self.send_stat_update(); - } + for stack in &planned_existing_counts { + self.update_inventory_item_object_like_cpp(stack.item_guid, |item| { + item.set_count(stack.new_count); + if stack.flags_changed { + item.replace_all_item_flags(ItemFieldFlags::from_bits_retain( + stack.dynamic_flags, + )); + } + }); } - } -} -fn master_loot_error_for_inventory_result_like_cpp(result: InventoryResult) -> Option { - match result { - InventoryResult::Ok => None, - InventoryResult::ItemMaxCount => Some(LOOT_ERROR_MASTER_UNIQUE_ITEM_LIKE_CPP), - InventoryResult::InvFull => Some(LOOT_ERROR_MASTER_INV_FULL_LIKE_CPP), - _ => Some(LOOT_ERROR_MASTER_OTHER_LIKE_CPP), - } -} + let mut collection_updates = Vec::new(); + for (stack, db_guid, item_guid) in &created_new_stacks { + self.insert_inventory_item_like_cpp( + stack.slot, + InventoryItem { + guid: *item_guid, + entry_id: stack.entry_id, + db_guid: *db_guid, + inventory_type: self.item_template_inventory_type(stack.entry_id), + }, + ); + let mut item_object = self.make_inventory_item_object( + *item_guid, + stack.entry_id, + player_guid, + stack.count, + stack.max_durability, + loot_item_context(stack.item_context), + stack.slot, + ); + self.apply_stored_new_item_flags_like_cpp(stack.entry_id, stack.slot, &mut item_object); + if stack.random_properties_id != 0 { + item_object.set_random_properties_id(stack.random_properties_id); + } + if stack.random_properties_seed != 0 { + item_object.set_property_seed(stack.random_properties_seed); + } + collection_updates.extend(self.on_item_added_to_collection_like_cpp(&item_object)); + self.insert_inventory_item_object(item_object); + } + self.sync_object_accessor_player(); + if let Some(runtime_inventory_applied) = runtime_inventory_applied { + runtime_inventory_applied.store(true, Ordering::Release); + } -// ── Loot generation ─────────────────────────────────────────────── + let quest_log_item_id = self + .load_creature_item_template_addon_loot_metadata_like_cpp(item_id) + .await + .quest_log_item_id + .try_into() + .unwrap_or(0); + let mut changed_quest_ids = self + .apply_quest_source_item_added_non_bound_objective_progress_like_cpp( + item_id, + quest_log_item_id, + count, + ) + .await; + self.save_changed_represented_quest_statuses_like_cpp(&mut changed_quest_ids) + .await; -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -struct ItemTemplateAddonLootMetadataLikeCpp { - flags_cu: u32, - quest_log_item_id: i32, -} + let map_id = self.player_map_id_like_cpp(); + if !created_new_stacks.is_empty() { + let item_creates = created_new_stacks + .iter() + .map(|(stack, _, item_guid)| ItemCreateData { + item_guid: *item_guid, + entry_id: stack.entry_id as i32, + owner_guid: player_guid, + contained_in: player_guid, + stack_count: stack.count, + dynamic_flags: stack.dynamic_flags, + durability: stack.max_durability, + max_durability: stack.max_durability, + random_properties_seed: stack.random_properties_seed, + random_properties_id: stack.random_properties_id, + enchantments: [ItemEnchantmentValuesUpdate::default(); 13], + gems: Vec::new(), + context: stack.item_context, + container_slots: 0, + container_item_guids: [ObjectGuid::EMPTY; 36], + }) + .collect(); + self.send_packet(&UpdateObject::create_stored_items(item_creates, map_id)); + } -#[derive(Debug, Clone, PartialEq, Eq)] -struct RepresentedLootPlayerContext { - race: u8, - class: u8, - gender: u8, - level: u8, - known_spells: Vec, - active_quest_statuses: HashMap, - active_quest_objective_counts: HashMap>, - rewarded_quests: HashSet, - inventory_item_counts: HashMap, - is_current: bool, -} + for stack in &planned_existing_counts { + let update = if stack.flags_changed { + UpdateObject::item_stack_count_and_flags_update( + stack.item_guid, + map_id, + stack.new_count, + stack.dynamic_flags, + ) + } else { + UpdateObject::item_stack_count_update(stack.item_guid, map_id, stack.new_count) + }; + self.send_packet(&update); + } -impl RepresentedLootPlayerContext { - fn quest_status(&self, quest_id: u32) -> u8 { - self.active_quest_statuses - .get(&quest_id) - .copied() - .or_else(|| { - self.rewarded_quests - .contains(&quest_id) - .then_some(QUEST_STATUS_REWARDED_LIKE_CPP) - }) - .unwrap_or(QUEST_STATUS_NONE_LIKE_CPP) - } + // The worker committed SQL and the authority claim before runtime + // publication. C++ `StoreNewItem` sends the stored item's update, + // `Player::StoreLootItem` then notifies removal, and only afterwards + // does `SendNewItem` emit `SMSG_ITEM_PUSH_RESULT`. + if !self.publish_persisted_loot_item_removal_like_cpp( + claim, + claim_commit_context, + durable_item_fanout.as_ref(), + ) { + return false; + } - fn inventory_item_count(&self, item_id: u32) -> u32 { - self.inventory_item_counts - .get(&item_id) - .copied() - .unwrap_or(0) - } -} + if !self + .wait_for_instance_send_before_realm_send_like_cpp() + .await + { + self.sync_player_registry_state_like_cpp(); + self.kick("loot socket ordering fence failed after durable item claim"); + return true; + } -impl ItemTemplateAddonLootMetadataLikeCpp { - fn ignores_quest_status(self) -> bool { - self.flags_cu & ITEM_FLAGS_CU_IGNORE_QUEST_STATUS_LIKE_CPP != 0 - } + for stack in &planned_existing_counts { + self.send_loot_item_push_result( + player_guid, + stack.item_guid, + loot_entry, + store_random_properties.id, + store_random_properties.seed, + stack.slot, + stack.added_count, + stack.new_count, + false, + dungeon_encounter_id, + ); + } - fn follows_loot_rules(self) -> bool { - self.flags_cu & ITEM_FLAGS_CU_FOLLOW_LOOT_RULES_LIKE_CPP != 0 - } -} + for (stack, _, item_guid) in &created_new_stacks { + self.send_loot_item_push_result( + player_guid, + *item_guid, + loot_entry, + stack.random_properties_id, + stack.random_properties_seed, + stack.slot, + stack.count, + stack.count, + false, + dungeon_encounter_id, + ); + } -fn player_class_mask_like_cpp(class_id: u8) -> Option { - if (1..=13).contains(&class_id) { - Some(1_u32 << (class_id - 1)) - } else { - None - } -} + if (!created_new_stacks.is_empty() || !collection_updates.is_empty()) + && !self + .wait_for_realm_send_before_instance_update_like_cpp() + .await + { + self.sync_player_registry_state_like_cpp(); + self.kick("loot socket ordering fence failed after durable item claim"); + return true; + } -fn player_race_mask_like_cpp(race_id: u8) -> Option { - let bit = match race_id { - 1..=11 => race_id - 1, - 22 => 21, - 24..=32 => race_id - 1, - 34 => 11, - 35 => 12, - 36 => 13, - 37 => 14, - 52 => 16, - 70 => 15, - _ => return None, - }; - Some(1_u32 << bit) -} + if !created_new_stacks.is_empty() { + let changed_slots: Vec<_> = created_new_stacks + .iter() + .map(|(stack, _, item_guid)| (stack.slot, *item_guid)) + .collect(); + self.send_player_values_update_from_entity_bridge(&changed_slots, &[], &[], &[], None); + } + for update in &collection_updates { + self.send_player_values_update_like_cpp(update); + } -fn player_team_for_race_cpp_representable(race: u8) -> u32 { - match race { - 2 | 5 | 6 | 8 | 9 | 10 | 26 | 27 | 28 | 31 | 35 | 36 | 70 => 67, - _ => 469, + self.sync_player_registry_state_like_cpp(); + true } -} - -fn represented_item_faction_flags_block_player_like_cpp(flags2: Option, race: u8) -> bool { - let Some(flags2) = flags2 else { - return false; - }; - let team = player_team_for_race_cpp_representable(race); - ((flags2 & ItemFlags2::FactionHorde as u32) != 0 && team != 67) - || ((flags2 & ItemFlags2::FactionAlliance as u32) != 0 && team != 469) -} + fn plan_direct_loot_item_preserving_cpp_store_metadata( + &self, + loot_entry: &LootEntry, + random_properties: LootStoreRandomProperties, + ) -> Option> { + let max_stack = self + .item_storage_template(loot_entry.item_id) + .map(|template| template.max_stack_size) + .unwrap_or(1) + .max(1); + let mut remaining = loot_entry.quantity; + let mut dest = Vec::new(); -fn player_quest_status_mask_like_cpp(status: Option, rewarded: bool) -> u32 { - if rewarded { - return 0x40; - } + let mut existing_slots: Vec = self.inventory_items_like_cpp().keys().copied().collect(); + existing_slots.sort_unstable(); + for slot in existing_slots { + if remaining == 0 { + break; + } + let Some(existing) = self.inventory_items_like_cpp().get(&slot) else { + continue; + }; + let Some(existing_object) = self.inventory_item_objects_like_cpp().get(&existing.guid) + else { + continue; + }; + if existing.entry_id != loot_entry.item_id + || !loot_store_data_can_stack_with_item( + loot_entry, + random_properties, + existing_object, + ) + || existing_object.count() >= max_stack + { + continue; + } + let can_add = max_stack + .saturating_sub(existing_object.count()) + .min(remaining); + if can_add > 0 { + dest.push(ItemPosCount::new( + make_item_pos(INVENTORY_SLOT_BAG_0, slot), + can_add, + )); + remaining = remaining.saturating_sub(can_add); + } + } - match status { - None => 0x01, - Some(QUEST_STATUS_COMPLETE_LIKE_CPP) => 0x02, - Some(QUEST_STATUS_INCOMPLETE_LIKE_CPP) => 0x08, - Some(QUEST_STATUS_FAILED_LIKE_CPP) => 0x20, - _ => 0, - } -} + let backpack_end = INVENTORY_SLOT_ITEM_START + .saturating_add(INVENTORY_DEFAULT_SIZE) + .min(INVENTORY_SLOT_ITEM_END); + for slot in INVENTORY_SLOT_ITEM_START..backpack_end { + if remaining == 0 { + break; + } + if self.inventory_items_like_cpp().contains_key(&slot) { + continue; + } + let quantity = max_stack.min(remaining); + dest.push(ItemPosCount::new( + make_item_pos(INVENTORY_SLOT_BAG_0, slot), + quantity, + )); + remaining = remaining.saturating_sub(quantity); + } -fn generated_creature_loot_item_to_entry_like_cpp( - item: GeneratedLootItem, - addon_metadata: ItemTemplateAddonLootMetadataLikeCpp, -) -> LootEntry { - LootEntry { - loot_list_id: item.loot_list_id as u8, - item_id: item.item_id, - quantity: item.count, - random_properties_id: item.random_properties_id, - random_properties_seed: item.random_properties_seed, - item_context: item.context, - flags: LootEntryFlags { - follow_loot_rules: !item.needs_quest || addon_metadata.follows_loot_rules(), - freeforall: item.free_for_all, - blocked: item.is_blocked, - counted: item.is_counted, - under_threshold: item.is_under_threshold, - needs_quest: item.needs_quest, - }, - allowed_looters: Vec::new(), - roll_winner: ObjectGuid::EMPTY, - ffa_looted_by: Vec::new(), - taken: item.is_looted, + (remaining == 0).then_some(dest) } -} - -#[derive(Debug, Clone)] -struct RepresentedCreatureLootStateLikeCpp { - is_alive: bool, - position: wow_core::Position, - level: u8, - entry: u32, - loot_id: u32, - gold_min: u32, - gold_max: u32, - dungeon_encounter_id: u32, - tappers: Vec, -} - -#[derive(Debug, Clone, Copy)] -struct RepresentedGameObjectLootStateLikeCpp { - position: Option, - display_id: Option, - scale: f32, - rotation: [f32; 4], - go_type: Option, - interact_radius_override: Option, - lock_id: Option, - owner_guid: Option, -} -pub(crate) fn represented_gameobject_interaction_distance_like_cpp( - go_type: Option, - interact_radius_override: Option, -) -> f32 { - // C++ ref: GameObject.cpp GetInteractionDistance(). - // Spell-lock range remains with the typed GameObject/SpellInfo port. - if let Some(override_hundredths) = interact_radius_override.filter(|value| *value != 0) { - return override_hundredths as f32 / 100.0; + fn send_loot_item_push_result( + &self, + player_guid: ObjectGuid, + item_guid: ObjectGuid, + loot_entry: &LootEntry, + random_properties_id: i32, + random_properties_seed: i32, + slot: u8, + quantity: u32, + quantity_in_inventory: u32, + created: bool, + dungeon_encounter_id: u32, + ) { + let is_encounter_loot = dungeon_encounter_id != 0; + self.send_packet_realm(&ItemPushResult { + player_guid, + slot: u8::from(INVENTORY_SLOT_BAG_0), + slot_in_bag: i32::from(slot), + item: ItemInstance { + item_id: loot_entry.item_id as i32, + random_properties_seed, + random_properties_id, + item_bonus: None, + modifications: ItemModList { values: Vec::new() }, + }, + quest_log_item_id: 0, + quantity: quantity as i32, + quantity_in_inventory: quantity_in_inventory as i32, + dungeon_encounter_id: dungeon_encounter_id as i32, + battle_pet_species_id: 0, + battle_pet_breed_id: 0, + battle_pet_breed_quality: 0, + battle_pet_level: 0, + item_guid, + pushed: false, + display_text: if is_encounter_loot { + ItemPushResultDisplayType::EncounterLoot + } else { + ItemPushResultDisplayType::Normal + }, + created, + is_bonus_roll: false, + is_encounter_loot, + }); } - match go_type.map(u32::from) { - Some(GAMEOBJECT_TYPE_AREADAMAGE) => 0.0, - Some(GAMEOBJECT_TYPE_QUESTGIVER) - | Some(GAMEOBJECT_TYPE_TEXT) - | Some(GAMEOBJECT_TYPE_FLAGSTAND) - | Some(GAMEOBJECT_TYPE_FLAGDROP) - | Some(GAMEOBJECT_TYPE_MINI_GAME) => 5.5555553, - Some(GAMEOBJECT_TYPE_BINDER) => 10.0, - Some(GAMEOBJECT_TYPE_CHAIR) | Some(GAMEOBJECT_TYPE_BARBER_CHAIR) => 3.0, - Some(GAMEOBJECT_TYPE_FISHING_NODE) => 100.0, - Some(GAMEOBJECT_TYPE_FISHING_HOLE) => 20.0 + wow_movement::CONTACT_DISTANCE_LIKE_CPP, - Some(GAMEOBJECT_TYPE_CAMERA) - | Some(GAMEOBJECT_TYPE_MAP_OBJECT) - | Some(GAMEOBJECT_TYPE_DUNGEON_DIFFICULTY) - | Some(GAMEOBJECT_TYPE_DESTRUCTIBLE_BUILDING) - | Some(GAMEOBJECT_TYPE_DOOR) => 5.0, - Some(GAMEOBJECT_TYPE_GUILD_BANK) | Some(GAMEOBJECT_TYPE_MAILBOX) => 10.0, - _ => 5.0, + async fn destroy_fully_looted_direct_item(&mut self, item_guid: ObjectGuid) { + self.destroy_direct_item_count_after_loot_release_like_cpp(item_guid, None) + .await; } -} - -fn represented_gameobject_display_box_contains_like_cpp( - go_position: wow_core::Position, - player_position: wow_core::Position, - display_info: &wow_data::GameObjectDisplayInfoEntry, - scale: f32, - rotation: [f32; 4], - radius: f32, -) -> bool { - let min_x = display_info.geo_box_min.x * scale - radius; - let min_y = display_info.geo_box_min.y * scale - radius; - let min_z = display_info.geo_box_min.z * scale - radius; - let max_x = display_info.geo_box_max.x * scale + radius; - let max_y = display_info.geo_box_max.y * scale + radius; - let max_z = display_info.geo_box_max.z * scale + radius; - - let dx = player_position.x - go_position.x; - let dy = player_position.y - go_position.y; - let dz = player_position.z - go_position.z; - let [qx, qy, qz, qw] = rotation; - let iqx = -qx; - let iqy = -qy; - let iqz = -qz; - let tx = 2.0 * (iqy * dz - iqz * dy); - let ty = 2.0 * (iqz * dx - iqx * dz); - let tz = 2.0 * (iqx * dy - iqy * dx); - let local_x = dx + qw * tx + (iqy * tz - iqz * ty); - let local_y = dy + qw * ty + (iqz * tx - iqx * tz); - let local_z = dz + qw * tz + (iqx * ty - iqy * tx); + async fn destroy_direct_item_count_after_loot_release_like_cpp( + &mut self, + item_guid: ObjectGuid, + maximum_destroy_count: Option, + ) { + let player_guid = match self.player_guid() { + Some(guid) => guid, + None => return, + }; - local_x >= min_x - && local_x <= max_x - && local_y >= min_y - && local_y <= max_y - && local_z >= min_z - && local_z <= max_z -} + let runtime_item = self + .inventory_item_objects_like_cpp() + .get(&item_guid) + .cloned(); + let (bag, slot) = match runtime_item.as_ref() { + Some(item) => (item.bag_slot(), item.slot()), + None => return, + }; -fn represented_loot_object_guid_like_cpp(owner: ObjectGuid) -> ObjectGuid { - if owner.is_empty() { - return ObjectGuid::EMPTY; - } + let Some(item) = self.get_inventory_item_by_pos(bag, slot) else { + return; + }; - ObjectGuid::create_world_object( - HighGuid::LootObject, - 0, - owner.realm_id(), - owner.map_id(), - owner.server_id(), - 0, - owner.counter(), - ) -} + let char_db = match self.char_db() { + Some(db) => Arc::clone(db), + None => return, + }; -fn loot_type_for_client_like_cpp(loot_type: u8) -> u8 { - match loot_type { - LOOT_TYPE_PROSPECTING_LIKE_CPP | LOOT_TYPE_MILLING_LIKE_CPP => { - LOOT_TYPE_DISENCHANTING_LIKE_CPP + let current_count = runtime_item.as_ref().map_or(1, Item::count); + let new_count = + direct_item_count_after_loot_release_like_cpp(current_count, maximum_destroy_count); + if new_count != 0 { + let mut update_count = char_db.prepare(CharStatements::UPD_ITEM_INSTANCE_COUNT); + update_count.set_u32(0, new_count); + update_count.set_u64(1, item.db_guid); + if let Err(error) = char_db.execute(&update_count).await { + warn!(?error, "LootRelease: update partially consumed item failed"); + return; + } + self.update_inventory_item_object_like_cpp(item_guid, |item| { + item.set_count(new_count); + item.set_loot_generated(false); + }); + self.sync_object_accessor_player(); + self.send_packet(&UpdateObject::item_stack_count_update( + item_guid, + self.player_map_id_like_cpp(), + new_count, + )); + return; } - LOOT_TYPE_INSIGNIA_LIKE_CPP => LOOT_TYPE_SKINNING_LIKE_CPP, - LOOT_TYPE_FISHINGHOLE_LIKE_CPP | LOOT_TYPE_FISHING_JUNK_LIKE_CPP => { - LOOT_TYPE_FISHING_LIKE_CPP + + let mut tx = SqlTransaction::new(); + let should_expire_refund = runtime_item + .as_ref() + .is_some_and(|item_object| item_object.is_refundable()); + if should_expire_refund { + let mut del_refund = char_db.prepare(CharStatements::DEL_ITEM_REFUND_INSTANCE); + del_refund.set_u64(0, item.db_guid); + tx.append(del_refund); } - _ => loot_type, - } -} -fn loot_is_looted_like_cpp(loot: &CreatureLoot) -> bool { - loot.coins == 0 && loot.unlooted_count == 0 -} + let mut del_inv = char_db.prepare(CharStatements::DEL_CHAR_INVENTORY_ITEM); + del_inv.set_u64(0, player_guid.counter() as u64); + del_inv.set_u64(1, item.db_guid); + tx.append(del_inv); -fn mark_loot_allowed_for_player_like_cpp(loot: &mut CreatureLoot, player_guid: ObjectGuid) { - if !player_guid.is_empty() && !loot.allowed_looters.contains(&player_guid) { - loot.allowed_looters.push(player_guid); - } + let mut del_item = char_db.prepare(CharStatements::DEL_ITEM_INSTANCE); + del_item.set_u64(0, item.db_guid); + tx.append(del_item); - for entry in &mut loot.items { - if entry.allowed_looters.is_empty() || entry.flags.freeforall { - entry.add_allowed_looter_like_cpp(player_guid); + if let Err(e) = char_db.commit_transaction(tx).await { + warn!("LootRelease: delete fully looted item failed: {e}"); + return; } - } - let existing_ffa_item_ids: Vec = loot - .player_ffa_items - .iter() - .find(|(player, _)| *player == player_guid) - .map(|(_, items)| items.iter().map(|item| item.loot_list_id).collect()) - .unwrap_or_default(); - let mut ffa_items = Vec::new(); - for entry in &mut loot.items { - if entry.flags.freeforall - && entry.has_allowed_looter_like_cpp(player_guid) - && !existing_ffa_item_ids.contains(&entry.loot_list_id) - { - ffa_items.push(NotNormalLootItem { - loot_list_id: entry.loot_list_id, - is_looted: false, + self.remove_fully_looted_runtime_item(bag, slot, item.guid); + + if should_expire_refund { + self.send_packet(&ItemExpirePurchaseRefund { + item_guid: item.guid, }); - loot.unlooted_count = loot.unlooted_count.saturating_add(1); - } else if !entry.flags.freeforall - && entry.has_allowed_looter_like_cpp(player_guid) - && !entry.flags.counted - { - entry.flags.counted = true; - loot.unlooted_count = loot.unlooted_count.saturating_add(1); } - } - if !ffa_items.is_empty() { - match loot - .player_ffa_items - .iter_mut() - .find(|(player, _)| *player == player_guid) - { - Some((_, existing)) => existing.extend(ffa_items), - None => loot.player_ffa_items.push((player_guid, ffa_items)), + // Player-values update and stat refresh only apply to top-level slots. + if bag == INVENTORY_SLOT_BAG_0 { + let mut visible_item_changes = Vec::new(); + let mut virtual_item_changes = Vec::new(); + if (slot as usize) < 19 { + visible_item_changes.push((slot, 0i32, 0u16, 0u16)); + } + if slot >= 15 && slot <= 17 { + virtual_item_changes.push((slot - 15, 0i32, 0u16, 0u16)); + } + + self.send_player_values_update_from_entity_bridge( + &[(slot, ObjectGuid::EMPTY)], + &visible_item_changes, + &virtual_item_changes, + &[], + None, + ); + + if slot < 19 { + self.send_stat_update(); + } } } } -#[cfg(test)] -fn assign_represented_personal_loot_items_like_cpp( - loot: &mut CreatureLoot, - tappers: &[ObjectGuid], - rng: &mut R, -) { - if tappers.is_empty() { - return; - } - - loot.unlooted_count = 0; - loot.player_ffa_items.clear(); - - for entry in &mut loot.items { - entry.allowed_looters.clear(); - entry.flags.counted = false; +fn durable_loot_item_fanout_viewers_like_cpp( + precommit_viewers: &[ObjectGuid], + committed_viewers: &[ObjectGuid], +) -> HashSet { + precommit_viewers + .iter() + .chain(committed_viewers) + .copied() + .collect() +} - let chosen_tapper = tappers[rng.gen_range(0..tappers.len())]; - entry.add_allowed_looter_like_cpp(chosen_tapper); +fn master_loot_error_for_inventory_result_like_cpp(result: InventoryResult) -> Option { + match result { + InventoryResult::Ok => None, + InventoryResult::ItemMaxCount => Some(LOOT_ERROR_MASTER_UNIQUE_ITEM_LIKE_CPP), + InventoryResult::InvFull => Some(LOOT_ERROR_MASTER_INV_FULL_LIKE_CPP), + _ => Some(LOOT_ERROR_MASTER_OTHER_LIKE_CPP), } +} - rebuild_represented_personal_loot_counts_like_cpp(loot); +// ── Loot generation ─────────────────────────────────────────────── + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +struct ItemTemplateAddonLootMetadataLikeCpp { + flags_cu: u32, + quest_log_item_id: i32, } -fn rebuild_represented_personal_loot_counts_like_cpp(loot: &mut CreatureLoot) { - loot.unlooted_count = 0; - loot.player_ffa_items.clear(); +#[derive(Debug, Clone, PartialEq, Eq)] +struct RepresentedLootPlayerContext { + race: u8, + class: u8, + gender: u8, + level: u8, + known_spells: Vec, + active_quest_statuses: HashMap, + active_quest_objective_counts: HashMap>, + rewarded_quests: HashSet, + inventory_item_counts: HashMap, + is_current: bool, +} - for entry in &mut loot.items { - entry.ffa_looted_by.clear(); - entry.flags.counted = false; +impl RepresentedLootPlayerContext { + fn quest_status(&self, quest_id: u32) -> u8 { + self.active_quest_statuses + .get(&quest_id) + .copied() + .or_else(|| { + self.rewarded_quests + .contains(&quest_id) + .then_some(QUEST_STATUS_REWARDED_LIKE_CPP) + }) + .unwrap_or(QUEST_STATUS_NONE_LIKE_CPP) + } - if entry.flags.freeforall { - for looter in &entry.allowed_looters { - match loot - .player_ffa_items - .iter_mut() - .find(|(player, _)| player == looter) - { - Some((_, existing)) => existing.push(NotNormalLootItem { - loot_list_id: entry.loot_list_id, - is_looted: false, - }), - None => loot.player_ffa_items.push(( - *looter, - vec![NotNormalLootItem { - loot_list_id: entry.loot_list_id, - is_looted: false, - }], - )), - } - loot.unlooted_count = loot.unlooted_count.saturating_add(1); - } - } else if !entry.allowed_looters.is_empty() { - entry.flags.counted = true; - loot.unlooted_count = loot.unlooted_count.saturating_add(1); - } + fn inventory_item_count(&self, item_id: u32) -> u32 { + self.inventory_item_counts + .get(&item_id) + .copied() + .unwrap_or(0) } } -fn loot_player_has_unlooted_ffa_item_like_cpp( - loot: &CreatureLoot, - player_guid: ObjectGuid, - loot_list_id: u8, -) -> bool { - loot.player_ffa_items - .iter() - .find(|(player, _)| *player == player_guid) - .is_some_and(|(_, items)| { - items - .iter() - .any(|item| item.loot_list_id == loot_list_id && !item.is_looted) - }) +impl ItemTemplateAddonLootMetadataLikeCpp { + fn ignores_quest_status(self) -> bool { + self.flags_cu & ITEM_FLAGS_CU_IGNORE_QUEST_STATUS_LIKE_CPP != 0 + } + + fn follows_loot_rules(self) -> bool { + self.flags_cu & ITEM_FLAGS_CU_FOLLOW_LOOT_RULES_LIKE_CPP != 0 + } } -fn loot_item_is_looted_for_player_like_cpp( - loot: &CreatureLoot, - entry: &LootEntry, - player_guid: ObjectGuid, -) -> bool { - if entry.flags.freeforall { - !loot_player_has_unlooted_ffa_item_like_cpp(loot, player_guid, entry.loot_list_id) +fn player_class_mask_like_cpp(class_id: u8) -> Option { + if (1..=13).contains(&class_id) { + Some(1_u32 << (class_id - 1)) } else { - entry.taken + None } } -fn mark_loot_item_looted_for_player_like_cpp( - loot: &mut CreatureLoot, - loot_list_id: u8, - player_guid: ObjectGuid, -) { - let should_decrement = loot - .items - .iter() - .find(|entry| entry.loot_list_id == loot_list_id) - .is_some_and(|entry| !loot_item_is_looted_for_player_like_cpp(loot, entry, player_guid)); +fn player_race_mask_like_cpp(race_id: u8) -> Option { + let bit = match race_id { + 1..=11 => race_id - 1, + 22 => 21, + 24..=32 => race_id - 1, + 34 => 11, + 35 => 12, + 36 => 13, + 37 => 14, + 52 => 16, + 70 => 15, + _ => return None, + }; + Some(1_u32 << bit) +} - if let Some(entry) = loot - .items - .iter_mut() - .find(|entry| entry.loot_list_id == loot_list_id) - { - entry.mark_looted_for_player_like_cpp(player_guid); - if entry.flags.freeforall { - if let Some((_, items)) = loot - .player_ffa_items - .iter_mut() - .find(|(player, _)| *player == player_guid) - && let Some(item) = items - .iter_mut() - .find(|item| item.loot_list_id == loot_list_id) - { - item.is_looted = true; - } - } - if should_decrement { - loot.unlooted_count = loot.unlooted_count.saturating_sub(1); - } +fn player_team_for_race_cpp_representable(race: u8) -> u32 { + match race { + 2 | 5 | 6 | 8 | 9 | 10 | 26 | 27 | 28 | 31 | 35 | 36 | 70 => 67, + _ => 469, } } -fn represented_loot_response_items_like_cpp( - loot: &CreatureLoot, - player_guid: ObjectGuid, -) -> Vec { - loot.items - .iter() - .filter_map(|entry| { - let ui_type = loot_item_ui_type_for_player_like_cpp( - player_guid, - &entry.allowed_looters, - loot_item_is_looted_for_player_like_cpp(loot, entry, player_guid), - entry.flags.freeforall, - loot_player_has_unlooted_ffa_item_like_cpp(loot, player_guid, entry.loot_list_id), - entry.flags.needs_quest, - entry.flags.follow_loot_rules, - loot.loot_method, - loot.round_robin_player, - loot.loot_master, - entry.flags.under_threshold, - entry.flags.blocked, - entry.roll_winner, - )?; +fn represented_item_faction_flags_block_player_like_cpp(flags2: Option, race: u8) -> bool { + let Some(flags2) = flags2 else { + return false; + }; - Some(LootItemData { - item_type: 0, - ui_type, - can_trade_to_tap_list: false, - loot: ItemInstance { - item_id: entry.item_id as i32, - ..ItemInstance::default() - }, - loot_list_id: entry.loot_list_id, - quantity: entry.quantity, - loot_item_type: 0, - }) - }) - .collect() + let team = player_team_for_race_cpp_representable(race); + ((flags2 & ItemFlags2::FactionHorde as u32) != 0 && team != 67) + || ((flags2 & ItemFlags2::FactionAlliance as u32) != 0 && team != 469) } -fn looted_corpse_decay_secs_like_cpp( - is_fully_skinned: bool, - corpse_delay_secs: u32, - ignore_decay_ratio: bool, - corpse_decay_looted_rate: f32, -) -> u32 { - if is_fully_skinned { - return 0; +fn player_quest_status_mask_like_cpp(status: Option, rewarded: bool) -> u32 { + if rewarded { + return 0x40; } - let rate = if ignore_decay_ratio { - 1.0 - } else { - corpse_decay_looted_rate.max(0.0) - }; - ((corpse_delay_secs as f32) * rate) as u32 + match status { + None => 0x01, + Some(QUEST_STATUS_COMPLETE_LIKE_CPP) => 0x02, + Some(QUEST_STATUS_INCOMPLETE_LIKE_CPP) => 0x08, + Some(QUEST_STATUS_FAILED_LIKE_CPP) => 0x20, + _ => 0, + } } -fn loot_can_be_opened_by_player_like_cpp(loot: &CreatureLoot, player_guid: ObjectGuid) -> bool { - if loot_is_looted_like_cpp(loot) { - return false; +fn generated_creature_loot_item_to_entry_like_cpp( + item: GeneratedLootItem, + addon_metadata: ItemTemplateAddonLootMetadataLikeCpp, +) -> LootEntry { + LootEntry { + loot_list_id: item.loot_list_id as u8, + item_id: item.item_id, + quantity: item.count, + random_properties_id: item.random_properties_id, + random_properties_seed: item.random_properties_seed, + item_context: item.context, + flags: LootEntryFlags { + follow_loot_rules: !item.needs_quest || addon_metadata.follows_loot_rules(), + freeforall: item.free_for_all, + blocked: item.is_blocked, + counted: item.is_counted, + under_threshold: item.is_under_threshold, + needs_quest: item.needs_quest, + }, + allowed_looters: Vec::new(), + roll_winner: ObjectGuid::EMPTY, + ffa_looted_by: Vec::new(), + taken: item.is_looted, } - - loot_has_item_for_all_like_cpp(loot, player_guid) - || loot_has_item_for_player_like_cpp(loot, player_guid) } -fn loot_has_over_threshold_item_like_cpp(loot: &CreatureLoot) -> bool { - loot.items - .iter() - .any(|entry| !entry.taken && entry.is_over_threshold_like_cpp()) +fn generated_shared_gameobject_loot_item_to_entry_like_cpp( + item: GeneratedLootItem, + addon_metadata: ItemTemplateAddonLootMetadataLikeCpp, + allowed_looters: &[ObjectGuid], + mut item_allowed_for_player: FAllowed, +) -> LootEntry +where + FAllowed: FnMut(LootStoreItemContext, ObjectGuid) -> bool, +{ + let store_item_context = item.store_item_context; + let mut entry = generated_creature_loot_item_to_entry_like_cpp(item, addon_metadata); + for looter in allowed_looters { + if item_allowed_for_player(store_item_context, *looter) { + entry.add_allowed_looter_like_cpp(*looter); + } + } + entry } -fn connected_roll_looters_like_cpp( - entry: &LootEntry, - player_guid: ObjectGuid, - current_map_id: u16, - player_registry: Option<&PlayerRegistry>, -) -> Vec { - let mut looters = Vec::new(); - - for looter in &entry.allowed_looters { - if *looter == player_guid { - looters.push(*looter); - continue; - } - - let Some(registry) = player_registry else { - continue; - }; - let Some(player) = registry.get(looter) else { - continue; - }; - if player.map_id == current_map_id { - looters.push(*looter); - } - } - - looters.sort_by_key(|guid| (guid.high_value(), guid.low_value())); - looters.dedup(); - looters +#[derive(Debug, Clone)] +struct RepresentedCreatureLootStateLikeCpp { + is_alive: bool, + position: wow_core::Position, + level: u8, + entry: u32, + loot_id: u32, + gold_min: u32, + gold_max: u32, + dungeon_encounter_id: u32, + tappers: Vec, + loot_lifecycle_revision: u64, } -fn represented_max_enchanting_skill_like_cpp( - looters: &[ObjectGuid], - current_player_guid: ObjectGuid, - current_player_enchanting_skill: u16, - player_registry: Option<&PlayerRegistry>, -) -> u16 { - looters.iter().fold(0, |max_skill, looter| { - if *looter == current_player_guid { - max_skill.max(current_player_enchanting_skill) - } else { - max_skill.max( - player_registry - .and_then(|registry| registry.get(looter)) - .map(|player| player.enchanting_skill) - .unwrap_or(0), - ) - } - }) +#[derive(Debug, Clone)] +struct RepresentedGameObjectLootInstallObservationLikeCpp { + authority: OwnedLootAuthority, + object_generation: u64, + loot_lifecycle_revision: u64, } -fn start_loot_roll_packet_like_cpp( - loot_obj: ObjectGuid, - map_id: u16, - loot_method: u8, - entry: &LootEntry, - valid_rolls: u8, - dungeon_encounter_id: i32, -) -> StartLootRoll { - StartLootRoll { - loot_obj, - map_id: map_id as i32, - roll_time_ms: LOOT_ROLL_TIMEOUT_MS_LIKE_CPP, - method: loot_method, - valid_rolls, - loot_roll_ineligible_reason: [0; 4], - item: LootItemData { - item_type: 0, - ui_type: LOOT_SLOT_TYPE_ROLL_ONGOING_LIKE_CPP, - can_trade_to_tap_list: entry.allowed_looters.len() > 1, - loot: ItemInstance { - item_id: entry.item_id as i32, - random_properties_id: entry.random_properties_id, - random_properties_seed: entry.random_properties_seed, - ..ItemInstance::default() - }, - loot_list_id: entry.loot_list_id, - quantity: entry.quantity, - loot_item_type: 0, - }, - dungeon_encounter_id, - } +#[derive(Debug, Clone, Copy)] +struct RepresentedGameObjectLootStateLikeCpp { + position: Option, + display_id: Option, + scale: f32, + rotation: [f32; 4], + go_type: Option, + interact_radius_override: Option, + lock_id: Option, + owner_guid: Option, } -fn loot_roll_broadcast_item_like_cpp(entry: &LootEntry, ui_type: u8) -> LootItemData { - LootItemData { - item_type: 0, - ui_type, - can_trade_to_tap_list: entry.allowed_looters.len() > 1, - loot: ItemInstance { - item_id: entry.item_id as i32, - random_properties_id: entry.random_properties_id, - random_properties_seed: entry.random_properties_seed, - ..ItemInstance::default() - }, - loot_list_id: entry.loot_list_id, - quantity: entry.quantity, - loot_item_type: 0, +pub(crate) fn represented_gameobject_interaction_distance_like_cpp( + go_type: Option, + interact_radius_override: Option, +) -> f32 { + // C++ ref: GameObject.cpp GetInteractionDistance(). + // Spell-lock range remains with the typed GameObject/SpellInfo port. + if let Some(override_hundredths) = interact_radius_override.filter(|value| *value != 0) { + return override_hundredths as f32 / 100.0; } -} -fn roll_chance_with_rate_like_cpp(chance: f32, rate: f32, rng: &mut R) -> bool { - if chance >= 100.0 { - return true; + match go_type.map(u32::from) { + Some(GAMEOBJECT_TYPE_AREADAMAGE) => 0.0, + Some(GAMEOBJECT_TYPE_QUESTGIVER) + | Some(GAMEOBJECT_TYPE_TEXT) + | Some(GAMEOBJECT_TYPE_FLAGSTAND) + | Some(GAMEOBJECT_TYPE_FLAGDROP) + | Some(GAMEOBJECT_TYPE_MINI_GAME) => 5.5555553, + Some(GAMEOBJECT_TYPE_BINDER) => 10.0, + Some(GAMEOBJECT_TYPE_CHAIR) | Some(GAMEOBJECT_TYPE_BARBER_CHAIR) => 3.0, + Some(GAMEOBJECT_TYPE_FISHING_NODE) => 100.0, + Some(GAMEOBJECT_TYPE_FISHING_HOLE) => 20.0 + wow_movement::CONTACT_DISTANCE_LIKE_CPP, + Some(GAMEOBJECT_TYPE_CAMERA) + | Some(GAMEOBJECT_TYPE_MAP_OBJECT) + | Some(GAMEOBJECT_TYPE_DUNGEON_DIFFICULTY) + | Some(GAMEOBJECT_TYPE_DESTRUCTIBLE_BUILDING) + | Some(GAMEOBJECT_TYPE_DOOR) => 5.0, + Some(GAMEOBJECT_TYPE_GUILD_BANK) | Some(GAMEOBJECT_TYPE_MAILBOX) => 10.0, + _ => 5.0, } - rng.gen_range(0.0f32..100.0f32) < chance * rate -} - -fn referenced_loot_max_count_like_cpp(max_count: u8, rate: f32) -> u32 { - ((max_count as f32) * rate) as u32 } -fn represented_disenchant_loot_plain_row_can_roll_like_cpp( - row: &LootStoreItem, - item_exists: bool, +fn represented_gameobject_display_box_contains_like_cpp( + go_position: wow_core::Position, + player_position: wow_core::Position, + display_info: &wow_data::GameObjectDisplayInfoEntry, + scale: f32, + rotation: [f32; 4], + radius: f32, ) -> bool { - row.can_roll_as_plain_entry_like_cpp(item_exists, LOOT_MODE_DEFAULT_LIKE_CPP) -} - -fn represented_disenchant_loot_reference_row_can_roll_like_cpp(row: &LootStoreItem) -> bool { - row.can_roll_as_reference_entry_like_cpp(LOOT_MODE_DEFAULT_LIKE_CPP) + let min_x = display_info.geo_box_min.x * scale - radius; + let min_y = display_info.geo_box_min.y * scale - radius; + let min_z = display_info.geo_box_min.z * scale - radius; + let max_x = display_info.geo_box_max.x * scale + radius; + let max_y = display_info.geo_box_max.y * scale + radius; + let max_z = display_info.geo_box_max.z * scale + radius; + + let dx = player_position.x - go_position.x; + let dy = player_position.y - go_position.y; + let dz = player_position.z - go_position.z; + let [qx, qy, qz, qw] = rotation; + let iqx = -qx; + let iqy = -qy; + let iqz = -qz; + + let tx = 2.0 * (iqy * dz - iqz * dy); + let ty = 2.0 * (iqz * dx - iqx * dz); + let tz = 2.0 * (iqx * dy - iqy * dx); + let local_x = dx + qw * tx + (iqy * tz - iqz * ty); + let local_y = dy + qw * ty + (iqz * tx - iqx * tz); + let local_z = dz + qw * tz + (iqx * ty - iqy * tx); + + local_x >= min_x + && local_x <= max_x + && local_y >= min_y + && local_y <= max_y + && local_z >= min_z + && local_z <= max_z } -fn add_loot_item_stacks_like_cpp( - loot_items: &mut Vec, - item_id: u32, - mut count: u32, - max_stack_size: u32, - flags: LootEntryFlags, -) { - while count > 0 && loot_items.len() < MAX_NR_LOOT_ITEMS_LIKE_CPP { - let quantity = count.min(max_stack_size); - loot_items.push(LootEntry { - loot_list_id: loot_items.len() as u8, - item_id, - quantity, - random_properties_id: 0, - random_properties_seed: 0, - item_context: 0, - flags, - allowed_looters: Vec::new(), - roll_winner: ObjectGuid::EMPTY, - ffa_looted_by: Vec::new(), - taken: false, - }); - count = count.saturating_sub(max_stack_size); +#[cfg(test)] +fn represented_loot_object_guid_like_cpp(owner: ObjectGuid) -> ObjectGuid { + if owner.is_empty() { + return ObjectGuid::EMPTY; } + + ObjectGuid::create_world_object( + HighGuid::LootObject, + 0, + owner.realm_id(), + owner.map_id(), + 0, + 0, + owner.counter(), + ) } -#[derive(Debug, Clone)] -struct DisenchantLootTemplateFrame { - template: LootTemplate, - entry_index: usize, - group_index: usize, - requested_group_id: u8, +/// Unit fixtures that predate canonical map objects may exercise packet-cache +/// behavior locally. This is a compile-time false branch in production: live +/// Creature/GameObject claims fail closed without their map-owned authority. +const fn represented_local_loot_fixture_allowed_like_cpp() -> bool { + cfg!(test) } -fn disenchant_loot_template_frame_like_cpp( - rows: Vec, - requested_group_id: u8, -) -> DisenchantLootTemplateFrame { - let mut template = LootTemplate::default(); - for row in rows { - template.add_entry_like_cpp(row); +fn loot_type_for_client_like_cpp(loot_type: u8) -> u8 { + match loot_type { + LOOT_TYPE_PROSPECTING_LIKE_CPP | LOOT_TYPE_MILLING_LIKE_CPP => { + LOOT_TYPE_DISENCHANTING_LIKE_CPP + } + LOOT_TYPE_INSIGNIA_LIKE_CPP => LOOT_TYPE_SKINNING_LIKE_CPP, + LOOT_TYPE_FISHINGHOLE_LIKE_CPP | LOOT_TYPE_FISHING_JUNK_LIKE_CPP => { + LOOT_TYPE_FISHING_LIKE_CPP + } + _ => loot_type, } +} - DisenchantLootTemplateFrame { - template, - entry_index: 0, - group_index: 0, - requested_group_id, - } +fn loot_is_looted_like_cpp(loot: &CreatureLoot) -> bool { + loot.coins == 0 && loot.unlooted_count == 0 } -#[derive(Debug, Clone, Copy)] -enum DisenchantLootTemplateTable { - Disenchant, - Reference, +fn direct_item_count_after_loot_release_like_cpp( + current_count: u32, + maximum_destroy_count: Option, +) -> u32 { + let destroy_count = maximum_destroy_count + .unwrap_or(current_count) + .min(current_count); + current_count.saturating_sub(destroy_count) } -impl DisenchantLootTemplateTable { - fn name(self) -> &'static str { - match self { - Self::Disenchant => "disenchant_loot_template", - Self::Reference => "reference_loot_template", - } +fn mark_loot_allowed_for_player_like_cpp(loot: &mut CreatureLoot, player_guid: ObjectGuid) { + if !player_guid.is_empty() && !loot.allowed_looters.contains(&player_guid) { + loot.allowed_looters.push(player_guid); } -} -fn represented_loot_roll_finish_winner_like_cpp( - state: &RepresentedLootRollState, -) -> Option> { - let mut winner = None; - let mut has_need = false; + for entry in &mut loot.items { + if entry.allowed_looters.is_empty() || entry.flags.freeforall { + entry.add_allowed_looter_like_cpp(player_guid); + } + } - for (player_guid, vote) in &state.voters { - match vote.vote { - ROLL_VOTE_NEED_LIKE_CPP => { - if !has_need - || winner.is_none_or(|(_, current): (ObjectGuid, RepresentedLootRollVote)| { - vote.roll_number > current.roll_number - }) - { - has_need = true; - winner = Some((*player_guid, *vote)); - } - } - ROLL_VOTE_GREED_LIKE_CPP | ROLL_VOTE_DISENCHANT_LIKE_CPP => { - if !has_need - && winner.is_none_or(|(_, current): (ObjectGuid, RepresentedLootRollVote)| { - vote.roll_number > current.roll_number - }) - { - winner = Some((*player_guid, *vote)); - } - } - ROLL_VOTE_PASS_LIKE_CPP | ROLL_VOTE_NOT_VALID_LIKE_CPP => {} - ROLL_VOTE_NOT_EMITTED_YET_LIKE_CPP => return None, - _ => {} + let existing_ffa_item_ids: Vec = loot + .player_ffa_items + .iter() + .find(|(player, _)| *player == player_guid) + .map(|(_, items)| items.iter().map(|item| item.loot_list_id).collect()) + .unwrap_or_default(); + let mut ffa_items = Vec::new(); + for entry in &mut loot.items { + if entry.flags.freeforall + && entry.has_allowed_looter_like_cpp(player_guid) + && !existing_ffa_item_ids.contains(&entry.loot_list_id) + { + ffa_items.push(NotNormalLootItem { + loot_list_id: entry.loot_list_id, + is_looted: false, + }); + loot.unlooted_count = loot.unlooted_count.saturating_add(1); + } else if !entry.flags.freeforall + && entry.has_allowed_looter_like_cpp(player_guid) + && !entry.flags.counted + { + entry.flags.counted = true; + loot.unlooted_count = loot.unlooted_count.saturating_add(1); } } - Some(winner) + if !ffa_items.is_empty() { + match loot + .player_ffa_items + .iter_mut() + .find(|(player, _)| *player == player_guid) + { + Some((_, existing)) => existing.extend(ffa_items), + None => loot.player_ffa_items.push((player_guid, ffa_items)), + } + } } -fn represented_loot_roll_current_winner_like_cpp( - state: &RepresentedLootRollState, -) -> Option<(ObjectGuid, RepresentedLootRollVote)> { - let mut winner = None; - let mut has_need = false; - - for (player_guid, vote) in &state.voters { - match vote.vote { - ROLL_VOTE_NEED_LIKE_CPP => { - if !has_need - || winner.is_none_or(|(_, current): (ObjectGuid, RepresentedLootRollVote)| { - vote.roll_number > current.roll_number - }) - { - has_need = true; - winner = Some((*player_guid, *vote)); - } - } - ROLL_VOTE_GREED_LIKE_CPP | ROLL_VOTE_DISENCHANT_LIKE_CPP => { - if !has_need - && winner.is_none_or(|(_, current): (ObjectGuid, RepresentedLootRollVote)| { - vote.roll_number > current.roll_number - }) - { - winner = Some((*player_guid, *vote)); - } - } - ROLL_VOTE_PASS_LIKE_CPP - | ROLL_VOTE_NOT_VALID_LIKE_CPP - | ROLL_VOTE_NOT_EMITTED_YET_LIKE_CPP => {} - _ => {} +/// Completes the shared C++ `Loot::FillLoot` visibility/count state before the +/// generation is published through the object-owned authority. Applying this +/// only to the session cache after publication is unsafe: reconciliation would +/// immediately restore the older authoritative snapshot and lose the tap list. +fn prepare_represented_shared_loot_generation_like_cpp( + loot: &mut CreatureLoot, + allowed_looters: &[ObjectGuid], +) { + for looter in allowed_looters { + if !looter.is_empty() && !loot.allowed_looters.contains(looter) { + loot.allowed_looters.push(*looter); } } + rebuild_represented_personal_loot_counts_preserving_consumed_like_cpp(loot); +} - winner +fn prepare_represented_shared_creature_loot_generation_like_cpp( + loot: &mut CreatureLoot, + allowed_looters: &[ObjectGuid], +) { + for looter in allowed_looters { + mark_loot_allowed_for_player_like_cpp(loot, *looter); + } + rebuild_represented_personal_loot_counts_preserving_consumed_like_cpp(loot); } -fn loot_has_item_for_all_like_cpp(loot: &CreatureLoot, player_guid: ObjectGuid) -> bool { - if loot.coins > 0 { - return true; +#[cfg(test)] +fn assign_represented_personal_loot_items_like_cpp( + loot: &mut CreatureLoot, + tappers: &[ObjectGuid], + rng: &mut R, +) { + if tappers.is_empty() { + return; } - loot.items.iter().any(|entry| { - !entry.taken - && entry.flags.follow_loot_rules - && !entry.flags.freeforall - && entry.has_allowed_looter_like_cpp(player_guid) - }) -} + loot.unlooted_count = 0; + loot.player_ffa_items.clear(); -fn loot_has_item_for_player_like_cpp(loot: &CreatureLoot, player_guid: ObjectGuid) -> bool { - loot.items.iter().any(|entry| { - !loot_item_is_looted_for_player_like_cpp(loot, entry, player_guid) - && entry.has_allowed_looter_like_cpp(player_guid) - && (!entry.flags.follow_loot_rules || entry.flags.freeforall) - }) -} + for entry in &mut loot.items { + entry.allowed_looters.clear(); + entry.flags.counted = false; -fn loot_item_context(context: u8) -> ItemContext { - ::from_u8(context).unwrap_or(ItemContext::None) + let chosen_tapper = tappers[rng.gen_range(0..tappers.len())]; + entry.add_allowed_looter_like_cpp(chosen_tapper); + } + + rebuild_represented_personal_loot_counts_like_cpp(loot); } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct LootStoreRandomProperties { - id: i32, - seed: i32, -} - -fn loot_store_data_can_stack_with_item( - loot_entry: &LootEntry, - random_properties: LootStoreRandomProperties, - item: &Item, -) -> bool { - let data = item.data(); - data.random_properties_id == random_properties.id - && data.property_seed == random_properties.seed - && u8::try_from(data.context).unwrap_or(0) == loot_entry.item_context -} - -impl WorldSession { - fn generate_loot_store_random_properties_with_rng_like_cpp( - &self, - item_id: u32, - rng: &mut R, - ) -> LootStoreRandomProperties { - // C++ Player::StoreLootItem calls ItemEnchantmentMgr::GenerateRandomProperties(itemid). - let random_select = self.item_template_random_select(item_id); - let random_suffix = self.item_template_random_suffix_group_id(item_id); - if random_select == 0 && random_suffix == 0 { - return LootStoreRandomProperties { id: 0, seed: 0 }; - } +fn rebuild_represented_personal_loot_counts_like_cpp(loot: &mut CreatureLoot) { + loot.unlooted_count = 0; + loot.player_ffa_items.clear(); - if random_select != 0 { - let Some(random_properties_id) = - self.select_random_enchantment_from_group_like_cpp(u32::from(random_select), rng) - else { - return LootStoreRandomProperties { id: 0, seed: 0 }; - }; + for entry in &mut loot.items { + entry.ffa_looted_by.clear(); + entry.flags.counted = false; - if self - .item_random_properties_store() - .and_then(|store| store.get(random_properties_id)) - .is_none() - { - return LootStoreRandomProperties { id: 0, seed: 0 }; + if entry.flags.freeforall { + for looter in &entry.allowed_looters { + match loot + .player_ffa_items + .iter_mut() + .find(|(player, _)| player == looter) + { + Some((_, existing)) => existing.push(NotNormalLootItem { + loot_list_id: entry.loot_list_id, + is_looted: false, + }), + None => loot.player_ffa_items.push(( + *looter, + vec![NotNormalLootItem { + loot_list_id: entry.loot_list_id, + is_looted: false, + }], + )), + } + loot.unlooted_count = loot.unlooted_count.saturating_add(1); } - - return LootStoreRandomProperties { - id: i32::try_from(random_properties_id).unwrap_or(0), - seed: 0, - }; - } - - let Some(random_suffix_id) = - self.select_random_enchantment_from_group_like_cpp(u32::from(random_suffix), rng) - else { - return LootStoreRandomProperties { id: 0, seed: 0 }; - }; - - if self - .item_random_suffix_store() - .and_then(|store| store.get(random_suffix_id)) - .is_none() - { - return LootStoreRandomProperties { id: 0, seed: 0 }; + } else if !entry.allowed_looters.is_empty() { + entry.flags.counted = true; + loot.unlooted_count = loot.unlooted_count.saturating_add(1); } + } +} - let seed = self - .item_random_property_template(item_id) - .map(|template| self.random_property_points_like_cpp(template)) - .unwrap_or(0); +/// Rebuild a player-scoped authority pool without resurrecting entries already +/// consumed in the session view. The generation helper above intentionally +/// starts fresh; authority synchronization can also run during release, after +/// `taken`/`ffa_looted_by` have already changed. +fn rebuild_represented_personal_loot_counts_preserving_consumed_like_cpp(loot: &mut CreatureLoot) { + loot.unlooted_count = 0; + loot.player_ffa_items.clear(); - LootStoreRandomProperties { - id: -i32::try_from(random_suffix_id).unwrap_or(0), - seed, + for entry in &mut loot.items { + if entry.flags.freeforall { + entry.flags.counted = false; + for looter in &entry.allowed_looters { + let is_looted = entry.ffa_looted_by.contains(looter); + let item = NotNormalLootItem { + loot_list_id: entry.loot_list_id, + is_looted, + }; + match loot + .player_ffa_items + .iter_mut() + .find(|(player, _)| player == looter) + { + Some((_, items)) => items.push(item), + None => loot.player_ffa_items.push((*looter, vec![item])), + } + if !is_looted { + loot.unlooted_count = loot.unlooted_count.saturating_add(1); + } + } + } else { + entry.flags.counted = !entry.allowed_looters.is_empty(); + if entry.flags.counted && !entry.taken { + loot.unlooted_count = loot.unlooted_count.saturating_add(1); + } } } +} - fn select_random_enchantment_from_group_like_cpp( - &self, - group_id: u32, - rng: &mut R, - ) -> Option { - let group = self - .item_random_enchantment_template_store() - .and_then(|store| store.group(group_id))?; - select_weighted_random_enchantment_like_cpp(group, rng) - } +fn loot_player_has_unlooted_ffa_item_like_cpp( + loot: &CreatureLoot, + player_guid: ObjectGuid, + loot_list_id: u8, +) -> bool { + loot.player_ffa_items + .iter() + .find(|(player, _)| *player == player_guid) + .is_some_and(|(_, items)| { + items + .iter() + .any(|item| item.loot_list_id == loot_list_id && !item.is_looted) + }) +} - fn random_property_points_like_cpp(&self, template: ItemRandomPropertyTemplateEntry) -> i32 { - let prop_index = - match ::from_i8(template.inventory_type) { - Some(InventoryType::NonEquip) - | Some(InventoryType::Bag) - | Some(InventoryType::Tabard) - | Some(InventoryType::Ammo) - | Some(InventoryType::Quiver) - | Some(InventoryType::Relic) - | None => return 0, - Some(InventoryType::Head) - | Some(InventoryType::Body) - | Some(InventoryType::Chest) - | Some(InventoryType::Legs) - | Some(InventoryType::Weapon2Hand) - | Some(InventoryType::Robe) => 0, - Some(InventoryType::Shoulders) - | Some(InventoryType::Waist) - | Some(InventoryType::Feet) - | Some(InventoryType::Hands) - | Some(InventoryType::Trinket) => 1, - Some(InventoryType::Neck) - | Some(InventoryType::Wrists) - | Some(InventoryType::Finger) - | Some(InventoryType::Shield) - | Some(InventoryType::Cloak) - | Some(InventoryType::Holdable) => 2, - Some(InventoryType::Weapon) - | Some(InventoryType::WeaponMainhand) - | Some(InventoryType::WeaponOffhand) => 3, - Some(InventoryType::Ranged) - | Some(InventoryType::Thrown) - | Some(InventoryType::RangedRight) => 4, - _ => return 0, - }; +fn loot_item_is_looted_for_player_like_cpp( + loot: &CreatureLoot, + entry: &LootEntry, + player_guid: ObjectGuid, +) -> bool { + if entry.flags.freeforall { + !loot_player_has_unlooted_ffa_item_like_cpp(loot, player_guid, entry.loot_list_id) + } else { + entry.taken + } +} - let Some(points) = self - .rand_prop_points_store() - .and_then(|store| store.get(u32::from(template.item_level))) - else { - return 0; - }; +fn mark_loot_item_looted_for_player_like_cpp( + loot: &mut CreatureLoot, + loot_list_id: u8, + player_guid: ObjectGuid, +) { + let should_decrement = loot + .items + .iter() + .find(|entry| entry.loot_list_id == loot_list_id) + .is_some_and(|entry| !loot_item_is_looted_for_player_like_cpp(loot, entry, player_guid)); - match ::from_i8(template.quality) { - Some(ItemQuality::Uncommon) => points.good[prop_index] as i32, - Some(ItemQuality::Rare) | Some(ItemQuality::Heirloom) => { - points.superior[prop_index] as i32 + if let Some(entry) = loot + .items + .iter_mut() + .find(|entry| entry.loot_list_id == loot_list_id) + { + entry.mark_looted_for_player_like_cpp(player_guid); + if entry.flags.freeforall { + if let Some((_, items)) = loot + .player_ffa_items + .iter_mut() + .find(|(player, _)| *player == player_guid) + && let Some(item) = items + .iter_mut() + .find(|item| item.loot_list_id == loot_list_id) + { + item.is_looted = true; } - Some(ItemQuality::Epic) - | Some(ItemQuality::Legendary) - | Some(ItemQuality::Artifact) => points.epic[prop_index] as i32, - _ => 0, + } + if should_decrement { + loot.unlooted_count = loot.unlooted_count.saturating_sub(1); } } } -fn select_weighted_random_enchantment_like_cpp( - group: &[ItemRandomEnchantmentTemplateEntry], - rng: &mut R, -) -> Option { - let valid_rows = group +fn represented_loot_response_items_like_cpp( + loot: &CreatureLoot, + player_guid: ObjectGuid, +) -> Vec { + loot.items .iter() - .filter(|row| (0.000001..=100.0).contains(&row.chance)) - .collect::>(); - let weights = valid_rows.iter().map(|row| row.chance).collect::>(); - let distribution = WeightedIndex::new(weights).ok()?; - Some(valid_rows[distribution.sample(rng)].enchantment_id) -} - -#[derive(Debug, Clone)] -struct PlannedLootNewStack { - slot: u8, - entry_id: u32, - count: u32, - max_durability: u32, - random_properties_id: i32, - random_properties_seed: i32, - item_context: u8, + .filter_map(|entry| { + let ui_type = loot_item_ui_type_for_player_like_cpp( + player_guid, + &entry.allowed_looters, + loot_item_is_looted_for_player_like_cpp(loot, entry, player_guid), + entry.flags.freeforall, + loot_player_has_unlooted_ffa_item_like_cpp(loot, player_guid, entry.loot_list_id), + entry.flags.needs_quest, + entry.flags.follow_loot_rules, + loot.loot_method, + loot.round_robin_player, + loot.loot_master, + entry.flags.under_threshold, + entry.flags.blocked, + entry.roll_winner, + )?; + + Some(LootItemData { + item_type: 0, + ui_type, + can_trade_to_tap_list: false, + loot: ItemInstance { + item_id: entry.item_id as i32, + ..ItemInstance::default() + }, + loot_list_id: entry.loot_list_id, + quantity: entry.quantity, + loot_item_type: 0, + }) + }) + .collect() } -#[cfg(test)] -mod tests { - use super::{ - GAMEOBJECT_TYPE_AREADAMAGE, GAMEOBJECT_TYPE_BINDER, GAMEOBJECT_TYPE_CHAIR, - GAMEOBJECT_TYPE_DOOR, GAMEOBJECT_TYPE_GUILD_BANK, GAMEOBJECT_TYPE_QUESTGIVER, - INVENTORY_SLOT_BAG_0, ITEM_FLAGS_CU_FOLLOW_LOOT_RULES_LIKE_CPP, - ItemTemplateAddonLootMetadataLikeCpp, LOCK_KEY_SKILL_LIKE_CPP, LOCK_KEY_SPELL_LIKE_CPP, - LOOT_METHOD_GROUP_LIKE_CPP, LOOT_METHOD_MASTER_LIKE_CPP, LOOT_MODE_DEFAULT_LIKE_CPP, - LOOT_MODE_JUNK_FISH_LIKE_CPP, LOOT_SLOT_TYPE_ALLOW_LOOT_LIKE_CPP, - LOOT_SLOT_TYPE_ROLL_ONGOING_LIKE_CPP, LootStoreRandomProperties, - ROLL_ALL_TYPE_NO_DISENCHANT_LIKE_CPP, ROLL_FLAG_TYPE_NEED_LIKE_CPP, - ROLL_VOTE_GREED_LIKE_CPP, ROLL_VOTE_NEED_LIKE_CPP, ROLL_VOTE_NOT_EMITTED_YET_LIKE_CPP, - ROLL_VOTE_NOT_VALID_LIKE_CPP, ROLL_VOTE_PASS_LIKE_CPP, RepresentedLootPlayerContext, - SPELL_EFFECT_OPEN_LOCK_LIKE_CPP, SyncChestGameobjectStateAndRefreshLikeCppCommand, - SyncGatheringNodeGameobjectStateAndRefreshLikeCppCommand, - SyncGooberGameobjectStateAndRefreshLikeCppCommand, - assign_represented_personal_loot_items_like_cpp, - generated_creature_loot_item_to_entry_like_cpp, loot_is_looted_like_cpp, loot_item_context, - loot_store_data_can_stack_with_item, loot_type_for_client_like_cpp, - looted_corpse_decay_secs_like_cpp, mark_loot_allowed_for_player_like_cpp, - mark_loot_item_looted_for_player_like_cpp, - represented_gameobject_display_box_contains_like_cpp, - represented_gameobject_interaction_distance_like_cpp, - represented_loot_object_guid_like_cpp, represented_loot_response_items_like_cpp, - select_weighted_random_enchantment_like_cpp, start_loot_roll_packet_like_cpp, - }; - use crate::conditions::QUEST_STATUS_REWARDED_LIKE_CPP; - use crate::session::{ - RepresentedGameObjectSpellCaster, RepresentedGameObjectUseEffect, - RepresentedLootRollCriteriaEvent, SessionState, - }; - use rand::{Rng, SeedableRng, rngs::StdRng}; - use std::time::{Duration, Instant}; - use std::{ - collections::{HashMap, HashSet}, - sync::{Arc, Mutex, RwLock}, - }; - use wow_ai::CreatureAI; - use wow_constants::{ - InventoryResult, InventoryType, ItemBondingType, ItemClass, ItemContext, ItemFlags2, - ItemQuality, TypeId, TypeMask, - }; - use wow_core::{ObjectGuid, Position, guid::HighGuid}; - use wow_data::quest::{ - QUEST_REWARD_REPUTATIONS_COUNT, QuestObjective, QuestStore, QuestTemplate, - }; - use wow_data::{ - AreaTableEntry, AreaTableStore, ChrSpecializationEntry, ChrSpecializationStore, - ItemDisenchantLootEntry, ItemDisenchantLootStore, ItemRandomEnchantmentTemplateEntry, - ItemRandomEnchantmentTemplateStore, ItemRandomPropertiesEntry, ItemRandomPropertiesStore, - ItemRandomPropertyTemplateEntry, ItemRandomSuffixEntry, ItemRandomSuffixStore, ItemRecord, - ItemSparseTemplateEntry, ItemStatsStore, ItemStore, RandPropPointsEntry, - RandPropPointsStore, SpellEffectInfo, SpellInfo, SpellMiscEntry, SpellMiscStore, - SpellRangeEntry, SpellRangeStore, SpellStore, - }; - use wow_database::{CharStatements, StatementDef}; - use wow_entities::{ - AccessorObjectKind, CORPSE_DYNFLAG_LOOTABLE, Corpse, CorpseType, Creature, - CreatureOwnedLoot, GAMEOBJECT_TYPE_CHEST, GAMEOBJECT_TYPE_FISHING_HOLE, - GAMEOBJECT_TYPE_FISHING_NODE, GAMEOBJECT_TYPE_GATHERING_NODE, GAMEOBJECT_TYPE_GOOBER, - GO_DYNFLAG_LO_NO_INTERACT, GameObject, GameObjectLootSource, GameObjectOwnedLoot, - GatheringNodeUseSource, GoState, Item, ItemCreateInfo, LootState, MAX_ITEM_SPELLS, - WorldObject, - }; - use wow_loot::{ - GeneratedLootItem, LOOT_SLOT_TYPE_OWNER_LIKE_CPP, LootConditionRowLikeCpp, LootStore, - LootStoreItem, LootStoreKind, LootStores, LootTemplateRow, - }; - use wow_network::{ - GroupInfo, GroupRegistry, LootDropRatesLikeCpp, LootRollVoteCommand, PendingInvites, - PlayerBroadcastInfo, PlayerRegistry, SessionCommand, - }; - use wow_packet::packets::loot::{ - CreatureLoot, LOOT_ERROR_MASTER_OTHER_LIKE_CPP, LOOT_ERROR_MASTER_UNIQUE_ITEM_LIKE_CPP, - LOOT_ERROR_NO_LOOT_LIKE_CPP, LOOT_ERROR_PLAYER_NOT_FOUND_LIKE_CPP, - LOOT_ERROR_TOO_FAR_LIKE_CPP, LOOT_RESPONSE_DEFAULT_FAILURE_REASON_LIKE_CPP, - LOOT_RESPONSE_DEFAULT_THRESHOLD_LIKE_CPP, LOOT_TYPE_CHEST_LIKE_CPP, - LOOT_TYPE_CORPSE_LIKE_CPP, LOOT_TYPE_CORPSE_PERSONAL_LIKE_CPP, - LOOT_TYPE_DISENCHANTING_LIKE_CPP, LOOT_TYPE_FISHING_JUNK_LIKE_CPP, - LOOT_TYPE_FISHING_LIKE_CPP, LOOT_TYPE_FISHINGHOLE_LIKE_CPP, - LOOT_TYPE_GATHERING_NODE_LIKE_CPP, LOOT_TYPE_INSIGNIA_LIKE_CPP, LOOT_TYPE_ITEM_LIKE_CPP, - LOOT_TYPE_MILLING_LIKE_CPP, LOOT_TYPE_NONE_LIKE_CPP, LOOT_TYPE_PROSPECTING_LIKE_CPP, - LOOT_TYPE_SKINNING_LIKE_CPP, LootEntry, LootEntryFlags, LootRoll, MasterLootItem, - SetLootSpecialization, +fn looted_corpse_decay_secs_like_cpp( + is_fully_skinned: bool, + corpse_delay_secs: u32, + ignore_decay_ratio: bool, + corpse_decay_looted_rate: f32, +) -> u32 { + if is_fully_skinned { + return 0; + } + + let rate = if ignore_decay_ratio { + 1.0 + } else { + corpse_decay_looted_rate.max(0.0) }; - use wow_packet::packets::update::CreatureCreateData; - use wow_packet::{ServerPacket, WorldPacket}; + ((corpse_delay_secs as f32) * rate) as u32 +} + +fn loot_can_be_opened_by_player_like_cpp(loot: &CreatureLoot, player_guid: ObjectGuid) -> bool { + if loot_is_looted_like_cpp(loot) { + return false; + } + + loot_has_item_for_all_like_cpp(loot, player_guid) + || loot_has_item_for_player_like_cpp(loot, player_guid) +} + +fn loot_has_over_threshold_item_like_cpp(loot: &CreatureLoot) -> bool { + loot.items + .iter() + .any(|entry| !entry.taken && entry.is_over_threshold_like_cpp()) +} + +fn connected_roll_looters_like_cpp( + entry: &LootEntry, + player_guid: ObjectGuid, + current_map_id: u16, + current_instance_id: u32, + player_registry: Option<&PlayerRegistry>, +) -> Vec { + let mut looters = Vec::new(); + + for looter in &entry.allowed_looters { + if *looter == player_guid { + looters.push(*looter); + continue; + } + + let Some(registry) = player_registry else { + continue; + }; + let Some(player) = registry.get(looter) else { + continue; + }; + if player.map_id == current_map_id && player.instance_id == current_instance_id { + looters.push(*looter); + } + } + + looters.sort_by_key(|guid| (guid.high_value(), guid.low_value())); + looters.dedup(); + looters +} + +fn represented_max_enchanting_skill_like_cpp( + looters: &[ObjectGuid], + current_player_guid: ObjectGuid, + current_player_enchanting_skill: u16, + player_registry: Option<&PlayerRegistry>, +) -> u16 { + looters.iter().fold(0, |max_skill, looter| { + if *looter == current_player_guid { + max_skill.max(current_player_enchanting_skill) + } else { + max_skill.max( + player_registry + .and_then(|registry| registry.get(looter)) + .map(|player| player.enchanting_skill) + .unwrap_or(0), + ) + } + }) +} + +fn start_loot_roll_packet_like_cpp( + loot_obj: ObjectGuid, + map_id: u16, + loot_method: u8, + entry: &LootEntry, + valid_rolls: u8, + dungeon_encounter_id: i32, +) -> StartLootRoll { + StartLootRoll { + loot_obj, + map_id: map_id as i32, + roll_time_ms: LOOT_ROLL_TIMEOUT_MS_LIKE_CPP, + method: loot_method, + valid_rolls, + loot_roll_ineligible_reason: [0; 4], + item: LootItemData { + item_type: 0, + ui_type: LOOT_SLOT_TYPE_ROLL_ONGOING_LIKE_CPP, + can_trade_to_tap_list: entry.allowed_looters.len() > 1, + loot: ItemInstance { + item_id: entry.item_id as i32, + random_properties_id: entry.random_properties_id, + random_properties_seed: entry.random_properties_seed, + ..ItemInstance::default() + }, + loot_list_id: entry.loot_list_id, + quantity: entry.quantity, + loot_item_type: 0, + }, + dungeon_encounter_id, + } +} + +fn loot_roll_broadcast_item_like_cpp(entry: &LootEntry, ui_type: u8) -> LootItemData { + LootItemData { + item_type: 0, + ui_type, + can_trade_to_tap_list: entry.allowed_looters.len() > 1, + loot: ItemInstance { + item_id: entry.item_id as i32, + random_properties_id: entry.random_properties_id, + random_properties_seed: entry.random_properties_seed, + ..ItemInstance::default() + }, + loot_list_id: entry.loot_list_id, + quantity: entry.quantity, + loot_item_type: 0, + } +} + +fn roll_chance_with_rate_like_cpp(chance: f32, rate: f32, rng: &mut R) -> bool { + if chance >= 100.0 { + return true; + } + rng.gen_range(0.0f32..100.0f32) < chance * rate +} + +fn referenced_loot_max_count_like_cpp(max_count: u8, rate: f32) -> u32 { + ((max_count as f32) * rate) as u32 +} + +fn represented_disenchant_loot_plain_row_can_roll_like_cpp( + row: &LootStoreItem, + item_exists: bool, +) -> bool { + row.can_roll_as_plain_entry_like_cpp(item_exists, LOOT_MODE_DEFAULT_LIKE_CPP) +} + +fn represented_disenchant_loot_reference_row_can_roll_like_cpp(row: &LootStoreItem) -> bool { + row.can_roll_as_reference_entry_like_cpp(LOOT_MODE_DEFAULT_LIKE_CPP) +} + +fn add_loot_item_stacks_like_cpp( + loot_items: &mut Vec, + item_id: u32, + mut count: u32, + max_stack_size: u32, + flags: LootEntryFlags, +) { + while count > 0 && loot_items.len() < MAX_NR_LOOT_ITEMS_LIKE_CPP { + let quantity = count.min(max_stack_size); + loot_items.push(LootEntry { + loot_list_id: loot_items.len() as u8, + item_id, + quantity, + random_properties_id: 0, + random_properties_seed: 0, + item_context: 0, + flags, + allowed_looters: Vec::new(), + roll_winner: ObjectGuid::EMPTY, + ffa_looted_by: Vec::new(), + taken: false, + }); + count = count.saturating_sub(max_stack_size); + } +} + +#[derive(Debug, Clone)] +struct DisenchantLootTemplateFrame { + template: LootTemplate, + entry_index: usize, + group_index: usize, + requested_group_id: u8, +} + +fn disenchant_loot_template_frame_like_cpp( + rows: Vec, + requested_group_id: u8, +) -> DisenchantLootTemplateFrame { + let mut template = LootTemplate::default(); + for row in rows { + template.add_entry_like_cpp(row); + } + + DisenchantLootTemplateFrame { + template, + entry_index: 0, + group_index: 0, + requested_group_id, + } +} + +#[derive(Debug, Clone, Copy)] +enum DisenchantLootTemplateTable { + Disenchant, + Reference, +} + +impl DisenchantLootTemplateTable { + fn name(self) -> &'static str { + match self { + Self::Disenchant => "disenchant_loot_template", + Self::Reference => "reference_loot_template", + } + } +} + +fn represented_loot_roll_finish_winner_like_cpp( + state: &RepresentedLootRollState, +) -> Option> { + let mut winner = None; + let mut has_need = false; + + for (player_guid, vote) in &state.voters { + match vote.vote { + ROLL_VOTE_NEED_LIKE_CPP => { + if !has_need + || winner.is_none_or(|(_, current): (ObjectGuid, RepresentedLootRollVote)| { + vote.roll_number > current.roll_number + }) + { + has_need = true; + winner = Some((*player_guid, *vote)); + } + } + ROLL_VOTE_GREED_LIKE_CPP | ROLL_VOTE_DISENCHANT_LIKE_CPP => { + if !has_need + && winner.is_none_or(|(_, current): (ObjectGuid, RepresentedLootRollVote)| { + vote.roll_number > current.roll_number + }) + { + winner = Some((*player_guid, *vote)); + } + } + ROLL_VOTE_PASS_LIKE_CPP | ROLL_VOTE_NOT_VALID_LIKE_CPP => {} + ROLL_VOTE_NOT_EMITTED_YET_LIKE_CPP => return None, + _ => {} + } + } + + Some(winner) +} + +fn represented_loot_roll_current_winner_like_cpp( + state: &RepresentedLootRollState, +) -> Option<(ObjectGuid, RepresentedLootRollVote)> { + let mut winner = None; + let mut has_need = false; + + for (player_guid, vote) in &state.voters { + match vote.vote { + ROLL_VOTE_NEED_LIKE_CPP => { + if !has_need + || winner.is_none_or(|(_, current): (ObjectGuid, RepresentedLootRollVote)| { + vote.roll_number > current.roll_number + }) + { + has_need = true; + winner = Some((*player_guid, *vote)); + } + } + ROLL_VOTE_GREED_LIKE_CPP | ROLL_VOTE_DISENCHANT_LIKE_CPP => { + if !has_need + && winner.is_none_or(|(_, current): (ObjectGuid, RepresentedLootRollVote)| { + vote.roll_number > current.roll_number + }) + { + winner = Some((*player_guid, *vote)); + } + } + ROLL_VOTE_PASS_LIKE_CPP + | ROLL_VOTE_NOT_VALID_LIKE_CPP + | ROLL_VOTE_NOT_EMITTED_YET_LIKE_CPP => {} + _ => {} + } + } + + winner +} + +fn loot_has_item_for_all_like_cpp(loot: &CreatureLoot, player_guid: ObjectGuid) -> bool { + if loot.coins > 0 { + return true; + } + + loot.items.iter().any(|entry| { + !entry.taken + && entry.flags.follow_loot_rules + && !entry.flags.freeforall + && entry.has_allowed_looter_like_cpp(player_guid) + }) +} + +fn loot_has_item_for_player_like_cpp(loot: &CreatureLoot, player_guid: ObjectGuid) -> bool { + loot.items.iter().any(|entry| { + !loot_item_is_looted_for_player_like_cpp(loot, entry, player_guid) + && entry.has_allowed_looter_like_cpp(player_guid) + && (!entry.flags.follow_loot_rules || entry.flags.freeforall) + }) +} + +fn loot_item_context(context: u8) -> ItemContext { + ::from_u8(context).unwrap_or(ItemContext::None) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct LootStoreRandomProperties { + id: i32, + seed: i32, +} + +fn loot_store_data_can_stack_with_item( + loot_entry: &LootEntry, + random_properties: LootStoreRandomProperties, + item: &Item, +) -> bool { + let data = item.data(); + data.random_properties_id == random_properties.id + && data.property_seed == random_properties.seed + && u8::try_from(data.context).unwrap_or(0) == loot_entry.item_context +} + +impl WorldSession { + fn generate_loot_store_random_properties_with_rng_like_cpp( + &self, + item_id: u32, + rng: &mut R, + ) -> LootStoreRandomProperties { + // C++ Player::StoreLootItem calls ItemEnchantmentMgr::GenerateRandomProperties(itemid). + let random_select = self.item_template_random_select(item_id); + let random_suffix = self.item_template_random_suffix_group_id(item_id); + if random_select == 0 && random_suffix == 0 { + return LootStoreRandomProperties { id: 0, seed: 0 }; + } + + if random_select != 0 { + let Some(random_properties_id) = + self.select_random_enchantment_from_group_like_cpp(u32::from(random_select), rng) + else { + return LootStoreRandomProperties { id: 0, seed: 0 }; + }; + + if self + .item_random_properties_store() + .and_then(|store| store.get(random_properties_id)) + .is_none() + { + return LootStoreRandomProperties { id: 0, seed: 0 }; + } + + return LootStoreRandomProperties { + id: i32::try_from(random_properties_id).unwrap_or(0), + seed: 0, + }; + } + + let Some(random_suffix_id) = + self.select_random_enchantment_from_group_like_cpp(u32::from(random_suffix), rng) + else { + return LootStoreRandomProperties { id: 0, seed: 0 }; + }; + + if self + .item_random_suffix_store() + .and_then(|store| store.get(random_suffix_id)) + .is_none() + { + return LootStoreRandomProperties { id: 0, seed: 0 }; + } + + let seed = self + .item_random_property_template(item_id) + .map(|template| self.random_property_points_like_cpp(template)) + .unwrap_or(0); + + LootStoreRandomProperties { + id: -i32::try_from(random_suffix_id).unwrap_or(0), + seed, + } + } + + fn select_random_enchantment_from_group_like_cpp( + &self, + group_id: u32, + rng: &mut R, + ) -> Option { + let group = self + .item_random_enchantment_template_store() + .and_then(|store| store.group(group_id))?; + select_weighted_random_enchantment_like_cpp(group, rng) + } + + fn random_property_points_like_cpp(&self, template: ItemRandomPropertyTemplateEntry) -> i32 { + let prop_index = + match ::from_i8(template.inventory_type) { + Some(InventoryType::NonEquip) + | Some(InventoryType::Bag) + | Some(InventoryType::Tabard) + | Some(InventoryType::Ammo) + | Some(InventoryType::Quiver) + | Some(InventoryType::Relic) + | None => return 0, + Some(InventoryType::Head) + | Some(InventoryType::Body) + | Some(InventoryType::Chest) + | Some(InventoryType::Legs) + | Some(InventoryType::Weapon2Hand) + | Some(InventoryType::Robe) => 0, + Some(InventoryType::Shoulders) + | Some(InventoryType::Waist) + | Some(InventoryType::Feet) + | Some(InventoryType::Hands) + | Some(InventoryType::Trinket) => 1, + Some(InventoryType::Neck) + | Some(InventoryType::Wrists) + | Some(InventoryType::Finger) + | Some(InventoryType::Shield) + | Some(InventoryType::Cloak) + | Some(InventoryType::Holdable) => 2, + Some(InventoryType::Weapon) + | Some(InventoryType::WeaponMainhand) + | Some(InventoryType::WeaponOffhand) => 3, + Some(InventoryType::Ranged) + | Some(InventoryType::Thrown) + | Some(InventoryType::RangedRight) => 4, + _ => return 0, + }; + + let Some(points) = self + .rand_prop_points_store() + .and_then(|store| store.get(u32::from(template.item_level))) + else { + return 0; + }; + + match ::from_i8(template.quality) { + Some(ItemQuality::Uncommon) => points.good[prop_index] as i32, + Some(ItemQuality::Rare) | Some(ItemQuality::Heirloom) => { + points.superior[prop_index] as i32 + } + Some(ItemQuality::Epic) + | Some(ItemQuality::Legendary) + | Some(ItemQuality::Artifact) => points.epic[prop_index] as i32, + _ => 0, + } + } +} + +fn select_weighted_random_enchantment_like_cpp( + group: &[ItemRandomEnchantmentTemplateEntry], + rng: &mut R, +) -> Option { + let valid_rows = group + .iter() + .filter(|row| (0.000001..=100.0).contains(&row.chance)) + .collect::>(); + let weights = valid_rows.iter().map(|row| row.chance).collect::>(); + let distribution = WeightedIndex::new(weights).ok()?; + Some(valid_rows[distribution.sample(rng)].enchantment_id) +} + +#[derive(Debug, Clone)] +struct PlannedLootNewStack { + slot: u8, + entry_id: u32, + count: u32, + max_durability: u32, + dynamic_flags: u32, + random_properties_id: i32, + random_properties_seed: i32, + item_context: u8, +} + +/// Everything needed to mirror C++ `Player::StoreLootItem`'s post-store wire +/// boundary. SQL and the object-owned claim are settled by the detached +/// worker; the session publishes the stored-item update before choosing the +/// direct-loot or disenchant-specific removal/`ItemPushResult` order. +#[derive(Debug, Clone, Copy)] +struct LootItemClaimCommitContextLikeCpp { + owner_guid: ObjectGuid, + loot_obj: ObjectGuid, + loot_list_id: u8, + player_guid: ObjectGuid, + free_for_all: bool, +} + +#[derive(Debug, Clone)] +struct PlannedDisenchantExistingStack { + slot: u8, + item_guid: ObjectGuid, + db_guid: u64, + new_count: u32, + dynamic_flags: u32, + flags_changed: bool, +} + +#[derive(Debug, Clone)] +struct PlannedDirectLootExistingStack { + slot: u8, + item_guid: ObjectGuid, + db_guid: u64, + new_count: u32, + added_count: u32, + dynamic_flags: u32, + flags_changed: bool, +} + +#[derive(Debug, Clone)] +struct PlannedDisenchantExistingPush { + slot: u8, + item_guid: ObjectGuid, + added_count: u32, + new_count: u32, +} + +#[derive(Debug, Clone)] +struct PlannedDisenchantNewPush { + stack_index: usize, + added_count: u32, + new_count: u32, +} + +#[derive(Debug, Clone)] +struct PlannedDisenchantGrant { + entry: LootEntry, + random_properties: LootStoreRandomProperties, + existing_pushes: Vec, + new_pushes: Vec, +} + +/// Own a loot lease in the same detached task that crosses the durable +/// persistence boundary. Tokio does not cancel a spawned task when the +/// caller drops its `JoinHandle`, so packet/session cancellation cannot turn a +/// successful SQL commit back into an available object-owned claim. +enum LootClaimPersistenceWorkerError { + Persistence(E), + Claim(LootClaimCommitError), +} + +impl std::fmt::Debug for LootClaimPersistenceWorkerError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Persistence(error) => formatter.debug_tuple("Persistence").field(error).finish(), + Self::Claim(error) => formatter.debug_tuple("Claim").field(error).finish(), + } + } +} + +/// The stored-money row is the durable single-winner token. Keeping its +/// deletion in the same transaction as the gold update and requiring exactly +/// one affected row turns retries/concurrent handlers into a database CAS. +const STORED_ITEM_MONEY_SOURCE_ROWS_EXPECTED_LIKE_CPP: u64 = 1; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct StoredItemMoneyDbOutcomeLikeCpp { + before: u64, + after: u64, + applied_delta: u64, + notified_amount: u64, +} + +#[derive(Debug)] +enum StoredItemMoneyAttemptErrorLikeCpp { + DefinitelyRolledBack(LootMoneyPersistenceErrorLikeCpp), + CommitOutcomeUnknown { + error: DatabaseError, + outcome: StoredItemMoneyDbOutcomeLikeCpp, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum StoredItemMoneyCommitReconciliationLikeCpp { + Committed, + RolledBack, + Indeterminate, +} + +fn classify_stored_item_money_commit_reconciliation_like_cpp( + outcome: StoredItemMoneyDbOutcomeLikeCpp, + observed_money: u64, + observed_source_money: Option, +) -> StoredItemMoneyCommitReconciliationLikeCpp { + let all_before = + observed_money == outcome.before && observed_source_money == Some(outcome.notified_amount); + let all_after = observed_money == outcome.after && observed_source_money.is_none(); + match (all_before, all_after) { + (true, false) => StoredItemMoneyCommitReconciliationLikeCpp::RolledBack, + (false, true) => StoredItemMoneyCommitReconciliationLikeCpp::Committed, + _ => StoredItemMoneyCommitReconciliationLikeCpp::Indeterminate, + } +} + +fn stored_item_money_zero_without_source_outcome_like_cpp( + before: u64, + cached_notified_amount: u64, +) -> Option { + (cached_notified_amount == 0).then_some(StoredItemMoneyDbOutcomeLikeCpp { + before, + after: before, + applied_delta: 0, + notified_amount: 0, + }) +} + +fn stored_item_money_attempt_is_deadlock_like_cpp( + error: &StoredItemMoneyAttemptErrorLikeCpp, +) -> bool { + matches!( + error, + StoredItemMoneyAttemptErrorLikeCpp::DefinitelyRolledBack( + LootMoneyPersistenceErrorLikeCpp::Database(error) + ) if is_database_deadlock_like_cpp(error) + ) +} + +async fn attempt_stored_item_money_transaction_like_cpp( + char_db: &CharacterDatabase, + player_guid: ObjectGuid, + item_guid: ObjectGuid, + cached_notified_amount: u64, +) -> Result { + let definitely = |error| { + StoredItemMoneyAttemptErrorLikeCpp::DefinitelyRolledBack( + LootMoneyPersistenceErrorLikeCpp::Database(DatabaseError::from(error)), + ) + }; + let mut transaction = char_db.pool().begin().await.map_err(definitely)?; + // Global order shared with group payouts: character mutation mutex, then + // character row, then the stored Item source row. + let before = sqlx::query_scalar::<_, u64>(CharStatements::SEL_CHAR_MONEY_FOR_UPDATE.sql()) + .bind(player_guid.counter() as u64) + .fetch_optional(&mut *transaction) + .await + .map_err(definitely)? + .ok_or_else(|| { + StoredItemMoneyAttemptErrorLikeCpp::DefinitelyRolledBack( + LootMoneyPersistenceErrorLikeCpp::MissingPlayer, + ) + })?; + let source_money = + sqlx::query_scalar::<_, u64>(CharStatements::SEL_ITEMCONTAINER_MONEY_FOR_UPDATE.sql()) + .bind(item_guid.counter() as u64) + .fetch_optional(&mut *transaction) + .await + .map_err(definitely)?; + let Some(notified_amount) = source_money else { + if let Some(outcome) = + stored_item_money_zero_without_source_outcome_like_cpp(before, cached_notified_amount) + { + transaction.rollback().await.map_err(definitely)?; + return Ok(outcome); + } + return Err(StoredItemMoneyAttemptErrorLikeCpp::DefinitelyRolledBack( + LootMoneyPersistenceErrorLikeCpp::Database(DatabaseError::Transaction( + "stored Item money source was already consumed".to_string(), + )), + )); + }; + let (after, applied_delta) = loot_money_durable_outcome_like_cpp(before, notified_amount); + let outcome = StoredItemMoneyDbOutcomeLikeCpp { + before, + after, + applied_delta, + notified_amount, + }; + + if applied_delta != 0 { + let result = sqlx::query(CharStatements::UPD_CHAR_MONEY.sql()) + .bind(after) + .bind(player_guid.counter() as u64) + .execute(&mut *transaction) + .await + .map_err(definitely)?; + if result.rows_affected() != 1 { + return Err(StoredItemMoneyAttemptErrorLikeCpp::DefinitelyRolledBack( + LootMoneyPersistenceErrorLikeCpp::Database(DatabaseError::Transaction(format!( + "stored Item money update affected {} rows; expected exactly 1", + result.rows_affected() + ))), + )); + } + } + let delete = sqlx::query(CharStatements::DEL_ITEMCONTAINER_MONEY.sql()) + .bind(item_guid.counter() as u64) + .execute(&mut *transaction) + .await + .map_err(definitely)?; + if delete.rows_affected() != STORED_ITEM_MONEY_SOURCE_ROWS_EXPECTED_LIKE_CPP { + return Err(StoredItemMoneyAttemptErrorLikeCpp::DefinitelyRolledBack( + LootMoneyPersistenceErrorLikeCpp::Database(DatabaseError::Transaction(format!( + "stored Item money source delete affected {} rows; expected exactly 1", + delete.rows_affected() + ))), + )); + } + + match transaction.commit().await { + Ok(()) => Ok(outcome), + Err(error) => { + let error = DatabaseError::from(error); + if is_database_deadlock_like_cpp(&error) { + Err(StoredItemMoneyAttemptErrorLikeCpp::DefinitelyRolledBack( + LootMoneyPersistenceErrorLikeCpp::Database(error), + )) + } else { + Err(StoredItemMoneyAttemptErrorLikeCpp::CommitOutcomeUnknown { error, outcome }) + } + } + } +} + +async fn reconcile_stored_item_money_commit_like_cpp( + char_db: &CharacterDatabase, + player_guid: ObjectGuid, + item_guid: ObjectGuid, + outcome: StoredItemMoneyDbOutcomeLikeCpp, +) -> Result { + let mut transaction = char_db.pool().begin().await.map_err(DatabaseError::from)?; + // Read and lock both facts in the same order as the original mutation. + // The per-character mutation mutex is still held, so a later local payout + // cannot manufacture a mixed observation while COMMIT is reconciled. + let observed_money = + sqlx::query_scalar::<_, u64>(CharStatements::SEL_CHAR_MONEY_FOR_UPDATE.sql()) + .bind(player_guid.counter() as u64) + .fetch_optional(&mut *transaction) + .await + .map_err(DatabaseError::from)? + .ok_or_else(|| { + DatabaseError::Transaction("stored-money character vanished".to_string()) + })?; + let observed_source_money = + sqlx::query_scalar::<_, u64>(CharStatements::SEL_ITEMCONTAINER_MONEY_FOR_UPDATE.sql()) + .bind(item_guid.counter() as u64) + .fetch_optional(&mut *transaction) + .await + .map_err(DatabaseError::from)?; + let classification = classify_stored_item_money_commit_reconciliation_like_cpp( + outcome, + observed_money, + observed_source_money, + ); + transaction.rollback().await.map_err(DatabaseError::from)?; + Ok(classification) +} + +fn queue_stored_item_money_indeterminate_kick_like_cpp(command_tx: &flume::Sender) { + let kick = SessionCommand::KickLikeCpp(KickLikeCppCommand { + reason: "stored Item money COMMIT outcome is unknown; relog required".to_string(), + }); + if let Err(error) = command_tx.try_send(kick) { + let kick = error.into_inner(); + let command_tx = command_tx.clone(); + tokio::spawn(async move { + let _ = command_tx.send_async(kick).await; + }); + } +} + +fn spawn_loot_claim_persistence_worker_like_cpp( + persistence: F, + claim: Option, + durable_item_completion: Option<( + DurableItemLootPersistenceGuardLikeCpp, + DurableItemLootCompletionLikeCpp, + )>, +) -> Result< + tokio::task::JoinHandle>>, + LootClaimCommitError, +> +where + F: std::future::Future> + Send + 'static, + E: Send + 'static, +{ + let persistence_guard = claim + .as_ref() + .map(LootClaimLease::begin_persistence_guard_like_cpp) + .transpose()?; + drop(claim); + Ok(tokio::spawn(async move { + let mut durable_item_completion = durable_item_completion; + persistence + .await + .map_err(LootClaimPersistenceWorkerError::Persistence)?; + if let Some(mut guard) = persistence_guard { + let (_, committed_snapshot) = guard + .commit_with_snapshot_like_cpp() + .map_err(LootClaimPersistenceWorkerError::Claim)?; + if let (Some(snapshot), Some((_, completion))) = + (committed_snapshot, durable_item_completion.as_ref()) + && let Some(fanout) = completion.item_fanout.as_ref() + { + // Publish the serialization cut before exposing the durable + // completion to the session. Sampling the authority later can + // include an opener that already saw the consumed slot. + let _ = fanout.committed_snapshot.set(snapshot); + } + } + if let Some((guard, completion)) = durable_item_completion.as_mut() { + guard.mark_committed_like_cpp(completion.clone()); + } + Ok(()) + })) +} + +/// Outcome-aware SQL variant for consume-and-grant item transactions. A +/// transport failure returned by COMMIT cannot be treated as rollback: the +/// old object allocation is quarantined permanently and the player is kicked +/// to reload whichever durable state MySQL ultimately kept. +fn spawn_sql_loot_claim_persistence_worker_like_cpp( + persistence: F, + claim: Option, + durable_item_completion: Option<( + DurableItemLootPersistenceGuardLikeCpp, + DurableItemLootCompletionLikeCpp, + )>, + command_tx: flume::Sender, +) -> Result< + tokio::task::JoinHandle>>, + LootClaimCommitError, +> +where + F: std::future::Future> + Send + 'static, +{ + let mut persistence_guard = claim + .as_ref() + .map(LootClaimLease::begin_persistence_guard_like_cpp) + .transpose()?; + drop(claim); + Ok(tokio::spawn(async move { + let mut durable_item_completion = durable_item_completion; + match persistence.await { + Ok(()) => {} + Err(error @ SqlTransactionCommitError::DefinitelyRolledBack(_)) => { + return Err(LootClaimPersistenceWorkerError::Persistence(error)); + } + Err(error @ SqlTransactionCommitError::CommitOutcomeUnknown(_)) => { + if let Some(guard) = persistence_guard.as_mut() { + let _ = guard.quarantine_commit_unknown_like_cpp(); + } + let kick = SessionCommand::KickLikeCpp(KickLikeCppCommand { + reason: "loot item COMMIT outcome is unknown; relog required".to_string(), + }); + if let Err(send_error) = command_tx.try_send(kick) { + let kick = send_error.into_inner(); + tokio::spawn(async move { + let _ = command_tx.send_async(kick).await; + }); + } + return Err(LootClaimPersistenceWorkerError::Persistence(error)); + } + } + if let Some(mut guard) = persistence_guard { + let (_, committed_snapshot) = guard + .commit_with_snapshot_like_cpp() + .map_err(LootClaimPersistenceWorkerError::Claim)?; + if let (Some(snapshot), Some((_, completion))) = + (committed_snapshot, durable_item_completion.as_ref()) + && let Some(fanout) = completion.item_fanout.as_ref() + { + let _ = fanout.committed_snapshot.set(snapshot); + } + } + if let Some((guard, completion)) = durable_item_completion.as_mut() { + guard.mark_committed_like_cpp(completion.clone()); + } + Ok(()) + })) +} + +#[cfg(test)] +mod tests { + use super::{ + GAMEOBJECT_TYPE_AREADAMAGE, GAMEOBJECT_TYPE_BINDER, GAMEOBJECT_TYPE_CHAIR, + GAMEOBJECT_TYPE_DOOR, GAMEOBJECT_TYPE_GUILD_BANK, GAMEOBJECT_TYPE_QUESTGIVER, + INVENTORY_SLOT_BAG_0, INVENTORY_SLOT_ITEM_START, ITEM_FLAGS_CU_FOLLOW_LOOT_RULES_LIKE_CPP, + ItemTemplateAddonLootMetadataLikeCpp, LOCK_KEY_SKILL_LIKE_CPP, LOCK_KEY_SPELL_LIKE_CPP, + LOOT_METHOD_GROUP_LIKE_CPP, LOOT_METHOD_MASTER_LIKE_CPP, LOOT_MODE_DEFAULT_LIKE_CPP, + LOOT_MODE_JUNK_FISH_LIKE_CPP, LOOT_SLOT_TYPE_ALLOW_LOOT_LIKE_CPP, + LOOT_SLOT_TYPE_ROLL_ONGOING_LIKE_CPP, LootItemClaimCommitContextLikeCpp, + LootStoreRandomProperties, ROLL_ALL_TYPE_NO_DISENCHANT_LIKE_CPP, + ROLL_FLAG_TYPE_NEED_LIKE_CPP, ROLL_VOTE_GREED_LIKE_CPP, ROLL_VOTE_NEED_LIKE_CPP, + ROLL_VOTE_NOT_EMITTED_YET_LIKE_CPP, ROLL_VOTE_NOT_VALID_LIKE_CPP, ROLL_VOTE_PASS_LIKE_CPP, + RepresentedLootPlayerContext, SPELL_EFFECT_OPEN_LOCK_LIKE_CPP, + StoredItemMoneyCommitReconciliationLikeCpp, StoredItemMoneyDbOutcomeLikeCpp, + SyncChestGameobjectStateAndRefreshLikeCppCommand, + SyncGatheringNodeGameobjectStateAndRefreshLikeCppCommand, + SyncGooberGameobjectStateAndRefreshLikeCppCommand, + assign_represented_personal_loot_items_like_cpp, + classify_stored_item_money_commit_reconciliation_like_cpp, + direct_item_count_after_loot_release_like_cpp, + generated_creature_loot_item_to_entry_like_cpp, + generated_shared_gameobject_loot_item_to_entry_like_cpp, loot_is_looted_like_cpp, + loot_item_context, loot_store_data_can_stack_with_item, loot_type_for_client_like_cpp, + looted_corpse_decay_secs_like_cpp, mark_loot_allowed_for_player_like_cpp, + mark_loot_item_looted_for_player_like_cpp, + prepare_represented_shared_loot_generation_like_cpp, + represented_gameobject_display_box_contains_like_cpp, + represented_gameobject_interaction_distance_like_cpp, + represented_loot_object_guid_like_cpp, represented_loot_response_items_like_cpp, + select_weighted_random_enchantment_like_cpp, start_loot_roll_packet_like_cpp, + stored_item_money_zero_without_source_outcome_like_cpp, + }; + use crate::conditions::QUEST_STATUS_REWARDED_LIKE_CPP; + use crate::session::{ + DurableItemLootCompletionLikeCpp, LootMoneyViewerFanoutLikeCpp, + RepresentedGameObjectSpellCaster, RepresentedGameObjectUseEffect, + RepresentedLootRollCriteriaEvent, SessionState, loot_money_durable_outcome_like_cpp, + }; + use rand::{Rng, SeedableRng, rngs::StdRng}; + use std::time::{Duration, Instant}; + use std::{ + collections::{HashMap, HashSet}, + sync::{ + Arc, Barrier, Mutex, RwLock, + atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}, + }, + }; + use wow_ai::CreatureAI; + use wow_constants::{ + InventoryResult, InventoryType, ItemBondingType, ItemClass, ItemContext, ItemFieldFlags, + ItemFlags2, ItemQuality, TypeId, TypeMask, + }; + use wow_core::{ObjectGuid, ObjectGuidGenerator, Position, guid::HighGuid}; + use wow_data::quest::{ + QUEST_REWARD_REPUTATIONS_COUNT, QuestObjective, QuestStore, QuestTemplate, + }; + use wow_data::{ + AreaTableEntry, AreaTableStore, ChrSpecializationEntry, ChrSpecializationStore, + ItemDisenchantLootEntry, ItemDisenchantLootStore, ItemRandomEnchantmentTemplateEntry, + ItemRandomEnchantmentTemplateStore, ItemRandomPropertiesEntry, ItemRandomPropertiesStore, + ItemRandomPropertyTemplateEntry, ItemRandomSuffixEntry, ItemRandomSuffixStore, ItemRecord, + ItemSparseTemplateEntry, ItemStatsStore, ItemStore, RandPropPointsEntry, + RandPropPointsStore, SpellEffectInfo, SpellInfo, SpellMiscEntry, SpellMiscStore, + SpellRangeEntry, SpellRangeStore, SpellStore, + }; + use wow_database::{CharStatements, DatabaseError, SqlTransactionCommitError, StatementDef}; + use wow_entities::{ + AccessorObjectKind, CORPSE_DYNFLAG_LOOTABLE, Corpse, CorpseType, Creature, + CreatureOwnedLoot, GAMEOBJECT_TYPE_CHEST, GAMEOBJECT_TYPE_FISHING_HOLE, + GAMEOBJECT_TYPE_FISHING_NODE, GAMEOBJECT_TYPE_GATHERING_NODE, GAMEOBJECT_TYPE_GOOBER, + GO_DYNFLAG_LO_NO_INTERACT, GameObject, GameObjectLootSource, GameObjectOwnedLoot, + GatheringNodeUseSource, GoState, Item, ItemCreateInfo, LootState, MAX_ITEM_SPELLS, + MAX_MONEY_AMOUNT, ObjectChangedFields, Player, WorldObject, + }; + use wow_loot::{ + GeneratedLootItem, LOOT_SLOT_TYPE_OWNER_LIKE_CPP, LootClaimPayload, + LootConditionRowLikeCpp, LootStore, LootStoreItem, LootStoreItemContext, LootStoreKind, + LootStores, LootTemplateRow, OwnedLootAuthority, OwnedLootAuthorityLifecycle, + }; + use wow_network::{ + ApplyLootMoneyLikeCppCommand, GroupInfo, GroupRegistry, KickLikeCppCommand, + LootDropRatesLikeCpp, LootRollCommandIdentityLikeCpp, LootRollVoteCommand, + MasterLootGiveResult, PendingInvites, PlayerBroadcastInfo, PlayerRegistry, SessionCommand, + }; + use wow_packet::packets::loot::{ + CreatureLoot, LOOT_ERROR_MASTER_OTHER_LIKE_CPP, LOOT_ERROR_MASTER_UNIQUE_ITEM_LIKE_CPP, + LOOT_ERROR_NO_LOOT_LIKE_CPP, LOOT_ERROR_PLAYER_NOT_FOUND_LIKE_CPP, + LOOT_ERROR_TOO_FAR_LIKE_CPP, LOOT_RESPONSE_DEFAULT_FAILURE_REASON_LIKE_CPP, + LOOT_RESPONSE_DEFAULT_THRESHOLD_LIKE_CPP, LOOT_TYPE_CHEST_LIKE_CPP, + LOOT_TYPE_CORPSE_LIKE_CPP, LOOT_TYPE_CORPSE_PERSONAL_LIKE_CPP, + LOOT_TYPE_DISENCHANTING_LIKE_CPP, LOOT_TYPE_FISHING_JUNK_LIKE_CPP, + LOOT_TYPE_FISHING_LIKE_CPP, LOOT_TYPE_FISHINGHOLE_LIKE_CPP, + LOOT_TYPE_GATHERING_NODE_LIKE_CPP, LOOT_TYPE_INSIGNIA_LIKE_CPP, LOOT_TYPE_ITEM_LIKE_CPP, + LOOT_TYPE_MILLING_LIKE_CPP, LOOT_TYPE_NONE_LIKE_CPP, LOOT_TYPE_PICKPOCKETING_LIKE_CPP, + LOOT_TYPE_PROSPECTING_LIKE_CPP, LOOT_TYPE_SKINNING_LIKE_CPP, LootEntry, LootEntryFlags, + LootResponse, LootRoll, MasterLootItem, SetLootSpecialization, + }; + use wow_packet::packets::update::CreatureCreateData; + use wow_packet::{ServerPacket, WorldPacket}; + + use crate::session::{ + AuraApplication, InventoryItem, SPELL_AURA_INTERRUPT_FLAG_LOOTING_LIKE_CPP, SpellCastState, + WorldSession, + }; + + fn make_session_with_send_capacity( + capacity: usize, + ) -> (WorldSession, flume::Receiver>) { + let (_pkt_tx, pkt_rx) = flume::bounded::(1); + let (send_tx, send_rx) = flume::bounded::>(capacity); + let mut session = WorldSession::new( + 1, + "TestAccount".into(), + 0, + 2, + 9, + 54261, + vec![0u8; 40], + "esES".into(), + pkt_rx, + send_tx, + ); + session.set_loot_money_persistence_test_result_like_cpp(true); + (session, send_rx) + } + + fn make_session_with_send() -> (WorldSession, flume::Receiver>) { + make_session_with_send_capacity(1) + } + + fn make_session() -> WorldSession { + make_session_with_send().0 + } + + #[test] + fn stored_item_money_commit_unknown_requires_joint_balance_and_source_evidence_like_cpp() { + let outcome = StoredItemMoneyDbOutcomeLikeCpp { + before: 100, + after: 107, + applied_delta: 7, + notified_amount: 7, + }; + assert_eq!( + classify_stored_item_money_commit_reconciliation_like_cpp(outcome, 100, Some(7)), + StoredItemMoneyCommitReconciliationLikeCpp::RolledBack + ); + assert_eq!( + classify_stored_item_money_commit_reconciliation_like_cpp(outcome, 107, None), + StoredItemMoneyCommitReconciliationLikeCpp::Committed + ); + assert_eq!( + classify_stored_item_money_commit_reconciliation_like_cpp(outcome, 100, None), + StoredItemMoneyCommitReconciliationLikeCpp::Indeterminate, + "a missing source alone cannot attribute a later consumer's commit to this attempt" + ); + assert_eq!( + classify_stored_item_money_commit_reconciliation_like_cpp(outcome, 107, Some(7)), + StoredItemMoneyCommitReconciliationLikeCpp::Indeterminate + ); + } + + #[test] + fn stored_item_money_cap_noop_still_reconciles_source_consumption_like_cpp() { + let outcome = StoredItemMoneyDbOutcomeLikeCpp { + before: MAX_MONEY_AMOUNT - 1, + after: MAX_MONEY_AMOUNT - 1, + applied_delta: 0, + notified_amount: 2, + }; + assert_eq!( + classify_stored_item_money_commit_reconciliation_like_cpp( + outcome, + MAX_MONEY_AMOUNT - 1, + None, + ), + StoredItemMoneyCommitReconciliationLikeCpp::Committed + ); + assert_eq!( + classify_stored_item_money_commit_reconciliation_like_cpp( + outcome, + MAX_MONEY_AMOUNT - 1, + Some(2), + ), + StoredItemMoneyCommitReconciliationLikeCpp::RolledBack + ); + } + + #[test] + fn stored_item_money_zero_without_db_source_is_success_but_positive_is_consumed() { + let zero = stored_item_money_zero_without_source_outcome_like_cpp(41, 0).unwrap(); + assert_eq!(zero.before, 41); + assert_eq!(zero.after, 41); + assert_eq!(zero.applied_delta, 0); + assert_eq!(zero.notified_amount, 0); + assert!(stored_item_money_zero_without_source_outcome_like_cpp(41, 1).is_none()); + } + + #[test] + fn stored_item_money_completion_applies_db_delta_to_divergent_runtime_base_like_cpp() { + let (db_after, durable_delta) = loot_money_durable_outcome_like_cpp(100, 7); + let runtime_before = 500; + let runtime_after = runtime_before + durable_delta; + + assert_eq!(db_after, 107); + assert_eq!(runtime_after, 507); + assert_ne!(runtime_after, db_after); + } + + #[test] + fn item_instance_guid_allocator_is_shared_across_concurrent_loot_sessions_like_cpp() { + const WORKERS: usize = 8; + const GUIDS_PER_WORKER: usize = 128; + const FIRST_GUID: i64 = 40_000; + + let generator = Arc::new(ObjectGuidGenerator::new(HighGuid::Item, FIRST_GUID)); + let start = Arc::new(Barrier::new(WORKERS)); + let handles = (0..WORKERS) + .map(|_| { + let generator = Arc::clone(&generator); + let start = Arc::clone(&start); + std::thread::spawn(move || { + let mut session = make_session(); + session.set_realm_id(7); + session.set_item_guid_generator_like_cpp(generator); + start.wait(); + session + .allocate_item_instance_guids_like_cpp(GUIDS_PER_WORKER) + .expect("shared item allocator must be installed") + }) + }) + .collect::>(); + + let mut allocated = handles + .into_iter() + .flat_map(|handle| handle.join().expect("allocation worker must finish")) + .collect::>(); + allocated.sort_unstable_by_key(|(db_guid, _)| *db_guid); + + assert_eq!(allocated.len(), WORKERS * GUIDS_PER_WORKER); + for (offset, (db_guid, object_guid)) in allocated.iter().enumerate() { + let expected = FIRST_GUID as u64 + offset as u64; + assert_eq!(*db_guid, expected); + assert!(object_guid.is_item()); + assert_eq!(object_guid.counter() as u64, expected); + } + assert_eq!( + generator.next_after_max_used(), + FIRST_GUID + (WORKERS * GUIDS_PER_WORKER) as i64 + ); + } + + #[test] + fn item_instance_guid_allocator_fails_closed_and_never_reuses_failed_grant_like_cpp() { + let mut session = make_session(); + assert_eq!(session.allocate_item_instance_guids_like_cpp(1), None); + + let generator = Arc::new(ObjectGuidGenerator::new(HighGuid::Item, 91_000)); + session.set_item_guid_generator_like_cpp(Arc::clone(&generator)); + + // C++ consumes a GUID when Item::CreateItem runs. A later storage or + // transaction failure may leave a gap, but must never make that GUID + // available to a competing durable grant. + let abandoned_after_persistence_failure = session + .allocate_item_instance_guids_like_cpp(1) + .expect("allocator must be installed"); + assert_eq!(abandoned_after_persistence_failure[0].0, 91_000); + drop(abandoned_after_persistence_failure); + + let next_grant = session + .allocate_item_instance_guids_like_cpp(1) + .expect("allocator must remain installed"); + assert_eq!(next_grant[0].0, 91_001); + assert_eq!(generator.next_after_max_used(), 91_002); + } + + fn canonical_world_object(guid: ObjectGuid, map_id: u32, position: Position) -> WorldObject { + let (type_id, type_mask) = if guid.is_game_object() { + (TypeId::GameObject, TypeMask::GAME_OBJECT) + } else { + (TypeId::Unit, TypeMask::UNIT) + }; + let mut object = WorldObject::new(false, type_id, type_mask); + object.object_mut().create(guid); + object.set_map(map_id, 0).unwrap(); + object.relocate(position); + object.object_mut().add_to_world(); + object + } + + fn attach_canonical_map_object( + session: &mut WorldSession, + kind: AccessorObjectKind, + object: WorldObject, + ) { + let map_id = object.map_id(); + let instance_id = object.instance_id(); + let manager = Arc::new(Mutex::new(wow_map::MapManager::default())); + { + let mut manager = manager.lock().unwrap(); + manager + .create_world_map(map_id, instance_id) + .map_mut() + .add_to_map_like_cpp(kind, object) + .unwrap(); + } + session.set_canonical_map_manager(manager); + } + + fn attach_loot_guid_allocator_for_owner(session: &mut WorldSession, owner_guid: ObjectGuid) { + let kind = if owner_guid.is_game_object() { + AccessorObjectKind::GameObject + } else { + AccessorObjectKind::Creature + }; + attach_canonical_map_object( + session, + kind, + canonical_world_object(owner_guid, u32::from(owner_guid.map_id()), Position::ZERO), + ); + } + + #[test] + fn map_owned_loot_guid_sequence_is_shared_across_owner_kinds_like_cpp() { + let mut session = make_session(); + let map_id = 571; + let realm_id = 7; + session.set_realm_id(realm_id); + let shared_owner_counter = 19_700; + let creature_owner = ObjectGuid::create_world_object( + HighGuid::Creature, + 0, + 11, + map_id, + 17, + 101, + shared_owner_counter, + ); + let gameobject_owner = ObjectGuid::create_world_object( + HighGuid::GameObject, + 0, + 12, + map_id, + 18, + 202, + shared_owner_counter, + ); + attach_loot_guid_allocator_for_owner(&mut session, creature_owner); + + let creature_loot = session + .next_canonical_loot_object_guid_like_cpp(creature_owner) + .expect("the creature owner map should allocate a LootObject"); + let gameobject_loot = session + .next_canonical_loot_object_guid_like_cpp(gameobject_owner) + .expect("the gameobject owner map should share the LootObject sequence"); + + assert_ne!(creature_loot, gameobject_loot); + assert_eq!(creature_loot.counter(), 1); + assert_eq!(gameobject_loot.counter(), 2); + for loot_guid in [creature_loot, gameobject_loot] { + assert_eq!(loot_guid.high_type(), HighGuid::LootObject); + assert_eq!(loot_guid.sub_type(), 0); + assert_eq!(loot_guid.realm_id(), realm_id); + assert_eq!(loot_guid.map_id(), map_id); + assert_eq!(loot_guid.server_id(), 0); + assert_eq!(loot_guid.entry(), 0); + } + + let manager = session.canonical_map_manager.as_ref().unwrap(); + let mut manager = manager.lock().unwrap(); + assert_eq!( + manager + .find_map_mut(u32::from(map_id), 0) + .unwrap() + .map_mut() + .get_max_low_guid_like_cpp(HighGuid::LootObject) + .unwrap(), + 3 + ); + } + + #[test] + fn loot_guid_allocator_without_canonical_map_fails_without_advancing_like_cpp() { + let mut session = make_session(); + let owner_guid = + ObjectGuid::create_world_object(HighGuid::Creature, 0, 9, 571, 7, 303, 19_701); + + assert!( + session + .next_canonical_loot_object_guid_like_cpp(owner_guid) + .is_none() + ); + + attach_loot_guid_allocator_for_owner(&mut session, owner_guid); + let first_allocated = session + .next_canonical_loot_object_guid_like_cpp(owner_guid) + .expect("the first allocation after attaching the map should succeed"); + assert_eq!(first_allocated.counter(), 1); + } + + #[test] + fn loot_guid_allocator_refuses_different_owner_map_without_advancing_like_cpp() { + let mut session = make_session(); + let canonical_owner = + ObjectGuid::create_world_object(HighGuid::GameObject, 0, 9, 571, 7, 404, 19_702); + let other_map_owner = + ObjectGuid::create_world_object(HighGuid::Creature, 0, 9, 0, 7, 505, 19_703); + attach_loot_guid_allocator_for_owner(&mut session, canonical_owner); + + assert!( + session + .next_canonical_loot_object_guid_like_cpp(other_map_owner) + .is_none() + ); + + let first_allocated = session + .next_canonical_loot_object_guid_like_cpp(canonical_owner) + .expect("the rejected owner must not consume the canonical map sequence"); + assert_eq!(first_allocated.counter(), 1); + } + + #[test] + fn personal_loot_pools_receive_distinct_map_owned_guids_like_cpp() { + let mut session = make_session(); + session.set_realm_id(7); + let owner_guid = + ObjectGuid::create_world_object(HighGuid::GameObject, 0, 9, 571, 7, 606, 19_704); + let first_player = ObjectGuid::create_player(1, 42); + let second_player = ObjectGuid::create_player(1, 77); + attach_loot_guid_allocator_for_owner(&mut session, owner_guid); + + let mut loot = authoritative_test_loot_like_cpp(0, true); + loot.loot_guid = session + .next_canonical_loot_object_guid_like_cpp(owner_guid) + .expect("the base personal pool should receive a map-owned LootObject"); + loot.allowed_looters = vec![second_player, first_player]; + + let (shared, personal) = session + .represented_loot_authority_pools_like_cpp(owner_guid, first_player, loot, true) + .expect("both personal pools should materialize"); + + assert!(shared.is_none()); + assert_eq!(personal.len(), 2); + let first_pool = personal.get(&first_player).unwrap(); + let second_pool = personal.get(&second_player).unwrap(); + assert_ne!(first_pool.loot_guid, second_pool.loot_guid); + assert_eq!(first_pool.loot_guid.counter(), 1); + assert_eq!(second_pool.loot_guid.counter(), 2); + for (player_guid, pool) in [(first_player, first_pool), (second_player, second_pool)] { + assert_eq!(pool.loot_guid.high_type(), HighGuid::LootObject); + assert_eq!(pool.loot_guid.realm_id(), 7); + assert_eq!(pool.loot_guid.map_id(), 571); + assert_eq!(pool.loot_guid.server_id(), 0); + assert_eq!(pool.loot_guid.entry(), 0); + assert_eq!(pool.allowed_looters, vec![player_guid]); + assert_eq!(pool.items[0].allowed_looters, vec![player_guid]); + } + } + + fn attach_canonical_gameobject(session: &mut WorldSession, game_object: GameObject) { + let map_id = game_object.world().map_id(); + let instance_id = game_object.world().instance_id(); + let manager = Arc::new(Mutex::new(wow_map::MapManager::default())); + { + let mut manager = manager.lock().unwrap(); + manager + .create_world_map(map_id, instance_id) + .map_mut() + .insert_map_object_record( + wow_entities::MapObjectRecord::new_game_object(game_object).unwrap(), + ) + .unwrap(); + } + session.set_canonical_map_manager(manager); + } + + fn attach_canonical_creature(session: &mut WorldSession, creature: Creature) { + let map_id = creature.unit().world().map_id(); + let instance_id = creature.unit().world().instance_id(); + let manager = Arc::new(Mutex::new(wow_map::MapManager::default())); + { + let mut manager = manager.lock().unwrap(); + manager + .create_world_map(map_id, instance_id) + .map_mut() + .insert_map_object_record( + wow_entities::MapObjectRecord::new_creature(creature).unwrap(), + ) + .unwrap(); + } + session.set_canonical_map_manager(manager); + } + + fn attach_canonical_corpse(session: &mut WorldSession, corpse: Corpse) { + let map_id = corpse.world().map_id(); + let instance_id = corpse.world().instance_id(); + let manager = Arc::new(Mutex::new(wow_map::MapManager::default())); + { + let mut manager = manager.lock().unwrap(); + manager + .create_world_map(map_id, instance_id) + .map_mut() + .insert_map_object_record( + wow_entities::MapObjectRecord::new_corpse(corpse).unwrap(), + ) + .unwrap(); + } + session.set_canonical_map_manager(manager); + } + + fn make_canonical_corpse_for_session(session: &WorldSession, guid: ObjectGuid) -> Corpse { + let mut corpse = Corpse::new_at(CorpseType::ResurrectablePvp, 1_000); + corpse.world_mut().object_mut().create(guid); + corpse + .world_mut() + .set_map(u32::from(session.player_map_id_like_cpp()), 0) + .unwrap(); + corpse.world_mut().relocate(Position::ZERO); + corpse.world_mut().object_mut().add_to_world(); + corpse.set_corpse_dynamic_flag(CORPSE_DYNFLAG_LOOTABLE); + corpse.clear_corpse_data_changes(); + corpse + } + + fn canonical_corpse_snapshot(session: &WorldSession, guid: ObjectGuid) -> Option { + let manager = session.canonical_map_manager.as_ref()?; + let manager = manager.lock().ok()?; + let map = manager.find_map(u32::from(session.player_map_id_like_cpp()), 0)?; + map.map().get_typed_corpse(guid).cloned() + } + + fn make_canonical_creature_for_session(session: &WorldSession, guid: ObjectGuid) -> Creature { + let mut creature = Creature::new(false); + creature.unit_mut().world_mut().object_mut().create(guid); + creature + .unit_mut() + .world_mut() + .set_map(u32::from(session.player_map_id_like_cpp()), 0) + .unwrap(); + creature.unit_mut().world_mut().relocate(Position::ZERO); + creature.unit_mut().world_mut().object_mut().add_to_world(); + creature + } + + fn canonical_creature_snapshot(session: &WorldSession, guid: ObjectGuid) -> Option { + let manager = session.canonical_map_manager.as_ref()?; + let manager = manager.lock().ok()?; + let map = manager.find_map(u32::from(session.player_map_id_like_cpp()), 0)?; + map.map().get_typed_creature(guid).cloned() + } + + fn make_canonical_gameobject_for_session( + session: &WorldSession, + guid: ObjectGuid, + go_type: u8, + ) -> GameObject { + let mut game_object = GameObject::new(); + game_object.world_mut().object_mut().create(guid); + game_object + .world_mut() + .set_map(u32::from(session.player_map_id_like_cpp()), 0) + .unwrap(); + game_object.world_mut().relocate(Position::ZERO); + game_object.world_mut().object_mut().add_to_world(); + game_object.set_go_type(go_type); + game_object + } + + fn canonical_gameobject_snapshot( + session: &WorldSession, + guid: ObjectGuid, + ) -> Option { + let manager = session.canonical_map_manager.as_ref()?; + let manager = manager.lock().ok()?; + let map = manager.find_map(u32::from(session.player_map_id_like_cpp()), 0)?; + map.map().get_typed_game_object(guid).cloned() + } + + fn loot_item_packet(object: ObjectGuid, loot_list_id: u8) -> WorldPacket { + let mut pkt = WorldPacket::new_empty(); + pkt.write_uint32(1); + pkt.write_packed_guid(&object); + pkt.write_uint8(loot_list_id); + pkt.write_bit(false); + pkt.flush_bits(); + pkt.reset_read(); + pkt + } + + fn loot_unit_packet(object: ObjectGuid) -> WorldPacket { + let mut pkt = WorldPacket::new_empty(); + pkt.write_packed_guid(&object); + pkt.reset_read(); + pkt + } + + fn represented_loot_entry( + loot_list_id: u8, + item_id: u32, + player_guid: ObjectGuid, + ) -> LootEntry { + LootEntry { + loot_list_id, + item_id, + quantity: 1, + random_properties_id: 0, + random_properties_seed: 0, + item_context: 0, + flags: LootEntryFlags { + follow_loot_rules: true, + freeforall: false, + blocked: false, + counted: false, + under_threshold: false, + needs_quest: false, + }, + allowed_looters: vec![player_guid], + roll_winner: ObjectGuid::EMPTY, + ffa_looted_by: Vec::new(), + taken: false, + } + } + + fn install_active_item_loot_completion_fixture_like_cpp( + session: &mut WorldSession, + player_guid: ObjectGuid, + owner_guid: ObjectGuid, + coins: u32, + ) { + assert!(owner_guid.is_item()); + session.set_player_guid(Some(player_guid)); + session.set_active_loot_guid(owner_guid); + session.loot_table.insert( + owner_guid, + CreatureLoot { + loot_guid: owner_guid, + coins, + unlooted_count: 1, + loot_type: LOOT_TYPE_ITEM_LIKE_CPP, + dungeon_encounter_id: 0, + loot_method: 0, + loot_master: ObjectGuid::EMPTY, + round_robin_player: ObjectGuid::EMPTY, + player_ffa_items: Vec::new(), + players_looting: vec![player_guid], + allowed_looters: vec![player_guid], + items: vec![represented_loot_entry(0, 25, player_guid)], + looted_by_player: false, + }, + ); + } + + #[test] + fn represented_loot_type_for_client_matches_cpp_aliases() { + assert_eq!( + loot_type_for_client_like_cpp(LOOT_TYPE_NONE_LIKE_CPP), + LOOT_TYPE_NONE_LIKE_CPP + ); + assert_eq!( + loot_type_for_client_like_cpp(LOOT_TYPE_CORPSE_LIKE_CPP), + LOOT_TYPE_CORPSE_LIKE_CPP + ); + assert_eq!( + loot_type_for_client_like_cpp(LOOT_TYPE_ITEM_LIKE_CPP), + LOOT_TYPE_ITEM_LIKE_CPP + ); + assert_eq!( + loot_type_for_client_like_cpp(LOOT_TYPE_GATHERING_NODE_LIKE_CPP), + LOOT_TYPE_GATHERING_NODE_LIKE_CPP + ); + assert_eq!( + loot_type_for_client_like_cpp(LOOT_TYPE_CHEST_LIKE_CPP), + LOOT_TYPE_CHEST_LIKE_CPP + ); + assert_eq!( + loot_type_for_client_like_cpp(LOOT_TYPE_CORPSE_PERSONAL_LIKE_CPP), + LOOT_TYPE_CORPSE_PERSONAL_LIKE_CPP + ); + assert_eq!( + loot_type_for_client_like_cpp(LOOT_TYPE_PROSPECTING_LIKE_CPP), + LOOT_TYPE_DISENCHANTING_LIKE_CPP + ); + assert_eq!( + loot_type_for_client_like_cpp(LOOT_TYPE_MILLING_LIKE_CPP), + LOOT_TYPE_DISENCHANTING_LIKE_CPP + ); + assert_eq!( + loot_type_for_client_like_cpp(LOOT_TYPE_INSIGNIA_LIKE_CPP), + LOOT_TYPE_SKINNING_LIKE_CPP + ); + assert_eq!( + loot_type_for_client_like_cpp(LOOT_TYPE_FISHINGHOLE_LIKE_CPP), + LOOT_TYPE_FISHING_LIKE_CPP + ); + assert_eq!( + loot_type_for_client_like_cpp(LOOT_TYPE_FISHING_JUNK_LIKE_CPP), + LOOT_TYPE_FISHING_LIKE_CPP + ); + } + + #[tokio::test] + async fn represented_loot_response_acquire_reason_uses_cpp_loot_type_mapping() { + let mut session = make_session(); + let player_guid = ObjectGuid::create_player(1, 42); + let owner_guid = test_creature_guid(19_096); + let loot_guid = represented_loot_object_guid_like_cpp(owner_guid); + let entry = represented_loot_entry(0, 25, player_guid); + register_test_creature_like_cpp(&mut session, test_creature(owner_guid, false)); + session.loot_table.insert( + owner_guid, + CreatureLoot { + loot_guid, + coins: 0, + unlooted_count: 1, + loot_type: LOOT_TYPE_PROSPECTING_LIKE_CPP, + dungeon_encounter_id: 0, + loot_method: 0, + loot_master: ObjectGuid::EMPTY, + round_robin_player: ObjectGuid::EMPTY, + player_ffa_items: Vec::new(), + players_looting: Vec::new(), + allowed_looters: vec![player_guid], + items: vec![entry], + looted_by_player: false, + }, + ); + install_cached_test_creature_loot_authority_like_cpp(&mut session, owner_guid, player_guid); + + let response = session + .represented_loot_response_for_owner_like_cpp(owner_guid, player_guid, false) + .await + .unwrap(); + + assert_eq!(response.acquire_reason, LOOT_TYPE_DISENCHANTING_LIKE_CPP); + } + + #[test] + fn represented_start_loot_roll_carries_cpp_dungeon_encounter_id() { + let player_guid = ObjectGuid::create_player(1, 42); + let loot_obj = ObjectGuid::create_world_object(HighGuid::LootObject, 0, 1, 0, 0, 1, 900); + let entry = represented_loot_entry(0, 25, player_guid); + + let packet = start_loot_roll_packet_like_cpp( + loot_obj, + 571, + LOOT_METHOD_GROUP_LIKE_CPP, + &entry, + ROLL_ALL_TYPE_NO_DISENCHANT_LIKE_CPP, + 615, + ); + + assert_eq!(packet.dungeon_encounter_id, 615); + } + + #[test] + fn represented_loot_item_push_result_uses_realm_route_and_cpp_encounter_fields() { + let (mut session, instance_rx) = make_session_with_send(); + let (realm_tx, realm_rx) = flume::bounded(1); + session.install_realm_send_channel_for_test(realm_tx); + let player_guid = ObjectGuid::create_player(1, 42); + let item_guid = ObjectGuid::create_item(1, 700); + let entry = represented_loot_entry(0, 25, player_guid); + + session.send_loot_item_push_result( + player_guid, + item_guid, + &entry, + 0, + 0, + 0, + 1, + 1, + false, + 615, + ); + + assert!(instance_rx.try_recv().is_err()); + let sent = realm_rx.try_recv().unwrap(); + let mut sent = WorldPacket::from_bytes(&sent); + assert_eq!( + sent.read_uint16().unwrap(), + wow_constants::ServerOpcodes::ItemPushResult as u16 + ); + assert_eq!(sent.read_packed_guid().unwrap(), player_guid); + assert_eq!(sent.read_uint8().unwrap(), u8::from(INVENTORY_SLOT_BAG_0)); + assert_eq!(sent.read_int32().unwrap(), 0); + assert_eq!(sent.read_int32().unwrap(), 0); + assert_eq!(sent.read_int32().unwrap(), 1); + assert_eq!(sent.read_int32().unwrap(), 1); + assert_eq!(sent.read_int32().unwrap(), 615); + assert_eq!(sent.read_int32().unwrap(), 0); + assert_eq!(sent.read_int32().unwrap(), 0); + assert_eq!(sent.read_uint32().unwrap(), 0); + assert_eq!(sent.read_int32().unwrap(), 0); + assert_eq!(sent.read_packed_guid().unwrap(), item_guid); + assert!(!sent.read_bit().unwrap()); + assert!(!sent.read_bit().unwrap()); + assert_eq!(sent.read_bits(3).unwrap(), 2); + assert!(!sent.read_bit().unwrap()); + assert!(sent.read_bit().unwrap()); + assert_eq!(sent.read_int32().unwrap(), 25); + } + + #[test] + fn creature_generated_loot_entry_uses_item_template_addon_follow_loot_rules_like_cpp() { + let generated = GeneratedLootItem { + item_id: 25, + count: 1, + loot_list_id: 7, + random_properties_id: -77, + random_properties_seed: 456, + context: ItemContext::DungeonNormal as u8, + store_item_context: LootStoreItemContext { + store_kind: LootStoreKind::Creature, + entry: 100, + item: LootStoreItem { + item_id: 25, + reference: 0, + chance: 100.0, + needs_quest: true, + loot_mode: LOOT_MODE_DEFAULT_LIKE_CPP, + group_id: 0, + min_count: 1, + max_count: 1, + }, + }, + free_for_all: false, + follow_loot_rules: false, + needs_quest: true, + is_looted: false, + is_blocked: false, + is_under_threshold: false, + is_counted: false, + }; + + let default_entry = generated_creature_loot_item_to_entry_like_cpp( + generated, + ItemTemplateAddonLootMetadataLikeCpp::default(), + ); + assert!(!default_entry.flags.follow_loot_rules); + assert_eq!(default_entry.loot_list_id, 7); + assert_eq!(default_entry.random_properties_id, -77); + assert_eq!(default_entry.random_properties_seed, 456); + assert_eq!(default_entry.item_context, ItemContext::DungeonNormal as u8); + + let follow_entry = generated_creature_loot_item_to_entry_like_cpp( + generated, + ItemTemplateAddonLootMetadataLikeCpp { + flags_cu: ITEM_FLAGS_CU_FOLLOW_LOOT_RULES_LIKE_CPP, + quest_log_item_id: 0, + }, + ); + assert!(follow_entry.flags.follow_loot_rules); + assert!(follow_entry.flags.needs_quest); + } + + #[test] + fn represented_loot_response_items_use_cpp_ui_type_decision_tree() { + let player_guid = ObjectGuid::create_player(1, 42); + let other_guid = ObjectGuid::create_player(1, 77); + let loot_guid = ObjectGuid::create_world_object(HighGuid::Creature, 0, 1, 0, 0, 1, 100); + + let mut rolling_entry = represented_loot_entry(0, 25, player_guid); + rolling_entry.flags.blocked = true; + + let mut won_entry = represented_loot_entry(1, 26, player_guid); + won_entry.roll_winner = player_guid; + + let mut hidden_entry = represented_loot_entry(2, 27, player_guid); + hidden_entry.roll_winner = other_guid; + + let mut allowed_entry = represented_loot_entry(3, 28, player_guid); + allowed_entry.flags.under_threshold = true; + + let loot = CreatureLoot { + loot_guid, + coins: 0, + unlooted_count: 0, + loot_type: LOOT_TYPE_CORPSE_LIKE_CPP, + dungeon_encounter_id: 0, + loot_method: LOOT_METHOD_GROUP_LIKE_CPP, + loot_master: ObjectGuid::EMPTY, + round_robin_player: ObjectGuid::EMPTY, + player_ffa_items: Vec::new(), + players_looting: Vec::new(), + allowed_looters: vec![player_guid], + items: vec![rolling_entry, won_entry, hidden_entry, allowed_entry], + looted_by_player: false, + }; + + let items = represented_loot_response_items_like_cpp(&loot, player_guid); + + assert_eq!(items.len(), 3); + assert_eq!(items[0].loot_list_id, 0); + assert_eq!(items[0].ui_type, LOOT_SLOT_TYPE_ROLL_ONGOING_LIKE_CPP); + assert_eq!(items[1].loot_list_id, 1); + assert_eq!(items[1].ui_type, LOOT_SLOT_TYPE_OWNER_LIKE_CPP); + assert_eq!(items[2].loot_list_id, 3); + assert_eq!(items[2].ui_type, LOOT_SLOT_TYPE_ALLOW_LOOT_LIKE_CPP); + } + + #[test] + fn represented_ffa_loot_uses_player_ffa_items_like_cpp() { + let player_guid = ObjectGuid::create_player(1, 42); + let other_guid = ObjectGuid::create_player(1, 77); + let loot_guid = ObjectGuid::create_world_object(HighGuid::Creature, 0, 1, 0, 0, 1, 101); + + let mut ffa_entry = represented_loot_entry(0, 25, player_guid); + ffa_entry.flags.freeforall = true; + ffa_entry.allowed_looters.clear(); + + let mut loot = CreatureLoot { + loot_guid, + coins: 0, + unlooted_count: 0, + loot_type: LOOT_TYPE_CORPSE_LIKE_CPP, + dungeon_encounter_id: 0, + loot_method: LOOT_METHOD_GROUP_LIKE_CPP, + loot_master: ObjectGuid::EMPTY, + round_robin_player: ObjectGuid::EMPTY, + player_ffa_items: Vec::new(), + players_looting: Vec::new(), + allowed_looters: Vec::new(), + items: vec![ffa_entry], + looted_by_player: false, + }; + + mark_loot_allowed_for_player_like_cpp(&mut loot, player_guid); + mark_loot_allowed_for_player_like_cpp(&mut loot, other_guid); + assert_eq!(loot.unlooted_count, 2); + + let player_items = represented_loot_response_items_like_cpp(&loot, player_guid); + let other_items = represented_loot_response_items_like_cpp(&loot, other_guid); + assert_eq!(player_items.len(), 1); + assert_eq!(other_items.len(), 1); + assert_eq!(player_items[0].ui_type, LOOT_SLOT_TYPE_ALLOW_LOOT_LIKE_CPP); + assert_eq!(other_items[0].ui_type, LOOT_SLOT_TYPE_ALLOW_LOOT_LIKE_CPP); + + mark_loot_item_looted_for_player_like_cpp(&mut loot, 0, player_guid); + assert_eq!(loot.unlooted_count, 1); + + let player_ffa = loot + .player_ffa_items + .iter() + .find(|(player, _)| *player == player_guid) + .and_then(|(_, items)| items.iter().find(|item| item.loot_list_id == 0)) + .unwrap(); + let other_ffa = loot + .player_ffa_items + .iter() + .find(|(player, _)| *player == other_guid) + .and_then(|(_, items)| items.iter().find(|item| item.loot_list_id == 0)) + .unwrap(); + + assert!(player_ffa.is_looted); + assert!(!other_ffa.is_looted); + assert!(represented_loot_response_items_like_cpp(&loot, player_guid).is_empty()); + assert_eq!( + represented_loot_response_items_like_cpp(&loot, other_guid).len(), + 1 + ); + } + + #[test] + fn prospecting_and_milling_release_consume_at_most_five_source_items_like_cpp() { + assert_eq!( + direct_item_count_after_loot_release_like_cpp(20, Some(5)), + 15 + ); + assert_eq!(direct_item_count_after_loot_release_like_cpp(5, Some(5)), 0); + assert_eq!(direct_item_count_after_loot_release_like_cpp(3, Some(5)), 0); + assert_eq!(direct_item_count_after_loot_release_like_cpp(20, None), 0); + } + + #[test] + fn represented_unlooted_count_counts_shared_items_once_like_cpp() { + let player_guid = ObjectGuid::create_player(1, 42); + let other_guid = ObjectGuid::create_player(1, 77); + let loot_guid = ObjectGuid::create_world_object(HighGuid::Creature, 0, 1, 0, 0, 1, 102); + + let mut entry = represented_loot_entry(0, 25, player_guid); + entry.allowed_looters.clear(); + + let mut loot = CreatureLoot { + loot_guid, + coins: 0, + unlooted_count: 0, + loot_type: LOOT_TYPE_CORPSE_LIKE_CPP, + dungeon_encounter_id: 0, + loot_method: LOOT_METHOD_GROUP_LIKE_CPP, + loot_master: ObjectGuid::EMPTY, + round_robin_player: ObjectGuid::EMPTY, + player_ffa_items: Vec::new(), + players_looting: Vec::new(), + allowed_looters: Vec::new(), + items: vec![entry], + looted_by_player: false, + }; + + mark_loot_allowed_for_player_like_cpp(&mut loot, player_guid); + assert_eq!(loot.unlooted_count, 1); + assert!(loot.items[0].flags.counted); + + mark_loot_allowed_for_player_like_cpp(&mut loot, other_guid); + assert_eq!(loot.unlooted_count, 1); + + mark_loot_item_looted_for_player_like_cpp(&mut loot, 0, player_guid); + assert_eq!(loot.unlooted_count, 0); + mark_loot_item_looted_for_player_like_cpp(&mut loot, 0, player_guid); + assert_eq!(loot.unlooted_count, 0); + } + + #[test] + fn shared_gameobject_normal_item_with_two_looters_counts_once_like_cpp() { + let first = ObjectGuid::create_player(1, 42); + let second = ObjectGuid::create_player(1, 77); + let mut entry = represented_loot_entry(0, 25, first); + entry.allowed_looters = vec![first, second]; + let mut loot = CreatureLoot { + loot_guid: represented_loot_object_guid_like_cpp(test_gameobject_guid(91_101)), + coins: 0, + unlooted_count: 0, + loot_type: LOOT_TYPE_CHEST_LIKE_CPP, + dungeon_encounter_id: 0, + loot_method: LOOT_METHOD_GROUP_LIKE_CPP, + loot_master: ObjectGuid::EMPTY, + round_robin_player: ObjectGuid::EMPTY, + player_ffa_items: Vec::new(), + players_looting: Vec::new(), + allowed_looters: Vec::new(), + items: vec![entry], + looted_by_player: false, + }; + + prepare_represented_shared_loot_generation_like_cpp(&mut loot, &[first, second]); + + assert_eq!(loot.allowed_looters, vec![first, second]); + assert_eq!(loot.items[0].allowed_looters, vec![first, second]); + assert_eq!(loot.unlooted_count, 1); + assert!(loot.items[0].flags.counted); + } + + #[test] + fn shared_gameobject_item_evaluates_each_looter_after_roll_like_cpp() { + let first = ObjectGuid::create_player(1, 42); + let second = ObjectGuid::create_player(1, 77); + let store_item_context = LootStoreItemContext { + store_kind: LootStoreKind::Reference, + entry: 900, + item: LootStoreItem { + item_id: 25, + reference: 0, + chance: 100.0, + needs_quest: false, + loot_mode: LOOT_MODE_DEFAULT_LIKE_CPP, + group_id: 0, + min_count: 1, + max_count: 1, + }, + }; + let generated = GeneratedLootItem { + item_id: 25, + count: 1, + loot_list_id: 0, + random_properties_id: 0, + random_properties_seed: 0, + context: ItemContext::None as u8, + store_item_context, + free_for_all: false, + follow_loot_rules: true, + needs_quest: false, + is_looted: false, + is_blocked: false, + is_under_threshold: false, + is_counted: false, + }; + let mut evaluated = Vec::new(); + + let entry = generated_shared_gameobject_loot_item_to_entry_like_cpp( + generated, + ItemTemplateAddonLootMetadataLikeCpp::default(), + &[first, second], + |context, looter| { + evaluated.push((context, looter)); + looter == second + }, + ); + + assert_eq!( + evaluated, + vec![(store_item_context, first), (store_item_context, second)] + ); + assert_eq!(entry.item_id, 25, "the rolled candidate remains present"); + assert_eq!(entry.allowed_looters, vec![second]); + } + + #[test] + fn represented_loot_removed_uses_players_looting_like_cpp() { + let (mut session, send_rx) = make_session_with_send(); + let player_guid = ObjectGuid::create_player(1, 42); + let open_guid = ObjectGuid::create_player(1, 77); + let closed_guid = ObjectGuid::create_player(1, 88); + let stale_guid = ObjectGuid::create_player(1, 99); + let owner_guid = test_creature_guid(19_095); + let loot_object = represented_loot_object_guid_like_cpp(owner_guid); + let (open_tx, open_rx) = flume::bounded::>(1); + let (closed_tx, closed_rx) = flume::bounded::>(1); + let player_registry = Arc::new(PlayerRegistry::default()); + player_registry.insert(open_guid, broadcast_info(open_guid, open_tx)); + player_registry.insert(closed_guid, broadcast_info(closed_guid, closed_tx)); + session.set_player_registry(player_registry); + session.set_player_guid(Some(player_guid)); + session.loot_table.insert( + owner_guid, + CreatureLoot { + loot_guid: loot_object, + coins: 0, + unlooted_count: 0, + loot_type: LOOT_TYPE_CORPSE_LIKE_CPP, + dungeon_encounter_id: 0, + loot_method: LOOT_METHOD_GROUP_LIKE_CPP, + loot_master: ObjectGuid::EMPTY, + round_robin_player: ObjectGuid::EMPTY, + player_ffa_items: Vec::new(), + players_looting: vec![player_guid, open_guid, stale_guid], + allowed_looters: vec![player_guid, open_guid, closed_guid, stale_guid], + items: vec![LootEntry { + loot_list_id: 0, + item_id: 25, + quantity: 1, + random_properties_id: 0, + random_properties_seed: 0, + item_context: 0, + flags: LootEntryFlags::default(), + allowed_looters: vec![player_guid, open_guid, closed_guid, stale_guid], + roll_winner: ObjectGuid::EMPTY, + ffa_looted_by: Vec::new(), + taken: false, + }], + looted_by_player: false, + }, + ); + + session.represented_notify_loot_item_removed_like_cpp(owner_guid, 0); + + let sent = send_rx.try_recv().unwrap(); + let mut sent = WorldPacket::from_bytes(&sent); + assert_eq!( + sent.read_uint16().unwrap(), + wow_constants::ServerOpcodes::LootRemoved as u16 + ); + assert_eq!(sent.read_packed_guid().unwrap(), owner_guid); + assert_eq!(sent.read_packed_guid().unwrap(), loot_object); + assert_eq!(sent.read_uint8().unwrap(), 0); + + let sent = open_rx.try_recv().unwrap(); + let mut sent = WorldPacket::from_bytes(&sent); + assert_eq!( + sent.read_uint16().unwrap(), + wow_constants::ServerOpcodes::LootRemoved as u16 + ); + assert!(closed_rx.try_recv().is_err()); + assert_eq!( + session.loot_table.get(&owner_guid).unwrap().players_looting, + vec![player_guid, open_guid] + ); + } + + #[test] + fn durable_item_fanout_uses_precommit_union_exact_commit_cut_like_cpp() { + let before = ObjectGuid::create_player(1, 41); + let during = ObjectGuid::create_player(1, 42); + let after = ObjectGuid::create_player(1, 43); + + let viewers = + super::durable_loot_item_fanout_viewers_like_cpp(&[before], &[before, during]); + + assert_eq!(viewers, HashSet::from([before, during])); + assert!( + !viewers.contains(&after), + "a later authority sample must never expand the exact commit fanout" + ); + } + + #[test] + fn represented_money_removed_erases_missing_players_looting_like_cpp() { + let (mut session, send_rx) = make_session_with_send(); + let player_guid = ObjectGuid::create_player(1, 42); + let open_guid = ObjectGuid::create_player(1, 77); + let stale_guid = ObjectGuid::create_player(1, 99); + let owner_guid = test_creature_guid(19_096); + let loot_object = represented_loot_object_guid_like_cpp(owner_guid); + let (open_tx, open_rx) = flume::bounded::>(1); + let player_registry = Arc::new(PlayerRegistry::default()); + player_registry.insert(open_guid, broadcast_info(open_guid, open_tx)); + session.set_player_registry(player_registry); + session.set_player_guid(Some(player_guid)); + session.loot_table.insert( + owner_guid, + CreatureLoot { + loot_guid: loot_object, + coins: 7, + unlooted_count: 0, + loot_type: LOOT_TYPE_CORPSE_LIKE_CPP, + dungeon_encounter_id: 0, + loot_method: LOOT_METHOD_GROUP_LIKE_CPP, + loot_master: ObjectGuid::EMPTY, + round_robin_player: ObjectGuid::EMPTY, + player_ffa_items: Vec::new(), + players_looting: vec![player_guid, open_guid, stale_guid], + allowed_looters: vec![player_guid, open_guid, stale_guid], + items: Vec::new(), + looted_by_player: false, + }, + ); + + session.represented_notify_money_removed_like_cpp(owner_guid); + + let sent = send_rx.try_recv().unwrap(); + let mut sent = WorldPacket::from_bytes(&sent); + assert_eq!( + sent.read_uint16().unwrap(), + wow_constants::ServerOpcodes::CoinRemoved as u16 + ); + assert_eq!(sent.read_packed_guid().unwrap(), loot_object); + + let sent = open_rx.try_recv().unwrap(); + let mut sent = WorldPacket::from_bytes(&sent); + assert_eq!( + sent.read_uint16().unwrap(), + wow_constants::ServerOpcodes::CoinRemoved as u16 + ); + assert_eq!( + session.loot_table.get(&owner_guid).unwrap().players_looting, + vec![player_guid, open_guid] + ); + } + + fn loot_release_packet(object: ObjectGuid) -> WorldPacket { + let mut pkt = WorldPacket::new_empty(); + pkt.write_packed_guid(&object); + pkt.reset_read(); + pkt + } + + fn loot_money_packet() -> WorldPacket { + let mut pkt = WorldPacket::new_empty(); + pkt.write_bit(false); + pkt.flush_bits(); + pkt.reset_read(); + pkt + } + + fn recv_packet_with_opcode( + rx: &flume::Receiver>, + opcode: wow_constants::ServerOpcodes, + ) -> WorldPacket { + for _ in 0..8 { + let sent = rx.try_recv().unwrap(); + let mut packet = WorldPacket::from_bytes(&sent); + if packet.read_uint16().unwrap() == opcode as u16 { + return packet; + } + } + panic!("expected packet opcode {:?}", opcode); + } + + fn drain_server_opcodes_like_cpp(rx: &flume::Receiver>) -> Vec { + let mut opcodes = Vec::new(); + while let Ok(bytes) = rx.try_recv() { + let mut packet = WorldPacket::from_bytes(&bytes); + opcodes.push(packet.read_uint16().unwrap()); + } + opcodes + } + + fn test_creature(guid: ObjectGuid, is_alive: bool) -> CreatureAI { + let mut creature = CreatureAI::new( + guid, + 1, + Position::ZERO, + 100, + 1, + 1, + 2, + 0.0, + 1, + 35, + 0, + 0, + 0, + 0, + 0, + None, + 0, + ); + creature.is_alive = is_alive; + creature + } + + fn register_test_creature_like_cpp(session: &mut WorldSession, creature: CreatureAI) { + if session.map_manager.is_none() { + session.set_map_manager(Arc::new(RwLock::new(crate::map_manager::MapManager::new()))); + } + + let create_data = CreatureCreateData { + guid: creature.guid, + entry: creature.entry, + display_id: creature.display_id, + native_display_id: creature.display_id, + display_scale: 1.0, + native_x_display_scale: 1.0, + bounding_radius: 0.389, + combat_reach: 1.5, + health: i64::from(creature.hp.max(1)), + max_health: i64::from(creature.max_hp.max(1)), + level: creature.level, + faction_template: creature.faction as i32, + npc_flags: u64::from(creature.npc_flags), + unit_flags: creature.unit_flags, + unit_flags2: 0, + unit_flags3: 0, + aura_state: crate::map_manager::WorldCreature::health_aura_state_like_cpp( + u64::from(creature.hp.max(1)), + u64::from(creature.max_hp.max(1)), + true, + ), + damage_school: wow_constants::spell::SpellSchools::Normal as u8, + scale: 1.0, + unit_class: 1, + display_power: 1, + power: [0; 10], + max_power: [0; 10], + base_mana: 0, + virtual_items: [(0, 0, 0); 3], + base_attack_time: 2000, + ranged_attack_time: 0, + movement_flags: 0, + vehicle_id: 0, + play_hover_anim: false, + hover_height: 1.0, + mount_display_id: 0, + stand_state: 0, + vis_flags: 0, + anim_tier: 0, + emote_state: 0, + sheathe_state: wow_constants::unit::SheathState::Melee as u8, + pvp_flags: 0, + current_area_id: 0, + speed_walk_rate: 1.0, + speed_run_rate: 1.14286, + ai_anim_kit_id: 0, + movement_anim_kit_id: 0, + melee_anim_kit_id: 0, + }; + let guid = creature.guid; + let is_alive = creature.is_alive; + session.register_world_creature( + session.player_map_id_like_cpp(), + creature.current_pos, + create_data, + creature.min_dmg, + creature.max_dmg, + creature.aggro_radius, + creature.loot_id, + 0, + creature.gold_min, + creature.gold_max, + creature.boss_id, + creature.dungeon_encounter_id, + 0, + 0, + 0, + -1, + ); + if !is_alive { + let _ = session.mutate_world_creature(guid, |world_creature| { + world_creature.creature.mark_ai_dead(0); + }); + } + } + + fn insert_allowed_coin_loot_like_cpp( + session: &mut WorldSession, + owner_guid: ObjectGuid, + player_guid: ObjectGuid, + coins: u32, + ) { + session.loot_table.insert( + owner_guid, + CreatureLoot { + loot_guid: represented_loot_object_guid_like_cpp(owner_guid), + coins, + unlooted_count: 0, + loot_type: LOOT_TYPE_CORPSE_LIKE_CPP, + dungeon_encounter_id: 0, + loot_method: 0, + loot_master: ObjectGuid::EMPTY, + round_robin_player: ObjectGuid::EMPTY, + player_ffa_items: Vec::new(), + players_looting: Vec::new(), + allowed_looters: vec![player_guid], + items: Vec::new(), + looted_by_player: false, + }, + ); + if session + .represented_owned_loot_authority_like_cpp(owner_guid) + .is_some() + { + install_cached_test_creature_loot_authority_like_cpp(session, owner_guid, player_guid); + } + } + + /// Legacy packet tests construct the result of `Unit::Kill` directly. + /// Install that fixture into the creature before exercising CMSG_LOOT_UNIT + /// so the request remains a pure read/reconciliation path, like C++. + fn install_cached_test_creature_loot_authority_like_cpp( + session: &mut WorldSession, + owner_guid: ObjectGuid, + scope_player: ObjectGuid, + ) { + if let Some(loot) = session.loot_table.get_mut(&owner_guid) { + // The fixtures describe the already-filtered post-FillLoot item + // set. Rebuild only its derived counters; adding looters here + // would erase negative eligibility cases the fixture represents. + super::rebuild_represented_personal_loot_counts_preserving_consumed_like_cpp(loot); + } + session + .sync_represented_creature_loot_to_canonical_like_cpp(owner_guid, scope_player) + .expect("the kill-time loot fixture must install into its creature authority"); + } + + fn two_sessions_with_authoritative_creature_loot_like_cpp( + mut loot: CreatureLoot, + ) -> ( + WorldSession, + flume::Receiver>, + WorldSession, + flume::Receiver>, + ObjectGuid, + ObjectGuid, + ObjectGuid, + ) { + let (mut first, first_rx) = make_session_with_send_capacity(32); + let (mut second, second_rx) = make_session_with_send_capacity(32); + let first_guid = ObjectGuid::create_player(1, 42); + let second_guid = ObjectGuid::create_player(1, 43); + let owner_guid = test_creature_guid(19_500); + let shared_map = Arc::new(RwLock::new(crate::map_manager::MapManager::new())); + + first.set_player_guid(Some(first_guid)); + second.set_player_guid(Some(second_guid)); + first.set_player_position_like_cpp(Position::ZERO); + second.set_player_position_like_cpp(Position::ZERO); + first.set_map_manager(Arc::clone(&shared_map)); + second.set_map_manager(shared_map); + register_test_creature_like_cpp(&mut first, test_creature(owner_guid, false)); + + loot.loot_guid = represented_loot_object_guid_like_cpp(owner_guid); + loot.allowed_looters = vec![first_guid, second_guid]; + for entry in &mut loot.items { + entry.allowed_looters = vec![first_guid, second_guid]; + } + first.loot_table.insert(owner_guid, loot); + first + .sync_represented_creature_loot_to_canonical_like_cpp(owner_guid, first_guid) + .unwrap(); + + first.set_active_loot_guid(owner_guid); + let first_response = authoritative_test_loot_response_like_cpp( + owner_guid, + &first.loot_table[&owner_guid], + first_guid, + ); + first.represented_on_loot_opened_like_cpp(owner_guid, first_guid, first_response); + assert!(second.reconcile_represented_loot_cache_like_cpp(owner_guid, second_guid)); + second.set_active_loot_guid(owner_guid); + let second_response = authoritative_test_loot_response_like_cpp( + owner_guid, + &second.loot_table[&owner_guid], + second_guid, + ); + second.represented_on_loot_opened_like_cpp(owner_guid, second_guid, second_response); + + ( + first, + first_rx, + second, + second_rx, + owner_guid, + first_guid, + second_guid, + ) + } + + fn authoritative_test_loot_like_cpp(coins: u32, with_item: bool) -> CreatureLoot { + CreatureLoot { + loot_guid: ObjectGuid::EMPTY, + coins, + unlooted_count: u8::from(with_item), + loot_type: LOOT_TYPE_CORPSE_LIKE_CPP, + dungeon_encounter_id: 0, + loot_method: 0, + loot_master: ObjectGuid::EMPTY, + round_robin_player: ObjectGuid::EMPTY, + player_ffa_items: Vec::new(), + players_looting: Vec::new(), + allowed_looters: Vec::new(), + items: with_item + .then(|| LootEntry { + loot_list_id: 0, + item_id: 25, + quantity: 1, + random_properties_id: 0, + random_properties_seed: 0, + item_context: 0, + flags: LootEntryFlags::default(), + allowed_looters: Vec::new(), + roll_winner: ObjectGuid::EMPTY, + ffa_looted_by: Vec::new(), + taken: false, + }) + .into_iter() + .collect(), + looted_by_player: false, + } + } - use crate::session::{ - AuraApplication, InventoryItem, SPELL_AURA_INTERRUPT_FLAG_LOOTING_LIKE_CPP, SpellCastState, - WorldSession, - }; + fn authoritative_test_loot_response_like_cpp( + owner_guid: ObjectGuid, + loot: &CreatureLoot, + player_guid: ObjectGuid, + ) -> LootResponse { + LootResponse { + owner: owner_guid, + loot_obj: loot.loot_guid, + failure_reason: LOOT_RESPONSE_DEFAULT_FAILURE_REASON_LIKE_CPP, + acquire_reason: loot_type_for_client_like_cpp(loot.loot_type), + loot_method: loot.loot_method, + threshold: LOOT_RESPONSE_DEFAULT_THRESHOLD_LIKE_CPP, + coins: loot.coins, + items: represented_loot_response_items_like_cpp(loot, player_guid), + currencies: Vec::new(), + acquired: true, + ae_looting: false, + } + } - fn make_session_with_send_capacity( - capacity: usize, - ) -> (WorldSession, flume::Receiver>) { - let (_pkt_tx, pkt_rx) = flume::bounded::(1); - let (send_tx, send_rx) = flume::bounded::>(capacity); - ( - WorldSession::new( - 1, - "TestAccount".into(), - 0, - 2, - 9, - 54261, - vec![0u8; 40], - "esES".into(), - pkt_rx, - send_tx, - ), - send_rx, + #[tokio::test] + async fn full_loot_response_queue_rolls_back_open_without_blocking_authority_like_cpp() { + let (mut session, send_rx) = make_session_with_send_capacity(1); + let player_guid = ObjectGuid::create_player(1, 61_900); + let owner_guid = test_creature_guid(61_901); + session.set_player_guid(Some(player_guid)); + session.set_player_position_like_cpp(Position::ZERO); + register_test_creature_like_cpp(&mut session, test_creature(owner_guid, false)); + + let mut loot = authoritative_test_loot_like_cpp(0, true); + loot.loot_guid = represented_loot_object_guid_like_cpp(owner_guid); + loot.allowed_looters = vec![player_guid]; + loot.items[0].allowed_looters = vec![player_guid]; + session.loot_table.insert(owner_guid, loot); + install_cached_test_creature_loot_authority_like_cpp(&mut session, owner_guid, player_guid); + let authority = session + .represented_owned_loot_authority_like_cpp(owner_guid) + .unwrap(); + session.set_active_loot_guid(owner_guid); + let response = authoritative_test_loot_response_like_cpp( + owner_guid, + &session.loot_table[&owner_guid], + player_guid, + ); + + let sentinel = vec![0xAA, 0x55]; + session.send_tx().send(sentinel.clone()).unwrap(); + let (done_tx, done_rx) = std::sync::mpsc::channel(); + let open_thread = std::thread::spawn(move || { + session.represented_on_loot_opened_like_cpp(owner_guid, player_guid, response); + done_tx + .send(( + session.loot_table.contains_key(&owner_guid), + session.active_loot_view_owners.contains(&owner_guid), + )) + .unwrap(); + }); + + let (cached, active) = done_rx + .recv_timeout(Duration::from_millis(500)) + .expect("a full socket queue must not block while loot authority is locked"); + open_thread.join().unwrap(); + assert!(!cached); + assert!(!active); + assert_eq!(send_rx.try_recv().unwrap(), sentinel); + assert!(send_rx.try_recv().is_err(), "no LootResponse was enqueued"); + + let rejected = authority.snapshot_for_player_like_cpp(player_guid).unwrap(); + assert!(rejected.loot.players_looting.is_empty()); + assert!(!rejected.loot.looted_by_player); + + let claim = tokio::time::timeout( + Duration::from_millis(500), + authority.reserve_item_like_cpp(player_guid, 0), ) + .await + .expect("a failed response enqueue must release the authority mutex") + .unwrap(); + assert!(claim.rollback_like_cpp()); + authority.retire_like_cpp(); + assert!(authority.is_retired_like_cpp()); } - fn make_session_with_send() -> (WorldSession, flume::Receiver>) { - make_session_with_send_capacity(1) + #[tokio::test] + async fn successful_loot_open_queues_response_before_claim_removal_like_cpp() { + let (mut session, send_rx) = make_session_with_send_capacity(4); + let player_guid = ObjectGuid::create_player(1, 61_910); + let owner_guid = test_creature_guid(61_911); + session.set_player_guid(Some(player_guid)); + session.set_player_position_like_cpp(Position::ZERO); + register_test_creature_like_cpp(&mut session, test_creature(owner_guid, false)); + + let mut loot = authoritative_test_loot_like_cpp(0, true); + loot.loot_guid = represented_loot_object_guid_like_cpp(owner_guid); + loot.allowed_looters = vec![player_guid]; + loot.items[0].allowed_looters = vec![player_guid]; + session.loot_table.insert(owner_guid, loot); + install_cached_test_creature_loot_authority_like_cpp(&mut session, owner_guid, player_guid); + let authority = session + .represented_owned_loot_authority_like_cpp(owner_guid) + .unwrap(); + session.set_active_loot_guid(owner_guid); + let response = authoritative_test_loot_response_like_cpp( + owner_guid, + &session.loot_table[&owner_guid], + player_guid, + ); + + session.represented_on_loot_opened_like_cpp(owner_guid, player_guid, response); + let claim = authority + .reserve_item_like_cpp(player_guid, 0) + .await + .unwrap(); + assert_eq!(claim.commit_like_cpp(), Ok(true)); + let committed = authority.snapshot_for_player_like_cpp(player_guid).unwrap(); + session.represented_notify_loot_item_removed_from_snapshot_like_cpp( + owner_guid, + Some(&authority), + &committed, + 0, + ); + + let opcodes = drain_server_opcodes_like_cpp(&send_rx); + let response_index = opcodes + .iter() + .position(|opcode| *opcode == wow_constants::ServerOpcodes::LootResponse as u16) + .expect("the accepted opening response was queued"); + let removal_index = opcodes + .iter() + .position(|opcode| *opcode == wow_constants::ServerOpcodes::LootRemoved as u16) + .expect("the committed claim removal was queued"); + assert!( + response_index < removal_index, + "the authority lock must order LootResponse before LootRemoved: {opcodes:?}" + ); } - fn make_session() -> WorldSession { - make_session_with_send().0 + #[test] + fn stale_player_map_key_does_not_rebind_creature_loot_authorities_like_cpp() { + let mut session = make_session(); + let player_guid = ObjectGuid::create_player(1, 61_709); + let owner_guid = test_creature_guid(61_710); + session.set_player_guid(Some(player_guid)); + session.set_state(SessionState::LoggedIn); + register_test_creature_like_cpp(&mut session, test_creature(owner_guid, false)); + let canonical_creature = make_canonical_creature_for_session(&session, owner_guid); + attach_canonical_creature(&mut session, canonical_creature); + + let (map_id, instance_id) = session.current_legacy_runtime_map_key_like_cpp(); + let map_key = wow_map::MapKey::new(u32::from(map_id), instance_id); + let legacy_before = session + .read_legacy_creature_loot_authority_on_map_like_cpp(owner_guid, map_key) + .expect("the legacy creature owns its pristine authority"); + let canonical_before = session + .read_canonical_creature_loot_authority_on_map_like_cpp(owner_guid, map_key) + .expect("the canonical creature owns a separate pristine authority"); + assert!(!legacy_before.shares_storage_like_cpp(&canonical_before)); + assert_eq!(session.current_canonical_player_map_key_like_cpp(), None); + + assert!( + session + .represented_owned_loot_authority_like_cpp(owner_guid) + .is_none(), + "a logged-in player between maps must fail closed" + ); + + let legacy_after = session + .read_legacy_creature_loot_authority_on_map_like_cpp(owner_guid, map_key) + .unwrap(); + let canonical_after = session + .read_canonical_creature_loot_authority_on_map_like_cpp(owner_guid, map_key) + .unwrap(); + assert!(legacy_after.shares_storage_like_cpp(&legacy_before)); + assert!(canonical_after.shares_storage_like_cpp(&canonical_before)); + assert!( + !legacy_after.shares_storage_like_cpp(&canonical_after), + "reconciliation must not mutate either stale-map mirror" + ); } - fn canonical_world_object(guid: ObjectGuid, map_id: u32, position: Position) -> WorldObject { - let (type_id, type_mask) = if guid.is_game_object() { - (TypeId::GameObject, TypeMask::GAME_OBJECT) - } else { - (TypeId::Unit, TypeMask::UNIT) - }; - let mut object = WorldObject::new(false, type_id, type_mask); - object.object_mut().create(guid); - object.set_map(map_id, 0).unwrap(); - object.relocate(position); - object.object_mut().add_to_world(); - object + fn represented_disenchant_test_outputs_like_cpp( + winner_guid: ObjectGuid, + item_id: u32, + ) -> Vec { + (0..2) + .map(|loot_list_id| LootEntry { + loot_list_id, + item_id, + quantity: 1, + random_properties_id: 0, + random_properties_seed: 0, + item_context: 0, + flags: LootEntryFlags { + follow_loot_rules: true, + ..Default::default() + }, + allowed_looters: vec![winner_guid], + roll_winner: winner_guid, + ffa_looted_by: Vec::new(), + taken: false, + }) + .collect() } - fn attach_canonical_map_object( - session: &mut WorldSession, - kind: AccessorObjectKind, - object: WorldObject, - ) { - let map_id = object.map_id(); - let instance_id = object.instance_id(); - let manager = Arc::new(Mutex::new(wow_map::MapManager::default())); - { - let mut manager = manager.lock().unwrap(); - manager - .create_world_map(map_id, instance_id) - .map_mut() - .add_to_map_like_cpp(kind, object) - .unwrap(); - } - session.set_canonical_map_manager(manager); + #[tokio::test] + async fn two_sessions_claim_one_authoritative_item_exactly_once_like_cpp() { + let (mut first, _first_rx, mut second, _second_rx, owner, first_guid, _) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(0, true), + ); + let grants = Arc::new(AtomicUsize::new(0)); + first.set_loot_item_store_test_seam_like_cpp(Arc::clone(&grants), true); + second.set_loot_item_store_test_seam_like_cpp(Arc::clone(&grants), true); + let barrier = Arc::new(tokio::sync::Barrier::new(3)); + let loot_obj = represented_loot_object_guid_like_cpp(owner); + + let first_barrier = Arc::clone(&barrier); + let first_task = tokio::spawn(async move { + first_barrier.wait().await; + first.handle_loot_item(loot_item_packet(loot_obj, 0)).await; + first + }); + let second_barrier = Arc::clone(&barrier); + let second_task = tokio::spawn(async move { + second_barrier.wait().await; + second.handle_loot_item(loot_item_packet(loot_obj, 0)).await; + second + }); + barrier.wait().await; + + let mut first = first_task.await.unwrap(); + let _second = second_task.await.unwrap(); + assert_eq!(grants.load(Ordering::SeqCst), 1); + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let snapshot = authority.snapshot_for_player_like_cpp(first_guid).unwrap(); + assert!(snapshot.loot.items[0].taken); + assert_eq!(snapshot.loot.unlooted_count, 0); } - fn attach_canonical_gameobject(session: &mut WorldSession, game_object: GameObject) { - let map_id = game_object.world().map_id(); - let instance_id = game_object.world().instance_id(); - let manager = Arc::new(Mutex::new(wow_map::MapManager::default())); - { - let mut manager = manager.lock().unwrap(); - manager - .create_world_map(map_id, instance_id) - .map_mut() - .insert_map_object_record( - wow_entities::MapObjectRecord::new_game_object(game_object).unwrap(), - ) - .unwrap(); - } - session.set_canonical_map_manager(manager); + #[tokio::test] + async fn durable_direct_item_claim_notifies_removed_before_item_push_like_cpp() { + let (mut first, first_rx, _second, _second_rx, owner, _first_guid, _) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(0, true), + ); + let _ = drain_server_opcodes_like_cpp(&first_rx); + // Use one observer channel for both physical routes so this test can + // assert their relative C++ wire order. Route separation is covered + // independently by the ItemPushResult test above. + let shared_send = first.send_tx().clone(); + first.install_realm_send_channel_for_test(shared_send); + let grants = Arc::new(AtomicUsize::new(0)); + first.set_loot_item_store_test_seam_like_cpp(Arc::clone(&grants), true); + + first + .handle_loot_item(loot_item_packet( + represented_loot_object_guid_like_cpp(owner), + 0, + )) + .await; + + assert_eq!(grants.load(Ordering::SeqCst), 1); + assert_eq!( + drain_server_opcodes_like_cpp(&first_rx), + vec![ + wow_constants::ServerOpcodes::LootRemoved as u16, + wow_constants::ServerOpcodes::ItemPushResult as u16, + ], + "C++ Player::StoreLootItem notifies removal before SendNewItem" + ); } - fn attach_canonical_creature(session: &mut WorldSession, creature: Creature) { - let map_id = creature.unit().world().map_id(); - let instance_id = creature.unit().world().instance_id(); - let manager = Arc::new(Mutex::new(wow_map::MapManager::default())); - { - let mut manager = manager.lock().unwrap(); - manager - .create_world_map(map_id, instance_id) - .map_mut() - .insert_map_object_record( - wow_entities::MapObjectRecord::new_creature(creature).unwrap(), - ) - .unwrap(); - } - session.set_canonical_map_manager(manager); + #[tokio::test] + async fn item_grant_commit_unknown_quarantines_claim_and_kicks_even_when_queue_full() { + let (mut first, _first_rx, _second, _second_rx, owner, first_guid, _) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(0, true), + ); + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let claim = authority + .reserve_item_for_award_like_cpp(first_guid, 0) + .await + .unwrap(); + let (command_tx, command_rx) = flume::bounded(1); + command_tx + .send(SessionCommand::KickLikeCpp(KickLikeCppCommand { + reason: "preexisting".to_string(), + })) + .unwrap(); + + let worker = super::spawn_sql_loot_claim_persistence_worker_like_cpp( + async { + Err(SqlTransactionCommitError::CommitOutcomeUnknown( + DatabaseError::Transaction("lost COMMIT reply".to_string()), + )) + }, + Some(claim), + None, + command_tx, + ) + .unwrap(); + let result = worker.await.unwrap(); + + assert!(matches!( + result, + Err(super::LootClaimPersistenceWorkerError::Persistence( + SqlTransactionCommitError::CommitOutcomeUnknown(_) + )) + )); + assert_eq!( + authority.lifecycle_like_cpp(), + OwnedLootAuthorityLifecycle::Quarantined + ); + assert!( + authority + .reserve_item_for_award_like_cpp(first_guid, 0) + .await + .is_err(), + "unknown COMMIT must never reopen the object-owned item claim" + ); + + let _preexisting = command_rx.recv().unwrap(); + let queued = tokio::time::timeout(Duration::from_secs(1), command_rx.recv_async()) + .await + .expect("full command queue fallback must eventually enqueue the kick") + .unwrap(); + assert!(matches!(queued, SessionCommand::KickLikeCpp(_))); } - fn attach_canonical_corpse(session: &mut WorldSession, corpse: Corpse) { - let map_id = corpse.world().map_id(); - let instance_id = corpse.world().instance_id(); - let manager = Arc::new(Mutex::new(wow_map::MapManager::default())); - { - let mut manager = manager.lock().unwrap(); - manager - .create_world_map(map_id, instance_id) - .map_mut() - .insert_map_object_record( - wow_entities::MapObjectRecord::new_corpse(corpse).unwrap(), + #[tokio::test] + async fn cancelled_after_runtime_apply_retains_multiviewer_fanout_and_corpse_lifecycle_like_cpp() + { + let (mut first, first_rx, mut second, second_rx, owner, first_guid, second_guid) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(0, true), + ); + let _ = drain_server_opcodes_like_cpp(&first_rx); + let _ = drain_server_opcodes_like_cpp(&second_rx); + + let registry = Arc::new(PlayerRegistry::default()); + let mut first_info = broadcast_info(first_guid, first.send_tx().clone()); + first_info.command_tx = first.session_command_tx(); + registry.insert(first_guid, first_info); + let mut second_info = broadcast_info(second_guid, second.send_tx().clone()); + second_info.command_tx = second.session_command_tx(); + registry.insert(second_guid, second_info); + first.set_player_registry(Arc::clone(®istry)); + second.set_player_registry(registry); + + first.set_loot_drop_rates_like_cpp(LootDropRatesLikeCpp { + corpse_decay_looted: 0.5, + ..LootDropRatesLikeCpp::default() + }); + let corpse_before = first + .mutate_world_creature(owner, |creature| { + creature.creature.set_corpse_delay(120, false); + creature.apply_corpse_loot_flags_after_death_state_like_cpp(true, false); + creature.corpse_despawn_at() + }) + .unwrap(); + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let claim = authority + .reserve_item_for_award_like_cpp(first_guid, 0) + .await + .unwrap(); + let context = LootItemClaimCommitContextLikeCpp { + owner_guid: owner, + loot_obj: represented_loot_object_guid_like_cpp(owner), + loot_list_id: 0, + player_guid: first_guid, + free_for_all: false, + }; + let fanout = first + .prepare_durable_loot_item_fanout_like_cpp(&claim, context) + .expect("pre-COMMIT fanout route"); + assert_eq!(fanout.precommit_snapshot.loot.players_looting.len(), 2); + + let runtime_inventory_applied = Arc::new(AtomicBool::new(true)); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (commit_tx, commit_rx) = tokio::sync::oneshot::channel(); + let guard = first.begin_durable_item_loot_persistence_like_cpp(); + let worker = super::spawn_loot_claim_persistence_worker_like_cpp( + async move { + let _ = started_tx.send(()); + let _ = commit_rx.await; + Ok::<(), ()>(()) + }, + Some(claim), + Some(( + guard, + DurableItemLootCompletionLikeCpp { + owner_guid: owner, + loot_list_id: 0, + player_guid: first_guid, + item_owner_auto_release: false, + durable_item_money_applied_amount: None, + durable_item_money_notified_amount: None, + durable_item_money_balance_applied: None, + item_fanout: Some(fanout), + runtime_inventory_applied: Arc::clone(&runtime_inventory_applied), + }, + )), + ) + .unwrap(); + let waiter = tokio::spawn(async move { worker.await }); + started_rx.await.unwrap(); + waiter.abort(); + let _ = waiter.await; + + first.handle_loot_release(loot_release_packet(owner)).await; + second.handle_loot_release(loot_release_packet(owner)).await; + commit_tx.send(()).unwrap(); + first.wait_for_active_loot_persistence_like_cpp().await; + + assert!(!first.is_disconnecting()); + assert!(runtime_inventory_applied.load(Ordering::Acquire)); + assert!( + drain_server_opcodes_like_cpp(&first_rx) + .contains(&(wow_constants::ServerOpcodes::LootRemoved as u16)) + ); + assert!( + drain_server_opcodes_like_cpp(&second_rx) + .contains(&(wow_constants::ServerOpcodes::LootRemoved as u16)) + ); + let corpse_after = first + .mutate_world_creature(owner, |creature| { + ( + creature.corpse_despawn_at(), + creature.has_lootable_dynamic_flag_like_cpp(), ) - .unwrap(); + }) + .unwrap(); + assert!(!corpse_after.1); + assert!(corpse_after.0 <= corpse_before); + } + + #[test] + fn retired_object_authority_releases_every_session_window_like_cpp() { + let (mut first, first_rx, mut second, second_rx, owner, first_guid, second_guid) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(9, true), + ); + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let _ = drain_server_opcodes_like_cpp(&first_rx); + let _ = drain_server_opcodes_like_cpp(&second_rx); + + authority.retire_like_cpp(); + first.close_retired_active_loot_windows_like_cpp(first_guid); + second.close_retired_active_loot_windows_like_cpp(second_guid); + + for (session, owner_guid) in [(&first, owner), (&second, owner)] { + assert!(!session.active_loot_view_owners.contains(&owner_guid)); + assert!(!session.loot_table.contains_key(&owner_guid)); + } + for rx in [&first_rx, &second_rx] { + assert_eq!( + drain_server_opcodes_like_cpp(rx), + vec![wow_constants::ServerOpcodes::LootRelease as u16] + ); } - session.set_canonical_map_manager(manager); } - fn make_canonical_corpse_for_session(session: &WorldSession, guid: ObjectGuid) -> Corpse { - let mut corpse = Corpse::new_at(CorpseType::ResurrectablePvp, 1_000); - corpse.world_mut().object_mut().create(guid); - corpse - .world_mut() - .set_map(u32::from(session.player_map_id_like_cpp()), 0) + #[tokio::test] + async fn failed_authoritative_item_store_rolls_back_for_retry_like_cpp() { + let (mut first, _first_rx, _second, _second_rx, owner, first_guid, _) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(0, true), + ); + let grants = Arc::new(AtomicUsize::new(0)); + first.set_loot_item_store_test_seam_like_cpp(Arc::clone(&grants), false); + let loot_obj = represented_loot_object_guid_like_cpp(owner); + + first.handle_loot_item(loot_item_packet(loot_obj, 0)).await; + let authority = first + .represented_owned_loot_authority_like_cpp(owner) .unwrap(); - corpse.world_mut().relocate(Position::ZERO); - corpse.world_mut().object_mut().add_to_world(); - corpse.set_corpse_dynamic_flag(CORPSE_DYNFLAG_LOOTABLE); - corpse.clear_corpse_data_changes(); - corpse + assert!( + !authority + .snapshot_for_player_like_cpp(first_guid) + .unwrap() + .loot + .items[0] + .taken + ); + + first.set_loot_item_store_test_seam_like_cpp(Arc::clone(&grants), true); + first.handle_loot_item(loot_item_packet(loot_obj, 0)).await; + assert_eq!(grants.load(Ordering::SeqCst), 1); + assert!( + authority + .snapshot_for_player_like_cpp(first_guid) + .unwrap() + .loot + .items[0] + .taken + ); } - fn canonical_corpse_snapshot(session: &WorldSession, guid: ObjectGuid) -> Option { - let manager = session.canonical_map_manager.as_ref()?; - let manager = manager.lock().ok()?; - let map = manager.find_map(u32::from(session.player_map_id_like_cpp()), 0)?; - map.map().get_typed_corpse(guid).cloned() + #[tokio::test] + async fn two_sessions_claim_one_authoritative_money_pool_exactly_once_like_cpp() { + let (first, _first_rx, second, _second_rx, owner, first_guid, _) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(9, false), + ); + let barrier = Arc::new(tokio::sync::Barrier::new(3)); + let first_barrier = Arc::clone(&barrier); + let first_task = tokio::spawn(async move { + let mut first = first; + first_barrier.wait().await; + first.handle_loot_money(loot_money_packet()).await; + first + }); + let second_barrier = Arc::clone(&barrier); + let second_task = tokio::spawn(async move { + let mut second = second; + second_barrier.wait().await; + second.handle_loot_money(loot_money_packet()).await; + second + }); + barrier.wait().await; + + let mut first = first_task.await.unwrap(); + let second = second_task.await.unwrap(); + assert_eq!( + first.player_gold_like_cpp() + second.player_gold_like_cpp(), + 9 + ); + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + assert_eq!( + authority + .snapshot_for_player_like_cpp(first_guid) + .unwrap() + .loot + .coins, + 0 + ); } - fn make_canonical_creature_for_session(session: &WorldSession, guid: ObjectGuid) -> Creature { - let mut creature = Creature::new(false); - creature.unit_mut().world_mut().object_mut().create(guid); - creature - .unit_mut() - .world_mut() - .set_map(u32::from(session.player_map_id_like_cpp()), 0) + #[tokio::test] + async fn failed_authoritative_money_persistence_rolls_back_for_retry_like_cpp() { + let (mut first, _first_rx, _second, _second_rx, owner, first_guid, _) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(7, false), + ); + first.set_loot_money_persistence_test_result_like_cpp(false); + + first.handle_loot_money(loot_money_packet()).await; + let authority = first + .represented_owned_loot_authority_like_cpp(owner) .unwrap(); - creature.unit_mut().world_mut().relocate(Position::ZERO); - creature.unit_mut().world_mut().object_mut().add_to_world(); - creature + assert_eq!(first.player_gold_like_cpp(), 0); + assert_eq!( + authority + .snapshot_for_player_like_cpp(first_guid) + .unwrap() + .loot + .coins, + 7 + ); + + first.set_loot_money_persistence_test_result_like_cpp(true); + first.handle_loot_money(loot_money_packet()).await; + assert_eq!(first.player_gold_like_cpp(), 7); + assert_eq!( + authority + .snapshot_for_player_like_cpp(first_guid) + .unwrap() + .loot + .coins, + 0 + ); } - fn canonical_creature_snapshot(session: &WorldSession, guid: ObjectGuid) -> Option { - let manager = session.canonical_map_manager.as_ref()?; - let manager = manager.lock().ok()?; - let map = manager.find_map(u32::from(session.player_map_id_like_cpp()), 0)?; - map.map().get_typed_creature(guid).cloned() + #[tokio::test] + async fn stale_active_money_view_cannot_claim_replacement_generation_like_cpp() { + let (mut first, _first_rx, _second, _second_rx, owner, first_guid, second_guid) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(7, false), + ); + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let opened_generation = first.active_loot_view_generations_like_cpp[&owner]; + let mut replacement = authoritative_test_loot_like_cpp(11, false); + replacement.loot_guid = represented_loot_object_guid_like_cpp(owner); + replacement.allowed_looters = vec![first_guid, second_guid]; + let replacement_generation = authority.replace_like_cpp(Some(replacement), HashMap::new()); + assert_ne!(opened_generation, replacement_generation); + + first.handle_loot_money(loot_money_packet()).await; + + assert_eq!(first.player_gold_like_cpp(), 0); + let snapshot = authority.snapshot_for_player_like_cpp(first_guid).unwrap(); + assert_eq!(snapshot.generation, replacement_generation); + assert_eq!(snapshot.loot.coins, 11); } - fn make_canonical_gameobject_for_session( - session: &WorldSession, - guid: ObjectGuid, - go_type: u8, - ) -> GameObject { - let mut game_object = GameObject::new(); - game_object.world_mut().object_mut().create(guid); - game_object - .world_mut() - .set_map(u32::from(session.player_map_id_like_cpp()), 0) + #[tokio::test] + async fn stale_active_item_view_cannot_claim_replacement_generation_like_cpp() { + let (mut first, _first_rx, _second, _second_rx, owner, first_guid, second_guid) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(0, true), + ); + let authority = first + .represented_owned_loot_authority_like_cpp(owner) .unwrap(); - game_object.world_mut().relocate(Position::ZERO); - game_object.world_mut().object_mut().add_to_world(); - game_object.set_go_type(go_type); - game_object + let mut replacement = authoritative_test_loot_like_cpp(0, true); + replacement.loot_guid = represented_loot_object_guid_like_cpp(owner); + replacement.allowed_looters = vec![first_guid, second_guid]; + replacement.items[0].allowed_looters = vec![first_guid, second_guid]; + let replacement_generation = authority.replace_like_cpp(Some(replacement), HashMap::new()); + let grants = Arc::new(AtomicUsize::new(0)); + first.set_loot_item_store_test_seam_like_cpp(Arc::clone(&grants), true); + + first + .handle_loot_item(loot_item_packet( + represented_loot_object_guid_like_cpp(owner), + 0, + )) + .await; + + assert_eq!(grants.load(Ordering::SeqCst), 0); + let snapshot = authority.snapshot_for_player_like_cpp(first_guid).unwrap(); + assert_eq!(snapshot.generation, replacement_generation); + assert!(!snapshot.loot.items[0].taken); } - fn canonical_gameobject_snapshot( - session: &WorldSession, - guid: ObjectGuid, - ) -> Option { - let manager = session.canonical_map_manager.as_ref()?; - let manager = manager.lock().ok()?; - let map = manager.find_map(u32::from(session.player_map_id_like_cpp()), 0)?; - map.map().get_typed_game_object(guid).cloned() + #[tokio::test] + async fn item_waiter_waking_on_replacement_rolls_back_new_generation_claim_like_cpp() { + let (mut first, _first_rx, _second, _second_rx, owner, first_guid, second_guid) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(0, true), + ); + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let blocker = authority + .reserve_item_like_cpp(first_guid, 0) + .await + .unwrap(); + let grants = Arc::new(AtomicUsize::new(0)); + first.set_loot_item_store_test_seam_like_cpp(Arc::clone(&grants), true); + let loot_obj = represented_loot_object_guid_like_cpp(owner); + let waiter = tokio::spawn(async move { + first.handle_loot_item(loot_item_packet(loot_obj, 0)).await; + first + }); + for _ in 0..4 { + tokio::task::yield_now().await; + } + + let mut replacement = authoritative_test_loot_like_cpp(0, true); + replacement.loot_guid = loot_obj; + replacement.allowed_looters = vec![first_guid, second_guid]; + replacement.items[0].allowed_looters = vec![first_guid, second_guid]; + let replacement_generation = authority.replace_like_cpp(Some(replacement), HashMap::new()); + drop(blocker); + let _first = waiter.await.unwrap(); + + assert_eq!(grants.load(Ordering::SeqCst), 0); + let snapshot = authority.snapshot_for_player_like_cpp(first_guid).unwrap(); + assert_eq!(snapshot.generation, replacement_generation); + assert!(!snapshot.loot.items[0].taken); } - fn loot_item_packet(object: ObjectGuid, loot_list_id: u8) -> WorldPacket { - let mut pkt = WorldPacket::new_empty(); - pkt.write_uint32(1); - pkt.write_packed_guid(&object); - pkt.write_uint8(loot_list_id); - pkt.write_bit(false); - pkt.flush_bits(); - pkt.reset_read(); - pkt + #[tokio::test] + async fn stale_release_keeps_replacement_viewer_and_pool_like_cpp() { + let (mut first, _first_rx, _second, _second_rx, owner, first_guid, second_guid) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(7, true), + ); + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let mut replacement = authoritative_test_loot_like_cpp(13, true); + replacement.loot_guid = represented_loot_object_guid_like_cpp(owner); + replacement.allowed_looters = vec![first_guid, second_guid]; + replacement.items[0].allowed_looters = vec![first_guid, second_guid]; + let replacement_generation = authority.replace_like_cpp(Some(replacement), HashMap::new()); + authority.add_viewer_like_cpp(first_guid).unwrap(); + + assert!( + first + .do_loot_release_owner_like_cpp(owner, first_guid) + .await + ); + + let snapshot = authority.snapshot_for_player_like_cpp(first_guid).unwrap(); + assert_eq!(snapshot.generation, replacement_generation); + assert_eq!(snapshot.loot.coins, 13); + assert!(!snapshot.loot.items[0].taken); + assert!(snapshot.loot.players_looting.contains(&first_guid)); + assert!(!first.active_loot_view_owners.contains(&owner)); } - fn loot_unit_packet(object: ObjectGuid) -> WorldPacket { - let mut pkt = WorldPacket::new_empty(); - pkt.write_packed_guid(&object); - pkt.reset_read(); - pkt + #[tokio::test] + async fn durable_money_delta_is_order_independent_near_gold_cap_like_cpp() { + let start = MAX_MONEY_AMOUNT - 7; + let (after_first, first_delta) = loot_money_durable_outcome_like_cpp(start, 5); + let (after_second, second_delta) = loot_money_durable_outcome_like_cpp(after_first, 5); + assert_eq!((after_second, first_delta, second_delta), (start + 5, 5, 0)); + + let (mut session, send_rx) = make_session_with_send_capacity(8); + let player_guid = ObjectGuid::create_player(1, 42); + session.set_player_guid(Some(player_guid)); + session.set_player_gold_like_cpp(start); + let committed = Arc::new(AtomicBool::new(false)); + let command_authority = OwnedLootAuthority::new(); + let command = |durable_delta| ApplyLootMoneyLikeCppCommand { + recipient: player_guid, + loot_owner: test_creature_guid(19_510), + loot_obj: represented_loot_object_guid_like_cpp(test_creature_guid(19_510)), + amount: 5, + durable_applied_amount: Arc::new(AtomicU64::new(durable_delta)), + durable_persistence_tracker: Default::default(), + sole_looter: false, + authority: command_authority.clone(), + authority_generation: 1, + authority_committed: Arc::clone(&committed), + send_coin_removed: Arc::new(AtomicBool::new(false)), + applied: Arc::new(AtomicBool::new(false)), + published: Arc::new(AtomicBool::new(false)), + }; + + // Runtime delivery is deliberately opposite to the locked DB order. + session + .handle_apply_loot_money_like_cpp_command(command(second_delta)) + .await; + session + .handle_apply_loot_money_like_cpp_command(command(first_delta)) + .await; + + assert_eq!(session.player_gold_like_cpp(), start + 5); + let opcodes = drain_server_opcodes_like_cpp(&send_rx); + assert_eq!( + opcodes, + vec![ + wow_constants::ServerOpcodes::LootMoneyNotify as u16, + wow_constants::ServerOpcodes::LootMoneyNotify as u16, + ] + ); } - fn represented_loot_entry( - loot_list_id: u8, - item_id: u32, - player_guid: ObjectGuid, - ) -> LootEntry { - LootEntry { - loot_list_id, - item_id, - quantity: 1, - random_properties_id: 0, - random_properties_seed: 0, - item_context: 0, - flags: LootEntryFlags { - follow_loot_rules: true, - freeforall: false, - blocked: false, - counted: false, - under_threshold: false, - needs_quest: false, - }, - allowed_looters: vec![player_guid], - roll_winner: ObjectGuid::EMPTY, - ffa_looted_by: Vec::new(), - taken: false, + #[tokio::test] + async fn durable_old_generation_payout_does_not_touch_replacement_loot_like_cpp() { + let (mut first, first_rx, _second, _second_rx, owner, first_guid, second_guid) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(7, false), + ); + let _ = drain_server_opcodes_like_cpp(&first_rx); + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let old_generation = first.active_loot_view_generations_like_cpp[&owner]; + let mut replacement = authoritative_test_loot_like_cpp(17, false); + replacement.loot_guid = represented_loot_object_guid_like_cpp(owner); + replacement.allowed_looters = vec![first_guid, second_guid]; + authority.replace_like_cpp(Some(replacement), HashMap::new()); + authority.add_viewer_like_cpp(first_guid).unwrap(); + + first + .handle_apply_loot_money_like_cpp_command(ApplyLootMoneyLikeCppCommand { + recipient: first_guid, + loot_owner: owner, + loot_obj: represented_loot_object_guid_like_cpp(owner), + amount: 3, + durable_applied_amount: Arc::new(AtomicU64::new(3)), + durable_persistence_tracker: Default::default(), + sole_looter: true, + authority: authority.clone(), + authority_generation: old_generation, + authority_committed: Arc::new(AtomicBool::new(true)), + send_coin_removed: Arc::new(AtomicBool::new(true)), + applied: Arc::new(AtomicBool::new(false)), + published: Arc::new(AtomicBool::new(false)), + }) + .await; + + assert_eq!(first.player_gold_like_cpp(), 3); + let snapshot = authority.snapshot_for_player_like_cpp(first_guid).unwrap(); + assert_eq!(snapshot.loot.coins, 17); + assert!(snapshot.loot.players_looting.contains(&first_guid)); + let opcodes = drain_server_opcodes_like_cpp(&first_rx); + assert!(!opcodes.contains(&(wow_constants::ServerOpcodes::CoinRemoved as u16))); + assert_eq!( + opcodes + .iter() + .filter(|opcode| { + **opcode == wow_constants::ServerOpcodes::LootMoneyNotify as u16 + }) + .count(), + 1 + ); + } + + #[tokio::test] + async fn remote_group_money_is_one_atomic_durable_fanout_like_cpp() { + let (mut first, first_rx, mut second, second_rx, owner, first_guid, second_guid) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(9, false), + ); + install_group_loot_group(&mut first, first_guid, second_guid); + let player_registry = Arc::new(PlayerRegistry::default()); + let (registry_send_tx, _registry_send_rx) = flume::bounded(8); + let mut second_info = broadcast_info(second_guid, registry_send_tx); + second_info.command_tx = second.session_command_tx(); + player_registry.insert(second_guid, second_info); + first.set_player_registry(player_registry); + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let _ = drain_server_opcodes_like_cpp(&first_rx); + let _ = drain_server_opcodes_like_cpp(&second_rx); + + first.handle_loot_money(loot_money_packet()).await; + second.process_represented_session_commands_like_cpp().await; + + // C++ divides integral copper and discards the remainder. + assert_eq!(first.player_gold_like_cpp(), 4); + assert_eq!(second.player_gold_like_cpp(), 4); + assert_eq!( + authority + .snapshot_for_player_like_cpp(first_guid) + .unwrap() + .loot + .coins, + 0 + ); + for opcodes in [ + drain_server_opcodes_like_cpp(&first_rx), + drain_server_opcodes_like_cpp(&second_rx), + ] { + let coin = opcodes + .iter() + .position(|opcode| *opcode == wow_constants::ServerOpcodes::CoinRemoved as u16) + .expect("active viewer must receive CoinRemoved"); + let money = opcodes + .iter() + .position(|opcode| *opcode == wow_constants::ServerOpcodes::LootMoneyNotify as u16) + .expect("payout recipient must receive LootMoneyNotify"); + assert!(coin < money, "C++ removes coins before notifying payout"); } } #[test] - fn represented_loot_type_for_client_matches_cpp_aliases() { + fn pickpocket_money_is_not_shared_with_the_group_like_cpp() { + let (mut session, _send_rx) = make_session_with_send(); + let player_guid = ObjectGuid::create_player(1, 42); + let member_guid = ObjectGuid::create_player(1, 43); + let owner = test_creature_guid(19_501); + session.set_player_guid(Some(player_guid)); + session.set_player_position_like_cpp(Position::ZERO); + install_group_loot_group(&mut session, player_guid, member_guid); + let registry = Arc::new(PlayerRegistry::default()); + let (member_tx, _member_rx) = flume::bounded(1); + registry.insert(member_guid, broadcast_info(member_guid, member_tx)); + session.set_player_registry(registry); + let mut loot = authoritative_test_loot_like_cpp(8, false); + loot.loot_type = LOOT_TYPE_PICKPOCKETING_LIKE_CPP; + loot.allowed_looters = vec![player_guid, member_guid]; + session.loot_table.insert(owner, loot); + assert_eq!( - loot_type_for_client_like_cpp(LOOT_TYPE_NONE_LIKE_CPP), - LOOT_TYPE_NONE_LIKE_CPP + session.represented_loot_money_recipients_like_cpp(owner), + vec![player_guid] ); + } + + #[test] + fn vehicle_corpse_money_shares_and_pool_allowed_looters_control_membership_like_cpp() { + let (mut session, _send_rx) = make_session_with_send(); + let player_guid = ObjectGuid::create_player(1, 42); + let member_guid = ObjectGuid::create_player(1, 43); + let owner = ObjectGuid::create_vehicle_like_cpp(1, 0, 1, 19_502); + session.set_player_guid(Some(player_guid)); + session.set_player_position_like_cpp(Position::ZERO); + install_group_loot_group(&mut session, player_guid, member_guid); + let registry = Arc::new(PlayerRegistry::default()); + let (member_tx, _member_rx) = flume::bounded(1); + registry.insert(member_guid, broadcast_info(member_guid, member_tx)); + session.set_player_registry(registry); + + let mut loot = authoritative_test_loot_like_cpp(8, false); + loot.loot_type = LOOT_TYPE_CORPSE_LIKE_CPP; + loot.allowed_looters = vec![player_guid]; + session.loot_table.insert(owner, loot); assert_eq!( - loot_type_for_client_like_cpp(LOOT_TYPE_CORPSE_LIKE_CPP), - LOOT_TYPE_CORPSE_LIKE_CPP + session.represented_loot_money_recipients_like_cpp(owner), + vec![player_guid] ); + + session + .loot_table + .get_mut(&owner) + .unwrap() + .allowed_looters + .push(member_guid); assert_eq!( - loot_type_for_client_like_cpp(LOOT_TYPE_ITEM_LIKE_CPP), - LOOT_TYPE_ITEM_LIKE_CPP + session.represented_loot_money_recipients_like_cpp(owner), + vec![player_guid, member_guid] ); + } + + #[test] + fn corpse_money_reward_distance_ignores_range_only_in_same_dungeon_instance_like_cpp() { + let (mut session, _send_rx) = make_session_with_send(); + let player_guid = ObjectGuid::create_player(1, 42); + let member_guid = ObjectGuid::create_player(1, 43); + let owner = test_creature_guid(19_503); + session.set_player_guid(Some(player_guid)); + session.set_player_position_like_cpp(Position::ZERO); + install_group_loot_group(&mut session, player_guid, member_guid); + let registry = Arc::new(PlayerRegistry::default()); + let (member_tx, _member_rx) = flume::bounded(1); + let mut member = broadcast_info(member_guid, member_tx.clone()); + member.position = Position::new(10_000.0, 0.0, 0.0, 0.0); + registry.insert(member_guid, member); + session.set_player_registry(Arc::clone(®istry)); + let mut loot = authoritative_test_loot_like_cpp(8, false); + loot.allowed_looters = vec![player_guid, member_guid]; + session.loot_table.insert(owner, loot); + + session.set_map_store(Arc::new(wow_data::MapStore::from_entries([ + wow_data::MapEntry { + id: 0, + instance_type: wow_data::map::MAP_COMMON, + expansion_id: 0, + parent_map_id: -1, + cosmetic_parent_map_id: -1, + flags1: 0, + flags2: 0, + }, + ]))); assert_eq!( - loot_type_for_client_like_cpp(LOOT_TYPE_GATHERING_NODE_LIKE_CPP), - LOOT_TYPE_GATHERING_NODE_LIKE_CPP - ); + session.represented_loot_money_recipients_like_cpp(owner), + vec![player_guid] + ); + + session.set_map_store(Arc::new(wow_data::MapStore::from_entries([ + wow_data::MapEntry { + id: 0, + instance_type: wow_data::map::MAP_INSTANCE, + expansion_id: 0, + parent_map_id: -1, + cosmetic_parent_map_id: -1, + flags1: 0, + flags2: 0, + }, + ]))); assert_eq!( - loot_type_for_client_like_cpp(LOOT_TYPE_CHEST_LIKE_CPP), - LOOT_TYPE_CHEST_LIKE_CPP + session.represented_loot_money_recipients_like_cpp(owner), + vec![player_guid, member_guid] ); + + let mut wrong_instance = broadcast_info(member_guid, member_tx); + wrong_instance.position = Position::new(10_000.0, 0.0, 0.0, 0.0); + wrong_instance.instance_id = 1; + registry.insert(member_guid, wrong_instance); assert_eq!( - loot_type_for_client_like_cpp(LOOT_TYPE_CORPSE_PERSONAL_LIKE_CPP), - LOOT_TYPE_CORPSE_PERSONAL_LIKE_CPP + session.represented_loot_money_recipients_like_cpp(owner), + vec![player_guid] ); + } + + #[test] + fn chest_allowed_looters_ignore_range_only_in_same_dungeon_instance_like_cpp() { + let (mut session, _send_rx) = make_session_with_send(); + let player_guid = ObjectGuid::create_player(1, 42); + let member_guid = ObjectGuid::create_player(1, 43); + session.set_player_guid(Some(player_guid)); + session.set_player_position_like_cpp(Position::ZERO); + install_group_loot_group(&mut session, player_guid, member_guid); + let registry = Arc::new(PlayerRegistry::default()); + let (member_tx, _member_rx) = flume::bounded(1); + let mut member = broadcast_info(member_guid, member_tx.clone()); + member.position = Position::new(10_000.0, 0.0, 0.0, 0.0); + registry.insert(member_guid, member); + session.set_player_registry(Arc::clone(®istry)); + + session.set_map_store(Arc::new(wow_data::MapStore::from_entries([ + wow_data::MapEntry { + id: 0, + instance_type: wow_data::map::MAP_COMMON, + expansion_id: 0, + parent_map_id: -1, + cosmetic_parent_map_id: -1, + flags1: 0, + flags2: 0, + }, + ]))); assert_eq!( - loot_type_for_client_like_cpp(LOOT_TYPE_PROSPECTING_LIKE_CPP), - LOOT_TYPE_DISENCHANTING_LIKE_CPP + session.represented_group_looters_at_reward_distance_like_cpp(player_guid), + vec![player_guid] + ); + + session.set_map_store(Arc::new(wow_data::MapStore::from_entries([ + wow_data::MapEntry { + id: 0, + instance_type: wow_data::map::MAP_INSTANCE, + expansion_id: 0, + parent_map_id: -1, + cosmetic_parent_map_id: -1, + flags1: 0, + flags2: 0, + }, + ]))); + assert_eq!( + session.represented_group_looters_at_reward_distance_like_cpp(player_guid), + vec![player_guid, member_guid] ); + + let mut wrong_instance = broadcast_info(member_guid, member_tx); + wrong_instance.position = Position::new(10_000.0, 0.0, 0.0, 0.0); + wrong_instance.instance_id = 1; + registry.insert(member_guid, wrong_instance); assert_eq!( - loot_type_for_client_like_cpp(LOOT_TYPE_MILLING_LIKE_CPP), - LOOT_TYPE_DISENCHANTING_LIKE_CPP + session.represented_group_looters_at_reward_distance_like_cpp(player_guid), + vec![player_guid] ); + } + + #[tokio::test] + async fn failed_remote_group_money_transaction_credits_nobody_and_retries_like_cpp() { + let (mut first, _first_rx, mut second, _second_rx, owner, first_guid, second_guid) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(9, false), + ); + install_group_loot_group(&mut first, first_guid, second_guid); + let player_registry = Arc::new(PlayerRegistry::default()); + let (registry_send_tx, _registry_send_rx) = flume::bounded(8); + let mut second_info = broadcast_info(second_guid, registry_send_tx); + second_info.command_tx = second.session_command_tx(); + player_registry.insert(second_guid, second_info); + first.set_player_registry(player_registry); + first.set_loot_money_persistence_test_result_like_cpp(false); + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + + first.handle_loot_money(loot_money_packet()).await; + second.process_represented_session_commands_like_cpp().await; + assert_eq!(first.player_gold_like_cpp(), 0); + assert_eq!(second.player_gold_like_cpp(), 0); assert_eq!( - loot_type_for_client_like_cpp(LOOT_TYPE_INSIGNIA_LIKE_CPP), - LOOT_TYPE_SKINNING_LIKE_CPP + authority + .snapshot_for_player_like_cpp(first_guid) + .unwrap() + .loot + .coins, + 9 ); + + first.set_loot_money_persistence_test_result_like_cpp(true); + first.handle_loot_money(loot_money_packet()).await; + second.process_represented_session_commands_like_cpp().await; + assert_eq!(first.player_gold_like_cpp(), 4); + assert_eq!(second.player_gold_like_cpp(), 4); assert_eq!( - loot_type_for_client_like_cpp(LOOT_TYPE_FISHINGHOLE_LIKE_CPP), - LOOT_TYPE_FISHING_LIKE_CPP + authority + .snapshot_for_player_like_cpp(first_guid) + .unwrap() + .loot + .coins, + 0 ); + } + + #[tokio::test] + async fn cancelled_money_waiter_cannot_reopen_a_durable_claim_like_cpp() { + let (mut first, _first_rx, mut second, second_rx, owner, first_guid, second_guid) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(9, false), + ); + first.set_loot_money_persistence_test_result_like_cpp(true); + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let claim = authority.reserve_money_like_cpp(first_guid).await.unwrap(); + let authority_generation = claim.generation_like_cpp(); + let authority_committed = Arc::new(AtomicBool::new(false)); + let application = ApplyLootMoneyLikeCppCommand { + recipient: second_guid, + loot_owner: owner, + loot_obj: represented_loot_object_guid_like_cpp(owner), + amount: 9, + durable_applied_amount: Arc::new(AtomicU64::new(0)), + durable_persistence_tracker: second.durable_loot_money_persistence_tracker_like_cpp(), + sole_looter: true, + authority: authority.clone(), + authority_generation, + authority_committed: Arc::clone(&authority_committed), + send_coin_removed: Arc::new(AtomicBool::new(true)), + applied: Arc::new(AtomicBool::new(false)), + published: Arc::new(AtomicBool::new(false)), + }; + let delivery = ( + second.session_command_tx(), + SessionCommand::ApplyLootMoneyLikeCpp(application), + ); + let viewer_fanout = LootMoneyViewerFanoutLikeCpp { + scope_player: first_guid, + source_player: first_guid, + source_command_tx: first.session_command_tx(), + player_registry: first.player_registry().cloned(), + map_id: first.player_map_id_like_cpp(), + instance_id: first + .current_canonical_player_map_key_like_cpp() + .map(|key| key.instance_id) + .unwrap_or(0), + loot_owner: owner, + loot_obj: represented_loot_object_guid_like_cpp(owner), + authority: authority.clone(), + authority_generation, + payout_recipients: [second_guid].into_iter().collect(), + }; + let _ = drain_server_opcodes_like_cpp(&second_rx); + let persistence = first + .spawn_group_loot_money_persistence_like_cpp( + vec![(second_guid, 9)], + claim, + vec![delivery], + Arc::clone(&authority_committed), + viewer_fanout, + ) + .unwrap(); + + // The outer packet task owns only the JoinHandle. Aborting it must not + // cancel the detached SQL+authority worker that owns the lease. + let waiter = tokio::spawn(async move { persistence.await }); + waiter.abort(); + let _ = waiter.await; + for _ in 0..4 { + tokio::task::yield_now().await; + } + second.process_represented_session_commands_like_cpp().await; assert_eq!( - loot_type_for_client_like_cpp(LOOT_TYPE_FISHING_JUNK_LIKE_CPP), - LOOT_TYPE_FISHING_LIKE_CPP + authority + .snapshot_for_player_like_cpp(first_guid) + .unwrap() + .loot + .coins, + 0 ); + let zero = authority + .reserve_money_like_cpp(first_guid) + .await + .expect("C++ keeps the view and serializes a later zero-money observation"); + assert_eq!(zero.payload_like_cpp(), &LootClaimPayload::Money(0)); + assert!(zero.commit_like_cpp().unwrap()); + assert!(authority_committed.load(Ordering::Acquire)); + assert_eq!(second.player_gold_like_cpp(), 9); + let opcodes = drain_server_opcodes_like_cpp(&second_rx); + let coin = opcodes + .iter() + .position(|opcode| *opcode == wow_constants::ServerOpcodes::CoinRemoved as u16) + .unwrap(); + let money = opcodes + .iter() + .position(|opcode| *opcode == wow_constants::ServerOpcodes::LootMoneyNotify as u16) + .unwrap(); + assert!(coin < money); } #[tokio::test] - async fn represented_loot_response_acquire_reason_uses_cpp_loot_type_mapping() { - let mut session = make_session(); - let player_guid = ObjectGuid::create_player(1, 42); - let owner_guid = test_creature_guid(19_096); - let loot_guid = represented_loot_object_guid_like_cpp(owner_guid); - let entry = represented_loot_entry(0, 25, player_guid); - register_test_creature_like_cpp(&mut session, test_creature(owner_guid, false)); - session.loot_table.insert( - owner_guid, - CreatureLoot { - loot_guid, - coins: 0, - unlooted_count: 1, - loot_type: LOOT_TYPE_PROSPECTING_LIKE_CPP, - dungeon_encounter_id: 0, - loot_method: 0, - loot_master: ObjectGuid::EMPTY, - round_robin_player: ObjectGuid::EMPTY, - player_ffa_items: Vec::new(), - players_looting: Vec::new(), - allowed_looters: vec![player_guid], - items: vec![entry], - looted_by_player: false, - }, + async fn money_viewer_opened_during_persistence_receives_coin_removed_like_cpp() { + let (mut first, first_rx, mut second, second_rx, owner, first_guid, second_guid) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(9, false), + ); + first.set_loot_money_persistence_test_result_like_cpp(true); + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + assert!(authority.remove_viewer_like_cpp(second_guid)); + let _ = drain_server_opcodes_like_cpp(&first_rx); + let _ = drain_server_opcodes_like_cpp(&second_rx); + + let registry = Arc::new(PlayerRegistry::default()); + let (registry_send_tx, _registry_send_rx) = flume::bounded(8); + let mut second_info = broadcast_info(second_guid, registry_send_tx); + second_info.command_tx = second.session_command_tx(); + registry.insert(second_guid, second_info); + first.set_player_registry(Arc::clone(®istry)); + + let claim = authority.reserve_money_like_cpp(first_guid).await.unwrap(); + let authority_generation = claim.generation_like_cpp(); + let authority_committed = Arc::new(AtomicBool::new(false)); + let application = ApplyLootMoneyLikeCppCommand { + recipient: first_guid, + loot_owner: owner, + loot_obj: represented_loot_object_guid_like_cpp(owner), + amount: 9, + durable_applied_amount: Arc::new(AtomicU64::new(0)), + durable_persistence_tracker: first.durable_loot_money_persistence_tracker_like_cpp(), + sole_looter: true, + authority: authority.clone(), + authority_generation, + authority_committed: Arc::clone(&authority_committed), + send_coin_removed: Arc::new(AtomicBool::new(false)), + applied: Arc::new(AtomicBool::new(false)), + published: Arc::new(AtomicBool::new(false)), + }; + let viewer_fanout = LootMoneyViewerFanoutLikeCpp { + scope_player: first_guid, + source_player: first_guid, + source_command_tx: first.session_command_tx(), + player_registry: Some(registry), + map_id: first.player_map_id_like_cpp(), + instance_id: first + .current_canonical_player_map_key_like_cpp() + .map(|key| key.instance_id) + .unwrap_or(0), + loot_owner: owner, + loot_obj: represented_loot_object_guid_like_cpp(owner), + authority: authority.clone(), + authority_generation, + payout_recipients: [first_guid].into_iter().collect(), + }; + let persistence = first + .spawn_group_loot_money_persistence_like_cpp( + vec![(first_guid, 9)], + claim, + vec![( + first.session_command_tx(), + SessionCommand::ApplyLootMoneyLikeCpp(application), + )], + Arc::clone(&authority_committed), + viewer_fanout, + ) + .unwrap(); + + authority + .open_view_with_snapshot_like_cpp(second_guid, |_, _| ()) + .expect("late viewer opens before the detached worker commits"); + persistence.await.unwrap().unwrap(); + second.process_represented_session_commands_like_cpp().await; + + assert!(authority_committed.load(Ordering::Acquire)); + assert!( + drain_server_opcodes_like_cpp(&second_rx) + .contains(&(wow_constants::ServerOpcodes::CoinRemoved as u16)), + "a viewer that saw non-zero money during SQL must receive C++ NotifyMoneyRemoved" ); + } - let response = session - .represented_loot_response_for_owner_like_cpp(owner_guid, player_guid, false) + #[tokio::test] + async fn cancelled_item_waiter_cannot_reopen_a_durable_claim_like_cpp() { + let (mut first, _first_rx, _second, _second_rx, owner, first_guid, _second_guid) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(0, true), + ); + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let claim = authority + .reserve_item_for_award_like_cpp(first_guid, 0) .await .unwrap(); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel(); + let worker = super::spawn_loot_claim_persistence_worker_like_cpp( + async move { + let _ = started_tx.send(()); + let _ = release_rx.await; + Ok::<(), ()>(()) + }, + Some(claim), + None, + ) + .unwrap(); + let waiter = tokio::spawn(async move { worker.await }); - assert_eq!(response.acquire_reason, LOOT_TYPE_DISENCHANTING_LIKE_CPP); + started_rx.await.unwrap(); + waiter.abort(); + let _ = waiter.await; + release_tx.send(()).unwrap(); + for _ in 0..4 { + tokio::task::yield_now().await; + } + + let snapshot = authority.snapshot_for_player_like_cpp(first_guid).unwrap(); + assert!(snapshot.loot.items[0].taken); + assert_eq!(snapshot.loot.unlooted_count, 0); + assert!( + authority + .reserve_item_for_award_like_cpp(first_guid, 0) + .await + .is_err() + ); } - #[test] - fn represented_start_loot_roll_carries_cpp_dungeon_encounter_id() { - let player_guid = ObjectGuid::create_player(1, 42); - let loot_obj = ObjectGuid::create_world_object(HighGuid::LootObject, 0, 1, 0, 0, 1, 900); - let entry = represented_loot_entry(0, 25, player_guid); + #[tokio::test] + async fn durable_item_completion_auto_releases_only_after_items_and_coins_are_empty_like_cpp() { + for (coins, should_release) in [(0, true), (7, false)] { + let (mut session, send_rx) = make_session_with_send_capacity(16); + let player_guid = ObjectGuid::create_player(1, 61_801 + i64::from(coins)); + let owner_guid = ObjectGuid::create_item(1, 61_811 + i64::from(coins)); + install_active_item_loot_completion_fixture_like_cpp( + &mut session, + player_guid, + owner_guid, + coins, + ); + let runtime_inventory_applied = Arc::new(AtomicBool::new(true)); + let guard = session.begin_durable_item_loot_persistence_like_cpp(); + super::spawn_loot_claim_persistence_worker_like_cpp( + async { Ok::<(), ()>(()) }, + None, + Some(( + guard, + DurableItemLootCompletionLikeCpp { + owner_guid, + loot_list_id: 0, + player_guid, + item_owner_auto_release: true, + durable_item_money_applied_amount: None, + durable_item_money_notified_amount: None, + durable_item_money_balance_applied: None, + item_fanout: None, + runtime_inventory_applied, + }, + )), + ) + .unwrap() + .await + .unwrap() + .unwrap(); - let packet = start_loot_roll_packet_like_cpp( - loot_obj, - 571, - LOOT_METHOD_GROUP_LIKE_CPP, - &entry, - ROLL_ALL_TYPE_NO_DISENCHANT_LIKE_CPP, - 615, - ); + session.wait_for_active_loot_persistence_like_cpp().await; - assert_eq!(packet.dungeon_encounter_id, 615); + assert!(!session.is_disconnecting()); + assert_eq!( + session.loot_table.contains_key(&owner_guid), + !should_release + ); + assert_eq!(session.is_active_loot_guid(owner_guid), !should_release); + if !should_release { + let loot = session.loot_table.get(&owner_guid).unwrap(); + assert!(loot.items[0].taken); + assert_eq!(loot.coins, coins); + } + assert_eq!( + drain_server_opcodes_like_cpp(&send_rx) + .into_iter() + .filter(|opcode| { + *opcode == wow_constants::ServerOpcodes::LootRelease as u16 + }) + .count(), + usize::from(should_release), + "C++ StoreLootItem checks Loot::isLooted(), including money" + ); + } } - #[test] - fn represented_loot_item_push_result_carries_cpp_encounter_loot_fields() { - let (session, send_rx) = make_session_with_send(); - let player_guid = ObjectGuid::create_player(1, 42); - let item_guid = ObjectGuid::create_item(1, 700); - let entry = represented_loot_entry(0, 25, player_guid); + #[tokio::test] + async fn durable_item_completion_never_auto_releases_creature_or_gameobject_owner_like_cpp() { + let (mut session, send_rx) = make_session_with_send_capacity(16); + let player_guid = ObjectGuid::create_player(1, 61_820); + session.set_player_guid(Some(player_guid)); + for owner_guid in [ + test_creature_guid(61_821), + ObjectGuid::create_world_object(HighGuid::GameObject, 0, 1, 0, 0, 1, 61_822), + ] { + session.set_active_loot_guid(owner_guid); + session.loot_table.insert( + owner_guid, + CreatureLoot { + loot_guid: owner_guid, + coins: 0, + unlooted_count: 1, + loot_type: LOOT_TYPE_ITEM_LIKE_CPP, + dungeon_encounter_id: 0, + loot_method: 0, + loot_master: ObjectGuid::EMPTY, + round_robin_player: ObjectGuid::EMPTY, + player_ffa_items: Vec::new(), + players_looting: vec![player_guid], + allowed_looters: vec![player_guid], + items: vec![represented_loot_entry(0, 25, player_guid)], + looted_by_player: false, + }, + ); + let guard = session.begin_durable_item_loot_persistence_like_cpp(); + super::spawn_loot_claim_persistence_worker_like_cpp( + async { Ok::<(), ()>(()) }, + None, + Some(( + guard, + DurableItemLootCompletionLikeCpp { + owner_guid, + loot_list_id: 0, + player_guid, + item_owner_auto_release: false, + durable_item_money_applied_amount: None, + durable_item_money_notified_amount: None, + durable_item_money_balance_applied: None, + item_fanout: None, + runtime_inventory_applied: Arc::new(AtomicBool::new(true)), + }, + )), + ) + .unwrap() + .await + .unwrap() + .unwrap(); + } - session.send_loot_item_push_result( + session.wait_for_active_loot_persistence_like_cpp().await; + + assert!(!session.is_disconnecting()); + assert!(session.loot_table.values().all(|loot| !loot.items[0].taken)); + assert!( + !drain_server_opcodes_like_cpp(&send_rx) + .contains(&(wow_constants::ServerOpcodes::LootRelease as u16)) + ); + } + + #[tokio::test] + async fn cancelled_item_handler_after_commit_releases_and_forces_inventory_reload_like_cpp() { + let (mut session, send_rx) = make_session_with_send_capacity(16); + let player_guid = ObjectGuid::create_player(1, 61_830); + let owner_guid = ObjectGuid::create_item(1, 61_831); + install_active_item_loot_completion_fixture_like_cpp( + &mut session, player_guid, - item_guid, - &entry, - 0, - 0, + owner_guid, 0, - 1, - 1, - false, - 615, ); - - let sent = send_rx.try_recv().unwrap(); - let mut sent = WorldPacket::from_bytes(&sent); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (commit_tx, commit_rx) = tokio::sync::oneshot::channel(); + let guard = session.begin_durable_item_loot_persistence_like_cpp(); + let worker = super::spawn_loot_claim_persistence_worker_like_cpp( + async move { + let _ = started_tx.send(()); + let _ = commit_rx.await; + Ok::<(), ()>(()) + }, + None, + Some(( + guard, + DurableItemLootCompletionLikeCpp { + owner_guid, + loot_list_id: 0, + player_guid, + item_owner_auto_release: true, + durable_item_money_applied_amount: None, + durable_item_money_notified_amount: None, + durable_item_money_balance_applied: None, + item_fanout: None, + runtime_inventory_applied: Arc::new(AtomicBool::new(false)), + }, + )), + ) + .unwrap(); + let waiter = tokio::spawn(async move { worker.await }); + started_rx.await.unwrap(); + waiter.abort(); + let _ = waiter.await; + commit_tx.send(()).unwrap(); + + session.wait_for_active_loot_persistence_like_cpp().await; + + assert!(session.is_disconnecting()); + assert!(!session.loot_table.contains_key(&owner_guid)); + assert!(!session.is_active_loot_guid(owner_guid)); assert_eq!( - sent.read_uint16().unwrap(), - wow_constants::ServerOpcodes::ItemPushResult as u16 + drain_server_opcodes_like_cpp(&send_rx) + .into_iter() + .filter(|opcode| *opcode == wow_constants::ServerOpcodes::LootRelease as u16) + .count(), + 1 ); - assert_eq!(sent.read_packed_guid().unwrap(), player_guid); - assert_eq!(sent.read_uint8().unwrap(), u8::from(INVENTORY_SLOT_BAG_0)); - assert_eq!(sent.read_int32().unwrap(), 0); - assert_eq!(sent.read_int32().unwrap(), 0); - assert_eq!(sent.read_int32().unwrap(), 1); - assert_eq!(sent.read_int32().unwrap(), 1); - assert_eq!(sent.read_int32().unwrap(), 615); - assert_eq!(sent.read_int32().unwrap(), 0); - assert_eq!(sent.read_int32().unwrap(), 0); - assert_eq!(sent.read_uint32().unwrap(), 0); - assert_eq!(sent.read_int32().unwrap(), 0); - assert_eq!(sent.read_packed_guid().unwrap(), item_guid); - assert!(!sent.read_bit().unwrap()); - assert!(!sent.read_bit().unwrap()); - assert_eq!(sent.read_bits(3).unwrap(), 2); - assert!(!sent.read_bit().unwrap()); - assert!(sent.read_bit().unwrap()); - assert_eq!(sent.read_int32().unwrap(), 25); } - #[test] - fn creature_generated_loot_entry_uses_item_template_addon_follow_loot_rules_like_cpp() { - let generated = GeneratedLootItem { - item_id: 25, - count: 1, - loot_list_id: 7, - random_properties_id: -77, - random_properties_seed: 456, - context: ItemContext::DungeonNormal as u8, - free_for_all: false, - follow_loot_rules: false, - needs_quest: true, - is_looted: false, - is_blocked: false, - is_under_threshold: false, - is_counted: false, - }; + #[tokio::test] + async fn cancelled_world_owner_claim_after_commit_reconciles_cache_and_forces_reload_like_cpp() + { + let (mut session, _send_rx, _second, _second_rx, owner_guid, player_guid, _) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(0, true), + ); + let authority = session + .represented_owned_loot_authority_like_cpp(owner_guid) + .unwrap(); + let claim = authority + .reserve_item_for_award_like_cpp(player_guid, 0) + .await + .unwrap(); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (commit_tx, commit_rx) = tokio::sync::oneshot::channel(); + let guard = session.begin_durable_item_loot_persistence_like_cpp(); + let worker = super::spawn_loot_claim_persistence_worker_like_cpp( + async move { + let _ = started_tx.send(()); + let _ = commit_rx.await; + Ok::<(), ()>(()) + }, + Some(claim), + Some(( + guard, + DurableItemLootCompletionLikeCpp { + owner_guid, + loot_list_id: 0, + player_guid, + item_owner_auto_release: false, + durable_item_money_applied_amount: None, + durable_item_money_notified_amount: None, + durable_item_money_balance_applied: None, + item_fanout: None, + runtime_inventory_applied: Arc::new(AtomicBool::new(false)), + }, + )), + ) + .unwrap(); + let waiter = tokio::spawn(async move { worker.await }); + started_rx.await.unwrap(); + waiter.abort(); + let _ = waiter.await; + commit_tx.send(()).unwrap(); + + session.wait_for_active_loot_persistence_like_cpp().await; + + assert!(session.is_disconnecting()); + assert!( + authority + .snapshot_for_player_like_cpp(player_guid) + .unwrap() + .loot + .items[0] + .taken + ); + assert!( + session.loot_table.get(&owner_guid).unwrap().items[0].taken, + "master/roll/direct world-owner grants share this claimed-store recovery path" + ); + } + + #[tokio::test] + async fn failed_item_persistence_publishes_no_removal_or_release_like_cpp() { + let (mut session, send_rx) = make_session_with_send_capacity(16); + let player_guid = ObjectGuid::create_player(1, 61_840); + let owner_guid = ObjectGuid::create_item(1, 61_841); + install_active_item_loot_completion_fixture_like_cpp( + &mut session, + player_guid, + owner_guid, + 0, + ); + let guard = session.begin_durable_item_loot_persistence_like_cpp(); + let result = super::spawn_loot_claim_persistence_worker_like_cpp( + async { Err::<(), ()>(()) }, + None, + Some(( + guard, + DurableItemLootCompletionLikeCpp { + owner_guid, + loot_list_id: 0, + player_guid, + item_owner_auto_release: true, + durable_item_money_applied_amount: None, + durable_item_money_notified_amount: None, + durable_item_money_balance_applied: None, + item_fanout: None, + runtime_inventory_applied: Arc::new(AtomicBool::new(false)), + }, + )), + ) + .unwrap() + .await + .unwrap(); + assert!(result.is_err()); - let default_entry = generated_creature_loot_item_to_entry_like_cpp( - generated, - ItemTemplateAddonLootMetadataLikeCpp::default(), + session.wait_for_active_loot_persistence_like_cpp().await; + + assert!(!session.is_disconnecting()); + assert!(session.is_active_loot_guid(owner_guid)); + assert!(!session.loot_table.get(&owner_guid).unwrap().items[0].taken); + assert!( + !drain_server_opcodes_like_cpp(&send_rx) + .contains(&(wow_constants::ServerOpcodes::LootRelease as u16)) ); - assert!(!default_entry.flags.follow_loot_rules); - assert_eq!(default_entry.loot_list_id, 7); - assert_eq!(default_entry.random_properties_id, -77); - assert_eq!(default_entry.random_properties_seed, 456); - assert_eq!(default_entry.item_context, ItemContext::DungeonNormal as u8); + } - let follow_entry = generated_creature_loot_item_to_entry_like_cpp( - generated, - ItemTemplateAddonLootMetadataLikeCpp { - flags_cu: ITEM_FLAGS_CU_FOLLOW_LOOT_RULES_LIKE_CPP, - quest_log_item_id: 0, + #[tokio::test] + async fn cancelled_stored_item_money_before_commit_retries_without_local_consumption_like_cpp() + { + let (mut session, _send_rx) = make_session_with_send_capacity(16); + let player_guid = ObjectGuid::create_player(1, 61_845); + let owner_guid = ObjectGuid::create_item(1, 61_846); + install_active_item_loot_completion_fixture_like_cpp( + &mut session, + player_guid, + owner_guid, + 7, + ); + session.set_player_gold_like_cpp(100); + + let durable_source_row = Arc::new(AtomicBool::new(true)); + let first_source = Arc::clone(&durable_source_row); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (_commit_tx, commit_rx) = tokio::sync::oneshot::channel::<()>(); + let first_balance_applied = Arc::new(AtomicBool::new(false)); + let first_runtime_applied = Arc::new(AtomicBool::new(false)); + let first_guard = session.begin_durable_item_loot_persistence_like_cpp(); + let first_worker = super::spawn_loot_claim_persistence_worker_like_cpp( + async move { + let _ = started_tx.send(()); + let _ = commit_rx.await; + first_source + .compare_exchange(true, false, Ordering::AcqRel, Ordering::Acquire) + .map(|_| ()) + .map_err(|_| ()) }, - ); - assert!(follow_entry.flags.follow_loot_rules); - assert!(follow_entry.flags.needs_quest); + None, + Some(( + first_guard, + DurableItemLootCompletionLikeCpp { + owner_guid, + loot_list_id: 0, + player_guid, + item_owner_auto_release: false, + durable_item_money_applied_amount: Some(7), + durable_item_money_notified_amount: Some(7), + durable_item_money_balance_applied: Some(Arc::clone(&first_balance_applied)), + item_fanout: None, + runtime_inventory_applied: Arc::clone(&first_runtime_applied), + }, + )), + ) + .unwrap(); + started_rx.await.unwrap(); + first_worker.abort(); + assert!(first_worker.await.unwrap_err().is_cancelled()); + + session.wait_for_active_loot_persistence_like_cpp().await; + assert!(durable_source_row.load(Ordering::Acquire)); + assert_eq!(session.player_gold_like_cpp(), 100); + assert_eq!(session.loot_table.get(&owner_guid).unwrap().coins, 7); + assert!(!first_runtime_applied.load(Ordering::Acquire)); + + let retry_source = Arc::clone(&durable_source_row); + let retry_balance_applied = Arc::new(AtomicBool::new(false)); + let retry_runtime_applied = Arc::new(AtomicBool::new(false)); + let retry_guard = session.begin_durable_item_loot_persistence_like_cpp(); + super::spawn_loot_claim_persistence_worker_like_cpp( + async move { + retry_source + .compare_exchange(true, false, Ordering::AcqRel, Ordering::Acquire) + .map(|_| ()) + .map_err(|_| ()) + }, + None, + Some(( + retry_guard, + DurableItemLootCompletionLikeCpp { + owner_guid, + loot_list_id: 0, + player_guid, + item_owner_auto_release: false, + durable_item_money_applied_amount: Some(7), + durable_item_money_notified_amount: Some(7), + durable_item_money_balance_applied: Some(Arc::clone(&retry_balance_applied)), + item_fanout: None, + runtime_inventory_applied: Arc::clone(&retry_runtime_applied), + }, + )), + ) + .unwrap() + .await + .unwrap() + .unwrap(); + + session.wait_for_active_loot_persistence_like_cpp().await; + assert!(!durable_source_row.load(Ordering::Acquire)); + assert_eq!(session.player_gold_like_cpp(), 107); + assert_eq!(session.loot_table.get(&owner_guid).unwrap().coins, 0); + assert!(retry_runtime_applied.load(Ordering::Acquire)); + assert!(session.is_active_loot_guid(owner_guid)); } - #[test] - fn represented_loot_response_items_use_cpp_ui_type_decision_tree() { - let player_guid = ObjectGuid::create_player(1, 42); - let other_guid = ObjectGuid::create_player(1, 77); - let loot_guid = ObjectGuid::create_world_object(HighGuid::Creature, 0, 1, 0, 0, 1, 100); + #[tokio::test] + async fn cancelled_stored_item_money_after_commit_is_replayed_before_disconnect_save_like_cpp() + { + let (mut session, _send_rx) = make_session_with_send_capacity(16); + let player_guid = ObjectGuid::create_player(1, 61_847); + let owner_guid = ObjectGuid::create_item(1, 61_848); + install_active_item_loot_completion_fixture_like_cpp( + &mut session, + player_guid, + owner_guid, + 7, + ); + session.set_player_gold_like_cpp(100); + + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (commit_tx, commit_rx) = tokio::sync::oneshot::channel(); + let balance_applied = Arc::new(AtomicBool::new(false)); + let runtime_money_applied = Arc::new(AtomicBool::new(false)); + let guard = session.begin_durable_item_loot_persistence_like_cpp(); + let worker = super::spawn_loot_claim_persistence_worker_like_cpp( + async move { + let _ = started_tx.send(()); + let _ = commit_rx.await; + Ok::<(), ()>(()) + }, + None, + Some(( + guard, + DurableItemLootCompletionLikeCpp { + owner_guid, + loot_list_id: 0, + player_guid, + item_owner_auto_release: false, + durable_item_money_applied_amount: Some(7), + durable_item_money_notified_amount: Some(7), + durable_item_money_balance_applied: Some(Arc::clone(&balance_applied)), + item_fanout: None, + runtime_inventory_applied: Arc::clone(&runtime_money_applied), + }, + )), + ) + .unwrap(); + let waiter = tokio::spawn(async move { worker.await }); + started_rx.await.unwrap(); + waiter.abort(); + let _ = waiter.await; - let mut rolling_entry = represented_loot_entry(0, 25, player_guid); - rolling_entry.flags.blocked = true; + let mut save = Box::pin(session.save_disconnect_player_to_db_like_cpp()); + assert!( + tokio::time::timeout(Duration::from_millis(5), &mut save) + .await + .is_err(), + "disconnect save must remain pending until durable loot publication is idle" + ); + commit_tx.send(()).unwrap(); + save.await; - let mut won_entry = represented_loot_entry(1, 26, player_guid); - won_entry.roll_winner = player_guid; + assert_eq!(session.player_gold_like_cpp(), 107); + assert!(runtime_money_applied.load(Ordering::Acquire)); + } - let mut hidden_entry = represented_loot_entry(2, 27, player_guid); - hidden_entry.roll_winner = other_guid; + #[tokio::test] + async fn stored_item_money_save_reconciled_balance_still_publishes_source_once_like_cpp() { + let (mut session, send_rx) = make_session_with_send_capacity(16); + let player_guid = ObjectGuid::create_player(1, 61_848); + let owner_guid = ObjectGuid::create_item(1, 61_849); + install_active_item_loot_completion_fixture_like_cpp( + &mut session, + player_guid, + owner_guid, + 7, + ); + session.set_player_gold_like_cpp(107); + let balance_applied = Arc::new(AtomicBool::new(true)); + let publication_applied = Arc::new(AtomicBool::new(false)); + let mut guard = session.begin_durable_item_loot_persistence_like_cpp(); + guard.mark_committed_like_cpp(DurableItemLootCompletionLikeCpp { + owner_guid, + loot_list_id: 0, + player_guid, + item_owner_auto_release: false, + durable_item_money_applied_amount: Some(7), + durable_item_money_notified_amount: Some(7), + durable_item_money_balance_applied: Some(Arc::clone(&balance_applied)), + item_fanout: None, + runtime_inventory_applied: Arc::clone(&publication_applied), + }); + drop(guard); - let mut allowed_entry = represented_loot_entry(3, 28, player_guid); - allowed_entry.flags.under_threshold = true; + session + .apply_pending_durable_item_loot_completions_like_cpp() + .await; - let loot = CreatureLoot { - loot_guid, - coins: 0, - unlooted_count: 0, - loot_type: LOOT_TYPE_CORPSE_LIKE_CPP, - dungeon_encounter_id: 0, - loot_method: LOOT_METHOD_GROUP_LIKE_CPP, - loot_master: ObjectGuid::EMPTY, - round_robin_player: ObjectGuid::EMPTY, - player_ffa_items: Vec::new(), - players_looting: Vec::new(), - allowed_looters: vec![player_guid], - items: vec![rolling_entry, won_entry, hidden_entry, allowed_entry], - looted_by_player: false, - }; + assert_eq!(session.player_gold_like_cpp(), 107); + assert_eq!(session.loot_table.get(&owner_guid).unwrap().coins, 0); + assert!(balance_applied.load(Ordering::Acquire)); + assert!(publication_applied.load(Ordering::Acquire)); + assert!( + drain_server_opcodes_like_cpp(&send_rx) + .contains(&(wow_constants::ServerOpcodes::LootMoneyNotify as u16)) + ); + } - let items = represented_loot_response_items_like_cpp(&loot, player_guid); + #[tokio::test] + async fn stored_item_money_delete_cas_allows_exactly_one_durable_grant_like_cpp() { + assert_eq!( + super::STORED_ITEM_MONEY_SOURCE_ROWS_EXPECTED_LIKE_CPP, + 1, + "the source-row delete must fail the whole transaction after another winner" + ); + let (mut session, send_rx) = make_session_with_send_capacity(16); + let player_guid = ObjectGuid::create_player(1, 61_849); + let owner_guid = ObjectGuid::create_item(1, 61_850); + install_active_item_loot_completion_fixture_like_cpp( + &mut session, + player_guid, + owner_guid, + 7, + ); + session.set_player_gold_like_cpp(100); + + let source_row = Arc::new(AtomicBool::new(true)); + let durable_grants = Arc::new(AtomicUsize::new(0)); + let mut workers = Vec::new(); + for _ in 0..2 { + let source_row = Arc::clone(&source_row); + let durable_grants = Arc::clone(&durable_grants); + let guard = session.begin_durable_item_loot_persistence_like_cpp(); + workers.push( + super::spawn_loot_claim_persistence_worker_like_cpp( + async move { + tokio::task::yield_now().await; + source_row + .compare_exchange(true, false, Ordering::AcqRel, Ordering::Acquire) + .map_err(|_| ())?; + durable_grants.fetch_add(1, Ordering::SeqCst); + Ok::<(), ()>(()) + }, + None, + Some(( + guard, + DurableItemLootCompletionLikeCpp { + owner_guid, + loot_list_id: 0, + player_guid, + item_owner_auto_release: false, + durable_item_money_applied_amount: Some(7), + durable_item_money_notified_amount: Some(7), + durable_item_money_balance_applied: Some(Arc::new(AtomicBool::new( + false, + ))), + item_fanout: None, + runtime_inventory_applied: Arc::new(AtomicBool::new(false)), + }, + )), + ) + .unwrap(), + ); + } - assert_eq!(items.len(), 3); - assert_eq!(items[0].loot_list_id, 0); - assert_eq!(items[0].ui_type, LOOT_SLOT_TYPE_ROLL_ONGOING_LIKE_CPP); - assert_eq!(items[1].loot_list_id, 1); - assert_eq!(items[1].ui_type, LOOT_SLOT_TYPE_OWNER_LIKE_CPP); - assert_eq!(items[2].loot_list_id, 3); - assert_eq!(items[2].ui_type, LOOT_SLOT_TYPE_ALLOW_LOOT_LIKE_CPP); + let mut successes = 0; + for worker in workers { + if matches!(worker.await, Ok(Ok(()))) { + successes += 1; + } + } + session.wait_for_active_loot_persistence_like_cpp().await; + + assert_eq!(successes, 1); + assert_eq!(durable_grants.load(Ordering::SeqCst), 1); + assert_eq!(session.player_gold_like_cpp(), 107); + assert_eq!(session.loot_table.get(&owner_guid).unwrap().coins, 0); + assert_eq!( + drain_server_opcodes_like_cpp(&send_rx) + .into_iter() + .filter(|opcode| { + *opcode == wow_constants::ServerOpcodes::LootMoneyNotify as u16 + }) + .count(), + 1 + ); } - #[test] - fn represented_ffa_loot_uses_player_ffa_items_like_cpp() { - let player_guid = ObjectGuid::create_player(1, 42); - let other_guid = ObjectGuid::create_player(1, 77); - let loot_guid = ObjectGuid::create_world_object(HighGuid::Creature, 0, 1, 0, 0, 1, 101); + #[tokio::test] + async fn cancelled_zero_stored_item_money_notifies_once_and_normal_completion_does_not_replay_like_cpp() + { + for cancelled_waiter in [true, false] { + let (mut session, send_rx) = make_session_with_send_capacity(16); + let player_guid = + ObjectGuid::create_player(1, if cancelled_waiter { 61_851 } else { 61_852 }); + let owner_guid = + ObjectGuid::create_item(1, if cancelled_waiter { 61_853 } else { 61_854 }); + install_active_item_loot_completion_fixture_like_cpp( + &mut session, + player_guid, + owner_guid, + 0, + ); - let mut ffa_entry = represented_loot_entry(0, 25, player_guid); - ffa_entry.flags.freeforall = true; - ffa_entry.allowed_looters.clear(); + if cancelled_waiter { + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (commit_tx, commit_rx) = tokio::sync::oneshot::channel(); + let guard = session.begin_durable_item_loot_persistence_like_cpp(); + let worker = super::spawn_loot_claim_persistence_worker_like_cpp( + async move { + let _ = started_tx.send(()); + let _ = commit_rx.await; + Ok::<(), ()>(()) + }, + None, + Some(( + guard, + DurableItemLootCompletionLikeCpp { + owner_guid, + loot_list_id: 0, + player_guid, + item_owner_auto_release: false, + durable_item_money_applied_amount: Some(0), + durable_item_money_notified_amount: Some(0), + durable_item_money_balance_applied: Some(Arc::new(AtomicBool::new( + false, + ))), + item_fanout: None, + runtime_inventory_applied: Arc::new(AtomicBool::new(false)), + }, + )), + ) + .unwrap(); + let waiter = tokio::spawn(async move { worker.await }); + started_rx.await.unwrap(); + waiter.abort(); + let _ = waiter.await; + commit_tx.send(()).unwrap(); + session.wait_for_active_loot_persistence_like_cpp().await; + } else { + session.set_loot_money_persistence_test_result_like_cpp(true); + session.handle_loot_money(loot_money_packet()).await; + session.wait_for_active_loot_persistence_like_cpp().await; + } - let mut loot = CreatureLoot { - loot_guid, - coins: 0, - unlooted_count: 0, - loot_type: LOOT_TYPE_CORPSE_LIKE_CPP, - dungeon_encounter_id: 0, - loot_method: LOOT_METHOD_GROUP_LIKE_CPP, - loot_master: ObjectGuid::EMPTY, - round_robin_player: ObjectGuid::EMPTY, - player_ffa_items: Vec::new(), - players_looting: Vec::new(), - allowed_looters: Vec::new(), - items: vec![ffa_entry], - looted_by_player: false, - }; + let opcodes = drain_server_opcodes_like_cpp(&send_rx); + assert_eq!( + opcodes + .iter() + .filter(|opcode| { + **opcode == wow_constants::ServerOpcodes::LootMoneyNotify as u16 + }) + .count(), + 1, + "zero money still notifies, while a normally published completion is not replayed" + ); + session.wait_for_active_loot_persistence_like_cpp().await; + assert!(drain_server_opcodes_like_cpp(&send_rx).is_empty()); + } + } - mark_loot_allowed_for_player_like_cpp(&mut loot, player_guid); - mark_loot_allowed_for_player_like_cpp(&mut loot, other_guid); - assert_eq!(loot.unlooted_count, 2); + #[tokio::test] + async fn disconnect_waits_for_item_publication_and_releases_once_like_cpp() { + let (mut session, send_rx) = make_session_with_send_capacity(16); + let player_guid = ObjectGuid::create_player(1, 61_850); + let owner_guid = ObjectGuid::create_item(1, 61_851); + install_active_item_loot_completion_fixture_like_cpp( + &mut session, + player_guid, + owner_guid, + 0, + ); + let (commit_tx, commit_rx) = tokio::sync::oneshot::channel(); + let guard = session.begin_durable_item_loot_persistence_like_cpp(); + let worker = super::spawn_loot_claim_persistence_worker_like_cpp( + async move { + let _ = commit_rx.await; + Ok::<(), ()>(()) + }, + None, + Some(( + guard, + DurableItemLootCompletionLikeCpp { + owner_guid, + loot_list_id: 0, + player_guid, + item_owner_auto_release: true, + durable_item_money_applied_amount: None, + durable_item_money_notified_amount: None, + durable_item_money_balance_applied: None, + item_fanout: None, + runtime_inventory_applied: Arc::new(AtomicBool::new(true)), + }, + )), + ) + .unwrap(); + let release_commit = tokio::spawn(async move { + tokio::task::yield_now().await; + commit_tx.send(()).unwrap(); + worker.await.unwrap().unwrap(); + }); - let player_items = represented_loot_response_items_like_cpp(&loot, player_guid); - let other_items = represented_loot_response_items_like_cpp(&loot, other_guid); - assert_eq!(player_items.len(), 1); - assert_eq!(other_items.len(), 1); - assert_eq!(player_items[0].ui_type, LOOT_SLOT_TYPE_ALLOW_LOOT_LIKE_CPP); - assert_eq!(other_items[0].ui_type, LOOT_SLOT_TYPE_ALLOW_LOOT_LIKE_CPP); + session + .cleanup_shared_runtime_state_on_disconnect_like_cpp() + .await; + release_commit.await.unwrap(); - mark_loot_item_looted_for_player_like_cpp(&mut loot, 0, player_guid); - assert_eq!(loot.unlooted_count, 1); + assert_eq!( + drain_server_opcodes_like_cpp(&send_rx) + .into_iter() + .filter(|opcode| *opcode == wow_constants::ServerOpcodes::LootRelease as u16) + .count(), + 1 + ); + } - let player_ffa = loot - .player_ffa_items - .iter() - .find(|(player, _)| *player == player_guid) - .and_then(|(_, items)| items.iter().find(|item| item.loot_list_id == 0)) + #[tokio::test] + async fn disconnect_runs_full_creature_release_lifecycle_after_persistence_like_cpp() { + let (mut session, _send_rx, _second, _second_rx, owner_guid, _player_guid, _) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(0, false), + ); + session.set_loot_drop_rates_like_cpp(LootDropRatesLikeCpp { + corpse_decay_looted: 0.5, + ..LootDropRatesLikeCpp::default() + }); + let before = session + .mutate_world_creature(owner_guid, |creature| { + creature.creature.set_corpse_delay(120, false); + creature.apply_corpse_loot_flags_after_death_state_like_cpp(true, false); + ( + creature.corpse_despawn_at(), + creature.has_lootable_dynamic_flag_like_cpp(), + ) + }) .unwrap(); - let other_ffa = loot - .player_ffa_items - .iter() - .find(|(player, _)| *player == other_guid) - .and_then(|(_, items)| items.iter().find(|item| item.loot_list_id == 0)) + assert!(before.1); + + session + .cleanup_shared_runtime_state_on_disconnect_like_cpp() + .await; + + let after = session + .mutate_world_creature(owner_guid, |creature| { + ( + creature.corpse_despawn_at(), + creature.has_lootable_dynamic_flag_like_cpp(), + ) + }) .unwrap(); + assert!(!after.1); + assert!(after.0 <= before.0); + } - assert!(player_ffa.is_looted); - assert!(!other_ffa.is_looted); - assert!(represented_loot_response_items_like_cpp(&loot, player_guid).is_empty()); - assert_eq!( - represented_loot_response_items_like_cpp(&loot, other_guid).len(), - 1 + #[tokio::test] + async fn remote_master_loot_command_transports_and_commits_claim_like_cpp() { + let (mut first, _first_rx, mut second, _second_rx, owner, first_guid, second_guid) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(0, true), + ); + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let claim = authority + .reserve_item_for_award_like_cpp(second_guid, 0) + .await + .unwrap(); + let entry = match claim.payload_like_cpp() { + LootClaimPayload::Item(entry) => entry.clone(), + LootClaimPayload::Money(_) => panic!("expected item claim"), + }; + let grants = Arc::new(AtomicUsize::new(0)); + install_limited_test_item_template(&mut second, entry.item_id, 0); + second.set_loot_item_store_test_seam_like_cpp(Arc::clone(&grants), true); + let player_registry = Arc::new(PlayerRegistry::default()); + let (registry_send_tx, _registry_send_rx) = flume::bounded(8); + let mut second_info = broadcast_info(second_guid, registry_send_tx); + second_info.command_tx = second.session_command_tx(); + player_registry.insert(second_guid, second_info); + first.set_player_registry(player_registry); + + let request = first.request_represented_remote_master_loot_give_like_cpp( + second_guid, + owner, + represented_loot_object_guid_like_cpp(owner), + 0, + 0, + entry, + Some(claim), ); - } + let target = async { + tokio::task::yield_now().await; + second.process_represented_session_commands_like_cpp().await; + }; + let (result, ()) = tokio::join!(request, target); - #[test] - fn represented_unlooted_count_counts_shared_items_once_like_cpp() { - let player_guid = ObjectGuid::create_player(1, 42); - let other_guid = ObjectGuid::create_player(1, 77); - let loot_guid = ObjectGuid::create_world_object(HighGuid::Creature, 0, 1, 0, 0, 1, 102); + assert_eq!(result, MasterLootGiveResult::Stored); + assert_eq!(grants.load(Ordering::SeqCst), 1); + assert!( + authority + .snapshot_for_player_like_cpp(first_guid) + .unwrap() + .loot + .items[0] + .taken + ); + } - let mut entry = represented_loot_entry(0, 25, player_guid); - entry.allowed_looters.clear(); + #[tokio::test] + async fn remote_master_timeout_then_release_still_fans_out_and_finalizes_corpse_like_cpp() { + let (mut first, first_rx, mut second, second_rx, owner, first_guid, second_guid) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(0, true), + ); + let _ = drain_server_opcodes_like_cpp(&first_rx); + let _ = drain_server_opcodes_like_cpp(&second_rx); + first.set_loot_drop_rates_like_cpp(LootDropRatesLikeCpp { + corpse_decay_looted: 0.5, + ..LootDropRatesLikeCpp::default() + }); + first + .mutate_world_creature(owner, |creature| { + creature.creature.set_corpse_delay(120, false); + creature.apply_corpse_loot_flags_after_death_state_like_cpp(true, false); + }) + .unwrap(); - let mut loot = CreatureLoot { - loot_guid, - coins: 0, - unlooted_count: 0, - loot_type: LOOT_TYPE_CORPSE_LIKE_CPP, - dungeon_encounter_id: 0, - loot_method: LOOT_METHOD_GROUP_LIKE_CPP, - loot_master: ObjectGuid::EMPTY, - round_robin_player: ObjectGuid::EMPTY, - player_ffa_items: Vec::new(), - players_looting: Vec::new(), - allowed_looters: Vec::new(), - items: vec![entry], - looted_by_player: false, + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let claim = authority + .reserve_item_for_award_like_cpp(second_guid, 0) + .await + .unwrap(); + let entry = match claim.payload_like_cpp() { + LootClaimPayload::Item(entry) => entry.clone(), + LootClaimPayload::Money(_) => panic!("expected item claim"), }; - mark_loot_allowed_for_player_like_cpp(&mut loot, player_guid); - assert_eq!(loot.unlooted_count, 1); - assert!(loot.items[0].flags.counted); + // The target already closed its own view. The source will close while + // the detached target worker owns the claim as `Persisting`. + second.handle_loot_release(loot_release_packet(owner)).await; + let _ = drain_server_opcodes_like_cpp(&second_rx); + + let registry = Arc::new(PlayerRegistry::default()); + let mut first_info = broadcast_info(first_guid, first.send_tx().clone()); + first_info.command_tx = first.session_command_tx(); + registry.insert(first_guid, first_info); + let mut second_info = broadcast_info(second_guid, second.send_tx().clone()); + second_info.command_tx = second.session_command_tx(); + registry.insert(second_guid, second_info); + first.set_player_registry(Arc::clone(®istry)); + second.set_player_registry(registry); + + let grants = Arc::new(AtomicUsize::new(0)); + install_limited_test_item_template(&mut second, entry.item_id, 0); + second.set_loot_item_store_test_seam_like_cpp(Arc::clone(&grants), true); + let commit_gate = Arc::new(tokio::sync::Notify::new()); + second.set_loot_item_store_test_commit_gate_like_cpp(Arc::clone(&commit_gate)); + + let mut request = Box::pin(first.request_represented_remote_master_loot_give_like_cpp( + second_guid, + owner, + represented_loot_object_guid_like_cpp(owner), + 0, + 0, + entry, + Some(claim), + )); + let mut target = Box::pin(async { + tokio::task::yield_now().await; + second.process_represented_session_commands_like_cpp().await; + }); + let result = tokio::select! { + result = &mut request => result, + _ = &mut target => panic!("target must remain behind the COMMIT gate"), + }; + drop(request); + assert_eq!(result, MasterLootGiveResult::TargetMismatch); - mark_loot_allowed_for_player_like_cpp(&mut loot, other_guid); - assert_eq!(loot.unlooted_count, 1); + first.handle_loot_release(loot_release_packet(owner)).await; + commit_gate.notify_one(); + target.await; - mark_loot_item_looted_for_player_like_cpp(&mut loot, 0, player_guid); - assert_eq!(loot.unlooted_count, 0); - mark_loot_item_looted_for_player_like_cpp(&mut loot, 0, player_guid); - assert_eq!(loot.unlooted_count, 0); + assert_eq!(grants.load(Ordering::SeqCst), 1); + assert!( + authority + .snapshot_for_player_like_cpp(first_guid) + .unwrap() + .loot + .items[0] + .taken + ); + assert!( + drain_server_opcodes_like_cpp(&first_rx) + .contains(&(wow_constants::ServerOpcodes::LootRemoved as u16)), + "the pre-COMMIT route survives request timeout and CMSG_LOOT_RELEASE" + ); + assert!( + !first + .mutate_world_creature(owner, |creature| { + creature.has_lootable_dynamic_flag_like_cpp() + }) + .unwrap(), + "completion must run AllLootRemovedFromCorpse without an active view" + ); } - #[test] - fn represented_loot_removed_uses_players_looting_like_cpp() { - let (mut session, send_rx) = make_session_with_send(); - let player_guid = ObjectGuid::create_player(1, 42); - let open_guid = ObjectGuid::create_player(1, 77); - let closed_guid = ObjectGuid::create_player(1, 88); - let stale_guid = ObjectGuid::create_player(1, 99); - let owner_guid = test_creature_guid(19_095); - let loot_object = represented_loot_object_guid_like_cpp(owner_guid); - let (open_tx, open_rx) = flume::bounded::>(1); - let (closed_tx, closed_rx) = flume::bounded::>(1); + #[tokio::test] + async fn remote_roll_winner_command_transports_and_commits_claim_like_cpp() { + let (mut first, _first_rx, mut second, _second_rx, owner, first_guid, second_guid) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(0, true), + ); + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let generation = authority + .snapshot_for_player_like_cpp(second_guid) + .unwrap() + .generation; + authority + .finish_item_roll_like_cpp(second_guid, generation, 0, false, Some(second_guid)) + .unwrap(); + let claim = authority + .reserve_item_for_award_like_cpp(second_guid, 0) + .await + .unwrap(); + let entry = match claim.payload_like_cpp() { + LootClaimPayload::Item(entry) => entry.clone(), + LootClaimPayload::Money(_) => panic!("expected item claim"), + }; + let grants = Arc::new(AtomicUsize::new(0)); + install_limited_test_item_template(&mut second, entry.item_id, 0); + second.set_loot_item_store_test_seam_like_cpp(Arc::clone(&grants), true); let player_registry = Arc::new(PlayerRegistry::default()); - player_registry.insert(open_guid, broadcast_info(open_guid, open_tx)); - player_registry.insert(closed_guid, broadcast_info(closed_guid, closed_tx)); - session.set_player_registry(player_registry); - session.set_player_guid(Some(player_guid)); - session.loot_table.insert( - owner_guid, - CreatureLoot { - loot_guid: loot_object, - coins: 0, - unlooted_count: 0, - loot_type: LOOT_TYPE_CORPSE_LIKE_CPP, - dungeon_encounter_id: 0, - loot_method: LOOT_METHOD_GROUP_LIKE_CPP, - loot_master: ObjectGuid::EMPTY, - round_robin_player: ObjectGuid::EMPTY, - player_ffa_items: Vec::new(), - players_looting: vec![player_guid, open_guid, stale_guid], - allowed_looters: vec![player_guid, open_guid, closed_guid, stale_guid], - items: vec![LootEntry { - loot_list_id: 0, - item_id: 25, - quantity: 1, - random_properties_id: 0, - random_properties_seed: 0, - item_context: 0, - flags: LootEntryFlags::default(), - allowed_looters: vec![player_guid, open_guid, closed_guid, stale_guid], - roll_winner: ObjectGuid::EMPTY, - ffa_looted_by: Vec::new(), - taken: false, - }], - looted_by_player: false, - }, + let (registry_send_tx, _registry_send_rx) = flume::bounded(8); + let mut second_info = broadcast_info(second_guid, registry_send_tx); + second_info.command_tx = second.session_command_tx(); + player_registry.insert(second_guid, second_info); + first.set_player_registry(player_registry); + + let request = first.request_represented_remote_loot_roll_winner_store_like_cpp( + second_guid, + owner, + represented_loot_object_guid_like_cpp(owner), + 0, + 0, + vec![entry], + false, + Some(claim), ); + let target = async { + tokio::task::yield_now().await; + second.process_represented_session_commands_like_cpp().await; + }; + let (result, ()) = tokio::join!(request, target); - session.represented_notify_loot_item_removed_like_cpp(owner_guid, 0); + assert_eq!(result, MasterLootGiveResult::Stored); + assert_eq!(grants.load(Ordering::SeqCst), 1); + assert!( + authority + .snapshot_for_player_like_cpp(first_guid) + .unwrap() + .loot + .items[0] + .taken + ); + } - let sent = send_rx.try_recv().unwrap(); - let mut sent = WorldPacket::from_bytes(&sent); - assert_eq!( - sent.read_uint16().unwrap(), - wow_constants::ServerOpcodes::LootRemoved as u16 + #[tokio::test] + async fn remote_roll_timeout_then_release_fans_out_once_and_finalizes_corpse_like_cpp() { + let (mut first, first_rx, mut second, second_rx, owner, first_guid, second_guid) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(0, true), + ); + let _ = drain_server_opcodes_like_cpp(&first_rx); + let _ = drain_server_opcodes_like_cpp(&second_rx); + first.set_loot_drop_rates_like_cpp(LootDropRatesLikeCpp { + corpse_decay_looted: 0.5, + ..LootDropRatesLikeCpp::default() + }); + first + .mutate_world_creature(owner, |creature| { + creature.creature.set_corpse_delay(120, false); + creature.apply_corpse_loot_flags_after_death_state_like_cpp(true, false); + }) + .unwrap(); + + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let generation = authority + .snapshot_for_player_like_cpp(second_guid) + .unwrap() + .generation; + authority + .finish_item_roll_like_cpp(second_guid, generation, 0, false, Some(second_guid)) + .unwrap(); + let claim = authority + .reserve_item_for_award_like_cpp(second_guid, 0) + .await + .unwrap(); + let entry = match claim.payload_like_cpp() { + LootClaimPayload::Item(entry) => entry.clone(), + LootClaimPayload::Money(_) => panic!("expected item claim"), + }; + + // The target no longer has a live loot window. The source closes its + // window only after the remote request times out with the target's + // detached persistence worker still owning the claim. + second.handle_loot_release(loot_release_packet(owner)).await; + let _ = drain_server_opcodes_like_cpp(&second_rx); + + let registry = Arc::new(PlayerRegistry::default()); + let mut first_info = broadcast_info(first_guid, first.send_tx().clone()); + first_info.command_tx = first.session_command_tx(); + registry.insert(first_guid, first_info); + let mut second_info = broadcast_info(second_guid, second.send_tx().clone()); + second_info.command_tx = second.session_command_tx(); + registry.insert(second_guid, second_info); + first.set_player_registry(Arc::clone(®istry)); + second.set_player_registry(registry); + + let grants = Arc::new(AtomicUsize::new(0)); + install_limited_test_item_template(&mut second, entry.item_id, 0); + second.set_loot_item_store_test_seam_like_cpp(Arc::clone(&grants), true); + let commit_gate = Arc::new(tokio::sync::Notify::new()); + second.set_loot_item_store_test_commit_gate_like_cpp(Arc::clone(&commit_gate)); + + let mut request = Box::pin( + first.request_represented_remote_loot_roll_winner_store_like_cpp( + second_guid, + owner, + represented_loot_object_guid_like_cpp(owner), + 0, + 0, + vec![entry], + false, + Some(claim), + ), ); - assert_eq!(sent.read_packed_guid().unwrap(), owner_guid); - assert_eq!(sent.read_packed_guid().unwrap(), loot_object); - assert_eq!(sent.read_uint8().unwrap(), 0); + let mut target = Box::pin(async { + tokio::task::yield_now().await; + second.process_represented_session_commands_like_cpp().await; + }); + let result = tokio::select! { + result = &mut request => result, + _ = &mut target => panic!("target must remain behind the COMMIT gate"), + }; + drop(request); + assert_eq!(result, MasterLootGiveResult::TargetMismatch); - let sent = open_rx.try_recv().unwrap(); - let mut sent = WorldPacket::from_bytes(&sent); - assert_eq!( - sent.read_uint16().unwrap(), - wow_constants::ServerOpcodes::LootRemoved as u16 + first.handle_loot_release(loot_release_packet(owner)).await; + commit_gate.notify_one(); + target.await; + + assert_eq!(grants.load(Ordering::SeqCst), 1); + assert!( + authority + .snapshot_for_player_like_cpp(first_guid) + .unwrap() + .loot + .items[0] + .taken, + "the roll claim is terminal after the durable commit" ); - assert!(closed_rx.try_recv().is_err()); assert_eq!( - session.loot_table.get(&owner_guid).unwrap().players_looting, - vec![player_guid, open_guid] + drain_server_opcodes_like_cpp(&first_rx) + .into_iter() + .filter(|opcode| *opcode == wow_constants::ServerOpcodes::LootRemoved as u16) + .count(), + 1, + "the pre-COMMIT route publishes the roll removal exactly once" ); - } - - #[test] - fn represented_money_removed_erases_missing_players_looting_like_cpp() { - let (mut session, send_rx) = make_session_with_send(); - let player_guid = ObjectGuid::create_player(1, 42); - let open_guid = ObjectGuid::create_player(1, 77); - let stale_guid = ObjectGuid::create_player(1, 99); - let owner_guid = test_creature_guid(19_096); - let loot_object = represented_loot_object_guid_like_cpp(owner_guid); - let (open_tx, open_rx) = flume::bounded::>(1); - let player_registry = Arc::new(PlayerRegistry::default()); - player_registry.insert(open_guid, broadcast_info(open_guid, open_tx)); - session.set_player_registry(player_registry); - session.set_player_guid(Some(player_guid)); - session.loot_table.insert( - owner_guid, - CreatureLoot { - loot_guid: loot_object, - coins: 7, - unlooted_count: 0, - loot_type: LOOT_TYPE_CORPSE_LIKE_CPP, - dungeon_encounter_id: 0, - loot_method: LOOT_METHOD_GROUP_LIKE_CPP, - loot_master: ObjectGuid::EMPTY, - round_robin_player: ObjectGuid::EMPTY, - player_ffa_items: Vec::new(), - players_looting: vec![player_guid, open_guid, stale_guid], - allowed_looters: vec![player_guid, open_guid, stale_guid], - items: Vec::new(), - looted_by_player: false, - }, + assert!( + !first + .mutate_world_creature(owner, |creature| { + creature.has_lootable_dynamic_flag_like_cpp() + }) + .unwrap(), + "post-COMMIT completion must finish the corpse lifecycle without an active view" ); + } - session.represented_notify_money_removed_like_cpp(owner_guid); + #[tokio::test] + async fn local_disenchant_batch_commits_all_materials_and_original_claim_like_cpp() { + let (mut session, rx, _second, _second_rx, owner, player_guid, _) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(0, true), + ); + let _ = drain_server_opcodes_like_cpp(&rx); + let shared_send = session.send_tx().clone(); + session.install_realm_send_channel_for_test(shared_send); + let authority = session + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let generation = authority + .snapshot_for_player_like_cpp(player_guid) + .unwrap() + .generation; + authority + .finish_item_roll_like_cpp(player_guid, generation, 0, false, Some(player_guid)) + .unwrap(); + let claim = authority + .reserve_item_for_award_like_cpp(player_guid, 0) + .await + .unwrap(); + let materials = represented_disenchant_test_outputs_like_cpp(player_guid, 700); + let grants = Arc::new(AtomicUsize::new(0)); + session.set_loot_item_store_test_seam_like_cpp(Arc::clone(&grants), true); - let sent = send_rx.try_recv().unwrap(); - let mut sent = WorldPacket::from_bytes(&sent); - assert_eq!( - sent.read_uint16().unwrap(), - wow_constants::ServerOpcodes::CoinRemoved as u16 + assert!( + session + .store_direct_disenchant_batch_like_cpp( + &materials, + 0, + Some(&claim), + Some(LootItemClaimCommitContextLikeCpp { + owner_guid: owner, + loot_obj: represented_loot_object_guid_like_cpp(owner), + loot_list_id: 0, + player_guid, + free_for_all: false, + }), + ) + .await ); - assert_eq!(sent.read_packed_guid().unwrap(), loot_object); - - let sent = open_rx.try_recv().unwrap(); - let mut sent = WorldPacket::from_bytes(&sent); + assert_eq!(grants.load(Ordering::SeqCst), 2); assert_eq!( - sent.read_uint16().unwrap(), - wow_constants::ServerOpcodes::CoinRemoved as u16 + drain_server_opcodes_like_cpp(&rx), + vec![ + wow_constants::ServerOpcodes::ItemPushResult as u16, + wow_constants::ServerOpcodes::ItemPushResult as u16, + wow_constants::ServerOpcodes::LootRemoved as u16, + ], + "C++ Loot::AutoStore sends every material before the original roll slot is removed" ); - assert_eq!( - session.loot_table.get(&owner_guid).unwrap().players_looting, - vec![player_guid, open_guid] + assert!(claim.is_committed_like_cpp()); + assert!( + authority + .reserve_item_for_award_like_cpp(player_guid, 0) + .await + .is_err() ); } - fn loot_release_packet(object: ObjectGuid) -> WorldPacket { - let mut pkt = WorldPacket::new_empty(); - pkt.write_packed_guid(&object); - pkt.reset_read(); - pkt - } - - fn loot_money_packet() -> WorldPacket { - let mut pkt = WorldPacket::new_empty(); - pkt.write_bit(false); - pkt.flush_bits(); - pkt.reset_read(); - pkt - } + #[tokio::test] + async fn failed_disenchant_batch_grants_zero_and_original_slot_retries_like_cpp() { + let (mut session, _rx, _second, _second_rx, owner, player_guid, _) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(0, true), + ); + let authority = session + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let generation = authority + .snapshot_for_player_like_cpp(player_guid) + .unwrap() + .generation; + authority + .finish_item_roll_like_cpp(player_guid, generation, 0, false, Some(player_guid)) + .unwrap(); + let claim = authority + .reserve_item_for_award_like_cpp(player_guid, 0) + .await + .unwrap(); + let materials = represented_disenchant_test_outputs_like_cpp(player_guid, 700); + let grants = Arc::new(AtomicUsize::new(0)); + // Both material grants are already planned when this seam rejects the + // one transaction, modelling a failure while persisting its second + // output. Runtime observes neither output. + session.set_loot_item_store_test_seam_like_cpp(Arc::clone(&grants), false); - fn recv_packet_with_opcode( - rx: &flume::Receiver>, - opcode: wow_constants::ServerOpcodes, - ) -> WorldPacket { - for _ in 0..8 { - let sent = rx.try_recv().unwrap(); - let mut packet = WorldPacket::from_bytes(&sent); - if packet.read_uint16().unwrap() == opcode as u16 { - return packet; - } - } - panic!("expected packet opcode {:?}", opcode); + assert!( + !session + .store_direct_disenchant_batch_like_cpp( + &materials, + 0, + Some(&claim), + Some(LootItemClaimCommitContextLikeCpp { + owner_guid: owner, + loot_obj: represented_loot_object_guid_like_cpp(owner), + loot_list_id: 0, + player_guid, + free_for_all: false, + }), + ) + .await + ); + assert_eq!(grants.load(Ordering::SeqCst), 0); + assert!(!claim.is_committed_like_cpp()); + drop(claim); + assert!( + authority + .reserve_item_for_award_like_cpp(player_guid, 0) + .await + .is_ok() + ); } - fn test_creature(guid: ObjectGuid, is_alive: bool) -> CreatureAI { - let mut creature = CreatureAI::new( - guid, - 1, - Position::ZERO, - 100, - 1, - 1, - 2, - 0.0, - 1, - 35, - 0, - 0, - 0, - 0, + #[tokio::test] + async fn remote_disenchant_batch_uses_one_command_and_commits_all_materials_like_cpp() { + let (mut first, _first_rx, mut second, _second_rx, owner, _first_guid, second_guid) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(0, true), + ); + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let generation = authority + .snapshot_for_player_like_cpp(second_guid) + .unwrap() + .generation; + authority + .finish_item_roll_like_cpp(second_guid, generation, 0, false, Some(second_guid)) + .unwrap(); + let claim = authority + .reserve_item_for_award_like_cpp(second_guid, 0) + .await + .unwrap(); + let materials = represented_disenchant_test_outputs_like_cpp(second_guid, 700); + let grants = Arc::new(AtomicUsize::new(0)); + install_limited_test_item_template(&mut second, 700, 0); + second.set_loot_item_store_test_seam_like_cpp(Arc::clone(&grants), true); + let player_registry = Arc::new(PlayerRegistry::default()); + let (registry_send_tx, _registry_send_rx) = flume::bounded(8); + let mut second_info = broadcast_info(second_guid, registry_send_tx); + second_info.command_tx = second.session_command_tx(); + player_registry.insert(second_guid, second_info); + first.set_player_registry(player_registry); + + let request = first.request_represented_remote_loot_roll_winner_store_like_cpp( + second_guid, + owner, + represented_loot_object_guid_like_cpp(owner), 0, - None, 0, + materials, + true, + Some(claim), + ); + let target = async { + tokio::task::yield_now().await; + // One drain handles the complete two-material result. A former + // implementation required one command/ack round-trip per item. + second.process_represented_session_commands_like_cpp().await; + }; + let (result, ()) = tokio::join!(request, target); + + assert_eq!(result, MasterLootGiveResult::Stored); + assert_eq!(grants.load(Ordering::SeqCst), 2); + assert!( + authority + .reserve_item_for_award_like_cpp(second_guid, 0) + .await + .is_err() ); - creature.is_alive = is_alive; - creature } - fn register_test_creature_like_cpp(session: &mut WorldSession, creature: CreatureAI) { - if session.map_manager.is_none() { - session.set_map_manager(Arc::new(RwLock::new(crate::map_manager::MapManager::new()))); - } + #[tokio::test] + async fn remote_disenchant_timeout_then_release_fans_out_once_and_finalizes_corpse_like_cpp() { + let (mut first, first_rx, mut second, second_rx, owner, first_guid, second_guid) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(0, true), + ); + let _ = drain_server_opcodes_like_cpp(&first_rx); + let _ = drain_server_opcodes_like_cpp(&second_rx); + first.set_loot_drop_rates_like_cpp(LootDropRatesLikeCpp { + corpse_decay_looted: 0.5, + ..LootDropRatesLikeCpp::default() + }); + first + .mutate_world_creature(owner, |creature| { + creature.creature.set_corpse_delay(120, false); + creature.apply_corpse_loot_flags_after_death_state_like_cpp(true, false); + }) + .unwrap(); - let create_data = CreatureCreateData { - guid: creature.guid, - entry: creature.entry, - display_id: creature.display_id, - native_display_id: creature.display_id, - display_scale: 1.0, - native_x_display_scale: 1.0, - bounding_radius: 0.389, - combat_reach: 1.5, - health: i64::from(creature.hp.max(1)), - max_health: i64::from(creature.max_hp.max(1)), - level: creature.level, - faction_template: creature.faction as i32, - npc_flags: u64::from(creature.npc_flags), - unit_flags: creature.unit_flags, - unit_flags2: 0, - unit_flags3: 0, - aura_state: crate::map_manager::WorldCreature::health_aura_state_like_cpp( - u64::from(creature.hp.max(1)), - u64::from(creature.max_hp.max(1)), + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let generation = authority + .snapshot_for_player_like_cpp(second_guid) + .unwrap() + .generation; + authority + .finish_item_roll_like_cpp(second_guid, generation, 0, false, Some(second_guid)) + .unwrap(); + let claim = authority + .reserve_item_for_award_like_cpp(second_guid, 0) + .await + .unwrap(); + let materials = represented_disenchant_test_outputs_like_cpp(second_guid, 700); + + second.handle_loot_release(loot_release_packet(owner)).await; + let _ = drain_server_opcodes_like_cpp(&second_rx); + + let registry = Arc::new(PlayerRegistry::default()); + let mut first_info = broadcast_info(first_guid, first.send_tx().clone()); + first_info.command_tx = first.session_command_tx(); + registry.insert(first_guid, first_info); + let mut second_info = broadcast_info(second_guid, second.send_tx().clone()); + second_info.command_tx = second.session_command_tx(); + registry.insert(second_guid, second_info); + first.set_player_registry(Arc::clone(®istry)); + second.set_player_registry(registry); + + let grants = Arc::new(AtomicUsize::new(0)); + install_limited_test_item_template(&mut second, 700, 0); + second.set_loot_item_store_test_seam_like_cpp(Arc::clone(&grants), true); + let commit_gate = Arc::new(tokio::sync::Notify::new()); + second.set_loot_item_store_test_commit_gate_like_cpp(Arc::clone(&commit_gate)); + + let mut request = Box::pin( + first.request_represented_remote_loot_roll_winner_store_like_cpp( + second_guid, + owner, + represented_loot_object_guid_like_cpp(owner), + 0, + 0, + materials, true, + Some(claim), ), - damage_school: wow_constants::spell::SpellSchools::Normal as u8, - scale: 1.0, - unit_class: 1, - display_power: 1, - power: [0; 10], - max_power: [0; 10], - base_mana: 0, - virtual_items: [(0, 0, 0); 3], - base_attack_time: 2000, - ranged_attack_time: 0, - movement_flags: 0, - vehicle_id: 0, - play_hover_anim: false, - hover_height: 1.0, - mount_display_id: 0, - stand_state: 0, - vis_flags: 0, - anim_tier: 0, - emote_state: 0, - sheathe_state: wow_constants::unit::SheathState::Melee as u8, - pvp_flags: 0, - current_area_id: 0, - speed_walk_rate: 1.0, - speed_run_rate: 1.14286, - ai_anim_kit_id: 0, - movement_anim_kit_id: 0, - melee_anim_kit_id: 0, + ); + let mut target = Box::pin(async { + tokio::task::yield_now().await; + second.process_represented_session_commands_like_cpp().await; + }); + let result = tokio::select! { + result = &mut request => result, + _ = &mut target => panic!("target must remain behind the COMMIT gate"), }; - let guid = creature.guid; - let is_alive = creature.is_alive; - session.register_world_creature( - session.player_map_id_like_cpp(), - creature.current_pos, - create_data, - creature.min_dmg, - creature.max_dmg, - creature.aggro_radius, - creature.loot_id, - 0, - creature.gold_min, - creature.gold_max, - creature.boss_id, - creature.dungeon_encounter_id, - 0, - 0, - 0, - -1, + drop(request); + assert_eq!(result, MasterLootGiveResult::TargetMismatch); + + first.handle_loot_release(loot_release_packet(owner)).await; + commit_gate.notify_one(); + target.await; + + assert_eq!(grants.load(Ordering::SeqCst), 2); + assert!( + authority + .reserve_item_for_award_like_cpp(second_guid, 0) + .await + .is_err(), + "the original roll claim remains terminal after every material commits" + ); + assert_eq!( + drain_server_opcodes_like_cpp(&first_rx) + .into_iter() + .filter(|opcode| *opcode == wow_constants::ServerOpcodes::LootRemoved as u16) + .count(), + 1, + "the material batch publishes the original roll removal exactly once" + ); + assert!( + !first + .mutate_world_creature(owner, |creature| { + creature.has_lootable_dynamic_flag_like_cpp() + }) + .unwrap(), + "post-COMMIT completion must finish the corpse lifecycle without an active view" ); - if !is_alive { - let _ = session.mutate_world_creature(guid, |world_creature| { - world_creature.creature.mark_ai_dead(0); - }); - } } - fn insert_allowed_coin_loot_like_cpp( - session: &mut WorldSession, - owner_guid: ObjectGuid, - player_guid: ObjectGuid, - coins: u32, - ) { - session.loot_table.insert( - owner_guid, - CreatureLoot { - loot_guid: represented_loot_object_guid_like_cpp(owner_guid), - coins, - unlooted_count: 0, - loot_type: LOOT_TYPE_CORPSE_LIKE_CPP, - dungeon_encounter_id: 0, - loot_method: 0, - loot_master: ObjectGuid::EMPTY, - round_robin_player: ObjectGuid::EMPTY, - player_ffa_items: Vec::new(), - players_looting: Vec::new(), - allowed_looters: vec![player_guid], - items: Vec::new(), - looted_by_player: false, + #[tokio::test] + async fn cancelled_disenchant_waiter_cannot_reopen_durable_batch_like_cpp() { + let (mut session, _rx, _second, _second_rx, owner, player_guid, _) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(0, true), + ); + let authority = session + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let generation = authority + .snapshot_for_player_like_cpp(player_guid) + .unwrap() + .generation; + authority + .finish_item_roll_like_cpp(player_guid, generation, 0, false, Some(player_guid)) + .unwrap(); + let claim = authority + .reserve_item_for_award_like_cpp(player_guid, 0) + .await + .unwrap(); + let durable_materials = Arc::new(AtomicUsize::new(0)); + let durable_materials_worker = Arc::clone(&durable_materials); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel(); + let worker = super::spawn_loot_claim_persistence_worker_like_cpp( + async move { + let _ = started_tx.send(()); + let _ = release_rx.await; + durable_materials_worker.fetch_add(2, Ordering::SeqCst); + Ok::<(), ()>(()) }, + Some(claim), + None, + ) + .unwrap(); + let waiter = tokio::spawn(async move { worker.await }); + + started_rx.await.unwrap(); + waiter.abort(); + let _ = waiter.await; + release_tx.send(()).unwrap(); + for _ in 0..4 { + tokio::task::yield_now().await; + } + + assert_eq!(durable_materials.load(Ordering::SeqCst), 2); + assert!( + authority + .reserve_item_for_award_like_cpp(player_guid, 0) + .await + .is_err() ); } @@ -9061,6 +17100,7 @@ mod tests { is_in_world: true, send_tx, command_tx, + durable_loot_money_tracker_like_cpp: Default::default(), active_loot_rolls: Vec::new(), pass_on_group_loot: false, enchanting_skill: 0, @@ -9783,6 +17823,124 @@ mod tests { session.set_group_registry(group_registry, Arc::new(PendingInvites::default())); } + fn generation_guarded_group_loot_like_cpp( + owner_guid: ObjectGuid, + player_guid: ObjectGuid, + candidate_guid: ObjectGuid, + ) -> CreatureLoot { + CreatureLoot { + loot_guid: represented_loot_object_guid_like_cpp(owner_guid), + coins: 0, + unlooted_count: 1, + loot_type: LOOT_TYPE_CORPSE_LIKE_CPP, + dungeon_encounter_id: 0, + loot_method: LOOT_METHOD_GROUP_LIKE_CPP, + loot_master: ObjectGuid::EMPTY, + round_robin_player: ObjectGuid::EMPTY, + player_ffa_items: Vec::new(), + players_looting: Vec::new(), + allowed_looters: vec![player_guid, candidate_guid], + items: vec![LootEntry { + loot_list_id: 0, + item_id: 25, + quantity: 1, + random_properties_id: 0, + random_properties_seed: 0, + item_context: 0, + flags: LootEntryFlags { + follow_loot_rules: true, + blocked: true, + ..Default::default() + }, + allowed_looters: vec![player_guid, candidate_guid], + roll_winner: ObjectGuid::EMPTY, + ffa_looted_by: Vec::new(), + taken: false, + }], + looted_by_player: false, + } + } + + async fn open_generation_guarded_group_roll_like_cpp( + spawn_id: i64, + ) -> ( + WorldSession, + flume::Receiver>, + flume::Receiver>, + ObjectGuid, + ObjectGuid, + ObjectGuid, + ) { + let (mut session, send_rx) = make_session_with_send_capacity(16); + let player_guid = ObjectGuid::create_player(1, 42); + let candidate_guid = ObjectGuid::create_player(1, 77); + let owner_guid = test_creature_guid(spawn_id); + let loot_object = represented_loot_object_guid_like_cpp(owner_guid); + let (candidate_tx, candidate_rx) = flume::bounded::>(16); + let player_registry = Arc::new(PlayerRegistry::default()); + player_registry.insert(candidate_guid, broadcast_info(candidate_guid, candidate_tx)); + session.set_player_registry(player_registry); + session.set_player_guid(Some(player_guid)); + install_group_loot_group(&mut session, player_guid, candidate_guid); + register_test_creature_like_cpp(&mut session, test_creature(owner_guid, false)); + session.loot_table.insert( + owner_guid, + generation_guarded_group_loot_like_cpp(owner_guid, player_guid, candidate_guid), + ); + install_cached_test_creature_loot_authority_like_cpp(&mut session, owner_guid, player_guid); + + session.handle_loot_unit(loot_unit_packet(owner_guid)).await; + while send_rx.try_recv().is_ok() {} + while candidate_rx.try_recv().is_ok() {} + + let state = session + .represented_loot_rolls + .get(&(loot_object, 0)) + .expect("first loot generation should start the group roll"); + assert_eq!(state.owner_guid, owner_guid); + assert_eq!( + state.authority_generation, + session + .represented_loot_cache_generations_like_cpp + .get(&owner_guid) + .copied() + .expect("opened loot cache should be generation-tagged") + ); + + ( + session, + send_rx, + candidate_rx, + player_guid, + candidate_guid, + owner_guid, + ) + } + + fn replace_generation_guarded_group_loot_like_cpp( + session: &mut WorldSession, + owner_guid: ObjectGuid, + player_guid: ObjectGuid, + candidate_guid: ObjectGuid, + ) -> u64 { + let authority = session + .represented_owned_loot_authority_like_cpp(owner_guid) + .expect("test creature should expose its object-owned loot authority"); + let previous_generation = authority.generation_like_cpp(); + let retired_generation = authority.retire_like_cpp(); + let replacement = + generation_guarded_group_loot_like_cpp(owner_guid, player_guid, candidate_guid); + let replacement_generation = authority + .replace_retired_generation_like_cpp( + retired_generation, + Some(replacement), + HashMap::new(), + ) + .expect("explicit test generation replaces the observed retired lifetime"); + assert!(replacement_generation > previous_generation); + replacement_generation + } + fn install_limited_test_item_template(session: &mut WorldSession, entry: u32, max_count: i32) { install_limited_test_item_template_with_flags2(session, entry, max_count, 0); } @@ -9792,6 +17950,22 @@ mod tests { entry: u32, max_count: i32, flags2: u32, + ) { + install_limited_test_item_template_with_flags2_and_bonding( + session, + entry, + max_count, + flags2, + ItemBondingType::None, + ); + } + + fn install_limited_test_item_template_with_flags2_and_bonding( + session: &mut WorldSession, + entry: u32, + max_count: i32, + flags2: u32, + bonding: ItemBondingType, ) { session.set_item_store(Arc::new(ItemStore::from_records([ItemRecord { id: entry, @@ -9830,291 +18004,1324 @@ mod tests { required_reputation_faction: 0, allowable_class: -1, required_expansion: 0, - bonding: ItemBondingType::None as u8, + bonding: bonding as u8, container_slots: 0, inventory_type: InventoryType::NonEquip as i8, }, )]))); } - fn install_disenchantable_test_item_template(session: &mut WorldSession, entry: u32) { - session.set_item_store(Arc::new(ItemStore::from_records([ItemRecord { - id: entry, - class_id: ItemClass::Armor as u8, + fn install_disenchantable_test_item_template(session: &mut WorldSession, entry: u32) { + session.set_item_store(Arc::new(ItemStore::from_records([ItemRecord { + id: entry, + class_id: ItemClass::Armor as u8, + subclass_id: 0, + material: 0, + inventory_type: InventoryType::Chest as i8, + sheathe_type: 0, + random_select: 0, + random_suffix_group_id: 0, + scaling_stat_distribution_id: 0, + scaling_stat_value: 0, + }]))); + session.set_item_stats_store(Arc::new( + ItemStatsStore::from_sparse_and_random_property_templates( + [( + entry, + ItemSparseTemplateEntry { + flags: [0, 0, 0, 0], + bag_family: 0, + start_quest_id: 0, + stackable: 1, + max_count: 0, + lock_id: 0, + required_reputation_rank: 0, + sell_price: 1, + buy_price: 0, + vendor_stack_count: 1, + price_variance: 1.0, + price_random_value: 1.0, + max_durability: 0, + other_faction_item_id: 0, + content_tuning_id: 0, + player_level_to_item_level_curve_id: 0, + limit_category: 0, + instance_bound: 0, + zone_bound: [0, 0], + required_reputation_faction: 0, + allowable_class: -1, + required_expansion: 0, + bonding: ItemBondingType::None as u8, + container_slots: 0, + inventory_type: InventoryType::Chest as i8, + }, + )], + [( + entry, + ItemRandomPropertyTemplateEntry { + item_level: 10, + quality: ItemQuality::Rare as i8, + inventory_type: InventoryType::Chest as i8, + }, + )], + ), + )); + session.set_item_disenchant_loot_store(Arc::new(ItemDisenchantLootStore::from_entries([ + ItemDisenchantLootEntry { + id: 901, + subclass: 0, + quality: ItemQuality::Rare as u8, + min_level: 1, + max_level: 20, + skill_required: 175, + expansion_id: -2, + class_id: ItemClass::Armor as u32, + }, + ]))); + } + + fn install_active_spell_cast(session: &mut WorldSession, player_guid: ObjectGuid) { + session.active_spell_cast = Some(SpellCastState { + spell_id: 133, + target_guid: player_guid, + target_data: wow_packet::packets::spell::SpellTargetData { + flags: 0x2, + unit: player_guid, + ..Default::default() + }, + cast_id: ObjectGuid::create_world_object(HighGuid::Cast, 0, 1, 0, 0, 1, 7), + cast_start_time: std::time::Instant::now(), + cast_time_ms: 30_000, + spell_visual: wow_packet::packets::spell::SpellCastVisual { + spell_visual_id: 1, + script_visual_id: 0, + }, + metadata: crate::session::SpellCastMetadata::default(), + }); + } + + fn install_visible_aura_with_interrupt_flags( + session: &mut WorldSession, + slot: u8, + spell_id: i32, + caster_guid: ObjectGuid, + aura_interrupt_flags: u32, + ) { + session.visible_auras.insert( + slot, + AuraApplication { + spell_id, + caster_guid, + slot, + duration_total: 30_000, + duration_remaining: 30_000, + stack_count: 1, + aura_flags: 0x0000_0001, + effect_mask: 0x0000_0001, + aura_interrupt_flags, + aura_interrupt_flags2: 0, + represented_effect: None, + represented_amount: 0, + represented_effect_amounts: Vec::new(), + represented_misc_value: None, + represented_multiplier: 1.0, + applied_at: std::time::Instant::now(), + }, + ); + } + + #[tokio::test] + async fn represented_creature_money_uses_cpp_money_drop_rate() { + let mut session = make_session(); + let owner_guid = test_creature_guid(1); + attach_loot_guid_allocator_for_owner(&mut session, owner_guid); + session.set_loot_drop_rates_like_cpp(LootDropRatesLikeCpp { + money: 2.5, + ..LootDropRatesLikeCpp::default() + }); + + let loot = session + .generate_represented_creature_loot_like_cpp( + owner_guid, + ObjectGuid::create_player(1, 42), + 10, + 25, + 0, + 100, + 100, + 0, + ) + .await + .expect("canonical owner map allocates a LootObject"); + + assert_eq!(loot.coins, 250); + assert!(loot.items.is_empty()); + } + + #[tokio::test] + async fn represented_creature_money_zero_gold_max_stays_zero_like_cpp() { + let mut session = make_session(); + let owner_guid = test_creature_guid(1); + attach_loot_guid_allocator_for_owner(&mut session, owner_guid); + + let loot = session + .generate_represented_creature_loot_like_cpp( + owner_guid, + ObjectGuid::create_player(1, 42), + 10, + 25, + 0, + 0, + 0, + 0, + ) + .await + .expect("canonical owner map allocates a LootObject"); + + assert_eq!(loot.coins, 0); + assert!(loot.items.is_empty()); + } + + #[tokio::test] + async fn loot_response_success_keeps_cpp_failure_and_threshold_defaults() { + let mut session = make_session(); + let player_guid = ObjectGuid::create_player(1, 42); + let creature_guid = test_creature_guid(19_118); + session.set_player_guid(Some(player_guid)); + register_test_creature_like_cpp(&mut session, test_creature(creature_guid, false)); + + let group_registry = Arc::new(GroupRegistry::default()); + let mut group = GroupInfo::new(player_guid); + group.loot_method = LOOT_METHOD_GROUP_LIKE_CPP; + group.loot_threshold = 4; + let group_guid = group.group_guid; + group_registry.insert(group_guid, group); + session.group_guid = Some(group_guid); + session.set_group_registry(group_registry, Arc::new(PendingInvites::default())); + + session.loot_table.insert( + creature_guid, + CreatureLoot { + loot_guid: represented_loot_object_guid_like_cpp(creature_guid), + coins: 1, + unlooted_count: 0, + loot_type: LOOT_TYPE_CORPSE_LIKE_CPP, + dungeon_encounter_id: 0, + loot_method: LOOT_METHOD_GROUP_LIKE_CPP, + loot_master: ObjectGuid::EMPTY, + round_robin_player: player_guid, + player_ffa_items: Vec::new(), + players_looting: Vec::new(), + allowed_looters: vec![player_guid], + items: Vec::new(), + looted_by_player: false, + }, + ); + install_cached_test_creature_loot_authority_like_cpp( + &mut session, + creature_guid, + player_guid, + ); + + let response = session + .represented_loot_response_for_owner_like_cpp(creature_guid, player_guid, false) + .await + .unwrap(); + + assert_eq!(response.loot_method, LOOT_METHOD_GROUP_LIKE_CPP); + assert_eq!( + response.failure_reason, + LOOT_RESPONSE_DEFAULT_FAILURE_REASON_LIKE_CPP + ); + assert_eq!(response.threshold, LOOT_RESPONSE_DEFAULT_THRESHOLD_LIKE_CPP); + } + + #[tokio::test] + async fn loot_error_response_keeps_cpp_threshold_default_like_cpp() { + let (session, send_rx) = make_session_with_send(); + let owner = test_creature_guid(19_119); + let loot_obj = represented_loot_object_guid_like_cpp(owner); + + session.send_loot_error_like_cpp(loot_obj, owner, LOOT_ERROR_TOO_FAR_LIKE_CPP); + + let sent = send_rx.try_recv().unwrap(); + assert_eq!( + loot_response_failure_reason(&sent), + LOOT_ERROR_TOO_FAR_LIKE_CPP + ); + assert_eq!( + loot_response_threshold(&sent), + LOOT_RESPONSE_DEFAULT_THRESHOLD_LIKE_CPP + ); + } + + #[tokio::test] + async fn represented_creature_loot_generation_carries_cpp_dungeon_encounter_id() { + let mut session = make_session(); + let owner_guid = test_creature_guid(19_097); + attach_loot_guid_allocator_for_owner(&mut session, owner_guid); + + let loot = session + .generate_represented_creature_loot_like_cpp( + owner_guid, + ObjectGuid::create_player(1, 42), + 10, + 25, + 0, + 0, + 0, + 615, + ) + .await + .expect("canonical owner map allocates a LootObject"); + + assert_eq!(loot.dungeon_encounter_id, 615); + } + + struct OverworldPersonalLootTestFixtureLikeCpp { + session: WorldSession, + owner_guid: ObjectGuid, + first_tapper: ObjectGuid, + second_tapper: ObjectGuid, + disconnected_tapper: ObjectGuid, + normal_item_id: u32, + alliance_item_id: u32, + } + + fn overworld_personal_loot_test_fixture_like_cpp() -> OverworldPersonalLootTestFixtureLikeCpp { + let mut session = make_session(); + let first_tapper = ObjectGuid::create_player(1, 42); + let second_tapper = ObjectGuid::create_player(1, 43); + let disconnected_tapper = ObjectGuid::create_player(1, 44); + let owner_guid = test_creature_guid(19_098); + let loot_id = 90_001; + let normal_item_id = 80_101; + let alliance_item_id = 80_102; + + session.set_player_guid(Some(first_tapper)); + session.set_loaded_player_identity_like_cpp(0, 1, 1, 10, 0); + session.set_map_store(Arc::new(wow_data::MapStore::from_entries([ + wow_data::MapEntry { + id: 0, + instance_type: wow_data::map::MAP_COMMON, + expansion_id: 0, + parent_map_id: -1, + cosmetic_parent_map_id: -1, + flags1: 0, + flags2: 0, + }, + ]))); + + let registry = Arc::new(PlayerRegistry::default()); + let (second_tx, _second_rx) = flume::bounded(1); + let mut second = broadcast_info(second_tapper, second_tx); + second.race = 2; + registry.insert(second_tapper, second); + let (disconnected_tx, _disconnected_rx) = flume::bounded(1); + let mut disconnected = broadcast_info(disconnected_tapper, disconnected_tx); + disconnected.is_in_world = false; + registry.insert(disconnected_tapper, disconnected); + session.set_player_registry(registry); + + let item_record = |id| ItemRecord { + id, + class_id: ItemClass::Consumable as u8, subclass_id: 0, material: 0, - inventory_type: InventoryType::Chest as i8, + inventory_type: InventoryType::NonEquip as i8, sheathe_type: 0, random_select: 0, random_suffix_group_id: 0, scaling_stat_distribution_id: 0, scaling_stat_value: 0, - }]))); - session.set_item_stats_store(Arc::new( - ItemStatsStore::from_sparse_and_random_property_templates( - [( - entry, - ItemSparseTemplateEntry { - flags: [0, 0, 0, 0], - bag_family: 0, - start_quest_id: 0, - stackable: 1, - max_count: 0, - lock_id: 0, - required_reputation_rank: 0, - sell_price: 1, - buy_price: 0, - vendor_stack_count: 1, - price_variance: 1.0, - price_random_value: 1.0, - max_durability: 0, - other_faction_item_id: 0, - content_tuning_id: 0, - player_level_to_item_level_curve_id: 0, - limit_category: 0, - instance_bound: 0, - zone_bound: [0, 0], - required_reputation_faction: 0, - allowable_class: -1, - required_expansion: 0, - bonding: ItemBondingType::None as u8, - container_slots: 0, - inventory_type: InventoryType::Chest as i8, - }, - )], - [( - entry, - ItemRandomPropertyTemplateEntry { - item_level: 10, - quality: ItemQuality::Rare as i8, - inventory_type: InventoryType::Chest as i8, - }, - )], + }; + let sparse_template = |flags2| ItemSparseTemplateEntry { + flags: [0, flags2, 0, 0], + bag_family: 0, + start_quest_id: 0, + stackable: 20, + max_count: 0, + lock_id: 0, + required_reputation_rank: 0, + sell_price: 0, + buy_price: 0, + vendor_stack_count: 1, + price_variance: 1.0, + price_random_value: 1.0, + max_durability: 0, + other_faction_item_id: 0, + content_tuning_id: 0, + player_level_to_item_level_curve_id: 0, + limit_category: 0, + instance_bound: 0, + zone_bound: [0, 0], + required_reputation_faction: 0, + allowable_class: -1, + required_expansion: 0, + bonding: ItemBondingType::None as u8, + container_slots: 0, + inventory_type: InventoryType::NonEquip as i8, + }; + session.set_item_store(Arc::new(ItemStore::from_records([ + item_record(normal_item_id), + item_record(alliance_item_id), + ]))); + session.set_item_stats_store(Arc::new(ItemStatsStore::from_sparse_templates([ + (normal_item_id, sparse_template(0)), + ( + alliance_item_id, + sparse_template(ItemFlags2::FactionAlliance as u32), ), - )); - session.set_item_disenchant_loot_store(Arc::new(ItemDisenchantLootStore::from_entries([ - ItemDisenchantLootEntry { - id: 901, - subclass: 0, - quality: ItemQuality::Rare as u8, - min_level: 1, - max_level: 20, - skill_required: 175, - expansion_id: -2, - class_id: ItemClass::Armor as u32, - }, ]))); - } - fn install_active_spell_cast(session: &mut WorldSession, player_guid: ObjectGuid) { - session.active_spell_cast = Some(SpellCastState { - spell_id: 133, - target_guid: player_guid, - target_data: wow_packet::packets::spell::SpellTargetData { - flags: 0x2, - unit: player_guid, - ..Default::default() - }, - cast_id: ObjectGuid::create_world_object(HighGuid::Cast, 0, 1, 0, 0, 1, 7), - cast_start_time: std::time::Instant::now(), - cast_time_ms: 30_000, - spell_visual: wow_packet::packets::spell::SpellCastVisual { - spell_visual_id: 1, - script_visual_id: 0, - }, - metadata: crate::session::SpellCastMetadata::default(), + let mut creature_store = LootStore::for_kind_like_cpp(LootStoreKind::Creature); + creature_store + .load_rows_like_cpp( + [normal_item_id, alliance_item_id].map(|item_id| LootTemplateRow { + entry: loot_id, + item: LootStoreItem { + item_id, + reference: 0, + chance: 100.0, + needs_quest: false, + loot_mode: LOOT_MODE_DEFAULT_LIKE_CPP, + group_id: 0, + min_count: 1, + max_count: 1, + }, + }), + |_| true, + ) + .unwrap(); + let mut stores = LootStores::new(); + stores.insert(LootStoreKind::Creature, creature_store); + session.set_loot_stores(Arc::new(stores)); + + let mut creature = test_creature(owner_guid, false); + creature.entry = 9_001; + creature.level = 10; + creature.loot_id = loot_id; + creature.gold_min = 7; + creature.gold_max = 7; + register_test_creature_like_cpp(&mut session, creature); + session.mutate_world_creature(owner_guid, |world_creature| { + world_creature + .creature + .set_tapped_by_player(disconnected_tapper, &[first_tapper, second_tapper]); }); + attach_loot_guid_allocator_for_owner(&mut session, owner_guid); + + OverworldPersonalLootTestFixtureLikeCpp { + session, + owner_guid, + first_tapper, + second_tapper, + disconnected_tapper, + normal_item_id, + alliance_item_id, + } + } + + fn assert_overworld_personal_loot_generation_like_cpp( + authority: &OwnedLootAuthority, + fixture: &OverworldPersonalLootTestFixtureLikeCpp, + ) -> (u8, u8) { + assert!(authority.shared_snapshot_like_cpp().is_none()); + let personal = authority.personal_snapshots_like_cpp(); + assert_eq!(personal.len(), 2); + assert!(!personal.contains_key(&fixture.disconnected_tapper)); + + let first = &personal[&fixture.first_tapper].loot; + let second = &personal[&fixture.second_tapper].loot; + assert_ne!(first.loot_guid, second.loot_guid); + for (tapper, loot) in [ + (fixture.first_tapper, first), + (fixture.second_tapper, second), + ] { + assert_eq!(loot.loot_guid.high_type(), HighGuid::LootObject); + assert_eq!(loot.coins, 7); + assert_eq!(loot.loot_method, 0); + assert_eq!(loot.allowed_looters, vec![tapper]); + assert!( + loot.items + .iter() + .all(|item| item.allowed_looters == vec![tapper]) + ); + } + assert!( + first + .items + .iter() + .any(|item| item.item_id == fixture.normal_item_id) + ); + assert!( + first + .items + .iter() + .any(|item| item.item_id == fixture.alliance_item_id) + ); + assert_eq!( + second + .items + .iter() + .map(|item| item.item_id) + .collect::>(), + vec![fixture.normal_item_id], + "FillLoot eligibility must run for the Horde tapper instead of cloning the first pool" + ); + + let first_normal_slot = first + .items + .iter() + .find(|item| item.item_id == fixture.normal_item_id) + .unwrap() + .loot_list_id; + (first_normal_slot, second.items[0].loot_list_id) } - fn install_visible_aura_with_interrupt_flags( - session: &mut WorldSession, - slot: u8, - spell_id: i32, - caster_guid: ObjectGuid, - aura_interrupt_flags: u32, + async fn assert_overworld_personal_loot_claims_are_independent_like_cpp( + authority: &OwnedLootAuthority, + first_tapper: ObjectGuid, + first_normal_slot: u8, + second_tapper: ObjectGuid, + second_normal_slot: u8, ) { - session.visible_auras.insert( - slot, - AuraApplication { - spell_id, - caster_guid, - slot, - duration_total: 30_000, - duration_remaining: 30_000, - stack_count: 1, - aura_flags: 0x0000_0001, - effect_mask: 0x0000_0001, - aura_interrupt_flags, - aura_interrupt_flags2: 0, - represented_effect: None, - represented_amount: 0, - represented_effect_amounts: Vec::new(), - represented_misc_value: None, - represented_multiplier: 1.0, - applied_at: std::time::Instant::now(), - }, + assert!( + authority + .reserve_item_like_cpp(first_tapper, first_normal_slot) + .await + .unwrap() + .commit_like_cpp() + .unwrap() + ); + assert!( + !authority + .personal_snapshot_like_cpp(second_tapper) + .unwrap() + .loot + .items[0] + .taken + ); + assert!( + authority + .reserve_money_like_cpp(first_tapper) + .await + .unwrap() + .commit_like_cpp() + .unwrap() + ); + assert_eq!( + authority + .personal_snapshot_like_cpp(second_tapper) + .unwrap() + .loot + .coins, + 7 + ); + assert!( + authority + .reserve_item_like_cpp(second_tapper, second_normal_slot) + .await + .unwrap() + .commit_like_cpp() + .unwrap() + ); + assert!( + authority + .reserve_money_like_cpp(second_tapper) + .await + .unwrap() + .commit_like_cpp() + .unwrap() + ); + } + + #[tokio::test] + async fn overworld_creature_builds_independent_personal_loot_per_connected_tapper_like_cpp() { + let mut fixture = overworld_personal_loot_test_fixture_like_cpp(); + + fixture + .session + .ensure_represented_creature_kill_loot_like_cpp(fixture.owner_guid) + .await; + + let authority = fixture + .session + .represented_owned_loot_authority_like_cpp(fixture.owner_guid) + .expect("the dead creature keeps its object-owned loot authority"); + let (first_normal_slot, second_normal_slot) = + assert_overworld_personal_loot_generation_like_cpp(&authority, &fixture); + assert_overworld_personal_loot_claims_are_independent_like_cpp( + &authority, + fixture.first_tapper, + first_normal_slot, + fixture.second_tapper, + second_normal_slot, + ) + .await; + } + + #[tokio::test] + async fn dungeon_encounter_builds_independent_unlocked_personal_pools_like_cpp() { + let mut fixture = overworld_personal_loot_test_fixture_like_cpp(); + fixture + .session + .set_map_store(Arc::new(wow_data::MapStore::from_entries([ + wow_data::MapEntry { + id: 0, + instance_type: wow_data::map::MAP_INSTANCE, + expansion_id: 0, + parent_map_id: -1, + cosmetic_parent_map_id: -1, + flags1: 0, + flags2: 0, + }, + ]))); + let encounter_id = 733; + fixture + .session + .mutate_world_creature(fixture.owner_guid, |creature| { + creature.creature.ai_ownership_mut().dungeon_encounter_id = encounter_id; + }); + fixture + .session + .represented_locked_dungeon_encounters + .insert((fixture.second_tapper, encounter_id)); + + fixture + .session + .ensure_represented_creature_kill_loot_like_cpp(fixture.owner_guid) + .await; + + let authority = fixture + .session + .represented_owned_loot_authority_like_cpp(fixture.owner_guid) + .unwrap(); + let personal = authority.personal_snapshots_like_cpp(); + assert_eq!(personal.len(), 1); + assert!(personal.contains_key(&fixture.first_tapper)); + assert!(!personal.contains_key(&fixture.second_tapper)); + assert!(!personal.contains_key(&fixture.disconnected_tapper)); + let first = &personal[&fixture.first_tapper].loot; + assert_eq!(first.dungeon_encounter_id, encounter_id); + assert_eq!(first.allowed_looters, vec![fixture.first_tapper]); + } + + #[tokio::test] + async fn dungeon_trash_builds_one_personal_pool_for_selected_group_looter_like_cpp() { + let mut fixture = overworld_personal_loot_test_fixture_like_cpp(); + fixture + .session + .set_map_store(Arc::new(wow_data::MapStore::from_entries([ + wow_data::MapEntry { + id: 0, + instance_type: wow_data::map::MAP_INSTANCE, + expansion_id: 0, + parent_map_id: -1, + cosmetic_parent_map_id: -1, + flags1: 0, + flags2: 0, + }, + ]))); + let groups = Arc::new(GroupRegistry::default()); + let mut group = GroupInfo::new(fixture.first_tapper); + group.add_member(fixture.second_tapper); + group.looter_guid = fixture.second_tapper; + let group_guid = group.group_guid; + groups.insert(group_guid, group); + fixture.session.group_guid = Some(group_guid); + fixture + .session + .set_group_registry(Arc::clone(&groups), Arc::new(PendingInvites::default())); + + fixture + .session + .ensure_represented_creature_kill_loot_like_cpp(fixture.owner_guid) + .await; + + let authority = fixture + .session + .represented_owned_loot_authority_like_cpp(fixture.owner_guid) + .unwrap(); + let personal = authority.personal_snapshots_like_cpp(); + assert_eq!(personal.len(), 1); + assert!(!personal.contains_key(&fixture.first_tapper)); + let selected = &personal[&fixture.second_tapper].loot; + assert_eq!(selected.dungeon_encounter_id, 0); + assert_eq!(selected.allowed_looters, vec![fixture.second_tapper]); + assert!( + selected + .items + .iter() + .all(|entry| { entry.allowed_looters == vec![fixture.second_tapper] }) + ); + assert_eq!( + groups.get(&group_guid).unwrap().looter_guid_like_cpp(), + fixture.first_tapper, + "non-empty dungeon trash advances the round-robin group looter" + ); + } + + #[tokio::test] + async fn cmsg_loot_unit_never_regenerates_after_creature_clear_loot_like_cpp() { + let mut fixture = overworld_personal_loot_test_fixture_like_cpp(); + fixture + .session + .ensure_represented_creature_kill_loot_like_cpp(fixture.owner_guid) + .await; + let authority = fixture + .session + .represented_owned_loot_authority_like_cpp(fixture.owner_guid) + .unwrap(); + fixture + .session + .mutate_world_creature(fixture.owner_guid, |creature| { + creature.creature.clear_loot_like_cpp(); + }); + let retired_generation = authority.generation_like_cpp(); + + assert!( + fixture + .session + .represented_loot_response_for_owner_like_cpp( + fixture.owner_guid, + fixture.first_tapper, + false, + ) + .await + .is_none() + ); + assert!(authority.is_retired_like_cpp()); + assert_eq!(authority.generation_like_cpp(), retired_generation); + assert!( + authority + .snapshot_for_player_like_cpp(fixture.first_tapper) + .is_none() + ); + } + + #[test] + fn stale_kill_generator_cannot_install_after_creature_lifecycle_aba_like_cpp() { + let mut fixture = overworld_personal_loot_test_fixture_like_cpp(); + let authority = fixture + .session + .represented_owned_loot_authority_like_cpp(fixture.owner_guid) + .unwrap(); + let expected_generation = authority.generation_like_cpp(); + let expected_revision = fixture + .session + .represented_creature_loot_state_like_cpp(fixture.owner_guid) + .unwrap() + .loot_lifecycle_revision; + let mut stale_pool = authoritative_test_loot_like_cpp(0, true); + stale_pool.loot_guid = represented_loot_object_guid_like_cpp(fixture.owner_guid); + stale_pool.allowed_looters = vec![fixture.first_tapper]; + stale_pool.items[0].allowed_looters = vec![fixture.first_tapper]; + + fixture + .session + .mutate_world_creature(fixture.owner_guid, |creature| { + creature.creature.clear_loot_like_cpp(); + creature + .creature + .set_death_state_runtime(wow_constants::DeathState::JustRespawned, 0); + creature + .creature + .set_death_state_runtime(wow_constants::DeathState::JustDied, 0); + }); + + assert!( + !fixture + .session + .install_represented_creature_kill_loot_if_current_like_cpp( + fixture.owner_guid, + &authority, + expected_generation, + expected_revision, + None, + HashMap::from([(fixture.first_tapper, stale_pool)]), + ) + ); + assert!(authority.is_retired_like_cpp()); + assert!( + authority + .snapshot_for_player_like_cpp(fixture.first_tapper) + .is_none() ); } #[tokio::test] - async fn represented_creature_money_uses_cpp_money_drop_rate() { + async fn represented_gameobject_chest_loot_carries_cpp_source_metadata() { let mut session = make_session(); - session.set_loot_drop_rates_like_cpp(LootDropRatesLikeCpp { - money: 2.5, - ..LootDropRatesLikeCpp::default() - }); + let gameobject_guid = test_gameobject_guid(91_001); + attach_loot_guid_allocator_for_owner(&mut session, gameobject_guid); + let source = GameObjectLootSource { + loot_id: 0, + use_group_loot_rules: false, + dungeon_encounter_id: 733, + personal_loot_id: 10_001, + push_loot_id: 0, + triggered_event_id: 0, + linked_trap_entry: 0, + ..Default::default() + }; let loot = session - .generate_represented_creature_loot_like_cpp( - test_creature_guid(1), + .generate_represented_gameobject_chest_loot_like_cpp( + gameobject_guid, ObjectGuid::create_player(1, 42), - 10, - 25, - 0, - 100, - 100, - 0, + source, + &[], ) - .await; + .await + .expect("canonical owner map allocates a LootObject"); - assert_eq!(loot.coins, 250); - assert!(loot.items.is_empty()); + assert_eq!(loot.loot_type, LOOT_TYPE_CHEST_LIKE_CPP); + assert_eq!(loot.dungeon_encounter_id, 733); + assert_eq!(loot.loot_method, 0); } #[tokio::test] - async fn represented_creature_money_zero_gold_max_stays_zero_like_cpp() { - let mut session = make_session(); + async fn represented_non_encounter_personal_chest_keeps_two_session_pools_independent_like_cpp() + { + let (mut first, _first_rx) = make_session_with_send_capacity(8); + let (mut second, _second_rx) = make_session_with_send_capacity(8); + let first_player = ObjectGuid::create_player(1, 42); + let second_player = ObjectGuid::create_player(1, 77); + let gameobject_guid = test_gameobject_guid(91_015); + let personal_loot_id = 10_015; + let item_id = 80_015; + + first.set_player_guid(Some(first_player)); + second.set_player_guid(Some(second_player)); + first.set_player_position_like_cpp(Position::ZERO); + second.set_player_position_like_cpp(Position::ZERO); + + let gameobject = make_canonical_gameobject_for_session( + &first, + gameobject_guid, + GAMEOBJECT_TYPE_CHEST as u8, + ); + attach_canonical_gameobject(&mut first, gameobject); + second.set_canonical_map_manager(Arc::clone( + first + .canonical_map_manager + .as_ref() + .expect("both sessions share the canonical map owner"), + )); - let loot = session - .generate_represented_creature_loot_like_cpp( - test_creature_guid(1), - ObjectGuid::create_player(1, 42), - 10, - 25, - 0, - 0, - 0, - 0, + install_limited_test_item_template(&mut first, item_id, 0); + install_limited_test_item_template(&mut second, item_id, 0); + let mut gameobject_store = LootStore::for_kind_like_cpp(LootStoreKind::Gameobject); + gameobject_store + .load_rows_like_cpp( + [LootTemplateRow { + entry: personal_loot_id, + item: LootStoreItem { + item_id, + reference: 0, + chance: 100.0, + needs_quest: false, + loot_mode: LOOT_MODE_DEFAULT_LIKE_CPP, + group_id: 0, + min_count: 1, + max_count: 1, + }, + }], + |_| true, ) + .unwrap(); + let mut stores = LootStores::new(); + stores.insert(LootStoreKind::Gameobject, gameobject_store); + let stores = Arc::new(stores); + first.set_loot_stores(Arc::clone(&stores)); + second.set_loot_stores(stores); + + let source = GameObjectLootSource { + loot_id: 0, + use_group_loot_rules: true, + dungeon_encounter_id: 0, + personal_loot_id, + ..Default::default() + }; + + first + .open_represented_gameobject_chest_like_cpp(gameobject_guid, source) + .await; + second + .open_represented_gameobject_chest_like_cpp(gameobject_guid, source) .await; - assert_eq!(loot.coins, 0); - assert!(loot.items.is_empty()); + let authority = canonical_gameobject_snapshot(&first, gameobject_guid) + .expect("canonical chest remains map-owned") + .loot_authority_like_cpp() + .clone(); + assert!(authority.shared_snapshot_like_cpp().is_none()); + let personal = authority.personal_snapshots_like_cpp(); + assert_eq!(personal.len(), 2); + + let first_pool = personal.get(&first_player).unwrap(); + let second_pool = personal.get(&second_player).unwrap(); + assert_ne!(first_pool.loot.loot_guid, second_pool.loot.loot_guid); + assert_eq!(first_pool.loot.loot_method, 0); + assert_eq!(second_pool.loot.loot_method, 0); + for (player, pool) in [(first_player, first_pool), (second_player, second_pool)] { + assert_eq!(pool.loot.allowed_looters, vec![player]); + assert_eq!(pool.loot.items.len(), 1); + assert_eq!(pool.loot.items[0].item_id, item_id); + assert_eq!(pool.loot.items[0].allowed_looters, vec![player]); + } + + let first_slot = first_pool.loot.items[0].loot_list_id; + authority + .reserve_item_like_cpp(first_player, first_slot) + .await + .unwrap() + .commit_like_cpp() + .unwrap(); + assert!( + authority + .snapshot_for_player_like_cpp(first_player) + .unwrap() + .loot + .items[0] + .taken + ); + assert!( + !authority + .snapshot_for_player_like_cpp(second_player) + .unwrap() + .loot + .items[0] + .taken + ); } #[tokio::test] - async fn loot_response_success_keeps_cpp_failure_and_threshold_defaults() { - let mut session = make_session(); - let player_guid = ObjectGuid::create_player(1, 42); - let creature_guid = test_creature_guid(19_118); + async fn represented_empty_non_encounter_personal_chest_still_opens_like_cpp() { + let (mut session, send_rx) = make_session_with_send_capacity(8); + let player_guid = ObjectGuid::create_player(1, 141); + let gameobject_guid = test_gameobject_guid(91_021); session.set_player_guid(Some(player_guid)); - register_test_creature_like_cpp(&mut session, test_creature(creature_guid, false)); + session.set_player_position_like_cpp(Position::ZERO); + let gameobject = make_canonical_gameobject_for_session( + &session, + gameobject_guid, + GAMEOBJECT_TYPE_CHEST as u8, + ); + attach_canonical_gameobject(&mut session, gameobject); - let group_registry = Arc::new(GroupRegistry::default()); - let mut group = GroupInfo::new(player_guid); - group.loot_method = LOOT_METHOD_GROUP_LIKE_CPP; - group.loot_threshold = 4; - let group_guid = group.group_guid; - group_registry.insert(group_guid, group); - session.group_guid = Some(group_guid); - session.set_group_registry(group_registry, Arc::new(PendingInvites::default())); + session + .open_represented_gameobject_chest_like_cpp( + gameobject_guid, + GameObjectLootSource { + loot_id: 0, + dungeon_encounter_id: 0, + personal_loot_id: 10_021, + ..Default::default() + }, + ) + .await; - session.loot_table.insert( - creature_guid, - CreatureLoot { - loot_guid: represented_loot_object_guid_like_cpp(creature_guid), - coins: 1, - unlooted_count: 0, - loot_type: LOOT_TYPE_CORPSE_LIKE_CPP, - dungeon_encounter_id: 0, - loot_method: LOOT_METHOD_GROUP_LIKE_CPP, - loot_master: ObjectGuid::EMPTY, - round_robin_player: player_guid, - player_ffa_items: Vec::new(), - players_looting: Vec::new(), - allowed_looters: vec![player_guid], - items: Vec::new(), - looted_by_player: false, - }, + let authority = canonical_gameobject_snapshot(&session, gameobject_guid) + .unwrap() + .loot_authority_like_cpp() + .clone(); + let pool = authority + .snapshot_for_player_like_cpp(player_guid) + .expect("C++ retains the empty non-encounter personal pool"); + assert!(loot_is_looted_like_cpp(&pool.loot)); + assert_eq!(pool.loot.allowed_looters, vec![player_guid]); + assert!(session.is_active_loot_guid(gameobject_guid)); + let _response = + recv_packet_with_opcode(&send_rx, wow_constants::ServerOpcodes::LootResponse); + } + + #[tokio::test] + async fn represented_personal_encounter_late_session_without_canonical_tap_list_fails_closed() { + let (mut first, _first_rx) = make_session_with_send_capacity(8); + let (mut second, second_rx) = make_session_with_send_capacity(8); + let first_player = ObjectGuid::create_player(1, 142); + let second_player = ObjectGuid::create_player(1, 177); + let gameobject_guid = test_gameobject_guid(91_018); + let personal_loot_id = 10_018; + let item_id = 80_018; + + first.set_player_guid(Some(first_player)); + second.set_player_guid(Some(second_player)); + first.set_player_position_like_cpp(Position::ZERO); + second.set_player_position_like_cpp(Position::ZERO); + let gameobject = make_canonical_gameobject_for_session( + &first, + gameobject_guid, + GAMEOBJECT_TYPE_CHEST as u8, ); + attach_canonical_gameobject(&mut first, gameobject); + second.set_canonical_map_manager(Arc::clone( + first + .canonical_map_manager + .as_ref() + .expect("both sessions share the canonical map owner"), + )); - let response = session - .represented_loot_response_for_owner_like_cpp(creature_guid, player_guid, false) - .await + install_limited_test_item_template(&mut first, item_id, 0); + install_limited_test_item_template(&mut second, item_id, 0); + let mut gameobject_store = LootStore::for_kind_like_cpp(LootStoreKind::Gameobject); + gameobject_store + .load_rows_like_cpp( + [LootTemplateRow { + entry: personal_loot_id, + item: LootStoreItem { + item_id, + reference: 0, + chance: 100.0, + needs_quest: false, + loot_mode: LOOT_MODE_DEFAULT_LIKE_CPP, + group_id: 0, + min_count: 1, + max_count: 1, + }, + }], + |_| true, + ) .unwrap(); + let mut stores = LootStores::new(); + stores.insert(LootStoreKind::Gameobject, gameobject_store); + let stores = Arc::new(stores); + first.set_loot_stores(Arc::clone(&stores)); + second.set_loot_stores(stores); - assert_eq!(response.loot_method, LOOT_METHOD_GROUP_LIKE_CPP); - assert_eq!( - response.failure_reason, - LOOT_RESPONSE_DEFAULT_FAILURE_REASON_LIKE_CPP + let source = GameObjectLootSource { + loot_id: 0, + dungeon_encounter_id: 733, + personal_loot_id, + ..Default::default() + }; + first + .open_represented_gameobject_chest_like_cpp(gameobject_guid, source) + .await; + let authority = canonical_gameobject_snapshot(&first, gameobject_guid) + .unwrap() + .loot_authority_like_cpp() + .clone(); + let first_before = authority + .snapshot_for_player_like_cpp(first_player) + .expect("the first opener owns the initial encounter pool"); + + second + .open_represented_gameobject_chest_like_cpp(gameobject_guid, source) + .await; + + let personal = authority.personal_snapshots_like_cpp(); + assert_eq!(personal.len(), 1); + let first_after = personal.get(&first_player).unwrap(); + assert_eq!(first_after, &first_before); + assert!( + authority + .snapshot_for_player_like_cpp(second_player) + .is_none(), + "without canonical GameObject::GetTapList state Rust must not fabricate outsider loot" ); - assert_eq!(response.threshold, LOOT_RESPONSE_DEFAULT_THRESHOLD_LIKE_CPP); + assert!(!second.is_active_loot_guid(gameobject_guid)); + assert!(second_rx.try_recv().is_err()); } #[tokio::test] - async fn loot_error_response_keeps_cpp_threshold_default_like_cpp() { - let (session, send_rx) = make_session_with_send(); - let owner = test_creature_guid(19_119); - let loot_obj = represented_loot_object_guid_like_cpp(owner); + async fn represented_personal_encounter_locked_or_empty_late_player_does_not_install_like_cpp() + { + let (mut first, _first_rx) = make_session_with_send_capacity(8); + let (mut locked, locked_rx) = make_session_with_send_capacity(8); + let (mut empty, empty_rx) = make_session_with_send_capacity(8); + let first_player = ObjectGuid::create_player(1, 242); + let locked_player = ObjectGuid::create_player(1, 277); + let empty_player = ObjectGuid::create_player(1, 288); + let gameobject_guid = test_gameobject_guid(91_019); + let personal_loot_id = 10_019; + let item_id = 80_019; + let encounter_id = 734; + + for (session, player) in [ + (&mut first, first_player), + (&mut locked, locked_player), + (&mut empty, empty_player), + ] { + session.set_player_guid(Some(player)); + session.set_player_position_like_cpp(Position::ZERO); + } + let gameobject = make_canonical_gameobject_for_session( + &first, + gameobject_guid, + GAMEOBJECT_TYPE_CHEST as u8, + ); + attach_canonical_gameobject(&mut first, gameobject); + let manager = Arc::clone(first.canonical_map_manager.as_ref().unwrap()); + locked.set_canonical_map_manager(Arc::clone(&manager)); + empty.set_canonical_map_manager(manager); + + install_limited_test_item_template(&mut first, item_id, 0); + install_limited_test_item_template(&mut locked, item_id, 0); + let mut gameobject_store = LootStore::for_kind_like_cpp(LootStoreKind::Gameobject); + gameobject_store + .load_rows_like_cpp( + [LootTemplateRow { + entry: personal_loot_id, + item: LootStoreItem { + item_id, + reference: 0, + chance: 100.0, + needs_quest: false, + loot_mode: LOOT_MODE_DEFAULT_LIKE_CPP, + group_id: 0, + min_count: 1, + max_count: 1, + }, + }], + |_| true, + ) + .unwrap(); + let mut stores = LootStores::new(); + stores.insert(LootStoreKind::Gameobject, gameobject_store); + let stores = Arc::new(stores); + first.set_loot_stores(Arc::clone(&stores)); + locked.set_loot_stores(stores); + locked + .represented_locked_dungeon_encounters + .insert((locked_player, encounter_id)); + + let source = GameObjectLootSource { + loot_id: 0, + dungeon_encounter_id: encounter_id, + personal_loot_id, + ..Default::default() + }; + first + .open_represented_gameobject_chest_like_cpp(gameobject_guid, source) + .await; + let authority = canonical_gameobject_snapshot(&first, gameobject_guid) + .unwrap() + .loot_authority_like_cpp() + .clone(); + let first_before = authority + .snapshot_for_player_like_cpp(first_player) + .unwrap(); + + locked + .open_represented_gameobject_chest_like_cpp(gameobject_guid, source) + .await; + empty + .open_represented_gameobject_chest_like_cpp(gameobject_guid, source) + .await; + + assert_eq!(authority.personal_snapshots_like_cpp().len(), 1); + assert_eq!( + authority + .snapshot_for_player_like_cpp(first_player) + .unwrap(), + first_before + ); + assert!( + authority + .snapshot_for_player_like_cpp(locked_player) + .is_none() + ); + assert!( + authority + .snapshot_for_player_like_cpp(empty_player) + .is_none() + ); + assert!(!locked.is_active_loot_guid(gameobject_guid)); + assert!(!empty.is_active_loot_guid(gameobject_guid)); + assert!(locked_rx.try_recv().is_err()); + assert!(empty_rx.try_recv().is_err()); + } + + #[test] + fn personal_encounter_late_upsert_cannot_cross_clear_loot_like_cpp() { + let mut session = make_session(); + let first_player = ObjectGuid::create_player(1, 342); + let late_player = ObjectGuid::create_player(1, 377); + let gameobject_guid = test_gameobject_guid(91_020); + let mut gameobject = make_canonical_gameobject_for_session( + &session, + gameobject_guid, + GAMEOBJECT_TYPE_CHEST as u8, + ); + let mut first_pool = authoritative_test_loot_like_cpp(0, true); + first_pool.loot_guid = represented_loot_object_guid_like_cpp(gameobject_guid); + first_pool.loot_type = LOOT_TYPE_CHEST_LIKE_CPP; + first_pool.allowed_looters = vec![first_player]; + first_pool.items[0].allowed_looters = vec![first_player]; + assert!( + gameobject + .initialize_loot_authority_like_cpp( + None, + HashMap::from([(first_player, first_pool)]), + ) + .installed() + ); + let authority = gameobject.loot_authority_like_cpp().clone(); + attach_canonical_gameobject(&mut session, gameobject); + let observation = session + .represented_gameobject_loot_install_observation_like_cpp(gameobject_guid) + .expect("late generation observes the active chest lifetime"); + + let mut stale_late_pool = authoritative_test_loot_like_cpp(0, true); + stale_late_pool.loot_guid = ObjectGuid::create_world_object( + HighGuid::LootObject, + 0, + gameobject_guid.realm_id(), + gameobject_guid.map_id(), + 0, + 0, + gameobject_guid.counter() + 1, + ); + stale_late_pool.loot_type = LOOT_TYPE_CHEST_LIKE_CPP; + stale_late_pool.allowed_looters = vec![late_player]; + stale_late_pool.items[0].allowed_looters = vec![late_player]; + session + .represented_personal_loot_owners + .insert(gameobject_guid); + session + .represented_personal_loot_money + .insert((gameobject_guid, late_player), 0); - session.send_loot_error_like_cpp(loot_obj, owner, LOOT_ERROR_TOO_FAR_LIKE_CPP); + session.mutate_canonical_gameobject_by_guid_like_cpp(gameobject_guid, |gameobject| { + gameobject.clear_loot_like_cpp(); + }); - let sent = send_rx.try_recv().unwrap(); - assert_eq!( - loot_response_failure_reason(&sent), - LOOT_ERROR_TOO_FAR_LIKE_CPP + assert!( + session + .upsert_represented_personal_gameobject_loot_authority_if_observed_with_empty_policy_like_cpp( + gameobject_guid, + late_player, + stale_late_pool, + false, + true, + &observation, + ) + .is_none() ); - assert_eq!( - loot_response_threshold(&sent), - LOOT_RESPONSE_DEFAULT_THRESHOLD_LIKE_CPP + assert!(authority.is_retired_like_cpp()); + assert!(authority.personal_snapshots_like_cpp().is_empty()); + assert!( + !session + .represented_personal_loot_money + .contains_key(&(gameobject_guid, late_player)) ); + assert!(!session.loot_table.contains_key(&gameobject_guid)); } #[tokio::test] - async fn represented_creature_loot_generation_carries_cpp_dungeon_encounter_id() { - let mut session = make_session(); + async fn represented_empty_personal_encounter_chest_does_not_install_or_open_like_cpp() { + let (mut session, send_rx) = make_session_with_send_capacity(8); + let player_guid = ObjectGuid::create_player(1, 42); + let gameobject_guid = test_gameobject_guid(91_016); + session.set_player_guid(Some(player_guid)); + session.set_player_position_like_cpp(Position::ZERO); + let gameobject = make_canonical_gameobject_for_session( + &session, + gameobject_guid, + GAMEOBJECT_TYPE_CHEST as u8, + ); + attach_canonical_gameobject(&mut session, gameobject); - let loot = session - .generate_represented_creature_loot_like_cpp( - test_creature_guid(19_097), - ObjectGuid::create_player(1, 42), - 10, - 25, - 0, - 0, - 0, - 615, + session + .open_represented_gameobject_chest_like_cpp( + gameobject_guid, + GameObjectLootSource { + loot_id: 0, + dungeon_encounter_id: 733, + personal_loot_id: 10_016, + ..Default::default() + }, ) .await; - assert_eq!(loot.dungeon_encounter_id, 615); + let gameobject = canonical_gameobject_snapshot(&session, gameobject_guid).unwrap(); + assert!(gameobject.loot_authority_like_cpp().is_pristine_like_cpp()); + assert!( + gameobject + .loot_authority_like_cpp() + .personal_snapshots_like_cpp() + .is_empty() + ); + assert!(!session.loot_table.contains_key(&gameobject_guid)); + assert!( + !session + .represented_personal_loot_owners + .contains(&gameobject_guid) + ); + assert!( + !session + .represented_personal_loot_money + .contains_key(&(gameobject_guid, player_guid)) + ); + assert!(!session.is_active_loot_guid(gameobject_guid)); + assert!(send_rx.try_recv().is_err()); } #[tokio::test] - async fn represented_gameobject_chest_loot_carries_cpp_source_metadata() { - let mut session = make_session(); - let source = GameObjectLootSource { - loot_id: 0, - use_group_loot_rules: false, - dungeon_encounter_id: 733, - personal_loot_id: 10_001, - push_loot_id: 0, - triggered_event_id: 0, - linked_trap_entry: 0, - ..Default::default() - }; + async fn represented_nonempty_personal_encounter_chest_keeps_live_canonical_pool_like_cpp() { + let (mut session, _send_rx) = make_session_with_send_capacity(8); + let player_guid = ObjectGuid::create_player(1, 42); + let gameobject_guid = test_gameobject_guid(91_017); + let personal_loot_id = 10_017; + let item_id = 80_017; + session.set_player_guid(Some(player_guid)); + session.set_player_position_like_cpp(Position::ZERO); + let gameobject = make_canonical_gameobject_for_session( + &session, + gameobject_guid, + GAMEOBJECT_TYPE_CHEST as u8, + ); + attach_canonical_gameobject(&mut session, gameobject); - let loot = session - .generate_represented_gameobject_chest_loot_like_cpp( - test_gameobject_guid(91_001), - ObjectGuid::create_player(1, 42), - source, + install_limited_test_item_template(&mut session, item_id, 0); + let mut gameobject_store = LootStore::for_kind_like_cpp(LootStoreKind::Gameobject); + gameobject_store + .load_rows_like_cpp( + [LootTemplateRow { + entry: personal_loot_id, + item: LootStoreItem { + item_id, + reference: 0, + chance: 100.0, + needs_quest: false, + loot_mode: LOOT_MODE_DEFAULT_LIKE_CPP, + group_id: 0, + min_count: 1, + max_count: 1, + }, + }], + |_| true, + ) + .unwrap(); + let mut stores = LootStores::new(); + stores.insert(LootStoreKind::Gameobject, gameobject_store); + session.set_loot_stores(Arc::new(stores)); + + session + .open_represented_gameobject_chest_like_cpp( + gameobject_guid, + GameObjectLootSource { + loot_id: 0, + dungeon_encounter_id: 733, + personal_loot_id, + ..Default::default() + }, ) .await; - assert_eq!(loot.loot_type, LOOT_TYPE_CHEST_LIKE_CPP); - assert_eq!(loot.dungeon_encounter_id, 733); - assert_eq!(loot.loot_method, 0); + let gameobject = canonical_gameobject_snapshot(&session, gameobject_guid).unwrap(); + let pool = gameobject + .loot_authority_like_cpp() + .snapshot_for_player_like_cpp(player_guid) + .expect("nonempty encounter pool remains map-owned"); + assert_eq!(pool.loot.allowed_looters, vec![player_guid]); + assert_eq!(pool.loot.items.len(), 1); + assert_eq!(pool.loot.items[0].item_id, item_id); + assert!(!loot_is_looted_like_cpp(&pool.loot)); + assert!(session.is_active_loot_guid(gameobject_guid)); } #[tokio::test] @@ -10123,6 +19330,7 @@ mod tests { let mut session = make_session(); let player_guid = ObjectGuid::create_player(1, 42); let gameobject_guid = test_gameobject_guid(91_008); + attach_loot_guid_allocator_for_owner(&mut session, gameobject_guid); let source = GameObjectLootSource { loot_id: 0, use_group_loot_rules: false, @@ -10139,8 +19347,10 @@ mod tests { gameobject_guid, player_guid, source, + &[], ) - .await; + .await + .expect("canonical owner map allocates a LootObject"); assert_eq!(loot.allowed_looters, vec![player_guid]); assert!( @@ -10168,6 +19378,7 @@ mod tests { let second_tapper = ObjectGuid::create_player(1, 77); let non_player_tapper = ObjectGuid::create_item(1, 900); let gameobject_guid = test_gameobject_guid(91_009); + attach_loot_guid_allocator_for_owner(&mut session, gameobject_guid); session.represented_gameobject_tap_lists.insert( gameobject_guid, vec![ @@ -10193,8 +19404,10 @@ mod tests { gameobject_guid, first_tapper, source, + &[], ) - .await; + .await + .expect("canonical owner map allocates a LootObject"); assert_eq!(loot.allowed_looters, vec![first_tapper, second_tapper]); assert!( @@ -10226,6 +19439,7 @@ mod tests { let locked_tapper = ObjectGuid::create_player(1, 42); let open_tapper = ObjectGuid::create_player(1, 77); let gameobject_guid = test_gameobject_guid(91_010); + attach_loot_guid_allocator_for_owner(&mut session, gameobject_guid); session .represented_gameobject_tap_lists .insert(gameobject_guid, vec![locked_tapper, open_tapper]); @@ -10248,8 +19462,10 @@ mod tests { gameobject_guid, locked_tapper, source, + &[], ) - .await; + .await + .expect("canonical owner map allocates a LootObject"); assert_eq!(loot.allowed_looters, vec![open_tapper]); assert!( @@ -11255,6 +20471,7 @@ mod tests { let master_guid = ObjectGuid::create_player(1, 42); let candidate_guid = ObjectGuid::create_player(1, 77); let owner_guid = test_creature_guid(19_049); + attach_loot_guid_allocator_for_owner(&mut session, owner_guid); install_master_loot_group(&mut session, master_guid, candidate_guid); let loot = session @@ -11268,13 +20485,29 @@ mod tests { 0, 0, ) - .await; + .await + .expect("canonical owner map allocates a LootObject"); assert_eq!(loot.loot_method, LOOT_METHOD_MASTER_LIKE_CPP); assert_eq!(loot.loot_master, master_guid); assert_eq!(loot.round_robin_player, master_guid); } + #[test] + fn represented_gameobject_group_loot_keeps_round_robin_empty_like_cpp() { + let mut session = make_session(); + let opener = ObjectGuid::create_player(1, 42); + let candidate = ObjectGuid::create_player(1, 77); + install_master_loot_group(&mut session, opener, candidate); + + let (loot_method, loot_master, round_robin_player) = + session.represented_gameobject_chest_group_state_like_cpp(true, opener); + + assert_eq!(loot_method, LOOT_METHOD_MASTER_LIKE_CPP); + assert_eq!(loot_master, opener); + assert_eq!(round_robin_player, ObjectGuid::EMPTY); + } + fn test_gameobject_guid(counter: i64) -> ObjectGuid { ObjectGuid::create_world_object(HighGuid::GameObject, 0, 1, 0, 0, 1, counter) } @@ -11298,32 +20531,232 @@ mod tests { } } - #[test] - fn loot_item_random_context_runtime_and_persistence_fields_match_entry() { - let sql = CharStatements::INS_ITEM_INSTANCE_WITH_RANDOM_CONTEXT.sql(); - assert!(sql.contains("randomPropertiesId")); - assert!(sql.contains("randomPropertiesSeed")); - assert!(sql.contains("context")); + #[test] + fn loot_item_random_context_runtime_and_persistence_fields_match_entry() { + let sql = CharStatements::INS_ITEM_INSTANCE_WITH_RANDOM_CONTEXT.sql(); + assert!(sql.contains("randomPropertiesId")); + assert!(sql.contains("randomPropertiesSeed")); + assert!(sql.contains("context")); + assert!( + sql.contains("'', '', ?, ?, ?, ?"), + "stored-new-item flags must be a bound parameter, not the old hard-coded zero" + ); + + let item_guid = ObjectGuid::create_item(1, 902); + let owner_guid = ObjectGuid::create_player(1, 42); + let mut item = Item::new(0); + item.initialize_created_state(ItemCreateInfo { + guid: item_guid, + item_id: 25, + context: loot_item_context(2), + owner: Some(owner_guid), + max_durability: 0, + expiration: 0, + spell_charges: [0; MAX_ITEM_SPELLS], + }); + item.set_random_properties_id(-77); + item.set_property_seed(456); + + let data = item.data(); + assert_eq!(data.random_properties_id, -77); + assert_eq!(data.property_seed, 456); + assert_eq!(u8::try_from(data.context).unwrap_or(0), 2); + } + + #[test] + fn stored_new_item_flags_follow_cpp_new_and_binding_rules() { + let mut session = make_session(); + let item_id = 25; + + install_limited_test_item_template_with_flags2_and_bonding( + &mut session, + item_id, + 0, + 0, + ItemBondingType::OnAcquire, + ); + assert_eq!( + session.stored_new_item_dynamic_flags_like_cpp(item_id, INVENTORY_SLOT_ITEM_START), + (ItemFieldFlags::NEW_ITEM | ItemFieldFlags::SOULBOUND).bits() + ); + + install_limited_test_item_template_with_flags2_and_bonding( + &mut session, + item_id, + 0, + 0, + ItemBondingType::None, + ); + assert_eq!( + session.stored_new_item_dynamic_flags_like_cpp(item_id, INVENTORY_SLOT_ITEM_START), + ItemFieldFlags::NEW_ITEM.bits(), + "an unbound backpack item must not acquire SOULBOUND" + ); + + install_limited_test_item_template_with_flags2_and_bonding( + &mut session, + item_id, + 0, + 0, + ItemBondingType::OnEquip, + ); + assert_eq!( + session.stored_new_item_dynamic_flags_like_cpp(item_id, INVENTORY_SLOT_ITEM_START), + ItemFieldFlags::NEW_ITEM.bits(), + "C++ bind-if-stored does not bind OnEquip items in backpack slots" + ); + } + + #[test] + fn historical_stack_binding_adds_only_soulbound_like_cpp() { + let mut session = make_session(); + let item_id = 25; + let historical = Item::new(0); + + install_limited_test_item_template_with_flags2_and_bonding( + &mut session, + item_id, + 0, + 0, + ItemBondingType::OnAcquire, + ); + let flags = session.stored_existing_item_dynamic_flags_like_cpp( + item_id, + INVENTORY_SLOT_ITEM_START, + &historical, + ); + assert_eq!(flags, ItemFieldFlags::SOULBOUND.bits()); + assert_eq!(flags & ItemFieldFlags::NEW_ITEM.bits(), 0); + + install_limited_test_item_template_with_flags2_and_bonding( + &mut session, + item_id, + 0, + 0, + ItemBondingType::OnEquip, + ); + assert_eq!( + session.stored_existing_item_dynamic_flags_like_cpp( + item_id, + wow_entities::INVENTORY_SLOT_BAG_START, + &historical, + ), + ItemFieldFlags::SOULBOUND.bits(), + "C++ binds an OnEquip item when that item is stored in a bag-equipment position" + ); + assert_eq!( + session.stored_existing_item_dynamic_flags_like_cpp( + item_id, + INVENTORY_SLOT_ITEM_START, + &historical, + ), + 0, + "C++ does not bind an OnEquip stack in an ordinary backpack slot" + ); + } + + #[tokio::test] + async fn existing_stack_count_and_binding_share_one_sql_transaction() { + let pool = sqlx::mysql::MySqlPoolOptions::new() + .max_connections(1) + .connect_lazy("mysql://rustycore:rustycore@127.0.0.1:1/characters") + .expect("syntactically valid lazy MySQL pool"); + let char_db = wow_database::CharacterDatabase::from_pool(pool); + + let mut bound_update = wow_database::SqlTransaction::new(); + WorldSession::append_existing_loot_stack_persistence_like_cpp( + &char_db, + &mut bound_update, + 77, + 19, + Some(ItemFieldFlags::SOULBOUND.bits()), + ); + assert_eq!( + bound_update.len(), + 2, + "count and DynamicFlags must be committed or rolled back together" + ); + + let mut count_only_update = wow_database::SqlTransaction::new(); + WorldSession::append_existing_loot_stack_persistence_like_cpp( + &char_db, + &mut count_only_update, + 77, + 19, + None, + ); + assert_eq!(count_only_update.len(), 1); + } + + #[tokio::test] + async fn failed_existing_stack_store_publishes_neither_count_nor_binding() { + let (mut session, _send_rx) = make_session_with_send_capacity(4); + let player_guid = ObjectGuid::create_player(1, 42); + let item_guid = ObjectGuid::create_item(1, 77); + let item_id = 25; + session.set_player_guid(Some(player_guid)); + install_limited_test_item_template_with_flags2_and_bonding( + &mut session, + item_id, + 0, + 0, + ItemBondingType::OnAcquire, + ); + session.insert_inventory_item_like_cpp( + INVENTORY_SLOT_ITEM_START, + InventoryItem { + guid: item_guid, + entry_id: item_id, + db_guid: 77, + inventory_type: None, + }, + ); + let item = session.make_inventory_item_object( + item_guid, + item_id, + player_guid, + 4, + 0, + ItemContext::None, + INVENTORY_SLOT_ITEM_START, + ); + session.insert_inventory_item_object(item); + + let failing_pool = sqlx::mysql::MySqlPoolOptions::new() + .max_connections(1) + .acquire_timeout(Duration::from_millis(100)) + .connect_lazy("mysql://rustycore:rustycore@127.0.0.1:1/characters") + .expect("syntactically valid lazy MySQL pool"); + session.set_char_db(Arc::new(wow_database::CharacterDatabase::from_pool( + failing_pool, + ))); - let item_guid = ObjectGuid::create_item(1, 902); - let owner_guid = ObjectGuid::create_player(1, 42); - let mut item = Item::new(0); - item.initialize_created_state(ItemCreateInfo { - guid: item_guid, - item_id: 25, - context: loot_item_context(2), - owner: Some(owner_guid), - max_durability: 0, - expiration: 0, - spell_charges: [0; MAX_ITEM_SPELLS], - }); - item.set_random_properties_id(-77); - item.set_property_seed(456); + let stored = session + .store_direct_loot_item_like_cpp( + &LootEntry { + loot_list_id: 0, + item_id, + quantity: 1, + random_properties_id: 0, + random_properties_seed: 0, + item_context: 0, + flags: LootEntryFlags::default(), + allowed_looters: vec![player_guid], + roll_winner: ObjectGuid::EMPTY, + ffa_looted_by: Vec::new(), + taken: false, + }, + 0, + ) + .await; - let data = item.data(); - assert_eq!(data.random_properties_id, -77); - assert_eq!(data.property_seed, 456); - assert_eq!(u8::try_from(data.context).unwrap_or(0), 2); + assert!(!stored); + let historical = session + .inventory_item_objects_like_cpp() + .get(&item_guid) + .expect("failed transaction keeps the historical stack"); + assert_eq!(historical.count(), 4); + assert_eq!(historical.item_flags_bits(), 0); } #[test] @@ -11992,6 +21425,7 @@ mod tests { }, ); + install_cached_test_creature_loot_authority_like_cpp(&mut session, owner_guid, master_guid); session.handle_loot_unit(loot_unit_packet(owner_guid)).await; let response = send_rx.try_recv().unwrap(); @@ -12059,6 +21493,7 @@ mod tests { }, ); + install_cached_test_creature_loot_authority_like_cpp(&mut session, owner_guid, master_guid); session.handle_loot_unit(loot_unit_packet(owner_guid)).await; session.handle_loot_unit(loot_unit_packet(owner_guid)).await; @@ -12123,6 +21558,7 @@ mod tests { }, ); + install_cached_test_creature_loot_authority_like_cpp(&mut session, owner_guid, master_guid); session.handle_loot_unit(loot_unit_packet(owner_guid)).await; let _response = send_rx.try_recv().unwrap(); @@ -12203,6 +21639,7 @@ mod tests { }, ); + install_cached_test_creature_loot_authority_like_cpp(&mut session, owner_guid, player_guid); session.handle_loot_unit(loot_unit_packet(owner_guid)).await; let response = send_rx.try_recv().unwrap(); @@ -12336,6 +21773,7 @@ mod tests { }, ); + install_cached_test_creature_loot_authority_like_cpp(&mut session, owner_guid, player_guid); session.handle_loot_unit(loot_unit_packet(owner_guid)).await; let _response = send_rx.try_recv().unwrap(); @@ -12407,6 +21845,7 @@ mod tests { }, ); + install_cached_test_creature_loot_authority_like_cpp(&mut session, owner_guid, player_guid); session.handle_loot_unit(loot_unit_packet(owner_guid)).await; let _response = send_rx.try_recv().unwrap(); @@ -12581,6 +22020,7 @@ mod tests { }, ); + install_cached_test_creature_loot_authority_like_cpp(&mut session, owner_guid, player_guid); session.handle_loot_unit(loot_unit_packet(owner_guid)).await; let _response = send_rx.try_recv().unwrap(); @@ -12642,6 +22082,7 @@ mod tests { }, ); + install_cached_test_creature_loot_authority_like_cpp(&mut session, owner_guid, player_guid); session.handle_loot_unit(loot_unit_packet(owner_guid)).await; let _response = send_rx.try_recv().unwrap(); @@ -12736,13 +22177,13 @@ mod tests { }, ); + install_cached_test_creature_loot_authority_like_cpp(&mut session, owner_guid, player_guid); session.handle_loot_unit(loot_unit_packet(owner_guid)).await; let _response = send_rx.try_recv().unwrap(); let _loot_list = send_rx.try_recv().unwrap(); let _start_roll = send_rx.try_recv().unwrap(); let _remote_loot_list = candidate_rx.try_recv().unwrap(); let _remote_start_roll = candidate_rx.try_recv().unwrap(); - session .handle_loot_roll(LootRoll { loot_obj: loot_object, @@ -12826,6 +22267,7 @@ mod tests { }, ); + install_cached_test_creature_loot_authority_like_cpp(&mut session, owner_guid, player_guid); session.handle_loot_unit(loot_unit_packet(owner_guid)).await; let _response = send_rx.try_recv().unwrap(); let _loot_list = send_rx.try_recv().unwrap(); @@ -12992,6 +22434,7 @@ mod tests { }, ); + install_cached_test_creature_loot_authority_like_cpp(&mut session, owner_guid, player_guid); session.handle_loot_unit(loot_unit_packet(owner_guid)).await; let _response = send_rx.try_recv().unwrap(); let _loot_list = send_rx.try_recv().unwrap(); @@ -13075,6 +22518,101 @@ mod tests { } } + #[tokio::test] + async fn stale_loot_roll_vote_does_not_mutate_replacement_generation_like_cpp() { + let (mut session, send_rx, candidate_rx, player_guid, candidate_guid, owner_guid) = + open_generation_guarded_group_roll_like_cpp(19_060).await; + let loot_object = represented_loot_object_guid_like_cpp(owner_guid); + let old_generation = session + .represented_loot_rolls + .get(&(loot_object, 0)) + .unwrap() + .authority_generation; + let replacement_generation = replace_generation_guarded_group_loot_like_cpp( + &mut session, + owner_guid, + player_guid, + candidate_guid, + ); + assert_ne!(old_generation, replacement_generation); + + session + .handle_loot_roll(LootRoll { + loot_obj: loot_object, + loot_list_id: 0, + roll_type: ROLL_VOTE_NEED_LIKE_CPP, + }) + .await; + + assert!( + !session + .represented_loot_rolls + .contains_key(&(loot_object, 0)), + "the stale roll must be cancelled instead of routed or voted" + ); + assert!(send_rx.try_recv().is_err()); + assert!(candidate_rx.try_recv().is_err()); + assert!(session.represented_loot_roll_criteria_events.is_empty()); + + let authority = session + .represented_owned_loot_authority_like_cpp(owner_guid) + .unwrap(); + let replacement = authority.shared_snapshot_like_cpp().unwrap(); + assert_eq!(replacement.generation, replacement_generation); + let entry = &replacement.loot.items[0]; + assert!(entry.flags.blocked); + assert!(entry.roll_winner.is_empty()); + assert!(!entry.taken); + assert_eq!(replacement.loot.unlooted_count, 1); + } + + #[tokio::test] + async fn stale_loot_roll_expiry_does_not_mutate_replacement_generation_like_cpp() { + let (mut session, send_rx, candidate_rx, player_guid, candidate_guid, owner_guid) = + open_generation_guarded_group_roll_like_cpp(19_061).await; + let loot_object = represented_loot_object_guid_like_cpp(owner_guid); + let old_generation = session + .represented_loot_rolls + .get(&(loot_object, 0)) + .unwrap() + .authority_generation; + let replacement_generation = replace_generation_guarded_group_loot_like_cpp( + &mut session, + owner_guid, + player_guid, + candidate_guid, + ); + assert_ne!(old_generation, replacement_generation); + session + .represented_loot_rolls + .get_mut(&(loot_object, 0)) + .unwrap() + .end_time = Instant::now() - Duration::from_millis(1); + + session.tick_represented_loot_rolls_like_cpp().await; + + assert!( + !session + .represented_loot_rolls + .contains_key(&(loot_object, 0)), + "the stale timer must be cancelled without finishing against replacement loot" + ); + assert!(send_rx.try_recv().is_err()); + assert!(candidate_rx.try_recv().is_err()); + assert!(session.represented_loot_roll_criteria_events.is_empty()); + + let authority = session + .represented_owned_loot_authority_like_cpp(owner_guid) + .unwrap(); + let replacement = authority.shared_snapshot_like_cpp().unwrap(); + assert_eq!(replacement.generation, replacement_generation); + let entry = &replacement.loot.items[0]; + assert!(entry.flags.blocked); + assert!(entry.roll_winner.is_empty()); + assert!(!entry.taken); + assert_eq!(replacement.loot.unlooted_count, 1); + } + #[tokio::test] async fn loot_roll_all_passed_unblocks_without_all_passed_to_valid_voters_like_cpp() { let (mut session, send_rx) = make_session_with_send_capacity(8); @@ -13126,6 +22664,7 @@ mod tests { }, ); + install_cached_test_creature_loot_authority_like_cpp(&mut session, owner_guid, player_guid); session.handle_loot_unit(loot_unit_packet(owner_guid)).await; let _response = send_rx.try_recv().unwrap(); let _loot_list = send_rx.try_recv().unwrap(); @@ -13247,12 +22786,19 @@ mod tests { }, ); + install_cached_test_creature_loot_authority_like_cpp(&mut session, owner_guid, player_guid); session.handle_loot_unit(loot_unit_packet(owner_guid)).await; let _response = send_rx.try_recv().unwrap(); let _loot_list = send_rx.try_recv().unwrap(); let _start_roll = send_rx.try_recv().unwrap(); let _remote_loot_list = candidate_rx.try_recv().unwrap(); let _remote_start_roll = candidate_rx.try_recv().unwrap(); + let roll_identity = session + .represented_loot_rolls + .get(&(loot_object, 0)) + .unwrap() + .command_identity + .clone(); session .session_command_tx() @@ -13262,6 +22808,7 @@ mod tests { loot_list_id: 0, roll_type: ROLL_VOTE_GREED_LIKE_CPP, pass_on_group_loot: false, + roll_identity, })) .unwrap(); session @@ -13298,6 +22845,55 @@ mod tests { ); } + #[test] + fn loot_roll_vote_command_accepts_exact_enqueued_roll_identity_like_cpp() { + let loot_object = represented_loot_object_guid_like_cpp(test_creature_guid(19_062)); + let authority = OwnedLootAuthority::new(); + let roll_identity = + LootRollCommandIdentityLikeCpp::new_like_cpp(loot_object, 0, authority, 7); + let command = LootRollVoteCommand { + voter_guid: ObjectGuid::create_player(1, 77), + loot_obj: loot_object, + loot_list_id: 0, + roll_type: ROLL_VOTE_GREED_LIKE_CPP, + pass_on_group_loot: false, + roll_identity: roll_identity.clone(), + }; + + assert!( + WorldSession::represented_loot_roll_vote_command_targets_identity_like_cpp( + &command, + &roll_identity, + ) + ); + } + + #[test] + fn queued_loot_roll_vote_rejects_replacement_with_same_key_and_generation_like_cpp() { + let loot_object = represented_loot_object_guid_like_cpp(test_creature_guid(19_063)); + let authority = OwnedLootAuthority::new(); + let stale_identity = + LootRollCommandIdentityLikeCpp::new_like_cpp(loot_object, 0, authority.clone(), 7); + let replacement_identity = + LootRollCommandIdentityLikeCpp::new_like_cpp(loot_object, 0, authority, 7); + let stale_command = LootRollVoteCommand { + voter_guid: ObjectGuid::create_player(1, 77), + loot_obj: loot_object, + loot_list_id: 0, + roll_type: ROLL_VOTE_GREED_LIKE_CPP, + pass_on_group_loot: false, + roll_identity: stale_identity, + }; + + assert!( + !WorldSession::represented_loot_roll_vote_command_targets_identity_like_cpp( + &stale_command, + &replacement_identity, + ), + "a command queued for the destroyed C++ LootRoll* must not vote on its replacement" + ); + } + #[tokio::test] async fn loot_roll_remote_session_routes_vote_to_owner_session_like_cpp() { let (mut owner_session, owner_rx) = make_session_with_send_capacity(8); @@ -13305,7 +22901,6 @@ mod tests { let player_guid = ObjectGuid::create_player(1, 42); let candidate_guid = ObjectGuid::create_player(1, 77); let owner_guid = test_creature_guid(19_056); - let loot_object = represented_loot_object_guid_like_cpp(owner_guid); let (candidate_tx, candidate_rx) = flume::bounded::>(8); let (owner_registry_tx, _owner_registry_rx) = flume::bounded::>(8); let player_registry = Arc::new(PlayerRegistry::default()); @@ -13320,41 +22915,61 @@ mod tests { remote_session.set_player_registry(Arc::clone(&player_registry)); remote_session.set_player_guid(Some(candidate_guid)); install_group_loot_group(&mut owner_session, player_guid, candidate_guid); + + let mut canonical_player = Player::new(Some(1), false); + canonical_player + .unit_mut() + .world_mut() + .object_mut() + .create(player_guid); + canonical_player + .unit_mut() + .world_mut() + .set_map(u32::from(owner_guid.map_id()), 0) + .unwrap(); + canonical_player + .unit_mut() + .world_mut() + .relocate(Position::ZERO); + canonical_player + .unit_mut() + .world_mut() + .object_mut() + .add_to_world(); + let canonical_creature = make_canonical_creature_for_session(&owner_session, owner_guid); + let canonical_manager = Arc::new(Mutex::new(wow_map::MapManager::default())); + { + let mut manager = canonical_manager.lock().unwrap(); + let map = manager.create_world_map(u32::from(owner_guid.map_id()), 0); + map.map_mut() + .insert_map_object_record( + wow_entities::MapObjectRecord::new_player(canonical_player).unwrap(), + ) + .unwrap(); + map.map_mut() + .insert_map_object_record( + wow_entities::MapObjectRecord::new_creature(canonical_creature).unwrap(), + ) + .unwrap(); + } + owner_session.set_canonical_map_manager(canonical_manager); + let loot_object = owner_session + .next_represented_loot_object_guid_like_cpp(owner_guid) + .expect("the canonical owner map must allocate the C++ LootObject identity"); + register_test_creature_like_cpp(&mut owner_session, test_creature(owner_guid, false)); - owner_session.loot_table.insert( - owner_guid, - CreatureLoot { - loot_guid: loot_object, - coins: 0, - unlooted_count: 0, - loot_type: LOOT_TYPE_CORPSE_LIKE_CPP, - dungeon_encounter_id: 0, - loot_method: LOOT_METHOD_GROUP_LIKE_CPP, - loot_master: ObjectGuid::EMPTY, - round_robin_player: ObjectGuid::EMPTY, - player_ffa_items: Vec::new(), - players_looting: Vec::new(), - allowed_looters: vec![player_guid, candidate_guid], - items: vec![LootEntry { - loot_list_id: 0, - item_id: 25, - quantity: 1, - random_properties_id: 0, - random_properties_seed: 0, - item_context: 0, - flags: LootEntryFlags { - follow_loot_rules: true, - blocked: true, - ..Default::default() - }, - allowed_looters: vec![player_guid, candidate_guid], - roll_winner: ObjectGuid::EMPTY, - ffa_looted_by: Vec::new(), - taken: false, - }], - looted_by_player: false, - }, - ); + let mut loot = + generation_guarded_group_loot_like_cpp(owner_guid, player_guid, candidate_guid); + loot.loot_guid = loot_object; + owner_session.loot_table.insert(owner_guid, loot); + owner_session + .sync_represented_creature_loot_to_canonical_like_cpp(owner_guid, player_guid) + .expect("the fixture loot must be installed into the object-owned authority"); + let installed = owner_session + .represented_owned_loot_authority_like_cpp(owner_guid) + .and_then(|authority| authority.shared_snapshot_like_cpp()) + .expect("the canonical creature must expose the installed shared loot"); + assert_eq!(installed.loot.loot_guid, loot_object); owner_session .handle_loot_unit(loot_unit_packet(owner_guid)) @@ -13370,7 +22985,8 @@ mod tests { .get(&player_guid) .unwrap() .active_loot_rolls - .contains(&(loot_object, 0)) + .iter() + .any(|identity| identity.matches_key_like_cpp(loot_object, 0)) ); remote_session @@ -13574,12 +23190,177 @@ mod tests { let sent = send_rx.try_recv().unwrap(); let mut sent = WorldPacket::from_bytes(&sent); assert_eq!( - sent.read_uint16().unwrap(), - wow_constants::ServerOpcodes::AeLootTargetAck as u16 + sent.read_uint16().unwrap(), + wow_constants::ServerOpcodes::AeLootTargetAck as u16 + ); + assert!(session.is_active_loot_guid(main_guid)); + assert!(session.active_loot_view_owners.contains(&main_guid)); + assert!(session.active_loot_view_owners.contains(&secondary_guid)); + } + + async fn open_test_ae_pair_like_cpp( + session: &mut WorldSession, + player_guid: ObjectGuid, + primary_guid: ObjectGuid, + secondary_guid: ObjectGuid, + ) -> OwnedLootAuthority { + session.set_player_guid(Some(player_guid)); + session.set_enable_ae_loot_like_cpp(true); + session.set_player_position_like_cpp(Position::ZERO); + register_test_creature_like_cpp(session, test_creature(primary_guid, false)); + register_test_creature_like_cpp(session, test_creature(secondary_guid, false)); + insert_allowed_coin_loot_like_cpp(session, primary_guid, player_guid, 7); + insert_allowed_coin_loot_like_cpp(session, secondary_guid, player_guid, 7); + + session + .handle_loot_unit(loot_unit_packet(primary_guid)) + .await; + + assert!(session.is_active_loot_guid(primary_guid)); + assert!(session.active_loot_view_owners.contains(&secondary_guid)); + let authority = session + .represented_owned_loot_authority_like_cpp(secondary_guid) + .expect("the secondary AE owner must expose its object-owned authority"); + assert!( + authority + .snapshot_for_player_like_cpp(player_guid) + .unwrap() + .loot + .players_looting + .contains(&player_guid) + ); + authority + } + + #[tokio::test] + async fn disconnect_after_primary_ae_release_closes_secondary_view_like_cpp() { + let (mut session, send_rx) = make_session_with_send_capacity(32); + let player_guid = ObjectGuid::create_player(1, 61_711); + let primary_guid = test_creature_guid(61_712); + let secondary_guid = test_creature_guid(61_713); + let secondary_authority = + open_test_ae_pair_like_cpp(&mut session, player_guid, primary_guid, secondary_guid) + .await; + + session + .handle_loot_release(loot_release_packet(primary_guid)) + .await; + assert!(session.active_loot_guid.is_empty()); + assert!(session.active_loot_view_owners.contains(&secondary_guid)); + + session + .cleanup_shared_runtime_state_on_disconnect_like_cpp() + .await; + + assert!(session.active_loot_view_owners.is_empty()); + assert!( + !secondary_authority + .snapshot_for_player_like_cpp(player_guid) + .unwrap() + .loot + .players_looting + .contains(&player_guid), + "logout must remove the secondary AE viewer even after the primary was released" + ); + assert_eq!( + drain_server_opcodes_like_cpp(&send_rx) + .into_iter() + .filter(|opcode| *opcode == wow_constants::ServerOpcodes::LootRelease as u16) + .count(), + 2 + ); + } + + #[tokio::test] + async fn new_loot_after_primary_ae_release_closes_secondary_before_replacing_tracking_like_cpp() + { + let (mut session, send_rx) = make_session_with_send_capacity(32); + let player_guid = ObjectGuid::create_player(1, 61_714); + let primary_guid = test_creature_guid(61_715); + let secondary_guid = test_creature_guid(61_716); + let new_guid = test_creature_guid(61_717); + let secondary_authority = + open_test_ae_pair_like_cpp(&mut session, player_guid, primary_guid, secondary_guid) + .await; + + session + .handle_loot_release(loot_release_packet(primary_guid)) + .await; + assert!(session.active_loot_guid.is_empty()); + assert!(session.active_loot_view_owners.contains(&secondary_guid)); + + session.set_enable_ae_loot_like_cpp(false); + register_test_creature_like_cpp(&mut session, test_creature(new_guid, false)); + insert_allowed_coin_loot_like_cpp(&mut session, new_guid, player_guid, 7); + session.handle_loot_unit(loot_unit_packet(new_guid)).await; + + assert!(session.is_active_loot_guid(new_guid)); + assert_eq!(session.active_loot_view_owners.len(), 1); + assert!(!session.active_loot_view_owners.contains(&secondary_guid)); + assert!( + !secondary_authority + .snapshot_for_player_like_cpp(player_guid) + .unwrap() + .loot + .players_looting + .contains(&player_guid), + "the secondary AE viewer must be released before set_active_loot_guid clears tracking" + ); + assert_eq!( + drain_server_opcodes_like_cpp(&send_rx) + .into_iter() + .filter(|opcode| *opcode == wow_constants::ServerOpcodes::LootRelease as u16) + .count(), + 2 + ); + } + + #[tokio::test] + async fn item_loot_releases_ae_view_and_tracks_multiple_items_like_cpp() { + let (mut session, send_rx) = make_session_with_send_capacity(32); + let player_guid = ObjectGuid::create_player(1, 61_718); + let primary_guid = test_creature_guid(61_719); + let secondary_guid = test_creature_guid(61_720); + let first_item = ObjectGuid::create_item(1, 61_721); + let second_item = ObjectGuid::create_item(1, 61_722); + let secondary_authority = + open_test_ae_pair_like_cpp(&mut session, player_guid, primary_guid, secondary_guid) + .await; + + session + .handle_loot_release(loot_release_packet(primary_guid)) + .await; + assert!(session.active_loot_guid.is_empty()); + assert!(session.active_loot_view_owners.contains(&secondary_guid)); + + session + .open_active_item_loot_view_like_cpp(player_guid, first_item) + .await; + session + .open_active_item_loot_view_like_cpp(player_guid, second_item) + .await; + + assert!(session.is_active_loot_guid(first_item)); + assert_eq!(session.active_loot_view_owners.len(), 2); + assert!(session.active_loot_view_owners.contains(&first_item)); + assert!(session.active_loot_view_owners.contains(&second_item)); + assert!(!session.active_loot_view_owners.contains(&secondary_guid)); + assert!( + !secondary_authority + .snapshot_for_player_like_cpp(player_guid) + .unwrap() + .loot + .players_looting + .contains(&player_guid), + "item loot must release the surviving secondary AE viewer first" + ); + assert_eq!( + drain_server_opcodes_like_cpp(&send_rx) + .into_iter() + .filter(|opcode| *opcode == wow_constants::ServerOpcodes::LootRelease as u16) + .count(), + 2 ); - assert!(session.is_active_loot_guid(main_guid)); - assert!(session.active_loot_view_owners.contains(&main_guid)); - assert!(session.active_loot_view_owners.contains(&secondary_guid)); } #[tokio::test] @@ -13608,6 +23389,7 @@ mod tests { }, ); + install_cached_test_creature_loot_authority_like_cpp(&mut session, loot_guid, player_guid); session.handle_loot_unit(loot_unit_packet(loot_guid)).await; assert!(send_rx.try_recv().is_err()); @@ -13697,6 +23479,7 @@ mod tests { }, ); + install_cached_test_creature_loot_authority_like_cpp(&mut session, loot_guid, player_guid); session.handle_loot_unit(loot_unit_packet(loot_guid)).await; assert!(send_rx.try_recv().is_err()); @@ -13933,6 +23716,7 @@ mod tests { #[tokio::test] async fn loot_money_consumes_all_active_loot_views_like_cpp() { let (mut session, send_rx) = make_session_with_send_capacity(4); + session.set_loot_money_persistence_test_result_like_cpp(true); let player_guid = ObjectGuid::create_player(1, 42); let owner_one = test_creature_guid(19_025); let owner_two = test_creature_guid(19_026); @@ -14021,6 +23805,7 @@ mod tests { #[tokio::test] async fn loot_money_gain_completes_money_tracking_event_objective_like_cpp() { let (mut session, send_rx) = make_session_with_send_capacity(5); + session.set_loot_money_persistence_test_result_like_cpp(true); let player_guid = ObjectGuid::create_player(1, 42); let loot_guid = test_creature_guid(19_029); let quest_id = 12_530; @@ -14099,6 +23884,7 @@ mod tests { #[tokio::test] async fn loot_money_splits_corpse_gold_to_near_group_members_like_cpp() { let (mut session, send_rx) = make_session_with_send_capacity(2); + session.set_loot_money_persistence_test_result_like_cpp(true); let player_guid = ObjectGuid::create_player(1, 42); let other_guid = ObjectGuid::create_player(1, 43); let loot_guid = test_creature_guid(19_027); @@ -15973,6 +25759,12 @@ mod tests { assert_eq!(sent.read_packed_guid().unwrap(), loot_guid); assert_eq!(sent.read_packed_guid().unwrap(), player_guid); assert!(!session.is_active_loot_guid(loot_guid)); + assert!( + !session.loot_table.contains_key(&loot_guid), + "the closed session view is a discardable cache; the creature authority keeps loot" + ); + assert!(session.reconcile_represented_loot_cache_like_cpp(loot_guid, player_guid)); + assert_eq!(session.loot_table[&loot_guid].coins, 7); assert!(session.loot_table.contains_key(&loot_guid)); assert_eq!( session.loot_table.get(&loot_guid).unwrap().players_looting, @@ -16027,7 +25819,7 @@ mod tests { assert!(send_rx.try_recv().is_ok()); assert!(!session.is_active_loot_guid(loot_guid)); - assert!(session.loot_table.contains_key(&loot_guid)); + assert!(!session.loot_table.contains_key(&loot_guid)); let canonical = canonical_creature_snapshot(&session, loot_guid).unwrap(); assert_eq!( canonical.shared_loot_like_cpp(), @@ -16039,6 +25831,9 @@ mod tests { Some(&CreatureOwnedLoot::new(7, 0)) ); assert!(!canonical.is_fully_looted_like_cpp()); + assert!(session.reconcile_represented_loot_cache_like_cpp(loot_guid, player_guid)); + assert_eq!(session.loot_table[&loot_guid].coins, 7); + assert!(session.loot_table.contains_key(&loot_guid)); assert_eq!( session .mutate_world_creature(loot_guid, |creature| creature.corpse_despawn_at()) @@ -16048,6 +25843,93 @@ mod tests { ); } + #[tokio::test] + async fn authoritative_partial_release_clears_round_robin_for_all_sessions_and_forces_dynflags_like_cpp() + { + let first_guid = ObjectGuid::create_player(1, 61_890); + let second_guid = ObjectGuid::create_player(1, 61_891); + let mut loot = authoritative_test_loot_like_cpp(7, false); + loot.round_robin_player = first_guid; + loot.allowed_looters = vec![first_guid, second_guid]; + let (mut first, _first_rx, mut second, _second_rx, owner_guid, _, _) = + two_sessions_with_authoritative_creature_loot_like_cpp(loot); + // The helper uses its own deterministic player ids; install the exact + // current round-robin holder from the opened first session. + let opened_first = first.player_guid().unwrap(); + let opened_second = second.player_guid().unwrap(); + let authority = first + .represented_owned_loot_authority_like_cpp(owner_guid) + .unwrap(); + let generation = authority + .snapshot_for_player_like_cpp(opened_first) + .unwrap() + .generation; + let mut replacement = authority + .snapshot_for_player_like_cpp(opened_first) + .unwrap() + .loot; + replacement.round_robin_player = opened_first; + authority.replace_like_cpp(Some(replacement), HashMap::new()); + let replacement_generation = authority + .snapshot_for_player_like_cpp(opened_first) + .unwrap() + .generation; + assert_ne!(generation, replacement_generation); + authority.add_viewer_like_cpp(opened_first).unwrap(); + first + .active_loot_view_generations_like_cpp + .insert(owner_guid, replacement_generation); + first + .active_loot_view_authorities_like_cpp + .insert(owner_guid, authority.clone()); + assert!(first.reconcile_represented_loot_cache_like_cpp(owner_guid, opened_first)); + let _ = first.mutate_world_creature(owner_guid, |creature| { + creature + .creature + .unit_mut() + .world_mut() + .object_mut() + .clear_update_mask(false); + }); + + assert!( + first + .do_loot_release_owner_like_cpp(owner_guid, opened_first) + .await + ); + + assert!( + authority + .snapshot_for_player_like_cpp(opened_second) + .unwrap() + .loot + .round_robin_player + .is_empty() + ); + assert!(second.reconcile_represented_loot_cache_like_cpp(owner_guid, opened_second)); + assert!( + second + .loot_table + .get(&owner_guid) + .unwrap() + .round_robin_player + .is_empty() + ); + assert!( + first + .mutate_world_creature(owner_guid, |creature| { + creature + .creature + .unit() + .world() + .object() + .changed_fields() + .contains(ObjectChangedFields::DYNAMIC_FLAGS) + }) + .unwrap() + ); + } + #[tokio::test] async fn creature_owned_loot_release_fully_consumed_uses_canonical_is_fully_looted_like_cpp() { let (mut session, send_rx) = make_session_with_send(); @@ -16114,6 +25996,111 @@ mod tests { ); } + #[tokio::test] + async fn personal_creature_release_starts_decay_only_after_every_pool_is_looted_like_cpp() { + let (mut session, send_rx) = make_session_with_send_capacity(16); + let first_player = ObjectGuid::create_player(1, 53); + let second_player = ObjectGuid::create_player(1, 54); + let owner_guid = test_creature_guid(19_119); + let mut creature = make_canonical_creature_for_session(&session, owner_guid); + + let mut first_pool = authoritative_test_loot_like_cpp(0, false); + first_pool.loot_guid = represented_loot_object_guid_like_cpp(owner_guid); + first_pool.allowed_looters = vec![first_player]; + let mut second_pool = authoritative_test_loot_like_cpp(0, true); + second_pool.loot_guid = ObjectGuid::create_world_object( + HighGuid::LootObject, + 0, + owner_guid.realm_id(), + owner_guid.map_id(), + 0, + 0, + owner_guid.counter() + 1, + ); + second_pool.allowed_looters = vec![second_player]; + second_pool.items[0].allowed_looters = vec![second_player]; + assert!( + creature + .initialize_loot_authority_like_cpp( + None, + HashMap::from([(first_player, first_pool), (second_player, second_pool),]), + ) + .installed() + ); + let authority = creature.loot_authority_like_cpp().clone(); + attach_canonical_creature(&mut session, creature); + register_test_creature_like_cpp(&mut session, test_creature(owner_guid, false)); + session.set_player_position_like_cpp(Position::ZERO); + session.set_loot_drop_rates_like_cpp(LootDropRatesLikeCpp { + corpse_decay_looted: 0.5, + ..LootDropRatesLikeCpp::default() + }); + let corpse_deadline_before = session + .mutate_world_creature(owner_guid, |creature| { + creature.creature.set_corpse_delay(120, false); + let deadline = Instant::now() + Duration::from_secs(120); + creature.set_corpse_despawn_at(Some(deadline)); + creature.corpse_despawn_at() + }) + .flatten() + .expect("dead creature should already own its normal corpse deadline"); + + session.set_player_guid(Some(first_player)); + assert!(session.reconcile_represented_loot_cache_like_cpp(owner_guid, first_player)); + session.set_active_loot_guid(owner_guid); + let response = authoritative_test_loot_response_like_cpp( + owner_guid, + &session.loot_table[&owner_guid], + first_player, + ); + session.represented_on_loot_opened_like_cpp(owner_guid, first_player, response); + let _ = drain_server_opcodes_like_cpp(&send_rx); + + assert!( + session + .do_loot_release_owner_like_cpp(owner_guid, first_player) + .await + ); + assert_eq!( + session + .mutate_world_creature(owner_guid, |creature| creature.corpse_despawn_at()) + .flatten(), + Some(corpse_deadline_before), + "one empty personal pool must not start global corpse decay while a peer has loot" + ); + assert!(!authority.is_fully_looted_like_cpp()); + + session.set_player_guid(Some(second_player)); + assert!(session.reconcile_represented_loot_cache_like_cpp(owner_guid, second_player)); + session.set_active_loot_guid(owner_guid); + let response = authoritative_test_loot_response_like_cpp( + owner_guid, + &session.loot_table[&owner_guid], + second_player, + ); + session.represented_on_loot_opened_like_cpp(owner_guid, second_player, response); + let claim = authority + .reserve_item_like_cpp(second_player, 0) + .await + .unwrap(); + assert_eq!(claim.commit_like_cpp(), Ok(true)); + let _ = drain_server_opcodes_like_cpp(&send_rx); + + assert!( + session + .do_loot_release_owner_like_cpp(owner_guid, second_player) + .await + ); + assert!(authority.is_fully_looted_like_cpp()); + let corpse_deadline_after = session + .mutate_world_creature(owner_guid, |creature| creature.corpse_despawn_at()) + .flatten() + .expect("last personal pool should start looted-corpse decay"); + assert!(corpse_deadline_after < corpse_deadline_before); + let remaining = corpse_deadline_after.saturating_duration_since(Instant::now()); + assert!((55..=60).contains(&remaining.as_secs())); + } + #[tokio::test] async fn creature_owned_loot_release_does_not_extend_expired_corpse_like_cpp() { let (mut session, send_rx) = make_session_with_send(); @@ -16597,7 +26584,177 @@ mod tests { let loot_guid = test_gameobject_guid(19_132); let mut game_object = make_canonical_gameobject_for_session(&session, loot_guid, GAMEOBJECT_TYPE_CHEST as u8); - game_object.set_personal_loot_like_cpp(player_guid, GameObjectOwnedLoot::new(0, 1)); + game_object.set_personal_loot_like_cpp(player_guid, GameObjectOwnedLoot::new(0, 1)); + attach_canonical_gameobject(&mut session, game_object); + session.set_player_guid(Some(player_guid)); + session.set_player_position_like_cpp(Position::ZERO); + session.set_active_loot_guid(loot_guid); + session.record_represented_gameobject_runtime_state_like_cpp( + 0, + loot_guid, + loot_guid.entry(), + Position::ZERO, + GAMEOBJECT_TYPE_CHEST as u8, + ); + session.record_represented_gameobject_chest_release_metadata_like_cpp( + loot_guid, + GameObjectLootSource { + chest_consumable: false, + chest_restock_time_secs: 7, + ..Default::default() + }, + ); + session.loot_table.insert( + loot_guid, + CreatureLoot { + loot_guid: represented_loot_object_guid_like_cpp(loot_guid), + coins: 0, + unlooted_count: 1, + loot_type: LOOT_TYPE_CHEST_LIKE_CPP, + dungeon_encounter_id: 0, + loot_method: 0, + loot_master: ObjectGuid::EMPTY, + round_robin_player: ObjectGuid::EMPTY, + player_ffa_items: Vec::new(), + players_looting: vec![player_guid], + allowed_looters: vec![player_guid], + items: vec![represented_loot_entry(0, 25, player_guid)], + looted_by_player: false, + }, + ); + + session + .handle_loot_release(loot_release_packet(loot_guid)) + .await; + + assert!(send_rx.try_recv().is_ok()); + let canonical = canonical_gameobject_snapshot(&session, loot_guid).unwrap(); + assert_eq!( + canonical.shared_loot_like_cpp(), + Some(&GameObjectOwnedLoot::new(0, 1)) + ); + assert_eq!(canonical.personal_loot_count_like_cpp(), 0); + assert_eq!( + canonical.loot_for_player_like_cpp(player_guid), + Some(&GameObjectOwnedLoot::new(0, 1)) + ); + assert!(!canonical.is_fully_looted_like_cpp()); + assert_eq!(canonical.loot_state(), LootState::Activated); + assert_eq!(canonical.loot_state_unit_guid(), player_guid); + assert!(canonical.restock_time() > 0); + assert!(!session.loot_table.contains_key(&loot_guid)); + assert!(session.reconcile_represented_loot_cache_like_cpp(loot_guid, player_guid)); + assert!(session.loot_table.contains_key(&loot_guid)); + assert_eq!( + session + .represented_gameobject_use_states + .get(&loot_guid) + .unwrap() + .loot_state, + Some(LootState::Activated) + ); + } + + #[tokio::test] + async fn loot_release_partial_chest_syncs_state_to_same_map_viewers_like_cpp() { + let (mut session, send_rx) = make_session_with_send(); + let player_guid = ObjectGuid::create_player(1, 42); + let same_map_guid = ObjectGuid::create_player(1, 77); + let loot_guid = test_gameobject_guid(19_138); + let (same_command_tx, same_command_rx) = flume::bounded(2); + let (same_send_tx, _same_send_rx) = flume::bounded::>(1); + let player_registry = Arc::new(PlayerRegistry::default()); + let mut same_info = broadcast_info(same_map_guid, same_send_tx); + same_info.map_id = 571; + same_info.command_tx = same_command_tx; + player_registry.insert(same_map_guid, same_info); + + session.set_player_registry(player_registry); + session.set_player_guid(Some(player_guid)); + session.set_player_position_like_cpp(Position::ZERO); + session.set_player_map_position_like_cpp(571, Position::ZERO); + session.set_active_loot_guid(loot_guid); + session.record_represented_gameobject_runtime_state_like_cpp( + 0, + loot_guid, + loot_guid.entry(), + Position::ZERO, + GAMEOBJECT_TYPE_CHEST as u8, + ); + session.record_represented_gameobject_chest_release_metadata_like_cpp( + loot_guid, + GameObjectLootSource { + loot_id: 7_001, + chest_restock_time_secs: 45, + chest_consumable: false, + ..Default::default() + }, + ); + session.loot_table.insert( + loot_guid, + CreatureLoot { + loot_guid: represented_loot_object_guid_like_cpp(loot_guid), + coins: 0, + unlooted_count: 1, + loot_type: LOOT_TYPE_CHEST_LIKE_CPP, + dungeon_encounter_id: 0, + loot_method: 0, + loot_master: ObjectGuid::EMPTY, + round_robin_player: ObjectGuid::EMPTY, + player_ffa_items: Vec::new(), + players_looting: vec![player_guid], + allowed_looters: Vec::new(), + items: vec![LootEntry { + loot_list_id: 0, + item_id: 25, + quantity: 1, + random_properties_id: 0, + random_properties_seed: 0, + item_context: 0, + flags: LootEntryFlags::default(), + allowed_looters: Vec::new(), + roll_winner: ObjectGuid::EMPTY, + ffa_looted_by: Vec::new(), + taken: false, + }], + looted_by_player: false, + }, + ); + + session + .handle_loot_release(loot_release_packet(loot_guid)) + .await; + + let release_bytes = send_rx.try_recv().unwrap(); + let mut release = WorldPacket::from_bytes(&release_bytes); + assert_eq!( + release.read_uint16().unwrap(), + wow_constants::ServerOpcodes::LootRelease as u16 + ); + let command = match same_command_rx.try_recv() { + Ok(SessionCommand::SyncChestGameobjectStateAndRefreshLikeCpp(command)) => command, + other => panic!("expected chest release sync command, got {other:?}"), + }; + assert_eq!(command.gameobject_guid, loot_guid); + assert_eq!(command.map_id, 571); + assert_eq!( + command.loot_state, + Some(wow_entities::LootState::Activated as u8) + ); + assert_eq!(command.loot_state_unit_guid, player_guid); + assert_eq!(command.chest_loot_id, 7_001); + assert_eq!(command.chest_restock_time_secs, 45); + } + + #[tokio::test] + async fn gameobject_owned_loot_release_fully_consumed_chest_uses_canonical_is_fully_looted_like_cpp() + { + let (mut session, send_rx) = make_session_with_send(); + let player_guid = ObjectGuid::create_player(1, 42); + let loot_guid = test_gameobject_guid(19_133); + let mut game_object = + make_canonical_gameobject_for_session(&session, loot_guid, GAMEOBJECT_TYPE_CHEST as u8); + game_object.set_shared_loot_like_cpp(GameObjectOwnedLoot::new(0, 1)); attach_canonical_gameobject(&mut session, game_object); session.set_player_guid(Some(player_guid)); session.set_player_position_like_cpp(Position::ZERO); @@ -16622,7 +26779,7 @@ mod tests { CreatureLoot { loot_guid: represented_loot_object_guid_like_cpp(loot_guid), coins: 0, - unlooted_count: 1, + unlooted_count: 0, loot_type: LOOT_TYPE_CHEST_LIKE_CPP, dungeon_encounter_id: 0, loot_method: 0, @@ -16631,7 +26788,7 @@ mod tests { player_ffa_items: Vec::new(), players_looting: vec![player_guid], allowed_looters: vec![player_guid], - items: vec![represented_loot_entry(0, 25, player_guid)], + items: Vec::new(), looted_by_player: false, }, ); @@ -16644,46 +26801,73 @@ mod tests { let canonical = canonical_gameobject_snapshot(&session, loot_guid).unwrap(); assert_eq!( canonical.shared_loot_like_cpp(), - Some(&GameObjectOwnedLoot::new(0, 1)) - ); - assert_eq!(canonical.personal_loot_count_like_cpp(), 0); - assert_eq!( - canonical.loot_for_player_like_cpp(player_guid), - Some(&GameObjectOwnedLoot::new(0, 1)) + Some(&GameObjectOwnedLoot::default()) ); - assert!(!canonical.is_fully_looted_like_cpp()); - assert_eq!(canonical.loot_state(), LootState::Activated); - assert_eq!(canonical.loot_state_unit_guid(), player_guid); - assert!(canonical.restock_time() > 0); - assert!(session.loot_table.contains_key(&loot_guid)); + assert!(canonical.is_fully_looted_like_cpp()); + assert_eq!(canonical.loot_state(), LootState::JustDeactivated); + assert_eq!(canonical.loot_state_unit_guid(), ObjectGuid::EMPTY); + assert_eq!(canonical.restock_time(), 0); + assert!(!session.loot_table.contains_key(&loot_guid)); assert_eq!( session .represented_gameobject_use_states .get(&loot_guid) .unwrap() .loot_state, - Some(LootState::Activated) + Some(LootState::JustDeactivated) ); } - #[tokio::test] - async fn loot_release_partial_chest_syncs_state_to_same_map_viewers_like_cpp() { - let (mut session, send_rx) = make_session_with_send(); + #[test] + fn gameobject_loot_release_without_canonical_manager_keeps_represented_restock_fallback_like_cpp() + { + let mut session = make_session(); let player_guid = ObjectGuid::create_player(1, 42); - let same_map_guid = ObjectGuid::create_player(1, 77); - let loot_guid = test_gameobject_guid(19_138); - let (same_command_tx, same_command_rx) = flume::bounded(2); - let (same_send_tx, _same_send_rx) = flume::bounded::>(1); - let player_registry = Arc::new(PlayerRegistry::default()); - let mut same_info = broadcast_info(same_map_guid, same_send_tx); - same_info.map_id = 571; - same_info.command_tx = same_command_tx; - player_registry.insert(same_map_guid, same_info); + let loot_guid = test_gameobject_guid(19_135); + session.record_represented_gameobject_runtime_state_like_cpp( + 0, + loot_guid, + loot_guid.entry(), + Position::ZERO, + GAMEOBJECT_TYPE_CHEST as u8, + ); + session.record_represented_gameobject_chest_release_metadata_like_cpp( + loot_guid, + GameObjectLootSource { + chest_consumable: false, + chest_restock_time_secs: 7, + ..Default::default() + }, + ); - session.set_player_registry(player_registry); + session.apply_represented_gameobject_loot_release_like_cpp( + loot_guid, + player_guid, + true, + true, + None, + ); + + let state = session + .represented_gameobject_use_states + .get(&loot_guid) + .unwrap(); + assert_eq!(state.loot_state, Some(LootState::NotReady)); + assert_eq!(state.loot_state_unit_guid, ObjectGuid::EMPTY); + assert!(state.chest_restock_until.is_some()); + } + + #[tokio::test] + async fn gameobject_owned_loot_release_personal_chest_syncs_current_player_and_despawns_like_cpp() + { + let (mut session, send_rx) = make_session_with_send_capacity(2); + let player_guid = ObjectGuid::create_player(1, 42); + let loot_guid = test_gameobject_guid(19_134); + let game_object = + make_canonical_gameobject_for_session(&session, loot_guid, GAMEOBJECT_TYPE_CHEST as u8); + attach_canonical_gameobject(&mut session, game_object); session.set_player_guid(Some(player_guid)); session.set_player_position_like_cpp(Position::ZERO); - session.set_player_map_position_like_cpp(571, Position::ZERO); session.set_active_loot_guid(loot_guid); session.record_represented_gameobject_runtime_state_like_cpp( 0, @@ -16695,38 +26879,33 @@ mod tests { session.record_represented_gameobject_chest_release_metadata_like_cpp( loot_guid, GameObjectLootSource { - loot_id: 7_001, - chest_restock_time_secs: 45, + personal_loot_id: 55, chest_consumable: false, + chest_restock_time_secs: 7, ..Default::default() }, ); + session.represented_personal_loot_owners.insert(loot_guid); + session + .represented_personal_loot_money + .insert((loot_guid, player_guid), 0); session.loot_table.insert( loot_guid, CreatureLoot { loot_guid: represented_loot_object_guid_like_cpp(loot_guid), coins: 0, - unlooted_count: 1, + unlooted_count: 0, loot_type: LOOT_TYPE_CHEST_LIKE_CPP, - dungeon_encounter_id: 0, + dungeon_encounter_id: 1, loot_method: 0, loot_master: ObjectGuid::EMPTY, round_robin_player: ObjectGuid::EMPTY, player_ffa_items: Vec::new(), players_looting: vec![player_guid], - allowed_looters: Vec::new(), + allowed_looters: vec![player_guid], items: vec![LootEntry { - loot_list_id: 0, - item_id: 25, - quantity: 1, - random_properties_id: 0, - random_properties_seed: 0, - item_context: 0, - flags: LootEntryFlags::default(), - allowed_looters: Vec::new(), - roll_winner: ObjectGuid::EMPTY, - ffa_looted_by: Vec::new(), - taken: false, + taken: true, + ..represented_loot_entry(0, 25, player_guid) }], looted_by_player: false, }, @@ -16736,204 +26915,602 @@ mod tests { .handle_loot_release(loot_release_packet(loot_guid)) .await; - let release_bytes = send_rx.try_recv().unwrap(); - let mut release = WorldPacket::from_bytes(&release_bytes); + assert!(send_rx.try_recv().is_ok()); + let canonical = canonical_gameobject_snapshot(&session, loot_guid).unwrap(); + assert_eq!(canonical.shared_loot_like_cpp(), None); assert_eq!( - release.read_uint16().unwrap(), - wow_constants::ServerOpcodes::LootRelease as u16 + canonical.personal_loot_like_cpp(player_guid), + Some(&GameObjectOwnedLoot::default()) ); - let command = match same_command_rx.try_recv() { - Ok(SessionCommand::SyncChestGameobjectStateAndRefreshLikeCpp(command)) => command, - other => panic!("expected chest release sync command, got {other:?}"), + let state = session + .represented_gameobject_use_states + .get(&loot_guid) + .unwrap(); + assert_eq!(state.per_player_state_player_guid, Some(player_guid)); + assert_eq!(state.per_player_despawn_secs, Some(7)); + assert!(state.per_player_despawn_until.is_some()); + } + + #[tokio::test] + async fn authoritative_partial_gameobject_release_drops_cache_and_reopen_rehydrates_like_cpp() { + let (mut session, send_rx) = make_session_with_send_capacity(8); + let player_guid = ObjectGuid::create_player(1, 451); + let owner_guid = test_gameobject_guid(19_451); + let mut gameobject = make_canonical_gameobject_for_session( + &session, + owner_guid, + GAMEOBJECT_TYPE_CHEST as u8, + ); + gameobject.set_loot_state(LootState::Activated, Some(player_guid)); + let mut pool = authoritative_test_loot_like_cpp(11, true); + pool.loot_guid = represented_loot_object_guid_like_cpp(owner_guid); + pool.loot_type = LOOT_TYPE_CHEST_LIKE_CPP; + pool.allowed_looters = vec![player_guid]; + pool.items[0].allowed_looters = vec![player_guid]; + assert!( + gameobject + .initialize_loot_authority_like_cpp(None, HashMap::from([(player_guid, pool)]),) + .installed() + ); + let authority = gameobject.loot_authority_like_cpp().clone(); + attach_canonical_gameobject(&mut session, gameobject); + session.set_player_guid(Some(player_guid)); + session.set_player_position_like_cpp(Position::ZERO); + session.record_represented_gameobject_runtime_state_like_cpp( + 0, + owner_guid, + owner_guid.entry(), + Position::ZERO, + GAMEOBJECT_TYPE_CHEST as u8, + ); + let source = GameObjectLootSource { + personal_loot_id: 55, + chest_consumable: false, + chest_restock_time_secs: 7, + ..Default::default() }; - assert_eq!(command.gameobject_guid, loot_guid); - assert_eq!(command.map_id, 571); + session.record_represented_gameobject_chest_release_metadata_like_cpp(owner_guid, source); + assert!(session.reconcile_represented_loot_cache_like_cpp(owner_guid, player_guid)); + session.set_active_loot_guid(owner_guid); + let response = authoritative_test_loot_response_like_cpp( + owner_guid, + &session.loot_table[&owner_guid], + player_guid, + ); + session.represented_on_loot_opened_like_cpp(owner_guid, player_guid, response); + let _ = drain_server_opcodes_like_cpp(&send_rx); + + assert!( + session + .do_loot_release_owner_like_cpp(owner_guid, player_guid) + .await + ); + assert!(!session.loot_table.contains_key(&owner_guid)); + assert!( + !session + .represented_loot_cache_generations_like_cpp + .contains_key(&owner_guid) + ); + assert!( + !session + .represented_personal_loot_money + .contains_key(&(owner_guid, player_guid)) + ); + let before_reopen = authority + .snapshot_for_player_like_cpp(player_guid) + .expect("release preserves the canonical personal pool"); + assert_eq!(before_reopen.loot.coins, 11); + assert!(!before_reopen.loot.items[0].taken); + + session + .open_represented_gameobject_chest_like_cpp(owner_guid, source) + .await; + assert!(session.loot_table.contains_key(&owner_guid)); + assert!( + session + .represented_personal_loot_owners + .contains(&owner_guid) + ); assert_eq!( - command.loot_state, - Some(wow_entities::LootState::Activated as u8) + session + .represented_personal_loot_money + .get(&(owner_guid, player_guid)), + Some(&11) + ); + assert!(session.is_active_loot_guid(owner_guid)); + + let slot = before_reopen.loot.items[0].loot_list_id; + authority + .reserve_item_like_cpp(player_guid, slot) + .await + .unwrap() + .commit_like_cpp() + .unwrap(); + assert!( + authority + .reserve_item_like_cpp(player_guid, slot) + .await + .is_err(), + "rehydration must not manufacture a second claim" ); - assert_eq!(command.loot_state_unit_guid, player_guid); - assert_eq!(command.chest_loot_id, 7_001); - assert_eq!(command.chest_restock_time_secs, 45); } #[tokio::test] - async fn gameobject_owned_loot_release_fully_consumed_chest_uses_canonical_is_fully_looted_like_cpp() + async fn authoritative_partial_personal_creature_release_drops_cache_and_reopen_rehydrates_like_cpp() { - let (mut session, send_rx) = make_session_with_send(); - let player_guid = ObjectGuid::create_player(1, 42); - let loot_guid = test_gameobject_guid(19_133); - let mut game_object = - make_canonical_gameobject_for_session(&session, loot_guid, GAMEOBJECT_TYPE_CHEST as u8); - game_object.set_shared_loot_like_cpp(GameObjectOwnedLoot::new(0, 1)); - attach_canonical_gameobject(&mut session, game_object); - session.set_player_guid(Some(player_guid)); + let mut fixture = overworld_personal_loot_test_fixture_like_cpp(); + fixture + .session + .ensure_represented_creature_kill_loot_like_cpp(fixture.owner_guid) + .await; + let authority = fixture + .session + .represented_owned_loot_authority_like_cpp(fixture.owner_guid) + .unwrap(); + let before_release = authority + .snapshot_for_player_like_cpp(fixture.first_tapper) + .unwrap(); + let slot = before_release + .loot + .items + .iter() + .find(|item| item.item_id == fixture.normal_item_id) + .unwrap() + .loot_list_id; + assert!( + fixture.session.reconcile_represented_loot_cache_like_cpp( + fixture.owner_guid, + fixture.first_tapper, + ) + ); + let opened = authority + .add_viewer_like_cpp(fixture.first_tapper) + .expect("the authoritative personal pool opens"); + fixture.session.set_active_loot_guid(fixture.owner_guid); + fixture + .session + .active_loot_view_generations_like_cpp + .insert(fixture.owner_guid, opened.generation); + fixture + .session + .active_loot_view_authorities_like_cpp + .insert(fixture.owner_guid, authority.clone()); + + assert!( + fixture + .session + .do_loot_release_owner_like_cpp(fixture.owner_guid, fixture.first_tapper) + .await + ); + assert!(!fixture.session.loot_table.contains_key(&fixture.owner_guid)); + assert!( + !fixture + .session + .represented_loot_cache_generations_like_cpp + .contains_key(&fixture.owner_guid) + ); + assert!( + !fixture + .session + .represented_personal_loot_money + .contains_key(&(fixture.owner_guid, fixture.first_tapper)) + ); + let after_release = authority + .snapshot_for_player_like_cpp(fixture.first_tapper) + .unwrap(); + assert_eq!(after_release.generation, before_release.generation); + assert_eq!(after_release.scope, before_release.scope); + assert_eq!(after_release.loot.coins, before_release.loot.coins); + assert_eq!(after_release.loot.items, before_release.loot.items); + assert!(after_release.loot.players_looting.is_empty()); + assert!( + after_release.loot.looted_by_player, + "C++ keeps the per-Loot was-opened state after closing the viewer" + ); + + let response = fixture + .session + .represented_loot_response_for_owner_like_cpp( + fixture.owner_guid, + fixture.first_tapper, + false, + ) + .await + .expect("the creature authority rehydrates a personal view"); + assert_eq!(response.coins, 7); + assert!( + fixture + .session + .represented_personal_loot_owners + .contains(&fixture.owner_guid) + ); + assert_eq!( + fixture + .session + .represented_personal_loot_money + .get(&(fixture.owner_guid, fixture.first_tapper)), + Some(&7) + ); + authority + .reserve_item_like_cpp(fixture.first_tapper, slot) + .await + .unwrap() + .commit_like_cpp() + .unwrap(); + assert!( + authority + .reserve_item_like_cpp(fixture.first_tapper, slot) + .await + .is_err() + ); + } + + #[tokio::test] + async fn personal_gameobject_release_deactivates_only_after_every_pool_is_looted_like_cpp() { + let (mut session, send_rx) = make_session_with_send_capacity(16); + let first_player = ObjectGuid::create_player(1, 51); + let second_player = ObjectGuid::create_player(1, 52); + let owner_guid = test_gameobject_guid(19_138); + let mut gameobject = make_canonical_gameobject_for_session( + &session, + owner_guid, + GAMEOBJECT_TYPE_CHEST as u8, + ); + gameobject.set_loot_state(LootState::Activated, Some(first_player)); + + let mut first_pool = authoritative_test_loot_like_cpp(0, false); + first_pool.loot_guid = represented_loot_object_guid_like_cpp(owner_guid); + first_pool.loot_type = LOOT_TYPE_CHEST_LIKE_CPP; + first_pool.allowed_looters = vec![first_player]; + let mut second_pool = authoritative_test_loot_like_cpp(0, true); + second_pool.loot_guid = ObjectGuid::create_world_object( + HighGuid::LootObject, + 0, + owner_guid.realm_id(), + owner_guid.map_id(), + 0, + 0, + owner_guid.counter() + 1, + ); + second_pool.loot_type = LOOT_TYPE_CHEST_LIKE_CPP; + second_pool.allowed_looters = vec![second_player]; + second_pool.items[0].allowed_looters = vec![second_player]; + assert!( + gameobject + .initialize_loot_authority_like_cpp( + None, + HashMap::from([(first_player, first_pool), (second_player, second_pool),]), + ) + .installed() + ); + let authority = gameobject.loot_authority_like_cpp().clone(); + attach_canonical_gameobject(&mut session, gameobject); session.set_player_position_like_cpp(Position::ZERO); - session.set_active_loot_guid(loot_guid); session.record_represented_gameobject_runtime_state_like_cpp( 0, - loot_guid, - loot_guid.entry(), + owner_guid, + owner_guid.entry(), Position::ZERO, GAMEOBJECT_TYPE_CHEST as u8, ); session.record_represented_gameobject_chest_release_metadata_like_cpp( - loot_guid, + owner_guid, GameObjectLootSource { + personal_loot_id: 55, chest_consumable: false, chest_restock_time_secs: 7, ..Default::default() }, ); - session.loot_table.insert( - loot_guid, - CreatureLoot { - loot_guid: represented_loot_object_guid_like_cpp(loot_guid), - coins: 0, - unlooted_count: 0, - loot_type: LOOT_TYPE_CHEST_LIKE_CPP, - dungeon_encounter_id: 0, - loot_method: 0, - loot_master: ObjectGuid::EMPTY, - round_robin_player: ObjectGuid::EMPTY, - player_ffa_items: Vec::new(), - players_looting: vec![player_guid], - allowed_looters: vec![player_guid], - items: Vec::new(), - looted_by_player: false, - }, - ); - session - .handle_loot_release(loot_release_packet(loot_guid)) - .await; + session.set_player_guid(Some(first_player)); + assert!(session.reconcile_represented_loot_cache_like_cpp(owner_guid, first_player)); + session.set_active_loot_guid(owner_guid); + let response = authoritative_test_loot_response_like_cpp( + owner_guid, + &session.loot_table[&owner_guid], + first_player, + ); + session.represented_on_loot_opened_like_cpp(owner_guid, first_player, response); + let _ = drain_server_opcodes_like_cpp(&send_rx); - assert!(send_rx.try_recv().is_ok()); - let canonical = canonical_gameobject_snapshot(&session, loot_guid).unwrap(); + assert!( + session + .do_loot_release_owner_like_cpp(owner_guid, first_player) + .await + ); assert_eq!( - canonical.shared_loot_like_cpp(), - Some(&GameObjectOwnedLoot::default()) + canonical_gameobject_snapshot(&session, owner_guid) + .unwrap() + .loot_state(), + LootState::Activated, + "one empty personal pool must not globally deactivate a chest while a peer has loot" ); - assert!(canonical.is_fully_looted_like_cpp()); - assert_eq!(canonical.loot_state(), LootState::JustDeactivated); - assert_eq!(canonical.loot_state_unit_guid(), ObjectGuid::EMPTY); - assert_eq!(canonical.restock_time(), 0); - assert!(!session.loot_table.contains_key(&loot_guid)); + assert!(!authority.is_retired_like_cpp()); + assert!(!authority.is_fully_looted_like_cpp()); assert_eq!( session .represented_gameobject_use_states - .get(&loot_guid) + .get(&owner_guid) .unwrap() - .loot_state, - Some(LootState::JustDeactivated) + .per_player_state_player_guid, + Some(first_player), + "C++ still runs OnLootRelease for the selected empty personal pool" + ); + + session.set_player_guid(Some(second_player)); + assert!(session.reconcile_represented_loot_cache_like_cpp(owner_guid, second_player)); + session.set_active_loot_guid(owner_guid); + let response = authoritative_test_loot_response_like_cpp( + owner_guid, + &session.loot_table[&owner_guid], + second_player, + ); + session.represented_on_loot_opened_like_cpp(owner_guid, second_player, response); + let claim = authority + .reserve_item_like_cpp(second_player, 0) + .await + .unwrap(); + assert_eq!(claim.commit_like_cpp(), Ok(true)); + let _ = drain_server_opcodes_like_cpp(&send_rx); + + assert!( + session + .do_loot_release_owner_like_cpp(owner_guid, second_player) + .await + ); + assert_eq!( + canonical_gameobject_snapshot(&session, owner_guid) + .unwrap() + .loot_state(), + LootState::JustDeactivated, + "the last empty personal pool must globally deactivate the chest" + ); + assert!(authority.is_fully_looted_like_cpp()); + assert!(!authority.is_retired_like_cpp()); + + let manager = Arc::clone(session.canonical_map_manager.as_ref().unwrap()); + let mut manager = manager.lock().unwrap(); + manager + .find_map_mut(u32::from(session.player_map_id_like_cpp()), 0) + .unwrap() + .map_mut() + .update_game_object_like_cpp(owner_guid, 1, 0); + drop(manager); + assert!( + authority.is_retired_like_cpp(), + "the canonical JustDeactivated update must clear and retire the completed authority" ); } #[test] - fn gameobject_loot_release_without_canonical_manager_keeps_represented_restock_fallback_like_cpp() - { + fn personal_gameobject_upsert_before_release_invalidates_global_deactivation_like_cpp() { let mut session = make_session(); - let player_guid = ObjectGuid::create_player(1, 42); - let loot_guid = test_gameobject_guid(19_135); - session.record_represented_gameobject_runtime_state_like_cpp( - 0, - loot_guid, - loot_guid.entry(), - Position::ZERO, + let first = ObjectGuid::create_player(1, 61_860); + let late = ObjectGuid::create_player(1, 61_861); + let owner_guid = test_gameobject_guid(61_862); + let mut gameobject = make_canonical_gameobject_for_session( + &session, + owner_guid, GAMEOBJECT_TYPE_CHEST as u8, ); - session.record_represented_gameobject_chest_release_metadata_like_cpp( - loot_guid, - GameObjectLootSource { - chest_consumable: false, - chest_restock_time_secs: 7, - ..Default::default() - }, + gameobject.set_loot_state(LootState::Activated, Some(first)); + let mut first_pool = authoritative_test_loot_like_cpp(0, false); + first_pool.loot_guid = represented_loot_object_guid_like_cpp(owner_guid); + first_pool.loot_type = LOOT_TYPE_CHEST_LIKE_CPP; + first_pool.allowed_looters = vec![first]; + assert!( + gameobject + .initialize_loot_authority_like_cpp(None, HashMap::from([(first, first_pool)]),) + .installed() + ); + let authority = gameobject.loot_authority_like_cpp().clone(); + attach_canonical_gameobject(&mut session, gameobject); + session.set_player_guid(Some(first)); + authority.add_viewer_like_cpp(first).unwrap(); + let generation = authority + .snapshot_for_player_like_cpp(first) + .unwrap() + .generation; + let close = authority + .close_viewer_if_generation_like_cpp(generation, first) + .unwrap(); + assert!(close.whole_object_fully_looted); + + let mut late_pool = authoritative_test_loot_like_cpp(0, true); + late_pool.loot_guid = ObjectGuid::create_world_object( + HighGuid::LootObject, + 0, + owner_guid.realm_id(), + owner_guid.map_id(), + 0, + 0, + owner_guid.counter() + 1, + ); + late_pool.loot_type = LOOT_TYPE_CHEST_LIKE_CPP; + late_pool.allowed_looters = vec![late]; + late_pool.items[0].allowed_looters = vec![late]; + assert!( + session + .upsert_represented_personal_gameobject_loot_authority_like_cpp( + owner_guid, late, late_pool, false, + ) + .is_some() ); - session.apply_represented_gameobject_loot_release_like_cpp(loot_guid, player_guid, true); + assert!( + session + .set_canonical_gameobject_loot_state_if_fully_looted_observation_like_cpp( + owner_guid, + &authority, + close.object_generation, + close.lifecycle_revision, + LootState::JustDeactivated, + None, + 0, + false, + ) + .is_none(), + "the late pool revision must invalidate the earlier fully-looted observation" + ); + assert_eq!( + canonical_gameobject_snapshot(&session, owner_guid) + .unwrap() + .loot_state(), + LootState::Activated + ); + assert!(authority.snapshot_for_player_like_cpp(late).is_some()); + } - let state = session - .represented_gameobject_use_states - .get(&loot_guid) + #[test] + fn personal_gameobject_release_before_upsert_rejects_resurrection_like_cpp() { + let mut session = make_session(); + let first = ObjectGuid::create_player(1, 61_870); + let late = ObjectGuid::create_player(1, 61_871); + let owner_guid = test_gameobject_guid(61_872); + let mut gameobject = make_canonical_gameobject_for_session( + &session, + owner_guid, + GAMEOBJECT_TYPE_CHEST as u8, + ); + gameobject.set_loot_state(LootState::Activated, Some(first)); + let mut first_pool = authoritative_test_loot_like_cpp(0, false); + first_pool.loot_guid = represented_loot_object_guid_like_cpp(owner_guid); + first_pool.loot_type = LOOT_TYPE_CHEST_LIKE_CPP; + first_pool.allowed_looters = vec![first]; + assert!( + gameobject + .initialize_loot_authority_like_cpp(None, HashMap::from([(first, first_pool)]),) + .installed() + ); + let authority = gameobject.loot_authority_like_cpp().clone(); + attach_canonical_gameobject(&mut session, gameobject); + session.set_player_guid(Some(first)); + authority.add_viewer_like_cpp(first).unwrap(); + let generation = authority + .snapshot_for_player_like_cpp(first) + .unwrap() + .generation; + let close = authority + .close_viewer_if_generation_like_cpp(generation, first) .unwrap(); - assert_eq!(state.loot_state, Some(LootState::NotReady)); - assert_eq!(state.loot_state_unit_guid, ObjectGuid::EMPTY); - assert!(state.chest_restock_until.is_some()); - } + assert!( + session + .set_canonical_gameobject_loot_state_if_fully_looted_observation_like_cpp( + owner_guid, + &authority, + close.object_generation, + close.lifecycle_revision, + LootState::JustDeactivated, + None, + 0, + false, + ) + .is_some() + ); - #[tokio::test] - async fn gameobject_owned_loot_release_personal_chest_syncs_current_player_and_despawns_like_cpp() - { - let (mut session, send_rx) = make_session_with_send_capacity(2); - let player_guid = ObjectGuid::create_player(1, 42); - let loot_guid = test_gameobject_guid(19_134); - let game_object = - make_canonical_gameobject_for_session(&session, loot_guid, GAMEOBJECT_TYPE_CHEST as u8); - attach_canonical_gameobject(&mut session, game_object); - session.set_player_guid(Some(player_guid)); - session.set_player_position_like_cpp(Position::ZERO); - session.set_active_loot_guid(loot_guid); - session.record_represented_gameobject_runtime_state_like_cpp( + let mut late_pool = authoritative_test_loot_like_cpp(0, true); + late_pool.loot_guid = ObjectGuid::create_world_object( + HighGuid::LootObject, 0, - loot_guid, - loot_guid.entry(), - Position::ZERO, - GAMEOBJECT_TYPE_CHEST as u8, + owner_guid.realm_id(), + owner_guid.map_id(), + 0, + 0, + owner_guid.counter() + 1, ); - session.record_represented_gameobject_chest_release_metadata_like_cpp( - loot_guid, - GameObjectLootSource { - personal_loot_id: 55, - chest_consumable: false, - chest_restock_time_secs: 7, - ..Default::default() - }, + late_pool.loot_type = LOOT_TYPE_CHEST_LIKE_CPP; + late_pool.allowed_looters = vec![late]; + late_pool.items[0].allowed_looters = vec![late]; + assert!( + session + .upsert_represented_personal_gameobject_loot_authority_like_cpp( + owner_guid, late, late_pool, false, + ) + .is_none(), + "a generator finishing after JustDeactivated must not resurrect the object" ); - session.represented_personal_loot_owners.insert(loot_guid); - session - .represented_personal_loot_money - .insert((loot_guid, player_guid), 0); - session.loot_table.insert( - loot_guid, - CreatureLoot { - loot_guid: represented_loot_object_guid_like_cpp(loot_guid), - coins: 0, - unlooted_count: 0, - loot_type: LOOT_TYPE_CHEST_LIKE_CPP, - dungeon_encounter_id: 1, - loot_method: 0, - loot_master: ObjectGuid::EMPTY, - round_robin_player: ObjectGuid::EMPTY, - player_ffa_items: Vec::new(), - players_looting: vec![player_guid], - allowed_looters: vec![player_guid], - items: vec![LootEntry { - taken: true, - ..represented_loot_entry(0, 25, player_guid) - }], - looted_by_player: false, - }, + assert_eq!( + canonical_gameobject_snapshot(&session, owner_guid) + .unwrap() + .loot_state(), + LootState::JustDeactivated + ); + assert!(authority.snapshot_for_player_like_cpp(late).is_none()); + } + + #[test] + fn personal_fishing_hole_restock_accepts_second_lifecycle_and_rejects_old_observation_like_cpp() + { + let mut session = make_session(); + let first = ObjectGuid::create_player(1, 61_880); + let second = ObjectGuid::create_player(1, 61_881); + let owner_guid = test_gameobject_guid(61_882); + let mut gameobject = make_canonical_gameobject_for_session( + &session, + owner_guid, + GAMEOBJECT_TYPE_FISHING_HOLE as u8, + ); + gameobject.set_loot_state(LootState::Activated, Some(first)); + let mut first_pool = authoritative_test_loot_like_cpp(0, false); + first_pool.loot_guid = represented_loot_object_guid_like_cpp(owner_guid); + first_pool.loot_type = LOOT_TYPE_FISHINGHOLE_LIKE_CPP; + first_pool.allowed_looters = vec![first]; + assert!( + gameobject + .initialize_loot_authority_like_cpp(None, HashMap::from([(first, first_pool)]),) + .installed() ); + let authority = gameobject.loot_authority_like_cpp().clone(); + let first_generation = authority.generation_like_cpp(); + attach_canonical_gameobject(&mut session, gameobject); + session.set_player_guid(Some(second)); + let stale_observation = session + .represented_gameobject_loot_install_observation_like_cpp(owner_guid) + .unwrap(); session - .handle_loot_release(loot_release_packet(loot_guid)) - .await; + .mutate_canonical_gameobject_by_guid_like_cpp(owner_guid, |gameobject| { + gameobject.clear_loot_like_cpp(); + gameobject.set_loot_state(LootState::Ready, None); + }) + .unwrap(); - assert!(send_rx.try_recv().is_ok()); - let canonical = canonical_gameobject_snapshot(&session, loot_guid).unwrap(); - assert_eq!(canonical.shared_loot_like_cpp(), None); + let mut stale_pool = authoritative_test_loot_like_cpp(0, true); + stale_pool.loot_guid = represented_loot_object_guid_like_cpp(owner_guid); + stale_pool.loot_type = LOOT_TYPE_FISHINGHOLE_LIKE_CPP; + stale_pool.allowed_looters = vec![second]; + stale_pool.items[0].allowed_looters = vec![second]; + assert!( + session + .upsert_represented_personal_gameobject_loot_authority_if_observed_like_cpp( + owner_guid, + second, + stale_pool.clone(), + false, + &stale_observation, + ) + .is_none(), + "an async generator from before ClearLoot must lose the lifecycle CAS" + ); + assert!(authority.is_retired_like_cpp()); + + assert!( + session + .upsert_represented_personal_gameobject_loot_authority_like_cpp( + owner_guid, second, stale_pool, false, + ) + .is_some(), + "a generator started after Ready must install the new fishing-hole lifetime" + ); + assert!(authority.generation_like_cpp() > first_generation); + assert!(authority.snapshot_for_player_like_cpp(second).is_some()); assert_eq!( - canonical.personal_loot_like_cpp(player_guid), - Some(&GameObjectOwnedLoot::default()) + canonical_gameobject_snapshot(&session, owner_guid) + .unwrap() + .loot_state(), + LootState::Ready ); - let state = session - .represented_gameobject_use_states - .get(&loot_guid) - .unwrap(); - assert_eq!(state.per_player_state_player_guid, Some(player_guid)); - assert_eq!(state.per_player_despawn_secs, Some(7)); - assert!(state.per_player_despawn_until.is_some()); } #[tokio::test] @@ -17082,6 +27659,51 @@ mod tests { assert_eq!(hole_state.loot_state, Some(LootState::JustDeactivated)); } + #[test] + fn concurrent_fishing_hole_releases_cannot_finish_ready_after_max_like_cpp() { + let mut first = make_session(); + let mut second = make_session(); + let fishing_hole = test_gameobject_guid(61_910); + let gameobject = make_canonical_gameobject_for_session( + &first, + fishing_hole, + GAMEOBJECT_TYPE_FISHING_HOLE as u8, + ); + attach_canonical_gameobject(&mut first, gameobject); + second.set_canonical_map_manager(Arc::clone(first.canonical_map_manager.as_ref().unwrap())); + let start = Arc::new(Barrier::new(2)); + + std::thread::scope(|scope| { + let first_start = Arc::clone(&start); + let first_session = &mut first; + let first_handle = scope.spawn(move || { + first_start.wait(); + first_session + .release_canonical_fishing_hole_like_cpp(fishing_hole, Some(2)) + .unwrap() + }); + let second_start = Arc::clone(&start); + let second_session = &mut second; + let second_handle = scope.spawn(move || { + second_start.wait(); + second_session + .release_canonical_fishing_hole_like_cpp(fishing_hole, Some(2)) + .unwrap() + }); + let first_outcome = first_handle.join().unwrap(); + let second_outcome = second_handle.join().unwrap(); + assert_eq!( + [first_outcome.0, second_outcome.0].into_iter().max(), + Some(2) + ); + assert!([first_outcome.1, second_outcome.1].contains(&LootState::JustDeactivated)); + }); + + let canonical = canonical_gameobject_snapshot(&first, fishing_hole).unwrap(); + assert_eq!(canonical.use_times(), 2); + assert_eq!(canonical.loot_state(), LootState::JustDeactivated); + } + #[tokio::test] async fn gameobject_loot_release_fishing_hole_uses_canonical_use_count_when_represented_stale_like_cpp() { @@ -17251,6 +27873,35 @@ mod tests { assert_eq!(send_rx.try_recv().unwrap(), expected); } + #[test] + fn partial_gathering_node_release_does_not_run_on_loot_release_state_like_cpp() { + let mut session = make_session(); + let player_guid = ObjectGuid::create_player(1, 61_900); + let gathering_node = test_gameobject_guid(61_901); + session.record_represented_gameobject_runtime_state_like_cpp( + 0, + gathering_node, + gathering_node.entry(), + Position::ZERO, + GAMEOBJECT_TYPE_GATHERING_NODE as u8, + ); + + session.apply_represented_gameobject_loot_release_like_cpp( + gathering_node, + player_guid, + false, + false, + None, + ); + + let state = session + .represented_gameobject_use_states + .get(&gathering_node) + .unwrap(); + assert_ne!(state.go_state, Some(GoState::Active)); + assert_eq!(state.loot_state, Some(LootState::Activated)); + } + #[tokio::test] async fn loot_release_personal_chest_records_per_player_despawn_like_cpp() { let (mut session, send_rx) = make_session_with_send_capacity(4); diff --git a/crates/wow-world/src/handlers/misc.rs b/crates/wow-world/src/handlers/misc.rs index 3f5c171a0..fced7401a 100644 --- a/crates/wow-world/src/handlers/misc.rs +++ b/crates/wow-world/src/handlers/misc.rs @@ -8328,6 +8328,7 @@ mod tests { is_in_world: true, send_tx, command_tx, + durable_loot_money_tracker_like_cpp: Default::default(), active_loot_rolls: Vec::new(), pass_on_group_loot: false, enchanting_skill: 0, diff --git a/crates/wow-world/src/handlers/movement.rs b/crates/wow-world/src/handlers/movement.rs index a1be822ac..83c5db8ac 100644 --- a/crates/wow-world/src/handlers/movement.rs +++ b/crates/wow-world/src/handlers/movement.rs @@ -2668,6 +2668,7 @@ mod tests { is_in_world: true, send_tx, command_tx, + durable_loot_money_tracker_like_cpp: Default::default(), active_loot_rolls: Vec::new(), pass_on_group_loot: false, enchanting_skill: 0, diff --git a/crates/wow-world/src/handlers/quest.rs b/crates/wow-world/src/handlers/quest.rs index ae580d104..e276c8476 100644 --- a/crates/wow-world/src/handlers/quest.rs +++ b/crates/wow-world/src/handlers/quest.rs @@ -2161,42 +2161,27 @@ impl WorldSession { let mut last_bag = u8::from(wow_entities::INVENTORY_SLOT_BAG_0); let mut last_slot = 0; let mut last_count_in_stack = 0; - let mut next_item_guid = if dest.iter().any(|dest| { - let bag = (dest.pos >> 8) as u8; - let slot = (dest.pos & 0x00FF) as u8; - self.get_inventory_item_by_pos(bag, slot).is_none() - }) { - if let Some(char_db) = self.char_db().map(Arc::clone) { - let max_guid_stmt = char_db.prepare(CharStatements::SEL_MAX_ITEM_GUID); - match char_db.query(&max_guid_stmt).await { - Ok(row) => row.try_read::(0).unwrap_or(0).saturating_add(1), - Err(error) => { - warn!( - account = self.account_id, - entry_id, - ?error, - "QuestConfirmAccept: failed to allocate DB item guid for source item" - ); - self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); - return None; - } - } - } else { - self.inventory_items_like_cpp() - .values() - .map(|item| item.db_guid) - .chain( - self.inventory_item_objects_like_cpp() - .keys() - .map(|guid| guid.counter() as u64), - ) - .max() - .unwrap_or(0) - .saturating_add(1) - } - } else { - 0 + let new_item_count = dest + .iter() + .filter(|dest| { + let bag = (dest.pos >> 8) as u8; + let slot = (dest.pos & 0x00FF) as u8; + self.get_inventory_item_by_pos(bag, slot).is_none() + }) + .count(); + let Some(allocated_new_item_guids) = + self.allocate_item_instance_guids_like_cpp(new_item_count) + else { + warn!( + account = self.account_id, + entry_id, + count = new_item_count, + "QuestConfirmAccept: process-wide item GUID allocator is unavailable" + ); + self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); + return None; }; + let mut allocated_new_item_guids = allocated_new_item_guids.into_iter(); for dest in dest { let bag = (dest.pos >> 8) as u8; @@ -2263,9 +2248,15 @@ impl WorldSession { return None; }; - let db_guid = next_item_guid; - next_item_guid = next_item_guid.saturating_add(1); - let item_guid = ObjectGuid::create_item(self.realm_id(), db_guid as i64); + let Some((db_guid, item_guid)) = allocated_new_item_guids.next() else { + warn!( + account = self.account_id, + entry_id, + "QuestConfirmAccept: preallocated item GUID count did not match store plan" + ); + self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); + return None; + }; let max_durability = self.item_template_max_durability(entry_id); let should_bind = source_item_bonding.is_some_and(|bonding| { matches!(bonding, ItemBondingType::OnAcquire | ItemBondingType::Quest) @@ -2522,42 +2513,27 @@ impl WorldSession { let mut last_bag = u8::from(wow_entities::INVENTORY_SLOT_BAG_0); let mut last_slot = 0; let mut last_count_in_stack = 0; - let mut next_item_guid = if dest.iter().any(|dest| { - let bag = (dest.pos >> 8) as u8; - let slot = (dest.pos & 0x00FF) as u8; - self.get_inventory_item_by_pos(bag, slot).is_none() - }) { - if let Some(char_db) = self.char_db().map(Arc::clone) { - let max_guid_stmt = char_db.prepare(CharStatements::SEL_MAX_ITEM_GUID); - match char_db.query(&max_guid_stmt).await { - Ok(row) => row.try_read::(0).unwrap_or(0).saturating_add(1), - Err(error) => { - warn!( - account = self.account_id, - entry_id, - ?error, - "RewardQuest: failed to allocate DB item guid for reward item" - ); - self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); - return false; - } - } - } else { - self.inventory_items_like_cpp() - .values() - .map(|item| item.db_guid) - .chain( - self.inventory_item_objects_like_cpp() - .keys() - .map(|guid| guid.counter() as u64), - ) - .max() - .unwrap_or(0) - .saturating_add(1) - } - } else { - 0 + let new_item_count = dest + .iter() + .filter(|dest| { + let bag = (dest.pos >> 8) as u8; + let slot = (dest.pos & 0x00FF) as u8; + self.get_inventory_item_by_pos(bag, slot).is_none() + }) + .count(); + let Some(allocated_new_item_guids) = + self.allocate_item_instance_guids_like_cpp(new_item_count) + else { + warn!( + account = self.account_id, + entry_id, + count = new_item_count, + "RewardQuest: process-wide item GUID allocator is unavailable" + ); + self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); + return false; }; + let mut allocated_new_item_guids = allocated_new_item_guids.into_iter(); for dest in dest { let bag = (dest.pos >> 8) as u8; @@ -2624,9 +2600,15 @@ impl WorldSession { return false; }; - let db_guid = next_item_guid; - next_item_guid = next_item_guid.saturating_add(1); - let item_guid = ObjectGuid::create_item(self.realm_id(), db_guid as i64); + let Some((db_guid, item_guid)) = allocated_new_item_guids.next() else { + warn!( + account = self.account_id, + entry_id, + "RewardQuest: preallocated item GUID count did not match store plan" + ); + self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); + return false; + }; let max_durability = self.item_template_max_durability(entry_id); let should_bind = item_bonding.is_some_and(|bonding| { matches!(bonding, ItemBondingType::OnAcquire | ItemBondingType::Quest) @@ -6121,16 +6103,39 @@ impl WorldSession { let money = quest.reward_money_difficulty; if money > 0 { - let old_money = self.player_gold_like_cpp(); - let new_money = old_money.saturating_add(money as u64); - self.enqueue_represented_quest_objective_progress_like_cpp( - RepresentedQuestObjectiveProgressEventLikeCpp::MoneyChanged { - old_money, - new_money, - }, - ); - self.set_player_gold_like_cpp(new_money); - self.save_player_gold().await; + match self + .mutate_and_persist_player_gold_exclusive_like_cpp(|old_money| { + crate::session::loot_money_durable_outcome_like_cpp(old_money, money as u64).0 + }) + .await + { + Some((old_money, new_money)) => { + if old_money != new_money { + self.enqueue_represented_quest_objective_progress_like_cpp( + RepresentedQuestObjectiveProgressEventLikeCpp::MoneyChanged { + old_money, + new_money, + }, + ); + } + } + None => { + // Boundary: the represented reward path persists item and + // currency grants before reaching money and does not yet + // own C++ `Player::RewardQuest` as one durable transaction. + // Aborting here would leave the quest retryable after those + // grants and permit duplicates. Preserve the existing + // completion behavior; an ambiguous money COMMIT has + // already quarantined/kicked the session in the shared + // helper. Atomic quest reward persistence is separate debt. + warn!( + account = self.account_id, + quest_id, + money, + "Quest reward money was not durably established; preserving non-atomic represented reward completion to avoid duplicate retry" + ); + } + } } self.apply_represented_quest_title_and_talent_rewards_like_cpp(quest); @@ -7724,7 +7729,7 @@ mod tests { ItemClass, ItemContext, }; use wow_core::guid::HighGuid; - use wow_core::{ObjectGuid, Position}; + use wow_core::{ObjectGuid, ObjectGuidGenerator, Position}; use wow_data::quest::{ QUEST_FLAGS_DAILY_LIKE_CPP, QUEST_FLAGS_WEEKLY_LIKE_CPP, QUEST_ITEM_DROP_COUNT, QUEST_REWARD_CHOICES_COUNT, QUEST_REWARD_CURRENCY_COUNT, QUEST_REWARD_DISPLAY_SPELL_COUNT, @@ -7769,6 +7774,14 @@ mod tests { session.set_player_guid(Some(ObjectGuid::create_player(1, 42))); session.set_loaded_player_identity_like_cpp(571, 1, 1, 80, 0); session.set_player_position_like_cpp(Position::new(10.0, 0.0, 0.0, 0.0)); + session.set_item_guid_generator_like_cpp(Arc::new(ObjectGuidGenerator::new( + HighGuid::Item, + 1, + ))); + // Reward tests model a successful CharacterDatabase commit. The + // production path is fail-closed when no database is available; unit + // fixtures have no pool and must opt into the explicit success seam. + session.set_loot_money_persistence_test_result_like_cpp(true); (session, send_rx) } diff --git a/crates/wow-world/src/handlers/spell.rs b/crates/wow-world/src/handlers/spell.rs index c347ef3e9..bf4a9e715 100644 --- a/crates/wow-world/src/handlers/spell.rs +++ b/crates/wow-world/src/handlers/spell.rs @@ -922,7 +922,8 @@ impl WorldSession { let loot_guid = loot.loot_guid; let coins = loot.coins; - self.set_active_loot_guid(item.guid); + self.open_active_item_loot_view_like_cpp(player_guid, item.guid) + .await; self.send_packet(&LootResponse { owner: item.guid, loot_obj: loot_guid, @@ -938,6 +939,17 @@ impl WorldSession { }); } + pub(crate) async fn open_active_item_loot_view_like_cpp( + &mut self, + player_guid: ObjectGuid, + item_guid: ObjectGuid, + ) { + if self.has_active_non_item_loot_views_like_cpp() { + self.do_loot_release_all_like_cpp(player_guid).await; + } + self.add_active_loot_view_owner_like_cpp(item_guid); + } + async fn open_wrapped_gift_like_cpp(&mut self, bag: u8, slot: u8, item_guid: ObjectGuid) { let gift = match self.load_wrapped_gift_row_like_cpp(item_guid).await { WrappedGiftLoad::Found(gift) => gift, diff --git a/crates/wow-world/src/handlers/talent.rs b/crates/wow-world/src/handlers/talent.rs index 8b604312d..0df1fa329 100644 --- a/crates/wow-world/src/handlers/talent.rs +++ b/crates/wow-world/src/handlers/talent.rs @@ -15,10 +15,9 @@ use wow_packet::packets::talent::{ }; use wow_packet::{ClientPacket, WorldPacket}; -use crate::session::{ - RepresentedConfirmRespecWipeLikeCpp, RepresentedTalentRespecVisualSpellCastLikeCpp, - WorldSession, -}; +#[cfg(test)] +use crate::session::RepresentedTalentRespecVisualSpellCastLikeCpp; +use crate::session::{RepresentedConfirmRespecWipeLikeCpp, WorldSession}; const CONFIRM_RESPEC_WIPE_NPC_FLAGS_LIKE_CPP: u32 = NPCFlags1::TRAINER.bits(); const MIN_TALENT_RESET_LEVEL_LIKE_CPP: u8 = 15; @@ -126,33 +125,27 @@ impl WorldSession { return; } + // Preserve C++ ResetTalents ordering for these precondition-side + // effects. The durable transaction covers money, reset metadata, and + // talent topology. Pet/spell runtime removal, criteria, and packets are + // published synchronously after COMMIT; exact `_SaveSpells` persistence + // remains bounded until Rust retains the complete PlayerSpellMap state. self.record_represented_talent_reset_script_hook_like_cpp(false); self.remove_represented_at_login_flag_like_cpp(AT_LOGIN_RESET_TALENTS_LIKE_CPP, true); - if !self.apply_represented_talent_reset_cost_like_cpp().await { + let Some(committed) = self.commit_represented_talent_reset_like_cpp().await else { return; - } - - self.remove_represented_pet_not_in_slot_like_cpp(); + }; - self.record_represented_confirm_respec_wipe_like_cpp(RepresentedConfirmRespecWipeLikeCpp { - respec_master: request.respec_master, - respec_type: request.respec_type, - }); - if self.reset_represented_active_talents_like_cpp() { - self.send_packet(&self.represented_update_talent_data_packet_like_cpp()); - if let Some(player_guid) = self.player_guid() { - self.record_represented_talent_respec_visual_spell_cast_like_cpp( - RepresentedTalentRespecVisualSpellCastLikeCpp { - caster_guid: request.respec_master, - target_guid: player_guid, - spell_id: UNTALENT_VISUAL_EFFECT_SPELL_ID_LIKE_CPP, - triggered: true, - spell_runtime_unrepresented: true, - }, - ); - } - } + self.publish_committed_represented_talent_reset_like_cpp( + committed, + RepresentedConfirmRespecWipeLikeCpp { + respec_master: request.respec_master, + respec_type: request.respec_type, + }, + UNTALENT_VISUAL_EFFECT_SPELL_ID_LIKE_CPP, + ) + .await; } fn represented_can_confirm_respec_wipe_like_cpp( @@ -1882,6 +1875,92 @@ mod tests { ); } + #[tokio::test] + async fn confirm_respec_wipe_definite_rollback_publishes_no_covered_runtime_or_packets() { + let (mut session, send_rx) = make_session_with_send_capacity(4); + let trainer = test_creature_guid(189); + let pet_guid = ObjectGuid::create_world_object(HighGuid::Pet, 0, 1, 0, 0, 500, 143); + let mut talent = test_talent_entry_like_cpp(101, 0, 50_101); + talent.spell_id = 70_101; + talent.overrides_spell_id = 60_101; + register_test_trainer(&mut session, trainer, NPCFlags1::TRAINER.bits()); + install_test_talent_entries_with_tab_class_mask(&mut session, vec![talent], 1); + session.mark_represented_talents_loaded_like_cpp(); + + session + .handle_learn_talent(learn_talent_packet(101, 0)) + .await; + let _ = drain_sent_packets(&send_rx); + session.set_represented_pet_mode_state_like_cpp( + Some(pet_guid), + wow_packet::packets::pet::REACT_DEFENSIVE_LIKE_CPP, + wow_packet::packets::pet::COMMAND_FOLLOW_LIKE_CPP, + ); + session.set_represented_pet_stable_like_cpp(represented_current_pet_stable_like_cpp(143)); + session.set_player_gold_like_cpp(20_000); + session.set_represented_at_login_flags_like_cpp( + AT_LOGIN_RENAME_LIKE_CPP | AT_LOGIN_RESET_TALENTS_LIKE_CPP, + ); + session.set_loot_money_persistence_test_result_like_cpp(false); + + session + .handle_confirm_respec_wipe(confirm_respec_wipe_packet( + trainer, + SPEC_RESET_TALENTS_LIKE_CPP, + )) + .await; + + assert!( + send_rx.try_recv().is_err(), + "a definite SQL rollback must not publish talent, spell, money, or visual packets" + ); + assert_eq!(session.player_gold_like_cpp(), 20_000); + assert_eq!(session.represented_talent_reset_cost_like_cpp(), 0); + assert_eq!(session.represented_talent_reset_time_secs_like_cpp(), 0); + assert!(session.known_spells_like_cpp().contains(&50_101)); + assert_eq!( + session + .represented_update_talent_data_packet_like_cpp() + .groups[0] + .talents, + vec![wow_packet::packets::misc::TalentInfoLikeCpp { + talent_id: 101, + rank: 0, + }] + ); + assert_eq!(session.represented_pet_guid_like_cpp(), Some(pet_guid)); + assert_eq!( + session.represented_pet_stable_current_index_like_cpp(), + Some(0) + ); + assert!( + session + .represented_talent_respec_criteria_events_like_cpp() + .is_empty() + ); + assert!( + session + .represented_confirm_respec_wipe_requests_like_cpp() + .is_empty() + ); + assert!( + session + .represented_talent_respec_visual_spell_casts_like_cpp() + .is_empty() + ); + + assert_eq!( + session.represented_talent_reset_script_hooks_like_cpp(), + &[RepresentedTalentResetScriptHookLikeCpp { no_cost: false }], + "C++ fires the script hook before the reset's money/transaction gate" + ); + assert_eq!( + session.represented_at_login_flags_like_cpp(), + AT_LOGIN_RENAME_LIKE_CPP, + "C++ removes AT_LOGIN_RESET_TALENTS before entering the fallible save" + ); + } + #[tokio::test] async fn confirm_respec_wipe_removes_feign_death_before_reset_talents_like_cpp() { let (mut session, send_rx) = make_session_with_send_capacity(4); diff --git a/crates/wow-world/src/handlers/trainer.rs b/crates/wow-world/src/handlers/trainer.rs index 1f93c7698..fc0c6b594 100644 --- a/crates/wow-world/src/handlers/trainer.rs +++ b/crates/wow-world/src/handlers/trainer.rs @@ -29,6 +29,7 @@ use tracing::{info, warn}; use wow_constants::ClientOpcodes; use wow_constants::unit::NPCFlags1; +use wow_database::SqlTransaction; use wow_database::statements::character::CharStatements; use wow_database::statements::world::WorldStatements; use wow_handler::{PacketHandlerEntry, PacketProcessing, SessionStatus}; @@ -38,7 +39,7 @@ use wow_packet::packets::trainer::{ }; use crate::conditions; -use crate::session::{RepresentedQuestObjectiveProgressEventLikeCpp, WorldSession}; +use crate::session::WorldSession; const TRAINER_LIST_NPC_FLAGS_LIKE_CPP: u32 = NPCFlags1::TRAINER.bits(); const TRAINER_BUY_NPC_FLAGS_LIKE_CPP: u32 = NPCFlags1::TRAINER.bits() @@ -550,41 +551,60 @@ impl WorldSession { None => return, }; + let Some(money_persistence) = self + .begin_exclusive_player_money_persistence_like_cpp() + .await + else { + return; + }; + // ── Deduct gold ──────────────────────────────────────────────────── let old_money = self.player_gold_like_cpp(); let new_money = old_money.saturating_sub(money_cost as u64); - self.enqueue_represented_quest_objective_progress_like_cpp( - RepresentedQuestObjectiveProgressEventLikeCpp::MoneyChanged { - old_money, - new_money, - }, - ); - self.set_player_gold_like_cpp(new_money); + let mut tx = SqlTransaction::new(); let mut upd_money = char_db.prepare(CharStatements::UPD_CHAR_MONEY); - upd_money.set_u64(0, self.player_gold_like_cpp()); + upd_money.set_u64(0, new_money); upd_money.set_u64(1, player_guid.counter() as u64); - if let Err(e) = char_db.execute(&upd_money).await { - warn!( - account = self.account_id, - "TrainerBuySpell: update money failed: {e}" - ); - } + tx.append(upd_money); // ── Persist spell to character_spell ─────────────────────────────── let mut ins_spell = char_db.prepare(CharStatements::INS_CHARACTER_SPELL); ins_spell.set_u64(0, player_guid.counter() as u64); ins_spell.set_i32(1, spell_id); - if let Err(e) = char_db.execute(&ins_spell).await { + tx.append(ins_spell); + let Some(money_persistence) = self + .commit_exclusive_player_money_transaction_like_cpp( + money_persistence, + char_db.as_ref(), + tx, + old_money, + new_money, + "trainer spell purchase", + ) + .await + else { warn!( account = self.account_id, spell_id = spell_id, - "TrainerBuySpell: insert character_spell failed: {e}" + "TrainerBuySpell: atomic money/spell transaction did not commit" ); - } - - // ── Update in-memory state ───────────────────────────────────────── + self.send_packet(&TrainerBuyFailed { + trainer_guid, + spell_id, + reason: 0, + }); + return; + }; + // Publish every runtime field represented by the committed money/spell + // transaction before reopening money-payout admission. There must be + // no cancellation point between COMMIT and this publication. + self.stage_player_money_change_like_cpp(old_money, new_money); self.learn_known_spell_like_cpp(spell_id); + self.sync_object_accessor_player(); self.sync_player_registry_state_like_cpp(); + drop(money_persistence); + + // ── Update in-memory state ───────────────────────────────────────── self.drain_represented_quest_objective_progress_like_cpp() .await; diff --git a/crates/wow-world/src/map_manager.rs b/crates/wow-world/src/map_manager.rs index ac21faec1..8dd3b7faa 100644 --- a/crates/wow-world/src/map_manager.rs +++ b/crates/wow-world/src/map_manager.rs @@ -1685,6 +1685,14 @@ impl WorldCreature { object.force_dynamic_flags_update_like_cpp(); } + pub fn force_dynamic_flags_update_like_cpp(&mut self) { + self.creature + .unit_mut() + .world_mut() + .object_mut() + .force_dynamic_flags_update_like_cpp(); + } + pub fn has_lootable_dynamic_flag_like_cpp(&self) -> bool { self.creature .unit() @@ -7913,6 +7921,10 @@ mod tests { static_flags: [0; 8], creature_type: 7, type_flags: 0, + loot_id: 21_779, + skin_loot_id: 21_780, + gold_min: 13, + gold_max: 31, movement_type: wow_entities::MovementGeneratorType::Idle, ground_movement_type: wow_constants::CreatureGroundMovementType::Run as u8, swim_allowed: true, @@ -8000,6 +8012,10 @@ mod tests { ); assert_eq!(bridged.create_data.speed_walk_rate, 1.0); assert_eq!(bridged.create_data.speed_run_rate, 1.14286); + assert_eq!(bridged.creature.ai_ownership().loot_id, 21_779); + assert_eq!(bridged.creature.ai_ownership().skin_loot_id, 21_780); + assert_eq!(bridged.creature.ai_ownership().gold_min, 13); + assert_eq!(bridged.creature.ai_ownership().gold_max, 31); } #[test] @@ -8036,6 +8052,10 @@ mod tests { static_flags: [0; 8], creature_type: 7, type_flags: 0, + loot_id: 0, + skin_loot_id: 0, + gold_min: 0, + gold_max: 0, movement_type: wow_entities::MovementGeneratorType::Idle, ground_movement_type: wow_constants::CreatureGroundMovementType::Run as u8, swim_allowed: true, diff --git a/crates/wow-world/src/session.rs b/crates/wow-world/src/session.rs index 82fb241ee..c4c6aff78 100644 --- a/crates/wow-world/src/session.rs +++ b/crates/wow-world/src/session.rs @@ -7,14 +7,17 @@ //! [`WorldSocket`](wow_network::WorldSocket) and dispatches them to handlers. use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque}; +#[cfg(test)] +use std::sync::atomic::AtomicUsize; use std::sync::{ Arc, Mutex, OnceLock, - atomic::{AtomicI64, Ordering}, + atomic::{AtomicBool, AtomicI64, Ordering}, }; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use parking_lot::RwLock; use rand::{Rng, RngCore, SeedableRng, rngs::StdRng, seq::SliceRandom}; +use sqlx::Row; use tracing::{debug, info, trace, warn}; use crate::entity_update_bridge::{ @@ -126,8 +129,9 @@ use wow_data::{ spell_duration_ms_like_cpp, spell_effect_radius_like_cpp, }; use wow_database::{ - CharStatements, CharacterDatabase, LoginDatabase, LoginStatements, PreparedStatement, - SqlTransaction, StatementDef, WorldDatabase, + CharStatements, CharacterDatabase, DatabaseError, LoginDatabase, LoginStatements, + PreparedStatement, SqlTransaction, SqlTransactionCommitError, StatementDef, WorldDatabase, + is_database_deadlock_like_cpp, retry_deadlocked_operation_like_cpp, }; use wow_entities::{ AccessorObjectKind, ActiveState, ApplyEnchantmentArgs, ApplyEnchantmentDurationAction, @@ -171,17 +175,23 @@ use wow_entities::{ CONTAINER_DATA_SLOTS_PARENT_BIT, ContainerDataUpdate, ContainerDataValues, }; use wow_handler::{PacketHandlerEntry, PacketProcessing, SessionStatus, build_dispatch_table}; -use wow_loot::{LootStoreKind, LootStores}; +use wow_loot::{ + LootClaimLease, LootStoreKind, LootStores, OwnedLootAuthority, OwnedLootAuthorityLifecycle, + OwnedLootAuthorityStamp, OwnedLootScope, OwnedLootSnapshot, +}; use wow_map::coords::SIZE_OF_GRID_CELL; use wow_network::player_registry::SendIfVisibleLikeCppCommand; use wow_network::session_mgr::{InstanceLink, SessionManager}; use wow_network::{ ChatFloodConfigLikeCpp, ChatLevelRequirementsLikeCpp, ChatListenRangesLikeCpp, + DurableLootMoneyCompletionLikeCpp, DurableLootMoneyPersistenceGuardLikeCpp, + DurableLootMoneyPersistenceTrackerLikeCpp, DurableLootMoneySaveFenceLikeCpp, GameEventQuestCompleteClientOutcomeLikeCpp, GameEventQuestCompleteCommandLikeCpp, GroupInfo, GroupInstanceResetMethodLikeCpp, GroupInstanceResetResultLikeCpp, GroupRegistry, - KickLikeCppCommand, LootDropRatesLikeCpp, PacketSpoofConfigLikeCpp, PendingInvites, + KickLikeCppCommand, LootDropRatesLikeCpp, LootRollCommandIdentityLikeCpp, + NotifyLootMoneyRemovedLikeCppCommand, PacketSpoofConfigLikeCpp, PendingInvites, PlayerBroadcastInfo, PlayerRegistry, ReputationRatesLikeCpp, SessionCommand, - SocketTimeoutsLikeCpp, group_guid_by_db_store_id_like_cpp, + SocketTimeoutsLikeCpp, SocketWriteFenceLikeCpp, group_guid_by_db_store_id_like_cpp, }; use wow_packet::packets::chat::{ChatMsg, ChatPkt, PrintNotification}; use wow_packet::packets::gossip::ClientGossipText; @@ -236,6 +246,487 @@ pub(crate) const REST_STATE_RESTED_LIKE_CPP: u8 = 1; pub(crate) const REST_STATE_NORMAL_LIKE_CPP: u8 = 2; pub(crate) const REST_STATE_RAF_LINKED_LIKE_CPP: u8 = 6; +#[derive(Debug)] +pub(crate) enum LootMoneyPersistenceErrorLikeCpp { + MissingPlayer, + MissingCharacterDatabase, + WorkerTerminated, + Claim(wow_loot::LootClaimCommitError), + Database(DatabaseError), + CommitOutcomeUnknown(DatabaseError), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct DurableLootMoneyDbOutcomeLikeCpp { + before: u64, + after: u64, + applied_delta: u64, +} + +/// Owned exclusion held while an absolute character-money mutation derives +/// and persists its new value. Admission stays closed and the same +/// per-character serial lock used by group/stored loot remains held through +/// the caller's COMMIT. +#[must_use] +pub(crate) struct ExclusivePlayerMoneyPersistenceLikeCpp { + _save_fence: DurableLootMoneySaveFenceLikeCpp, + _mutation_lock: tokio::sync::OwnedMutexGuard<()>, +} + +/// Pure post-reset snapshot used to make the durable talent reset and its +/// runtime publication describe the same state. +/// +/// C++ `Player::ResetTalents` calls `RemoveTalent` for the active group, then +/// `_SaveTalents` rewrites every group. `RemoveTalent` calls +/// `RemoveSpell(..., disabled=true)`, and the complete C++ `_SaveSpells` path +/// rewrites those rows with their exact active/disabled/favorite state. Rust +/// does not retain the full `PlayerSpellMap` active/disabled/temporary state, +/// so this deliberately leaves `character_spell` and +/// `character_spell_favorite` untouched. A known non-dependent spell proves a +/// normal persisted row exists; it does not prove that the talent is its only +/// source. Deleting that row would lose normal ownership where C++ instead +/// preserves a disabled row. Recursive `RemoveSpell` persistence and exact +/// disabled/favorite preservation therefore remain a represented boundary, +/// while active `character_talent` rows can no longer survive a committed fee. +#[derive(Debug, Clone, PartialEq, Eq)] +struct RepresentedTalentResetStatePlanLikeCpp { + active_group: u8, + active_talents: BTreeMap, + post_talents: [BTreeMap; MAX_SPECIALIZATIONS_LIKE_CPP], +} + +/// A successfully committed reset whose covered runtime state still has to be +/// published synchronously before the money exclusion is released. +#[must_use] +pub(crate) struct CommittedRepresentedTalentResetLikeCpp { + money_persistence: ExclusivePlayerMoneyPersistenceLikeCpp, + old_money: u64, + new_money: u64, + cost: u32, + reset_time_secs: u64, + state_plan: RepresentedTalentResetStatePlanLikeCpp, +} + +/// Once a SQL transaction containing an absolute money write starts awaiting +/// COMMIT, cancellation is itself an unknown outcome. This synchronous drop +/// fence prevents a cancelled packet/shutdown future from reopening payout +/// admission and then letting disconnect-save overwrite a transaction whose +/// COMMIT reply was never observed. +struct PlayerMoneyCommitCancellationFenceLikeCpp { + tracker: Arc, + armed: bool, +} + +impl PlayerMoneyCommitCancellationFenceLikeCpp { + fn new(tracker: Arc) -> Self { + Self { + tracker, + armed: true, + } + } + + fn disarm_like_cpp(&mut self) { + self.armed = false; + } +} + +impl Drop for PlayerMoneyCommitCancellationFenceLikeCpp { + fn drop(&mut self) { + if self.armed { + self.tracker.mark_indeterminate_like_cpp(); + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum AbsolutePlayerMoneyCommitReconciliationLikeCpp { + Committed, + RolledBack, + Indeterminate, +} + +/// Reconcile an ambiguous COMMIT using a money row whose value changed in the +/// transaction. Equal before/after values are deliberately not evidence: a +/// caller may have bundled other durable mutations whose outcome cannot be +/// inferred from an unchanged money column. +fn reconcile_absolute_player_money_commit_like_cpp( + money_before: u64, + money_after: u64, + observed_money: Option, +) -> AbsolutePlayerMoneyCommitReconciliationLikeCpp { + if money_before == money_after { + return AbsolutePlayerMoneyCommitReconciliationLikeCpp::Indeterminate; + } + + match observed_money { + Some(observed) if observed == money_after => { + AbsolutePlayerMoneyCommitReconciliationLikeCpp::Committed + } + Some(observed) if observed == money_before => { + AbsolutePlayerMoneyCommitReconciliationLikeCpp::RolledBack + } + Some(_) | None => AbsolutePlayerMoneyCommitReconciliationLikeCpp::Indeterminate, + } +} + +/// Routing data retained by the detached durable worker so viewers that open +/// the same C++ `Loot` while SQL is in flight are not missed. The worker +/// samples the authority only after the money claim commits; an opener before +/// that point saw the non-zero pool and receives `CoinRemoved`, while a later +/// opener observes zero directly in its `LootResponse`. +pub(crate) struct LootMoneyViewerFanoutLikeCpp { + pub scope_player: ObjectGuid, + pub source_player: ObjectGuid, + pub source_command_tx: flume::Sender, + pub player_registry: Option>, + pub map_id: u16, + pub instance_id: u32, + pub loot_owner: ObjectGuid, + pub loot_obj: ObjectGuid, + pub authority: OwnedLootAuthority, + pub authority_generation: u64, + pub payout_recipients: HashSet, +} + +/// Routing state for one durable item claim. The completion owns this +/// independently of the packet waiter, so a timeout, cancellation, or later +/// `CMSG_LOOT_RELEASE` cannot suppress the result of an already ordered claim. +/// C++ serializes these handlers and notifies synchronously; Rust's SQL wait is +/// an implementation detail, so the pre-COMMIT cohort is retained and the +/// exact COMMIT snapshot adds viewers that opened during that wait. `published` +/// is shared with the normal handler path and provides the single publication +/// CAS. +#[derive(Clone)] +pub(crate) struct DurableLootItemFanoutLikeCpp { + pub owner_guid: ObjectGuid, + pub loot_obj: ObjectGuid, + pub loot_list_id: u8, + pub player_guid: ObjectGuid, + pub free_for_all: bool, + pub authority: OwnedLootAuthority, + pub authority_generation: u64, + pub precommit_snapshot: OwnedLootSnapshot, + /// Exact post-mutation pool captured by the claim commit while the + /// authority mutex is still held. A viewer opening after that point has + /// already observed the removed item and must not receive a stale + /// `LootRemoved` fanout. + pub committed_snapshot: Arc>, + pub source_send_tx: flume::Sender>, + pub player_registry: Option>, + pub map_id: u16, + pub instance_id: u32, + pub published: Arc, +} + +impl std::fmt::Debug for DurableLootItemFanoutLikeCpp { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("DurableLootItemFanoutLikeCpp") + .field("owner_guid", &self.owner_guid) + .field("loot_obj", &self.loot_obj) + .field("loot_list_id", &self.loot_list_id) + .field("player_guid", &self.player_guid) + .field("free_for_all", &self.free_for_all) + .field("authority_generation", &self.authority_generation) + .field("map_id", &self.map_id) + .field("instance_id", &self.instance_id) + .field("published", &self.published.load(Ordering::Acquire)) + .finish_non_exhaustive() + } +} + +/// Runtime publication retained by a detached durable-loot worker after its +/// SQL transaction commits. Every detached item grant is tracked until the +/// live Player inventory is synchronized; Item owners additionally use the +/// completion to publish stored-container removal/release state. The same +/// tracker also prevents a committed stored-container money payout from being +/// overwritten by a stale disconnect save. +#[derive(Debug, Clone)] +pub(crate) struct DurableItemLootCompletionLikeCpp { + pub owner_guid: ObjectGuid, + pub loot_list_id: u8, + pub player_guid: ObjectGuid, + pub item_owner_auto_release: bool, + /// Delta accepted from the character row locked in the same transaction + /// that deletes an Item owner's stored-money row. Keeping a delta preserves + /// intervening local/group changes; `None` identifies an item grant. + pub durable_item_money_applied_amount: Option, + /// Original C++ loot-money notification amount. This is retained instead + /// of reconstructing it from runtime balances, and is emitted even when it + /// is zero. + pub durable_item_money_notified_amount: Option, + /// Exact-once gate shared with the per-character durable money tracker. + /// A save fence may apply the balance delta before this completion gets a + /// chance to publish source removal and client notification. + pub durable_item_money_balance_applied: Option>, + /// Retained only for object-owned item claims. Stored-container money has + /// its own durable fanout route. + pub item_fanout: Option, + /// Exact-once gate for item/source publication and lifecycle. This is + /// intentionally separate from `durable_item_money_balance_applied`. + /// Item-grant completions observed false require a relog; stored-money + /// completions can publish source removal after a save applied the balance. + pub runtime_inventory_applied: Arc, +} + +#[derive(Debug, Default)] +struct DurableItemLootPersistenceStateLikeCpp { + in_flight: usize, + completions: Vec, +} + +/// Session-local counterpart to an authority persistence guard for durable +/// loot grants. It lets logout/disconnect wait for detached item/money +/// transactions, publish their committed runtime state, and only then run C++ +/// `DoLootReleaseAll` and save the Player. +#[derive(Debug, Clone)] +pub(crate) struct DurableItemLootPersistenceTrackerLikeCpp { + state: Arc>, + changed: tokio::sync::watch::Sender, +} + +impl Default for DurableItemLootPersistenceTrackerLikeCpp { + fn default() -> Self { + let (changed, _) = tokio::sync::watch::channel(0); + Self { + state: Arc::new(Mutex::new(DurableItemLootPersistenceStateLikeCpp::default())), + changed, + } + } +} + +impl DurableItemLootPersistenceTrackerLikeCpp { + pub(crate) fn begin_like_cpp(&self) -> DurableItemLootPersistenceGuardLikeCpp { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .in_flight += 1; + DurableItemLootPersistenceGuardLikeCpp { + tracker: self.clone(), + completion: None, + } + } + + pub(crate) async fn wait_until_idle_like_cpp(&self) { + let mut changed = self.changed.subscribe(); + loop { + if self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .in_flight + == 0 + { + return; + } + if changed.changed().await.is_err() { + return; + } + } + } + + pub(crate) fn take_completions_like_cpp(&self) -> Vec { + std::mem::take( + &mut self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .completions, + ) + } +} + +#[derive(Debug)] +pub(crate) struct DurableItemLootPersistenceGuardLikeCpp { + tracker: DurableItemLootPersistenceTrackerLikeCpp, + completion: Option, +} + +impl DurableItemLootPersistenceGuardLikeCpp { + pub(crate) fn mark_committed_like_cpp(&mut self, completion: DurableItemLootCompletionLikeCpp) { + self.completion = Some(completion); + } +} + +impl Drop for DurableItemLootPersistenceGuardLikeCpp { + fn drop(&mut self) { + let mut state = self + .tracker + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(completion) = self.completion.take() { + state.completions.push(completion); + } + state.in_flight = state.in_flight.saturating_sub(1); + drop(state); + self.tracker + .changed + .send_modify(|version| *version = version.wrapping_add(1)); + } +} + +#[cfg(test)] +mod durable_item_loot_persistence_tracker_tests { + use std::time::Duration; + + use super::DurableItemLootPersistenceTrackerLikeCpp; + + #[tokio::test] + async fn completion_between_idle_check_and_wait_poll_is_not_lost_like_cpp() { + let tracker = DurableItemLootPersistenceTrackerLikeCpp::default(); + let guard = tracker.begin_like_cpp(); + let mut changed = tracker.changed.subscribe(); + assert_eq!( + tracker + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .in_flight, + 1 + ); + + // Deliberately publish after the locked busy observation but before + // `changed()` is first polled. watch retains the version transition. + drop(guard); + tokio::time::timeout(Duration::from_secs(1), changed.changed()) + .await + .expect("durable item persistence wake must not be lost") + .unwrap(); + tracker.wait_until_idle_like_cpp().await; + } +} + +impl std::fmt::Display for LootMoneyPersistenceErrorLikeCpp { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MissingPlayer => formatter.write_str("loot-money player is missing"), + Self::MissingCharacterDatabase => { + formatter.write_str("loot-money character database is missing") + } + Self::WorkerTerminated => formatter.write_str("loot-money persistence worker stopped"), + Self::Claim(error) => write!(formatter, "loot-money claim failure: {error:?}"), + Self::Database(error) => write!(formatter, "loot-money database failure: {error}"), + Self::CommitOutcomeUnknown(error) => { + write!(formatter, "loot-money COMMIT outcome is unknown: {error}") + } + } + } +} + +impl std::error::Error for LootMoneyPersistenceErrorLikeCpp { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Database(error) | Self::CommitOutcomeUnknown(error) => Some(error), + Self::MissingPlayer + | Self::MissingCharacterDatabase + | Self::WorkerTerminated + | Self::Claim(_) => None, + } + } +} + +/// C++ `Player::ModifyMoney` accepts the whole positive delta or leaves the +/// balance unchanged when it would cross `MAX_MONEY_AMOUNT`. +pub(crate) fn loot_money_durable_outcome_like_cpp( + current_money: u64, + requested_delta: u64, +) -> (u64, u64) { + current_money + .checked_add(requested_delta) + .filter(|new_money| *new_money <= MAX_MONEY_AMOUNT) + .map_or((current_money, 0), |new_money| (new_money, requested_delta)) +} + +#[derive(Debug)] +enum GroupLootMoneyAttemptErrorLikeCpp { + DefinitelyRolledBack(LootMoneyPersistenceErrorLikeCpp), + CommitOutcomeUnknown { + error: DatabaseError, + outcomes: HashMap, + }, +} + +fn group_loot_money_attempt_is_deadlock_like_cpp( + error: &GroupLootMoneyAttemptErrorLikeCpp, +) -> bool { + matches!( + error, + GroupLootMoneyAttemptErrorLikeCpp::DefinitelyRolledBack( + LootMoneyPersistenceErrorLikeCpp::Database(error) + ) if is_database_deadlock_like_cpp(error) + ) +} + +async fn attempt_group_loot_money_transaction_like_cpp( + char_db: &CharacterDatabase, + payouts: &[(ObjectGuid, u64)], +) -> Result, GroupLootMoneyAttemptErrorLikeCpp> +{ + let definitely = |error| { + GroupLootMoneyAttemptErrorLikeCpp::DefinitelyRolledBack( + LootMoneyPersistenceErrorLikeCpp::Database(DatabaseError::from(error)), + ) + }; + let mut transaction = char_db.pool().begin().await.map_err(definitely)?; + let mut outcomes = HashMap::with_capacity(payouts.len()); + for (recipient, amount) in payouts { + let row = sqlx::query(CharStatements::SEL_CHAR_MONEY_FOR_UPDATE.sql()) + .bind(recipient.counter() as u64) + .fetch_optional(&mut *transaction) + .await + .map_err(definitely)? + .ok_or_else(|| { + GroupLootMoneyAttemptErrorLikeCpp::DefinitelyRolledBack( + LootMoneyPersistenceErrorLikeCpp::MissingPlayer, + ) + })?; + let current_money = row.try_get::("money").map_err(definitely)?; + let (new_money, applied_delta) = + loot_money_durable_outcome_like_cpp(current_money, *amount); + if applied_delta != 0 { + let update_result = sqlx::query("UPDATE characters SET money = ? WHERE guid = ?") + .bind(new_money) + .bind(recipient.counter() as u64) + .execute(&mut *transaction) + .await + .map_err(definitely)?; + if update_result.rows_affected() != 1 { + return Err(GroupLootMoneyAttemptErrorLikeCpp::DefinitelyRolledBack( + LootMoneyPersistenceErrorLikeCpp::Database(DatabaseError::Transaction( + format!( + "loot-money update for character {} affected {} rows; expected exactly 1", + recipient.counter(), + update_result.rows_affected() + ), + )), + )); + } + } + outcomes.insert( + *recipient, + DurableLootMoneyDbOutcomeLikeCpp { + before: current_money, + after: new_money, + applied_delta, + }, + ); + } + match transaction.commit().await { + Ok(()) => Ok(outcomes), + Err(error) => { + let error = DatabaseError::from(error); + if is_database_deadlock_like_cpp(&error) { + Err(GroupLootMoneyAttemptErrorLikeCpp::DefinitelyRolledBack( + LootMoneyPersistenceErrorLikeCpp::Database(error), + )) + } else { + Err(GroupLootMoneyAttemptErrorLikeCpp::CommitOutcomeUnknown { error, outcomes }) + } + } + } +} + /// Live seam for C++ `ScriptMgr::OnAreaTrigger`. /// /// A ported content script receives the mutable session and returns the same @@ -1174,8 +1665,16 @@ pub(crate) struct RepresentedLootRollVote { #[derive(Debug, Clone)] pub(crate) struct RepresentedLootRollState { + pub owner_guid: ObjectGuid, pub loot_obj: ObjectGuid, pub loot_list_id: u8, + pub authority: OwnedLootAuthority, + pub authority_generation: u64, + pub authority_scope: OwnedLootScope, + /// Exact C++ `LootRoll*` lifetime surrogate published to remote sessions. + /// This must change even if a replacement reuses the same packet key, + /// authority allocation, and authority generation. + pub command_identity: LootRollCommandIdentityLikeCpp, pub end_time: Instant, pub voters: HashMap, } @@ -3013,22 +3512,96 @@ pub(crate) fn sync_canonical_creature_entity_on_map_like_cpp( map_id: u32, instance_id: u32, mut creature: wow_entities::Creature, -) { +) -> Option { let guid = creature.unit().world().object().guid(); let Ok(mut manager) = manager.lock() else { - return; + return None; }; let Some(map) = manager.find_map_mut(map_id, instance_id) else { - return; + return None; }; if map.map().get_creature(guid).is_none() { - return; + return None; + } + + if let Some(current_authority) = map + .map() + .get_typed_creature(guid) + .map(|current| current.loot_authority_like_cpp().clone()) + { + let incoming_authority = creature.loot_authority_like_cpp().clone(); + let current_stamp = current_authority.stamp_like_cpp(); + let incoming_stamp = incoming_authority.stamp_like_cpp(); + let authority = reconcile_creature_loot_authority_mirrors_like_cpp( + ¤t_authority, + current_stamp, + &incoming_authority, + incoming_stamp, + ); + map.map_mut() + .get_typed_creature_mut(guid)? + .rebind_loot_authority_if_current_like_cpp( + ¤t_authority, + current_stamp, + authority.clone(), + )?; + // `creature` is a cloned transport snapshot whose old authority is + // still owned by the live legacy entity. Do not detach it here; the + // caller performs the expected-stamp CAS on that actual entity. + creature.adopt_loot_authority_for_snapshot_like_cpp(authority); } creature.unit_mut().world_mut().object_mut().add_to_world(); let Ok(record) = wow_entities::MapObjectRecord::new_creature(creature) else { - return; + return None; }; - let _ = map.map_mut().insert_map_object_record(record); + let authority = record + .creature() + .map(|creature| creature.loot_authority_like_cpp().clone())?; + map.map_mut().insert_map_object_record(record).ok()?; + Some(authority) +} + +/// Selects one backing authority for two mirrors without ever merging two +/// independently claimable active states. Distinct non-pristine authorities +/// are quarantined as one retired canonical tombstone. +pub(crate) fn reconcile_creature_loot_authority_mirrors_like_cpp( + canonical: &OwnedLootAuthority, + canonical_stamp: OwnedLootAuthorityStamp, + incoming: &OwnedLootAuthority, + incoming_stamp: OwnedLootAuthorityStamp, +) -> OwnedLootAuthority { + if canonical.shares_storage_like_cpp(incoming) { + return canonical.clone(); + } + + use OwnedLootAuthorityLifecycle::{Active, Detached, Pristine, Quarantined, Retired}; + + match (canonical_stamp.lifecycle, incoming_stamp.lifecycle) { + // Once divergent live pools were observed, keep the attached terminal + // tombstone until object destruction. It must not be reopened merely + // because another stale mirror still looks active. + (Quarantined, _) => canonical.clone(), + (_, Quarantined) => incoming.clone(), + // Two independently claimable live pools, or a live pool conflicting + // with an attached destruction tombstone, are ambiguous without a + // shared incarnation id. Converge on a terminal fail-closed authority. + (Active, Active) | (Active, Retired) | (Retired, Active) => { + return OwnedLootAuthority::new_retired_tombstone_like_cpp(); + } + // A live authority can safely fill a never-used placeholder. A + // detached allocation has already lost entity ownership. + (Active, Pristine | Detached) => canonical.clone(), + (Pristine | Detached, Active) => incoming.clone(), + // A still-attached retired authority is the lifetime tombstone shared + // across respawn/restock. A displaced authority is classified as + // `Detached`, so it cannot win this branch or be resurrected. + (Retired, Pristine) => canonical.clone(), + (Pristine, Retired) => incoming.clone(), + (Pristine, Pristine) | (Retired, Retired) => canonical.clone(), + (Detached, Detached) => OwnedLootAuthority::new_retired_tombstone_like_cpp(), + (Detached, _) => incoming.clone(), + (_, Detached) => canonical.clone(), + } } pub(crate) fn insert_canonical_creature_map_object_on_map_like_cpp( @@ -3036,16 +3609,39 @@ pub(crate) fn insert_canonical_creature_map_object_on_map_like_cpp( map_id: u32, instance_id: u32, mut creature: wow_entities::Creature, -) { +) -> Option { let guid = creature.unit().world().object().guid(); let Ok(mut manager) = manager.lock() else { - return; + return None; }; let Some(map) = manager.find_map_mut(map_id, instance_id) else { - return; + return None; }; if map.map().get_creature(guid).is_some() { - return; + let current = map.map_mut().get_typed_creature_mut(guid)?; + let current_authority = current.loot_authority_like_cpp().clone(); + let incoming_authority = creature.loot_authority_like_cpp().clone(); + let current_stamp = current_authority.stamp_like_cpp(); + let incoming_stamp = incoming_authority.stamp_like_cpp(); + let authority = reconcile_creature_loot_authority_mirrors_like_cpp( + ¤t_authority, + current_stamp, + &incoming_authority, + incoming_stamp, + ); + current.rebind_loot_authority_if_current_like_cpp( + ¤t_authority, + current_stamp, + authority.clone(), + )?; + creature.adopt_loot_authority_for_snapshot_like_cpp(authority.clone()); + return Some(authority); + } + + if creature.loot_authority_like_cpp().lifecycle_like_cpp() + == OwnedLootAuthorityLifecycle::Detached + { + return None; } let object = creature.unit().world().clone(); @@ -3053,10 +3649,12 @@ pub(crate) fn insert_canonical_creature_map_object_on_map_like_cpp( .map_mut() .add_to_map_like_cpp(AccessorObjectKind::Creature, object); creature.unit_mut().world_mut().object_mut().add_to_world(); + let authority = creature.loot_authority_like_cpp().clone(); let Ok(record) = wow_entities::MapObjectRecord::new_creature(creature) else { - return; + return None; }; - let _ = map.map_mut().insert_map_object_record(record); + map.map_mut().insert_map_object_record(record).ok()?; + Some(authority) } pub(crate) fn remove_canonical_creature_map_object_on_map_like_cpp( @@ -3922,6 +4520,8 @@ pub struct WorldSession { // Outbound channel (serialized bytes back to WorldSocket) send_tx: flume::Sender>, + // FIFO completion fence paired with the current physical send channel. + send_write_fence_like_cpp: Option, // Cross-session commands executed by this session's own update loop. session_command_tx: flume::Sender, @@ -4214,6 +4814,8 @@ pub struct WorldSession { // GUID generator for new characters guid_generator: Option>, + // Process-wide C++ ObjectMgr generator for new item instances. + item_guid_generator_like_cpp: Option>, // Characters confirmed for this account legit_characters: Vec, @@ -4482,6 +5084,8 @@ pub struct WorldSession { realm_packet_rx: Option>, /// Realm send channel — kept alive so the realm writer task persists. realm_send_tx: Option>>, + /// FIFO completion fence paired with `realm_send_tx` after ConnectTo. + realm_send_write_fence_like_cpp: Option, // ── Movement & World position ───────────────────────────────── /// Server-side position of the player (updated from CMSG_MOVE_*). @@ -5052,13 +5656,47 @@ pub struct WorldSession { /// Active loot windows keyed by creature GUID. pub(crate) loot_table: std::collections::HashMap, + /// Object-owned loot generation represented by each session-local packet cache. + pub(crate) represented_loot_cache_generations_like_cpp: + std::collections::HashMap, /// Mirrors C++ PlayerData::LootTargetGUID for guards that compare active loot by GUID. pub(crate) active_loot_guid: wow_core::ObjectGuid, /// Represented owner GUIDs currently visible through C++ `Player::m_AELootView`. pub(crate) active_loot_view_owners: std::collections::HashSet, + /// Object-owned generation that was actually opened for each active loot view. + /// + /// GUIDs are reused across creature respawns and gameobject restocks. A delayed + /// packet from an older window must therefore not be authorized merely because + /// the replacement lifetime has the same owner/loot GUID and player eligibility. + pub(crate) active_loot_view_generations_like_cpp: + std::collections::HashMap, + /// Exact backing allocation opened for each view. Scope epochs restart at + /// one in a newly allocated authority, so the generation map alone cannot + /// prevent ABA when a creature GUID is recreated. + pub(crate) active_loot_view_authorities_like_cpp: + std::collections::HashMap, + /// Detached durable loot grants and their post-commit runtime + /// publications. This covers claimed world-owner items plus Item-owner + /// items/money; Item owners have no map-owned loot authority. + durable_item_loot_persistence_like_cpp: DurableItemLootPersistenceTrackerLikeCpp, + /// Per-character fence published to remote loot sources before they begin + /// mutating this character's durable balance. + durable_loot_money_persistence_like_cpp: Arc, /// Represented pending group/NBG loot rolls keyed by `(LootObj, LootListID)`. pub(crate) represented_loot_rolls: std::collections::HashMap<(wow_core::ObjectGuid, u8), RepresentedLootRollState>, + /// Explicit test seam for persistence-sensitive loot-money paths. Production + /// never bypasses the character database. + #[cfg(test)] + loot_money_persistence_test_result_like_cpp: Option, + #[cfg(test)] + pub(crate) loot_item_store_test_grants_like_cpp: Option>, + #[cfg(test)] + pub(crate) loot_item_store_test_success_like_cpp: bool, + /// Optional test-only COMMIT gate for exercising remote command timeout + /// and release while the authority claim is already persistence-owned. + #[cfg(test)] + pub(crate) loot_item_store_test_commit_gate_like_cpp: Option>, #[cfg(test)] pub(crate) represented_loot_roll_criteria_events: Vec, #[cfg(test)] @@ -6020,6 +6658,7 @@ impl WorldSession { mute_time_like_cpp: 0, packet_rx, send_tx, + send_write_fence_like_cpp: None, session_command_tx, session_command_rx, state: SessionState::Authed, @@ -6180,6 +6819,7 @@ impl WorldSession { ("RustyCore".to_string(), "RustyCore".to_string()), )]), guid_generator: None, + item_guid_generator_like_cpp: None, legit_characters: Vec::new(), pending_packets: Vec::new(), player_loading: None, @@ -6331,6 +6971,7 @@ impl WorldSession { cuf_profiles_loaded_like_cpp: false, realm_packet_rx: None, realm_send_tx: None, + realm_send_write_fence_like_cpp: None, player_position: None, player_movement_flags_like_cpp: MovementFlag::NONE, represented_can_swim_to_fly_transition_like_cpp: false, @@ -6619,10 +7260,26 @@ impl WorldSession { MAX_SPECIALIZATIONS_LIKE_CPP], represented_glyphs_loaded_like_cpp: false, loot_table: std::collections::HashMap::new(), + represented_loot_cache_generations_like_cpp: std::collections::HashMap::new(), active_loot_guid: ObjectGuid::EMPTY, active_loot_view_owners: std::collections::HashSet::new(), + active_loot_view_generations_like_cpp: std::collections::HashMap::new(), + active_loot_view_authorities_like_cpp: std::collections::HashMap::new(), + durable_item_loot_persistence_like_cpp: + DurableItemLootPersistenceTrackerLikeCpp::default(), + durable_loot_money_persistence_like_cpp: Arc::new( + DurableLootMoneyPersistenceTrackerLikeCpp::default(), + ), represented_loot_rolls: std::collections::HashMap::new(), #[cfg(test)] + loot_money_persistence_test_result_like_cpp: None, + #[cfg(test)] + loot_item_store_test_grants_like_cpp: None, + #[cfg(test)] + loot_item_store_test_success_like_cpp: true, + #[cfg(test)] + loot_item_store_test_commit_gate_like_cpp: None, + #[cfg(test)] represented_loot_roll_criteria_events: Vec::new(), #[cfg(test)] represented_gameobject_criteria_events: Vec::new(), @@ -8624,10 +9281,11 @@ impl WorldSession { guid: ObjectGuid, f: impl FnOnce(&mut wow_entities::Creature) -> R, ) -> Option { - let map_id = u32::from(self.player_map_id_like_cpp()); + let map_key = self + .canonical_object_lookup_map_key_like_cpp(u32::from(self.player_map_id_like_cpp()))?; let manager = Arc::clone(self.canonical_map_manager.as_ref()?); let mut manager = manager.lock().ok()?; - let managed = manager.find_map_mut(map_id, 0)?; + let managed = manager.find_map_mut(map_key.map_id, map_key.instance_id)?; let creature = managed.map_mut().get_typed_creature_mut(guid)?; Some(f(creature)) } @@ -8637,10 +9295,11 @@ impl WorldSession { guid: ObjectGuid, f: impl FnOnce(&mut wow_entities::GameObject) -> R, ) -> Option { - let map_id = u32::from(self.player_map_id_like_cpp()); + let map_key = self + .canonical_object_lookup_map_key_like_cpp(u32::from(self.player_map_id_like_cpp()))?; let manager = Arc::clone(self.canonical_map_manager.as_ref()?); let mut manager = manager.lock().ok()?; - let managed = manager.find_map_mut(map_id, 0)?; + let managed = manager.find_map_mut(map_key.map_id, map_key.instance_id)?; let gameobject = managed.map_mut().get_typed_game_object_mut(guid)?; Some(f(gameobject)) } @@ -8652,11 +9311,13 @@ impl WorldSession { if guid.is_empty() || !guid.is_game_object() { return None; } + let map_key = self + .canonical_object_lookup_map_key_like_cpp(u32::from(self.player_map_id_like_cpp()))?; let manager = self.canonical_map_manager.as_ref()?; let Ok(manager) = manager.lock() else { return None; }; - let map = manager.find_map(u32::from(self.player_map_id_like_cpp()), 0)?; + let map = manager.find_map(map_key.map_id, map_key.instance_id)?; let gameobject = map.map().get_typed_game_object(guid)?; let linked_trap_guid = gameobject.linked_trap_guid_like_cpp(); (!linked_trap_guid.is_empty()).then_some(linked_trap_guid) @@ -8679,11 +9340,12 @@ impl WorldSession { chest_restock_time_secs: u32, shared_loot_is_changed_like_cpp: bool, ) -> Option { - let map_id = u32::from(self.player_map_id_like_cpp()); + let map_key = self + .canonical_object_lookup_map_key_like_cpp(u32::from(self.player_map_id_like_cpp()))?; let game_time_secs = i64::try_from(wow_core::GameTime::now().as_secs()).unwrap_or(i64::MAX); let manager = Arc::clone(self.canonical_map_manager.as_ref()?); let mut manager = manager.lock().ok()?; - let managed = manager.find_map_mut(map_id, 0)?; + let managed = manager.find_map_mut(map_key.map_id, map_key.instance_id)?; Some(managed.map_mut().set_gameobject_loot_state_like_cpp( guid, state, @@ -8694,6 +9356,93 @@ impl WorldSession { )) } + /// Applies the global fully-looted transition only if the exact authority + /// generation and pool topology observed by `DoLootRelease` are still + /// current. The canonical map lock is acquired before the authority lock, + /// matching personal-loot upsert order and making check+state mutation one + /// C++-serialized operation. + pub(crate) fn set_canonical_gameobject_loot_state_if_fully_looted_observation_like_cpp( + &mut self, + guid: ObjectGuid, + authority: &OwnedLootAuthority, + object_generation: u64, + lifecycle_revision: u64, + state: wow_entities::LootState, + unit_guid: Option, + chest_restock_time_secs: u32, + shared_loot_is_changed_like_cpp: bool, + ) -> Option { + let map_key = self + .canonical_object_lookup_map_key_like_cpp(u32::from(self.player_map_id_like_cpp()))?; + let game_time_secs = i64::try_from(wow_core::GameTime::now().as_secs()).unwrap_or(i64::MAX); + let manager = Arc::clone(self.canonical_map_manager.as_ref()?); + let mut manager = manager.lock().ok()?; + let managed = manager.find_map_mut(map_key.map_id, map_key.instance_id)?; + let object_authority = managed + .map() + .get_typed_game_object(guid)? + .loot_authority_like_cpp() + .clone(); + if !object_authority.shares_storage_like_cpp(authority) { + return None; + } + + authority.with_fully_looted_lifecycle_observation_like_cpp( + object_generation, + lifecycle_revision, + || { + managed.map_mut().set_gameobject_loot_state_like_cpp( + guid, + state, + unit_guid, + game_time_secs, + chest_restock_time_secs, + shared_loot_is_changed_like_cpp, + ) + }, + ) + } + + /// C++ fishing-hole release performs AddUse, MaxOpens comparison, and + /// SetLootState on one world thread. Keep all three under one map lock so + /// two concurrent personal releases cannot finish in `Ready` after max. + pub(crate) fn release_canonical_fishing_hole_like_cpp( + &mut self, + guid: ObjectGuid, + max_opens: Option, + ) -> Option<( + u32, + wow_entities::LootState, + wow_map::map::GameObjectSetLootStateOutcomeLikeCpp, + )> { + let map_key = self + .canonical_object_lookup_map_key_like_cpp(u32::from(self.player_map_id_like_cpp()))?; + let game_time_secs = i64::try_from(wow_core::GameTime::now().as_secs()).unwrap_or(i64::MAX); + let manager = Arc::clone(self.canonical_map_manager.as_ref()?); + let mut manager = manager.lock().ok()?; + let managed = manager.find_map_mut(map_key.map_id, map_key.instance_id)?; + let map = managed.map_mut(); + let use_count = { + let gameobject = map.get_typed_game_object_mut(guid)?; + gameobject.add_use_like_cpp(); + gameobject.use_times() + }; + let loot_state = if max_opens.is_some_and(|max_opens| use_count >= max_opens) { + wow_entities::LootState::JustDeactivated + } else { + wow_entities::LootState::Ready + }; + let outcome = map.set_gameobject_loot_state_like_cpp( + guid, + loot_state, + None, + game_time_secs, + 0, + false, + ); + Some((use_count, loot_state, outcome)) + } + pub(crate) fn add_use_and_get_canonical_gameobject_use_count_like_cpp( &mut self, guid: ObjectGuid, @@ -10844,7 +11593,11 @@ impl WorldSession { creature }; canonical_creature.clear_data_changes(); - self.insert_canonical_creature_map_object_like_cpp(map_id, canonical_creature.clone()); + if let Some(authority) = + self.insert_canonical_creature_map_object_like_cpp(map_id, canonical_creature.clone()) + { + canonical_creature.rebind_loot_authority_like_cpp(authority); + } if let Some(manager) = &self.map_manager { let (grid_x, grid_y) = crate::map_manager::world_to_grid_coords(position.x, position.y); @@ -10877,28 +11630,32 @@ impl WorldSession { &mut self, map_id: u16, creature: wow_entities::Creature, - ) { + ) -> Option { let Some(manager) = self.canonical_map_manager.as_ref() else { - return; + return None; }; insert_canonical_creature_map_object_on_map_like_cpp( manager, u32::from(map_id), 0, creature, - ); + ) } pub(crate) fn remove_world_creature( &mut self, guid: ObjectGuid, ) -> Option { + let (map_id, instance_id) = self.current_legacy_runtime_map_key_like_cpp(); let manager = self.map_manager.as_ref().cloned()?; let removed = { let mut manager = manager .write() .unwrap_or_else(|poisoned| poisoned.into_inner()); - manager.remove_creature_any(self.player_map_id_like_cpp(), 0, guid) + if let Some(creature) = manager.find_creature_mut(map_id, instance_id, guid) { + creature.creature.clear_loot_like_cpp(); + } + manager.remove_creature_any(map_id, instance_id, guid) }; if removed.is_some() { self.remove_canonical_creature_map_object_like_cpp(guid); @@ -10907,13 +11664,14 @@ impl WorldSession { } fn remove_canonical_creature_map_object_like_cpp(&mut self, guid: ObjectGuid) { + let (map_id, instance_id) = self.current_legacy_runtime_map_key_like_cpp(); let Some(manager) = self.canonical_map_manager.as_ref() else { return; }; remove_canonical_creature_map_object_on_map_like_cpp( manager, - u32::from(self.player_map_id_like_cpp()), - 0, + u32::from(map_id), + instance_id, guid, ); } @@ -10923,28 +11681,41 @@ impl WorldSession { guid: ObjectGuid, position: wow_core::Position, ) { + let (map_id, instance_id) = self.current_legacy_runtime_map_key_like_cpp(); let Some(manager) = self.canonical_map_manager.as_ref() else { return; }; relocate_canonical_creature_map_object_on_map_like_cpp( manager, - u32::from(self.player_map_id_like_cpp()), - 0, + u32::from(map_id), + instance_id, guid, position, ); } fn sync_canonical_creature_entity_like_cpp(&mut self, creature: wow_entities::Creature) { + let guid = creature.guid(); + let expected_legacy_authority = creature.loot_authority_like_cpp().clone(); + let expected_legacy_stamp = expected_legacy_authority.stamp_like_cpp(); + let (map_id, instance_id) = self.current_legacy_runtime_map_key_like_cpp(); let Some(manager) = self.canonical_map_manager.as_ref() else { return; }; - sync_canonical_creature_entity_on_map_like_cpp( + let authority = sync_canonical_creature_entity_on_map_like_cpp( manager, - u32::from(self.player_map_id_like_cpp()), - 0, + u32::from(map_id), + instance_id, creature, ); + if let Some(authority) = authority { + let _ = self.rebind_legacy_creature_loot_authority_like_cpp( + guid, + &expected_legacy_authority, + expected_legacy_stamp, + authority, + ); + } } pub(crate) fn record_represented_gameobject_runtime_state_like_cpp( @@ -11080,11 +11851,13 @@ impl WorldSession { if guid.is_empty() || !guid.is_game_object() { return None; } + let map_key = self + .canonical_object_lookup_map_key_like_cpp(u32::from(self.player_map_id_like_cpp()))?; let manager = self.canonical_map_manager.as_ref()?; let Ok(manager) = manager.lock() else { return None; }; - let map = manager.find_map(u32::from(self.player_map_id_like_cpp()), 0)?; + let map = manager.find_map(map_key.map_id, map_key.instance_id)?; let game_object = map.map().get_typed_game_object(guid)?; Some(RepresentedGameObjectAccessLikeCpp { entry: game_object.world().object().entry(), @@ -11200,11 +11973,13 @@ impl WorldSession { if guid.is_empty() || !guid.is_any_type_creature() { return None; } + let map_key = self + .canonical_object_lookup_map_key_like_cpp(u32::from(self.player_map_id_like_cpp()))?; let manager = self.canonical_map_manager.as_ref()?; let Ok(manager) = manager.lock() else { return None; }; - let map = manager.find_map(u32::from(self.player_map_id_like_cpp()), 0)?; + let map = manager.find_map(map_key.map_id, map_key.instance_id)?; let record = map.map().map_object_record(guid)?; let creature = if guid.is_pet() { record.pet()?.creature() @@ -12735,13 +13510,18 @@ impl WorldSession { let Some(owner_guid) = owner_guid else { return; }; + let Some(map_key) = + self.canonical_object_lookup_map_key_like_cpp(u32::from(self.player_map_id_like_cpp())) + else { + return; + }; let Some(manager) = self.canonical_map_manager.as_ref() else { return; }; let Ok(mut manager) = manager.lock() else { return; }; - let Some(map) = manager.find_map_mut(u32::from(self.player_map_id_like_cpp()), 0) else { + let Some(map) = manager.find_map_mut(map_key.map_id, map_key.instance_id) else { return; }; if let Some(game_object) = map.map_mut().get_typed_game_object_mut(guid) { @@ -12750,13 +13530,18 @@ impl WorldSession { } fn set_canonical_gameobject_spell_id_like_cpp(&mut self, guid: ObjectGuid, spell_id: u32) { + let Some(map_key) = + self.canonical_object_lookup_map_key_like_cpp(u32::from(self.player_map_id_like_cpp())) + else { + return; + }; let Some(manager) = self.canonical_map_manager.as_ref() else { return; }; let Ok(mut manager) = manager.lock() else { return; }; - let Some(map) = manager.find_map_mut(u32::from(self.player_map_id_like_cpp()), 0) else { + let Some(map) = manager.find_map_mut(map_key.map_id, map_key.instance_id) else { return; }; if let Some(game_object) = map.map_mut().get_typed_game_object_mut(guid) { @@ -12768,15 +13553,19 @@ impl WorldSession { &self, guid: ObjectGuid, ) -> Option { - let canonical_owner = self - .canonical_map_manager - .as_ref() - .and_then(|manager| manager.lock().ok()) - .and_then(|manager| { - manager - .find_map(u32::from(self.player_map_id_like_cpp()), 0) - .and_then(|map| map.map().get_typed_game_object(guid)) - .map(|game_object| game_object.owner_guid()) + let map_key = + self.canonical_object_lookup_map_key_like_cpp(u32::from(self.player_map_id_like_cpp())); + let canonical_owner = map_key + .and_then(|map_key| { + self.canonical_map_manager + .as_ref() + .and_then(|manager| manager.lock().ok()) + .and_then(|manager| { + manager + .find_map(map_key.map_id, map_key.instance_id) + .and_then(|map| map.map().get_typed_game_object(guid)) + .map(|game_object| game_object.owner_guid()) + }) }) .filter(|owner_guid| !owner_guid.is_empty()); canonical_owner.or_else(|| { @@ -12828,13 +13617,21 @@ impl WorldSession { .represented_gameobject_use_states .get(&guid) .and_then(|state| state.owner_guid); + let Some(map_key) = self.canonical_object_lookup_map_key_like_cpp(u32::from(map_id)) else { + return; + }; + // A represented object from a stale client/map context must never be + // materialized beside the player in a different map. + if map_key.map_id != u32::from(map_id) { + return; + } let Some(manager) = self.canonical_map_manager.as_ref() else { return; }; let Ok(mut manager) = manager.lock() else { return; }; - let Some(map) = manager.find_map_mut(u32::from(map_id), 0) else { + let Some(map) = manager.find_map_mut(map_key.map_id, map_key.instance_id) else { return; }; if map.map().get_game_object(guid).is_some() { @@ -12855,7 +13652,7 @@ impl WorldSession { } if game_object .world_mut() - .set_map(u32::from(map_id), 0) + .set_map(map_key.map_id, map_key.instance_id) .is_err() { return; @@ -12871,19 +13668,192 @@ impl WorldSession { let _ = map.map_mut().insert_map_object_record(record); } + pub(crate) fn read_legacy_creature_loot_authority_like_cpp( + &self, + guid: ObjectGuid, + ) -> Option { + let (map_id, instance_id) = self.current_legacy_runtime_map_key_like_cpp(); + self.read_legacy_creature_loot_authority_on_map_like_cpp( + guid, + wow_map::MapKey::new(u32::from(map_id), instance_id), + ) + } + + pub(crate) fn read_legacy_creature_loot_authority_on_map_like_cpp( + &self, + guid: ObjectGuid, + map_key: wow_map::MapKey, + ) -> Option { + let map_id = u16::try_from(map_key.map_id).ok()?; + let manager = self.map_manager.as_ref()?; + manager + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .find_creature(map_id, map_key.instance_id, guid) + .map(|world_creature| world_creature.creature.loot_authority_like_cpp().clone()) + } + + pub(crate) fn rebind_legacy_creature_loot_authority_like_cpp( + &self, + guid: ObjectGuid, + expected: &OwnedLootAuthority, + expected_stamp: OwnedLootAuthorityStamp, + authority: OwnedLootAuthority, + ) -> Option { + let (map_id, instance_id) = self.current_legacy_runtime_map_key_like_cpp(); + self.rebind_legacy_creature_loot_authority_on_map_like_cpp( + guid, + wow_map::MapKey::new(u32::from(map_id), instance_id), + expected, + expected_stamp, + authority, + ) + } + + pub(crate) fn rebind_legacy_creature_loot_authority_on_map_like_cpp( + &self, + guid: ObjectGuid, + map_key: wow_map::MapKey, + expected: &OwnedLootAuthority, + expected_stamp: OwnedLootAuthorityStamp, + authority: OwnedLootAuthority, + ) -> Option { + let map_id = u16::try_from(map_key.map_id).ok()?; + let manager = self.map_manager.as_ref()?; + manager + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .find_creature_mut(map_id, map_key.instance_id, guid) + .and_then(|world_creature| { + world_creature + .creature + .rebind_loot_authority_if_current_like_cpp(expected, expected_stamp, authority) + }) + } + + pub(crate) fn read_canonical_creature_loot_authority_like_cpp( + &self, + guid: ObjectGuid, + ) -> Option { + let map_key = self + .canonical_object_lookup_map_key_like_cpp(u32::from(self.player_map_id_like_cpp()))?; + self.read_canonical_creature_loot_authority_on_map_like_cpp(guid, map_key) + } + + pub(crate) fn read_canonical_creature_loot_authority_on_map_like_cpp( + &self, + guid: ObjectGuid, + map_key: wow_map::MapKey, + ) -> Option { + let manager = self.canonical_map_manager.as_ref()?; + let manager = manager.lock().ok()?; + manager + .find_map(map_key.map_id, map_key.instance_id)? + .map() + .get_typed_creature(guid) + .map(|creature| creature.loot_authority_like_cpp().clone()) + } + + pub(crate) fn rebind_canonical_creature_loot_authority_like_cpp( + &self, + guid: ObjectGuid, + expected: &OwnedLootAuthority, + expected_stamp: OwnedLootAuthorityStamp, + authority: OwnedLootAuthority, + ) -> Option { + let map_key = self + .canonical_object_lookup_map_key_like_cpp(u32::from(self.player_map_id_like_cpp()))?; + self.rebind_canonical_creature_loot_authority_on_map_like_cpp( + guid, + map_key, + expected, + expected_stamp, + authority, + ) + } + + pub(crate) fn rebind_canonical_creature_loot_authority_on_map_like_cpp( + &self, + guid: ObjectGuid, + map_key: wow_map::MapKey, + expected: &OwnedLootAuthority, + expected_stamp: OwnedLootAuthorityStamp, + authority: OwnedLootAuthority, + ) -> Option { + let manager = self.canonical_map_manager.as_ref()?; + let mut manager = manager.lock().ok()?; + manager + .find_map_mut(map_key.map_id, map_key.instance_id)? + .map_mut() + .get_typed_creature_mut(guid) + .and_then(|creature| { + creature.rebind_loot_authority_if_current_like_cpp( + expected, + expected_stamp, + authority, + ) + }) + } + + pub(crate) fn read_canonical_gameobject_loot_authority_like_cpp( + &self, + guid: ObjectGuid, + ) -> Option { + let map_key = self + .canonical_object_lookup_map_key_like_cpp(u32::from(self.player_map_id_like_cpp()))?; + self.read_canonical_gameobject_loot_authority_on_map_like_cpp(guid, map_key) + } + + pub(crate) fn read_canonical_gameobject_loot_authority_on_map_like_cpp( + &self, + guid: ObjectGuid, + map_key: wow_map::MapKey, + ) -> Option { + let manager = self.canonical_map_manager.as_ref()?; + let manager = manager.lock().ok()?; + manager + .find_map(map_key.map_id, map_key.instance_id)? + .map() + .get_typed_game_object(guid) + .map(|gameobject| gameobject.loot_authority_like_cpp().clone()) + } + + pub(crate) fn rebind_canonical_gameobject_loot_authority_like_cpp( + &self, + guid: ObjectGuid, + expected: &OwnedLootAuthority, + expected_stamp: OwnedLootAuthorityStamp, + authority: OwnedLootAuthority, + ) -> Option { + let map_key = self + .canonical_object_lookup_map_key_like_cpp(u32::from(self.player_map_id_like_cpp()))?; + let manager = self.canonical_map_manager.as_ref()?; + let mut manager = manager.lock().ok()?; + manager + .find_map_mut(map_key.map_id, map_key.instance_id)? + .map_mut() + .get_typed_game_object_mut(guid) + .and_then(|gameobject| { + gameobject.rebind_loot_authority_if_current_like_cpp( + expected, + expected_stamp, + authority, + ) + }) + } + pub(crate) fn mutate_world_creature(&mut self, guid: ObjectGuid, f: F) -> Option where F: FnOnce(&mut crate::map_manager::WorldCreature) -> R, { + let (map_id, instance_id) = self.current_legacy_runtime_map_key_like_cpp(); let mut f = Some(f); if let Some(manager) = self.map_manager.as_ref().cloned() { let result = { let mut manager = manager .write() .unwrap_or_else(|poisoned| poisoned.into_inner()); - if let Some(creature) = - manager.find_creature_mut(self.player_map_id_like_cpp(), 0, guid) - { + if let Some(creature) = manager.find_creature_mut(map_id, instance_id, guid) { let result = f.take().expect("creature mutator is called once")(creature); Some((result, creature.position(), creature.creature.clone())) } else { @@ -12900,6 +13870,46 @@ impl WorldSession { None } + pub(crate) fn mutate_world_creature_if_fully_looted_observation_like_cpp( + &mut self, + guid: ObjectGuid, + authority: &OwnedLootAuthority, + object_generation: u64, + lifecycle_revision: u64, + f: F, + ) -> Option + where + F: FnOnce(&mut crate::map_manager::WorldCreature) -> R, + { + let (map_id, instance_id) = self.current_legacy_runtime_map_key_like_cpp(); + let manager = self.map_manager.as_ref().cloned()?; + let guarded_result = { + let mut manager = manager + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let creature = manager.find_creature_mut(map_id, instance_id, guid)?; + if !creature + .creature + .loot_authority_like_cpp() + .shares_storage_like_cpp(authority) + { + return None; + } + authority.with_fully_looted_lifecycle_observation_like_cpp( + object_generation, + lifecycle_revision, + || { + let result = f(creature); + (result, creature.position(), creature.creature.clone()) + }, + ) + }?; + let (result, position, creature) = guarded_result; + self.relocate_canonical_creature_map_object_like_cpp(guid, position); + self.sync_canonical_creature_entity_like_cpp(creature); + Some(result) + } + pub(crate) fn pause_interacted_creature_movement_like_cpp(&mut self, guid: ObjectGuid) -> bool { self.mutate_world_creature(guid, |creature| { creature.pause_interaction_movement_like_cpp() @@ -12908,11 +13918,12 @@ impl WorldSession { } pub(crate) fn world_creature_guids(&self) -> Vec { + let (map_id, instance_id) = self.current_legacy_runtime_map_key_like_cpp(); if let Some(manager) = &self.map_manager { return manager .read() .unwrap_or_else(|poisoned| poisoned.into_inner()) - .creature_guids(self.player_map_id_like_cpp(), 0); + .creature_guids(map_id, instance_id); } Vec::new() @@ -12925,12 +13936,13 @@ impl WorldSession { let Some(manager) = &self.map_manager else { return Vec::new(); }; + let (map_id, instance_id) = self.current_legacy_runtime_map_key_like_cpp(); manager .read() .unwrap_or_else(|poisoned| poisoned.into_inner()) .active_creature_guids_for_player_update_like_cpp( - self.player_map_id_like_cpp(), - 0, + map_id, + instance_id, player_position, self.represented_player_phase_shift_like_cpp(), ) @@ -12942,9 +13954,9 @@ impl WorldSession { /// Push a `PendingRespawn` into the shared map's respawn queue. /// - /// instance_id=0: legacy path limitation — consistent with `register_world_creature`, - /// `remove_world_creature`, and `mutate_world_creature` which all hardcode 0. - /// The canonical respawn store (wow_map `RespawnStoreLikeCpp`) is a separate step. + /// The registration bootstrap still defaults to instance `0`; live removal/mutation + /// follows the canonical Player instance. The canonical respawn store + /// (`wow_map::RespawnStoreLikeCpp`) is a separate step. /// Lock is acquired, respawn is pushed, then lock is released before returning. /// No `.await` is performed under lock. pub(crate) fn push_map_respawn_like_cpp( @@ -12997,6 +14009,7 @@ impl WorldSession { let mut seen = std::collections::HashSet::new(); if let Some(manager) = &self.map_manager { + let (_, instance_id) = self.current_legacy_runtime_map_key_like_cpp(); let visibility_range = self.player_map_visibility_range_like_cpp(map_id); creatures.extend( manager @@ -13004,7 +14017,7 @@ impl WorldSession { .unwrap_or_else(|poisoned| poisoned.into_inner()) .get_visible_creatures_in_phase( map_id, - 0, + instance_id, position.x, position.y, position.z, @@ -13717,6 +14730,17 @@ impl WorldSession { self.guid_generator = Some(generator); } + /// Install the process-wide C++ + /// `sObjectMgr->GetGenerator()` mirror. + pub fn set_item_guid_generator_like_cpp(&mut self, generator: Arc) { + assert_eq!( + generator.high_guid(), + HighGuid::Item, + "item GUID allocator must use HighGuid::Item" + ); + self.item_guid_generator_like_cpp = Some(generator); + } + /// Set the login database for this session. pub fn set_login_db(&mut self, db: Arc) { self.login_db = Some(db); @@ -18677,7 +19701,6 @@ impl WorldSession { return false; }; - let top_level_slot = top_level_inventory_item.as_ref().map(|(slot, _)| *slot); let top_level_inventory_item = top_level_inventory_item.map(|(_, item)| item); let item_entry_id = top_level_inventory_item .as_ref() @@ -18689,9 +19712,6 @@ impl WorldSession { .unwrap_or_else(|| item_guid.counter() as u64); let current_durability = item_object.data().durability; let max_durability = item_object.data().max_durability; - let was_broken = item_object.is_broken(); - let equipped_slot = top_level_slot - .filter(|slot| is_equipment_packed_pos(make_item_pos(INVENTORY_SLOT_BAG_0, *slot))); let cost = self.item_durability_repair_cost_like_cpp( item_entry_id, current_durability, @@ -18700,18 +19720,64 @@ impl WorldSession { repair_cost_rate, ); - if take_cost { + if take_cost && cost != 0 { + let Some(money_persistence) = self + .begin_exclusive_player_money_persistence_like_cpp() + .await + else { + return false; + }; let old_money = self.player_gold_like_cpp(); if old_money < cost { return false; } - if cost != 0 { - self.apply_player_money_change_like_cpp(old_money, old_money - cost) - .await; - } - } + let new_money = old_money - cost; - if let Some(char_db) = self.char_db.as_ref() { + let money_persistence = if let Some(char_db) = self.char_db.as_ref().map(Arc::clone) { + let Some(player_guid) = self.player_guid() else { + return false; + }; + let mut transaction = SqlTransaction::new(); + transaction.append(Self::build_character_gold_save_statement_like_cpp( + new_money, + player_guid.counter() as u64, + )); + let mut durability = char_db.prepare(CharStatements::UPD_ITEM_INSTANCE_DURABILITY); + durability.set_u32(0, max_durability); + durability.set_u64(1, item_db_guid); + transaction.append(durability); + let Some(money_persistence) = self + .commit_exclusive_player_money_transaction_like_cpp( + money_persistence, + char_db.as_ref(), + transaction, + old_money, + new_money, + "single item durability repair", + ) + .await + else { + return false; + }; + money_persistence + } else { + // Unit fixtures do not install a CharacterDatabase. A live + // session always has one; without it no detached SQL payout + // can originate from this session either. + money_persistence + }; + + // The committed transaction repaired the item and charged money. + // Publish both runtime fields before the guard opens; otherwise a + // cancelled criteria drain can leave durable durability repaired + // while the live item remains broken. + self.stage_player_money_change_like_cpp(old_money, new_money); + let repaired = self.apply_inventory_item_durability_repair_runtime_like_cpp(item_guid); + drop(money_persistence); + self.drain_represented_quest_objective_progress_like_cpp() + .await; + return repaired; + } else if let Some(char_db) = self.char_db.as_ref() { let mut stmt = char_db.prepare(CharStatements::UPD_ITEM_INSTANCE_DURABILITY); stmt.set_u32(0, max_durability); stmt.set_u64(1, item_db_guid); @@ -18725,6 +19791,24 @@ impl WorldSession { } } + self.apply_inventory_item_durability_repair_runtime_like_cpp(item_guid) + } + + fn apply_inventory_item_durability_repair_runtime_like_cpp( + &mut self, + item_guid: ObjectGuid, + ) -> bool { + let top_level_slot = self + .inventory_items_like_cpp() + .iter() + .find_map(|(&slot, item)| (item.guid == item_guid).then_some(slot)); + let Some(item_object) = self.inventory_item_objects_like_cpp().get(&item_guid) else { + return false; + }; + let max_durability = item_object.data().max_durability; + let was_broken = item_object.is_broken(); + let equipped_slot = top_level_slot + .filter(|slot| is_equipment_packed_pos(make_item_pos(INVENTORY_SLOT_BAG_0, *slot))); let updated = self.update_inventory_item_object_like_cpp(item_guid, |item| { item.set_durability(max_durability); }); @@ -18767,27 +19851,86 @@ impl WorldSession { ) -> bool { let repair_items = self.repairable_inventory_item_costs_like_cpp(discount, repair_cost_rate); + if repair_items.is_empty() { + return true; + } let total_cost = repair_items .iter() .fold(0u64, |total, (_, cost)| total.saturating_add(*cost)); - - if self.player_gold_like_cpp() < total_cost { + let planned_repairs = repair_items + .iter() + .filter_map(|(item_guid, _)| { + let item = self.inventory_item_objects_like_cpp().get(item_guid)?; + let db_guid = self + .inventory_items_like_cpp() + .values() + .find(|inventory_item| inventory_item.guid == *item_guid) + .map(|inventory_item| inventory_item.db_guid) + .unwrap_or_else(|| item_guid.counter() as u64); + Some((*item_guid, db_guid, item.data().max_durability)) + }) + .collect::>(); + if planned_repairs.len() != repair_items.len() { return false; } - if total_cost != 0 { - let old_money = self.player_gold_like_cpp(); - self.apply_player_money_change_like_cpp(old_money, old_money - total_cost) - .await; + let Some(money_persistence) = self + .begin_exclusive_player_money_persistence_like_cpp() + .await + else { + return false; + }; + let old_money = self.player_gold_like_cpp(); + if old_money < total_cost { + return false; } + let new_money = old_money - total_cost; + + let money_persistence = if let Some(char_db) = self.char_db.as_ref().map(Arc::clone) { + let Some(player_guid) = self.player_guid() else { + return false; + }; + let mut transaction = SqlTransaction::new(); + transaction.append(Self::build_character_gold_save_statement_like_cpp( + new_money, + player_guid.counter() as u64, + )); + for &(_, db_guid, max_durability) in &planned_repairs { + let mut durability = char_db.prepare(CharStatements::UPD_ITEM_INSTANCE_DURABILITY); + durability.set_u32(0, max_durability); + durability.set_u64(1, db_guid); + transaction.append(durability); + } + let Some(money_persistence) = self + .commit_exclusive_player_money_transaction_like_cpp( + money_persistence, + char_db.as_ref(), + transaction, + old_money, + new_money, + "all-items durability repair", + ) + .await + else { + return false; + }; + money_persistence + } else { + money_persistence + }; + // Money and every durability row share one COMMIT. Mirror all of those + // rows in runtime while admission is still fenced and before the first + // cancellation point. + self.stage_player_money_change_like_cpp(old_money, new_money); let mut repaired_any = false; - for (item_guid, _) in repair_items { - repaired_any |= self - .repair_inventory_item_durability_like_cpp(item_guid, false, 0.0, repair_cost_rate) - .await; + for (item_guid, _, _) in planned_repairs { + repaired_any |= self.apply_inventory_item_durability_repair_runtime_like_cpp(item_guid); } - repaired_any || total_cost == 0 + drop(money_persistence); + self.drain_represented_quest_objective_progress_like_cpp() + .await; + repaired_any } /// C++ `Player::DurabilityRepairAll(takeCost=true, guildBank=true)` for represented items. @@ -20078,9 +21221,23 @@ impl WorldSession { pub(crate) fn set_active_loot_guid(&mut self, guid: ObjectGuid) { self.active_loot_guid = ObjectGuid::EMPTY; self.active_loot_view_owners.clear(); + self.active_loot_view_generations_like_cpp.clear(); + self.active_loot_view_authorities_like_cpp.clear(); self.add_active_loot_view_owner_like_cpp(guid); } + pub(crate) fn has_active_loot_views_like_cpp(&self) -> bool { + !self.active_loot_guid.is_empty() || !self.active_loot_view_owners.is_empty() + } + + pub(crate) fn has_active_non_item_loot_views_like_cpp(&self) -> bool { + (!self.active_loot_guid.is_empty() && !self.active_loot_guid.is_item()) + || self + .active_loot_view_owners + .iter() + .any(|guid| !guid.is_item()) + } + pub(crate) fn add_active_loot_view_owner_like_cpp(&mut self, guid: ObjectGuid) { if guid.is_empty() { return; @@ -20091,10 +21248,20 @@ impl WorldSession { } self.active_loot_view_owners.insert(guid); + if let Some(generation) = self + .represented_loot_cache_generations_like_cpp + .get(&guid) + .copied() + { + self.active_loot_view_generations_like_cpp + .insert(guid, generation); + } } pub(crate) fn clear_active_loot_guid_if(&mut self, guid: ObjectGuid) { self.active_loot_view_owners.remove(&guid); + self.active_loot_view_generations_like_cpp.remove(&guid); + self.active_loot_view_authorities_like_cpp.remove(&guid); if self.active_loot_guid == guid { self.active_loot_guid = ObjectGuid::EMPTY; } @@ -23330,8 +24497,228 @@ impl WorldSession { self.quest_faction_reward_store = Some(store); } + async fn reconcile_durable_loot_money_before_save_like_cpp(&mut self) -> bool { + let tracker = Arc::clone(&self.durable_loot_money_persistence_like_cpp); + tracker.wait_until_idle_like_cpp().await; + let completions = tracker.pending_completions_like_cpp(); + for completion in completions { + if completion + .applied + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + continue; + } + let old_money = self.player_gold_like_cpp(); + let new_money = old_money + .checked_add(completion.durable_applied_amount) + .filter(|money| *money <= MAX_MONEY_AMOUNT) + .unwrap_or(old_money); + self.set_player_gold_like_cpp(new_money); + if old_money != new_money { + self.enqueue_represented_quest_objective_progress_like_cpp( + RepresentedQuestObjectiveProgressEventLikeCpp::MoneyChanged { + old_money, + new_money, + }, + ); + } + } + + // Do not drain money criteria while the save fence is held. That path + // can reward a quest and re-enter `save_player_gold`, which would wait + // on this same fence. Queue the exact transition here; normal command + // publication or the save caller drains it only after releasing the + // fence. This keeps a save-first completion from losing MoneyChanged. + + if tracker.is_indeterminate_like_cpp() { + self.kick("loot-money COMMIT outcome is unknown; skipping absolute money save"); + return false; + } + true + } + + /// Close detached-payout admission, wait for every previously admitted + /// worker, apply its exact-once durable deltas, then acquire the character + /// money mutation lock. Callers must derive old/new runtime values only + /// after this returns and retain the guard through their DB COMMIT. + pub(crate) async fn begin_exclusive_player_money_persistence_like_cpp( + &mut self, + ) -> Option { + let tracker = Arc::clone(&self.durable_loot_money_persistence_like_cpp); + let save_fence = tracker.close_admission_for_save_like_cpp(); + tracker.wait_until_idle_like_cpp().await; + if !self + .reconcile_durable_loot_money_before_save_like_cpp() + .await + { + return None; + } + let mutation_lock = tracker.lock_money_mutation_like_cpp().await; + Some(ExclusivePlayerMoneyPersistenceLikeCpp { + _save_fence: save_fence, + _mutation_lock: mutation_lock, + }) + } + + /// Derive one runtime money change only after the shared payout barrier, + /// persist it while admission and the mutation mutex remain held, then + /// publish the runtime value. Criteria must be queued/drained by the caller + /// after this returns so reward callbacks cannot re-enter under the fence. + pub(crate) async fn mutate_and_persist_player_gold_exclusive_like_cpp( + &mut self, + mutation: F, + ) -> Option<(u64, u64)> + where + F: FnOnce(u64) -> u64, + { + let money_persistence = self + .begin_exclusive_player_money_persistence_like_cpp() + .await?; + let guid = self.player_guid()?.counter() as u64; + let old_money = self.player_gold_like_cpp(); + let new_money = mutation(old_money); + + #[cfg(test)] + if let Some(success) = self.loot_money_persistence_test_result_like_cpp { + if !success { + return None; + } + self.set_player_gold_like_cpp(new_money); + drop(money_persistence); + return Some((old_money, new_money)); + } + + if old_money == new_money { + drop(money_persistence); + return Some((old_money, new_money)); + } + + let char_db = self.char_db().map(Arc::clone)?; + let mut transaction = SqlTransaction::new(); + transaction.append(Self::build_character_gold_save_statement_like_cpp( + new_money, guid, + )); + let money_persistence = self + .commit_exclusive_player_money_transaction_like_cpp( + money_persistence, + char_db.as_ref(), + transaction, + old_money, + new_money, + "exclusive player-money mutation", + ) + .await?; + self.set_player_gold_like_cpp(new_money); + drop(money_persistence); + Some((old_money, new_money)) + } + + /// Commit a transaction whose money change can act as its reconciliation + /// marker. On a definite rollback, dropping the returned `None` reopens + /// normal payout admission. On an ambiguous COMMIT, the current durable + /// money row is compared with the exact pre/post values while the shared + /// per-character mutation lock remains held. + /// + /// Callers must publish every runtime mutation covered by `transaction` + /// before dropping the returned guard. If `money_before == money_after`, + /// an ambiguous COMMIT is necessarily quarantined because an unchanged + /// money row cannot prove whether other statements committed. + pub(crate) async fn commit_exclusive_player_money_transaction_like_cpp( + &mut self, + money_persistence: ExclusivePlayerMoneyPersistenceLikeCpp, + char_db: &CharacterDatabase, + transaction: SqlTransaction, + money_before: u64, + money_after: u64, + operation: &'static str, + ) -> Option { + let mut cancellation_fence = PlayerMoneyCommitCancellationFenceLikeCpp::new(Arc::clone( + &self.durable_loot_money_persistence_like_cpp, + )); + let commit_result = transaction + .commit_with_outcome_like_cpp(char_db.pool()) + .await; + match commit_result { + Ok(()) => { + cancellation_fence.disarm_like_cpp(); + Some(money_persistence) + } + Err(SqlTransactionCommitError::DefinitelyRolledBack(error)) => { + cancellation_fence.disarm_like_cpp(); + warn!(%error, operation, "player-money transaction definitely rolled back"); + None + } + Err(SqlTransactionCommitError::CommitOutcomeUnknown(error)) => { + let observed_money = match self.player_guid() { + Some(player_guid) => { + sqlx::query_scalar::<_, u64>("SELECT money FROM characters WHERE guid = ?") + .bind(player_guid.counter() as u64) + .fetch_optional(char_db.pool()) + .await + .ok() + .flatten() + } + None => None, + }; + + match reconcile_absolute_player_money_commit_like_cpp( + money_before, + money_after, + observed_money, + ) { + AbsolutePlayerMoneyCommitReconciliationLikeCpp::Committed => { + cancellation_fence.disarm_like_cpp(); + warn!( + %error, + operation, + money_before, + money_after, + "player-money COMMIT reply was lost but durable money proves the transaction committed" + ); + Some(money_persistence) + } + AbsolutePlayerMoneyCommitReconciliationLikeCpp::RolledBack => { + cancellation_fence.disarm_like_cpp(); + warn!( + %error, + operation, + money_before, + money_after, + "player-money COMMIT reply was lost but durable money proves the transaction rolled back" + ); + None + } + AbsolutePlayerMoneyCommitReconciliationLikeCpp::Indeterminate => { + self.durable_loot_money_persistence_like_cpp + .mark_indeterminate_like_cpp(); + cancellation_fence.disarm_like_cpp(); + self.kick( + "player-money COMMIT outcome is unknown; relog required before another money mutation", + ); + warn!( + %error, + operation, + money_before, + money_after, + ?observed_money, + "player-money COMMIT outcome remains indeterminate; quarantined the session" + ); + None + } + } + } + } + } + /// Save current player gold to the characters DB. - pub(crate) async fn save_player_gold(&self) { + pub(crate) async fn save_player_gold(&mut self) { + let Some(money_persistence) = self + .begin_exclusive_player_money_persistence_like_cpp() + .await + else { + return; + }; let guid = match self.player_guid() { Some(g) => g.counter() as u64, None => return, @@ -23340,9 +24727,446 @@ impl WorldSession { Some(db) => Arc::clone(db), None => return, }; - let stmt = - Self::build_character_gold_save_statement_like_cpp(self.player_gold_like_cpp(), guid); - let _ = char_db.execute(&stmt).await; + let money = self.player_gold_like_cpp(); + let mut transaction = SqlTransaction::new(); + transaction.append(Self::build_character_gold_save_statement_like_cpp( + money, guid, + )); + // For an absolute one-column save the pre-transaction durable value is + // unknown. Pass equal sentinels so any lost COMMIT reply is quarantined + // instead of guessing from a coincidentally equal row. + let money_persistence = self + .commit_exclusive_player_money_transaction_like_cpp( + money_persistence, + char_db.as_ref(), + transaction, + money, + money, + "absolute player-money save", + ) + .await; + drop(money_persistence); + self.drain_represented_quest_objective_progress_like_cpp() + .await; + } + + /// Persist an explicit player-money value and surface database failures to + /// callers that must not expose a loot payout before it is durable. + /// + /// Unlike [`Self::save_player_gold`], this helper fails closed when there is + /// no selected player or character database. Focused tests must opt into an + /// explicit persistence result through the test seam below. + pub(crate) async fn persist_player_gold_checked_like_cpp( + &self, + money: u64, + ) -> Result<(), LootMoneyPersistenceErrorLikeCpp> { + #[cfg(test)] + if let Some(success) = self.loot_money_persistence_test_result_like_cpp { + return success + .then_some(()) + .ok_or(LootMoneyPersistenceErrorLikeCpp::MissingCharacterDatabase); + } + + let guid = self + .player_guid() + .ok_or(LootMoneyPersistenceErrorLikeCpp::MissingPlayer)?; + let char_db = self + .char_db() + .map(Arc::clone) + .ok_or(LootMoneyPersistenceErrorLikeCpp::MissingCharacterDatabase)?; + let stmt = Self::build_character_gold_save_statement_like_cpp(money, guid.counter() as u64); + char_db + .execute(&stmt) + .await + .map(|_| ()) + .map_err(LootMoneyPersistenceErrorLikeCpp::Database) + } + + /// Start the complete durable half of one shared money claim in a detached + /// task. The task owns the lease across `COMMIT`, commits the authority in + /// the same task immediately after SQL success, then schedules the + /// already-durable session-local applications. + /// + /// Dropping or aborting the packet-handler future only drops its + /// `JoinHandle`; Tokio keeps this worker alive. This closes the duplicate + /// window where SQL could commit after the handler was cancelled while the + /// lease's `Drop` reopened the object pool. + /// + /// Boundary: this is an in-process guarantee, not a durable claim journal. + /// Aborting this detached task (including runtime/process shutdown) at the + /// database commit await boundary can still lose the continuation between + /// durable SQL and the synchronous authority commit. Recovery across that + /// boundary requires persisting claim identity in the same transaction and + /// replaying it at startup; #106 does not yet provide such a journal. + /// Delivery tasks may likewise outlive a target session, but they carry + /// only runtime publication: durable player money reloads from SQL and the + /// already-committed authority prevents a second in-process payout. + pub(crate) fn spawn_group_loot_money_persistence_like_cpp( + &self, + mut payouts: Vec<(ObjectGuid, u64)>, + claim: LootClaimLease, + mut deliveries: Vec<(flume::Sender, SessionCommand)>, + authority_committed: Arc, + viewer_fanout: LootMoneyViewerFanoutLikeCpp, + ) -> Result< + tokio::task::JoinHandle>, + LootMoneyPersistenceErrorLikeCpp, + > { + if payouts.is_empty() { + return Err(LootMoneyPersistenceErrorLikeCpp::MissingPlayer); + } + if self.player_guid().is_none() { + return Err(LootMoneyPersistenceErrorLikeCpp::MissingPlayer); + } + + #[cfg(test)] + let test_result = self.loot_money_persistence_test_result_like_cpp; + #[cfg(not(test))] + let test_result: Option = None; + + payouts + .sort_unstable_by_key(|(recipient, _)| (recipient.high_value(), recipient.low_value())); + payouts.dedup_by_key(|(recipient, _)| *recipient); + + // Register every recipient directly before any SQL begins. Commands + // are publication-only; waiting for target acknowledgements here would + // create self/A↔B deadlocks between concurrent group looters. + let payout_recipients = payouts + .iter() + .map(|(recipient, _)| *recipient) + .collect::>(); + let mut money_persistence_guards = HashMap::< + ObjectGuid, + DurableLootMoneyPersistenceGuardLikeCpp, + >::with_capacity(payouts.len()); + let mut money_mutation_trackers = HashMap::< + ObjectGuid, + Arc, + >::with_capacity(payouts.len()); + for (_, command) in &deliveries { + let SessionCommand::ApplyLootMoneyLikeCpp(command) = command else { + continue; + }; + if payout_recipients.contains(&command.recipient) + && !money_persistence_guards.contains_key(&command.recipient) + { + let guard = command + .durable_persistence_tracker + .begin_like_cpp() + .map_err(|_| LootMoneyPersistenceErrorLikeCpp::MissingPlayer)?; + money_persistence_guards.insert(command.recipient, guard); + money_mutation_trackers.insert( + command.recipient, + Arc::clone(&command.durable_persistence_tracker), + ); + } + } + if money_persistence_guards.len() != payouts.len() { + return Err(LootMoneyPersistenceErrorLikeCpp::MissingPlayer); + } + + let char_db = if test_result.is_some() { + None + } else { + Some( + self.char_db() + .map(Arc::clone) + .ok_or(LootMoneyPersistenceErrorLikeCpp::MissingCharacterDatabase)?, + ) + }; + + let mut persistence_guard = claim + .begin_persistence_guard_like_cpp() + .map_err(LootMoneyPersistenceErrorLikeCpp::Claim)?; + drop(claim); + + Ok(tokio::spawn(async move { + // Match the sorted character-row lock order below. Stored-item + // money uses the same per-character lock before it takes the row, + // so COMMIT reconciliation cannot be confused by a later local + // payout interleaving between the failed reply and our reads. + let mut _money_mutation_locks = Vec::with_capacity(payouts.len()); + for (recipient, _) in &payouts { + _money_mutation_locks.push( + money_mutation_trackers + .get(recipient) + .expect("every payout retained its target mutation lock") + .lock_money_mutation_like_cpp() + .await, + ); + } + + let durable_outcomes = if let Some(success) = test_result { + // Give cancellation regressions a deterministic opportunity to + // drop the outer waiter while this detached task owns `claim`. + tokio::task::yield_now().await; + if !success { + return Err(LootMoneyPersistenceErrorLikeCpp::MissingCharacterDatabase); + } + payouts + .iter() + .map(|(recipient, amount)| { + ( + *recipient, + DurableLootMoneyDbOutcomeLikeCpp { + before: 0, + after: *amount, + applied_delta: *amount, + }, + ) + }) + .collect::>() + } else { + let char_db = + char_db.expect("production loot-money worker must own a character database"); + let attempt = retry_deadlocked_operation_like_cpp( + || attempt_group_loot_money_transaction_like_cpp(char_db.as_ref(), &payouts), + group_loot_money_attempt_is_deadlock_like_cpp, + ) + .await; + let durable_outcomes = match attempt { + Ok(outcomes) => outcomes, + Err(GroupLootMoneyAttemptErrorLikeCpp::DefinitelyRolledBack(error)) => { + return Err(error); + } + Err(GroupLootMoneyAttemptErrorLikeCpp::CommitOutcomeUnknown { + error: commit_error, + outcomes: durable_outcomes, + }) => { + // A transport failure at COMMIT is ambiguous. While every + // recipient fence remains registered, compare only rows + // whose value changed. A cap-only no-op has no durable + // mutation to distinguish: either COMMIT outcome permits + // the same safe authority consumption with zero delta. + let changed = durable_outcomes + .iter() + .filter(|(_, outcome)| outcome.before != outcome.after) + .collect::>(); + let cap_only_noop = changed.is_empty(); + let mut all_before = !cap_only_noop; + let mut all_after = !cap_only_noop; + let mut reconciliation_failed = false; + for (recipient, outcome) in changed { + match sqlx::query_scalar::<_, u64>( + "SELECT money FROM characters WHERE guid = ?", + ) + .bind(recipient.counter() as u64) + .fetch_optional(char_db.pool()) + .await + { + Ok(Some(current)) => { + all_before &= current == outcome.before; + all_after &= current == outcome.after; + } + Ok(None) | Err(_) => { + reconciliation_failed = true; + break; + } + } + } + + if all_before && !all_after && !reconciliation_failed { + return Err(LootMoneyPersistenceErrorLikeCpp::Database( + DatabaseError::Transaction( + "loot-money COMMIT was reconciled as rolled back".to_string(), + ), + )); + } + if !cap_only_noop && (!all_after || all_before || reconciliation_failed) { + for guard in money_persistence_guards.values_mut() { + guard.mark_indeterminate_like_cpp(); + } + let _ = persistence_guard.quarantine_commit_unknown_like_cpp(); + for (command_tx, _) in &deliveries { + let kick = SessionCommand::KickLikeCpp(KickLikeCppCommand { + reason: "loot-money COMMIT outcome is unknown; relog required" + .to_string(), + }); + if let Err(error) = command_tx.try_send(kick) { + let command_tx = command_tx.clone(); + let kick = error.into_inner(); + tokio::spawn(async move { + let _ = command_tx.send_async(kick).await; + }); + } + } + return Err(LootMoneyPersistenceErrorLikeCpp::CommitOutcomeUnknown( + commit_error, + )); + } + durable_outcomes + } + }; + durable_outcomes + }; + + for (_, command) in &mut deliveries { + if let SessionCommand::ApplyLootMoneyLikeCpp(command) = command { + let outcome = durable_outcomes + .get(&command.recipient) + .copied() + .expect("every admitted payout retains its locked DB outcome"); + command + .durable_applied_amount + .store(outcome.applied_delta, Ordering::Release); + money_persistence_guards + .get_mut(&command.recipient) + .expect("every payout registered its target money fence") + .commit_like_cpp(DurableLootMoneyCompletionLikeCpp { + durable_money_before: outcome.before, + durable_money_after: outcome.after, + durable_applied_amount: outcome.applied_delta, + applied: Arc::clone(&command.applied), + }); + } + } + + let committed_snapshot = match persistence_guard.commit_with_snapshot_like_cpp() { + Ok((_, committed_snapshot)) => { + authority_committed.store(true, Ordering::Release); + committed_snapshot + } + Err(error) => { + // SQL is already durable. Never let a lifecycle race reopen + // this old allocation; payout publication still proceeds, + // but replacement loot is not touched. + warn!(?error, "durable loot-money authority commit failed closed"); + let _ = persistence_guard.quarantine_commit_unknown_like_cpp(); + None + } + }; + + // `Loot::PlayersLooting` can grow while the detached SQL task is + // running. The snapshot above was captured by the commit while + // holding the same authority mutex: every included opener saw + // non-zero money, and every later opener sees zero directly. + let mut viewers = committed_snapshot + .filter(|snapshot| { + snapshot.generation == viewer_fanout.authority_generation + && snapshot.loot.loot_guid == viewer_fanout.loot_obj + }) + .map(|snapshot| { + snapshot + .loot + .players_looting + .into_iter() + .collect::>() + }) + .unwrap_or_default(); + + for (_, command) in &mut deliveries { + if let SessionCommand::ApplyLootMoneyLikeCpp(command) = command { + command + .send_coin_removed + .store(viewers.remove(&command.recipient), Ordering::Release); + } + } + + for viewer in viewers { + if viewer_fanout.payout_recipients.contains(&viewer) { + continue; + } + let command_tx = if viewer == viewer_fanout.source_player { + Some(viewer_fanout.source_command_tx.clone()) + } else { + viewer_fanout.player_registry.as_ref().and_then(|registry| { + let target = registry.get(&viewer)?; + (target.is_in_world + && target.map_id == viewer_fanout.map_id + && target.instance_id == viewer_fanout.instance_id) + .then(|| target.command_tx.clone()) + }) + }; + let Some(command_tx) = command_tx else { + continue; + }; + deliveries.push(( + command_tx, + SessionCommand::NotifyLootMoneyRemovedLikeCpp( + NotifyLootMoneyRemovedLikeCppCommand { + recipient: viewer, + loot_owner: viewer_fanout.loot_owner, + loot_obj: viewer_fanout.loot_obj, + authority: viewer_fanout.authority.clone(), + authority_generation: viewer_fanout.authority_generation, + authority_committed: Arc::clone(&authority_committed), + }, + ), + )); + } + + // Do not make persistence wait for another session's bounded + // command queue. Each delivery task owns its command until the + // target drains capacity or disconnects. + for (command_tx, command) in deliveries { + if let Err(error) = command_tx.try_send(command) { + let command = error.into_inner(); + tokio::spawn(async move { + let _ = command_tx.send_async(command).await; + }); + } + } + Ok(()) + })) + } + + #[cfg(test)] + pub(crate) fn set_loot_money_persistence_test_result_like_cpp(&mut self, success: bool) { + self.loot_money_persistence_test_result_like_cpp = Some(success); + } + + pub(crate) fn loot_money_persistence_test_result_for_worker_like_cpp(&self) -> Option { + #[cfg(test)] + { + self.loot_money_persistence_test_result_like_cpp + } + #[cfg(not(test))] + { + None + } + } + + pub(crate) fn begin_durable_item_loot_persistence_like_cpp( + &self, + ) -> DurableItemLootPersistenceGuardLikeCpp { + self.durable_item_loot_persistence_like_cpp.begin_like_cpp() + } + + pub(crate) async fn wait_for_durable_item_loot_persistence_like_cpp(&self) { + self.durable_item_loot_persistence_like_cpp + .wait_until_idle_like_cpp() + .await; + } + + pub(crate) fn durable_loot_money_persistence_tracker_like_cpp( + &self, + ) -> Arc { + Arc::clone(&self.durable_loot_money_persistence_like_cpp) + } + + pub(crate) fn take_durable_item_loot_completions_like_cpp( + &self, + ) -> Vec { + self.durable_item_loot_persistence_like_cpp + .take_completions_like_cpp() + } + + #[cfg(test)] + pub(crate) fn set_loot_item_store_test_seam_like_cpp( + &mut self, + grants: Arc, + success: bool, + ) { + self.loot_item_store_test_grants_like_cpp = Some(grants); + self.loot_item_store_test_success_like_cpp = success; + } + + #[cfg(test)] + pub(crate) fn set_loot_item_store_test_commit_gate_like_cpp( + &mut self, + gate: Arc, + ) { + self.loot_item_store_test_commit_gate_like_cpp = Some(gate); } fn build_character_gold_save_statement_like_cpp( @@ -24390,7 +26214,18 @@ impl WorldSession { &mut self, snapshot: &PlayerSaveToDbSnapshotLikeCpp, now_unix_secs: i64, - ) -> PlayerSaveToDbStatementPlanLikeCpp { + ) -> Option { + // A cancelled/ambiguous transaction can contain money plus other + // absolute replacements (for example a talent reset). Building a + // partial full-save plan from the pre-COMMIT runtime snapshot would + // overwrite those non-money rows even if the earlier COMMIT succeeded. + if self + .durable_loot_money_persistence_like_cpp + .is_indeterminate_like_cpp() + { + return None; + } + let guid_counter = snapshot.guid.counter() as u64; let mut plan = PlayerSaveToDbStatementPlanLikeCpp::default(); @@ -24578,7 +26413,7 @@ impl WorldSession { ); } - plan + Some(plan) } fn mark_current_player_save_to_db_committed_like_cpp( @@ -24605,6 +26440,35 @@ impl WorldSession { // autosave callers before it appends statements. self.reset_player_save_timer_like_cpp(); + let money_tracker = Arc::clone(&self.durable_loot_money_persistence_like_cpp); + let money_save_fence = money_tracker.close_admission_for_save_like_cpp(); + self.wait_for_durable_item_loot_persistence_like_cpp().await; + self.apply_pending_durable_item_loot_completions_with_objective_drain_like_cpp(false) + .await; + let money_state_is_determinate = self + .reconcile_durable_loot_money_before_save_like_cpp() + .await; + if !money_state_is_determinate { + // The same unknown transaction may also have committed talents, + // reset metadata, inventory, or other absolute state. Do not let a + // disconnect/autosave restore any pre-COMMIT runtime snapshot. + drop(money_save_fence); + self.drain_represented_quest_objective_progress_like_cpp() + .await; + return; + } + let money_mutation_lock = money_tracker.lock_money_mutation_like_cpp().await; + if money_tracker.is_indeterminate_like_cpp() { + self.kick( + "player persistence became indeterminate while waiting for the full-save money lock; aborting the entire save", + ); + drop(money_mutation_lock); + drop(money_save_fence); + self.drain_represented_quest_objective_progress_like_cpp() + .await; + return; + } + let Some(snapshot) = self.sync_session_from_save_to_db_snapshot_like_cpp() else { warn!( account = self.account_id, @@ -24613,6 +26477,10 @@ impl WorldSession { has_canonical_map_manager = self.canonical_map_manager.is_some(), "Skipping Player::SaveToDB represented save because no coherent player snapshot is available" ); + drop(money_mutation_lock); + drop(money_save_fence); + self.drain_represented_quest_objective_progress_like_cpp() + .await; return; }; let Some(char_db) = self.char_db().map(Arc::clone) else { @@ -24621,19 +26489,37 @@ impl WorldSession { player_guid = ?self.player_guid(), "Skipping Player::SaveToDB represented save because character database is unavailable" ); + drop(money_mutation_lock); + drop(money_save_fence); + self.drain_represented_quest_objective_progress_like_cpp() + .await; return; }; - let mut plan = - self.current_player_save_to_db_statement_plan_like_cpp(&snapshot, unix_now()); + let Some(mut plan) = + self.current_player_save_to_db_statement_plan_like_cpp(&snapshot, unix_now()) + else { + self.kick( + "player persistence became indeterminate before the full-save statement snapshot; aborting the entire save", + ); + drop(money_mutation_lock); + drop(money_save_fence); + self.drain_represented_quest_objective_progress_like_cpp() + .await; + return; + }; let statement_count = plan.statements.len(); let mut tx = SqlTransaction::new(); for statement in std::mem::take(&mut plan.statements) { tx.append(statement); } - match char_db.commit_transaction(tx).await { + let mut cancellation_fence = + PlayerMoneyCommitCancellationFenceLikeCpp::new(Arc::clone(&money_tracker)); + let commit_result = tx.commit_with_outcome_like_cpp(char_db.pool()).await; + match commit_result { Ok(()) => { + cancellation_fence.disarm_like_cpp(); self.mark_current_player_save_to_db_committed_like_cpp(&plan); info!( guid = snapshot.guid.counter(), @@ -24641,11 +26527,35 @@ impl WorldSession { "Player::SaveToDB represented save committed in one CharacterDatabase transaction" ); } - Err(err) => warn!( - guid = snapshot.guid.counter(), - statement_count, "Failed to commit Player::SaveToDB represented transaction: {err}" - ), + Err(SqlTransactionCommitError::DefinitelyRolledBack(err)) => { + cancellation_fence.disarm_like_cpp(); + warn!( + guid = snapshot.guid.counter(), + statement_count, + "Failed to commit Player::SaveToDB represented transaction: {err}" + ); + } + Err(SqlTransactionCommitError::CommitOutcomeUnknown(err)) => { + // The full save includes many absolute replacements. A money + // row alone cannot establish whether that whole transaction + // committed, so preserve dirty flags and force a reload before + // any further money mutation can race an unknown durable base. + money_tracker.mark_indeterminate_like_cpp(); + cancellation_fence.disarm_like_cpp(); + self.kick( + "Player::SaveToDB COMMIT outcome is unknown; relog required before another money mutation", + ); + warn!( + guid = snapshot.guid.counter(), + statement_count, + "Player::SaveToDB represented transaction COMMIT outcome is unknown: {err}" + ); + } } + drop(money_mutation_lock); + drop(money_save_fence); + self.drain_represented_quest_objective_progress_like_cpp() + .await; } async fn process_pending_periodic_player_save_like_cpp(&mut self) { @@ -25761,6 +27671,77 @@ impl WorldSession { .unwrap_or_default() } + fn represented_talent_reset_state_plan_like_cpp( + &self, + ) -> Option { + if !self.represented_talents_loaded_like_cpp { + return None; + } + + let active_group = self.represented_active_talent_group_like_cpp; + let active_group_index = usize::from(active_group); + let active_talents = self + .represented_talents_like_cpp + .get(active_group_index)? + .clone(); + let mut post_talents = self.represented_talents_like_cpp.clone(); + post_talents[active_group_index].clear(); + + Some(RepresentedTalentResetStatePlanLikeCpp { + active_group, + active_talents, + post_talents, + }) + } + + /// Build the represented durable money/reset-metadata/talent transaction + /// without mutating the session. The absolute money write is retained even + /// for a zero-cost reset so a lost COMMIT reply cannot be mistaken for proof + /// that the talent statements rolled back. `character_spell` is deliberately + /// untouched until Rust retains the complete C++ `PlayerSpellMap` row state. + fn represented_talent_reset_transaction_statement_plan_like_cpp( + &self, + guid_counter: u64, + old_money: u64, + new_money: u64, + cost: u32, + reset_time_secs: u64, + ) -> Option<( + RepresentedTalentResetStatePlanLikeCpp, + Vec, + )> { + let state_plan = self.represented_talent_reset_state_plan_like_cpp()?; + let mut statements = vec![ + Self::build_character_gold_save_statement_like_cpp(new_money, guid_counter), + Self::build_character_talent_reset_state_save_statement_like_cpp( + cost, + reset_time_secs, + guid_counter, + ), + Self::build_character_talent_delete_statement_like_cpp(guid_counter), + ]; + + for (talent_group, talents) in state_plan.post_talents.iter().enumerate() { + for (talent_id, rank) in talents { + if self + .represented_talent_info_like_cpp(*talent_id, *rank) + .is_none() + { + continue; + } + statements.push(Self::build_character_talent_insert_statement_like_cpp( + guid_counter, + *talent_id, + *rank, + talent_group as u8, + )); + } + } + + debug_assert_eq!(old_money.saturating_sub(new_money), u64::from(cost)); + Some((state_plan, statements)) + } + pub(crate) fn build_character_talent_delete_statement_like_cpp( guid_counter: u64, ) -> PreparedStatement { @@ -28243,6 +30224,16 @@ impl WorldSession { old_money: u64, new_money: u64, ) { + self.stage_player_money_change_like_cpp(old_money, new_money); + self.drain_represented_quest_objective_progress_like_cpp() + .await; + } + + /// Publish an already-durable absolute money mutation without awaiting. + /// Transactional callers use this while their exclusive money guard is + /// still held, then drop the guard before draining criteria (which can + /// re-enter money persistence through a quest reward). + pub(crate) fn stage_player_money_change_like_cpp(&mut self, old_money: u64, new_money: u64) { if old_money != new_money { self.enqueue_represented_quest_objective_progress_like_cpp( RepresentedQuestObjectiveProgressEventLikeCpp::MoneyChanged { @@ -28252,8 +30243,6 @@ impl WorldSession { ); } self.set_player_gold_like_cpp(new_money); - self.drain_represented_quest_objective_progress_like_cpp() - .await; } pub(crate) fn represented_next_reset_talents_cost_like_cpp(&self, now_secs: u64) -> u32 { @@ -28264,36 +30253,142 @@ impl WorldSession { ) } - pub(crate) async fn apply_represented_talent_reset_cost_like_cpp(&mut self) -> bool { + pub(crate) async fn commit_represented_talent_reset_like_cpp( + &mut self, + ) -> Option { let now_secs = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs(); - self.apply_represented_talent_reset_cost_at_like_cpp(now_secs) + self.commit_represented_talent_reset_at_like_cpp(now_secs) .await } - pub(crate) async fn apply_represented_talent_reset_cost_at_like_cpp( + async fn commit_represented_talent_reset_at_like_cpp( &mut self, now_secs: u64, - ) -> bool { + ) -> Option { let cost = if self.no_reset_talent_cost_like_cpp { 0 } else { u64::from(self.represented_next_reset_talents_cost_like_cpp(now_secs)) }; + + let money_persistence = self + .begin_exclusive_player_money_persistence_like_cpp() + .await?; let old_money = self.player_gold_like_cpp(); - if !self.no_reset_talent_cost_like_cpp && old_money < cost { + if old_money < cost { self.send_buy_error(BuyResult::NotEnoughtMoney, None, 0); - return false; + return None; + } + let new_money = old_money - cost; + let player_guid = self.player_guid()?; + let (state_plan, statements) = self + .represented_talent_reset_transaction_statement_plan_like_cpp( + player_guid.counter() as u64, + old_money, + new_money, + cost as u32, + now_secs, + )?; + let mut transaction = SqlTransaction::new(); + for statement in statements { + transaction.append(statement); + } + + // Unit fixtures without a CharacterDatabase explicitly model a + // successful COMMIT. The failure seam proves that no covered runtime + // state is published on a definite rollback. + #[cfg(test)] + if self.loot_money_persistence_test_result_like_cpp == Some(false) { + return None; + } + #[cfg(test)] + let bypass_database_like_cpp = self.loot_money_persistence_test_result_like_cpp + == Some(true) + || self.char_db.is_none(); + #[cfg(not(test))] + let bypass_database_like_cpp = false; + + let money_persistence = if bypass_database_like_cpp { + money_persistence + } else { + let char_db = self.char_db.as_ref().map(Arc::clone)?; + self.commit_exclusive_player_money_transaction_like_cpp( + money_persistence, + char_db.as_ref(), + transaction, + old_money, + new_money, + "talent reset", + ) + .await? + }; + + Some(CommittedRepresentedTalentResetLikeCpp { + money_persistence, + old_money, + new_money, + cost: cost as u32, + reset_time_secs: now_secs, + state_plan, + }) + } + + /// Publish every runtime effect covered by the committed reset before the + /// shared money guard is released. There is deliberately no `.await` + /// between entry and `drop(money_persistence)`: cancellation cannot expose + /// a durable fee/talent reset with the old session state still live. + pub(crate) async fn publish_committed_represented_talent_reset_like_cpp( + &mut self, + committed: CommittedRepresentedTalentResetLikeCpp, + request: RepresentedConfirmRespecWipeLikeCpp, + visual_spell_id: u32, + ) { + let CommittedRepresentedTalentResetLikeCpp { + money_persistence, + old_money, + new_money, + cost, + reset_time_secs, + state_plan, + } = committed; + + self.remove_represented_pet_not_in_slot_like_cpp(); + self.record_represented_confirm_respec_wipe_like_cpp(request); + + debug_assert_eq!( + self.represented_active_talent_group_like_cpp, + state_plan.active_group + ); + for (talent_id, rank) in &state_plan.active_talents { + self.remove_represented_active_talent_side_effects_like_cpp(*talent_id, *rank); + } + self.represented_talents_like_cpp = state_plan.post_talents; + self.refresh_represented_talent_points_like_cpp(); + + self.stage_player_money_change_like_cpp(old_money, new_money); + self.represented_talent_reset_cost_like_cpp = cost; + self.represented_talent_reset_time_secs_like_cpp = reset_time_secs; + self.record_represented_talent_respec_criteria_like_cpp(cost); + + self.send_packet(&self.represented_update_talent_data_packet_like_cpp()); + if let Some(player_guid) = self.player_guid() { + self.record_represented_talent_respec_visual_spell_cast_like_cpp( + RepresentedTalentRespecVisualSpellCastLikeCpp { + caster_guid: request.respec_master, + target_guid: player_guid, + spell_id: visual_spell_id, + triggered: true, + spell_runtime_unrepresented: true, + }, + ); } - self.apply_player_money_change_like_cpp(old_money, old_money - cost) + drop(money_persistence); + self.drain_represented_quest_objective_progress_like_cpp() .await; - self.record_represented_talent_respec_criteria_like_cpp(cost as u32); - self.represented_talent_reset_cost_like_cpp = cost as u32; - self.represented_talent_reset_time_secs_like_cpp = now_secs; - true } fn record_represented_talent_respec_criteria_like_cpp(&mut self, cost: u32) { @@ -29083,10 +31178,13 @@ impl WorldSession { is_in_world: self.player_is_in_world_for_registry_like_cpp(), send_tx: self.send_tx.clone(), command_tx: self.session_command_tx.clone(), + durable_loot_money_tracker_like_cpp: Arc::clone( + &self.durable_loot_money_persistence_like_cpp, + ), active_loot_rolls: self .represented_loot_rolls - .keys() - .map(|key| (key.0, key.1)) + .values() + .map(|state| state.command_identity.clone()) .collect(), pass_on_group_loot: self.pass_on_group_loot, enchanting_skill: self.represented_enchanting_skill, @@ -29190,8 +31288,8 @@ impl WorldSession { info.liquid_status = self.player_liquid_status_like_cpp(); info.active_loot_rolls = self .represented_loot_rolls - .keys() - .map(|key| (key.0, key.1)) + .values() + .map(|state| state.command_identity.clone()) .collect(); info.pass_on_group_loot = self.pass_on_group_loot; info.enchanting_skill = self.represented_enchanting_skill; @@ -29391,10 +31489,11 @@ impl WorldSession { } pub async fn cleanup_shared_runtime_state_on_disconnect_like_cpp(&mut self) { + self.wait_for_active_loot_persistence_like_cpp().await; if let Some(player_guid) = self.player_guid() - && !self.active_loot_guid.is_empty() + && self.has_active_loot_views_like_cpp() { - self.close_active_loot_windows_like_cpp(player_guid); + self.do_loot_release_all_like_cpp(player_guid).await; } self.cleanup_shared_runtime_state(); } @@ -29412,6 +31511,10 @@ impl WorldSession { "Saving player on disconnect" ); self.set_player_logout_like_cpp(true); + self.wait_for_active_loot_persistence_like_cpp().await; + if self.has_active_loot_views_like_cpp() { + self.do_loot_release_all_like_cpp(player_guid).await; + } self.clear_buyback_on_logout().await; self.save_current_player_to_db_like_cpp().await; self.save_account_mounts_like_cpp().await; @@ -29480,6 +31583,35 @@ impl WorldSession { self.guid_generator.as_ref() } + /// Allocate item database/object GUIDs from the process-wide generator. + /// + /// C++ initializes this generator once from `MAX(item_instance.guid) + 1` + /// in `ObjectMgr::SetHighestGuids`, and every `Item::CreateItem` consumes + /// the next value. Rust sessions execute concurrently, so the shared + /// `ObjectGuidGenerator` uses an atomic fetch-add. Allocations are never + /// returned after a later persistence failure, matching C++ Item creation. + pub(crate) fn allocate_item_instance_guids_like_cpp( + &self, + count: usize, + ) -> Option> { + if count == 0 { + return Some(Vec::new()); + } + let generator = self.item_guid_generator_like_cpp.as_ref()?; + if generator.high_guid() != HighGuid::Item { + return None; + } + + let realm_id = self.realm_id(); + (0..count) + .map(|_| { + let counter = generator.generate(); + let db_guid = u64::try_from(counter).ok()?; + Some((db_guid, ObjectGuid::create_item(realm_id, counter))) + }) + .collect() + } + /// Set the session manager for ConnectTo flow. pub fn set_session_mgr(&mut self, mgr: Arc) { self.session_mgr = Some(mgr); @@ -29519,6 +31651,10 @@ impl WorldSession { pub(crate) fn set_player_logout_like_cpp(&mut self, player_logout: bool) { self.player_logout_like_cpp = player_logout; + if player_logout { + self.durable_loot_money_persistence_like_cpp + .close_admission_permanently_like_cpp(); + } self.sync_current_player_session_visibility_detection_like_cpp(); } @@ -29552,6 +31688,13 @@ impl WorldSession { &self.send_tx } + /// Install the FIFO completion fence paired with the session's initial + /// realm socket. Runtime does this immediately after constructing the + /// session; unit sessions that never own a physical writer leave it empty. + pub fn set_send_write_fence_like_cpp(&mut self, fence: SocketWriteFenceLikeCpp) { + self.send_write_fence_like_cpp = Some(fence); + } + /// Clone the C++-style cross-session command channel for this active /// session. /// @@ -31599,6 +33742,9 @@ impl WorldSession { // ── Spell casting tick ───────────────────────────────────────── // Check if an active spell cast has completed and execute it. if self.state == SessionState::LoggedIn { + if let Some(player_guid) = self.player_guid() { + self.close_retired_active_loot_windows_like_cpp(player_guid); + } self.tick_represented_loot_rolls_like_cpp().await; self.tick_represented_gameobject_update_like_cpp(); self.send_represented_gameobject_visibility_on_destroy_from_last_update_like_cpp(); @@ -31728,6 +33874,12 @@ impl WorldSession { // realm socket from closing. let old_send_tx = std::mem::replace(&mut self.send_tx, link.send_tx); self.realm_send_tx = Some(old_send_tx); + let next_write_fence = link + .send_write_fence_like_cpp + .or_else(|| self.send_write_fence_like_cpp.clone()); + let old_write_fence = + std::mem::replace(&mut self.send_write_fence_like_cpp, next_write_fence); + self.realm_send_write_fence_like_cpp = old_write_fence; if let Some(pkt_rx) = link.pkt_rx { let old_packet_rx = std::mem::replace(&mut self.packet_rx, pkt_rx); @@ -34293,6 +36445,38 @@ impl WorldSession { } } + /// Attempts to enqueue one instance-channel packet without waiting for + /// socket-writer capacity. + /// + /// This is deliberately narrow rather than a replacement for the normal + /// packet API. Object-owned loot uses it while holding its short authority + /// mutex so a stalled client cannot block every concurrent claim/release. + pub(crate) fn try_send_packet(&self, pkt: &P) -> bool { + let data = pkt.to_bytes(); + if std::env::var_os("RUSTYCORE_LOGIN_TRACE").is_some() { + info!( + account = self.account_id, + opcode = ?P::OPCODE, + bytes = data.len(), + "RUST_LOGIN_TRACE try_send_packet" + ); + } + match self.send_tx.try_send(data) { + Ok(()) => true, + Err(flume::TrySendError::Full(_)) => { + warn!( + "Send channel full for account {}; packet rejected without blocking", + self.account_id + ); + false + } + Err(flume::TrySendError::Disconnected(_)) => { + warn!("Send channel closed for account {}", self.account_id); + false + } + } + } + fn send_system_message_like_cpp(&self, text: &str) { for line in text.split('\n') { self.send_packet(&ChatPkt { @@ -34376,11 +36560,78 @@ impl WorldSession { } } + /// Wait for current-instance packets to reach their physical socket before + /// emitting a later realm packet. C++ performs both `SendDirectMessage` + /// calls synchronously; Rust's two independent writer tasks need this FIFO + /// completion fence to retain the same cross-connection order. + pub(crate) async fn wait_for_instance_send_before_realm_send_like_cpp(&self) -> bool { + let Some(realm_send_tx) = self.realm_send_tx.as_ref() else { + return true; + }; + if realm_send_tx.same_channel(&self.send_tx) { + return true; + } + let Some(write_fence) = self.send_write_fence_like_cpp.as_ref() else { + warn!( + account = self.account_id, + "instance/realm ordering fence unavailable for separate sockets" + ); + return false; + }; + let acknowledged = write_fence + .wait_for_prior_packets_written_like_cpp(&self.send_tx, Duration::from_millis(250)) + .await; + if !acknowledged { + warn!( + account = self.account_id, + "instance writer did not acknowledge the bounded ordering fence" + ); + } + acknowledged + } + + /// Wait for realm packets to reach their physical socket before emitting a + /// later instance update. This mirrors `Player::SendNewItem` preceding the + /// deferred `Map::SendObjectUpdates` player-field flush in C++. + pub(crate) async fn wait_for_realm_send_before_instance_update_like_cpp(&self) -> bool { + let Some(realm_send_tx) = self.realm_send_tx.as_ref() else { + return true; + }; + if realm_send_tx.same_channel(&self.send_tx) { + return true; + } + let Some(write_fence) = self.realm_send_write_fence_like_cpp.as_ref() else { + warn!( + account = self.account_id, + "realm/instance ordering fence unavailable for separate sockets" + ); + return false; + }; + let acknowledged = write_fence + .wait_for_prior_packets_written_like_cpp(realm_send_tx, Duration::from_millis(250)) + .await; + if !acknowledged { + warn!( + account = self.account_id, + "realm writer did not acknowledge the bounded ordering fence" + ); + } + acknowledged + } + #[cfg(test)] pub(crate) fn install_realm_send_channel_for_test(&mut self, tx: flume::Sender>) { self.realm_send_tx = Some(tx); } + #[cfg(test)] + pub(crate) fn install_realm_send_write_fence_for_test( + &mut self, + fence: SocketWriteFenceLikeCpp, + ) { + self.realm_send_write_fence_like_cpp = Some(fence); + } + /// Send pre-serialized packet bytes to the client. /// /// Used for packets with dynamic opcodes (e.g. `SetSpellModifier` @@ -34403,6 +36654,19 @@ impl WorldSession { } } + /// Send pre-serialized packet bytes on the realm connection. + /// + /// This is the cross-session counterpart of [`Self::send_packet_realm`]: + /// registry commands already carry serialized bytes, but C++ opcode + /// routing still requires packets such as `SMSG_PARTY_INVITE` and + /// `SMSG_PARTY_MEMBER_FULL_STATE` to use `CONNECTION_TYPE_REALM`. + pub(crate) fn send_raw_packet_realm(&self, data: &[u8]) { + let tx = self.realm_send_tx.as_ref().unwrap_or(&self.send_tx); + if tx.send(data.to_vec()).is_err() { + warn!("Realm send channel closed for account {}", self.account_id); + } + } + pub fn send_equip_error( &self, result: InventoryResult, @@ -34694,6 +36958,10 @@ impl WorldSession { if guid.is_none() { self.player_controller = None; self.represented_seer_guid_like_cpp = None; + // Old registry clones remain permanently closed; a later character + // selected on this authenticated session receives a fresh fence. + self.durable_loot_money_persistence_like_cpp = + Arc::new(DurableLootMoneyPersistenceTrackerLikeCpp::default()); } } @@ -49352,15 +51620,102 @@ impl WorldSession { let manager = self.canonical_map_manager.as_ref()?; let manager = manager.lock().ok()?; let mut key = None; + let mut ambiguous = false; manager.do_for_all_maps(|managed| { - if key.is_none() && managed.map().get_typed_player(guid).is_some() { + if managed.map().get_typed_player(guid).is_none() { + return; + } + if key.is_some() { + ambiguous = true; + } else { key = Some(wow_map::MapKey::new( managed.map_id(), managed.instance_id(), )); } }); - key + (!ambiguous).then_some(key).flatten() + } + + /// Resolve object access through the player's exact canonical `Map`, as + /// C++ `ObjectAccessor::GetCreature/GetGameObject(WorldObject const&, ...)` + /// does through `world_object.GetMap()`. During pre-player bootstrap and + /// focused represented tests, accept a sole map for the expected map id; + /// multiple instances without a canonical Player fail closed. + pub(crate) fn canonical_object_lookup_map_key_like_cpp( + &self, + fallback_map_id: u32, + ) -> Option { + if let Some(map_key) = self.current_canonical_player_map_key_like_cpp() { + return Some(map_key); + } + + let manager = self.canonical_map_manager.as_ref()?; + let manager = manager.lock().ok()?; + if let Some(player_guid) = self.player_guid() { + let mut player_map_count = 0usize; + manager.do_for_all_maps(|managed| { + if managed.map().get_typed_player(player_guid).is_some() { + player_map_count = player_map_count.saturating_add(1); + } + }); + // A player temporarily visible in two canonical maps is a + // transfer boundary, not permission to choose one by iteration + // order. Object-owned mutations fail closed until ownership is + // unambiguous. + if player_map_count != 0 || self.state == SessionState::LoggedIn { + return None; + } + } + let mut fallback_key = None; + let mut ambiguous = false; + manager.do_for_all_maps(|managed| { + if managed.map_id() != fallback_map_id { + return; + } + if fallback_key.is_some() { + ambiguous = true; + } else { + fallback_key = Some(wow_map::MapKey::new( + managed.map_id(), + managed.instance_id(), + )); + } + }); + (!ambiguous).then_some(fallback_key).flatten() + } + + /// Revalidates the exact map ownership captured before a multi-lock loot + /// authority reconciliation. If a canonical Player existed at capture + /// time, fallback lookup is forbidden: disappearing or moving during the + /// attempt must fail closed rather than mutate the old map. + pub(crate) fn loot_reconciliation_map_key_still_valid_like_cpp( + &self, + map_key: wow_map::MapKey, + canonical_player_was_present: bool, + ) -> bool { + if canonical_player_was_present { + return self.current_canonical_player_map_key_like_cpp() == Some(map_key); + } + if self.canonical_map_manager.is_some() { + return self.canonical_object_lookup_map_key_like_cpp(map_key.map_id) == Some(map_key); + } + let (map_id, instance_id) = self.current_legacy_runtime_map_key_like_cpp(); + u32::from(map_id) == map_key.map_id && instance_id == map_key.instance_id + } + + /// The legacy map facade must follow the same map instance that owns the + /// canonical Player. Instance `0` remains only the bootstrap fallback for + /// tests/runtime phases where no canonical Player has been materialized. + pub(crate) fn current_legacy_runtime_map_key_like_cpp(&self) -> (u16, u32) { + let fallback_map_id = self.player_map_id_like_cpp(); + let Some(map_key) = self.current_canonical_player_map_key_like_cpp() else { + return (fallback_map_id, 0); + }; + let Ok(map_id) = u16::try_from(map_key.map_id) else { + return (fallback_map_id, 0); + }; + (map_id, map_key.instance_id) } fn represented_dynamic_object_values_update_delivery_fingerprint_like_cpp( @@ -50222,6 +52577,9 @@ impl WorldSession { ); self.send_tx = realm_tx; } + if let Some(realm_write_fence) = self.realm_send_write_fence_like_cpp.take() { + self.send_write_fence_like_cpp = Some(realm_write_fence); + } if let Some(realm_rx) = self.realm_packet_rx.take() { info!( "Restoring realm packet channel as primary for account {}", @@ -50573,12 +52931,30 @@ pub fn run_legacy_creature_movement_tick_once_like_cpp( guid, position, ); - sync_canonical_creature_entity_on_map_like_cpp( + let expected_legacy_authority = creature.loot_authority_like_cpp().clone(); + let expected_legacy_stamp = expected_legacy_authority.stamp_like_cpp(); + let authority = sync_canonical_creature_entity_on_map_like_cpp( canonical_map_manager, map_id, instance_id, creature, ); + if let Some(authority) = authority { + let mut legacy = legacy_map_manager + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(world_creature) = + legacy.find_creature_mut(map_id as u16, instance_id, guid) + { + let _ = world_creature + .creature + .rebind_loot_authority_if_current_like_cpp( + &expected_legacy_authority, + expected_legacy_stamp, + authority, + ); + } + } outcome.canonical_syncs += 1; } } @@ -50714,6 +53090,9 @@ pub fn run_legacy_creature_lifecycle_tick_once_like_cpp( .collect(); for guid in despawn_guids { + if let Some(creature) = manager.find_creature_mut(map_id, instance_id, guid) { + creature.creature.clear_loot_like_cpp(); + } let Some(creature) = manager.remove_creature_any(map_id, instance_id, guid) else { continue; }; @@ -50890,12 +53269,31 @@ pub fn run_legacy_creature_lifecycle_tick_once_like_cpp( } } for (map_id, instance_id, creature) in canonical_inserts { - insert_canonical_creature_map_object_on_map_like_cpp( + let guid = creature.guid(); + let expected_legacy_authority = creature.loot_authority_like_cpp().clone(); + let expected_legacy_stamp = expected_legacy_authority.stamp_like_cpp(); + let authority = insert_canonical_creature_map_object_on_map_like_cpp( canonical_map_manager, map_id, instance_id, creature, ); + if let Some(authority) = authority { + let mut legacy = legacy_map_manager + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(world_creature) = + legacy.find_creature_mut(map_id as u16, instance_id, guid) + { + let _ = world_creature + .creature + .rebind_loot_authority_if_current_like_cpp( + &expected_legacy_authority, + expected_legacy_stamp, + authority, + ); + } + } outcome.canonical_inserts += 1; } } @@ -57228,11 +59626,17 @@ impl WorldSession { restored.max(represented_restored) } - fn current_map_is_dungeon_like_cpp(&self) -> bool { + pub(crate) fn current_map_is_dungeon_like_cpp(&self) -> bool { + self.current_map_dungeon_state_like_cpp().unwrap_or(false) + } + + /// Resolve the current map's C++ `MapEntry::IsDungeon` classification + /// without conflating missing Map.db2 metadata with an overworld map. + pub(crate) fn current_map_dungeon_state_like_cpp(&self) -> Option { self.map_store .as_ref() .and_then(|store| store.get(u32::from(self.player_map_id_like_cpp()))) - .is_some_and(|entry| entry.is_dungeon()) + .map(|entry| entry.is_dungeon()) } fn canonical_threatened_by_me_owner_guids_like_cpp( @@ -58137,7 +60541,7 @@ fn default_available_classes() -> Vec = plan.statements.iter().map(PreparedStatement::sql).collect(); assert_eq!( @@ -106785,6 +109965,107 @@ mod tests { ); } + #[test] + fn ambiguous_absolute_money_commit_requires_exact_changed_row_evidence_like_cpp() { + assert_eq!( + reconcile_absolute_player_money_commit_like_cpp(100, 75, Some(75)), + AbsolutePlayerMoneyCommitReconciliationLikeCpp::Committed + ); + assert_eq!( + reconcile_absolute_player_money_commit_like_cpp(100, 75, Some(100)), + AbsolutePlayerMoneyCommitReconciliationLikeCpp::RolledBack + ); + assert_eq!( + reconcile_absolute_player_money_commit_like_cpp(100, 75, Some(90)), + AbsolutePlayerMoneyCommitReconciliationLikeCpp::Indeterminate + ); + assert_eq!( + reconcile_absolute_player_money_commit_like_cpp(100, 75, None), + AbsolutePlayerMoneyCommitReconciliationLikeCpp::Indeterminate + ); + assert_eq!( + reconcile_absolute_player_money_commit_like_cpp(100, 100, Some(100)), + AbsolutePlayerMoneyCommitReconciliationLikeCpp::Indeterminate, + "an unchanged money row cannot prove whether bundled non-money statements committed" + ); + } + + #[tokio::test] + async fn failed_exclusive_money_persistence_never_publishes_runtime_and_reopens_admission() { + let (mut session, _, _) = make_session(); + let player_guid = ObjectGuid::create_player(1, 5_012); + session.set_player_guid(Some(player_guid)); + session.set_player_gold_like_cpp(100); + session.set_loot_money_persistence_test_result_like_cpp(false); + let tracker = session.durable_loot_money_persistence_tracker_like_cpp(); + + assert_eq!( + session + .mutate_and_persist_player_gold_exclusive_like_cpp(|money| money + 25) + .await, + None + ); + assert_eq!(session.player_gold_like_cpp(), 100); + assert!( + tracker.begin_like_cpp().is_ok(), + "a definite persistence failure must drop the save fence and reopen payout admission" + ); + } + + #[tokio::test] + async fn indeterminate_money_state_blocks_full_save_reconciliation_like_cpp() { + let (mut session, _, _) = make_session(); + let tracker = session.durable_loot_money_persistence_tracker_like_cpp(); + tracker.mark_indeterminate_like_cpp(); + + assert!( + !session + .reconcile_durable_loot_money_before_save_like_cpp() + .await + ); + assert!(tracker.is_indeterminate_like_cpp()); + assert!( + session + .current_player_save_to_db_statement_plan_like_cpp( + &PlayerSaveToDbSnapshotLikeCpp { + guid: ObjectGuid::create_player(1, 50_013), + map_id: 0, + instance_id: 0, + position: Position::default(), + level: 1, + xp: 0, + money: 0, + health: 1, + max_health: 1, + powers: empty_character_power_snapshot_like_cpp(), + }, + 0, + ) + .is_none(), + "an indeterminate money transaction must prevent every partial full-save plan" + ); + } + + #[test] + fn cancelled_money_commit_fence_permanently_closes_admission_before_disconnect_save() { + let tracker = Arc::new(DurableLootMoneyPersistenceTrackerLikeCpp::default()); + { + let _armed = PlayerMoneyCommitCancellationFenceLikeCpp::new(Arc::clone(&tracker)); + } + + assert!(tracker.is_indeterminate_like_cpp()); + assert!(tracker.begin_like_cpp().is_err()); + + let safe_tracker = Arc::new(DurableLootMoneyPersistenceTrackerLikeCpp::default()); + { + let mut known_outcome = + PlayerMoneyCommitCancellationFenceLikeCpp::new(Arc::clone(&safe_tracker)); + known_outcome.disarm_like_cpp(); + } + assert!(!safe_tracker.is_indeterminate_like_cpp()); + assert!(safe_tracker.begin_like_cpp().is_ok()); + } + #[test] fn offline_rested_xp_tavern_accrues_more_than_wilderness_like_cpp() { let (mut wilderness, _, _) = make_session(); @@ -106856,7 +110137,9 @@ mod tests { ) }); - let plan = session.current_player_save_to_db_statement_plan_like_cpp(&snapshot, 1_000); + let plan = session + .current_player_save_to_db_statement_plan_like_cpp(&snapshot, 1_000) + .expect("determinate tracker should allow a full-save plan"); assert!(plan.tutorials_changed_committed_like_cpp); assert!(plan.equipment_sets_committed_like_cpp); @@ -110929,6 +114212,138 @@ mod tests { ); } + #[test] + fn talent_reset_transaction_plan_clears_active_preserves_inactive_and_persists_zero_cost() { + let (mut session, _, _) = make_session(); + session.set_talent_store(Arc::new(wow_data::TalentStore::from_entries([ + test_talent_entry_like_cpp(101, 0, 50_101), + test_talent_entry_like_cpp(202, 1, 50_202), + ]))); + let mut spell_store = wow_data::SpellStore::new(); + spell_store.insert( + 50_101, + wow_data::SpellInfo { + effects: vec![wow_data::SpellEffectInfo { + effect: wow_data::spell::spell_effect_types::SPELL_EFFECT_LEARN_SPELL, + effect_trigger_spell: 60_101, + ..Default::default() + }], + ..test_spell_info_like_cpp(50_101) + }, + ); + spell_store.insert(60_101, test_spell_info_like_cpp(60_101)); + spell_store.insert(50_202, test_spell_info_like_cpp(50_202)); + session.set_spell_store(Arc::new(spell_store)); + install_test_talent_tab_store_like_cpp(&mut session); + + session.set_represented_active_talent_group_like_cpp(0); + assert!(session.load_represented_talent_row_like_cpp(101, 0, 0)); + assert!(session.load_represented_talent_row_like_cpp(202, 1, 1)); + session.mark_represented_talents_loaded_like_cpp(); + session.set_known_spells_like_cpp(vec![50_101, 60_101, 50_202]); + + let (state_plan, statements) = session + .represented_talent_reset_transaction_statement_plan_like_cpp(42, 777, 777, 0, 123) + .expect("coherent talent state should produce an atomic reset plan"); + + assert_eq!(state_plan.active_group, 0); + assert_eq!(state_plan.active_talents, BTreeMap::from([(101, 0)])); + assert!(state_plan.post_talents[0].is_empty()); + assert_eq!(state_plan.post_talents[1], BTreeMap::from([(202, 1)])); + assert_eq!(statements.len(), 4); + assert_eq!(statements[0].sql(), CharStatements::UPD_CHAR_MONEY.sql()); + assert_eq!( + statements[0].params(), + &[ + wow_database::SqlParam::U64(777), + wow_database::SqlParam::U64(42), + ], + "zero-cost reset still carries an absolute money marker in the same transaction" + ); + assert_eq!( + statements[1].sql(), + CharStatements::UPD_CHAR_TALENT_RESET_STATE.sql() + ); + assert_eq!( + statements[1].params(), + &[ + wow_database::SqlParam::U32(0), + wow_database::SqlParam::U64(123), + wow_database::SqlParam::U64(42), + ] + ); + assert_eq!(statements[2].sql(), CharStatements::DEL_CHAR_TALENT.sql()); + assert_eq!(statements[3].sql(), CharStatements::INS_CHAR_TALENT.sql()); + assert_eq!( + statements[3].params(), + &[ + wow_database::SqlParam::U64(42), + wow_database::SqlParam::U32(202), + wow_database::SqlParam::U8(1), + wow_database::SqlParam::U8(1), + ], + "the inactive talent group is reinserted after the delete-all" + ); + assert!( + statements.iter().all(|statement| { + !matches!( + statement.sql(), + sql if sql == CharStatements::DEL_CHAR_SPELL_BY_SPELL.sql() + || sql == CharStatements::INS_CHAR_SPELL.sql() + || sql == CharStatements::DEL_CHAR_SPELL_FAVORITE.sql() + || sql == CharStatements::INS_CHAR_SPELL_FAVORITE.sql() + ) + }), + "talent reset leaves character_spell ownership untouched until PlayerSpellMap is represented" + ); + + assert_eq!(session.player_gold_like_cpp(), 0); + assert_eq!( + session + .represented_update_talent_data_packet_like_cpp() + .groups[0] + .talents + .len(), + 1, + "building the transaction plan is pure and does not publish the reset" + ); + } + + #[test] + fn talent_reset_transaction_plan_persists_capped_fee_with_talent_delete() { + let (mut session, _, _) = make_session(); + session.mark_represented_talents_loaded_like_cpp(); + let month = TALENT_RESET_MONTH_SECS_LIKE_CPP; + let now = 10 * month; + session.set_represented_talent_reset_state_like_cpp(500_000, now); + let cost = session.represented_next_reset_talents_cost_like_cpp(now); + assert_eq!(cost, 500_000); + + let (_, statements) = session + .represented_talent_reset_transaction_statement_plan_like_cpp( + 42, 1_000_000, 500_000, cost, now, + ) + .expect("loaded empty active group should still persist its reset"); + + assert_eq!(statements.len(), 3); + assert_eq!( + statements[0].params(), + &[ + wow_database::SqlParam::U64(500_000), + wow_database::SqlParam::U64(42), + ] + ); + assert_eq!( + statements[1].params(), + &[ + wow_database::SqlParam::U32(500_000), + wow_database::SqlParam::U64(now), + wow_database::SqlParam::U64(42), + ] + ); + assert_eq!(statements[2].sql(), CharStatements::DEL_CHAR_TALENT.sql()); + } + #[test] fn character_glyph_save_requires_coherent_load_like_cpp() { let (session, _, _) = make_session(); @@ -124236,6 +127651,7 @@ mod tests { #[tokio::test] async fn loot_money_consumes_only_current_active_loot_like_cpp() { let (mut session, _, send_rx) = make_session(); + session.set_loot_money_persistence_test_result_like_cpp(true); let player_guid = ObjectGuid::create_player(1, 42); let active_guid = test_creature_guid(19_001); let inactive_guid = test_creature_guid(19_002); @@ -125603,7 +129019,10 @@ mod tests { assert_eq!(sent.read_packed_guid().unwrap(), loot_guid); assert_eq!(sent.read_packed_guid().unwrap(), player_guid); assert!(!session.is_active_loot_guid(loot_guid)); - assert!(session.loot_table.contains_key(&loot_guid)); + assert!( + !session.loot_table.contains_key(&loot_guid), + "full disconnect release retires the session packet-cache copy like C++" + ); } #[test] @@ -131869,6 +135288,7 @@ mod tests { ObjectGuid::create_world_object(HighGuid::Creature, 0, 1, 571, 0, 792, 40); let position = Position::new(1.0, 2.0, 3.0, 0.0); session.set_player_guid(Some(player_guid)); + session.set_loot_money_persistence_test_result_like_cpp(true); session.set_player_map_position_like_cpp(571, position); session.set_canonical_map_manager(Arc::clone(&canonical)); add_canonical_test_player_on_map(&canonical, player_guid, position, 571, 0); @@ -131920,6 +135340,59 @@ mod tests { ); } + #[tokio::test] + async fn quest_reward_money_crossing_cap_leaves_balance_unchanged_like_cpp() { + let (mut session, _pkt_tx, _send_rx) = make_session(); + let player_guid = ObjectGuid::create_player(1, 99); + let source_guid = + ObjectGuid::create_world_object(HighGuid::Creature, 0, 1, 571, 0, 792, 40); + let position = Position::new(1.0, 2.0, 3.0, 0.0); + session.set_player_guid(Some(player_guid)); + session.set_player_map_position_like_cpp(571, position); + let canonical = shared_canonical_map_manager(); + session.set_canonical_map_manager(Arc::clone(&canonical)); + add_canonical_test_player_on_map(&canonical, player_guid, position, 571, 0); + add_canonical_test_creature( + &canonical, + source_guid, + 792, + position, + wow_constants::unit::NPCFlags1::QUEST_GIVER.bits(), + ); + + let quest_id = 9_233; + let mut quest = test_quest_template(quest_id); + quest.reward_money_difficulty = 2; + let mut quest_store = wow_data::quest::QuestStore::from_quests_like_cpp([quest]); + quest_store.ender_quests.insert(792, vec![quest_id]); + session.quest_store = Some(Arc::new(quest_store)); + session.set_player_gold_like_cpp(MAX_MONEY_AMOUNT - 1); + session.player_quests.insert( + quest_id, + crate::handlers::quest::PlayerQuestStatus { + quest_id, + status: crate::conditions::QUEST_STATUS_COMPLETE_LIKE_CPP, + explored: false, + accept_time_secs: 0, + end_time_secs: 0, + objective_counts: Vec::new(), + slot: 0, + }, + ); + + session + .handle_quest_giver_choose_reward(quest_giver_choose_reward_packet_like_cpp( + source_guid, + quest_id, + 0, + 0, + )) + .await; + + assert_eq!(session.player_gold_like_cpp(), MAX_MONEY_AMOUNT - 1); + assert!(session.rewarded_quests.contains(&quest_id)); + } + #[tokio::test] async fn quest_giver_choose_reward_missing_source_rejects_before_mutation_like_cpp() { let (mut session, _pkt_tx, send_rx) = make_session(); @@ -132023,6 +135496,7 @@ mod tests { let (mut session, _pkt_tx, send_rx) = make_session(); let player_guid = ObjectGuid::create_player(1, 99); session.set_player_guid(Some(player_guid)); + session.set_loot_money_persistence_test_result_like_cpp(true); session.set_player_gold_like_cpp(5); let mut quest = test_quest_template(9_226); quest.flags = crate::handlers::quest::QUEST_FLAGS_AUTO_COMPLETE_LIKE_CPP; @@ -134896,6 +138370,14 @@ mod tests { wow_entities::CreatureAiState::WalkingRandom ); } + let legacy_authority = manager + .read() + .unwrap() + .find_creature(0, 0, guid) + .unwrap() + .creature + .loot_authority_like_cpp() + .clone(); { let guard = canonical.lock().unwrap(); let typed = guard @@ -134908,6 +138390,7 @@ mod tests { typed.ai_state(), wow_entities::CreatureAiState::WalkingRandom ); + assert!(legacy_authority.shares_storage_like_cpp(typed.loot_authority_like_cpp())); } } @@ -135500,6 +138983,10 @@ mod tests { static_flags: [0; 8], creature_type: 0, type_flags: 0, + loot_id: 0, + skin_loot_id: 0, + gold_min: 0, + gold_max: 0, movement_type: MovementGeneratorType::Idle, ground_movement_type: wow_constants::CreatureGroundMovementType::Run as u8, swim_allowed: true, diff --git a/docs/migration/EXISTING-CODE-DEFECTS.md b/docs/migration/EXISTING-CODE-DEFECTS.md index 12133ef01..c205a4311 100644 --- a/docs/migration/EXISTING-CODE-DEFECTS.md +++ b/docs/migration/EXISTING-CODE-DEFECTS.md @@ -68,10 +68,27 @@ mutates DB" ≠ "computes the right result / can't lose or dupe data." rejected after a stack overflow and replaced with the verified release artifact before these passes. Kept open only until the PR current-HEAD gates pass. - [ ] **D-C5 Loot item TOCTOU → duplication.** Slot marked looted *after* the async inventory - store; two concurrent looters both store it. `handlers/loot.rs:1143-1219`. C++ blocks the - slot *before* store. + store; two concurrent looters both store it. `handlers/loot.rs`. C++ instead gets safety from + object-owned `Loot` plus globally serialized `PROCESS_THREADUNSAFE` session work; it validates + storage before mutating the shared slot. + - 2026-07-18 issue #106 local slice: creatures/gameobjects now own a generation-tagged shared + loot authority; item/master/roll/disenchant paths use cancellation-safe leases whose detached + persistence worker owns the claim across SQL `COMMIT`. Session loot tables are packet caches, + and stale corpse/GO generations and stale group rolls fail closed. Kept open until the live + two-bot race, single-session capture, manual original-client QA, CI, and current-HEAD review + gates pass. - [ ] **D-C6 Loot money TOCTOU → duplication.** `loot.coins` zeroed *after* distribute; two - concurrent `handle_loot_money` both pay out. `handlers/loot.rs:1293-1356`. + concurrent `handle_loot_money` both pay out. `handlers/loot.rs`. + - 2026-07-18 issue #106 local slice: one detached worker atomically persists every connected, + allowed, in-range group share, commits the object-owned money claim, and then schedules one + exact-once runtime application per session without cross-session acknowledgement waits. A + cancelled packet future cannot reopen a successful DB transaction. Kept open for the same + runtime/review gates as D-C5. + - **Open crash boundary (not acceptance credit for #106):** these detached workers and their + completion trackers are in-process only. A runtime/process abort exactly after SQL `COMMIT` + but before the synchronous authority/completion continuation can still lose that continuation. + Closing `kill -9` recovery requires a durable claim journal written in the same transaction + and replayed at startup; neither the current authority nor the session tracker is that journal. - [ ] **D-C7 Player save has incomplete transaction coverage.** Issue #17 wraps the Rust-covered represented `Player::SaveToDB` character statements in one `SqlTransaction`, but full C++ save parity, login/account transaction coupling, capture diff, and manual live-client @@ -128,7 +145,9 @@ mutates DB" ≠ "computes the right result / can't lose or dupe data." ## MED — wrong values / loose checks / minor loss - [ ] **D-M1 Silent gold-save error.** `let _ = char_db.execute(stmt).await` swallows failures. `session.rs:21495`. -- [ ] **D-M2 Money split rounding loss.** Integer division discards remainder copper (C++ gives it to first recipient). `handlers/loot.rs:1323`. +- [x] **D-M2 was not a defect: C++ also discards group-money division remainder.** + `LootHandler.cpp::HandleLootMoneyOpcode` computes `loot->gold / playersNear.size()` and credits + that same truncated amount to every recipient; there is no first-recipient remainder branch. - [ ] **D-M3 Off-hand dual-wield damage has no penalty** (~25% too high). `session.rs:7923`. - [ ] **D-M4 Haste has no attack-speed cap** → scales unbounded. `session.rs:1358`. - [ ] **D-M5 Threat = raw damage**, no ability/role threat modifiers. `session.rs:47875`. diff --git a/docs/operations/pr-preflight.md b/docs/operations/pr-preflight.md index 86d11fbc0..bf426e7f0 100644 --- a/docs/operations/pr-preflight.md +++ b/docs/operations/pr-preflight.md @@ -26,29 +26,126 @@ Printing `review` or `full` with `--dry-run` does not require Codex or its execu | `diff [BASE]` | Whitespace-check committed, staged, and unstaged changes | No | | `format` | Run harness self-tests and both formatting checks from CI | No | | `check` | Run locked core checks, bot check, and server builds from CI | No | -| `test` | Run the four focused library suites from CI | No | +| `test` | Run focused suites, loot-race tests, and the required capture gate from CI | No | | `ci` | Run `format`, `check`, and `test` | No | | `quick [BASE]` | Run `diff`, `format`, and `check` during iteration | No | -| `capture` | Test the committed C++↔Rust capture-diff fixtures without `protoc` | No | +| `capture` | Test committed captures and enforce required capture contracts without `protoc` | No | | `review [BASE]` | Review a clean committed diff with Codex in read-only mode | No | | `review-uncommitted` | Review staged, unstaged, and untracked work during iteration | No | -| `full [BASE]` | Run `diff`, `ci`, `capture`, and `review` | No | +| `full [BASE]` | Run `diff`, CI (including the capture gate), and `review` | No | | `stable` | Check/build server binaries with latest stable Rust | No | | `qa-login` | Run the integrated live login bot | **Yes** | +| `qa-loot-race` | Run the destructive two-session atomic-loot bot | **Yes** | `BASE` defaults to `origin/3.4.3`. Use `--dry-run` before a command to print its underlying commands without executing them or provisioning optional review tools. -`qa-login` is intentionally outside `full`. It requires running services and may create/update -local QA accounts and session data, so it refuses to run without explicit acknowledgement: +`qa-login` is intentionally outside `full`. It requires running services, may create missing +local QA auth rows, and writes normal login/session data, so it refuses to run without explicit +acknowledgement. Existing BNet/game identities and character ownership are validation-only: a +credential, numeric-ID, ownership, ban, online-state, or realm-count mismatch fails closed rather +than being rewritten. ```bash ./tools/pr-preflight.sh --allow-runtime-qa qa-login ``` +`qa-loot-race` additionally refuses to trust whichever process happens to be +listening or whichever bot an ignored `.env.local` happens to select. Pin both +the feature-branch `world-server` and `wow-test-bot` files plus their SHA-256 +digests. The +gate snapshots and accredits the current normal PM2 executable, drives the +repository's guarded `capture-rust.sh loot-two-session-atomic-race --yes` +through a FIFO, then accredits the replacement capture PID/path/hash and both +distinct listeners against that same PID before running the bot. The capture +identity must remain unchanged; +after the bot, the gate signals completion and waits for fixture cleanup plus +exact restoration of the original PM2 executable/profile. +The bot writes its report to a fresh private path; exit status zero is accepted +only when that report proves the exact two accounts, two successful logins, +party/target/loot observations, exactly one item winner, both removal fanouts, +money notifications of `10` and `0`, one persisted item, an exact persisted +money delta of `10`, and relog verification. + +The preflight creates a private `WOW_BOT_FIXTURE_JOURNAL` path and forces +`WOW_BOT_ENSURE_TEST_ACCOUNTS=0`; loot QA therefore requires pre-provisioned +disposable accounts and never runs the generic account/character bootstrap. +The bot must durably journal before its first mutation, remove the pending +journal only after bounded restoration, and atomically create the mode-0600 +JSON marker `${WOW_BOT_FIXTURE_JOURNAL}.cleanup-complete`. The marker pins the +journal SHA-256 and cleanup PID. A pending journal, unsafe/malformed marker, or +digest mismatch prevents the normal PM2 world from restarting. Cleanup/restoration +failure takes precedence over the original bot/signal exit status, and the +recovery directory is retained for inspection. The preflight tracks the bot PID, +terminates and waits for it before capture cleanup on signals, and the wrapper +executes the bot with Linux parent-death protection so no detached child can +continue mutating the fixture. + +The bot is forced to loopback ports `8085`/`8086` (override the accredited +ports with `RUST_WORLD_PORT`/`RUST_INSTANCE_PORT`) and rejects a different +instance port advertised by `SMSG_CONNECT_TO`. Run this only on an isolated +host/network namespace whose firewall restricts the BNet/world/instance ports +to loopback, because the server process itself may use wildcard listeners: + +```bash +test -z "$(git status --porcelain=v1 --untracked-files=normal)" +TARGET_EXEC="$(realpath /absolute/path/to/feature-branch/world-server)" +TARGET_SHA="$(sha256sum "$TARGET_EXEC" | awk '{print $1}')" +BOT_EXEC="$(realpath tools/wow-test-bot/target/debug/wow-test-bot)" +BOT_SHA="$(sha256sum "$BOT_EXEC" | awk '{print $1}')" +RUST_CAPTURE_DB_CONF=/home/server/trinity-legacy-install/bin/worldserver.conf \ +RUST_CAPTURE_EFFECTIVE_CONFIG=/home/server/trinity-legacy-install/etc/worldserver.conf \ +WOW_BOT_DB_CONF=/home/server/trinity-legacy-install/bin/worldserver.conf \ +WOW_BOT_WORLD_EXEC="$TARGET_EXEC" \ +WOW_BOT_WORLD_EXEC_SHA256="$TARGET_SHA" \ +WOW_BOT_EXEC="$BOT_EXEC" \ +WOW_BOT_EXEC_SHA256="$BOT_SHA" \ +./tools/pr-preflight.sh --allow-runtime-qa \ + --ack-disposable-overworld-loot-race qa-loot-race +``` + +`RUST_CAPTURE_DB_CONF` is the credential source used by the bounded fixture +guard; `RUST_CAPTURE_EFFECTIVE_CONFIG` is independently pinned to the exact +config selected by the PM2 cwd/argv profile. They are intentionally different +on the current host, and the capture wrapper fails closed if the latter does +not match PM2. + +`RUST_WORLD_PORT` and `RUST_INSTANCE_PORT` must be different. The fixture guard +also rejects orphan `gameobject_addon`, `gameobject_overrides`, `spawn_group`, +pool, event, or linked-respawn rows before claiming spawn `9106001`. Cleanup +checks the complete pinned spawn (including persisted `state`) and addon row +before its first write; it never resets state merely to make deletion pass. + +For a no-build QA run, also provide the independently pinned +`WOW_BOT_EXEC`/`WOW_BOT_EXEC_SHA256`. Caller-supplied runtime and bot variables +take precedence over ignored `.env.local` defaults. + Fresh C++ or Rust packet recording is also intentionally excluded: the capture scripts can -restart services and require an interactive client flow. The safe `capture` profile only tests -the fixtures already committed to the repository and does not invoke protobuf tooling. +restart services and require an interactive client flow. The `capture` profile does not invoke +protobuf tooling. It tests committed fixtures and runs `verify-required` for milestone contracts. +A required contract whose matched real C++/Rust artifacts have not been installed fails closed. +Issue #106's pre-lineage one-client loot claim still compares CLEAN at the strict six-packet +semantic layer, but it is intentionally `awaiting-real-captures`: it lacks the mandatory completed +RAW manifests and process/config lineage. A fresh import must validate those manifests, retain exact +copies, hash the exact reviewed selection and all installed outputs, and publish the complete flow +generation atomically before `verify-required` can pass. The separate two-client race remains +runtime evidence rather than a golden, because global packet logs merge concurrent sessions. +RAW capture-manifest schema v3 distinguishes PM2's configured entry +PID/start-time/path/hash/profile from the unique PID/start-time owning both +listeners: they may be identical for a direct Rust binary, while the legacy +non-`exec` shell wrapper requires a verified descendant listener. It also +attests that the capture runtime/process tree is absent, fixture cleanup was +verified, and the normal runtime is healthy before no-overwrite publication. +For `loot-single-item-claim`, guard, canonical TESTBOT2/TESTBOT3 fixture +identity, pinned bot executable, and a semantically validated successful bot +report are mandatory on both sides. C++ derives its effective config from the +PM2 argv/wrapper and requires an exact canonical `CPP_CONF` match. Required +recording also needs a clean committed RustyCore harness/source. The known-dirty +legacy C++ checkout is identified by HEAD plus an explicit dirty flag and +deterministic worktree-state digest; its pinned live listener-binary SHA-256 +remains primary. The shared private mode-0700 orchestration lock, output paths, +and raw packet inventory reject symlinks; DB password loading and both mysql +calls suppress `bash -x` so credentials and `MYSQL_PWD` cannot enter logs. ## Recommended maintainer flow diff --git a/tools/pr-preflight.sh b/tools/pr-preflight.sh index 987456480..cadc714c7 100755 --- a/tools/pr-preflight.sh +++ b/tools/pr-preflight.sh @@ -6,9 +6,21 @@ POLICY_FILE="$REPO_ROOT/tools/codex-review-policy.md" SCHEMA_FILE="$REPO_ROOT/tools/codex-review-schema.json" PROTOC_VERSION_FILE="$REPO_ROOT/.protoc-version" DEFAULT_BASE="origin/3.4.3" +DEFAULT_RUST_MIN_STACK=268435456 CODEX_REVIEW_TIMEOUT_SECONDS="${CODEX_REVIEW_TIMEOUT_SECONDS:-1800}" DRY_RUN=0 ALLOW_RUNTIME_QA=0 +ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=0 +RUST_MIN_STACK="${RUST_MIN_STACK:-$DEFAULT_RUST_MIN_STACK}" +export RUST_MIN_STACK +QA_LOOT_RACE_CAPTURE_SCRIPT="$REPO_ROOT/crates/capture-diff/scripts/capture-rust.sh" +QA_LOOT_RACE_CAPTURE_PID="" +QA_LOOT_RACE_CAPTURE_FD="" +QA_LOOT_RACE_CAPTURE_DIR="" +QA_LOOT_RACE_CAPTURE_LOG="" +QA_LOOT_RACE_FIXTURE_JOURNAL="" +QA_LOOT_RACE_CAPTURE_WAIT_STATUS=0 +QA_LOOT_RACE_BOT_PID="" usage() { cat <<'EOF' @@ -18,24 +30,26 @@ Usage: ./tools/pr-preflight.sh [OPTIONS] [BASE] Options: - --dry-run Print commands without running them. - --allow-runtime-qa Allow qa-login to touch local QA account/session data. - -h, --help Show this help. + --dry-run Print commands without running them. + --allow-runtime-qa Allow live QA commands to modify local QA data. + --ack-disposable-overworld-loot-race Acknowledge qa-loot-race mutates its exact shared-chest fixture. + -h, --help Show this help. Commands: self-test Test harness parsing and pinned-version invariants. format Run the two formatting checks used by GitHub Actions. check Run the locked core checks and server builds used by CI. - test Run the four focused library suites used by CI. + test Run focused suites, loot-race tests, and required capture gate used by CI. ci Run format, check, and test (the required Rust CI jobs). diff [BASE] Check committed, staged, and unstaged diffs for whitespace errors. quick [BASE] Run diff, format, and check. capture Run capture-diff regression tests (protoc not required). review [BASE] Review the clean committed diff with local Codex. review-uncommitted Review staged, unstaged, and untracked changes with local Codex. - full [BASE] Run diff, ci, capture, and review on a clean committed HEAD. + full [BASE] Run diff, CI (including capture), and review on a clean committed HEAD. stable Check/build the server binaries with latest stable Rust. qa-login Run the existing live login bot; requires --allow-runtime-qa. + qa-loot-race Run destructive live two-session loot QA; requires both QA flags. BASE defaults to origin/3.4.3. The GitHub Codex reviewer verdict remains required. EOF @@ -54,6 +68,13 @@ die() { exit 64 } +validate_rust_min_stack() { + [[ "$RUST_MIN_STACK" =~ ^[1-9][0-9]*$ ]] || die \ + "RUST_MIN_STACK must be a positive integer" + ((RUST_MIN_STACK >= DEFAULT_RUST_MIN_STACK)) || die \ + "RUST_MIN_STACK must be at least $DEFAULT_RUST_MIN_STACK bytes for Rust 1.88" +} + print_command() { printf '+' printf ' %q' "$@" @@ -71,6 +92,226 @@ require_command() { command -v "$1" >/dev/null 2>&1 || die "required command not found: $1" } +sha256_of_file() { + local output digest + output="$(sha256sum <"$1")" || return 1 + digest="${output%% *}" + [[ "$digest" =~ ^[0-9a-f]{64}$ ]] || return 1 + printf '%s' "$digest" +} + +qa_world_identity() { + local process_name="$1" + local expected_exec="$2" + pm2 jlist | jq -er --arg name "$process_name" --arg expected_exec "$expected_exec" ' + [.[] | select(.name == $name)] as $matches + | if ($matches | length) != 1 then + error("PM2 world process must exist exactly once") + else $matches[0] end + | if .pm2_env.status != "online" + or (.pid // 0) <= 0 + or .pm2_env.pm_exec_path != $expected_exec + then error("PM2 world process is not the pinned online executable") + else [.pid, (.pm2_env.restart_time // 0)] | @tsv end + ' +} + +qa_world_snapshot() { + local process_name="$1" + pm2 jlist | jq -er --arg name "$process_name" ' + [.[] | select(.name == $name)] as $matches + | if ($matches | length) != 1 then + error("PM2 world process must exist exactly once") + else $matches[0] end + | if .pm2_env.status != "online" + or (.pid // 0) <= 0 + or (.pm2_env.pm_exec_path | type) != "string" + or .pm2_env.pm_exec_path == "" + then error("PM2 world process is not an online executable") + else [.pid, (.pm2_env.restart_time // 0), .pm2_env.pm_exec_path] | @tsv end + ' +} + +qa_world_process_matches() { + local identity="$1" + local expected_exec="$2" + local expected_sha="$3" + local pid proc_exe live_exec source_sha live_sha + + [[ "$identity" == *$'\t'* ]] || return 1 + pid="${identity%%$'\t'*}" + [[ "$pid" =~ ^[1-9][0-9]*$ ]] || return 1 + proc_exe="/proc/${pid}/exe" + [[ -L "$proc_exe" ]] || return 1 + live_exec="$(realpath -e -- "$proc_exe" 2>/dev/null)" || return 1 + [[ "$live_exec" == "$expected_exec" ]] || return 1 + source_sha="$(sha256_of_file "$expected_exec")" || return 1 + live_sha="$(sha256_of_file "$proc_exe")" || return 1 + [[ "$source_sha" == "$expected_sha" && "$live_sha" == "$expected_sha" ]] +} + +qa_world_ports_ready() { + local identity="$1" + local world_port="$2" + local instance_port="$3" + local pid + + [[ "$world_port" != "$instance_port" ]] || return 1 + pid="${identity%%$'\t'*}" + qa_port_owned_exclusively_by_pid "$world_port" "$pid" \ + && qa_port_owned_exclusively_by_pid "$instance_port" "$pid" +} + +qa_port_owned_exclusively_by_pid() { + local port="$1" + local pid="$2" + local sockets socket remaining seen_pid matched_pid + + [[ "$pid" =~ ^[1-9][0-9]*$ ]] || return 1 + sockets="$(ss -H -ltnp "sport = :${port}" 2>/dev/null)" || return 1 + [[ -n "$sockets" ]] || return 1 + while IFS= read -r socket; do + remaining="$socket" + seen_pid=0 + while [[ "$remaining" =~ pid=([0-9]+), ]]; do + matched_pid="${BASH_REMATCH[1]}" + [[ "$matched_pid" == "$pid" ]] || return 1 + seen_pid=1 + remaining="${remaining#*"${BASH_REMATCH[0]}"}" + done + ((seen_pid == 1)) || return 1 + done <<<"$sockets" +} + +qa_world_packet_dump_absent() { + local process_name="$1" + pm2 jlist | jq -e --arg name "$process_name" ' + [.[] | select(.name == $name)] as $matches + | ($matches | length) == 1 + and (($matches[0].pm2_env | has("RUSTYCORE_PACKET_DUMP_DIR")) | not) + and (((($matches[0].pm2_env.env // {}) + | has("RUSTYCORE_PACKET_DUMP_DIR"))) | not) + ' >/dev/null +} + +qa_loot_race_capture_cleanup() { + local status=$? + local bot_wait_status=0 + local wrapper_status="$QA_LOOT_RACE_CAPTURE_WAIT_STATUS" + local recovery_pending=0 + + trap - EXIT + trap '' HUP INT TERM + if [[ "$QA_LOOT_RACE_BOT_PID" =~ ^[1-9][0-9]*$ ]]; then + if kill -0 "$QA_LOOT_RACE_BOT_PID" 2>/dev/null; then + kill -TERM "$QA_LOOT_RACE_BOT_PID" 2>/dev/null || true + fi + wait "$QA_LOOT_RACE_BOT_PID" || bot_wait_status=$? + fi + QA_LOOT_RACE_BOT_PID="" + if [[ "$QA_LOOT_RACE_CAPTURE_PID" =~ ^[1-9][0-9]*$ ]]; then + if kill -0 "$QA_LOOT_RACE_CAPTURE_PID" 2>/dev/null; then + kill -TERM "$QA_LOOT_RACE_CAPTURE_PID" 2>/dev/null || true + fi + wait "$QA_LOOT_RACE_CAPTURE_PID" || wrapper_status=$? + fi + QA_LOOT_RACE_CAPTURE_PID="" + if [[ "$QA_LOOT_RACE_CAPTURE_FD" =~ ^[0-9]+$ ]]; then + exec {QA_LOOT_RACE_CAPTURE_FD}>&- || true + fi + QA_LOOT_RACE_CAPTURE_FD="" + # A wrapper cleanup/restoration failure is more important than the original + # bot/signal status because it means the normal world may be unsafe to start. + if ((wrapper_status != 0)); then + status=$wrapper_status + elif ((status == 0 && bot_wait_status != 0)); then + status=$bot_wait_status + fi + if [[ -n "$QA_LOOT_RACE_FIXTURE_JOURNAL" ]] \ + && { [[ -e "$QA_LOOT_RACE_FIXTURE_JOURNAL" \ + || -L "$QA_LOOT_RACE_FIXTURE_JOURNAL" ]] \ + || [[ -e "${QA_LOOT_RACE_FIXTURE_JOURNAL}.cleanup-complete" \ + || -L "${QA_LOOT_RACE_FIXTURE_JOURNAL}.cleanup-complete" ]]; }; then + recovery_pending=1 + fi + if ((status != 0)) && [[ -s "$QA_LOOT_RACE_CAPTURE_LOG" ]]; then + echo "qa-loot-race guarded capture log:" >&2 + sed -n '1,240p' "$QA_LOOT_RACE_CAPTURE_LOG" >&2 || true + fi + if [[ -n "$QA_LOOT_RACE_CAPTURE_DIR" && -d "$QA_LOOT_RACE_CAPTURE_DIR" ]]; then + rm -f -- "$QA_LOOT_RACE_CAPTURE_DIR/control.fifo" + if ((status == 0 && recovery_pending == 0)); then + rm -f -- "$QA_LOOT_RACE_CAPTURE_DIR/capture.log" + rmdir -- "$QA_LOOT_RACE_CAPTURE_DIR" 2>/dev/null || true + else + echo "qa-loot-race recovery artifacts retained at ${QA_LOOT_RACE_CAPTURE_DIR}" >&2 + fi + fi + QA_LOOT_RACE_CAPTURE_DIR="" + QA_LOOT_RACE_CAPTURE_LOG="" + QA_LOOT_RACE_FIXTURE_JOURNAL="" + QA_LOOT_RACE_CAPTURE_WAIT_STATUS=0 + exit "$status" +} + +qa_wait_for_loot_capture_ready() { + local capture_pid="$1" + local capture_log="$2" + local marker=">>> Perform the 'loot-two-session-atomic-race' flow with the client now." + local wrapper_status=0 + + for _ in $(seq 1 240); do + if [[ -f "$capture_log" ]] && rg -Fq -- "$marker" "$capture_log"; then + return 0 + fi + if ! kill -0 "$capture_pid" 2>/dev/null; then + wait "$capture_pid" || wrapper_status=$? + QA_LOOT_RACE_CAPTURE_WAIT_STATUS=$wrapper_status + QA_LOOT_RACE_CAPTURE_PID="" + echo "guarded capture wrapper exited before its ready marker (status $wrapper_status)" >&2 + return 1 + fi + sleep 0.25 + done + echo "timed out waiting 60 seconds for the guarded capture ready marker" >&2 + return 1 +} + +require_exact_occurrences() { + local text="$1" + local needle="$2" + local expected="$3" + local label="$4" + local count=0 + + [[ -n "$needle" ]] || die "cannot count an empty self-test pattern for $label" + while [[ "$text" == *"$needle"* ]]; do + text="${text#*"$needle"}" + ((count += 1)) + done + ((count == expected)) || die \ + "$label appeared $count time(s); expected exactly $expected" +} + +self_test_cleanup() { + local world_pid="${1:-}" + local artifacts="${2:-}" + + if [[ "$world_pid" =~ ^[1-9][0-9]*$ ]] && kill -0 "$world_pid" 2>/dev/null; then + kill "$world_pid" 2>/dev/null || true + wait "$world_pid" 2>/dev/null || true + fi + if [[ -n "$artifacts" && -d "$artifacts" ]]; then + rm -rf -- "$artifacts" + fi +} + +valid_tcp_port() { + local value="$1" + [[ "$value" =~ ^[0-9]+$ ]] || return 1 + ((10#$value >= 1 && 10#$value <= 65535)) +} + toolchain_channel() { local channel channel="$(sed -n 's/^channel = "\([^"]*\)"/\1/p' "$REPO_ROOT/rust-toolchain.toml" | head -n 1)" @@ -200,6 +441,17 @@ run_check() { -p world-server cargo_cmd check --locked --manifest-path tools/wow-test-bot/Cargo.toml cargo_cmd build --locked -p bnet-server -p world-server + cargo_cmd clippy --locked --no-deps --message-format short \ + -p wow-loot \ + -p wow-entities \ + -p wow-map \ + -p wow-network \ + --lib + cargo_cmd clippy --locked --no-deps --message-format short \ + -p wow-world \ + --lib \ + -- \ + --cap-lints warn } run_test() { @@ -207,8 +459,13 @@ run_test() { resolve_protoc cargo_cmd test --locked -p wow-data --lib cargo_cmd test --locked -p wow-packet --lib + cargo_cmd test --locked -p wow-loot --lib + cargo_cmd test --locked -p wow-entities --lib cargo_cmd test --locked -p wow-map --lib + cargo_cmd test --locked -p wow-network --lib cargo_cmd test --locked -p wow-world --lib + cargo_cmd test --locked --manifest-path tools/wow-test-bot/Cargo.toml loot_race::tests + run_capture } run_ci() { @@ -238,6 +495,8 @@ run_quick() { run_capture() { log "Committed capture-diff regression gate" cargo_cmd test --locked -p capture-diff + cargo_cmd run --locked -p capture-diff -- \ + verify-required loot-single-item-claim } review_result() { @@ -468,6 +727,9 @@ run_self_test() { local artifacts local capture_output local clean_result + local cpp_capture_text + local cpp_missing_pin_output + local ci_dry_run_output local combined_inspection_result local committed_inspection_result local dependency @@ -475,15 +737,66 @@ run_self_test() { local echo_inspection_result local findings_result local full_dry_run_output + local github_workflow_text local incomplete_inspection_result local inspection_result local invalid_result + local loot_race_fake_bot + local loot_race_fake_bot_sha + local loot_race_malicious_env + local loot_race_wrapper_args + local loot_race_wrapper_args_file + local loot_race_wrapper_missing_ack_output + local loot_race_wrapper_xtrace_output + local loot_race_wrapper_output + local loot_race_secret_sentinel="rc106-secret-must-not-appear" local no_inspection_result local path_limited_inspection_result local protoc_output + local qa_fake_bin + local qa_fake_bot + local qa_fake_bot_marker + local qa_fake_bot_sha + local qa_fake_capture + local qa_fake_world + local qa_fake_world_other + local qa_identity + local qa_instance_port=45124 + local qa_loot_race_dry_run_output + local qa_loot_race_missing_ack_output + local qa_loot_race_missing_bot_pin_output + local qa_loot_race_missing_world_pin_output + local qa_pin_fixture + local qa_pin_fixture_sha + local qa_pm2_after_file + local qa_pm2_before_file + local qa_pm2_duplicate + local qa_pm2_json_file + local qa_pm2_offline + local qa_pm2_online + local qa_pm2_pid_changed + local qa_pm2_restart_changed + local qa_pm2_state_file + local qa_pm2_wrong_path + local qa_positive_output + local qa_cleanup_output + local qa_drift_output + local qa_early_output + local qa_early_status=0 + local qa_kill_output + local qa_restart_output + local qa_pid_output + local qa_term_output + local qa_world_pid="" + local qa_world_port=45123 + local qa_world_sha + local qa_target_sha local range_summary_inspection_result + local rust_capture_text + local rust_missing_pin_output local review_dry_run_output local staged_alias_inspection_result + local -a qa_common_env=() local rc=0 if ((DRY_RUN)); then @@ -498,6 +811,7 @@ run_self_test() { python3 -m json.tool "$SCHEMA_FILE" >/dev/null || die "invalid Codex review JSON schema" artifacts="$(mktemp -d "${TMPDIR:-/tmp}/rustycore-preflight-self-test.XXXXXX")" + trap 'self_test_cleanup "${qa_world_pid:-}" "${artifacts:-}"' EXIT clean_result="$artifacts/clean.json" findings_result="$artifacts/findings.json" invalid_result="$artifacts/invalid.json" @@ -584,10 +898,11 @@ run_self_test() { [[ "$rc" == "65" ]] || die "staged alias inspection self-test returned $rc instead of 65" mkdir -p "$artifacts/bin" - for dependency in awk dirname git head sed tr; do + for dependency in awk dirname env git head realpath sed sha256sum tr; do ln -s "$(command -v "$dependency")" "$artifacts/bin/$dependency" done - printf '#!/bin/sh\nexit 0\n' >"$artifacts/bin/cargo" + printf '#!/bin/sh\n[ "${RUST_MIN_STACK:-0}" -ge %s ] || exit 70\nexit 0\n' \ + "$DEFAULT_RUST_MIN_STACK" >"$artifacts/bin/cargo" expected_protoc_version="$(project_protoc_version)" printf '#!/bin/sh\nprintf "libprotoc %s\\n"\n' \ "$expected_protoc_version" >"$artifacts/bin/protoc" @@ -602,8 +917,1155 @@ run_self_test() { capture_output="$(PATH="$artifacts/bin" PROTOC="$artifacts/missing-protoc" \ "$BASH" "$REPO_ROOT/tools/pr-preflight.sh" capture 2>&1)" || die \ "capture profile unexpectedly requires protoc" - [[ "$capture_output" == *"test --locked -p capture-diff"* ]] || die \ - "capture profile did not run the capture-diff tests" + require_exact_occurrences "$capture_output" \ + "test --locked -p capture-diff" 1 \ + "capture profile capture-diff test command" + require_exact_occurrences "$capture_output" \ + "verify-required loot-single-item-claim" 1 \ + "capture profile required-flow command" + + ci_dry_run_output="$(PATH="$artifacts/bin" \ + "$BASH" "$REPO_ROOT/tools/pr-preflight.sh" --dry-run ci 2>&1)" || die \ + "CI dry-run unexpectedly requires optional execution tools" + [[ "$ci_dry_run_output" == *"clippy --locked --no-deps --message-format short -p wow-loot"* ]] || die \ + "CI profile did not print the loot-authority clippy command" + [[ "$ci_dry_run_output" == *"clippy --locked --no-deps --message-format short -p wow-world --lib -- --cap-lints warn"* ]] || die \ + "CI profile did not print the capped wow-world clippy command" + [[ "$ci_dry_run_output" == *"test --locked -p wow-loot --lib"* ]] || die \ + "CI profile did not print the wow-loot tests" + [[ "$ci_dry_run_output" == *"test --locked -p wow-entities --lib"* ]] || die \ + "CI profile did not print the wow-entities tests" + [[ "$ci_dry_run_output" == *"test --locked -p wow-network --lib"* ]] || die \ + "CI profile did not print the wow-network tests" + [[ "$ci_dry_run_output" == *"--manifest-path tools/wow-test-bot/Cargo.toml loot_race::tests"* ]] || die \ + "CI profile did not print the focused loot-race harness tests" + require_exact_occurrences "$ci_dry_run_output" \ + "test --locked -p capture-diff" 1 \ + "local CI capture-diff test command" + require_exact_occurrences "$ci_dry_run_output" \ + "verify-required loot-single-item-claim" 1 \ + "local CI required-flow command" + [[ "$ci_dry_run_output" != *"WOW_BOT_LOOT_RACE_SMOKE=1"* ]] || die \ + "normal CI profile must never activate destructive live loot-race QA" + + github_workflow_text="$(<"$REPO_ROOT/.github/workflows/rust-ci.yml")" + require_exact_occurrences "$github_workflow_text" \ + "cargo +1.88.0 test --locked -p capture-diff" 1 \ + "GitHub workflow capture-diff test command" + require_exact_occurrences "$github_workflow_text" \ + "cargo +1.88.0 run --locked -p capture-diff -- verify-required loot-single-item-claim" 1 \ + "GitHub workflow required-flow command" + + if qa_loot_race_missing_ack_output="$(PATH="$artifacts/bin" \ + "$BASH" "$REPO_ROOT/tools/pr-preflight.sh" --dry-run --allow-runtime-qa \ + qa-loot-race 2>&1)"; then + die "qa-loot-race accepted live mutation without its destructive acknowledgement" + fi + [[ "$qa_loot_race_missing_ack_output" == *"--ack-disposable-overworld-loot-race"* ]] || die \ + "qa-loot-race missing-ack error did not name the required acknowledgement" + + qa_loot_race_dry_run_output="$(PATH="$artifacts/bin" \ + "$BASH" "$REPO_ROOT/tools/pr-preflight.sh" --dry-run --allow-runtime-qa \ + --ack-disposable-overworld-loot-race qa-loot-race 2>&1)" || die \ + "acknowledged qa-loot-race dry-run failed" + [[ "$qa_loot_race_dry_run_output" == *"WOW_BOT_LOOT_RACE_SMOKE=1"* ]] || die \ + "qa-loot-race did not select the loot-race bot mode" + [[ "$qa_loot_race_dry_run_output" == *"WOW_BOT_EXEC="* \ + && "$qa_loot_race_dry_run_output" == *"WOW_BOT_EXEC_SHA256="* ]] || die \ + "qa-loot-race dry-run did not disclose the pinned bot provenance" + [[ "$qa_loot_race_dry_run_output" == *"WOW_BOT_ENSURE_TEST_ACCOUNTS=0"* \ + && "$qa_loot_race_dry_run_output" == *"WOW_BOT_FIXTURE_JOURNAL="* ]] || die \ + "qa-loot-race did not disable identity bootstrap and disclose its recovery journal" + [[ "$qa_loot_race_dry_run_output" == *"WOW_BOT_ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=1"* ]] || die \ + "qa-loot-race did not forward the destructive acknowledgement" + [[ "$qa_loot_race_dry_run_output" == *"run_rustycore_login_smoke.sh"* ]] || die \ + "qa-loot-race did not invoke the live QA wrapper" + [[ "$qa_loot_race_dry_run_output" == *"capture-rust.sh loot-two-session-atomic-race --yes"* \ + && "$qa_loot_race_dry_run_output" == *"RUST_CAPTURE_LOOT_FIXTURE_GUARD=1"* \ + && "$qa_loot_race_dry_run_output" == *"RUST_CAPTURE_ACK_LOOT_FIXTURE_MUTATION=1"* \ + && "$qa_loot_race_dry_run_output" == *"RUST_CAPTURE_DB_CONF=/home/server/trinity-legacy-install/bin/worldserver.conf"* \ + && "$qa_loot_race_dry_run_output" == *"RUST_CAPTURE_EFFECTIVE_CONFIG=/home/server/trinity-legacy-install/etc/worldserver.conf"* \ + && "$qa_loot_race_dry_run_output" == *"wait for guarded capture READY marker"* \ + && "$qa_loot_race_dry_run_output" == *"wait for exact PM2/fixture restoration"* ]] || die \ + "qa-loot-race dry-run did not disclose its guarded capture lifecycle" + [[ "$qa_loot_race_dry_run_output" == *"WORLD_HOST=127.0.0.1"* \ + && "$qa_loot_race_dry_run_output" == *"WORLD_PORT=8085"* \ + && "$qa_loot_race_dry_run_output" == *"INSTANCE_HOST=127.0.0.1"* \ + && "$qa_loot_race_dry_run_output" == *"INSTANCE_PORT=8086"* ]] || die \ + "qa-loot-race dry-run did not pin the bot to the accredited local listeners" + [[ "$qa_loot_race_dry_run_output" == *"BNET_HOST=127.0.0.1"* \ + && "$qa_loot_race_dry_run_output" == *"BNET_PORT=8081"* \ + && "$qa_loot_race_dry_run_output" == *"WOW_BOT_LOOT_RACE_ACCOUNT_A=TESTBOT2@bot.local"* \ + && "$qa_loot_race_dry_run_output" == *"WOW_BOT_LOOT_RACE_ACCOUNT_B=TESTBOT3@bot.local"* \ + && "$qa_loot_race_dry_run_output" == *"WOW_BOT_LOOT_RACE_GAMEOBJECT_ENTRY=2846"* \ + && "$qa_loot_race_dry_run_output" == *"WOW_BOT_LOOT_RACE_GAMEOBJECT_SPAWN_GUID=9106001"* \ + && "$qa_loot_race_dry_run_output" == *"WOW_BOT_LOOT_RACE_ITEM_ENTRY=38"* ]] || die \ + "qa-loot-race dry-run did not pin BNet and the exact disposable fixture" + + if qa_loot_race_missing_world_pin_output="$(PATH="$artifacts/bin" \ + "$BASH" "$REPO_ROOT/tools/pr-preflight.sh" --allow-runtime-qa \ + --ack-disposable-overworld-loot-race qa-loot-race 2>&1)"; then + die "qa-loot-race accepted an unpinned running world server" + fi + [[ "$qa_loot_race_missing_world_pin_output" == *"WOW_BOT_WORLD_EXEC"* ]] || die \ + "qa-loot-race missing-world-pin error did not name WOW_BOT_WORLD_EXEC" + + qa_pin_fixture="$artifacts/bin/cargo" + qa_pin_fixture_sha="$(sha256sum "$qa_pin_fixture" | awk '{print $1}')" + if qa_loot_race_missing_bot_pin_output="$(PATH="$artifacts/bin" \ + WOW_BOT_WORLD_EXEC="$qa_pin_fixture" \ + WOW_BOT_WORLD_EXEC_SHA256="$qa_pin_fixture_sha" \ + "$BASH" "$REPO_ROOT/tools/pr-preflight.sh" --allow-runtime-qa \ + --ack-disposable-overworld-loot-race qa-loot-race 2>&1)"; then + die "qa-loot-race accepted an unpinned bot executable" + fi + [[ "$qa_loot_race_missing_bot_pin_output" == *"WOW_BOT_EXEC"* ]] || die \ + "qa-loot-race missing-bot-pin error did not name WOW_BOT_EXEC" + + if loot_race_wrapper_missing_ack_output="$(PATH="$artifacts/bin" \ + WOW_BOT_PASSWORD="$loot_race_secret_sentinel" WOW_BOT_GENERATE_LOCAL_PASSWORD=0 \ + WOW_BOT_LOOT_RACE_SMOKE=1 \ + WOW_BOT_ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=0 \ + WOW_BOT_ENV_FILE=/dev/null \ + "$BASH" "$REPO_ROOT/tools/wow-test-bot/run_rustycore_login_smoke.sh" 2>&1)"; then + die "loot-race wrapper accepted destructive mode without acknowledgement" + fi + [[ "$loot_race_wrapper_missing_ack_output" == *"WOW_BOT_ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=1"* ]] || die \ + "loot-race wrapper missing-ack error did not name the required acknowledgement" + [[ "$loot_race_wrapper_missing_ack_output" != *"$loot_race_secret_sentinel"* ]] || die \ + "loot-race wrapper exposed a caller secret while loading defaults" + loot_race_wrapper_xtrace_output="$(PATH="$artifacts/bin" \ + WOW_BOT_PASSWORD="$loot_race_secret_sentinel" WOW_BOT_GENERATE_LOCAL_PASSWORD=0 \ + WOW_BOT_LOOT_RACE_SMOKE=1 \ + WOW_BOT_ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=0 \ + WOW_BOT_ENV_FILE=/dev/null \ + "$BASH" -x "$REPO_ROOT/tools/wow-test-bot/run_rustycore_login_smoke.sh" 2>&1 || true)" + [[ "$loot_race_wrapper_xtrace_output" != *"$loot_race_secret_sentinel"* ]] || die \ + "loot-race wrapper exposed a caller secret when invoked with bash -x" + + loot_race_fake_bot="$artifacts/fake-loot-race-bot" + loot_race_malicious_env="$artifacts/malicious.env.local" + loot_race_wrapper_args_file="$artifacts/loot-race-wrapper-args" + printf '#!/bin/sh\nprintf "%%s\\n" "$@" >"$WOW_BOT_SELF_TEST_ARGS"\n' >"$loot_race_fake_bot" + printf '%s\n' \ + 'set -x' \ + 'WOW_BOT_LOOT_RACE_SMOKE=0' \ + 'WOW_BOT_ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=0' \ + 'WOW_BOT_EXEC=/tmp/not-the-pinned-bot' \ + 'WOW_BOT_EXEC_SHA256=0000000000000000000000000000000000000000000000000000000000000000' \ + >"$loot_race_malicious_env" + printf 'WOW_BOT_SELF_TEST_ARGS=%q\nWOW_BOT_REPORT=%q\nWOW_BOT_LOG=%q\n' \ + "$artifacts/not-the-pinned-args" \ + "$artifacts/not-the-pinned-report" \ + "$artifacts/not-the-pinned-log" >>"$loot_race_malicious_env" + chmod +x "$loot_race_fake_bot" + loot_race_fake_bot_sha="$(sha256sum "$loot_race_fake_bot" | awk '{print $1}')" + loot_race_wrapper_output="$(PATH="$artifacts/bin" \ + WOW_BOT_PASSWORD="$loot_race_secret_sentinel" WOW_BOT_GENERATE_LOCAL_PASSWORD=0 \ + WOW_BOT_ENSURE_TEST_ACCOUNTS=1 WOW_BOT_LOOT_RACE_SMOKE=1 \ + WOW_BOT_ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=1 \ + WOW_BOT_LOOT_RACE_ACCOUNT_A=WRONG1@bot.local \ + WOW_BOT_LOOT_RACE_ACCOUNT_B=WRONG2@bot.local \ + WOW_BOT_LOOT_RACE_GAMEOBJECT_ENTRY=9999 \ + WOW_BOT_LOOT_RACE_GAMEOBJECT_SPAWN_GUID=9998 \ + WOW_BOT_LOOT_RACE_RUNTIME_COUNTER=77 \ + WOW_BOT_LOOT_RACE_ITEM_ENTRY=9997 \ + WOW_BOT_FIXTURE_JOURNAL="$artifacts/standalone-loot-race.journal" \ + WOW_BOT_ENV_FILE="$loot_race_malicious_env" \ + WOW_BOT_EXEC="$loot_race_fake_bot" WOW_BOT_EXEC_SHA256="$loot_race_fake_bot_sha" \ + WOW_BOT_REPORT="$artifacts/loot-race-report.json" \ + WOW_BOT_LOG="$artifacts/loot-race.log" \ + WOW_BOT_SELF_TEST_ARGS="$loot_race_wrapper_args_file" \ + "$BASH" "$REPO_ROOT/tools/wow-test-bot/run_rustycore_login_smoke.sh" 2>&1)" || die \ + "acknowledged loot-race wrapper self-test failed" + [[ "$loot_race_wrapper_output" != *"$loot_race_secret_sentinel"* ]] || die \ + "loot-race wrapper exposed a caller secret while execing the bot" + loot_race_wrapper_args="$(<"$loot_race_wrapper_args_file")" + [[ "$loot_race_wrapper_args" == *"--loot-race-smoke"* ]] || die \ + "loot-race wrapper did not pass its bot mode" + [[ "$loot_race_wrapper_args" == *"--ack-disposable-overworld-loot-race"* ]] || die \ + "loot-race wrapper did not translate its acknowledgement to the CLI guard" + [[ "$loot_race_wrapper_args" == *$'--loot-race-gameobject-entry\n2846\n'* \ + && "$loot_race_wrapper_args" == *$'--loot-race-gameobject-spawn-guid\n9106001\n'* \ + && "$loot_race_wrapper_args" == *$'--loot-race-item-entry\n38\n'* ]] || die \ + "loot-race wrapper did not pin the Tattered Chest 2846/9106001/item-38 defaults" + [[ "$loot_race_wrapper_args" == *$'--loot-race-account-a\nTESTBOT2@bot.local\n'* \ + && "$loot_race_wrapper_args" == *$'--loot-race-account-b\nTESTBOT3@bot.local\n'* \ + && "$loot_race_wrapper_args" == *$'--loot-race-runtime-counter\n0\n'* ]] || die \ + "loot-race wrapper allowed hostile environment overrides of its disposable identities/runtime counter" + [[ "$loot_race_wrapper_args" != *"--single"* ]] || die \ + "loot-race wrapper incorrectly reduced the two-session flow to one account" + [[ "$loot_race_wrapper_args" != *"--ensure-test-accounts"* ]] || die \ + "loot-race wrapper did not force the destructive identity bootstrap off" + [[ -f "$artifacts/loot-race.log" && ! -e "$artifacts/not-the-pinned-args" ]] || die \ + "loot-race wrapper allowed .env.local to replace caller-pinned QA inputs" + [[ "$loot_race_wrapper_output" != *"$loot_race_secret_sentinel"* ]] || die \ + "loot-race wrapper exposed a caller secret through env-file xtrace" + + require_command cp + require_command jq + require_command rg + require_command sleep + ( + # Exercise the SQL/journal guard shared by capture-cpp.sh and + # capture-rust.sh without touching a real database or service. + # shellcheck source=crates/capture-diff/scripts/loot-fixture-common.sh + source "$REPO_ROOT/crates/capture-diff/scripts/loot-fixture-common.sh" + fixture_guard_dir="$artifacts/common-fixture-guard" + fixture_health_state="$fixture_guard_dir/health" + mkdir -m 700 "$fixture_guard_dir" + WOW_BOT_FIXTURE_JOURNAL="$fixture_guard_dir/fixture.journal" + LOOT_FIXTURE_CLEANUP_MARKER="" + LOOT_FIXTURE_GUARD_ENABLED=1 + LOOT_FIXTURE_ENTRY=21779 + LOOT_FIXTURE_EXPECTED_HEALTH_MODIFIER=1 + LOOT_FIXTURE_TEMP_HEALTH_MODIFIER=0.0001 + LOOT_FIXTURE_SNAPSHOT_READY=0 + + validate_fresh_loot_fixture_journal + : >"$WOW_BOT_FIXTURE_JOURNAL" + if validate_fresh_loot_fixture_journal >/dev/null 2>&1; then + exit 80 + fi + rm -f "$WOW_BOT_FIXTURE_JOURNAL" + chmod 755 "$fixture_guard_dir" + if validate_fresh_loot_fixture_journal >/dev/null 2>&1; then + exit 146 + fi + chmod 700 "$fixture_guard_dir" + validate_fresh_loot_fixture_journal + + printf '%s\n' \ + '{"version":1,"journal_sha256":"0000000000000000000000000000000000000000000000000000000000000000","cleanup_pid":123}' \ + >"$LOOT_FIXTURE_CLEANUP_MARKER" + chmod 600 "$LOOT_FIXTURE_CLEANUP_MARKER" + loot_fixture_bot_cleanup_complete + chmod 644 "$LOOT_FIXTURE_CLEANUP_MARKER" + if loot_fixture_bot_cleanup_complete >/dev/null 2>&1; then + exit 81 + fi + chmod 600 "$LOOT_FIXTURE_CLEANUP_MARKER" + : >"$WOW_BOT_FIXTURE_JOURNAL" + if loot_fixture_bot_cleanup_complete >/dev/null 2>&1; then + exit 82 + fi + rm -f "$WOW_BOT_FIXTURE_JOURNAL" "$LOOT_FIXTURE_CLEANUP_MARKER" + + printf '%s\n' 1 >"$fixture_health_state" + loot_fixture_character_mysql() { + printf '%s\n' 0 + } + loot_fixture_world_mysql() { + local query="${2:-}" + local state + state="$(<"$fixture_health_state")" + if [[ "$query" == *"UPDATE creature_template_difficulty"* \ + && "$query" == *"SET HealthModifier = 0.0001"* ]]; then + if [[ "$state" == 1 ]]; then + printf '%s\n' 0.0001 >"$fixture_health_state" + printf '%s\n' 1 + else + printf '%s\n' 0 + fi + elif [[ "$query" == *"UPDATE creature_template_difficulty"* \ + && "$query" == *"SET HealthModifier = 1"* ]]; then + if [[ "$state" == 0.0001 ]]; then + printf '%s\n' 1 >"$fixture_health_state" + printf '%s\n' 1 + else + printf '%s\n' 0 + fi + elif [[ "$query" == *"SELECT COUNT(*)"* \ + && "$query" == *"HealthModifier - 0.0001"* ]]; then + [[ "$state" == 0.0001 ]] && printf '%s\n' 1 || printf '%s\n' 0 + elif [[ "$query" == *"SELECT COUNT(*)"* \ + && "$query" == *"HealthModifier - 1"* ]]; then + [[ "$state" == 1 ]] && printf '%s\n' 1 || printf '%s\n' 0 + else + return 83 + fi + } + loot_fixture_wait_until_all_characters_offline + apply_creature_health_fixture_guard >/dev/null + [[ "$(<"$fixture_health_state")" == 0.0001 \ + && "$LOOT_FIXTURE_SNAPSHOT_READY" == 1 ]] || exit 84 + restore_creature_health_fixture_guard >/dev/null + [[ "$(<"$fixture_health_state")" == 1 \ + && "$LOOT_FIXTURE_SNAPSHOT_READY" == 0 ]] || exit 85 + + printf '%s\n' 2 >"$fixture_health_state" + LOOT_FIXTURE_SNAPSHOT_READY=1 + if restore_creature_health_fixture_guard >/dev/null 2>&1; then + exit 86 + fi + [[ "$(<"$fixture_health_state")" == 2 ]] || exit 87 + ) || die "shared C++/Rust loot-fixture guard self-test failed" + + ( + # Prove both supported topologies: a direct Rust PM2 entry/listener and a + # non-exec C++ shell wrapper with one descendant owning both listeners. + # Reject changed ancestry, changed/foreign listeners, duplicate PM2 + # identity, and incomplete stopped state before DB restoration. + # shellcheck source=crates/capture-diff/scripts/capture-service-common.sh + source "$REPO_ROOT/crates/capture-diff/scripts/capture-service-common.sh" + CAPTURE_WORLD_PORT=45123 + CAPTURE_INSTANCE_PORT=45124 + CAPTURE_PROC_ROOT="$artifacts/fake-capture-proc" + mkdir -p "$CAPTURE_PROC_ROOT/42" "$CAPTURE_PROC_ROOT/43" "$CAPTURE_PROC_ROOT/44" + printf 'PPid:\t1\n' >"$CAPTURE_PROC_ROOT/42/status" + printf 'PPid:\t42\n' >"$CAPTURE_PROC_ROOT/43/status" + printf 'PPid:\t43\n' >"$CAPTURE_PROC_ROOT/44/status" + for pid_and_start in 42:42000 43:43000 44:44000; do + fake_pid="${pid_and_start%%:*}" + fake_start="${pid_and_start#*:}" + { + printf '%s (fake world process) S' "$fake_pid" + for _ in $(seq 4 21); do printf ' 0'; done + printf ' %s 0\n' "$fake_start" + } >"$CAPTURE_PROC_ROOT/$fake_pid/stat" + done + [ "$(capture_pid_starttime 42)" = 42000 ] \ + && [ "$(capture_process_tree_identity 42)" \ + = $'42:42000\n43:43000\n44:44000' ] || exit 143 + service_state="$artifacts/cpp-service-state.json" + service_world_listener=1 + service_instance_listener=1 + service_foreign_listener=0 + service_foreign_same_row=0 + service_listener_pid=44 + service_parent_alive=1 + service_listener_alive=1 + pm2() { + [ "${1:-}" = jlist ] || return 90 + cat "$service_state" + } + ss() { + case "$*" in + *"sport = :$CAPTURE_WORLD_PORT"*) + if ((service_world_listener && service_foreign_same_row)); then + printf 'LISTEN 0 128 127.0.0.1:%s 0.0.0.0:* users:((world,pid=%s,fd=3),(other,pid=99,fd=5))\n' "$CAPTURE_WORLD_PORT" "$service_listener_pid" + elif ((service_world_listener)); then + printf 'LISTEN 0 128 127.0.0.1:%s 0.0.0.0:* users:((world,pid=%s,fd=3))\n' "$CAPTURE_WORLD_PORT" "$service_listener_pid" + fi + ((service_foreign_listener)) \ + && printf 'LISTEN 0 128 127.0.0.1:%s 0.0.0.0:* users:((other,pid=99,fd=5))\n' "$CAPTURE_WORLD_PORT" + ;; + *"sport = :$CAPTURE_INSTANCE_PORT"*) + ((service_instance_listener)) \ + && printf 'LISTEN 0 128 127.0.0.1:%s 0.0.0.0:* users:((world,pid=%s,fd=4))\n' "$CAPTURE_INSTANCE_PORT" "$service_listener_pid" + ;; + *) + ((service_world_listener)) \ + && printf 'LISTEN 0 128 127.0.0.1:%s 0.0.0.0:*\n' "$CAPTURE_WORLD_PORT" + ((service_instance_listener)) \ + && printf 'LISTEN 0 128 127.0.0.1:%s 0.0.0.0:*\n' "$CAPTURE_INSTANCE_PORT" + ;; + esac + return 0 + } + kill() { + [ "${1:-}" = -0 ] || return 1 + case "${3:-${2:-}}" in + 42) ((service_parent_alive)) ;; + 44) ((service_listener_alive)) ;; + *) return 1 ;; + esac + } + + printf '%s\n' \ + '[{"name":"cpp-world","pid":42,"pm2_env":{"status":"online"}}]' \ + >"$service_state" + [[ "$(capture_world_ready_once cpp-world)" == $'42\t44' ]] || exit 91 + # A direct binary is also valid: PM2 entry PID and listener PID coincide. + service_listener_pid=42 + [[ "$(capture_world_ready_once cpp-world)" == $'42\t42' ]] || exit 110 + service_listener_pid=44 + # Breaking the wrapper -> listener ancestry must invalidate the identity. + printf 'PPid:\t99\n' >"$CAPTURE_PROC_ROOT/43/status" + if capture_world_ready_once cpp-world >/dev/null 2>&1; then + exit 111 + fi + printf 'PPid:\t42\n' >"$CAPTURE_PROC_ROOT/43/status" + printf '%s\n' \ + '[{"name":"cpp-world","pid":45,"pm2_env":{"status":"online"}}]' \ + >"$service_state" + if capture_world_ready_once cpp-world >/dev/null 2>&1; then + exit 112 + fi + printf '%s\n' \ + '[{"name":"cpp-world","pid":42,"pm2_env":{"status":"online"}}]' \ + >"$service_state" + service_foreign_listener=1 + if capture_world_ready_once cpp-world >/dev/null 2>&1; then + exit 97 + fi + service_foreign_listener=0 + service_foreign_same_row=1 + if capture_world_ready_once cpp-world >/dev/null 2>&1; then + exit 98 + fi + service_foreign_same_row=0 + service_instance_listener=0 + if capture_world_ready_once cpp-world >/dev/null 2>&1; then + exit 92 + fi + service_instance_listener=1 + printf '%s\n' \ + '[{"name":"cpp-world","pid":42,"pm2_env":{"status":"online"}},{"name":"cpp-world","pid":43,"pm2_env":{"status":"online"}}]' \ + >"$service_state" + if capture_world_ready_once cpp-world >/dev/null 2>&1; then + exit 93 + fi + + printf '%s\n' \ + '[{"name":"cpp-world","pid":0,"pm2_env":{"status":"launching"}}]' \ + >"$service_state" + service_world_listener=0 + service_instance_listener=0 + service_parent_alive=0 + service_listener_alive=0 + if capture_world_stopped_once cpp-world $'42\t44'; then + exit 99 + fi + + printf '%s\n' \ + '[{"name":"cpp-world","pid":0,"pm2_env":{"status":"stopped"}}]' \ + >"$service_state" + service_world_listener=0 + service_instance_listener=0 + service_parent_alive=0 + service_listener_alive=0 + capture_world_stopped_once cpp-world $'42\t44' || exit 94 + service_parent_alive=1 + if capture_world_stopped_once cpp-world $'42\t44'; then + exit 95 + fi + service_parent_alive=0 + service_listener_alive=1 + if capture_world_stopped_once cpp-world $'42\t44'; then + exit 113 + fi + service_listener_alive=0 + service_world_listener=1 + if capture_world_stopped_once cpp-world $'42\t44'; then + exit 96 + fi + ) || die "C++ PM2/PID/listener fail-closed self-test failed" + + ( + # PM2 entrypoint bytes and Git worktree state are provenance, not comments. + # A changed wrapper, dirty harness, or newly dirty source must change/fail + # the recorded identity before any service mutation. + # shellcheck source=crates/capture-diff/scripts/capture-service-common.sh + source "$REPO_ROOT/crates/capture-diff/scripts/capture-service-common.sh" + entrypoint="$artifacts/non-exec-world-wrapper.sh" + entry_state="$artifacts/entrypoint-pm2.json" + printf '#!/bin/sh\n./world-server\n' >"$entrypoint" + chmod 700 "$entrypoint" + pm2() { + [ "${1:-}" = jlist ] || return 114 + cat "$entry_state" + } + jq -n --arg path "$entrypoint" \ + '[{name:"world",pid:42,pm2_env:{status:"online",restart_time:7,pm_exec_path:$path}}]' \ + >"$entry_state" + entry_before="$(capture_pm2_entrypoint_identity world 42)" || exit 115 + printf '#!/bin/sh\n./different-world-server\n' >"$entrypoint" + entry_after="$(capture_pm2_entrypoint_identity world 42)" || exit 116 + [ "$entry_before" != "$entry_after" ] || exit 117 + + harness_repo="$artifacts/clean-harness-repo" + source_repo="$artifacts/dirty-source-repo" + for repository in "$harness_repo" "$source_repo"; do + git init -q "$repository" + git -C "$repository" config user.name preflight + git -C "$repository" config user.email preflight@example.invalid + printf 'committed\n' >"$repository/tracked.txt" + git -C "$repository" add tracked.txt + git -C "$repository" commit -qm initial + done + harness_head="$(git -C "$harness_repo" rev-parse HEAD)" + source_head="$(git -C "$source_repo" rev-parse HEAD)" + capture_git_repo_clean_at_head "$harness_repo" "$harness_head" || exit 118 + capture_git_repo_clean_at_head "$source_repo" "$source_head" || exit 124 + harness_clean_digest="$(capture_git_worktree_state_sha256 "$harness_repo")" \ + || exit 119 + source_clean_digest="$(capture_git_worktree_state_sha256 "$source_repo")" \ + || exit 125 + printf 'dirty\n' >>"$harness_repo/tracked.txt" + if capture_git_repo_clean_at_head "$harness_repo" "$harness_head"; then + exit 120 + fi + [ "$(capture_git_worktree_state_sha256 "$harness_repo")" \ + != "$harness_clean_digest" ] || exit 121 + printf 'untracked source state\n' >"$source_repo/local.patch" + capture_git_repo_is_dirty "$source_repo" || exit 122 + [ "$(capture_git_worktree_state_sha256 "$source_repo")" \ + != "$source_clean_digest" ] || exit 123 + ) || die "capture entrypoint/worktree provenance self-test failed" + + ( + # Credential changes must not become dictionary-testable config hashes, + # while a capture-relevant non-secret change must still alter the digest. + # shellcheck source=crates/capture-diff/scripts/capture-service-common.sh + source "$REPO_ROOT/crates/capture-diff/scripts/capture-service-common.sh" + conf_a="$artifacts/redacted-config-a.conf" + conf_b="$artifacts/redacted-config-b.conf" + conf_c="$artifacts/redacted-config-c.conf" + printf '%s\n' \ + 'WorldServerPort = 8085' \ + 'WorldDatabaseInfo = "mysql://user:first-secret@localhost/world"' >"$conf_a" + printf '%s\n' \ + 'WorldServerPort = 8085' \ + 'WorldDatabaseInfo = "mysql://user:second-secret@localhost/world"' >"$conf_b" + printf '%s\n' \ + 'WorldServerPort = 9085' \ + 'WorldDatabaseInfo = "mysql://user:first-secret@localhost/world"' >"$conf_c" + hash_a="$(capture_effective_config_redacted_sha256 \ + "$conf_a" 'capture.packet_dump=enabled' WorldServerPort WorldDatabaseInfo)" || exit 105 + hash_b="$(capture_effective_config_redacted_sha256 \ + "$conf_b" 'capture.packet_dump=enabled' WorldServerPort WorldDatabaseInfo)" || exit 106 + hash_c="$(capture_effective_config_redacted_sha256 \ + "$conf_c" 'capture.packet_dump=enabled' WorldServerPort WorldDatabaseInfo)" || exit 107 + [ "$hash_a" = "$hash_b" ] || exit 108 + [ "$hash_a" != "$hash_c" ] || exit 109 + ) || die "redacted effective-config hash self-test failed" + + ( + # C++ provenance must be derived from the PM2 profile/entrypoint, and a + # profile selecting config B must never accredit caller-declared config A. + # Environment values are secret and intentionally do not affect the + # stable profile hash; argv/config selection does. + # shellcheck source=crates/capture-diff/scripts/capture-service-common.sh + source "$REPO_ROOT/crates/capture-diff/scripts/capture-service-common.sh" + pm2_state="$artifacts/effective-config-pm2.json" + pm2_wrapper="$artifacts/effective-config-wrapper.sh" + config_a="$artifacts/effective-config-a.conf" + config_b="$artifacts/effective-config-b.conf" + printf 'WorldServerPort = 45123\n' >"$config_a" + printf 'WorldServerPort = 45125\n' >"$config_b" + printf '#!/bin/sh\nexec /opt/fake/worldserver -c %q\n' "$config_a" \ + >"$pm2_wrapper" + chmod 700 "$pm2_wrapper" + pm2() { + [ "${1:-}" = jlist ] || return 126 + cat "$pm2_state" + } + jq -n --arg wrapper "$pm2_wrapper" --arg cwd "$artifacts" \ + '[{name:"cpp-world",pid:0,pm2_env:{status:"stopped",pm_exec_path:$wrapper,pm_cwd:$cwd,args:[],env:{DB_PASSWORD:"first-secret",VISIBLE:"one"}}}]' \ + >"$pm2_state" + [ "$(capture_pm2_effective_config_path cpp-world)" = "$config_a" ] \ + || exit 127 + profile_a="$(capture_pm2_profile_redacted_sha256 cpp-world)" || exit 128 + jq '.[0].pm2_env.env.DB_PASSWORD = "second-secret"' \ + "$pm2_state" >"$pm2_state.next" + mv -- "$pm2_state.next" "$pm2_state" + profile_secret_changed="$(capture_pm2_profile_redacted_sha256 cpp-world)" \ + || exit 129 + [ "$profile_a" = "$profile_secret_changed" ] || exit 130 + jq --arg config "$config_b" \ + '.[0].pm2_env.args = ["-c", $config]' \ + "$pm2_state" >"$pm2_state.next" + mv -- "$pm2_state.next" "$pm2_state" + [ "$(capture_pm2_effective_config_path cpp-world)" = "$config_b" ] \ + || exit 131 + [ "$(capture_pm2_effective_config_path cpp-world)" != "$config_a" ] \ + || exit 132 + profile_b="$(capture_pm2_profile_redacted_sha256 cpp-world)" || exit 133 + [ "$profile_a" != "$profile_b" ] || exit 134 + jq '.[0].pm2_env.args = ["-c"]' \ + "$pm2_state" >"$pm2_state.next" + mv -- "$pm2_state.next" "$pm2_state" + if capture_pm2_effective_config_path cpp-world >/dev/null 2>&1; then + exit 144 + fi + jq --arg a "$config_a" --arg b "$config_b" \ + '.[0].pm2_env.args = ["-c", $a, "--config", $b]' \ + "$pm2_state" >"$pm2_state.next" + mv -- "$pm2_state.next" "$pm2_state" + if capture_pm2_effective_config_path cpp-world >/dev/null 2>&1; then + exit 145 + fi + ) || die "PM2 effective-config/profile A/B self-test failed" + + ( + # `bash -x` must never reveal DatabaseInfo credentials or MYSQL_PWD. + # Use a shell-local fake mysql command, so this test cannot reach a DB. + # shellcheck source=crates/capture-diff/scripts/loot-fixture-common.sh + source "$REPO_ROOT/crates/capture-diff/scripts/loot-fixture-common.sh" + xtrace_conf="$artifacts/xtrace-worldserver.conf" + xtrace_log="$artifacts/xtrace-credentials.log" + world_secret="rc106-world-secret-must-not-appear" + character_secret="rc106-character-secret-must-not-appear" + printf '%s\n' \ + "WorldDatabaseInfo = \"127.0.0.1;3306;world-user;${world_secret};world\"" \ + "CharacterDatabaseInfo = \"127.0.0.1;3306;character-user;${character_secret};characters\"" \ + >"$xtrace_conf" + LOOT_FIXTURE_DB_CONF="$xtrace_conf" + mysql() { + case " $* " in + *" world "*) [ "${MYSQL_PWD:-}" = "$world_secret" ] || return 135 ;; + *" characters "*) [ "${MYSQL_PWD:-}" = "$character_secret" ] || return 136 ;; + *) return 137 ;; + esac + printf '0\n' + } + { + set -x + load_loot_fixture_database_credentials + loot_fixture_world_mysql -e 'SELECT 0' >/dev/null + loot_fixture_character_mysql -e 'SELECT 0' >/dev/null + set +x + } 2>"$xtrace_log" || exit 138 + if rg -q "${world_secret}|${character_secret}|MYSQL_PWD=.*secret" \ + "$xtrace_log"; then + exit 139 + fi + ) || die "credential/MYSQL_PWD xtrace redaction self-test failed" + + ( + # Two wrappers that target the same host ports must never pass their + # service/SQL preconditions concurrently. + # shellcheck source=crates/capture-diff/scripts/capture-service-common.sh + source "$REPO_ROOT/crates/capture-diff/scripts/capture-service-common.sh" + lock_file="$artifacts/capture-orchestration.lock" + ready_file="$artifacts/capture-orchestration.ready" + ( + capture_acquire_orchestration_lock "$lock_file" || exit 100 + : >"$ready_file" + sleep 1 + capture_release_orchestration_lock + ) & + holder_pid=$! + for _ in $(seq 1 40); do + [ -e "$ready_file" ] && break + sleep 0.025 + done + [ -e "$ready_file" ] || exit 101 + if capture_acquire_orchestration_lock "$lock_file"; then + capture_release_orchestration_lock + exit 102 + fi + wait "$holder_pid" || exit 103 + capture_acquire_orchestration_lock "$lock_file" || exit 104 + capture_release_orchestration_lock + [ -d "$lock_file" ] && [ ! -L "$lock_file" ] \ + && [ "$(stat -c '%a' -- "$lock_file")" = 700 ] \ + && [ "$(stat -c '%u' -- "$lock_file")" = "$(id -u)" ] || exit 140 + chmod 755 "$lock_file" + if capture_acquire_orchestration_lock "$lock_file"; then + capture_release_orchestration_lock + exit 141 + fi + chmod 700 "$lock_file" + lock_symlink="$artifacts/capture-orchestration-symlink" + ln -s "$lock_file" "$lock_symlink" + if capture_acquire_orchestration_lock "$lock_symlink"; then + capture_release_orchestration_lock + exit 142 + fi + ) || die "capture orchestration lock self-test failed" + + cpp_capture_text="$(<"$REPO_ROOT/crates/capture-diff/scripts/capture-cpp.sh")" + [[ "$cpp_capture_text" == *"loot-fixture-common.sh"* \ + && "$cpp_capture_text" == *"capture-service-common.sh"* \ + && "$cpp_capture_text" == *"capture_wait_for_world_stopped"* \ + && "$cpp_capture_text" == *"apply_creature_health_fixture_guard"* \ + && "$cpp_capture_text" == *"loot_fixture_bot_cleanup_complete"* ]] || die \ + "C++ capture wrapper is not wired to the shared fail-closed loot guard" + [[ "$cpp_capture_text" == *"CPP_CAPTURE_EXEC_SHA256"* \ + && "$cpp_capture_text" == *"cpp.capture-manifest.json"* \ + && "$cpp_capture_text" == *"OUT_PKT_STAGE"* \ + && "$cpp_capture_text" == *"capture_acquire_orchestration_lock"* \ + && "$cpp_capture_text" == *"cpp_capture_executable_unchanged"* \ + && "$cpp_capture_text" == *"source_repo_head"* \ + && "$cpp_capture_text" == *"effective_config_redacted_sha256"* \ + && "$cpp_capture_text" == *"pm2_entry_pid"* \ + && "$cpp_capture_text" == *"pm2_exec_sha256"* \ + && "$cpp_capture_text" == *"source_worktree_state_sha256"* ]] || die \ + "C++ capture wrapper lacks executable provenance, lock, or atomic manifest publication" + if cpp_missing_pin_output="$( + CPP_CAPTURE_LOOT_FIXTURE_GUARD=1 \ + CPP_CAPTURE_ACK_LOOT_FIXTURE_MUTATION=1 \ + "$BASH" "$REPO_ROOT/crates/capture-diff/scripts/capture-cpp.sh" \ + loot-single-item-claim --yes 2>&1 + )"; then + die "required C++ loot capture accepted missing executable path/SHA pin" + fi + [[ "$cpp_missing_pin_output" == *"requires CPP_CAPTURE_EXEC and CPP_CAPTURE_EXEC_SHA256"* ]] || die \ + "required C++ loot capture missing-pin error was not explicit" + + rust_capture_text="$(<"$REPO_ROOT/crates/capture-diff/scripts/capture-rust.sh")" + [[ "$rust_capture_text" == *"guarded Rust evidence requires RUST_CAPTURE_EXEC"* \ + && "$rust_capture_text" == *"rust.capture-manifest.json"* \ + && "$rust_capture_text" == *"DUMP_STAGE_DIR"* \ + && "$rust_capture_text" == *"capture_process_tree_identity"* \ + && "$rust_capture_text" == *"capture_terminate_process_tree"* \ + && "$rust_capture_text" == *"capture_process_tree_absent"* \ + && "$rust_capture_text" == *"capture_publish_noreplace"* \ + && "$rust_capture_text" == *"pm2_entry_starttime"* \ + && "$rust_capture_text" == *"pm2_profile_redacted_sha256"* \ + && "$rust_capture_text" == *"capture_pm2_process_stopped"* \ + && "$rust_capture_text" == *"source_repo_head"* \ + && "$rust_capture_text" == *"effective_config_redacted_sha256"* \ + && "$rust_capture_text" == *"pm2_entry_pid"* \ + && "$rust_capture_text" == *"pm2_exec_sha256"* \ + && "$rust_capture_text" == *"harness_worktree_state_sha256"* \ + && "$rust_capture_text" != *'rm -rf -- "$DUMP_DIR"'* ]] || die \ + "Rust capture wrapper lacks guarded provenance, PID death, C++ stop, or atomic manifest checks" + if rust_missing_pin_output="$( + RUST_CAPTURE_LOOT_FIXTURE_GUARD=1 \ + RUST_CAPTURE_ACK_LOOT_FIXTURE_MUTATION=1 \ + "$BASH" "$REPO_ROOT/crates/capture-diff/scripts/capture-rust.sh" \ + loot-single-item-claim --yes 2>&1 + )"; then + die "required Rust loot capture accepted missing executable path/SHA pin" + fi + [[ "$rust_missing_pin_output" == *"requires RUST_CAPTURE_EXEC and RUST_CAPTURE_EXEC_SHA256"* ]] || die \ + "required Rust loot capture missing-pin error was not explicit" + qa_fake_bin="$artifacts/qa-bin" + qa_fake_world="$artifacts/fake-world-server" + qa_fake_world_other="$artifacts/not-the-live-world-server" + qa_fake_bot="$artifacts/fake-loot-race-bot-only" + qa_fake_capture="$artifacts/fake-capture-rust.sh" + qa_fake_bot_marker="$artifacts/fake-loot-race-bot-ran" + qa_pm2_json_file="$artifacts/fake-pm2.json" + qa_pm2_before_file="$artifacts/fake-pm2-before.json" + qa_pm2_after_file="$artifacts/fake-pm2-after.json" + qa_pm2_state_file="$artifacts/fake-pm2-state" + mkdir -p "$qa_fake_bin" + for dependency in awk bash chmod dirname env git jq mkfifo mktemp mv realpath rg rm rmdir sed seq sha256sum sleep stat; do + ln -s "$(command -v "$dependency")" "$qa_fake_bin/$dependency" + done + printf '#!/bin/sh\nexit 0\n' >"$qa_fake_bin/mysql" + printf '%s\n' \ + '#!/bin/sh' \ + 'set -eu' \ + '[ "${1:-}" = jlist ] || exit 64' \ + 'json_file="${QA_FAKE_PM2_JSON_FILE:?}"' \ + 'if [ -n "${QA_FAKE_PM2_STATE_FILE:-}" ]; then' \ + ' if [ -e "$QA_FAKE_PM2_STATE_FILE" ]; then' \ + ' json_file="${QA_FAKE_PM2_AFTER_FILE:?}"' \ + ' else' \ + ' : >"$QA_FAKE_PM2_STATE_FILE"' \ + ' json_file="${QA_FAKE_PM2_BEFORE_FILE:?}"' \ + ' fi' \ + 'fi' \ + 'IFS= read -r payload <"$json_file"' \ + 'printf "%s\n" "$payload"' \ + >"$qa_fake_bin/pm2" + printf '%s\n' \ + '#!/bin/sh' \ + 'set -eu' \ + 'listener_pid=""' \ + 'if [ "${QA_FAKE_SS_DYNAMIC:-0}" = 1 ]; then' \ + ' listener_pid="$(jq -r ".[0].pid // empty" "${QA_FAKE_PM2_JSON_FILE:?}")"' \ + 'else' \ + ' case "$*" in' \ + ' *":${QA_FAKE_WORLD_PORT:?}"*) listener_pid="${QA_FAKE_SS_WORLD_PID:-}" ;;' \ + ' *":${QA_FAKE_INSTANCE_PORT:?}"*) listener_pid="${QA_FAKE_SS_INSTANCE_PID:-}" ;;' \ + ' esac' \ + 'fi' \ + 'if [ -n "$listener_pid" ] && [ -n "${QA_FAKE_SS_EXTRA_PID:-}" ]; then' \ + ' printf "LISTEN 0 128 127.0.0.1:* 0.0.0.0:* users:((fake-world,pid=%s,fd=3),(foreign,pid=%s,fd=4))\n" "$listener_pid" "$QA_FAKE_SS_EXTRA_PID"' \ + 'elif [ -n "$listener_pid" ]; then' \ + ' printf "LISTEN 0 128 127.0.0.1:* 0.0.0.0:* users:((fake-world,pid=%s,fd=3))\n" "$listener_pid"' \ + 'fi' \ + >"$qa_fake_bin/ss" + printf '%s\n' \ + '#!/bin/sh' \ + 'set -eu' \ + '[ "${WOW_BOT_ENSURE_TEST_ACCOUNTS:-}" = 0 ]' \ + 'journal="${WOW_BOT_FIXTURE_JOURNAL:?}"' \ + 'umask 077' \ + 'write_marker() {' \ + ' printf "%s\n" "{\"version\":1,\"journal_sha256\":\"0000000000000000000000000000000000000000000000000000000000000000\",\"cleanup_pid\":123}" >"${journal}.cleanup-complete"' \ + ' chmod 600 "${journal}.cleanup-complete"' \ + '}' \ + ': >"$journal"' \ + 'report=""' \ + 'while [ "$#" -gt 0 ]; do' \ + ' if [ "$1" = --report ]; then' \ + ' shift' \ + ' report="${1:?missing report path}"' \ + ' fi' \ + ' shift' \ + 'done' \ + '[ -n "$report" ] || exit 65' \ + 'case "${QA_FAKE_BOT_MUTATION:-}" in' \ + ' restart) jq -c ".[0].pm2_env.restart_time += 1" "${QA_FAKE_PM2_JSON_FILE:?}" >"${QA_FAKE_PM2_JSON_FILE}.tmp" ;;' \ + ' pid) jq -c --argjson pid "${QA_FAKE_ORIGINAL_PID:?}" ".[0].pid = \$pid" "${QA_FAKE_PM2_JSON_FILE:?}" >"${QA_FAKE_PM2_JSON_FILE}.tmp" ;;' \ + ' term) kill -TERM "$$" ;;' \ + ' kill) kill -KILL "$$" ;;' \ + ' pending) exit 72 ;;' \ + ' drift) write_marker ;;' \ + 'esac' \ + 'if [ -e "${QA_FAKE_PM2_JSON_FILE:-}.tmp" ]; then mv "${QA_FAKE_PM2_JSON_FILE}.tmp" "$QA_FAKE_PM2_JSON_FILE"; fi' \ + 'if [ "${QA_FAKE_BOT_REPORT_MODE:-full}" = summary ]; then' \ + ' printf "%s\n" '\''{"loot_race_smoke":true,"loot_item_capture":false,"results":[{"account":"TESTBOT2@bot.local","world_auth":true,"enum_characters":true,"player_login_verified":true,"loot_race_smoke":true,"loot_race_smoke_passed":true,"loot_race_failure":null,"loot_race_party_confirmed":true,"loot_race_target_discovered":true,"loot_race_loot_opened":true,"loot_race_relog_verified":true},{"account":"TESTBOT3@bot.local","world_auth":true,"enum_characters":true,"player_login_verified":true,"loot_race_smoke":true,"loot_race_smoke_passed":true,"loot_race_failure":null,"loot_race_party_confirmed":true,"loot_race_target_discovered":true,"loot_race_loot_opened":true,"loot_race_relog_verified":true}]}'\'' >"$report"' \ + 'else' \ + ' printf "%s\n" '\''{"loot_race_smoke":true,"loot_item_capture":false,"results":[{"account":"TESTBOT2@bot.local","world_auth":true,"enum_characters":true,"player_login_verified":true,"loot_race_smoke":true,"loot_race_smoke_passed":true,"loot_race_failure":null,"loot_race_target_entry":2846,"loot_race_target_spawn_guid":9106001,"loot_race_target_runtime_counter":12345,"loot_race_party_confirmed":true,"loot_race_target_discovered":true,"loot_race_loot_opened":true,"loot_race_loot_list_id":0,"loot_race_loot_coins":10,"loot_race_item_push_seen":true,"loot_race_loot_removed_seen":true,"loot_race_money_notify_amount":10,"loot_race_coin_removed_seen":true,"loot_race_db_item_total":1,"loot_race_db_money_delta":10,"loot_race_relog_verified":true},{"account":"TESTBOT3@bot.local","world_auth":true,"enum_characters":true,"player_login_verified":true,"loot_race_smoke":true,"loot_race_smoke_passed":true,"loot_race_failure":null,"loot_race_target_entry":2846,"loot_race_target_spawn_guid":9106001,"loot_race_target_runtime_counter":12345,"loot_race_party_confirmed":true,"loot_race_target_discovered":true,"loot_race_loot_opened":true,"loot_race_loot_list_id":0,"loot_race_loot_coins":10,"loot_race_item_push_seen":false,"loot_race_loot_removed_seen":true,"loot_race_money_notify_amount":0,"loot_race_coin_removed_seen":true,"loot_race_db_item_total":1,"loot_race_db_money_delta":10,"loot_race_relog_verified":true}]}'\'' >"$report"' \ + 'fi' \ + 'printf "%s\n" fake-bot-only >"${QA_FAKE_BOT_MARKER:?}"' \ + 'if [ "${QA_FAKE_BOT_MUTATION:-}" != drift ]; then' \ + ' rm -f -- "$journal"' \ + ' write_marker' \ + 'fi' \ + >"$qa_fake_bot" + printf '%s\n' \ + '#!/bin/sh' \ + 'set -eu' \ + '[ "${1:-}" = loot-two-session-atomic-race ] && [ "${2:-}" = --yes ]' \ + '[ "${RUST_CAPTURE_LOOT_FIXTURE_GUARD:-}" = 1 ]' \ + '[ "${RUST_CAPTURE_ACK_LOOT_FIXTURE_MUTATION:-}" = 1 ]' \ + '[ "${RUST_CAPTURE_EXEC:?}" = "${QA_FAKE_TARGET_EXEC:?}" ]' \ + '[ "${RUST_CAPTURE_EXEC_SHA256:?}" = "${QA_FAKE_TARGET_SHA:?}" ]' \ + '[ -n "${RUST_CAPTURE_DB_CONF:?}" ]' \ + '[ "${PM2_RUST_WORLD:?}" = "${QA_FAKE_PROCESS_NAME:?}" ]' \ + '[ "${RUST_WORLD_PORT:?}" = "${QA_FAKE_WORLD_PORT:?}" ]' \ + '[ "${RUST_INSTANCE_PORT:?}" = "${QA_FAKE_INSTANCE_PORT:?}" ]' \ + 'journal="${WOW_BOT_FIXTURE_JOURNAL:?}"' \ + 'capture_pid=""' \ + 'restore() {' \ + ' status=$?' \ + ' trap - EXIT HUP INT TERM' \ + ' set +e' \ + ' if [ -n "$capture_pid" ]; then kill "$capture_pid" 2>/dev/null; wait "$capture_pid" 2>/dev/null; fi' \ + ' marker="${journal}.cleanup-complete"' \ + ' marker_mode="$(stat -c %a -- "$marker" 2>/dev/null || true)"' \ + ' if [ -e "$journal" ] || [ -L "$journal" ] || [ ! -f "$marker" ] || [ -L "$marker" ] || [ "$marker_mode" != 600 ] || ! jq -e ".version == 1 and (.journal_sha256 | type == \"string\" and length == 64) and (.cleanup_pid | type == \"number\" and . > 0)" "$marker" >/dev/null 2>&1; then' \ + ' printf "%s\n" "fake guarded capture refused normal PM2 restore: pending journal or missing cleanup-complete" >&2' \ + ' exit 74' \ + ' fi' \ + ' jq -cn --arg name "$QA_FAKE_PROCESS_NAME" --arg exec "$QA_FAKE_ORIGINAL_EXEC" --argjson pid "$QA_FAKE_ORIGINAL_PID" '\''[{name:$name,pid:$pid,pm2_env:{status:"online",pm_exec_path:$exec,restart_time:9,env:{}}}]'\'' >"$QA_FAKE_PM2_JSON_FILE"' \ + ' rm -f -- "${journal}.cleanup-complete"' \ + ' printf "%s\n" "fake guarded capture restored normal PM2 profile"' \ + ' exit "$status"' \ + '}' \ + 'trap restore EXIT' \ + 'trap '\''exit 129'\'' HUP' \ + 'trap '\''exit 130'\'' INT' \ + 'trap '\''exit 143'\'' TERM' \ + 'if [ -n "${QA_FAKE_CAPTURE_BEFORE_READY_STATUS:-}" ]; then exit "$QA_FAKE_CAPTURE_BEFORE_READY_STATUS"; fi' \ + '"$QA_FAKE_TARGET_EXEC" 300 &' \ + 'capture_pid=$!' \ + 'jq -cn --arg name "$QA_FAKE_PROCESS_NAME" --arg exec "$QA_FAKE_TARGET_EXEC" --argjson pid "$capture_pid" '\''[{name:$name,pid:$pid,pm2_env:{status:"online",pm_exec_path:$exec,restart_time:8,RUSTYCORE_PACKET_DUMP_DIR:"/tmp/fake-dump",env:{RUSTYCORE_PACKET_DUMP_DIR:"/tmp/fake-dump"}}}]'\'' >"$QA_FAKE_PM2_JSON_FILE"' \ + 'printf "%s\n" ">>> Perform the '\''loot-two-session-atomic-race'\'' flow with the client now."' \ + 'IFS= read -r _' \ + 'exit "${QA_FAKE_CAPTURE_EXIT_STATUS:-0}"' \ + >"$qa_fake_capture" + chmod +x "$qa_fake_bin/mysql" "$qa_fake_bin/pm2" "$qa_fake_bin/ss" \ + "$qa_fake_bot" "$qa_fake_capture" + + cp -- "$(command -v sleep)" "$qa_fake_world" + cp -- "$qa_fake_world" "$qa_fake_world_other" + chmod +x "$qa_fake_world" "$qa_fake_world_other" + "$qa_fake_world" 300 & + qa_world_pid=$! + kill -0 "$qa_world_pid" 2>/dev/null || die \ + "temporary world-process self-test fixture did not start" + qa_world_sha="$(sha256_of_file "$qa_fake_world")" || die \ + "cannot hash temporary world-process self-test fixture" + qa_target_sha="$(sha256_of_file "$qa_fake_world_other")" || die \ + "cannot hash temporary target-world self-test fixture" + qa_fake_bot_sha="$(sha256_of_file "$qa_fake_bot")" || die \ + "cannot hash fake loot-race bot" + + qa_pm2_online="$(jq -cn \ + --arg name self-test-world \ + --arg exec "$qa_fake_world" \ + --argjson pid "$qa_world_pid" \ + '[{name:$name,pid:$pid,pm2_env:{status:"online",pm_exec_path:$exec,restart_time:7}}]')" + qa_pm2_duplicate="$(jq -cn --argjson rows "$qa_pm2_online" '$rows + $rows')" + qa_pm2_offline="$(jq -cn \ + --arg name self-test-world \ + --arg exec "$qa_fake_world" \ + --argjson pid "$qa_world_pid" \ + '[{name:$name,pid:$pid,pm2_env:{status:"stopped",pm_exec_path:$exec,restart_time:7}}]')" + qa_pm2_wrong_path="$(jq -cn \ + --arg name self-test-world \ + --arg exec "$qa_fake_world_other" \ + --argjson pid "$qa_world_pid" \ + '[{name:$name,pid:$pid,pm2_env:{status:"online",pm_exec_path:$exec,restart_time:7}}]')" + qa_pm2_restart_changed="$(jq -cn \ + --arg name self-test-world \ + --arg exec "$qa_fake_world" \ + --argjson pid "$qa_world_pid" \ + '[{name:$name,pid:$pid,pm2_env:{status:"online",pm_exec_path:$exec,restart_time:8}}]')" + qa_pm2_pid_changed="$(jq -cn \ + --arg name self-test-world \ + --arg exec "$qa_fake_world" \ + --argjson pid "$((qa_world_pid + 1))" \ + '[{name:$name,pid:$pid,pm2_env:{status:"online",pm_exec_path:$exec,restart_time:7}}]')" + + printf '%s\n' "$qa_pm2_duplicate" >"$qa_pm2_json_file" + if ( + export PATH="$qa_fake_bin" QA_FAKE_PM2_JSON_FILE="$qa_pm2_json_file" + qa_world_identity self-test-world "$qa_fake_world" >/dev/null 2>&1 + ); then + die "world identity accepted duplicate PM2 entries" + fi + printf '%s\n' "$qa_pm2_offline" >"$qa_pm2_json_file" + if ( + export PATH="$qa_fake_bin" QA_FAKE_PM2_JSON_FILE="$qa_pm2_json_file" + qa_world_identity self-test-world "$qa_fake_world" >/dev/null 2>&1 + ); then + die "world identity accepted an offline PM2 entry" + fi + printf '%s\n' "$qa_pm2_wrong_path" >"$qa_pm2_json_file" + if ( + export PATH="$qa_fake_bin" QA_FAKE_PM2_JSON_FILE="$qa_pm2_json_file" + qa_world_identity self-test-world "$qa_fake_world" >/dev/null 2>&1 + ); then + die "world identity accepted the wrong PM2 executable path" + fi + + printf '%s\n' "$qa_pm2_online" >"$qa_pm2_json_file" + qa_identity="$( + export PATH="$qa_fake_bin" QA_FAKE_PM2_JSON_FILE="$qa_pm2_json_file" + qa_world_identity self-test-world "$qa_fake_world" + )" || die "world identity rejected the valid fake PM2 entry" + [[ "$qa_identity" == "$qa_world_pid"$'\t''7' ]] || die \ + "world identity returned an unexpected PID/restart tuple" + qa_world_process_matches "$qa_identity" "$qa_fake_world" "$qa_world_sha" || die \ + "world process pin rejected the valid live fake executable" + if qa_world_process_matches \ + "$qa_identity" "$qa_fake_world_other" "$qa_world_sha"; then + die "world process pin accepted the wrong executable path" + fi + if qa_world_process_matches \ + "$qa_identity" "$qa_fake_world" \ + 0000000000000000000000000000000000000000000000000000000000000000; then + die "world process pin accepted the wrong SHA-256" + fi + if ( + export PATH="$qa_fake_bin" \ + QA_FAKE_WORLD_PORT="$qa_world_port" \ + QA_FAKE_INSTANCE_PORT="$qa_instance_port" \ + QA_FAKE_SS_WORLD_PID="$qa_world_pid" \ + QA_FAKE_SS_INSTANCE_PID= + qa_world_ports_ready "$qa_identity" "$qa_world_port" "$qa_instance_port" + ); then + die "world listener gate accepted a missing instance listener" + fi + ( + export PATH="$qa_fake_bin" \ + QA_FAKE_WORLD_PORT="$qa_world_port" \ + QA_FAKE_INSTANCE_PORT="$qa_instance_port" \ + QA_FAKE_SS_WORLD_PID="$qa_world_pid" \ + QA_FAKE_SS_INSTANCE_PID="$qa_world_pid" + qa_world_ports_ready "$qa_identity" "$qa_world_port" "$qa_instance_port" + ) || die "world listener gate rejected the valid fake listeners" + if ( + export PATH="$qa_fake_bin" \ + QA_FAKE_WORLD_PORT="$qa_world_port" \ + QA_FAKE_INSTANCE_PORT="$qa_instance_port" \ + QA_FAKE_SS_WORLD_PID="$qa_world_pid" \ + QA_FAKE_SS_INSTANCE_PID="$qa_world_pid" \ + QA_FAKE_SS_EXTRA_PID="$((qa_world_pid + 1))" + qa_world_ports_ready "$qa_identity" "$qa_world_port" "$qa_instance_port" + ); then + die "world listener gate accepted a foreign PID on the same SO_REUSEPORT row" + fi + + printf '%s\n' "$qa_pm2_online" >"$qa_pm2_json_file" + printf '%s\n' "$qa_pm2_online" >"$qa_pm2_before_file" + printf '%s\n' "$qa_pm2_restart_changed" >"$qa_pm2_after_file" + qa_common_env=( + "PATH=$qa_fake_bin" + "TMPDIR=$artifacts" + "RUST_MIN_STACK=$DEFAULT_RUST_MIN_STACK" + "PM2_RUST_WORLD=self-test-world" + "WOW_BOT_WORLD_EXEC=$qa_fake_world_other" + "WOW_BOT_WORLD_EXEC_SHA256=$qa_target_sha" + "RUST_WORLD_PORT=$qa_world_port" + "RUST_INSTANCE_PORT=$qa_instance_port" + "QA_FAKE_PM2_JSON_FILE=$qa_pm2_json_file" + "QA_FAKE_PM2_BEFORE_FILE=$qa_pm2_before_file" + "QA_FAKE_PM2_AFTER_FILE=$qa_pm2_after_file" + "QA_FAKE_PM2_STATE_FILE=" + "QA_FAKE_WORLD_PORT=$qa_world_port" + "QA_FAKE_INSTANCE_PORT=$qa_instance_port" + "QA_FAKE_SS_WORLD_PID=$qa_world_pid" + "QA_FAKE_SS_INSTANCE_PID=$qa_world_pid" + "QA_FAKE_SS_DYNAMIC=1" + "QA_FAKE_ORIGINAL_EXEC=$qa_fake_world" + "QA_FAKE_TARGET_EXEC=$qa_fake_world_other" + "QA_FAKE_TARGET_SHA=$qa_target_sha" + "QA_FAKE_PROCESS_NAME=self-test-world" + "QA_FAKE_ORIGINAL_PID=$qa_world_pid" + "RUST_CAPTURE_DB_CONF=/dev/null" + "WOW_BOT_ENV_FILE=/dev/null" + "WOW_BOT_PASSWORD=self-test" + "WOW_BOT_GENERATE_LOCAL_PASSWORD=0" + "WOW_BOT_ENSURE_TEST_ACCOUNTS=1" + "WOW_BOT_EXEC=$qa_fake_bot" + "WOW_BOT_EXEC_SHA256=$qa_fake_bot_sha" + "WOW_BOT_REPORT=$artifacts/fake-loot-race-report.json" + "WOW_BOT_LOG=$artifacts/fake-loot-race.log" + "QA_FAKE_BOT_MARKER=$qa_fake_bot_marker" + ) + + rm -f "$qa_fake_bot_marker" "$qa_pm2_state_file" + printf '%s\n' "$qa_pm2_online" >"$qa_pm2_json_file" + qa_early_output="$( + ( + export "${qa_common_env[@]}" QA_FAKE_CAPTURE_BEFORE_READY_STATUS=73 + ALLOW_RUNTIME_QA=1 + ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=1 + QA_LOOT_RACE_CAPTURE_SCRIPT="$qa_fake_capture" + run_qa_loot_race + ) 2>&1 + )" || qa_early_status=$? + [[ "$qa_early_status" == 74 \ + && "$qa_early_output" == *"exited before its ready marker (status 74)"* ]] || die \ + "pre-ready cleanup failure was masked instead of taking exit-status priority" + + rm -f "$qa_fake_bot_marker" "$qa_pm2_state_file" + printf '%s\n' "$qa_pm2_online" >"$qa_pm2_json_file" + qa_positive_output="$( + ( + export "${qa_common_env[@]}" + ALLOW_RUNTIME_QA=1 + ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=1 + QA_LOOT_RACE_CAPTURE_SCRIPT="$qa_fake_capture" + run_qa_loot_race + ) 2>&1 + )" || { + printf '%s\n' "$qa_positive_output" >&2 + die "guarded capture QA self-test rejected valid fake infrastructure" + } + [[ "$(<"$qa_fake_bot_marker")" == fake-bot-only ]] || die \ + "guarded capture QA self-test did not execute only the fake bot" + [[ "$qa_positive_output" == *"fake guarded capture restored normal PM2 profile"* ]] || die \ + "guarded capture QA self-test did not run the bot and wait for restoration" + + rm -f "$qa_fake_bot_marker" "$qa_pm2_state_file" + printf '%s\n' "$qa_pm2_online" >"$qa_pm2_json_file" + if qa_summary_output="$( + ( + export "${qa_common_env[@]}" QA_FAKE_BOT_REPORT_MODE=summary + ALLOW_RUNTIME_QA=1 + ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=1 + QA_LOOT_RACE_CAPTURE_SCRIPT="$qa_fake_capture" + run_qa_loot_race + ) 2>&1 + )"; then + die "guarded capture QA accepted a summary-only bot report" + fi + [[ -f "$qa_fake_bot_marker" \ + && "$qa_summary_output" == *"report did not prove the exact successful two-session contract"* \ + && "$qa_summary_output" == *"fake guarded capture restored normal PM2 profile"* ]] || die \ + "summary-only bot report self-test did not fail closed and restore PM2" + + rm -f "$qa_fake_bot_marker" "$qa_pm2_state_file" + printf '%s\n' "$qa_pm2_online" >"$qa_pm2_json_file" + if qa_restart_output="$( + ( + export "${qa_common_env[@]}" QA_FAKE_BOT_MUTATION=restart + ALLOW_RUNTIME_QA=1 + ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=1 + QA_LOOT_RACE_CAPTURE_SCRIPT="$qa_fake_capture" + run_qa_loot_race + ) 2>&1 + )"; then + die "guarded capture QA accepted a changed PM2 restart count" + fi + if [[ ! -f "$qa_fake_bot_marker" \ + || "$qa_restart_output" != *"guarded capture world restarted during loot-race QA"* \ + || "$qa_restart_output" != *"fake guarded capture restored normal PM2 profile"* ]]; then + printf '%s\n' "$qa_restart_output" >&2 + die "restart-count self-test did not fail closed and restore after the fake bot" + fi + + rm -f "$qa_fake_bot_marker" "$qa_pm2_state_file" + printf '%s\n' "$qa_pm2_online" >"$qa_pm2_json_file" + if qa_pid_output="$( + ( + export "${qa_common_env[@]}" QA_FAKE_BOT_MUTATION=pid + ALLOW_RUNTIME_QA=1 + ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=1 + QA_LOOT_RACE_CAPTURE_SCRIPT="$qa_fake_capture" + run_qa_loot_race + ) 2>&1 + )"; then + die "guarded capture QA accepted a changed PM2 PID" + fi + [[ -f "$qa_fake_bot_marker" \ + && "$qa_pid_output" == *"guarded capture world restarted during loot-race QA"* \ + && "$qa_pid_output" == *"fake guarded capture restored normal PM2 profile"* ]] || die \ + "PID-change self-test did not fail closed and restore after the fake bot" + + rm -f "$qa_fake_bot_marker" "$qa_pm2_state_file" + printf '%s\n' "$qa_pm2_online" >"$qa_pm2_json_file" + if qa_cleanup_output="$( + ( + export "${qa_common_env[@]}" QA_FAKE_CAPTURE_EXIT_STATUS=73 + ALLOW_RUNTIME_QA=1 + ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=1 + QA_LOOT_RACE_CAPTURE_SCRIPT="$qa_fake_capture" + run_qa_loot_race + ) 2>&1 + )"; then + die "guarded capture QA ignored fixture-cleanup wrapper failure" + fi + [[ "$qa_cleanup_output" == *"guarded capture/fixture restoration failed with status 73"* \ + && "$qa_cleanup_output" == *"fake guarded capture restored normal PM2 profile"* ]] || die \ + "fixture-cleanup failure self-test did not propagate the wrapper status" + + rm -f "$qa_fake_bot_marker" "$qa_pm2_state_file" + printf '%s\n' "$qa_pm2_online" >"$qa_pm2_json_file" + if qa_term_output="$( + ( + export "${qa_common_env[@]}" QA_FAKE_BOT_MUTATION=term + ALLOW_RUNTIME_QA=1 + ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=1 + QA_LOOT_RACE_CAPTURE_SCRIPT="$qa_fake_capture" + run_qa_loot_race + ) 2>&1 + )"; then + die "guarded capture QA restored normal PM2 after a TERM-interrupted fixture" + fi + [[ "$qa_term_output" == *"guarded capture/fixture restoration failed with status 74"* \ + && "$qa_term_output" == *"pending journal or missing cleanup-complete"* \ + && "$qa_term_output" != *"restored normal PM2 profile"* ]] || die \ + "TERM self-test did not retain the pending journal and prioritize cleanup failure" + + rm -f "$qa_fake_bot_marker" "$qa_pm2_state_file" + printf '%s\n' "$qa_pm2_online" >"$qa_pm2_json_file" + if qa_kill_output="$( + ( + export "${qa_common_env[@]}" QA_FAKE_BOT_MUTATION=kill + ALLOW_RUNTIME_QA=1 + ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=1 + QA_LOOT_RACE_CAPTURE_SCRIPT="$qa_fake_capture" + run_qa_loot_race + ) 2>&1 + )"; then + die "guarded capture QA restored normal PM2 after a KILL-interrupted fixture" + fi + [[ "$qa_kill_output" == *"guarded capture/fixture restoration failed with status 74"* \ + && "$qa_kill_output" == *"pending journal or missing cleanup-complete"* \ + && "$qa_kill_output" != *"restored normal PM2 profile"* ]] || die \ + "KILL self-test did not retain the pending journal and prioritize cleanup failure" + + rm -f "$qa_fake_bot_marker" "$qa_pm2_state_file" + printf '%s\n' "$qa_pm2_online" >"$qa_pm2_json_file" + if qa_drift_output="$( + ( + export "${qa_common_env[@]}" QA_FAKE_BOT_MUTATION=drift + ALLOW_RUNTIME_QA=1 + ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=1 + QA_LOOT_RACE_CAPTURE_SCRIPT="$qa_fake_capture" + run_qa_loot_race + ) 2>&1 + )"; then + die "guarded capture QA accepted cleanup-complete while its recovery journal remained pending" + fi + [[ "$qa_drift_output" == *"guarded capture/fixture restoration failed with status 74"* \ + && "$qa_drift_output" == *"pending journal or missing cleanup-complete"* \ + && "$qa_drift_output" != *"restored normal PM2 profile"* ]] || die \ + "journal-drift self-test did not fail closed" review_dry_run_output="$(PATH="$artifacts/bin" \ "$BASH" "$REPO_ROOT/tools/pr-preflight.sh" --dry-run review HEAD 2>&1)" || die \ @@ -616,10 +2078,17 @@ run_self_test() { "full dry-run unexpectedly requires optional execution tools" [[ "$full_dry_run_output" == *"+ codex"* ]] || die \ "full dry-run did not print the Codex command" - [[ "$full_dry_run_output" == *"test --locked -p capture-diff"* ]] || die \ - "full dry-run did not print the capture-diff command" - - rm -rf "$artifacts" + require_exact_occurrences "$full_dry_run_output" \ + "test --locked -p capture-diff" 1 \ + "local full capture-diff test command" + require_exact_occurrences "$full_dry_run_output" \ + "verify-required loot-single-item-claim" 1 \ + "local full required-flow command" + + self_test_cleanup "$qa_world_pid" "$artifacts" + qa_world_pid="" + artifacts="" + trap - EXIT log "Preflight self-test passed" } @@ -715,7 +2184,6 @@ run_full() { require_clean_worktree run_diff "$base" run_ci - run_capture run_review "$base" } @@ -733,6 +2201,337 @@ run_qa_login() { run_cmd "$REPO_ROOT/tools/wow-test-bot/run_rustycore_login_smoke.sh" } +run_qa_loot_race() { + local capture_script="$QA_LOOT_RACE_CAPTURE_SCRIPT" + local capture_status=0 + local bot_exec="${WOW_BOT_EXEC:-}" + local bot_expected_sha="${WOW_BOT_EXEC_SHA256:-}" + local bot_log + local bot_report + local control_fifo + local cpp_process_name="${PM2_CPP_WORLD:-cpp-world}" + local db_conf="${RUST_CAPTURE_DB_CONF:-${WOW_BOT_DB_CONF:-/home/server/trinity-legacy-install/bin/worldserver.conf}}" + local effective_config="${RUST_CAPTURE_EFFECTIVE_CONFIG:-/home/server/trinity-legacy-install/etc/worldserver.conf}" + local expected_sha + local fixture_cleanup_marker + local fixture_journal + local identity_before + local identity_capture + local identity_after + local identity_restored + local original_canonical_exec + local original_exec + local original_pid + local original_restart + local original_sha + local original_snapshot + local process_name="${PM2_RUST_WORLD:-rustycore-world}" + local world_exec="${WOW_BOT_WORLD_EXEC:-}" + local world_port="${RUST_WORLD_PORT:-8085}" + local instance_port="${RUST_INSTANCE_PORT:-8086}" + local bot_status=0 + local bnet_port="${BNET_PORT:-8081}" + local qa_status=0 + + ((ALLOW_RUNTIME_QA == 1)) || die \ + "qa-loot-race modifies disposable characters and world state; rerun with --allow-runtime-qa" + ((ACK_DISPOSABLE_OVERWORLD_LOOT_RACE == 1)) || die \ + "qa-loot-race mutates an exact shared-chest fixture; rerun with --ack-disposable-overworld-loot-race" + log "Live two-session atomic loot-claim QA bot" + + valid_tcp_port "$world_port" || die "RUST_WORLD_PORT must be an integer from 1 through 65535" + valid_tcp_port "$instance_port" || die \ + "RUST_INSTANCE_PORT must be an integer from 1 through 65535" + [[ "$world_port" != "$instance_port" ]] || die \ + "RUST_WORLD_PORT and RUST_INSTANCE_PORT must be distinct" + valid_tcp_port "$bnet_port" || die "BNET_PORT must be an integer from 1 through 65535" + + if ((DRY_RUN)); then + fixture_journal="/tmp/rustycore-loot-race-qa/fixture.journal" + printf '+ snapshot the pinned PM2 world, then start the guarded capture world via FIFO/background\n' + run_cmd env \ + PM2_RUST_WORLD="$process_name" \ + PM2_CPP_WORLD="$cpp_process_name" \ + RUST_WORLD_PORT="$world_port" \ + RUST_INSTANCE_PORT="$instance_port" \ + RUST_CAPTURE_EXEC="$world_exec" \ + RUST_CAPTURE_EXEC_SHA256="${WOW_BOT_WORLD_EXEC_SHA256:-}" \ + RUST_CAPTURE_LOOT_FIXTURE_GUARD=1 \ + RUST_CAPTURE_ACK_LOOT_FIXTURE_MUTATION=1 \ + RUST_CAPTURE_DB_CONF="$db_conf" \ + RUST_CAPTURE_EFFECTIVE_CONFIG="$effective_config" \ + WOW_BOT_EXEC="$bot_exec" \ + WOW_BOT_EXEC_SHA256="$bot_expected_sha" \ + WOW_BOT_FIXTURE_JOURNAL="$fixture_journal" \ + "$capture_script" loot-two-session-atomic-race --yes + printf '+ wait for guarded capture READY marker and accredit its exact PID/executable/listeners\n' + run_cmd env \ + BNET_HOST=127.0.0.1 \ + BNET_PORT="$bnet_port" \ + WORLD_HOST=127.0.0.1 \ + WORLD_PORT="$world_port" \ + INSTANCE_HOST=127.0.0.1 \ + INSTANCE_PORT="$instance_port" \ + WOW_BOT_DB_CONF="$db_conf" \ + WOW_BOT_ENSURE_TEST_ACCOUNTS=0 \ + WOW_BOT_EXEC="$bot_exec" \ + WOW_BOT_EXEC_SHA256="$bot_expected_sha" \ + WOW_BOT_FIXTURE_JOURNAL="$fixture_journal" \ + WOW_BOT_LOOT_RACE_SMOKE=1 \ + WOW_BOT_ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=1 \ + WOW_BOT_LOOT_RACE_ACCOUNT_A=TESTBOT2@bot.local \ + WOW_BOT_LOOT_RACE_ACCOUNT_B=TESTBOT3@bot.local \ + WOW_BOT_LOOT_RACE_GAMEOBJECT_ENTRY=2846 \ + WOW_BOT_LOOT_RACE_GAMEOBJECT_SPAWN_GUID=9106001 \ + WOW_BOT_LOOT_RACE_RUNTIME_COUNTER=0 \ + WOW_BOT_LOOT_RACE_ITEM_ENTRY=38 \ + "$REPO_ROOT/tools/wow-test-bot/run_rustycore_login_smoke.sh" + printf '+ verify capture PID/restart/executable/listeners unchanged, send ENTER, and wait for exact PM2/fixture restoration\n' + return + fi + + [[ -n "$world_exec" ]] || die \ + "qa-loot-race requires WOW_BOT_WORLD_EXEC pinned to the target capture world-server binary" + [[ "$world_exec" == /* ]] || die "WOW_BOT_WORLD_EXEC must be an absolute canonical path" + [[ ! -L "$world_exec" && -f "$world_exec" && -x "$world_exec" ]] || die \ + "WOW_BOT_WORLD_EXEC must be a regular executable file (not a symlink)" + [[ "$(realpath -e -- "$world_exec" 2>/dev/null)" == "$world_exec" ]] || die \ + "WOW_BOT_WORLD_EXEC must already be canonical" + expected_sha="${WOW_BOT_WORLD_EXEC_SHA256:-}" + [[ "$expected_sha" =~ ^[[:xdigit:]]{64}$ ]] || die \ + "WOW_BOT_WORLD_EXEC_SHA256 must contain the pinned 64-digit SHA-256" + expected_sha="${expected_sha,,}" + [[ -n "$bot_exec" ]] || die \ + "qa-loot-race requires WOW_BOT_EXEC pinned to the exact wow-test-bot binary" + [[ "$bot_exec" == /* ]] || die "WOW_BOT_EXEC must be an absolute canonical path" + [[ ! -L "$bot_exec" && -f "$bot_exec" && -x "$bot_exec" ]] || die \ + "WOW_BOT_EXEC must be a regular executable file (not a symlink)" + [[ "$(realpath -e -- "$bot_exec" 2>/dev/null)" == "$bot_exec" ]] || die \ + "WOW_BOT_EXEC must already be canonical" + [[ "$bot_expected_sha" =~ ^[[:xdigit:]]{64}$ ]] || die \ + "WOW_BOT_EXEC_SHA256 must contain the pinned 64-digit SHA-256" + bot_expected_sha="${bot_expected_sha,,}" + require_command jq + require_command mkfifo + require_command mysql + require_command pm2 + require_command realpath + require_command rg + require_command seq + require_command sha256sum + require_command ss + [[ -x "$capture_script" && ! -L "$capture_script" ]] || die \ + "qa-loot-race capture wrapper is missing, non-executable, or a symlink: $capture_script" + [[ "$(sha256_of_file "$world_exec")" == "$expected_sha" ]] || die \ + "WOW_BOT_WORLD_EXEC does not match WOW_BOT_WORLD_EXEC_SHA256" + [[ "$(sha256_of_file "$bot_exec")" == "$bot_expected_sha" ]] || die \ + "WOW_BOT_EXEC does not match WOW_BOT_EXEC_SHA256" + + original_snapshot="$(qa_world_snapshot "$process_name")" || die \ + "PM2 process $process_name is not one exact online world executable" + IFS=$'\t' read -r original_pid original_restart original_exec <<<"$original_snapshot" + [[ "$original_pid" =~ ^[1-9][0-9]*$ && "$original_restart" =~ ^[0-9]+$ \ + && -n "$original_exec" ]] || die "PM2 normal-world snapshot was malformed" + original_canonical_exec="$(realpath -e -- "$original_exec" 2>/dev/null)" || die \ + "normal PM2 world executable does not resolve: $original_exec" + [[ -f "$original_canonical_exec" && -x "$original_canonical_exec" ]] || die \ + "normal PM2 world executable is not a regular executable file" + original_sha="$(sha256_of_file "$original_canonical_exec")" || die \ + "cannot hash the normal PM2 world executable" + identity_before="${original_pid}"$'\t'"${original_restart}" + qa_world_process_matches \ + "$identity_before" "$original_canonical_exec" "$original_sha" || die \ + "live normal PM2 world bytes do not match its snapshotted executable" + qa_world_ports_ready "$identity_before" "$world_port" "$instance_port" || die \ + "pinned PM2 world process does not own listeners $world_port and $instance_port" + qa_world_packet_dump_absent "$process_name" || die \ + "normal PM2 world already carries RUSTYCORE_PACKET_DUMP_DIR; refusing nested capture QA" + + QA_LOOT_RACE_CAPTURE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/rustycore-loot-race-qa.XXXXXX")" + QA_LOOT_RACE_CAPTURE_WAIT_STATUS=0 + QA_LOOT_RACE_CAPTURE_LOG="$QA_LOOT_RACE_CAPTURE_DIR/capture.log" + QA_LOOT_RACE_FIXTURE_JOURNAL="$QA_LOOT_RACE_CAPTURE_DIR/fixture.journal" + bot_report="$QA_LOOT_RACE_CAPTURE_DIR/bot-report.json" + bot_log="$QA_LOOT_RACE_CAPTURE_DIR/bot.log" + fixture_journal="$QA_LOOT_RACE_FIXTURE_JOURNAL" + fixture_cleanup_marker="${fixture_journal}.cleanup-complete" + [[ ! -e "$fixture_journal" && ! -L "$fixture_journal" \ + && ! -e "$fixture_cleanup_marker" && ! -L "$fixture_cleanup_marker" \ + && ! -e "$bot_report" && ! -L "$bot_report" \ + && ! -e "$bot_log" && ! -L "$bot_log" ]] || die \ + "fresh QA fixture/report path is unexpectedly occupied" + control_fifo="$QA_LOOT_RACE_CAPTURE_DIR/control.fifo" + mkfifo -- "$control_fifo" + exec {QA_LOOT_RACE_CAPTURE_FD}<>"$control_fifo" + trap qa_loot_race_capture_cleanup EXIT + trap 'exit 129' HUP + trap 'exit 130' INT + trap 'exit 143' TERM + + env \ + PM2_RUST_WORLD="$process_name" \ + PM2_CPP_WORLD="$cpp_process_name" \ + RUST_WORLD_PORT="$world_port" \ + RUST_INSTANCE_PORT="$instance_port" \ + RUST_CAPTURE_EXEC="$world_exec" \ + RUST_CAPTURE_EXEC_SHA256="$expected_sha" \ + RUST_CAPTURE_LOOT_FIXTURE_GUARD=1 \ + RUST_CAPTURE_ACK_LOOT_FIXTURE_MUTATION=1 \ + RUST_CAPTURE_DB_CONF="$db_conf" \ + RUST_CAPTURE_EFFECTIVE_CONFIG="$effective_config" \ + WOW_BOT_EXEC="$bot_exec" \ + WOW_BOT_EXEC_SHA256="$bot_expected_sha" \ + WOW_BOT_FIXTURE_JOURNAL="$fixture_journal" \ + "$capture_script" loot-two-session-atomic-race --yes \ + <&"$QA_LOOT_RACE_CAPTURE_FD" >"$QA_LOOT_RACE_CAPTURE_LOG" 2>&1 & + QA_LOOT_RACE_CAPTURE_PID=$! + + qa_wait_for_loot_capture_ready \ + "$QA_LOOT_RACE_CAPTURE_PID" "$QA_LOOT_RACE_CAPTURE_LOG" || die \ + "guarded capture wrapper did not become ready" + identity_capture="$(qa_world_identity "$process_name" "$world_exec")" || die \ + "guarded capture world is not the pinned online executable" + [[ "$identity_capture" != "$identity_before" ]] || die \ + "guarded capture wrapper did not replace the normal PM2 world process" + qa_world_process_matches "$identity_capture" "$world_exec" "$expected_sha" || die \ + "guarded capture world bytes do not match the pinned executable" + qa_world_ports_ready "$identity_capture" "$world_port" "$instance_port" || die \ + "guarded capture world does not own listeners $world_port and $instance_port" + + env \ + BNET_HOST=127.0.0.1 \ + BNET_PORT="$bnet_port" \ + WORLD_HOST=127.0.0.1 \ + WORLD_PORT="$world_port" \ + INSTANCE_HOST=127.0.0.1 \ + INSTANCE_PORT="$instance_port" \ + WOW_BOT_DB_CONF="$db_conf" \ + WOW_BOT_ENSURE_TEST_ACCOUNTS=0 \ + WOW_BOT_EXEC="$bot_exec" \ + WOW_BOT_EXEC_SHA256="$bot_expected_sha" \ + WOW_BOT_REPORT="$bot_report" \ + WOW_BOT_LOG="$bot_log" \ + WOW_BOT_FIXTURE_JOURNAL="$fixture_journal" \ + WOW_BOT_LOOT_RACE_SMOKE=1 \ + WOW_BOT_ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=1 \ + WOW_BOT_LOOT_RACE_ACCOUNT_A=TESTBOT2@bot.local \ + WOW_BOT_LOOT_RACE_ACCOUNT_B=TESTBOT3@bot.local \ + WOW_BOT_LOOT_RACE_GAMEOBJECT_ENTRY=2846 \ + WOW_BOT_LOOT_RACE_GAMEOBJECT_SPAWN_GUID=9106001 \ + WOW_BOT_LOOT_RACE_RUNTIME_COUNTER=0 \ + WOW_BOT_LOOT_RACE_ITEM_ENTRY=38 \ + "$REPO_ROOT/tools/wow-test-bot/run_rustycore_login_smoke.sh" & + QA_LOOT_RACE_BOT_PID=$! + wait "$QA_LOOT_RACE_BOT_PID" || bot_status=$? + QA_LOOT_RACE_BOT_PID="" + + if ((bot_status == 0)); then + if [[ ! -f "$bot_report" || -L "$bot_report" ]]; then + echo "loot-race bot exited successfully without a fresh regular report" >&2 + bot_status=1 + elif ! jq -e ' + .loot_race_smoke == true + and .loot_item_capture == false + and (.results | length == 2) + and ([.results[].account] | sort == ["TESTBOT2@bot.local", "TESTBOT3@bot.local"]) + and all(.results[]; + .world_auth == true + and .enum_characters == true + and .player_login_verified == true + and .loot_race_smoke == true + and .loot_race_smoke_passed == true + and .loot_race_failure == null + and .loot_race_target_entry == 2846 + and .loot_race_target_spawn_guid == 9106001 + and (.loot_race_target_runtime_counter | type == "number" and . > 0) + and .loot_race_party_confirmed == true + and .loot_race_target_discovered == true + and .loot_race_loot_opened == true + and (.loot_race_loot_list_id | type == "number" and . >= 0 and . <= 255) + and .loot_race_loot_coins == 10 + and .loot_race_loot_removed_seen == true + and .loot_race_coin_removed_seen == true + and .loot_race_db_item_total == 1 + and .loot_race_db_money_delta == 10 + and .loot_race_relog_verified == true) + and ([.results[].loot_race_loot_list_id] | unique | length == 1) + and ([.results[] | select(.loot_race_item_push_seen == true)] | length == 1) + and ([.results[] | select(.loot_race_item_push_seen == false)] | length == 1) + and ([.results[].loot_race_money_notify_amount] | sort == [0, 10]) + ' "$bot_report" >/dev/null; then + echo "loot-race bot report did not prove the exact successful two-session contract" >&2 + bot_status=1 + fi + fi + + if ! identity_after="$(qa_world_identity "$process_name" "$world_exec")"; then + echo "guarded capture world changed or stopped during loot-race QA" >&2 + qa_status=1 + elif [[ "$identity_after" != "$identity_capture" ]]; then + echo "guarded capture world restarted during loot-race QA" >&2 + qa_status=1 + elif ! qa_world_process_matches "$identity_after" "$world_exec" "$expected_sha"; then + echo "guarded capture world bytes changed during loot-race QA" >&2 + qa_status=1 + elif ! qa_world_ports_ready "$identity_after" "$world_port" "$instance_port"; then + echo "guarded capture world listeners disappeared during loot-race QA" >&2 + qa_status=1 + fi + if ((bot_status != 0)); then + qa_status=$bot_status + fi + + if ! printf '\n' >&"$QA_LOOT_RACE_CAPTURE_FD"; then + echo "failed to signal guarded capture completion" >&2 + ((qa_status != 0)) || qa_status=1 + fi + exec {QA_LOOT_RACE_CAPTURE_FD}>&- + QA_LOOT_RACE_CAPTURE_FD="" + wait "$QA_LOOT_RACE_CAPTURE_PID" || capture_status=$? + QA_LOOT_RACE_CAPTURE_WAIT_STATUS=$capture_status + QA_LOOT_RACE_CAPTURE_PID="" + sed -n '1,240p' "$QA_LOOT_RACE_CAPTURE_LOG" + if ((capture_status != 0)); then + echo "guarded capture/fixture restoration failed with status $capture_status" >&2 + qa_status=$capture_status + else + if ! identity_restored="$(qa_world_identity "$process_name" "$original_exec")"; then + echo "normal PM2 world was not restored after loot-race QA" >&2 + ((qa_status != 0)) || qa_status=1 + elif [[ "$identity_restored" == "$identity_capture" ]]; then + echo "capture PM2 identity remained live instead of restoring the normal world" >&2 + ((qa_status != 0)) || qa_status=1 + elif ! qa_world_process_matches \ + "$identity_restored" "$original_canonical_exec" "$original_sha"; then + echo "restored PM2 world does not match the snapshotted normal executable" >&2 + ((qa_status != 0)) || qa_status=1 + elif ! qa_world_ports_ready "$identity_restored" "$world_port" "$instance_port"; then + echo "restored PM2 world does not own the accredited listeners" >&2 + ((qa_status != 0)) || qa_status=1 + elif ! qa_world_packet_dump_absent "$process_name"; then + echo "restored PM2 world still carries RUSTYCORE_PACKET_DUMP_DIR" >&2 + ((qa_status != 0)) || qa_status=1 + fi + fi + + trap - EXIT + trap '' HUP INT TERM + rm -f -- "$control_fifo" + if ((qa_status == 0)) \ + && [[ ! -e "$fixture_journal" && ! -L "$fixture_journal" \ + && ! -e "$fixture_cleanup_marker" && ! -L "$fixture_cleanup_marker" ]]; then + rm -f -- "$QA_LOOT_RACE_CAPTURE_LOG" "$bot_report" "$bot_log" + rmdir -- "$QA_LOOT_RACE_CAPTURE_DIR" 2>/dev/null || true + else + echo "qa-loot-race recovery artifacts retained at ${QA_LOOT_RACE_CAPTURE_DIR}" >&2 + fi + QA_LOOT_RACE_CAPTURE_DIR="" + QA_LOOT_RACE_CAPTURE_LOG="" + QA_LOOT_RACE_FIXTURE_JOURNAL="" + QA_LOOT_RACE_CAPTURE_WAIT_STATUS=0 + return "$qa_status" +} + while (($# > 0)); do case "$1" in --dry-run) @@ -743,6 +2542,10 @@ while (($# > 0)); do ALLOW_RUNTIME_QA=1 shift ;; + --ack-disposable-overworld-loot-race) + ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=1 + shift + ;; -h | --help) usage exit 0 @@ -764,6 +2567,7 @@ fi cd "$REPO_ROOT" require_command git +validate_rust_min_stack case "$COMMAND" in self-test) @@ -818,6 +2622,10 @@ case "$COMMAND" in (($# == 0)) || die "qa-login does not accept arguments" run_qa_login ;; + qa-loot-race) + (($# == 0)) || die "qa-loot-race does not accept arguments" + run_qa_loot_race + ;; help) usage ;; diff --git a/tools/wow-test-bot/Cargo.lock b/tools/wow-test-bot/Cargo.lock index 9b60daf30..3d34b6c5c 100644 --- a/tools/wow-test-bot/Cargo.lock +++ b/tools/wow-test-bot/Cargo.lock @@ -3067,6 +3067,7 @@ dependencies = [ "flate2", "hex", "hmac", + "libc", "mysql", "num-bigint", "num-traits", @@ -3077,6 +3078,7 @@ dependencies = [ "serde_json", "sha2", "tokio", + "tokio-util", "tracing", "tracing-subscriber", ] diff --git a/tools/wow-test-bot/Cargo.toml b/tools/wow-test-bot/Cargo.toml index 8c739f07c..812627bef 100644 --- a/tools/wow-test-bot/Cargo.toml +++ b/tools/wow-test-bot/Cargo.toml @@ -6,6 +6,7 @@ rust-version = "1.88" [dependencies] tokio = { version = "1", features = ["full"] } +tokio-util = "0.7" anyhow = "1" tracing = "0.1" tracing-subscriber = "0.3" @@ -23,6 +24,7 @@ aes-gcm = "0.10" openssl = "0.10" mysql = "25.0" flate2 = "1" +libc = "0.2" [[bin]] name = "wow-test-bot" diff --git a/tools/wow-test-bot/README.md b/tools/wow-test-bot/README.md index 4cc9f3d0a..addd4c8e9 100644 --- a/tools/wow-test-bot/README.md +++ b/tools/wow-test-bot/README.md @@ -63,15 +63,206 @@ baseline-zero login rows. Any other rows created by the server remain visible, which is why the acknowledged disposable identity and clean preflight are mandatory. +For the two-session atomic loot-claim smoke, use the guarded local preflight +command with the feature-branch world executable pinned. The currently running +normal PM2 world may be a different build; its executable/profile is snapshotted +and restored after QA: + +```bash +test -z "$(git status --porcelain=v1 --untracked-files=normal)" +TARGET_EXEC="$(realpath /absolute/path/to/issue-106/world-server)" +BOT_EXEC="$(realpath tools/wow-test-bot/target/debug/wow-test-bot)" +RUST_CAPTURE_DB_CONF=/home/server/trinity-legacy-install/bin/worldserver.conf \ +RUST_CAPTURE_EFFECTIVE_CONFIG=/home/server/trinity-legacy-install/etc/worldserver.conf \ +WOW_BOT_DB_CONF=/home/server/trinity-legacy-install/bin/worldserver.conf \ +WOW_BOT_WORLD_EXEC="$TARGET_EXEC" \ +WOW_BOT_WORLD_EXEC_SHA256="$(sha256sum "$TARGET_EXEC" | awk '{print $1}')" \ +WOW_BOT_EXEC="$BOT_EXEC" \ +WOW_BOT_EXEC_SHA256="$(sha256sum "$BOT_EXEC" | awk '{print $1}')" \ +./tools/pr-preflight.sh --allow-runtime-qa \ + --ack-disposable-overworld-loot-race qa-loot-race +``` + +The DB fixture guard and the PM2 runtime config are separate pins on this host: +the former reads the full legacy `bin/worldserver.conf`, while the latter must +match the Rust PM2 profile's effective `etc/worldserver.conf` exactly. + +This live mode is never part of normal CI. Both flags are mandatory because the +guard temporarily mutates a world GameObject fixture and two disposable +characters. The wrapper translates that explicit acknowledgement to the +binary's CLI-only, legacy-named +`--ack-disposable-overworld-loot-race` guard; direct binary runs must pass +`--loot-race-smoke --ack-disposable-overworld-loot-race` themselves. The +default disposable characters are `TESTBOT2@bot.local` and +`TESTBOT3@bot.local` (the versioned config maps them to character GUIDs `15` +and `16`). Setup rechecks each configured GUID's account ownership and requires +one character per account; every destructive cleanup query remains scoped to +those two verified GUIDs. + +Both loot modes force `WOW_BOT_ENSURE_TEST_ACCOUNTS=0`: provision the two +disposable identities first. They also require an absolute +`WOW_BOT_FIXTURE_JOURNAL`. The bot creates that mode-0600 journal before its +first mutation, removes it after verified restoration, then atomically writes +the mode-0600 JSON marker `${WOW_BOT_FIXTURE_JOURNAL}.cleanup-complete`. The +marker records schema version `1`, the journal SHA-256, and the cleanup PID. A +matching journal+marker pair is recoverable/idempotent; a mismatched marker is +rejected. The guarded preflight supplies a private path automatically and will +not restart the normal PM2 world while the journal is pending or the marker is +missing, unsafe, or malformed. Loot workflows also have a hard end-to-end +deadline of 900 seconds; override it with +`WOW_BOT_LOOT_WORKFLOW_DEADLINE_SECS` or `--loot-workflow-deadline`. + +The wrapper `exec`s loot-mode bots and enables Linux parent-death signalling so +TERM/INT reaches the handler registered before fixture mutation and a dead +wrapper cannot leave a detached mutator. If a run leaves a journal, keep the +normal world stopped and run explicit recovery with the same DB configuration: + +```bash +WOW_BOT_DB_CONF=/absolute/path/to/worldserver.conf \ +WOW_BOT_FIXTURE_JOURNAL=/absolute/path/to/fixture.journal \ +tools/wow-test-bot/target/debug/wow-test-bot --recover-loot-fixture +``` + +Recovery performs no login or service action. Only after it verifies bounded +database restoration does it remove the journal and produce the cleanup marker; +the operator may then let the outer capture/preflight wrapper restore PM2. + +The race fixture is the existing Tattered Chest template (entry `2846`, loot +template `2278`) plus wrapper-owned spawn `9106001`. The guard temporarily +installs that exact spawn and changes only the chest addon's original `0/0` +money bounds to deterministic `10/10`; the loot template must contain exactly +one unconditional item `38`. It fails closed on template/data drift, a +pre-existing spawn or GameObject respawn, loot conditions, or pool/event/linked, +spawn-group, addon, or override ownership. Its full template-addon fields are +pinned as well. The group must report C++ `PERSONAL_LOOT` (`5`). The default runtime +counter is `0` (auto): each client discovers and preserves the complete live +GameObject ObjectGuid from `SMSG_UPDATE_OBJECT`, while the low counter must +still equal exact SQL spawn `9106001`. A nonzero +`WOW_BOT_LOOT_RACE_RUNTIME_COUNTER` is only a strict override; it never replaces +live discovery. + +The smoke snapshots and relocates both characters, forms the real two-player +party, positions both clients at the chest, and synchronizes two +`CMSG_GAME_OBJ_USE` requests for the same packed GUID. A separate barrier proves +that both `SMSG_LOOT_RESPONSE` packets arrived before the competing item and +money claims begin. It verifies one global item award, exact database +persistence after logout/relogin, and fail-closed loser evidence. + +Chest money intentionally does not use the corpse group split. In C++ +`HandleLootMoneyOpcode`, `LOOT_CHEST` keeps `shareMoney=false`: the first +serialized requester receives all `10` copper with `SoleLooter=true`, the +second receives `0` with `SoleLooter=true`, and both active viewers observe two +matching `CoinRemoved` notifications because both requests notify viewers. The +database total must therefore increase by exactly `10`, and the persisted +winner must match the positive wire notification. + +After both clients log out, the bot restores its bounded character, item, +group, and respawn snapshots. The outer fixture guard stops the QA world before +removing unchanged spawn `9106001` and restoring the addon's `0/0` money bounds; +external drift is reported instead of overwritten. Cleanup validates the whole +spawn, including persisted `state`, before any delete; the bot must not reset +that field to conceal runtime drift. + +For the item proof, every observed `ItemPushResult` is retained before checking +its entry. The harness requires one winner payload, at most one identical copy +per socket, one exact `LootGone` on the loser, and one exact `LootRemoved` per +viewer; it repeats that proof after the later money settle window. Rust's +complete `StoreLootItem` side-effect cascade is deliberately tracked by #55, +so this #106 atomicity smoke accepts either winner-only delivery or one +identical C++ group broadcast per socket, but never a divergent or duplicate +logical grant. The winner's complete wire Item GUID, +player owner, entry, count, and top-level backpack slot are then keyed back to +one `item_instance` plus `character_inventory` row and checked again after +relogin. The database stores only the Item GUID counter (not its wire high/realm +half) and does not persist packet-only fields such as `QuestLogItemID`, display, +or delivery flags; those fields are validated from the wire where the fixed +fixture makes them deterministic, not claimed as database-verifiable evidence. + +Overrides are available through `WOW_BOT_LOOT_RACE_ACCOUNT_A/B`, +`WOW_BOT_LOOT_RACE_GAMEOBJECT_ENTRY`, +`WOW_BOT_LOOT_RACE_GAMEOBJECT_SPAWN_GUID`, +`WOW_BOT_LOOT_RACE_RUNTIME_COUNTER`, `WOW_BOT_LOOT_RACE_ITEM_ENTRY`, and +`WOW_BOT_LOOT_RACE_TIMEOUT_SECS`. The older +`WOW_BOT_LOOT_RACE_CREATURE_ENTRY` and +`WOW_BOT_LOOT_RACE_CREATURE_SPAWN_GUID` names remain fallback aliases so +existing callers do not break; they describe the GameObject only in race mode. + +The preflight first snapshots and accredits the exact normal PM2 executable, +then drives `capture-rust.sh loot-two-session-atomic-race --yes` through a FIFO. +After its ready marker, it accredits the new capture PID against the pinned +target path/hash and world/instance listeners. That identity must not restart or +change during the bot run. The preflight then signals completion, waits for the +capture wrapper to stop the QA world and restore the fixture plus original PM2 +profile, and accredits the restored original executable again. Bot failure, +capture drift, or wrapper cleanup failure all remain failures. + +The bot endpoints are forced to `WORLD_HOST=127.0.0.1`, the accredited world +port, `INSTANCE_HOST=127.0.0.1`, and an expected `INSTANCE_PORT`; the bot rejects +a different port in `SMSG_CONNECT_TO`. Because the server may still bind a +wildcard listener, run this destructive QA only on an isolated host or network +namespace whose firewall restricts the BNet/world/instance ports to loopback +traffic. This prevents another client from entering the temporary fixture world +or a green result coming from a service other than the accredited process. + +For a capture-diff golden, use the separate single-session item-only mode: + +```bash +FIXTURE_RECOVERY_DIR="$(mktemp -d "${TMPDIR:-/tmp}/rustycore-loot-item.XXXXXX")" +FIXTURE_JOURNAL="$FIXTURE_RECOVERY_DIR/fixture.journal" +if WOW_BOT_ENSURE_TEST_ACCOUNTS=0 \ + WOW_BOT_FIXTURE_JOURNAL="$FIXTURE_JOURNAL" \ + WOW_BOT_LOOT_ITEM_CAPTURE=1 \ + WOW_BOT_ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=1 \ + ./run_rustycore_login_smoke.sh \ + && test ! -e "$FIXTURE_JOURNAL" \ + && test -f "${FIXTURE_JOURNAL}.cleanup-complete"; then + rm -f -- "${FIXTURE_JOURNAL}.cleanup-complete" + rmdir -- "$FIXTURE_RECOVERY_DIR" +else + echo "fixture recovery evidence retained at $FIXTURE_RECOVERY_DIR" >&2 + false +fi +``` + +It uses the separate deterministic Doctor Maleficus fixture (entry `21779`, +unique world spawn `1117`, guaranteed item `30712`, and no coin pool) +with the same fail-closed character, inventory, respawn, and cleanup checks. +The item is The Doctor's Key: account A's exact top-level keyring slot +`bag=0, slot=106` must start empty, the wire push and persisted inventory row +must both use that slot, and cleanup must restore it to empty. A matching key +placed in an ordinary backpack slot does not pass. +Its full runtime ObjectGuid is auto-discovered by default as above; the SQL +spawn id is not assumed to be the live ObjectGuid counter. +Only account A connects; account B stays offline and is used solely by the +existing bounded fixture snapshot. The bot kills and opens the creature, sends +one `CMSG_LOOT_ITEM`, and requires exactly one matching +`SMSG_LOOT_REMOVED` plus one owner-correct `SMSG_ITEM_PUSH_RESULT`, refuses any +money or inventory-failure packets, then emits a fixed-serial zero-latency +`CMSG_PING` fence immediately after those two responses and verifies its Pong +before logout. It does not retain the two-client smoke's two-second evidence +settle window. It also verifies the one item row persisted +and that neither fixture character's money changed before cleanup. This mode +must not be confused with the two-session race; it exists so the global C++ and +Rust packet logs contain one attributable session for +`loot-single-item-claim`. + `config.example.json` is versioned with blank passwords. Use `WOW_BOT_PASSWORD`, the per-account `WOW_BOT_PASSWORD_` override, or an ignored local `config.json` for credentials. Do not commit real local bot passwords. -The RustyCore wrapper defaults to `WOW_BOT_ENSURE_TEST_ACCOUNTS=1`: it -generates an ignored `.env.local` password when none exists, then upserts only -local `@bot.local` BNet/game account rows with matching SRP credentials before -running. Set `WOW_BOT_GENERATE_LOCAL_PASSWORD=0` or +Outside loot modes, the RustyCore wrapper defaults to +`WOW_BOT_ENSURE_TEST_ACCOUNTS=1`: it +generates an ignored `.env.local` password when none exists, then either creates +both missing local `@bot.local` BNet/game auth rows or validates an already +complete identity exactly before running. It never updates credentials, +reassigns a character, repairs a partial BNet/game identity, clears a ban, or +changes an online/realm-count mismatch. Set `WOW_BOT_GENERATE_LOCAL_PASSWORD=0` or `WOW_BOT_ENSURE_TEST_ACCOUNTS=0` to disable that local QA bootstrap. +Loot modes override this variable to `0` even when the caller or `.env.local` +sets it to `1`. +Explicit caller environment values always win over `.env.local`, including +mode/acknowledgement, endpoint, executable/hash, and credential variables; the +wrapper suppresses xtrace while it loads and restores those values. Build and test this bot with Rust 1.88.0 (`cargo +1.88.0 ...`), matching the RustyCore toolchain. diff --git a/tools/wow-test-bot/run_rustycore_login_smoke.sh b/tools/wow-test-bot/run_rustycore_login_smoke.sh index 9b35cec26..f2009639d 100755 --- a/tools/wow-test-bot/run_rustycore_login_smoke.sh +++ b/tools/wow-test-bot/run_rustycore_login_smoke.sh @@ -1,13 +1,55 @@ #!/usr/bin/env bash set -euo pipefail +# The ignored defaults file is data owned by the local operator. Keep xtrace +# off while snapshotting/restoring exported values so neither caller secrets +# nor values from that file can be echoed accidentally. +RC_CALLER_XTRACE=0 +if [[ "$-" == *x* ]]; then + RC_CALLER_XTRACE=1 + set +x +fi +readonly RC_CALLER_XTRACE + cd "$(dirname "$0")" -if [[ -f .env.local ]]; then +# Local defaults must never replace values explicitly pinned by the caller. +# This is especially important for runtime QA provenance (`WOW_BOT_EXEC*`) and +# for the selected destructive mode. Preserve every exported bot/runtime input, +# source the ignored defaults, then restore the caller-owned values verbatim. +declare -A caller_runtime_env=() +while IFS='=' read -r -d '' env_name env_value; do + case "$env_name" in + WOW_BOT_* | BNET_HOST | BNET_PORT | WORLD_HOST | WORLD_PORT | INSTANCE_HOST | INSTANCE_PORT | REALM_ID) + caller_runtime_env["$env_name"]="$env_value" + ;; + esac +done < <(env -0) +readonly -A caller_runtime_env + +RC_ENV_FILE="${WOW_BOT_ENV_FILE:-.env.local}" +readonly RC_ENV_FILE +RC_ENV_STATUS=0 +if [[ -f "$RC_ENV_FILE" ]]; then + set +e set -a - . ./.env.local + . "$RC_ENV_FILE" 2>/dev/null + RC_ENV_STATUS=$? set +a + set -e + set +x +fi +if ((RC_ENV_STATUS != 0)); then + echo "Failed to load WOW_BOT_ENV_FILE" >&2 + exit 2 fi +for env_name in "${!caller_runtime_env[@]}"; do + export "$env_name=${caller_runtime_env[$env_name]}" +done +unset env_name env_value +# Keep xtrace disabled while credentials and caller-owned runtime values are +# inspected. It is restored only after the complete argv has been assembled; +# the bot password is inherited through the environment, never argv. password_env_name() { local account="$1" @@ -62,6 +104,7 @@ homebind_timeout_secs="${WOW_BOT_HOMEBIND_TIMEOUT_SECS:-8}" inventory_swap_timeout_secs="${WOW_BOT_INVENTORY_SWAP_TIMEOUT_SECS:-8}" rested_xp_timeout_secs="${WOW_BOT_RESTED_XP_TIMEOUT_SECS:-120}" rested_xp_offline_secs="${WOW_BOT_RESTED_XP_OFFLINE_SECS:-86400}" +loot_race_timeout_secs="${WOW_BOT_LOOT_RACE_TIMEOUT_SECS:-30}" ensure_accounts="${WOW_BOT_ENSURE_TEST_ACCOUNTS:-1}" export BNET_HOST="${BNET_HOST:-127.0.0.1}" @@ -102,16 +145,99 @@ rested_xp_acknowledged=0 if [[ "${WOW_BOT_ACK_DISPOSABLE_RESTED_XP:-0}" =~ ^(1|true|TRUE|yes|YES|on|ON)$ ]]; then rested_xp_acknowledged=1 fi +loot_race_requested=0 +if [[ "${WOW_BOT_LOOT_RACE_SMOKE:-0}" =~ ^(1|true|TRUE|yes|YES|on|ON)$ ]]; then + loot_race_requested=1 +fi +loot_item_capture_requested=0 +if [[ "${WOW_BOT_LOOT_ITEM_CAPTURE:-0}" =~ ^(1|true|TRUE|yes|YES|on|ON)$ ]]; then + loot_item_capture_requested=1 +fi +loot_race_acknowledged=0 +if [[ "${WOW_BOT_ACK_DISPOSABLE_OVERWORLD_LOOT_RACE:-0}" =~ ^(1|true|TRUE|yes|YES|on|ON)$ ]]; then + loot_race_acknowledged=1 +fi if ((rested_xp_acknowledged && !rested_xp_requested)); then echo "WOW_BOT_ACK_DISPOSABLE_RESTED_XP is only valid with WOW_BOT_RESTED_XP_SMOKE" >&2 exit 2 fi -if ((stand_state_requested + quest_requested + bank_requested + homebind_requested + inventory_swap_requested + rested_xp_requested > 1)); then - echo "Stand-state, quest, bank, homebind, inventory-swap, and rested-XP smoke are separate modes" >&2 +if ((loot_race_acknowledged && !loot_race_requested && !loot_item_capture_requested)); then + echo "WOW_BOT_ACK_DISPOSABLE_OVERWORLD_LOOT_RACE is only valid with WOW_BOT_LOOT_RACE_SMOKE or WOW_BOT_LOOT_ITEM_CAPTURE" >&2 + exit 2 +fi +if ((stand_state_requested + quest_requested + bank_requested + homebind_requested + inventory_swap_requested + rested_xp_requested + loot_race_requested + loot_item_capture_requested > 1)); then + echo "Stand-state, quest, bank, homebind, inventory-swap, rested-XP, loot-race, and loot-item-capture are separate modes" >&2 + exit 2 +fi +if ((loot_race_requested || loot_item_capture_requested)) \ + && ((!loot_race_acknowledged)); then + echo "Loot fixture modes require WOW_BOT_ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=1" >&2 exit 2 fi -if ((rested_xp_requested)); then +if ((loot_race_requested || loot_item_capture_requested)); then + # Loot fixtures require pre-provisioned, already-owned disposable identities. + # Never let the generic local bootstrap overwrite an existing numeric account + # id or reassign a configured character before the fixture contract validates. + ensure_accounts=0 + export WOW_BOT_ENSURE_TEST_ACCOUNTS=0 + fixture_journal="${WOW_BOT_FIXTURE_JOURNAL:-}" + if [[ -z "$fixture_journal" || "$fixture_journal" != /* \ + || "$fixture_journal" == *$'\n'* ]]; then + echo "Loot fixture modes require an absolute WOW_BOT_FIXTURE_JOURNAL path" >&2 + exit 2 + fi + if [[ ! -d "$(dirname -- "$fixture_journal")" ]]; then + echo "WOW_BOT_FIXTURE_JOURNAL parent directory does not exist" >&2 + exit 2 + fi + fixture_cleanup_marker="${fixture_journal}.cleanup-complete" + if [[ -e "$fixture_journal" || -L "$fixture_journal" \ + || -e "$fixture_cleanup_marker" || -L "$fixture_cleanup_marker" ]]; then + echo "Fixture journal/cleanup marker already exists; recover or remove it explicitly before running" >&2 + exit 2 + fi + export WOW_BOT_FIXTURE_JOURNAL="$fixture_journal" + export WOW_BOT_REQUIRE_PARENT_DEATH_GUARD=1 +fi + +if ((loot_item_capture_requested)); then + if ((!loot_race_acknowledged)); then + echo "Loot-item capture kills an exact overworld fixture and requires WOW_BOT_ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=1" >&2 + exit 2 + fi + report_path="${WOW_BOT_REPORT:-/tmp/rustycore-bot-loot-item-capture-report.json}" + log_path="${WOW_BOT_LOG:-/tmp/rustycore-bot-loot-item-capture.log}" + mode_args=( + --loot-item-capture + --ack-disposable-overworld-loot-race + --loot-race-account-a "TESTBOT2@bot.local" + --loot-race-account-b "TESTBOT3@bot.local" + --loot-race-creature-entry "21779" + --loot-race-creature-spawn-guid "1117" + --loot-race-runtime-counter "0" + --loot-race-item-entry "30712" + --loot-race-timeout "$loot_race_timeout_secs" + ) +elif ((loot_race_requested)); then + if ((!loot_race_acknowledged)); then + echo "Loot-race smoke mutates an exact shared-chest fixture and requires WOW_BOT_ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=1" >&2 + exit 2 + fi + report_path="${WOW_BOT_REPORT:-/tmp/rustycore-bot-loot-race-smoke-report.json}" + log_path="${WOW_BOT_LOG:-/tmp/rustycore-bot-loot-race-smoke.log}" + mode_args=( + --loot-race-smoke + --ack-disposable-overworld-loot-race + --loot-race-account-a "TESTBOT2@bot.local" + --loot-race-account-b "TESTBOT3@bot.local" + --loot-race-gameobject-entry "2846" + --loot-race-gameobject-spawn-guid "9106001" + --loot-race-runtime-counter "0" + --loot-race-item-entry "38" + --loot-race-timeout "$loot_race_timeout_secs" + ) +elif ((rested_xp_requested)); then if ((!rested_xp_acknowledged)); then echo "Rested-XP smoke is destructive and requires WOW_BOT_ACK_DISPOSABLE_RESTED_XP=1" >&2 exit 2 @@ -255,20 +381,45 @@ if [[ "$ensure_accounts" =~ ^(1|true|TRUE|yes|YES|on|ON)$ ]]; then ensure_args+=(--ensure-test-accounts) fi -"$bot_exec" \ - --config "${WOW_BOT_CONFIG:-config.example.json}" \ - --single "$account" \ - "${mode_args[@]}" \ - "${ensure_args[@]}" \ - --no-cleanup-groups \ - --no-auto-teleport \ - --report "$report_path" \ - >"$log_path" 2>&1 +selection_args=(--single "$account") +smoke_subject="$account" +if ((loot_race_requested || loot_item_capture_requested)); then + selection_args=() + if ((loot_item_capture_requested)); then + smoke_subject="TESTBOT2@bot.local (single-session item capture)" + else + smoke_subject="TESTBOT2@bot.local and TESTBOT3@bot.local" + fi +fi + +bot_command=( + "$bot_exec" + --config "${WOW_BOT_CONFIG:-config.example.json}" + "${selection_args[@]}" + "${mode_args[@]}" + "${ensure_args[@]}" + --no-cleanup-groups + --no-auto-teleport + --report "$report_path" +) + +if ((RC_CALLER_XTRACE)); then + set -x +fi + +if ((loot_race_requested || loot_item_capture_requested)); then + # Preserve PID ownership across the shell boundary. TERM/INT reaches the + # Rust handler installed before fixture mutation, and SIGKILL cannot leave a + # detached bot child continuing to mutate the durable fixture. + exec "${bot_command[@]}" >"$log_path" 2>&1 +fi + +"${bot_command[@]}" >"$log_path" 2>&1 -echo "RustyCore smoke passed for $account" +echo "RustyCore smoke passed for $smoke_subject" echo "log: $log_path" echo "report: $report_path" if command -v jq >/dev/null 2>&1; then - jq '{login_only, quest_smoke, stand_state_smoke, bank_smoke, homebind_smoke, inventory_swap_smoke, rested_xp_smoke, results: [.results[] | {account, world_auth, enum_characters, player_login_verified, stand_state_smoke, stand_state_smoke_passed, stand_states_requested, stand_states_confirmed, stand_state_failure, bank_smoke, bank_smoke_passed, bank_banker_entry, bank_banker_spawn_guid, bank_banker_guid_counter, bank_item_guid, bank_item_entry, bank_inventory_slot, bank_bank_slot, bank_open_confirmed, bank_deposit_persisted, bank_relogin_after_deposit, bank_withdraw_persisted, bank_failure, homebind_smoke, homebind_smoke_passed, homebind_innkeeper_entry, homebind_innkeeper_spawn_guid, homebind_innkeeper_guid_counter, homebind_spell_go_seen, homebind_bind_point_update_seen, homebind_player_bound_seen, homebind_gossip_complete_seen, homebind_db_persisted, homebind_relogin_verified, homebind_failure, inventory_swap_smoke, inventory_swap_smoke_passed, inventory_swap_item_guid_a, inventory_swap_item_guid_b, inventory_swap_item_entry_a, inventory_swap_item_entry_b, inventory_swap_slot_a, inventory_swap_slot_b, inventory_swap_forward_persisted, inventory_swap_relogin_after_forward, inventory_swap_reverse_persisted, inventory_swap_failure, rested_xp_smoke, rested_xp_smoke_passed, rested_xp_offline_wilderness_bonus, rested_xp_offline_resting_bonus, rested_xp_target_entry, rested_xp_target_spawn_guid, rested_xp_target_guid_counter, rested_xp_packet_amount, rested_xp_packet_original, rested_xp_db_xp_before, rested_xp_db_xp_after, rested_xp_db_rest_before, rested_xp_db_rest_after, rested_xp_relog_verified, rested_xp_failure, quest_smoke_passed, quest_target_entry, quest_target_spawn_guid, quest_target_guid_counter, quest_ids_seen, quest_titles_seen, quest_accept_sent, quest_accept_confirm_seen, quest_db_verified, quest_db_status, quest_failure, join_result}]}' "$report_path" + jq '{login_only, quest_smoke, stand_state_smoke, bank_smoke, homebind_smoke, inventory_swap_smoke, rested_xp_smoke, loot_race_smoke, results: [.results[] | {account, world_auth, enum_characters, player_login_verified, stand_state_smoke, stand_state_smoke_passed, stand_states_requested, stand_states_confirmed, stand_state_failure, bank_smoke, bank_smoke_passed, bank_banker_entry, bank_banker_spawn_guid, bank_banker_guid_counter, bank_item_guid, bank_item_entry, bank_inventory_slot, bank_bank_slot, bank_open_confirmed, bank_deposit_persisted, bank_relogin_after_deposit, bank_withdraw_persisted, bank_failure, homebind_smoke, homebind_smoke_passed, homebind_innkeeper_entry, homebind_innkeeper_spawn_guid, homebind_innkeeper_guid_counter, homebind_spell_go_seen, homebind_bind_point_update_seen, homebind_player_bound_seen, homebind_gossip_complete_seen, homebind_db_persisted, homebind_relogin_verified, homebind_failure, inventory_swap_smoke, inventory_swap_smoke_passed, inventory_swap_item_guid_a, inventory_swap_item_guid_b, inventory_swap_item_entry_a, inventory_swap_item_entry_b, inventory_swap_slot_a, inventory_swap_slot_b, inventory_swap_forward_persisted, inventory_swap_relogin_after_forward, inventory_swap_reverse_persisted, inventory_swap_failure, rested_xp_smoke, rested_xp_smoke_passed, rested_xp_offline_wilderness_bonus, rested_xp_offline_resting_bonus, rested_xp_target_entry, rested_xp_target_spawn_guid, rested_xp_target_guid_counter, rested_xp_packet_amount, rested_xp_packet_original, rested_xp_db_xp_before, rested_xp_db_xp_after, rested_xp_db_rest_before, rested_xp_db_rest_after, rested_xp_relog_verified, rested_xp_failure, loot_race_smoke, loot_race_smoke_passed, loot_race_target_entry, loot_race_target_spawn_guid, loot_race_target_runtime_counter, loot_race_party_confirmed, loot_race_target_discovered, loot_race_loot_opened, loot_race_loot_list_id, loot_race_loot_coins, loot_race_item_push_seen, loot_race_loot_removed_seen, loot_race_money_notify_amount, loot_race_coin_removed_seen, loot_race_db_item_total, loot_race_db_money_delta, loot_race_relog_verified, loot_race_failure, quest_smoke_passed, quest_target_entry, quest_target_spawn_guid, quest_target_guid_counter, quest_ids_seen, quest_titles_seen, quest_accept_sent, quest_accept_confirm_seen, quest_db_verified, quest_db_status, quest_failure, join_result}]}' "$report_path" fi diff --git a/tools/wow-test-bot/src/bot_srp6.rs b/tools/wow-test-bot/src/bot_srp6.rs index 78c8a0b43..8fd3fcb72 100644 --- a/tools/wow-test-bot/src/bot_srp6.rs +++ b/tools/wow-test-bot/src/bot_srp6.rs @@ -17,6 +17,10 @@ use rand::{thread_rng, RngCore}; use reqwest::Client; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; +use std::time::Duration; + +const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const HTTP_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); const BOT_SRP_N_HEX: &str = "894B645E89E1535BBDAD5B8B290650530801B18EBFBF5E8FAB3C82872A3E9BB7"; const BNET_SRP_V1_N_HEX: &str = concat!( @@ -58,19 +62,31 @@ pub fn bnet_v1_registration_material_like_cpp( email: &str, password: &str, ) -> (String, [u8; 32], Vec) { + let mut salt = [0u8; 32]; + thread_rng().fill_bytes(&mut salt); + let (normalized_email, verifier) = bnet_v1_verifier_for_salt_like_cpp(email, password, &salt); + (normalized_email, salt, verifier) +} + +/// Recompute the Battle.net SRP v1 verifier for an existing salt without +/// mutating account state. The loot QA preflight uses this to prove that the +/// configured credentials already belong to the exact disposable fixture. +pub fn bnet_v1_verifier_for_salt_like_cpp( + email: &str, + password: &str, + salt: &[u8], +) -> (String, Vec) { let normalized_email = utf8_to_upper_only_latin_like_cpp(email); let username = bnet_srp_username_like_cpp(&normalized_email); let password = utf8_to_upper_only_latin_like_cpp(password); - let mut salt = [0u8; 32]; - thread_rng().fill_bytes(&mut salt); let inner = sha256_concat(&[username.as_bytes(), b":", password.as_bytes()]); - let outer = sha256_concat(&[&salt, &inner]); + let outer = sha256_concat(&[salt, &inner]); let x = BigUint::from_bytes_le(&outer); let n = BigUint::parse_bytes(BNET_SRP_V1_N_HEX.as_bytes(), 16) .expect("failed to parse BNet SRP v1 modulus"); let g = BigUint::from(BOT_SRP_G); let verifier = g.modpow(&x, &n).to_bytes_le(); - (normalized_email, salt, verifier) + (normalized_email, verifier) } fn decode_server_hex(value: &str) -> Result, hex::FromHexError> { @@ -223,6 +239,8 @@ pub async fn authenticate_bot( Client::builder() .danger_accept_invalid_certs(true) .http1_only() + .connect_timeout(HTTP_CONNECT_TIMEOUT) + .timeout(HTTP_REQUEST_TIMEOUT) .redirect(reqwest::redirect::Policy::none()) .build() }; @@ -396,6 +414,8 @@ async fn authenticate_direct_login_fallback( let client = Client::builder() .danger_accept_invalid_certs(true) .http1_only() + .connect_timeout(HTTP_CONNECT_TIMEOUT) + .timeout(HTTP_REQUEST_TIMEOUT) .pool_max_idle_per_host(0) .redirect(reqwest::redirect::Policy::none()) .build()?; @@ -437,6 +457,20 @@ async fn authenticate_direct_login_fallback( mod tests { use super::*; + #[test] + fn stored_salt_verifier_preflight_is_deterministic() { + let (email, salt, verifier) = + bnet_v1_registration_material_like_cpp("testbot@bot.local", "fixture-password"); + let (recomputed_email, recomputed) = + bnet_v1_verifier_for_salt_like_cpp("testbot@bot.local", "fixture-password", &salt); + assert_eq!(email, recomputed_email); + assert_eq!(verifier, recomputed); + assert_ne!( + verifier, + bnet_v1_verifier_for_salt_like_cpp("testbot@bot.local", "different-password", &salt).1 + ); + } + #[test] fn test_bot_srp6_calculations() { let mut client = BotSrp6Client::new("test@example.com", "password123"); diff --git a/tools/wow-test-bot/src/loot_race.rs b/tools/wow-test-bot/src/loot_race.rs new file mode 100644 index 000000000..2b7031312 --- /dev/null +++ b/tools/wow-test-bot/src/loot_race.rs @@ -0,0 +1,6577 @@ +//! Two-client live smoke for atomic shared world-loot claims. +//! +//! This is deliberately a destructive QA-only workflow. The two-session race +//! uses a wrapper-installed, shared Tattered Chest GameObject, while the strict +//! one-session capture continues to use stationary Doctor Maleficus. Callers +//! must acknowledge that consuming either live fixture requires a world +//! restart before it can be reused. + +use super::*; +use mysql::params; +use mysql::prelude::Queryable; +use serde::{Deserialize, Serialize}; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::os::unix::fs::OpenOptionsExt; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex as StdMutex}; +use tokio::sync::{Barrier, Mutex}; +use tokio_util::sync::CancellationToken; + +pub(super) const DEFAULT_ACCOUNT_A: &str = "TESTBOT2@bot.local"; +pub(super) const DEFAULT_ACCOUNT_B: &str = "TESTBOT3@bot.local"; +// Race and strict capture intentionally use different fixture kinds. The +// wrapper installs exactly one non-pooled Tattered Chest spawn before the +// world starts; Doctor Maleficus remains the creature-only capture fixture. +// The public constant names remain stable for existing callers; main.rs now +// exposes GameObject spellings and retains `--loot-race-creature-*` aliases. +pub(super) const DEFAULT_CREATURE_ENTRY: u32 = 2_846; +pub(super) const DEFAULT_CREATURE_SPAWN_GUID: u64 = 9_106_001; +pub(super) const DEFAULT_RUNTIME_COUNTER: u64 = 0; +pub(super) const DEFAULT_ITEM_ENTRY: u32 = 38; +pub(super) const DEFAULT_CAPTURE_CREATURE_ENTRY: u32 = 21_779; +pub(super) const DEFAULT_CAPTURE_CREATURE_SPAWN_GUID: u64 = 1_117; +pub(super) const DEFAULT_CAPTURE_RUNTIME_COUNTER: u64 = 0; +pub(super) const DEFAULT_CAPTURE_ITEM_ENTRY: u32 = 30_712; +pub(super) const DEFAULT_TIMEOUT_SECS: u64 = 30; +pub(super) const DEFAULT_WORKFLOW_DEADLINE_SECS: u64 = 900; +pub(super) const ACK_FLAG: &str = "--ack-disposable-overworld-loot-race"; +const GUARDED_FIXTURE_HEALTH_MODIFIER: f32 = 0.0001; +const RACE_GAMEOBJECT_MAP_ID: u16 = 0; +const RACE_GAMEOBJECT_X: f64 = -8_946.95; +const RACE_GAMEOBJECT_Y: f64 = -132.493; +const RACE_GAMEOBJECT_Z: f64 = 83.5312; +const RACE_GAMEOBJECT_LOOT_ID: u32 = 2_278; +const RACE_GAMEOBJECT_MONEY: u32 = 10; +const RACE_GAMEOBJECT_ADDON_FACTION: u16 = 101; +const RACE_GAMEOBJECT_RESPAWN_SECS: u32 = 300; +const RACE_GAMEOBJECT_STATE: u8 = 1; +const RACE_GAMEOBJECT_ANIM_PROGRESS: u8 = 255; +const RACE_GAMEOBJECT_TEMPLATE_DATA: [i32; 35] = [ + 57, + RACE_GAMEOBJECT_LOOT_ID as i32, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 1, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, +]; +const PERSONAL_LOOT_METHOD_LIKE_CPP: u8 = 5; +const HIGH_GUID_CREATURE: u64 = 8; +const HIGH_GUID_GAMEOBJECT: u64 = 11; + +const CMSG_PARTY_INVITE: u16 = 0x3604; +const CMSG_PARTY_INVITE_RESPONSE: u16 = 0x3606; +const CMSG_LEAVE_GROUP: u16 = 0x364C; +const CMSG_GAME_OBJ_USE: u16 = 0x34EE; +const CMSG_LOOT_UNIT: u16 = 0x320F; +const CMSG_LOOT_ITEM: u16 = 0x3211; +const CMSG_LOOT_MONEY: u16 = 0x3210; +const SMSG_PARTY_INVITE: u16 = 0x25BD; +const SMSG_PARTY_UPDATE: u16 = 0x25F4; +const SMSG_PARTY_COMMAND_RESULT: u16 = 0x2796; +const SMSG_PARTY_MEMBER_FULL_STATE: u16 = 0x2759; +const SMSG_LOOT_RESPONSE: u16 = 0x2614; +const SMSG_LOOT_REMOVED: u16 = 0x2615; +const SMSG_LOOT_MONEY_NOTIFY: u16 = 0x261C; +const SMSG_COIN_REMOVED: u16 = 0x2617; +const SMSG_ITEM_PUSH_RESULT: u16 = 0x2623; +const RESPONSE_SETTLE: Duration = Duration::from_secs(2); +const LOOT_ITEM_CAPTURE_FENCE_SERIAL: u32 = 0x4C4F_4F54; +// The Doctor's Key (30712) is a key and C++ stores this exact fixture in the +// first keyring destination represented by the committed capture. +const LOOT_ITEM_CAPTURE_KEYRING_SLOT: u8 = 106; +// Normal C++ logout consumes 20 seconds before SMSG_LOGOUT_COMPLETE. Leave a +// bounded disconnect-save margin before any destructive fixture restoration. +const LOOT_FIXTURE_OFFLINE_WAIT_SECS: u64 = 90; +const LOOT_LOGOUT_DB_CONFIRM_WAIT_SECS: u64 = 5; +const LOOT_DB_OPERATION_TIMEOUT_SECS: u64 = 30; +const LOOT_CLEANUP_TIMEOUT_SECS: u64 = 180; +const FIXTURE_JOURNAL_VERSION: u32 = 1; +const FIXTURE_JOURNAL_ENV: &str = "WOW_BOT_FIXTURE_JOURNAL"; +const HIGH_GUID_ITEM: u64 = 3; +const HIGH_GUID_LOOT_OBJECT: u64 = 15; +const GUID_HIGH_TYPE_MASK: u64 = 0x3F; +const GUID_REALM_SPECIFIC_MASK: u64 = 0xFFFF; +const GUID_REALM_MASK: u64 = 0x1FFF; +const GUID_MAP_MASK: u64 = 0x1FFF; +const GUID_ENTRY_MASK: u64 = 0x7F_FFFF; +const GUID_SUBTYPE_MASK: u64 = 0x3F; +const GUID_SERVER_MASK: u64 = 0xFF_FFFF; +const GUID_COUNTER_MASK: u64 = 0xFF_FFFF_FFFF; +// C++ Player.cpp `MAX_MONEY_AMOUNT` and Rust's represented player cap. +const MAX_PLAYER_MONEY_LIKE_CPP: u64 = 99_999_999_999; +const CHARACTER_PROGRESS_TABLES: &[&str] = &[ + "character_achievement_progress", + "character_achievement", + "character_queststatus_objectives_criteria_progress", + "character_queststatus_objectives_criteria", + "character_queststatus_objectives", + "character_queststatus_daily", + "character_queststatus_monthly", + "character_queststatus_rewarded", + "character_queststatus_seasonal", + "character_queststatus_weekly", + "character_queststatus", + "character_reputation", +]; + +#[derive(Debug, Clone)] +pub(super) struct LootRaceCli { + pub account_a: String, + pub account_b: String, + pub entry: u32, + pub spawn_guid: u64, + pub runtime_counter: u64, + pub item_entry: u32, + pub timeout_secs: u64, + pub workflow_deadline_secs: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum LootRacePhase { + Race, + CaptureItem, + VerifyRelog, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +enum LootRaceTargetKind { + Creature, + GameObject, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LootFixturePurpose { + Race, + CaptureItem, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LogoutCompletionRoute { + Realm, + Instance, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PartyPacketRoute { + Realm, + Instance, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(super) struct LootRaceTarget { + kind: LootRaceTargetKind, + pub entry: u32, + pub spawn_guid: u64, + /// Optional operator override. Zero means discover the complete live GUID + /// from SMSG_UPDATE_OBJECT after the exact SQL spawn has passed preflight. + pub runtime_counter_override: u64, + pub map_id: u16, + pub x: f64, + pub y: f64, + pub z: f64, + pub item_entry: u32, +} + +#[derive(Debug, Clone)] +pub(super) struct LootRaceOptions { + pub phase: LootRacePhase, + pub participant: usize, + pub character_guid: u64, + pub peer_name: String, + pub peer_character_guid: u64, + pub killer_character_guid: u64, + pub target: LootRaceTarget, + pub timeout_secs: u64, + sync: Arc, +} + +#[derive(Debug, Clone)] +struct CharacterFixture { + bot: config::BotConfig, + name: String, + race: u8, + money: u64, + core: CharacterCoreSnapshot, + position: CharacterPositionSnapshot, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +struct CharacterCoreSnapshot { + level: u8, + xp: u32, + health: u32, + powers: [u32; 10], + rest_state: u8, + rest_bonus: f32, + explored_zones: Option, + known_titles: Option, + chosen_title: u32, +} + +/// Persistent rows that a creature kill, item acquisition, or the resulting +/// C++ criteria fanout can mutate. These are snapshotted for both dedicated +/// disposable characters and restored transactionally after every run. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct CharacterProgressSnapshot { + achievements: Vec<(u64, u32, i64)>, + achievement_progress: Vec<(u64, u32, u64, i64)>, + quest_status: Vec<(u64, u32, u8, u8, i64, i64)>, + quest_daily: Vec<(u64, u32, i64)>, + quest_monthly: Vec<(u64, u32)>, + quest_objectives: Vec<(u64, u32, u8, i32)>, + quest_objective_criteria: Vec<(u64, u32)>, + quest_objective_criteria_progress: Vec<(u64, u32, u64, i64)>, + quest_rewarded: Vec<(u64, u32, u8)>, + quest_seasonal: Vec<(u64, u32, u32, i64)>, + quest_weekly: Vec<(u64, u32)>, + reputation: Vec<(u64, u16, i32, u16)>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct RespawnSnapshot { + respawn_time: i64, + map_id: u16, + instance_id: u32, +} + +#[derive(Debug, Clone)] +struct LootRaceFixture { + characters: [CharacterFixture; 2], + target: LootRaceTarget, + respawn: Option, + respawn_type: u16, + gameobject_state: Option, + progress: CharacterProgressSnapshot, + journal: FixtureJournal, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct JournalCharacterFixture { + account: String, + account_id: u32, + character_guid: u64, + name: String, + race: u8, + money: u64, + core: CharacterCoreSnapshot, + position: CharacterPositionSnapshot, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct FixtureJournalRecord { + version: u32, + created_by_pid: u32, + characters: [JournalCharacterFixture; 2], + target: LootRaceTarget, + respawn: Option, + respawn_type: u16, + gameobject_state: Option, + progress: CharacterProgressSnapshot, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CleanupMarkerRecord { + version: u32, + journal_sha256: String, + cleanup_pid: u32, +} + +#[derive(Debug, Clone)] +struct FixtureJournal { + path: PathBuf, +} + +fn configured_fixture_journal_path() -> Result { + let raw = std::env::var(FIXTURE_JOURNAL_ENV).map_err(|_| { + anyhow!("loot workflows require {FIXTURE_JOURNAL_ENV}=") + })?; + let path = PathBuf::from(raw); + if !path.is_absolute() || path.file_name().is_none() { + bail!("{FIXTURE_JOURNAL_ENV} must name an absolute file path"); + } + let parent = path + .parent() + .ok_or_else(|| anyhow!("fixture journal has no parent directory"))?; + let metadata = fs::symlink_metadata(parent) + .with_context(|| format!("Inspect fixture-journal directory {}", parent.display()))?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + bail!( + "fixture-journal parent {} must be a real directory", + parent.display() + ); + } + Ok(path) +} + +fn cleanup_marker_path(path: &Path) -> PathBuf { + PathBuf::from(format!("{}.cleanup-complete", path.display())) +} + +pub(super) fn validate_journal_contract() -> Result<()> { + let path = configured_fixture_journal_path()?; + let marker = cleanup_marker_path(&path); + if fs::symlink_metadata(&path).is_ok() { + bail!( + "pending fixture journal {} exists; run --recover-loot-fixture before another QA run", + path.display() + ); + } + if fs::symlink_metadata(&marker).is_ok() { + bail!( + "stale cleanup marker {} exists; each QA run must use a fresh journal path", + marker.display() + ); + } + Ok(()) +} + +impl FixtureJournalRecord { + fn from_fixture(fixture: &LootRaceFixture) -> Self { + Self { + version: FIXTURE_JOURNAL_VERSION, + created_by_pid: std::process::id(), + characters: fixture + .characters + .clone() + .map(|character| JournalCharacterFixture { + account: character.bot.account, + account_id: character.bot.account_id, + character_guid: character.bot.character_guid, + name: character.name, + race: character.race, + money: character.money, + core: character.core, + position: character.position, + }), + target: fixture.target.clone(), + respawn: fixture.respawn.clone(), + respawn_type: fixture.respawn_type, + gameobject_state: fixture.gameobject_state, + progress: fixture.progress.clone(), + } + } + + fn into_fixture(self, journal: FixtureJournal) -> Result { + if self.version != FIXTURE_JOURNAL_VERSION { + bail!( + "unsupported fixture-journal version {}; expected {}", + self.version, + FIXTURE_JOURNAL_VERSION + ); + } + Ok(LootRaceFixture { + characters: self.characters.map(|character| CharacterFixture { + bot: config::BotConfig { + account: character.account, + password: String::new(), + character_guid: character.character_guid, + account_id: character.account_id, + lfg_role: 0, + class: String::new(), + enabled: false, + session_key_bnet: String::new(), + }, + name: character.name, + race: character.race, + money: character.money, + core: character.core, + position: character.position, + }), + target: self.target, + respawn: self.respawn, + respawn_type: self.respawn_type, + gameobject_state: self.gameobject_state, + progress: self.progress, + journal, + }) + } +} + +impl FixtureJournal { + fn configured() -> Result { + Ok(Self { + path: configured_fixture_journal_path()?, + }) + } + + fn persist(&self, fixture: &LootRaceFixture) -> Result<()> { + let record = FixtureJournalRecord::from_fixture(fixture); + let payload = serde_json::to_vec_pretty(&record) + .context("Serialize durable loot-fixture recovery journal")?; + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&self.path) + .with_context(|| { + format!( + "Create durable loot-fixture journal {}", + self.path.display() + ) + })?; + file.write_all(&payload) + .context("Write durable loot-fixture recovery journal")?; + file.write_all(b"\n") + .context("Terminate durable loot-fixture recovery journal")?; + file.sync_all() + .context("fsync durable loot-fixture recovery journal")?; + sync_parent_directory(&self.path)?; + Ok(()) + } + + fn load(path: PathBuf) -> Result { + let metadata = fs::symlink_metadata(&path) + .with_context(|| format!("Inspect pending fixture journal {}", path.display()))?; + if !metadata.is_file() || metadata.file_type().is_symlink() { + bail!("pending fixture journal must be a regular non-symlink file"); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if metadata.mode() & 0o777 != 0o600 { + bail!("pending fixture journal permissions must be exactly 0600"); + } + } + let bytes = fs::read(&path) + .with_context(|| format!("Read pending fixture journal {}", path.display()))?; + let record: FixtureJournalRecord = + serde_json::from_slice(&bytes).context("Parse pending fixture journal")?; + record.into_fixture(Self { path }) + } + + fn complete(&self) -> Result<()> { + use sha2::{Digest, Sha256}; + + let journal_bytes = fs::read(&self.path) + .with_context(|| format!("Read fixture journal {}", self.path.display()))?; + let digest = hex::encode(Sha256::digest(&journal_bytes)); + let marker = cleanup_marker_path(&self.path); + if fs::symlink_metadata(&marker).is_ok() { + validate_cleanup_marker(&marker, Some(&digest))?; + } else { + let temp_marker = + PathBuf::from(format!("{}.tmp.{}", marker.display(), std::process::id())); + let marker_payload = CleanupMarkerRecord { + version: FIXTURE_JOURNAL_VERSION, + journal_sha256: digest.clone(), + cleanup_pid: std::process::id(), + }; + let write_result = (|| -> Result<()> { + let mut marker_file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&temp_marker) + .with_context(|| { + format!("Create temporary cleanup marker {}", temp_marker.display()) + })?; + serde_json::to_writer_pretty(&mut marker_file, &marker_payload) + .context("Write fixture cleanup marker")?; + marker_file + .write_all(b"\n") + .context("Terminate fixture cleanup marker")?; + marker_file + .sync_all() + .context("fsync fixture cleanup marker")?; + fs::hard_link(&temp_marker, &marker).with_context(|| { + format!( + "Atomically publish cleanup marker without replacement {}", + marker.display() + ) + })?; + fs::remove_file(&temp_marker).with_context(|| { + format!("Remove temporary cleanup marker {}", temp_marker.display()) + })?; + sync_parent_directory(&marker) + })(); + if write_result.is_err() { + let _ = fs::remove_file(&temp_marker); + } + write_result?; + validate_cleanup_marker(&marker, Some(&digest))?; + } + fs::remove_file(&self.path) + .with_context(|| format!("Remove completed journal {}", self.path.display()))?; + sync_parent_directory(&marker)?; + Ok(()) + } +} + +fn validate_cleanup_marker(path: &Path, expected_digest: Option<&str>) -> Result<()> { + let metadata = fs::symlink_metadata(path) + .with_context(|| format!("Inspect cleanup marker {}", path.display()))?; + if !metadata.is_file() || metadata.file_type().is_symlink() { + bail!("cleanup marker must be a regular non-symlink file"); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if metadata.mode() & 0o777 != 0o600 { + bail!("cleanup marker permissions must be exactly 0600"); + } + } + let marker: CleanupMarkerRecord = serde_json::from_slice( + &fs::read(path).with_context(|| format!("Read cleanup marker {}", path.display()))?, + ) + .context("Parse cleanup marker")?; + if marker.version != FIXTURE_JOURNAL_VERSION + || marker.journal_sha256.len() != 64 + || !marker + .journal_sha256 + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + || expected_digest.is_some_and(|expected| marker.journal_sha256 != expected) + { + bail!("cleanup marker does not match the durable journal contract"); + } + Ok(()) +} + +fn sync_parent_directory(path: &Path) -> Result<()> { + let parent = path + .parent() + .ok_or_else(|| anyhow!("path {} has no parent", path.display()))?; + let directory = fs::File::open(parent) + .with_context(|| format!("Open directory {} for fsync", parent.display()))?; + directory + .sync_all() + .with_context(|| format!("fsync directory {}", parent.display())) +} + +pub(super) async fn recover_pending_fixture() -> Result<()> { + if !recover_pending_fixture_if_present().await? { + let path = configured_fixture_journal_path()?; + bail!("no pending fixture journal exists at {}", path.display()); + } + Ok(()) +} + +pub(super) async fn recover_pending_fixture_if_present() -> Result { + let path = configured_fixture_journal_path()?; + if fs::symlink_metadata(&path).is_err() { + let marker = cleanup_marker_path(&path); + if fs::symlink_metadata(&marker).is_ok() { + validate_cleanup_marker(&marker, None)?; + return Ok(true); + } + return Ok(false); + } + tokio::time::timeout( + Duration::from_secs(LOOT_CLEANUP_TIMEOUT_SECS), + tokio::task::spawn_blocking(move || { + let fixture = FixtureJournal::load(path)?; + cleanup_fixture(&fixture) + }), + ) + .await + .map_err(|_| anyhow!("fixture recovery exceeded {LOOT_CLEANUP_TIMEOUT_SECS}s"))? + .map_err(|error| anyhow!("fixture recovery DB worker join failed: {error}"))??; + Ok(true) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct LootWindow { + owner_low: u64, + owner_high: u64, + loot_low: u64, + loot_high: u64, + coins: u32, + item_entry: u32, + quantity: u32, + loot_list_id: u8, + loot_method: u8, +} + +#[derive(Debug, Clone, Default)] +struct WireEvidence { + item_pushes: Vec, + loot_removed: Vec, + money_notifies: Vec, + coin_removed: Vec<(u64, u64)>, + inventory_failures: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct LootRemovedEvidence { + owner_low: u64, + owner_high: u64, + loot_low: u64, + loot_high: u64, + loot_list_id: u8, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +struct ItemPush { + player_low: u64, + player_high: u64, + slot: u8, + slot_in_bag: i32, + quest_log_item_id: i32, + quantity: i32, + quantity_in_inventory: i32, + dungeon_encounter_id: i32, + item_guid_low: u64, + item_guid_high: u64, + pushed: bool, + created: bool, + display_text: u8, + is_bonus_roll: bool, + is_encounter_loot: bool, + item_entry: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct WireItemGrant { + participant: usize, + push: ItemPush, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ExpectedPersistedItemGrant { + owner_guid: u64, + push: ItemPush, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ExpectedPersistedMoneyGrant { + owner_guid: u64, + amount: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct PersistedItemGrantRow { + item_guid: u64, + owner_guid: u64, + item_entry: u32, + count: u32, + inventory_owner: Option, + bag_guid: Option, + slot: Option, + bag_slot: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct InventoryFailure { + result: i32, + item_0_low: u64, + item_0_high: u64, + item_1_low: u64, + item_1_high: u64, + container_b_slot: u8, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct MoneyNotify { + money: u64, + money_mod: u64, + sole_looter: bool, +} + +#[derive(Debug)] +struct LootRaceSync { + logged_in: Barrier, + party_ready: Barrier, + positioned: Barrier, + use_ready: Barrier, + response_received: Barrier, + windows_ready: Barrier, + item_claim: Barrier, + item_observed: Barrier, + money_claim: Barrier, + money_observed: Barrier, + before_leave: Barrier, + windows: Mutex<[Option; 2]>, + evidence: Mutex<[WireEvidence; 2]>, + cancel_reason: StdMutex>, + cancellation: CancellationToken, + /// Complete live ObjectGuid observed on the wire. C++ includes realm bits + /// in the high half, so reconstructing it from the SQL spawn/counter is + /// not equivalent to preserving this value. + runtime_guid: StdMutex>, +} + +impl LootRaceSync { + fn new() -> Self { + Self { + logged_in: Barrier::new(2), + party_ready: Barrier::new(2), + positioned: Barrier::new(2), + use_ready: Barrier::new(2), + response_received: Barrier::new(2), + windows_ready: Barrier::new(2), + item_claim: Barrier::new(2), + item_observed: Barrier::new(2), + money_claim: Barrier::new(2), + money_observed: Barrier::new(2), + before_leave: Barrier::new(2), + windows: Mutex::new([None, None]), + evidence: Mutex::new([WireEvidence::default(), WireEvidence::default()]), + cancel_reason: StdMutex::new(None), + cancellation: CancellationToken::new(), + runtime_guid: StdMutex::new(None), + } + } + + fn cancel(&self, reason: impl Into) { + let reason = reason.into(); + if let Ok(mut stored) = self.cancel_reason.lock() { + if stored.is_none() { + *stored = Some(reason); + } + } + self.cancellation.cancel(); + } + + fn cancellation_error(&self) -> Result<()> { + if !self.cancellation.is_cancelled() { + return Ok(()); + } + let reason = self + .cancel_reason + .lock() + .ok() + .and_then(|reason| reason.clone()) + .unwrap_or_else(|| "peer failed without a recorded reason".to_string()); + bail!("loot-race cancelled because {reason}") + } + + async fn cancelled(&self) { + self.cancellation.cancelled().await; + } +} + +impl LootRaceOptions { + fn resolved_runtime_guid(&self) -> Result<(u64, u64)> { + self.sync + .runtime_guid + .lock() + .map_err(|_| anyhow!("loot-race runtime GUID state was poisoned"))? + .as_ref() + .copied() + .ok_or_else(|| { + anyhow!( + "loot-race target entry {} spawn {} has no discovered live ObjectGuid", + self.target.entry, + self.target.spawn_guid + ) + }) + } + + pub(super) fn resolved_runtime_counter(&self) -> Result { + Ok(self.resolved_runtime_guid()?.0 & OBJECT_GUID_COUNTER_MASK) + } + + fn resolved_packed_guid(&self) -> Result> { + let (low, high) = self.resolved_runtime_guid()?; + Ok(build_packed_guid(low, high)) + } +} + +pub(super) fn validate_cli( + race_enabled: bool, + capture_enabled: bool, + acknowledged: bool, + bots: &[config::BotConfig], + cli: &LootRaceCli, +) -> Result<()> { + if race_enabled && capture_enabled { + bail!("--loot-race-smoke and --loot-item-capture are separate workflows"); + } + let enabled = race_enabled || capture_enabled; + if !enabled { + if acknowledged { + bail!( + "{} is only valid with --loot-race-smoke or --loot-item-capture", + ACK_FLAG + ); + } + return Ok(()); + } + if !acknowledged { + bail!( + "the selected loot workflow requires {}; this acknowledges consuming a disposable world-loot fixture whose live runtime cannot be restored without a world restart", + ACK_FLAG + ); + } + if cli.account_a.eq_ignore_ascii_case(&cli.account_b) { + bail!("loot-race account A and B must be different"); + } + if bots.len() != 2 { + bail!( + "the guarded loot fixture requires exactly the two selected configured bots; capture mode logs in only account A and keeps account B offline" + ); + } + for expected in [&cli.account_a, &cli.account_b] { + if !bots + .iter() + .any(|bot| bot.account.eq_ignore_ascii_case(expected)) + { + bail!("loot-race account `{expected}` is not an enabled configured bot"); + } + } + if cli.entry == 0 || cli.spawn_guid == 0 || cli.item_entry == 0 { + bail!("loot-race target entry/spawn/item entry must all be nonzero"); + } + if race_enabled + && (cli.entry, cli.spawn_guid, cli.item_entry) + != ( + DEFAULT_CREATURE_ENTRY, + DEFAULT_CREATURE_SPAWN_GUID, + DEFAULT_ITEM_ENTRY, + ) + { + bail!( + "the two-client race is pinned to wrapper-owned Tattered Chest entry/spawn/item {}/{}/{}; custom race fixtures are not cancel-safe", + DEFAULT_CREATURE_ENTRY, + DEFAULT_CREATURE_SPAWN_GUID, + DEFAULT_ITEM_ENTRY + ); + } + if cli.runtime_counter & !OBJECT_GUID_COUNTER_MASK != 0 { + bail!("loot-race runtime counter exceeds the 40-bit ObjectGuid counter field"); + } + if cli.timeout_secs == 0 { + bail!("--loot-race-timeout must be greater than zero"); + } + if cli.workflow_deadline_secs == 0 || cli.workflow_deadline_secs <= cli.timeout_secs { + bail!("--loot-workflow-deadline must be greater than the per-phase loot timeout"); + } + Ok(()) +} + +pub(super) async fn run_workflow( + mut bots: Vec, + cli: LootRaceCli, + dungeon_id: u32, + lfg_secs: u64, + auto_teleport: bool, + shutdown: CancellationToken, +) -> Result> { + let workflow_deadline = + tokio::time::Instant::now() + Duration::from_secs(cli.workflow_deadline_secs); + if shutdown.is_cancelled() { + bail!("loot-race cancelled before fixture setup"); + } + bots.sort_by_key(|bot| { + if bot.account.eq_ignore_ascii_case(&cli.account_a) { + 0 + } else { + 1 + } + }); + let setup_bots = bots.clone(); + let setup_cli = cli.clone(); + let setup_shutdown = shutdown.clone(); + let fixture = tokio::task::spawn_blocking(move || { + prepare_fixture( + &setup_bots, + &setup_cli, + LootFixturePurpose::Race, + &setup_shutdown, + ) + }) + .await + .map_err(|error| anyhow!("loot-race fixture DB worker join failed: {error}"))??; + + let sync = Arc::new(LootRaceSync::new()); + let mut handles = tokio::task::JoinSet::new(); + for participant in 0..2 { + let bot = bots[participant].clone(); + let options = LootRaceOptions { + phase: LootRacePhase::Race, + participant, + character_guid: fixture.characters[participant].bot.character_guid, + peer_name: fixture.characters[1 - participant].name.clone(), + peer_character_guid: fixture.characters[1 - participant].bot.character_guid, + killer_character_guid: fixture.characters[0].bot.character_guid, + target: fixture.target.clone(), + timeout_secs: cli.timeout_secs, + sync: Arc::clone(&sync), + }; + let task_sync = Arc::clone(&sync); + handles.spawn(async move { + let run = run_bot( + bot, + dungeon_id, + lfg_secs, + auto_teleport, + false, + None, + None, + None, + None, + None, + Some(options), + None, + ) + .await; + match &run { + Err(error) => task_sync.cancel(format!( + "participant {participant} transport/login failed: {error:#}" + )), + Ok(result) if result.loot_race_smoke_passed == Some(false) => { + task_sync.cancel(format!( + "participant {participant} failed: {}", + result + .loot_race_failure + .as_deref() + .unwrap_or("unknown loot-race failure") + )); + } + _ => {} + } + run + }); + } + + let mut results = Vec::with_capacity(2); + let mut task_error = None; + loop { + let joined = tokio::select! { + joined = handles.join_next() => joined, + _ = shutdown.cancelled() => { + let message = "loot-race received SIGINT/SIGTERM".to_string(); + sync.cancel(message.clone()); + handles.abort_all(); + while handles.join_next().await.is_some() {} + task_error = Some(message); + break; + } + _ = tokio::time::sleep_until(workflow_deadline) => { + let message = format!( + "loot-race exceeded the {}s end-to-end deadline", + cli.workflow_deadline_secs + ); + sync.cancel(message.clone()); + handles.abort_all(); + while handles.join_next().await.is_some() {} + task_error = Some(message); + break; + } + }; + let Some(joined) = joined else { break }; + match joined { + Ok(Ok(result)) => results.push(result), + Ok(Err(error)) => { + let message = error.to_string(); + sync.cancel(message.clone()); + handles.abort_all(); + task_error.get_or_insert(message); + } + Err(error) => { + let message = format!("loot-race task join failed: {error}"); + sync.cancel(message.clone()); + handles.abort_all(); + task_error.get_or_insert(message); + } + } + } + results.sort_by_key(|result| result.account_id); + if shutdown.is_cancelled() { + task_error.get_or_insert_with(|| "loot-race received SIGINT/SIGTERM".to_string()); + } else if tokio::time::Instant::now() >= workflow_deadline { + task_error.get_or_insert_with(|| { + format!( + "loot-race exceeded the {}s end-to-end deadline", + cli.workflow_deadline_secs + ) + }); + } + + if task_error.is_none() + && results.len() == 2 + && results + .iter() + .all(|result| result.loot_race_smoke_passed == Some(true)) + { + let expected_source_coins = results[0] + .loot_race_loot_coins + .map(u64::from) + .ok_or_else(|| anyhow!("loot-race passed without recording source money")); + let expected_item_grant = expected_persisted_item_grant(&fixture, &sync).await; + let expected_money_grant = match expected_source_coins.as_ref() { + Ok(source_coins) => { + expected_persisted_money_grant(&fixture, &sync, *source_coins).await + } + Err(error) => Err(anyhow!(error.to_string())), + }; + let verification = match ( + expected_source_coins, + expected_item_grant, + expected_money_grant, + ) { + (Ok(expected_source_coins), Ok(expected_item_grant), Ok(expected_money_grant)) => { + if expected_source_coins != expected_money_grant.amount { + Err(anyhow!( + "wire money winner amount {} did not consume exact source pool {expected_source_coins}", + expected_money_grant.amount + )) + } else { + let fixture_for_db = fixture.clone(); + match tokio::task::spawn_blocking(move || { + verify_persisted_grants( + &fixture_for_db, + expected_money_grant, + expected_item_grant, + ) + }) + .await + { + Ok(verification) => verification, + Err(error) => Err(anyhow!( + "loot-race verification DB worker join failed: {error}" + )), + } + } + } + (Err(error), _, _) | (_, Err(error), _) | (_, _, Err(error)) => Err(error), + }; + match verification { + Ok((item_total, money_delta, item_grant, money_grant)) => { + for result in &mut results { + result.loot_race_db_item_total = Some(item_total); + result.loot_race_db_money_delta = Some(money_delta); + } + let mut relog_ok = true; + for participant in 0..2 { + let options = LootRaceOptions { + phase: LootRacePhase::VerifyRelog, + participant, + character_guid: fixture.characters[participant].bot.character_guid, + peer_name: fixture.characters[1 - participant].name.clone(), + peer_character_guid: fixture.characters[1 - participant].bot.character_guid, + killer_character_guid: fixture.characters[0].bot.character_guid, + target: fixture.target.clone(), + timeout_secs: cli.timeout_secs, + sync: Arc::clone(&sync), + }; + let relog = tokio::select! { + _ = shutdown.cancelled() => { + Err(anyhow!("loot-race relog cancelled by SIGINT/SIGTERM")) + } + result = tokio::time::timeout_at(workflow_deadline, run_bot( + bots[participant].clone(), + dungeon_id, + lfg_secs, + auto_teleport, + false, + None, + None, + None, + None, + None, + Some(options), + None, + )) => match result { + Ok(run) => run, + Err(_) => Err(anyhow!( + "loot-race relog exceeded the {}s end-to-end deadline", + cli.workflow_deadline_secs + )), + }, + }; + match relog { + Ok(relog) if relog.loot_race_relog_verified => { + if let Some(result) = results + .iter_mut() + .find(|result| result.account_id == bots[participant].account_id) + { + result.loot_race_relog_verified = true; + } + } + Ok(_) | Err(_) => relog_ok = false, + } + } + let fixture_for_relog = fixture.clone(); + let after_relog = match tokio::task::spawn_blocking(move || { + verify_persisted_grants(&fixture_for_relog, money_grant, item_grant) + }) + .await + { + Ok(verification) => verification, + Err(error) => Err(anyhow!("loot-race relog DB worker join failed: {error}")), + }; + if !matches!( + after_relog, + Ok(persisted) if persisted == (item_total, money_delta, item_grant, money_grant) + ) { + relog_ok = false; + } + for result in &mut results { + result.loot_race_smoke_passed = Some( + result.loot_race_smoke_passed.unwrap_or(false) + && relog_ok + && result.loot_race_relog_verified, + ); + if !result.loot_race_smoke_passed.unwrap_or(false) + && result.loot_race_failure.is_none() + { + result.loot_race_failure = Some( + "loot grants did not survive a clean logout/relogin unchanged".into(), + ); + } + } + } + Err(error) => { + for result in &mut results { + result.loot_race_smoke_passed = Some(false); + result.loot_race_failure = Some(error.to_string()); + } + } + } + } + + if let Some(ref error) = task_error { + for result in &mut results { + result.loot_race_smoke_passed = Some(false); + result.loot_race_failure.get_or_insert(error.clone()); + } + } + + let fixture_for_cleanup = fixture.clone(); + let cleanup = tokio::task::spawn_blocking(move || cleanup_fixture(&fixture_for_cleanup)) + .await + .map_err(|error| anyhow!("loot-race cleanup DB worker join failed: {error}"))?; + if let Err(error) = cleanup { + bail!("loot-race fixture cleanup failed: {error:#}"); + } + if results.len() != 2 { + bail!( + "loot-race produced {} results instead of 2{}", + results.len(), + task_error + .as_deref() + .map(|error| format!(": {error}")) + .unwrap_or_default() + ); + } + Ok(results) +} + +/// Record one deterministic, item-only loot action with exactly one connected +/// client. The second guarded bot remains offline; it is retained only because +/// the shared disposable-fixture snapshot/cleanup predates this capture mode. +pub(super) async fn run_single_item_capture_workflow( + mut bots: Vec, + cli: LootRaceCli, + dungeon_id: u32, + lfg_secs: u64, + auto_teleport: bool, + shutdown: CancellationToken, +) -> Result> { + let workflow_deadline = + tokio::time::Instant::now() + Duration::from_secs(cli.workflow_deadline_secs); + if shutdown.is_cancelled() { + bail!("loot-item capture cancelled before fixture setup"); + } + bots.sort_by_key(|bot| { + if bot.account.eq_ignore_ascii_case(&cli.account_a) { + 0 + } else { + 1 + } + }); + let setup_bots = bots.clone(); + let setup_cli = cli.clone(); + let setup_shutdown = shutdown.clone(); + let fixture = tokio::task::spawn_blocking(move || { + prepare_fixture( + &setup_bots, + &setup_cli, + LootFixturePurpose::CaptureItem, + &setup_shutdown, + ) + }) + .await + .map_err(|error| anyhow!("loot-item capture fixture DB worker join failed: {error}"))??; + + let sync = Arc::new(LootRaceSync::new()); + let options = LootRaceOptions { + phase: LootRacePhase::CaptureItem, + participant: 0, + character_guid: fixture.characters[0].bot.character_guid, + peer_name: fixture.characters[1].name.clone(), + peer_character_guid: fixture.characters[1].bot.character_guid, + killer_character_guid: fixture.characters[0].bot.character_guid, + target: fixture.target.clone(), + timeout_secs: cli.timeout_secs, + sync, + }; + let run = tokio::select! { + _ = shutdown.cancelled() => Err(anyhow!("loot-item capture received SIGINT/SIGTERM")), + result = tokio::time::timeout_at(workflow_deadline, run_bot( + bots[0].clone(), + dungeon_id, + lfg_secs, + auto_teleport, + false, + None, + None, + None, + None, + None, + Some(options), + None, + )) => match result { + Ok(run) => run, + Err(_) => Err(anyhow!( + "loot-item capture exceeded the {}s end-to-end deadline", + cli.workflow_deadline_secs + )), + }, + }; + + let mut result = match run { + Ok(result) => Some(result), + Err(error) => { + let fixture_for_cleanup = fixture.clone(); + let cleanup = + tokio::task::spawn_blocking(move || cleanup_fixture(&fixture_for_cleanup)) + .await + .map_err(|join| { + anyhow!("loot-item capture cleanup DB worker join failed: {join}") + })?; + if let Err(cleanup_error) = cleanup { + bail!( + "loot-item capture failed ({error:#}) and fixture cleanup also failed ({cleanup_error:#})" + ); + } + return Err(error); + } + }; + + if result + .as_ref() + .is_some_and(|result| result.loot_race_smoke_passed == Some(true)) + { + let fixture_for_verification = fixture.clone(); + match tokio::task::spawn_blocking(move || { + verify_single_item_capture_persistence(&fixture_for_verification) + }) + .await + { + Ok(Ok(item_total)) => { + let result = result + .as_mut() + .expect("successful wire result remains available"); + result.loot_race_db_item_total = Some(item_total); + result.loot_race_db_money_delta = Some(0); + } + Ok(Err(error)) => { + let result = result + .as_mut() + .expect("successful wire result remains available"); + result.loot_race_smoke_passed = Some(false); + result.loot_race_failure = Some(error.to_string()); + } + Err(error) => { + let result = result + .as_mut() + .expect("successful wire result remains available"); + result.loot_race_smoke_passed = Some(false); + result.loot_race_failure = Some(format!( + "loot-item capture verification DB worker join failed: {error}" + )); + } + } + } + + let fixture_for_cleanup = fixture.clone(); + let cleanup = tokio::task::spawn_blocking(move || cleanup_fixture(&fixture_for_cleanup)) + .await + .map_err(|error| anyhow!("loot-item capture cleanup DB worker join failed: {error}"))?; + if let Err(error) = cleanup { + bail!("loot-item capture fixture cleanup failed: {error:#}"); + } + + Ok(vec![result.expect("successful bot run remains available")]) +} + +fn record_discovered_runtime_guid(options: &LootRaceOptions, candidate: (u64, u64)) -> Result { + let (low, high) = candidate; + let counter = low & OBJECT_GUID_COUNTER_MASK; + if counter == 0 { + bail!( + "loot-race discovered an empty runtime counter for entry {} spawn {}", + options.target.entry, + options.target.spawn_guid + ); + } + let high_type = (high >> 58) & GUID_HIGH_TYPE_MASK; + let expected_high_type = match options.target.kind { + LootRaceTargetKind::Creature => HIGH_GUID_CREATURE, + LootRaceTargetKind::GameObject => HIGH_GUID_GAMEOBJECT, + }; + let high_map = (high >> 29) & GUID_MAP_MASK; + let high_entry = (high >> 6) & GUID_ENTRY_MASK; + if high_type != expected_high_type + || high_map != u64::from(options.target.map_id) + || high_entry != u64::from(options.target.entry) + { + bail!( + "loot-race discovered malformed {:?} ObjectGuid {low:#018X}/{high:#018X} for entry {} map {}", + options.target.kind, + options.target.entry, + options.target.map_id + ); + } + if options.target.kind == LootRaceTargetKind::GameObject && counter != options.target.spawn_guid + { + bail!( + "loot-race GameObject counter {counter} did not match exact SQL spawn {}", + options.target.spawn_guid + ); + } + let configured = options.target.runtime_counter_override; + if configured != 0 && counter != configured { + bail!( + "loot-race runtime counter override {configured} did not match discovered counter {counter} for SQL spawn {}", + options.target.spawn_guid + ); + } + + let mut resolved = options + .sync + .runtime_guid + .lock() + .map_err(|_| anyhow!("loot-race runtime GUID state was poisoned"))?; + if let Some(previous) = *resolved { + if previous != candidate { + bail!( + "loot-race bots discovered different live ObjectGuids for exact SQL spawn {}: first={:#018X}/{:#018X}, current={low:#018X}/{high:#018X}", + options.target.spawn_guid, + previous.0, + previous.1 + ); + } + } else { + *resolved = Some(candidate); + } + Ok(counter) +} + +pub(super) fn target_seen_in_update( + options: &LootRaceOptions, + opcode: u16, + payload: &[u8], +) -> Result> { + if options.phase == LootRacePhase::VerifyRelog || opcode != SMSG_UPDATE_OBJECT { + return Ok(None); + } + // Keep the complete GUID seen on the wire: C++ includes realm/server bits + // that must not be reconstructed from the SQL spawn id. The GameObject + // fixture additionally requires its exact spawn counter because its entry + // need not be globally unique. + let Some(candidate) = find_loot_target_guid_in_update_object(payload, &options.target) else { + return Ok(None); + }; + record_discovered_runtime_guid(options, candidate).map(Some) +} + +fn find_loot_target_guid_in_update_object( + payload: &[u8], + target: &LootRaceTarget, +) -> Option<(u64, u64)> { + let expected_high_type = match target.kind { + LootRaceTargetKind::Creature => HIGH_GUID_CREATURE, + LootRaceTargetKind::GameObject => HIGH_GUID_GAMEOBJECT, + }; + for offset in 0..payload.len().saturating_sub(2) { + if !matches!(payload[offset], 1 | 2) { + continue; + } + let Some((_, low, high)) = parse_packed_guid(&payload[offset + 1..]) else { + continue; + }; + if ((high >> 58) & GUID_HIGH_TYPE_MASK) != expected_high_type + || ((high >> 29) & GUID_MAP_MASK) != u64::from(target.map_id) + || ((high >> 6) & GUID_ENTRY_MASK) != u64::from(target.entry) + { + continue; + } + let counter = low & OBJECT_GUID_COUNTER_MASK; + if target.kind == LootRaceTargetKind::GameObject && counter != target.spawn_guid { + continue; + } + return Some((low, high)); + } + None +} + +pub(super) async fn run_phase( + bot_index: usize, + stream: &mut TcpStream, + crypt: &mut WorldCrypt, + inflater: &mut ServerPacketInflater, + realm_connection: &mut Option, + options: &LootRaceOptions, + target_seen: bool, + result: &mut BotRunResult, +) -> Result<()> { + let run = run_phase_inner( + bot_index, + stream, + crypt, + inflater, + realm_connection, + options, + target_seen, + result, + ) + .await; + if let Err(error) = &run { + options.sync.cancel(format!( + "participant {} phase {:?} failed: {error:#}", + options.participant, options.phase + )); + } + run +} + +async fn run_phase_inner( + bot_index: usize, + stream: &mut TcpStream, + crypt: &mut WorldCrypt, + inflater: &mut ServerPacketInflater, + realm_connection: &mut Option, + options: &LootRaceOptions, + mut target_seen: bool, + result: &mut BotRunResult, +) -> Result<()> { + if options.phase == LootRacePhase::VerifyRelog { + result.loot_race_target_runtime_counter = Some(options.resolved_runtime_counter()?); + logout_and_wait_routed_like_cpp( + bot_index, + stream, + crypt, + inflater, + realm_connection.as_mut(), + options.character_guid, + result, + ) + .await?; + result.loot_race_relog_verified = true; + result.loot_race_smoke_passed = Some(true); + return Ok(()); + } + let realm = realm_connection + .as_mut() + .ok_or_else(|| anyhow!("loot-race requires separate realm and instance sockets"))?; + + // C++ can defer nearby-object visibility until the client confirms that its + // active mover is initialized. Reuse the same real movement handshake as + // the rested-XP combat smoke before proving the exact creature is live. + send_encrypted_packet( + stream, + crypt, + CMSG_MOVE_INIT_ACTIVE_MOVER_COMPLETE, + &build_move_init_active_mover_complete_payload(0), + ) + .await?; + target_seen |= + drain_until_target_or_quiet(bot_index, stream, crypt, inflater, realm, options, result) + .await?; + if !target_seen { + let override_detail = if options.target.runtime_counter_override == 0 { + "auto-discovery".to_string() + } else { + format!("override {}", options.target.runtime_counter_override) + }; + bail!( + "world-loot target entry {} spawn {} ({override_detail}) was not present in SMSG_UPDATE_OBJECT; require a fresh world/runtime and matching override", + options.target.entry, + options.target.spawn_guid + ); + } + result.loot_race_target_runtime_counter = Some(options.resolved_runtime_counter()?); + result.loot_race_target_discovered = true; + if options.phase == LootRacePhase::CaptureItem { + return run_single_item_capture_phase( + bot_index, stream, crypt, inflater, realm, options, result, + ) + .await; + } + options.sync.cancellation_error()?; + wait_phase(options, &options.sync.logged_in, "both bots logged in").await?; + + form_party(bot_index, stream, crypt, inflater, realm, options, result).await?; + result.loot_race_party_confirmed = true; + wait_phase(options, &options.sync.party_ready, "PERSONAL party formed").await?; + + let (player_low, player_high) = create_player_guid_raw(options.character_guid, realm_id()); + let player_x = options.target.x + 1.0 + options.participant as f64; + let player_y = options.target.y; + let player_z = options.target.z; + let player_orientation = + (options.target.y - player_y).atan2(options.target.x - player_x) as f32; + let movement = build_move_heartbeat_payload( + player_low, + player_high, + player_x as f32, + player_y as f32, + player_z as f32, + player_orientation, + ); + send_encrypted_packet(stream, crypt, CMSG_MOVE_HEARTBEAT, &movement).await?; + wait_phase( + options, + &options.sync.positioned, + "both bots positioned at the shared chest", + ) + .await?; + + if options.target.kind != LootRaceTargetKind::GameObject { + bail!("two-client Race must use the guarded shared GameObject fixture"); + } + wait_phase( + options, + &options.sync.use_ready, + "simultaneous CMSG_GAME_OBJ_USE", + ) + .await?; + // C++ `WorldPackets::GameObject::GameObjUse::Read` consumes one packed + // GameObject ObjectGuid; both clients deliberately send the same wire GUID. + send_encrypted_packet( + stream, + crypt, + CMSG_GAME_OBJ_USE, + &options.resolved_packed_guid()?, + ) + .await?; + info!( + "[Bot {}] loot-race phase: CMSG_GAME_OBJ_USE sent for shared spawn {}", + bot_index, options.target.spawn_guid + ); + let window = + wait_for_loot_window(bot_index, stream, crypt, inflater, realm, options, result).await?; + result.loot_race_loot_opened = true; + result.loot_race_loot_list_id = Some(window.loot_list_id); + result.loot_race_loot_coins = Some(window.coins); + info!( + "[Bot {}] loot-race phase: SMSG_LOOT_RESPONSE received (loot_list_id={}, coins={})", + bot_index, window.loot_list_id, window.coins + ); + options.sync.windows.lock().await[options.participant] = Some(window.clone()); + wait_phase( + options, + &options.sync.response_received, + "both SMSG_LOOT_RESPONSE packets received", + ) + .await?; + wait_phase( + options, + &options.sync.windows_ready, + "shared loot windows recorded", + ) + .await?; + validate_shared_windows(options).await?; + + wait_phase(options, &options.sync.item_claim, "simultaneous item claim").await?; + let item_claim = build_loot_item_claim(&window); + send_encrypted_packet(stream, crypt, CMSG_LOOT_ITEM, &item_claim).await?; + let expected_loot_owner = options.resolved_runtime_guid()?; + let mut evidence = collect_evidence( + bot_index, + stream, + crypt, + inflater, + realm, + expected_loot_owner, + RESPONSE_SETTLE, + options, + result, + ) + .await?; + options.sync.evidence.lock().await[options.participant] = evidence.clone(); + wait_phase( + options, + &options.sync.item_observed, + "item outcomes observed", + ) + .await?; + validate_item_outcome(options, result).await?; + + wait_phase( + options, + &options.sync.money_claim, + "simultaneous money claim", + ) + .await?; + send_encrypted_packet(stream, crypt, CMSG_LOOT_MONEY, &[0]).await?; + evidence.merge( + collect_evidence( + bot_index, + stream, + crypt, + inflater, + realm, + expected_loot_owner, + RESPONSE_SETTLE, + options, + result, + ) + .await?, + ); + options.sync.evidence.lock().await[options.participant] = evidence; + wait_phase( + options, + &options.sync.money_observed, + "money outcomes observed", + ) + .await?; + // The money settle window can also surface a delayed item response. Merge + // first, then re-run the exact item fanout proof so a late duplicate, + // foreign push/failure, or extra removal cannot escape the earlier fence. + validate_item_outcome(options, result).await?; + validate_money_outcome(options, result).await?; + + wait_phase(options, &options.sync.before_leave, "party cleanup").await?; + send_encrypted_packet(&mut realm.stream, &mut realm.crypt, CMSG_LEAVE_GROUP, &[0]).await?; + logout_and_wait_routed_like_cpp( + bot_index, + stream, + crypt, + inflater, + Some(realm), + options.character_guid, + result, + ) + .await?; + result.loot_race_smoke_passed = Some(true); + Ok(()) +} + +async fn run_single_item_capture_phase( + bot_index: usize, + stream: &mut TcpStream, + crypt: &mut WorldCrypt, + inflater: &mut ServerPacketInflater, + realm: &mut EncryptedWorldConnection, + options: &LootRaceOptions, + result: &mut BotRunResult, +) -> Result<()> { + if options.participant != 0 || options.character_guid != options.killer_character_guid { + bail!("loot-item capture requires account A as its sole connected killer"); + } + + let (player_low, player_high) = create_player_guid_raw(options.character_guid, realm_id()); + let player_x = options.target.x + 1.0; + let player_y = options.target.y; + let player_z = options.target.z; + let player_orientation = + (options.target.y - player_y).atan2(options.target.x - player_x) as f32; + let movement = build_move_heartbeat_payload( + player_low, + player_high, + player_x as f32, + player_y as f32, + player_z as f32, + player_orientation, + ); + send_encrypted_packet(stream, crypt, CMSG_MOVE_HEARTBEAT, &movement).await?; + + kill_target_once(bot_index, stream, crypt, inflater, realm, options, result).await?; + send_encrypted_packet( + stream, + crypt, + CMSG_LOOT_UNIT, + &options.resolved_packed_guid()?, + ) + .await?; + let window = + wait_for_loot_window(bot_index, stream, crypt, inflater, realm, options, result).await?; + result.loot_race_loot_opened = true; + result.loot_race_loot_list_id = Some(window.loot_list_id); + result.loot_race_loot_coins = Some(window.coins); + + // Capture-diff starts at this exact CMSG. Opening the corpse (including its + // random coin roll) is deliberately outside the item-only window. + let item_claim = build_loot_item_claim(&window); + send_encrypted_packet(stream, crypt, CMSG_LOOT_ITEM, &item_claim).await?; + let evidence = collect_single_item_capture_evidence( + bot_index, + stream, + crypt, + inflater, + realm, + options.resolved_runtime_guid()?, + options.timeout_secs, + result, + ) + .await?; + let expected_player = create_player_guid_raw(options.character_guid, realm_id()); + validate_single_item_capture_evidence( + &evidence, + expected_player, + options.target.item_entry, + &window, + )?; + result.loot_race_item_push_seen = true; + result.loot_race_loot_removed_seen = true; + + send_and_verify_loot_item_capture_fence( + bot_index, + stream, + crypt, + inflater, + options.timeout_secs, + result, + ) + .await?; + logout_and_wait_routed_like_cpp( + bot_index, + stream, + crypt, + inflater, + Some(realm), + options.character_guid, + result, + ) + .await?; + result.loot_race_smoke_passed = Some(true); + Ok(()) +} + +/// Read only until the two C++-anchored item-claim responses have arrived. +/// +/// The two-client race deliberately keeps a settle window so it can prove the +/// absence/presence of competing outcomes on both sockets. A capture golden +/// has a different requirement: put the fixed ping fence immediately after +/// the one `LootRemoved` and one `ItemPushResult`, rather than admitting an +/// arbitrary two seconds of periodic traffic into the strict diff window. +async fn collect_single_item_capture_evidence( + bot_index: usize, + stream: &mut TcpStream, + crypt: &mut WorldCrypt, + inflater: &mut ServerPacketInflater, + realm: &mut EncryptedWorldConnection, + expected_loot_owner: (u64, u64), + timeout_secs: u64, + result: &mut BotRunResult, +) -> Result { + let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs); + let mut evidence = WireEvidence::default(); + + loop { + if evidence.item_pushes.len() == 1 && evidence.loot_removed.len() == 1 { + return Ok(evidence); + } + + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + bail!( + "timed out waiting for the capture item responses (item pushes={}, loot removals={})", + evidence.item_pushes.len(), + evidence.loot_removed.len() + ); + } + + // Do not `select!` two in-progress encrypted reads: cancelling the + // losing `read_exact` could consume part of a framed packet. Poll + // socket readiness briefly, then finish any selected frame without + // cancellation. + if let Some((opcode, payload)) = read_encrypted_packet_if_ready( + &mut realm.stream, + &mut realm.crypt, + &mut realm.inflater, + remaining.min(Duration::from_millis(5)), + remaining, + "loot-item capture realm response", + ) + .await? + { + result.seen_opcodes.push(format!("0x{opcode:04X}")); + record_evidence(opcode, &payload, expected_loot_owner, &mut evidence)?; + validate_single_item_capture_candidate(&evidence)?; + } + + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + continue; + } + if let Some((opcode, payload)) = read_encrypted_packet_if_ready( + stream, + crypt, + inflater, + Duration::from_millis(1), + remaining, + "loot-item capture instance response", + ) + .await? + { + result.seen_opcodes.push(format!("0x{opcode:04X}")); + record_evidence(opcode, &payload, expected_loot_owner, &mut evidence)?; + validate_single_item_capture_candidate(&evidence)?; + handle_instance_housekeeping(bot_index, stream, crypt, opcode, &payload).await?; + } + } +} + +fn validate_single_item_capture_candidate(evidence: &WireEvidence) -> Result<()> { + if evidence.item_pushes.len() > 1 || evidence.loot_removed.len() > 1 { + bail!( + "loot-item capture observed duplicate functional responses before its fence (item pushes={}, loot removals={})", + evidence.item_pushes.len(), + evidence.loot_removed.len() + ); + } + if !evidence.inventory_failures.is_empty() { + bail!("loot-item capture observed an inventory failure before its success fence"); + } + if !evidence.money_notifies.is_empty() || !evidence.coin_removed.is_empty() { + bail!("loot-item capture observed money-claim traffic before its item-only fence"); + } + Ok(()) +} + +fn validate_single_item_capture_evidence( + evidence: &WireEvidence, + expected_player: (u64, u64), + expected_item_entry: u32, + window: &LootWindow, +) -> Result<()> { + if evidence.item_pushes.len() != 1 { + bail!( + "single-session item capture observed item pushes {:?}; expected exactly one", + evidence.item_pushes + ); + } + let push = &evidence.item_pushes[0]; + if push.item_entry != expected_item_entry + || push.quantity != 1 + || push.quantity_in_inventory != 1 + || push.slot != INVENTORY_SLOT_BAG_0 + || push.slot_in_bag != i32::from(LOOT_ITEM_CAPTURE_KEYRING_SLOT) + || (push.player_low, push.player_high) != expected_player + { + bail!( + "single-session item capture observed item pushes {:?}; expected exactly item {} quantity 1 in keyring slot {}/{} for the sole character", + evidence.item_pushes, + expected_item_entry, + INVENTORY_SLOT_BAG_0, + LOOT_ITEM_CAPTURE_KEYRING_SLOT + ); + } + let expected_removal = LootRemovedEvidence { + owner_low: window.owner_low, + owner_high: window.owner_high, + loot_low: window.loot_low, + loot_high: window.loot_high, + loot_list_id: window.loot_list_id, + }; + if evidence.loot_removed != [expected_removal] { + bail!( + "single-session item capture observed removals {:?}; expected exactly {:?}", + evidence.loot_removed, + expected_removal + ); + } + if !evidence.inventory_failures.is_empty() { + bail!("single-session item capture unexpectedly failed inventory storage"); + } + if !evidence.money_notifies.is_empty() || !evidence.coin_removed.is_empty() { + bail!("item-only capture unexpectedly emitted money-claim packets"); + } + Ok(()) +} + +async fn send_and_verify_loot_item_capture_fence( + bot_index: usize, + stream: &mut TcpStream, + crypt: &mut WorldCrypt, + inflater: &mut ServerPacketInflater, + timeout_secs: u64, + result: &mut BotRunResult, +) -> Result<()> { + let payload = build_ping_payload(LOOT_ITEM_CAPTURE_FENCE_SERIAL); + send_encrypted_packet(stream, crypt, CMSG_PING, &payload).await?; + info!( + "[Bot {}] deterministic loot-item CMSG_PING fence sent (serial=0x{:08X})", + bot_index, LOOT_ITEM_CAPTURE_FENCE_SERIAL + ); + + let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs); + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + bail!("timed out waiting for loot-item capture-fence SMSG_PONG"); + } + let (opcode, payload) = + tokio::time::timeout(remaining, read_encrypted_packet(stream, crypt, inflater)) + .await + .map_err(|_| { + anyhow!("timed out waiting for loot-item capture-fence SMSG_PONG") + })??; + result.seen_opcodes.push(format!("0x{opcode:04X}")); + if opcode == SMSG_PONG { + if payload != LOOT_ITEM_CAPTURE_FENCE_SERIAL.to_le_bytes() { + bail!( + "loot-item capture-fence SMSG_PONG mismatch: expected 0x{:08X}, got {:02X?}", + LOOT_ITEM_CAPTURE_FENCE_SERIAL, + payload + ); + } + return Ok(()); + } + handle_instance_housekeeping(bot_index, stream, crypt, opcode, &payload).await?; + } +} + +fn logout_completion_route( + opcode: u16, + route: LogoutCompletionRoute, +) -> Option { + (opcode == SMSG_LOGOUT_COMPLETE).then_some(route) +} + +fn wait_for_loot_character_offline(character_guid: u64, timeout: Duration) -> Result<()> { + let url = characters_db_url()?; + let opts = loot_db_opts(&url, "characters")?; + let mut conn = mysql::Conn::new(opts) + .map_err(|error| anyhow!("Connect to characters DB failed: {error}"))?; + let deadline = std::time::Instant::now() + timeout; + loop { + let online: u8 = conn + .exec_first( + "SELECT online FROM characters WHERE guid = ?", + (character_guid,), + ) + .map_err(|error| anyhow!("Check loot logout offline state: {error}"))? + .ok_or_else(|| anyhow!("Loot character {character_guid} disappeared during logout"))?; + if online == 0 { + return Ok(()); + } + if std::time::Instant::now() >= deadline { + bail!( + "loot character {character_guid} remained online after its bounded logout DB proof" + ); + } + std::thread::sleep(Duration::from_millis(100)); + } +} + +/// Finish a loot workflow across the real 3.4.3 socket topology. +/// +/// C++ routes `SMSG_LOGOUT_RESPONSE` on instance and +/// `SMSG_LOGOUT_COMPLETE` on realm. Rust currently may complete on instance, +/// so both sockets are drained safely and the observed route is reported. In +/// every case, success additionally requires the exact character row to be +/// offline; a closed socket by itself is never accepted as logout proof. +async fn logout_and_wait_routed_like_cpp( + bot_index: usize, + stream: &mut TcpStream, + crypt: &mut WorldCrypt, + inflater: &mut ServerPacketInflater, + mut realm_connection: Option<&mut EncryptedWorldConnection>, + character_guid: u64, + result: &mut BotRunResult, +) -> Result<()> { + send_encrypted_packet(stream, crypt, CMSG_LOGOUT_REQUEST, &[0]).await?; + info!("[Bot {}] ✅ CMSG_LOGOUT_REQUEST sent", bot_index); + + let deadline = + tokio::time::Instant::now() + Duration::from_secs(NORMAL_LOGOUT_COMPLETE_WAIT_SECS); + let mut instance_open = true; + let mut realm_open = realm_connection.is_some(); + let mut completion_route = None; + let mut transport_errors = Vec::new(); + + while tokio::time::Instant::now() < deadline && completion_route.is_none() { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + break; + } + if !realm_open && !instance_open { + tokio::time::sleep(remaining).await; + break; + } + + if realm_open { + let realm = realm_connection + .as_deref_mut() + .expect("realm-open state requires the preserved realm connection"); + match read_encrypted_packet_if_ready( + &mut realm.stream, + &mut realm.crypt, + &mut realm.inflater, + remaining.min(Duration::from_millis(50)), + remaining, + "loot logout realm packet", + ) + .await + { + Ok(Some((opcode, payload))) => { + result.seen_opcodes.push(format!("0x{opcode:04X}")); + info!( + "[Bot {}] 📦 realm loot logout {}", + bot_index, + parse_packet(opcode, &payload) + ); + completion_route = + logout_completion_route(opcode, LogoutCompletionRoute::Realm); + } + Ok(None) => {} + Err(error) => { + realm_open = false; + transport_errors.push(format!("realm: {error}")); + } + } + } + + if completion_route.is_some() { + break; + } + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + break; + } + if instance_open { + let readiness_wait = if realm_open { + Duration::from_millis(1) + } else { + remaining.min(Duration::from_millis(50)) + }; + match read_encrypted_packet_if_ready( + stream, + crypt, + inflater, + readiness_wait, + remaining, + "loot logout instance packet", + ) + .await + { + Ok(Some((opcode, payload))) => { + result.seen_opcodes.push(format!("0x{opcode:04X}")); + info!( + "[Bot {}] 📦 instance loot logout {}", + bot_index, + parse_packet(opcode, &payload) + ); + completion_route = + logout_completion_route(opcode, LogoutCompletionRoute::Instance); + if completion_route.is_none() { + handle_instance_housekeeping(bot_index, stream, crypt, opcode, &payload) + .await?; + } + } + Ok(None) => {} + Err(error) => { + instance_open = false; + transport_errors.push(format!("instance: {error}")); + } + } + } + } + + let offline = tokio::task::spawn_blocking(move || { + wait_for_loot_character_offline( + character_guid, + Duration::from_secs(LOOT_LOGOUT_DB_CONFIRM_WAIT_SECS), + ) + }) + .await + .map_err(|error| anyhow!("loot logout DB worker join failed: {error}"))?; + if let Err(error) = offline { + let route = completion_route + .map(|route| format!("{route:?}")) + .unwrap_or_else(|| "none".to_string()); + let transport = if transport_errors.is_empty() { + "none".to_string() + } else { + transport_errors.join("; ") + }; + bail!( + "loot logout was not proven for character {character_guid} (LogoutComplete route={route}, transport errors={transport}): {error}" + ); + } + + match completion_route { + Some(LogoutCompletionRoute::Realm) => info!( + "[Bot {}] ✅ realm SMSG_LOGOUT_COMPLETE and exact offline DB state confirmed", + bot_index + ), + Some(LogoutCompletionRoute::Instance) => warn!( + "[Bot {}] SMSG_LOGOUT_COMPLETE arrived on instance instead of the C++ realm route; exact offline DB state confirmed", + bot_index + ), + None => warn!( + "[Bot {}] no SMSG_LOGOUT_COMPLETE arrived within {}s; exact offline DB state confirmed as the bounded fallback{}", + bot_index, + NORMAL_LOGOUT_COMPLETE_WAIT_SECS, + if transport_errors.is_empty() { + String::new() + } else { + format!(" ({})", transport_errors.join("; ")) + } + ), + } + Ok(()) +} + +pub(super) async fn best_effort_close( + bot_index: usize, + stream: &mut TcpStream, + crypt: &mut WorldCrypt, + inflater: &mut ServerPacketInflater, + realm_connection: &mut Option, + character_guid: u64, + result: &mut BotRunResult, +) { + if let Some(realm) = realm_connection.as_mut() { + let _ = send_encrypted_packet(&mut realm.stream, &mut realm.crypt, CMSG_LEAVE_GROUP, &[0]) + .await; + } + let _ = logout_and_wait_routed_like_cpp( + bot_index, + stream, + crypt, + inflater, + realm_connection.as_mut(), + character_guid, + result, + ) + .await; +} + +impl WireEvidence { + fn merge(&mut self, other: Self) { + self.item_pushes.extend(other.item_pushes); + self.loot_removed.extend(other.loot_removed); + self.money_notifies.extend(other.money_notifies); + self.coin_removed.extend(other.coin_removed); + self.inventory_failures.extend(other.inventory_failures); + } +} + +async fn wait_phase(options: &LootRaceOptions, barrier: &Barrier, label: &str) -> Result<()> { + options.sync.cancellation_error()?; + info!( + "[Bot {}] loot-race phase: waiting for {label}", + options.participant + 1 + ); + tokio::time::timeout(Duration::from_secs(options.timeout_secs), async { + tokio::select! { + _ = barrier.wait() => Ok(()), + _ = options.sync.cancelled() => options.sync.cancellation_error(), + } + }) + .await + .map_err(|_| anyhow!("loot-race timed out waiting for {label}"))??; + info!( + "[Bot {}] loot-race phase: {label} complete", + options.participant + 1 + ); + Ok(()) +} + +/// C++ `Opcodes.cpp` maps these party packets to +/// `CONNECTION_TYPE_REALM`. Seeing one on the instance socket is a routing +/// defect, not harmless traffic that the atomic-loot harness may discard. +fn validate_party_packet_route_like_cpp(opcode: u16, route: PartyPacketRoute) -> Result<()> { + if route == PartyPacketRoute::Instance + && matches!( + opcode, + SMSG_PARTY_INVITE + | SMSG_PARTY_UPDATE + | SMSG_PARTY_COMMAND_RESULT + | SMSG_PARTY_MEMBER_FULL_STATE + ) + { + bail!( + "realm-only party opcode 0x{opcode:04X} arrived on instance while forming the loot-race party; C++ Opcodes.cpp requires CONNECTION_TYPE_REALM" + ); + } + Ok(()) +} + +struct PartyUpdateCursor<'a> { + payload: &'a [u8], + offset: usize, +} + +impl<'a> PartyUpdateCursor<'a> { + fn new(payload: &'a [u8]) -> Self { + Self { payload, offset: 0 } + } + + fn take(&mut self, len: usize, field: &str) -> Result<&'a [u8]> { + let end = self + .offset + .checked_add(len) + .ok_or_else(|| anyhow!("PartyUpdate {field} offset overflow"))?; + let bytes = self.payload.get(self.offset..end).ok_or_else(|| { + anyhow!( + "malformed PartyUpdate: {field} needs {len} byte(s) at offset {}, payload has {}", + self.offset, + self.payload.len() + ) + })?; + self.offset = end; + Ok(bytes) + } + + fn read_u8(&mut self, field: &str) -> Result { + Ok(self.take(1, field)?[0]) + } + + fn read_u16(&mut self, field: &str) -> Result { + Ok(u16::from_le_bytes( + self.take(2, field)?.try_into().expect("exact u16 slice"), + )) + } + + fn read_u32(&mut self, field: &str) -> Result { + Ok(u32::from_le_bytes( + self.take(4, field)?.try_into().expect("exact u32 slice"), + )) + } + + fn read_i32(&mut self, field: &str) -> Result { + Ok(i32::from_le_bytes( + self.take(4, field)?.try_into().expect("exact i32 slice"), + )) + } + + fn read_packed_guid(&mut self, field: &str) -> Result<(u64, u64)> { + let (consumed, low, high) = parse_packed_guid(&self.payload[self.offset..]) + .ok_or_else(|| anyhow!("malformed PartyUpdate {field} packed ObjectGuid"))?; + self.offset = self + .offset + .checked_add(consumed) + .ok_or_else(|| anyhow!("PartyUpdate {field} offset overflow"))?; + Ok((low, high)) + } +} + +/// Decode the C++ `PartyPackets.cpp::PartyUpdate::Write` prefix and each +/// `PartyPlayerInfo`. The race must prove an actual two-player HOME party; a +/// stray, destroyed, raid, LFG, or malformed PartyUpdate is not coordination. +fn validate_party_update_like_cpp( + payload: &[u8], + receiver_guid: (u64, u64), + peer_guid: (u64, u64), + expected_leader_guid: (u64, u64), +) -> Result<()> { + const GROUP_CATEGORY_HOME_LIKE_CPP: u8 = 0; + const GROUP_TYPE_NORMAL_LIKE_CPP: u8 = 1; + const EXPECTED_PARTY_MEMBERS: u32 = 2; + + let mut cursor = PartyUpdateCursor::new(payload); + let party_flags = cursor.read_u16("PartyFlags")?; + let party_index = cursor.read_u8("PartyIndex")?; + let party_type = cursor.read_u8("PartyType")?; + let my_index = cursor.read_i32("MyIndex")?; + let party_guid = cursor.read_packed_guid("PartyGUID")?; + let _sequence_num = cursor.read_u32("SequenceNum")?; + let leader_guid = cursor.read_packed_guid("LeaderGUID")?; + let _leader_faction_group = cursor.read_u8("LeaderFactionGroup")?; + let player_count = cursor.read_u32("PlayerList.size")?; + let optional_bits = cursor.read_u8("optional-value bits")?; + let has_lfg_info = optional_bits & 0x80 != 0; + let has_loot_settings = optional_bits & 0x40 != 0; + let has_difficulty_settings = optional_bits & 0x20 != 0; + + if party_flags != 0 + || party_index != GROUP_CATEGORY_HOME_LIKE_CPP + || party_type != GROUP_TYPE_NORMAL_LIKE_CPP + { + bail!( + "loot-race PartyUpdate was not a normal HOME party: flags={party_flags:#06X} index={party_index} type={party_type}" + ); + } + if party_guid == (0, 0) { + bail!("loot-race PartyUpdate carried an empty PartyGUID"); + } + if leader_guid != expected_leader_guid { + bail!( + "loot-race PartyUpdate leader {:#018X}/{:#018X} did not match inviter {:#018X}/{:#018X}", + leader_guid.0, + leader_guid.1, + expected_leader_guid.0, + expected_leader_guid.1 + ); + } + if player_count != EXPECTED_PARTY_MEMBERS { + bail!( + "loot-race PartyUpdate carried {player_count} player(s), expected {EXPECTED_PARTY_MEMBERS}" + ); + } + if optional_bits & 0x1F != 0 { + bail!( + "malformed PartyUpdate optional-value bit padding {:#04X}", + optional_bits & 0x1F + ); + } + // C++ `Group::SendUpdateToPlayer` includes loot and difficulty settings + // for a non-LFG group with more than one member. + if has_lfg_info || !has_loot_settings || !has_difficulty_settings { + bail!( + "loot-race PartyUpdate optional values did not describe a normal two-player group: lfg={has_lfg_info} loot={has_loot_settings} difficulty={has_difficulty_settings}" + ); + } + + let mut roster = Vec::with_capacity(EXPECTED_PARTY_MEMBERS as usize); + for player_index in 0..player_count { + // `PartyPlayerInfo::operator<<` writes 15 MSB-first bits, then the + // following packed GUID byte-write aligns to the next byte. + let info_bits = u16::from_be_bytes( + cursor + .take(2, "PartyPlayerInfo bit fields")? + .try_into() + .expect("exact bit-field slice"), + ); + if info_bits & 1 != 0 { + bail!("malformed PartyUpdate player {player_index}: nonzero aligned bit padding"); + } + let info_bits = info_bits >> 1; + let name_len = usize::from((info_bits >> 9) & 0x3F); + let voice_len_plus_one = usize::from((info_bits >> 3) & 0x3F); + let connected = info_bits & 0x04 != 0; + if name_len == 0 || voice_len_plus_one == 0 { + bail!( + "malformed PartyUpdate player {player_index}: name_len={name_len} voice_len_plus_one={voice_len_plus_one}" + ); + } + + let guid = cursor.read_packed_guid("PartyPlayerInfo.GUID")?; + let subgroup = cursor.read_u8("PartyPlayerInfo.Subgroup")?; + let _flags = cursor.read_u8("PartyPlayerInfo.Flags")?; + let _roles = cursor.read_u8("PartyPlayerInfo.RolesAssigned")?; + let _class = cursor.read_u8("PartyPlayerInfo.Class")?; + let _faction = cursor.read_u8("PartyPlayerInfo.FactionGroup")?; + let _name = cursor.take(name_len, "PartyPlayerInfo.Name")?; + let _voice_state = cursor.take(voice_len_plus_one - 1, "PartyPlayerInfo.VoiceStateID")?; + + if !connected || subgroup != 0 { + bail!( + "loot-race PartyUpdate player {player_index} was not a connected HOME subgroup member: connected={connected} subgroup={subgroup}" + ); + } + roster.push(guid); + } + + let receiver_index = usize::try_from(my_index) + .ok() + .filter(|index| *index < roster.len()) + .ok_or_else(|| { + anyhow!("loot-race PartyUpdate MyIndex {my_index} was outside the roster") + })?; + if roster[receiver_index] != receiver_guid { + bail!( + "loot-race PartyUpdate MyIndex {my_index} identified {:#018X}/{:#018X}, expected receiver {:#018X}/{:#018X}", + roster[receiver_index].0, + roster[receiver_index].1, + receiver_guid.0, + receiver_guid.1 + ); + } + let mut expected_roster = [receiver_guid, peer_guid]; + expected_roster.sort_unstable(); + roster.sort_unstable(); + if roster.as_slice() != expected_roster { + bail!( + "loot-race PartyUpdate roster {roster:?} did not contain exactly receiver/peer {expected_roster:?}" + ); + } + + let loot_method = cursor.read_u8("PartyLootSettings.Method")?; + let _loot_master = cursor.read_packed_guid("PartyLootSettings.LootMaster")?; + let _loot_threshold = cursor.read_u8("PartyLootSettings.Threshold")?; + if loot_method != PERSONAL_LOOT_METHOD_LIKE_CPP { + bail!( + "loot-race PartyUpdate loot method {loot_method} was not C++ PERSONAL_LOOT ({PERSONAL_LOOT_METHOD_LIKE_CPP})" + ); + } + let _difficulty_settings = cursor.take(12, "PartyDifficultySettings")?; + if cursor.offset != payload.len() { + bail!( + "malformed PartyUpdate left {} trailing byte(s)", + payload.len() - cursor.offset + ); + } + Ok(()) +} + +async fn form_party( + bot_index: usize, + stream: &mut TcpStream, + crypt: &mut WorldCrypt, + inflater: &mut ServerPacketInflater, + realm: &mut EncryptedWorldConnection, + options: &LootRaceOptions, + result: &mut BotRunResult, +) -> Result<()> { + if options.participant == 0 { + let (peer_low, peer_high) = create_player_guid_raw(options.peer_character_guid, realm_id()); + let payload = build_party_invite(&options.peer_name, peer_low, peer_high)?; + send_encrypted_packet( + &mut realm.stream, + &mut realm.crypt, + CMSG_PARTY_INVITE, + &payload, + ) + .await?; + wait_for_realm_opcode( + bot_index, + stream, + crypt, + inflater, + realm, + options, + SMSG_PARTY_UPDATE, + result, + ) + .await?; + } else { + wait_for_realm_opcode( + bot_index, + stream, + crypt, + inflater, + realm, + options, + SMSG_PARTY_INVITE, + result, + ) + .await?; + send_encrypted_packet( + &mut realm.stream, + &mut realm.crypt, + CMSG_PARTY_INVITE_RESPONSE, + &[0x40], + ) + .await?; + wait_for_realm_opcode( + bot_index, + stream, + crypt, + inflater, + realm, + options, + SMSG_PARTY_UPDATE, + result, + ) + .await?; + } + Ok(()) +} + +async fn wait_for_realm_opcode( + bot_index: usize, + stream: &mut TcpStream, + crypt: &mut WorldCrypt, + inflater: &mut ServerPacketInflater, + realm: &mut EncryptedWorldConnection, + options: &LootRaceOptions, + expected: u16, + result: &mut BotRunResult, +) -> Result> { + let deadline = tokio::time::Instant::now() + Duration::from_secs(options.timeout_secs); + loop { + options.sync.cancellation_error()?; + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + bail!("timed out waiting for realm opcode 0x{expected:04X}"); + } + if let Some((opcode, payload)) = read_encrypted_packet_if_ready( + &mut realm.stream, + &mut realm.crypt, + &mut realm.inflater, + remaining.min(Duration::from_millis(50)), + remaining, + "loot-race realm packet", + ) + .await? + { + result.seen_opcodes.push(format!("0x{opcode:04X}")); + validate_party_packet_route_like_cpp(opcode, PartyPacketRoute::Realm)?; + if opcode == SMSG_PARTY_UPDATE { + validate_party_update_like_cpp( + &payload, + create_player_guid_raw(options.character_guid, realm_id()), + create_player_guid_raw(options.peer_character_guid, realm_id()), + create_player_guid_raw(options.killer_character_guid, realm_id()), + )?; + } + if opcode == expected { + return Ok(payload); + } + } + if let Some((opcode, payload)) = read_encrypted_packet_if_ready( + stream, + crypt, + inflater, + Duration::from_millis(1), + remaining, + "loot-race instance packet while forming party", + ) + .await? + { + result.seen_opcodes.push(format!("0x{opcode:04X}")); + validate_party_packet_route_like_cpp(opcode, PartyPacketRoute::Instance)?; + handle_instance_housekeeping(bot_index, stream, crypt, opcode, &payload).await?; + } + } +} + +async fn drain_until_target_or_quiet( + bot_index: usize, + stream: &mut TcpStream, + crypt: &mut WorldCrypt, + inflater: &mut ServerPacketInflater, + realm: &mut EncryptedWorldConnection, + options: &LootRaceOptions, + result: &mut BotRunResult, +) -> Result { + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + let mut seen = false; + while tokio::time::Instant::now() < deadline { + options.sync.cancellation_error()?; + let Some((opcode, payload)) = read_encrypted_packet_if_ready( + stream, + crypt, + inflater, + Duration::from_millis(100), + Duration::from_secs(2), + "loot-race target discovery", + ) + .await? + else { + continue; + }; + result.seen_opcodes.push(format!("0x{opcode:04X}")); + if let Some(counter) = target_seen_in_update(options, opcode, &payload)? { + result.loot_race_target_runtime_counter = Some(counter); + seen = true; + } + handle_instance_housekeeping(bot_index, stream, crypt, opcode, &payload).await?; + if seen { + break; + } + } + // Do not let unrelated queued realm traffic grow without bound. + let _ = read_encrypted_packet_if_ready( + &mut realm.stream, + &mut realm.crypt, + &mut realm.inflater, + Duration::from_millis(1), + Duration::from_secs(1), + "loot-race realm discovery drain", + ) + .await?; + Ok(seen) +} + +async fn kill_target_once( + bot_index: usize, + stream: &mut TcpStream, + crypt: &mut WorldCrypt, + inflater: &mut ServerPacketInflater, + realm: &mut EncryptedWorldConnection, + options: &LootRaceOptions, + result: &mut BotRunResult, +) -> Result<()> { + let (target_low, target_high) = options.resolved_runtime_guid()?; + let (killer_low, killer_high) = + create_player_guid_raw(options.killer_character_guid, realm_id()); + send_encrypted_packet( + stream, + crypt, + CMSG_ATTACK_SWING, + &build_packed_guid(target_low, target_high), + ) + .await?; + + let deadline = tokio::time::Instant::now() + Duration::from_secs(options.timeout_secs); + let mut positive_damage = 0i64; + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + bail!( + "timed out waiting for the disposable loot-race creature to die after {positive_damage} observed damage" + ); + } + if let Some((opcode, payload)) = read_encrypted_packet_if_ready( + stream, + crypt, + inflater, + remaining.min(Duration::from_millis(50)), + remaining, + "loot-race killer combat packet", + ) + .await? + { + result.seen_opcodes.push(format!("0x{opcode:04X}")); + handle_instance_housekeeping(bot_index, stream, crypt, opcode, &payload).await?; + if opcode == SMSG_ATTACKER_STATE_UPDATE { + let update = parse_attacker_state_update_summary(&payload) + .context("malformed loot-race SMSG_ATTACKER_STATE_UPDATE")?; + if (update.attacker_guid_low, update.attacker_guid_high) + == (killer_low, killer_high) + && (update.victim_guid_low, update.victim_guid_high) + == (target_low, target_high) + { + if update.damage < 0 { + bail!( + "loot-race killer reported negative damage {}", + update.damage + ); + } + positive_damage += i64::from(update.damage); + if update.over_damage >= 0 { + if positive_damage == 0 { + bail!("loot-race target death had no positive killer damage evidence"); + } + return Ok(()); + } + } + } else if opcode == SMSG_ATTACK_STOP { + let stop = parse_attack_stop_summary(&payload) + .context("malformed loot-race SMSG_ATTACK_STOP")?; + if (stop.attacker_guid_low, stop.attacker_guid_high) == (killer_low, killer_high) + && (stop.victim_guid_low, stop.victim_guid_high) == (target_low, target_high) + { + if !stop.now_dead { + bail!("server stopped the loot-race attack before the target died"); + } + if positive_damage == 0 { + bail!("loot-race target death had no positive killer damage evidence"); + } + return Ok(()); + } + } + } + if let Some((opcode, _payload)) = read_encrypted_packet_if_ready( + &mut realm.stream, + &mut realm.crypt, + &mut realm.inflater, + Duration::from_millis(1), + remaining, + "loot-race killer realm combat packet", + ) + .await? + { + result.seen_opcodes.push(format!("0x{opcode:04X}")); + } + } +} + +async fn wait_for_loot_window( + bot_index: usize, + stream: &mut TcpStream, + crypt: &mut WorldCrypt, + inflater: &mut ServerPacketInflater, + realm: &mut EncryptedWorldConnection, + options: &LootRaceOptions, + result: &mut BotRunResult, +) -> Result { + let deadline = tokio::time::Instant::now() + Duration::from_secs(options.timeout_secs); + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + bail!("timed out waiting for SMSG_LOOT_RESPONSE"); + } + if let Some((opcode, payload)) = read_encrypted_packet_if_ready( + stream, + crypt, + inflater, + remaining.min(Duration::from_millis(50)), + remaining, + "loot-race loot response", + ) + .await? + { + result.seen_opcodes.push(format!("0x{opcode:04X}")); + if opcode == SMSG_LOOT_RESPONSE { + let response = parse_loot_response(&payload)?; + let expected_items = response + .items + .iter() + .filter(|item| { + item.item_entry == options.target.item_entry && item.quantity == 1 + }) + .collect::>(); + let wrong_item_shape = expected_items.len() != 1 + || (options.phase == LootRacePhase::CaptureItem && response.items.len() != 1); + if wrong_item_shape { + bail!( + "loot response contained {} total rows and {} matching single-item rows for expected entry {}; strict capture requires one of each and race requires exactly one expected row", + response.items.len(), + expected_items.len(), + options.target.item_entry, + ); + } + let item = expected_items[0]; + let (owner_low, owner_high) = options.resolved_runtime_guid()?; + if (response.owner_low, response.owner_high) != (owner_low, owner_high) { + bail!("loot response owner did not match the exact acknowledged world spawn"); + } + validate_loot_object_guid_like_cpp( + response.loot_low, + response.loot_high, + options.target.map_id, + realm_id(), + )?; + let wrong_money_shape = match options.phase { + LootRacePhase::CaptureItem => response.coins != 0, + LootRacePhase::Race => response.coins != RACE_GAMEOBJECT_MONEY, + LootRacePhase::VerifyRelog => true, + }; + let wrong_method = options.phase == LootRacePhase::Race + && response.loot_method != PERSONAL_LOOT_METHOD_LIKE_CPP; + if !response.acquired + || response.failure_reason != 17 + || wrong_money_shape + || wrong_method + { + bail!( + "loot response did not match the acquired item/money/method contract for {:?} (failure={}, acquired={}, coins={}, method={})", + options.phase, + response.failure_reason, + response.acquired, + response.coins, + response.loot_method + ); + } + return Ok(LootWindow { + owner_low: response.owner_low, + owner_high: response.owner_high, + loot_low: response.loot_low, + loot_high: response.loot_high, + coins: response.coins, + item_entry: item.item_entry, + quantity: item.quantity, + loot_list_id: item.loot_list_id, + loot_method: response.loot_method, + }); + } + handle_instance_housekeeping(bot_index, stream, crypt, opcode, &payload).await?; + } + let _ = read_encrypted_packet_if_ready( + &mut realm.stream, + &mut realm.crypt, + &mut realm.inflater, + Duration::from_millis(1), + remaining, + "loot-race realm while opening", + ) + .await?; + } +} + +async fn collect_evidence( + bot_index: usize, + stream: &mut TcpStream, + crypt: &mut WorldCrypt, + inflater: &mut ServerPacketInflater, + realm: &mut EncryptedWorldConnection, + expected_loot_owner: (u64, u64), + window: Duration, + options: &LootRaceOptions, + result: &mut BotRunResult, +) -> Result { + let deadline = tokio::time::Instant::now() + window; + let mut evidence = WireEvidence::default(); + while tokio::time::Instant::now() < deadline { + options.sync.cancellation_error()?; + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if let Some((opcode, payload)) = read_encrypted_packet_if_ready( + &mut realm.stream, + &mut realm.crypt, + &mut realm.inflater, + remaining.min(Duration::from_millis(20)), + remaining, + "loot-race realm evidence", + ) + .await? + { + result.seen_opcodes.push(format!("0x{opcode:04X}")); + record_evidence(opcode, &payload, expected_loot_owner, &mut evidence)?; + } + if let Some((opcode, payload)) = read_encrypted_packet_if_ready( + stream, + crypt, + inflater, + Duration::from_millis(1), + remaining, + "loot-race instance evidence", + ) + .await? + { + result.seen_opcodes.push(format!("0x{opcode:04X}")); + record_evidence(opcode, &payload, expected_loot_owner, &mut evidence)?; + handle_instance_housekeeping(bot_index, stream, crypt, opcode, &payload).await?; + } + } + Ok(evidence) +} + +fn record_evidence( + opcode: u16, + payload: &[u8], + expected_loot_owner: (u64, u64), + evidence: &mut WireEvidence, +) -> Result<()> { + match opcode { + SMSG_ITEM_PUSH_RESULT => evidence.item_pushes.push(parse_item_push(payload)?), + SMSG_LOOT_REMOVED => { + let (owner_used, owner_low, owner_high) = parse_packed_guid(payload) + .ok_or_else(|| anyhow!("malformed SMSG_LOOT_REMOVED owner guid"))?; + if (owner_low, owner_high) != expected_loot_owner { + bail!( + "SMSG_LOOT_REMOVED owner ({owner_low:#x}, {owner_high:#x}) did not match discovered world-object GUID ({:#x}, {:#x})", + expected_loot_owner.0, + expected_loot_owner.1 + ); + } + let (loot_used, loot_low, loot_high) = parse_packed_guid(&payload[owner_used..]) + .ok_or_else(|| anyhow!("malformed SMSG_LOOT_REMOVED loot guid"))?; + let list_id = *payload + .get(owner_used + loot_used) + .ok_or_else(|| anyhow!("malformed SMSG_LOOT_REMOVED list id"))?; + if owner_used + loot_used + 1 != payload.len() { + bail!("SMSG_LOOT_REMOVED has unexpected trailing bytes"); + } + evidence.loot_removed.push(LootRemovedEvidence { + owner_low, + owner_high, + loot_low, + loot_high, + loot_list_id: list_id, + }); + } + SMSG_LOOT_MONEY_NOTIFY => { + if payload.len() != 17 { + bail!("malformed SMSG_LOOT_MONEY_NOTIFY"); + } + evidence.money_notifies.push(MoneyNotify { + money: u64::from_le_bytes(payload[0..8].try_into()?), + money_mod: u64::from_le_bytes(payload[8..16].try_into()?), + sole_looter: payload[16] & 0x80 != 0, + }); + } + SMSG_COIN_REMOVED => { + let (used, low, high) = + parse_packed_guid(payload).ok_or_else(|| anyhow!("malformed SMSG_COIN_REMOVED"))?; + if used != payload.len() { + bail!("SMSG_COIN_REMOVED has unexpected trailing bytes"); + } + evidence.coin_removed.push((low, high)); + } + SMSG_INVENTORY_CHANGE_FAILURE => evidence + .inventory_failures + .push(parse_inventory_failure(payload)?), + _ => {} + } + Ok(()) +} + +async fn validate_shared_windows(options: &LootRaceOptions) -> Result<()> { + let windows = options.sync.windows.lock().await; + let left = windows[0] + .as_ref() + .ok_or_else(|| anyhow!("bot A did not record a loot window"))?; + let right = windows[1] + .as_ref() + .ok_or_else(|| anyhow!("bot B did not record a loot window"))?; + if left != right { + bail!("two bots did not receive the same shared loot authority: {left:?} vs {right:?}"); + } + Ok(()) +} + +/// Validate the exact `ObjectGuid::Create` wire shape. +/// +/// C++ `ObjectGuidFactory::CreateWorldObject` substitutes the active realm +/// when its realm argument is zero, then encodes a map-local LootObject with +/// zero subtype/server/entry and a nonzero map sequence counter. +fn validate_loot_object_guid_like_cpp( + low: u64, + high: u64, + expected_map_id: u16, + expected_realm_id: u32, +) -> Result<()> { + let high_type = (high >> 58) & GUID_HIGH_TYPE_MASK; + let realm = (high >> 42) & GUID_REALM_MASK; + let map = (high >> 29) & GUID_MAP_MASK; + let entry = (high >> 6) & GUID_ENTRY_MASK; + let subtype = high & GUID_SUBTYPE_MASK; + let server = (low >> 40) & GUID_SERVER_MASK; + let counter = low & GUID_COUNTER_MASK; + let expected_realm = u64::from(expected_realm_id) & GUID_REALM_MASK; + let expected_map = u64::from(expected_map_id) & GUID_MAP_MASK; + + if high_type != HIGH_GUID_LOOT_OBJECT + || realm != expected_realm + || map != expected_map + || entry != 0 + || subtype != 0 + || server != 0 + || counter == 0 + { + bail!( + "loot response LootObject GUID has invalid C++ structure: type={high_type}, realm={realm}, map={map}, entry={entry}, subtype={subtype}, server={server}, counter={counter}; expected type={HIGH_GUID_LOOT_OBJECT}, realm={expected_realm}, map={expected_map}, entry/subtype/server=0 and nonzero counter" + ); + } + + Ok(()) +} + +async fn validate_item_outcome(options: &LootRaceOptions, result: &mut BotRunResult) -> Result<()> { + let window = options.sync.windows.lock().await[options.participant] + .clone() + .ok_or_else(|| anyhow!("loot-race participant has no recorded loot window"))?; + let (owner_low, owner_high) = options.resolved_runtime_guid()?; + let expected_removal = LootRemovedEvidence { + owner_low, + owner_high, + loot_low: window.loot_low, + loot_high: window.loot_high, + loot_list_id: window.loot_list_id, + }; + let evidence = options.sync.evidence.lock().await; + let character_guids = if options.participant == 0 { + [options.character_guid, options.peer_character_guid] + } else { + [options.peer_character_guid, options.character_guid] + }; + let grant = validate_atomic_item_wire_outcome_like_cpp( + &evidence, + character_guids, + options.target.item_entry, + window.quantity, + expected_removal, + realm_id(), + )?; + // `ItemPushResult::PlayerGUID`, rather than the receiving socket, names + // the winner. This remains correct when #55 adds C++'s group broadcast. + result.loot_race_item_push_seen = grant.owner_guid == options.character_guid; + result.loot_race_loot_removed_seen = + evidence[options.participant].loot_removed == [expected_removal]; + Ok(()) +} + +/// Prove one logical item grant without mistaking C++ party fanout for a +/// duplicate grant. +/// +/// Issue #106 owns atomic claim authority, while the complete +/// `StoreLootItem` side-effect cascade remains #55. The current Rust path may +/// send only to the winner; C++ broadcasts the same packet to both group +/// members for the default item. Both shapes represent one grant, but any +/// second packet on one socket or any divergent packet fails closed. +fn validate_atomic_item_wire_outcome_like_cpp( + evidence: &[WireEvidence; 2], + character_guids: [u64; 2], + expected_item_entry: u32, + expected_quantity: u32, + expected_removal: LootRemovedEvidence, + expected_realm_id: u32, +) -> Result { + for (participant, entry) in evidence.iter().enumerate() { + if entry.loot_removed.as_slice() != [expected_removal] { + bail!( + "participant {participant} observed LootRemoved fanout {:?}; expected exactly {:?}", + entry.loot_removed, + expected_removal + ); + } + if entry.item_pushes.len() > 1 { + bail!( + "participant {participant} observed {} ItemPush packets; one logical grant permits at most one observation per socket", + entry.item_pushes.len() + ); + } + } + + // Deliberately inspect every ItemPush before checking the expected entry. + // Filtering by entry would hide a foreign or late duplicate grant. + let wire_grants = evidence + .iter() + .enumerate() + .flat_map(|(participant, entry)| { + entry + .item_pushes + .iter() + .copied() + .map(move |push| WireItemGrant { participant, push }) + }) + .collect::>(); + let first = wire_grants + .first() + .copied() + .ok_or_else(|| anyhow!("atomic ITEM race emitted no ItemPush result"))?; + if wire_grants.iter().any(|grant| grant.push != first.push) { + bail!("atomic ITEM race emitted divergent ItemPush observations: {wire_grants:?}"); + } + + let push = first.push; + let expected_quantity = i32::try_from(expected_quantity) + .map_err(|_| anyhow!("loot-race expected item quantity exceeds i32"))?; + if push.item_entry != expected_item_entry + || push.quantity != expected_quantity + || push.quantity_in_inventory != expected_quantity + { + bail!( + "atomic ITEM push entry/quantity/inventory {:?} did not match expected {expected_item_entry}/{expected_quantity}/{expected_quantity}", + (push.item_entry, push.quantity, push.quantity_in_inventory) + ); + } + if push.slot != INVENTORY_SLOT_BAG_0 + || !(i32::from(INVENTORY_SLOT_ITEM_START)..i32::from(INVENTORY_SLOT_ITEM_START + 16)) + .contains(&push.slot_in_bag) + { + bail!( + "atomic ITEM push slot {}/{} was not one of the preflight-guaranteed base-backpack destinations", + push.slot, + push.slot_in_bag + ); + } + if push.pushed + || push.created + || push.display_text != 1 + || push.is_bonus_roll + || push.is_encounter_loot + || push.dungeon_encounter_id != 0 + { + bail!( + "atomic ITEM push flags were not the ordinary overworld StoreLootItem shape: {push:?}" + ); + } + let expected_item_high = + (HIGH_GUID_ITEM << 58) | ((u64::from(expected_realm_id) & GUID_REALM_SPECIFIC_MASK) << 42); + if push.item_guid_low == 0 + || push.item_guid_low & !GUID_COUNTER_MASK != 0 + || push.item_guid_high != expected_item_high + { + bail!( + "atomic ITEM push GUID {:#018X}/{:#018X} was not a nonempty C++ Item GUID for realm {}", + push.item_guid_low, + push.item_guid_high, + expected_realm_id + ); + } + + let winner = character_guids + .iter() + .position(|guid| { + create_player_guid_raw(*guid, expected_realm_id) == (push.player_low, push.player_high) + }) + .ok_or_else(|| { + anyhow!( + "atomic ITEM push winner {:#018X}/{:#018X} was neither disposable character", + push.player_low, + push.player_high + ) + })?; + let loser = 1 - winner; + if evidence[winner].item_pushes.as_slice() != [push] { + bail!("atomic ITEM winner socket did not receive exactly its direct ItemPush"); + } + if !evidence[loser].item_pushes.is_empty() && evidence[loser].item_pushes.as_slice() != [push] { + bail!("atomic ITEM loser observed a non-identical group ItemPush"); + } + + let loot_gone = InventoryFailure { + result: 50, + item_0_low: 0, + item_0_high: 0, + item_1_low: 0, + item_1_high: 0, + container_b_slot: 0, + }; + if !evidence[winner].inventory_failures.is_empty() + || evidence[loser].inventory_failures.as_slice() != [loot_gone] + { + bail!( + "atomic ITEM failure fanout was winner={:?}, loser={:?}; expected no winner failure and one exact EQUIP_ERR_LOOT_GONE (50)", + evidence[winner].inventory_failures, + evidence[loser].inventory_failures + ); + } + + Ok(ExpectedPersistedItemGrant { + owner_guid: character_guids[winner], + push, + }) +} + +async fn validate_money_outcome( + options: &LootRaceOptions, + result: &mut BotRunResult, +) -> Result<()> { + let window = options.sync.windows.lock().await[options.participant] + .clone() + .ok_or_else(|| anyhow!("loot-race participant has no recorded loot window"))?; + let evidence = options.sync.evidence.lock().await; + let source_coins = u64::from(window.coins); + let expected_source = (window.loot_low, window.loot_high); + let winner = validate_serialized_gameobject_money_wire_outcome_like_cpp( + &evidence, + expected_source, + source_coins, + )?; + result.loot_race_money_notify_amount = Some(if winner == options.participant { + source_coins + } else { + 0 + }); + result.loot_race_coin_removed_seen = evidence[options.participant] + .coin_removed + .contains(&expected_source); + Ok(()) +} + +/// Validate the two serialized C++ `HandleLootMoneyOpcode` observations. +/// +/// Both clients send `CMSG_LOOT_MONEY`. For `LOOT_CHEST`, C++ deliberately +/// keeps `shareMoney=false`: the first serialized requester receives the whole +/// pool with `SoleLooter=true`, then `Loot::LootMoney()` sets gold to zero. The +/// second requester receives one zero notification. Each request still calls +/// `Loot::NotifyMoneyRemoved`, so both active viewers observe two removals. +fn validate_serialized_gameobject_money_wire_outcome_like_cpp( + evidence: &[WireEvidence; 2], + expected_source: (u64, u64), + expected_pool: u64, +) -> Result { + if expected_pool == 0 { + bail!("loot-race cannot prove a positive serialized money winner"); + } + + let positive = MoneyNotify { + money: expected_pool, + money_mod: 0, + sole_looter: true, + }; + let zero = MoneyNotify { + money: 0, + money_mod: 0, + sole_looter: true, + }; + + let mut winner = None; + for (participant, entry) in evidence.iter().enumerate() { + match entry.money_notifies.as_slice() { + [notify] if *notify == positive => { + if winner.replace(participant).is_some() { + bail!("loot-race observed more than one positive chest-money winner"); + } + } + [notify] if *notify == zero => {} + _ => { + bail!( + "loot-race participant {participant} observed money notifications {:?}; C++ LOOT_CHEST requires one requester-local whole-pool {expected_pool} or serialized zero notification, both SoleLooter=true", + entry.money_notifies + ); + } + } + + let matching_removals = entry + .coin_removed + .iter() + .filter(|source| **source == expected_source) + .count(); + if entry.coin_removed.len() != 2 || matching_removals != 2 { + bail!( + "loot-race participant {participant} observed CoinRemoved sources {:?}; C++ calls NotifyMoneyRemoved once for each of the two serialized requests", + entry.coin_removed + ); + } + } + + winner.ok_or_else(|| anyhow!("loot-race emitted no positive chest-money winner")) +} + +async fn handle_instance_housekeeping( + _bot_index: usize, + stream: &mut TcpStream, + crypt: &mut WorldCrypt, + opcode: u16, + payload: &[u8], +) -> Result<()> { + if opcode == SMSG_TIME_SYNC_REQUEST { + let sequence = parse_time_sync_request_sequence(payload)?; + let response = build_time_sync_response_payload(sequence, current_millis_u32()); + send_encrypted_packet(stream, crypt, CMSG_TIME_SYNC_RESPONSE, &response).await?; + } + Ok(()) +} + +fn build_party_invite(name: &str, target_low: u64, target_high: u64) -> Result> { + if name.len() > 0x1FF { + bail!("party invite name is too long"); + } + // C++ reads HasPartyIndex as one bit and then ResetBitPos(), so the two + // string lengths begin at the next byte rather than sharing that bit byte. + let mut payload = vec![0]; + payload.extend_from_slice(&pack_msb_fields(&[(name.len() as u32, 9), (0, 9)])); + payload.extend_from_slice(&0u32.to_le_bytes()); + payload.extend_from_slice(&build_packed_guid(target_low, target_high)); + payload.extend_from_slice(name.as_bytes()); + Ok(payload) +} + +fn build_loot_item_claim(window: &LootWindow) -> Vec { + let mut payload = 1u32.to_le_bytes().to_vec(); + payload.extend_from_slice(&build_packed_guid(window.loot_low, window.loot_high)); + payload.push(window.loot_list_id); + payload.push(0); // IsSoftInteract=false, flushed to a complete byte. + payload +} + +fn pack_msb_fields(fields: &[(u32, usize)]) -> Vec { + let mut bytes = Vec::new(); + let mut current = 0u8; + let mut used = 0usize; + for &(value, width) in fields { + for bit in (0..width).rev() { + current |= (((value >> bit) & 1) as u8) << (7 - used); + used += 1; + if used == 8 { + bytes.push(current); + current = 0; + used = 0; + } + } + } + if used != 0 { + bytes.push(current); + } + bytes +} + +#[derive(Debug)] +struct ParsedLootItem { + item_entry: u32, + quantity: u32, + loot_list_id: u8, +} + +#[derive(Debug)] +struct ParsedLootResponse { + owner_low: u64, + owner_high: u64, + loot_low: u64, + loot_high: u64, + failure_reason: u8, + loot_method: u8, + coins: u32, + acquired: bool, + items: Vec, +} + +fn parse_loot_response(payload: &[u8]) -> Result { + let (owner_used, owner_low, owner_high) = + parse_packed_guid(payload).ok_or_else(|| anyhow!("malformed loot response owner guid"))?; + let (loot_used, loot_low, loot_high) = parse_packed_guid(&payload[owner_used..]) + .ok_or_else(|| anyhow!("malformed loot response loot guid"))?; + let mut offset = owner_used + loot_used; + let failure_reason = take_u8(payload, &mut offset)?; + let _acquire_reason = take_u8(payload, &mut offset)?; + let loot_method = take_u8(payload, &mut offset)?; + let _threshold = take_u8(payload, &mut offset)?; + let coins = take_u32(payload, &mut offset)?; + let item_count = take_u32(payload, &mut offset)? as usize; + let currency_count = take_u32(payload, &mut offset)? as usize; + let bits = take_u8(payload, &mut offset)?; + let acquired = bits & 0x80 != 0; + let mut items = Vec::with_capacity(item_count); + for _ in 0..item_count { + let _item_bits = take_u8(payload, &mut offset)?; + let item_entry = take_i32(payload, &mut offset)?; + if item_entry <= 0 { + bail!("loot response has nonpositive item entry {item_entry}"); + } + let _random_seed = take_i32(payload, &mut offset)?; + let _random_property = take_i32(payload, &mut offset)?; + let has_bonus = take_u8(payload, &mut offset)? & 0x80 != 0; + let mod_count = usize::from(take_u8(payload, &mut offset)? >> 2); + offset = offset + .checked_add(mod_count * 5) + .filter(|end| *end <= payload.len()) + .ok_or_else(|| anyhow!("loot response item modifications are truncated"))?; + if has_bonus { + let _context = take_u8(payload, &mut offset)?; + let bonus_count = take_u32(payload, &mut offset)? as usize; + offset = offset + .checked_add(bonus_count * 4) + .filter(|end| *end <= payload.len()) + .ok_or_else(|| anyhow!("loot response item bonuses are truncated"))?; + } + let quantity = take_u32(payload, &mut offset)?; + let _loot_item_type = take_u8(payload, &mut offset)?; + let loot_list_id = take_u8(payload, &mut offset)?; + items.push(ParsedLootItem { + item_entry: item_entry as u32, + quantity, + loot_list_id, + }); + } + // The fixture forbids currencies; parsing their variable bit tail is not + // needed and accepting it would make the preflight weaker. + if currency_count != 0 { + bail!("loot-race fixture unexpectedly produced {currency_count} currencies"); + } + if offset != payload.len() { + bail!( + "loot response has {} unexpected trailing bytes", + payload.len() - offset + ); + } + Ok(ParsedLootResponse { + owner_low, + owner_high, + loot_low, + loot_high, + failure_reason, + loot_method, + coins, + acquired, + items, + }) +} + +fn parse_item_push(payload: &[u8]) -> Result { + let (player_used, player_low, player_high) = parse_packed_guid(payload) + .ok_or_else(|| anyhow!("malformed SMSG_ITEM_PUSH_RESULT player guid"))?; + let mut offset = player_used; + let slot = take_u8(payload, &mut offset)?; + let slot_in_bag = take_i32(payload, &mut offset)?; + let quest_log_item_id = take_i32(payload, &mut offset)?; + let quantity = take_i32(payload, &mut offset)?; + let quantity_in_inventory = take_i32(payload, &mut offset)?; + let dungeon_encounter_id = take_i32(payload, &mut offset)?; + // Battle-pet metadata is part of the 3.4.3 layout but is unrelated to + // this ordinary item fixture. Consume it so the following GUID/bit fields + // remain aligned. + for _ in 0..4 { + let _ = take_i32(payload, &mut offset)?; + } + let (item_guid_used, item_guid_low, item_guid_high) = parse_packed_guid(&payload[offset..]) + .ok_or_else(|| anyhow!("malformed SMSG_ITEM_PUSH_RESULT item guid"))?; + offset += item_guid_used; + let flags = take_u8(payload, &mut offset)?; + if flags & 0x01 != 0 { + bail!("SMSG_ITEM_PUSH_RESULT has a nonzero reserved padding bit"); + } + let pushed = flags & 0x80 != 0; + let created = flags & 0x40 != 0; + let display_text = (flags >> 3) & 0x07; + let is_bonus_roll = flags & 0x04 != 0; + let is_encounter_loot = flags & 0x02 != 0; + let item_entry = take_i32(payload, &mut offset)?; + if item_entry <= 0 { + bail!("item push has nonpositive item entry {item_entry}"); + } + let _random_properties_seed = take_i32(payload, &mut offset)?; + let _random_properties_id = take_i32(payload, &mut offset)?; + let item_bonus_bits = take_u8(payload, &mut offset)?; + if item_bonus_bits & 0x7F != 0 { + bail!("SMSG_ITEM_PUSH_RESULT ItemBonus has nonzero padding bits"); + } + let has_item_bonus = item_bonus_bits & 0x80 != 0; + let modification_bits = take_u8(payload, &mut offset)?; + if modification_bits & 0x03 != 0 { + bail!("SMSG_ITEM_PUSH_RESULT ItemModList has nonzero padding bits"); + } + let modification_count = usize::from(modification_bits >> 2); + for _ in 0..modification_count { + let _value = take_i32(payload, &mut offset)?; + let _modifier_type = take_u8(payload, &mut offset)?; + } + if has_item_bonus { + let _context = take_u8(payload, &mut offset)?; + let bonus_count = usize::try_from(take_u32(payload, &mut offset)?)?; + let bonus_bytes = bonus_count + .checked_mul(std::mem::size_of::()) + .ok_or_else(|| anyhow!("SMSG_ITEM_PUSH_RESULT bonus-list length overflow"))?; + offset = offset + .checked_add(bonus_bytes) + .filter(|end| *end <= payload.len()) + .ok_or_else(|| anyhow!("SMSG_ITEM_PUSH_RESULT item bonuses are truncated"))?; + } + if offset != payload.len() { + bail!( + "SMSG_ITEM_PUSH_RESULT has {} unexpected trailing bytes", + payload.len() - offset + ); + } + Ok(ItemPush { + player_low, + player_high, + slot, + slot_in_bag, + quest_log_item_id, + quantity, + quantity_in_inventory, + dungeon_encounter_id, + item_guid_low, + item_guid_high, + pushed, + created, + display_text, + is_bonus_roll, + is_encounter_loot, + item_entry: item_entry as u32, + }) +} + +fn parse_inventory_failure(payload: &[u8]) -> Result { + if payload.len() < 4 { + bail!("malformed SMSG_INVENTORY_CHANGE_FAILURE result"); + } + let result = i32::from_le_bytes(payload[0..4].try_into()?); + let (item_0_used, item_0_low, item_0_high) = parse_packed_guid(&payload[4..]) + .ok_or_else(|| anyhow!("malformed SMSG_INVENTORY_CHANGE_FAILURE item 0"))?; + let item_1_offset = 4 + item_0_used; + let (item_1_used, item_1_low, item_1_high) = parse_packed_guid(&payload[item_1_offset..]) + .ok_or_else(|| anyhow!("malformed SMSG_INVENTORY_CHANGE_FAILURE item 1"))?; + let end = item_1_offset + item_1_used; + let container_b_slot = *payload + .get(end) + .ok_or_else(|| anyhow!("malformed SMSG_INVENTORY_CHANGE_FAILURE container slot"))?; + // EQUIP_ERR_LOOT_GONE has no result-specific tail in C++. + if result == 50 && end + 1 != payload.len() { + bail!("EQUIP_ERR_LOOT_GONE has unexpected trailing bytes"); + } + Ok(InventoryFailure { + result, + item_0_low, + item_0_high, + item_1_low, + item_1_high, + container_b_slot, + }) +} + +fn take_u8(payload: &[u8], offset: &mut usize) -> Result { + let value = *payload + .get(*offset) + .ok_or_else(|| anyhow!("packet truncated at byte {}", *offset))?; + *offset += 1; + Ok(value) +} + +fn take_u32(payload: &[u8], offset: &mut usize) -> Result { + let end = offset + .checked_add(4) + .ok_or_else(|| anyhow!("packet offset overflow"))?; + let bytes: [u8; 4] = payload + .get(*offset..end) + .ok_or_else(|| anyhow!("packet truncated at byte {}", *offset))? + .try_into()?; + *offset = end; + Ok(u32::from_le_bytes(bytes)) +} + +fn take_i32(payload: &[u8], offset: &mut usize) -> Result { + Ok(take_u32(payload, offset)? as i32) +} + +fn generated_fixture_health_like_cpp(base_health: u32, health_modifier: f32) -> u32 { + (base_health as f32 * health_modifier).ceil() as u32 +} + +fn loot_db_opts(url: &str, label: &str) -> Result { + let opts = + mysql::Opts::from_url(url).map_err(|error| anyhow!("Bad {label} DB URL: {error}"))?; + Ok(mysql::OptsBuilder::from_opts(opts) + .tcp_connect_timeout(Some(Duration::from_secs(10))) + .read_timeout(Some(Duration::from_secs(LOOT_DB_OPERATION_TIMEOUT_SECS))) + .write_timeout(Some(Duration::from_secs(LOOT_DB_OPERATION_TIMEOUT_SECS))) + .into()) +} + +fn validate_guarded_fixture_health(health_modifier: f32, generated_max_health: u32) -> Result<()> { + if (health_modifier - GUARDED_FIXTURE_HEALTH_MODIFIER).abs() > 0.000_000_1 + || generated_max_health != 1 + { + bail!( + "loot fixture must be loaded through the pre-start health guard: expected HealthModifier {GUARDED_FIXTURE_HEALTH_MODIFIER} and generated base health 1, got modifier {health_modifier} and health {generated_max_health}; restart the exact world artifact with RUST_CAPTURE_LOOT_FIXTURE_GUARD=1" + ); + } + Ok(()) +} + +fn prepare_gameobject_race_fixture( + bots: &[config::BotConfig], + cli: &LootRaceCli, + shutdown: &CancellationToken, +) -> Result { + if shutdown.is_cancelled() { + bail!("loot-race cancelled before GameObject fixture preflight"); + } + if (cli.entry, cli.spawn_guid, cli.item_entry) + != ( + DEFAULT_CREATURE_ENTRY, + DEFAULT_CREATURE_SPAWN_GUID, + DEFAULT_ITEM_ENTRY, + ) + { + bail!("GameObject race fixture did not match the pinned wrapper-owned contract"); + } + if cli.runtime_counter != 0 && cli.runtime_counter != cli.spawn_guid { + bail!( + "GameObject runtime counter override {} must be zero (wire discovery) or exact spawn {}", + cli.runtime_counter, + cli.spawn_guid + ); + } + + let world_url = world_db_url()?; + let world_opts = loot_db_opts(&world_url, "world")?; + let mut world = mysql::Conn::new(world_opts) + .map_err(|error| anyhow!("Connect to world DB failed: {error}"))?; + let spawn: mysql::Row = world + .exec_first( + "SELECT guid, id, map, zoneId, areaId, spawnDifficulties, phaseUseFlags, \ + PhaseId, PhaseGroup, terrainSwapMap, position_x, position_y, position_z, \ + orientation, rotation0, rotation1, rotation2, rotation3, spawntimesecs, \ + animprogress, state, ScriptName, StringId, VerifiedBuild \ + FROM gameobject WHERE guid = ?", + (cli.spawn_guid,), + ) + .map_err(|error| anyhow!("Load wrapper-owned GameObject race spawn: {error}"))? + .ok_or_else(|| { + anyhow!( + "wrapper-owned world.gameobject spawn {} is absent; start the world through the loot-race fixture guard", + cli.spawn_guid + ) + })?; + let spawn_guid: u64 = required_row_value(&spawn, "guid")?; + let entry: u32 = required_row_value(&spawn, "id")?; + let map_id: u16 = required_row_value(&spawn, "map")?; + let zone_id: u16 = required_row_value(&spawn, "zoneId")?; + let area_id: u16 = required_row_value(&spawn, "areaId")?; + let difficulties: String = required_row_value(&spawn, "spawnDifficulties")?; + let phase_flags: u8 = required_row_value(&spawn, "phaseUseFlags")?; + let phase_id: i32 = required_row_value(&spawn, "PhaseId")?; + let phase_group: i32 = required_row_value(&spawn, "PhaseGroup")?; + let terrain_swap_map: i32 = required_row_value(&spawn, "terrainSwapMap")?; + let x: f64 = required_row_value(&spawn, "position_x")?; + let y: f64 = required_row_value(&spawn, "position_y")?; + let z: f64 = required_row_value(&spawn, "position_z")?; + let orientation: f32 = required_row_value(&spawn, "orientation")?; + let rotations = [ + required_row_value::(&spawn, "rotation0")?, + required_row_value::(&spawn, "rotation1")?, + required_row_value::(&spawn, "rotation2")?, + required_row_value::(&spawn, "rotation3")?, + ]; + let spawntime: u32 = required_row_value(&spawn, "spawntimesecs")?; + let anim_progress: u8 = required_row_value(&spawn, "animprogress")?; + let state: u8 = required_row_value(&spawn, "state")?; + let spawn_script: String = required_row_value(&spawn, "ScriptName")?; + let string_id: Option = required_row_value(&spawn, "StringId")?; + let verified_build: i32 = required_row_value(&spawn, "VerifiedBuild")?; + let position_matches = (x - RACE_GAMEOBJECT_X).abs() <= 0.01 + && (y - RACE_GAMEOBJECT_Y).abs() <= 0.01 + && (z - RACE_GAMEOBJECT_Z).abs() <= 0.01; + if spawn_guid != DEFAULT_CREATURE_SPAWN_GUID + || entry != DEFAULT_CREATURE_ENTRY + || map_id != RACE_GAMEOBJECT_MAP_ID + || zone_id != 0 + || area_id != 0 + || difficulties != "0" + || phase_flags != 0 + || phase_id != 0 + || phase_group != 0 + || terrain_swap_map != -1 + || !position_matches + || orientation.abs() > f32::EPSILON + || rotations + .iter() + .any(|rotation| rotation.abs() > f32::EPSILON) + || spawntime != RACE_GAMEOBJECT_RESPAWN_SECS + || anim_progress != RACE_GAMEOBJECT_ANIM_PROGRESS + || state != RACE_GAMEOBJECT_STATE + || !spawn_script.is_empty() + || string_id.is_some() + || verified_build != 0 + { + bail!( + "wrapper-owned GameObject spawn drifted from the exact QA contract: guid={spawn_guid} entry={entry} map={map_id} zone={zone_id} area={area_id} difficulties={difficulties:?} phase={phase_flags}/{phase_id}/{phase_group} terrain={terrain_swap_map} pos=({x},{y},{z}) orientation={orientation} rotations={rotations:?} respawn={spawntime} anim={anim_progress} state={state} script={spawn_script:?} string_id={string_id:?} build={verified_build}" + ); + } + + let template: mysql::Row = world + .exec_first( + "SELECT type, displayId, name, size, \ + Data0, Data1, Data2, Data3, Data4, Data5, Data6, Data7, Data8, Data9, \ + Data10, Data11, Data12, Data13, Data14, Data15, Data16, Data17, Data18, Data19, \ + Data20, Data21, Data22, Data23, Data24, Data25, Data26, Data27, Data28, Data29, \ + Data30, Data31, Data32, Data33, Data34, ContentTuningId, AIName, ScriptName, \ + StringId, VerifiedBuild \ + FROM gameobject_template WHERE entry = ?", + (entry,), + ) + .map_err(|error| anyhow!("Load Tattered Chest template: {error}"))? + .ok_or_else(|| anyhow!("Tattered Chest template {entry} is absent"))?; + let go_type: u8 = required_row_value(&template, "type")?; + let display_id: u32 = required_row_value(&template, "displayId")?; + let name: String = required_row_value(&template, "name")?; + let size: f32 = required_row_value(&template, "size")?; + let mut template_data = [0_i32; 35]; + for (index, value) in template_data.iter_mut().enumerate() { + *value = required_row_value(&template, &format!("Data{index}"))?; + } + let content_tuning_id: u32 = required_row_value(&template, "ContentTuningId")?; + let ai_name: String = required_row_value(&template, "AIName")?; + let template_script: String = required_row_value(&template, "ScriptName")?; + let template_string_id: Option = required_row_value(&template, "StringId")?; + let template_build: i32 = required_row_value(&template, "VerifiedBuild")?; + if go_type != 3 + || display_id != 259 + || name != "Tattered Chest" + || (size - 1.0).abs() > f32::EPSILON + || template_data != RACE_GAMEOBJECT_TEMPLATE_DATA + || content_tuning_id != 0 + || !ai_name.is_empty() + || !template_script.is_empty() + || template_string_id.is_some() + || template_build != 11_723 + { + bail!( + "Tattered Chest template/data drifted from the exact shared C++ fixture contract: type={go_type} display={display_id} name={name:?} size={size} data={template_data:?} content_tuning={content_tuning_id} ai={ai_name:?} script={template_script:?} string_id={template_string_id:?} build={template_build}" + ); + } + + let loot_rows: Vec<(u32, u32, f32, u8, u16, u8, u8, u8)> = world + .exec( + "SELECT Item, Reference, Chance, QuestRequired, LootMode, GroupId, MinCount, MaxCount \ + FROM gameobject_loot_template WHERE Entry = ? ORDER BY Item, Reference", + (RACE_GAMEOBJECT_LOOT_ID,), + ) + .map_err(|error| anyhow!("Load Tattered Chest loot template: {error}"))?; + if loot_rows.len() != 1 + || loot_rows[0].0 != DEFAULT_ITEM_ENTRY + || loot_rows[0].1 != 0 + || (loot_rows[0].2 - 100.0).abs() > f32::EPSILON + || loot_rows[0].3 != 0 + || loot_rows[0].4 != 1 + || loot_rows[0].5 != 0 + || loot_rows[0].6 != 1 + || loot_rows[0].7 != 1 + { + bail!( + "GameObject loot id {RACE_GAMEOBJECT_LOOT_ID} was not exactly one unconditional item-{DEFAULT_ITEM_ENTRY} grant: {loot_rows:?}" + ); + } + let loot_conditions: u64 = world + .exec_first( + "SELECT COUNT(*) FROM conditions \ + WHERE SourceTypeOrReferenceId = 4 AND SourceGroup = ?", + (RACE_GAMEOBJECT_LOOT_ID,), + ) + .map_err(|error| anyhow!("Check Tattered Chest loot conditions: {error}"))? + .unwrap_or(0); + let addon_rows: Vec<(u16, u32, u32, u32, i32, i32, i32, i32, i32, u32, u32)> = world + .exec( + "SELECT faction, flags, Mingold, Maxgold, artkit0, artkit1, artkit2, artkit3, artkit4, \ + WorldEffectID, AIAnimKitID \ + FROM gameobject_template_addon WHERE entry = ?", + (entry,), + ) + .map_err(|error| anyhow!("Load guarded Tattered Chest addon: {error}"))?; + if addon_rows.as_slice() + != [( + RACE_GAMEOBJECT_ADDON_FACTION, + 0, + RACE_GAMEOBJECT_MONEY, + RACE_GAMEOBJECT_MONEY, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + )] + { + bail!( + "wrapper-owned GameObject addon must match exact faction/flags/artkits/effects and deterministic money {0}/{0}, got {addon_rows:?}", + RACE_GAMEOBJECT_MONEY + ); + } + let event_rows: u64 = world + .exec_first( + "SELECT COUNT(*) FROM game_event_gameobject WHERE guid = ?", + (spawn_guid,), + ) + .map_err(|error| anyhow!("Check GameObject event ownership: {error}"))? + .unwrap_or(0); + let pool_rows: u64 = world + .exec_first( + "SELECT COUNT(*) FROM pool_members WHERE type = 1 AND spawnId = ?", + (spawn_guid,), + ) + .map_err(|error| anyhow!("Check GameObject pool ownership: {error}"))? + .unwrap_or(0); + let linked_rows: u64 = world + .exec_first( + "SELECT COUNT(*) FROM linked_respawn WHERE guid = ? OR linkedGuid = ?", + (spawn_guid, spawn_guid), + ) + .map_err(|error| anyhow!("Check GameObject linked respawn: {error}"))? + .unwrap_or(0); + let spawn_addon_rows: u64 = world + .exec_first( + "SELECT COUNT(*) FROM gameobject_addon WHERE guid = ?", + (spawn_guid,), + ) + .map_err(|error| anyhow!("Check GameObject spawn addon ownership: {error}"))? + .unwrap_or(0); + let override_rows: u64 = world + .exec_first( + "SELECT COUNT(*) FROM gameobject_overrides WHERE spawnId = ?", + (spawn_guid,), + ) + .map_err(|error| anyhow!("Check GameObject override ownership: {error}"))? + .unwrap_or(0); + let spawn_group_rows: u64 = world + .exec_first( + "SELECT COUNT(*) FROM spawn_group WHERE spawnType = 1 AND spawnId = ?", + (spawn_guid,), + ) + .map_err(|error| anyhow!("Check GameObject spawn-group ownership: {error}"))? + .unwrap_or(0); + if loot_conditions != 0 + || event_rows != 0 + || pool_rows != 0 + || linked_rows != 0 + || spawn_addon_rows != 0 + || override_rows != 0 + || spawn_group_rows != 0 + { + bail!( + "GameObject fixture has conditional/spawn metadata: conditions={loot_conditions} event={event_rows} pool={pool_rows} linked={linked_rows} addon={spawn_addon_rows} override={override_rows} spawn_group={spawn_group_rows}" + ); + } + + let character_url = characters_db_url()?; + let character_opts = loot_db_opts(&character_url, "characters")?; + let mut character_db = mysql::Conn::new(character_opts) + .map_err(|error| anyhow!("Connect to characters DB failed: {error}"))?; + ensure_no_online_characters(&mut character_db, "GameObject loot-race fixture setup")?; + let respawn_rows: Vec<(i64, u16, u32)> = character_db + .exec( + "SELECT respawnTime, mapId, instanceId FROM respawn \ + WHERE type = 1 AND spawnId = ? ORDER BY mapId, instanceId", + (spawn_guid,), + ) + .map_err(|error| anyhow!("Load GameObject respawn snapshot: {error}"))?; + if !respawn_rows.is_empty() { + bail!( + "GameObject spawn {spawn_guid} already has {} type=1 respawn row(s); restart through a clean fixture guard", + respawn_rows.len() + ); + } + + let (characters, progress) = snapshot_loot_fixture_characters( + &mut character_db, + bots, + DEFAULT_ITEM_ENTRY, + 0, + RACE_GAMEOBJECT_MONEY, + None, + )?; + info!( + "Loot-race shared GameObject fixture: `{name}` entry={entry} spawn={spawn_guid} map={map_id} runtime=wire-discovered loot={RACE_GAMEOBJECT_LOOT_ID} item={DEFAULT_ITEM_ENTRY} money={RACE_GAMEOBJECT_MONEY} respawn={spawntime}s state={state}; C++ GameObject.cpp shared group-rules path" + ); + let journal = FixtureJournal::configured()?; + let fixture = LootRaceFixture { + characters, + target: LootRaceTarget { + kind: LootRaceTargetKind::GameObject, + entry, + spawn_guid, + runtime_counter_override: cli.runtime_counter, + map_id, + x, + y, + z, + item_entry: DEFAULT_ITEM_ENTRY, + }, + respawn: None, + respawn_type: 1, + gameobject_state: Some(state), + progress, + journal, + }; + if shutdown.is_cancelled() { + bail!("loot-race cancelled before durable fixture snapshot"); + } + fixture.journal.persist(&fixture)?; + if shutdown.is_cancelled() { + bail!("loot-race cancelled after durable snapshot and before relocation"); + } + relocate_loot_fixture_characters(&mut character_db, &fixture.characters, map_id, x, y, z)?; + Ok(fixture) +} + +fn prepare_fixture( + bots: &[config::BotConfig], + cli: &LootRaceCli, + purpose: LootFixturePurpose, + shutdown: &CancellationToken, +) -> Result { + if shutdown.is_cancelled() { + bail!("loot workflow cancelled before fixture preflight"); + } + if bots.len() != 2 { + bail!("internal loot-race setup requires exactly two bots"); + } + for bot in bots { + if !bot.account.to_ascii_uppercase().ends_with("@BOT.LOCAL") { + bail!( + "refusing destructive loot-race setup for non-local account {}", + bot.account + ); + } + } + + if purpose == LootFixturePurpose::Race { + return prepare_gameobject_race_fixture(bots, cli, shutdown); + } + + let world_url = world_db_url()?; + let world_opts = loot_db_opts(&world_url, "world")?; + let mut world = mysql::Conn::new(world_opts) + .map_err(|error| anyhow!("Connect to world DB failed: {error}"))?; + let row: mysql::Row = world + .exec_first( + "SELECT c.guid, c.id, c.map, c.spawnDifficulties, c.spawntimesecs, \ + c.position_x, c.position_y, c.position_z, c.orientation, \ + c.phaseUseFlags, c.PhaseId, c.PhaseGroup, c.wander_distance, c.MovementType, \ + ct.name, ct.type, ct.unit_class, ct.unit_flags, ct.flags_extra, \ + d.MinLevel, d.MaxLevel, d.HealthScalingExpansion, d.HealthModifier, \ + d.LootID, d.GoldMin, d.GoldMax, \ + d.StaticFlags1 \ + FROM creature c \ + JOIN creature_template ct ON ct.entry = c.id \ + JOIN creature_template_difficulty d ON d.Entry = c.id AND d.DifficultyID = 0 \ + WHERE c.guid = ?", + (cli.spawn_guid,), + ) + .map_err(|error| anyhow!("Load loot-race creature fixture: {error}"))? + .ok_or_else(|| anyhow!("No world.creature row for spawn {}", cli.spawn_guid))?; + let spawn_guid: u64 = required_row_value(&row, "guid")?; + let entry: u32 = required_row_value(&row, "id")?; + let map_id: u16 = required_row_value(&row, "map")?; + let difficulties: String = required_row_value(&row, "spawnDifficulties")?; + let spawntime: u32 = required_row_value(&row, "spawntimesecs")?; + let x: f64 = required_row_value(&row, "position_x")?; + let y: f64 = required_row_value(&row, "position_y")?; + let z: f64 = required_row_value(&row, "position_z")?; + let _orientation: f32 = required_row_value(&row, "orientation")?; + let phase_flags: u8 = required_row_value(&row, "phaseUseFlags")?; + let phase_id: i32 = required_row_value(&row, "PhaseId")?; + let phase_group: i32 = required_row_value(&row, "PhaseGroup")?; + let wander_distance: f32 = required_row_value(&row, "wander_distance")?; + let movement_type: u8 = required_row_value(&row, "MovementType")?; + let name: String = required_row_value(&row, "name")?; + let creature_type: u8 = required_row_value(&row, "type")?; + let unit_class: u8 = required_row_value(&row, "unit_class")?; + let unit_flags: u32 = required_row_value(&row, "unit_flags")?; + let flags_extra: u32 = required_row_value(&row, "flags_extra")?; + let min_level: u8 = required_row_value(&row, "MinLevel")?; + let max_level: u8 = required_row_value(&row, "MaxLevel")?; + let health_scaling_expansion: i32 = required_row_value(&row, "HealthScalingExpansion")?; + let health_modifier: f32 = required_row_value(&row, "HealthModifier")?; + let loot_id: u32 = required_row_value(&row, "LootID")?; + let min_gold: u32 = required_row_value(&row, "GoldMin")?; + let max_gold: u32 = required_row_value(&row, "GoldMax")?; + let static_flags1: u32 = required_row_value(&row, "StaticFlags1")?; + if spawn_guid != cli.spawn_guid || entry != cli.entry || loot_id == 0 { + bail!("creature fixture does not match the exact spawn/entry/loot-id contract"); + } + let same_entry_map_spawns: Vec = world + .exec_map( + "SELECT guid FROM creature WHERE id = ? AND map = ? ORDER BY guid", + (entry, map_id), + |guid: u64| guid, + ) + .map_err(|error| anyhow!("Check loot-race map/entry spawn uniqueness: {error}"))?; + validate_unique_sql_spawn(&same_entry_map_spawns, cli.spawn_guid, entry, map_id)?; + if !matches!(map_id, 0 | 1 | 530 | 571) { + bail!("loot-race creature must be on a known overworld map, got map {map_id}"); + } + if !difficulties + .split(',') + .any(|difficulty| difficulty.trim() == "0") + { + bail!("creature fixture is not available on overworld difficulty 0"); + } + if phase_flags != 0 || phase_id != 0 || phase_group != 0 { + bail!("creature fixture has phase behavior outside this focused smoke"); + } + if wander_distance != 0.0 || movement_type != 0 { + bail!("creature fixture must be stationary for deterministic two-client engagement"); + } + if spawntime == 0 || spawntime > 3_600 { + bail!("creature respawn interval {spawntime} is outside the acknowledged QA boundary"); + } + if purpose != LootFixturePurpose::CaptureItem { + bail!("internal error: creature fixture is reserved for loot-item capture"); + } + if min_gold != 0 || max_gold != 0 { + bail!("loot-item capture creature must have no random money pool"); + } + if min_level == 0 || max_level < min_level || max_level > 70 { + bail!("loot-item capture creature exceeds the bounded combat target level"); + } + let health_expansion_index = match health_scaling_expansion { + -1 | 2 => 2, + 0 => 0, + 1 => 1, + value => bail!( + "loot fixture has unsupported HealthScalingExpansion {value}; expected the C++ -1..=2 domain" + ), + }; + let base_health_rows: Vec<(u8, u32, u32, u32)> = world + .exec( + "SELECT level, basehp0, basehp1, basehp2 \ + FROM creature_classlevelstats \ + WHERE class = ? AND level BETWEEN ? AND ? ORDER BY level", + (unit_class, min_level, max_level), + ) + .map_err(|error| anyhow!("Load loot fixture class-level health: {error}"))?; + let expected_level_rows = usize::from(max_level - min_level) + 1; + if base_health_rows.len() != expected_level_rows { + bail!( + "loot fixture class {} level range {}..{} resolved {} class-level health row(s), expected {expected_level_rows}", + unit_class, + min_level, + max_level, + base_health_rows.len() + ); + } + let guarded_max_health = base_health_rows + .iter() + .map(|(_, hp0, hp1, hp2)| { + let base_health = [*hp0, *hp1, *hp2][health_expansion_index].max(1); + generated_fixture_health_like_cpp(base_health, health_modifier) + }) + .max() + .unwrap_or(0); + validate_guarded_fixture_health(health_modifier, guarded_max_health)?; + const UNATTACKABLE_UNIT_FLAGS: u32 = 0x0000_0002 | 0x0000_0100 | 0x0001_0000 | 0x0200_0000; + const UNSUITABLE_STATIC_FLAGS1: u32 = 0x0000_0004 | 0x0000_0020 | 0x0000_0200; + if unit_flags & UNATTACKABLE_UNIT_FLAGS != 0 + || static_flags1 & UNSUITABLE_STATIC_FLAGS1 != 0 + || creature_type == CREATURE_TYPE_CRITTER + { + bail!("creature fixture is not a normal attackable shared-loot target"); + } + const UNSUITABLE_FLAGS_EXTRA: u32 = 0x0000_0080 | 0x0000_0400 | 0x0000_2000 | 0x0000_4000; + if flags_extra & UNSUITABLE_FLAGS_EXTRA != 0 { + bail!("creature fixture has trigger/ghost/no-combat/world-event flags"); + } + let loot_rows: Vec<(u32, u32, f32, u8, u16, u8, u8, u8)> = world + .exec( + "SELECT Item, Reference, Chance, QuestRequired, LootMode, GroupId, MinCount, MaxCount \ + FROM creature_loot_template WHERE Entry = ? ORDER BY Item, Reference", + (loot_id,), + ) + .map_err(|error| anyhow!("Load loot-race creature loot rows: {error}"))?; + if purpose == LootFixturePurpose::CaptureItem && loot_rows.len() != 1 { + bail!( + "creature loot id {loot_id} contains {} rows; strict capture requires exactly one logical item pool", + loot_rows.len() + ); + } + let matching_item_rows = loot_rows + .iter() + .filter(|&&(item, _, _, _, _, _, _, _)| item == cli.item_entry) + .collect::>(); + if matching_item_rows.len() != 1 { + bail!( + "creature loot id {loot_id} contains {} rows for expected item {}; expected exactly one", + matching_item_rows.len(), + cli.item_entry + ); + } + let &(_item, reference, chance, quest_required, loot_mode, group_id, min_count, max_count) = + matching_item_rows[0]; + if reference != 0 + || (chance - 100.0).abs() > f32::EPSILON + || quest_required != 0 + || loot_mode != 1 + || group_id != 0 + || min_count != 1 + || max_count != 1 + { + bail!("expected creature item row is not one unconditional normal single-item grant"); + } + let condition_rows: u64 = world + .exec_first( + "SELECT COUNT(*) FROM conditions \ + WHERE SourceTypeOrReferenceId = 1 AND SourceGroup = ?", + (loot_id,), + ) + .map_err(|error| anyhow!("Check loot-race creature loot conditions: {error}"))? + .unwrap_or(0); + if condition_rows != 0 { + bail!("creature loot id {loot_id} has conditions outside this focused smoke"); + } + let event_rows: u64 = world + .exec_first( + "SELECT COUNT(*) FROM game_event_creature WHERE guid = ?", + (cli.spawn_guid,), + ) + .map_err(|error| anyhow!("Check loot-race game-event ownership: {error}"))? + .unwrap_or(0); + let pool_rows: u64 = world + .exec_first( + "SELECT COUNT(*) FROM pool_members WHERE type = 0 AND spawnId = ?", + (cli.spawn_guid,), + ) + .map_err(|error| anyhow!("Check loot-race pool ownership: {error}"))? + .unwrap_or(0); + let linked_rows: u64 = world + .exec_first( + "SELECT COUNT(*) FROM linked_respawn WHERE guid = ? OR linkedGuid = ?", + (cli.spawn_guid, cli.spawn_guid), + ) + .map_err(|error| anyhow!("Check loot-race linked respawn: {error}"))? + .unwrap_or(0); + if event_rows != 0 || pool_rows != 0 || linked_rows != 0 { + bail!("creature fixture is event/pool/linked managed and cannot be consumed safely"); + } + + let character_url = characters_db_url()?; + let character_opts = loot_db_opts(&character_url, "characters")?; + let mut character_db = mysql::Conn::new(character_opts) + .map_err(|error| anyhow!("Connect to characters DB failed: {error}"))?; + ensure_no_online_characters(&mut character_db, "loot fixture setup")?; + let respawn_rows: Vec<(i64, u16, u32)> = character_db + .exec( + "SELECT respawnTime, mapId, instanceId FROM respawn \ + WHERE type = 0 AND spawnId = ? ORDER BY mapId, instanceId", + (cli.spawn_guid,), + ) + .map_err(|error| anyhow!("Load loot-race respawn snapshot: {error}"))?; + if !respawn_rows.is_empty() { + bail!( + "creature spawn {} already has {} persisted respawn timer row(s); use a fresh runtime/fixture", + cli.spawn_guid, + respawn_rows.len() + ); + } + let respawn = respawn_rows.into_iter().next(); + + let (fixture_characters, progress) = snapshot_loot_fixture_characters( + &mut character_db, + bots, + cli.item_entry, + max_level, + max_gold, + Some((bots[0].character_guid, LOOT_ITEM_CAPTURE_KEYRING_SLOT)), + )?; + + info!( + "Loot-race disposable fixture: `{}` entry={} spawn={} map={} runtime_counter={} level={}..{} guarded_base_health={} loot={} item={} gold={}..{} respawn={}s; live creature cannot be restored without restart", + name, + entry, + cli.spawn_guid, + map_id, + if cli.runtime_counter == 0 { + "auto".to_string() + } else { + cli.runtime_counter.to_string() + }, + min_level, + max_level, + guarded_max_health, + loot_id, + cli.item_entry, + min_gold, + max_gold, + spawntime, + ); + let journal = FixtureJournal::configured()?; + let fixture = LootRaceFixture { + characters: fixture_characters, + target: LootRaceTarget { + kind: LootRaceTargetKind::Creature, + entry, + spawn_guid: cli.spawn_guid, + runtime_counter_override: cli.runtime_counter, + map_id, + x, + y, + z, + item_entry: cli.item_entry, + }, + respawn: respawn.map(|(respawn_time, map_id, instance_id)| RespawnSnapshot { + respawn_time, + map_id, + instance_id, + }), + respawn_type: 0, + gameobject_state: None, + progress, + journal, + }; + if shutdown.is_cancelled() { + bail!("loot-item capture cancelled before durable fixture snapshot"); + } + fixture.journal.persist(&fixture)?; + if shutdown.is_cancelled() { + bail!("loot-item capture cancelled after durable snapshot and before relocation"); + } + relocate_loot_fixture_characters(&mut character_db, &fixture.characters, map_id, x, y, z)?; + Ok(fixture) +} + +fn snapshot_loot_fixture_characters( + character_db: &mut mysql::Conn, + bots: &[config::BotConfig], + item_entry: u32, + target_max_level: u8, + maximum_money_gain: u32, + required_empty_top_level_slot: Option<(u64, u8)>, +) -> Result<([CharacterFixture; 2], CharacterProgressSnapshot)> { + let mut fixtures = Vec::with_capacity(2); + for bot in bots { + let character: mysql::Row = character_db + .exec_first( + "SELECT account, name, race, level, xp, health, \ + power1, power2, power3, power4, power5, power6, power7, power8, power9, power10, \ + restState, rest_bonus, exploredZones, knownTitles, chosenTitle, \ + online, at_login, money, map, zone, instance_id, \ + position_x, position_y, position_z, orientation \ + FROM characters WHERE guid = ?", + (bot.character_guid,), + ) + .map_err(|error| anyhow!("Load loot-race character {}: {error}", bot.character_guid))? + .ok_or_else(|| anyhow!("No characters row for guid {}", bot.character_guid))?; + let account: u32 = required_row_value(&character, "account")?; + let name: String = required_row_value(&character, "name")?; + let race: u8 = required_row_value(&character, "race")?; + let level: u8 = required_row_value(&character, "level")?; + let xp: u32 = required_row_value(&character, "xp")?; + let health: u32 = required_row_value(&character, "health")?; + let powers = [ + required_row_value(&character, "power1")?, + required_row_value(&character, "power2")?, + required_row_value(&character, "power3")?, + required_row_value(&character, "power4")?, + required_row_value(&character, "power5")?, + required_row_value(&character, "power6")?, + required_row_value(&character, "power7")?, + required_row_value(&character, "power8")?, + required_row_value(&character, "power9")?, + required_row_value(&character, "power10")?, + ]; + let rest_state: u8 = required_row_value(&character, "restState")?; + let rest_bonus: f32 = required_row_value(&character, "rest_bonus")?; + let explored_zones: Option = required_row_value(&character, "exploredZones")?; + let known_titles: Option = required_row_value(&character, "knownTitles")?; + let chosen_title: u32 = required_row_value(&character, "chosenTitle")?; + let online: u8 = required_row_value(&character, "online")?; + let at_login: u16 = required_row_value(&character, "at_login")?; + let money: u64 = required_row_value(&character, "money")?; + let old_map: u32 = required_row_value(&character, "map")?; + let zone: u32 = required_row_value(&character, "zone")?; + let instance_id: u32 = required_row_value(&character, "instance_id")?; + let old_x: f64 = required_row_value(&character, "position_x")?; + let old_y: f64 = required_row_value(&character, "position_y")?; + let old_z: f64 = required_row_value(&character, "position_z")?; + let orientation: f32 = required_row_value(&character, "orientation")?; + if account != bot.account_id || online != 0 || at_login != 0 { + bail!( + "loot-race character {} owner/online/at_login safety check failed", + bot.character_guid + ); + } + if health == 0 || level <= target_max_level { + bail!( + "loot-race character {} must be alive and above target level {}", + bot.character_guid, + target_max_level + ); + } + let maximum_safe_start = MAX_PLAYER_MONEY_LIKE_CPP + .checked_sub(u64::from(maximum_money_gain)) + .ok_or_else(|| anyhow!("loot-race creature maximum gold exceeds the player cap"))?; + if money > maximum_safe_start { + bail!( + "loot-race character {} lacks headroom for the fixture's maximum money roll", + bot.character_guid + ); + } + let account_chars: u64 = character_db + .exec_first( + "SELECT COUNT(*) FROM characters WHERE account = ?", + (bot.account_id,), + ) + .map_err(|error| anyhow!("Count dedicated loot-race characters: {error}"))? + .unwrap_or(0); + if account_chars != 1 { + bail!( + "loot-race requires one dedicated character per game account; {} has {account_chars}", + bot.account + ); + } + let group_rows: u64 = character_db + .exec_first( + "SELECT COUNT(*) FROM (\ + SELECT guid FROM group_member WHERE memberGuid = ? \ + UNION ALL \ + SELECT guid FROM `groups` WHERE leaderGuid = ?\ + ) AS persisted_group_state", + (bot.character_guid, bot.character_guid), + ) + .map_err(|error| anyhow!("Check loot-race group state: {error}"))? + .unwrap_or(0); + if group_rows != 0 { + bail!( + "loot-race character {} is already in a persisted group", + bot.character_guid + ); + } + let persistent_auras: u64 = character_db + .exec_first( + "SELECT COUNT(*) FROM character_aura WHERE guid = ?", + (bot.character_guid,), + ) + .map_err(|error| anyhow!("Check loot-race persistent auras: {error}"))? + .unwrap_or(0); + if persistent_auras != 0 { + bail!( + "loot-race character {} has {persistent_auras} persistent aura row(s); money modifiers must be absent", + bot.character_guid + ); + } + let owned_expected: u64 = character_db + .exec_first( + "SELECT COUNT(*) FROM item_instance WHERE owner_guid = ? AND itemEntry = ?", + (bot.character_guid, item_entry), + ) + .map_err(|error| anyhow!("Check existing loot-race item: {error}"))? + .unwrap_or(0); + if owned_expected != 0 { + bail!( + "loot-race character {} already owns item entry {}", + bot.character_guid, + item_entry + ); + } + let occupied: Vec = character_db + .exec_map( + "SELECT slot FROM character_inventory WHERE guid = ? AND bag = 0", + (bot.character_guid,), + |slot: u8| slot, + ) + .map_err(|error| anyhow!("Load loot-race backpack slots: {error}"))?; + if !(INVENTORY_SLOT_ITEM_START..INVENTORY_SLOT_ITEM_START + 16) + .any(|slot| !occupied.contains(&slot)) + { + bail!( + "loot-race character {} has no empty backpack slot", + bot.character_guid + ); + } + validate_required_empty_top_level_slot( + bot.character_guid, + &occupied, + required_empty_top_level_slot, + )?; + fixtures.push(CharacterFixture { + bot: bot.clone(), + name, + race, + money, + core: CharacterCoreSnapshot { + level, + xp, + health, + powers, + rest_state, + rest_bonus, + explored_zones, + known_titles, + chosen_title, + }, + position: CharacterPositionSnapshot { + map_id: old_map, + zone_id: zone, + instance_id, + x: old_x, + y: old_y, + z: old_z, + orientation, + }, + }); + } + if faction_for_race(fixtures[0].race) != faction_for_race(fixtures[1].race) { + bail!( + "loot-race characters must be the same faction so C++ party invite rules permit grouping" + ); + } + let fixture_characters: [CharacterFixture; 2] = fixtures + .try_into() + .map_err(|_| anyhow!("internal loot-race character count mismatch"))?; + let guid_a = fixture_characters[0].bot.character_guid; + let guid_b = fixture_characters[1].bot.character_guid; + let guild_members: u64 = character_db + .exec_first( + "SELECT COUNT(*) FROM guild_member WHERE guid IN (?, ?)", + (guid_a, guid_b), + ) + .map_err(|error| anyhow!("Check loot-race guild criteria isolation: {error}"))? + .unwrap_or(0); + if guild_members != 0 { + bail!( + "loot-race disposable characters have {guild_members} guild membership row(s); a kill/item criteria update could mutate guild-wide state" + ); + } + let progress = load_character_progress_snapshot(character_db, guid_a, guid_b)?; + Ok((fixture_characters, progress)) +} + +fn validate_required_empty_top_level_slot( + character_guid: u64, + occupied: &[u8], + required: Option<(u64, u8)>, +) -> Result<()> { + if let Some((required_guid, required_slot)) = required { + if character_guid == required_guid && occupied.contains(&required_slot) { + bail!( + "loot-item capture character {character_guid} requires exact top-level keyring slot {required_slot} empty" + ); + } + } + Ok(()) +} + +fn relocate_loot_fixture_characters( + character_db: &mut mysql::Conn, + fixture_characters: &[CharacterFixture; 2], + map_id: u16, + x: f64, + y: f64, + z: f64, +) -> Result<()> { + let mut tx = character_db + .start_transaction(mysql::TxOpts::default()) + .map_err(|error| anyhow!("Start loot-race relocation transaction: {error}"))?; + // C++ Player::LoadFromDB and Rust `restored_saved_health_like_cpp` both + // clamp this sentinel to the recomputed max health on login. The original + // health remains in CharacterCoreSnapshot and cleanup restores it exactly; + // powers are intentionally untouched. + for (index, fixture) in fixture_characters.iter().enumerate() { + let player_x = x + 1.0 + index as f64; + let player_orientation = 0.0_f64.atan2(x - player_x); + tx.exec_drop( + "UPDATE characters SET map = :new_map, zone = 0, instance_id = 0, \ + position_x = :new_x, position_y = :new_y, position_z = :new_z, \ + orientation = :new_orientation, health = :new_health \ + WHERE guid = :guid AND account = :account AND online = 0 AND at_login = 0 \ + AND map = :old_map AND zone = :old_zone AND instance_id = :old_instance \ + AND position_x = :old_x AND position_y = :old_y AND position_z = :old_z \ + AND orientation = :old_orientation AND health = :old_health", + mysql::params! { + "new_map" => u32::from(map_id), + "new_x" => player_x, + "new_y" => y, + "new_z" => z, + "new_orientation" => player_orientation, + "new_health" => u32::MAX, + "guid" => fixture.bot.character_guid, + "account" => fixture.bot.account_id, + "old_map" => fixture.position.map_id, + "old_zone" => fixture.position.zone_id, + "old_instance" => fixture.position.instance_id, + "old_x" => fixture.position.x, + "old_y" => fixture.position.y, + "old_z" => fixture.position.z, + "old_orientation" => fixture.position.orientation, + "old_health" => fixture.core.health, + }, + ) + .map_err(|error| anyhow!("Relocate loot-race character: {error}"))?; + if tx.affected_rows() != 1 { + bail!( + "loot-race character {} drifted after its durable snapshot; relocation applied to {} rows", + fixture.bot.character_guid, + tx.affected_rows() + ); + } + } + tx.commit() + .map_err(|error| anyhow!("Commit loot-race relocation: {error}"))?; + Ok(()) +} + +fn validate_unique_sql_spawn( + spawns: &[u64], + configured_spawn: u64, + entry: u32, + map_id: u16, +) -> Result<()> { + if spawns.len() != 1 { + bail!( + "loot-race target entry {entry} map {map_id} has {} SQL spawns ({spawns:?}); runtime GUID auto-discovery requires exactly one", + spawns.len() + ); + } + if spawns[0] != configured_spawn { + bail!( + "loot-race target entry {entry} map {map_id} uniquely resolves SQL spawn {}, not configured spawn {configured_spawn}", + spawns[0] + ); + } + Ok(()) +} + +fn ensure_no_online_characters(conn: &mut mysql::Conn, stage: &str) -> Result<()> { + let online: u64 = conn + .query_first("SELECT COUNT(*) FROM characters WHERE online <> 0") + .map_err(|error| anyhow!("Check global online-character isolation at {stage}: {error}"))? + .unwrap_or(0); + validate_online_character_count(online, stage) +} + +fn validate_online_character_count(online: u64, stage: &str) -> Result<()> { + if online != 0 { + bail!( + "loot capture requires exclusive world access at {stage}, but {online} character(s) are marked online" + ); + } + Ok(()) +} + +fn load_character_progress_snapshot( + conn: &mut mysql::Conn, + guid_a: u64, + guid_b: u64, +) -> Result { + let guids = (guid_a, guid_b); + Ok(CharacterProgressSnapshot { + achievements: conn + .exec( + "SELECT guid, achievement, `date` FROM character_achievement \ + WHERE guid IN (?, ?) ORDER BY guid, achievement", + guids, + ) + .map_err(|error| anyhow!("Snapshot character achievements: {error}"))?, + achievement_progress: conn + .exec( + "SELECT guid, criteria, counter, `date` FROM character_achievement_progress \ + WHERE guid IN (?, ?) ORDER BY guid, criteria", + guids, + ) + .map_err(|error| anyhow!("Snapshot character achievement criteria: {error}"))?, + quest_status: conn + .exec( + "SELECT guid, quest, status, explored, acceptTime, endTime \ + FROM character_queststatus WHERE guid IN (?, ?) ORDER BY guid, quest", + guids, + ) + .map_err(|error| anyhow!("Snapshot character quest status: {error}"))?, + quest_daily: conn + .exec( + "SELECT guid, quest, `time` FROM character_queststatus_daily \ + WHERE guid IN (?, ?) ORDER BY guid, quest", + guids, + ) + .map_err(|error| anyhow!("Snapshot character daily quests: {error}"))?, + quest_monthly: conn + .exec( + "SELECT guid, quest FROM character_queststatus_monthly \ + WHERE guid IN (?, ?) ORDER BY guid, quest", + guids, + ) + .map_err(|error| anyhow!("Snapshot character monthly quests: {error}"))?, + quest_objectives: conn + .exec( + "SELECT guid, quest, objective, data FROM character_queststatus_objectives \ + WHERE guid IN (?, ?) ORDER BY guid, quest, objective", + guids, + ) + .map_err(|error| anyhow!("Snapshot character quest objectives: {error}"))?, + quest_objective_criteria: conn + .exec( + "SELECT guid, questObjectiveId FROM character_queststatus_objectives_criteria \ + WHERE guid IN (?, ?) ORDER BY guid, questObjectiveId", + guids, + ) + .map_err(|error| anyhow!("Snapshot character quest objective criteria: {error}"))?, + quest_objective_criteria_progress: conn + .exec( + "SELECT guid, criteriaId, counter, `date` \ + FROM character_queststatus_objectives_criteria_progress \ + WHERE guid IN (?, ?) ORDER BY guid, criteriaId", + guids, + ) + .map_err(|error| anyhow!("Snapshot character quest criteria progress: {error}"))?, + quest_rewarded: conn + .exec( + "SELECT guid, quest, active FROM character_queststatus_rewarded \ + WHERE guid IN (?, ?) ORDER BY guid, quest", + guids, + ) + .map_err(|error| anyhow!("Snapshot character rewarded quests: {error}"))?, + quest_seasonal: conn + .exec( + "SELECT guid, quest, event, completedTime FROM character_queststatus_seasonal \ + WHERE guid IN (?, ?) ORDER BY guid, quest", + guids, + ) + .map_err(|error| anyhow!("Snapshot character seasonal quests: {error}"))?, + quest_weekly: conn + .exec( + "SELECT guid, quest FROM character_queststatus_weekly \ + WHERE guid IN (?, ?) ORDER BY guid, quest", + guids, + ) + .map_err(|error| anyhow!("Snapshot character weekly quests: {error}"))?, + reputation: conn + .exec( + "SELECT guid, faction, standing, flags FROM character_reputation \ + WHERE guid IN (?, ?) ORDER BY guid, faction", + guids, + ) + .map_err(|error| anyhow!("Snapshot character reputation: {error}"))?, + }) +} + +fn restore_character_progress_snapshot( + tx: &mut mysql::Transaction<'_>, + guid_a: u64, + guid_b: u64, + snapshot: &CharacterProgressSnapshot, +) -> Result<()> { + for table in CHARACTER_PROGRESS_TABLES { + tx.exec_drop( + format!("DELETE FROM `{table}` WHERE guid IN (?, ?)"), + (guid_a, guid_b), + ) + .map_err(|error| anyhow!("Clear loot-fixture progress table {table}: {error}"))?; + } + + for &(guid, achievement, date) in &snapshot.achievements { + tx.exec_drop( + "INSERT INTO character_achievement (guid, achievement, `date`) VALUES (?, ?, ?)", + (guid, achievement, date), + ) + .map_err(|error| anyhow!("Restore character achievements: {error}"))?; + } + for &(guid, criteria, counter, date) in &snapshot.achievement_progress { + tx.exec_drop( + "INSERT INTO character_achievement_progress (guid, criteria, counter, `date`) \ + VALUES (?, ?, ?, ?)", + (guid, criteria, counter, date), + ) + .map_err(|error| anyhow!("Restore character achievement criteria: {error}"))?; + } + for &(guid, quest, status, explored, accept_time, end_time) in &snapshot.quest_status { + tx.exec_drop( + "INSERT INTO character_queststatus \ + (guid, quest, status, explored, acceptTime, endTime) VALUES (?, ?, ?, ?, ?, ?)", + (guid, quest, status, explored, accept_time, end_time), + ) + .map_err(|error| anyhow!("Restore character quest status: {error}"))?; + } + for &(guid, quest, time) in &snapshot.quest_daily { + tx.exec_drop( + "INSERT INTO character_queststatus_daily (guid, quest, `time`) VALUES (?, ?, ?)", + (guid, quest, time), + ) + .map_err(|error| anyhow!("Restore character daily quests: {error}"))?; + } + for &(guid, quest) in &snapshot.quest_monthly { + tx.exec_drop( + "INSERT INTO character_queststatus_monthly (guid, quest) VALUES (?, ?)", + (guid, quest), + ) + .map_err(|error| anyhow!("Restore character monthly quests: {error}"))?; + } + for &(guid, quest, objective, data) in &snapshot.quest_objectives { + tx.exec_drop( + "INSERT INTO character_queststatus_objectives (guid, quest, objective, data) \ + VALUES (?, ?, ?, ?)", + (guid, quest, objective, data), + ) + .map_err(|error| anyhow!("Restore character quest objectives: {error}"))?; + } + for &(guid, objective) in &snapshot.quest_objective_criteria { + tx.exec_drop( + "INSERT INTO character_queststatus_objectives_criteria (guid, questObjectiveId) \ + VALUES (?, ?)", + (guid, objective), + ) + .map_err(|error| anyhow!("Restore character quest objective criteria: {error}"))?; + } + for &(guid, criteria, counter, date) in &snapshot.quest_objective_criteria_progress { + tx.exec_drop( + "INSERT INTO character_queststatus_objectives_criteria_progress \ + (guid, criteriaId, counter, `date`) VALUES (?, ?, ?, ?)", + (guid, criteria, counter, date), + ) + .map_err(|error| anyhow!("Restore character quest criteria progress: {error}"))?; + } + for &(guid, quest, active) in &snapshot.quest_rewarded { + tx.exec_drop( + "INSERT INTO character_queststatus_rewarded (guid, quest, active) VALUES (?, ?, ?)", + (guid, quest, active), + ) + .map_err(|error| anyhow!("Restore character rewarded quests: {error}"))?; + } + for &(guid, quest, event, completed_time) in &snapshot.quest_seasonal { + tx.exec_drop( + "INSERT INTO character_queststatus_seasonal (guid, quest, event, completedTime) \ + VALUES (?, ?, ?, ?)", + (guid, quest, event, completed_time), + ) + .map_err(|error| anyhow!("Restore character seasonal quests: {error}"))?; + } + for &(guid, quest) in &snapshot.quest_weekly { + tx.exec_drop( + "INSERT INTO character_queststatus_weekly (guid, quest) VALUES (?, ?)", + (guid, quest), + ) + .map_err(|error| anyhow!("Restore character weekly quests: {error}"))?; + } + for &(guid, faction, standing, flags) in &snapshot.reputation { + tx.exec_drop( + "INSERT INTO character_reputation (guid, faction, standing, flags) VALUES (?, ?, ?, ?)", + (guid, faction, standing, flags), + ) + .map_err(|error| anyhow!("Restore character reputation: {error}"))?; + } + Ok(()) +} + +fn load_character_core_snapshot( + conn: &mut mysql::Conn, + character_guid: u64, +) -> Result { + let row: mysql::Row = conn + .exec_first( + "SELECT level, xp, health, \ + power1, power2, power3, power4, power5, power6, power7, power8, power9, power10, \ + restState, rest_bonus, exploredZones, knownTitles, chosenTitle \ + FROM characters WHERE guid = ?", + (character_guid,), + ) + .map_err(|error| anyhow!("Reload loot-fixture character core state: {error}"))? + .ok_or_else(|| anyhow!("Loot-fixture character {character_guid} disappeared"))?; + Ok(CharacterCoreSnapshot { + level: required_row_value(&row, "level")?, + xp: required_row_value(&row, "xp")?, + health: required_row_value(&row, "health")?, + powers: [ + required_row_value(&row, "power1")?, + required_row_value(&row, "power2")?, + required_row_value(&row, "power3")?, + required_row_value(&row, "power4")?, + required_row_value(&row, "power5")?, + required_row_value(&row, "power6")?, + required_row_value(&row, "power7")?, + required_row_value(&row, "power8")?, + required_row_value(&row, "power9")?, + required_row_value(&row, "power10")?, + ], + rest_state: required_row_value(&row, "restState")?, + rest_bonus: required_row_value(&row, "rest_bonus")?, + explored_zones: required_row_value(&row, "exploredZones")?, + known_titles: required_row_value(&row, "knownTitles")?, + chosen_title: required_row_value(&row, "chosenTitle")?, + }) +} + +async fn expected_persisted_item_grant( + fixture: &LootRaceFixture, + sync: &LootRaceSync, +) -> Result { + let window = sync.windows.lock().await[0] + .clone() + .ok_or_else(|| anyhow!("loot-race passed without a retained item window"))?; + let (owner_low, owner_high) = sync + .runtime_guid + .lock() + .map_err(|_| anyhow!("loot-race runtime GUID state was poisoned"))? + .as_ref() + .copied() + .ok_or_else(|| anyhow!("loot-race passed without a retained world-object ObjectGuid"))?; + let expected_removal = LootRemovedEvidence { + owner_low, + owner_high, + loot_low: window.loot_low, + loot_high: window.loot_high, + loot_list_id: window.loot_list_id, + }; + let evidence = sync.evidence.lock().await; + validate_atomic_item_wire_outcome_like_cpp( + &evidence, + [ + fixture.characters[0].bot.character_guid, + fixture.characters[1].bot.character_guid, + ], + fixture.target.item_entry, + window.quantity, + expected_removal, + realm_id(), + ) +} + +async fn expected_persisted_money_grant( + fixture: &LootRaceFixture, + sync: &LootRaceSync, + source_coins: u64, +) -> Result { + if source_coins != u64::from(RACE_GAMEOBJECT_MONEY) { + bail!( + "shared chest source money was {source_coins}, expected exact guarded pool {RACE_GAMEOBJECT_MONEY}" + ); + } + let window = sync.windows.lock().await[0] + .clone() + .ok_or_else(|| anyhow!("loot-race passed without a retained money window"))?; + let evidence = sync.evidence.lock().await; + let winner = validate_serialized_gameobject_money_wire_outcome_like_cpp( + &evidence, + (window.loot_low, window.loot_high), + source_coins, + )?; + Ok(ExpectedPersistedMoneyGrant { + owner_guid: fixture.characters[winner].bot.character_guid, + amount: source_coins, + }) +} + +fn validate_persisted_item_grant_like_cpp( + expected: ExpectedPersistedItemGrant, + persisted: PersistedItemGrantRow, +) -> Result<()> { + let quantity = u32::try_from(expected.push.quantity) + .map_err(|_| anyhow!("wire item quantity was negative"))?; + let quantity_in_inventory = u32::try_from(expected.push.quantity_in_inventory) + .map_err(|_| anyhow!("wire inventory quantity was negative"))?; + let slot_in_bag = u8::try_from(expected.push.slot_in_bag) + .map_err(|_| anyhow!("wire SlotInBag did not fit a persisted inventory slot"))?; + + if persisted.item_guid != expected.push.item_guid_low + || persisted.owner_guid != expected.owner_guid + || persisted.owner_guid != expected.push.player_low + || persisted.item_entry != expected.push.item_entry + || persisted.count != quantity + || persisted.count != quantity_in_inventory + { + bail!( + "wire-keyed persisted item {:?} did not match grant owner/item/count {:?}", + persisted, + expected + ); + } + // For this clean fixture CanStoreNewItem chooses a free top-level + // backpack slot. C++ serializes that as wire Slot=255 but persists bag=0; + // those values are intentionally not compared numerically. + if expected.push.slot != INVENTORY_SLOT_BAG_0 + || persisted.inventory_owner != Some(expected.owner_guid) + || persisted.bag_guid != Some(0) + || persisted.slot != Some(slot_in_bag) + || persisted.bag_slot.is_some() + { + bail!( + "wire slot {}/{} did not bind to the expected top-level character_inventory row {:?}", + expected.push.slot, + expected.push.slot_in_bag, + persisted + ); + } + Ok(()) +} + +fn verify_persisted_grants( + fixture: &LootRaceFixture, + expected_money_grant: ExpectedPersistedMoneyGrant, + expected_item_grant: ExpectedPersistedItemGrant, +) -> Result<( + u64, + u64, + ExpectedPersistedItemGrant, + ExpectedPersistedMoneyGrant, +)> { + let url = characters_db_url()?; + let opts = loot_db_opts(&url, "characters")?; + let mut conn = mysql::Conn::new(opts) + .map_err(|error| anyhow!("Connect to characters DB failed: {error}"))?; + wait_both_offline(&mut conn, fixture)?; + ensure_no_online_characters(&mut conn, "loot-race persistence verification")?; + let guids = ( + fixture.characters[0].bot.character_guid, + fixture.characters[1].bot.character_guid, + ); + let (item_total, item_rows, inventory_rows, persisted_item_owner): (u64, u64, u64, u64) = conn + .exec_first( + "SELECT COALESCE(SUM(ii.count), 0), COUNT(DISTINCT ii.guid), \ + COUNT(DISTINCT ci.item), COALESCE(MIN(ii.owner_guid), 0) \ + FROM item_instance ii \ + LEFT JOIN character_inventory ci \ + ON ci.item = ii.guid AND ci.guid = ii.owner_guid \ + WHERE ii.itemEntry = ? AND ii.owner_guid IN (?, ?)", + (fixture.target.item_entry, guids.0, guids.1), + ) + .map_err(|error| anyhow!("Verify loot-race item persistence: {error}"))? + .ok_or_else(|| anyhow!("loot-race item aggregate query returned no row"))?; + let current_money: Vec<(u64, u64)> = conn + .exec( + "SELECT guid, money FROM characters WHERE guid IN (?, ?) ORDER BY guid", + guids, + ) + .map_err(|error| anyhow!("Verify loot-race money: {error}"))?; + if current_money.len() != 2 { + bail!("loot-race character rows disappeared during verification"); + } + if item_total != 1 || item_rows != 1 || inventory_rows != 1 { + bail!( + "atomic ITEM race persisted quantity/instances/inventory rows {item_total}/{item_rows}/{inventory_rows}; expected 1/1/1" + ); + } + if persisted_item_owner != expected_item_grant.owner_guid { + bail!( + "atomic ITEM race persisted owner {persisted_item_owner}, but wire winner was character {}", + expected_item_grant.owner_guid + ); + } + let persisted_tuple: Option<( + u64, + u64, + u32, + u32, + Option, + Option, + Option, + Option, + )> = conn + .exec_first( + "SELECT ii.guid, ii.owner_guid, ii.itemEntry, ii.count, \ + ci.guid, ci.bag, ci.slot, bag_ci.slot \ + FROM item_instance ii \ + LEFT JOIN character_inventory ci \ + ON ci.item = ii.guid \ + LEFT JOIN character_inventory bag_ci \ + ON ci.bag <> 0 AND bag_ci.item = ci.bag AND bag_ci.guid = ci.guid \ + WHERE ii.guid = ?", + (expected_item_grant.push.item_guid_low,), + ) + .map_err(|error| anyhow!("Verify wire-keyed loot-race item persistence: {error}"))?; + let persisted = persisted_tuple + .map( + |( + item_guid, + owner_guid, + item_entry, + count, + inventory_owner, + bag_guid, + slot, + bag_slot, + )| PersistedItemGrantRow { + item_guid, + owner_guid, + item_entry, + count, + inventory_owner, + bag_guid, + slot, + bag_slot, + }, + ) + .ok_or_else(|| { + anyhow!( + "wire ItemGUID counter {} did not identify a persisted item_instance row", + expected_item_grant.push.item_guid_low + ) + })?; + validate_persisted_item_grant_like_cpp(expected_item_grant, persisted)?; + let mut money_delta = 0u64; + for character in &fixture.characters { + let current = current_money + .iter() + .find_map(|(guid, money)| (*guid == character.bot.character_guid).then_some(*money)) + .ok_or_else(|| { + anyhow!( + "loot-race money verification omitted character {}", + character.bot.character_guid + ) + })?; + let character_delta = if character.bot.character_guid == expected_money_grant.owner_guid { + expected_money_grant.amount + } else { + 0 + }; + let expected = character + .money + .checked_add(character_delta) + .ok_or_else(|| anyhow!("loot-race expected money overflow"))?; + if current != expected { + bail!( + "atomic GAMEOBJECT MONEY race persisted character {} money {}; expected {} from wire winner {} and whole-pool amount {}", + character.bot.character_guid, + current, + expected, + expected_money_grant.owner_guid, + expected_money_grant.amount + ); + } + money_delta = money_delta + .checked_add(current - character.money) + .ok_or_else(|| anyhow!("loot-race persisted money delta overflow"))?; + } + if money_delta != expected_money_grant.amount { + bail!( + "atomic GAMEOBJECT MONEY race persisted total delta {money_delta}; expected one exact C++ whole-pool grant {}", + expected_money_grant.amount + ); + } + Ok(( + item_total, + money_delta, + expected_item_grant, + expected_money_grant, + )) +} + +fn verify_single_item_capture_persistence(fixture: &LootRaceFixture) -> Result { + let url = characters_db_url()?; + let opts = loot_db_opts(&url, "characters")?; + let mut conn = mysql::Conn::new(opts) + .map_err(|error| anyhow!("Connect to characters DB failed: {error}"))?; + wait_both_offline(&mut conn, fixture)?; + ensure_no_online_characters(&mut conn, "loot-item capture persistence verification")?; + + let owner = fixture.characters[0].bot.character_guid; + let offline_peer = fixture.characters[1].bot.character_guid; + let (item_total, item_rows, inventory_rows, persisted_owner): (u64, u64, u64, u64) = conn + .exec_first( + "SELECT COALESCE(SUM(ii.count), 0), COUNT(DISTINCT ii.guid), \ + COUNT(DISTINCT ci.item), COALESCE(MIN(ii.owner_guid), 0) \ + FROM item_instance ii \ + LEFT JOIN character_inventory ci \ + ON ci.item = ii.guid AND ci.guid = ii.owner_guid \ + WHERE ii.itemEntry = ? AND ii.owner_guid IN (?, ?)", + (fixture.target.item_entry, owner, offline_peer), + ) + .map_err(|error| anyhow!("Verify loot-item capture persistence: {error}"))? + .ok_or_else(|| anyhow!("loot-item capture aggregate query returned no row"))?; + if (item_total, item_rows, inventory_rows, persisted_owner) != (1, 1, 1, owner) { + bail!( + "single-session item capture persisted quantity/instances/inventory/owner {item_total}/{item_rows}/{inventory_rows}/{persisted_owner}; expected 1/1/1/{owner}" + ); + } + let persisted_slots: Vec<(u64, u8)> = conn + .exec( + "SELECT ci.bag, ci.slot FROM character_inventory ci \ + JOIN item_instance ii ON ii.guid = ci.item \ + WHERE ii.itemEntry = ? AND ii.owner_guid = ? AND ci.guid = ?", + (fixture.target.item_entry, owner, owner), + ) + .map_err(|error| anyhow!("Verify loot-item capture keyring slot: {error}"))?; + if persisted_slots.as_slice() != [(0, LOOT_ITEM_CAPTURE_KEYRING_SLOT)] { + bail!( + "single-session item capture persisted item in slots {persisted_slots:?}; expected exact top-level keyring slot 0/{LOOT_ITEM_CAPTURE_KEYRING_SLOT}" + ); + } + + let money_rows: Vec<(u64, u64)> = conn + .exec( + "SELECT guid, money FROM characters WHERE guid IN (?, ?) ORDER BY guid", + (owner, offline_peer), + ) + .map_err(|error| anyhow!("Verify loot-item capture money isolation: {error}"))?; + if money_rows.len() != 2 { + bail!("loot-item capture character rows disappeared during verification"); + } + for character in &fixture.characters { + let current = money_rows + .iter() + .find_map(|(guid, money)| (*guid == character.bot.character_guid).then_some(*money)) + .ok_or_else(|| { + anyhow!( + "loot-item capture money verification omitted character {}", + character.bot.character_guid + ) + })?; + if current != character.money { + bail!( + "item-only capture changed character {} money from {} to {} without CMSG_LOOT_MONEY", + character.bot.character_guid, + character.money, + current + ); + } + } + + Ok(item_total) +} + +fn cleanup_fixture(fixture: &LootRaceFixture) -> Result<()> { + let url = characters_db_url()?; + let opts = loot_db_opts(&url, "characters")?; + let mut conn = mysql::Conn::new(opts) + .map_err(|error| anyhow!("Connect to characters DB failed: {error}"))?; + wait_both_offline(&mut conn, fixture)?; + let guid_a = fixture.characters[0].bot.character_guid; + let guid_b = fixture.characters[1].bot.character_guid; + let gained_items: Vec = conn + .exec_map( + "SELECT guid FROM item_instance WHERE itemEntry = ? AND owner_guid IN (?, ?)", + (fixture.target.item_entry, guid_a, guid_b), + |guid: u64| guid, + ) + .map_err(|error| anyhow!("Load gained loot-race items for cleanup: {error}"))?; + let group_ids: Vec = conn + .exec_map( + "SELECT DISTINCT guid FROM group_member WHERE memberGuid IN (?, ?) \ + UNION SELECT guid FROM `groups` WHERE leaderGuid IN (?, ?)", + (guid_a, guid_b, guid_a, guid_b), + |guid: u32| guid, + ) + .map_err(|error| anyhow!("Load loot-race groups for cleanup: {error}"))?; + for group_id in &group_ids { + let unrelated_members: u64 = conn + .exec_first( + "SELECT COUNT(*) FROM group_member \ + WHERE guid = ? AND memberGuid NOT IN (?, ?)", + (*group_id, guid_a, guid_b), + ) + .map_err(|error| anyhow!("Check loot-race group cleanup scope: {error}"))? + .unwrap_or(0); + if unrelated_members != 0 { + bail!( + "refusing to delete loot-race group {group_id}: it gained {unrelated_members} unrelated members" + ); + } + } + if let Some(expected_state) = fixture.gameobject_state { + let world_url = world_db_url()?; + let world_opts = loot_db_opts(&world_url, "world")?; + let mut world = mysql::Conn::new(world_opts).map_err(|error| { + anyhow!("Connect to world DB during GameObject cleanup failed: {error}") + })?; + let observed: Option = world + .exec_first( + "SELECT state FROM gameobject WHERE guid = ? AND id = ?", + (fixture.target.spawn_guid, fixture.target.entry), + ) + .map_err(|error| anyhow!("Verify GameObject fixture SQL state: {error}"))?; + if observed != Some(expected_state) { + bail!( + "GameObject fixture SQL state drifted: expected {expected_state}, got {observed:?}; refusing to hide drift or restore normal PM2" + ); + } + } + let observed_respawn: Vec<(i64, u16, u32)> = conn + .exec( + "SELECT respawnTime, mapId, instanceId FROM respawn \ + WHERE type = ? AND spawnId = ? ORDER BY mapId, instanceId, respawnTime", + (fixture.respawn_type, fixture.target.spawn_guid), + ) + .map_err(|error| anyhow!("Inspect loot-fixture respawn rows before cleanup: {error}"))?; + let baseline_respawn = fixture + .respawn + .iter() + .map(|row| (row.respawn_time, row.map_id, row.instance_id)) + .collect::>(); + let generated_respawn = validate_respawn_cleanup_scope( + fixture.target.map_id, + &baseline_respawn, + &observed_respawn, + )?; + let mut tx = conn + .start_transaction(mysql::TxOpts::default()) + .map_err(|error| anyhow!("Start loot-race cleanup transaction: {error}"))?; + for item_guid in gained_items { + tx.exec_drop( + "DELETE FROM character_inventory WHERE item = ?", + (item_guid,), + ) + .map_err(|error| anyhow!("Delete loot-race inventory row: {error}"))?; + tx.exec_drop( + "DELETE FROM item_instance_gems WHERE itemGuid = ?", + (item_guid,), + ) + .map_err(|error| anyhow!("Delete loot-race gem row: {error}"))?; + tx.exec_drop("DELETE FROM item_instance WHERE guid = ?", (item_guid,)) + .map_err(|error| anyhow!("Delete loot-race item row: {error}"))?; + } + for group_id in group_ids { + tx.exec_drop("DELETE FROM group_member WHERE guid = ?", (group_id,)) + .map_err(|error| anyhow!("Delete loot-race group members: {error}"))?; + tx.exec_drop("DELETE FROM `groups` WHERE guid = ?", (group_id,)) + .map_err(|error| anyhow!("Delete loot-race group row: {error}"))?; + } + restore_character_progress_snapshot(&mut tx, guid_a, guid_b, &fixture.progress)?; + for character in &fixture.characters { + tx.exec_drop( + "UPDATE characters SET \ + money = :money, level = :level, xp = :xp, health = :health, \ + power1 = :power1, power2 = :power2, power3 = :power3, \ + power4 = :power4, power5 = :power5, power6 = :power6, \ + power7 = :power7, power8 = :power8, power9 = :power9, power10 = :power10, \ + restState = :rest_state, rest_bonus = :rest_bonus, \ + exploredZones = :explored_zones, knownTitles = :known_titles, \ + chosenTitle = :chosen_title, \ + map = :map, zone = :zone, instance_id = :instance_id, \ + position_x = :position_x, position_y = :position_y, \ + position_z = :position_z, orientation = :orientation \ + WHERE guid = :guid AND online = 0", + mysql::params! { + "money" => character.money, + "level" => character.core.level, + "xp" => character.core.xp, + "health" => character.core.health, + "power1" => character.core.powers[0], + "power2" => character.core.powers[1], + "power3" => character.core.powers[2], + "power4" => character.core.powers[3], + "power5" => character.core.powers[4], + "power6" => character.core.powers[5], + "power7" => character.core.powers[6], + "power8" => character.core.powers[7], + "power9" => character.core.powers[8], + "power10" => character.core.powers[9], + "rest_state" => character.core.rest_state, + "rest_bonus" => character.core.rest_bonus, + "explored_zones" => character.core.explored_zones.clone(), + "known_titles" => character.core.known_titles.clone(), + "chosen_title" => character.core.chosen_title, + "map" => character.position.map_id, + "zone" => character.position.zone_id, + "instance_id" => character.position.instance_id, + "position_x" => character.position.x, + "position_y" => character.position.y, + "position_z" => character.position.z, + "orientation" => character.position.orientation, + "guid" => character.bot.character_guid, + }, + ) + .map_err(|error| anyhow!("Restore loot-race character snapshot: {error}"))?; + if tx.affected_rows() != 1 { + bail!( + "loot-race character {} became online or disappeared during cleanup", + character.bot.character_guid + ); + } + } + if fixture.target.kind == LootRaceTargetKind::Creature { + if let Some((respawn_time, map_id, instance_id)) = generated_respawn { + tx.exec_drop( + "DELETE FROM respawn \ + WHERE type = ? AND spawnId = ? AND respawnTime = ? AND mapId = ? AND instanceId = ?", + ( + fixture.respawn_type, + fixture.target.spawn_guid, + respawn_time, + map_id, + instance_id, + ), + ) + .map_err(|error| anyhow!("Delete exact generated loot respawn row: {error}"))?; + if tx.affected_rows() != 1 { + bail!( + "exact generated respawn row drifted before cleanup; deleted {} rows", + tx.affected_rows() + ); + } + } + } + tx.commit() + .map_err(|error| anyhow!("Commit loot-race cleanup: {error}"))?; + + ensure_no_online_characters(&mut conn, "loot fixture cleanup")?; + let remaining_expected_items: u64 = conn + .exec_first( + "SELECT COUNT(*) FROM item_instance WHERE itemEntry = ? AND owner_guid IN (?, ?)", + (fixture.target.item_entry, guid_a, guid_b), + ) + .map_err(|error| anyhow!("Verify loot-fixture item cleanup: {error}"))? + .unwrap_or(0); + if remaining_expected_items != 0 { + bail!( + "loot fixture cleanup left {remaining_expected_items} expected-item instance row(s) behind" + ); + } + let remaining_groups: u64 = conn + .exec_first( + "SELECT COUNT(*) FROM (\ + SELECT guid FROM group_member WHERE memberGuid IN (?, ?) \ + UNION ALL \ + SELECT guid FROM `groups` WHERE leaderGuid IN (?, ?)\ + ) AS persisted_group_state", + (guid_a, guid_b, guid_a, guid_b), + ) + .map_err(|error| anyhow!("Verify loot-fixture group cleanup: {error}"))? + .unwrap_or(0); + if remaining_groups != 0 { + bail!("loot fixture cleanup left {remaining_groups} persisted group row(s) behind"); + } + if fixture.target.kind == LootRaceTargetKind::Creature { + let occupied_capture_slot: u64 = conn + .exec_first( + "SELECT COUNT(*) FROM character_inventory WHERE guid = ? AND bag = 0 AND slot = ?", + (guid_a, LOOT_ITEM_CAPTURE_KEYRING_SLOT), + ) + .map_err(|error| anyhow!("Verify loot-item keyring-slot cleanup: {error}"))? + .unwrap_or(0); + if occupied_capture_slot != 0 { + bail!( + "loot fixture cleanup did not restore exact empty keyring slot 0/{LOOT_ITEM_CAPTURE_KEYRING_SLOT}" + ); + } + } + let restored_respawn: Vec<(i64, u16, u32)> = conn + .exec( + "SELECT respawnTime, mapId, instanceId FROM respawn \ + WHERE type = ? AND spawnId = ? ORDER BY mapId, instanceId", + (fixture.respawn_type, fixture.target.spawn_guid), + ) + .map_err(|error| anyhow!("Verify loot-fixture respawn cleanup: {error}"))?; + let expected_respawn = if fixture.target.kind == LootRaceTargetKind::GameObject { + observed_respawn + } else { + baseline_respawn + }; + if restored_respawn != expected_respawn { + bail!("loot fixture target respawn rows did not restore exactly"); + } + let restored_progress = load_character_progress_snapshot(&mut conn, guid_a, guid_b)?; + if restored_progress != fixture.progress { + bail!("loot fixture quest/achievement/criteria/reputation rows did not restore exactly"); + } + for character in &fixture.characters { + let restored_core = load_character_core_snapshot(&mut conn, character.bot.character_guid)?; + if restored_core != character.core { + bail!( + "loot fixture character {} XP/level/health/rest/exploration/title state did not restore exactly", + character.bot.character_guid + ); + } + let restored_position_and_money: (u64, u32, u32, u32, f64, f64, f64, f32) = conn + .exec_first( + "SELECT money, map, zone, instance_id, position_x, position_y, position_z, orientation \ + FROM characters WHERE guid = ?", + (character.bot.character_guid,), + ) + .map_err(|error| anyhow!("Reload loot-fixture money/position state: {error}"))? + .ok_or_else(|| { + anyhow!( + "Loot-fixture character {} disappeared during cleanup verification", + character.bot.character_guid + ) + })?; + let expected_position_and_money = ( + character.money, + character.position.map_id, + character.position.zone_id, + character.position.instance_id, + character.position.x, + character.position.y, + character.position.z, + character.position.orientation, + ); + if restored_position_and_money != expected_position_and_money { + bail!( + "loot fixture character {} money/position state did not restore exactly", + character.bot.character_guid + ); + } + } + + fixture.journal.complete()?; + Ok(()) +} + +fn validate_respawn_cleanup_scope( + target_map: u16, + baseline: &[(i64, u16, u32)], + observed: &[(i64, u16, u32)], +) -> Result> { + if observed == baseline { + return Ok(None); + } + if !baseline.is_empty() || observed.len() != 1 { + bail!( + "loot fixture respawn drift: baseline={baseline:?}, observed={observed:?}; refusing broad cleanup" + ); + } + let row = observed[0]; + if row.0 <= 0 || row.1 != target_map || row.2 != 0 { + bail!( + "loot fixture generated respawn has wrong time/map/instance: {row:?}, expected positive/{target_map}/0" + ); + } + Ok(Some(row)) +} + +fn wait_both_offline(conn: &mut mysql::Conn, fixture: &LootRaceFixture) -> Result<()> { + let deadline = std::time::Instant::now() + Duration::from_secs(LOOT_FIXTURE_OFFLINE_WAIT_SECS); + loop { + let online: u64 = conn + .exec_first( + "SELECT COUNT(*) FROM characters WHERE guid IN (?, ?) AND online <> 0", + ( + fixture.characters[0].bot.character_guid, + fixture.characters[1].bot.character_guid, + ), + ) + .map_err(|error| anyhow!("Check loot-race offline state: {error}"))? + .unwrap_or(2); + if online == 0 { + return Ok(()); + } + if std::time::Instant::now() >= deadline { + bail!( + "loot-race characters remained online; refusing fixture restoration before disconnect saves" + ); + } + std::thread::sleep(Duration::from_millis(100)); + } +} + +fn faction_for_race(race: u8) -> u8 { + match race { + 1 | 3 | 4 | 7 | 11 | 22 | 25 => 0, + _ => 1, + } +} + +fn current_millis_u32() -> u32 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u32 +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::PermissionsExt; + + const ITEM_TEST_REALM: u32 = 7; + const ITEM_TEST_ENTRY: u32 = 18_610; + const ITEM_TEST_CHARACTERS: [u64; 2] = [15, 16]; + + #[test] + fn loot_fixture_health_guard_produces_one_health_like_cpp() { + assert_eq!( + generated_fixture_health_like_cpp(115, GUARDED_FIXTURE_HEALTH_MODIFIER), + 1 + ); + assert!(validate_guarded_fixture_health(GUARDED_FIXTURE_HEALTH_MODIFIER, 1).is_ok()); + } + + #[tokio::test] + async fn cancellation_token_cannot_lose_a_preexisting_cancel() { + let sync = LootRaceSync::new(); + sync.cancel("deterministic peer failure"); + tokio::time::timeout(Duration::from_millis(50), sync.cancelled()) + .await + .expect("CancellationToken retains cancellation before waiter registration"); + assert!(sync + .cancellation_error() + .expect_err("cancelled sync must fail") + .to_string() + .contains("deterministic peer failure")); + } + + #[test] + fn respawn_cleanup_scope_is_exact_and_fail_closed() { + assert_eq!(validate_respawn_cleanup_scope(0, &[], &[]).unwrap(), None); + assert_eq!( + validate_respawn_cleanup_scope(0, &[], &[(1_700_000_000, 0, 0)]).unwrap(), + Some((1_700_000_000, 0, 0)) + ); + assert!(validate_respawn_cleanup_scope( + 0, + &[], + &[(1_700_000_000, 0, 0), (1_700_000_001, 0, 1)] + ) + .is_err()); + assert!(validate_respawn_cleanup_scope(0, &[], &[(1_700_000_000, 1, 0)]).is_err()); + assert!(validate_respawn_cleanup_scope(0, &[], &[(1_700_000_000, 0, 1)]).is_err()); + assert!(validate_respawn_cleanup_scope(0, &[], &[(0, 0, 0)]).is_err()); + assert!(validate_respawn_cleanup_scope( + 0, + &[(1_600_000_000, 0, 0)], + &[(1_700_000_000, 0, 0)] + ) + .is_err()); + } + + #[test] + fn cleanup_marker_publish_is_0600_and_both_present_recovery_is_idempotent() { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let directory = std::env::temp_dir().join(format!( + "wow-test-bot-journal-{}-{unique}", + std::process::id() + )); + fs::create_dir(&directory).unwrap(); + fs::set_permissions(&directory, fs::Permissions::from_mode(0o700)).unwrap(); + let path = directory.join("fixture.journal"); + let journal = FixtureJournal { path: path.clone() }; + let payload = b"durable fixture snapshot\n"; + write_test_journal(&path, payload); + + journal.complete().unwrap(); + let marker = cleanup_marker_path(&path); + assert!(!path.exists()); + assert_eq!( + fs::metadata(&marker).unwrap().permissions().mode() & 0o777, + 0o600 + ); + validate_cleanup_marker(&marker, None).unwrap(); + + // Model SIGKILL after atomic marker rename but before journal unlink. + // Recovery re-verifies DB state, then accepts only the same digest. + write_test_journal(&path, payload); + journal.complete().unwrap(); + assert!(!path.exists()); + + // A different pending snapshot must not be hidden by a stale marker. + write_test_journal(&path, b"different snapshot\n"); + assert!(journal.complete().is_err()); + assert!(path.exists()); + + fs::remove_file(&path).unwrap(); + fs::remove_file(&marker).unwrap(); + fs::remove_dir(&directory).unwrap(); + } + + fn write_test_journal(path: &Path, payload: &[u8]) { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(path) + .unwrap(); + file.write_all(payload).unwrap(); + file.sync_all().unwrap(); + } + + #[test] + fn loot_fixture_health_guard_rejects_original_or_stale_runtime_data() { + let original_health = generated_fixture_health_like_cpp(115, 1.5); + assert_eq!(original_health, 173); + assert!(validate_guarded_fixture_health(1.5, original_health).is_err()); + assert!( + validate_guarded_fixture_health(GUARDED_FIXTURE_HEALTH_MODIFIER, 2).is_err(), + "a non-default classification multiplier or stale runtime must fail closed" + ); + } + + #[test] + fn single_item_fixture_requires_exact_keyring_destination_empty() { + let required = Some((15, LOOT_ITEM_CAPTURE_KEYRING_SLOT)); + validate_required_empty_top_level_slot(15, &[35, 50], required).unwrap(); + assert!(validate_required_empty_top_level_slot( + 15, + &[LOOT_ITEM_CAPTURE_KEYRING_SLOT], + required + ) + .is_err()); + validate_required_empty_top_level_slot(16, &[LOOT_ITEM_CAPTURE_KEYRING_SLOT], required) + .unwrap(); + } + + fn valid_atomic_item_push() -> ItemPush { + let (player_low, player_high) = + create_player_guid_raw(ITEM_TEST_CHARACTERS[0], ITEM_TEST_REALM); + ItemPush { + player_low, + player_high, + slot: INVENTORY_SLOT_BAG_0, + slot_in_bag: i32::from(INVENTORY_SLOT_ITEM_START), + quest_log_item_id: 0, + quantity: 1, + quantity_in_inventory: 1, + dungeon_encounter_id: 0, + item_guid_low: 0x1234, + item_guid_high: (HIGH_GUID_ITEM << 58) | (u64::from(ITEM_TEST_REALM) << 42), + pushed: false, + created: false, + display_text: 1, + is_bonus_roll: false, + is_encounter_loot: false, + item_entry: ITEM_TEST_ENTRY, + } + } + + fn valid_atomic_item_outcome( + group_broadcast: bool, + ) -> ([WireEvidence; 2], LootRemovedEvidence) { + let push = valid_atomic_item_push(); + let removal = LootRemovedEvidence { + owner_low: 268, + owner_high: 0x2000_0442_4015_44C0, + loot_low: 41, + loot_high: (HIGH_GUID_LOOT_OBJECT << 58) | (u64::from(ITEM_TEST_REALM) << 42), + loot_list_id: 3, + }; + let loot_gone = InventoryFailure { + result: 50, + item_0_low: 0, + item_0_high: 0, + item_1_low: 0, + item_1_high: 0, + container_b_slot: 0, + }; + ( + [ + WireEvidence { + item_pushes: vec![push], + loot_removed: vec![removal], + ..Default::default() + }, + WireEvidence { + item_pushes: group_broadcast.then_some(push).into_iter().collect(), + loot_removed: vec![removal], + inventory_failures: vec![loot_gone], + ..Default::default() + }, + ], + removal, + ) + } + + fn runtime_discovery_options(override_counter: u64) -> LootRaceOptions { + LootRaceOptions { + phase: LootRacePhase::CaptureItem, + participant: 0, + character_guid: 15, + peer_name: "Peer".to_string(), + peer_character_guid: 16, + killer_character_guid: 15, + target: LootRaceTarget { + kind: LootRaceTargetKind::Creature, + entry: 21_779, + spawn_guid: 1_117, + runtime_counter_override: override_counter, + map_id: 530, + x: -2_695.57, + y: 2_633.82, + z: 74.6837, + item_entry: 30_712, + }, + timeout_secs: 30, + sync: Arc::new(LootRaceSync::new()), + } + } + + fn gameobject_discovery_options(override_counter: u64) -> LootRaceOptions { + LootRaceOptions { + phase: LootRacePhase::Race, + participant: 0, + character_guid: 15, + peer_name: "Peer".to_string(), + peer_character_guid: 16, + killer_character_guid: 15, + target: LootRaceTarget { + kind: LootRaceTargetKind::GameObject, + entry: DEFAULT_CREATURE_ENTRY, + spawn_guid: DEFAULT_CREATURE_SPAWN_GUID, + runtime_counter_override: override_counter, + map_id: RACE_GAMEOBJECT_MAP_ID, + x: RACE_GAMEOBJECT_X, + y: RACE_GAMEOBJECT_Y, + z: RACE_GAMEOBJECT_Z, + item_entry: DEFAULT_ITEM_ENTRY, + }, + timeout_secs: 30, + sync: Arc::new(LootRaceSync::new()), + } + } + + fn update_object_with_guid(low: u64, high: u64) -> Vec { + let mut payload = vec![1]; + payload.extend_from_slice(&build_packed_guid(low, high)); + payload + } + + fn append_party_player_info_for_test(payload: &mut Vec, guid: (u64, u64), name: &str) { + let name_len = u16::try_from(name.len()).unwrap(); + let voice_len_plus_one = 1u16; + // C++ PartyPackets.cpp writes 15 MSB-first bits and the following + // packed-GUID write pads the last low bit to the next byte. + let info_bits = ((name_len << 9) | (voice_len_plus_one << 3) | 0x04) << 1; + payload.extend_from_slice(&info_bits.to_be_bytes()); + payload.extend_from_slice(&build_packed_guid(guid.0, guid.1)); + payload.extend_from_slice(&[0, 0, 0, 1, 0]); + payload.extend_from_slice(name.as_bytes()); + } + + fn party_update_for_test( + party_flags: u16, + party_index: u8, + party_type: u8, + my_index: i32, + party_guid: (u64, u64), + leader_guid: (u64, u64), + roster: [(u64, u64); 2], + loot_method: u8, + ) -> Vec { + let mut payload = Vec::new(); + payload.extend_from_slice(&party_flags.to_le_bytes()); + payload.push(party_index); + payload.push(party_type); + payload.extend_from_slice(&my_index.to_le_bytes()); + payload.extend_from_slice(&build_packed_guid(party_guid.0, party_guid.1)); + payload.extend_from_slice(&1u32.to_le_bytes()); + payload.extend_from_slice(&build_packed_guid(leader_guid.0, leader_guid.1)); + payload.push(0); + payload.extend_from_slice(&2u32.to_le_bytes()); + payload.push(0x60); // no LFG, with loot and difficulty settings + append_party_player_info_for_test(&mut payload, roster[0], "Leader"); + append_party_player_info_for_test(&mut payload, roster[1], "Peer"); + payload.push(loot_method); // PartyLootSettings.Method + payload.extend_from_slice(&build_packed_guid(0, 0)); + payload.push(2); // PartyLootSettings.Threshold + payload.extend_from_slice(&[0; 12]); // PartyDifficultySettings + payload + } + + #[test] + fn sql_spawn_uniqueness_is_required_for_runtime_auto_discovery() { + validate_unique_sql_spawn(&[1_117], 1_117, 21_779, 530).unwrap(); + + let missing = validate_unique_sql_spawn(&[], 1_117, 21_779, 530) + .expect_err("a missing SQL spawn must fail closed"); + assert!(missing.to_string().contains("exactly one")); + + let ambiguous = validate_unique_sql_spawn(&[1_117, 2_268], 1_117, 21_779, 530) + .expect_err("same-entry map ambiguity must fail closed"); + assert!(ambiguous.to_string().contains("2 SQL spawns")); + + let mismatch = validate_unique_sql_spawn(&[1_117], 2_268, 21_779, 530) + .expect_err("the unique row must equal the configured spawn"); + assert!(mismatch.to_string().contains("not configured spawn")); + } + + #[test] + fn runtime_auto_discovery_preserves_cpp_full_guid_including_realm() { + const CPP_DOCTOR_COUNTER: u64 = 268; + const CPP_DOCTOR_HIGH: u64 = 0x2000_0442_4015_44C0; + let options = runtime_discovery_options(0); + let payload = update_object_with_guid(CPP_DOCTOR_COUNTER, CPP_DOCTOR_HIGH); + + assert_eq!( + target_seen_in_update(&options, SMSG_UPDATE_OBJECT, &payload).unwrap(), + Some(CPP_DOCTOR_COUNTER) + ); + assert_eq!( + options.resolved_runtime_guid().unwrap(), + (CPP_DOCTOR_COUNTER, CPP_DOCTOR_HIGH) + ); + assert_eq!( + options.resolved_packed_guid().unwrap(), + build_packed_guid(CPP_DOCTOR_COUNTER, CPP_DOCTOR_HIGH) + ); + assert_eq!((CPP_DOCTOR_HIGH >> 42) & GUID_REALM_MASK, 1); + + let reconstructed_without_realm = create_creature_guid_raw(530, 21_779, 268); + assert_ne!( + reconstructed_without_realm, + (CPP_DOCTOR_COUNTER, CPP_DOCTOR_HIGH), + "the SQL spawn/counter helper must not replace the full discovered C++ GUID" + ); + } + + #[test] + fn runtime_override_and_two_bot_convergence_fail_closed() { + const CPP_DOCTOR_HIGH: u64 = 0x2000_0442_4015_44C0; + let strict = runtime_discovery_options(1_117); + let mismatch = target_seen_in_update( + &strict, + SMSG_UPDATE_OBJECT, + &update_object_with_guid(268, CPP_DOCTOR_HIGH), + ) + .expect_err("a stale SQL-spawn-as-counter override must be rejected"); + assert!(mismatch + .to_string() + .contains("did not match discovered counter 268")); + + let first = runtime_discovery_options(0); + let mut second = first.clone(); + second.participant = 1; + assert_eq!( + target_seen_in_update( + &first, + SMSG_UPDATE_OBJECT, + &update_object_with_guid(268, CPP_DOCTOR_HIGH), + ) + .unwrap(), + Some(268) + ); + assert_eq!( + target_seen_in_update( + &second, + SMSG_UPDATE_OBJECT, + &update_object_with_guid(268, CPP_DOCTOR_HIGH), + ) + .unwrap(), + Some(268) + ); + + let different = target_seen_in_update( + &second, + SMSG_UPDATE_OBJECT, + &update_object_with_guid(269, CPP_DOCTOR_HIGH), + ) + .expect_err("two bots must not bind different runtime counters"); + assert!(different.to_string().contains("different live ObjectGuids")); + } + + #[test] + fn gameobject_discovery_requires_exact_high_type_entry_map_and_spawn_counter() { + let options = gameobject_discovery_options(0); + let high = (HIGH_GUID_GAMEOBJECT << 58) + | (u64::from(RACE_GAMEOBJECT_MAP_ID) << 29) + | (u64::from(DEFAULT_CREATURE_ENTRY) << 6); + let exact = update_object_with_guid(DEFAULT_CREATURE_SPAWN_GUID, high); + assert_eq!( + target_seen_in_update(&options, SMSG_UPDATE_OBJECT, &exact).unwrap(), + Some(DEFAULT_CREATURE_SPAWN_GUID) + ); + assert_eq!( + options.resolved_runtime_guid().unwrap(), + (DEFAULT_CREATURE_SPAWN_GUID, high) + ); + + let wrong_spawn = gameobject_discovery_options(0); + assert_eq!( + target_seen_in_update( + &wrong_spawn, + SMSG_UPDATE_OBJECT, + &update_object_with_guid(DEFAULT_CREATURE_SPAWN_GUID + 1, high), + ) + .unwrap(), + None + ); + + let creature_high = (HIGH_GUID_CREATURE << 58) + | (u64::from(RACE_GAMEOBJECT_MAP_ID) << 29) + | (u64::from(DEFAULT_CREATURE_ENTRY) << 6); + let wrong_type = gameobject_discovery_options(0); + assert_eq!( + target_seen_in_update( + &wrong_type, + SMSG_UPDATE_OBJECT, + &update_object_with_guid(DEFAULT_CREATURE_SPAWN_GUID, creature_high), + ) + .unwrap(), + None + ); + } + + #[tokio::test] + async fn peer_failure_cancels_a_phase_barrier_without_waiting_for_timeout() { + let options = gameobject_discovery_options(0); + let sync = options.sync.clone(); + let waiter = tokio::spawn(async move { + wait_phase(&options, &options.sync.logged_in, "test peer barrier").await + }); + tokio::task::yield_now().await; + sync.cancel("peer failed before reaching the barrier"); + + let error = tokio::time::timeout(Duration::from_millis(250), waiter) + .await + .expect("cancellation must wake the peer promptly") + .expect("barrier waiter task must not panic") + .expect_err("the peer barrier must fail after cancellation"); + assert!(error + .to_string() + .contains("peer failed before reaching the barrier")); + } + + #[test] + fn logout_complete_is_recognized_on_cpp_realm_and_legacy_instance_routes() { + assert_eq!( + logout_completion_route(SMSG_LOGOUT_COMPLETE, LogoutCompletionRoute::Realm), + Some(LogoutCompletionRoute::Realm) + ); + assert_eq!( + logout_completion_route(SMSG_LOGOUT_COMPLETE, LogoutCompletionRoute::Instance), + Some(LogoutCompletionRoute::Instance) + ); + assert_eq!( + logout_completion_route(SMSG_LOOT_RESPONSE, LogoutCompletionRoute::Realm), + None + ); + } + + #[test] + fn cpp_realm_only_party_opcodes_fail_fast_on_instance() { + for opcode in [ + SMSG_PARTY_INVITE, + SMSG_PARTY_UPDATE, + SMSG_PARTY_COMMAND_RESULT, + SMSG_PARTY_MEMBER_FULL_STATE, + ] { + let error = validate_party_packet_route_like_cpp(opcode, PartyPacketRoute::Instance) + .unwrap_err(); + assert!(error.to_string().contains("CONNECTION_TYPE_REALM")); + } + } + + #[test] + fn party_route_guard_accepts_cpp_realm_and_unrelated_instance_packets() { + validate_party_packet_route_like_cpp(SMSG_PARTY_UPDATE, PartyPacketRoute::Realm).unwrap(); + validate_party_packet_route_like_cpp(SMSG_TIME_SYNC_REQUEST, PartyPacketRoute::Instance) + .unwrap(); + } + + #[test] + fn party_update_proves_exact_normal_home_two_player_roster() { + let leader = create_player_guid_raw(15, ITEM_TEST_REALM); + let peer = create_player_guid_raw(16, ITEM_TEST_REALM); + let payload = party_update_for_test( + 0, + 0, + 1, + 0, + (77, 88), + leader, + [leader, peer], + PERSONAL_LOOT_METHOD_LIKE_CPP, + ); + + validate_party_update_like_cpp(&payload, leader, peer, leader).unwrap(); + } + + #[test] + fn party_update_rejects_non_personal_loot_for_shared_chest_race() { + let leader = create_player_guid_raw(15, ITEM_TEST_REALM); + let peer = create_player_guid_raw(16, ITEM_TEST_REALM); + let payload = party_update_for_test(0, 0, 1, 0, (77, 88), leader, [leader, peer], 0); + + let error = validate_party_update_like_cpp(&payload, leader, peer, leader) + .expect_err("shared chest race must pin C++ PERSONAL_LOOT"); + assert!(error.to_string().contains("PERSONAL_LOOT")); + } + + #[test] + fn party_update_rejects_non_home_empty_or_wrong_roster_states() { + let leader = create_player_guid_raw(15, ITEM_TEST_REALM); + let peer = create_player_guid_raw(16, ITEM_TEST_REALM); + + let non_home = party_update_for_test( + 0, + 1, + 1, + 0, + (77, 88), + leader, + [leader, peer], + PERSONAL_LOOT_METHOD_LIKE_CPP, + ); + assert!( + validate_party_update_like_cpp(&non_home, leader, peer, leader) + .unwrap_err() + .to_string() + .contains("normal HOME") + ); + + let empty_group = party_update_for_test( + 0, + 0, + 1, + 0, + (0, 0), + leader, + [leader, peer], + PERSONAL_LOOT_METHOD_LIKE_CPP, + ); + assert!( + validate_party_update_like_cpp(&empty_group, leader, peer, leader) + .unwrap_err() + .to_string() + .contains("empty PartyGUID") + ); + + let duplicate = party_update_for_test( + 0, + 0, + 1, + 0, + (77, 88), + leader, + [leader, leader], + PERSONAL_LOOT_METHOD_LIKE_CPP, + ); + assert!( + validate_party_update_like_cpp(&duplicate, leader, peer, leader) + .unwrap_err() + .to_string() + .contains("did not contain exactly") + ); + + let wrong_receiver_index = party_update_for_test( + 0, + 0, + 1, + 1, + (77, 88), + leader, + [leader, peer], + PERSONAL_LOOT_METHOD_LIKE_CPP, + ); + assert!( + validate_party_update_like_cpp(&wrong_receiver_index, leader, peer, leader) + .unwrap_err() + .to_string() + .contains("expected receiver") + ); + } + + #[test] + fn party_update_rejects_truncated_or_trailing_payloads() { + let leader = create_player_guid_raw(15, ITEM_TEST_REALM); + let peer = create_player_guid_raw(16, ITEM_TEST_REALM); + let payload = party_update_for_test( + 0, + 0, + 1, + 0, + (77, 88), + leader, + [leader, peer], + PERSONAL_LOOT_METHOD_LIKE_CPP, + ); + + let truncated = &payload[..payload.len() - 1]; + assert!(validate_party_update_like_cpp(truncated, leader, peer, leader).is_err()); + + let mut trailing = payload; + trailing.push(0); + assert!( + validate_party_update_like_cpp(&trailing, leader, peer, leader) + .unwrap_err() + .to_string() + .contains("trailing byte") + ); + } + + #[test] + fn party_invite_matches_cpp_bit_and_field_order() { + let payload = build_party_invite("Peer", 0x11, 0x22).unwrap(); + assert_eq!(&payload[..4], &[0x00, 0x02, 0x00, 0x00]); + assert_eq!(&payload[4..8], &[0, 0, 0, 0]); + assert!(payload.ends_with(b"Peer")); + } + + #[test] + fn loot_item_claim_contains_one_exact_request_and_soft_interact_false() { + let window = LootWindow { + owner_low: 1, + owner_high: 2, + loot_low: 0x11, + loot_high: 0x22, + coins: 10, + item_entry: 46_052, + quantity: 1, + loot_list_id: 7, + loot_method: PERSONAL_LOOT_METHOD_LIKE_CPP, + }; + let payload = build_loot_item_claim(&window); + assert_eq!(&payload[..4], &1u32.to_le_bytes()); + assert_eq!(&payload[payload.len() - 2..], &[7, 0]); + } + + #[test] + fn creature_guid_and_loot_unit_match_cpp_wire() { + let (low, high) = create_creature_guid_raw(0, 62, 279_748); + assert_eq!(low, 279_748); + assert_eq!(high >> 58, 8); + assert_eq!((high >> 29) & 0x1FFF, 0); + assert_eq!((high >> 6) & 0x7F_FFFF, 62); + assert_eq!( + build_packed_guid(low, high), + vec![0x07, 0x83, 0xC4, 0x44, 0x04, 0x80, 0x0F, 0x20] + ); + } + + #[test] + fn loot_removed_requires_the_full_discovered_creature_owner_guid() { + const CREATURE_COUNTER: u64 = 268; + const CREATURE_HIGH: u64 = 0x2000_0442_4015_44C0; + let expected_owner = (CREATURE_COUNTER, CREATURE_HIGH); + let loot_obj = ( + 41, + (HIGH_GUID_LOOT_OBJECT << 58) | (1 << 42) | (u64::from(530u16) << 29), + ); + let removal_payload = |owner: (u64, u64)| { + let mut payload = build_packed_guid(owner.0, owner.1); + payload.extend_from_slice(&build_packed_guid(loot_obj.0, loot_obj.1)); + payload.push(3); + payload + }; + + let mut evidence = WireEvidence::default(); + record_evidence( + SMSG_LOOT_REMOVED, + &removal_payload(expected_owner), + expected_owner, + &mut evidence, + ) + .unwrap(); + assert_eq!( + evidence.loot_removed, + vec![LootRemovedEvidence { + owner_low: expected_owner.0, + owner_high: expected_owner.1, + loot_low: loot_obj.0, + loot_high: loot_obj.1, + loot_list_id: 3, + }] + ); + + let wrong_runtime_counter = (CREATURE_COUNTER + 1, CREATURE_HIGH); + let error = record_evidence( + SMSG_LOOT_REMOVED, + &removal_payload(wrong_runtime_counter), + expected_owner, + &mut evidence, + ) + .expect_err("a removal for another runtime creature must fail closed"); + assert!(error.to_string().contains("discovered world-object GUID")); + assert_eq!(evidence.loot_removed.len(), 1); + } + + #[test] + fn item_push_parser_consumes_the_complete_cpp_343_shape_and_rejects_a_tail() { + let mut payload = build_packed_guid(0x0102, 0); + payload.push(4); + for value in [-1, 777, 3, 9, 615, 123, 188, 26, 25] { + payload.extend_from_slice(&i32::to_le_bytes(value)); + } + payload.extend_from_slice(&build_packed_guid(0x0506, 0)); + // Pushed=true, Created=false, DisplayText=EncounterLoot, + // IsBonusRoll=false, IsEncounterLoot=true, then byte-align. + payload.push(0x92); + payload.extend_from_slice(&9001i32.to_le_bytes()); + payload.extend_from_slice(&12i32.to_le_bytes()); + payload.extend_from_slice(&(-77i32).to_le_bytes()); + payload.push(0x00); // ItemBonus absent, then byte-align. + payload.push(0x00); // ItemModList has zero 6-bit entries. + + assert_eq!( + parse_item_push(&payload).unwrap(), + ItemPush { + player_low: 0x0102, + player_high: 0, + slot: 4, + slot_in_bag: -1, + quest_log_item_id: 777, + quantity: 3, + quantity_in_inventory: 9, + dungeon_encounter_id: 615, + item_guid_low: 0x0506, + item_guid_high: 0, + pushed: true, + created: false, + display_text: 2, + is_bonus_roll: false, + is_encounter_loot: true, + item_entry: 9001, + } + ); + + payload.push(0xAA); + let error = parse_item_push(&payload) + .expect_err("SMSG_ITEM_PUSH_RESULT trailing bytes must fail closed"); + assert!(error.to_string().contains("unexpected trailing bytes")); + } + + #[test] + fn atomic_item_wire_proves_one_logical_grant_for_direct_or_cpp_group_fanout() { + for group_broadcast in [false, true] { + let (evidence, removal) = valid_atomic_item_outcome(group_broadcast); + let grant = validate_atomic_item_wire_outcome_like_cpp( + &evidence, + ITEM_TEST_CHARACTERS, + ITEM_TEST_ENTRY, + 1, + removal, + ITEM_TEST_REALM, + ) + .unwrap(); + assert_eq!(grant.owner_guid, ITEM_TEST_CHARACTERS[0]); + assert_eq!(grant.push, valid_atomic_item_push()); + } + } + + #[test] + fn atomic_item_wire_rejects_foreign_push_extra_removal_and_late_duplicate() { + let (mut foreign_push, removal) = valid_atomic_item_outcome(true); + foreign_push[1].item_pushes[0].item_entry += 1; + assert!(validate_atomic_item_wire_outcome_like_cpp( + &foreign_push, + ITEM_TEST_CHARACTERS, + ITEM_TEST_ENTRY, + 1, + removal, + ITEM_TEST_REALM, + ) + .is_err()); + + let (mut extra_removal, removal) = valid_atomic_item_outcome(false); + extra_removal[0].loot_removed.push(removal); + assert!(validate_atomic_item_wire_outcome_like_cpp( + &extra_removal, + ITEM_TEST_CHARACTERS, + ITEM_TEST_ENTRY, + 1, + removal, + ITEM_TEST_REALM, + ) + .is_err()); + + let (mut late_duplicate, removal) = valid_atomic_item_outcome(false); + validate_atomic_item_wire_outcome_like_cpp( + &late_duplicate, + ITEM_TEST_CHARACTERS, + ITEM_TEST_ENTRY, + 1, + removal, + ITEM_TEST_REALM, + ) + .unwrap(); + late_duplicate[0].merge(WireEvidence { + item_pushes: vec![valid_atomic_item_push()], + ..Default::default() + }); + assert!(validate_atomic_item_wire_outcome_like_cpp( + &late_duplicate, + ITEM_TEST_CHARACTERS, + ITEM_TEST_ENTRY, + 1, + removal, + ITEM_TEST_REALM, + ) + .is_err()); + } + + #[test] + fn atomic_item_wire_rejects_foreign_or_additional_inventory_failure() { + let (mut foreign, removal) = valid_atomic_item_outcome(false); + foreign[1].inventory_failures[0].result = 49; + assert!(validate_atomic_item_wire_outcome_like_cpp( + &foreign, + ITEM_TEST_CHARACTERS, + ITEM_TEST_ENTRY, + 1, + removal, + ITEM_TEST_REALM, + ) + .is_err()); + + let (mut additional, removal) = valid_atomic_item_outcome(false); + additional[0] + .inventory_failures + .push(additional[1].inventory_failures[0]); + assert!(validate_atomic_item_wire_outcome_like_cpp( + &additional, + ITEM_TEST_CHARACTERS, + ITEM_TEST_ENTRY, + 1, + removal, + ITEM_TEST_REALM, + ) + .is_err()); + } + + #[test] + fn persisted_item_row_binds_wire_guid_owner_entry_count_and_slot() { + let expected = ExpectedPersistedItemGrant { + owner_guid: ITEM_TEST_CHARACTERS[0], + push: valid_atomic_item_push(), + }; + let valid = PersistedItemGrantRow { + item_guid: expected.push.item_guid_low, + owner_guid: expected.owner_guid, + item_entry: expected.push.item_entry, + count: 1, + inventory_owner: Some(expected.owner_guid), + bag_guid: Some(0), + slot: Some(INVENTORY_SLOT_ITEM_START), + bag_slot: None, + }; + validate_persisted_item_grant_like_cpp(expected, valid).unwrap(); + + for malformed in [ + PersistedItemGrantRow { + item_guid: valid.item_guid + 1, + ..valid + }, + PersistedItemGrantRow { + owner_guid: valid.owner_guid + 1, + ..valid + }, + PersistedItemGrantRow { + item_entry: valid.item_entry + 1, + ..valid + }, + PersistedItemGrantRow { count: 2, ..valid }, + PersistedItemGrantRow { + slot: Some(INVENTORY_SLOT_ITEM_START + 1), + ..valid + }, + ] { + assert!(validate_persisted_item_grant_like_cpp(expected, malformed).is_err()); + } + } + + #[test] + fn tattered_chest_template_data_pins_shared_group_rules_and_loot_id() { + assert_eq!(RACE_GAMEOBJECT_TEMPLATE_DATA.len(), 35); + assert_eq!(RACE_GAMEOBJECT_TEMPLATE_DATA[0], 57); + assert_eq!(RACE_GAMEOBJECT_TEMPLATE_DATA[1], 2_278); + assert_eq!(RACE_GAMEOBJECT_TEMPLATE_DATA[3], 1); + assert_eq!(RACE_GAMEOBJECT_TEMPLATE_DATA[10], 1); + assert_eq!(RACE_GAMEOBJECT_TEMPLATE_DATA[12], 1); + assert_eq!(RACE_GAMEOBJECT_TEMPLATE_DATA[15], 1); + assert!(RACE_GAMEOBJECT_TEMPLATE_DATA + .iter() + .enumerate() + .all(|(index, value)| matches!(index, 0 | 1 | 3 | 10 | 12 | 15) || *value == 0)); + } + + #[test] + fn loot_object_guid_requires_exact_cpp_world_object_structure() { + let realm_id = 7u32; + let map_id = 571u16; + let counter = 41u64; + let high = + (HIGH_GUID_LOOT_OBJECT << 58) | (u64::from(realm_id) << 42) | (u64::from(map_id) << 29); + + validate_loot_object_guid_like_cpp(counter, high, map_id, realm_id).unwrap(); + + let malformed = [ + (counter, high ^ (1 << 58)), + (counter, high ^ (1 << 42)), + (counter, high ^ (1 << 29)), + (counter, high | (1 << 6)), + (counter, high | 1), + (counter | (1 << 40), high), + (0, high), + ]; + for (low, high) in malformed { + assert!( + validate_loot_object_guid_like_cpp(low, high, map_id, realm_id).is_err(), + "malformed LootObject unexpectedly passed: low={low:#x}, high={high:#x}" + ); + } + } + + #[test] + fn serialized_money_race_requires_one_positive_and_one_zero_fanout_like_cpp() { + let source = (0x11, 0x22); + let positive = MoneyNotify { + money: 10, + money_mod: 0, + sole_looter: true, + }; + let zero = MoneyNotify { + money: 0, + money_mod: 0, + sole_looter: true, + }; + let evidence = [ + WireEvidence { + money_notifies: vec![positive], + coin_removed: vec![source, source], + ..Default::default() + }, + WireEvidence { + money_notifies: vec![zero], + coin_removed: vec![source, source], + ..Default::default() + }, + ]; + + assert_eq!( + validate_serialized_gameobject_money_wire_outcome_like_cpp(&evidence, source, 10) + .unwrap(), + 0 + ); + + let reversed = [evidence[1].clone(), evidence[0].clone()]; + assert_eq!( + validate_serialized_gameobject_money_wire_outcome_like_cpp(&reversed, source, 10) + .unwrap(), + 1 + ); + } + + #[test] + fn serialized_money_race_rejects_duplicate_positive_or_missing_coin_fanout() { + let source = (0x11, 0x22); + let positive = MoneyNotify { + money: 10, + money_mod: 0, + sole_looter: true, + }; + let zero = MoneyNotify { + money: 0, + money_mod: 0, + sole_looter: true, + }; + let valid = WireEvidence { + money_notifies: vec![zero], + coin_removed: vec![source, source], + ..Default::default() + }; + + let duplicate_positive = WireEvidence { + money_notifies: vec![positive], + coin_removed: vec![source, source], + ..Default::default() + }; + assert!(validate_serialized_gameobject_money_wire_outcome_like_cpp( + &[duplicate_positive.clone(), duplicate_positive], + source, + 10, + ) + .is_err()); + + let missing_coin = WireEvidence { + money_notifies: vec![positive], + coin_removed: vec![source], + ..Default::default() + }; + assert!(validate_serialized_gameobject_money_wire_outcome_like_cpp( + &[valid.clone(), missing_coin], + source, + 10, + ) + .is_err()); + + let wrong_sole_looter = WireEvidence { + money_notifies: vec![MoneyNotify { + money: 10, + money_mod: 0, + sole_looter: false, + }], + coin_removed: vec![source, source], + ..Default::default() + }; + assert!(validate_serialized_gameobject_money_wire_outcome_like_cpp( + &[valid, wrong_sole_looter], + source, + 10, + ) + .is_err()); + } + + #[test] + fn loot_gone_failure_matches_cpp_wire_and_rejects_a_tail() { + let mut payload = 50i32.to_le_bytes().to_vec(); + payload.extend_from_slice(&build_packed_guid(0, 0)); + payload.extend_from_slice(&build_packed_guid(0, 0)); + payload.push(0); + assert_eq!( + parse_inventory_failure(&payload).unwrap(), + InventoryFailure { + result: 50, + item_0_low: 0, + item_0_high: 0, + item_1_low: 0, + item_1_high: 0, + container_b_slot: 0, + } + ); + payload.push(0); + assert!(parse_inventory_failure(&payload).is_err()); + } + + #[test] + fn money_notify_reads_two_u64s_and_msb_sole_looter_bit() { + let mut payload = 3u64.to_le_bytes().to_vec(); + payload.extend_from_slice(&2u64.to_le_bytes()); + payload.push(0x80); + let mut evidence = WireEvidence::default(); + record_evidence(SMSG_LOOT_MONEY_NOTIFY, &payload, (0, 0), &mut evidence).unwrap(); + assert_eq!( + evidence.money_notifies, + vec![MoneyNotify { + money: 3, + money_mod: 2, + sole_looter: true, + }] + ); + payload.push(0); + assert!(record_evidence(SMSG_LOOT_MONEY_NOTIFY, &payload, (0, 0), &mut evidence).is_err()); + } + + #[test] + fn single_item_capture_requires_one_owner_push_one_removal_and_no_money() { + let window = LootWindow { + owner_low: 1, + owner_high: 2, + loot_low: 0x11, + loot_high: 0x22, + coins: 7, + item_entry: 56_147, + quantity: 1, + loot_list_id: 3, + loot_method: 0, + }; + let expected_player = (0x33, 0x44); + let evidence = WireEvidence { + item_pushes: vec![ItemPush { + player_low: expected_player.0, + player_high: expected_player.1, + item_entry: window.item_entry, + slot: INVENTORY_SLOT_BAG_0, + slot_in_bag: i32::from(LOOT_ITEM_CAPTURE_KEYRING_SLOT), + quantity: 1, + quantity_in_inventory: 1, + ..Default::default() + }], + loot_removed: vec![LootRemovedEvidence { + owner_low: window.owner_low, + owner_high: window.owner_high, + loot_low: window.loot_low, + loot_high: window.loot_high, + loot_list_id: window.loot_list_id, + }], + ..Default::default() + }; + + validate_single_item_capture_evidence( + &evidence, + expected_player, + window.item_entry, + &window, + ) + .unwrap(); + + let mut with_money = evidence.clone(); + with_money.money_notifies.push(MoneyNotify { + money: 7, + money_mod: 0, + sole_looter: true, + }); + assert!(validate_single_item_capture_evidence( + &with_money, + expected_player, + window.item_entry, + &window, + ) + .is_err()); + + let mut displaced_key = evidence.clone(); + displaced_key.item_pushes[0].slot_in_bag = i32::from(INVENTORY_SLOT_ITEM_START); + assert!(validate_single_item_capture_evidence( + &displaced_key, + expected_player, + window.item_entry, + &window, + ) + .is_err()); + + let mut wrong_route_owner = evidence; + wrong_route_owner.item_pushes[0].player_low ^= 1; + assert!(validate_single_item_capture_evidence( + &wrong_route_owner, + expected_player, + window.item_entry, + &window, + ) + .is_err()); + } + + #[test] + fn capture_collector_fails_closed_on_duplicate_or_failure_before_fence() { + let push = ItemPush { + player_low: 1, + player_high: 2, + item_entry: 56_147, + ..Default::default() + }; + validate_single_item_capture_candidate(&WireEvidence { + item_pushes: vec![push], + ..Default::default() + }) + .unwrap(); + + assert!(validate_single_item_capture_candidate(&WireEvidence { + item_pushes: vec![push, push], + ..Default::default() + }) + .is_err()); + assert!(validate_single_item_capture_candidate(&WireEvidence { + inventory_failures: vec![InventoryFailure { + result: 50, + item_0_low: 0, + item_0_high: 0, + item_1_low: 0, + item_1_high: 0, + container_b_slot: 0, + }], + ..Default::default() + }) + .is_err()); + } + + #[test] + fn capture_exclusivity_requires_zero_globally_online_characters() { + validate_online_character_count(0, "test preflight").unwrap(); + let error = validate_online_character_count(1, "test preflight") + .expect_err("one unrelated online character must fail closed"); + assert!(error.to_string().contains("exclusive world access")); + assert!(error.to_string().contains("1 character(s)")); + } + + #[test] + fn progress_restore_table_scope_has_no_duplicates_or_global_tables() { + let unique = CHARACTER_PROGRESS_TABLES + .iter() + .copied() + .collect::>(); + assert_eq!(unique.len(), CHARACTER_PROGRESS_TABLES.len()); + assert!(unique.contains("character_achievement")); + assert!(unique.contains("character_achievement_progress")); + assert!(unique.contains("character_queststatus_objectives_criteria_progress")); + assert!(unique.contains("character_reputation")); + assert!(!unique.iter().any(|table| table.starts_with("guild_"))); + } +} diff --git a/tools/wow-test-bot/src/main.rs b/tools/wow-test-bot/src/main.rs index aec4ad5e0..5e779e7f2 100644 --- a/tools/wow-test-bot/src/main.rs +++ b/tools/wow-test-bot/src/main.rs @@ -9,10 +9,12 @@ use std::net::IpAddr; use std::time::Duration; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; +use tokio_util::sync::CancellationToken; use tracing::{debug, error, info, warn}; mod bot_srp6; mod config; +mod loot_race; mod packet_parser; mod protocol; mod srp6_auth; @@ -51,7 +53,17 @@ const SMSG_PLAYER_BOUND: u16 = 0x2FF8; const SMSG_SPELL_GO: u16 = 0x2C36; const CMSG_LOGOUT_REQUEST: u16 = 0x34D6; const SMSG_LOGOUT_COMPLETE: u16 = 0x2684; -const RESTED_XP_GRACEFUL_LOGOUT_WAIT_SECS: u64 = 30; +// Login can legitimately contain more than 30 packets before +// SMSG_LOGIN_VERIFY_WORLD when another player is already on the map and its +// CREATE/broadcast traffic is interleaved. Keep the guard wall-clock based so +// a busy but healthy login cannot exhaust an arbitrary packet budget. +const LOGIN_VERIFY_TIMEOUT: Duration = Duration::from_secs(30); +const LOGIN_VERIFY_READ_SLICE: Duration = Duration::from_secs(5); +const INITIAL_NETWORK_IO_TIMEOUT: Duration = Duration::from_secs(15); +// C++ WorldSession::ShouldLogOut waits 20 wall-clock seconds after a normal +// logout request (`WorldSession.h`) before it can send SMSG_LOGOUT_COMPLETE. +// Keep a bounded margin for the world update that observes the expired timer. +const NORMAL_LOGOUT_COMPLETE_WAIT_SECS: u64 = 30; const RESTED_XP_DISCONNECT_SAVE_MAX_WAIT_SECS: u64 = 90; const INVENTORY_SLOT_BAG_0: u8 = 255; const INVENTORY_SLOT_ITEM_START: u8 = 35; @@ -102,6 +114,26 @@ struct ServerPacketInflater { decompressor: Decompress, } +#[derive(Debug, Clone, Copy)] +struct LoginVerifyBudget { + deadline: tokio::time::Instant, +} + +impl LoginVerifyBudget { + fn new(timeout: Duration) -> Self { + Self { + deadline: tokio::time::Instant::now() + timeout, + } + } + + fn next_read_timeout(self) -> Option { + let remaining = self + .deadline + .saturating_duration_since(tokio::time::Instant::now()); + (!remaining.is_zero()).then(|| remaining.min(LOGIN_VERIFY_READ_SLICE)) + } +} + impl Default for ServerPacketInflater { fn default() -> Self { Self { @@ -196,6 +228,18 @@ struct CliOptions { rested_xp_runtime_counter: Option, rested_xp_offline_secs: u64, rested_xp_timeout_secs: u64, + loot_race_smoke: bool, + loot_item_capture: bool, + ack_disposable_overworld_loot_race: bool, + loot_race_account_a: String, + loot_race_account_b: String, + loot_race_creature_entry: u32, + loot_race_creature_spawn_guid: u64, + loot_race_runtime_counter: u64, + loot_race_item_entry: u32, + loot_race_timeout_secs: u64, + loot_workflow_deadline_secs: u64, + recover_loot_fixture: bool, quest_smoke: bool, quest_creature_entry: Option, quest_creature_guid: Option, @@ -298,6 +342,24 @@ struct BotRunResult { rested_xp_db_rest_after: Option, rested_xp_relog_verified: bool, rested_xp_failure: Option, + loot_race_smoke: bool, + loot_race_smoke_passed: Option, + loot_race_target_entry: Option, + loot_race_target_spawn_guid: Option, + loot_race_target_runtime_counter: Option, + loot_race_party_confirmed: bool, + loot_race_target_discovered: bool, + loot_race_loot_opened: bool, + loot_race_loot_list_id: Option, + loot_race_loot_coins: Option, + loot_race_item_push_seen: bool, + loot_race_loot_removed_seen: bool, + loot_race_money_notify_amount: Option, + loot_race_coin_removed_seen: bool, + loot_race_db_item_total: Option, + loot_race_db_money_delta: Option, + loot_race_relog_verified: bool, + loot_race_failure: Option, quest_smoke: bool, quest_smoke_passed: Option, quest_target_entry: Option, @@ -370,6 +432,12 @@ impl BotRunResult { && self.player_login_verified && self.rested_xp_smoke_passed.unwrap_or(false); } + if self.loot_race_smoke { + return self.world_auth + && self.enum_characters + && self.player_login_verified + && self.loot_race_smoke_passed.unwrap_or(false); + } if login_only { return self.world_auth && self.enum_characters && self.player_login_verified; } @@ -392,6 +460,8 @@ struct RunReport { homebind_smoke: bool, inventory_swap_smoke: bool, rested_xp_smoke: bool, + loot_race_smoke: bool, + loot_item_capture: bool, quest_smoke: bool, results: Vec, } @@ -466,7 +536,7 @@ struct BankSmokeOptions { timeout_secs: u64, } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, Serialize, serde::Deserialize)] struct CharacterPositionSnapshot { map_id: u32, zone_id: u32, @@ -823,6 +893,52 @@ fn parse_cli() -> Result { .map(|value| value.parse::()) .transpose()? .unwrap_or(DEFAULT_RESTED_XP_TIMEOUT_SECS), + loot_race_smoke: std::env::var("WOW_BOT_LOOT_RACE_SMOKE") + .ok() + .is_some_and(|value| is_truthy(&value)), + loot_item_capture: std::env::var("WOW_BOT_LOOT_ITEM_CAPTURE") + .ok() + .is_some_and(|value| is_truthy(&value)), + // Deliberately CLI-only: inherited environment cannot acknowledge + // disposable loot-fixture mutation on the caller's behalf. + ack_disposable_overworld_loot_race: false, + loot_race_account_a: std::env::var("WOW_BOT_LOOT_RACE_ACCOUNT_A") + .unwrap_or_else(|_| loot_race::DEFAULT_ACCOUNT_A.to_string()), + loot_race_account_b: std::env::var("WOW_BOT_LOOT_RACE_ACCOUNT_B") + .unwrap_or_else(|_| loot_race::DEFAULT_ACCOUNT_B.to_string()), + loot_race_creature_entry: std::env::var("WOW_BOT_LOOT_RACE_GAMEOBJECT_ENTRY") + .or_else(|_| std::env::var("WOW_BOT_LOOT_RACE_CREATURE_ENTRY")) + .ok() + .map(|value| value.parse::()) + .transpose()? + .unwrap_or(loot_race::DEFAULT_CREATURE_ENTRY), + loot_race_creature_spawn_guid: std::env::var("WOW_BOT_LOOT_RACE_GAMEOBJECT_SPAWN_GUID") + .or_else(|_| std::env::var("WOW_BOT_LOOT_RACE_CREATURE_SPAWN_GUID")) + .ok() + .map(|value| value.parse::()) + .transpose()? + .unwrap_or(loot_race::DEFAULT_CREATURE_SPAWN_GUID), + loot_race_runtime_counter: std::env::var("WOW_BOT_LOOT_RACE_RUNTIME_COUNTER") + .ok() + .map(|value| value.parse::()) + .transpose()? + .unwrap_or(loot_race::DEFAULT_RUNTIME_COUNTER), + loot_race_item_entry: std::env::var("WOW_BOT_LOOT_RACE_ITEM_ENTRY") + .ok() + .map(|value| value.parse::()) + .transpose()? + .unwrap_or(loot_race::DEFAULT_ITEM_ENTRY), + loot_race_timeout_secs: std::env::var("WOW_BOT_LOOT_RACE_TIMEOUT_SECS") + .ok() + .map(|value| value.parse::()) + .transpose()? + .unwrap_or(loot_race::DEFAULT_TIMEOUT_SECS), + loot_workflow_deadline_secs: std::env::var("WOW_BOT_LOOT_WORKFLOW_DEADLINE_SECS") + .ok() + .map(|value| value.parse::()) + .transpose()? + .unwrap_or(loot_race::DEFAULT_WORKFLOW_DEADLINE_SECS), + recover_loot_fixture: false, quest_smoke: std::env::var("WOW_BOT_QUEST_SMOKE") .ok() .map(|v| is_truthy(&v)) @@ -987,6 +1103,38 @@ fn parse_cli() -> Result { opts.rested_xp_timeout_secs = next_arg(&mut args, "--rested-xp-timeout")?.parse()?; } + "--loot-race-smoke" => opts.loot_race_smoke = true, + "--loot-item-capture" => opts.loot_item_capture = true, + arg if arg == loot_race::ACK_FLAG => opts.ack_disposable_overworld_loot_race = true, + "--loot-race-account-a" => { + opts.loot_race_account_a = next_arg(&mut args, "--loot-race-account-a")?; + } + "--loot-race-account-b" => { + opts.loot_race_account_b = next_arg(&mut args, "--loot-race-account-b")?; + } + flag @ ("--loot-race-gameobject-entry" | "--loot-race-creature-entry") => { + opts.loot_race_creature_entry = next_arg(&mut args, flag)?.parse()?; + } + flag @ ("--loot-race-gameobject-spawn-guid" | "--loot-race-creature-spawn-guid") => { + opts.loot_race_creature_spawn_guid = next_arg(&mut args, flag)?.parse()?; + } + "--loot-race-runtime-counter" => { + opts.loot_race_runtime_counter = + next_arg(&mut args, "--loot-race-runtime-counter")?.parse()?; + } + "--loot-race-item-entry" => { + opts.loot_race_item_entry = + next_arg(&mut args, "--loot-race-item-entry")?.parse()?; + } + "--loot-race-timeout" => { + opts.loot_race_timeout_secs = + next_arg(&mut args, "--loot-race-timeout")?.parse()?; + } + "--loot-workflow-deadline" => { + opts.loot_workflow_deadline_secs = + next_arg(&mut args, "--loot-workflow-deadline")?.parse()?; + } + "--recover-loot-fixture" => opts.recover_loot_fixture = true, "--quest-smoke" => opts.quest_smoke = true, "--quest-creature-entry" => { opts.quest_creature_entry = @@ -1057,6 +1205,17 @@ fn parse_cli() -> Result { _ => bail!("Unknown argument `{}`. Use --help.", arg), } } + if opts.loot_item_capture + && opts.loot_race_creature_entry == loot_race::DEFAULT_CREATURE_ENTRY + && opts.loot_race_creature_spawn_guid == loot_race::DEFAULT_CREATURE_SPAWN_GUID + && opts.loot_race_runtime_counter == loot_race::DEFAULT_RUNTIME_COUNTER + && opts.loot_race_item_entry == loot_race::DEFAULT_ITEM_ENTRY + { + opts.loot_race_creature_entry = loot_race::DEFAULT_CAPTURE_CREATURE_ENTRY; + opts.loot_race_creature_spawn_guid = loot_race::DEFAULT_CAPTURE_CREATURE_SPAWN_GUID; + opts.loot_race_runtime_counter = loot_race::DEFAULT_CAPTURE_RUNTIME_COUNTER; + opts.loot_race_item_entry = loot_race::DEFAULT_CAPTURE_ITEM_ENTRY; + } Ok(opts) } @@ -1080,6 +1239,84 @@ fn is_truthy(value: &str) -> bool { ) } +fn validate_provisioning_mode(loot_mode: bool, ensure_test_accounts: bool) -> Result<()> { + if loot_mode && ensure_test_accounts { + bail!( + "loot workflows forbid --ensure-test-accounts/WOW_BOT_ENSURE_TEST_ACCOUNTS; provision fixtures separately, then run the read-only identity preflight" + ); + } + Ok(()) +} + +fn install_loot_termination_token() -> Result { + let token = CancellationToken::new(); + let signal_token = token.clone(); + #[cfg(unix)] + let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt()) + .context("Install loot-fixture SIGINT handler before mutation")?; + #[cfg(unix)] + let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .context("Install loot-fixture SIGTERM handler before mutation")?; + if std::env::var("WOW_BOT_REQUIRE_PARENT_DEATH_GUARD").is_ok_and(|value| is_truthy(&value)) { + #[cfg(target_os = "linux")] + { + // SAFETY: getppid takes no pointers and has no preconditions. + let parent_before = unsafe { libc::getppid() }; + if parent_before <= 1 { + bail!("loot parent-death guard has no live supervising parent"); + } + // SAFETY: PR_SET_PDEATHSIG accepts an integer signal number. SIGTERM + // is handled by the streams registered synchronously above. + let result = unsafe { libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM) }; + if result != 0 { + return Err(std::io::Error::last_os_error()) + .context("Arm Linux parent-death SIGTERM before fixture mutation"); + } + // Close the documented prctl race: if the supervisor disappeared + // before PR_SET_PDEATHSIG was armed, refuse to enter the fixture. + // SAFETY: getppid takes no pointers and has no preconditions. + let parent_after = unsafe { libc::getppid() }; + if parent_after != parent_before { + bail!("loot supervisor changed while arming parent-death guard"); + } + } + #[cfg(not(target_os = "linux"))] + bail!("WOW_BOT_REQUIRE_PARENT_DEATH_GUARD is supported only on Linux"); + } + tokio::spawn(async move { + #[cfg(unix)] + { + tokio::select! { + _ = interrupt.recv() => {} + _ = terminate.recv() => {} + } + } + #[cfg(not(unix))] + if let Err(error) = tokio::signal::ctrl_c().await { + error!("Termination handler failed: {error}"); + } + signal_token.cancel(); + }); + Ok(token) +} + +async fn finish_guarded_loot_result( + result: Result>, +) -> Result> { + match result { + Ok(results) => Ok(results), + Err(primary) => match loot_race::recover_pending_fixture_if_present().await { + Ok(true) => Err(primary.context( + "loot workflow failed; the durable fixture journal was recovered before exit", + )), + Ok(false) => Err(primary), + Err(recovery) => bail!( + "loot workflow failed ({primary:#}) and durable fixture recovery also failed ({recovery:#}); leave the normal world stopped and run --recover-loot-fixture" + ), + }, + } +} + fn validate_rested_xp_cli_values( enabled: bool, acknowledged_disposable: bool, @@ -1209,7 +1446,7 @@ fn print_help() { " --cleanup-groups Delete stale group rows for configured bot GUIDs before run" ); println!(" --require-group Treat missing party info/group formation as failure"); - println!(" --ensure-test-accounts Upsert local TESTBOT auth rows before login"); + println!(" --ensure-test-accounts Create missing local TESTBOT auth rows; validate existing rows without rewriting them"); println!(" --login-only Stop after SMSG_LOGIN_VERIFY_WORLD; do not run LFG"); println!(" --stand-state-smoke After login, verify Sit then Stand state round-trips"); println!( @@ -1254,6 +1491,38 @@ fn print_help() { println!( " Env: WOW_BOT_RESTED_XP_SMOKE, WOW_BOT_RESTED_XP_CREATURE_ENTRY, WOW_BOT_RESTED_XP_CREATURE_GUID, WOW_BOT_RESTED_XP_RUNTIME_COUNTER, WOW_BOT_RESTED_XP_OFFLINE_SECS, WOW_BOT_RESTED_XP_TIMEOUT_SECS" ); + println!( + " --loot-race-smoke Race ITEM and MONEY claims on one shared chest from two real sessions" + ); + println!( + " --loot-item-capture One real session kills/opens/claims only the item and emits a fixed capture fence" + ); + println!( + " {} REQUIRED acknowledgement: mutates disposable loot fixtures (capture also kills its creature)", + loot_race::ACK_FLAG + ); + println!( + " --loot-race-account-a First disposable contender/capture killer (default TESTBOT2@bot.local)" + ); + println!( + " --loot-race-account-b Second disposable bot (default TESTBOT3@bot.local)" + ); + println!(" --loot-race-gameobject-entry Exact chest entry (default 2846 Tattered Chest)"); + println!( + " --loot-race-gameobject-spawn-guid Exact wrapper-owned world.gameobject spawn (default 9106001)" + ); + println!(" Legacy aliases: --loot-race-creature-entry / --loot-race-creature-spawn-guid"); + println!(" --loot-race-runtime-counter Optional strict live counter override (default 0=auto-discover full GUID)"); + println!(" --loot-race-item-entry Exact shared-chest loot item (default 38)"); + println!(" --loot-race-timeout Per coordination/loot timeout (capture also uses it for combat; default 30)"); + println!(" --loot-workflow-deadline Hard end-to-end loot deadline before guarded cleanup (default 900)"); + println!(" --recover-loot-fixture Restore one pending WOW_BOT_FIXTURE_JOURNAL; no login/service actions"); + println!( + " Race uses a guarded temporary chest; capture keeps its separate Doctor creature fixture" + ); + println!( + " Env capture mode: WOW_BOT_LOOT_ITEM_CAPTURE=1 (uses account A; account B remains offline as a guarded fixture snapshot)" + ); println!(" --quest-smoke After login, right-click/query one questgiver NPC"); println!(" --quest-creature-entry Creature entry to resolve from world.creature"); println!(" --quest-creature-guid Optional world.creature spawn guid override"); @@ -1316,26 +1585,51 @@ fn password_env_name(account: &str) -> String { } fn ensure_test_accounts(bots: &[config::BotConfig]) -> Result<()> { + use mysql::prelude::Queryable; + let auth_db = auth_db_url()?; let char_db = characters_db_url()?; - let auth_opts = mysql::Opts::from_url(&auth_db).map_err(|e| anyhow!("Bad auth DB URL: {e}"))?; - let char_opts = - mysql::Opts::from_url(&char_db).map_err(|e| anyhow!("Bad character DB URL: {e}"))?; + let auth_opts = qa_mysql_opts(&auth_db, "auth")?; + let char_opts = qa_mysql_opts(&char_db, "characters")?; let mut auth_conn = mysql::Conn::new(auth_opts).map_err(|e| anyhow!("Connect to auth DB failed: {e}"))?; let mut char_conn = mysql::Conn::new(char_opts).map_err(|e| anyhow!("Connect to characters DB failed: {e}"))?; + // Validate every existing character before the first auth write. Account + // provisioning is intentionally create-only: an ID collision must never + // become authority to rewrite credentials or character ownership. + for bot in bots { + validate_local_bot_character_owner(&mut char_conn, bot)?; + } for bot in bots { - ensure_local_bot_account(&mut auth_conn, bot)?; - ensure_local_bot_character_owner(&mut char_conn, bot)?; - sync_realm_character_count(&mut auth_conn, &mut char_conn, bot)?; + let character_count: u64 = char_conn + .exec_first( + "SELECT COUNT(*) FROM characters WHERE account = ?", + (bot.account_id,), + ) + .map_err(|e| anyhow!("Count characters for account {}: {e}", bot.account_id))? + .unwrap_or(0); + provision_local_bot_account_create_only(&mut auth_conn, bot, character_count)?; } Ok(()) } -fn ensure_local_bot_account(conn: &mut mysql::Conn, bot: &config::BotConfig) -> Result<()> { +fn qa_mysql_opts(url: &str, label: &str) -> Result { + let opts = mysql::Opts::from_url(url).map_err(|e| anyhow!("Bad {label} DB URL: {e}"))?; + Ok(mysql::OptsBuilder::from_opts(opts) + .tcp_connect_timeout(Some(Duration::from_secs(10))) + .read_timeout(Some(Duration::from_secs(30))) + .write_timeout(Some(Duration::from_secs(30))) + .into()) +} + +fn provision_local_bot_account_create_only( + conn: &mut mysql::Conn, + bot: &config::BotConfig, + character_count: u64, +) -> Result<()> { use mysql::prelude::Queryable; let (email, bnet_salt, bnet_verifier) = @@ -1347,69 +1641,86 @@ fn ensure_local_bot_account(conn: &mut mysql::Conn, bot: &config::BotConfig) -> "Refusing to bootstrap non-local bot account {email}; set WOW_BOT_ALLOW_NONLOCAL_ACCOUNT_BOOTSTRAP=1 if this is intentional" ); } - - let bnet_id = if let Some(id) = conn - .exec_first::( - "SELECT id FROM battlenet_accounts WHERE email = ?", + let game_username = game_account_username(&bot.account)?; + let bnet_rows: Vec<(u32, String)> = conn + .exec( + "SELECT id, email FROM battlenet_accounts WHERE email = ? ORDER BY id", (&email,), ) - .map_err(|e| anyhow!("Lookup BNet account {email}: {e}"))? - { - conn.exec_drop( - "UPDATE battlenet_accounts \ - SET srp_version = 1, salt = ?, verifier = ?, failed_logins = 0, locked = 0, lock_country = '00' \ - WHERE id = ?", - (bnet_salt.to_vec(), bnet_verifier, id), + .map_err(|e| anyhow!("Lookup BNet account {email}: {e}"))?; + if bnet_rows.len() > 1 { + bail!( + "Refusing account provisioning: BNet email {email} has {} rows", + bnet_rows.len() + ); + } + let game_account_exists = conn + .exec_first::("SELECT id FROM account WHERE id = ?", (bot.account_id,)) + .map_err(|e| anyhow!("Lookup game account id {}: {e}", bot.account_id))? + .is_some(); + + // Existing identities are validation-only. This makes repeated + // provisioning idempotent while forbidding credential rewrites. + match create_only_provisioning_plan(!bnet_rows.is_empty(), game_account_exists)? { + CreateOnlyProvisioningPlan::ValidateExisting => { + validate_exact_bot_identity(conn, None, bot)?; + validate_realm_character_count(conn, bot, character_count)?; + info!( + "[Bot {}] existing local auth fixture validated without mutation", + bot.account_id + ); + return Ok(()); + } + CreateOnlyProvisioningPlan::CreateBoth => {} + } + + let colliding_accounts: u64 = conn + .exec_first( + "SELECT COUNT(*) FROM account WHERE username = ? OR email = ? OR reg_mail = ?", + (&game_username, &email, &email), ) - .map_err(|e| anyhow!("Update BNet account {email}: {e}"))?; - id + .map_err(|e| anyhow!("Check game-account identity collisions: {e}"))? + .unwrap_or(0); + if !game_account_exists && colliding_accounts != 0 { + bail!( + "Refusing account provisioning: username/email for {} already belongs to another game account", + bot.account + ); + } + + let expected_numchars = u8::try_from(character_count).map_err(|_| { + anyhow!("Character count {character_count} exceeds realmcharacters capacity") + })?; + let mut tx = conn + .start_transaction(mysql::TxOpts::default()) + .map_err(|e| anyhow!("Start create-only account transaction: {e}"))?; + let bnet_id = if let Some((id, _)) = bnet_rows.first() { + *id } else { - conn.exec_drop( + tx.exec_drop( "INSERT INTO battlenet_accounts (email, srp_version, salt, verifier) VALUES (?, 1, ?, ?)", (&email, bnet_salt.to_vec(), bnet_verifier), ) .map_err(|e| anyhow!("Insert BNet account {email}: {e}"))?; - u32::try_from(conn.last_insert_id()).map_err(|_| anyhow!("BNet account id overflow"))? + u32::try_from( + tx.last_insert_id() + .ok_or_else(|| anyhow!("BNet account insert returned no id"))?, + ) + .map_err(|_| anyhow!("BNet account id overflow"))? }; - let game_username = game_account_username(&bot.account)?; - // The 3.4.3 world login path below authenticates through - // account.session_key_bnet. These legacy Grunt fields are still NOT NULL in - // the auth schema, so keep them initialized for local bot rows. - let account_salt = random_32(); - let account_verifier = fixed_le_32(Vec::new()); - let account_exists = conn - .exec_first::("SELECT id FROM account WHERE id = ?", (bot.account_id,)) - .map_err(|e| anyhow!("Lookup game account id {}: {e}", bot.account_id))? - .is_some(); - - if account_exists { - conn.exec_drop( - "UPDATE account \ - SET username = ?, salt = ?, verifier = ?, reg_mail = ?, email = ?, battlenet_account = ?, \ - battlenet_index = 1, expansion = 9, failed_logins = 0, locked = 0, lock_country = '00', online = 0 \ - WHERE id = ?", - ( - &game_username, - account_salt.to_vec(), - account_verifier, - &email, - &email, - bnet_id, - bot.account_id, - ), - ) - .map_err(|e| anyhow!("Update game account {}: {e}", bot.account_id))?; - } else { - conn.exec_drop( + if !game_account_exists { + // The 3.4.3 world login path authenticates through + // account.session_key_bnet. Legacy Grunt fields remain NOT NULL. + tx.exec_drop( "INSERT INTO account \ (id, username, salt, verifier, reg_mail, email, joindate, battlenet_account, battlenet_index, expansion) \ VALUES (?, ?, ?, ?, ?, ?, NOW(), ?, 1, 9)", ( bot.account_id, &game_username, - account_salt.to_vec(), - account_verifier, + random_32().to_vec(), + fixed_le_32(Vec::new()), &email, &email, bnet_id, @@ -1418,24 +1729,86 @@ fn ensure_local_bot_account(conn: &mut mysql::Conn, bot: &config::BotConfig) -> .map_err(|e| anyhow!("Insert game account {}: {e}", bot.account_id))?; } - conn.exec_drop( - "DELETE FROM battlenet_account_bans WHERE id = ?", - (bnet_id,), - ) - .map_err(|e| anyhow!("Clear BNet bans for {email}: {e}"))?; - conn.exec_drop( - "UPDATE account_banned SET active = 0 WHERE id = ?", - (bot.account_id,), - ) - .map_err(|e| anyhow!("Clear game account bans for {}: {e}", bot.account_id))?; - + let existing_realm_count: Option = tx + .exec_first( + "SELECT numchars FROM realmcharacters WHERE acctid = ? AND realmid = ?", + (bot.account_id, realm_id()), + ) + .map_err(|e| anyhow!("Load realmcharacters for account {}: {e}", bot.account_id))?; + match existing_realm_count { + Some(actual) if actual != expected_numchars => bail!( + "Refusing to rewrite realmcharacters for account {}: expected {}, found {}", + bot.account_id, + expected_numchars, + actual + ), + Some(_) => {} + None => tx + .exec_drop( + "INSERT INTO realmcharacters (numchars, acctid, realmid) VALUES (?, ?, ?)", + (expected_numchars, bot.account_id, realm_id()), + ) + .map_err(|e| anyhow!("Insert realmcharacters for account {}: {e}", bot.account_id))?, + } + tx.commit() + .map_err(|e| anyhow!("Commit create-only account transaction: {e}"))?; + validate_exact_bot_identity(conn, None, bot)?; + validate_realm_character_count(conn, bot, character_count)?; info!( - "[Bot {}] ensured local auth account {} / character GUID {}", - bot.account_id, email, bot.character_guid + "[Bot {}] created missing local auth rows without rewriting existing identities", + bot.account_id ); Ok(()) } +fn validate_realm_character_count( + auth_conn: &mut mysql::Conn, + bot: &config::BotConfig, + character_count: u64, +) -> Result<()> { + use mysql::prelude::Queryable; + + let expected = u8::try_from(character_count).map_err(|_| { + anyhow!("Character count {character_count} exceeds realmcharacters capacity") + })?; + let actual: Option = auth_conn + .exec_first( + "SELECT numchars FROM realmcharacters WHERE acctid = ? AND realmid = ?", + (bot.account_id, realm_id()), + ) + .map_err(|e| anyhow!("Load realmcharacters for account {}: {e}", bot.account_id))?; + if actual != Some(expected) { + bail!( + "realmcharacters for account {} must remain {}, found {actual:?}; create-only provisioning will not rewrite it", + bot.account_id, + expected + ); + } + Ok(()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CreateOnlyProvisioningPlan { + ValidateExisting, + CreateBoth, +} + +fn create_only_provisioning_plan( + bnet_exists: bool, + game_account_exists: bool, +) -> Result { + match (bnet_exists, game_account_exists) { + (true, true) => Ok(CreateOnlyProvisioningPlan::ValidateExisting), + (false, false) => Ok(CreateOnlyProvisioningPlan::CreateBoth), + (true, false) => bail!( + "Refusing create-only provisioning: a BNet identity already exists without the configured game account" + ), + (false, true) => bail!( + "Refusing create-only provisioning: the numeric game-account ID already exists without the configured BNet identity" + ), + } +} + fn game_account_username(account: &str) -> Result { let local_part = account .split('@') @@ -1461,52 +1834,186 @@ fn random_32() -> [u8; 32] { bytes } -fn ensure_local_bot_character_owner(conn: &mut mysql::Conn, bot: &config::BotConfig) -> Result<()> { +fn validate_local_bot_character_owner( + conn: &mut mysql::Conn, + bot: &config::BotConfig, +) -> Result<()> { use mysql::prelude::Queryable; - let owner = conn - .exec_first::( - "SELECT account FROM characters WHERE guid = ?", + let (owner, online, at_login) = conn + .exec_first::<(u32, u8, u16), _, _>( + "SELECT account, online, at_login FROM characters WHERE guid = ?", (bot.character_guid,), ) .map_err(|e| anyhow!("Lookup character {}: {e}", bot.character_guid))? .ok_or_else(|| anyhow!("No characters row for guid {}", bot.character_guid))?; if owner != bot.account_id { - warn!( - "[Bot {}] character GUID {} belonged to account {}; reassigning to test account", - bot.account_id, bot.character_guid, owner + bail!( + "Refusing to reassign character GUID {} from account {} to configured test account {}", + bot.character_guid, + owner, + bot.account_id + ); + } + if online != 0 || at_login != 0 { + bail!( + "Character GUID {} is not an offline clean fixture (online={online}, at_login={at_login})", + bot.character_guid ); - conn.exec_drop( - "UPDATE characters SET account = ? WHERE guid = ?", - (bot.account_id, bot.character_guid), - ) - .map_err(|e| anyhow!("Update owner for character {}: {e}", bot.character_guid))?; } Ok(()) } -fn sync_realm_character_count( +fn validate_exact_bot_identity( auth_conn: &mut mysql::Conn, - char_conn: &mut mysql::Conn, + mut character_conn: Option<&mut mysql::Conn>, bot: &config::BotConfig, ) -> Result<()> { use mysql::prelude::Queryable; - let count = char_conn - .exec_first::( - "SELECT COUNT(*) FROM characters WHERE account = ?", + let expected_email = bot_srp6::utf8_to_upper_only_latin_like_cpp(&bot.account); + let expected_username = game_account_username(&bot.account)?; + let bnet_rows: Vec<(u32, String, i8, Vec, Vec, u32, u8, u8)> = auth_conn + .exec( + "SELECT id, email, srp_version, salt, verifier, failed_logins, locked, online \ + FROM battlenet_accounts WHERE email = ? ORDER BY id", + (&expected_email,), + ) + .map_err(|e| anyhow!("Load exact BNet fixture {}: {e}", bot.account))?; + if bnet_rows.len() != 1 { + bail!( + "Expected exactly one BNet row for {}, found {}", + bot.account, + bnet_rows.len() + ); + } + let (bnet_id, email, srp_version, salt, verifier, failed_logins, locked, bnet_online) = + &bnet_rows[0]; + let (_, expected_verifier) = + bot_srp6::bnet_v1_verifier_for_salt_like_cpp(&bot.account, &bot.password, salt); + if !email.eq_ignore_ascii_case(&expected_email) + || *srp_version != 1 + || salt.len() != 32 + || *verifier != expected_verifier + || *failed_logins != 0 + || *locked != 0 + || *bnet_online != 0 + { + bail!( + "BNet fixture {} does not exactly match configured credentials/offline state", + bot.account + ); + } + + let account = auth_conn + .exec_first::<( + String, + String, + String, + Option, + Option, + u8, + u32, + u8, + u8, + ), _, _>( + "SELECT username, reg_mail, email, battlenet_account, battlenet_index, expansion, \ + failed_logins, locked, online FROM account WHERE id = ?", (bot.account_id,), ) - .map_err(|e| anyhow!("Count characters for account {}: {e}", bot.account_id))? + .map_err(|e| anyhow!("Load exact game-account fixture {}: {e}", bot.account_id))? + .ok_or_else(|| anyhow!("No game-account row for id {}", bot.account_id))?; + if !account.0.eq_ignore_ascii_case(&expected_username) + || !account.1.eq_ignore_ascii_case(&expected_email) + || !account.2.eq_ignore_ascii_case(&expected_email) + || account.3 != Some(*bnet_id) + || account.4 != Some(1) + || account.5 != 9 + || account.6 != 0 + || account.7 != 0 + || account.8 != 0 + { + bail!( + "Game account {} does not exactly match username/email/BNet/offline fixture contract", + bot.account_id + ); + } + let game_accounts_on_bnet: u64 = auth_conn + .exec_first( + "SELECT COUNT(*) FROM account WHERE battlenet_account = ?", + (*bnet_id,), + ) + .map_err(|e| anyhow!("Count game accounts on BNet fixture: {e}"))? .unwrap_or(0); - auth_conn - .exec_drop( - "REPLACE INTO realmcharacters (numchars, acctid, realmid) VALUES (?, ?, ?)", - (count, bot.account_id, realm_id()), + if game_accounts_on_bnet != 1 { + bail!( + "BNet fixture {} must own exactly one game account, found {game_accounts_on_bnet}", + bot.account + ); + } + let bnet_bans: u64 = auth_conn + .exec_first( + "SELECT COUNT(*) FROM battlenet_account_bans WHERE id = ?", + (*bnet_id,), ) - .map_err(|e| anyhow!("Sync realmcharacters for account {}: {e}", bot.account_id))?; + .map_err(|e| anyhow!("Check BNet fixture bans: {e}"))? + .unwrap_or(0); + let game_bans: u64 = auth_conn + .exec_first( + "SELECT COUNT(*) FROM account_banned WHERE id = ? AND active <> 0", + (bot.account_id,), + ) + .map_err(|e| anyhow!("Check game-account fixture bans: {e}"))? + .unwrap_or(0); + if bnet_bans != 0 || game_bans != 0 { + bail!( + "Configured bot fixture is banned (bnet rows={bnet_bans}, active game rows={game_bans}); provisioning will not clear bans" + ); + } + + if let Some(char_conn) = character_conn.as_mut() { + validate_local_bot_character_owner(char_conn, bot)?; + let count: u64 = char_conn + .exec_first( + "SELECT COUNT(*) FROM characters WHERE account = ?", + (bot.account_id,), + ) + .map_err(|e| anyhow!("Count dedicated fixture characters: {e}"))? + .unwrap_or(0); + if count != 1 { + bail!( + "Loot fixture account {} must own exactly one character, found {count}", + bot.account_id + ); + } + let realm_count: Option = auth_conn + .exec_first( + "SELECT numchars FROM realmcharacters WHERE acctid = ? AND realmid = ?", + (bot.account_id, realm_id()), + ) + .map_err(|e| anyhow!("Load realmcharacters fixture count: {e}"))?; + if realm_count != Some(1) { + bail!( + "realmcharacters fixture count for account {} must be exactly 1, found {realm_count:?}", + bot.account_id + ); + } + } + Ok(()) +} + +fn validate_exact_loot_bot_identities(bots: &[config::BotConfig]) -> Result<()> { + let auth_opts = qa_mysql_opts(&auth_db_url()?, "auth")?; + let char_opts = qa_mysql_opts(&characters_db_url()?, "characters")?; + let mut auth_conn = + mysql::Conn::new(auth_opts).map_err(|e| anyhow!("Connect to auth DB failed: {e}"))?; + let mut character_conn = + mysql::Conn::new(char_opts).map_err(|e| anyhow!("Connect to characters DB failed: {e}"))?; + for bot in bots { + validate_exact_bot_identity(&mut auth_conn, Some(&mut character_conn), bot)?; + } Ok(()) } @@ -1518,6 +2025,25 @@ async fn main() -> Result<()> { info!("═══════════════════════════════════════════════════"); let cli = parse_cli()?; + if cli.recover_loot_fixture { + let conflicting_mode = cli.ensure_test_accounts + || cli.login_only + || cli.stand_state_smoke + || cli.bank_smoke + || cli.homebind_smoke + || cli.inventory_swap_smoke + || cli.rested_xp_smoke + || cli.loot_race_smoke + || cli.loot_item_capture + || cli.quest_smoke + || cli.single_account.is_some(); + if conflicting_mode { + bail!("--recover-loot-fixture must be used alone"); + } + loot_race::recover_pending_fixture().await?; + info!("Pending loot fixture recovered and cleanup marker written"); + return Ok(()); + } let app_config = config::AppConfig::load_or_create(&cli.config_path)?; let mut bots: Vec = app_config.get_enabled_bots().into_iter().cloned().collect(); @@ -1525,6 +2051,17 @@ async fn main() -> Result<()> { if let Some(account) = &cli.single_account { bots.retain(|bot| bot.account.eq_ignore_ascii_case(account)); } + if cli.loot_race_smoke || cli.loot_item_capture { + if cli.single_account.is_some() { + bail!( + "--single is incompatible with the loot workflows; select the guarded fixture with the loot account flags" + ); + } + bots.retain(|bot| { + bot.account.eq_ignore_ascii_case(&cli.loot_race_account_a) + || bot.account.eq_ignore_ascii_case(&cli.loot_race_account_b) + }); + } apply_password_overrides(&mut bots); if bots.is_empty() { @@ -1542,26 +2079,23 @@ async fn main() -> Result<()> { password_env_name(missing_passwords[0]) ); } - if cli.ensure_test_accounts { - let bots_for_db = bots.clone(); - tokio::task::spawn_blocking(move || ensure_test_accounts(&bots_for_db)) - .await - .map_err(|e| anyhow!("DB worker join failed while ensuring test accounts: {}", e))? - .map_err(|e| anyhow!("Failed to ensure test accounts: {}", e))?; - } + let loot_mode = cli.loot_race_smoke || cli.loot_item_capture; + validate_provisioning_mode(loot_mode, cli.ensure_test_accounts)?; let post_login_mode_count = [ cli.stand_state_smoke, cli.bank_smoke, cli.homebind_smoke, cli.inventory_swap_smoke, cli.rested_xp_smoke, + cli.loot_race_smoke, + cli.loot_item_capture, cli.quest_smoke, ] .into_iter() .filter(|enabled| *enabled) .count(); if post_login_mode_count > 1 { - bail!("stand-state, bank, homebind, inventory-swap, rested-xp, and quest smoke are separate post-login modes"); + bail!("stand-state, bank, homebind, inventory-swap, rested-xp, loot-race, loot-item-capture, and quest smoke are separate post-login modes"); } if cli.bank_smoke && bots.len() != 1 { bail!("--bank-smoke requires exactly one bot; select it with --single"); @@ -1600,6 +2134,38 @@ async fn main() -> Result<()> { cli.rested_xp_timeout_secs, current_epoch_secs(), )?; + let loot_race_cli = loot_race::LootRaceCli { + account_a: cli.loot_race_account_a.clone(), + account_b: cli.loot_race_account_b.clone(), + entry: cli.loot_race_creature_entry, + spawn_guid: cli.loot_race_creature_spawn_guid, + runtime_counter: cli.loot_race_runtime_counter, + item_entry: cli.loot_race_item_entry, + timeout_secs: cli.loot_race_timeout_secs, + workflow_deadline_secs: cli.loot_workflow_deadline_secs, + }; + loot_race::validate_cli( + cli.loot_race_smoke, + cli.loot_item_capture, + cli.ack_disposable_overworld_loot_race, + &bots, + &loot_race_cli, + )?; + if loot_mode { + loot_race::validate_journal_contract()?; + let bots_for_validation = bots.clone(); + tokio::task::spawn_blocking(move || { + validate_exact_loot_bot_identities(&bots_for_validation) + }) + .await + .map_err(|e| anyhow!("Loot identity-preflight DB worker join failed: {e}"))??; + } else if cli.ensure_test_accounts { + let bots_for_db = bots.clone(); + tokio::task::spawn_blocking(move || ensure_test_accounts(&bots_for_db)) + .await + .map_err(|e| anyhow!("DB worker join failed while provisioning test accounts: {e}"))? + .map_err(|e| anyhow!("Failed to provision test accounts: {e}"))?; + } let stand_state_options = if cli.stand_state_smoke { Some(stand_state_smoke_options_from_cli(&cli)?) } else { @@ -1646,6 +2212,10 @@ async fn main() -> Result<()> { "inventory-swap-smoke" } else if cli.rested_xp_smoke { "rested-xp-smoke" + } else if cli.loot_race_smoke { + "loot-race-smoke" + } else if cli.loot_item_capture { + "loot-item-capture" } else if cli.quest_smoke { "quest-smoke" } else if cli.login_only { @@ -1667,13 +2237,49 @@ async fn main() -> Result<()> { && !cli.homebind_smoke && !cli.inventory_swap_smoke && !cli.rested_xp_smoke + && !cli.loot_race_smoke + && !cli.loot_item_capture { cleanup_bot_group_state(&bots)?; } - let expected_bot_count = bots.len(); + let expected_bot_count = if cli.loot_item_capture { 1 } else { bots.len() }; let mut results = Vec::new(); - if cli.sequential || bots.len() == 1 { + if cli.loot_item_capture { + let shutdown = install_loot_termination_token()?; + results = finish_guarded_loot_result( + loot_race::run_single_item_capture_workflow( + bots, + loot_race_cli, + dungeon_id, + timeout_secs, + auto_teleport, + shutdown, + ) + .await, + ) + .await?; + for result in &results { + log_bot_summary(result, require_proposal, require_group, cli.login_only); + } + } else if cli.loot_race_smoke { + let shutdown = install_loot_termination_token()?; + results = finish_guarded_loot_result( + loot_race::run_workflow( + bots, + loot_race_cli, + dungeon_id, + timeout_secs, + auto_teleport, + shutdown, + ) + .await, + ) + .await?; + for result in &results { + log_bot_summary(result, require_proposal, require_group, cli.login_only); + } + } else if cli.sequential || bots.len() == 1 { for bot in bots { info!("\n[Bot {}] Starting...", bot.account); let run = if cli.bank_smoke { @@ -1733,6 +2339,7 @@ async fn main() -> Result<()> { None, None, None, + None, quest_options.clone(), ) .await @@ -1770,6 +2377,7 @@ async fn main() -> Result<()> { None, None, None, + None, quest_options_for_bot, ) .await; @@ -1803,6 +2411,8 @@ async fn main() -> Result<()> { cli.homebind_smoke, cli.inventory_swap_smoke, cli.rested_xp_smoke, + cli.loot_race_smoke, + cli.loot_item_capture, cli.quest_smoke, &results, )?; @@ -1928,6 +2538,7 @@ async fn run_bot( homebind_options: Option, inventory_swap_options: Option, rested_xp_options: Option, + loot_race_options: Option, quest_options: Option, ) -> Result { let bot_index = bot.account_id as usize; @@ -2038,6 +2649,30 @@ async fn run_bot( rested_xp_db_rest_after: None, rested_xp_relog_verified: false, rested_xp_failure: None, + loot_race_smoke: loot_race_options.is_some(), + loot_race_smoke_passed: None, + loot_race_target_entry: loot_race_options + .as_ref() + .map(|options| options.target.entry), + loot_race_target_spawn_guid: loot_race_options + .as_ref() + .map(|options| options.target.spawn_guid), + loot_race_target_runtime_counter: loot_race_options + .as_ref() + .and_then(|options| options.resolved_runtime_counter().ok()), + loot_race_party_confirmed: false, + loot_race_target_discovered: false, + loot_race_loot_opened: false, + loot_race_loot_list_id: None, + loot_race_loot_coins: None, + loot_race_item_push_seen: false, + loot_race_loot_removed_seen: false, + loot_race_money_notify_amount: None, + loot_race_coin_removed_seen: false, + loot_race_db_item_total: None, + loot_race_db_money_delta: None, + loot_race_relog_verified: false, + loot_race_failure: None, quest_smoke: quest_options.is_some(), quest_smoke_passed: None, quest_target_entry: None, @@ -2157,15 +2792,19 @@ async fn run_bot( world_port() ); let world_addr = format!("{}:{}", world_host(), world_port()); - let mut stream = TcpStream::connect(&world_addr) - .await - .map_err(|e| anyhow!("Failed to connect to world server: {}", e))?; + let mut stream = + tokio::time::timeout(INITIAL_NETWORK_IO_TIMEOUT, TcpStream::connect(&world_addr)) + .await + .map_err(|_| anyhow!("Timed out connecting to world server {world_addr}"))? + .map_err(|e| anyhow!("Failed to connect to world server: {}", e))?; info!("[Bot {}] ✅ TCP connected", bot_index); // ── Step 3: World Server Handshake ────────────────────────────────────── info!("[Bot {}] Step 3: Handshake...", bot_index); let mut init_buf = vec![0u8; 256]; - let n = stream.read(&mut init_buf).await?; + let n = tokio::time::timeout(INITIAL_NETWORK_IO_TIMEOUT, stream.read(&mut init_buf)) + .await + .map_err(|_| anyhow!("Timed out reading SERVER_INIT"))??; if !init_buf[..n].starts_with(&SERVER_INIT[..SERVER_INIT.len().min(n)]) { bail!( "Unexpected server init: {:?}", @@ -2174,13 +2813,22 @@ async fn run_bot( } info!("[Bot {}] ✅ SERVER_INIT received", bot_index); - stream.write_all(CLIENT_INIT).await?; - stream.flush().await?; + tokio::time::timeout(INITIAL_NETWORK_IO_TIMEOUT, async { + stream.write_all(CLIENT_INIT).await?; + stream.flush().await + }) + .await + .map_err(|_| anyhow!("Timed out writing CLIENT_INIT"))??; info!("[Bot {}] ✅ CLIENT_INIT sent", bot_index); // ── Step 4: Read SMSG_AUTH_CHALLENGE ──────────────────────────────────── info!("[Bot {}] Step 4: Reading SMSG_AUTH_CHALLENGE...", bot_index); - let (opcode, challenge_data) = read_unencrypted_packet(&mut stream).await?; + let (opcode, challenge_data) = tokio::time::timeout( + INITIAL_NETWORK_IO_TIMEOUT, + read_unencrypted_packet(&mut stream), + ) + .await + .map_err(|_| anyhow!("Timed out reading SMSG_AUTH_CHALLENGE"))??; if opcode != 0x3048 { bail!( "Expected SMSG_AUTH_CHALLENGE (0x3048), got 0x{:04X}", @@ -2341,17 +2989,28 @@ async fn run_bot( info!("[Bot {}] ✅ CMSG_PLAYER_LOGIN sent", bot_index); let mut login_ok = false; - let preserve_realm_connection = - stand_state_options.is_some() || homebind_options.is_some() || rested_xp_options.is_some(); - for _ in 0..30 { + let preserve_realm_connection = stand_state_options.is_some() + || homebind_options.is_some() + || rested_xp_options.is_some() + || loot_race_options.is_some(); + let mut loot_race_target_seen = false; + let login_budget = LoginVerifyBudget::new(LOGIN_VERIFY_TIMEOUT); + while let Some(read_timeout) = login_budget.next_read_timeout() { match tokio::time::timeout( - Duration::from_secs(5), + read_timeout, read_encrypted_packet(&mut stream, &mut crypt, &mut server_inflater), ) .await { Ok(Ok((op, payload))) => { result.seen_opcodes.push(format!("0x{:04X}", op)); + if let Some(options) = loot_race_options.as_ref() { + if let Some(counter) = loot_race::target_seen_in_update(options, op, &payload)? + { + loot_race_target_seen = true; + result.loot_race_target_runtime_counter = Some(counter); + } + } if let Some(options) = quest_options.as_ref() { record_quest_objective_login_signal(op, &payload, options, &mut result); } @@ -2412,7 +3071,9 @@ async fn run_bot( warn!("[Bot {}] Login read error: {}", bot_index, e); break; } - Err(_) => break, + // A quiet five-second slice does not invalidate an otherwise + // healthy login. The absolute deadline above remains the guard. + Err(_) => continue, } } if !login_ok { @@ -2540,6 +3201,35 @@ async fn run_bot( return Ok(result); } + if let Some(loot_race_options) = loot_race_options { + if let Err(error) = loot_race::run_phase( + bot_index, + &mut stream, + &mut crypt, + &mut server_inflater, + &mut realm_connection, + &loot_race_options, + loot_race_target_seen, + &mut result, + ) + .await + { + result.loot_race_failure = Some(error.to_string()); + result.loot_race_smoke_passed = Some(false); + loot_race::best_effort_close( + bot_index, + &mut stream, + &mut crypt, + &mut server_inflater, + &mut realm_connection, + loot_race_options.character_guid, + &mut result, + ) + .await; + } + return Ok(result); + } + if login_only { info!( "[Bot {}] ✅ Login-only smoke passed: world_auth=true enum_characters=true player_login=true", @@ -2817,6 +3507,29 @@ fn log_bot_summary( ); return; } + if result.loot_race_smoke { + info!( + "✅ Bot {}: SUCCESS loot_race target={:?}/{:?}/counter={:?} party={} discovered={} opened={} list={:?} coins={:?} item_push={} removed={} money_notify={:?} coin_removed={} db_item={:?} db_money_delta={:?} relog={} failure={:?}", + result.account, + result.loot_race_target_entry, + result.loot_race_target_spawn_guid, + result.loot_race_target_runtime_counter, + result.loot_race_party_confirmed, + result.loot_race_target_discovered, + result.loot_race_loot_opened, + result.loot_race_loot_list_id, + result.loot_race_loot_coins, + result.loot_race_item_push_seen, + result.loot_race_loot_removed_seen, + result.loot_race_money_notify_amount, + result.loot_race_coin_removed_seen, + result.loot_race_db_item_total, + result.loot_race_db_money_delta, + result.loot_race_relog_verified, + result.loot_race_failure, + ); + return; + } info!( "✅ Bot {}: SUCCESS login={{auth:{}, enum:{}, player:{}}} join={:?}/{:?} proposal={} group={} teleport_denied={:?}", result.account, @@ -2930,6 +3643,29 @@ fn log_bot_summary( ); return; } + if result.loot_race_smoke { + error!( + "❌ Bot {}: FAILED loot_race target={:?}/{:?}/counter={:?} party={} discovered={} opened={} list={:?} coins={:?} item_push={} removed={} money_notify={:?} coin_removed={} db_item={:?} db_money_delta={:?} relog={} failure={:?}", + result.account, + result.loot_race_target_entry, + result.loot_race_target_spawn_guid, + result.loot_race_target_runtime_counter, + result.loot_race_party_confirmed, + result.loot_race_target_discovered, + result.loot_race_loot_opened, + result.loot_race_loot_list_id, + result.loot_race_loot_coins, + result.loot_race_item_push_seen, + result.loot_race_loot_removed_seen, + result.loot_race_money_notify_amount, + result.loot_race_coin_removed_seen, + result.loot_race_db_item_total, + result.loot_race_db_money_delta, + result.loot_race_relog_verified, + result.loot_race_failure, + ); + return; + } error!( "❌ Bot {}: FAILED login={{auth:{}, enum:{}, player:{}}} join={:?}/{:?} proposal={} group={} teleport_denied={:?}", result.account, @@ -2958,6 +3694,8 @@ fn write_report_if_requested( homebind_smoke: bool, inventory_swap_smoke: bool, rested_xp_smoke: bool, + loot_race_smoke: bool, + loot_item_capture: bool, quest_smoke: bool, results: &[BotRunResult], ) -> Result<()> { @@ -2979,6 +3717,8 @@ fn write_report_if_requested( homebind_smoke, inventory_swap_smoke, rested_xp_smoke, + loot_race_smoke, + loot_item_capture, quest_smoke, results: results.to_vec(), }; @@ -3770,6 +4510,7 @@ async fn run_rested_xp_smoke_workflow_inner( None, Some(wilderness_options), None, + None, ) .await?; if !combined.rested_xp_smoke_passed.unwrap_or(false) { @@ -3831,6 +4572,7 @@ async fn run_rested_xp_smoke_workflow_inner( None, Some(resting_options), None, + None, ) .await?; merge_rested_xp_results(&mut combined, resting_result); @@ -3903,6 +4645,7 @@ async fn run_rested_xp_smoke_workflow_inner( None, Some(consume_options), None, + None, ) .await?; merge_rested_xp_results(&mut combined, consume_result); @@ -3932,6 +4675,7 @@ async fn run_rested_xp_smoke_workflow_inner( None, Some(verify_options), None, + None, ) .await?; merge_rested_xp_results(&mut combined, verify_result); @@ -4040,6 +4784,7 @@ async fn run_bank_smoke_workflow( None, None, None, + None, ) .await; @@ -4071,6 +4816,7 @@ async fn run_bank_smoke_workflow( None, None, None, + None, ) .await { @@ -4139,6 +4885,7 @@ async fn run_homebind_smoke_workflow( None, None, None, + None, ) .await; @@ -4204,6 +4951,7 @@ async fn run_homebind_smoke_workflow( None, None, None, + None, ) .await { @@ -5053,7 +5801,7 @@ async fn disconnect_rested_xp_and_wait( .await .context("send rested-XP CMSG_LOGOUT_REQUEST")?; info!("[Bot {}] ✅ rested-XP CMSG_LOGOUT_REQUEST sent", bot_index); - let logout_wait_secs = timeout_secs.min(RESTED_XP_GRACEFUL_LOGOUT_WAIT_SECS); + let logout_wait_secs = timeout_secs.min(NORMAL_LOGOUT_COMPLETE_WAIT_SECS); let logout_deadline = tokio::time::Instant::now() + Duration::from_secs(logout_wait_secs); let client_clock_origin = tokio::time::Instant::now(); let mut logout_complete = false; @@ -5391,6 +6139,7 @@ async fn run_inventory_swap_smoke_workflow( Some(forward_options), None, None, + None, ) .await; @@ -5422,6 +6171,7 @@ async fn run_inventory_swap_smoke_workflow( Some(reverse_options), None, None, + None, ) .await { @@ -5660,7 +6410,8 @@ async fn logout_and_wait( ) -> Result<()> { send_encrypted_packet(stream, crypt, CMSG_LOGOUT_REQUEST, &[0]).await?; info!("[Bot {}] ✅ CMSG_LOGOUT_REQUEST sent", bot_index); - let deadline = tokio::time::Instant::now() + Duration::from_secs(8); + let deadline = + tokio::time::Instant::now() + Duration::from_secs(NORMAL_LOGOUT_COMPLETE_WAIT_SECS); while tokio::time::Instant::now() < deadline { let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); match tokio::time::timeout( @@ -9227,7 +9978,7 @@ fn build_player_login(guid: u64, realm_id: u32, far_clip: f32) -> Vec { fn create_player_guid_raw(guid: u64, realm_id: u32) -> (u64, u64) { // C++ ObjectGuid::Create(realmId, guid). - let high = (2u64 << 58) | ((u64::from(realm_id) & 0x1FFF) << 42); + let high = (2u64 << 58) | ((u64::from(realm_id) & 0xFFFF) << 42); (guid, high) } @@ -9611,6 +10362,23 @@ fn build_packed_guid(low: u64, high: u64) -> Vec { mod tests { use super::*; + #[test] + fn login_verify_budget_is_time_based_not_packet_count() { + let budget = LoginVerifyBudget::new(Duration::from_secs(1)); + + // The former `for _ in 0..30` guard disconnected a second concurrent + // client after 30 fast CREATE/broadcast packets. Observing packets must + // not consume the wall-clock budget. + for _ in 0..64 { + assert!(budget.next_read_timeout().is_some()); + } + + assert_eq!( + LoginVerifyBudget::new(Duration::ZERO).next_read_timeout(), + None + ); + } + fn pack_msb_fields(fields: &[(u32, usize)]) -> Vec { let mut bytes = Vec::new(); let mut current = 0u8; @@ -9819,7 +10587,7 @@ mod tests { } #[test] - fn creature_guid_uses_runtime_counter_and_zero_realm_like_cpp() { + fn legacy_creature_guid_constructor_keeps_counter_map_and_entry_fields() { let (low, high) = create_creature_guid_raw(571, 15_513, 77_001); assert_eq!(low, 77_001); @@ -10363,6 +11131,44 @@ mod tests { .to_string() .contains("invalid PlayerSave.Stats.MinLevel value")); } + + #[test] + fn pinned_instance_port_rejects_invalid_or_different_connect_to_target() { + assert!(validate_pinned_instance_port(8086, None).is_ok()); + assert!(validate_pinned_instance_port(8086, Some("8086")).is_ok()); + + let different = validate_pinned_instance_port(9000, Some("8086")) + .expect_err("a different advertised instance port must fail closed"); + assert!(different + .to_string() + .contains("advertised instance port 9000")); + assert!(validate_pinned_instance_port(8086, Some("0")).is_err()); + assert!(validate_pinned_instance_port(8086, Some("not-a-port")).is_err()); + } + + #[test] + fn loot_mode_rejects_generic_account_provisioning() { + assert!(validate_provisioning_mode(false, false).is_ok()); + assert!(validate_provisioning_mode(false, true).is_ok()); + assert!(validate_provisioning_mode(true, false).is_ok()); + let error = validate_provisioning_mode(true, true) + .expect_err("loot mode must reject provisioning before any DB mutation"); + assert!(error.to_string().contains("forbid --ensure-test-accounts")); + } + + #[test] + fn create_only_provisioning_rejects_partial_identity_collisions() { + assert_eq!( + create_only_provisioning_plan(false, false).unwrap(), + CreateOnlyProvisioningPlan::CreateBoth + ); + assert_eq!( + create_only_provisioning_plan(true, true).unwrap(), + CreateOnlyProvisioningPlan::ValidateExisting + ); + assert!(create_only_provisioning_plan(true, false).is_err()); + assert!(create_only_provisioning_plan(false, true).is_err()); + } } fn build_quest_giver_query_quest(packed_guid: &[u8], quest_id: u32) -> Vec { @@ -10452,6 +11258,9 @@ async fn connect_to_instance( ); } + let expected_port = std::env::var("INSTANCE_PORT").ok(); + validate_pinned_instance_port(connect_to.port, expected_port.as_deref())?; + let target_host = std::env::var("INSTANCE_HOST").unwrap_or_else(|_| connect_to.address.to_string()); let addr = format!("{}:{}", target_host, connect_to.port); @@ -10459,12 +11268,15 @@ async fn connect_to_instance( "[Bot {}] Connecting to instance socket {}...", bot_index, addr ); - let mut stream = TcpStream::connect(&addr) + let mut stream = tokio::time::timeout(INITIAL_NETWORK_IO_TIMEOUT, TcpStream::connect(&addr)) .await + .map_err(|_| anyhow!("Timed out connecting to instance socket {addr}"))? .map_err(|e| anyhow!("Failed to connect to instance socket {}: {}", addr, e))?; let mut init_buf = vec![0u8; 256]; - let n = stream.read(&mut init_buf).await?; + let n = tokio::time::timeout(INITIAL_NETWORK_IO_TIMEOUT, stream.read(&mut init_buf)) + .await + .map_err(|_| anyhow!("Timed out reading instance SERVER_INIT"))??; if !init_buf[..n].starts_with(&SERVER_INIT[..SERVER_INIT.len().min(n)]) { bail!( "Unexpected instance server init: {:?}", @@ -10472,10 +11284,19 @@ async fn connect_to_instance( ); } - stream.write_all(CLIENT_INIT).await?; - stream.flush().await?; + tokio::time::timeout(INITIAL_NETWORK_IO_TIMEOUT, async { + stream.write_all(CLIENT_INIT).await?; + stream.flush().await + }) + .await + .map_err(|_| anyhow!("Timed out writing instance CLIENT_INIT"))??; - let (opcode, challenge_data) = read_unencrypted_packet(&mut stream).await?; + let (opcode, challenge_data) = tokio::time::timeout( + INITIAL_NETWORK_IO_TIMEOUT, + read_unencrypted_packet(&mut stream), + ) + .await + .map_err(|_| anyhow!("Timed out reading instance SMSG_AUTH_CHALLENGE"))??; if opcode != 0x3048 { bail!( "Expected instance SMSG_AUTH_CHALLENGE (0x3048), got 0x{:04X}", @@ -10528,6 +11349,26 @@ async fn connect_to_instance( Ok((stream, WorldCrypt::new_with_counters(&enc_key, 2, 2))) } +fn validate_pinned_instance_port(advertised_port: u16, expected_port: Option<&str>) -> Result<()> { + let Some(expected_port) = expected_port else { + return Ok(()); + }; + let expected_port = expected_port + .parse::() + .with_context(|| "INSTANCE_PORT must be a valid nonzero TCP port")?; + if expected_port == 0 { + bail!("INSTANCE_PORT must be a valid nonzero TCP port"); + } + if advertised_port != expected_port { + bail!( + "SMSG_CONNECT_TO advertised instance port {}, expected pinned INSTANCE_PORT {}", + advertised_port, + expected_port + ); + } + Ok(()) +} + /// Pack u64 into WoW's packed format (mask + non-zero bytes) fn pack_u64(value: u64) -> (u8, Vec) { let mut mask = 0u8; From 79405dba36969c299a854861bbf9bb2eab4bb75e Mon Sep 17 00:00:00 2001 From: cDmonio Date: Sun, 19 Jul 2026 11:47:05 +0000 Subject: [PATCH 02/14] [08.5] Allow bounded slow startup in capture QA --- crates/capture-diff/README.md | 24 +- crates/capture-diff/scripts/capture-cpp.sh | 19 +- crates/capture-diff/scripts/capture-rust.sh | 165 ++++-- .../scripts/capture-service-common.sh | 41 +- .../scripts/loot-fixture-common.sh | 36 ++ tools/pr-preflight.sh | 476 +++++++++++++++++- tools/wow-test-bot/README.md | 5 +- tools/wow-test-bot/src/loot_race.rs | 193 +++++-- 8 files changed, 852 insertions(+), 107 deletions(-) diff --git a/crates/capture-diff/README.md b/crates/capture-diff/README.md index 99793b096..e6403851a 100644 --- a/crates/capture-diff/README.md +++ b/crates/capture-diff/README.md @@ -250,7 +250,15 @@ and after the interactive capture; a provenance mismatch fails closed. The snapshot's original executable is still restored on normal exit or a caught termination signal, but readiness requires both distinct world/instance listeners to belong to one accredited PID that is the PM2 entry itself or its -verified descendant. Before any fixture restoration, cleanup removes the PM2 +verified descendant. The stopped-world accreditation used during C++ swaps +waits at most 30 seconds and stable startup waits at most 180 seconds by +default; operators may override the bounded +`CAPTURE_WORLD_STOP_TIMEOUT_SECONDS` and +`CAPTURE_WORLD_READY_TIMEOUT_SECONDS` values from 1 and 3, respectively, +through 3600 seconds. The automated two-session QA gives the wrapper both +budgets plus a separate 120-second pre-readiness margin for offline-character, +tree-termination, SQL, PM2, and accreditation work. +Before any fixture restoration, cleanup removes the PM2 entry and verifies the recorded entry, listener, and descendant process-tree identities plus both listeners are absent; a stubborn tree is terminated and rechecked. The dump parser accepts only a flat set of regular, non-symlink @@ -294,12 +302,14 @@ leaves the normal world stopped for manual inspection. The bot writes a mode-0600 recovery journal before its first character/fixture mutation, removes it only after verified restoration, and atomically creates `${WOW_BOT_FIXTURE_JOURNAL}.cleanup-complete`. The capture wrapper removes that -marker only after the original PM2 profile is healthy again. A pending journal, -missing marker, caught TERM, state/metadata drift, or cleanup error leaves the -normal world stopped and retains recovery evidence; cleanup failure is the -reported exit status even when the capture already failed. `SIGKILL` cannot be -trapped, so an operator must recover a retained journal before restarting the -normal world. +marker only after the original PM2 profile is healthy again. If the wrapper +fails before it exposes the flow prompt, both journal and marker must still be +absent and the original world can be restored without inventing a cleanup +receipt. Once the prompt is exposed, a pending journal, missing marker, caught +TERM, state/metadata drift, or cleanup error leaves the normal world stopped +and retains recovery evidence; cleanup failure is the reported exit status even +when the capture already failed. `SIGKILL` cannot be trapped, so an operator +must recover a retained journal before restarting the normal world. The shared-chest preflight claims no spawn that has pool, event, linked-respawn, `gameobject_addon`, `gameobject_overrides`, or `spawn_group` diff --git a/crates/capture-diff/scripts/capture-cpp.sh b/crates/capture-diff/scripts/capture-cpp.sh index bb097b5ec..286bdf7f3 100755 --- a/crates/capture-diff/scripts/capture-cpp.sh +++ b/crates/capture-diff/scripts/capture-cpp.sh @@ -30,6 +30,10 @@ # is recorded (default: /home/server/woltk-trinity-legacy) # CAPTURE_ORCHESTRATION_LOCK optional absolute private lock directory shared # with capture-rust.sh (default: /tmp, keyed by uid+ports) +# CAPTURE_WORLD_STOP_TIMEOUT_SECONDS bounded wait for a stopped world/ports +# (default: 30, range: 1 through 3600) +# CAPTURE_WORLD_READY_TIMEOUT_SECONDS bounded wait for a stable ready world +# (default: 180, range: 3 through 3600) # CPP_CAPTURE_LOOT_FIXTURE_GUARD set to 1 only for the versioned # loot-single-item-claim fixture. It CAS-lowers Doctor # Maleficus 21779 HealthModifier while both worlds are @@ -95,6 +99,7 @@ LOOT_FIXTURE_CHARACTER_USER="" LOOT_FIXTURE_CHARACTER_PASSWORD="" LOOT_FIXTURE_CHARACTER_DATABASE="" CAPTURE_SWAPPED=0 +CPP_CAPTURE_BOT_READY=0 CAPTURE_RESTORE_FAILURE_STATUS=74 RUST_ORIGINAL_IDENTITY="" CPP_CAPTURE_IDENTITY="" @@ -136,6 +141,7 @@ CPP_CONF_BACKUP_SHA256="" source "$(dirname "${BASH_SOURCE[0]}")/loot-fixture-common.sh" # shellcheck source=capture-service-common.sh source "$(dirname "${BASH_SOURCE[0]}")/capture-service-common.sh" +capture_validate_world_timeouts || exit 2 [[ "$CPP_WORLD_PORT" =~ ^[1-9][0-9]*$ ]] \ && ((CPP_WORLD_PORT <= 65535)) || { @@ -228,7 +234,7 @@ elif [ -n "$CPP_CAPTURE_EXEC_SHA256" ]; then fi for dependency in awk chmod cp date dirname flock git grep id jq mkdir mktemp mv \ - pm2 realpath rg sed sha256sum ss stat sync tail; do + pm2 realpath rg sed sha256sum sleep ss stat sync tail; do command -v "$dependency" >/dev/null 2>&1 || { echo "error: required command not found: $dependency" >&2 exit 2 @@ -418,9 +424,10 @@ finalize_cpp_capture_artifact() { [ "$CAPTURE_ARTIFACT_READY" -eq 1 ] && [ -f "$OUT_PKT_STAGE" ] || return 1 local created_at manifest_stage packet_sha packet_size bot_evidence - [ "$CPP_CAPTURE_NORMAL_RUNTIME_RESTORED" -eq 1 ] \ - && [ "$CPP_CAPTURE_FIXTURE_CLEANUP_VERIFIED" -eq 1 \ - || "$FLOW" != "loot-single-item-claim" ] || return 1 + [ "$CPP_CAPTURE_NORMAL_RUNTIME_RESTORED" -eq 1 ] || return 1 + capture_fixture_cleanup_verified_for_publication \ + "$CPP_CAPTURE_LOOT_FIXTURE_GUARD" \ + "$CPP_CAPTURE_FIXTURE_CLEANUP_VERIFIED" || return 1 if [ "$FLOW" = "loot-single-item-claim" ]; then bot_evidence="$(capture_loot_item_bot_evidence \ "$WOW_BOT_REPORT" "$WOW_BOT_EXEC" "$WOW_BOT_EXEC_SHA256")" || return 1 @@ -663,7 +670,8 @@ restore() { restore_status=1 fi if [ "$restore_status" -eq 0 ] \ - && ! loot_fixture_bot_cleanup_complete; then + && ! loot_fixture_bot_cleanup_safe_for_capture_state \ + "$CPP_CAPTURE_BOT_READY"; then echo "WARNING: bot fixture cleanup is unproven; the normal Rust world will remain stopped" >&2 restore_status=1 fi @@ -789,6 +797,7 @@ accredit_cpp_capture_executable || { exit 1 } +CPP_CAPTURE_BOT_READY=1 echo echo ">>> Perform the '${FLOW}' flow with the client now." read -r -p ">>> Press ENTER when the flow is complete to collect the capture... " _ diff --git a/crates/capture-diff/scripts/capture-rust.sh b/crates/capture-diff/scripts/capture-rust.sh index 3e5f11a8b..1dab876f0 100755 --- a/crates/capture-diff/scripts/capture-rust.sh +++ b/crates/capture-diff/scripts/capture-rust.sh @@ -39,6 +39,8 @@ # the normal PM2 world may be restored # CAPTURE_ORCHESTRATION_LOCK optional absolute private lock directory shared # with capture-cpp.sh (default: /tmp, keyed by uid+ports) +# CAPTURE_WORLD_READY_TIMEOUT_SECONDS bounded wait for a stable ready world +# (default: 180, range: 3 through 3600) # WOW_BOT_EXEC / WOW_BOT_EXEC_SHA256 pinned bot executable used for required # loot-single-item-claim evidence # WOW_BOT_REPORT fresh absolute bot JSON report path for that flow @@ -138,6 +140,7 @@ CAPTURE_BOT_EXEC="" CAPTURE_BOT_EXEC_SHA256="" CAPTURE_BOT_REPORT="" CAPTURE_BOT_REPORT_SHA256="" +CAPTURE_BOT_READY=0 RESTORE_FILE_SHA256="" CAPTURE_CONFIG_FILE_SHA256="" @@ -147,6 +150,7 @@ CAPTURE_CONFIG_FILE_SHA256="" source "$(dirname "${BASH_SOURCE[0]}")/loot-fixture-common.sh" # shellcheck source=capture-service-common.sh source "$(dirname "${BASH_SOURCE[0]}")/capture-service-common.sh" +capture_validate_world_timeouts || exit 2 [[ "$RUST_WORLD_PORT" =~ ^[1-9][0-9]*$ ]] \ && ((RUST_WORLD_PORT <= 65535)) || { @@ -292,6 +296,52 @@ shared_chest_spawn_exact_count() { AND VerifiedBuild = 0" } +shared_chest_spawn_cleanup_counts() { + loot_fixture_world_mysql -e \ + "SELECT + COUNT(*), + COALESCE(SUM( + id = ${LOOT_FIXTURE_CHEST_TEMPLATE_ENTRY} + AND map = 0 + AND zoneId = 0 + AND areaId = 0 + AND spawnDifficulties = '0' + AND phaseUseFlags = 0 + AND PhaseId = 0 + AND PhaseGroup = 0 + AND terrainSwapMap = -1 + AND position_x = CAST(-8946.95 AS FLOAT) + AND position_y = CAST(-132.493 AS FLOAT) + AND position_z = CAST(83.5312 AS FLOAT) + AND orientation = 0 + AND rotation0 = 0 + AND rotation1 = 0 + AND rotation2 = 0 + AND rotation3 = 0 + AND spawntimesecs = 300 + AND animprogress = 255 + AND state = 1 + AND ScriptName = '' + AND StringId IS NULL + AND VerifiedBuild = 0 + ), 0), + (SELECT COUNT(*) FROM pool_members + WHERE type = 1 AND spawnId = ${LOOT_FIXTURE_CHEST_GUID}), + (SELECT COUNT(*) FROM game_event_gameobject + WHERE guid = ${LOOT_FIXTURE_CHEST_GUID}), + (SELECT COUNT(*) FROM linked_respawn + WHERE guid = ${LOOT_FIXTURE_CHEST_GUID} + OR linkedGuid = ${LOOT_FIXTURE_CHEST_GUID}), + (SELECT COUNT(*) FROM gameobject_addon + WHERE guid = ${LOOT_FIXTURE_CHEST_GUID}), + (SELECT COUNT(*) FROM gameobject_overrides + WHERE spawnId = ${LOOT_FIXTURE_CHEST_GUID}), + (SELECT COUNT(*) FROM spawn_group + WHERE spawnType = 1 AND spawnId = ${LOOT_FIXTURE_CHEST_GUID}) + FROM gameobject + WHERE guid = ${LOOT_FIXTURE_CHEST_GUID}" +} + shared_chest_addon_exact_count() { local min_gold="$1" local max_gold="$2" @@ -467,19 +517,20 @@ apply_shared_chest_fixture_guard() { } restore_shared_chest_fixture_guard() { - local restore_status=0 total matching respawn_time ownership deleted + local total matching respawn_time ownership deleted spawn_counts # Preflight every wrapper-owned surface before the first cleanup write. In # particular, do not erase a generated respawn and only afterwards discover # that the world spawn's persisted `state` or spawn metadata drifted. if [ "$LOOT_FIXTURE_CHEST_SPAWN_DELETE_READY" -eq 1 ]; then - total="$(loot_fixture_world_mysql -e \ - "SELECT COUNT(*) FROM gameobject WHERE guid = ${LOOT_FIXTURE_CHEST_GUID}")" || total="" - matching="$(shared_chest_spawn_exact_count)" || matching="" - ownership="$(shared_chest_spawn_metadata_counts)" || ownership="" - if [ "$total" != "1" ] || [ "$matching" != "1" ] \ - || [ "$ownership" != $'0\t0\t0\t0\t0\t0' ]; then - echo "WARNING: shared chest spawn/state/metadata drifted; refusing every cleanup write (${total:-query-failed}/${matching:-query-failed}/${ownership:-query-failed})" >&2 + spawn_counts="$(shared_chest_spawn_cleanup_counts)" || spawn_counts="" + if [ "$spawn_counts" = $'0\t0\t0\t0\t0\t0\t0\t0' ]; then + # The flag is armed before INSERT. A failure between those operations, + # or a successful prior DELETE whose verification query failed, already + # satisfies the exact owned-spawn cleanup postcondition. + LOOT_FIXTURE_CHEST_SPAWN_DELETE_READY=0 + elif [ "$spawn_counts" != $'1\t1\t0\t0\t0\t0\t0\t0' ]; then + echo "WARNING: shared chest spawn/state/metadata drifted; refusing every cleanup write (${spawn_counts:-query-failed})" >&2 return 1 fi fi @@ -525,25 +576,17 @@ restore_shared_chest_fixture_guard() { echo "loot fixture: removed generated respawn for shared chest guid ${LOOT_FIXTURE_CHEST_GUID}" else echo "WARNING: shared chest respawn ${LOOT_FIXTURE_CHEST_GUID} changed externally; refusing a non-exact delete" >&2 - restore_status=1 fi else echo "WARNING: shared chest guid ${LOOT_FIXTURE_CHEST_GUID} has unexpected respawn ownership (${total:-query-failed}); refusing to delete it" >&2 - restore_status=1 fi fi if [ "$LOOT_FIXTURE_CHEST_SPAWN_DELETE_READY" -eq 1 ]; then - total="$(loot_fixture_world_mysql -e \ - "SELECT COUNT(*) FROM gameobject WHERE guid = ${LOOT_FIXTURE_CHEST_GUID}")" || total="" - matching="$(shared_chest_spawn_exact_count)" || matching="" - ownership="$(shared_chest_spawn_metadata_counts)" || ownership="" - if [ "$ownership" != $'0\t0\t0\t0\t0\t0' ]; then - echo "WARNING: shared chest guid ${LOOT_FIXTURE_CHEST_GUID} gained spawn-scoped metadata; refusing to hide or delete the drift (${ownership:-query-failed})" >&2 - restore_status=1 - elif [ "$total" = "0" ]; then + spawn_counts="$(shared_chest_spawn_cleanup_counts)" || spawn_counts="" + if [ "$spawn_counts" = $'0\t0\t0\t0\t0\t0\t0\t0' ]; then LOOT_FIXTURE_CHEST_SPAWN_DELETE_READY=0 - elif [ "$total" = "1" ] && [ "$matching" = "1" ]; then + elif [ "$spawn_counts" = $'1\t1\t0\t0\t0\t0\t0\t0' ]; then deleted="$(loot_fixture_world_mysql -e \ "DELETE FROM gameobject WHERE guid = ${LOOT_FIXTURE_CHEST_GUID} @@ -573,17 +616,15 @@ restore_shared_chest_fixture_guard() { SELECT ROW_COUNT();")" \ || deleted="" if [ "$deleted" = "1" ] \ - && [ "$(loot_fixture_world_mysql -e \ - "SELECT COUNT(*) FROM gameobject WHERE guid = ${LOOT_FIXTURE_CHEST_GUID}")" = "0" ]; then + && [ "$(shared_chest_spawn_cleanup_counts)" \ + = $'0\t0\t0\t0\t0\t0\t0\t0' ]; then LOOT_FIXTURE_CHEST_SPAWN_DELETE_READY=0 echo "loot fixture: removed shared chest guid ${LOOT_FIXTURE_CHEST_GUID}" else echo "WARNING: failed to remove shared chest guid ${LOOT_FIXTURE_CHEST_GUID}" >&2 - restore_status=1 fi else echo "WARNING: shared chest guid ${LOOT_FIXTURE_CHEST_GUID} changed externally; refusing to delete it" >&2 - restore_status=1 fi fi @@ -595,7 +636,6 @@ restore_shared_chest_fixture_guard() { matching="$(shared_chest_addon_exact_count 10 10)" || matching="" if [ "$matching" != "1" ]; then echo "WARNING: shared chest addon ${LOOT_FIXTURE_CHEST_TEMPLATE_ENTRY} changed externally; refusing to overwrite it" >&2 - restore_status=1 else deleted="$(loot_fixture_world_mysql -e \ "UPDATE gameobject_template_addon @@ -619,13 +659,45 @@ restore_shared_chest_fixture_guard() { echo "loot fixture: restored chest addon ${LOOT_FIXTURE_CHEST_TEMPLATE_ENTRY} mingold/maxgold 0/0" else echo "WARNING: failed to restore shared chest addon ${LOOT_FIXTURE_CHEST_TEMPLATE_ENTRY}" >&2 - restore_status=1 fi fi fi fi - return "$restore_status" + # Reconcile each still-armed surface from fresh DB postconditions. This + # self-heals a cleanup write that committed successfully when only its first + # verification query failed, without ever treating a present/drifted row as + # clean. + if [ "$LOOT_FIXTURE_CHEST_RESPAWN_DELETE_READY" -eq 1 ] \ + && [ "$(loot_fixture_character_mysql -e \ + "SELECT COUNT(*) FROM respawn + WHERE type = 1 + AND spawnId = ${LOOT_FIXTURE_CHEST_GUID}" 2>/dev/null || true)" = "0" ]; then + LOOT_FIXTURE_CHEST_RESPAWN_DELETE_READY=0 + fi + if [ "$LOOT_FIXTURE_CHEST_SPAWN_DELETE_READY" -eq 1 ]; then + spawn_counts="$(shared_chest_spawn_cleanup_counts 2>/dev/null || true)" + if [ "$spawn_counts" = $'0\t0\t0\t0\t0\t0\t0\t0' ]; then + LOOT_FIXTURE_CHEST_SPAWN_DELETE_READY=0 + fi + fi + if [ "$LOOT_FIXTURE_CHEST_ADDON_RESTORE_READY" -eq 1 ] \ + && [ "$(shared_chest_addon_exact_count 0 0 2>/dev/null || true)" = "1" ]; then + LOOT_FIXTURE_CHEST_ADDON_RESTORE_READY=0 + fi + + # The armed flags are the durable cleanup invariants: each one is cleared + # only after its owned row is absent or its exact original value is restored. + # Derive success from those postconditions instead of a parallel accumulator, + # so the wrapper cannot report failure after every owned surface is clean. + if [ "$LOOT_FIXTURE_CHEST_RESPAWN_DELETE_READY" -eq 0 ] \ + && [ "$LOOT_FIXTURE_CHEST_SPAWN_DELETE_READY" -eq 0 ] \ + && [ "$LOOT_FIXTURE_CHEST_ADDON_RESTORE_READY" -eq 0 ]; then + return 0 + fi + + echo "WARNING: shared chest cleanup remains armed (respawn=${LOOT_FIXTURE_CHEST_RESPAWN_DELETE_READY}, spawn=${LOOT_FIXTURE_CHEST_SPAWN_DELETE_READY}, addon=${LOOT_FIXTURE_CHEST_ADDON_RESTORE_READY})" >&2 + return 1 } capture_exec_source_matches() { @@ -808,9 +880,10 @@ finalize_rust_capture_artifact() { local created_at manifest packet_count tree_sha bot_evidence path [ "$CAPTURE_RUNTIME_CLEANUP_VERIFIED" -eq 1 ] \ - && [ "$CAPTURE_NORMAL_RUNTIME_RESTORED" -eq 1 ] \ - && [ "$CAPTURE_FIXTURE_CLEANUP_VERIFIED" -eq 1 \ - || "$FLOW" != "loot-single-item-claim" ] || return 1 + && [ "$CAPTURE_NORMAL_RUNTIME_RESTORED" -eq 1 ] || return 1 + capture_fixture_cleanup_verified_for_publication \ + "$RUST_CAPTURE_LOOT_FIXTURE_GUARD" \ + "$CAPTURE_FIXTURE_CLEANUP_VERIFIED" || return 1 if [ "$FLOW" = "loot-single-item-claim" ]; then bot_evidence="$(capture_loot_item_bot_evidence \ "$WOW_BOT_REPORT" "$WOW_BOT_EXEC" "$WOW_BOT_EXEC_SHA256")" || return 1 @@ -960,7 +1033,7 @@ command -v rg >/dev/null 2>&1 || { exit 1 } for dependency in awk chmod date dirname find flock git id mkdir mktemp mv \ - realpath sed seq sha256sum sleep sort stat sync wc; do + realpath sed sha256sum sleep sort stat sync wc; do command -v "$dependency" >/dev/null 2>&1 || { echo "error: required command not found: $dependency" >&2 exit 1 @@ -1264,12 +1337,14 @@ cleanup() { echo "WARNING: failed to restore the loot fixture; the normal world will remain stopped" >&2 restore_status=1 fi - if [ "$restore_status" -eq 0 ] && ! loot_fixture_bot_cleanup_complete; then + if [ "$restore_status" -eq 0 ] \ + && ! loot_fixture_bot_cleanup_safe_for_capture_state \ + "$CAPTURE_BOT_READY"; then echo "WARNING: bot fixture cleanup is unproven; the normal world will remain stopped" >&2 restore_status=1 fi if [ "$restore_status" -eq 0 ] \ - && [ "$FLOW" = "loot-single-item-claim" ]; then + && [ "$RUST_CAPTURE_LOOT_FIXTURE_GUARD" = "1" ]; then CAPTURE_FIXTURE_CLEANUP_VERIFIED=1 fi if [ "$restore_status" -eq 0 ] \ @@ -1290,7 +1365,8 @@ cleanup() { local last_identity="" local stable_samples=0 local identity="" - for _ in $(seq 1 40); do + local restore_deadline=$((SECONDS + CAPTURE_WORLD_READY_TIMEOUT_SECONDS)) + while ((SECONDS < restore_deadline)); do if identity="$(snapshot_process_identity "$RESTORE_FILE" 2>/dev/null)" \ && rust_world_ports_ready "$identity"; then if [ "$identity" = "$last_identity" ]; then @@ -1555,14 +1631,28 @@ env -i \ CAPTURE_READY=0 CAPTURE_IDENTITY="" -for _ in $(seq 1 40); do +CAPTURE_LAST_IDENTITY="" +CAPTURE_STABLE_SAMPLES=0 +CAPTURE_START_DEADLINE=$((SECONDS + CAPTURE_WORLD_READY_TIMEOUT_SECONDS)) +while ((SECONDS < CAPTURE_START_DEADLINE)); do if CAPTURE_IDENTITY="$(snapshot_process_identity "$CAPTURE_CONFIG_FILE" 2>/dev/null)" \ && rust_world_ports_ready "$CAPTURE_IDENTITY" \ && capture_process_exec_matches "$CAPTURE_IDENTITY"; then - CAPTURE_READY=1 - break + if [ "$CAPTURE_IDENTITY" = "$CAPTURE_LAST_IDENTITY" ]; then + CAPTURE_STABLE_SAMPLES=$((CAPTURE_STABLE_SAMPLES + 1)) + else + CAPTURE_LAST_IDENTITY="$CAPTURE_IDENTITY" + CAPTURE_STABLE_SAMPLES=1 + fi + if [ "$CAPTURE_STABLE_SAMPLES" -ge 4 ]; then + CAPTURE_READY=1 + break + fi + else + CAPTURE_LAST_IDENTITY="" + CAPTURE_STABLE_SAMPLES=0 fi - sleep 0.25 + sleep 0.5 done [ "$CAPTURE_READY" -eq 1 ] || { echo "error: ${PM2_RUST_WORLD} did not start online with the pinned capture executable and packet dumping enabled" >&2 @@ -1641,6 +1731,7 @@ if [ -z "$CAPTURE_EXEC" ]; then CAPTURE_SOURCE_SHA256="$CAPTURE_LIVE_SHA256" fi +CAPTURE_BOT_READY=1 echo echo ">>> Perform the '${FLOW}' flow with the client now." read -r -p ">>> Press ENTER when the flow is complete to finish the capture... " _ diff --git a/crates/capture-diff/scripts/capture-service-common.sh b/crates/capture-diff/scripts/capture-service-common.sh index 90d411437..182e1b612 100644 --- a/crates/capture-diff/scripts/capture-service-common.sh +++ b/crates/capture-diff/scripts/capture-service-common.sh @@ -2,6 +2,38 @@ # Source-only PM2/PID/listener accreditation shared by capture wrapper tests. # Callers define distinct CAPTURE_WORLD_PORT and CAPTURE_INSTANCE_PORT. +CAPTURE_WORLD_STOP_TIMEOUT_SECONDS="${CAPTURE_WORLD_STOP_TIMEOUT_SECONDS:-30}" +CAPTURE_WORLD_READY_TIMEOUT_SECONDS="${CAPTURE_WORLD_READY_TIMEOUT_SECONDS:-180}" +CAPTURE_WORLD_TIMEOUT_MAX_SECONDS=3600 + +capture_validate_world_timeouts() { + local name value minimum + for name in \ + CAPTURE_WORLD_STOP_TIMEOUT_SECONDS \ + CAPTURE_WORLD_READY_TIMEOUT_SECONDS; do + value="${!name:-}" + minimum=1 + [ "$name" != CAPTURE_WORLD_READY_TIMEOUT_SECONDS ] || minimum=3 + if [[ ! "$value" =~ ^[1-9][0-9]*$ ]] \ + || ((${#value} > 4)) \ + || ((10#$value < minimum)) \ + || ((10#$value > CAPTURE_WORLD_TIMEOUT_MAX_SECONDS)); then + echo "error: ${name} must be an integer from ${minimum} through ${CAPTURE_WORLD_TIMEOUT_MAX_SECONDS}" >&2 + return 1 + fi + done +} + +capture_fixture_cleanup_verified_for_publication() { + local guard_enabled="$1" + local cleanup_verified="$2" + + case "$guard_enabled:$cleanup_verified" in + 0:0|0:1|1:1) return 0 ;; + *) return 1 ;; + esac +} + capture_pm2_online_pid() { local process_name="$1" pm2 jlist | jq -er --arg name "$process_name" ' @@ -716,8 +748,8 @@ capture_world_ready_once() { capture_wait_for_world_stopped() { local process_name="$1" local saved_pid="$2" - local attempt - for ((attempt = 0; attempt < 40; attempt++)); do + local deadline=$((SECONDS + CAPTURE_WORLD_STOP_TIMEOUT_SECONDS)) + while ((SECONDS < deadline)); do capture_world_stopped_once "$process_name" "$saved_pid" && return 0 sleep 0.5 done @@ -726,8 +758,9 @@ capture_wait_for_world_stopped() { capture_wait_for_world_ready() { local process_name="$1" - local attempt identity="" last_identity="" stable_samples=0 - for ((attempt = 0; attempt < 40; attempt++)); do + local identity="" last_identity="" stable_samples=0 + local deadline=$((SECONDS + CAPTURE_WORLD_READY_TIMEOUT_SECONDS)) + while ((SECONDS < deadline)); do if identity="$(capture_world_ready_once "$process_name")"; then if [ "$identity" = "$last_identity" ]; then stable_samples=$((stable_samples + 1)) diff --git a/crates/capture-diff/scripts/loot-fixture-common.sh b/crates/capture-diff/scripts/loot-fixture-common.sh index 6bc72d706..95233890e 100644 --- a/crates/capture-diff/scripts/loot-fixture-common.sh +++ b/crates/capture-diff/scripts/loot-fixture-common.sh @@ -293,3 +293,39 @@ loot_fixture_bot_cleanup_complete() { return 1 fi } + +# Before the flow is exposed to the client/bot, absence of both recovery files +# proves that no bot-side mutation needs recovery. Once exposed, require the +# durable cleanup marker contract above. Ambiguous state always fails closed. +loot_fixture_bot_cleanup_safe_for_capture_state() { + local capture_bot_ready="${1:-}" + + [ "${LOOT_FIXTURE_GUARD_ENABLED:-0}" = "1" ] || return 0 + + [ -n "${WOW_BOT_FIXTURE_JOURNAL:-}" ] \ + && [ -n "${LOOT_FIXTURE_CLEANUP_MARKER:-}" ] \ + && [ "$LOOT_FIXTURE_CLEANUP_MARKER" \ + = "${WOW_BOT_FIXTURE_JOURNAL}.cleanup-complete" ] || { + echo "WARNING: bot fixture cleanup paths are missing or inconsistent; refusing to start the normal PM2 world" >&2 + return 1 + } + + case "$capture_bot_ready" in + 0) + if [ -e "$WOW_BOT_FIXTURE_JOURNAL" ] \ + || [ -L "$WOW_BOT_FIXTURE_JOURNAL" ] \ + || [ -e "$LOOT_FIXTURE_CLEANUP_MARKER" ] \ + || [ -L "$LOOT_FIXTURE_CLEANUP_MARKER" ]; then + echo "WARNING: bot fixture recovery state appeared before capture readiness; refusing to start the normal PM2 world" >&2 + return 1 + fi + ;; + 1) + loot_fixture_bot_cleanup_complete + ;; + *) + echo "WARNING: invalid capture bot-readiness state; refusing to start the normal PM2 world" >&2 + return 1 + ;; + esac +} diff --git a/tools/pr-preflight.sh b/tools/pr-preflight.sh index cadc714c7..d5ef39614 100755 --- a/tools/pr-preflight.sh +++ b/tools/pr-preflight.sh @@ -21,6 +21,7 @@ QA_LOOT_RACE_CAPTURE_LOG="" QA_LOOT_RACE_FIXTURE_JOURNAL="" QA_LOOT_RACE_CAPTURE_WAIT_STATUS=0 QA_LOOT_RACE_BOT_PID="" +QA_LOOT_RACE_PRE_READY_MARGIN_SECONDS=120 usage() { cat <<'EOF' @@ -254,13 +255,36 @@ qa_loot_race_capture_cleanup() { exit "$status" } +qa_loot_capture_ready_timeout_seconds() { + local world_ready_timeout="${1:-${CAPTURE_WORLD_READY_TIMEOUT_SECONDS:-180}}" + local world_stop_timeout="${2:-${CAPTURE_WORLD_STOP_TIMEOUT_SECONDS:-30}}" + + [[ "$world_ready_timeout" =~ ^[1-9][0-9]*$ ]] \ + && ((${#world_ready_timeout} <= 4)) \ + && ((10#$world_ready_timeout >= 3)) \ + && ((10#$world_ready_timeout <= 3600)) || { + echo "CAPTURE_WORLD_READY_TIMEOUT_SECONDS must be an integer from 3 through 3600" >&2 + return 1 + } + [[ "$world_stop_timeout" =~ ^[1-9][0-9]*$ ]] \ + && ((${#world_stop_timeout} <= 4)) \ + && ((10#$world_stop_timeout <= 3600)) || { + echo "CAPTURE_WORLD_STOP_TIMEOUT_SECONDS must be an integer from 1 through 3600" >&2 + return 1 + } + printf '%s\n' "$((world_ready_timeout + world_stop_timeout + QA_LOOT_RACE_PRE_READY_MARGIN_SECONDS))" +} + qa_wait_for_loot_capture_ready() { local capture_pid="$1" local capture_log="$2" local marker=">>> Perform the 'loot-two-session-atomic-race' flow with the client now." local wrapper_status=0 + local timeout_seconds deadline - for _ in $(seq 1 240); do + timeout_seconds="$(qa_loot_capture_ready_timeout_seconds)" || return 1 + deadline=$((SECONDS + timeout_seconds)) + while ((SECONDS < deadline)); do if [[ -f "$capture_log" ]] && rg -Fq -- "$marker" "$capture_log"; then return 0 fi @@ -273,7 +297,7 @@ qa_wait_for_loot_capture_ready() { fi sleep 0.25 done - echo "timed out waiting 60 seconds for the guarded capture ready marker" >&2 + echo "timed out waiting ${timeout_seconds} seconds for the guarded capture ready marker" >&2 return 1 } @@ -807,6 +831,12 @@ run_self_test() { fi require_command python3 + [[ "$(qa_loot_capture_ready_timeout_seconds 180 30)" == 330 ]] || die \ + "loot-race pre-ready timeout does not dominate explicit wrapper budgets" + if qa_loot_capture_ready_timeout_seconds 2 30 >/dev/null 2>&1 \ + || qa_loot_capture_ready_timeout_seconds 180 3601 >/dev/null 2>&1; then + die "loot-race pre-ready timeout accepted an invalid inner timeout" + fi project_protoc_version >/dev/null python3 -m json.tool "$SCHEMA_FILE" >/dev/null || die "invalid Codex review JSON schema" @@ -1134,11 +1164,34 @@ run_self_test() { fi chmod 700 "$fixture_guard_dir" validate_fresh_loot_fixture_journal + loot_fixture_bot_cleanup_safe_for_capture_state 0 + if loot_fixture_bot_cleanup_safe_for_capture_state 2 >/dev/null 2>&1; then + exit 147 + fi + ln -s "$fixture_guard_dir/missing-journal" "$WOW_BOT_FIXTURE_JOURNAL" + if loot_fixture_bot_cleanup_safe_for_capture_state 0 >/dev/null 2>&1; then + exit 148 + fi + rm -f "$WOW_BOT_FIXTURE_JOURNAL" + : >"$WOW_BOT_FIXTURE_JOURNAL" + if loot_fixture_bot_cleanup_safe_for_capture_state 0 >/dev/null 2>&1; then + exit 152 + fi + rm -f "$WOW_BOT_FIXTURE_JOURNAL" + ln -s "$fixture_guard_dir/missing-marker" "$LOOT_FIXTURE_CLEANUP_MARKER" + if loot_fixture_bot_cleanup_safe_for_capture_state 0 >/dev/null 2>&1; then + exit 149 + fi + rm -f "$LOOT_FIXTURE_CLEANUP_MARKER" printf '%s\n' \ '{"version":1,"journal_sha256":"0000000000000000000000000000000000000000000000000000000000000000","cleanup_pid":123}' \ >"$LOOT_FIXTURE_CLEANUP_MARKER" chmod 600 "$LOOT_FIXTURE_CLEANUP_MARKER" + if loot_fixture_bot_cleanup_safe_for_capture_state 0 >/dev/null 2>&1; then + exit 150 + fi + loot_fixture_bot_cleanup_safe_for_capture_state 1 loot_fixture_bot_cleanup_complete chmod 644 "$LOOT_FIXTURE_CLEANUP_MARKER" if loot_fixture_bot_cleanup_complete >/dev/null 2>&1; then @@ -1150,6 +1203,12 @@ run_self_test() { exit 82 fi rm -f "$WOW_BOT_FIXTURE_JOURNAL" "$LOOT_FIXTURE_CLEANUP_MARKER" + if loot_fixture_bot_cleanup_safe_for_capture_state 1 >/dev/null 2>&1; then + exit 151 + fi + LOOT_FIXTURE_GUARD_ENABLED=0 + loot_fixture_bot_cleanup_safe_for_capture_state invalid + LOOT_FIXTURE_GUARD_ENABLED=1 printf '%s\n' 1 >"$fixture_health_state" loot_fixture_character_mysql() { @@ -1201,13 +1260,402 @@ run_self_test() { [[ "$(<"$fixture_health_state")" == 2 ]] || exit 87 ) || die "shared C++/Rust loot-fixture guard self-test failed" + ( + # Exercise the exact shared-chest cleanup implementation without sourcing + # capture-rust.sh's executable entrypoint or touching a real database. + capture_rust_script="$REPO_ROOT/crates/capture-diff/scripts/capture-rust.sh" + shared_chest_functions="$artifacts/shared-chest-restore-functions.sh" + shared_chest_state_dir="$artifacts/shared-chest-restore-state" + mkdir -m 700 "$shared_chest_state_dir" + + extract_capture_function() { + local function_name="$1" + awk -v signature="${function_name}() {" ' + $0 == signature { copying = 1 } + copying { print } + copying && $0 == "}" { found = 1; exit } + END { if (!found) exit 1 } + ' "$capture_rust_script" + } + + : >"$shared_chest_functions" + for function_name in \ + restore_loot_fixture_guard \ + shared_chest_spawn_exact_count \ + shared_chest_spawn_cleanup_counts \ + shared_chest_addon_exact_count \ + shared_chest_spawn_metadata_counts \ + restore_shared_chest_fixture_guard; do + extract_capture_function "$function_name" \ + >>"$shared_chest_functions" || exit 157 + done + # shellcheck disable=SC1090 + source "$shared_chest_functions" + + LOOT_FIXTURE_KIND=shared-chest + LOOT_FIXTURE_CHEST_GUID=9106001 + LOOT_FIXTURE_CHEST_TEMPLATE_ENTRY=2846 + LOOT_FIXTURE_CHEST_ADDON_FACTION=101 + shared_chest_spawn_state="$shared_chest_state_dir/spawn" + shared_chest_addon_state="$shared_chest_state_dir/addon" + shared_chest_respawn_state="$shared_chest_state_dir/respawn" + shared_chest_metadata_state="$shared_chest_state_dir/metadata" + shared_chest_write_log="$shared_chest_state_dir/writes" + shared_chest_failure_file="$shared_chest_state_dir/failure" + + reset_shared_chest_restore_state() { + local spawn="$1" + local addon="$2" + local respawn="$3" + local metadata="$4" + printf '%s\n' "$spawn" >"$shared_chest_spawn_state" + printf '%s\n' "$addon" >"$shared_chest_addon_state" + printf '%s\n' "$respawn" >"$shared_chest_respawn_state" + printf '%s\n' "$metadata" >"$shared_chest_metadata_state" + : >"$shared_chest_write_log" + : >"$shared_chest_failure_file" + LOOT_FIXTURE_CHEST_RESPAWN_DELETE_READY=1 + LOOT_FIXTURE_CHEST_SPAWN_DELETE_READY=1 + LOOT_FIXTURE_CHEST_ADDON_RESTORE_READY=1 + } + + loot_fixture_world_mysql() { + local query="${2:-}" + local expected state + if [[ "$query" == *"DELETE FROM gameobject"* ]]; then + printf '%s\n' delete-spawn >>"$shared_chest_write_log" + for expected in \ + "WHERE guid = 9106001" \ + "AND id = 2846" \ + "AND map = 0" \ + "AND zoneId = 0" \ + "AND areaId = 0" \ + "AND spawnDifficulties = '0'" \ + "AND phaseUseFlags = 0" \ + "AND PhaseId = 0" \ + "AND PhaseGroup = 0" \ + "AND terrainSwapMap = -1" \ + "AND position_x = CAST(-8946.95 AS FLOAT)" \ + "AND position_y = CAST(-132.493 AS FLOAT)" \ + "AND position_z = CAST(83.5312 AS FLOAT)" \ + "AND orientation = 0" \ + "AND rotation0 = 0" \ + "AND rotation1 = 0" \ + "AND rotation2 = 0" \ + "AND rotation3 = 0" \ + "AND spawntimesecs = 300" \ + "AND animprogress = 255" \ + "AND state = 1" \ + "AND ScriptName = ''" \ + "AND StringId IS NULL" \ + "AND VerifiedBuild = 0"; do + [[ "$query" == *"$expected"* ]] || return 169 + done + state="$(<"$shared_chest_spawn_state")" + if [[ "$state" == exact || "$state" == exact-verify-fails ]]; then + if [[ "$state" == exact-verify-fails ]]; then + printf '%s\n' absent-fail-once >"$shared_chest_spawn_state" + else + printf '%s\n' absent >"$shared_chest_spawn_state" + fi + printf '%s\n' 1 + else + printf '%s\n' 0 + fi + elif [[ "$query" == *"UPDATE gameobject_template_addon"* ]]; then + printf '%s\n' restore-addon >>"$shared_chest_write_log" + for expected in \ + "SET mingold = 0, maxgold = 0" \ + "WHERE entry = 2846" \ + "AND faction = 101" \ + "AND flags = 0" \ + "AND mingold = 10" \ + "AND maxgold = 10" \ + "AND artkit0 = 0" \ + "AND artkit1 = 0" \ + "AND artkit2 = 0" \ + "AND artkit3 = 0" \ + "AND artkit4 = 0" \ + "AND WorldEffectID = 0" \ + "AND AIAnimKitID = 0"; do + [[ "$query" == *"$expected"* ]] || return 170 + done + state="$(<"$shared_chest_addon_state")" + if [[ "$state" == 10 ]]; then + printf '%s\n' 0 >"$shared_chest_addon_state" + printf '%s\n' 1 + else + printf '%s\n' 0 + fi + elif [[ "$query" == *"COALESCE(SUM("* \ + && "$query" == *"FROM gameobject"* ]]; then + for expected in \ + "id = 2846" \ + "AND map = 0" \ + "AND zoneId = 0" \ + "AND areaId = 0" \ + "AND spawnDifficulties = '0'" \ + "AND phaseUseFlags = 0" \ + "AND PhaseId = 0" \ + "AND PhaseGroup = 0" \ + "AND terrainSwapMap = -1" \ + "AND position_x = CAST(-8946.95 AS FLOAT)" \ + "AND position_y = CAST(-132.493 AS FLOAT)" \ + "AND position_z = CAST(83.5312 AS FLOAT)" \ + "AND orientation = 0" \ + "AND rotation0 = 0" \ + "AND rotation1 = 0" \ + "AND rotation2 = 0" \ + "AND rotation3 = 0" \ + "AND spawntimesecs = 300" \ + "AND animprogress = 255" \ + "AND state = 1" \ + "AND ScriptName = ''" \ + "AND StringId IS NULL" \ + "AND VerifiedBuild = 0" \ + "FROM pool_members + WHERE type = 1 AND spawnId = 9106001" \ + "FROM game_event_gameobject + WHERE guid = 9106001" \ + "FROM linked_respawn + WHERE guid = 9106001 + OR linkedGuid = 9106001" \ + "FROM gameobject_addon + WHERE guid = 9106001" \ + "FROM gameobject_overrides + WHERE spawnId = 9106001" \ + "FROM spawn_group + WHERE spawnType = 1 AND spawnId = 9106001" \ + "FROM gameobject + WHERE guid = 9106001"; do + [[ "$query" == *"$expected"* ]] || return 179 + done + state="$(<"$shared_chest_spawn_state")" + case "$state" in + exact|exact-verify-fails) + printf '1\t1\t%s\n' "$(<"$shared_chest_metadata_state")" + ;; + absent) + printf '0\t0\t%s\n' "$(<"$shared_chest_metadata_state")" + ;; + absent-fail-once) + printf '%s\n' absent >"$shared_chest_spawn_state" + return 171 + ;; + *) + printf '1\t0\t%s\n' "$(<"$shared_chest_metadata_state")" + ;; + esac + elif [[ "$query" == *"FROM pool_members"* ]]; then + for expected in \ + "FROM pool_members + WHERE type = 1 AND spawnId = 9106001" \ + "FROM game_event_gameobject + WHERE guid = 9106001" \ + "FROM linked_respawn + WHERE guid = 9106001 + OR linkedGuid = 9106001" \ + "FROM gameobject_addon + WHERE guid = 9106001" \ + "FROM gameobject_overrides + WHERE spawnId = 9106001" \ + "FROM spawn_group + WHERE spawnType = 1 AND spawnId = 9106001"; do + [[ "$query" == *"$expected"* ]] || return 179 + done + cat "$shared_chest_metadata_state" + elif [[ "$query" == *"FROM gameobject_template_addon"* ]]; then + for expected in \ + "WHERE entry = 2846" \ + "AND faction = 101" \ + "AND flags = 0" \ + "AND artkit0 = 0" \ + "AND artkit1 = 0" \ + "AND artkit2 = 0" \ + "AND artkit3 = 0" \ + "AND artkit4 = 0" \ + "AND WorldEffectID = 0" \ + "AND AIAnimKitID = 0"; do + [[ "$query" == *"$expected"* ]] || return 180 + done + state="$(<"$shared_chest_addon_state")" + if [[ "$state" == 0 \ + && "$query" == *"mingold = 0"* \ + && "$query" == *"maxgold = 0"* ]] \ + || [[ "$state" == 10 \ + && "$query" == *"mingold = 10"* \ + && "$query" == *"maxgold = 10"* ]]; then + printf '%s\n' 1 + else + printf '%s\n' 0 + fi + elif [[ "$query" == *"SELECT COUNT(*) FROM gameobject"* \ + && "$query" == *"AND id ="* ]]; then + state="$(<"$shared_chest_spawn_state")" + [[ "$state" == exact || "$state" == exact-verify-fails ]] \ + && printf '%s\n' 1 || printf '%s\n' 0 + elif [[ "$query" == *"SELECT COUNT(*) FROM gameobject"* ]]; then + state="$(<"$shared_chest_spawn_state")" + case "$state" in + absent) printf '%s\n' 0 ;; + absent-fail-once) + printf '%s\n' absent >"$shared_chest_spawn_state" + return 171 + ;; + *) printf '%s\n' 1 ;; + esac + else + return 158 + fi + } + + loot_fixture_character_mysql() { + local query="${2:-}" + local state + state="$(<"$shared_chest_respawn_state")" + if [[ "$query" == *"DELETE FROM respawn"* ]]; then + printf '%s\n' delete-respawn >>"$shared_chest_write_log" + if [[ "$state" == generated \ + && "$query" == *"WHERE type = 1"* \ + && "$query" == *"spawnId = 9106001"* \ + && "$query" == *"respawnTime = 123456"* \ + && "$query" == *"mapId = 0"* \ + && "$query" == *"instanceId = 0"* ]]; then + printf '%s\n' none >"$shared_chest_respawn_state" + printf '%s\n' 1 + else + printf '%s\n' 0 + fi + elif [[ "$query" == *"SELECT respawnTime FROM respawn"* ]]; then + [[ "$state" == generated \ + && "$query" == *"WHERE type = 1"* \ + && "$query" == *"spawnId = 9106001"* \ + && "$query" == *"mapId = 0"* \ + && "$query" == *"instanceId = 0"* ]] \ + && printf '%s\n' 123456 + elif [[ "$query" == *"SELECT COUNT(*) FROM respawn"* ]]; then + [[ "$query" == *"WHERE type = 1"* \ + && "$query" == *"spawnId = 9106001"* ]] || return 172 + case "$state" in + none) printf '%s\n' 0 ;; + generated) printf '%s\n' 1 ;; + drift) printf '%s\n' 2 ;; + *) return 159 ;; + esac + else + return 160 + fi + } + + reset_shared_chest_restore_state exact 10 none $'0\t0\t0\t0\t0\t0' + restore_loot_fixture_guard >/dev/null + [[ "$(<"$shared_chest_spawn_state")" == absent \ + && "$(<"$shared_chest_addon_state")" == 0 \ + && "$(<"$shared_chest_respawn_state")" == none \ + && "$(<"$shared_chest_write_log")" == $'delete-spawn\nrestore-addon' \ + && "$LOOT_FIXTURE_CHEST_RESPAWN_DELETE_READY" == 0 \ + && "$LOOT_FIXTURE_CHEST_SPAWN_DELETE_READY" == 0 \ + && "$LOOT_FIXTURE_CHEST_ADDON_RESTORE_READY" == 0 ]] || exit 161 + + reset_shared_chest_restore_state exact 10 generated $'0\t0\t0\t0\t0\t0' + restore_loot_fixture_guard >/dev/null + [[ "$(<"$shared_chest_spawn_state")" == absent \ + && "$(<"$shared_chest_addon_state")" == 0 \ + && "$(<"$shared_chest_respawn_state")" == none \ + && "$(<"$shared_chest_write_log")" == $'delete-respawn\ndelete-spawn\nrestore-addon' \ + && "$LOOT_FIXTURE_CHEST_RESPAWN_DELETE_READY" == 0 \ + && "$LOOT_FIXTURE_CHEST_SPAWN_DELETE_READY" == 0 \ + && "$LOOT_FIXTURE_CHEST_ADDON_RESTORE_READY" == 0 ]] || exit 162 + + # The spawn flag is armed before INSERT. Absence is already the exact + # postcondition and must not block restoration of the addon mutation. + reset_shared_chest_restore_state absent 10 none $'0\t0\t0\t0\t0\t0' + restore_loot_fixture_guard >/dev/null + [[ "$(<"$shared_chest_spawn_state")" == absent \ + && "$(<"$shared_chest_addon_state")" == 0 \ + && "$(<"$shared_chest_write_log")" == restore-addon \ + && "$LOOT_FIXTURE_CHEST_RESPAWN_DELETE_READY" == 0 \ + && "$LOOT_FIXTURE_CHEST_SPAWN_DELETE_READY" == 0 \ + && "$LOOT_FIXTURE_CHEST_ADDON_RESTORE_READY" == 0 ]] || exit 173 + + # Simulate DELETE committing while its immediate COUNT verification fails. + # A fresh final reconciliation must observe absence and disarm cleanup. + reset_shared_chest_restore_state exact-verify-fails 10 none $'0\t0\t0\t0\t0\t0' + restore_loot_fixture_guard >/dev/null 2>&1 + [[ "$(<"$shared_chest_spawn_state")" == absent \ + && "$(<"$shared_chest_addon_state")" == 0 \ + && "$(<"$shared_chest_write_log")" == $'delete-spawn\nrestore-addon' \ + && "$LOOT_FIXTURE_CHEST_RESPAWN_DELETE_READY" == 0 \ + && "$LOOT_FIXTURE_CHEST_SPAWN_DELETE_READY" == 0 \ + && "$LOOT_FIXTURE_CHEST_ADDON_RESTORE_READY" == 0 ]] || exit 174 + + reset_shared_chest_restore_state exact 10 none $'1\t0\t0\t0\t0\t0' + if restore_loot_fixture_guard > /dev/null 2>"$shared_chest_failure_file"; then + exit 163 + fi + shared_chest_failure="$(<"$shared_chest_failure_file")" + [[ ! -s "$shared_chest_write_log" \ + && "$(<"$shared_chest_spawn_state")" == exact \ + && "$shared_chest_failure" == *"spawn/state/metadata drifted"* ]] || exit 164 + + reset_shared_chest_restore_state exact drift none $'0\t0\t0\t0\t0\t0' + LOOT_FIXTURE_CHEST_RESPAWN_DELETE_READY=0 + LOOT_FIXTURE_CHEST_SPAWN_DELETE_READY=0 + if restore_loot_fixture_guard > /dev/null 2>"$shared_chest_failure_file"; then + exit 165 + fi + shared_chest_failure="$(<"$shared_chest_failure_file")" + [[ ! -s "$shared_chest_write_log" \ + && "$(<"$shared_chest_addon_state")" == drift \ + && "$shared_chest_failure" == *"template addon drifted"* ]] || exit 166 + + reset_shared_chest_restore_state absent 0 drift $'0\t0\t0\t0\t0\t0' + LOOT_FIXTURE_CHEST_SPAWN_DELETE_READY=0 + LOOT_FIXTURE_CHEST_ADDON_RESTORE_READY=0 + if restore_loot_fixture_guard > /dev/null 2>"$shared_chest_failure_file"; then + exit 167 + fi + shared_chest_failure="$(<"$shared_chest_failure_file")" + [[ ! -s "$shared_chest_write_log" \ + && "$(<"$shared_chest_respawn_state")" == drift \ + && "$LOOT_FIXTURE_CHEST_RESPAWN_DELETE_READY" == 1 \ + && "$shared_chest_failure" == *"unexpected respawn ownership"* ]] || exit 168 + ) || die "shared-chest exact restore self-test failed" + ( # Prove both supported topologies: a direct Rust PM2 entry/listener and a # non-exec C++ shell wrapper with one descendant owning both listeners. # Reject changed ancestry, changed/foreign listeners, duplicate PM2 # identity, and incomplete stopped state before DB restoration. # shellcheck source=crates/capture-diff/scripts/capture-service-common.sh + unset CAPTURE_WORLD_STOP_TIMEOUT_SECONDS CAPTURE_WORLD_READY_TIMEOUT_SECONDS source "$REPO_ROOT/crates/capture-diff/scripts/capture-service-common.sh" + [[ "$CAPTURE_WORLD_STOP_TIMEOUT_SECONDS" == 30 \ + && "$CAPTURE_WORLD_READY_TIMEOUT_SECONDS" == 180 ]] || exit 153 + capture_validate_world_timeouts + CAPTURE_WORLD_STOP_TIMEOUT_SECONDS=0 + if capture_validate_world_timeouts >/dev/null 2>&1; then + exit 154 + fi + CAPTURE_WORLD_STOP_TIMEOUT_SECONDS=30 + CAPTURE_WORLD_READY_TIMEOUT_SECONDS=2 + if capture_validate_world_timeouts >/dev/null 2>&1; then + exit 156 + fi + CAPTURE_WORLD_READY_TIMEOUT_SECONDS=3601 + if capture_validate_world_timeouts >/dev/null 2>&1; then + exit 155 + fi + CAPTURE_WORLD_READY_TIMEOUT_SECONDS=180 + capture_validate_world_timeouts + capture_fixture_cleanup_verified_for_publication 0 0 || exit 175 + capture_fixture_cleanup_verified_for_publication 0 1 || exit 176 + capture_fixture_cleanup_verified_for_publication 1 1 || exit 177 + if capture_fixture_cleanup_verified_for_publication 1 0 \ + || capture_fixture_cleanup_verified_for_publication invalid 1 \ + || capture_fixture_cleanup_verified_for_publication 1 invalid; then + exit 178 + fi CAPTURE_WORLD_PORT=45123 CAPTURE_INSTANCE_PORT=45124 CAPTURE_PROC_ROOT="$artifacts/fake-capture-proc" @@ -1571,7 +2019,9 @@ run_self_test() { && "$cpp_capture_text" == *"capture-service-common.sh"* \ && "$cpp_capture_text" == *"capture_wait_for_world_stopped"* \ && "$cpp_capture_text" == *"apply_creature_health_fixture_guard"* \ - && "$cpp_capture_text" == *"loot_fixture_bot_cleanup_complete"* ]] || die \ + && "$cpp_capture_text" == *"capture_validate_world_timeouts"* \ + && "$cpp_capture_text" == *"CPP_CAPTURE_BOT_READY=1"* \ + && "$cpp_capture_text" == *"loot_fixture_bot_cleanup_safe_for_capture_state"* ]] || die \ "C++ capture wrapper is not wired to the shared fail-closed loot guard" [[ "$cpp_capture_text" == *"CPP_CAPTURE_EXEC_SHA256"* \ && "$cpp_capture_text" == *"cpp.capture-manifest.json"* \ @@ -1606,6 +2056,10 @@ run_self_test() { && "$rust_capture_text" == *"pm2_entry_starttime"* \ && "$rust_capture_text" == *"pm2_profile_redacted_sha256"* \ && "$rust_capture_text" == *"capture_pm2_process_stopped"* \ + && "$rust_capture_text" == *"capture_validate_world_timeouts"* \ + && "$rust_capture_text" == *'CAPTURE_STABLE_SAMPLES" -ge 4'* \ + && "$rust_capture_text" == *"CAPTURE_BOT_READY=1"* \ + && "$rust_capture_text" == *"loot_fixture_bot_cleanup_safe_for_capture_state"* \ && "$rust_capture_text" == *"source_repo_head"* \ && "$rust_capture_text" == *"effective_config_redacted_sha256"* \ && "$rust_capture_text" == *"pm2_entry_pid"* \ @@ -1726,6 +2180,7 @@ run_self_test() { '[ "${RUST_INSTANCE_PORT:?}" = "${QA_FAKE_INSTANCE_PORT:?}" ]' \ 'journal="${WOW_BOT_FIXTURE_JOURNAL:?}"' \ 'capture_pid=""' \ + 'capture_bot_ready=0' \ 'restore() {' \ ' status=$?' \ ' trap - EXIT HUP INT TERM' \ @@ -1733,7 +2188,12 @@ run_self_test() { ' if [ -n "$capture_pid" ]; then kill "$capture_pid" 2>/dev/null; wait "$capture_pid" 2>/dev/null; fi' \ ' marker="${journal}.cleanup-complete"' \ ' marker_mode="$(stat -c %a -- "$marker" 2>/dev/null || true)"' \ - ' if [ -e "$journal" ] || [ -L "$journal" ] || [ ! -f "$marker" ] || [ -L "$marker" ] || [ "$marker_mode" != 600 ] || ! jq -e ".version == 1 and (.journal_sha256 | type == \"string\" and length == 64) and (.cleanup_pid | type == \"number\" and . > 0)" "$marker" >/dev/null 2>&1; then' \ + ' if [ "$capture_bot_ready" = 0 ]; then' \ + ' cleanup_safe=$([ ! -e "$journal" ] && [ ! -L "$journal" ] && [ ! -e "$marker" ] && [ ! -L "$marker" ]; echo $?)' \ + ' else' \ + ' cleanup_safe=$([ ! -e "$journal" ] && [ ! -L "$journal" ] && [ -f "$marker" ] && [ ! -L "$marker" ] && [ "$marker_mode" = 600 ] && jq -e ".version == 1 and (.journal_sha256 | type == \"string\" and length == 64) and (.cleanup_pid | type == \"number\" and . > 0)" "$marker" >/dev/null 2>&1; echo $?)' \ + ' fi' \ + ' if [ "$cleanup_safe" != 0 ]; then' \ ' printf "%s\n" "fake guarded capture refused normal PM2 restore: pending journal or missing cleanup-complete" >&2' \ ' exit 74' \ ' fi' \ @@ -1750,6 +2210,7 @@ run_self_test() { '"$QA_FAKE_TARGET_EXEC" 300 &' \ 'capture_pid=$!' \ 'jq -cn --arg name "$QA_FAKE_PROCESS_NAME" --arg exec "$QA_FAKE_TARGET_EXEC" --argjson pid "$capture_pid" '\''[{name:$name,pid:$pid,pm2_env:{status:"online",pm_exec_path:$exec,restart_time:8,RUSTYCORE_PACKET_DUMP_DIR:"/tmp/fake-dump",env:{RUSTYCORE_PACKET_DUMP_DIR:"/tmp/fake-dump"}}}]'\'' >"$QA_FAKE_PM2_JSON_FILE"' \ + 'capture_bot_ready=1' \ 'printf "%s\n" ">>> Perform the '\''loot-two-session-atomic-race'\'' flow with the client now."' \ 'IFS= read -r _' \ 'exit "${QA_FAKE_CAPTURE_EXIT_STATUS:-0}"' \ @@ -1917,9 +2378,10 @@ run_self_test() { run_qa_loot_race ) 2>&1 )" || qa_early_status=$? - [[ "$qa_early_status" == 74 \ - && "$qa_early_output" == *"exited before its ready marker (status 74)"* ]] || die \ - "pre-ready cleanup failure was masked instead of taking exit-status priority" + [[ "$qa_early_status" == 73 \ + && "$qa_early_output" == *"exited before its ready marker (status 73)"* \ + && "$qa_early_output" == *"fake guarded capture restored normal PM2 profile"* ]] || die \ + "pre-ready wrapper failure did not restore safely and preserve its status" rm -f "$qa_fake_bot_marker" "$qa_pm2_state_file" printf '%s\n' "$qa_pm2_online" >"$qa_pm2_json_file" diff --git a/tools/wow-test-bot/README.md b/tools/wow-test-bot/README.md index addd4c8e9..b23c2e56c 100644 --- a/tools/wow-test-bot/README.md +++ b/tools/wow-test-bot/README.md @@ -136,8 +136,9 @@ pre-existing spawn or GameObject respawn, loot conditions, or pool/event/linked, spawn-group, addon, or override ownership. Its full template-addon fields are pinned as well. The group must report C++ `PERSONAL_LOOT` (`5`). The default runtime counter is `0` (auto): each client discovers and preserves the complete live -GameObject ObjectGuid from `SMSG_UPDATE_OBJECT`, while the low counter must -still equal exact SQL spawn `9106001`. A nonzero +GameObject ObjectGuid from `SMSG_UPDATE_OBJECT`. The SQL spawn id remains +`9106001`, but, as in C++, the live low counter is generated independently by +the map and is not assumed to equal that spawn id. A nonzero `WOW_BOT_LOOT_RACE_RUNTIME_COUNTER` is only a strict override; it never replaces live discovery. diff --git a/tools/wow-test-bot/src/loot_race.rs b/tools/wow-test-bot/src/loot_race.rs index 2b7031312..8b8905bc4 100644 --- a/tools/wow-test-bot/src/loot_race.rs +++ b/tools/wow-test-bot/src/loot_race.rs @@ -1345,13 +1345,6 @@ fn record_discovered_runtime_guid(options: &LootRaceOptions, candidate: (u64, u6 options.target.map_id ); } - if options.target.kind == LootRaceTargetKind::GameObject && counter != options.target.spawn_guid - { - bail!( - "loot-race GameObject counter {counter} did not match exact SQL spawn {}", - options.target.spawn_guid - ); - } let configured = options.target.runtime_counter_override; if configured != 0 && counter != configured { bail!( @@ -1389,10 +1382,13 @@ pub(super) fn target_seen_in_update( return Ok(None); } // Keep the complete GUID seen on the wire: C++ includes realm/server bits - // that must not be reconstructed from the SQL spawn id. The GameObject - // fixture additionally requires its exact spawn counter because its entry - // need not be globally unique. - let Some(candidate) = find_loot_target_guid_in_update_object(payload, &options.target) else { + // that must not be reconstructed from the SQL spawn id. C++ + // `GameObject::LoadFromDB` stores the SQL spawn id in `m_spawnId`, while + // `GameObject::Create` builds the live ObjectGuid with the map-local + // `GenerateLowGuid()` counter. The guarded DB + // preflight therefore proves entry/map uniqueness and wire discovery owns + // the independent runtime counter. + let Some(candidate) = find_loot_target_guid_in_update_object(payload, &options.target)? else { return Ok(None); }; record_discovered_runtime_guid(options, candidate).map(Some) @@ -1401,11 +1397,12 @@ pub(super) fn target_seen_in_update( fn find_loot_target_guid_in_update_object( payload: &[u8], target: &LootRaceTarget, -) -> Option<(u64, u64)> { +) -> Result> { let expected_high_type = match target.kind { LootRaceTargetKind::Creature => HIGH_GUID_CREATURE, LootRaceTargetKind::GameObject => HIGH_GUID_GAMEOBJECT, }; + let mut candidates = std::collections::BTreeSet::new(); for offset in 0..payload.len().saturating_sub(2) { if !matches!(payload[offset], 1 | 2) { continue; @@ -1419,13 +1416,19 @@ fn find_loot_target_guid_in_update_object( { continue; } - let counter = low & OBJECT_GUID_COUNTER_MASK; - if target.kind == LootRaceTargetKind::GameObject && counter != target.spawn_guid { - continue; - } - return Some((low, high)); + candidates.insert((low, high)); + } + + if candidates.len() > 1 { + bail!( + "loot-race update contained {} distinct live ObjectGuid candidates for {:?} entry {} map {}: {candidates:?}", + candidates.len(), + target.kind, + target.entry, + target.map_id + ); } - None + Ok(candidates.into_iter().next()) } pub(super) async fn run_phase( @@ -3540,14 +3543,6 @@ fn prepare_gameobject_race_fixture( { bail!("GameObject race fixture did not match the pinned wrapper-owned contract"); } - if cli.runtime_counter != 0 && cli.runtime_counter != cli.spawn_guid { - bail!( - "GameObject runtime counter override {} must be zero (wire discovery) or exact spawn {}", - cli.runtime_counter, - cli.spawn_guid - ); - } - let world_url = world_db_url()?; let world_opts = loot_db_opts(&world_url, "world")?; let mut world = mysql::Conn::new(world_opts) @@ -3623,6 +3618,14 @@ fn prepare_gameobject_race_fixture( "wrapper-owned GameObject spawn drifted from the exact QA contract: guid={spawn_guid} entry={entry} map={map_id} zone={zone_id} area={area_id} difficulties={difficulties:?} phase={phase_flags}/{phase_id}/{phase_group} terrain={terrain_swap_map} pos=({x},{y},{z}) orientation={orientation} rotations={rotations:?} respawn={spawntime} anim={anim_progress} state={state} script={spawn_script:?} string_id={string_id:?} build={verified_build}" ); } + let same_entry_map_spawns: Vec = world + .exec_map( + "SELECT guid FROM gameobject WHERE id = ? AND map = ? ORDER BY guid", + (entry, map_id), + |guid: u64| guid, + ) + .map_err(|error| anyhow!("Check GameObject race map/entry spawn uniqueness: {error}"))?; + validate_unique_sql_spawn(&same_entry_map_spawns, spawn_guid, entry, map_id)?; let template: mysql::Row = world .exec_first( @@ -5620,6 +5623,10 @@ mod tests { } } + fn gameobject_runtime_high(map_id: u16, entry: u32) -> u64 { + (HIGH_GUID_GAMEOBJECT << 58) | (u64::from(map_id) << 29) | (u64::from(entry) << 6) + } + fn update_object_with_guid(low: u64, high: u64) -> Vec { let mut payload = vec![1]; payload.extend_from_slice(&build_packed_guid(low, high)); @@ -5669,7 +5676,7 @@ mod tests { } #[test] - fn sql_spawn_uniqueness_is_required_for_runtime_auto_discovery() { + fn sql_spawn_uniqueness_is_required_for_entry_map_runtime_auto_discovery() { validate_unique_sql_spawn(&[1_117], 1_117, 21_779, 530).unwrap(); let missing = validate_unique_sql_spawn(&[], 1_117, 21_779, 530) @@ -5760,45 +5767,141 @@ mod tests { } #[test] - fn gameobject_discovery_requires_exact_high_type_entry_map_and_spawn_counter() { + fn gameobject_discovery_keeps_sql_spawn_and_runtime_counter_distinct_like_cpp() { + const LIVE_COUNTER: u64 = 40; let options = gameobject_discovery_options(0); - let high = (HIGH_GUID_GAMEOBJECT << 58) - | (u64::from(RACE_GAMEOBJECT_MAP_ID) << 29) - | (u64::from(DEFAULT_CREATURE_ENTRY) << 6); - let exact = update_object_with_guid(DEFAULT_CREATURE_SPAWN_GUID, high); + let high = gameobject_runtime_high(RACE_GAMEOBJECT_MAP_ID, DEFAULT_CREATURE_ENTRY); + let exact = update_object_with_guid(LIVE_COUNTER, high); assert_eq!( target_seen_in_update(&options, SMSG_UPDATE_OBJECT, &exact).unwrap(), - Some(DEFAULT_CREATURE_SPAWN_GUID) + Some(LIVE_COUNTER) ); assert_eq!( options.resolved_runtime_guid().unwrap(), - (DEFAULT_CREATURE_SPAWN_GUID, high) + (LIVE_COUNTER, high) ); + assert_ne!(LIVE_COUNTER, DEFAULT_CREATURE_SPAWN_GUID); + } + + #[test] + fn gameobject_discovery_requires_exact_high_type_entry_and_map() { + const LIVE_COUNTER: u64 = 40; + let high = gameobject_runtime_high(RACE_GAMEOBJECT_MAP_ID, DEFAULT_CREATURE_ENTRY); + let creature_high = (HIGH_GUID_CREATURE << 58) + | (u64::from(RACE_GAMEOBJECT_MAP_ID) << 29) + | (u64::from(DEFAULT_CREATURE_ENTRY) << 6); + let wrong_entry_high = + gameobject_runtime_high(RACE_GAMEOBJECT_MAP_ID, DEFAULT_CREATURE_ENTRY + 1); + let wrong_map_high = + gameobject_runtime_high(RACE_GAMEOBJECT_MAP_ID + 1, DEFAULT_CREATURE_ENTRY); + + for wrong_high in [creature_high, wrong_entry_high, wrong_map_high] { + let options = gameobject_discovery_options(0); + assert_eq!( + target_seen_in_update( + &options, + SMSG_UPDATE_OBJECT, + &update_object_with_guid(LIVE_COUNTER, wrong_high), + ) + .unwrap(), + None + ); + } - let wrong_spawn = gameobject_discovery_options(0); + let wrong_opcode = gameobject_discovery_options(0); assert_eq!( target_seen_in_update( - &wrong_spawn, - SMSG_UPDATE_OBJECT, - &update_object_with_guid(DEFAULT_CREATURE_SPAWN_GUID + 1, high), + &wrong_opcode, + SMSG_LOOT_RESPONSE, + &update_object_with_guid(LIVE_COUNTER, high), ) .unwrap(), None ); + } - let creature_high = (HIGH_GUID_CREATURE << 58) - | (u64::from(RACE_GAMEOBJECT_MAP_ID) << 29) - | (u64::from(DEFAULT_CREATURE_ENTRY) << 6); - let wrong_type = gameobject_discovery_options(0); + #[test] + fn gameobject_runtime_override_checks_the_live_counter_not_the_sql_spawn() { + const LIVE_COUNTER: u64 = 40; + let high = gameobject_runtime_high(RACE_GAMEOBJECT_MAP_ID, DEFAULT_CREATURE_ENTRY); + let exact = gameobject_discovery_options(LIVE_COUNTER); assert_eq!( target_seen_in_update( - &wrong_type, + &exact, SMSG_UPDATE_OBJECT, - &update_object_with_guid(DEFAULT_CREATURE_SPAWN_GUID, creature_high), + &update_object_with_guid(LIVE_COUNTER, high), ) .unwrap(), - None + Some(LIVE_COUNTER) + ); + + let stale = gameobject_discovery_options(LIVE_COUNTER + 1); + let error = target_seen_in_update( + &stale, + SMSG_UPDATE_OBJECT, + &update_object_with_guid(LIVE_COUNTER, high), + ) + .expect_err("a mismatching live GameObject counter override must fail closed"); + assert!(error + .to_string() + .contains("did not match discovered counter 40")); + } + + #[test] + fn gameobject_discovery_deduplicates_one_guid_and_rejects_packet_ambiguity() { + const LIVE_COUNTER: u64 = 40; + let high = gameobject_runtime_high(RACE_GAMEOBJECT_MAP_ID, DEFAULT_CREATURE_ENTRY); + + let mut duplicate = update_object_with_guid(LIVE_COUNTER, high); + duplicate.extend_from_slice(&update_object_with_guid(LIVE_COUNTER, high)); + let options = gameobject_discovery_options(0); + assert_eq!( + target_seen_in_update(&options, SMSG_UPDATE_OBJECT, &duplicate).unwrap(), + Some(LIVE_COUNTER) ); + + let mut ambiguous = update_object_with_guid(LIVE_COUNTER, high); + ambiguous.extend_from_slice(&update_object_with_guid(LIVE_COUNTER + 1, high)); + let options = gameobject_discovery_options(0); + let error = target_seen_in_update(&options, SMSG_UPDATE_OBJECT, &ambiguous) + .expect_err("two distinct matching GameObjects in one update must fail closed"); + assert!(error.to_string().contains("2 distinct live ObjectGuid")); + } + + #[test] + fn gameobject_discovery_requires_two_bots_to_converge_on_one_full_guid() { + const LIVE_COUNTER: u64 = 40; + let high = gameobject_runtime_high(RACE_GAMEOBJECT_MAP_ID, DEFAULT_CREATURE_ENTRY); + let first = gameobject_discovery_options(0); + let mut second = first.clone(); + second.participant = 1; + + assert_eq!( + target_seen_in_update( + &first, + SMSG_UPDATE_OBJECT, + &update_object_with_guid(LIVE_COUNTER, high), + ) + .unwrap(), + Some(LIVE_COUNTER) + ); + assert_eq!( + target_seen_in_update( + &second, + SMSG_UPDATE_OBJECT, + &update_object_with_guid(LIVE_COUNTER, high), + ) + .unwrap(), + Some(LIVE_COUNTER) + ); + + let error = target_seen_in_update( + &second, + SMSG_UPDATE_OBJECT, + &update_object_with_guid(LIVE_COUNTER + 1, high), + ) + .expect_err("two bots must not bind different live GameObject GUIDs"); + assert!(error.to_string().contains("different live ObjectGuids")); } #[tokio::test] From bfbd42a18df05a94c8a145af425e5230ff76700d Mon Sep 17 00:00:00 2001 From: cDmonio Date: Sun, 19 Jul 2026 13:53:59 +0000 Subject: [PATCH 03/14] [08.5] Preserve money loot types and gate race evidence --- crates/capture-diff/README.md | 15 + crates/capture-diff/scripts/capture-rust.sh | 132 +++---- .../scripts/capture-service-common.sh | 144 ++++++++ crates/capture-diff/src/lineage.rs | 323 +++++++++++++++++- crates/capture-diff/src/rustdump.rs | 65 +++- crates/capture-diff/tests/diff_engine.rs | 49 +++ crates/wow-database/src/statements/mod.rs | 14 + crates/wow-database/src/statements/world.rs | 6 +- crates/wow-loot/src/lib.rs | 4 + crates/wow-world/src/handlers/loot.rs | 13 +- crates/wow-world/src/handlers/spell.rs | 53 ++- docs/operations/pr-preflight.md | 11 +- tools/pr-preflight.sh | 173 +++++++++- tools/wow-test-bot/README.md | 8 +- 14 files changed, 899 insertions(+), 111 deletions(-) diff --git a/crates/capture-diff/README.md b/crates/capture-diff/README.md index e6403851a..6e696466b 100644 --- a/crates/capture-diff/README.md +++ b/crates/capture-diff/README.md @@ -299,6 +299,21 @@ unchanged fixture-owned values—including its exact generated respawn row—bef restarting the original PM2 profile. Any external drift fails visibly and leaves the normal world stopped for manual inspection. +Both guarded Rust flows also require a pinned `WOW_BOT_EXEC`/SHA-256 and a +fresh absolute `WOW_BOT_REPORT` path before any service mutation. For +`loot-two-session-atomic-race`, the outer preflight runs the two clients and +writes that report while the wrapper is waiting at its prompt. Publication is +fail-closed: only the exact TESTBOT2/TESTBOT3 success contract (same live target +counter/list ID, one item winner, `10`/`0` money notifications, exact database +deltas, and relog proof) can produce a completed race dump. Cleanup and normal +PM2 restoration still run when the report is absent or invalid. A direct/manual +invocation must arrange the same pinned bot report; pressing ENTER without that +evidence intentionally restores the runtime without publishing. +A successful race generation retains `race.bot-report.json` inside the atomic +Rust artifact and records its final path/SHA-256 plus non-null fixture/bot +contracts in the manifest; the preflight may then remove its private source +copy without orphaning the provenance record. + The bot writes a mode-0600 recovery journal before its first character/fixture mutation, removes it only after verified restoration, and atomically creates `${WOW_BOT_FIXTURE_JOURNAL}.cleanup-complete`. The capture wrapper removes that diff --git a/crates/capture-diff/scripts/capture-rust.sh b/crates/capture-diff/scripts/capture-rust.sh index 1dab876f0..eddb4b7c5 100755 --- a/crates/capture-diff/scripts/capture-rust.sh +++ b/crates/capture-diff/scripts/capture-rust.sh @@ -7,7 +7,8 @@ # # Usage: crates/capture-diff/scripts/capture-rust.sh [--yes] # Output: target/captures//rust/ (gitignored; .bin/.meta per packet, -# including rust.capture-manifest.json) +# rust.capture-manifest.json, plus the retained race bot report for +# loot-two-session-atomic-race) # # Honored env vars: # PM2_RUST_WORLD pm2 name of the Rust world (default: rustycore-world) @@ -41,9 +42,11 @@ # with capture-cpp.sh (default: /tmp, keyed by uid+ports) # CAPTURE_WORLD_READY_TIMEOUT_SECONDS bounded wait for a stable ready world # (default: 180, range: 3 through 3600) -# WOW_BOT_EXEC / WOW_BOT_EXEC_SHA256 pinned bot executable used for required -# loot-single-item-claim evidence -# WOW_BOT_REPORT fresh absolute bot JSON report path for that flow +# WOW_BOT_EXEC / WOW_BOT_EXEC_SHA256 pinned bot executable used for both +# guarded #106 loot evidence flows +# WOW_BOT_REPORT fresh absolute bot JSON report path. The guarded +# wrapper independently validates the exact selected +# single-item or two-session contract before publish # # This restarts the live world server (disconnecting players). Pass --yes to skip # the confirmation prompt. @@ -194,33 +197,6 @@ case "$RUST_CAPTURE_LOOT_FIXTURE_GUARD" in LOOT_FIXTURE_KIND=creature-health LOOT_FIXTURE_ENTRY=21779 LOOT_FIXTURE_EXPECTED_HEALTH_MODIFIER=1 - [ -n "$WOW_BOT_EXEC" ] && [ -n "$WOW_BOT_EXEC_SHA256" ] \ - && [ -n "$WOW_BOT_REPORT" ] || { - echo "error: guarded #106 evidence requires WOW_BOT_EXEC, WOW_BOT_EXEC_SHA256, and WOW_BOT_REPORT" >&2 - exit 2 - } - [[ "$WOW_BOT_EXEC_SHA256" =~ ^[0-9A-Fa-f]{64}$ ]] || { - echo "error: WOW_BOT_EXEC_SHA256 must contain exactly 64 hexadecimal characters" >&2 - exit 2 - } - WOW_BOT_EXEC_SHA256="${WOW_BOT_EXEC_SHA256,,}" - [[ "$WOW_BOT_REPORT" = /* && "$WOW_BOT_REPORT" != *$'\n'* ]] \ - && [ -d "$(dirname -- "$WOW_BOT_REPORT")" ] \ - && [ ! -e "$WOW_BOT_REPORT" ] && [ ! -L "$WOW_BOT_REPORT" ] || { - echo "error: WOW_BOT_REPORT must be a fresh absolute path with an existing parent" >&2 - exit 2 - } - WOW_BOT_REPORT_PARENT="$(dirname -- "$WOW_BOT_REPORT")" - [ "$(realpath -e -- "$WOW_BOT_REPORT_PARENT" 2>/dev/null)" \ - = "$WOW_BOT_REPORT_PARENT" ] \ - && [ ! -L "$WOW_BOT_REPORT_PARENT" ] || { - echo "error: WOW_BOT_REPORT parent must be canonical and non-symlink" >&2 - exit 2 - } - capture_exec_source_matches "$WOW_BOT_EXEC" "$WOW_BOT_EXEC_SHA256" || { - echo "error: WOW_BOT_EXEC is not a canonical pinned executable" >&2 - exit 2 - } ;; loot-two-session-atomic-race) LOOT_FIXTURE_KIND=shared-chest @@ -230,6 +206,33 @@ case "$RUST_CAPTURE_LOOT_FIXTURE_GUARD" in exit 2 ;; esac + [ -n "$WOW_BOT_EXEC" ] && [ -n "$WOW_BOT_EXEC_SHA256" ] \ + && [ -n "$WOW_BOT_REPORT" ] || { + echo "error: guarded #106 evidence requires WOW_BOT_EXEC, WOW_BOT_EXEC_SHA256, and WOW_BOT_REPORT" >&2 + exit 2 + } + [[ "$WOW_BOT_EXEC_SHA256" =~ ^[0-9A-Fa-f]{64}$ ]] || { + echo "error: WOW_BOT_EXEC_SHA256 must contain exactly 64 hexadecimal characters" >&2 + exit 2 + } + WOW_BOT_EXEC_SHA256="${WOW_BOT_EXEC_SHA256,,}" + [[ "$WOW_BOT_REPORT" = /* && "$WOW_BOT_REPORT" != *$'\n'* ]] \ + && [ -d "$(dirname -- "$WOW_BOT_REPORT")" ] \ + && [ ! -e "$WOW_BOT_REPORT" ] && [ ! -L "$WOW_BOT_REPORT" ] || { + echo "error: WOW_BOT_REPORT must be a fresh absolute path with an existing parent" >&2 + exit 2 + } + WOW_BOT_REPORT_PARENT="$(dirname -- "$WOW_BOT_REPORT")" + [ "$(realpath -e -- "$WOW_BOT_REPORT_PARENT" 2>/dev/null)" \ + = "$WOW_BOT_REPORT_PARENT" ] \ + && [ ! -L "$WOW_BOT_REPORT_PARENT" ] || { + echo "error: WOW_BOT_REPORT parent must be canonical and non-symlink" >&2 + exit 2 + } + capture_exec_source_matches "$WOW_BOT_EXEC" "$WOW_BOT_EXEC_SHA256" || { + echo "error: WOW_BOT_EXEC is not a canonical pinned executable" >&2 + exit 2 + } validate_fresh_loot_fixture_journal || exit 2 ;; *) @@ -867,6 +870,9 @@ rust_capture_flat_tree_is_safe() { base="${path##*/}" case "$base" in *.bin|*.meta|rust.capture-manifest.json) ;; + race.bot-report.json) + [ "$FLOW" = "loot-two-session-atomic-race" ] || return 1 + ;; *) return 1 ;; esac done < <(find "$directory" -mindepth 1 -maxdepth 1 -print0) @@ -878,18 +884,41 @@ finalize_rust_capture_artifact() { && [ -n "$CAPTURE_LIVE_EXEC" ] \ && [ -n "$CAPTURE_LIVE_SHA256" ] || return 1 - local created_at manifest packet_count tree_sha bot_evidence path + local bot_evidence="" capture_evidence created_at manifest packet_count + local retained_bot_report path tree_sha [ "$CAPTURE_RUNTIME_CLEANUP_VERIFIED" -eq 1 ] \ && [ "$CAPTURE_NORMAL_RUNTIME_RESTORED" -eq 1 ] || return 1 capture_fixture_cleanup_verified_for_publication \ "$RUST_CAPTURE_LOOT_FIXTURE_GUARD" \ "$CAPTURE_FIXTURE_CLEANUP_VERIFIED" || return 1 - if [ "$FLOW" = "loot-single-item-claim" ]; then - bot_evidence="$(capture_loot_item_bot_evidence \ - "$WOW_BOT_REPORT" "$WOW_BOT_EXEC" "$WOW_BOT_EXEC_SHA256")" || return 1 + case "$FLOW" in + loot-single-item-claim) + bot_evidence="$(capture_loot_item_bot_evidence \ + "$WOW_BOT_REPORT" "$WOW_BOT_EXEC" "$WOW_BOT_EXEC_SHA256")" || return 1 + ;; + loot-two-session-atomic-race) + bot_evidence="$(capture_loot_race_bot_evidence \ + "$WOW_BOT_REPORT" "$WOW_BOT_EXEC" "$WOW_BOT_EXEC_SHA256")" || return 1 + ;; + esac + if [ -n "$bot_evidence" ]; then IFS=$'\t' read -r CAPTURE_BOT_EXEC CAPTURE_BOT_EXEC_SHA256 \ CAPTURE_BOT_REPORT CAPTURE_BOT_REPORT_SHA256 <<<"$bot_evidence" fi + if [ "$FLOW" = "loot-two-session-atomic-race" ]; then + retained_bot_report="$DUMP_STAGE_DIR/race.bot-report.json" + [ ! -e "$retained_bot_report" ] && [ ! -L "$retained_bot_report" ] \ + && cp --no-clobber -- "$CAPTURE_BOT_REPORT" "$retained_bot_report" \ + && chmod 600 "$retained_bot_report" \ + && [ "$(sha256_of_file "$retained_bot_report")" \ + = "$CAPTURE_BOT_REPORT_SHA256" ] \ + && [ "$(sha256_of_file "$CAPTURE_BOT_REPORT")" \ + = "$CAPTURE_BOT_REPORT_SHA256" ] || return 1 + CAPTURE_BOT_REPORT="$DUMP_DIR/race.bot-report.json" + fi + capture_evidence="$(capture_bot_manifest_evidence \ + "$FLOW" "$CAPTURE_BOT_EXEC" "$CAPTURE_BOT_EXEC_SHA256" \ + "$CAPTURE_BOT_REPORT" "$CAPTURE_BOT_REPORT_SHA256")" || return 1 tree_sha="$(rust_capture_tree_digest "$DUMP_STAGE_DIR")" || return 1 packet_count="$(find "$DUMP_STAGE_DIR" -mindepth 1 -maxdepth 1 \ -type f -name '*.meta' -print | wc -l)" \ @@ -917,11 +946,8 @@ finalize_rust_capture_artifact() { --arg pm2_profile_sha256 "$CAPTURE_PM2_PROFILE_SHA256" \ --arg effective_config_path "$CAPTURE_EFFECTIVE_CONFIG_PATH" \ --arg effective_config_sha256 "$CAPTURE_EFFECTIVE_CONFIG_SHA256" \ - --arg bot_exec_path "$CAPTURE_BOT_EXEC" \ - --arg bot_exec_sha256 "$CAPTURE_BOT_EXEC_SHA256" \ - --arg bot_report_path "$CAPTURE_BOT_REPORT" \ - --arg bot_report_sha256 "$CAPTURE_BOT_REPORT_SHA256" \ --arg tree_sha256 "$tree_sha" \ + --argjson capture_evidence "$capture_evidence" \ --argjson pm2_entry_pid "$CAPTURE_PM2_ENTRY_PID" \ --argjson pm2_entry_starttime "$CAPTURE_PM2_ENTRY_STARTTIME" \ --argjson listener_runtime_pid "$CAPTURE_PROCESS_PID" \ @@ -929,7 +955,6 @@ finalize_rust_capture_artifact() { --argjson restart_count "$CAPTURE_RESTART_COUNT" \ --argjson packet_count "$packet_count" \ --argjson pinned "$([ -n "$CAPTURE_EXEC" ] && printf true || printf false)" \ - --argjson guarded "$([ "$FLOW" = "loot-single-item-claim" ] && printf true || printf false)" \ '{ version: 3, flow: $flow, @@ -964,31 +989,8 @@ finalize_rust_capture_artifact() { effective_config_algorithm: "capture-relevant-redacted-v1", runtime_cleanup_verified: true, normal_runtime_restored: true, - fixture_guard: (if $guarded then { - enabled: true, - contract: "loot-single-item-claim-fixture-v1", - account: "TESTBOT2@bot.local", - account_id: 9, - character_guid: 15, - peer_account: "TESTBOT3@bot.local", - peer_account_id: 10, - peer_character_guid: 16, - creature_entry: 21779, - creature_spawn_guid: 1117, - item_entry: 30712, - cleanup_verified: true - } else null end), - bot_report: (if $guarded then { - contract: "wow-test-bot-loot-item-capture-report-v1", - exec_path: $bot_exec_path, - exec_sha256: $bot_exec_sha256, - report_path: $bot_report_path, - report_sha256: $bot_report_sha256, - account: "TESTBOT2@bot.local", - account_id: 9, - character_guid: 15, - report_validated: true - } else null end), + fixture_guard: $capture_evidence.fixture_guard, + bot_report: $capture_evidence.bot_report, artifact: { path: "rust", packet_count: $packet_count, diff --git a/crates/capture-diff/scripts/capture-service-common.sh b/crates/capture-diff/scripts/capture-service-common.sh index 182e1b612..e318e458b 100644 --- a/crates/capture-diff/scripts/capture-service-common.sh +++ b/crates/capture-diff/scripts/capture-service-common.sh @@ -586,6 +586,150 @@ capture_loot_item_bot_evidence() { "$canonical_exec" "$bot_sha" "$canonical_report" "$report_sha" } +capture_loot_race_report_proves_exact_success() { + local report_path="$1" + + jq -e ' + .loot_race_smoke == true + and .loot_item_capture == false + and (.results | type == "array" and length == 2) + and ([.results[] | [.account, .account_id, .character_guid]] | sort + == [["TESTBOT2@bot.local", 9, 15], ["TESTBOT3@bot.local", 10, 16]]) + and all(.results[]; + .world_auth == true + and .enum_characters == true + and .player_login_verified == true + and .loot_race_smoke == true + and .loot_race_smoke_passed == true + and .loot_race_failure == null + and .loot_race_target_entry == 2846 + and .loot_race_target_spawn_guid == 9106001 + and (.loot_race_target_runtime_counter | type == "number" and . > 0) + and .loot_race_party_confirmed == true + and .loot_race_target_discovered == true + and .loot_race_loot_opened == true + and (.loot_race_loot_list_id | type == "number" and . >= 0 and . <= 255) + and .loot_race_loot_coins == 10 + and .loot_race_loot_removed_seen == true + and .loot_race_coin_removed_seen == true + and .loot_race_db_item_total == 1 + and .loot_race_db_money_delta == 10 + and .loot_race_relog_verified == true) + and ([.results[].loot_race_target_runtime_counter] | unique | length == 1) + and ([.results[].loot_race_loot_list_id] | unique | length == 1) + and ([.results[] | select(.loot_race_item_push_seen == true)] | length == 1) + and ([.results[] | select(.loot_race_item_push_seen == false)] | length == 1) + and ([.results[].loot_race_money_notify_amount] | sort == [0, 10]) + ' "$report_path" >/dev/null +} + +capture_loot_race_bot_evidence() { + local report_path="$1" + local bot_exec="$2" + local expected_bot_sha="$3" + local canonical_report canonical_exec report_sha bot_sha + + [[ "$report_path" = /* && "$bot_exec" = /* \ + && "$expected_bot_sha" =~ ^[0-9a-f]{64}$ ]] || return 1 + canonical_report="$(realpath -e -- "$report_path" 2>/dev/null)" || return 1 + canonical_exec="$(realpath -e -- "$bot_exec" 2>/dev/null)" || return 1 + [ "$canonical_report" = "$report_path" ] \ + && [ -f "$report_path" ] && [ ! -L "$report_path" ] \ + && [ "$canonical_exec" = "$bot_exec" ] \ + && [ -f "$bot_exec" ] && [ -x "$bot_exec" ] && [ ! -L "$bot_exec" ] \ + || return 1 + bot_sha="$(capture_sha256_of_file "$bot_exec")" || return 1 + [ "$bot_sha" = "$expected_bot_sha" ] || return 1 + capture_loot_race_report_proves_exact_success "$report_path" || return 1 + report_sha="$(capture_sha256_of_file "$report_path")" || return 1 + printf '%s\t%s\t%s\t%s\n' \ + "$canonical_exec" "$bot_sha" "$canonical_report" "$report_sha" +} + +capture_bot_manifest_evidence() { + local flow="$1" + local bot_exec="$2" + local bot_exec_sha256="$3" + local bot_report="$4" + local bot_report_sha256="$5" + + case "$flow" in + loot-single-item-claim) + jq -n \ + --arg exec_path "$bot_exec" \ + --arg exec_sha256 "$bot_exec_sha256" \ + --arg report_path "$bot_report" \ + --arg report_sha256 "$bot_report_sha256" ' + { + fixture_guard: { + enabled: true, + contract: "loot-single-item-claim-fixture-v1", + account: "TESTBOT2@bot.local", + account_id: 9, + character_guid: 15, + peer_account: "TESTBOT3@bot.local", + peer_account_id: 10, + peer_character_guid: 16, + creature_entry: 21779, + creature_spawn_guid: 1117, + item_entry: 30712, + cleanup_verified: true + }, + bot_report: { + contract: "wow-test-bot-loot-item-capture-report-v1", + exec_path: $exec_path, + exec_sha256: $exec_sha256, + report_path: $report_path, + report_sha256: $report_sha256, + account: "TESTBOT2@bot.local", + account_id: 9, + character_guid: 15, + report_validated: true + } + } + ' + ;; + loot-two-session-atomic-race) + jq -n \ + --arg exec_path "$bot_exec" \ + --arg exec_sha256 "$bot_exec_sha256" \ + --arg report_path "$bot_report" \ + --arg report_sha256 "$bot_report_sha256" ' + { + fixture_guard: { + enabled: true, + contract: "loot-two-session-atomic-race-fixture-v1", + account: "TESTBOT2@bot.local", + account_id: 9, + character_guid: 15, + peer_account: "TESTBOT3@bot.local", + peer_account_id: 10, + peer_character_guid: 16, + gameobject_entry: 2846, + gameobject_spawn_guid: 9106001, + item_entry: 38, + cleanup_verified: true + }, + bot_report: { + contract: "wow-test-bot-loot-two-session-atomic-race-report-v1", + exec_path: $exec_path, + exec_sha256: $exec_sha256, + report_path: $report_path, + report_sha256: $report_sha256, + account: "TESTBOT2@bot.local", + account_id: 9, + character_guid: 15, + report_validated: true + } + } + ' + ;; + *) + printf '%s\n' '{"fixture_guard":null,"bot_report":null}' + ;; + esac +} + capture_release_orchestration_lock() { if [[ "${CAPTURE_ORCHESTRATION_LOCK_FD:-}" =~ ^[0-9]+$ ]]; then flock -u "$CAPTURE_ORCHESTRATION_LOCK_FD" 2>/dev/null || true diff --git a/crates/capture-diff/src/lineage.rs b/crates/capture-diff/src/lineage.rs index bef14ddd8..7386a9f53 100644 --- a/crates/capture-diff/src/lineage.rs +++ b/crates/capture-diff/src/lineage.rs @@ -67,8 +67,14 @@ struct FixtureGuardEvidence { peer_account: String, peer_account_id: u32, peer_character_guid: u64, - creature_entry: u32, - creature_spawn_guid: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + creature_entry: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + creature_spawn_guid: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + gameobject_entry: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + gameobject_spawn_guid: Option, item_entry: u32, cleanup_verified: bool, } @@ -461,8 +467,12 @@ fn validate_raw_manifest_schema( "required evidence must set executable_pin_enforced=true" ); } - if flow == "loot-single-item-claim" { - validate_canonical_loot_identity(manifest)?; + match flow { + "loot-single-item-claim" => validate_canonical_loot_identity(manifest)?, + "loot-two-session-atomic-race" => { + validate_canonical_loot_race_identity(manifest)?; + } + _ => {} } match side { @@ -536,8 +546,10 @@ fn validate_canonical_loot_identity(manifest: &RawCaptureManifest) -> Result<()> "fixture_guard bot identity is not the canonical TESTBOT2/TESTBOT3 fixture" ); ensure!( - fixture.creature_entry == 21_779 - && fixture.creature_spawn_guid == 1_117 + fixture.creature_entry == Some(21_779) + && fixture.creature_spawn_guid == Some(1_117) + && fixture.gameobject_entry.is_none() + && fixture.gameobject_spawn_guid.is_none() && fixture.item_entry == 30_712, "fixture_guard world/item identity is not the canonical Doctor Maleficus fixture" ); @@ -564,6 +576,56 @@ fn validate_canonical_loot_identity(manifest: &RawCaptureManifest) -> Result<()> Ok(()) } +fn validate_canonical_loot_race_identity(manifest: &RawCaptureManifest) -> Result<()> { + let fixture = manifest + .fixture_guard + .as_ref() + .context("loot-two-session-atomic-race requires fixture_guard evidence")?; + ensure!(fixture.enabled, "fixture_guard.enabled must be true"); + ensure!( + fixture.contract == "loot-two-session-atomic-race-fixture-v1", + "unexpected fixture_guard contract" + ); + ensure!( + fixture.account == "TESTBOT2@bot.local" + && fixture.account_id == 9 + && fixture.character_guid == 15 + && fixture.peer_account == "TESTBOT3@bot.local" + && fixture.peer_account_id == 10 + && fixture.peer_character_guid == 16, + "fixture_guard bot identity is not the canonical TESTBOT2/TESTBOT3 fixture" + ); + ensure!( + fixture.creature_entry.is_none() + && fixture.creature_spawn_guid.is_none() + && fixture.gameobject_entry == Some(2_846) + && fixture.gameobject_spawn_guid == Some(9_106_001) + && fixture.item_entry == 38, + "fixture_guard world/item identity is not the canonical shared-chest race fixture" + ); + ensure!( + fixture.cleanup_verified, + "fixture_guard cleanup was not verified" + ); + + let bot = manifest + .bot_report + .as_ref() + .context("loot-two-session-atomic-race requires bot_report evidence")?; + ensure!( + bot.contract == "wow-test-bot-loot-two-session-atomic-race-report-v1", + "unexpected bot_report contract" + ); + ensure!(bot.report_validated, "bot_report was not validated"); + ensure!( + bot.account == fixture.account + && bot.account_id == fixture.account_id + && bot.character_guid == fixture.character_guid, + "bot_report identity does not match fixture_guard identity" + ); + Ok(()) +} + fn validate_cross_side_identity( flow: &str, cpp: &RawCaptureManifest, @@ -581,10 +643,13 @@ fn validate_cross_side_identity( cpp.worktree_state_algorithm == rust.worktree_state_algorithm, "C++ and Rust harness digest algorithms differ" ); - if flow == "loot-single-item-claim" { + if matches!( + flow, + "loot-single-item-claim" | "loot-two-session-atomic-race" + ) { ensure!( cpp.fixture_guard == rust.fixture_guard, - "C++ and Rust fixture_guard identities differ" + "C++ and Rust guarded-loot fixture identities differ" ); let cpp_bot = cpp.bot_report.as_ref().context("C++ bot report missing")?; let rust_bot = rust @@ -621,6 +686,21 @@ fn validate_bot_report_artifact(manifest: &RawCaptureManifest) -> Result Result<()> { let report: serde_json::Value = serde_json::from_slice(bytes).context("parsing bot report evidence")?; + match evidence.contract.as_str() { + "wow-test-bot-loot-item-capture-report-v1" => { + validate_loot_item_bot_report_json(&report, evidence) + } + "wow-test-bot-loot-two-session-atomic-race-report-v1" => { + validate_loot_race_bot_report_json(&report, evidence) + } + contract => bail!("unsupported bot report contract {contract:?}"), + } +} + +fn validate_loot_item_bot_report_json( + report: &serde_json::Value, + evidence: &BotReportEvidence, +) -> Result<()> { let results = report .get("results") .and_then(serde_json::Value::as_array) @@ -672,6 +752,114 @@ fn validate_bot_report_json(bytes: &[u8], evidence: &BotReportEvidence) -> Resul Ok(()) } +fn validate_loot_race_bot_report_json( + report: &serde_json::Value, + evidence: &BotReportEvidence, +) -> Result<()> { + let results = report + .get("results") + .and_then(serde_json::Value::as_array) + .context("bot report results must be an array")?; + ensure!( + report + .get("loot_item_capture") + .and_then(serde_json::Value::as_bool) + == Some(false) + && report + .get("loot_race_smoke") + .and_then(serde_json::Value::as_bool) + == Some(true) + && results.len() == 2, + "bot report is not a two-session loot race capture" + ); + ensure!( + evidence.account == "TESTBOT2@bot.local" + && evidence.account_id == 9 + && evidence.character_guid == 15, + "race bot report manifest identity is not canonical TESTBOT2" + ); + + let mut by_account = BTreeMap::new(); + let mut runtime_counters = Vec::with_capacity(2); + let mut loot_list_ids = Vec::with_capacity(2); + let mut item_pushes = Vec::with_capacity(2); + let mut money_notifications = Vec::with_capacity(2); + for result in results { + let string = |key: &str| result.get(key).and_then(serde_json::Value::as_str); + let u64_value = |key: &str| result.get(key).and_then(serde_json::Value::as_u64); + let boolean = |key: &str| result.get(key).and_then(serde_json::Value::as_bool); + let account = string("account").context("race result account must be a string")?; + ensure!( + by_account.insert(account, result).is_none(), + "race bot report contains a duplicate account" + ); + let (account_id, character_guid) = match account { + "TESTBOT2@bot.local" => (9, 15), + "TESTBOT3@bot.local" => (10, 16), + _ => bail!("race bot report contains unexpected account {account:?}"), + }; + ensure!( + u64_value("account_id") == Some(account_id) + && u64_value("character_guid") == Some(character_guid), + "race bot report account identity does not match the canonical fixture" + ); + ensure!( + boolean("world_auth") == Some(true) + && boolean("enum_characters") == Some(true) + && boolean("player_login_verified") == Some(true) + && boolean("loot_race_smoke") == Some(true) + && boolean("loot_race_smoke_passed") == Some(true) + && result + .get("loot_race_failure") + .is_some_and(serde_json::Value::is_null) + && u64_value("loot_race_target_entry") == Some(2_846) + && u64_value("loot_race_target_spawn_guid") == Some(9_106_001) + && u64_value("loot_race_target_runtime_counter").is_some_and(|value| value > 0) + && boolean("loot_race_party_confirmed") == Some(true) + && boolean("loot_race_target_discovered") == Some(true) + && boolean("loot_race_loot_opened") == Some(true) + && u64_value("loot_race_loot_list_id").is_some_and(|value| value <= 255) + && u64_value("loot_race_loot_coins") == Some(10) + && boolean("loot_race_loot_removed_seen") == Some(true) + && boolean("loot_race_coin_removed_seen") == Some(true) + && u64_value("loot_race_db_item_total") == Some(1) + && u64_value("loot_race_db_money_delta") == Some(10) + && boolean("loot_race_relog_verified") == Some(true), + "bot report does not prove the exact successful two-session loot race" + ); + runtime_counters.push(u64_value("loot_race_target_runtime_counter").unwrap()); + loot_list_ids.push(u64_value("loot_race_loot_list_id").unwrap()); + item_pushes.push( + boolean("loot_race_item_push_seen") + .context("race result item-push observation must be boolean")?, + ); + money_notifications.push( + u64_value("loot_race_money_notify_amount") + .context("race result money notification must be unsigned")?, + ); + } + ensure!( + by_account.len() == 2 + && by_account.contains_key("TESTBOT2@bot.local") + && by_account.contains_key("TESTBOT3@bot.local"), + "race bot report does not contain the exact two canonical accounts" + ); + runtime_counters.sort_unstable(); + runtime_counters.dedup(); + loot_list_ids.sort_unstable(); + loot_list_ids.dedup(); + item_pushes.sort_unstable(); + money_notifications.sort_unstable(); + ensure!( + runtime_counters.len() == 1 + && loot_list_ids.len() == 1 + && item_pushes == [false, true] + && money_notifications == [0, 10], + "race bot report does not prove one shared target/list, one item winner, and 0/10 money fanout" + ); + Ok(()) +} + fn validate_cpp_artifact(manifest: &RawCaptureManifest, path: &Path) -> Result<()> { let bytes = read_regular_file(path) .with_context(|| format!("reading raw C++ capture {}", path.display()))?; @@ -891,10 +1079,13 @@ pub fn verify_required_lineage( == lineage.sources.rust.worktree_state_algorithm, "required lineage C++/Rust harness identities differ" ); - if flow == "loot-single-item-claim" { + if matches!( + flow, + "loot-single-item-claim" | "loot-two-session-atomic-race" + ) { ensure!( lineage.sources.cpp.fixture_guard == lineage.sources.rust.fixture_guard, - "required lineage C++/Rust fixture identities differ" + "required lineage C++/Rust guarded-loot fixture identities differ" ); let cpp_bot = lineage .sources @@ -1817,6 +2008,86 @@ mod tests { }); fs::write(manifest_path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap(); } + } else if flow == "loot-two-session-atomic-race" { + let fixture = serde_json::json!({ + "enabled": true, + "contract": "loot-two-session-atomic-race-fixture-v1", + "account": "TESTBOT2@bot.local", + "account_id": 9, + "character_guid": 15, + "peer_account": "TESTBOT3@bot.local", + "peer_account_id": 10, + "peer_character_guid": 16, + "gameobject_entry": 2846, + "gameobject_spawn_guid": 9106001, + "item_entry": 38, + "cleanup_verified": true + }); + let result = |account: &str, + account_id: u32, + character_guid: u64, + item_push: bool, + money: u64| { + serde_json::json!({ + "account": account, + "account_id": account_id, + "character_guid": character_guid, + "world_auth": true, + "enum_characters": true, + "player_login_verified": true, + "loot_race_smoke": true, + "loot_race_smoke_passed": true, + "loot_race_failure": null, + "loot_race_target_entry": 2846, + "loot_race_target_spawn_guid": 9106001, + "loot_race_target_runtime_counter": 40, + "loot_race_party_confirmed": true, + "loot_race_target_discovered": true, + "loot_race_loot_opened": true, + "loot_race_loot_list_id": 0, + "loot_race_loot_coins": 10, + "loot_race_item_push_seen": item_push, + "loot_race_loot_removed_seen": true, + "loot_race_money_notify_amount": money, + "loot_race_coin_removed_seen": true, + "loot_race_db_item_total": 1, + "loot_race_db_money_delta": 10, + "loot_race_relog_verified": true + }) + }; + let report_json = serde_json::json!({ + "loot_item_capture": false, + "loot_race_smoke": true, + "results": [ + result("TESTBOT2@bot.local", 9, 15, true, 10), + result("TESTBOT3@bot.local", 10, 16, false, 0) + ] + }); + let report_bytes = serde_json::to_vec_pretty(&report_json).unwrap(); + let cpp_report = raw.join("cpp-race-report.json"); + let rust_report = raw.join("rust-race-report.json"); + fs::write(&cpp_report, &report_bytes).unwrap(); + fs::write(&rust_report, &report_bytes).unwrap(); + + for (manifest_path, report_path) in + [(&cpp_manifest, cpp_report), (&rust_manifest, rust_report)] + { + let mut manifest: serde_json::Value = + serde_json::from_slice(&fs::read(manifest_path).unwrap()).unwrap(); + manifest["fixture_guard"] = fixture.clone(); + manifest["bot_report"] = serde_json::json!({ + "contract": "wow-test-bot-loot-two-session-atomic-race-report-v1", + "exec_path": "/opt/rustycore/wow-test-bot", + "exec_sha256": "7".repeat(64), + "report_path": report_path.to_string_lossy(), + "report_sha256": sha256_bytes(&report_bytes), + "account": "TESTBOT2@bot.local", + "account_id": 9, + "character_guid": 15, + "report_validated": true + }); + fs::write(manifest_path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap(); + } } (cpp, cpp_manifest, rust, rust_manifest) } @@ -2139,6 +2410,38 @@ mod tests { fs::remove_dir_all(root).unwrap(); } + #[test] + fn loot_race_raw_pair_accepts_gameobject_guard_and_rejects_split_runtime_target() { + let root = test_root("loot-race-identity"); + let flow = "loot-two-session-atomic-race"; + let (cpp, cpp_manifest, rust, rust_manifest) = make_raw_pair(&root, flow); + validate_raw_pair(flow, &cpp, &cpp_manifest, &rust, &rust_manifest, true).unwrap(); + + let mut manifest: serde_json::Value = + serde_json::from_slice(&fs::read(&rust_manifest).unwrap()).unwrap(); + let report_path = PathBuf::from(manifest["bot_report"]["report_path"].as_str().unwrap()); + let mut report: serde_json::Value = + serde_json::from_slice(&fs::read(&report_path).unwrap()).unwrap(); + report["results"][1]["loot_race_target_runtime_counter"] = serde_json::Value::from(41); + let report_bytes = serde_json::to_vec_pretty(&report).unwrap(); + fs::write(&report_path, &report_bytes).unwrap(); + manifest["bot_report"]["report_sha256"] = + serde_json::Value::String(sha256_bytes(&report_bytes)); + fs::write( + &rust_manifest, + serde_json::to_vec_pretty(&manifest).unwrap(), + ) + .unwrap(); + + let error = validate_raw_pair(flow, &cpp, &cpp_manifest, &rust, &rust_manifest, true) + .expect_err("split live target counter must fail"); + assert!( + format!("{error:#}").contains("one shared target/list"), + "unexpected error: {error:#}" + ); + fs::remove_dir_all(root).unwrap(); + } + #[test] fn derived_loot_lineage_retains_and_revalidates_bot_reports() { let root = test_root("loot-report-retention"); diff --git a/crates/capture-diff/src/rustdump.rs b/crates/capture-diff/src/rustdump.rs index a80114773..9616fb3c7 100644 --- a/crates/capture-diff/src/rustdump.rs +++ b/crates/capture-diff/src/rustdump.rs @@ -21,6 +21,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; +use sha2::{Digest, Sha256}; use crate::model::{Capture, CapturedPacket, Direction}; @@ -44,6 +45,59 @@ const META_KEYS: [&str; 8] = [ "name", "len", ]; +const RUST_CAPTURE_MANIFEST_FILE: &str = "rust.capture-manifest.json"; +const RACE_BOT_REPORT_FILE: &str = "race.bot-report.json"; + +fn validate_race_bot_report_sidecar(dir: &Path, report_path: &Path) -> Result<()> { + let manifest_path = dir.join(RUST_CAPTURE_MANIFEST_FILE); + let manifest_metadata = std::fs::symlink_metadata(&manifest_path).with_context(|| { + format!( + "race bot report requires capture manifest {}", + manifest_path.display() + ) + })?; + if manifest_metadata.file_type().is_symlink() || !manifest_metadata.file_type().is_file() { + bail!( + "race bot report capture manifest is not a regular non-symlink file: {}", + manifest_path.display() + ); + } + let manifest: serde_json::Value = serde_json::from_slice( + &std::fs::read(&manifest_path) + .with_context(|| format!("reading capture manifest {}", manifest_path.display()))?, + ) + .with_context(|| format!("parsing capture manifest {}", manifest_path.display()))?; + let evidence = manifest + .get("bot_report") + .and_then(serde_json::Value::as_object) + .context("race capture manifest is missing bot_report evidence")?; + let expected_sha = evidence + .get("report_sha256") + .and_then(serde_json::Value::as_str) + .context("race capture manifest bot_report SHA-256 is missing")?; + let declared_path = evidence + .get("report_path") + .and_then(serde_json::Value::as_str) + .context("race capture manifest bot_report path is missing")?; + if manifest.get("flow").and_then(serde_json::Value::as_str) + != Some("loot-two-session-atomic-race") + || evidence.get("contract").and_then(serde_json::Value::as_str) + != Some("wow-test-bot-loot-two-session-atomic-race-report-v1") + || Path::new(declared_path) + .file_name() + .and_then(|name| name.to_str()) + != Some(RACE_BOT_REPORT_FILE) + { + bail!("race bot report sidecar is not declared by the matching race manifest contract"); + } + let report = std::fs::read(report_path) + .with_context(|| format!("reading race bot report {}", report_path.display()))?; + let actual_sha = format!("{:x}", Sha256::digest(&report)); + if actual_sha != expected_sha { + bail!("race bot report SHA-256 does not match its capture manifest"); + } + Ok(()) +} fn parse_decimal(path: &Path, key: &str, value: &str) -> Result where @@ -173,6 +227,7 @@ pub fn parse_rust_dump(dir: &Path) -> Result { let mut meta_paths = BTreeMap::::new(); let mut bin_paths = BTreeMap::::new(); + let mut race_bot_report_path = None; for entry in std::fs::read_dir(dir).with_context(|| format!("reading dump dir {}", dir.display()))? { @@ -190,7 +245,11 @@ pub fn parse_rust_dump(dir: &Path) -> Result { .file_name() .into_string() .map_err(|_| anyhow::anyhow!("rust dump contains a non-UTF-8 filename"))?; - if file_name == "rust.capture-manifest.json" { + if file_name == RUST_CAPTURE_MANIFEST_FILE { + continue; + } + if file_name == RACE_BOT_REPORT_FILE { + race_bot_report_path = Some(path); continue; } let (stem, paths) = if let Some(stem) = file_name.strip_suffix(".meta") { @@ -205,6 +264,10 @@ pub fn parse_rust_dump(dir: &Path) -> Result { } } + if let Some(report_path) = race_bot_report_path { + validate_race_bot_report_sidecar(dir, &report_path)?; + } + if meta_paths.is_empty() { bail!("no .meta files found in dump dir {}", dir.display()); } diff --git a/crates/capture-diff/tests/diff_engine.rs b/crates/capture-diff/tests/diff_engine.rs index 6a8b79a0d..1b170c823 100644 --- a/crates/capture-diff/tests/diff_engine.rs +++ b/crates/capture-diff/tests/diff_engine.rs @@ -9,6 +9,7 @@ use std::path::Path; use capture_diff::diff::{DiffReport, DivergenceKind, baseline_delta}; use capture_diff::model::{Capture, CapturedPacket, Direction, PacketBoundary}; use capture_diff::{pkt, rustdump}; +use sha2::{Digest, Sha256}; fn s2c(opcode: u16, body: &[u8]) -> CapturedPacket { CapturedPacket { @@ -474,6 +475,54 @@ fn rust_dump_rejects_orphans_extras_subdirectories_and_symlinks() { } } +#[test] +fn rust_dump_accepts_only_manifest_bound_race_bot_report_sidecar() { + let root = Path::new(env!("CARGO_TARGET_TMPDIR")).join("race_report_sidecar_contract"); + let _ = std::fs::remove_dir_all(&root); + write_dump_record(&root, "c2s", 0, 0, "1", 0x3211, "LootItem", &[0]); + let report_path = root.join("race.bot-report.json"); + let report = br#"{"loot_race_smoke":true}"#; + std::fs::write(&report_path, report).unwrap(); + let report_sha = format!("{:x}", Sha256::digest(report)); + let manifest = serde_json::json!({ + "flow": "loot-two-session-atomic-race", + "bot_report": { + "contract": "wow-test-bot-loot-two-session-atomic-race-report-v1", + "report_path": report_path.to_string_lossy(), + "report_sha256": report_sha + } + }); + std::fs::write( + root.join("rust.capture-manifest.json"), + serde_json::to_vec_pretty(&manifest).unwrap(), + ) + .unwrap(); + assert_eq!(rustdump::parse_rust_dump(&root).unwrap().packets.len(), 1); + + std::fs::write(&report_path, b"{}").unwrap(); + assert!( + rustdump::parse_rust_dump(&root) + .unwrap_err() + .to_string() + .contains("SHA-256") + ); + + std::fs::write(&report_path, report).unwrap(); + let mut wrong_flow = manifest; + wrong_flow["flow"] = serde_json::Value::String("login".to_string()); + std::fs::write( + root.join("rust.capture-manifest.json"), + serde_json::to_vec_pretty(&wrong_flow).unwrap(), + ) + .unwrap(); + assert!( + rustdump::parse_rust_dump(&root) + .unwrap_err() + .to_string() + .contains("matching race manifest contract") + ); +} + #[test] fn rust_dump_binds_metadata_filename_and_binary_length() { let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join("metadata_binding_contract"); diff --git a/crates/wow-database/src/statements/mod.rs b/crates/wow-database/src/statements/mod.rs index 534674bb6..4ada3307d 100644 --- a/crates/wow-database/src/statements/mod.rs +++ b/crates/wow-database/src/statements/mod.rs @@ -179,6 +179,20 @@ mod tests { .count(), 1 ); + // C++ ObjectMgr loads these unsigned columns directly. MariaDB + // promotes LEAST/GREATEST over them to NEWDECIMAL, which sqlx cannot + // decode as the u32 expected by the loot handlers and would silently + // turn deterministic money into zero through `try_read(...).unwrap_or(0)`. + // The item loader performs C++'s post-read min/max swap separately; + // gameobject addon bounds intentionally remain unnormalized. + let item_money_sql = WorldStatements::SEL_ITEM_TEMPLATE_ADDON_MONEY_LOOT.sql(); + assert!(item_money_sql.starts_with("SELECT MinMoneyLoot, MaxMoneyLoot ")); + assert!(!item_money_sql.contains("LEAST")); + assert!(!item_money_sql.contains("GREATEST")); + let gameobject_money_sql = WorldStatements::SEL_GAMEOBJECT_TEMPLATE_ADDON_MONEY_LOOT.sql(); + assert!(gameobject_money_sql.starts_with("SELECT mingold, maxgold ")); + assert!(!gameobject_money_sql.contains("LEAST")); + assert!(!gameobject_money_sql.contains("GREATEST")); assert_eq!( WorldStatements::SEL_ITEM_TEMPLATE_ADDON_LOOT_METADATA .sql() diff --git a/crates/wow-database/src/statements/world.rs b/crates/wow-database/src/statements/world.rs index 9e73c3dc3..9b9c62f6b 100644 --- a/crates/wow-database/src/statements/world.rs +++ b/crates/wow-database/src/statements/world.rs @@ -922,13 +922,11 @@ impl StatementDef for WorldStatements { "SELECT COALESCE(SellPrice, 0) FROM hotfixes.item_sparse WHERE ID = ? LIMIT 1" } Self::SEL_ITEM_TEMPLATE_ADDON_MONEY_LOOT => concat!( - "SELECT LEAST(COALESCE(MinMoneyLoot, 0), COALESCE(MaxMoneyLoot, 0)), ", - "GREATEST(COALESCE(MinMoneyLoot, 0), COALESCE(MaxMoneyLoot, 0)) ", + "SELECT MinMoneyLoot, MaxMoneyLoot ", "FROM item_template_addon WHERE Id = ? LIMIT 1", ), Self::SEL_GAMEOBJECT_TEMPLATE_ADDON_MONEY_LOOT => concat!( - "SELECT LEAST(COALESCE(mingold, 0), COALESCE(maxgold, 0)), ", - "GREATEST(COALESCE(mingold, 0), COALESCE(maxgold, 0)) ", + "SELECT mingold, maxgold ", "FROM gameobject_template_addon WHERE entry = ? LIMIT 1", ), Self::SEL_ITEM_TEMPLATE_ADDON_LOOT_METADATA => concat!( diff --git a/crates/wow-loot/src/lib.rs b/crates/wow-loot/src/lib.rs index 3c2f8fed2..6af372077 100644 --- a/crates/wow-loot/src/lib.rs +++ b/crates/wow-loot/src/lib.rs @@ -3443,6 +3443,10 @@ mod tests { generate_money_loot_with_rate_like_cpp(120, 100, 1.0, &mut rng), 100 ); + assert_eq!( + generate_money_loot_with_rate_like_cpp(10, 10, 1.0, &mut rng), + 10 + ); assert_eq!( generate_money_loot_with_rate_like_cpp(120, 100, 2.5, &mut rng), 250 diff --git a/crates/wow-world/src/handlers/loot.rs b/crates/wow-world/src/handlers/loot.rs index 83efcf418..0fd4a9567 100644 --- a/crates/wow-world/src/handlers/loot.rs +++ b/crates/wow-world/src/handlers/loot.rs @@ -7569,9 +7569,16 @@ impl WorldSession { match world_db.query(&stmt).await { Ok(result) if !result.is_empty() => { - let min_money = result.try_read::(0).unwrap_or(0); - let max_money = result.try_read::(1).unwrap_or(0); - (min_money, max_money) + match (result.try_read::(0), result.try_read::(1)) { + (Some(min_money), Some(max_money)) => (min_money, max_money), + _ => { + warn!( + gameobject_entry, + "failed to decode gameobject_template_addon money loot as C++ uint32 columns" + ); + (0, 0) + } + } } Ok(_) => (0, 0), Err(err) => { diff --git a/crates/wow-world/src/handlers/spell.rs b/crates/wow-world/src/handlers/spell.rs index bf4a9e715..3ff2d89da 100644 --- a/crates/wow-world/src/handlers/spell.rs +++ b/crates/wow-world/src/handlers/spell.rs @@ -84,6 +84,14 @@ fn spell_power_trace_enabled_like_cpp() -> bool { }) } +fn normalize_item_money_loot_bounds_like_cpp(min_money: u32, max_money: u32) -> (u32, u32) { + if min_money > max_money { + (max_money, min_money) + } else { + (min_money, max_money) + } +} + // ── Handler registrations ───────────────────────────────────────── inventory::submit! { @@ -1152,9 +1160,29 @@ impl WorldSession { match world_db.query(&stmt).await { Ok(result) if !result.is_empty() => { - let min_money = result.try_read::(0).unwrap_or(0); - let max_money = result.try_read::(1).unwrap_or(0); - (min_money, max_money) + match (result.try_read::(0), result.try_read::(1)) { + (Some(min_money), Some(max_money)) => { + if min_money > max_money { + // ObjectMgr::LoadItemTemplateAddon swaps invalid item + // bounds before storing the template. GameObject addon + // money deliberately does not share this normalization. + warn!( + item_entry, + min_money, + max_money, + "minimum item money loot exceeded maximum; swapping like C++" + ); + } + normalize_item_money_loot_bounds_like_cpp(min_money, max_money) + } + _ => { + warn!( + item_entry, + "failed to decode item_template_addon money loot as C++ uint32 columns" + ); + (0, 0) + } + } } Ok(_) => (0, 0), Err(err) => { @@ -2757,7 +2785,8 @@ mod tests { apply_wrapped_gift_transform_like_cpp, item_loot_quest_status_allows_like_cpp, loot_entry_flags_for_row_metadata_like_cpp, loot_template_group_row_can_roll_like_cpp, loot_template_plain_row_can_roll_like_cpp, loot_template_reference_row_can_roll_like_cpp, - player_class_mask_like_cpp, player_quest_status_mask_like_cpp, player_race_mask_like_cpp, + normalize_item_money_loot_bounds_like_cpp, player_class_mask_like_cpp, + player_quest_status_mask_like_cpp, player_race_mask_like_cpp, referenced_loot_max_count_like_cpp, roll_chance_with_rate_like_cpp, roll_group_loot_row_like_cpp, stored_item_row_can_load_like_cpp_representable, stored_loot_item_should_persist_like_cpp, @@ -5934,18 +5963,18 @@ mod tests { wow_loot::generate_money_loot_with_rate_like_cpp(0, 0, 1.0, &mut rng), 0 ); - assert_eq!( - wow_loot::generate_money_loot_with_rate_like_cpp(120, 100, 1.0, &mut rng), - 100 - ); + let swapped = normalize_item_money_loot_bounds_like_cpp(120, 100); + assert_eq!(swapped, (100, 120)); + let swapped_roll = + wow_loot::generate_money_loot_with_rate_like_cpp(swapped.0, swapped.1, 1.0, &mut rng); + assert!((100..=120).contains(&swapped_roll)); assert_eq!( wow_loot::generate_money_loot_with_rate_like_cpp(100, 100, 1.0, &mut rng), 100 ); - assert_eq!( - wow_loot::generate_money_loot_with_rate_like_cpp(120, 100, 2.5, &mut rng), - 250 - ); + let swapped_rate_roll = + wow_loot::generate_money_loot_with_rate_like_cpp(swapped.0, swapped.1, 2.5, &mut rng); + assert!((250..=300).contains(&swapped_rate_roll)); let small_range = wow_loot::generate_money_loot_with_rate_like_cpp(100, 200, 1.0, &mut rng); assert!((100..=200).contains(&small_range)); diff --git a/docs/operations/pr-preflight.md b/docs/operations/pr-preflight.md index bf426e7f0..a4d6dc89f 100644 --- a/docs/operations/pr-preflight.md +++ b/docs/operations/pr-preflight.md @@ -65,7 +65,16 @@ The bot writes its report to a fresh private path; exit status zero is accepted only when that report proves the exact two accounts, two successful logins, party/target/loot observations, exactly one item winner, both removal fanouts, money notifications of `10` and `0`, one persisted item, an exact persisted -money delta of `10`, and relog verification. +money delta of `10`, and relog verification. The preflight passes that same +fresh path plus the pinned bot executable/hash to the guarded capture wrapper. +After stopping the capture world and restoring the fixture and normal PM2 +profile, the wrapper independently revalidates the complete report contract; +missing, symlinked, malformed, failed, wrong-identity, split-runtime-target, or +wrong-bot evidence prevents atomic dump/manifest publication even though +cleanup and normal-runtime restoration still complete. A successful race +artifact retains a mode-0600 copy as `rust/race.bot-report.json`; the manifest +records its SHA-256 and final absolute path together with non-null race fixture +and bot-evidence contracts. The preflight creates a private `WOW_BOT_FIXTURE_JOURNAL` path and forces `WOW_BOT_ENSURE_TEST_ACCOUNTS=0`; loot QA therefore requires pre-provisioned diff --git a/tools/pr-preflight.sh b/tools/pr-preflight.sh index d5ef39614..52836e5fd 100755 --- a/tools/pr-preflight.sh +++ b/tools/pr-preflight.sh @@ -782,6 +782,7 @@ run_self_test() { local qa_fake_bot_marker local qa_fake_bot_sha local qa_fake_capture + local qa_fake_capture_publish_marker local qa_fake_world local qa_fake_world_other local qa_identity @@ -818,6 +819,7 @@ run_self_test() { local range_summary_inspection_result local rust_capture_text local rust_missing_pin_output + local rust_race_missing_bot_output local review_dry_run_output local staged_alias_inspection_result local -a qa_common_env=() @@ -1013,6 +1015,7 @@ run_self_test() { [[ "$qa_loot_race_dry_run_output" == *"capture-rust.sh loot-two-session-atomic-race --yes"* \ && "$qa_loot_race_dry_run_output" == *"RUST_CAPTURE_LOOT_FIXTURE_GUARD=1"* \ && "$qa_loot_race_dry_run_output" == *"RUST_CAPTURE_ACK_LOOT_FIXTURE_MUTATION=1"* \ + && "$qa_loot_race_dry_run_output" == *"WOW_BOT_REPORT=/tmp/rustycore-loot-race-qa/bot-report.json"* \ && "$qa_loot_race_dry_run_output" == *"RUST_CAPTURE_DB_CONF=/home/server/trinity-legacy-install/bin/worldserver.conf"* \ && "$qa_loot_race_dry_run_output" == *"RUST_CAPTURE_EFFECTIVE_CONFIG=/home/server/trinity-legacy-install/etc/worldserver.conf"* \ && "$qa_loot_race_dry_run_output" == *"wait for guarded capture READY marker"* \ @@ -1879,6 +1882,115 @@ run_self_test() { [ "$hash_a" != "$hash_c" ] || exit 109 ) || die "redacted effective-config hash self-test failed" + ( + # A race dump may publish only when a regular report from the exact pinned + # bot proves the complete two-session result. This is intentionally + # independent of the outer preflight's own report check. + # shellcheck source=crates/capture-diff/scripts/capture-service-common.sh + source "$REPO_ROOT/crates/capture-diff/scripts/capture-service-common.sh" + race_bot="$artifacts/race-evidence-bot" + race_report="$artifacts/race-evidence-valid.json" + race_invalid="$artifacts/race-evidence-invalid.json" + race_split_counter="$artifacts/race-evidence-split-counter.json" + race_report_link="$artifacts/race-evidence-link.json" + printf '#!/bin/sh\nexit 0\n' >"$race_bot" + chmod 700 "$race_bot" + jq -n ' + def result($account; $account_id; $character_guid; $item_push; $money): { + account: $account, + account_id: $account_id, + character_guid: $character_guid, + world_auth: true, + enum_characters: true, + player_login_verified: true, + loot_race_smoke: true, + loot_race_smoke_passed: true, + loot_race_failure: null, + loot_race_target_entry: 2846, + loot_race_target_spawn_guid: 9106001, + loot_race_target_runtime_counter: 40, + loot_race_party_confirmed: true, + loot_race_target_discovered: true, + loot_race_loot_opened: true, + loot_race_loot_list_id: 0, + loot_race_loot_coins: 10, + loot_race_item_push_seen: $item_push, + loot_race_loot_removed_seen: true, + loot_race_money_notify_amount: $money, + loot_race_coin_removed_seen: true, + loot_race_db_item_total: 1, + loot_race_db_money_delta: 10, + loot_race_relog_verified: true + }; + { + loot_race_smoke: true, + loot_item_capture: false, + results: [ + result("TESTBOT2@bot.local"; 9; 15; true; 10), + result("TESTBOT3@bot.local"; 10; 16; false; 0) + ] + } + ' >"$race_report" + race_bot_sha="$(capture_sha256_of_file "$race_bot")" || exit 179 + race_report_sha="$(capture_sha256_of_file "$race_report")" || exit 180 + race_evidence="$(capture_loot_race_bot_evidence \ + "$race_report" "$race_bot" "$race_bot_sha")" || exit 181 + [ "$race_evidence" \ + = "$race_bot"$'\t'"$race_bot_sha"$'\t'"$race_report"$'\t'"$race_report_sha" ] \ + || exit 182 + race_manifest_evidence="$(capture_bot_manifest_evidence \ + loot-two-session-atomic-race "$race_bot" "$race_bot_sha" \ + /target/captures/loot-two-session-atomic-race/rust/race.bot-report.json \ + "$race_report_sha")" || exit 186 + jq -e \ + --arg bot "$race_bot" \ + --arg bot_sha "$race_bot_sha" \ + --arg report_sha "$race_report_sha" ' + .fixture_guard != null + and .fixture_guard.enabled == true + and .fixture_guard.contract == "loot-two-session-atomic-race-fixture-v1" + and .fixture_guard.account == "TESTBOT2@bot.local" + and .fixture_guard.peer_account == "TESTBOT3@bot.local" + and .fixture_guard.gameobject_entry == 2846 + and .fixture_guard.gameobject_spawn_guid == 9106001 + and .fixture_guard.item_entry == 38 + and .fixture_guard.cleanup_verified == true + and .bot_report != null + and .bot_report.contract + == "wow-test-bot-loot-two-session-atomic-race-report-v1" + and .bot_report.exec_path == $bot + and .bot_report.exec_sha256 == $bot_sha + and .bot_report.report_path + == "/target/captures/loot-two-session-atomic-race/rust/race.bot-report.json" + and .bot_report.report_sha256 == $report_sha + and .bot_report.report_validated == true + ' <<<"$race_manifest_evidence" >/dev/null || exit 187 + jq -e '.fixture_guard == null and .bot_report == null' \ + <<<"$(capture_bot_manifest_evidence login '' '' '' '')" >/dev/null \ + || exit 188 + + jq '.results[0].loot_race_smoke_passed = false + | .results[0].loot_race_failure = "failure=17"' \ + "$race_report" >"$race_invalid" + if capture_loot_race_bot_evidence \ + "$race_invalid" "$race_bot" "$race_bot_sha" >/dev/null; then + exit 183 + fi + jq '.results[1].loot_race_target_runtime_counter = 41' \ + "$race_report" >"$race_split_counter" + if capture_loot_race_bot_evidence \ + "$race_split_counter" "$race_bot" "$race_bot_sha" >/dev/null; then + exit 184 + fi + ln -s "$race_report" "$race_report_link" + if capture_loot_race_bot_evidence \ + "$race_report_link" "$race_bot" "$race_bot_sha" >/dev/null \ + || capture_loot_race_bot_evidence \ + "$race_report" "$race_bot" "$(printf '0%.0s' {1..64})" >/dev/null; then + exit 185 + fi + ) || die "two-session race publication evidence self-test failed" + ( # C++ provenance must be derived from the PM2 profile/entrypoint, and a # profile selecting config B must never accredit caller-declared config A. @@ -2059,6 +2171,7 @@ run_self_test() { && "$rust_capture_text" == *"capture_validate_world_timeouts"* \ && "$rust_capture_text" == *'CAPTURE_STABLE_SAMPLES" -ge 4'* \ && "$rust_capture_text" == *"CAPTURE_BOT_READY=1"* \ + && "$rust_capture_text" == *"capture_loot_race_bot_evidence"* \ && "$rust_capture_text" == *"loot_fixture_bot_cleanup_safe_for_capture_state"* \ && "$rust_capture_text" == *"source_repo_head"* \ && "$rust_capture_text" == *"effective_config_redacted_sha256"* \ @@ -2077,6 +2190,20 @@ run_self_test() { fi [[ "$rust_missing_pin_output" == *"requires RUST_CAPTURE_EXEC and RUST_CAPTURE_EXEC_SHA256"* ]] || die \ "required Rust loot capture missing-pin error was not explicit" + if rust_race_missing_bot_output="$( + RUST_CAPTURE_LOOT_FIXTURE_GUARD=1 \ + RUST_CAPTURE_ACK_LOOT_FIXTURE_MUTATION=1 \ + RUST_CAPTURE_EXEC=/not-used-before-bot-validation \ + RUST_CAPTURE_EXEC_SHA256="$(printf '0%.0s' {1..64})" \ + RUST_CAPTURE_EFFECTIVE_CONFIG=/dev/null \ + "$BASH" "$REPO_ROOT/crates/capture-diff/scripts/capture-rust.sh" \ + loot-two-session-atomic-race --yes 2>&1 + )"; then + die "guarded Rust race capture accepted missing pinned bot report evidence" + fi + [[ "$rust_race_missing_bot_output" \ + == *"requires WOW_BOT_EXEC, WOW_BOT_EXEC_SHA256, and WOW_BOT_REPORT"* ]] || die \ + "guarded Rust race capture missing-bot-report error was not explicit" qa_fake_bin="$artifacts/qa-bin" qa_fake_world="$artifacts/fake-world-server" qa_fake_world_other="$artifacts/not-the-live-world-server" @@ -2156,9 +2283,9 @@ run_self_test() { 'esac' \ 'if [ -e "${QA_FAKE_PM2_JSON_FILE:-}.tmp" ]; then mv "${QA_FAKE_PM2_JSON_FILE}.tmp" "$QA_FAKE_PM2_JSON_FILE"; fi' \ 'if [ "${QA_FAKE_BOT_REPORT_MODE:-full}" = summary ]; then' \ - ' printf "%s\n" '\''{"loot_race_smoke":true,"loot_item_capture":false,"results":[{"account":"TESTBOT2@bot.local","world_auth":true,"enum_characters":true,"player_login_verified":true,"loot_race_smoke":true,"loot_race_smoke_passed":true,"loot_race_failure":null,"loot_race_party_confirmed":true,"loot_race_target_discovered":true,"loot_race_loot_opened":true,"loot_race_relog_verified":true},{"account":"TESTBOT3@bot.local","world_auth":true,"enum_characters":true,"player_login_verified":true,"loot_race_smoke":true,"loot_race_smoke_passed":true,"loot_race_failure":null,"loot_race_party_confirmed":true,"loot_race_target_discovered":true,"loot_race_loot_opened":true,"loot_race_relog_verified":true}]}'\'' >"$report"' \ + ' printf "%s\n" '\''{"loot_race_smoke":true,"loot_item_capture":false,"results":[{"account":"TESTBOT2@bot.local","account_id":9,"character_guid":15,"world_auth":true,"enum_characters":true,"player_login_verified":true,"loot_race_smoke":true,"loot_race_smoke_passed":true,"loot_race_failure":null,"loot_race_party_confirmed":true,"loot_race_target_discovered":true,"loot_race_loot_opened":true,"loot_race_relog_verified":true},{"account":"TESTBOT3@bot.local","account_id":10,"character_guid":16,"world_auth":true,"enum_characters":true,"player_login_verified":true,"loot_race_smoke":true,"loot_race_smoke_passed":true,"loot_race_failure":null,"loot_race_party_confirmed":true,"loot_race_target_discovered":true,"loot_race_loot_opened":true,"loot_race_relog_verified":true}]}'\'' >"$report"' \ 'else' \ - ' printf "%s\n" '\''{"loot_race_smoke":true,"loot_item_capture":false,"results":[{"account":"TESTBOT2@bot.local","world_auth":true,"enum_characters":true,"player_login_verified":true,"loot_race_smoke":true,"loot_race_smoke_passed":true,"loot_race_failure":null,"loot_race_target_entry":2846,"loot_race_target_spawn_guid":9106001,"loot_race_target_runtime_counter":12345,"loot_race_party_confirmed":true,"loot_race_target_discovered":true,"loot_race_loot_opened":true,"loot_race_loot_list_id":0,"loot_race_loot_coins":10,"loot_race_item_push_seen":true,"loot_race_loot_removed_seen":true,"loot_race_money_notify_amount":10,"loot_race_coin_removed_seen":true,"loot_race_db_item_total":1,"loot_race_db_money_delta":10,"loot_race_relog_verified":true},{"account":"TESTBOT3@bot.local","world_auth":true,"enum_characters":true,"player_login_verified":true,"loot_race_smoke":true,"loot_race_smoke_passed":true,"loot_race_failure":null,"loot_race_target_entry":2846,"loot_race_target_spawn_guid":9106001,"loot_race_target_runtime_counter":12345,"loot_race_party_confirmed":true,"loot_race_target_discovered":true,"loot_race_loot_opened":true,"loot_race_loot_list_id":0,"loot_race_loot_coins":10,"loot_race_item_push_seen":false,"loot_race_loot_removed_seen":true,"loot_race_money_notify_amount":0,"loot_race_coin_removed_seen":true,"loot_race_db_item_total":1,"loot_race_db_money_delta":10,"loot_race_relog_verified":true}]}'\'' >"$report"' \ + ' printf "%s\n" '\''{"loot_race_smoke":true,"loot_item_capture":false,"results":[{"account":"TESTBOT2@bot.local","account_id":9,"character_guid":15,"world_auth":true,"enum_characters":true,"player_login_verified":true,"loot_race_smoke":true,"loot_race_smoke_passed":true,"loot_race_failure":null,"loot_race_target_entry":2846,"loot_race_target_spawn_guid":9106001,"loot_race_target_runtime_counter":12345,"loot_race_party_confirmed":true,"loot_race_target_discovered":true,"loot_race_loot_opened":true,"loot_race_loot_list_id":0,"loot_race_loot_coins":10,"loot_race_item_push_seen":true,"loot_race_loot_removed_seen":true,"loot_race_money_notify_amount":10,"loot_race_coin_removed_seen":true,"loot_race_db_item_total":1,"loot_race_db_money_delta":10,"loot_race_relog_verified":true},{"account":"TESTBOT3@bot.local","account_id":10,"character_guid":16,"world_auth":true,"enum_characters":true,"player_login_verified":true,"loot_race_smoke":true,"loot_race_smoke_passed":true,"loot_race_failure":null,"loot_race_target_entry":2846,"loot_race_target_spawn_guid":9106001,"loot_race_target_runtime_counter":12345,"loot_race_party_confirmed":true,"loot_race_target_discovered":true,"loot_race_loot_opened":true,"loot_race_loot_list_id":0,"loot_race_loot_coins":10,"loot_race_item_push_seen":false,"loot_race_loot_removed_seen":true,"loot_race_money_notify_amount":0,"loot_race_coin_removed_seen":true,"loot_race_db_item_total":1,"loot_race_db_money_delta":10,"loot_race_relog_verified":true}]}'\'' >"$report"' \ 'fi' \ 'printf "%s\n" fake-bot-only >"${QA_FAKE_BOT_MARKER:?}"' \ 'if [ "${QA_FAKE_BOT_MUTATION:-}" != drift ]; then' \ @@ -2167,8 +2294,8 @@ run_self_test() { 'fi' \ >"$qa_fake_bot" printf '%s\n' \ - '#!/bin/sh' \ - 'set -eu' \ + '#!/usr/bin/env bash' \ + 'set -euo pipefail' \ '[ "${1:-}" = loot-two-session-atomic-race ] && [ "${2:-}" = --yes ]' \ '[ "${RUST_CAPTURE_LOOT_FIXTURE_GUARD:-}" = 1 ]' \ '[ "${RUST_CAPTURE_ACK_LOOT_FIXTURE_MUTATION:-}" = 1 ]' \ @@ -2178,6 +2305,11 @@ run_self_test() { '[ "${PM2_RUST_WORLD:?}" = "${QA_FAKE_PROCESS_NAME:?}" ]' \ '[ "${RUST_WORLD_PORT:?}" = "${QA_FAKE_WORLD_PORT:?}" ]' \ '[ "${RUST_INSTANCE_PORT:?}" = "${QA_FAKE_INSTANCE_PORT:?}" ]' \ + 'report="${WOW_BOT_REPORT:?}"' \ + '[[ "$report" = /* && "$report" != *$'\''\n'\''* ]]' \ + '[ -d "$(dirname -- "$report")" ] && [ ! -L "$(dirname -- "$report")" ]' \ + '[ ! -e "$report" ] && [ ! -L "$report" ]' \ + 'source "${QA_FAKE_CAPTURE_COMMON:?}"' \ 'journal="${WOW_BOT_FIXTURE_JOURNAL:?}"' \ 'capture_pid=""' \ 'capture_bot_ready=0' \ @@ -2213,6 +2345,11 @@ run_self_test() { 'capture_bot_ready=1' \ 'printf "%s\n" ">>> Perform the '\''loot-two-session-atomic-race'\'' flow with the client now."' \ 'IFS= read -r _' \ + 'if ! capture_loot_race_bot_evidence "$report" "$WOW_BOT_EXEC" "$WOW_BOT_EXEC_SHA256" >/dev/null; then' \ + ' printf "%s\n" "fake guarded capture refused publication without exact pinned race report" >&2' \ + ' exit 75' \ + 'fi' \ + 'printf "%s\n" published >"${QA_FAKE_CAPTURE_PUBLISH_MARKER:?}"' \ 'exit "${QA_FAKE_CAPTURE_EXIT_STATUS:-0}"' \ >"$qa_fake_capture" chmod +x "$qa_fake_bin/mysql" "$qa_fake_bin/pm2" "$qa_fake_bin/ss" \ @@ -2231,6 +2368,7 @@ run_self_test() { "cannot hash temporary target-world self-test fixture" qa_fake_bot_sha="$(sha256_of_file "$qa_fake_bot")" || die \ "cannot hash fake loot-race bot" + qa_fake_capture_publish_marker="$artifacts/fake-loot-race-capture-published" qa_pm2_online="$(jq -cn \ --arg name self-test-world \ @@ -2365,9 +2503,11 @@ run_self_test() { "WOW_BOT_REPORT=$artifacts/fake-loot-race-report.json" "WOW_BOT_LOG=$artifacts/fake-loot-race.log" "QA_FAKE_BOT_MARKER=$qa_fake_bot_marker" + "QA_FAKE_CAPTURE_COMMON=$REPO_ROOT/crates/capture-diff/scripts/capture-service-common.sh" + "QA_FAKE_CAPTURE_PUBLISH_MARKER=$qa_fake_capture_publish_marker" ) - rm -f "$qa_fake_bot_marker" "$qa_pm2_state_file" + rm -f "$qa_fake_bot_marker" "$qa_fake_capture_publish_marker" "$qa_pm2_state_file" printf '%s\n' "$qa_pm2_online" >"$qa_pm2_json_file" qa_early_output="$( ( @@ -2383,7 +2523,7 @@ run_self_test() { && "$qa_early_output" == *"fake guarded capture restored normal PM2 profile"* ]] || die \ "pre-ready wrapper failure did not restore safely and preserve its status" - rm -f "$qa_fake_bot_marker" "$qa_pm2_state_file" + rm -f "$qa_fake_bot_marker" "$qa_fake_capture_publish_marker" "$qa_pm2_state_file" printf '%s\n' "$qa_pm2_online" >"$qa_pm2_json_file" qa_positive_output="$( ( @@ -2399,10 +2539,11 @@ run_self_test() { } [[ "$(<"$qa_fake_bot_marker")" == fake-bot-only ]] || die \ "guarded capture QA self-test did not execute only the fake bot" - [[ "$qa_positive_output" == *"fake guarded capture restored normal PM2 profile"* ]] || die \ - "guarded capture QA self-test did not run the bot and wait for restoration" + [[ "$(<"$qa_fake_capture_publish_marker")" == published \ + && "$qa_positive_output" == *"fake guarded capture restored normal PM2 profile"* ]] || die \ + "guarded capture QA self-test did not gate publication on the exact report and restore" - rm -f "$qa_fake_bot_marker" "$qa_pm2_state_file" + rm -f "$qa_fake_bot_marker" "$qa_fake_capture_publish_marker" "$qa_pm2_state_file" printf '%s\n' "$qa_pm2_online" >"$qa_pm2_json_file" if qa_summary_output="$( ( @@ -2415,10 +2556,11 @@ run_self_test() { )"; then die "guarded capture QA accepted a summary-only bot report" fi - [[ -f "$qa_fake_bot_marker" \ + [[ -f "$qa_fake_bot_marker" && ! -e "$qa_fake_capture_publish_marker" \ && "$qa_summary_output" == *"report did not prove the exact successful two-session contract"* \ + && "$qa_summary_output" == *"refused publication without exact pinned race report"* \ && "$qa_summary_output" == *"fake guarded capture restored normal PM2 profile"* ]] || die \ - "summary-only bot report self-test did not fail closed and restore PM2" + "summary-only bot report self-test did not block publication and restore PM2" rm -f "$qa_fake_bot_marker" "$qa_pm2_state_file" printf '%s\n' "$qa_pm2_online" >"$qa_pm2_json_file" @@ -2710,6 +2852,7 @@ run_qa_loot_race() { if ((DRY_RUN)); then fixture_journal="/tmp/rustycore-loot-race-qa/fixture.journal" + bot_report="/tmp/rustycore-loot-race-qa/bot-report.json" printf '+ snapshot the pinned PM2 world, then start the guarded capture world via FIFO/background\n' run_cmd env \ PM2_RUST_WORLD="$process_name" \ @@ -2724,6 +2867,7 @@ run_qa_loot_race() { RUST_CAPTURE_EFFECTIVE_CONFIG="$effective_config" \ WOW_BOT_EXEC="$bot_exec" \ WOW_BOT_EXEC_SHA256="$bot_expected_sha" \ + WOW_BOT_REPORT="$bot_report" \ WOW_BOT_FIXTURE_JOURNAL="$fixture_journal" \ "$capture_script" loot-two-session-atomic-race --yes printf '+ wait for guarded capture READY marker and accredit its exact PID/executable/listeners\n' @@ -2843,6 +2987,7 @@ run_qa_loot_race() { RUST_CAPTURE_EFFECTIVE_CONFIG="$effective_config" \ WOW_BOT_EXEC="$bot_exec" \ WOW_BOT_EXEC_SHA256="$bot_expected_sha" \ + WOW_BOT_REPORT="$bot_report" \ WOW_BOT_FIXTURE_JOURNAL="$fixture_journal" \ "$capture_script" loot-two-session-atomic-race --yes \ <&"$QA_LOOT_RACE_CAPTURE_FD" >"$QA_LOOT_RACE_CAPTURE_LOG" 2>&1 & @@ -2894,8 +3039,9 @@ run_qa_loot_race() { elif ! jq -e ' .loot_race_smoke == true and .loot_item_capture == false - and (.results | length == 2) - and ([.results[].account] | sort == ["TESTBOT2@bot.local", "TESTBOT3@bot.local"]) + and (.results | type == "array" and length == 2) + and ([.results[] | [.account, .account_id, .character_guid]] | sort + == [["TESTBOT2@bot.local", 9, 15], ["TESTBOT3@bot.local", 10, 16]]) and all(.results[]; .world_auth == true and .enum_characters == true @@ -2916,6 +3062,7 @@ run_qa_loot_race() { and .loot_race_db_item_total == 1 and .loot_race_db_money_delta == 10 and .loot_race_relog_verified == true) + and ([.results[].loot_race_target_runtime_counter] | unique | length == 1) and ([.results[].loot_race_loot_list_id] | unique | length == 1) and ([.results[] | select(.loot_race_item_push_seen == true)] | length == 1) and ([.results[] | select(.loot_race_item_push_seen == false)] | length == 1) diff --git a/tools/wow-test-bot/README.md b/tools/wow-test-bot/README.md index b23c2e56c..2b1ee06e5 100644 --- a/tools/wow-test-bot/README.md +++ b/tools/wow-test-bot/README.md @@ -194,8 +194,12 @@ After its ready marker, it accredits the new capture PID against the pinned target path/hash and world/instance listeners. That identity must not restart or change during the bot run. The preflight then signals completion, waits for the capture wrapper to stop the QA world and restore the fixture plus original PM2 -profile, and accredits the restored original executable again. Bot failure, -capture drift, or wrapper cleanup failure all remain failures. +profile, and accredits the restored original executable again. The wrapper gets +the same fresh `WOW_BOT_REPORT` path and pinned bot path/hash and independently +requires the exact successful two-session report before it can publish a +completed capture. A failed report therefore still drives fixture/runtime +cleanup but leaves no completed race artifact. Bot failure, capture drift, or +wrapper cleanup failure all remain failures. The bot endpoints are forced to `WORLD_HOST=127.0.0.1`, the accredited world port, `INSTANCE_HOST=127.0.0.1`, and an expected `INSTANCE_PORT`; the bot rejects From 72c5967dad248f869b2d740b55e885ce51db1a56 Mon Sep 17 00:00:00 2001 From: cDmonio Date: Sun, 19 Jul 2026 14:48:39 +0000 Subject: [PATCH 04/14] [08.5] Require relog for single-item capture evidence --- tools/wow-test-bot/src/loot_race.rs | 118 +++++++++++++++++++++++++++- tools/wow-test-bot/src/main.rs | 21 ++++- 2 files changed, 137 insertions(+), 2 deletions(-) diff --git a/tools/wow-test-bot/src/loot_race.rs b/tools/wow-test-bot/src/loot_race.rs index 8b8905bc4..f7e08e012 100644 --- a/tools/wow-test-bot/src/loot_race.rs +++ b/tools/wow-test-bot/src/loot_race.rs @@ -1225,7 +1225,7 @@ pub(super) async fn run_single_item_capture_workflow( killer_character_guid: fixture.characters[0].bot.character_guid, target: fixture.target.clone(), timeout_secs: cli.timeout_secs, - sync, + sync: Arc::clone(&sync), }; let run = tokio::select! { _ = shutdown.cancelled() => Err(anyhow!("loot-item capture received SIGINT/SIGTERM")), @@ -1306,6 +1306,105 @@ pub(super) async fn run_single_item_capture_workflow( } } + if result + .as_ref() + .is_some_and(|result| result.loot_race_smoke_passed == Some(true)) + { + let relog_options = LootRaceOptions { + phase: LootRacePhase::VerifyRelog, + participant: 0, + character_guid: fixture.characters[0].bot.character_guid, + peer_name: fixture.characters[1].name.clone(), + peer_character_guid: fixture.characters[1].bot.character_guid, + killer_character_guid: fixture.characters[0].bot.character_guid, + target: fixture.target.clone(), + timeout_secs: cli.timeout_secs, + sync, + }; + let relog = tokio::select! { + _ = shutdown.cancelled() => { + Err(anyhow!("loot-item capture relog cancelled by SIGINT/SIGTERM")) + } + relog = tokio::time::timeout_at(workflow_deadline, run_bot( + bots[0].clone(), + dungeon_id, + lfg_secs, + auto_teleport, + false, + None, + None, + None, + None, + None, + Some(relog_options), + None, + )) => match relog { + Ok(relog) => relog, + Err(_) => Err(anyhow!( + "loot-item capture relog exceeded the {}s end-to-end deadline", + cli.workflow_deadline_secs + )), + }, + }; + match relog { + Ok(relog) if relog.loot_race_relog_verified => { + let fixture_for_verification = fixture.clone(); + let persisted_after_relog = tokio::task::spawn_blocking(move || { + verify_single_item_capture_persistence(&fixture_for_verification) + }) + .await; + let result = result + .as_mut() + .expect("successful capture result remains available for relog merge"); + result.world_auth &= relog.world_auth; + result.enum_characters &= relog.enum_characters; + result.player_login_verified &= relog.player_login_verified; + result.seen_opcodes.extend(relog.seen_opcodes); + match persisted_after_relog { + Ok(Ok(item_total)) if Some(item_total) == result.loot_race_db_item_total => { + result.loot_race_relog_verified = true; + } + Ok(Ok(item_total)) => { + result.loot_race_smoke_passed = Some(false); + result.loot_race_failure = Some(format!( + "loot-item capture persisted item total changed across relog: before {:?}, after {item_total}", + result.loot_race_db_item_total + )); + } + Ok(Err(error)) => { + result.loot_race_smoke_passed = Some(false); + result.loot_race_failure = Some(format!( + "loot-item capture post-relog persistence verification failed: {error}" + )); + } + Err(error) => { + result.loot_race_smoke_passed = Some(false); + result.loot_race_failure = Some(format!( + "loot-item capture post-relog DB worker join failed: {error}" + )); + } + } + } + Ok(_) => { + let result = result + .as_mut() + .expect("successful capture result remains available for relog failure"); + result.loot_race_smoke_passed = Some(false); + result.loot_race_failure = Some( + "loot-item capture relog did not verify a clean logout/login cycle".into(), + ); + } + Err(error) => { + let result = result + .as_mut() + .expect("successful capture result remains available for relog error"); + result.loot_race_smoke_passed = Some(false); + result.loot_race_failure = Some(error.to_string()); + } + } + } + + let deadline_exceeded_before_cleanup = tokio::time::Instant::now() >= workflow_deadline; let fixture_for_cleanup = fixture.clone(); let cleanup = tokio::task::spawn_blocking(move || cleanup_fixture(&fixture_for_cleanup)) .await @@ -1314,6 +1413,23 @@ pub(super) async fn run_single_item_capture_workflow( bail!("loot-item capture fixture cleanup failed: {error:#}"); } + let workflow_failure = if shutdown.is_cancelled() { + Some("loot-item capture was cancelled before end-to-end verification completed") + } else if deadline_exceeded_before_cleanup { + Some("loot-item capture exceeded its end-to-end deadline during final verification") + } else { + None + }; + if let Some(failure) = workflow_failure { + let result = result + .as_mut() + .expect("successful bot run remains available after cleanup"); + result.loot_race_smoke_passed = Some(false); + result + .loot_race_failure + .get_or_insert_with(|| failure.to_owned()); + } + Ok(vec![result.expect("successful bot run remains available")]) } diff --git a/tools/wow-test-bot/src/main.rs b/tools/wow-test-bot/src/main.rs index 5e779e7f2..de598b5b2 100644 --- a/tools/wow-test-bot/src/main.rs +++ b/tools/wow-test-bot/src/main.rs @@ -266,6 +266,7 @@ struct CliOptions { } #[derive(Debug, Clone, Serialize)] +#[cfg_attr(test, derive(Default))] struct BotRunResult { account: String, account_id: u32, @@ -436,7 +437,8 @@ impl BotRunResult { return self.world_auth && self.enum_characters && self.player_login_verified - && self.loot_race_smoke_passed.unwrap_or(false); + && self.loot_race_smoke_passed.unwrap_or(false) + && self.loot_race_relog_verified; } if login_only { return self.world_auth && self.enum_characters && self.player_login_verified; @@ -10362,6 +10364,23 @@ fn build_packed_guid(low: u64, high: u64) -> Vec { mod tests { use super::*; + #[test] + fn loot_result_requires_verified_relog_for_success() { + let mut result = BotRunResult { + world_auth: true, + enum_characters: true, + player_login_verified: true, + loot_race_smoke: true, + loot_race_smoke_passed: Some(true), + ..BotRunResult::default() + }; + + assert!(!result.success(false, false, false)); + + result.loot_race_relog_verified = true; + assert!(result.success(false, false, false)); + } + #[test] fn login_verify_budget_is_time_based_not_packet_count() { let budget = LoginVerifyBudget::new(Duration::from_secs(1)); From 56622e39fafb661e082156688019409451022e1c Mon Sep 17 00:00:00 2001 From: cDmonio Date: Sun, 19 Jul 2026 15:06:18 +0000 Subject: [PATCH 05/14] [08.5] Record clean single-item loot capture --- .../capture-lineage.json | 176 ++++++++ .../capture-provenance/cpp.bot-report.json | 387 ++++++++++++++++++ .../cpp.capture-manifest.json | 65 +++ .../capture-provenance/rust.bot-report.json | 290 +++++++++++++ .../rust.capture-manifest.json | 65 +++ .../loot-single-item-claim/requirement.json | 3 +- 6 files changed, 984 insertions(+), 2 deletions(-) create mode 100644 crates/capture-diff/flows/loot-single-item-claim/capture-lineage.json create mode 100644 crates/capture-diff/flows/loot-single-item-claim/capture-provenance/cpp.bot-report.json create mode 100644 crates/capture-diff/flows/loot-single-item-claim/capture-provenance/cpp.capture-manifest.json create mode 100644 crates/capture-diff/flows/loot-single-item-claim/capture-provenance/rust.bot-report.json create mode 100644 crates/capture-diff/flows/loot-single-item-claim/capture-provenance/rust.capture-manifest.json diff --git a/crates/capture-diff/flows/loot-single-item-claim/capture-lineage.json b/crates/capture-diff/flows/loot-single-item-claim/capture-lineage.json new file mode 100644 index 000000000..223c19633 --- /dev/null +++ b/crates/capture-diff/flows/loot-single-item-claim/capture-lineage.json @@ -0,0 +1,176 @@ +{ + "version": 3, + "flow": "loot-single-item-claim", + "completed": true, + "sources": { + "cpp": { + "manifest_path": "capture-provenance/cpp.capture-manifest.json", + "manifest_sha256": "8bd4e2e5afa0969a50852f21d01cdd5f9e48508ddabc01c3ea77031aa377ff8c", + "raw_artifact_sha256": "a25f2c2bbf60de6cda7e32f305d732733017e711eb474dd5dbf6e007690143a8", + "raw_artifact_size": 111420, + "raw_packet_count": null, + "harness_repo_head": "72c5967dad248f869b2d740b55e885ce51db1a56", + "source_repo_head": "5100ce3d8fc6150feedf902e62512760ec03ea92", + "harness_worktree_clean": true, + "harness_worktree_state_sha256": "7f1df5ac739db5b1f58a4a49a9950d9e88c7fbcf5d6efdac91368ed5da32d052", + "source_worktree_dirty": true, + "source_worktree_state_sha256": "3eeb18895c18215d381c7dc13cab516bb06e0441be2901e92c05d1845107afb1", + "worktree_state_algorithm": "git-head-path-mode-content-sha256-v1", + "expected_exec_path": "/home/server/trinity-legacy-install/bin/worldserver", + "expected_exec_sha256": "189ac0ef6a5fef0fe3033a9844e2251523a442b746734cbcfd667277bdf99603", + "source_exec_path": "/home/server/trinity-legacy-install/bin/worldserver", + "source_exec_sha256": "189ac0ef6a5fef0fe3033a9844e2251523a442b746734cbcfd667277bdf99603", + "live_exec_path": "/home/server/trinity-legacy-install/bin/worldserver", + "live_exec_sha256": "189ac0ef6a5fef0fe3033a9844e2251523a442b746734cbcfd667277bdf99603", + "executable_pin_enforced": true, + "pm2_entry_pid": 881529, + "pm2_entry_starttime": 70890932, + "pm2_exec_path": "/home/server/trinity-legacy-install/bin/worldserver-wrapper.sh", + "pm2_exec_sha256": "41c0e84f282bf5711a2d4b433a054426d28de9875cfce1b3f7fcfe6c576df213", + "pm2_profile_redacted_sha256": "5de156ef078858c9d979e6bcc274d5d06e4d44588c273357fbf3f34dc235aca0", + "listener_runtime_pid": 881531, + "listener_runtime_starttime": 70890933, + "listener_relationship_verified": true, + "restart_count": 0, + "effective_config_path": "/home/server/trinity-legacy-install/bin/worldserver.conf", + "effective_config_redacted_sha256": "8d55b563745586a1d0014a1ee19704ec018e2097fd566116fec99090434c2242", + "effective_config_algorithm": "capture-relevant-redacted-v1", + "runtime_cleanup_verified": true, + "normal_runtime_restored": true, + "fixture_guard": { + "enabled": true, + "contract": "loot-single-item-claim-fixture-v1", + "account": "TESTBOT2@bot.local", + "account_id": 9, + "character_guid": 15, + "peer_account": "TESTBOT3@bot.local", + "peer_account_id": 10, + "peer_character_guid": 16, + "creature_entry": 21779, + "creature_spawn_guid": 1117, + "item_entry": 30712, + "cleanup_verified": true + }, + "bot_report": { + "contract": "wow-test-bot-loot-item-capture-report-v1", + "exec_path": "/home/server/rustycore-issue-106/tools/wow-test-bot/target/debug/wow-test-bot", + "exec_sha256": "da98cd7f0a884fdbc1cd7f971d567c50143984d0430d7aba1b14a00d84a900ad", + "report_path": "/tmp/rustycore-loot-item-cpp.rlOxSn/bot-report.json", + "report_sha256": "e546d8019eaec3ce2850a8f58f837ee0335b0fb8e1bc2f28283c0d9953a34784", + "account": "TESTBOT2@bot.local", + "account_id": 9, + "character_guid": 15, + "report_validated": true + }, + "retained_bot_report_path": "capture-provenance/cpp.bot-report.json" + }, + "rust": { + "manifest_path": "capture-provenance/rust.capture-manifest.json", + "manifest_sha256": "95f5dce92ad6ecf67268a7e8b7db3526dea6f9a8d7c2a3dda2a292a80cc0ca31", + "raw_artifact_sha256": "5fe815b89f622bddd862232d7e00d288c569655872d02b4760220fd8a2eef818", + "raw_artifact_size": null, + "raw_packet_count": 191, + "harness_repo_head": "72c5967dad248f869b2d740b55e885ce51db1a56", + "source_repo_head": "72c5967dad248f869b2d740b55e885ce51db1a56", + "harness_worktree_clean": true, + "harness_worktree_state_sha256": "7f1df5ac739db5b1f58a4a49a9950d9e88c7fbcf5d6efdac91368ed5da32d052", + "source_worktree_dirty": false, + "source_worktree_state_sha256": "7f1df5ac739db5b1f58a4a49a9950d9e88c7fbcf5d6efdac91368ed5da32d052", + "worktree_state_algorithm": "git-head-path-mode-content-sha256-v1", + "expected_exec_path": "/home/server/rustycore-issue-106/target/issue106-release/release/world-server", + "expected_exec_sha256": "6234630546446b115a74014b3ced1d99d1c9d9946a136e5171f1a3dd01a2cba5", + "source_exec_path": "/home/server/rustycore-issue-106/target/issue106-release/release/world-server", + "source_exec_sha256": "6234630546446b115a74014b3ced1d99d1c9d9946a136e5171f1a3dd01a2cba5", + "live_exec_path": "/home/server/rustycore-issue-106/target/issue106-release/release/world-server", + "live_exec_sha256": "6234630546446b115a74014b3ced1d99d1c9d9946a136e5171f1a3dd01a2cba5", + "executable_pin_enforced": true, + "pm2_entry_pid": 885094, + "pm2_entry_starttime": 70907497, + "pm2_exec_path": "/home/server/rustycore-issue-106/target/issue106-release/release/world-server", + "pm2_exec_sha256": "6234630546446b115a74014b3ced1d99d1c9d9946a136e5171f1a3dd01a2cba5", + "pm2_profile_redacted_sha256": "546d60f16e924203996a1a738cd9013d152eda7fb67e7bbb107d64792716e195", + "listener_runtime_pid": 885094, + "listener_runtime_starttime": 70907497, + "listener_relationship_verified": true, + "restart_count": 0, + "effective_config_path": "/home/server/trinity-legacy-install/etc/worldserver.conf", + "effective_config_redacted_sha256": "5e3f4e1ff0b296932b6d620f5a71ce03d13137f494e4812db8b9623dde0f5606", + "effective_config_algorithm": "capture-relevant-redacted-v1", + "runtime_cleanup_verified": true, + "normal_runtime_restored": true, + "fixture_guard": { + "enabled": true, + "contract": "loot-single-item-claim-fixture-v1", + "account": "TESTBOT2@bot.local", + "account_id": 9, + "character_guid": 15, + "peer_account": "TESTBOT3@bot.local", + "peer_account_id": 10, + "peer_character_guid": 16, + "creature_entry": 21779, + "creature_spawn_guid": 1117, + "item_entry": 30712, + "cleanup_verified": true + }, + "bot_report": { + "contract": "wow-test-bot-loot-item-capture-report-v1", + "exec_path": "/home/server/rustycore-issue-106/tools/wow-test-bot/target/debug/wow-test-bot", + "exec_sha256": "da98cd7f0a884fdbc1cd7f971d567c50143984d0430d7aba1b14a00d84a900ad", + "report_path": "/tmp/rustycore-loot-item-rust.CA4pL6/bot-report.json", + "report_sha256": "7c93aac0aebde59248716a6e593ee9a80c055411e256a9c63b4a249d953c7f40", + "account": "TESTBOT2@bot.local", + "account_id": 9, + "character_guid": 15, + "report_validated": true + }, + "retained_bot_report_path": "capture-provenance/rust.bot-report.json" + } + }, + "selection": { + "directions": [ + "s2c", + "c2s" + ], + "from_opcode": { + "direction": "c2s", + "opcode": 12817 + }, + "until_opcode": { + "direction": "c2s", + "opcode": 14184 + }, + "ignored_opcodes": [ + { + "direction": "s2c", + "opcode": 11730 + }, + { + "direction": "c2s", + "opcode": 14909 + }, + { + "direction": "s2c", + "opcode": 11732 + } + ], + "strict": true + }, + "outputs": { + "cpp_pkt": { + "path": "cpp.pkt", + "size": 795, + "sha256": "a84abf8f1d067fc68a9fdbe1608243479b0740a703b07444097ab5af0aebf487" + }, + "rust": { + "path": "rust", + "file_count": 12, + "packet_count": 6, + "tree_sha256": "afb4de111efb401703c4828a73018f9e878d1c52e6e2544868241435c705f795" + }, + "expected_divergences": { + "path": "expected-divergences.json", + "size": 2, + "sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945" + } + } +} \ No newline at end of file diff --git a/crates/capture-diff/flows/loot-single-item-claim/capture-provenance/cpp.bot-report.json b/crates/capture-diff/flows/loot-single-item-claim/capture-provenance/cpp.bot-report.json new file mode 100644 index 000000000..2070f0142 --- /dev/null +++ b/crates/capture-diff/flows/loot-single-item-claim/capture-provenance/cpp.bot-report.json @@ -0,0 +1,387 @@ +{ + "dungeon_id": 220, + "timeout_secs": 60, + "require_proposal": true, + "require_group": false, + "auto_teleport": false, + "login_only": false, + "stand_state_smoke": false, + "bank_smoke": false, + "homebind_smoke": false, + "inventory_swap_smoke": false, + "rested_xp_smoke": false, + "loot_race_smoke": false, + "loot_item_capture": true, + "quest_smoke": false, + "results": [ + { + "account": "TESTBOT2@bot.local", + "account_id": 9, + "character_guid": 15, + "dungeon_id": 220, + "role": 4, + "join_result": null, + "join_detail": null, + "got_proposal": false, + "accepted_proposal": false, + "got_ready_check": false, + "group_formed": false, + "teleport_denied_reason": null, + "entered_world": false, + "world_auth": true, + "enum_characters": true, + "player_login_verified": true, + "login_only": false, + "stand_state_smoke": false, + "stand_state_smoke_passed": null, + "stand_states_requested": [], + "stand_states_confirmed": [], + "stand_state_failure": null, + "bank_smoke": false, + "bank_smoke_passed": null, + "bank_banker_entry": null, + "bank_banker_spawn_guid": null, + "bank_banker_guid_counter": null, + "bank_item_guid": null, + "bank_item_entry": null, + "bank_inventory_slot": null, + "bank_bank_slot": null, + "bank_open_confirmed": false, + "bank_deposit_persisted": false, + "bank_relogin_after_deposit": false, + "bank_withdraw_persisted": false, + "bank_failure": null, + "homebind_smoke": false, + "homebind_smoke_passed": null, + "homebind_innkeeper_entry": null, + "homebind_innkeeper_spawn_guid": null, + "homebind_innkeeper_guid_counter": null, + "homebind_spell_go_seen": false, + "homebind_bind_point_update_seen": false, + "homebind_player_bound_seen": false, + "homebind_gossip_complete_seen": false, + "homebind_db_persisted": false, + "homebind_relogin_verified": false, + "homebind_failure": null, + "inventory_swap_smoke": false, + "inventory_swap_smoke_passed": null, + "inventory_swap_item_guid_a": null, + "inventory_swap_item_guid_b": null, + "inventory_swap_item_entry_a": null, + "inventory_swap_item_entry_b": null, + "inventory_swap_slot_a": null, + "inventory_swap_slot_b": null, + "inventory_swap_forward_persisted": false, + "inventory_swap_relogin_after_forward": false, + "inventory_swap_reverse_persisted": false, + "inventory_swap_failure": null, + "rested_xp_smoke": false, + "rested_xp_smoke_passed": null, + "rested_xp_offline_wilderness_bonus": null, + "rested_xp_offline_resting_bonus": null, + "rested_xp_target_entry": null, + "rested_xp_target_spawn_guid": null, + "rested_xp_target_guid_counter": null, + "rested_xp_packet_amount": null, + "rested_xp_packet_original": null, + "rested_xp_db_xp_before": null, + "rested_xp_db_xp_after": null, + "rested_xp_db_rest_before": null, + "rested_xp_db_rest_after": null, + "rested_xp_relog_verified": false, + "rested_xp_failure": null, + "loot_race_smoke": true, + "loot_race_smoke_passed": true, + "loot_race_target_entry": 21779, + "loot_race_target_spawn_guid": 1117, + "loot_race_target_runtime_counter": 268, + "loot_race_party_confirmed": false, + "loot_race_target_discovered": true, + "loot_race_loot_opened": true, + "loot_race_loot_list_id": 0, + "loot_race_loot_coins": 0, + "loot_race_item_push_seen": true, + "loot_race_loot_removed_seen": true, + "loot_race_money_notify_amount": null, + "loot_race_coin_removed_seen": false, + "loot_race_db_item_total": 1, + "loot_race_db_money_delta": 0, + "loot_race_relog_verified": true, + "loot_race_failure": null, + "quest_smoke": false, + "quest_smoke_passed": null, + "quest_target_entry": null, + "quest_target_spawn_guid": null, + "quest_target_guid_counter": null, + "quest_target_map_id": null, + "quest_gossip_hello_sent": false, + "quest_questgiver_hello_sent": false, + "quest_gossip_id_seen": null, + "quest_gossip_select_sent": false, + "quest_gossip_message_seen": false, + "quest_quest_list_seen": false, + "quest_details_seen": false, + "quest_request_items_seen": false, + "trainer_list_seen": false, + "trainer_id_seen": null, + "trainer_spell_count_seen": null, + "quest_accept_sent": false, + "quest_accept_confirm_seen": false, + "quest_db_verified": false, + "quest_db_status": null, + "quest_objective_persist": false, + "quest_objective_seeded": [], + "quest_objective_db_before": [], + "quest_objective_db_after": [], + "quest_objective_db_verified": false, + "quest_objective_update_seen": false, + "quest_objective_update_has_expected": false, + "quest_ids_seen": [], + "quest_titles_seen": [], + "quest_failure": null, + "seen_opcodes": [ + "0x3049", + "0x304D", + "0x304B", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2597", + "0x2DD2", + "0x257D", + "0x25D7", + "0x2C27", + "0x2C2B", + "0x2C28", + "0x2C2A", + "0x25E0", + "0x2724", + "0x2573", + "0x270E", + "0x2570", + "0x270D", + "0x25AD", + "0x2C33", + "0x2C34", + "0x25AE", + "0x25B0", + "0x2580", + "0x2DD5", + "0x27CB", + "0x27CB", + "0x2578", + "0x2746", + "0x25BC", + "0x2C1F", + "0x2578", + "0x25E0", + "0x2C1F", + "0x2C1F", + "0x2C1F", + "0x27CB", + "0x27CB", + "0x26D9", + "0x27CB", + "0x26B5", + "0x27BE", + "0x293D", + "0x26A4", + "0x2952", + "0x270A", + "0x26E1", + "0x25BF", + "0x27CB", + "0x2BC5", + "0x293D", + "0x2677", + "0x2952", + "0x25ED", + "0x27CB", + "0x278C", + "0x2C51", + "0x26D1", + "0x2DD4", + "0x2DD4", + "0x2DD4", + "0x2DD4", + "0x2DD4", + "0x2DD4", + "0x2DD4", + "0x2DD4", + "0x2DD4", + "0x2DD4", + "0x2DD4", + "0x2DD4", + "0x2DD4", + "0x2DD4", + "0x2DD4", + "0x2DD4", + "0x2DD4", + "0x2952", + "0x26D1", + "0x26E1", + "0x27CB", + "0x27CB", + "0x26D2", + "0x2952", + "0x2952", + "0x26D1", + "0x26E1", + "0x27CB", + "0x27CB", + "0x26D2", + "0x2952", + "0x26E1", + "0x26E1", + "0x26E1", + "0x26E1", + "0x26E1", + "0x26E1", + "0x26E1", + "0x293E", + "0x293E", + "0x27CB", + "0x2614", + "0x27CB", + "0x2623", + "0x27CB", + "0x2615", + "0x27CB", + "0x304E", + "0x271C", + "0x261A", + "0x2683", + "0x2DF9", + "0x27CB", + "0x2DD2", + "0x26D2", + "0x27CB", + "0x2DD4", + "0x26D2", + "0x27CB", + "0x2DD4", + "0x2DD4", + "0x26D2", + "0x2DD4", + "0x27CB", + "0x27CB", + "0x2DD4", + "0x26D2", + "0x2DD4", + "0x27CB", + "0x26D2", + "0x27CB", + "0x27CB", + "0x2DD4", + "0x2DD4", + "0x2DD2", + "0x2DD4", + "0x2DD4", + "0x2DD4", + "0x26D2", + "0x27CB", + "0x2DD4", + "0x26D2", + "0x27CB", + "0x2DD4", + "0x26D2", + "0x27CB", + "0x2DD4", + "0x2DD4", + "0x2DD4", + "0x2DD4", + "0x26D2", + "0x27CB", + "0x2DD4", + "0x2DD4", + "0x294B", + "0x294B", + "0x2684", + "0x3049", + "0x304D", + "0x304B", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2597", + "0x270A", + "0x2DD2", + "0x27BE", + "0x257D", + "0x26A4", + "0x25D7", + "0x270A", + "0x2C27", + "0x25BF", + "0x2C2B", + "0x2BC5", + "0x2C28", + "0x2677", + "0x2C2A", + "0x25ED", + "0x25E0", + "0x278C", + "0x2724", + "0x2C51", + "0x2573", + "0x271C", + "0x270E", + "0x2570", + "0x270D", + "0x25AD", + "0x2C33", + "0x2C34", + "0x25AE", + "0x25B0", + "0x2580", + "0x2DD5", + "0x27CB", + "0x27CB", + "0x2578", + "0x2746", + "0x25BC", + "0x2C1F", + "0x2578", + "0x25E0", + "0x2C1F", + "0x2C1F", + "0x2C1F", + "0x27CB", + "0x2683", + "0x2DF9", + "0x27CB", + "0x26D2", + "0x27CB", + "0x26D2", + "0x27CB", + "0x2DD2", + "0x26D2", + "0x27CB", + "0x26D2", + "0x27CB", + "0x26D2", + "0x27CB", + "0x26D2", + "0x26D2", + "0x2DD2", + "0x26D2", + "0x26D2", + "0x294B", + "0x294B", + "0x2684" + ] + } + ] +} \ No newline at end of file diff --git a/crates/capture-diff/flows/loot-single-item-claim/capture-provenance/cpp.capture-manifest.json b/crates/capture-diff/flows/loot-single-item-claim/capture-provenance/cpp.capture-manifest.json new file mode 100644 index 000000000..c31693707 --- /dev/null +++ b/crates/capture-diff/flows/loot-single-item-claim/capture-provenance/cpp.capture-manifest.json @@ -0,0 +1,65 @@ +{ + "version": 3, + "flow": "loot-single-item-claim", + "side": "cpp", + "completed": true, + "created_at": "2026-07-19T14:51:28Z", + "harness_repo_head": "72c5967dad248f869b2d740b55e885ce51db1a56", + "source_repo_head": "5100ce3d8fc6150feedf902e62512760ec03ea92", + "harness_worktree_clean": true, + "harness_worktree_state_sha256": "7f1df5ac739db5b1f58a4a49a9950d9e88c7fbcf5d6efdac91368ed5da32d052", + "source_worktree_dirty": true, + "source_worktree_state_sha256": "3eeb18895c18215d381c7dc13cab516bb06e0441be2901e92c05d1845107afb1", + "worktree_state_algorithm": "git-head-path-mode-content-sha256-v1", + "expected_exec_path": "/home/server/trinity-legacy-install/bin/worldserver", + "expected_exec_sha256": "189ac0ef6a5fef0fe3033a9844e2251523a442b746734cbcfd667277bdf99603", + "source_exec_path": "/home/server/trinity-legacy-install/bin/worldserver", + "source_exec_sha256": "189ac0ef6a5fef0fe3033a9844e2251523a442b746734cbcfd667277bdf99603", + "live_exec_path": "/home/server/trinity-legacy-install/bin/worldserver", + "live_exec_sha256": "189ac0ef6a5fef0fe3033a9844e2251523a442b746734cbcfd667277bdf99603", + "executable_pin_enforced": true, + "pm2_entry_pid": 881529, + "pm2_entry_starttime": 70890932, + "pm2_exec_path": "/home/server/trinity-legacy-install/bin/worldserver-wrapper.sh", + "pm2_exec_sha256": "41c0e84f282bf5711a2d4b433a054426d28de9875cfce1b3f7fcfe6c576df213", + "pm2_profile_redacted_sha256": "5de156ef078858c9d979e6bcc274d5d06e4d44588c273357fbf3f34dc235aca0", + "listener_runtime_pid": 881531, + "listener_runtime_starttime": 70890933, + "listener_relationship_verified": true, + "restart_count": 0, + "effective_config_path": "/home/server/trinity-legacy-install/bin/worldserver.conf", + "effective_config_redacted_sha256": "8d55b563745586a1d0014a1ee19704ec018e2097fd566116fec99090434c2242", + "effective_config_algorithm": "capture-relevant-redacted-v1", + "runtime_cleanup_verified": true, + "normal_runtime_restored": true, + "fixture_guard": { + "enabled": true, + "contract": "loot-single-item-claim-fixture-v1", + "account": "TESTBOT2@bot.local", + "account_id": 9, + "character_guid": 15, + "peer_account": "TESTBOT3@bot.local", + "peer_account_id": 10, + "peer_character_guid": 16, + "creature_entry": 21779, + "creature_spawn_guid": 1117, + "item_entry": 30712, + "cleanup_verified": true + }, + "bot_report": { + "contract": "wow-test-bot-loot-item-capture-report-v1", + "exec_path": "/home/server/rustycore-issue-106/tools/wow-test-bot/target/debug/wow-test-bot", + "exec_sha256": "da98cd7f0a884fdbc1cd7f971d567c50143984d0430d7aba1b14a00d84a900ad", + "report_path": "/tmp/rustycore-loot-item-cpp.rlOxSn/bot-report.json", + "report_sha256": "e546d8019eaec3ce2850a8f58f837ee0335b0fb8e1bc2f28283c0d9953a34784", + "account": "TESTBOT2@bot.local", + "account_id": 9, + "character_guid": 15, + "report_validated": true + }, + "artifact": { + "path": "cpp.pkt", + "size": 111420, + "sha256": "a25f2c2bbf60de6cda7e32f305d732733017e711eb474dd5dbf6e007690143a8" + } +} diff --git a/crates/capture-diff/flows/loot-single-item-claim/capture-provenance/rust.bot-report.json b/crates/capture-diff/flows/loot-single-item-claim/capture-provenance/rust.bot-report.json new file mode 100644 index 000000000..dab1d87b8 --- /dev/null +++ b/crates/capture-diff/flows/loot-single-item-claim/capture-provenance/rust.bot-report.json @@ -0,0 +1,290 @@ +{ + "dungeon_id": 220, + "timeout_secs": 60, + "require_proposal": true, + "require_group": false, + "auto_teleport": false, + "login_only": false, + "stand_state_smoke": false, + "bank_smoke": false, + "homebind_smoke": false, + "inventory_swap_smoke": false, + "rested_xp_smoke": false, + "loot_race_smoke": false, + "loot_item_capture": true, + "quest_smoke": false, + "results": [ + { + "account": "TESTBOT2@bot.local", + "account_id": 9, + "character_guid": 15, + "dungeon_id": 220, + "role": 4, + "join_result": null, + "join_detail": null, + "got_proposal": false, + "accepted_proposal": false, + "got_ready_check": false, + "group_formed": false, + "teleport_denied_reason": null, + "entered_world": false, + "world_auth": true, + "enum_characters": true, + "player_login_verified": true, + "login_only": false, + "stand_state_smoke": false, + "stand_state_smoke_passed": null, + "stand_states_requested": [], + "stand_states_confirmed": [], + "stand_state_failure": null, + "bank_smoke": false, + "bank_smoke_passed": null, + "bank_banker_entry": null, + "bank_banker_spawn_guid": null, + "bank_banker_guid_counter": null, + "bank_item_guid": null, + "bank_item_entry": null, + "bank_inventory_slot": null, + "bank_bank_slot": null, + "bank_open_confirmed": false, + "bank_deposit_persisted": false, + "bank_relogin_after_deposit": false, + "bank_withdraw_persisted": false, + "bank_failure": null, + "homebind_smoke": false, + "homebind_smoke_passed": null, + "homebind_innkeeper_entry": null, + "homebind_innkeeper_spawn_guid": null, + "homebind_innkeeper_guid_counter": null, + "homebind_spell_go_seen": false, + "homebind_bind_point_update_seen": false, + "homebind_player_bound_seen": false, + "homebind_gossip_complete_seen": false, + "homebind_db_persisted": false, + "homebind_relogin_verified": false, + "homebind_failure": null, + "inventory_swap_smoke": false, + "inventory_swap_smoke_passed": null, + "inventory_swap_item_guid_a": null, + "inventory_swap_item_guid_b": null, + "inventory_swap_item_entry_a": null, + "inventory_swap_item_entry_b": null, + "inventory_swap_slot_a": null, + "inventory_swap_slot_b": null, + "inventory_swap_forward_persisted": false, + "inventory_swap_relogin_after_forward": false, + "inventory_swap_reverse_persisted": false, + "inventory_swap_failure": null, + "rested_xp_smoke": false, + "rested_xp_smoke_passed": null, + "rested_xp_offline_wilderness_bonus": null, + "rested_xp_offline_resting_bonus": null, + "rested_xp_target_entry": null, + "rested_xp_target_spawn_guid": null, + "rested_xp_target_guid_counter": null, + "rested_xp_packet_amount": null, + "rested_xp_packet_original": null, + "rested_xp_db_xp_before": null, + "rested_xp_db_xp_after": null, + "rested_xp_db_rest_before": null, + "rested_xp_db_rest_after": null, + "rested_xp_relog_verified": false, + "rested_xp_failure": null, + "loot_race_smoke": true, + "loot_race_smoke_passed": true, + "loot_race_target_entry": 21779, + "loot_race_target_spawn_guid": 1117, + "loot_race_target_runtime_counter": 1, + "loot_race_party_confirmed": false, + "loot_race_target_discovered": true, + "loot_race_loot_opened": true, + "loot_race_loot_list_id": 0, + "loot_race_loot_coins": 0, + "loot_race_item_push_seen": true, + "loot_race_loot_removed_seen": true, + "loot_race_money_notify_amount": null, + "loot_race_coin_removed_seen": false, + "loot_race_db_item_total": 1, + "loot_race_db_money_delta": 0, + "loot_race_relog_verified": true, + "loot_race_failure": null, + "quest_smoke": false, + "quest_smoke_passed": null, + "quest_target_entry": null, + "quest_target_spawn_guid": null, + "quest_target_guid_counter": null, + "quest_target_map_id": null, + "quest_gossip_hello_sent": false, + "quest_questgiver_hello_sent": false, + "quest_gossip_id_seen": null, + "quest_gossip_select_sent": false, + "quest_gossip_message_seen": false, + "quest_quest_list_seen": false, + "quest_details_seen": false, + "quest_request_items_seen": false, + "trainer_list_seen": false, + "trainer_id_seen": null, + "trainer_spell_count_seen": null, + "quest_accept_sent": false, + "quest_accept_confirm_seen": false, + "quest_db_verified": false, + "quest_db_status": null, + "quest_objective_persist": false, + "quest_objective_seeded": [], + "quest_objective_db_before": [], + "quest_objective_db_after": [], + "quest_objective_db_verified": false, + "quest_objective_update_seen": false, + "quest_objective_update_has_expected": false, + "quest_ids_seen": [], + "quest_titles_seen": [], + "quest_failure": null, + "seen_opcodes": [ + "0x3049", + "0x304D", + "0x304B", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2C1F", + "0x2C1F", + "0x2C1F", + "0x2C1F", + "0x2C1F", + "0x2C1F", + "0x2C1F", + "0x2C1F", + "0x2C1F", + "0x2C1F", + "0x2597", + "0x2DD2", + "0x257D", + "0x25D7", + "0x2C27", + "0x2C2B", + "0x2C28", + "0x2C2A", + "0x25E0", + "0x2724", + "0x2573", + "0x270E", + "0x2570", + "0x270D", + "0x25AD", + "0x2C33", + "0x2C34", + "0x25AE", + "0x25B0", + "0x2580", + "0x2DD5", + "0x27CB", + "0x27CB", + "0x2578", + "0x27CB", + "0x2746", + "0x270A", + "0x2578", + "0x25BF", + "0x25BC", + "0x2BC5", + "0x2C1F", + "0x2677", + "0x27CB", + "0x278C", + "0x27CB", + "0x2C51", + "0x27CB", + "0x293D", + "0x2952", + "0x27CB", + "0x293E", + "0x27CB", + "0x2614", + "0x2623", + "0x27CB", + "0x2615", + "0x27CB", + "0x304E", + "0x261A", + "0x2683", + "0x261B", + "0x2684", + "0x3049", + "0x304D", + "0x304B", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2C1F", + "0x2C1F", + "0x2C1F", + "0x2C1F", + "0x2C1F", + "0x2C1F", + "0x2C1F", + "0x2C1F", + "0x2C1F", + "0x2C1F", + "0x2597", + "0x26A4", + "0x2DD2", + "0x270A", + "0x257D", + "0x25BF", + "0x25D7", + "0x2BC5", + "0x2C27", + "0x2677", + "0x2C2B", + "0x278C", + "0x2C28", + "0x2C51", + "0x2C2A", + "0x25E0", + "0x2724", + "0x2573", + "0x270E", + "0x2570", + "0x270D", + "0x25AD", + "0x2C33", + "0x2C34", + "0x25AE", + "0x25B0", + "0x2580", + "0x2DD5", + "0x27CB", + "0x27CB", + "0x2578", + "0x27CB", + "0x2746", + "0x2578", + "0x25BC", + "0x2C1F", + "0x27CB", + "0x2683", + "0x2684" + ] + } + ] +} \ No newline at end of file diff --git a/crates/capture-diff/flows/loot-single-item-claim/capture-provenance/rust.capture-manifest.json b/crates/capture-diff/flows/loot-single-item-claim/capture-provenance/rust.capture-manifest.json new file mode 100644 index 000000000..26bdeb5e4 --- /dev/null +++ b/crates/capture-diff/flows/loot-single-item-claim/capture-provenance/rust.capture-manifest.json @@ -0,0 +1,65 @@ +{ + "version": 3, + "flow": "loot-single-item-claim", + "side": "rust", + "completed": true, + "created_at": "2026-07-19T14:53:28Z", + "harness_repo_head": "72c5967dad248f869b2d740b55e885ce51db1a56", + "source_repo_head": "72c5967dad248f869b2d740b55e885ce51db1a56", + "harness_worktree_clean": true, + "harness_worktree_state_sha256": "7f1df5ac739db5b1f58a4a49a9950d9e88c7fbcf5d6efdac91368ed5da32d052", + "source_worktree_dirty": false, + "source_worktree_state_sha256": "7f1df5ac739db5b1f58a4a49a9950d9e88c7fbcf5d6efdac91368ed5da32d052", + "worktree_state_algorithm": "git-head-path-mode-content-sha256-v1", + "expected_exec_path": "/home/server/rustycore-issue-106/target/issue106-release/release/world-server", + "expected_exec_sha256": "6234630546446b115a74014b3ced1d99d1c9d9946a136e5171f1a3dd01a2cba5", + "source_exec_path": "/home/server/rustycore-issue-106/target/issue106-release/release/world-server", + "source_exec_sha256": "6234630546446b115a74014b3ced1d99d1c9d9946a136e5171f1a3dd01a2cba5", + "live_exec_path": "/home/server/rustycore-issue-106/target/issue106-release/release/world-server", + "live_exec_sha256": "6234630546446b115a74014b3ced1d99d1c9d9946a136e5171f1a3dd01a2cba5", + "executable_pin_enforced": true, + "pm2_entry_pid": 885094, + "pm2_entry_starttime": 70907497, + "pm2_exec_path": "/home/server/rustycore-issue-106/target/issue106-release/release/world-server", + "pm2_exec_sha256": "6234630546446b115a74014b3ced1d99d1c9d9946a136e5171f1a3dd01a2cba5", + "pm2_profile_redacted_sha256": "546d60f16e924203996a1a738cd9013d152eda7fb67e7bbb107d64792716e195", + "listener_runtime_pid": 885094, + "listener_runtime_starttime": 70907497, + "listener_relationship_verified": true, + "restart_count": 0, + "effective_config_path": "/home/server/trinity-legacy-install/etc/worldserver.conf", + "effective_config_redacted_sha256": "5e3f4e1ff0b296932b6d620f5a71ce03d13137f494e4812db8b9623dde0f5606", + "effective_config_algorithm": "capture-relevant-redacted-v1", + "runtime_cleanup_verified": true, + "normal_runtime_restored": true, + "fixture_guard": { + "enabled": true, + "contract": "loot-single-item-claim-fixture-v1", + "account": "TESTBOT2@bot.local", + "account_id": 9, + "character_guid": 15, + "peer_account": "TESTBOT3@bot.local", + "peer_account_id": 10, + "peer_character_guid": 16, + "creature_entry": 21779, + "creature_spawn_guid": 1117, + "item_entry": 30712, + "cleanup_verified": true + }, + "bot_report": { + "contract": "wow-test-bot-loot-item-capture-report-v1", + "exec_path": "/home/server/rustycore-issue-106/tools/wow-test-bot/target/debug/wow-test-bot", + "exec_sha256": "da98cd7f0a884fdbc1cd7f971d567c50143984d0430d7aba1b14a00d84a900ad", + "report_path": "/tmp/rustycore-loot-item-rust.CA4pL6/bot-report.json", + "report_sha256": "7c93aac0aebde59248716a6e593ee9a80c055411e256a9c63b4a249d953c7f40", + "account": "TESTBOT2@bot.local", + "account_id": 9, + "character_guid": 15, + "report_validated": true + }, + "artifact": { + "path": "rust", + "packet_count": 191, + "tree_sha256": "5fe815b89f622bddd862232d7e00d288c569655872d02b4760220fd8a2eef818" + } +} diff --git a/crates/capture-diff/flows/loot-single-item-claim/requirement.json b/crates/capture-diff/flows/loot-single-item-claim/requirement.json index 69f8f8488..5f279265d 100644 --- a/crates/capture-diff/flows/loot-single-item-claim/requirement.json +++ b/crates/capture-diff/flows/loot-single-item-claim/requirement.json @@ -1,7 +1,6 @@ { "issue": "#106", - "status": "awaiting-real-captures", - "blocked_reason": "The existing CLEAN six-packet C++/Rust pair predates mandatory completed RAW manifests, so executable/process/config provenance and RAW-to-derived lineage cannot be verified. Re-record and strictly import both sides with the guarded wrappers.", + "status": "ready", "description": "One solo character claims one deterministic creature-loot item; the isolated wire window starts at CMSG_LOOT_ITEM and ends at a fixed instance CMSG_PING fence.", "directions": [ "s2c", From 7df291a939f894148e3c9756912331d30698056a Mon Sep 17 00:00:00 2001 From: cDmonio Date: Sun, 19 Jul 2026 15:57:46 +0000 Subject: [PATCH 06/14] [08.5] Disable incremental Rust 1.88 CI builds --- .github/workflows/rust-ci.yml | 4 ++++ tools/pr-preflight.sh | 13 +++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 3bc21e511..ea2ce236c 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -48,6 +48,8 @@ jobs: name: Check core crates runs-on: ubuntu-24.04 timeout-minutes: 20 + env: + CARGO_INCREMENTAL: "0" steps: - name: Checkout uses: actions/checkout@v4 @@ -86,6 +88,8 @@ jobs: name: Focused library tests runs-on: ubuntu-24.04 timeout-minutes: 30 + env: + CARGO_INCREMENTAL: "0" steps: - name: Checkout uses: actions/checkout@v4 diff --git a/tools/pr-preflight.sh b/tools/pr-preflight.sh index 52836e5fd..f2799ddbe 100755 --- a/tools/pr-preflight.sh +++ b/tools/pr-preflight.sh @@ -347,7 +347,9 @@ cargo_cmd() { local channel ((DRY_RUN)) || require_command cargo channel="$(toolchain_channel)" - run_cmd cargo "+$channel" "$@" + # Rust 1.88 can ICE while reloading a stale incremental dep graph. Keep the + # compile/test gate deterministic without leaking this setting into live QA. + run_cmd env CARGO_INCREMENTAL=0 cargo "+$channel" "$@" } project_protoc_version() { @@ -933,7 +935,7 @@ run_self_test() { for dependency in awk dirname env git head realpath sed sha256sum tr; do ln -s "$(command -v "$dependency")" "$artifacts/bin/$dependency" done - printf '#!/bin/sh\n[ "${RUST_MIN_STACK:-0}" -ge %s ] || exit 70\nexit 0\n' \ + printf '#!/bin/sh\n[ "${RUST_MIN_STACK:-0}" -ge %s ] || exit 70\n[ "${CARGO_INCREMENTAL:-}" = 0 ] || exit 71\nexit 0\n' \ "$DEFAULT_RUST_MIN_STACK" >"$artifacts/bin/cargo" expected_protoc_version="$(project_protoc_version)" printf '#!/bin/sh\nprintf "libprotoc %s\\n"\n' \ @@ -971,6 +973,8 @@ run_self_test() { "CI profile did not print the wow-network tests" [[ "$ci_dry_run_output" == *"--manifest-path tools/wow-test-bot/Cargo.toml loot_race::tests"* ]] || die \ "CI profile did not print the focused loot-race harness tests" + [[ "$ci_dry_run_output" == *"+ env CARGO_INCREMENTAL=0 cargo +1.88.0 test --locked -p wow-world --lib"* ]] || die \ + "local CI profile did not disable Rust 1.88 incremental compilation" require_exact_occurrences "$ci_dry_run_output" \ "test --locked -p capture-diff" 1 \ "local CI capture-diff test command" @@ -981,6 +985,9 @@ run_self_test() { "normal CI profile must never activate destructive live loot-race QA" github_workflow_text="$(<"$REPO_ROOT/.github/workflows/rust-ci.yml")" + require_exact_occurrences "$github_workflow_text" \ + 'CARGO_INCREMENTAL: "0"' 2 \ + "GitHub workflow non-incremental Rust 1.88 contract" require_exact_occurrences "$github_workflow_text" \ "cargo +1.88.0 test --locked -p capture-diff" 1 \ "GitHub workflow capture-diff test command" @@ -1002,6 +1009,8 @@ run_self_test() { "acknowledged qa-loot-race dry-run failed" [[ "$qa_loot_race_dry_run_output" == *"WOW_BOT_LOOT_RACE_SMOKE=1"* ]] || die \ "qa-loot-race did not select the loot-race bot mode" + [[ "$qa_loot_race_dry_run_output" != *"CARGO_INCREMENTAL=0"* ]] || die \ + "qa-loot-race must not inherit compile-only incremental settings" [[ "$qa_loot_race_dry_run_output" == *"WOW_BOT_EXEC="* \ && "$qa_loot_race_dry_run_output" == *"WOW_BOT_EXEC_SHA256="* ]] || die \ "qa-loot-race dry-run did not disclose the pinned bot provenance" From bd8e170cc01fc2b83dba719be14902d4ff728a09 Mon Sep 17 00:00:00 2001 From: cDmonio Date: Sun, 19 Jul 2026 21:24:47 +0000 Subject: [PATCH 07/14] [08.5] Publish creature loot release updates --- crates/wow-network/src/lib.rs | 4 +- crates/wow-network/src/player_registry.rs | 15 + crates/wow-world/src/handlers/loot.rs | 585 ++++++++++++++++++++-- 3 files changed, 556 insertions(+), 48 deletions(-) diff --git a/crates/wow-network/src/lib.rs b/crates/wow-network/src/lib.rs index 727441f77..db2e62db2 100644 --- a/crates/wow-network/src/lib.rs +++ b/crates/wow-network/src/lib.rs @@ -44,8 +44,8 @@ pub use player_registry::{ LootRollVoteCommand, MasterLootGiveCommand, MasterLootGiveResult, NotifyLootMoneyRemovedLikeCppCommand, PlayerBroadcastInfo, PlayerRegistry, RefreshVisibleWorldCreaturesLikeCppCommand, ResetSeasonalQuestStatusCommand, - SendAddonIfRegisteredLikeCppCommand, SendIfVisibleLikeCppCommand, - SendPartyUpdateLikeCppCommand, SendRealmPacketLikeCppCommand, + SendAddonIfRegisteredLikeCppCommand, SendCreatureLootReleaseValuesUpdateLikeCppCommand, + SendIfVisibleLikeCppCommand, SendPartyUpdateLikeCppCommand, SendRealmPacketLikeCppCommand, SendVisibleObjectValuesUpdateCommand, SessionCommand, WorldSessionShutdownFlushLikeCppCommand, WorldSessionShutdownFlushResultLikeCpp, }; diff --git a/crates/wow-network/src/player_registry.rs b/crates/wow-network/src/player_registry.rs index bc5351462..6ed74d862 100644 --- a/crates/wow-network/src/player_registry.rs +++ b/crates/wow-network/src/player_registry.rs @@ -37,6 +37,7 @@ pub enum SessionCommand { ResetSeasonalQuestStatus(ResetSeasonalQuestStatusCommand), SendVisibleObjectValuesUpdate(SendVisibleObjectValuesUpdateCommand), RefreshVisibleWorldCreaturesLikeCpp(RefreshVisibleWorldCreaturesLikeCppCommand), + SendCreatureLootReleaseValuesUpdateLikeCpp(SendCreatureLootReleaseValuesUpdateLikeCppCommand), RefreshVisibleGameobjectsOrSpellClicksLikeCpp, SyncGatheringNodeGameobjectStateAndRefreshLikeCpp( SyncGatheringNodeGameobjectStateAndRefreshLikeCppCommand, @@ -244,6 +245,20 @@ pub struct SendIfVisibleLikeCppCommand { pub packet_bytes: Vec, } +/// Carries C++ `WorldSession::DoLootRelease`'s forced creature DynamicFlags +/// update to the receiving session. The receiver must apply its own +/// `Player::isAllowedToLoot` view before serialising the VALUES packet; the +/// source session cannot safely predict session-local state such as a pending +/// instance bind. +#[derive(Clone, Debug)] +pub struct SendCreatureLootReleaseValuesUpdateLikeCppCommand { + pub creature_guid: ObjectGuid, + pub map_id: u16, + pub instance_id: u32, + pub unit_values_update: wow_packet::packets::update::UnitDataValuesDeltaUpdate, + pub authority: Option, +} + /// Payload for [`SessionCommand::SendAddonIfRegisteredLikeCpp`]. #[derive(Clone, Debug)] pub struct SendAddonIfRegisteredLikeCppCommand { diff --git a/crates/wow-world/src/handlers/loot.rs b/crates/wow-world/src/handlers/loot.rs index 0fd4a9567..2f2b44ba8 100644 --- a/crates/wow-world/src/handlers/loot.rs +++ b/crates/wow-world/src/handlers/loot.rs @@ -25,7 +25,7 @@ use tracing::{debug, info, warn}; use wow_constants::{ ClientOpcodes, InventoryResult, InventoryType, ItemContext, ItemFieldFlags, ItemFlags, - ItemFlags2, ItemQuality, + ItemFlags2, ItemQuality, UnitDynFlags, }; use wow_core::{ObjectGuid, guid::HighGuid}; use wow_data::{ItemRandomEnchantmentTemplateEntry, ItemRandomPropertyTemplateEntry}; @@ -63,11 +63,11 @@ use wow_network::player_registry::{ ApplyCreatureMeleeDamageLikeCppCommand, ApplyGroupJoinLikeCppCommand, ApplyGroupRemovalLikeCppCommand, CancelRepresentedTradeLikeCppCommand, CreatureAttackStartLikeCppCommand, RefreshVisibleWorldCreaturesLikeCppCommand, - SendAddonIfRegisteredLikeCppCommand, SendIfVisibleLikeCppCommand, - SendPartyUpdateLikeCppCommand, SendRepeatableTurnInRequestItemsLikeCppCommand, - SendRepresentedDuelCountdownLikeCppCommand, SendRepresentedDuelRequestedLikeCppCommand, - SendRepresentedTradeStatusLikeCppCommand, SetQuestSharingInfoAndSendDetailsCommand, - SyncChestGameobjectStateAndRefreshLikeCppCommand, + SendAddonIfRegisteredLikeCppCommand, SendCreatureLootReleaseValuesUpdateLikeCppCommand, + SendIfVisibleLikeCppCommand, SendPartyUpdateLikeCppCommand, + SendRepeatableTurnInRequestItemsLikeCppCommand, SendRepresentedDuelCountdownLikeCppCommand, + SendRepresentedDuelRequestedLikeCppCommand, SendRepresentedTradeStatusLikeCppCommand, + SetQuestSharingInfoAndSendDetailsCommand, SyncChestGameobjectStateAndRefreshLikeCppCommand, SyncGatheringNodeGameobjectStateAndRefreshLikeCppCommand, SyncGooberGameobjectStateAndRefreshLikeCppCommand, UnacceptRepresentedTradeLikeCppCommand, WorldSessionShutdownFlushResultLikeCpp, @@ -111,9 +111,12 @@ use crate::session::{ loot_money_durable_outcome_like_cpp, }; +const LOOT_METHOD_FREE_FOR_ALL_LIKE_CPP: u8 = 0; +const LOOT_METHOD_ROUND_ROBIN_LIKE_CPP: u8 = 1; const LOOT_METHOD_MASTER_LIKE_CPP: u8 = 2; const LOOT_METHOD_GROUP_LIKE_CPP: u8 = 3; const LOOT_METHOD_NEED_BEFORE_GREED_LIKE_CPP: u8 = 4; +const LOOT_METHOD_PERSONAL_LIKE_CPP: u8 = 5; const MAX_NR_LOOT_ITEMS_LIKE_CPP: usize = 18; const LOOT_ROLL_TIMEOUT_MS_LIKE_CPP: u32 = 60_000; #[cfg(test)] @@ -876,6 +879,224 @@ impl WorldSession { queued } + fn represented_creature_is_dead_for_loot_visibility_like_cpp( + &self, + creature_guid: ObjectGuid, + ) -> bool { + let (map_id, instance_id) = self.current_legacy_runtime_map_key_like_cpp(); + if let Some(manager) = self.map_manager.as_ref() + && let Some(creature) = manager + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .find_creature(map_id, instance_id, creature_guid) + { + return !creature.is_alive(); + } + + let Some(map_key) = + self.canonical_object_lookup_map_key_like_cpp(u32::from(self.player_map_id_like_cpp())) + else { + return false; + }; + let Some(manager) = self.canonical_map_manager.as_ref() else { + return false; + }; + let Ok(manager) = manager.lock() else { + return false; + }; + manager + .find_map(map_key.map_id, map_key.instance_id) + .and_then(|map| map.map().get_typed_creature(creature_guid)) + .is_some_and(|creature| !creature.is_alive()) + } + + fn creature_loot_release_values_for_viewer_like_cpp( + &self, + creature_guid: ObjectGuid, + viewer_guid: ObjectGuid, + viewer_has_pending_bind: bool, + authority: Option<&OwnedLootAuthority>, + mut update: wow_packet::packets::update::UnitDataValuesDeltaUpdate, + ) -> wow_packet::packets::update::UnitDataValuesDeltaUpdate { + let Some(object_data) = update.object_data.as_mut() else { + return update; + }; + if object_data.dynamic_flags & UnitDynFlags::Lootable as u32 == 0 { + return update; + } + let Some(authority) = authority else { + // The authority-less path exists only for bounded unit fixtures. + // Preserve the canonical flag rather than inventing per-viewer + // ownership without `Creature::GetLootForPlayer` evidence. + return update; + }; + + // C++ `ViewerDependentValue` removes + // UNIT_DYNFLAG_LOOTABLE when the complete `Player::isAllowedToLoot` + // predicate is false. The object-owned authority is the Rust + // equivalent of `Creature::GetLootForPlayer`; one exhausted personal + // pool must not hide a different player's still-live pool. + let creature_is_dead = + self.represented_creature_is_dead_for_loot_visibility_like_cpp(creature_guid); + let viewer_can_still_loot = authority + .snapshot_for_player_like_cpp(viewer_guid) + .is_some_and(|snapshot| { + creature_loot_is_allowed_to_player_like_cpp( + creature_is_dead, + viewer_has_pending_bind, + &snapshot.loot, + viewer_guid, + ) + }); + if !viewer_can_still_loot { + object_data.dynamic_flags &= !(UnitDynFlags::Lootable as u32); + } + update + } + + /// Publishes the dirty DynamicFlags field created by C++ + /// `WorldSession::DoLootRelease` to every same-map session that currently + /// has the creature at the client. The canonical object mutation alone is + /// insufficient until the global `Map::SendObjectUpdates` bridge owns + /// normal VALUES fanout. + fn send_creature_loot_release_dynamic_flags_update_like_cpp( + &self, + creature_guid: ObjectGuid, + values_update: &wow_entities::UnitValuesUpdate, + authority: Option<&OwnedLootAuthority>, + ) -> usize { + let Some(player_guid) = self.player_guid() else { + return 0; + }; + let Some(packet_update) = + crate::entity_update_bridge::unit_values_update_to_packet(values_update) + else { + return 0; + }; + let map_id = self.player_map_id_like_cpp(); + let instance_id = self + .current_canonical_player_map_key_like_cpp() + .map(|key| key.instance_id) + .unwrap_or(0); + let mut sent = 0; + + if self.client_visible_guids_like_cpp.contains(&creature_guid) { + let source_update = self.creature_loot_release_values_for_viewer_like_cpp( + creature_guid, + player_guid, + self.pending_bind.is_some(), + authority, + packet_update.clone(), + ); + self.send_packet(&UpdateObject::unit_values_update( + creature_guid, + map_id, + source_update, + )); + sent += 1; + } + + let Some(registry) = self.player_registry() else { + return sent; + }; + let remote_command_txs = registry + .iter() + .filter_map(|entry| { + let (candidate_guid, candidate) = entry.pair(); + (*candidate_guid != player_guid + && candidate.is_in_world + && candidate.map_id == map_id + && candidate.instance_id == instance_id) + .then(|| candidate.command_tx.clone()) + }) + .collect::>(); + for command_tx in remote_command_txs { + // C++'s dirty-field pass cannot silently lose this forced update. + // Do not retain a DashMap guard (or any map/authority lock) while + // queueing the bounded target-session command rail. + if queue_creature_loot_release_command_reliably_like_cpp( + &command_tx, + SessionCommand::SendCreatureLootReleaseValuesUpdateLikeCpp( + SendCreatureLootReleaseValuesUpdateLikeCppCommand { + creature_guid, + map_id, + instance_id, + unit_values_update: packet_update.clone(), + authority: authority.cloned(), + }, + ), + ) != CreatureLootReleaseCommandQueueOutcomeLikeCpp::Disconnected + { + sent += 1; + } + } + + sent + } + + /// Receiver-owned half of the loot-release VALUES fanout. Applying + /// `Player::isAllowedToLoot` here preserves session-local pending-bind + /// state and avoids serialising one player's dynamic flags for another. + fn handle_send_creature_loot_release_values_update_command_like_cpp( + &mut self, + command: SendCreatureLootReleaseValuesUpdateLikeCppCommand, + ) { + if self.state() != crate::session::SessionState::LoggedIn + || self.player_map_id_like_cpp() != command.map_id + { + return; + } + let instance_id = self + .current_canonical_player_map_key_like_cpp() + .map(|key| key.instance_id) + .unwrap_or(0); + if instance_id != command.instance_id + || !self + .client_visible_guids_like_cpp + .contains(&command.creature_guid) + { + return; + } + if self.represented_can_receive_creature_message_to_set_by_guid_like_cpp( + command.creature_guid, + command.map_id, + command.instance_id, + false, + ) != Some(true) + { + return; + } + let Some(expected_authority) = command.authority.as_ref() else { + return; + }; + let Some(current_authority) = + self.represented_owned_loot_authority_like_cpp(command.creature_guid) + else { + return; + }; + if !current_authority.shares_storage_like_cpp(expected_authority) { + // The queued update belongs to an older corpse generation. C++ + // publishes synchronously before respawn; Rust must not apply the + // delayed VALUES delta to a replacement creature with the same GUID. + return; + } + let Some(viewer_guid) = self.player_guid() else { + return; + }; + let viewer_update = self.creature_loot_release_values_for_viewer_like_cpp( + command.creature_guid, + viewer_guid, + self.pending_bind.is_some(), + Some(expected_authority), + command.unit_values_update, + ); + self.send_packet(&UpdateObject::unit_values_update( + command.creature_guid, + command.map_id, + viewer_update, + )); + } + fn queue_gathering_node_gameobject_state_refresh_for_same_map_like_cpp( &self, gameobject_guid: ObjectGuid, @@ -4132,6 +4353,9 @@ impl WorldSession { self.handle_refresh_visible_world_creatures_like_cpp_command_like_cpp(command) .await; } + SessionCommand::SendCreatureLootReleaseValuesUpdateLikeCpp(command) => { + self.handle_send_creature_loot_release_values_update_command_like_cpp(command); + } SessionCommand::RefreshVisibleGameobjectsOrSpellClicksLikeCpp => { let _ = self.update_visible_gameobjects_or_spell_clicks_like_cpp(); } @@ -5420,18 +5644,17 @@ impl WorldSession { let corpse_decay_looted_rate = self.loot_drop_rates_like_cpp().corpse_decay_looted; let whole_object_fully_skinned = observation.whole_object_fully_skinned; - let marked = self - .mutate_world_creature_if_fully_looted_observation_like_cpp( - route.owner_guid, - &route.authority, - observation.object_generation, - observation.lifecycle_revision, - |creature| { - creature.force_dynamic_flags_update_like_cpp(); - creature.remove_lootable_dynamic_flag_like_cpp(); - if creature.is_alive() { - return None; - } + let lifecycle_update = self.mutate_world_creature_if_fully_looted_observation_like_cpp( + route.owner_guid, + &route.authority, + observation.object_generation, + observation.lifecycle_revision, + |creature| { + creature.force_dynamic_flags_update_like_cpp(); + creature.remove_lootable_dynamic_flag_like_cpp(); + let marked = if creature.is_alive() { + None + } else { let corpse_decay_secs = looted_corpse_decay_secs_like_cpp( whole_object_fully_skinned, creature.corpse_delay_secs_like_cpp(), @@ -5444,10 +5667,19 @@ impl WorldSession { whole_object_fully_skinned, ) .then_some((creature.entry(), corpse_decay_secs)) - }, - ) - .flatten(); + }; + (marked, creature.creature.unit().values_update()) + }, + ); self.loot_table.remove(&route.owner_guid); + if let Some((_, values_update)) = lifecycle_update.as_ref() { + self.send_creature_loot_release_dynamic_flags_update_like_cpp( + route.owner_guid, + values_update, + Some(&route.authority), + ); + } + let marked = lifecycle_update.and_then(|(marked, _)| marked); if let Some((entry, corpse_decay_secs)) = marked { info!( "Creature {:?} (entry {}) fully looted after durable claim — despawning in {}s", @@ -9583,9 +9815,19 @@ impl WorldSession { self.represented_notify_loot_list_like_cpp(owner_guid); } if owner_guid.is_creature_or_vehicle() { - let _ = self.mutate_world_creature(owner_guid, |creature| { + let values_update = self.mutate_world_creature(owner_guid, |creature| { creature.force_dynamic_flags_update_like_cpp(); + creature.creature.unit().values_update() }); + if let Some(values_update) = values_update.as_ref() { + self.send_creature_loot_release_dynamic_flags_update_like_cpp( + owner_guid, + values_update, + authoritative_release + .as_ref() + .map(|release| &release.authority), + ); + } } if authoritative_release.is_some() { self.discard_represented_personal_loot_cache_for_player_like_cpp( @@ -9612,11 +9854,21 @@ impl WorldSession { // C++ forces the viewer-dependent DynamicFlags field after every // creature release, including a selected personal pool that completed // while another pool remains. - let _ = self.mutate_world_creature(owner_guid, |creature| { + let forced_values_update = self.mutate_world_creature(owner_guid, |creature| { creature.force_dynamic_flags_update_like_cpp(); + creature.creature.unit().values_update() }); if !whole_object_fully_looted { + if let Some(values_update) = forced_values_update.as_ref() { + self.send_creature_loot_release_dynamic_flags_update_like_cpp( + owner_guid, + values_update, + authoritative_release + .as_ref() + .map(|release| &release.authority), + ); + } if authoritative_release.is_some() { self.discard_represented_personal_loot_cache_for_player_like_cpp( owner_guid, @@ -9635,7 +9887,7 @@ impl WorldSession { ); let apply_lifecycle = |creature: &mut crate::map_manager::WorldCreature| { creature.remove_lootable_dynamic_flag_like_cpp(); - if !creature.is_alive() { + let marked = if !creature.is_alive() { let corpse_decay_secs = looted_corpse_decay_secs_like_cpp( whole_object_fully_skinned, creature.corpse_delay_secs_like_cpp(), @@ -9648,14 +9900,16 @@ impl WorldSession { ) { // C++ returns without resetting an already-expired // corpse. The lifecycle mirror must remain expired too. - return None; + None + } else { + Some((creature.entry(), corpse_decay_secs)) } - Some((creature.entry(), corpse_decay_secs)) } else { None - } + }; + (marked, creature.creature.unit().values_update()) }; - let marked = if let Some(release) = authoritative_release.as_ref() { + let lifecycle_update = if let Some(release) = authoritative_release.as_ref() { self.mutate_world_creature_if_fully_looted_observation_like_cpp( owner_guid, &release.authority, @@ -9665,8 +9919,18 @@ impl WorldSession { ) } else { self.mutate_world_creature(owner_guid, apply_lifecycle) + }; + + if let Some((_, values_update)) = lifecycle_update.as_ref() { + self.send_creature_loot_release_dynamic_flags_update_like_cpp( + owner_guid, + values_update, + authoritative_release + .as_ref() + .map(|release| &release.authority), + ); } - .flatten(); + let marked = lifecycle_update.and_then(|(marked, _)| marked); if let Some((entry, corpse_decay_secs)) = marked { info!( @@ -11965,6 +12229,78 @@ fn loot_can_be_opened_by_player_like_cpp(loot: &CreatureLoot, player_guid: Objec || loot_has_item_for_player_like_cpp(loot, player_guid) } +/// Exact represented branch order of C++ `Player::isAllowedToLoot` for a +/// creature and the pool selected by `Creature::GetLootForPlayer`. +fn creature_loot_is_allowed_to_player_like_cpp( + creature_is_dead: bool, + player_has_pending_bind: bool, + loot: &CreatureLoot, + player_guid: ObjectGuid, +) -> bool { + if !creature_is_dead || player_has_pending_bind || loot_is_looted_like_cpp(loot) { + return false; + } + if !loot.allowed_looters.contains(&player_guid) + || (!loot_has_item_for_all_like_cpp(loot, player_guid) + && !loot_has_item_for_player_like_cpp(loot, player_guid)) + { + return false; + } + + match loot.loot_method { + LOOT_METHOD_FREE_FOR_ALL_LIKE_CPP | LOOT_METHOD_PERSONAL_LIKE_CPP => true, + LOOT_METHOD_ROUND_ROBIN_LIKE_CPP => { + loot.round_robin_player.is_empty() + || loot.round_robin_player == player_guid + || loot_has_item_for_player_like_cpp(loot, player_guid) + } + LOOT_METHOD_MASTER_LIKE_CPP + | LOOT_METHOD_GROUP_LIKE_CPP + | LOOT_METHOD_NEED_BEFORE_GREED_LIKE_CPP => { + loot.round_robin_player.is_empty() + || loot.round_robin_player == player_guid + || loot_has_over_threshold_item_like_cpp(loot) + || loot_has_item_for_player_like_cpp(loot, player_guid) + } + _ => false, + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CreatureLootReleaseCommandQueueOutcomeLikeCpp { + Queued, + Retrying, + Disconnected, +} + +fn queue_creature_loot_release_command_reliably_like_cpp( + command_tx: &flume::Sender, + command: SessionCommand, +) -> CreatureLootReleaseCommandQueueOutcomeLikeCpp { + match command_tx.try_send(command) { + Ok(()) => CreatureLootReleaseCommandQueueOutcomeLikeCpp::Queued, + Err(flume::TrySendError::Disconnected(_)) => { + CreatureLootReleaseCommandQueueOutcomeLikeCpp::Disconnected + } + Err(flume::TrySendError::Full(command)) => { + let command_tx = command_tx.clone(); + // Never await another session from the source session loop: two + // full queues could otherwise wait on each other forever. The + // detached retry retains the exact command until capacity opens; + // receiver-side authority/lifecycle gates coalesce its meaning to + // the current corpse generation and reject stale respawn reuse. + tokio::spawn(async move { + if command_tx.send_async(command).await.is_err() { + tracing::debug!( + "loot-release DynamicFlags retry ended after target session disconnected" + ); + } + }); + CreatureLootReleaseCommandQueueOutcomeLikeCpp::Retrying + } + } +} + fn loot_has_over_threshold_item_like_cpp(loot: &CreatureLoot) -> bool { loot.items .iter() @@ -12825,30 +13161,32 @@ where #[cfg(test)] mod tests { use super::{ - GAMEOBJECT_TYPE_AREADAMAGE, GAMEOBJECT_TYPE_BINDER, GAMEOBJECT_TYPE_CHAIR, - GAMEOBJECT_TYPE_DOOR, GAMEOBJECT_TYPE_GUILD_BANK, GAMEOBJECT_TYPE_QUESTGIVER, - INVENTORY_SLOT_BAG_0, INVENTORY_SLOT_ITEM_START, ITEM_FLAGS_CU_FOLLOW_LOOT_RULES_LIKE_CPP, + CreatureLootReleaseCommandQueueOutcomeLikeCpp, GAMEOBJECT_TYPE_AREADAMAGE, + GAMEOBJECT_TYPE_BINDER, GAMEOBJECT_TYPE_CHAIR, GAMEOBJECT_TYPE_DOOR, + GAMEOBJECT_TYPE_GUILD_BANK, GAMEOBJECT_TYPE_QUESTGIVER, INVENTORY_SLOT_BAG_0, + INVENTORY_SLOT_ITEM_START, ITEM_FLAGS_CU_FOLLOW_LOOT_RULES_LIKE_CPP, ItemTemplateAddonLootMetadataLikeCpp, LOCK_KEY_SKILL_LIKE_CPP, LOCK_KEY_SPELL_LIKE_CPP, - LOOT_METHOD_GROUP_LIKE_CPP, LOOT_METHOD_MASTER_LIKE_CPP, LOOT_MODE_DEFAULT_LIKE_CPP, - LOOT_MODE_JUNK_FISH_LIKE_CPP, LOOT_SLOT_TYPE_ALLOW_LOOT_LIKE_CPP, - LOOT_SLOT_TYPE_ROLL_ONGOING_LIKE_CPP, LootItemClaimCommitContextLikeCpp, - LootStoreRandomProperties, ROLL_ALL_TYPE_NO_DISENCHANT_LIKE_CPP, - ROLL_FLAG_TYPE_NEED_LIKE_CPP, ROLL_VOTE_GREED_LIKE_CPP, ROLL_VOTE_NEED_LIKE_CPP, - ROLL_VOTE_NOT_EMITTED_YET_LIKE_CPP, ROLL_VOTE_NOT_VALID_LIKE_CPP, ROLL_VOTE_PASS_LIKE_CPP, - RepresentedLootPlayerContext, SPELL_EFFECT_OPEN_LOCK_LIKE_CPP, - StoredItemMoneyCommitReconciliationLikeCpp, StoredItemMoneyDbOutcomeLikeCpp, - SyncChestGameobjectStateAndRefreshLikeCppCommand, + LOOT_METHOD_GROUP_LIKE_CPP, LOOT_METHOD_MASTER_LIKE_CPP, LOOT_METHOD_ROUND_ROBIN_LIKE_CPP, + LOOT_MODE_DEFAULT_LIKE_CPP, LOOT_MODE_JUNK_FISH_LIKE_CPP, + LOOT_SLOT_TYPE_ALLOW_LOOT_LIKE_CPP, LOOT_SLOT_TYPE_ROLL_ONGOING_LIKE_CPP, + LootItemClaimCommitContextLikeCpp, LootStoreRandomProperties, + ROLL_ALL_TYPE_NO_DISENCHANT_LIKE_CPP, ROLL_FLAG_TYPE_NEED_LIKE_CPP, + ROLL_VOTE_GREED_LIKE_CPP, ROLL_VOTE_NEED_LIKE_CPP, ROLL_VOTE_NOT_EMITTED_YET_LIKE_CPP, + ROLL_VOTE_NOT_VALID_LIKE_CPP, ROLL_VOTE_PASS_LIKE_CPP, RepresentedLootPlayerContext, + SPELL_EFFECT_OPEN_LOCK_LIKE_CPP, StoredItemMoneyCommitReconciliationLikeCpp, + StoredItemMoneyDbOutcomeLikeCpp, SyncChestGameobjectStateAndRefreshLikeCppCommand, SyncGatheringNodeGameobjectStateAndRefreshLikeCppCommand, SyncGooberGameobjectStateAndRefreshLikeCppCommand, assign_represented_personal_loot_items_like_cpp, classify_stored_item_money_commit_reconciliation_like_cpp, - direct_item_count_after_loot_release_like_cpp, + creature_loot_is_allowed_to_player_like_cpp, direct_item_count_after_loot_release_like_cpp, generated_creature_loot_item_to_entry_like_cpp, generated_shared_gameobject_loot_item_to_entry_like_cpp, loot_is_looted_like_cpp, loot_item_context, loot_store_data_can_stack_with_item, loot_type_for_client_like_cpp, looted_corpse_decay_secs_like_cpp, mark_loot_allowed_for_player_like_cpp, mark_loot_item_looted_for_player_like_cpp, prepare_represented_shared_loot_generation_like_cpp, + queue_creature_loot_release_command_reliably_like_cpp, represented_gameobject_display_box_contains_like_cpp, represented_gameobject_interaction_distance_like_cpp, represented_loot_object_guid_like_cpp, represented_loot_response_items_like_cpp, @@ -12873,7 +13211,7 @@ mod tests { use wow_ai::CreatureAI; use wow_constants::{ InventoryResult, InventoryType, ItemBondingType, ItemClass, ItemContext, ItemFieldFlags, - ItemFlags2, ItemQuality, TypeId, TypeMask, + ItemFlags2, ItemQuality, TypeId, TypeMask, UnitDynFlags, }; use wow_core::{ObjectGuid, ObjectGuidGenerator, Position, guid::HighGuid}; use wow_data::quest::{ @@ -12920,7 +13258,9 @@ mod tests { LOOT_TYPE_PROSPECTING_LIKE_CPP, LOOT_TYPE_SKINNING_LIKE_CPP, LootEntry, LootEntryFlags, LootResponse, LootRoll, MasterLootItem, SetLootSpecialization, }; - use wow_packet::packets::update::CreatureCreateData; + use wow_packet::packets::update::{ + CreatureCreateData, ObjectDataValuesUpdate, UnitDataValuesDeltaUpdate, + }; use wow_packet::{ServerPacket, WorldPacket}; use crate::session::{ @@ -26108,6 +26448,151 @@ mod tests { assert!((55..=60).contains(&remaining.as_secs())); } + #[test] + fn creature_loot_release_dynamic_flags_are_viewer_dependent_like_cpp() { + let mut session = make_session(); + let first_player = ObjectGuid::create_player(1, 61); + let second_player = ObjectGuid::create_player(1, 62); + let unrelated_player = ObjectGuid::create_player(1, 63); + let owner_guid = test_creature_guid(19_120); + let mut creature = make_canonical_creature_for_session(&session, owner_guid); + + let mut consumed_pool = authoritative_test_loot_like_cpp(0, false); + consumed_pool.loot_guid = represented_loot_object_guid_like_cpp(owner_guid); + consumed_pool.allowed_looters = vec![first_player]; + let mut live_pool = authoritative_test_loot_like_cpp(0, true); + live_pool.loot_guid = ObjectGuid::create_world_object( + HighGuid::LootObject, + 0, + owner_guid.realm_id(), + owner_guid.map_id(), + 0, + 0, + owner_guid.counter() + 1, + ); + live_pool.allowed_looters = vec![second_player]; + live_pool.items[0].allowed_looters = vec![second_player]; + assert!( + creature + .initialize_loot_authority_like_cpp( + None, + HashMap::from([(first_player, consumed_pool), (second_player, live_pool),]), + ) + .installed() + ); + let authority = creature.loot_authority_like_cpp().clone(); + attach_canonical_creature(&mut session, creature); + register_test_creature_like_cpp(&mut session, test_creature(owner_guid, false)); + session.set_player_guid(Some(first_player)); + + let update = UnitDataValuesDeltaUpdate { + object_data: Some(ObjectDataValuesUpdate { + changed_object_type_mask: 1, + object_data_mask: 1 << 2, + entry_id: 0, + dynamic_flags: UnitDynFlags::Lootable as u32, + scale: 1.0, + }), + ..UnitDataValuesDeltaUpdate::default() + }; + let dynamic_flags_for = |viewer_guid| { + session + .creature_loot_release_values_for_viewer_like_cpp( + owner_guid, + viewer_guid, + false, + Some(&authority), + update.clone(), + ) + .object_data + .unwrap() + .dynamic_flags + }; + + assert_eq!(dynamic_flags_for(first_player), 0); + assert_eq!( + dynamic_flags_for(second_player), + UnitDynFlags::Lootable as u32, + "one exhausted personal pool must not hide another player's live loot" + ); + assert_eq!(dynamic_flags_for(unrelated_player), 0); + } + + #[test] + fn creature_loot_visibility_applies_full_cpp_allowed_to_loot_gate() { + let round_robin_owner = ObjectGuid::create_player(1, 64); + let other_player = ObjectGuid::create_player(1, 65); + let mut loot = authoritative_test_loot_like_cpp(0, true); + loot.loot_method = LOOT_METHOD_ROUND_ROBIN_LIKE_CPP; + loot.round_robin_player = round_robin_owner; + loot.allowed_looters = vec![round_robin_owner, other_player]; + loot.items[0].allowed_looters = vec![round_robin_owner, other_player]; + loot.items[0].flags.follow_loot_rules = true; + + assert!(creature_loot_is_allowed_to_player_like_cpp( + true, + false, + &loot, + round_robin_owner, + )); + assert!( + !creature_loot_is_allowed_to_player_like_cpp(true, false, &loot, other_player), + "ordinary shared round-robin loot belongs only to the selected player" + ); + assert!( + !creature_loot_is_allowed_to_player_like_cpp(false, false, &loot, round_robin_owner,), + "C++ rejects loot visibility for a living creature" + ); + assert!( + !creature_loot_is_allowed_to_player_like_cpp(true, true, &loot, round_robin_owner,), + "C++ HasPendingBind suppresses loot visibility" + ); + + loot.items[0].flags.follow_loot_rules = false; + assert!( + creature_loot_is_allowed_to_player_like_cpp(true, false, &loot, other_player), + "quest/conditional/free-for-player loot remains visible outside round robin" + ); + } + + #[tokio::test] + async fn creature_loot_release_command_retries_without_blocking_source_like_cpp() { + let (command_tx, command_rx) = flume::bounded(1); + command_tx + .send(SessionCommand::RefreshVisibleGameobjectsOrSpellClicksLikeCpp) + .unwrap(); + let creature_guid = test_creature_guid(19_121); + assert_eq!( + queue_creature_loot_release_command_reliably_like_cpp( + &command_tx, + SessionCommand::SendCreatureLootReleaseValuesUpdateLikeCpp( + wow_network::SendCreatureLootReleaseValuesUpdateLikeCppCommand { + creature_guid, + map_id: 0, + instance_id: 0, + unit_values_update: UnitDataValuesDeltaUpdate::default(), + authority: None, + }, + ), + ), + CreatureLootReleaseCommandQueueOutcomeLikeCpp::Retrying, + "a full peer queue must schedule retry without blocking this session" + ); + assert!(matches!( + command_rx.recv().unwrap(), + SessionCommand::RefreshVisibleGameobjectsOrSpellClicksLikeCpp + )); + let queued = tokio::time::timeout(Duration::from_secs(1), command_rx.recv_async()) + .await + .expect("detached retry should enqueue after capacity opens") + .unwrap(); + assert!(matches!( + queued, + SessionCommand::SendCreatureLootReleaseValuesUpdateLikeCpp(command) + if command.creature_guid == creature_guid + )); + } + #[tokio::test] async fn creature_owned_loot_release_does_not_extend_expired_corpse_like_cpp() { let (mut session, send_rx) = make_session_with_send(); @@ -26171,13 +26656,14 @@ mod tests { #[tokio::test] async fn creature_owned_loot_release_fully_consumed_removes_lootable_dynflag_like_cpp() { - let (mut session, send_rx) = make_session_with_send(); + let (mut session, send_rx) = make_session_with_send_capacity(4); let player_guid = ObjectGuid::create_player(1, 42); let loot_guid = test_creature_guid(19_116); let creature = make_canonical_creature_for_session(&session, loot_guid); attach_canonical_creature(&mut session, creature); session.set_player_guid(Some(player_guid)); session.set_active_loot_guid(loot_guid); + session.client_visible_guids_like_cpp.insert(loot_guid); register_test_creature_like_cpp(&mut session, test_creature(loot_guid, false)); let _ = session.mutate_world_creature(loot_guid, |creature| { creature.apply_corpse_loot_flags_after_death_state_like_cpp(true, false); @@ -26211,7 +26697,14 @@ mod tests { .handle_loot_release(loot_release_packet(loot_guid)) .await; - assert!(send_rx.try_recv().is_ok()); + assert_eq!( + drain_server_opcodes_like_cpp(&send_rx), + vec![ + wow_constants::ServerOpcodes::LootRelease as u16, + wow_constants::ServerOpcodes::UpdateObject as u16, + ], + "C++ ForceUpdateFieldChange must become a visible VALUES update so the client removes the loot cursor" + ); assert!( !session .mutate_world_creature(loot_guid, |creature| creature From 9ca5eae87a1495c949020ead474bea789e32dfad Mon Sep 17 00:00:00 2001 From: cDmonio Date: Sun, 19 Jul 2026 22:15:38 +0000 Subject: [PATCH 08/14] [08.5] Preserve quest-bound loot objectives --- crates/wow-world/src/handlers/loot.rs | 397 +++++++++++++++++++++++-- crates/wow-world/src/handlers/quest.rs | 108 ++++++- 2 files changed, 471 insertions(+), 34 deletions(-) diff --git a/crates/wow-world/src/handlers/loot.rs b/crates/wow-world/src/handlers/loot.rs index 2f2b44ba8..ea57576eb 100644 --- a/crates/wow-world/src/handlers/loot.rs +++ b/crates/wow-world/src/handlers/loot.rs @@ -10833,10 +10833,36 @@ impl WorldSession { (None, None) => None, _ => return false, }; + // C++ Loot::AutoStore validates CanStoreNewItem before StoreNewItem. + // That ordering still applies when StoreNewItem later converts a + // quest-bound Item into objective credit and returns nullptr. + let Some((store_result, mut store_dest, _)) = + self.plan_store_new_direct_inventory_item(item_id, count) + else { + self.send_equip_error(InventoryResult::ItemNotFound, None, None, 0, 0); + return false; + }; + if store_result != InventoryResult::Ok { + self.send_equip_error(store_result, None, None, 0, 0); + return false; + } + let quest_log_item_id = self + .load_creature_item_template_addon_loot_metadata_like_cpp(item_id) + .await + .quest_log_item_id + .try_into() + .unwrap_or(0); + let bound_objective_plan = self + .plan_quest_source_item_bound_objective_persistence_like_cpp( + item_id, + quest_log_item_id, + count, + ); #[cfg(test)] if let Some(grants) = self.loot_item_store_test_grants_like_cpp.clone() { let success = self.loot_item_store_test_success_like_cpp; let commit_gate = self.loot_item_store_test_commit_gate_like_cpp.clone(); + let materializes_inventory_item = bound_objective_plan.is_none(); let durable_completion_context = stored_item_loot_source .map(|owner_guid| (owner_guid, loot_entry.loot_list_id, player_guid, true)) .or_else(|| { @@ -10883,7 +10909,9 @@ impl WorldSession { if !success { return Err(()); } - grants.fetch_add(1, Ordering::SeqCst); + if materializes_inventory_item { + grants.fetch_add(1, Ordering::SeqCst); + } Ok(()) }, claim.cloned(), @@ -10894,6 +10922,24 @@ impl WorldSession { if !matches!(worker.await, Ok(Ok(()))) { return false; } + if let Some(plan) = bound_objective_plan.as_ref() { + let applied = self + .apply_quest_source_item_bound_objective_preflight_like_cpp( + item_id, + quest_log_item_id, + count, + ) + .await; + debug_assert!(applied.as_ref().is_some_and(|result| result.no_grant)); + debug_assert!(plan.statuses.iter().all(|planned| { + self.player_quests + .get(&planned.quest_id) + .is_some_and(|actual| { + actual.status == planned.status + && actual.objective_counts == planned.objective_counts + }) + })); + } if !self.publish_persisted_loot_item_removal_like_cpp( claim, claim_commit_context, @@ -10901,18 +10947,20 @@ impl WorldSession { ) { return false; } - self.send_loot_item_push_result( - player_guid, - ObjectGuid::EMPTY, - loot_entry, - 0, - 0, - 0, - count, - count, - false, - dungeon_encounter_id, - ); + if bound_objective_plan.is_none() { + self.send_loot_item_push_result( + player_guid, + ObjectGuid::EMPTY, + loot_entry, + 0, + 0, + 0, + count, + count, + false, + dungeon_encounter_id, + ); + } if let Some(runtime_inventory_applied) = runtime_inventory_applied { runtime_inventory_applied.store(true, Ordering::Release); } @@ -10921,17 +10969,130 @@ impl WorldSession { let Some(char_db) = self.char_db().map(Arc::clone) else { return false; }; - let Some((store_result, mut store_dest, _)) = - self.plan_store_new_direct_inventory_item(item_id, count) - else { - self.send_equip_error(InventoryResult::ItemNotFound, None, None, 0, 0); - return false; - }; - if store_result != InventoryResult::Ok { - self.send_equip_error(store_result, None, None, 0, 0); - return false; - } + if let Some(bound_objective_plan) = bound_objective_plan { + let mut tx = SqlTransaction::new(); + self.append_planned_quest_statuses_to_transaction_like_cpp( + &mut tx, + char_db.as_ref(), + player_guid.counter() as u64, + &bound_objective_plan.statuses, + ); + if let Some(item_guid) = stored_item_loot_source { + let mut delete_source = char_db.prepare(CharStatements::DEL_ITEMCONTAINER_ITEM); + delete_source.set_u64(0, item_guid.counter() as u64); + delete_source.set_u32(1, item_id); + delete_source.set_u32(2, count); + delete_source.set_u32(3, u32::from(loot_entry.loot_list_id)); + tx.append_expect_rows_affected(delete_source, 1); + } + let durable_completion_context = stored_item_loot_source + .map(|owner_guid| (owner_guid, loot_entry.loot_list_id, player_guid, true)) + .or_else(|| { + claim_commit_context.map(|context| { + ( + context.owner_guid, + context.loot_list_id, + context.player_guid, + false, + ) + }) + }); + let runtime_inventory_applied = + durable_completion_context.map(|_| Arc::new(AtomicBool::new(false))); + let durable_item_completion = durable_completion_context + .zip(runtime_inventory_applied.as_ref().map(Arc::clone)) + .map( + |( + (owner_guid, loot_list_id, player_guid, item_owner_auto_release), + runtime_inventory_applied, + )| { + ( + self.begin_durable_item_loot_persistence_like_cpp(), + DurableItemLootCompletionLikeCpp { + owner_guid, + loot_list_id, + player_guid, + item_owner_auto_release, + durable_item_money_applied_amount: None, + durable_item_money_notified_amount: None, + durable_item_money_balance_applied: None, + item_fanout: durable_item_fanout.clone(), + runtime_inventory_applied, + }, + ) + }, + ); + let persistence_char_db = Arc::clone(&char_db); + let persistence = match spawn_sql_loot_claim_persistence_worker_like_cpp( + async move { + tx.commit_with_outcome_like_cpp(persistence_char_db.pool()) + .await + }, + claim.cloned(), + durable_item_completion, + self.session_command_tx(), + ) { + Ok(persistence) => persistence, + Err(error) => { + warn!( + ?error, + "LootItem: quest-bound claim closed before persistence started" + ); + self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); + return false; + } + }; + match persistence.await { + Ok(Ok(())) => {} + Ok(Err(error)) => { + warn!(?error, "LootItem: quest-bound objective transaction failed"); + self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); + return false; + } + Err(error) => { + warn!( + ?error, + "LootItem: detached quest-bound transaction worker terminated" + ); + self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); + return false; + } + } + + let applied = self + .apply_quest_source_item_bound_objective_preflight_like_cpp( + item_id, + quest_log_item_id, + count, + ) + .await; + if !applied.as_ref().is_some_and(|result| result.no_grant) + || !bound_objective_plan.statuses.iter().all(|planned| { + self.player_quests + .get(&planned.quest_id) + .is_some_and(|actual| { + actual.status == planned.status + && actual.objective_counts == planned.objective_counts + }) + }) + { + self.kick("durable quest-bound loot state diverged; relog required"); + return true; + } + if let Some(runtime_inventory_applied) = runtime_inventory_applied { + runtime_inventory_applied.store(true, Ordering::Release); + } + if !self.publish_persisted_loot_item_removal_like_cpp( + claim, + claim_commit_context, + durable_item_fanout.as_ref(), + ) { + return false; + } + self.sync_player_registry_state_like_cpp(); + return true; + } let store_random_properties = { let mut rng = self.represented_runtime_subrng_like_cpp(); self.generate_loot_store_random_properties_with_rng_like_cpp(item_id, &mut rng) @@ -11241,12 +11402,6 @@ impl WorldSession { runtime_inventory_applied.store(true, Ordering::Release); } - let quest_log_item_id = self - .load_creature_item_template_addon_loot_metadata_like_cpp(item_id) - .await - .quest_log_item_id - .try_into() - .unwrap_or(0); let mut changed_quest_ids = self .apply_quest_source_item_added_non_bound_objective_progress_like_cpp( item_id, @@ -14615,6 +14770,8 @@ mod tests { first.set_player_guid(Some(first_guid)); second.set_player_guid(Some(second_guid)); + install_limited_test_item_template(&mut first, 25, 0); + install_limited_test_item_template(&mut second, 25, 0); first.set_player_position_like_cpp(Position::ZERO); second.set_player_position_like_cpp(Position::ZERO); first.set_map_manager(Arc::clone(&shared_map)); @@ -14965,6 +15122,188 @@ mod tests { ); } + fn install_quest_bound_loot_objective_like_cpp( + session: &mut WorldSession, + quest_id: u32, + item_id: u32, + current_count: i32, + required_count: i32, + ) { + let mut quest = test_quest_template(quest_id); + quest.objectives.push(QuestObjective { + id: quest_id * 10, + quest_id, + obj_type: 1, + order: 0, + storage_index: 0, + object_id: item_id as i32, + amount: required_count, + flags: 0, + flags2: 1, + progress_bar_weight: 0.0, + description: String::new(), + }); + session.set_quest_store(Arc::new(QuestStore::from_quests_like_cpp([quest]))); + session.player_quests.insert( + quest_id, + crate::handlers::quest::PlayerQuestStatus { + quest_id, + status: crate::conditions::QUEST_STATUS_INCOMPLETE_LIKE_CPP, + explored: false, + accept_time_secs: 0, + end_time_secs: 0, + objective_counts: vec![current_count], + slot: 0, + }, + ); + } + + #[tokio::test] + async fn quest_bound_loot_credits_objective_without_physical_item_like_cpp() { + let (mut first, first_rx, _second, _second_rx, owner, first_guid, _) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(0, true), + ); + let _ = drain_server_opcodes_like_cpp(&first_rx); + let quest_id = 8_336; + let item_id = 25; + install_quest_bound_loot_objective_like_cpp(&mut first, quest_id, item_id, 5, 6); + let grants = Arc::new(AtomicUsize::new(0)); + first.set_loot_item_store_test_seam_like_cpp(Arc::clone(&grants), true); + + first + .handle_loot_item(loot_item_packet( + represented_loot_object_guid_like_cpp(owner), + 0, + )) + .await; + + assert_eq!( + grants.load(Ordering::SeqCst), + 0, + "C++ StoreNewItem returns nullptr for quest-bound objective credit" + ); + let status = first.player_quests.get(&quest_id).expect("active quest"); + assert_eq!(status.objective_counts, vec![6]); + assert_eq!( + status.status, + crate::conditions::QUEST_STATUS_COMPLETE_LIKE_CPP + ); + assert!(!first.item_loot_quest_status_allows_like_cpp( + item_id, + true, + ItemTemplateAddonLootMetadataLikeCpp::default(), + )); + + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let snapshot = authority.snapshot_for_player_like_cpp(first_guid).unwrap(); + assert!(snapshot.loot.items[0].taken); + assert_eq!(snapshot.loot.unlooted_count, 0); + + let opcodes = drain_server_opcodes_like_cpp(&first_rx); + let bound_credit = wow_constants::ServerOpcodes::ItemPushResult as u16; + let loot_removed = wow_constants::ServerOpcodes::LootRemoved as u16; + assert!(opcodes.contains(&bound_credit), "{opcodes:?}"); + assert!(opcodes.contains(&loot_removed), "{opcodes:?}"); + assert!( + opcodes.iter().position(|opcode| *opcode == bound_credit) + < opcodes.iter().position(|opcode| *opcode == loot_removed), + "C++ bound objective notification precedes the committed loot removal: {opcodes:?}" + ); + } + + #[tokio::test] + async fn failed_quest_bound_loot_persistence_rolls_back_credit_and_claim_like_cpp() { + let (mut first, first_rx, _second, _second_rx, owner, first_guid, _) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(0, true), + ); + let _ = drain_server_opcodes_like_cpp(&first_rx); + let quest_id = 8_336; + install_quest_bound_loot_objective_like_cpp(&mut first, quest_id, 25, 5, 6); + let grants = Arc::new(AtomicUsize::new(0)); + first.set_loot_item_store_test_seam_like_cpp(Arc::clone(&grants), false); + + first + .handle_loot_item(loot_item_packet( + represented_loot_object_guid_like_cpp(owner), + 0, + )) + .await; + + assert_eq!(grants.load(Ordering::SeqCst), 0); + let status = first.player_quests.get(&quest_id).expect("active quest"); + assert_eq!(status.objective_counts, vec![5]); + assert_eq!( + status.status, + crate::conditions::QUEST_STATUS_INCOMPLETE_LIKE_CPP + ); + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let snapshot = authority.snapshot_for_player_like_cpp(first_guid).unwrap(); + assert!(!snapshot.loot.items[0].taken); + assert_eq!(snapshot.loot.unlooted_count, 1); + } + + #[tokio::test] + async fn quest_bound_loot_still_requires_can_store_new_item_like_cpp() { + let (mut first, first_rx, _second, _second_rx, owner, first_guid, _) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(0, true), + ); + let _ = drain_server_opcodes_like_cpp(&first_rx); + let quest_id = 8_336; + let item_id = 25; + install_quest_bound_loot_objective_like_cpp(&mut first, quest_id, item_id, 5, 6); + install_limited_test_item_template(&mut first, item_id, 1); + let existing_guid = ObjectGuid::create_item(1, 83_360); + first.insert_inventory_item_like_cpp( + INVENTORY_SLOT_ITEM_START, + InventoryItem { + guid: existing_guid, + entry_id: item_id, + db_guid: existing_guid.counter() as u64, + inventory_type: None, + }, + ); + let existing_item = first.make_inventory_item_object( + existing_guid, + item_id, + first_guid, + 1, + 0, + ItemContext::None, + INVENTORY_SLOT_ITEM_START, + ); + first.insert_inventory_item_object(existing_item); + let grants = Arc::new(AtomicUsize::new(0)); + first.set_loot_item_store_test_seam_like_cpp(Arc::clone(&grants), true); + + first + .handle_loot_item(loot_item_packet( + represented_loot_object_guid_like_cpp(owner), + 0, + )) + .await; + + assert_eq!(grants.load(Ordering::SeqCst), 0); + let status = first.player_quests.get(&quest_id).expect("active quest"); + assert_eq!(status.objective_counts, vec![5]); + assert_eq!( + status.status, + crate::conditions::QUEST_STATUS_INCOMPLETE_LIKE_CPP + ); + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let snapshot = authority.snapshot_for_player_like_cpp(first_guid).unwrap(); + assert!(!snapshot.loot.items[0].taken); + assert_eq!(snapshot.loot.unlooted_count, 1); + } + #[tokio::test] async fn item_grant_commit_unknown_quarantines_claim_and_kicks_even_when_queue_full() { let (mut first, _first_rx, _second, _second_rx, owner, first_guid, _) = diff --git a/crates/wow-world/src/handlers/quest.rs b/crates/wow-world/src/handlers/quest.rs index e276c8476..a1c202780 100644 --- a/crates/wow-world/src/handlers/quest.rs +++ b/crates/wow-world/src/handlers/quest.rs @@ -159,9 +159,17 @@ enum QuestSourceItemStoreOutcomeLikeCpp { } #[derive(Debug, Clone, PartialEq, Eq)] -struct QuestSourceItemBoundPreflightLikeCpp { - no_grant: bool, - changed_quest_ids: Vec, +pub(crate) struct QuestSourceItemBoundPreflightLikeCpp { + pub(crate) no_grant: bool, + pub(crate) changed_quest_ids: Vec, +} + +/// Durable snapshot of C++ `StoreNewItem`'s first, quest-bound +/// `ItemAddedQuestCheck` pass. A single matching bound objective consumes the +/// loot award as quest credit without materialising an inventory Item. +#[derive(Debug, Clone)] +pub(crate) struct QuestSourceItemBoundPersistencePlanLikeCpp { + pub(crate) statuses: Vec, } fn reputation_rank_from_standing_like_cpp(standing: i32) -> u8 { @@ -1571,6 +1579,93 @@ impl WorldSession { } } + /// Pure form of the first C++ `Player::StoreNewItem` quest pass: + /// `ItemAddedQuestCheck(itemId, count, true, &hadBoundItemObjective)`. + /// + /// `UpdateQuestObjectiveProgress` stops after the first matching + /// `QUEST_OBJECTIVE_FLAG_2_QUEST_BOUND_ITEM` objective. When it changes + /// one objective, `StoreNewItem` returns `nullptr` and no physical Item is + /// created. Keeping this as a snapshot lets loot persist the objective and + /// consume its object-owned claim in one SQL/authority transaction. + pub(crate) fn plan_quest_source_item_bound_objective_persistence_like_cpp( + &self, + entry_id: u32, + quest_log_item_id: u32, + count: u32, + ) -> Option { + let quest_store = self.quest_store.as_ref()?; + let count_i32 = i32::try_from(count).unwrap_or(i32::MAX); + let entry_object_id = i32::try_from(entry_id).unwrap_or(i32::MAX); + let quest_log_object_id = i32::try_from(quest_log_item_id).unwrap_or(i32::MAX); + + for object_id in [entry_object_id, quest_log_object_id] { + if object_id == quest_log_object_id && quest_log_item_id == 0 { + continue; + } + + for current_status in self.player_quests.values() { + if current_status.status != QUEST_STATUS_INCOMPLETE_LIKE_CPP { + continue; + } + let Some(quest) = quest_store.get(current_status.quest_id) else { + continue; + }; + + for (objective_index, objective) in quest.objectives.iter().enumerate() { + if objective.obj_type != QUEST_OBJECTIVE_ITEM_LIKE_CPP_LOCAL + || (objective.flags2 + & QUEST_OBJECTIVE_FLAG_2_QUEST_BOUND_ITEM_LIKE_CPP_LOCAL) + == 0 + || objective.object_id != object_id + || !Self::represented_quest_objective_completable_like_cpp( + current_status, + quest, + objective_index, + ) + { + continue; + } + + let Ok(storage_index) = usize::try_from(objective.storage_index) else { + continue; + }; + let current = current_status + .objective_counts + .get(storage_index) + .copied() + .unwrap_or(0); + if current >= objective.amount { + continue; + } + + let mut planned_status = current_status.clone(); + if planned_status.objective_counts.len() <= storage_index { + planned_status.objective_counts.resize(storage_index + 1, 0); + } + let new_count = current.saturating_add(count_i32).clamp(0, objective.amount); + planned_status.objective_counts[storage_index] = new_count; + let quest_already_rewarded = self.rewarded_quests.contains(&quest.id); + if new_count >= objective.amount + && Self::represented_can_complete_quest_after_objective_like_cpp( + &planned_status, + quest, + objective.id, + quest_already_rewarded, + ) + { + planned_status.status = QUEST_STATUS_COMPLETE_LIKE_CPP; + } + + return Some(QuestSourceItemBoundPersistencePlanLikeCpp { + statuses: vec![planned_status], + }); + } + } + } + + None + } + async fn apply_quest_source_item_bound_objective_progress_for_object_like_cpp( &mut self, quest_store: &QuestStore, @@ -1580,7 +1675,7 @@ impl WorldSession { let mut updated_counts = Vec::new(); let mut quests_to_complete = Vec::new(); - for status in self.player_quests.values_mut() { + 'quests: for status in self.player_quests.values_mut() { if status.status != QUEST_STATUS_INCOMPLETE_LIKE_CPP { continue; } @@ -1632,6 +1727,9 @@ impl WorldSession { { quests_to_complete.push(status.quest_id); } + // C++ `UpdateQuestObjectiveProgress` stops after the first + // credited quest-bound Item objective. + break 'quests; } } @@ -1645,7 +1743,7 @@ impl WorldSession { updated_counts } - async fn apply_quest_source_item_bound_objective_preflight_like_cpp( + pub(crate) async fn apply_quest_source_item_bound_objective_preflight_like_cpp( &mut self, entry_id: u32, quest_log_item_id: u32, From 9bb9acd9ab59f628488c53749d65459bc0fbdb3a Mon Sep 17 00:00:00 2001 From: cDmonio Date: Sun, 19 Jul 2026 23:19:20 +0000 Subject: [PATCH 09/14] [08.5] Preserve quest-required loot gates --- crates/world-server/src/main.rs | 21 +++++++++- crates/wow-world/src/handlers/loot.rs | 56 +++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/crates/world-server/src/main.rs b/crates/world-server/src/main.rs index b9a3dab7d..d22e204f8 100644 --- a/crates/world-server/src/main.rs +++ b/crates/world-server/src/main.rs @@ -7130,13 +7130,20 @@ async fn load_loot_template_rows_like_cpp( } loop { + // Trinity's QuestRequired column is a signed TINYINT(1). Reading it + // as u8 makes sqlx reject the signed MySQL type; defaulting that + // decode failure to zero turns every quest-only drop into normal + // loot. Fail startup instead of silently disabling the quest gate. + let quest_required = result + .try_read::(4) + .with_context(|| format!("failed to decode {kind:?} loot QuestRequired as TINYINT"))?; rows.push(LootTemplateRow { entry: result.try_read::(0).unwrap_or(0), item: wow_loot::LootStoreItem { item_id: result.try_read::(1).unwrap_or(0), reference: result.try_read::(2).unwrap_or(0), chance: result.try_read::(3).unwrap_or(0.0), - needs_quest: result.try_read::(4).unwrap_or(0) != 0, + needs_quest: loot_quest_required_from_signed_db_like_cpp(quest_required), loot_mode: result.try_read::(5).unwrap_or(0), group_id: result.try_read::(6).unwrap_or(0), min_count: result.try_read::(7).unwrap_or(0), @@ -7152,6 +7159,10 @@ async fn load_loot_template_rows_like_cpp( Ok(rows) } +const fn loot_quest_required_from_signed_db_like_cpp(value: i8) -> bool { + value != 0 +} + fn loot_store_all_rows_statement_like_cpp(kind: LootStoreKind) -> WorldStatements { match kind { LootStoreKind::Creature => WorldStatements::SEL_CREATURE_LOOT_TEMPLATE_ALL_ROWS, @@ -14374,6 +14385,7 @@ mod tests { legacy_creature_aggro_config_like_cpp, legacy_creature_global_runtime_enabled_from_config_like_cpp, load_loaded_grid_area_triggers_like_cpp, load_world_config_from, loot_drop_rates_like_cpp, + loot_quest_required_from_signed_db_like_cpp, materialize_game_event_quest_complete_db_bridge_like_cpp, materialize_game_event_world_event_state_db_bridge_like_cpp, max_core_stuck_time_ms_like_cpp, max_core_stuck_time_secs_like_cpp, @@ -14429,6 +14441,13 @@ mod tests { packets::chat::{ChatMsg, ChatPkt}, }; + #[test] + fn signed_tinyint_quest_required_preserves_cpp_boolean_semantics() { + assert!(!loot_quest_required_from_signed_db_like_cpp(0)); + assert!(loot_quest_required_from_signed_db_like_cpp(1)); + assert!(loot_quest_required_from_signed_db_like_cpp(-1)); + } + fn legacy_runtime_world_map_store_like_cpp() -> wow_data::MapStore { wow_data::MapStore::from_entries([wow_data::MapEntry { id: 0, diff --git a/crates/wow-world/src/handlers/loot.rs b/crates/wow-world/src/handlers/loot.rs index ea57576eb..d7bc5270a 100644 --- a/crates/wow-world/src/handlers/loot.rs +++ b/crates/wow-world/src/handlers/loot.rs @@ -18478,6 +18478,62 @@ mod tests { ); } + #[tokio::test] + async fn quest_required_creature_loot_is_not_generated_after_completion_like_cpp() { + let (mut session, _) = make_session_with_send_capacity(4); + let player_guid = ObjectGuid::create_player(1, 42); + let quest_id = 8_336; + let item_id = 20_482; + let loot_id = 15_274; + session.set_player_guid(Some(player_guid)); + install_limited_test_item_template(&mut session, item_id, 0); + install_quest_bound_loot_objective_like_cpp(&mut session, quest_id, item_id, 6, 6); + session.player_quests.get_mut(&quest_id).unwrap().status = + crate::conditions::QUEST_STATUS_COMPLETE_LIKE_CPP; + + let mut creature_store = LootStore::for_kind_like_cpp(LootStoreKind::Creature); + creature_store + .load_rows_like_cpp( + [LootTemplateRow { + entry: loot_id, + item: LootStoreItem { + item_id, + reference: 0, + chance: 100.0, + needs_quest: true, + loot_mode: LOOT_MODE_DEFAULT_LIKE_CPP, + group_id: 0, + min_count: 1, + max_count: 1, + }, + }], + |_| true, + ) + .unwrap(); + let mut stores = LootStores::new(); + stores.insert(LootStoreKind::Creature, creature_store); + session.set_loot_stores(Arc::new(stores)); + + let complete_loot = session + .generate_represented_creature_loot_items_for_player_like_cpp(loot_id, player_guid) + .await + .unwrap(); + assert!( + complete_loot.is_empty(), + "C++ LootItem::AllowedForPlayer rejects QuestRequired items after HasQuestForItem becomes false" + ); + + let status = session.player_quests.get_mut(&quest_id).unwrap(); + status.status = crate::conditions::QUEST_STATUS_INCOMPLETE_LIKE_CPP; + status.objective_counts[0] = 5; + let incomplete_loot = session + .generate_represented_creature_loot_items_for_player_like_cpp(loot_id, player_guid) + .await + .unwrap(); + assert_eq!(incomplete_loot.len(), 1); + assert!(incomplete_loot[0].flags.needs_quest); + } + fn install_master_loot_group( session: &mut WorldSession, master_guid: ObjectGuid, From 8c29ebfce8cb6afb804b2108a9562d03dd5598a2 Mon Sep 17 00:00:00 2001 From: cDmonio Date: Sun, 19 Jul 2026 23:36:21 +0000 Subject: [PATCH 10/14] ci: install preflight ripgrep dependency --- .github/workflows/rust-ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index ea2ce236c..6f2c617a3 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -36,6 +36,9 @@ jobs: with: components: rustfmt + - name: Install preflight dependencies + run: sudo apt-get update && sudo apt-get install --yes ripgrep + - name: Validate local preflight harness run: ./tools/pr-preflight.sh self-test From fa391d978ab4313522bbd49539f71fe22d2fb766 Mon Sep 17 00:00:00 2001 From: cDmonio Date: Sun, 19 Jul 2026 23:57:30 +0000 Subject: [PATCH 11/14] test: align bound objective expectation with C++ --- crates/wow-world/src/handlers/quest.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/wow-world/src/handlers/quest.rs b/crates/wow-world/src/handlers/quest.rs index a1c202780..eaee6956c 100644 --- a/crates/wow-world/src/handlers/quest.rs +++ b/crates/wow-world/src/handlers/quest.rs @@ -13123,8 +13123,8 @@ mod tests { } #[tokio::test] - async fn quest_confirm_accept_source_item_multiple_bound_objectives_still_creates_item_like_cpp() - { + async fn quest_confirm_accept_source_item_multiple_bound_objectives_stops_after_first_like_cpp() + { let (mut session, send_rx) = make_session(); let receiver_guid = session.player_guid().unwrap(); let sender_guid = ObjectGuid::create_player(1, 192); @@ -13174,7 +13174,7 @@ mod tests { .player_quests .get(&quest_id) .expect("source-item quest should add local quest state"); - assert_eq!(status.objective_counts, vec![2, 2]); + assert_eq!(status.objective_counts, vec![2, 0]); let stored_source_item_count: u32 = session .inventory_items_like_cpp() .values() @@ -13182,7 +13182,7 @@ mod tests { .filter_map(|item| session.inventory_item_objects_like_cpp().get(&item.guid)) .map(|item| item.count()) .sum(); - assert_eq!(stored_source_item_count, 2); + assert_eq!(stored_source_item_count, 0); assert_eq!( session.represented_quest_confirm_accepts_like_cpp(), &[RepresentedQuestConfirmAcceptLikeCpp { @@ -13190,7 +13190,7 @@ mod tests { sender_guid_before_clear: sender_guid, quest_id, raw_quest_id: quest_id as i32, - reason: RepresentedQuestConfirmAcceptOutcomeReasonLikeCpp::ReceiverGiveQuestSourceItemStoredNewItem, + reason: RepresentedQuestConfirmAcceptOutcomeReasonLikeCpp::ReceiverGiveQuestSourceItemBoundObjectiveNoGrant, object_accessor_unrepresented: true, party_runtime_unrepresented: true, can_add_source_item_unrepresented: false, @@ -13208,7 +13208,7 @@ mod tests { packet.read_uint16().ok() == Some(wow_constants::ServerOpcodes::ItemPushResult as u16) }), - "multiple bound objectives do not trigger C++ no-grant path, so SendNewItem still runs" + "C++ sends the bound-objective ItemPushResult without materializing an inventory Item" ); assert!(sender_rx.try_recv().is_err()); } From 8e2502c6a6b35c168e1acf1bf16f95e7c8b8c671 Mon Sep 17 00:00:00 2001 From: cDmonio Date: Mon, 20 Jul 2026 00:07:47 +0000 Subject: [PATCH 12/14] ci: retry transient GitHub review API failures --- .github/workflows/codex-review-gate.yml | 36 ++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codex-review-gate.yml b/.github/workflows/codex-review-gate.yml index 958224125..f6c7c6be6 100644 --- a/.github/workflows/codex-review-gate.yml +++ b/.github/workflows/codex-review-gate.yml @@ -62,9 +62,21 @@ jobs: "p3 badge", ) + class TransientGitHubApiError(RuntimeError): + pass + def gh_json(path): - output = subprocess.check_output(["gh", "api", path], text=True) - return json.loads(output) + result = subprocess.run( + ["gh", "api", path], + text=True, + capture_output=True, + ) + if result.returncode: + error = result.stderr.strip() or result.stdout.strip() + if re.search(r"\(HTTP 5\d\d\)", error): + raise TransientGitHubApiError(error) + raise RuntimeError(f"gh api {path} failed: {error}") + return json.loads(result.stdout) def gh_json_pages(path): page = 1 @@ -157,7 +169,25 @@ jobs: deadline = time.time() + timeout_seconds while True: - state, items = verdict() + try: + state, items = verdict() + except TransientGitHubApiError as error: + remaining = int(deadline - time.time()) + if remaining <= 0: + print( + "Timed out waiting for GitHub API recovery while checking " + f"the Codex verdict: {error}" + ) + sys.exit(1) + + retry_seconds = min(poll_seconds, remaining) + print( + "Transient GitHub API failure while checking the Codex verdict; " + f"retrying in {retry_seconds}s ({remaining}s left): {error}" + ) + time.sleep(retry_seconds) + continue + if state == "pass": print("Found clean Codex review for current HEAD:") for kind, url in items: From 65cc320f2323d9a5146d4dbc5a4358292dd5d5df Mon Sep 17 00:00:00 2001 From: cDmonio Date: Mon, 20 Jul 2026 10:28:45 +0000 Subject: [PATCH 13/14] fix review findings for atomic loot claims --- crates/wow-loot/src/authority.rs | 102 ++++++++++- crates/wow-world/src/handlers/group.rs | 58 ++++++- crates/wow-world/src/handlers/loot.rs | 230 +++++++++++++++++++++---- crates/wow-world/src/handlers/quest.rs | 86 ++++++++- crates/wow-world/src/session.rs | 88 ++++++++++ 5 files changed, 517 insertions(+), 47 deletions(-) diff --git a/crates/wow-loot/src/authority.rs b/crates/wow-loot/src/authority.rs index ae97756d9..e0481bbae 100644 --- a/crates/wow-loot/src/authority.rs +++ b/crates/wow-loot/src/authority.rs @@ -639,16 +639,43 @@ impl OwnedLootAuthority { Some(apply_before_unlock()) } - /// Captures the exact fully-looted generation for lifecycle work that is - /// triggered after a detached durable claim commits. Unlike - /// `close_viewer_if_generation_like_cpp`, this does not require or mutate - /// `players_looting`. + /// Runs lifecycle work for a detached durable claim only while every + /// authoritative `Loot::PlayersLooting` set is empty. Unlike the normal + /// `DoLootRelease` path, the worker is not itself closing a client view; + /// it must therefore leave the object active for any other open viewer. + /// The no-viewer check belongs in the same critical section as the map + /// mutation so a concurrent open cannot slip between observation and use. + pub fn with_unviewed_fully_looted_lifecycle_observation_like_cpp( + &self, + expected_object_generation: u64, + expected_lifecycle_revision: u64, + apply_before_unlock: impl FnOnce() -> R, + ) -> Option { + let state = self.lock_state(); + if state.retired + || state.generation != expected_object_generation + || state.lifecycle_revision != expected_lifecycle_revision + || !active_loot_pools_fully_looted_like_cpp(&state) + || !active_loot_pools_have_no_viewers_like_cpp(&state) + { + return None; + } + Some(apply_before_unlock()) + } + + /// Captures the exact fully-looted, globally unviewed generation for + /// lifecycle work triggered after a detached durable claim commits. + /// Unlike `close_viewer_if_generation_like_cpp`, this does not mutate + /// `players_looting`; any open shared or personal view rejects the work. #[must_use] - pub fn fully_looted_lifecycle_observation_like_cpp( + pub fn fully_looted_unviewed_lifecycle_observation_like_cpp( &self, ) -> Option { let state = self.lock_state(); - if state.retired || !active_loot_pools_fully_looted_like_cpp(&state) { + if state.retired + || !active_loot_pools_fully_looted_like_cpp(&state) + || !active_loot_pools_have_no_viewers_like_cpp(&state) + { return None; } Some(LootFullyLootedLifecycleObservation { @@ -1892,6 +1919,17 @@ fn active_loot_pools_fully_looted_like_cpp(state: &AuthorityState) -> bool { .all(CreatureLoot::is_looted_like_cpp) } +fn active_loot_pools_have_no_viewers_like_cpp(state: &AuthorityState) -> bool { + state + .shared + .as_ref() + .is_none_or(|loot| loot.players_looting.is_empty()) + && state + .personal + .values() + .all(|loot| loot.players_looting.is_empty()) +} + fn active_loot_pools_fully_skinned_like_cpp(state: &AuthorityState) -> bool { let skinning = wow_constants::LootType::Skinning as u8; if state @@ -3265,6 +3303,58 @@ mod tests { assert_eq!(applications.load(Ordering::SeqCst), 1); } + #[test] + fn detached_lifecycle_observation_requires_every_loot_view_to_be_closed_like_cpp() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let authority = OwnedLootAuthority::new(); + let first = player(1); + let second = player(2); + authority.replace_like_cpp(Some(loot(owner(1), 0, vec![])), HashMap::new()); + let first_open = authority.add_viewer_like_cpp(first).unwrap(); + let second_open = authority.add_viewer_like_cpp(second).unwrap(); + + authority + .close_viewer_if_generation_like_cpp(second_open.generation, second) + .unwrap(); + assert!( + authority + .fully_looted_unviewed_lifecycle_observation_like_cpp() + .is_none(), + "the remaining viewer belongs to the global authority, not the detached worker session" + ); + + authority + .close_viewer_if_generation_like_cpp(first_open.generation, first) + .unwrap(); + let observation = authority + .fully_looted_unviewed_lifecycle_observation_like_cpp() + .expect("the fully-looted owner is unviewed after both releases"); + let applications = AtomicUsize::new(0); + assert!( + authority + .with_unviewed_fully_looted_lifecycle_observation_like_cpp( + observation.object_generation, + observation.lifecycle_revision, + || applications.fetch_add(1, Ordering::SeqCst), + ) + .is_some() + ); + + authority.add_viewer_like_cpp(first).unwrap(); + assert!( + authority + .with_unviewed_fully_looted_lifecycle_observation_like_cpp( + observation.object_generation, + observation.lifecycle_revision, + || applications.fetch_add(1, Ordering::SeqCst), + ) + .is_none(), + "a view opened after the snapshot must still veto the serialized lifecycle mutation" + ); + assert_eq!(applications.load(Ordering::SeqCst), 1); + } + #[test] fn round_robin_clear_is_generation_guarded_and_returns_authoritative_snapshot_like_cpp() { let authority = OwnedLootAuthority::new(); diff --git a/crates/wow-world/src/handlers/group.rs b/crates/wow-world/src/handlers/group.rs index fd5c7d92a..23c8147ef 100644 --- a/crates/wow-world/src/handlers/group.rs +++ b/crates/wow-world/src/handlers/group.rs @@ -2025,7 +2025,10 @@ impl WorldSession { } } - if let Some(group) = group_reg.get(&group_guid) { + // `queue_visible...` may wait on a full member command channel. Clone + // the value and release DashMap's read guard before the first await so + // unrelated group mutations are never stalled behind that backpressure. + if let Some(group) = group_reg.get(&group_guid).map(|group| group.clone()) { send_party_update(&group, ®istry, vra); queue_visible_gameobjects_or_spellclicks_refresh_like_cpp(&group, ®istry, my_guid) .await; @@ -6049,6 +6052,59 @@ mod tests { ); } + #[tokio::test] + async fn convert_raid_releases_group_guard_before_refresh_backpressure_like_cpp() { + let (mut session, _send_rx) = make_session_with_send(); + let leader = ObjectGuid::create_player(1, 142); + let member = ObjectGuid::create_player(1, 143); + let group_registry = Arc::new(GroupRegistry::default()); + let mut group = GroupInfo::new(leader); + group.add_member(member); + let group_guid = group.group_guid; + group_registry.insert(group_guid, group); + + let player_registry = Arc::new(PlayerRegistry::default()); + let (leader_tx, _leader_rx) = bounded(8); + player_registry.insert(leader, broadcast_info(leader, leader_tx)); + let (member_tx, _member_rx) = bounded(8); + // PartyUpdate fills this single slot; the following async refresh then + // waits for its timeout and exposes any DashMap guard held across it. + let (member_command_tx, _member_command_rx) = flume::bounded(1); + player_registry.insert( + member, + broadcast_info_with_command_tx(member, member_tx, member_command_tx), + ); + + session.set_player_guid(Some(leader)); + session.group_guid = Some(group_guid); + session.set_player_registry(player_registry); + session.set_group_registry( + Arc::clone(&group_registry), + Arc::new(PendingInvites::default()), + ); + + let mut conversion = Box::pin(session.handle_convert_raid(convert_raid_packet(true))); + tokio::select! { + () = &mut conversion => panic!("refresh should be waiting on the full command channel"), + () = tokio::time::sleep(Duration::from_millis(20)) => {} + } + + let writer_registry = Arc::clone(&group_registry); + let writer = tokio::task::spawn_blocking(move || { + writer_registry + .get_mut(&group_guid) + .map(|group| group.group_flags) + }); + let observed_flags = tokio::time::timeout(Duration::from_secs(1), writer) + .await + .expect("the group write must not wait for refresh channel backpressure") + .expect("group writer task should not panic") + .expect("converted group should remain registered"); + assert_ne!(observed_flags & wow_network::GROUP_FLAG_RAID_LIKE_CPP, 0); + + conversion.await; + } + #[tokio::test] async fn convert_raid_to_group_rejects_over_five_members_like_cpp() { let (mut session, _send_rx) = make_session_with_send(); diff --git a/crates/wow-world/src/handlers/loot.rs b/crates/wow-world/src/handlers/loot.rs index d7bc5270a..eb433f43f 100644 --- a/crates/wow-world/src/handlers/loot.rs +++ b/crates/wow-world/src/handlers/loot.rs @@ -157,6 +157,7 @@ struct AuthoritativeLootReleaseLikeCpp { whole_object_fully_skinned: bool, object_generation: u64, lifecycle_revision: u64, + require_no_viewers: bool, } // ── Handler registrations ───────────────────────────────────────── @@ -5578,7 +5579,7 @@ impl WorldSession { } let Some(observation) = route .authority - .fully_looted_lifecycle_observation_like_cpp() + .fully_looted_unviewed_lifecycle_observation_like_cpp() else { return; }; @@ -5604,6 +5605,7 @@ impl WorldSession { whole_object_fully_skinned: observation.whole_object_fully_skinned, object_generation: observation.object_generation, lifecycle_revision: observation.lifecycle_revision, + require_no_viewers: true, }; self.apply_represented_gameobject_loot_release_like_cpp( route.owner_guid, @@ -5633,7 +5635,12 @@ impl WorldSession { } if route.owner_guid.is_corpse() { - self.remove_canonical_corpse_lootable_dynamic_flag_like_cpp(route.owner_guid); + self.remove_canonical_corpse_lootable_dynamic_flag_if_unviewed_fully_looted_observation_like_cpp( + route.owner_guid, + &route.authority, + observation.object_generation, + observation.lifecycle_revision, + ); self.loot_table.remove(&route.owner_guid); return; } @@ -5644,33 +5651,34 @@ impl WorldSession { let corpse_decay_looted_rate = self.loot_drop_rates_like_cpp().corpse_decay_looted; let whole_object_fully_skinned = observation.whole_object_fully_skinned; - let lifecycle_update = self.mutate_world_creature_if_fully_looted_observation_like_cpp( - route.owner_guid, - &route.authority, - observation.object_generation, - observation.lifecycle_revision, - |creature| { - creature.force_dynamic_flags_update_like_cpp(); - creature.remove_lootable_dynamic_flag_like_cpp(); - let marked = if creature.is_alive() { - None - } else { - let corpse_decay_secs = looted_corpse_decay_secs_like_cpp( - whole_object_fully_skinned, - creature.corpse_delay_secs_like_cpp(), - creature.ignore_corpse_decay_ratio_like_cpp(), - corpse_decay_looted_rate, - ); - creature - .all_loot_removed_from_corpse_like_cpp( - corpse_decay_looted_rate, + let lifecycle_update = self + .mutate_world_creature_if_unviewed_fully_looted_observation_like_cpp( + route.owner_guid, + &route.authority, + observation.object_generation, + observation.lifecycle_revision, + |creature| { + creature.force_dynamic_flags_update_like_cpp(); + creature.remove_lootable_dynamic_flag_like_cpp(); + let marked = if creature.is_alive() { + None + } else { + let corpse_decay_secs = looted_corpse_decay_secs_like_cpp( whole_object_fully_skinned, - ) - .then_some((creature.entry(), corpse_decay_secs)) - }; - (marked, creature.creature.unit().values_update()) - }, - ); + creature.corpse_delay_secs_like_cpp(), + creature.ignore_corpse_decay_ratio_like_cpp(), + corpse_decay_looted_rate, + ); + creature + .all_loot_removed_from_corpse_like_cpp( + corpse_decay_looted_rate, + whole_object_fully_skinned, + ) + .then_some((creature.entry(), corpse_decay_secs)) + }; + (marked, creature.creature.unit().values_update()) + }, + ); self.loot_table.remove(&route.owner_guid); if let Some((_, values_update)) = lifecycle_update.as_ref() { self.send_creature_loot_release_dynamic_flags_update_like_cpp( @@ -8865,6 +8873,40 @@ impl WorldSession { true } + fn remove_canonical_corpse_lootable_dynamic_flag_if_unviewed_fully_looted_observation_like_cpp( + &mut self, + corpse_guid: ObjectGuid, + authority: &OwnedLootAuthority, + object_generation: u64, + lifecycle_revision: u64, + ) -> bool { + let Some(map_key) = + self.canonical_object_lookup_map_key_like_cpp(u32::from(self.player_map_id_like_cpp())) + else { + return false; + }; + let Some(manager) = self.canonical_map_manager.as_ref().cloned() else { + return false; + }; + let Ok(mut manager) = manager.lock() else { + return false; + }; + let Some(map) = manager.find_map_mut(map_key.map_id, map_key.instance_id) else { + return false; + }; + let Some(corpse) = map.map_mut().get_typed_corpse_mut(corpse_guid) else { + return false; + }; + + authority + .with_unviewed_fully_looted_lifecycle_observation_like_cpp( + object_generation, + lifecycle_revision, + || corpse.remove_corpse_dynamic_flag(CORPSE_DYNFLAG_LOOTABLE), + ) + .is_some() + } + fn represented_creature_loot_state_like_cpp( &mut self, guid: ObjectGuid, @@ -9099,16 +9141,29 @@ impl WorldSession { let guarded_global_transition = authoritative_release .filter(|_| guarded_global_transition_attempted) .and_then(|release| { - self.set_canonical_gameobject_loot_state_if_fully_looted_observation_like_cpp( - guid, - &release.authority, - release.object_generation, - release.lifecycle_revision, - LootState::JustDeactivated, - None, - represented_chest_restock_time_secs, - false, - ) + if release.require_no_viewers { + self.set_canonical_gameobject_loot_state_if_unviewed_fully_looted_observation_like_cpp( + guid, + &release.authority, + release.object_generation, + release.lifecycle_revision, + LootState::JustDeactivated, + None, + represented_chest_restock_time_secs, + false, + ) + } else { + self.set_canonical_gameobject_loot_state_if_fully_looted_observation_like_cpp( + guid, + &release.authority, + release.object_generation, + release.lifecycle_revision, + LootState::JustDeactivated, + None, + represented_chest_restock_time_secs, + false, + ) + } }); if guarded_global_transition_attempted && guarded_global_transition.is_none() { // An upsert/install/replacement won the serialization point after @@ -9645,6 +9700,7 @@ impl WorldSession { whole_object_fully_skinned: close.whole_object_fully_skinned, object_generation: close.object_generation, lifecycle_revision: close.lifecycle_revision, + require_no_viewers: false, }) } else { None @@ -17285,6 +17341,104 @@ mod tests { ); } + #[tokio::test] + async fn detached_remote_claim_waits_for_every_authority_viewer_before_corpse_lifecycle_like_cpp() + { + let (mut first, first_rx, mut second, second_rx, owner, first_guid, second_guid) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(0, true), + ); + let _ = drain_server_opcodes_like_cpp(&first_rx); + let _ = drain_server_opcodes_like_cpp(&second_rx); + first + .mutate_world_creature(owner, |creature| { + creature.apply_corpse_loot_flags_after_death_state_like_cpp(true, false); + }) + .unwrap(); + + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let generation = authority + .snapshot_for_player_like_cpp(second_guid) + .unwrap() + .generation; + authority + .finish_item_roll_like_cpp(second_guid, generation, 0, false, Some(second_guid)) + .unwrap(); + let claim = authority + .reserve_item_for_award_like_cpp(second_guid, 0) + .await + .unwrap(); + let entry = match claim.payload_like_cpp() { + LootClaimPayload::Item(entry) => entry.clone(), + LootClaimPayload::Money(_) => panic!("expected item claim"), + }; + + // The remote winner closes, while the original looter deliberately + // keeps the same authoritative Loot window open. + second.handle_loot_release(loot_release_packet(owner)).await; + let _ = drain_server_opcodes_like_cpp(&second_rx); + + let player_registry = Arc::new(PlayerRegistry::default()); + let mut first_info = broadcast_info(first_guid, first.send_tx().clone()); + first_info.command_tx = first.session_command_tx(); + player_registry.insert(first_guid, first_info); + let mut second_info = broadcast_info(second_guid, second.send_tx().clone()); + second_info.command_tx = second.session_command_tx(); + player_registry.insert(second_guid, second_info); + first.set_player_registry(Arc::clone(&player_registry)); + second.set_player_registry(player_registry); + let grants = Arc::new(AtomicUsize::new(0)); + install_limited_test_item_template(&mut second, entry.item_id, 0); + second.set_loot_item_store_test_seam_like_cpp(Arc::clone(&grants), true); + + let request = first.request_represented_remote_loot_roll_winner_store_like_cpp( + second_guid, + owner, + represented_loot_object_guid_like_cpp(owner), + 0, + 0, + vec![entry], + false, + Some(claim), + ); + let target = async { + tokio::task::yield_now().await; + second.process_represented_session_commands_like_cpp().await; + }; + let (result, ()) = tokio::join!(request, target); + + assert_eq!(result, MasterLootGiveResult::Stored); + assert_eq!(grants.load(Ordering::SeqCst), 1); + assert!( + first + .mutate_world_creature(owner, |creature| { + creature.has_lootable_dynamic_flag_like_cpp() + }) + .unwrap(), + "the detached winner cannot finish lifecycle while the original viewer remains open" + ); + assert_eq!( + authority + .snapshot_for_player_like_cpp(first_guid) + .unwrap() + .loot + .players_looting, + vec![first_guid] + ); + + first.handle_loot_release(loot_release_packet(owner)).await; + assert!( + !first + .mutate_world_creature(owner, |creature| { + creature.has_lootable_dynamic_flag_like_cpp() + }) + .unwrap(), + "the final real viewer release performs the ordinary C++ corpse transition" + ); + } + #[tokio::test] async fn remote_roll_timeout_then_release_fans_out_once_and_finalizes_corpse_like_cpp() { let (mut first, first_rx, mut second, second_rx, owner, first_guid, second_guid) = diff --git a/crates/wow-world/src/handlers/quest.rs b/crates/wow-world/src/handlers/quest.rs index eaee6956c..e4bb3c8da 100644 --- a/crates/wow-world/src/handlers/quest.rs +++ b/crates/wow-world/src/handlers/quest.rs @@ -1579,6 +1579,21 @@ impl WorldSession { } } + /// C++ walks one objective-status index and stops at the first quest-bound + /// item objective. Rust stores statuses in a `HashMap`, so two independent + /// scans could select different quests. Use the explicit quest-log slot + /// (then quest id as a deterministic duplicate-slot fallback) for both the + /// durable plan and its post-commit application. + fn quest_bound_item_objective_quest_order_like_cpp(&self) -> Vec { + let mut quests = self + .player_quests + .values() + .map(|status| (status.slot, status.quest_id)) + .collect::>(); + quests.sort_unstable(); + quests.into_iter().map(|(_, quest_id)| quest_id).collect() + } + /// Pure form of the first C++ `Player::StoreNewItem` quest pass: /// `ItemAddedQuestCheck(itemId, count, true, &hadBoundItemObjective)`. /// @@ -1597,13 +1612,17 @@ impl WorldSession { let count_i32 = i32::try_from(count).unwrap_or(i32::MAX); let entry_object_id = i32::try_from(entry_id).unwrap_or(i32::MAX); let quest_log_object_id = i32::try_from(quest_log_item_id).unwrap_or(i32::MAX); + let ordered_quest_ids = self.quest_bound_item_objective_quest_order_like_cpp(); for object_id in [entry_object_id, quest_log_object_id] { if object_id == quest_log_object_id && quest_log_item_id == 0 { continue; } - for current_status in self.player_quests.values() { + for quest_id in &ordered_quest_ids { + let Some(current_status) = self.player_quests.get(quest_id) else { + continue; + }; if current_status.status != QUEST_STATUS_INCOMPLETE_LIKE_CPP { continue; } @@ -1674,8 +1693,12 @@ impl WorldSession { ) -> Vec<(u32, i32)> { let mut updated_counts = Vec::new(); let mut quests_to_complete = Vec::new(); + let ordered_quest_ids = self.quest_bound_item_objective_quest_order_like_cpp(); - 'quests: for status in self.player_quests.values_mut() { + 'quests: for quest_id in ordered_quest_ids { + let Some(status) = self.player_quests.get_mut(&quest_id) else { + continue; + }; if status.status != QUEST_STATUS_INCOMPLETE_LIKE_CPP { continue; } @@ -8782,6 +8805,65 @@ mod tests { assert!(send_rx.try_recv().is_err()); } + #[tokio::test] + async fn bound_item_durable_plan_and_apply_use_the_same_quest_log_order_like_cpp() { + let (mut session, _send_rx) = make_session(); + let item_id = 19_950; + let early_slot_quest_id = 7_452; + let late_slot_quest_id = 7_451; + let bound_quest = |quest_id: u32| { + let mut quest = quest_template(quest_id); + quest.objectives = vec![QuestObjective { + id: quest_id * 10, + quest_id, + obj_type: QUEST_OBJECTIVE_ITEM_LIKE_CPP_LOCAL, + order: 0, + storage_index: 0, + object_id: item_id, + amount: 2, + flags: 0, + flags2: QUEST_OBJECTIVE_FLAG_2_QUEST_BOUND_ITEM_LIKE_CPP_LOCAL, + progress_bar_weight: 0.0, + description: String::new(), + }]; + quest + }; + let quest_store = Arc::new(QuestStore::from_quests_like_cpp([ + bound_quest(late_slot_quest_id), + bound_quest(early_slot_quest_id), + ])); + session.set_quest_store(Arc::clone(&quest_store)); + // Insert the numerically smaller quest first, but give it the later + // quest-log slot. HashMap bucket/insertion order must affect neither + // the pre-SQL plan nor the post-COMMIT mutation. + add_active_quest_in_slot(&mut session, late_slot_quest_id, 9); + add_active_quest_in_slot(&mut session, early_slot_quest_id, 2); + + let planned = session + .plan_quest_source_item_bound_objective_persistence_like_cpp(item_id as u32, 0, 1) + .expect("one bound objective should be planned"); + assert_eq!(planned.statuses.len(), 1); + assert_eq!(planned.statuses[0].quest_id, early_slot_quest_id); + + let applied = session + .apply_quest_source_item_bound_objective_progress_for_object_like_cpp( + quest_store.as_ref(), + item_id, + 1, + ) + .await; + assert_eq!(applied, vec![(early_slot_quest_id, 1)]); + assert_eq!( + session.player_quests[&early_slot_quest_id].objective_counts, + vec![1] + ); + assert!( + session.player_quests[&late_slot_quest_id] + .objective_counts + .is_empty() + ); + } + #[tokio::test] async fn bank_withdrawal_item_objective_never_sends_generic_credit_like_cpp() { let (mut session, send_rx) = make_session(); diff --git a/crates/wow-world/src/session.rs b/crates/wow-world/src/session.rs index c4c6aff78..ee35b4be8 100644 --- a/crates/wow-world/src/session.rs +++ b/crates/wow-world/src/session.rs @@ -9403,6 +9403,51 @@ impl WorldSession { ) } + /// Detached durable-claim completion may transition the object only when + /// no client still has any shared or personal loot pool open. The final + /// viewer check and map mutation are serialized under the authority lock. + pub(crate) fn set_canonical_gameobject_loot_state_if_unviewed_fully_looted_observation_like_cpp( + &mut self, + guid: ObjectGuid, + authority: &OwnedLootAuthority, + object_generation: u64, + lifecycle_revision: u64, + state: wow_entities::LootState, + unit_guid: Option, + chest_restock_time_secs: u32, + shared_loot_is_changed_like_cpp: bool, + ) -> Option { + let map_key = self + .canonical_object_lookup_map_key_like_cpp(u32::from(self.player_map_id_like_cpp()))?; + let game_time_secs = i64::try_from(wow_core::GameTime::now().as_secs()).unwrap_or(i64::MAX); + let manager = Arc::clone(self.canonical_map_manager.as_ref()?); + let mut manager = manager.lock().ok()?; + let managed = manager.find_map_mut(map_key.map_id, map_key.instance_id)?; + let object_authority = managed + .map() + .get_typed_game_object(guid)? + .loot_authority_like_cpp() + .clone(); + if !object_authority.shares_storage_like_cpp(authority) { + return None; + } + + authority.with_unviewed_fully_looted_lifecycle_observation_like_cpp( + object_generation, + lifecycle_revision, + || { + managed.map_mut().set_gameobject_loot_state_like_cpp( + guid, + state, + unit_guid, + game_time_secs, + chest_restock_time_secs, + shared_loot_is_changed_like_cpp, + ) + }, + ) + } + /// C++ fishing-hole release performs AddUse, MaxOpens comparison, and /// SetLootState on one world thread. Keep all three under one map lock so /// two concurrent personal releases cannot finish in `Ready` after max. @@ -13910,6 +13955,49 @@ impl WorldSession { Some(result) } + /// Detached durable-claim completion variant of the guarded creature + /// mutation. It additionally requires every authoritative loot viewer set + /// to remain empty through the map mutation. + pub(crate) fn mutate_world_creature_if_unviewed_fully_looted_observation_like_cpp( + &mut self, + guid: ObjectGuid, + authority: &OwnedLootAuthority, + object_generation: u64, + lifecycle_revision: u64, + f: F, + ) -> Option + where + F: FnOnce(&mut crate::map_manager::WorldCreature) -> R, + { + let (map_id, instance_id) = self.current_legacy_runtime_map_key_like_cpp(); + let manager = self.map_manager.as_ref().cloned()?; + let guarded_result = { + let mut manager = manager + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let creature = manager.find_creature_mut(map_id, instance_id, guid)?; + if !creature + .creature + .loot_authority_like_cpp() + .shares_storage_like_cpp(authority) + { + return None; + } + authority.with_unviewed_fully_looted_lifecycle_observation_like_cpp( + object_generation, + lifecycle_revision, + || { + let result = f(creature); + (result, creature.position(), creature.creature.clone()) + }, + ) + }?; + let (result, position, creature) = guarded_result; + self.relocate_canonical_creature_map_object_like_cpp(guid, position); + self.sync_canonical_creature_entity_like_cpp(creature); + Some(result) + } + pub(crate) fn pause_interacted_creature_movement_like_cpp(&mut self, guid: ObjectGuid) -> bool { self.mutate_world_creature(guid, |creature| { creature.pause_interaction_movement_like_cpp() From 49cf27dace7859d3e1896c4353e99afb17bbe48f Mon Sep 17 00:00:00 2001 From: cDmonio Date: Mon, 20 Jul 2026 15:04:52 +0000 Subject: [PATCH 14/14] fix second review findings for atomic loot claims --- crates/world-server/src/main.rs | 20 ++- .../wow-database/src/statements/character.rs | 25 ++++ crates/wow-world/src/handlers/group.rs | 125 ++++++++++++++++-- 3 files changed, 159 insertions(+), 11 deletions(-) diff --git a/crates/world-server/src/main.rs b/crates/world-server/src/main.rs index d22e204f8..2513cce46 100644 --- a/crates/world-server/src/main.rs +++ b/crates/world-server/src/main.rs @@ -98,11 +98,17 @@ fn next_item_guid_allocator_start_like_cpp(max_persisted_guid: Option) -> R Ok(next) } -const ITEM_GUID_DANGLING_REFERENCE_CLEANUP_STATEMENTS_LIKE_CPP: [CharStatements; 4] = [ +// The first four statements mirror C++ `ObjectMgr::SetHighestGuids`. C++ does +// not clean orphaned stored-loot rows, but Rust loads those rows by item GUID on +// demand; clean them before publishing the allocator so a future item cannot +// inherit loot left behind by a deleted container. +const ITEM_GUID_DANGLING_REFERENCE_CLEANUP_STATEMENTS_LIKE_CPP: [CharStatements; 6] = [ CharStatements::DEL_INVALID_CHAR_INVENTORY_ITEM_GUIDS, CharStatements::DEL_INVALID_MAIL_ITEM_GUIDS, CharStatements::DEL_INVALID_AUCTION_ITEM_GUIDS, CharStatements::DEL_INVALID_GUILD_BANK_ITEM_GUIDS, + CharStatements::DEL_INVALID_ITEM_LOOT_ITEMS_GUIDS, + CharStatements::DEL_INVALID_ITEM_LOOT_MONEY_GUIDS, ]; fn item_guid_reference_cleanup_transaction_like_cpp( @@ -14497,14 +14503,22 @@ mod tests { } #[test] - fn item_guid_allocator_cleans_every_cpp_dangling_reference_table_before_publication() { + fn item_guid_allocator_cleans_every_dangling_reference_before_publication() { let sql = ITEM_GUID_DANGLING_REFERENCE_CLEANUP_STATEMENTS_LIKE_CPP .map(|statement| statement.sql()); - assert_eq!(sql.len(), 4); + assert_eq!(sql.len(), 6); assert!(sql[0].contains("character_inventory")); assert!(sql[1].contains("mail_items")); assert!(sql[2].contains("auctionhouse")); assert!(sql[3].contains("guild_bank_item")); + assert_eq!( + sql[4], + "DELETE FROM item_loot_items WHERE container_id >= ?" + ); + assert_eq!( + sql[5], + "DELETE FROM item_loot_money WHERE container_id >= ?" + ); for statement in sql { assert!(statement.contains(">= ?")); } diff --git a/crates/wow-database/src/statements/character.rs b/crates/wow-database/src/statements/character.rs index c90cf7122..cf5d77d4c 100644 --- a/crates/wow-database/src/statements/character.rs +++ b/crates/wow-database/src/statements/character.rs @@ -1215,6 +1215,11 @@ pub enum CharStatements { DEL_INVALID_MAIL_ITEM_GUIDS, DEL_INVALID_AUCTION_ITEM_GUIDS, DEL_INVALID_GUILD_BANK_ITEM_GUIDS, + /// Rust safety extension to C++ `ObjectMgr::SetHighestGuids`: stored loot + /// has no foreign key to `item_instance`, so orphan rows at or above the + /// next allocator value must not be inherited by a reused item GUID. + DEL_INVALID_ITEM_LOOT_ITEMS_GUIDS, + DEL_INVALID_ITEM_LOOT_MONEY_GUIDS, /// INSERT INTO item_instance (guid, itemEntry, owner_guid, count, durability, enchantments, charges) /// VALUES (?, ?, ?, ?, ?, '', '') @@ -2820,6 +2825,12 @@ impl StatementDef for CharStatements { Self::DEL_INVALID_GUILD_BANK_ITEM_GUIDS => { "DELETE FROM guild_bank_item WHERE item_guid >= ?" } + Self::DEL_INVALID_ITEM_LOOT_ITEMS_GUIDS => { + "DELETE FROM item_loot_items WHERE container_id >= ?" + } + Self::DEL_INVALID_ITEM_LOOT_MONEY_GUIDS => { + "DELETE FROM item_loot_money WHERE container_id >= ?" + } Self::INS_ITEM_INSTANCE => { "INSERT INTO item_instance \ (guid, itemEntry, owner_guid, creatorGuid, giftCreatorGuid, count, \ @@ -6239,6 +6250,20 @@ mod tests { .count(), 1 ); + assert_eq!( + CharStatements::DEL_INVALID_ITEM_LOOT_MONEY_GUIDS + .sql() + .matches('?') + .count(), + 1 + ); + assert_eq!( + CharStatements::DEL_INVALID_ITEM_LOOT_ITEMS_GUIDS + .sql() + .matches('?') + .count(), + 1 + ); assert_eq!( CharStatements::DEL_ITEMCONTAINER_ITEMS .sql() diff --git a/crates/wow-world/src/handlers/group.rs b/crates/wow-world/src/handlers/group.rs index 23c8147ef..dda962261 100644 --- a/crates/wow-world/src/handlers/group.rs +++ b/crates/wow-world/src/handlers/group.rs @@ -879,6 +879,37 @@ async fn send_realm_packet_to_player_like_cpp( } } +/// Queue the actual invite dialog without treating bounded-channel pressure as +/// an offline player. +/// +/// C++ `HandlePartyInviteOpcode` stores the `GroupInvite` and then calls +/// `invitedPlayer->SendDirectMessage`; it does not turn a busy socket queue into +/// `ERR_BAD_PLAYER_NAME_S`. Rust therefore waits until the owning session drains +/// capacity or disconnects. Other existing group notifications keep their +/// finite timeout in [`send_realm_packet_to_player_like_cpp`]; this stronger +/// delivery rule is intentionally scoped to the invite state transition. +async fn send_realm_party_invite_to_player_like_cpp( + recipient: ObjectGuid, + command_tx: &flume::Sender, + packet_bytes: Vec, +) -> bool { + let command = SessionCommand::SendRealmPacketLikeCpp(SendRealmPacketLikeCppCommand { + recipient, + packet_bytes, + }); + match command_tx.send_async(command).await { + Ok(()) => { + #[cfg(test)] + tokio::task::yield_now().await; + true + } + Err(error) => { + warn!(recipient = %recipient, %error, "realm-routed party invite channel closed"); + false + } + } +} + fn role_changed_inform_like_cpp( party_index: u8, from: ObjectGuid, @@ -1347,7 +1378,7 @@ impl WorldSession { realm_name, realm_name_normalized, }; - if !send_realm_packet_to_player_like_cpp( + if !send_realm_party_invite_to_player_like_cpp( real_target_guid, &target_snapshot.command_tx, invite_packet.to_bytes(), @@ -3268,7 +3299,8 @@ impl WorldSession { #[cfg(test)] mod tests { use super::{ - SOCIAL_FLAG_FRIEND_LIKE_CPP, SOCIAL_FLAG_IGNORED_LIKE_CPP, current_group_guid_like_cpp, + PARTY_REALM_COMMAND_TIMEOUT_LIKE_CPP, SOCIAL_FLAG_FRIEND_LIKE_CPP, + SOCIAL_FLAG_IGNORED_LIKE_CPP, current_group_guid_like_cpp, first_connected_group_member_like_cpp, group_delete_statement_like_cpp, group_insert_statement_like_cpp, group_leader_update_statement_like_cpp, group_lfg_data_delete_statement_like_cpp, group_member_delete_all_statement_like_cpp, @@ -3289,7 +3321,7 @@ mod tests { use wow_network::{ GroupInfo, GroupMemberCharacterLikeCpp, GroupRegistry, PendingInviteLikeCpp, PendingInvites, PlayerBroadcastInfo, PlayerRegistry, ReadyCheckEventLikeCpp, - SessionCommand, + SendRealmPacketLikeCppCommand, SessionCommand, }; use wow_packet::{ServerPacket, WorldPacket, packets::party::party_result}; @@ -4212,7 +4244,86 @@ mod tests { } #[tokio::test] - async fn party_invite_queue_timeout_rolls_back_pending_and_never_reports_ok_like_cpp() { + async fn party_invite_waits_through_command_backpressure_like_cpp() { + let (mut session, send_rx) = make_session_with_send(); + let inviter = ObjectGuid::create_player(1, 42); + let target = ObjectGuid::create_player(1, 77); + let target_name = format!("Player{}", target.low_value()); + session.set_player_guid(Some(inviter)); + session.set_loaded_player_name_like_cpp("Leader".to_string()); + + let player_registry = Arc::new(PlayerRegistry::default()); + let (target_send_tx, target_send_rx) = bounded(8); + let (target_command_tx, target_command_rx) = bounded(1); + target_command_tx + .try_send(SessionCommand::SendRealmPacketLikeCpp( + SendRealmPacketLikeCppCommand { + recipient: target, + packet_bytes: vec![0xAA], + }, + )) + .expect("fill target command queue"); + player_registry.insert( + target, + broadcast_info_with_command_tx(target, target_send_tx, target_command_tx.clone()), + ); + let pending = Arc::new(PendingInvites::default()); + session.set_player_registry(player_registry); + session.set_group_registry(Arc::new(GroupRegistry::default()), Arc::clone(&pending)); + + let invite = + session.handle_party_invite(party_invite_packet(target, &target_name, None, 0)); + tokio::pin!(invite); + + assert!( + tokio::time::timeout( + PARTY_REALM_COMMAND_TIMEOUT_LIKE_CPP + Duration::from_millis(50), + &mut invite, + ) + .await + .is_err(), + "temporary command backpressure must not be converted into a failed invite" + ); + assert!(pending.get(&target).is_some()); + assert!(pending.get(&inviter).is_some()); + assert!(send_rx.try_recv().is_err()); + assert!(target_send_rx.try_recv().is_err()); + + let SessionCommand::SendRealmPacketLikeCpp(blocker) = target_command_rx + .try_recv() + .expect("release target command capacity") + else { + panic!("expected command queue blocker"); + }; + assert_eq!(blocker.packet_bytes, vec![0xAA]); + + tokio::time::timeout(Duration::from_secs(1), &mut invite) + .await + .expect("invite resumes when the target command queue drains"); + + let SessionCommand::SendRealmPacketLikeCpp(command) = target_command_rx + .try_recv() + .expect("queued party invite after backpressure") + else { + panic!("expected remote realm packet command"); + }; + assert_eq!(command.recipient, target); + assert_eq!( + u16::from_le_bytes([command.packet_bytes[0], command.packet_bytes[1]]), + ServerOpcodes::PartyInvite as u16 + ); + + assert_eq!( + party_command_result_code(&send_rx.try_recv().expect("successful invite result")), + party_result::OK + ); + assert!(pending.get(&target).is_some()); + assert!(pending.get(&inviter).is_some()); + assert!(target_send_rx.try_recv().is_err()); + } + + #[tokio::test] + async fn party_invite_closed_command_channel_rolls_back_pending_like_cpp() { let (mut session, send_rx) = make_session_with_send(); let inviter = ObjectGuid::create_player(1, 42); let target = ObjectGuid::create_player(1, 77); @@ -4222,10 +4333,8 @@ mod tests { let player_registry = Arc::new(PlayerRegistry::default()); let (target_send_tx, target_send_rx) = bounded(8); - // Keep the zero-capacity receiver alive without polling it. The - // production helper must time out rather than drop the command or use - // the target's primary/INSTANCE sender. - let (target_command_tx, _target_command_rx) = bounded(0); + let (target_command_tx, target_command_rx) = bounded(1); + drop(target_command_rx); player_registry.insert( target, broadcast_info_with_command_tx(target, target_send_tx, target_command_tx),