diff --git a/crates/capture-diff/README.md b/crates/capture-diff/README.md index 6e696466..2aaa46c5 100644 --- a/crates/capture-diff/README.md +++ b/crates/capture-diff/README.md @@ -136,6 +136,23 @@ 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-#108 vendor fixture isolates the exact post-COMMIT realm response: +`SMSG_BUY_SUCCEEDED` followed by `SMSG_ITEM_PUSH_RESULT`. Paired C++ and Rust +bot runs bought item `30183` from G'eras (entry `18525`, spawn `96654`) for +extended cost `1642`, proved currency `42` changed `30→15`, verified the item +after a fresh authentication, and restored the fixture. The strict two-packet +flow is CLEAN with an empty divergence baseline. Both schema-v3 manifests bind +the pinned bot executable and exact validated report, and import retains both +reports under `capture-provenance/` for re-verification. Its narrow +`SMSG_BUY_SUCCEEDED` comparator omits only G'eras' nonzero lower 40-bit +map-runtime GUID counter while pinning Creature/realm 1/map 530/entry 18525/ +subtype 0/server 0, MUID 59, `NewQuantity = -1`, `QuantityBought = 1`, S2C +direction, realm routing, and canonical decoding. `SMSG_ITEM_PUSH_RESULT` +remains byte-exact. The complete raw action also exposed the independently +missing Rust `SMSG_CRITERIA_UPDATE`; that achievement-subsystem gap is neither +ignored nor accepted by this vendor fixture. See the flow's README for the +precise scope and reproduction command. + 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) diff --git a/crates/capture-diff/flows/vendor-extended-cost-purchase/README.md b/crates/capture-diff/flows/vendor-extended-cost-purchase/README.md new file mode 100644 index 00000000..ead85158 --- /dev/null +++ b/crates/capture-diff/flows/vendor-extended-cost-purchase/README.md @@ -0,0 +1,54 @@ +# `vendor-extended-cost-purchase` + +This issue-#108 fixture is the exact server-response window after the durable +commit of one real extended-cost item purchase. It contains, in order and on +the realm socket: + +1. `SMSG_BUY_SUCCEEDED` (`0x26C6`) +2. `SMSG_ITEM_PUSH_RESULT` (`0x2623`) + +It was imported strictly from paired C++ and Rust captures made at Rust +`7bee9bfe71940048c721c14845dc0a0ad0811c5d`. The retained schema-v3 manifests +pin the raw capture, executable, source, process/listener, configuration and +cleanup identities; `capture-lineage.json` pins the derived two-packet +artifacts. Each manifest also pins its exact bot executable and validated JSON +report; import retains those reports as `capture-provenance/cpp.bot-report.json` +and `capture-provenance/rust.bot-report.json`, so the persistence/restoration +claims remain independently recheckable. The accepted-divergence baseline is +empty. + +The bot used the unique G'eras fixture (Creature entry `18525`, SQL spawn +`96654`, realm `1`, map `530`) and vendor row item `30183`, extended cost +`1642`. On both servers it proved currency `42` changed from `30` to `15`, one +item was present after logout and fresh authentication, the expected +`VendorInventory`, `SetCurrency`, `BuySucceeded` and `ItemPushResult` packets +arrived on their C++ sockets, and cleanup restored the original character +state. + +## Narrow semantic normalization + +C++ and Rust allocate different nonzero lower 40-bit map-runtime counters for +G'eras. The `SMSG_BUY_SUCCEEDED` comparator omits only that counter and only +for the exact stable identity Creature/realm `1`/map `530`/entry `18525`/ +subtype `0`/server `0`. It still requires a nonzero counter, MUID `59`, +`NewQuantity = -1`, `QuantityBought = 1`, canonical packet decoding, +S2C direction and realm routing. Every field of `SMSG_ITEM_PUSH_RESULT` +remains byte-exact. + +## Scope boundary + +The complete raw bot action ran from `CMSG_BUY_ITEM` through the fixed +`CMSG_PING` fence. Its purchase-owned responses and final inventory state +matched after the reviewed runtime-GUID normalization. C++ also emitted +`SMSG_CRITERIA_UPDATE`; Rust does not yet implement the corresponding +achievement subsystem. That pre-existing out-of-scope gap is deliberately +not ignored or accepted in this fixture. Consequently this committed flow +claims only the clean post-COMMIT realm-response window, while the bot proves +the complete currency, inventory, persistence and restoration behavior. + +Recheck the committed evidence with: + +```bash +cargo +1.88.0 run -q -p capture-diff -- \ + diff vendor-extended-cost-purchase --strict +``` diff --git a/crates/capture-diff/flows/vendor-extended-cost-purchase/capture-lineage.json b/crates/capture-diff/flows/vendor-extended-cost-purchase/capture-lineage.json new file mode 100644 index 00000000..2d4c292e --- /dev/null +++ b/crates/capture-diff/flows/vendor-extended-cost-purchase/capture-lineage.json @@ -0,0 +1,136 @@ +{ + "version": 3, + "flow": "vendor-extended-cost-purchase", + "completed": true, + "sources": { + "cpp": { + "manifest_path": "capture-provenance/cpp.capture-manifest.json", + "manifest_sha256": "3275309e4a542772b982dc30b26db4d6fa7d1206ade8375c34299c0dacb7edb1", + "raw_artifact_sha256": "f037313fabf483655897aa2744ce80c0022236ef0632e08861f6d788f9afc6de", + "raw_artifact_size": 133941, + "raw_packet_count": null, + "harness_repo_head": "7bee9bfe71940048c721c14845dc0a0ad0811c5d", + "source_repo_head": "5100ce3d8fc6150feedf902e62512760ec03ea92", + "harness_worktree_clean": true, + "harness_worktree_state_sha256": "ce537655f53231c5303e35c1178d2996786316f51edf6d96a40282ccadc93c29", + "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": 1641361, + "pm2_entry_starttime": 83203231, + "pm2_exec_path": "/home/server/trinity-legacy-install/bin/worldserver-wrapper.sh", + "pm2_exec_sha256": "41c0e84f282bf5711a2d4b433a054426d28de9875cfce1b3f7fcfe6c576df213", + "pm2_profile_redacted_sha256": "5de156ef078858c9d979e6bcc274d5d06e4d44588c273357fbf3f34dc235aca0", + "listener_runtime_pid": 1641363, + "listener_runtime_starttime": 83203231, + "listener_relationship_verified": true, + "restart_count": 0, + "effective_config_path": "/home/server/trinity-legacy-install/bin/worldserver.conf", + "effective_config_redacted_sha256": "d1194cfd24f63527cd930493f8ffd7610dfd8b2586e17a5b9a03cd3c97295437", + "effective_config_algorithm": "capture-relevant-redacted-v1", + "runtime_cleanup_verified": true, + "normal_runtime_restored": true, + "fixture_guard": null, + "bot_report": { + "contract": "wow-test-bot-vendor-extended-cost-purchase-report-v1", + "exec_path": "/home/server/rustycore-issue-108/tools/wow-test-bot/target/release/wow-test-bot", + "exec_sha256": "fad985573f17d24dd7d538ab499d3ef56189a63b233b1431028d8354704ee27b", + "report_path": "/tmp/rustycore-issue108-capture-7bee9bfe/cpp.bot-report.json", + "report_sha256": "caa1bbc6d304bac0157e4f939e099e01f6f30a3d2d492cf589f5d83515cfe056", + "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": "8ab981d481eaf401e52240601b61a2bdaeb43d394bfffc3a0b1aff8a3d8efa60", + "raw_artifact_sha256": "11563c26932f862d6bf1315f785f7c71002522a26f9489d9e65b72080c46d61c", + "raw_artifact_size": null, + "raw_packet_count": 196, + "harness_repo_head": "7bee9bfe71940048c721c14845dc0a0ad0811c5d", + "source_repo_head": "7bee9bfe71940048c721c14845dc0a0ad0811c5d", + "harness_worktree_clean": true, + "harness_worktree_state_sha256": "ce537655f53231c5303e35c1178d2996786316f51edf6d96a40282ccadc93c29", + "source_worktree_dirty": false, + "source_worktree_state_sha256": "ce537655f53231c5303e35c1178d2996786316f51edf6d96a40282ccadc93c29", + "worktree_state_algorithm": "git-head-path-mode-content-sha256-v1", + "expected_exec_path": "/home/server/rustycore-issue-108/target/release/world-server", + "expected_exec_sha256": "18292b8fe1ec4cf646c90125814cb395446e944c6d2a2d1ebc4412737d412b00", + "source_exec_path": "/home/server/rustycore-issue-108/target/release/world-server", + "source_exec_sha256": "18292b8fe1ec4cf646c90125814cb395446e944c6d2a2d1ebc4412737d412b00", + "live_exec_path": "/home/server/rustycore-issue-108/target/release/world-server", + "live_exec_sha256": "18292b8fe1ec4cf646c90125814cb395446e944c6d2a2d1ebc4412737d412b00", + "executable_pin_enforced": true, + "pm2_entry_pid": 1644097, + "pm2_entry_starttime": 83211941, + "pm2_exec_path": "/home/server/rustycore-issue-108/target/release/world-server", + "pm2_exec_sha256": "18292b8fe1ec4cf646c90125814cb395446e944c6d2a2d1ebc4412737d412b00", + "pm2_profile_redacted_sha256": "ad8a27657fdd7584b28ff3fc5150a1ce98f0b575511d87889dbb4830d2e791ab", + "listener_runtime_pid": 1644097, + "listener_runtime_starttime": 83211941, + "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": null, + "bot_report": { + "contract": "wow-test-bot-vendor-extended-cost-purchase-report-v1", + "exec_path": "/home/server/rustycore-issue-108/tools/wow-test-bot/target/release/wow-test-bot", + "exec_sha256": "fad985573f17d24dd7d538ab499d3ef56189a63b233b1431028d8354704ee27b", + "report_path": "/tmp/rustycore-issue108-capture-7bee9bfe/rust.bot-report.json", + "report_sha256": "8dd538caf0879add1cee437e8434ea7716a9f5dc95ff804e75f3be79841b15cf", + "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" + ], + "from_opcode": { + "direction": "s2c", + "opcode": 9926 + }, + "until_opcode": { + "direction": "s2c", + "opcode": 9763 + }, + "ignored_opcodes": [], + "strict": true + }, + "outputs": { + "cpp_pkt": { + "path": "cpp.pkt", + "size": 240, + "sha256": "ef95922e260a96d8ef9140d0378095476f7b35db5475e6e6459ae1ec2dcb51a5" + }, + "rust": { + "path": "rust", + "file_count": 4, + "packet_count": 2, + "tree_sha256": "52575840c5e9db44452e5e0194ec8944c5f227136fb12b7e7885ef417d4c249e" + }, + "expected_divergences": { + "path": "expected-divergences.json", + "size": 2, + "sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945" + } + } +} \ No newline at end of file diff --git a/crates/capture-diff/flows/vendor-extended-cost-purchase/capture-provenance/cpp.bot-report.json b/crates/capture-diff/flows/vendor-extended-cost-purchase/capture-provenance/cpp.bot-report.json new file mode 100644 index 00000000..ddfdc787 --- /dev/null +++ b/crates/capture-diff/flows/vendor-extended-cost-purchase/capture-provenance/cpp.bot-report.json @@ -0,0 +1,295 @@ +{ + "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, + "vendor_smoke": true, + "rested_xp_smoke": false, + "loot_race_smoke": false, + "loot_item_capture": false, + "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, + "vendor_smoke": true, + "vendor_smoke_passed": true, + "vendor_entry": 18525, + "vendor_spawn_guid": 96654, + "vendor_runtime_counter": 111, + "vendor_item_entry": 30183, + "vendor_extended_cost": 1642, + "vendor_currency_id": 42, + "vendor_currency_before": 30, + "vendor_currency_after": 15, + "vendor_item_total_after": 1, + "vendor_inventory_seen": true, + "vendor_buy_succeeded_seen": true, + "vendor_set_currency_seen": true, + "vendor_item_push_seen": true, + "vendor_relogin_verified": true, + "vendor_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": false, + "loot_race_smoke_passed": null, + "loot_race_target_entry": null, + "loot_race_target_spawn_guid": null, + "loot_race_target_runtime_counter": null, + "loot_race_party_confirmed": false, + "loot_race_target_discovered": false, + "loot_race_loot_opened": false, + "loot_race_loot_list_id": null, + "loot_race_loot_coins": null, + "loot_race_item_push_seen": false, + "loot_race_loot_removed_seen": false, + "loot_race_money_notify_amount": null, + "loot_race_coin_removed_seen": false, + "loot_race_db_item_total": null, + "loot_race_db_money_delta": null, + "loot_race_relog_verified": false, + "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", + "0x256D", + "0x2677", + "0x25C0", + "0x291C", + "0x290F", + "0x270A", + "0x27BE", + "0x2809", + "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", + "0x27CB", + "0x27CB", + "0x27CB", + "0x27CB", + "0x2C1F", + "0x2C1F", + "0x2C1F", + "0x2C1F", + "0x2C1F", + "0x2C1F", + "0x25B8", + "0x270A", + "0x27BE", + "0x26A4", + "0x270A", + "0x25BF", + "0x2BC5", + "0x2677", + "0x25ED", + "0x278C", + "0x2C51", + "0x26C6", + "0x2623", + "0x2574", + "0x27CB", + "0x26E1", + "0x27CB", + "0x304E", + "0x294B", + "0x294B", + "0x2684", + "0x3049", + "0x256D", + "0x2677", + "0x25C0", + "0x291C", + "0x290F", + "0x270A", + "0x27BE", + "0x2809", + "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", + "0x294B", + "0x270E", + "0x294B", + "0x2570", + "0x2684" + ] + } + ] +} \ No newline at end of file diff --git a/crates/capture-diff/flows/vendor-extended-cost-purchase/capture-provenance/cpp.capture-manifest.json b/crates/capture-diff/flows/vendor-extended-cost-purchase/capture-provenance/cpp.capture-manifest.json new file mode 100644 index 00000000..43d2840b --- /dev/null +++ b/crates/capture-diff/flows/vendor-extended-cost-purchase/capture-provenance/cpp.capture-manifest.json @@ -0,0 +1,52 @@ +{ + "version": 3, + "flow": "vendor-extended-cost-purchase", + "side": "cpp", + "completed": true, + "created_at": "2026-07-21T01:03:05Z", + "harness_repo_head": "7bee9bfe71940048c721c14845dc0a0ad0811c5d", + "source_repo_head": "5100ce3d8fc6150feedf902e62512760ec03ea92", + "harness_worktree_clean": true, + "harness_worktree_state_sha256": "ce537655f53231c5303e35c1178d2996786316f51edf6d96a40282ccadc93c29", + "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": 1641361, + "pm2_entry_starttime": 83203231, + "pm2_exec_path": "/home/server/trinity-legacy-install/bin/worldserver-wrapper.sh", + "pm2_exec_sha256": "41c0e84f282bf5711a2d4b433a054426d28de9875cfce1b3f7fcfe6c576df213", + "pm2_profile_redacted_sha256": "5de156ef078858c9d979e6bcc274d5d06e4d44588c273357fbf3f34dc235aca0", + "listener_runtime_pid": 1641363, + "listener_runtime_starttime": 83203231, + "listener_relationship_verified": true, + "restart_count": 0, + "effective_config_path": "/home/server/trinity-legacy-install/bin/worldserver.conf", + "effective_config_redacted_sha256": "d1194cfd24f63527cd930493f8ffd7610dfd8b2586e17a5b9a03cd3c97295437", + "effective_config_algorithm": "capture-relevant-redacted-v1", + "runtime_cleanup_verified": true, + "normal_runtime_restored": true, + "fixture_guard": null, + "bot_report": { + "contract": "wow-test-bot-vendor-extended-cost-purchase-report-v1", + "exec_path": "/home/server/rustycore-issue-108/tools/wow-test-bot/target/release/wow-test-bot", + "exec_sha256": "fad985573f17d24dd7d538ab499d3ef56189a63b233b1431028d8354704ee27b", + "report_path": "/tmp/rustycore-issue108-capture-7bee9bfe/cpp.bot-report.json", + "report_sha256": "caa1bbc6d304bac0157e4f939e099e01f6f30a3d2d492cf589f5d83515cfe056", + "account": "TESTBOT2@bot.local", + "account_id": 9, + "character_guid": 15, + "report_validated": true + }, + "artifact": { + "path": "cpp.pkt", + "size": 133941, + "sha256": "f037313fabf483655897aa2744ce80c0022236ef0632e08861f6d788f9afc6de" + } +} diff --git a/crates/capture-diff/flows/vendor-extended-cost-purchase/capture-provenance/rust.bot-report.json b/crates/capture-diff/flows/vendor-extended-cost-purchase/capture-provenance/rust.bot-report.json new file mode 100644 index 00000000..bb026fec --- /dev/null +++ b/crates/capture-diff/flows/vendor-extended-cost-purchase/capture-provenance/rust.bot-report.json @@ -0,0 +1,316 @@ +{ + "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, + "vendor_smoke": true, + "rested_xp_smoke": false, + "loot_race_smoke": false, + "loot_item_capture": false, + "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, + "vendor_smoke": true, + "vendor_smoke_passed": true, + "vendor_entry": 18525, + "vendor_spawn_guid": 96654, + "vendor_runtime_counter": 234, + "vendor_item_entry": 30183, + "vendor_extended_cost": 1642, + "vendor_currency_id": 42, + "vendor_currency_before": 30, + "vendor_currency_after": 15, + "vendor_item_total_after": 1, + "vendor_inventory_seen": true, + "vendor_buy_succeeded_seen": true, + "vendor_set_currency_seen": true, + "vendor_item_push_seen": true, + "vendor_relogin_verified": true, + "vendor_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": false, + "loot_race_smoke_passed": null, + "loot_race_target_entry": null, + "loot_race_target_spawn_guid": null, + "loot_race_target_runtime_counter": null, + "loot_race_party_confirmed": false, + "loot_race_target_discovered": false, + "loot_race_loot_opened": false, + "loot_race_loot_list_id": null, + "loot_race_loot_coins": null, + "loot_race_item_push_seen": false, + "loot_race_loot_removed_seen": false, + "loot_race_money_notify_amount": null, + "loot_race_coin_removed_seen": false, + "loot_race_db_item_total": null, + "loot_race_db_money_delta": null, + "loot_race_relog_verified": false, + "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", + "0x2C1F", + "0x2C1F", + "0x2C1F", + "0x2C1F", + "0x2C1F", + "0x2C1F", + "0x2746", + "0x2578", + "0x25BC", + "0x2C1F", + "0x27CB", + "0x27CB", + "0x27CB", + "0x25B8", + "0x26A4", + "0x270A", + "0x25BF", + "0x2BC5", + "0x2677", + "0x278C", + "0x2C51", + "0x2574", + "0x26C6", + "0x2623", + "0x27CB", + "0x27CB", + "0x304E", + "0x2683", + "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", + "0x2C1F", + "0x2C1F", + "0x2C1F", + "0x2C1F", + "0x2C1F", + "0x2C1F", + "0x2746", + "0x2578", + "0x25BC", + "0x2C1F", + "0x27CB", + "0x27CB", + "0x2683", + "0x2684" + ] + } + ] +} \ No newline at end of file diff --git a/crates/capture-diff/flows/vendor-extended-cost-purchase/capture-provenance/rust.capture-manifest.json b/crates/capture-diff/flows/vendor-extended-cost-purchase/capture-provenance/rust.capture-manifest.json new file mode 100644 index 00000000..004b258d --- /dev/null +++ b/crates/capture-diff/flows/vendor-extended-cost-purchase/capture-provenance/rust.capture-manifest.json @@ -0,0 +1,52 @@ +{ + "version": 3, + "flow": "vendor-extended-cost-purchase", + "side": "rust", + "completed": true, + "created_at": "2026-07-21T01:04:35Z", + "harness_repo_head": "7bee9bfe71940048c721c14845dc0a0ad0811c5d", + "source_repo_head": "7bee9bfe71940048c721c14845dc0a0ad0811c5d", + "harness_worktree_clean": true, + "harness_worktree_state_sha256": "ce537655f53231c5303e35c1178d2996786316f51edf6d96a40282ccadc93c29", + "source_worktree_dirty": false, + "source_worktree_state_sha256": "ce537655f53231c5303e35c1178d2996786316f51edf6d96a40282ccadc93c29", + "worktree_state_algorithm": "git-head-path-mode-content-sha256-v1", + "expected_exec_path": "/home/server/rustycore-issue-108/target/release/world-server", + "expected_exec_sha256": "18292b8fe1ec4cf646c90125814cb395446e944c6d2a2d1ebc4412737d412b00", + "source_exec_path": "/home/server/rustycore-issue-108/target/release/world-server", + "source_exec_sha256": "18292b8fe1ec4cf646c90125814cb395446e944c6d2a2d1ebc4412737d412b00", + "live_exec_path": "/home/server/rustycore-issue-108/target/release/world-server", + "live_exec_sha256": "18292b8fe1ec4cf646c90125814cb395446e944c6d2a2d1ebc4412737d412b00", + "executable_pin_enforced": true, + "pm2_entry_pid": 1644097, + "pm2_entry_starttime": 83211941, + "pm2_exec_path": "/home/server/rustycore-issue-108/target/release/world-server", + "pm2_exec_sha256": "18292b8fe1ec4cf646c90125814cb395446e944c6d2a2d1ebc4412737d412b00", + "pm2_profile_redacted_sha256": "ad8a27657fdd7584b28ff3fc5150a1ce98f0b575511d87889dbb4830d2e791ab", + "listener_runtime_pid": 1644097, + "listener_runtime_starttime": 83211941, + "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": null, + "bot_report": { + "contract": "wow-test-bot-vendor-extended-cost-purchase-report-v1", + "exec_path": "/home/server/rustycore-issue-108/tools/wow-test-bot/target/release/wow-test-bot", + "exec_sha256": "fad985573f17d24dd7d538ab499d3ef56189a63b233b1431028d8354704ee27b", + "report_path": "/tmp/rustycore-issue108-capture-7bee9bfe/rust.bot-report.json", + "report_sha256": "8dd538caf0879add1cee437e8434ea7716a9f5dc95ff804e75f3be79841b15cf", + "account": "TESTBOT2@bot.local", + "account_id": 9, + "character_guid": 15, + "report_validated": true + }, + "artifact": { + "path": "rust", + "packet_count": 196, + "tree_sha256": "11563c26932f862d6bf1315f785f7c71002522a26f9489d9e65b72080c46d61c" + } +} diff --git a/crates/capture-diff/flows/vendor-extended-cost-purchase/cpp.pkt b/crates/capture-diff/flows/vendor-extended-cost-purchase/cpp.pkt new file mode 100644 index 00000000..132d27ff Binary files /dev/null and b/crates/capture-diff/flows/vendor-extended-cost-purchase/cpp.pkt differ diff --git a/crates/capture-diff/flows/vendor-extended-cost-purchase/expected-divergences.json b/crates/capture-diff/flows/vendor-extended-cost-purchase/expected-divergences.json new file mode 100644 index 00000000..0637a088 --- /dev/null +++ b/crates/capture-diff/flows/vendor-extended-cost-purchase/expected-divergences.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/crates/capture-diff/flows/vendor-extended-cost-purchase/flow.json b/crates/capture-diff/flows/vendor-extended-cost-purchase/flow.json new file mode 100644 index 00000000..3b68c4d7 --- /dev/null +++ b/crates/capture-diff/flows/vendor-extended-cost-purchase/flow.json @@ -0,0 +1,6 @@ +{ + "description": "The exact realm-routed post-COMMIT response window for one extended-cost item purchase: SMSG_BUY_SUCCEEDED followed by SMSG_ITEM_PUSH_RESULT. The paired bot run separately proves the instance-routed currency debit, item creation, logout/relogin persistence and bounded fixture restoration.", + "directions": [ + "s2c" + ] +} diff --git a/crates/capture-diff/flows/vendor-extended-cost-purchase/rust/rust-s2c-00000000-counter0-0x26C6-BuySucceeded-len24.bin b/crates/capture-diff/flows/vendor-extended-cost-purchase/rust/rust-s2c-00000000-counter0-0x26C6-BuySucceeded-len24.bin new file mode 100644 index 00000000..eb9fe31e Binary files /dev/null and b/crates/capture-diff/flows/vendor-extended-cost-purchase/rust/rust-s2c-00000000-counter0-0x26C6-BuySucceeded-len24.bin differ diff --git a/crates/capture-diff/flows/vendor-extended-cost-purchase/rust/rust-s2c-00000000-counter0-0x26C6-BuySucceeded-len24.meta b/crates/capture-diff/flows/vendor-extended-cost-purchase/rust/rust-s2c-00000000-counter0-0x26C6-BuySucceeded-len24.meta new file mode 100644 index 00000000..4fb86e45 --- /dev/null +++ b/crates/capture-diff/flows/vendor-extended-cost-purchase/rust/rust-s2c-00000000-counter0-0x26C6-BuySucceeded-len24.meta @@ -0,0 +1,8 @@ +direction=s2c +connection_id=0 +addr=127.0.0.1:0 +seq=0 +counter=0 +opcode=0x26C6 +name=BuySucceeded +len=24 diff --git a/crates/capture-diff/flows/vendor-extended-cost-purchase/rust/rust-s2c-00000001-counter1-0x2623-ItemPushResult-len66.bin b/crates/capture-diff/flows/vendor-extended-cost-purchase/rust/rust-s2c-00000001-counter1-0x2623-ItemPushResult-len66.bin new file mode 100644 index 00000000..cbe15ac5 Binary files /dev/null and b/crates/capture-diff/flows/vendor-extended-cost-purchase/rust/rust-s2c-00000001-counter1-0x2623-ItemPushResult-len66.bin differ diff --git a/crates/capture-diff/flows/vendor-extended-cost-purchase/rust/rust-s2c-00000001-counter1-0x2623-ItemPushResult-len66.meta b/crates/capture-diff/flows/vendor-extended-cost-purchase/rust/rust-s2c-00000001-counter1-0x2623-ItemPushResult-len66.meta new file mode 100644 index 00000000..9622edad --- /dev/null +++ b/crates/capture-diff/flows/vendor-extended-cost-purchase/rust/rust-s2c-00000001-counter1-0x2623-ItemPushResult-len66.meta @@ -0,0 +1,8 @@ +direction=s2c +connection_id=0 +addr=127.0.0.1:0 +seq=1 +counter=1 +opcode=0x2623 +name=ItemPushResult +len=66 diff --git a/crates/capture-diff/scripts/capture-cpp.sh b/crates/capture-diff/scripts/capture-cpp.sh index 286bdf7f..1ff547c9 100755 --- a/crates/capture-diff/scripts/capture-cpp.sh +++ b/crates/capture-diff/scripts/capture-cpp.sh @@ -45,8 +45,9 @@ # 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 +# loot-single-item-claim and vendor evidence +# WOW_BOT_REPORT fresh absolute bot JSON report path for that exact flow; +# mandatory for vendor-extended-cost-purchase # # This stops the live RustyCore world server (disconnecting players). It refuses # to run without confirmation; pass --yes to skip the prompt. @@ -221,6 +222,28 @@ case "$CPP_CAPTURE_LOOT_FIXTURE_GUARD" in ;; esac +if [ "$FLOW" = "vendor-extended-cost-purchase" ]; then + [ -n "$CPP_CAPTURE_EXEC" ] && [ -n "$CPP_CAPTURE_EXEC_SHA256" ] || { + echo "error: vendor 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: vendor 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,,}" + capture_validate_fresh_bot_inputs \ + "$WOW_BOT_EXEC" "$WOW_BOT_EXEC_SHA256" "$WOW_BOT_REPORT" || { + echo "error: vendor bot executable/report inputs are not fresh, canonical, and pinned" >&2 + exit 2 + } +fi + 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 @@ -423,17 +446,28 @@ cpp_capture_executable_unchanged() { 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 + local capture_evidence created_at manifest_stage packet_sha packet_size bot_evidence [ "$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 + 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 + ;; + vendor-extended-cost-purchase) + bot_evidence="$(capture_vendor_bot_evidence \ + "$WOW_BOT_REPORT" "$WOW_BOT_EXEC" "$WOW_BOT_EXEC_SHA256")" || return 1 + ;; + esac + if [ -n "$bot_evidence" ]; then 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 + capture_evidence="$(capture_bot_manifest_evidence \ + "$FLOW" "$CPP_CAPTURE_BOT_EXEC" "$CPP_CAPTURE_BOT_EXEC_SHA256" \ + "$CPP_CAPTURE_BOT_REPORT" "$CPP_CAPTURE_BOT_REPORT_SHA256")" || return 1 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 @@ -472,13 +506,13 @@ finalize_cpp_capture_artifact() { --arg bot_report_path "$CPP_CAPTURE_BOT_REPORT" \ --arg bot_report_sha256 "$CPP_CAPTURE_BOT_REPORT_SHA256" \ --arg packet_sha256 "$packet_sha" \ + --argjson capture_evidence "$capture_evidence" \ --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)" \ @@ -516,31 +550,8 @@ finalize_cpp_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: "cpp.pkt", size: $packet_size, diff --git a/crates/capture-diff/scripts/capture-rust.sh b/crates/capture-diff/scripts/capture-rust.sh index eddb4b7c..7f274d7c 100755 --- a/crates/capture-diff/scripts/capture-rust.sh +++ b/crates/capture-diff/scripts/capture-rust.sh @@ -42,11 +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 both -# guarded #106 loot evidence flows +# WOW_BOT_EXEC / WOW_BOT_EXEC_SHA256 pinned bot executable used for guarded +# #106 loot and vendor-extended-cost-purchase evidence # 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 +# loot or vendor contract before publish # # This restarts the live world server (disconnecting players). Pass --yes to skip # the confirmation prompt. @@ -241,6 +241,33 @@ case "$RUST_CAPTURE_LOOT_FIXTURE_GUARD" in ;; esac +if [ "$FLOW" = "vendor-extended-cost-purchase" ]; then + [ -n "$RUST_CAPTURE_EXEC" ] && [ -n "$RUST_CAPTURE_EXEC_SHA256" ] || { + echo "error: vendor 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: vendor evidence requires RUST_CAPTURE_EFFECTIVE_CONFIG" >&2 + exit 2 + } + [ -n "$WOW_BOT_EXEC" ] && [ -n "$WOW_BOT_EXEC_SHA256" ] \ + && [ -n "$WOW_BOT_REPORT" ] || { + echo "error: vendor 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,,}" + capture_validate_fresh_bot_inputs \ + "$WOW_BOT_EXEC" "$WOW_BOT_EXEC_SHA256" "$WOW_BOT_REPORT" || { + echo "error: vendor bot executable/report inputs are not fresh, canonical, and pinned" >&2 + exit 2 + } +fi + sha256_of_file() { local output digest output="$(sha256sum <"$1")" || return 1 @@ -900,6 +927,10 @@ finalize_rust_capture_artifact() { bot_evidence="$(capture_loot_race_bot_evidence \ "$WOW_BOT_REPORT" "$WOW_BOT_EXEC" "$WOW_BOT_EXEC_SHA256")" || return 1 ;; + vendor-extended-cost-purchase) + bot_evidence="$(capture_vendor_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 \ diff --git a/crates/capture-diff/scripts/capture-service-common.sh b/crates/capture-diff/scripts/capture-service-common.sh index e318e458..e4b258a3 100644 --- a/crates/capture-diff/scripts/capture-service-common.sh +++ b/crates/capture-diff/scripts/capture-service-common.sh @@ -532,6 +532,82 @@ capture_publish_noreplace() { [ "$target_identity" = "$source_identity" ] } +capture_vendor_report_proves_exact_success() { + local report_path="$1" + + jq -e ' + .vendor_smoke == true + and .loot_item_capture == false + and .loot_race_smoke == false + and (.results | type == "array" and length == 1) + and (.results[0] + | .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 .vendor_smoke == true + and .vendor_smoke_passed == true + and .vendor_entry == 18525 + and .vendor_spawn_guid == 96654 + and (.vendor_runtime_counter | type == "number" and . > 0) + and .vendor_item_entry == 30183 + and .vendor_extended_cost == 1642 + and .vendor_currency_id == 42 + and .vendor_currency_before == 30 + and .vendor_currency_after == 15 + and .vendor_item_total_after == 1 + and .vendor_inventory_seen == true + and .vendor_buy_succeeded_seen == true + and .vendor_set_currency_seen == true + and .vendor_item_push_seen == true + and .vendor_relogin_verified == true + and .vendor_failure == null) + ' "$report_path" >/dev/null +} + +capture_vendor_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_vendor_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_validate_fresh_bot_inputs() { + local bot_exec="$1" + local expected_bot_sha="$2" + local report_path="$3" + local report_parent + + [ -n "$bot_exec" ] && [ -n "$expected_bot_sha" ] \ + && [ -n "$report_path" ] || return 1 + [[ "$expected_bot_sha" =~ ^[0-9a-f]{64}$ ]] || return 1 + [[ "$report_path" = /* && "$report_path" != *$'\n'* ]] \ + && [ -d "$(dirname -- "$report_path")" ] \ + && [ ! -e "$report_path" ] && [ ! -L "$report_path" ] || return 1 + report_parent="$(dirname -- "$report_path")" + [ "$(realpath -e -- "$report_parent" 2>/dev/null)" = "$report_parent" ] \ + && [ ! -L "$report_parent" ] || return 1 + capture_exec_source_matches "$bot_exec" "$expected_bot_sha" +} + capture_loot_item_bot_evidence() { local report_path="$1" local bot_exec="$2" @@ -724,6 +800,28 @@ capture_bot_manifest_evidence() { } ' ;; + vendor-extended-cost-purchase) + 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: null, + bot_report: { + contract: "wow-test-bot-vendor-extended-cost-purchase-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}' ;; diff --git a/crates/capture-diff/src/lib.rs b/crates/capture-diff/src/lib.rs index 710a4af5..933ee74d 100644 --- a/crates/capture-diff/src/lib.rs +++ b/crates/capture-diff/src/lib.rs @@ -39,7 +39,8 @@ pub use flow::{ }; pub use model::{Capture, CapturedPacket, Direction, PacketBoundary, opcode_name}; pub use semantic::{ - ExactObjectGuid, InvSlotValue, LogXpGainBody, LootRemovedBody, SemanticBodyDiff, - SemanticBodySide, StableObjectGuid, UpdateObjectInvSlotsBody, decode_log_xp_gain_body, - decode_loot_removed_body, validate_loot_single_item_claim_capture, + BuySucceededBody, ExactObjectGuid, InvSlotValue, LogXpGainBody, LootRemovedBody, + SemanticBodyDiff, SemanticBodySide, StableObjectGuid, UpdateObjectInvSlotsBody, + decode_buy_succeeded_body, 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 index 7386a9f5..f7f85f11 100644 --- a/crates/capture-diff/src/lineage.rs +++ b/crates/capture-diff/src/lineage.rs @@ -472,6 +472,7 @@ fn validate_raw_manifest_schema( "loot-two-session-atomic-race" => { validate_canonical_loot_race_identity(manifest)?; } + "vendor-extended-cost-purchase" => validate_canonical_vendor_identity(manifest)?, _ => {} } @@ -626,6 +627,27 @@ fn validate_canonical_loot_race_identity(manifest: &RawCaptureManifest) -> Resul Ok(()) } +fn validate_canonical_vendor_identity(manifest: &RawCaptureManifest) -> Result<()> { + ensure!( + manifest.fixture_guard.is_none(), + "vendor-extended-cost-purchase uses the bot-owned fixture and must not claim a wrapper fixture_guard" + ); + let bot = manifest + .bot_report + .as_ref() + .context("vendor-extended-cost-purchase requires bot_report evidence")?; + ensure!( + bot.contract == "wow-test-bot-vendor-extended-cost-purchase-report-v1", + "unexpected bot_report contract" + ); + ensure!(bot.report_validated, "bot_report was not validated"); + ensure!( + bot.account == "TESTBOT2@bot.local" && bot.account_id == 9 && bot.character_guid == 15, + "vendor bot report manifest identity is not canonical TESTBOT2" + ); + Ok(()) +} + fn validate_cross_side_identity( flow: &str, cpp: &RawCaptureManifest, @@ -645,12 +667,14 @@ fn validate_cross_side_identity( ); if matches!( flow, - "loot-single-item-claim" | "loot-two-session-atomic-race" + "loot-single-item-claim" | "loot-two-session-atomic-race" | "vendor-extended-cost-purchase" ) { - ensure!( - cpp.fixture_guard == rust.fixture_guard, - "C++ and Rust guarded-loot fixture identities differ" - ); + if flow != "vendor-extended-cost-purchase" { + ensure!( + cpp.fixture_guard == rust.fixture_guard, + "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 .bot_report @@ -693,10 +717,75 @@ fn validate_bot_report_json(bytes: &[u8], evidence: &BotReportEvidence) -> Resul "wow-test-bot-loot-two-session-atomic-race-report-v1" => { validate_loot_race_bot_report_json(&report, evidence) } + "wow-test-bot-vendor-extended-cost-purchase-report-v1" => { + validate_vendor_bot_report_json(&report, evidence) + } contract => bail!("unsupported bot report contract {contract:?}"), } } +fn validate_vendor_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("vendor_smoke") + .and_then(serde_json::Value::as_bool) + == Some(true) + && 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(false) + && results.len() == 1, + "bot report is not a single-session vendor 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("vendor_smoke") == Some(true) + && boolean("vendor_smoke_passed") == Some(true) + && u64_value("vendor_entry") == Some(18_525) + && u64_value("vendor_spawn_guid") == Some(96_654) + && u64_value("vendor_runtime_counter").is_some_and(|counter| counter > 0) + && u64_value("vendor_item_entry") == Some(30_183) + && u64_value("vendor_extended_cost") == Some(1_642) + && u64_value("vendor_currency_id") == Some(42) + && u64_value("vendor_currency_before") == Some(30) + && u64_value("vendor_currency_after") == Some(15) + && u64_value("vendor_item_total_after") == Some(1) + && boolean("vendor_inventory_seen") == Some(true) + && boolean("vendor_buy_succeeded_seen") == Some(true) + && boolean("vendor_set_currency_seen") == Some(true) + && boolean("vendor_item_push_seen") == Some(true) + && boolean("vendor_relogin_verified") == Some(true) + && result + .get("vendor_failure") + .is_some_and(serde_json::Value::is_null), + "bot report does not prove the canonical successful vendor flow" + ); + Ok(()) +} + fn validate_loot_item_bot_report_json( report: &serde_json::Value, evidence: &BotReportEvidence, @@ -1081,12 +1170,14 @@ pub fn verify_required_lineage( ); if matches!( flow, - "loot-single-item-claim" | "loot-two-session-atomic-race" + "loot-single-item-claim" | "loot-two-session-atomic-race" | "vendor-extended-cost-purchase" ) { - ensure!( - lineage.sources.cpp.fixture_guard == lineage.sources.rust.fixture_guard, - "required lineage C++/Rust guarded-loot fixture identities differ" - ); + if flow != "vendor-extended-cost-purchase" { + ensure!( + lineage.sources.cpp.fixture_guard == lineage.sources.rust.fixture_guard, + "required lineage C++/Rust guarded-loot fixture identities differ" + ); + } let cpp_bot = lineage .sources .cpp @@ -2088,6 +2179,61 @@ mod tests { }); fs::write(manifest_path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap(); } + } else if flow == "vendor-extended-cost-purchase" { + let report_json = serde_json::json!({ + "vendor_smoke": true, + "loot_item_capture": false, + "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, + "vendor_smoke": true, + "vendor_smoke_passed": true, + "vendor_entry": 18525, + "vendor_spawn_guid": 96654, + "vendor_runtime_counter": 111, + "vendor_item_entry": 30183, + "vendor_extended_cost": 1642, + "vendor_currency_id": 42, + "vendor_currency_before": 30, + "vendor_currency_after": 15, + "vendor_item_total_after": 1, + "vendor_inventory_seen": true, + "vendor_buy_succeeded_seen": true, + "vendor_set_currency_seen": true, + "vendor_item_push_seen": true, + "vendor_relogin_verified": true, + "vendor_failure": null + }] + }); + let report_bytes = serde_json::to_vec_pretty(&report_json).unwrap(); + let cpp_report = raw.join("cpp-vendor-report.json"); + let rust_report = raw.join("rust-vendor-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["bot_report"] = serde_json::json!({ + "contract": "wow-test-bot-vendor-extended-cost-purchase-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) } @@ -2442,6 +2588,49 @@ mod tests { fs::remove_dir_all(root).unwrap(); } + #[test] + fn vendor_raw_pair_requires_exact_bot_report_and_retains_both_reports() { + let root = test_root("vendor-report"); + let flow = "vendor-extended-cost-purchase"; + 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); + assert!( + flow_dir + .join(RAW_PROVENANCE_DIR) + .join(CPP_BOT_REPORT_FILE) + .is_file() + ); + assert!( + flow_dir + .join(RAW_PROVENANCE_DIR) + .join(RUST_BOT_REPORT_FILE) + .is_file() + ); + + 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"][0]["vendor_relogin_verified"] = serde_json::Value::Bool(false); + 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("vendor report without relog proof must fail"); + assert!(format!("{error:#}").contains("canonical successful vendor flow")); + 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/semantic.rs b/crates/capture-diff/src/semantic.rs index ecb2854f..08a1d100 100644 --- a/crates/capture-diff/src/semantic.rs +++ b/crates/capture-diff/src/semantic.rs @@ -24,6 +24,9 @@ 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_BUY_SUCCEEDED` in the 3.4.3 opcode table. +pub const SMSG_BUY_SUCCEEDED: u16 = 0x26C6; + /// `SMSG_UPDATE_OBJECT` in the 3.4.3 opcode table. pub const SMSG_UPDATE_OBJECT: u16 = 0x27CB; @@ -60,6 +63,17 @@ 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]; +const ISSUE_108_VENDOR_IDENTITY: StableObjectGuid = StableObjectGuid { + high_type: HIGH_GUID_CREATURE, + realm_id: 1, + map_id: 530, + entry: 18_525, + subtype: 0, + server_id: 0, +}; +const ISSUE_108_VENDOR_MUID: u32 = 59; +const ISSUE_108_VENDOR_NEW_QUANTITY: i32 = -1; +const ISSUE_108_VENDOR_QUANTITY_BOUGHT: u32 = 1; /// Stable identity fields of a world-object `ObjectGuid` whose map-runtime /// counter has one narrowly reviewed normalization. @@ -115,6 +129,18 @@ pub struct LootRemovedBody { pub loot_list_id: u8, } +/// Stable semantic representation of the issue-#108 vendor success ACK. +/// +/// Only the lower 40-bit map-runtime counter of the reviewed G'eras Creature +/// GUID is absent. The response fields remain exact. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct BuySucceededBody { + pub vendor: StableObjectGuid, + pub muid: u32, + pub new_quantity: i32, + pub quantity_bought: u32, +} + /// One exact `ActivePlayerData::InvSlots` value in a normalized player VALUES /// update. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -145,6 +171,8 @@ pub struct SemanticBodySide { #[serde(skip_serializing_if = "Option::is_none", default)] pub loot_removed: Option, #[serde(skip_serializing_if = "Option::is_none", default)] + pub buy_succeeded: 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, @@ -170,6 +198,7 @@ impl SemanticBodySide { Self { log_xp_gain: Some(decoded.body), loot_removed: None, + buy_succeeded: None, update_object_inv_slots: None, decode_error: Some( "kill XP creature victim has a zero runtime GUID counter".to_string(), @@ -180,6 +209,7 @@ impl SemanticBodySide { Ok(decoded) => Self { log_xp_gain: Some(decoded.body), loot_removed: None, + buy_succeeded: None, update_object_inv_slots: None, decode_error: None, raw_body_sha256: (!decoded.is_creature_kill()).then(|| raw_body_sha256(raw_body)), @@ -187,6 +217,7 @@ impl SemanticBodySide { Err(error) => Self { log_xp_gain: None, loot_removed: None, + buy_succeeded: None, update_object_inv_slots: None, decode_error: Some(error), raw_body_sha256: Some(raw_body_sha256(raw_body)), @@ -205,6 +236,7 @@ impl SemanticBodySide { Self { log_xp_gain: None, loot_removed: Some(decoded.body), + buy_succeeded: None, update_object_inv_slots: None, decode_error: shape_error, // Only the exact issue-#106 Doctor/LootObject/list shape may @@ -217,6 +249,35 @@ impl SemanticBodySide { Err(error) => Self { log_xp_gain: None, loot_removed: None, + buy_succeeded: None, + update_object_inv_slots: None, + decode_error: Some(error), + raw_body_sha256: Some(raw_body_sha256(raw_body)), + }, + } + } + + fn from_decoded_buy_succeeded( + decoded: Result, + raw_body: &[u8], + ) -> Self { + match decoded { + Ok(decoded) => { + let shape_error = decoded.issue_108_shape_error(); + let reviewed_shape = decoded.is_issue_108_reviewed_shape(); + Self { + log_xp_gain: None, + loot_removed: None, + buy_succeeded: Some(decoded.body), + update_object_inv_slots: None, + decode_error: shape_error, + raw_body_sha256: (!reviewed_shape).then(|| raw_body_sha256(raw_body)), + } + } + Err(error) => Self { + log_xp_gain: None, + loot_removed: None, + buy_succeeded: None, update_object_inv_slots: None, decode_error: Some(error), raw_body_sha256: Some(raw_body_sha256(raw_body)), @@ -232,6 +293,7 @@ impl SemanticBodySide { UpdateObjectInvSlotsDecode::Candidate(decoded) => Self { log_xp_gain: None, loot_removed: None, + buy_succeeded: None, update_object_inv_slots: Some(decoded.body), decode_error: None, raw_body_sha256: None, @@ -239,6 +301,7 @@ impl SemanticBodySide { UpdateObjectInvSlotsDecode::NotEligible(reason) => Self { log_xp_gain: None, loot_removed: None, + buy_succeeded: None, update_object_inv_slots: None, decode_error: Some(format!( "not the reviewed single-player InvSlots VALUES shape: {reason}" @@ -248,6 +311,7 @@ impl SemanticBodySide { UpdateObjectInvSlotsDecode::Malformed(error) => Self { log_xp_gain: None, loot_removed: None, + buy_succeeded: None, update_object_inv_slots: None, decode_error: Some(error), raw_body_sha256: Some(raw_body_sha256(raw_body)), @@ -277,6 +341,7 @@ impl SemanticBodyDiff { && 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.buy_succeeded == self.rust.buy_succeeded && self.cpp.update_object_inv_slots == self.rust.update_object_inv_slots && self.cpp.raw_body_sha256 == self.rust.raw_body_sha256 } @@ -299,6 +364,10 @@ impl SemanticBodyDiff { return mismatch_loot_removed(cpp, rust, &self.cpp, &self.rust); } + if let (Some(cpp), Some(rust)) = (self.cpp.buy_succeeded, self.rust.buy_succeeded) { + return mismatch_buy_succeeded(cpp, rust, &self.cpp, &self.rust); + } + if let (Some(cpp), Some(rust)) = ( self.cpp.update_object_inv_slots.as_ref(), self.rust.update_object_inv_slots.as_ref(), @@ -429,6 +498,52 @@ fn mismatch_update_object_inv_slots( } } +fn mismatch_buy_succeeded( + cpp: BuySucceededBody, + rust: BuySucceededBody, + cpp_side: &SemanticBodySide, + rust_side: &SemanticBodySide, +) -> String { + let mut fields = Vec::new(); + if cpp.vendor.high_type != rust.vendor.high_type { + fields.push("vendor.high_type"); + } + if cpp.vendor.realm_id != rust.vendor.realm_id { + fields.push("vendor.realm_id"); + } + if cpp.vendor.map_id != rust.vendor.map_id { + fields.push("vendor.map_id"); + } + if cpp.vendor.entry != rust.vendor.entry { + fields.push("vendor.entry"); + } + if cpp.vendor.subtype != rust.vendor.subtype { + fields.push("vendor.subtype"); + } + if cpp.vendor.server_id != rust.vendor.server_id { + fields.push("vendor.server_id"); + } + if cpp.muid != rust.muid { + fields.push("muid"); + } + if cpp.new_quantity != rust.new_quantity { + fields.push("new_quantity"); + } + if cpp.quantity_bought != rust.quantity_bought { + fields.push("quantity_bought"); + } + + if fields.is_empty() { + if cpp_side.raw_body_sha256 == rust_side.raw_body_sha256 { + "semantic values are equal".to_string() + } else { + "raw body identity differs outside the reviewed vendor-success shape".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 @@ -449,11 +564,40 @@ pub fn compare_packet_bodies( match opcode { SMSG_LOG_XP_GAIN => compare_log_xp_gain_bodies(cpp, rust), SMSG_LOOT_REMOVED => compare_loot_removed_bodies(cpp, rust), + SMSG_BUY_SUCCEEDED => compare_buy_succeeded_bodies(cpp, rust), SMSG_UPDATE_OBJECT => compare_update_object_inv_slots_bodies(cpp, rust), _ => None, } } +fn compare_buy_succeeded_bodies(cpp: &[u8], rust: &[u8]) -> Option { + let cpp_decoded = decode_buy_succeeded_body_with_counter(cpp); + let rust_decoded = decode_buy_succeeded_body_with_counter(rust); + + // Normalize only the exact G'eras fixture identity. The bot preflight + // pins SQL spawn 96654 and rejects an overlapping same-entry spawn before + // it performs the purchase. + let cpp_has_reviewed_vendor = cpp_decoded + .as_ref() + .is_ok_and(DecodedBuySucceededBody::has_issue_108_vendor_identity); + let rust_has_reviewed_vendor = rust_decoded + .as_ref() + .is_ok_and(DecodedBuySucceededBody::has_issue_108_vendor_identity); + if cpp_decoded.is_ok() + && rust_decoded.is_ok() + && !cpp_has_reviewed_vendor + && !rust_has_reviewed_vendor + { + return None; + } + + Some(SemanticBodyDiff { + comparator: "smsg_buy_succeeded_without_vendor_runtime_guid_counter".to_string(), + cpp: SemanticBodySide::from_decoded_buy_succeeded(cpp_decoded, cpp), + rust: SemanticBodySide::from_decoded_buy_succeeded(rust_decoded, rust), + }) +} + 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); @@ -591,6 +735,12 @@ struct DecodedLootRemovedBody { owner_runtime_counter: u64, } +#[derive(Debug, Clone, Copy)] +struct DecodedBuySucceededBody { + body: BuySucceededBody, + vendor_runtime_counter: u64, +} + #[derive(Debug, Clone)] struct DecodedUpdateObjectInvSlotsBody { body: UpdateObjectInvSlotsBody, @@ -648,6 +798,44 @@ impl DecodedLootRemovedBody { } } +impl DecodedBuySucceededBody { + fn has_issue_108_vendor_identity(&self) -> bool { + self.body.vendor == ISSUE_108_VENDOR_IDENTITY + } + + fn issue_108_shape_error(&self) -> Option { + if !self.has_issue_108_vendor_identity() { + return None; + } + if self.vendor_runtime_counter == 0 { + return Some("issue-#108 vendor has a zero runtime GUID counter".to_string()); + } + if self.body.muid != ISSUE_108_VENDOR_MUID { + return Some(format!( + "issue-#108 vendor MUID is {}, expected {}", + self.body.muid, ISSUE_108_VENDOR_MUID + )); + } + if self.body.new_quantity != ISSUE_108_VENDOR_NEW_QUANTITY { + return Some(format!( + "issue-#108 vendor NewQuantity is {}, expected {}", + self.body.new_quantity, ISSUE_108_VENDOR_NEW_QUANTITY + )); + } + if self.body.quantity_bought != ISSUE_108_VENDOR_QUANTITY_BOUGHT { + return Some(format!( + "issue-#108 vendor QuantityBought is {}, expected {}", + self.body.quantity_bought, ISSUE_108_VENDOR_QUANTITY_BOUGHT + )); + } + None + } + + fn is_issue_108_reviewed_shape(&self) -> bool { + self.has_issue_108_vendor_identity() && self.issue_108_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 @@ -1438,6 +1626,36 @@ pub fn decode_loot_removed_body(body: &[u8]) -> Result decode_loot_removed_body_with_counter(body).map(|decoded| decoded.body) } +/// Decode the opcode-less body emitted by C++ +/// `WorldPackets::Item::BuySucceeded::Write`. +pub fn decode_buy_succeeded_body(body: &[u8]) -> Result { + decode_buy_succeeded_body_with_counter(body).map(|decoded| decoded.body) +} + +fn decode_buy_succeeded_body_with_counter(body: &[u8]) -> Result { + let mut cursor = 0usize; + let (vendor_low, vendor_high) = read_packed_guid(body, &mut cursor, "VendorGUID")?; + let muid = read_u32(body, &mut cursor, "Muid")?; + let new_quantity = read_i32(body, &mut cursor, "NewQuantity")?; + let quantity_bought = read_u32(body, &mut cursor, "QuantityBought")?; + if cursor != body.len() { + return Err(format!( + "trailing bytes after QuantityBought: decoded {cursor} of {} bytes", + body.len() + )); + } + + Ok(DecodedBuySucceededBody { + body: BuySucceededBody { + vendor: stable_object_guid(vendor_low, vendor_high), + muid, + new_quantity, + quantity_bought, + }, + vendor_runtime_counter: vendor_low & OBJECT_GUID_COUNTER_MASK, + }) +} + 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")?; diff --git a/crates/capture-diff/tests/buy_succeeded_semantic.rs b/crates/capture-diff/tests/buy_succeeded_semantic.rs new file mode 100644 index 00000000..a1d8740a --- /dev/null +++ b/crates/capture-diff/tests/buy_succeeded_semantic.rs @@ -0,0 +1,260 @@ +//! Focused tests for the issue-#108 `SMSG_BUY_SUCCEEDED` normalization. +//! +//! The paired C++/Rust runs load the same unique G'eras SQL spawn with +//! different map-runtime counters. Only that counter may differ; the complete +//! stable Creature identity, success fields, packet shape, direction and realm +//! routing stay strict. + +use capture_diff::diff::DiffReport; +use capture_diff::model::{Capture, CapturedPacket, Direction}; +use capture_diff::semantic::{SMSG_BUY_SUCCEEDED, decode_buy_succeeded_body}; + +const REAL_CPP_BODY: [u8; 22] = [ + 0x01, 0xBF, 0x6F, 0x40, 0x17, 0x12, 0x40, 0x42, 0x04, 0x20, 0x3B, 0x00, 0x00, 0x00, 0xFF, 0xFF, + 0xFF, 0xFF, 0x01, 0x00, 0x00, 0x00, +]; +const REAL_RUST_BODY: [u8; 22] = [ + 0x01, 0xBF, 0xEA, 0x40, 0x17, 0x12, 0x40, 0x42, 0x04, 0x20, 0x3B, 0x00, 0x00, 0x00, 0xFF, 0xFF, + 0xFF, 0xFF, 0x01, 0x00, 0x00, 0x00, +]; + +#[derive(Clone, Copy)] +struct VendorGuid { + entry: u32, + counter: u64, +} + +impl VendorGuid { + fn geras(counter: u64) -> Self { + Self { + entry: 18_525, + counter, + } + } + + fn words(self) -> (u64, u64) { + let high = (8_u64 << 58) // HighGuid::Creature + | (1_u64 << 42) // realm 1 + | (530_u64 << 29) + | (u64::from(self.entry) << 6); + (self.counter, high) + } +} + +fn packed_guid(low: u64, high: u64) -> Vec { + let low_bytes = low.to_le_bytes(); + let high_bytes = high.to_le_bytes(); + let low_mask = low_bytes + .iter() + .enumerate() + .fold(0_u8, |mask, (index, byte)| { + mask | ((*byte != 0) as u8) << index + }); + let high_mask = high_bytes + .iter() + .enumerate() + .fold(0_u8, |mask, (index, byte)| { + mask | ((*byte != 0) as u8) << 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 buy_succeeded_body( + vendor: VendorGuid, + muid: u32, + new_quantity: i32, + quantity_bought: u32, +) -> Vec { + let (low, high) = vendor.words(); + let mut body = packed_guid(low, high); + body.extend(muid.to_le_bytes()); + body.extend(new_quantity.to_le_bytes()); + body.extend(quantity_bought.to_le_bytes()); + 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, 0, SMSG_BUY_SUCCEEDED, cpp_body), + packet(Direction::S2C, 0, SMSG_BUY_SUCCEEDED, rust_body), + Direction::S2C, + ) +} + +#[test] +fn real_pair_decodes_and_omits_only_geras_runtime_counter() { + let cpp = decode_buy_succeeded_body(&REAL_CPP_BODY).expect("decode real C++ body"); + let rust = decode_buy_succeeded_body(&REAL_RUST_BODY).expect("decode real Rust body"); + assert_eq!(cpp, rust); + assert_eq!(cpp.vendor.high_type, 8); + assert_eq!(cpp.vendor.realm_id, 1); + assert_eq!(cpp.vendor.map_id, 530); + assert_eq!(cpp.vendor.entry, 18_525); + assert_eq!(cpp.vendor.subtype, 0); + assert_eq!(cpp.vendor.server_id, 0); + assert_eq!(cpp.muid, 59); + assert_eq!(cpp.new_quantity, -1); + assert_eq!(cpp.quantity_bought, 1); + + let report = s2c_report(REAL_CPP_BODY.to_vec(), REAL_RUST_BODY.to_vec()); + assert!(report.is_clean(), "{}", report.render_text()); + let semantic = report.ops[0] + .body + .as_ref() + .and_then(|body| body.semantic.as_ref()) + .expect("semantic comparator"); + assert_eq!( + semantic.comparator, + "smsg_buy_succeeded_without_vendor_runtime_guid_counter" + ); + assert_eq!(semantic.cpp.raw_body_sha256, None); + assert_eq!(semantic.rust.raw_body_sha256, None); +} + +#[test] +fn reviewed_vendor_requires_exact_fields_and_nonzero_counters() { + let valid_cpp = buy_succeeded_body(VendorGuid::geras(111), 59, -1, 1); + for (rust_body, expected) in [ + ( + buy_succeeded_body(VendorGuid::geras(234), 60, -1, 1), + "MUID", + ), + ( + buy_succeeded_body(VendorGuid::geras(234), 59, 0, 1), + "NewQuantity", + ), + ( + buy_succeeded_body(VendorGuid::geras(234), 59, -1, 2), + "QuantityBought", + ), + ( + buy_succeeded_body(VendorGuid::geras(0), 59, -1, 1), + "zero runtime GUID counter", + ), + ] { + let report = s2c_report(valid_cpp.clone(), rust_body); + assert!(!report.is_clean(), "accepted {expected} mismatch"); + assert!( + report.render_text().contains(expected), + "{}", + report.render_text() + ); + } +} + +#[test] +fn another_vendor_keeps_its_runtime_counter_byte_strict() { + let first = buy_succeeded_body( + VendorGuid { + entry: 18_526, + counter: 1, + }, + 59, + -1, + 1, + ); + let second = buy_succeeded_body( + VendorGuid { + entry: 18_526, + counter: 2, + }, + 59, + -1, + 1, + ); + let report = s2c_report(first, second); + assert!(!report.is_clean()); + assert!(report.ops[0].body.as_ref().unwrap().semantic.is_none()); +} + +#[test] +fn malformed_body_and_wrong_direction_never_receive_normalization() { + let mut trailing = REAL_CPP_BODY.to_vec(); + trailing.push(0xAA); + let malformed = s2c_report(trailing.clone(), trailing); + assert!(!malformed.is_clean()); + assert!( + malformed.ops[0] + .body + .as_ref() + .and_then(|body| body.semantic.as_ref()) + .is_some_and(|semantic| semantic.cpp.decode_error.is_some()) + ); + + let wrong_direction = report( + packet( + Direction::C2S, + 0, + SMSG_BUY_SUCCEEDED, + REAL_CPP_BODY.to_vec(), + ), + packet( + Direction::C2S, + 0, + SMSG_BUY_SUCCEEDED, + REAL_RUST_BODY.to_vec(), + ), + Direction::C2S, + ); + assert!(!wrong_direction.is_clean()); + assert!( + wrong_direction.ops[0] + .body + .as_ref() + .unwrap() + .semantic + .is_none() + ); +} + +#[test] +fn vendor_success_still_requires_cpp_realm_socket_routing() { + let report = report( + packet( + Direction::S2C, + 0, + SMSG_BUY_SUCCEEDED, + REAL_CPP_BODY.to_vec(), + ), + packet( + Direction::S2C, + 1, + SMSG_BUY_SUCCEEDED, + 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); +} diff --git a/crates/capture-diff/tests/vendor_purchase_golden.rs b/crates/capture-diff/tests/vendor_purchase_golden.rs new file mode 100644 index 00000000..fa351bd1 --- /dev/null +++ b/crates/capture-diff/tests/vendor_purchase_golden.rs @@ -0,0 +1,116 @@ +//! Vendor-purchase golden-fixture regression gate for issue #108. +//! +//! The committed pair isolates the two realm-routed responses emitted after +//! the extended-cost purchase commits. The retained, hash-bound bot reports +//! separately prove the currency debit, item persistence after fresh auth, +//! and fixture restoration on both servers. + +use capture_diff::diff::{DiffReport, DivergenceSignature}; +use capture_diff::lineage::{ImportSelection, verify_required_lineage}; +use capture_diff::model::{Direction, PacketBoundary}; +use capture_diff::semantic::{SMSG_BUY_SUCCEEDED, decode_buy_succeeded_body}; +use capture_diff::{flow, pkt, rustdump}; + +const SMSG_ITEM_PUSH_RESULT: u16 = 0x2623; + +fn load_vendor_diff() -> (DiffReport, flow::Flow) { + let flow = + flow::load_flow("vendor-extended-cost-purchase").expect("vendor purchase flow must exist"); + let cpp = pkt::parse_pkt_file(&flow.golden_pkt).expect("parse vendor C++ cpp.pkt"); + let rust = + rustdump::parse_rust_dump(&flow.reference_rust).expect("parse vendor Rust packet dump"); + let report = DiffReport::compute(&cpp, &rust, &flow.directions); + (report, flow) +} + +#[test] +fn vendor_purchase_diff_matches_clean_committed_baseline() { + let (report, flow) = load_vendor_diff(); + let expected_text = + std::fs::read_to_string(&flow.expected).expect("read expected-divergences.json"); + let expected: Vec = + serde_json::from_str(&expected_text).expect("parse expected-divergences.json"); + + assert!( + expected.is_empty(), + "vendor purchase must not pin accepted divergences" + ); + assert_eq!(report.signatures(), expected, "{}", report.render_text()); + assert!(report.is_clean(), "{}", report.render_text()); + assert_eq!(report.counts.matched, 2); + assert_eq!(report.counts.body_mismatches, 0); + assert_eq!(report.counts.connection_mismatches, 0); + assert_eq!(report.counts.missing_in_rust, 0); + assert_eq!(report.counts.extra_in_rust, 0); + + let semantic = report.ops[0] + .body + .as_ref() + .and_then(|body| body.semantic.as_ref()) + .expect("BuySucceeded must exercise the reviewed GUID comparator"); + assert_eq!( + semantic.comparator, + "smsg_buy_succeeded_without_vendor_runtime_guid_counter" + ); + assert!(semantic.is_identical()); +} + +#[test] +fn vendor_purchase_golden_pins_order_route_and_purchase_fields() { + let flow = + flow::load_flow("vendor-extended-cost-purchase").expect("vendor purchase flow must exist"); + let cpp = pkt::parse_pkt_file(&flow.golden_pkt).expect("parse vendor C++ cpp.pkt"); + let rust = + rustdump::parse_rust_dump(&flow.reference_rust).expect("parse vendor Rust packet dump"); + + let expected = [SMSG_BUY_SUCCEEDED, SMSG_ITEM_PUSH_RESULT]; + for capture in [&cpp, &rust] { + assert_eq!(capture.packets.len(), expected.len()); + for (packet, opcode) in capture.packets.iter().zip(expected) { + assert_eq!(packet.direction, Direction::S2C); + assert_eq!(packet.connection_id, 0, "response must use realm"); + assert_eq!(packet.opcode, opcode); + } + let success = decode_buy_succeeded_body(&capture.packets[0].body) + .expect("decode vendor BuySucceeded"); + assert_eq!(success.vendor.high_type, 8); + assert_eq!(success.vendor.realm_id, 1); + assert_eq!(success.vendor.map_id, 530); + assert_eq!(success.vendor.entry, 18_525); + assert_eq!(success.vendor.subtype, 0); + assert_eq!(success.vendor.server_id, 0); + assert_eq!(success.muid, 59); + assert_eq!(success.new_quantity, -1); + assert_eq!(success.quantity_bought, 1); + } + assert_ne!( + cpp.packets[0].body, rust.packets[0].body, + "fixture must retain the distinct real runtime counters" + ); +} + +#[test] +fn vendor_purchase_lineage_revalidates_manifests_and_bot_reports() { + let flow = + flow::load_flow("vendor-extended-cost-purchase").expect("vendor purchase flow must exist"); + let flow_dir = flow + .golden_pkt + .parent() + .expect("vendor golden must have a flow directory"); + let selection = ImportSelection::new( + vec![Direction::S2C], + Some(PacketBoundary { + direction: Some(Direction::S2C), + opcode: SMSG_BUY_SUCCEEDED, + }), + Some(PacketBoundary { + direction: Some(Direction::S2C), + opcode: SMSG_ITEM_PUSH_RESULT, + }), + &[], + true, + ); + + verify_required_lineage("vendor-extended-cost-purchase", flow_dir, &selection) + .expect("vendor lineage and retained reports must verify"); +} diff --git a/crates/world-server/src/main.rs b/crates/world-server/src/main.rs index 2513cce4..0e42a617 100644 --- a/crates/world-server/src/main.rs +++ b/crates/world-server/src/main.rs @@ -14551,6 +14551,7 @@ mod tests { combat_reach: 0.0, liquid_status: 0, is_in_world: true, + realm_send_tx: send_tx.clone(), send_tx, command_tx, durable_loot_money_tracker_like_cpp: Default::default(), @@ -21157,6 +21158,7 @@ mmap.enablePathFinding = 0 combat_reach: 0.0, liquid_status: 0, is_in_world: true, + realm_send_tx: send_tx.clone(), send_tx, command_tx, durable_loot_money_tracker_like_cpp: Default::default(), diff --git a/crates/wow-network/src/lib.rs b/crates/wow-network/src/lib.rs index db2e62db..f3e01975 100644 --- a/crates/wow-network/src/lib.rs +++ b/crates/wow-network/src/lib.rs @@ -51,5 +51,6 @@ pub use player_registry::{ }; pub use session_mgr::{InstanceLink, SessionManager}; pub use world_socket::{ - AccountInfo, SocketReader, SocketWriteFenceLikeCpp, SocketWriter, WorldSocket, WorldSocketError, + AccountInfo, SocketReader, SocketWriteFenceLikeCpp, SocketWriteFenceWaitResultLikeCpp, + SocketWriter, WorldSocket, WorldSocketError, }; diff --git a/crates/wow-network/src/player_registry.rs b/crates/wow-network/src/player_registry.rs index 6ed74d86..6f0ccb8a 100644 --- a/crates/wow-network/src/player_registry.rs +++ b/crates/wow-network/src/player_registry.rs @@ -52,9 +52,8 @@ pub enum SessionCommand { /// 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. + /// `CONNECTION_TYPE_REALM`. Callers that hold only the target session's + /// command channel use this command to request session-local routing. SendRealmPacketLikeCpp(SendRealmPacketLikeCppCommand), /// Apply C++ `Group::Disband`/`Group::RemoveMember` session-local cleanup /// for a connected remote member. @@ -892,8 +891,13 @@ pub struct PlayerBroadcastInfo { pub liquid_status: u32, /// Represented C++ `Player::IsInWorld()` receiver gate for global-message fanout. pub is_in_world: bool, - /// Channel used to push serialised packets to this player's socket. + /// Channel used to push serialised packets to this player's primary + /// (instance after `ConnectTo`) socket. pub send_tx: flume::Sender>, + /// Channel used for opcodes registered on `CONNECTION_TYPE_REALM`. + /// Before `ConnectTo`, or in single-socket tests, this may be the same + /// channel as [`Self::send_tx`]. + pub realm_send_tx: flume::Sender>, /// Channel used for C++-style cross-session state mutations. pub command_tx: flume::Sender, /// Per-character durable loot-money fence used by remote source sessions. @@ -1057,6 +1061,7 @@ mod tests { combat_reach: 0.0, liquid_status: 0, is_in_world: true, + realm_send_tx: send_tx.clone(), send_tx, command_tx, durable_loot_money_tracker_like_cpp: Default::default(), diff --git a/crates/wow-network/src/world_socket.rs b/crates/wow-network/src/world_socket.rs index 5cadc777..c4a7927c 100644 --- a/crates/wow-network/src/world_socket.rs +++ b/crates/wow-network/src/world_socket.rs @@ -23,7 +23,7 @@ use std::future::Future; use std::net::SocketAddr; use std::path::Path; use std::pin::Pin; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -86,14 +86,15 @@ const WRITE_FENCE_PREFIX_LIKE_CPP: [u8; 8] = [0, 0, b'R', b'C', b'F', b'E', b'N' #[derive(Default)] struct SocketWriteFenceStateLikeCpp { next_id: AtomicU64, + writer_closed: AtomicBool, 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. +/// A session handler can be dropped while `send_async` is waiting for 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, @@ -111,16 +112,24 @@ impl Drop for PendingSocketWriteFenceLikeCpp { /// 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()`. +/// TrinityCore enqueues each `SendDirectMessage` during one session update, +/// while RustyCore has independent realm and instance writer tasks. A fence +/// marker is queued after a packet and acknowledged by that socket's writer +/// only after it has fully written every earlier packet. This lets a caller +/// retain observed cross-connection order without relying on scheduler timing +/// or `flume::Sender::is_empty()`. #[derive(Clone, Default)] pub struct SocketWriteFenceLikeCpp { state: Arc, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SocketWriteFenceWaitResultLikeCpp { + Written, + TimedOut, + WriterClosed, +} + impl SocketWriteFenceLikeCpp { fn marker_like_cpp(id: u64) -> Vec { let mut marker = Vec::with_capacity(16); @@ -152,36 +161,54 @@ impl SocketWriteFenceLikeCpp { true } + fn close_writer_like_cpp(&self) { + self.state.writer_closed.store(true, Ordering::Release); + self.state + .pending + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clear(); + } + /// 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. + /// the fence. A slow but live writer is allowed to apply normal socket + /// backpressure up to the caller's configured socket-liveness bound. + /// Callers must keep durable gameplay state committed for every outcome. pub async fn wait_for_prior_packets_written_like_cpp( &self, send_tx: &flume::Sender>, timeout: Duration, - ) -> bool { + ) -> SocketWriteFenceWaitResultLikeCpp { 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 mut pending = self + .state + .pending + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if self.state.writer_closed.load(Ordering::Acquire) { + return SocketWriteFenceWaitResultLikeCpp::WriterClosed; + } + pending.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, + match tokio::time::timeout(timeout, async { + send_tx + .send_async(Self::marker_like_cpp(id)) + .await + .map_err(|_| ())?; + acknowledgement_rx.await.map_err(|_| ()) + }) + .await + { + Ok(Ok(())) => SocketWriteFenceWaitResultLikeCpp::Written, + Ok(Err(())) => SocketWriteFenceWaitResultLikeCpp::WriterClosed, + Err(_) => SocketWriteFenceWaitResultLikeCpp::TimedOut, } } } @@ -1284,6 +1311,15 @@ pub struct SocketWriter { compressor: compression::PacketCompressor, } +impl Drop for SocketWriter { + fn drop(&mut self) { + // Wake any cross-socket ordering wait immediately when this physical + // writer ends. Transient backpressure must not masquerade as failure, + // but a dead socket can never acknowledge an already queued marker. + self.send_write_fence_like_cpp.close_writer_like_cpp(); + } +} + impl SocketWriter { /// Run the write loop: receive serialized packets from session, encrypt, write to TCP. /// @@ -1530,7 +1566,93 @@ mod tests { 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()); + assert_eq!( + wait_fence.await.unwrap(), + SocketWriteFenceWaitResultLikeCpp::Written + ); + } + + #[tokio::test] + async fn write_fence_waits_for_acknowledgement_within_caller_bound_like_cpp() { + let fence = SocketWriteFenceLikeCpp::default(); + let (send_tx, send_rx) = flume::bounded::>(2); + let wait_fence = { + let fence = fence.clone(); + tokio::spawn(async move { + fence + .wait_for_prior_packets_written_like_cpp(&send_tx, Duration::from_secs(1)) + .await + }) + }; + + let marker = send_rx.recv_async().await.unwrap(); + assert!(SocketWriteFenceLikeCpp::marker_id_like_cpp(&marker).is_some()); + tokio::time::sleep(Duration::from_millis(300)).await; + assert!(!wait_fence.is_finished()); + assert!(fence.acknowledge_marker_like_cpp(&marker)); + assert_eq!( + wait_fence.await.unwrap(), + SocketWriteFenceWaitResultLikeCpp::Written + ); + } + + #[tokio::test] + async fn write_fence_fails_when_physical_writer_closes_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(1)) + .await + }) + }; + + let marker = send_rx.recv_async().await.unwrap(); + assert!(SocketWriteFenceLikeCpp::marker_id_like_cpp(&marker).is_some()); + fence.close_writer_like_cpp(); + assert_eq!( + wait_fence.await.unwrap(), + SocketWriteFenceWaitResultLikeCpp::WriterClosed + ); + assert!( + fence + .state + .pending + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .is_empty() + ); + + assert_eq!( + fence + .wait_for_prior_packets_written_like_cpp(&send_tx, Duration::from_secs(1)) + .await, + SocketWriteFenceWaitResultLikeCpp::WriterClosed + ); + } + + #[tokio::test] + async fn write_fence_reports_configured_stalled_writer_timeout_like_cpp() { + let fence = SocketWriteFenceLikeCpp::default(); + let (send_tx, _send_rx) = flume::bounded::>(1); + + assert_eq!( + fence + .wait_for_prior_packets_written_like_cpp(&send_tx, Duration::from_millis(20),) + .await, + SocketWriteFenceWaitResultLikeCpp::TimedOut + ); + assert!( + fence + .state + .pending + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .is_empty() + ); } #[tokio::test] diff --git a/crates/wow-world/src/handlers/character.rs b/crates/wow-world/src/handlers/character.rs index e5049518..043cf48b 100644 --- a/crates/wow-world/src/handlers/character.rs +++ b/crates/wow-world/src/handlers/character.rs @@ -19,8 +19,8 @@ use wow_constants::unit::{ use wow_constants::{ ClientOpcodes, ConditionSourceType, CreatureFlagsExtra, CreatureRandomMovementType, EnchantmentSlot, InventoryResult, InventoryType, ItemBondingType, ItemContext, - ItemExtendedCostFlags, ItemFieldFlags, ItemFlags, ItemFlags2, ItemUpdateState, ItemVendorType, - PowerType, Team, TypeId, TypeMask, UnitStandStateType, + ItemExtendedCostFlags, ItemFieldFlags, ItemFlags, ItemFlags2, ItemModifier, ItemUpdateState, + ItemVendorType, PowerType, Team, TypeId, TypeMask, UnitStandStateType, }; use wow_core::guid::HighGuid; use wow_core::{ObjectGuid, Position}; @@ -43,7 +43,8 @@ use wow_entities::{ GameObjectTemplateData, INVENTORY_DEFAULT_SIZE, INVENTORY_SLOT_BAG_0, INVENTORY_SLOT_BAG_END, INVENTORY_SLOT_BAG_START, INVENTORY_SLOT_ITEM_START, MAX_BAG_SIZE, MAX_GAMEOBJECT_DATA, MovementGeneratorType, NULL_BAG, NULL_SLOT, REAGENT_BAG_SLOT_END, REAGENT_BAG_SLOT_START, - SocketedGem, WorldObject, is_bank_pos, is_equipment_pos, is_inventory_pos, + SendNewItemDelivery, SendNewItemDisplayText, SendNewItemInstancePlan, SendNewItemModifier, + SendNewItemPlan, SocketedGem, WorldObject, is_bank_pos, is_equipment_pos, is_inventory_pos, normalize_creature_chase_movement_type_like_cpp, normalize_creature_random_movement_type_like_cpp, }; @@ -2057,6 +2058,31 @@ fn vendor_buy_quantity_and_price(buy_price: u64, buy_count: u32, quantity: u32) (quantity, price) } +fn vendor_buy_coinage_update_like_cpp(buy_price: u64, remaining_gold: u64) -> Option { + // C++ `_StoreOrEquipNewItem` calls `ModifyMoney(-price)`, whose first + // branch returns without dirtying `ActivePlayerData::Coinage` when the + // amount is zero (`Player.cpp::ModifyMoney`). + (buy_price != 0).then_some(remaining_gold) +} + +fn vendor_stored_new_item_flags_like_cpp( + template: Option<&wow_entities::ItemStorageTemplate>, + bag: u8, + slot: u8, +) -> u32 { + // C++ `StoreNewItem` marks the object new before `_StoreItem`, then the + // store path applies the template bonding rule at its destination. + let mut item = wow_entities::Item::new(0); + if let Some(template) = template { + item.set_bonding(template.bonding); + } + item.set_item_flag(ItemFieldFlags::NEW_ITEM); + item.bind_if_stored(wow_entities::is_bag_pos(wow_entities::make_item_pos( + bag, slot, + ))); + item.item_flags_bits() +} + fn player_money_gain_like_cpp(current_money: u64, amount: u64) -> Option { if amount == 0 { return Some(current_money); @@ -3101,6 +3127,25 @@ impl WorldSession { vendor_slot: u32, expected_item_id: u32, ) -> Option { + #[cfg(test)] + if let Some(item) = self.vendor_buy_item_test_override_like_cpp() { + if vendor_slot != 0 || item.item_id != expected_item_id { + return None; + } + return Some(VendorBuyItem { + item_id: item.item_id, + item_type: item.item_type, + max_count: item.max_count, + incr_time: item.incr_time, + player_condition_id: item.player_condition_id, + has_vendor_conditions: item.has_vendor_conditions, + extended_cost: item.extended_cost, + buy_price: item.buy_price, + max_durability: item.max_durability, + buy_count: item.buy_count, + }); + } + let mut raw_slot = 0u32; let mut expanded = std::collections::HashSet::::new(); let mut queue = std::collections::VecDeque::new(); @@ -11753,7 +11798,6 @@ impl WorldSession { /// /// C++ refs: `HandleBuyItemOpcode` (`Handlers/ItemHandler.cpp:530-564`) /// delegates to `Player::BuyItemFromVendorSlot` (`Player.cpp:22362+`). - /// Simplified: no reputation discount, no extended cost, no stack logic. pub async fn handle_buy_item(&mut self, buy: BuyItem) { use wow_packet::packets::update::{ItemCreateData, UpdateObject}; @@ -11923,18 +11967,26 @@ impl WorldSession { }; item_turnin_changes.append(&mut changes); } - let currency_snapshot = self.player_currencies_like_cpp().clone(); - let currency_gain = match self.add_currency_vendor(buy.item_id as u32, quantity) { + let mut planned_currencies = self.player_currencies_like_cpp().clone(); + let currency_gain = match self.plan_add_currency_vendor_like_cpp( + &mut planned_currencies, + buy.item_id as u32, + quantity, + ) { Ok(delta) => delta, Err(()) => { - self.set_player_currencies_like_cpp(currency_snapshot); self.send_equip_error(InventoryResult::VendorMissingTurnins, None, None, 0, 0); return; } }; for &(currency_id, amount) in &extended_cost_currency_costs { - if i32::try_from(amount).is_err() || !self.remove_currency(currency_id, amount) { - self.set_player_currencies_like_cpp(currency_snapshot); + if i32::try_from(amount).is_err() + || !Self::plan_remove_currency_like_cpp( + &mut planned_currencies, + currency_id, + amount, + ) + { self.send_equip_error(InventoryResult::VendorMissingTurnins, None, None, 0, 0); return; } @@ -11947,17 +11999,50 @@ impl WorldSession { player_guid, &item_turnin_changes, ); - self.append_player_currency_save_statements(&mut tx, player_guid.counter() as u64); - if let Err(e) = char_db.commit_transaction(tx).await { - self.set_player_currencies_like_cpp(currency_snapshot); - warn!("BuyItem: currency vendor transaction failed: {e}"); + self.append_planned_player_currency_save_statements_like_cpp( + &mut tx, + player_guid.counter() as u64, + &mut planned_currencies, + ); + + // C++ mutates currency plus extended-cost turn-ins in one + // serialized Player turn. Rust crosses SQL here, so retain the + // same cancellation/unknown-COMMIT quarantine used by purchases + // that also change money. Equal money sentinels deliberately make + // an ambiguous result indeterminate: the money row cannot prove + // whether these currency/item statements committed. + let Some(money_persistence) = self + .begin_exclusive_player_money_persistence_like_cpp() + .await + else { + return; + }; + let money_marker = self.player_gold_like_cpp(); + let Some(money_persistence) = self + .commit_exclusive_player_money_transaction_like_cpp( + money_persistence, + char_db.as_ref(), + tx, + money_marker, + money_marker, + "vendor currency purchase", + ) + .await + else { + warn!("BuyItem: currency vendor transaction did not commit"); self.send_buy_error( BuyResult::CantFindItem, Some(buy.vendor_guid), buy.item_id as u32, ); return; - } + }; + + // Publish the entire committed state before reopening payout/save + // admission. No await may split durable success from runtime. + self.set_player_currencies_like_cpp(planned_currencies); + self.apply_item_turnin_changes(player_guid, map_id, &item_turnin_changes); + drop(money_persistence); if let Some(delta) = currency_gain { let (Some(quantity), Some(amount)) = ( @@ -11980,7 +12065,6 @@ impl WorldSession { packet.suppress_chat_log = delta.suppress_chat_log; self.send_packet(&packet); } - 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 { @@ -12189,6 +12273,9 @@ impl WorldSession { self.send_equip_error(store_result, None, None, 0, 0); return; } + let quest_log_item_id = self + .quest_source_item_quest_log_item_id_like_cpp(buy.item_id as u32) + .await; let new_item_count = store_dest .iter() @@ -12270,12 +12357,19 @@ impl WorldSession { return; }; - let mut ins_item = char_db.prepare(CharStatements::INS_ITEM_INSTANCE); + let item_flags = + vendor_stored_new_item_flags_like_cpp(refund_template.as_ref(), bag, slot); + 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, buy.item_id as u32); ins_item.set_u64(2, player_guid.counter() as u64); ins_item.set_u32(3, dest.count); ins_item.set_u32(4, max_durability); + ins_item.set_u32(5, item_flags); + ins_item.set_i32(6, 0); + ins_item.set_i32(7, 0); + ins_item.set_u8(8, ItemContext::Vendor as u8); tx.append(ins_item); let mut ins_inv = char_db.prepare(CharStatements::INS_CHAR_INVENTORY); @@ -12284,15 +12378,20 @@ impl WorldSession { ins_inv.set_u64(2, db_guid); tx.append(ins_inv); - new_stacks.push((slot, db_guid, item_guid, dest.count)); + new_stacks.push((slot, db_guid, item_guid, dest.count, item_flags)); } } let refund_item_db_guid = creates_refund_metadata - .then(|| new_stacks.last().map(|&(_, db_guid, _, _)| db_guid)) + .then(|| { + new_stacks.last_mut().map(|stack| { + stack.4 |= ItemFieldFlags::REFUNDABLE.bits(); + (stack.1, stack.4) + }) + }) .flatten(); - if let Some(refund_item_db_guid) = refund_item_db_guid { + if let Some((refund_item_db_guid, refund_item_flags)) = refund_item_db_guid { let mut upd_flags = char_db.prepare(CharStatements::UPD_ITEM_INSTANCE_FLAGS); - upd_flags.set_u32(0, ItemFieldFlags::REFUNDABLE.bits()); + upd_flags.set_u32(0, refund_item_flags); upd_flags.set_u64(1, refund_item_db_guid); tx.append(upd_flags); append_item_refund_insert_statements( @@ -12321,15 +12420,24 @@ impl WorldSession { &item_turnin_changes, ); - let currency_snapshot = self.player_currencies_like_cpp().clone(); + let mut planned_currencies = self.player_currencies_like_cpp().clone(); for &(currency_id, amount) in &extended_cost_currency_costs { - if i32::try_from(amount).is_err() || !self.remove_currency(currency_id, amount) { - self.set_player_currencies_like_cpp(currency_snapshot); + if i32::try_from(amount).is_err() + || !Self::plan_remove_currency_like_cpp( + &mut planned_currencies, + currency_id, + amount, + ) + { self.send_equip_error(InventoryResult::VendorMissingTurnins, None, None, 0, 0); return; } } - self.append_player_currency_save_statements(&mut tx, player_guid.counter() as u64); + self.append_planned_player_currency_save_statements_like_cpp( + &mut tx, + player_guid.counter() as u64, + &mut planned_currencies, + ); let Some(money_persistence) = self .commit_exclusive_player_money_transaction_like_cpp( @@ -12342,7 +12450,6 @@ impl WorldSession { ) .await else { - self.set_player_currencies_like_cpp(currency_snapshot); warn!("BuyItem: store transaction did not commit"); self.send_buy_error( BuyResult::CantFindItem, @@ -12358,6 +12465,7 @@ impl WorldSession { // 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); + self.set_player_currencies_like_cpp(planned_currencies); for &(_, item_guid, new_count) in &existing_updates { self.update_inventory_item_object_like_cpp(item_guid, |item| { item.set_count(new_count); @@ -12366,7 +12474,7 @@ impl WorldSession { let inv_type = self.item_template_inventory_type(buy.item_id as u32); let mut collection_updates = Vec::new(); - for &(slot, db_guid, item_guid, stack_count) in &new_stacks { + for &(slot, db_guid, item_guid, stack_count, item_flags) in &new_stacks { self.insert_inventory_item_like_cpp( slot, crate::session::InventoryItem { @@ -12385,8 +12493,8 @@ impl WorldSession { ItemContext::Vendor, slot, ); - if refund_item_db_guid == Some(db_guid) { - item_object.set_item_flag(ItemFieldFlags::REFUNDABLE); + item_object.replace_all_item_flags(ItemFieldFlags::from_bits_retain(item_flags)); + if refund_item_db_guid.is_some_and(|(refund_db_guid, _)| refund_db_guid == db_guid) { item_object.set_refund_recipient(player_guid); item_object.set_paid_money(buy_price); item_object.set_paid_extended_cost(vendor_item.extended_cost as u32); @@ -12398,8 +12506,68 @@ impl WorldSession { let changed_slots: Vec<_> = new_stacks .iter() - .map(|&(slot, _, item_guid, _)| (slot, item_guid)) + .map(|&(slot, _, item_guid, _, _)| (slot, item_guid)) .collect(); + let quantity_in_inventory = + self.represented_non_bank_item_count_like_cpp(buy.item_id as u32); + let purchased_item_plan = store_dest.last().and_then(|dest| { + let slot = (dest.pos & 0x00FF) as u8; + let item_guid = self.inventory_items_like_cpp().get(&slot)?.guid; + let item = self.inventory_item_objects_like_cpp().get(&item_guid)?; + let battle_pet_breed_data = item.get_modifier(ItemModifier::BattlePetBreedData); + let modifications = item + .data() + .modifiers + .iter() + .enumerate() + .filter_map(|(modifier_type, &value)| { + (value != 0).then_some(SendNewItemModifier { + value: value as i32, + modifier_type: modifier_type as u8, + }) + }) + .collect(); + Some(SendNewItemPlan { + player_guid, + item_guid, + item_entry: item.object().entry(), + item_instance: SendNewItemInstancePlan { + item_id: item.object().entry(), + random_properties_seed: item.data().property_seed, + random_properties_id: item.data().random_properties_id, + modifications, + }, + slot: item.bag_slot(), + slot_in_bag: if item.count() == quantity { + i16::from(item.slot()) + } else { + -1 + }, + quest_log_item_id, + quantity, + quantity_in_inventory, + battle_pet_species_id: item.get_modifier(ItemModifier::BattlePetSpeciesId), + battle_pet_breed_id: battle_pet_breed_data & 0x00FF_FFFF, + battle_pet_breed_quality: ((battle_pet_breed_data >> 24) & 0xFF) as u8, + battle_pet_level: item.get_modifier(ItemModifier::BattlePetLevel), + pushed: true, + created: false, + display_text: SendNewItemDisplayText::Normal, + dungeon_encounter_id: 0, + is_encounter_loot: false, + delivery: SendNewItemDelivery::Direct, + }) + }); + let Some(purchased_item_plan) = purchased_item_plan else { + // The durable purchase is already committed. Fail closed at the + // packet boundary rather than fabricating an ItemPush GUID or + // rolling runtime back out of sync with the database. + warn!( + item = buy.item_id, + "BuyItem: committed item is missing from the published runtime inventory" + ); + return; + }; let new_quantity = if vendor_item.max_count == 0 { -1 } else { @@ -12439,36 +12607,31 @@ impl WorldSession { buy_price, self.player_gold_like_cpp() ); - // ── Send BuySucceeded ── - self.send_packet(&BuySucceeded { - vendor_guid: buy.vendor_guid, - muid: buy.muid, - new_quantity, - quantity_bought: quantity as i32, - }); if !new_stacks.is_empty() { let item_creates = new_stacks .iter() - .map(|&(_, _, item_guid, stack_count)| ItemCreateData { - item_guid, - entry_id: buy.item_id, - owner_guid: player_guid, - contained_in: player_guid, - stack_count, - dynamic_flags: 0, - durability: max_durability, - max_durability, - 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], - }) + .map( + |&(_, _, item_guid, stack_count, item_flags)| ItemCreateData { + item_guid, + entry_id: buy.item_id, + owner_guid: player_guid, + contained_in: player_guid, + stack_count, + dynamic_flags: item_flags, + durability: max_durability, + max_durability, + random_properties_seed: 0, + random_properties_id: 0, + enchantments: [ItemEnchantmentValuesUpdate::default(); 13], + gems: Vec::new(), + context: ItemContext::Vendor as u8, + container_slots: 0, + 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 &(_, item_guid, new_count) in &existing_updates { @@ -12477,12 +12640,39 @@ impl WorldSession { )); } + // C++ `StoreNewItem` publishes item object changes on the instance + // socket before `_StoreOrEquipNewItem` emits its two realm-routed + // result packets. Preserve that physical cross-socket order. + if !self + .wait_for_instance_send_before_realm_send_like_cpp() + .await + { + self.sync_player_registry_state_like_cpp(); + self.kick("vendor socket ordering fence failed after durable item purchase"); + return; + } + self.send_packet_realm(&BuySucceeded { + vendor_guid: buy.vendor_guid, + muid: buy.muid, + new_quantity, + quantity_bought: quantity as i32, + }); + self.send_new_item_plan(&purchased_item_plan); + if !self + .wait_for_realm_send_before_instance_update_like_cpp() + .await + { + self.sync_player_registry_state_like_cpp(); + self.kick("vendor socket ordering fence failed after durable item purchase"); + return; + } + self.send_player_values_update_from_entity_bridge( &changed_slots, &[], &[], &[], - Some(self.player_gold_like_cpp()), + vendor_buy_coinage_update_like_cpp(buy_price, self.player_gold_like_cpp()), ); for update in &collection_updates { self.send_player_values_update_like_cpp(update); @@ -16536,6 +16726,10 @@ impl WorldSession { } } +#[cfg(test)] +#[path = "character_vendor_atomicity_tests.rs"] +mod vendor_atomicity_tests; + #[cfg(test)] mod tests { use super::*; @@ -20410,11 +20604,18 @@ mod tests { "self.stage_player_money_change_like_cpp", &[ "self.apply_item_turnin_changes", + "self.set_player_currencies_like_cpp(planned_currencies);", "self.insert_inventory_item_like_cpp", "self.update_vendor_item_current_count", "self.sync_object_accessor_player();", ], ); + assert_publication_segment( + character, + "vendor currency purchase", + "self.set_player_currencies_like_cpp(planned_currencies);", + &["self.apply_item_turnin_changes"], + ); assert_publication_segment( character, "vendor buyback purchase", @@ -22841,6 +23042,27 @@ mod tests { ); } + #[test] + fn vendor_buy_zero_gold_price_does_not_dirty_coinage_like_cpp() { + assert_eq!(vendor_buy_coinage_update_like_cpp(0, 12_345), None); + assert_eq!(vendor_buy_coinage_update_like_cpp(1, 12_344), Some(12_344)); + } + + #[test] + fn vendor_stored_new_item_keeps_cpp_new_and_bonding_flags() { + assert_eq!( + vendor_stored_new_item_flags_like_cpp(None, INVENTORY_SLOT_BAG_0, 23), + ItemFieldFlags::NEW_ITEM.bits() + ); + + let mut template = wow_entities::ItemStorageTemplate::regular_item(700, 1); + template.bonding = ItemBondingType::OnAcquire; + assert_eq!( + vendor_stored_new_item_flags_like_cpp(Some(&template), INVENTORY_SLOT_BAG_0, 23), + (ItemFieldFlags::NEW_ITEM | ItemFieldFlags::SOULBOUND).bits() + ); + } + #[test] fn vendor_buy_packet_quantity_uses_cpp_uint8_count_conversion() { assert_eq!(vendor_buy_packet_quantity_to_cpp_count(0), 1); diff --git a/crates/wow-world/src/handlers/character_vendor_atomicity_tests.rs b/crates/wow-world/src/handlers/character_vendor_atomicity_tests.rs new file mode 100644 index 00000000..3c344087 --- /dev/null +++ b/crates/wow-world/src/handlers/character_vendor_atomicity_tests.rs @@ -0,0 +1,169 @@ +use super::*; +use crate::session::{SessionPlayerController, VendorBuyItemTestOverrideLikeCpp}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use wow_constants::ServerOpcodes; +use wow_core::ObjectGuidGenerator; + +fn make_vendor_session() -> ( + WorldSession, + flume::Receiver>, + Arc>, +) { + let (_packet_tx, packet_rx) = flume::bounded::(1); + let (send_tx, send_rx) = flume::bounded::>(4); + let mut session = WorldSession::new( + 1, + "TestAccount".into(), + 0, + 2, + 9, + 54261, + vec![0u8; 40], + "esES".into(), + packet_rx, + send_tx, + ); + session.set_item_guid_generator_like_cpp(Arc::new(ObjectGuidGenerator::new(HighGuid::Item, 1))); + let player_guid = ObjectGuid::create_player(1, 42); + session.attach_player_controller_like_cpp(SessionPlayerController::new( + player_guid, + "Tester".to_string(), + Position::new(0.0, 0.0, 0.0, 0.0), + 571, + 1, + 1, + 80, + 0, + )); + let canonical = Arc::new(Mutex::new(wow_map::MapManager::new(60_000, 10))); + session.set_canonical_map_manager(Arc::clone(&canonical)); + (session, send_rx, canonical) +} + +fn insert_vendor(manager: &Arc>, guid: ObjectGuid, entry: u32) { + let mut creature = wow_entities::Creature::new(false); + creature.unit_mut().world_mut().object_mut().create(guid); + creature + .unit_mut() + .world_mut() + .object_mut() + .set_entry(entry); + creature.unit_mut().world_mut().set_map(571, 0).unwrap(); + creature + .unit_mut() + .world_mut() + .relocate(Position::new(5.0, 0.0, 0.0, 0.0)); + creature.unit_mut().world_mut().set_combat_reach(1.0); + creature.unit_mut().set_level(80); + creature.unit_mut().set_max_health(100); + creature.unit_mut().set_health(100); + creature.set_ai_identity_runtime(1, 35, NPCFlags1::VENDOR.bits(), 0); + creature.unit_mut().world_mut().object_mut().add_to_world(); + manager + .lock() + .unwrap() + .create_world_map(571, 0) + .map_mut() + .insert_map_object_record(wow_entities::MapObjectRecord::new_creature(creature).unwrap()) + .unwrap(); +} + +fn drain_server_opcodes(send_rx: &flume::Receiver>) -> Vec { + send_rx + .try_iter() + .filter_map(|bytes| WorldPacket::from_bytes(&bytes).server_opcode()) + .collect() +} + +#[tokio::test] +async fn vendor_currency_purchase_definite_rollback_keeps_runtime_unchanged_like_cpp() { + let (mut session, send_rx, canonical) = make_vendor_session(); + let vendor = ObjectGuid::create_world_object(HighGuid::Creature, 0, 1, 571, 0, 2456, 43); + insert_vendor(&canonical, vendor, 2456); + session.set_currency_types_store(Arc::new(CurrencyTypesStore::from_entries([ + wow_data::CurrencyTypesEntry { + id: 395, + category_id: 0, + inventory_icon_file_id: 0, + spell_weight: 0, + spell_category: 0, + max_qty: 0, + max_earnable_per_week: 0, + quality: 0, + faction_id: 0, + award_condition_id: 0, + flags: wow_constants::CurrencyTypesFlags::empty(), + flags_b: wow_constants::CurrencyTypesFlagsB::empty(), + }, + ]))); + session.set_item_extended_cost_store(Arc::new(ItemExtendedCostStore::from_entries([ + wow_data::ItemExtendedCostEntry { + id: 12, + required_arena_rating: 0, + arena_bracket: 0, + flags: wow_constants::ItemExtendedCostFlags::empty(), + min_faction_id: 0, + min_reputation: 0, + required_achievement: 0, + item_id: [0; wow_data::MAX_ITEM_EXT_COST_ITEMS], + item_count: [0; wow_data::MAX_ITEM_EXT_COST_ITEMS], + currency_id: [0; wow_data::MAX_ITEM_EXT_COST_CURRENCIES], + currency_count: [0; wow_data::MAX_ITEM_EXT_COST_CURRENCIES], + }, + ]))); + session.set_vendor_buy_item_test_override_like_cpp(VendorBuyItemTestOverrideLikeCpp { + item_id: 395, + item_type: ItemVendorType::Currency as i32, + max_count: 0, + incr_time: 0, + player_condition_id: 0, + has_vendor_conditions: false, + extended_cost: 12, + buy_price: 0, + max_durability: 0, + buy_count: 1, + }); + + let failing_world_pool = sqlx::mysql::MySqlPoolOptions::new() + .max_connections(1) + .acquire_timeout(Duration::from_millis(100)) + .connect_lazy("mysql://rustycore:rustycore@127.0.0.1:1/world") + .expect("syntactically valid lazy WorldDB pool"); + session.set_world_db(Arc::new(wow_database::WorldDatabase::from_pool( + failing_world_pool, + ))); + let failing_character_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 CharacterDB pool"); + session.set_char_db(Arc::new(wow_database::CharacterDatabase::from_pool( + failing_character_pool, + ))); + + session + .handle_buy_item(BuyItem { + vendor_guid: vendor, + container_guid: ObjectGuid::EMPTY, + quantity: 1, + muid: 1, + slot: 0, + item_type: ItemVendorType::Currency as i32, + item_id: 395, + }) + .await; + + assert_eq!(session.player_currency_quantity(395), 0); + assert_eq!( + drain_server_opcodes(&send_rx), + vec![ServerOpcodes::BuyFailed] + ); + assert!( + session + .durable_loot_money_persistence_tracker_like_cpp() + .begin_like_cpp() + .is_ok(), + "a definite rollback must reopen payout/save admission" + ); +} diff --git a/crates/wow-world/src/handlers/chat.rs b/crates/wow-world/src/handlers/chat.rs index 98a13d55..3cbe4d93 100644 --- a/crates/wow-world/src/handlers/chat.rs +++ b/crates/wow-world/src/handlers/chat.rs @@ -1928,6 +1928,7 @@ mod tests { combat_reach: 0.0, liquid_status: 0, is_in_world: true, + realm_send_tx: send_tx.clone(), send_tx, command_tx, durable_loot_money_tracker_like_cpp: Default::default(), diff --git a/crates/wow-world/src/handlers/group.rs b/crates/wow-world/src/handlers/group.rs index dda96226..aaa36174 100644 --- a/crates/wow-world/src/handlers/group.rs +++ b/crates/wow-world/src/handlers/group.rs @@ -840,10 +840,9 @@ 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. +/// Legacy C++ routes party-control packets through REALM +/// (`Opcodes.cpp:1826-1832`). This path receives only the target command +/// sender, so the target session performs the final realm-socket routing. async fn send_realm_packet_to_player_like_cpp( recipient: ObjectGuid, command_tx: &flume::Sender, @@ -3389,6 +3388,7 @@ mod tests { combat_reach: 0.0, liquid_status: 0, is_in_world: true, + realm_send_tx: send_tx.clone(), send_tx, command_tx, durable_loot_money_tracker_like_cpp: Default::default(), diff --git a/crates/wow-world/src/handlers/loot.rs b/crates/wow-world/src/handlers/loot.rs index eb433f43..9bec32d7 100644 --- a/crates/wow-world/src/handlers/loot.rs +++ b/crates/wow-world/src/handlers/loot.rs @@ -17938,6 +17938,7 @@ mod tests { combat_reach: 0.0, liquid_status: 0, is_in_world: true, + realm_send_tx: send_tx.clone(), send_tx, command_tx, durable_loot_money_tracker_like_cpp: Default::default(), diff --git a/crates/wow-world/src/handlers/misc.rs b/crates/wow-world/src/handlers/misc.rs index fced7401..d4a7545d 100644 --- a/crates/wow-world/src/handlers/misc.rs +++ b/crates/wow-world/src/handlers/misc.rs @@ -8326,6 +8326,7 @@ mod tests { combat_reach: 0.0, liquid_status: 0, is_in_world: true, + realm_send_tx: send_tx.clone(), send_tx, command_tx, durable_loot_money_tracker_like_cpp: Default::default(), diff --git a/crates/wow-world/src/handlers/movement.rs b/crates/wow-world/src/handlers/movement.rs index 83c5db8a..75a482e9 100644 --- a/crates/wow-world/src/handlers/movement.rs +++ b/crates/wow-world/src/handlers/movement.rs @@ -2666,6 +2666,7 @@ mod tests { combat_reach: 0.0, liquid_status: 0, is_in_world: true, + realm_send_tx: send_tx.clone(), send_tx, command_tx, durable_loot_money_tracker_like_cpp: Default::default(), diff --git a/crates/wow-world/src/session.rs b/crates/wow-world/src/session.rs index ee35b4be..979f1155 100644 --- a/crates/wow-world/src/session.rs +++ b/crates/wow-world/src/session.rs @@ -191,7 +191,8 @@ use wow_network::{ KickLikeCppCommand, LootDropRatesLikeCpp, LootRollCommandIdentityLikeCpp, NotifyLootMoneyRemovedLikeCppCommand, PacketSpoofConfigLikeCpp, PendingInvites, PlayerBroadcastInfo, PlayerRegistry, ReputationRatesLikeCpp, SessionCommand, - SocketTimeoutsLikeCpp, SocketWriteFenceLikeCpp, group_guid_by_db_store_id_like_cpp, + SocketTimeoutsLikeCpp, SocketWriteFenceLikeCpp, SocketWriteFenceWaitResultLikeCpp, + group_guid_by_db_store_id_like_cpp, }; use wow_packet::packets::chat::{ChatMsg, ChatPkt, PrintNotification}; use wow_packet::packets::gossip::ClientGossipText; @@ -217,6 +218,10 @@ use wow_packet::packets::quest::{ use wow_packet::packets::spell::SpellTargetData; use wow_recastdetour::PathQueryFilterContext; +// TrinityCore enqueues cross-connection sends without waiting for physical TCP +// progress. RustyCore waits briefly to retain the order observed in captures, +// then completes the already-committed gameplay fanout if a writer stalls. +const CROSS_SOCKET_WRITE_FENCE_TIMEOUT: Duration = Duration::from_millis(250); const QUEST_OBJECTIVE_ITEM_LIKE_CPP: u8 = 1; const QUEST_OBJECTIVE_CURRENCY_LIKE_CPP: u8 = 4; const QUEST_OBJECTIVE_MIN_REPUTATION_LIKE_CPP: u8 = 6; @@ -5115,6 +5120,10 @@ pub struct WorldSession { /// Per-session finite vendor stock state, mirroring Creature::m_vendorItemCounts /// until vendor ownership moves into the shared creature model. pub(crate) vendor_item_counts: HashMap<(wow_core::ObjectGuid, u32), VendorItemCount>, + /// Test-only replacement for one resolved `VendorItem` row. Production + /// always resolves the row through CharacterHandler's WorldDB query. + #[cfg(test)] + vendor_buy_item_test_override_like_cpp: Option, /// Shared, server-wide map state. When `Some`, creature reads/writes can /// route through here so all sessions on the same map see the same world. @@ -6035,6 +6044,21 @@ pub(crate) struct VendorItemCount { pub last_increment_time: u64, } +#[cfg(test)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct VendorBuyItemTestOverrideLikeCpp { + pub(crate) item_id: u32, + pub(crate) item_type: i32, + pub(crate) max_count: u32, + pub(crate) incr_time: u32, + pub(crate) player_condition_id: u32, + pub(crate) has_vendor_conditions: bool, + pub(crate) extended_cost: u32, + pub(crate) buy_price: u64, + pub(crate) max_durability: u32, + pub(crate) buy_count: u32, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[allow(dead_code)] pub(crate) enum PlayerCurrencyState { @@ -6983,6 +7007,8 @@ impl WorldSession { filter_addon_messages: false, creature_tick: 0, vendor_item_counts: HashMap::new(), + #[cfg(test)] + vendor_buy_item_test_override_like_cpp: None, map_manager: None, canonical_map_manager: None, mmap_pathfinder_like_cpp: None, @@ -8106,6 +8132,21 @@ impl WorldSession { self.canonical_map_manager = Some(mgr); } + #[cfg(test)] + pub(crate) fn set_vendor_buy_item_test_override_like_cpp( + &mut self, + item: VendorBuyItemTestOverrideLikeCpp, + ) { + self.vendor_buy_item_test_override_like_cpp = Some(item); + } + + #[cfg(test)] + pub(crate) fn vendor_buy_item_test_override_like_cpp( + &self, + ) -> Option { + self.vendor_buy_item_test_override_like_cpp + } + pub(crate) fn auto_reply_msg_like_cpp(&self) -> &str { &self.auto_reply_msg_like_cpp } @@ -15375,8 +15416,9 @@ impl WorldSession { } /// C++ `Player::AddCurrency(..., CurrencyGainSource::Vendor)` without aura gain bonuses. - pub(crate) fn add_currency_vendor( - &mut self, + pub(crate) fn plan_add_currency_vendor_like_cpp( + &self, + currencies: &mut HashMap, currency_id: u32, amount: u32, ) -> Result, ()> { @@ -15407,7 +15449,6 @@ impl WorldSession { return Err(()); } - let mut currencies = self.player_currencies_like_cpp().clone(); let currency = currencies.entry(currency_id).or_insert(PlayerCurrency { state: PlayerCurrencyState::New, quantity: 0, @@ -15458,10 +15499,24 @@ impl WorldSession { total_earned: entry.has_total_earned().then_some(currency.earned_quantity), suppress_chat_log: entry.is_suppressing_chat_log(false), }; - self.set_player_currencies_like_cpp(currencies); Ok(Some(delta)) } + /// Publish the C++ vendor gain immediately for callers that do not own a + /// wider durable transaction. Persistence-sensitive vendor handlers use + /// [`Self::plan_add_currency_vendor_like_cpp`] and publish only after + /// their combined item/currency transaction commits. + pub(crate) fn add_currency_vendor( + &mut self, + currency_id: u32, + amount: u32, + ) -> Result, ()> { + let mut currencies = self.player_currencies_like_cpp().clone(); + let delta = self.plan_add_currency_vendor_like_cpp(&mut currencies, currency_id, amount)?; + self.set_player_currencies_like_cpp(currencies); + Ok(delta) + } + /// C++ `Player::AddCurrency(..., CurrencyGainSource::ItemRefund)`. pub(crate) fn add_currency_item_refund( &mut self, @@ -15630,12 +15685,15 @@ impl WorldSession { } /// C++ `Player::RemoveCurrency` underflow guard for vendor costs. - pub(crate) fn remove_currency(&mut self, currency_id: u32, amount: u32) -> bool { + pub(crate) fn plan_remove_currency_like_cpp( + currencies: &mut HashMap, + currency_id: u32, + amount: u32, + ) -> bool { if amount == 0 { return true; } - let mut currencies = self.player_currencies_like_cpp().clone(); let Some(currency) = currencies.get_mut(¤cy_id) else { return false; }; @@ -15648,21 +15706,29 @@ impl WorldSession { if currency.state != PlayerCurrencyState::New { currency.state = PlayerCurrencyState::Changed; } + true + } + + pub(crate) fn remove_currency(&mut self, currency_id: u32, amount: u32) -> bool { + let mut currencies = self.player_currencies_like_cpp().clone(); + if !Self::plan_remove_currency_like_cpp(&mut currencies, currency_id, amount) { + return false; + } self.set_player_currencies_like_cpp(currencies); true } /// C++ `Player::_SaveCurrency` for changed/new currency rows. - pub(crate) fn append_player_currency_save_statements( - &mut self, + pub(crate) fn append_planned_player_currency_save_statements_like_cpp( + &self, tx: &mut SqlTransaction, character_guid: u64, + currencies: &mut HashMap, ) { let Some(store) = self.currency_types_store.as_ref() else { return; }; - let mut currencies = self.player_currencies_like_cpp().clone(); - for (¤cy_id, currency) in &mut currencies { + for (¤cy_id, currency) in currencies.iter_mut() { if !store.has_record(currency_id) { continue; } @@ -15702,6 +15768,19 @@ impl WorldSession { PlayerCurrencyState::Unchanged | PlayerCurrencyState::Removed => {} } } + } + + pub(crate) fn append_player_currency_save_statements( + &mut self, + tx: &mut SqlTransaction, + character_guid: u64, + ) { + let mut currencies = self.player_currencies_like_cpp().clone(); + self.append_planned_player_currency_save_statements_like_cpp( + tx, + character_guid, + &mut currencies, + ); self.set_player_currencies_like_cpp(currencies); } @@ -31265,6 +31344,7 @@ impl WorldSession { liquid_status: self.player_liquid_status_like_cpp(), is_in_world: self.player_is_in_world_for_registry_like_cpp(), send_tx: self.send_tx.clone(), + realm_send_tx: self.realm_send_tx.as_ref().unwrap_or(&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, @@ -36649,9 +36729,10 @@ 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. + /// emitting a later realm packet. C++ enqueues both `SendDirectMessage` + /// calls during one session update; Rust's two independent writer tasks + /// need this FIFO completion fence to retain observed 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; @@ -36666,16 +36747,33 @@ impl WorldSession { ); 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" - ); + match write_fence + .wait_for_prior_packets_written_like_cpp( + &self.send_tx, + CROSS_SOCKET_WRITE_FENCE_TIMEOUT, + ) + .await + { + SocketWriteFenceWaitResultLikeCpp::Written => true, + SocketWriteFenceWaitResultLikeCpp::TimedOut => { + // C++ queues `SendPacket` without turning transient socket + // backpressure into a gameplay failure. Preserve the durable + // result and finish enqueuing its remaining fanout. + warn!( + account = self.account_id, + timeout_ms = CROSS_SOCKET_WRITE_FENCE_TIMEOUT.as_millis(), + "instance writer ordering fence timed out; continuing committed fanout" + ); + true + } + SocketWriteFenceWaitResultLikeCpp::WriterClosed => { + warn!( + account = self.account_id, + "instance writer closed before acknowledging the ordering fence" + ); + false + } } - acknowledged } /// Wait for realm packets to reach their physical socket before emitting a @@ -36695,16 +36793,30 @@ impl WorldSession { ); 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" - ); + match write_fence + .wait_for_prior_packets_written_like_cpp( + realm_send_tx, + CROSS_SOCKET_WRITE_FENCE_TIMEOUT, + ) + .await + { + SocketWriteFenceWaitResultLikeCpp::Written => true, + SocketWriteFenceWaitResultLikeCpp::TimedOut => { + warn!( + account = self.account_id, + timeout_ms = CROSS_SOCKET_WRITE_FENCE_TIMEOUT.as_millis(), + "realm writer ordering fence timed out; continuing committed fanout" + ); + true + } + SocketWriteFenceWaitResultLikeCpp::WriterClosed => { + warn!( + account = self.account_id, + "realm writer closed before acknowledging the ordering fence" + ); + false + } } - acknowledged } #[cfg(test)] @@ -36860,7 +36972,10 @@ impl WorldSession { } } - self.send_packet(&packet); + // C++ `Player::SendNewItem` uses `SendDirectMessage`; opcode routing + // places `SMSG_ITEM_PUSH_RESULT` on CONNECTION_TYPE_REALM even while + // the inventory object updates remain on the instance connection. + self.send_packet_realm(&packet); } fn broadcast_item_push_result_to_group(&self, bytes: Vec) -> bool { @@ -36877,7 +36992,7 @@ impl WorldSession { let mut delivered = false; for member_guid in &group.members { if let Some(member) = player_registry.get(member_guid) { - delivered |= member.send_tx.send(bytes.clone()).is_ok(); + delivered |= member.realm_send_tx.send(bytes.clone()).is_ok(); } } @@ -94411,6 +94526,7 @@ mod tests { combat_reach: 0.0, liquid_status: 0, is_in_world: true, + realm_send_tx: send_tx.clone(), send_tx, command_tx, durable_loot_money_tracker_like_cpp: Default::default(), @@ -116697,6 +116813,117 @@ mod tests { ); } + #[test] + fn vendor_currency_purchase_plan_does_not_publish_before_commit_like_cpp() { + let (mut session, _, _) = make_session(); + session.player_race = 1; + session.set_currency_types_store(Arc::new(wow_data::CurrencyTypesStore::from_entries([ + currency_entry(395), + currency_entry(396), + ]))); + session.player_currencies.insert( + 396, + PlayerCurrency { + state: PlayerCurrencyState::Unchanged, + quantity: 10, + weekly_quantity: 0, + tracked_quantity: 0, + increased_cap_quantity: 0, + earned_quantity: 0, + flags: 0, + }, + ); + let runtime_before = session.player_currencies_like_cpp().clone(); + let mut planned = runtime_before.clone(); + + let gain = session + .plan_add_currency_vendor_like_cpp(&mut planned, 395, 3) + .expect("represented vendor currency should be plannable") + .expect("the uncapped gain should be nonzero"); + assert!(WorldSession::plan_remove_currency_like_cpp( + &mut planned, + 396, + 4 + )); + + let mut tx = SqlTransaction::new(); + session.append_planned_player_currency_save_statements_like_cpp(&mut tx, 42, &mut planned); + + assert_eq!(gain.quantity, 3); + assert_eq!(planned.get(&395).map(|currency| currency.quantity), Some(3)); + assert_eq!(planned.get(&396).map(|currency| currency.quantity), Some(6)); + assert_eq!( + planned.get(&395).map(|currency| currency.state), + Some(PlayerCurrencyState::Unchanged) + ); + assert_eq!( + planned.get(&396).map(|currency| currency.state), + Some(PlayerCurrencyState::Unchanged) + ); + assert_eq!(tx.len(), 2); + assert_eq!( + session.player_currencies_like_cpp(), + &runtime_before, + "a definite rollback or cancellation before COMMIT must leave runtime unchanged" + ); + } + + #[test] + fn vendor_currency_purchase_publishes_only_committed_plan_like_cpp() { + let (mut session, _, _) = make_session(); + session.player_race = 1; + session.set_currency_types_store(Arc::new(wow_data::CurrencyTypesStore::from_entries([ + currency_entry(395), + currency_entry(396), + ]))); + session.player_currencies.insert( + 396, + PlayerCurrency { + state: PlayerCurrencyState::Unchanged, + quantity: 10, + weekly_quantity: 0, + tracked_quantity: 0, + increased_cap_quantity: 0, + earned_quantity: 0, + flags: 0, + }, + ); + let mut planned = session.player_currencies_like_cpp().clone(); + session + .plan_add_currency_vendor_like_cpp(&mut planned, 395, 3) + .unwrap(); + assert!(WorldSession::plan_remove_currency_like_cpp( + &mut planned, + 396, + 4 + )); + let mut tx = SqlTransaction::new(); + session.append_planned_player_currency_save_statements_like_cpp(&mut tx, 42, &mut planned); + + // This synchronous publication is the post-COMMIT half used by + // `handle_buy_item`; no fallible/async operation separates it from the + // durable success branch. + session.set_player_currencies_like_cpp(planned); + + assert_eq!(session.player_currency_quantity(395), 3); + assert_eq!(session.player_currency_quantity(396), 6); + assert!( + session + .player_currencies_like_cpp() + .values() + .all(|currency| currency.state == PlayerCurrencyState::Unchanged) + ); + } + + #[test] + fn vendor_currency_unknown_commit_is_quarantined_without_money_evidence_like_cpp() { + assert_eq!( + reconcile_absolute_player_money_commit_like_cpp(100, 100, Some(100)), + AbsolutePlayerMoneyCommitReconciliationLikeCpp::Indeterminate, + "currency-only vendor transactions use equal money markers, so a lost COMMIT reply must require relog instead of guessing" + ); + } + #[test] fn player_currency_item_refund_ignores_caps_and_total_counters_like_cpp() { let (mut session, _, _) = make_session(); @@ -130531,15 +130758,41 @@ mod tests { ); } + #[tokio::test] + async fn cross_socket_fence_timeout_continues_committed_fanout_like_cpp() { + let (mut session, _, _instance_rx) = make_session(); + let (realm_tx, _realm_rx) = flume::unbounded(); + session.install_realm_send_channel_for_test(realm_tx); + session.set_send_write_fence_like_cpp(SocketWriteFenceLikeCpp::default()); + session.install_realm_send_write_fence_for_test(SocketWriteFenceLikeCpp::default()); + + assert!( + session + .wait_for_instance_send_before_realm_send_like_cpp() + .await, + "a bounded fence timeout must not discard fanout after durable commit" + ); + assert!( + session + .wait_for_realm_send_before_instance_update_like_cpp() + .await, + "a bounded fence timeout must not discard later instance updates" + ); + assert_ne!(session.state(), SessionState::Disconnecting); + } + #[test] - fn send_new_item_plan_direct_sends_item_push_result_to_session() { - let (session, _, send_rx) = make_session(); + fn send_new_item_plan_direct_routes_item_push_result_to_realm_like_cpp() { + let (mut session, _, send_rx) = make_session(); + let (realm_tx, realm_rx) = flume::bounded(1); + session.install_realm_send_channel_for_test(realm_tx); let plan = send_new_item_plan(SendNewItemDelivery::Direct); let expected = WorldSession::item_push_result_from_send_new_item_plan(&plan).to_bytes(); session.send_new_item_plan(&plan); - assert_eq!(send_rx.try_recv().unwrap(), expected); + assert_eq!(realm_rx.try_recv().unwrap(), expected); + assert!(send_rx.try_recv().is_err()); } #[test] @@ -130548,10 +130801,16 @@ mod tests { let self_guid = ObjectGuid::create_player(1, 42); let other_guid = ObjectGuid::create_player(1, 43); let (self_tx, self_rx) = flume::bounded(10); + let (self_realm_tx, self_realm_rx) = flume::bounded(10); let (other_tx, other_rx) = flume::bounded(10); + let (other_realm_tx, other_realm_rx) = flume::bounded(10); let player_registry = Arc::new(PlayerRegistry::default()); - player_registry.insert(self_guid, broadcast_info(self_guid, self_tx)); - player_registry.insert(other_guid, broadcast_info(other_guid, other_tx)); + let mut self_info = broadcast_info(self_guid, self_tx); + self_info.realm_send_tx = self_realm_tx; + player_registry.insert(self_guid, self_info); + let mut other_info = broadcast_info(other_guid, other_tx); + other_info.realm_send_tx = other_realm_tx; + player_registry.insert(other_guid, other_info); let group_registry = Arc::new(GroupRegistry::default()); let mut group = GroupInfo::new(self_guid); group.add_member(other_guid); @@ -130566,8 +130825,10 @@ mod tests { session.send_new_item_plan(&plan); - assert_eq!(self_rx.try_recv().unwrap(), expected); - assert_eq!(other_rx.try_recv().unwrap(), expected); + assert_eq!(self_realm_rx.try_recv().unwrap(), expected); + assert_eq!(other_realm_rx.try_recv().unwrap(), expected); + assert!(self_rx.try_recv().is_err()); + assert!(other_rx.try_recv().is_err()); assert!(send_rx.try_recv().is_err()); } diff --git a/docs/migration/EXISTING-CODE-DEFECTS.md b/docs/migration/EXISTING-CODE-DEFECTS.md index c205a431..b178d7c5 100644 --- a/docs/migration/EXISTING-CODE-DEFECTS.md +++ b/docs/migration/EXISTING-CODE-DEFECTS.md @@ -99,6 +99,24 @@ mutates DB" ≠ "computes the right result / can't lose or dupe data." `session.rs`. - [ ] **D-C8 Vendor buy not atomic.** Gold/currency applied to runtime before item DB commit; commit fail = paid, no item. `handlers/character.rs:10177-10292`. + - 2026-07-20 issue #108 local slice: ordinary item purchases already gained a combined + gold/item/turn-in transaction in #107, but item extended-cost currencies and the entire + currency-vendor branch still changed session currency before awaiting COMMIT. Both paths now + build detached currency plans and publish them only after the purchase transaction commits. + Currency-only purchases reuse the cancellation/unknown-COMMIT quarantine with equal money + markers, so definite rollback leaves runtime untouched and an ambiguous result requires relog + without allowing a stale full save. A failed-connection handler regression exercises the real + rollback branch and proves that it emits only `BuyFailed`, preserves runtime currency, and + reopens payout/save admission. Paired C++/Rust bot QA now proves a real extended-cost purchase, + currency debit, item creation, fresh-authentication persistence, packet routing, and cleanup; + the committed post-COMMIT realm response is 2/2 CLEAN with no accepted divergences. Capture + contrast also fixed zero-price Coinage publication and C++ vendor-item create/context/flag + metadata. The wider action still shows the separately scoped missing achievement + `SMSG_CRITERIA_UPDATE`. Installed original-client QA on 2026-07-21 bought two extended-cost + items across a relog and confirmed exact item/currency persistence in CharacterDB; the fixture + was then fully restored. The confusing client `You receive currency` line was backed by the + same byte-exact loss packet as C++ (quantity 15, delta -15, Vendor reason), not a refund. Kept + open until final CI, current-HEAD reviewer verdict, and merge. - [ ] **D-C9 Group full-check race.** Size checked then join without re-check → 6+ member groups under concurrent accepts. `handlers/group.rs:928-1044`. diff --git a/docs/migration/current-session-handoff.md b/docs/migration/current-session-handoff.md index d5c7a0f1..2199e773 100644 --- a/docs/migration/current-session-handoff.md +++ b/docs/migration/current-session-handoff.md @@ -1,3 +1,51 @@ +- `#NEXT.R8.ENTITIES.1202` — issue #108 closes the remaining D-C8 vendor-purchase + publication gap. C++ anchors: + `/home/server/woltk-trinity-legacy/src/server/game/Handlers/ItemHandler.cpp:530-564` and + `/home/server/woltk-trinity-legacy/src/server/game/Entities/Player/Player.cpp:22207-22590`. + C++ applies a currency purchase or item purchase with extended costs in one serialized + `Player` turn. Rust must also cross CharacterDB, so `handle_buy_item` now computes currency + gains/costs on a detached plan, appends that plan with the purchase item/gold/turn-in rows, + and publishes neither currencies nor inventory turn-ins until the combined transaction has + committed. Currency-only purchases now use the existing per-character money/save fence with + equal money markers: a definite rollback leaves runtime untouched, while cancellation or a + lost COMMIT reply cannot be guessed from an unchanged money row and quarantines the session + for relog instead of allowing a stale full save. Ordinary item purchases use the same detached + currency plan and publish it synchronously with money, inventory, and turn-ins before reopening + payout/save admission. Capture contrast also corrected the purchase wire path: zero-price buys + no longer dirty Coinage; a newly stored vendor item uses C++ `CreateObject` (not + `CreateObject2`), carries `NEW_ITEM`, destination bonding, and `ItemContext::Vendor` in DB, + runtime, and update fields; refundable items retain those flags; item create/values and currency + updates use the instance socket, while `BuySucceeded` and `ItemPushResult` use realm. + Post-review transport hardening distinguishes a written fence, a real writer close, and the + short 250 ms best-effort cross-socket ordering timeout: a timeout now logs and completes the + already-committed fanout instead of disconnecting or suppressing packets, while `SocketWriter` + drop wakes pending fences immediately. + Focused tests cover detached planning, a real failed-connection rollback through + `handle_buy_item` with no runtime publication, post-COMMIT publication, the equal-marker + indeterminate decision, exact routing, zero-price coinage, and stored-item metadata. + + Paired real C++/Rust bot runs at `7bee9bfe` bought G'eras item `30183`/extended cost `1642`, + observed currency `42` move `30→15`, required the exact inventory/currency/success/push packet + shapes and C++ socket routing, verified one durable item after fresh authentication, and restored + the fixture. The committed strict capture window contains realm-routed `BuySucceeded` followed + by `ItemPushResult`: 2/2 packets, empty accepted-divergence baseline, CLEAN. Its semantic + comparator omits only the exact G'eras lower 40-bit nonzero runtime counter while pinning all + stable GUID and purchase fields. The wider raw action retains one visible pre-existing boundary: + C++ emits `SMSG_CRITERIA_UPDATE`, while Rust's achievement subsystem does not yet do so; it is + not ignored or accepted by the committed flow. Installed original-client QA on 2026-07-21 also + passed against the release binary: offline `Luqedos` was moved beside G'eras and seeded with 30 + Badge of Justice, bought item `30183` for 15, relogged with the item and remaining currency + durable, then bought item `23572` for 10 (durable balance 5). CharacterDB and server logs proved + both single item creations and both debits; cleanup removed the two QA items/currency row and + restored the exact original character position. The client's misleading `You receive currency + ... x15` text did not represent a refund: the captured Rust `SMSG_SET_CURRENCY` body is + byte-identical to C++ and carries quantity 15, delta -15, and destroy reason Vendor. + Checks include `cargo +1.88.0 fmt --all -- + --check`, focused `wow-world --lib` vendor tests, the complete `capture-diff` suite, pinned-protoc + `world-server` check, `git diff --check`, and repeated local Codex reviews CLEAN. D-C8 remains + open only for final validation/CI/current-HEAD GitHub Codex verdict and merge. Finite-stock + oversell (D-H11), buyback/refund, achievements, and broader vendor validation are outside D-C8. + - `#NEXT.R8.ENTITIES.1201` — issue #102 implements atomic personal-bank item moves for `CMSG_AUTOBANK_ITEM` and `CMSG_AUTOSTORE_BANK_ITEM`. C++ anchors: `/home/server/woltk-trinity-legacy/src/server/game/Handlers/BankHandler.cpp:25-121`, diff --git a/docs/migration/inventory/r8-entities-miniphase.md b/docs/migration/inventory/r8-entities-miniphase.md index bce9714f..586d8be8 100644 --- a/docs/migration/inventory/r8-entities-miniphase.md +++ b/docs/migration/inventory/r8-entities-miniphase.md @@ -1,3 +1,38 @@ +# `#NEXT.R8.ENTITIES.1202` — atomic vendor-purchase currency publication (issue #108). + +Source-of-truth C++ was checked before implementation: +`Handlers/ItemHandler.cpp:530-564` and `Player.cpp:22207-22590`. C++ performs +`BuyCurrencyFromVendorSlot` and `BuyItemFromVendorSlot` currency/item-cost mutations in one +serialized Player turn. Rust additionally crosses CharacterDB, so both `handle_buy_item` +branches now build currency gains/costs on a detached plan, append them with the same transaction +as item turn-ins plus applicable inventory/gold rows, and publish the plan only after durable +success. A definite rollback exposes no runtime currency/turn-in mutation. Currency-only +purchases use the existing cancellation fence with equal money markers, making a lost COMMIT +reply indeterminate and forcing relog rather than permitting a stale full save to overwrite an +unknown durable result. Item purchases publish their committed currency plan synchronously with +money, inventory, and turn-ins before reopening payout/save admission. + +Focused tests cover detached pre-COMMIT isolation, a real failed-connection rollback through +`handle_buy_item`, successful publication, generated currency save statements, and the +unknown-COMMIT equal-marker rule. Capture contrast additionally corrected zero-price Coinage +publication; C++ `CreateObject` storage shape; `NEW_ITEM`, bonding, refundable-flag composition, +and `ItemContext::Vendor` persistence/wire metadata; and realm-vs-instance response routing. +Paired real C++/Rust bot runs bought G'eras item `30183` for currency `42` (`30→15`), required the +exact C++ packet routes/shapes, verified one item after fresh authentication, and restored the +fixture. The committed strict realm-response window is 2/2 CLEAN with no accepted divergences; +its comparator normalizes only G'eras' nonzero lower 40-bit runtime counter. The wider raw action +also exposes an out-of-scope missing Rust `SMSG_CRITERIA_UPDATE` instead of hiding it. Focused +vendor tests, the complete `capture-diff` suite, pinned-protoc `world-server` check, formatting, +diff checks, and repeated local Codex reviews are clean. Boundary: represented-partial because +finite-stock oversell (D-H11), buyback/refund, achievements, and wider vendor validation remain +separate; D-C8 itself still needs final CI, current-HEAD GitHub reviewer verdict, and merge. +Installed original-client QA passed on 2026-07-21: `Luqedos` bought item `30183` for 15 Badge of +Justice, relogged with the item and remaining currency persisted, then bought item `23572` for 10; +CharacterDB retained exactly one of each item and balance 5. The apparent `You receive currency +... x15` client text was not a refund: Rust and C++ captured byte-identical `SMSG_SET_CURRENCY` +bodies with quantity 15, delta -15 and Vendor loss reason. Cleanup restored the character's exact +pre-QA position and removed both test items plus the previously absent currency row. + # `#NEXT.R8.ENTITIES.1201` — atomic personal-bank item moves (issue #102). Source-of-truth C++ was checked before and throughout implementation: diff --git a/docs/migration/inventory/r8-entities-miniphase.tsv b/docs/migration/inventory/r8-entities-miniphase.tsv index cadac194..d8d5fb2f 100644 --- a/docs/migration/inventory/r8-entities-miniphase.tsv +++ b/docs/migration/inventory/r8-entities-miniphase.tsv @@ -1,4 +1,5 @@ id kind status depends_on cpp_refs rust_targets acceptance rollback_or_parking notes +#NEXT.R8.ENTITIES.1202 implementation represented-partial #NEXT.R8.ENTITIES.1201 /home/server/woltk-trinity-legacy/src/server/game/Handlers/ItemHandler.cpp:530-564; /home/server/woltk-trinity-legacy/src/server/game/Entities/Player/Player.cpp:11190-11270,22143-22217 crates/wow-world/src/handlers/character.rs; crates/wow-world/src/handlers/character_vendor_atomicity_tests.rs; crates/wow-world/src/session.rs; crates/capture-diff/src/semantic.rs; crates/capture-diff/tests/buy_succeeded_semantic.rs; crates/capture-diff/flows/vendor-extended-cost-purchase; tools/wow-test-bot/src/main.rs; docs/migration/EXISTING-CODE-DEFECTS.md; docs/migration/current-session-handoff.md; docs/migration/inventory/r8-entities-miniphase.md; docs/migration/inventory/r8-entities-miniphase.tsv Vendor currency gains/costs are planned without runtime publication, committed with item turn-ins and applicable item/gold rows, left unpublished on definite rollback, quarantined on cancellation or unknown equal-money-marker COMMIT, and published synchronously after durable success; C++ create/context/flag metadata and realm/instance routing match; paired bot QA proves currency 30→15, item creation, fresh-auth persistence and cleanup; the exact 2-packet post-COMMIT realm capture is CLEAN with no accepted divergences; installed original-client QA proves two purchases across relog with exact item/currency durability and full fixture restoration Revert the issue #108 commits and delete this TSV/handoff/miniphase/defect note plus the vendor capture fixture. represented-partial: D-C8 implementation plus bot/capture and installed original-client QA are complete locally; final CI/current-HEAD GitHub verdict/merge remain; finite-stock oversell D-H11, buyback/refund, missing achievement CriteriaUpdate, and broader vendor validation are outside scope. #NEXT.R8.ENTITIES.1201 implementation represented-partial #NEXT.R8.ENTITIES.1200 /home/server/woltk-trinity-legacy/src/server/game/Handlers/BankHandler.cpp:25-121; /home/server/woltk-trinity-legacy/src/server/game/Entities/Player/Player.cpp:11244-11372,11559-11645; /home/server/woltk-trinity-legacy/src/server/game/Entities/Item/Item.cpp:383-576,748-765 crates/wow-data/src/item_stats.rs; crates/wow-database/src/statements/character.rs; crates/wow-world/src/handlers/character.rs; crates/wow-world/src/handlers/loot.rs; crates/wow-world/src/handlers/quest.rs; crates/wow-world/src/session.rs; docs/migration/EXISTING-CODE-DEFECTS.md; docs/migration/current-session-handoff.md; docs/migration/inventory/r8-entities-miniphase.md; docs/migration/inventory/r8-entities-miniphase.tsv AUTOBANK and AUTOSTORE_BANK_ITEM plan C++ destinations; atomically persist locations plus surviving item state, applicable quest rows, and full absorbed-source cleanup including stored loot; expose no runtime change on failed SQL commit; then synchronize runtime, exact item/player/quest masks, duration refs, first-match quest-bound ItemPushResult without generic credit, binding, equipment effects, and obtain spells; login normalizes template duration, limits charges to real effects, and restores duration trackers; focused positive/negative tests and repeated local Codex review CLEAN Revert the issue #102 commit and delete this TSV/handoff/miniphase/defect note. represented-partial: personal-bank D-C3 slice only; CharacterDB relog round-trip, installed restart, capture-diff, bot/manual client QA, CI, and current-HEAD GitHub Codex verdict remain required; equipment-set and void-storage persistence remain separate children. #NEXT.R8.ENTITIES.1199 implementation represented-partial - /home/server/woltk-trinity-legacy/src/server/game/Entities/Player/Player.cpp:23211-23249; /home/server/woltk-trinity-legacy/src/server/game/Grids/Notifiers/GridNotifiers.cpp:29-89; /home/server/woltk-trinity-legacy/src/server/game/Grids/Notifiers/GridNotifiersImpl.h crates/wow-world/src/handlers/character.rs; crates/wow-world/src/session.rs; docs/migration/current-session-handoff.md; docs/migration/inventory/r8-entities-miniphase.tsv send_nearby_creatures and send_nearby_gameobjects gate each CREATE on !client_visible_guids_like_cpp.contains(guid), matching C++ Player::UpdateVisibilityOf !HaveAtClient (Player.cpp:23211-23249). Stops the duplicate CREATE for already-known guids that crashed the Wrath client (connection reset) a few seconds after entering the world. cargo fmt -p wow-world -- --check; PROTOC=/home/cdmonio/.local/protoc/bin/protoc cargo test -p wow-world --lib send_nearby; PROTOC=/home/cdmonio/.local/protoc/bin/protoc cargo check -p world-server Revert this commit and delete this TSV/handoff update. represented-partial only: stops duplicate CREATES on the world-port/spawn send_nearby_* path; does not add C++-style DESTROY for objects leaving range on that path (known set resynced silently); two parallel visibility paths (Path A movement-driven vs Path B send_nearby_*) remain un-unified; creature E2E test blocked by pre-existing canonical-creature-visibility test-infra gap; NOT yet manually verified against the client. #NEXT.R8.ENTITIES.1198 implementation represented-partial #NEXT.R8.ENTITIES.1197 /home/server/woltk-trinity-legacy/src/server/game/Entities/Player/Player.cpp:8187-8220; /home/server/woltk-trinity-legacy/src/server/game/Entities/Player/Player.cpp:8238-8260; /home/server/woltk-trinity-legacy/src/server/game/Entities/Unit/Unit.cpp:3806; /home/server/woltk-trinity-legacy/src/server/game/Entities/Unit/Unit.cpp:3951 crates/wow-world/src/session.rs; docs/migration/current-session-handoff.md; docs/migration/inventory/r8-entities-miniphase.tsv; docs/migration/honest-progress-audit.md Represented item-set aura refresh events can now materialize the C++ item-set ApplyEquipSpell remove/apply effects into visible aura state: remove events call the represented RemoveAurasDueToSpell path, apply events add one permanent represented set aura, and formChange apply skips duplication when the aura is already active. cargo fmt --all --check; PROTOC=/home/cdmonio/.local/protoc/bin/protoc cargo test -p wow-world represented_item_set_aura_refresh --lib; PROTOC=/home/cdmonio/.local/protoc/bin/protoc cargo test -p wow-world represented_update_item_set_auras --lib; PROTOC=/home/cdmonio/.local/protoc/bin/protoc cargo check -p wow-world; PROTOC=/home/cdmonio/.local/protoc/bin/protoc cargo check -p world-server; git diff --check Revert this commit and delete this TSV/handoff/audit update. Represented-partial only: closes represented item-set aura final-state materialization for the current refresh event stream, but not full CastSpell, item-guid RemoveAurasDueToItemSpell, update-field emission, persistence, fanout, bot validation, live-client validation, manual validation, or full spell-effect runtime parity. diff --git a/tools/wow-test-bot/README.md b/tools/wow-test-bot/README.md index 2b1ee06e..3ff36cd6 100644 --- a/tools/wow-test-bot/README.md +++ b/tools/wow-test-bot/README.md @@ -47,6 +47,23 @@ authenticates again, swaps them back, verifies both items after both commits, and removes the fixture. Optional entry overrides are `WOW_BOT_INVENTORY_SWAP_ITEM_ENTRY_A/B` (defaults `2589`/`2592`). +For an atomic extended-cost vendor round-trip, set `WOW_BOT_VENDOR_SMOKE=1` +and select exactly one local bot account. The default fixture relocates that +offline character beside G'eras (entry `18525`, SQL spawn `96654`), seeds `30` +units of currency `42`, selects the unique item/cost row `30183`/`1642`, and +buys one item for `15` currency. The bot discovers the vendor's live +map-generated ObjectGuid from `SMSG_UPDATE_OBJECT` near the exact SQL position; +`WOW_BOT_VENDOR_RUNTIME_COUNTER` is only an optional checked override. It then +requires the exact C++ `VendorInventory`, instance-routed `SetCurrency`, and +realm-routed `BuySucceeded` plus `ItemPushResult` wire shapes, fences the +capture with a fixed `CMSG_PING`, verifies currency +`30→15` plus one persisted item after logout and a fresh authentication, and +finally removes the purchased item and restores the original currency row and +position. Optional fixture overrides are `WOW_BOT_VENDOR_ENTRY`, +`WOW_BOT_VENDOR_SPAWN_GUID`, `WOW_BOT_VENDOR_ITEM_ENTRY`, +`WOW_BOT_VENDOR_EXTENDED_COST`, `WOW_BOT_VENDOR_CURRENCY_ID`, +`WOW_BOT_VENDOR_CURRENCY_COST`, and `WOW_BOT_VENDOR_CURRENCY_QUANTITY`. + For a complete rested-XP calculation and consumption round-trip, set both `WOW_BOT_RESTED_XP_SMOKE=1` and `WOW_BOT_ACK_DISPOSABLE_RESTED_XP=1` when using the wrapper. The bot records and later restores only the selected character diff --git a/tools/wow-test-bot/run_rustycore_login_smoke.sh b/tools/wow-test-bot/run_rustycore_login_smoke.sh index f2009639..54258db5 100755 --- a/tools/wow-test-bot/run_rustycore_login_smoke.sh +++ b/tools/wow-test-bot/run_rustycore_login_smoke.sh @@ -102,6 +102,7 @@ stand_state_timeout_secs="${WOW_BOT_STAND_STATE_TIMEOUT_SECS:-5}" bank_timeout_secs="${WOW_BOT_BANK_TIMEOUT_SECS:-8}" homebind_timeout_secs="${WOW_BOT_HOMEBIND_TIMEOUT_SECS:-8}" inventory_swap_timeout_secs="${WOW_BOT_INVENTORY_SWAP_TIMEOUT_SECS:-8}" +vendor_timeout_secs="${WOW_BOT_VENDOR_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}" @@ -137,6 +138,10 @@ inventory_swap_requested=0 if [[ "${WOW_BOT_INVENTORY_SWAP_SMOKE:-0}" =~ ^(1|true|TRUE|yes|YES|on|ON)$ ]]; then inventory_swap_requested=1 fi +vendor_requested=0 +if [[ "${WOW_BOT_VENDOR_SMOKE:-0}" =~ ^(1|true|TRUE|yes|YES|on|ON)$ ]]; then + vendor_requested=1 +fi rested_xp_requested=0 if [[ "${WOW_BOT_RESTED_XP_SMOKE:-0}" =~ ^(1|true|TRUE|yes|YES|on|ON)$ ]]; then rested_xp_requested=1 @@ -165,8 +170,8 @@ if ((loot_race_acknowledged && !loot_race_requested && !loot_item_capture_reques 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 +if ((stand_state_requested + quest_requested + bank_requested + homebind_requested + inventory_swap_requested + vendor_requested + rested_xp_requested + loot_race_requested + loot_item_capture_requested > 1)); then + echo "Stand-state, quest, bank, homebind, inventory-swap, vendor, rested-XP, loot-race, and loot-item-capture are separate modes" >&2 exit 2 fi if ((loot_race_requested || loot_item_capture_requested)) \ @@ -289,6 +294,23 @@ elif ((inventory_swap_requested)); then if [[ -n "${WOW_BOT_INVENTORY_SWAP_ITEM_ENTRY_B:-}" ]]; then mode_args+=(--inventory-swap-item-entry-b "$WOW_BOT_INVENTORY_SWAP_ITEM_ENTRY_B") fi +elif ((vendor_requested)); then + report_path="${WOW_BOT_REPORT:-/tmp/rustycore-bot-vendor-smoke-report.json}" + log_path="${WOW_BOT_LOG:-/tmp/rustycore-bot-vendor-smoke.log}" + mode_args=( + --vendor-smoke + --vendor-entry "${WOW_BOT_VENDOR_ENTRY:-18525}" + --vendor-spawn-guid "${WOW_BOT_VENDOR_SPAWN_GUID:-96654}" + --vendor-item-entry "${WOW_BOT_VENDOR_ITEM_ENTRY:-30183}" + --vendor-extended-cost "${WOW_BOT_VENDOR_EXTENDED_COST:-1642}" + --vendor-currency-id "${WOW_BOT_VENDOR_CURRENCY_ID:-42}" + --vendor-currency-cost "${WOW_BOT_VENDOR_CURRENCY_COST:-15}" + --vendor-currency-quantity "${WOW_BOT_VENDOR_CURRENCY_QUANTITY:-30}" + --vendor-timeout "$vendor_timeout_secs" + ) + if [[ -n "${WOW_BOT_VENDOR_RUNTIME_COUNTER:-}" ]]; then + mode_args+=(--vendor-runtime-counter "$WOW_BOT_VENDOR_RUNTIME_COUNTER") + fi elif ((quest_requested)); then : "${WOW_BOT_QUEST_CREATURE_ENTRY:?Set WOW_BOT_QUEST_CREATURE_ENTRY for quest-smoke mode}" report_path="${WOW_BOT_REPORT:-/tmp/rustycore-bot-quest-smoke-report.json}" @@ -421,5 +443,5 @@ 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, 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" + jq '{login_only, quest_smoke, stand_state_smoke, bank_smoke, homebind_smoke, inventory_swap_smoke, vendor_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, vendor_smoke, vendor_smoke_passed, vendor_entry, vendor_spawn_guid, vendor_runtime_counter, vendor_item_entry, vendor_extended_cost, vendor_currency_id, vendor_currency_before, vendor_currency_after, vendor_item_total_after, vendor_inventory_seen, vendor_buy_succeeded_seen, vendor_set_currency_seen, vendor_item_push_seen, vendor_relogin_verified, vendor_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/loot_race.rs b/tools/wow-test-bot/src/loot_race.rs index f7e08e01..14738216 100644 --- a/tools/wow-test-bot/src/loot_race.rs +++ b/tools/wow-test-bot/src/loot_race.rs @@ -931,6 +931,7 @@ pub(super) async fn run_workflow( None, None, None, + None, Some(options), None, ) @@ -1090,6 +1091,7 @@ pub(super) async fn run_workflow( None, None, None, + None, Some(options), None, )) => match result { @@ -1240,6 +1242,7 @@ pub(super) async fn run_single_item_capture_workflow( None, None, None, + None, Some(options), None, )) => match result { @@ -1336,6 +1339,7 @@ pub(super) async fn run_single_item_capture_workflow( None, None, None, + None, Some(relog_options), None, )) => match relog { @@ -2106,7 +2110,7 @@ fn wait_for_loot_character_offline(character_guid: u64, timeout: Duration) -> Re /// 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( +pub(super) async fn logout_and_wait_routed_like_cpp( bot_index: usize, stream: &mut TcpStream, crypt: &mut WorldCrypt, @@ -3565,6 +3569,70 @@ fn parse_item_push(payload: &[u8]) -> Result { }) } +pub(super) fn validate_vendor_item_push_result_like_cpp( + payload: &[u8], + expected_character_guid: u64, + expected_item_entry: u32, + expected_quantity: u32, + expected_realm_id: u32, +) -> Result<()> { + let push = parse_item_push(payload)?; + let expected_quantity = i32::try_from(expected_quantity) + .map_err(|_| anyhow!("vendor item quantity exceeds i32"))?; + let expected_player = create_player_guid_raw(expected_character_guid, expected_realm_id); + if (push.player_low, push.player_high) != expected_player { + bail!( + "vendor ItemPush player {:#018X}/{:#018X} did not match character {}", + push.player_low, + push.player_high, + expected_character_guid + ); + } + if push.item_entry != expected_item_entry + || push.quantity != expected_quantity + || push.quantity_in_inventory != expected_quantity + { + bail!( + "vendor ItemPush entry/quantity/inventory {:?} did not match {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!( + "vendor ItemPush slot {}/{} was not a base-backpack destination", + push.slot, + push.slot_in_bag + ); + } + if push.quest_log_item_id != 0 + || !push.pushed + || push.created + || push.display_text != 1 + || push.is_bonus_roll + || push.is_encounter_loot + || push.dungeon_encounter_id != 0 + { + bail!("vendor ItemPush flags were not the ordinary C++ purchase 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!( + "vendor ItemPush GUID {:#018X}/{:#018X} was not a nonempty C++ Item GUID for realm {}", + push.item_guid_low, + push.item_guid_high, + expected_realm_id + ); + } + Ok(()) +} + fn parse_inventory_failure(payload: &[u8]) -> Result { if payload.len() < 4 { bail!("malformed SMSG_INVENTORY_CHANGE_FAILURE result"); diff --git a/tools/wow-test-bot/src/main.rs b/tools/wow-test-bot/src/main.rs index de598b5b..b47d95f1 100644 --- a/tools/wow-test-bot/src/main.rs +++ b/tools/wow-test-bot/src/main.rs @@ -40,6 +40,8 @@ const CMSG_BINDER_ACTIVATE: u16 = 0x34B2; const CMSG_AUTOBANK_ITEM: u16 = 0x3997; const CMSG_AUTOSTORE_BANK_ITEM: u16 = 0x3996; const CMSG_SWAP_INV_ITEM: u16 = 0x399B; +const CMSG_LIST_INVENTORY: u16 = 0x34A1; +const CMSG_BUY_ITEM: u16 = 0x34A3; const CMSG_ATTACK_SWING: u16 = 0x3255; const CMSG_MOVE_HEARTBEAT: u16 = 0x3A10; const CMSG_MOVE_INIT_ACTIVE_MOVER_COMPLETE: u16 = 0x3A46; @@ -53,6 +55,11 @@ 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 SMSG_VENDOR_INVENTORY: u16 = 0x25B8; +const SMSG_ITEM_PUSH_RESULT: u16 = 0x2623; +const SMSG_BUY_SUCCEEDED: u16 = 0x26C6; +const SMSG_BUY_FAILED: u16 = 0x26C7; +const SMSG_SET_CURRENCY: u16 = 0x2574; // 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 @@ -74,6 +81,14 @@ const NPC_FLAG_INNKEEPER: u32 = 0x10000; const DEFAULT_BANK_SMOKE_ITEM_ENTRY: u32 = 2589; const DEFAULT_INVENTORY_SWAP_ITEM_ENTRY_A: u32 = 2589; const DEFAULT_INVENTORY_SWAP_ITEM_ENTRY_B: u32 = 2592; +const DEFAULT_VENDOR_ENTRY: u32 = 18_525; +const DEFAULT_VENDOR_SPAWN_GUID: u64 = 96_654; +const DEFAULT_VENDOR_ITEM_ENTRY: u32 = 30_183; +const DEFAULT_VENDOR_EXTENDED_COST: u32 = 1_642; +const DEFAULT_VENDOR_CURRENCY_ID: u32 = 42; +const DEFAULT_VENDOR_CURRENCY_COST: u32 = 15; +const DEFAULT_VENDOR_CURRENCY_QUANTITY: u32 = 30; +const VENDOR_CAPTURE_FENCE_SERIAL: u32 = 0x5645_4E44; const DEFAULT_RESTED_XP_CREATURE_ENTRY: u32 = 15274; const DEFAULT_RESTED_XP_OFFLINE_SECS: u64 = 86_400; // A fresh level-1 Mana Wyrm has 42 HP. The intentionally empty disposable @@ -221,6 +236,16 @@ struct CliOptions { inventory_swap_item_entry_a: u32, inventory_swap_item_entry_b: u32, inventory_swap_timeout_secs: u64, + vendor_smoke: bool, + vendor_entry: u32, + vendor_spawn_guid: u64, + vendor_runtime_counter: Option, + vendor_item_entry: u32, + vendor_extended_cost: u32, + vendor_currency_id: u32, + vendor_currency_cost: u32, + vendor_currency_quantity: u32, + vendor_timeout_secs: u64, rested_xp_smoke: bool, ack_disposable_rested_xp: bool, rested_xp_creature_entry: u32, @@ -328,6 +353,23 @@ struct BotRunResult { inventory_swap_relogin_after_forward: bool, inventory_swap_reverse_persisted: bool, inventory_swap_failure: Option, + vendor_smoke: bool, + vendor_smoke_passed: Option, + vendor_entry: Option, + vendor_spawn_guid: Option, + vendor_runtime_counter: Option, + vendor_item_entry: Option, + vendor_extended_cost: Option, + vendor_currency_id: Option, + vendor_currency_before: Option, + vendor_currency_after: Option, + vendor_item_total_after: Option, + vendor_inventory_seen: bool, + vendor_buy_succeeded_seen: bool, + vendor_set_currency_seen: bool, + vendor_item_push_seen: bool, + vendor_relogin_verified: bool, + vendor_failure: Option, rested_xp_smoke: bool, rested_xp_smoke_passed: Option, rested_xp_offline_wilderness_bonus: Option, @@ -427,6 +469,13 @@ impl BotRunResult { && self.player_login_verified && self.inventory_swap_smoke_passed.unwrap_or(false); } + if self.vendor_smoke { + return self.world_auth + && self.enum_characters + && self.player_login_verified + && self.vendor_smoke_passed.unwrap_or(false) + && self.vendor_relogin_verified; + } if self.rested_xp_smoke { return self.world_auth && self.enum_characters @@ -461,6 +510,7 @@ struct RunReport { bank_smoke: bool, homebind_smoke: bool, inventory_swap_smoke: bool, + vendor_smoke: bool, rested_xp_smoke: bool, loot_race_smoke: bool, loot_item_capture: bool, @@ -610,6 +660,53 @@ struct InventorySwapSmokeFixture { options: InventorySwapSmokeOptions, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum VendorSmokePhase { + Purchase, + VerifyRelog, +} + +#[derive(Debug, Clone)] +struct VendorSmokeOptions { + phase: VendorSmokePhase, + vendor: ResolvedCreatureTarget, + target_match_radius: f32, + item_entry: u32, + extended_cost: u32, + currency_id: u32, + currency_before: u32, + currency_cost: u32, + expected_item_total: u64, + timeout_secs: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct VendorCurrencyRowSnapshot { + quantity: u32, + weekly_quantity: u32, + tracked_quantity: u32, + increased_cap_quantity: u32, + earned_quantity: u32, + flags: u8, +} + +#[derive(Debug, Clone)] +struct VendorSmokeFixture { + options: VendorSmokeOptions, + original_position: CharacterPositionSnapshot, + original_currency: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct VendorInventoryItemWire { + muid: i32, + item_id: i32, + item_type: i32, + price: u64, + stack_count: i32, + extended_cost: i32, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RestedXpSmokePhase { OfflineWilderness, @@ -865,6 +962,53 @@ fn parse_cli() -> Result { .map(|value| value.parse::()) .transpose()? .unwrap_or(8), + vendor_smoke: std::env::var("WOW_BOT_VENDOR_SMOKE") + .ok() + .is_some_and(|value| is_truthy(&value)), + vendor_entry: std::env::var("WOW_BOT_VENDOR_ENTRY") + .ok() + .map(|value| value.parse::()) + .transpose()? + .unwrap_or(DEFAULT_VENDOR_ENTRY), + vendor_spawn_guid: std::env::var("WOW_BOT_VENDOR_SPAWN_GUID") + .ok() + .map(|value| value.parse::()) + .transpose()? + .unwrap_or(DEFAULT_VENDOR_SPAWN_GUID), + vendor_runtime_counter: std::env::var("WOW_BOT_VENDOR_RUNTIME_COUNTER") + .ok() + .map(|value| value.parse::()) + .transpose()?, + vendor_item_entry: std::env::var("WOW_BOT_VENDOR_ITEM_ENTRY") + .ok() + .map(|value| value.parse::()) + .transpose()? + .unwrap_or(DEFAULT_VENDOR_ITEM_ENTRY), + vendor_extended_cost: std::env::var("WOW_BOT_VENDOR_EXTENDED_COST") + .ok() + .map(|value| value.parse::()) + .transpose()? + .unwrap_or(DEFAULT_VENDOR_EXTENDED_COST), + vendor_currency_id: std::env::var("WOW_BOT_VENDOR_CURRENCY_ID") + .ok() + .map(|value| value.parse::()) + .transpose()? + .unwrap_or(DEFAULT_VENDOR_CURRENCY_ID), + vendor_currency_cost: std::env::var("WOW_BOT_VENDOR_CURRENCY_COST") + .ok() + .map(|value| value.parse::()) + .transpose()? + .unwrap_or(DEFAULT_VENDOR_CURRENCY_COST), + vendor_currency_quantity: std::env::var("WOW_BOT_VENDOR_CURRENCY_QUANTITY") + .ok() + .map(|value| value.parse::()) + .transpose()? + .unwrap_or(DEFAULT_VENDOR_CURRENCY_QUANTITY), + vendor_timeout_secs: std::env::var("WOW_BOT_VENDOR_TIMEOUT_SECS") + .ok() + .map(|value| value.parse::()) + .transpose()? + .unwrap_or(8), rested_xp_smoke: std::env::var("WOW_BOT_RESTED_XP_SMOKE") .ok() .map(|value| is_truthy(&value)) @@ -1083,6 +1227,38 @@ fn parse_cli() -> Result { opts.inventory_swap_timeout_secs = next_arg(&mut args, "--inventory-swap-timeout")?.parse()?; } + "--vendor-smoke" => opts.vendor_smoke = true, + "--vendor-entry" => { + opts.vendor_entry = next_arg(&mut args, "--vendor-entry")?.parse()?; + } + "--vendor-spawn-guid" => { + opts.vendor_spawn_guid = next_arg(&mut args, "--vendor-spawn-guid")?.parse()?; + } + "--vendor-runtime-counter" => { + opts.vendor_runtime_counter = + Some(next_arg(&mut args, "--vendor-runtime-counter")?.parse()?); + } + "--vendor-item-entry" => { + opts.vendor_item_entry = next_arg(&mut args, "--vendor-item-entry")?.parse()?; + } + "--vendor-extended-cost" => { + opts.vendor_extended_cost = + next_arg(&mut args, "--vendor-extended-cost")?.parse()?; + } + "--vendor-currency-id" => { + opts.vendor_currency_id = next_arg(&mut args, "--vendor-currency-id")?.parse()?; + } + "--vendor-currency-cost" => { + opts.vendor_currency_cost = + next_arg(&mut args, "--vendor-currency-cost")?.parse()?; + } + "--vendor-currency-quantity" => { + opts.vendor_currency_quantity = + next_arg(&mut args, "--vendor-currency-quantity")?.parse()?; + } + "--vendor-timeout" => { + opts.vendor_timeout_secs = next_arg(&mut args, "--vendor-timeout")?.parse()?; + } "--rested-xp-smoke" => opts.rested_xp_smoke = true, arg if parse_ack_disposable_rested_xp_arg(arg, &mut opts.ack_disposable_rested_xp) => {} "--rested-xp-creature-entry" => { @@ -1467,6 +1643,23 @@ fn print_help() { println!( " Env: WOW_BOT_BANK_SMOKE, WOW_BOT_BANK_ITEM_ENTRY, WOW_BOT_BANK_RUNTIME_COUNTER, WOW_BOT_BANK_TIMEOUT_SECS" ); + println!( + " --vendor-smoke Buy one extended-cost vendor item, relog, verify DB persistence, and restore the fixture" + ); + println!(" --vendor-entry Vendor creature entry (default: 18525 G'eras)"); + println!(" --vendor-spawn-guid Exact world.creature spawn (default: 96654)"); + println!( + " --vendor-runtime-counter Optional checked live ObjectGuid low-counter override" + ); + println!(" --vendor-item-entry Item to buy (default: 30183)"); + println!(" --vendor-extended-cost Required vendor extended cost (default: 1642)"); + println!(" --vendor-currency-id Cost currency (default: 42)"); + println!(" --vendor-currency-cost Cost for one purchase (default: 15)"); + println!(" --vendor-currency-quantity Seeded quantity (default: 30)"); + println!(" --vendor-timeout Vendor response timeout (default: 8)"); + println!( + " Env: WOW_BOT_VENDOR_SMOKE, WOW_BOT_VENDOR_ENTRY, WOW_BOT_VENDOR_SPAWN_GUID, WOW_BOT_VENDOR_RUNTIME_COUNTER, WOW_BOT_VENDOR_ITEM_ENTRY, WOW_BOT_VENDOR_EXTENDED_COST, WOW_BOT_VENDOR_CURRENCY_ID, WOW_BOT_VENDOR_CURRENCY_COST, WOW_BOT_VENDOR_CURRENCY_QUANTITY, WOW_BOT_VENDOR_TIMEOUT_SECS" + ); println!( " --homebind-smoke Bind at an innkeeper, relog, and verify response packets plus DB persistence" ); @@ -2034,6 +2227,7 @@ async fn main() -> Result<()> { || cli.bank_smoke || cli.homebind_smoke || cli.inventory_swap_smoke + || cli.vendor_smoke || cli.rested_xp_smoke || cli.loot_race_smoke || cli.loot_item_capture @@ -2088,6 +2282,7 @@ async fn main() -> Result<()> { cli.bank_smoke, cli.homebind_smoke, cli.inventory_swap_smoke, + cli.vendor_smoke, cli.rested_xp_smoke, cli.loot_race_smoke, cli.loot_item_capture, @@ -2097,7 +2292,7 @@ async fn main() -> Result<()> { .filter(|enabled| *enabled) .count(); if post_login_mode_count > 1 { - bail!("stand-state, bank, homebind, inventory-swap, rested-xp, loot-race, loot-item-capture, and quest smoke are separate post-login modes"); + bail!("stand-state, bank, homebind, inventory-swap, vendor, 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"); @@ -2127,6 +2322,23 @@ async fn main() -> Result<()> { { bail!("inventory-swap fixture item entries must be different to avoid stack merging"); } + if cli.vendor_smoke && bots.len() != 1 { + bail!("--vendor-smoke requires exactly one bot; select it with --single"); + } + if cli.vendor_smoke && cli.vendor_timeout_secs == 0 { + bail!("--vendor-timeout must be greater than zero"); + } + if cli.vendor_smoke + && (cli.vendor_entry == 0 + || cli.vendor_spawn_guid == 0 + || cli.vendor_item_entry == 0 + || cli.vendor_extended_cost == 0 + || cli.vendor_currency_id == 0 + || cli.vendor_currency_cost == 0 + || cli.vendor_currency_quantity <= cli.vendor_currency_cost) + { + bail!("vendor smoke requires nonzero fixture identifiers/cost and a seeded currency quantity greater than one purchase cost"); + } validate_rested_xp_cli_values( cli.rested_xp_smoke, cli.ack_disposable_rested_xp, @@ -2212,6 +2424,8 @@ async fn main() -> Result<()> { "homebind-smoke" } else if cli.inventory_swap_smoke { "inventory-swap-smoke" + } else if cli.vendor_smoke { + "vendor-smoke" } else if cli.rested_xp_smoke { "rested-xp-smoke" } else if cli.loot_race_smoke { @@ -2238,6 +2452,7 @@ async fn main() -> Result<()> { && !cli.bank_smoke && !cli.homebind_smoke && !cli.inventory_swap_smoke + && !cli.vendor_smoke && !cli.rested_xp_smoke && !cli.loot_race_smoke && !cli.loot_item_capture @@ -2316,6 +2531,23 @@ async fn main() -> Result<()> { cli.inventory_swap_timeout_secs, ) .await + } else if cli.vendor_smoke { + run_vendor_smoke_workflow( + bot, + dungeon_id, + timeout_secs, + auto_teleport, + cli.vendor_entry, + cli.vendor_spawn_guid, + cli.vendor_runtime_counter, + cli.vendor_item_entry, + cli.vendor_extended_cost, + cli.vendor_currency_id, + cli.vendor_currency_cost, + cli.vendor_currency_quantity, + cli.vendor_timeout_secs, + ) + .await } else if cli.rested_xp_smoke { run_rested_xp_smoke_workflow( bot, @@ -2342,6 +2574,7 @@ async fn main() -> Result<()> { None, None, None, + None, quest_options.clone(), ) .await @@ -2380,6 +2613,7 @@ async fn main() -> Result<()> { None, None, None, + None, quest_options_for_bot, ) .await; @@ -2412,6 +2646,7 @@ async fn main() -> Result<()> { cli.bank_smoke, cli.homebind_smoke, cli.inventory_swap_smoke, + cli.vendor_smoke, cli.rested_xp_smoke, cli.loot_race_smoke, cli.loot_item_capture, @@ -2539,6 +2774,7 @@ async fn run_bot( bank_options: Option, homebind_options: Option, inventory_swap_options: Option, + vendor_options: Option, rested_xp_options: Option, loot_race_options: Option, quest_options: Option, @@ -2630,6 +2866,29 @@ async fn run_bot( inventory_swap_relogin_after_forward: false, inventory_swap_reverse_persisted: false, inventory_swap_failure: None, + vendor_smoke: vendor_options.is_some(), + vendor_smoke_passed: None, + vendor_entry: vendor_options.as_ref().map(|options| options.vendor.entry), + vendor_spawn_guid: vendor_options + .as_ref() + .map(|options| options.vendor.spawn_guid), + vendor_runtime_counter: vendor_options.as_ref().and_then(|options| { + (options.vendor.guid_counter != 0).then_some(options.vendor.guid_counter) + }), + vendor_item_entry: vendor_options.as_ref().map(|options| options.item_entry), + vendor_extended_cost: vendor_options.as_ref().map(|options| options.extended_cost), + vendor_currency_id: vendor_options.as_ref().map(|options| options.currency_id), + vendor_currency_before: vendor_options + .as_ref() + .map(|options| options.currency_before), + vendor_currency_after: None, + vendor_item_total_after: None, + vendor_inventory_seen: false, + vendor_buy_succeeded_seen: false, + vendor_set_currency_seen: false, + vendor_item_push_seen: false, + vendor_relogin_verified: false, + vendor_failure: None, rested_xp_smoke: rested_xp_options.is_some(), rested_xp_smoke_passed: None, rested_xp_offline_wilderness_bonus: None, @@ -2993,9 +3252,11 @@ async fn run_bot( let mut login_ok = false; let preserve_realm_connection = stand_state_options.is_some() || homebind_options.is_some() + || vendor_options.is_some() || rested_xp_options.is_some() || loot_race_options.is_some(); let mut loot_race_target_seen = false; + let mut vendor_target_seen: Option = None; let login_budget = LoginVerifyBudget::new(LOGIN_VERIFY_TIMEOUT); while let Some(read_timeout) = login_budget.next_read_timeout() { match tokio::time::timeout( @@ -3013,6 +3274,38 @@ async fn run_bot( result.loot_race_target_runtime_counter = Some(counter); } } + if let Some(options) = vendor_options.as_ref() { + let candidate = (op == SMSG_UPDATE_OBJECT) + .then(|| { + find_creature_guid_near_position_in_update_object( + &payload, + options.vendor.map_id, + options.vendor.entry, + options.vendor.x as f32, + options.vendor.y as f32, + options.vendor.z as f32, + options.target_match_radius, + (options.vendor.guid_counter != 0).then_some( + options.vendor.guid_counter & OBJECT_GUID_COUNTER_MASK, + ), + ) + }) + .flatten(); + if let Some(candidate) = candidate { + match vendor_target_seen { + Some(previous) + if (previous.low, previous.high) + != (candidate.low, candidate.high) => + { + bail!( + "vendor login discovery produced two different live candidates near SQL spawn {}", + options.vendor.spawn_guid + ); + } + _ => vendor_target_seen = Some(candidate), + } + } + } if let Some(options) = quest_options.as_ref() { record_quest_objective_login_signal(op, &payload, options, &mut result); } @@ -3184,6 +3477,42 @@ async fn run_bot( return Ok(result); } + if let Some(vendor_options) = vendor_options { + if let Err(error) = run_vendor_smoke_phase( + bot_index, + &bot, + &mut stream, + &mut crypt, + &mut server_inflater, + &mut realm_connection, + &vendor_options, + vendor_target_seen, + &mut result, + ) + .await + { + let mut failure = error.to_string(); + if let Err(logout_error) = loot_race::logout_and_wait_routed_like_cpp( + bot_index, + &mut stream, + &mut crypt, + &mut server_inflater, + realm_connection.as_mut(), + bot.character_guid, + &mut result, + ) + .await + { + failure.push_str(&format!( + "; graceful logout after failure also failed: {logout_error}" + )); + } + result.vendor_failure = Some(failure); + result.vendor_smoke_passed = Some(false); + } + return Ok(result); + } + if let Some(rested_xp_options) = rested_xp_options { if let Err(error) = run_rested_xp_smoke_phase( bot_index, @@ -3491,6 +3820,28 @@ fn log_bot_summary( ); return; } + if result.vendor_smoke { + info!( + "✅ Bot {}: SUCCESS vendor_smoke vendor={:?}/{:?}/counter={:?} item={:?}/cost={:?} currency={:?} {:?}->{:?} item_total={:?} list={} buy={} set_currency={} item_push={} relog={} failure={:?}", + result.account, + result.vendor_entry, + result.vendor_spawn_guid, + result.vendor_runtime_counter, + result.vendor_item_entry, + result.vendor_extended_cost, + result.vendor_currency_id, + result.vendor_currency_before, + result.vendor_currency_after, + result.vendor_item_total_after, + result.vendor_inventory_seen, + result.vendor_buy_succeeded_seen, + result.vendor_set_currency_seen, + result.vendor_item_push_seen, + result.vendor_relogin_verified, + result.vendor_failure, + ); + return; + } if result.rested_xp_smoke { info!( "✅ Bot {}: SUCCESS rested_xp_smoke offline={:?}/{:?} target={:?}/{:?}/counter={:?} xp={:?}+{:?} rest={:?}->{:?} relog={} failure={:?}", @@ -3625,6 +3976,28 @@ fn log_bot_summary( ); return; } + if result.vendor_smoke { + error!( + "❌ Bot {}: FAILED vendor_smoke vendor={:?}/{:?}/counter={:?} item={:?}/cost={:?} currency={:?} {:?}->{:?} item_total={:?} list={} buy={} set_currency={} item_push={} relog={} failure={:?}", + result.account, + result.vendor_entry, + result.vendor_spawn_guid, + result.vendor_runtime_counter, + result.vendor_item_entry, + result.vendor_extended_cost, + result.vendor_currency_id, + result.vendor_currency_before, + result.vendor_currency_after, + result.vendor_item_total_after, + result.vendor_inventory_seen, + result.vendor_buy_succeeded_seen, + result.vendor_set_currency_seen, + result.vendor_item_push_seen, + result.vendor_relogin_verified, + result.vendor_failure, + ); + return; + } if result.rested_xp_smoke { error!( "❌ Bot {}: FAILED rested_xp_smoke offline={:?}/{:?} target={:?}/{:?}/counter={:?} packet={:?}/{:?} db_xp={:?}->{:?} db_rest={:?}->{:?} relog={} failure={:?}", @@ -3695,6 +4068,7 @@ fn write_report_if_requested( bank_smoke: bool, homebind_smoke: bool, inventory_swap_smoke: bool, + vendor_smoke: bool, rested_xp_smoke: bool, loot_race_smoke: bool, loot_item_capture: bool, @@ -3718,6 +4092,7 @@ fn write_report_if_requested( bank_smoke, homebind_smoke, inventory_swap_smoke, + vendor_smoke, rested_xp_smoke, loot_race_smoke, loot_item_capture, @@ -4510,6 +4885,7 @@ async fn run_rested_xp_smoke_workflow_inner( None, None, None, + None, Some(wilderness_options), None, None, @@ -4572,6 +4948,7 @@ async fn run_rested_xp_smoke_workflow_inner( None, None, None, + None, Some(resting_options), None, None, @@ -4645,6 +5022,7 @@ async fn run_rested_xp_smoke_workflow_inner( None, None, None, + None, Some(consume_options), None, None, @@ -4675,6 +5053,7 @@ async fn run_rested_xp_smoke_workflow_inner( None, None, None, + None, Some(verify_options), None, None, @@ -4787,6 +5166,7 @@ async fn run_bank_smoke_workflow( None, None, None, + None, ) .await; @@ -4819,6 +5199,7 @@ async fn run_bank_smoke_workflow( None, None, None, + None, ) .await { @@ -4888,6 +5269,7 @@ async fn run_homebind_smoke_workflow( None, None, None, + None, ) .await; @@ -4954,6 +5336,7 @@ async fn run_homebind_smoke_workflow( None, None, None, + None, ) .await { @@ -6142,6 +6525,7 @@ async fn run_inventory_swap_smoke_workflow( None, None, None, + None, ) .await; @@ -6174,6 +6558,7 @@ async fn run_inventory_swap_smoke_workflow( None, None, None, + None, ) .await { @@ -6217,51 +6602,381 @@ async fn run_inventory_swap_smoke_workflow( Ok(combined) } -async fn run_inventory_swap_smoke_phase( - bot_index: usize, - bot: &config::BotConfig, - stream: &mut TcpStream, - crypt: &mut WorldCrypt, - server_inflater: &mut ServerPacketInflater, - options: &InventorySwapSmokeOptions, - result: &mut BotRunResult, -) -> Result<()> { - let (expected_before_a, expected_before_b, expected_after_a, expected_after_b) = - match options.phase { - InventorySwapSmokePhase::Forward => ( - options.slot_a, - options.slot_b, - options.slot_b, - options.slot_a, - ), - InventorySwapSmokePhase::Reverse => { - result.inventory_swap_relogin_after_forward = true; - ( - options.slot_b, - options.slot_a, - options.slot_a, - options.slot_b, +async fn run_vendor_smoke_workflow( + bot: config::BotConfig, + dungeon_id: u32, + lfg_secs: u64, + auto_teleport: bool, + vendor_entry: u32, + vendor_spawn_guid: u64, + runtime_counter: Option, + item_entry: u32, + extended_cost: u32, + currency_id: u32, + currency_cost: u32, + currency_quantity: u32, + timeout_secs: u64, +) -> Result { + let bot_for_setup = bot.clone(); + let fixture = tokio::task::spawn_blocking(move || { + prepare_vendor_smoke_fixture( + &bot_for_setup, + vendor_entry, + vendor_spawn_guid, + runtime_counter, + item_entry, + extended_cost, + currency_id, + currency_cost, + currency_quantity, + timeout_secs, + ) + }) + .await + .map_err(|error| anyhow!("Vendor smoke setup DB worker join failed: {error}"))??; + + let first = run_bot( + bot.clone(), + dungeon_id, + lfg_secs, + auto_teleport, + false, + None, + None, + None, + None, + Some(fixture.options.clone()), + None, + None, + None, + ) + .await; + + let mut combined = match first { + Ok(result) => result, + Err(error) => { + let bot_for_cleanup = bot.clone(); + let fixture_for_cleanup = fixture.clone(); + let cleanup = tokio::task::spawn_blocking(move || { + cleanup_vendor_smoke_fixture(&bot_for_cleanup, &fixture_for_cleanup) + }) + .await + .map_err(|join_error| { + anyhow!( + "Vendor smoke purchase login/phase failed: {error}; cleanup worker failed: {join_error}" ) + })?; + if let Err(cleanup_error) = cleanup { + bail!( + "Vendor smoke purchase login/phase failed: {error}; fixture cleanup failed: {cleanup_error}" + ); } - }; + return Err(error.context("Vendor smoke purchase login/phase failed")); + } + }; - let bot_for_before = bot.clone(); - let options_for_before = options.clone(); - let before = tokio::task::spawn_blocking(move || { - verify_inventory_swap_fixture_locations( - &bot_for_before, - &options_for_before, - expected_before_a, - expected_before_b, + if combined.vendor_smoke_passed.unwrap_or(false) { + let mut relog_options = fixture.options.clone(); + relog_options.phase = VendorSmokePhase::VerifyRelog; + match run_bot( + bot.clone(), + dungeon_id, + lfg_secs, + auto_teleport, + false, + None, + None, + None, + None, + Some(relog_options), + None, + None, + None, ) + .await + { + Ok(second) => { + combined.world_auth &= second.world_auth; + combined.enum_characters &= second.enum_characters; + combined.player_login_verified &= second.player_login_verified; + combined.vendor_relogin_verified = second.vendor_relogin_verified; + combined.vendor_currency_after = second.vendor_currency_after; + combined.vendor_item_total_after = second.vendor_item_total_after; + combined.seen_opcodes.extend(second.seen_opcodes); + combined.vendor_failure = second.vendor_failure; + combined.vendor_smoke_passed = Some( + combined.vendor_inventory_seen + && combined.vendor_buy_succeeded_seen + && combined.vendor_set_currency_seen + && combined.vendor_item_push_seen + && combined.vendor_relogin_verified + && second.vendor_smoke_passed.unwrap_or(false), + ); + } + Err(error) => { + combined.vendor_failure = + Some(format!("Vendor persistence relog/phase failed: {error}")); + combined.vendor_smoke_passed = Some(false); + } + } + } + + let bot_for_cleanup = bot.clone(); + let fixture_for_cleanup = fixture.clone(); + let cleanup = tokio::task::spawn_blocking(move || { + cleanup_vendor_smoke_fixture(&bot_for_cleanup, &fixture_for_cleanup) }) .await - .map_err(|e| anyhow!("Inventory swap pre-phase DB worker join failed: {e}"))??; - if !before { - bail!( - "inventory swap fixture was not in expected slots {expected_before_a}/{expected_before_b} before {:?}", - options.phase - ); + .map_err(|error| anyhow!("Vendor smoke cleanup DB worker join failed: {error}"))?; + if let Err(error) = cleanup { + let cleanup_failure = format!("Vendor fixture cleanup failed: {error}"); + combined.vendor_failure = Some(match combined.vendor_failure.take() { + Some(previous) => format!("{previous}; {cleanup_failure}"), + None => cleanup_failure, + }); + combined.vendor_smoke_passed = Some(false); + } + + Ok(combined) +} + +async fn run_vendor_smoke_phase( + bot_index: usize, + bot: &config::BotConfig, + stream: &mut TcpStream, + crypt: &mut WorldCrypt, + server_inflater: &mut ServerPacketInflater, + realm_connection: &mut Option, + options: &VendorSmokeOptions, + login_discovered_target: Option, + result: &mut BotRunResult, +) -> Result<()> { + let expected_currency_after = options + .currency_before + .checked_sub(options.currency_cost) + .ok_or_else(|| anyhow!("Vendor currency fixture underflow"))?; + + if options.phase == VendorSmokePhase::VerifyRelog { + let bot_for_db = bot.clone(); + let currency_id = options.currency_id; + let item_entry = options.item_entry; + let (currency, item_total) = tokio::task::spawn_blocking(move || { + load_vendor_smoke_db_state(&bot_for_db, currency_id, item_entry) + }) + .await + .map_err(|error| anyhow!("Vendor relog DB worker join failed: {error}"))??; + result.vendor_currency_after = Some(currency); + result.vendor_item_total_after = Some(item_total); + if currency != expected_currency_after || item_total != options.expected_item_total { + bail!( + "vendor state after relog is currency/item {currency}/{item_total}, expected {expected_currency_after}/{}", + options.expected_item_total + ); + } + result.vendor_relogin_verified = true; + loot_race::logout_and_wait_routed_like_cpp( + bot_index, + stream, + crypt, + server_inflater, + realm_connection.as_mut(), + bot.character_guid, + result, + ) + .await?; + result.vendor_smoke_passed = Some(true); + return Ok(()); + } + + let bot_for_before = bot.clone(); + let currency_id = options.currency_id; + let item_entry = options.item_entry; + let (currency_before, item_before) = tokio::task::spawn_blocking(move || { + load_vendor_smoke_db_state(&bot_for_before, currency_id, item_entry) + }) + .await + .map_err(|error| anyhow!("Vendor pre-purchase DB worker join failed: {error}"))??; + if currency_before != options.currency_before || item_before != 0 { + bail!( + "vendor fixture drifted before purchase: currency/item {currency_before}/{item_before}, expected {}/0", + options.currency_before + ); + } + + // C++ Player::CanNeverSee keeps nearby world objects hidden until the + // client acknowledges that its active mover is initialized. Rust may have + // queued the vendor CREATE earlier, so send the canonical ACK before the + // cross-server discovery window in both cases. + let active_mover_complete = build_move_init_active_mover_complete_payload(0); + send_encrypted_packet( + stream, + crypt, + CMSG_MOVE_INIT_ACTIVE_MOVER_COMPLETE, + &active_mover_complete, + ) + .await?; + info!( + "[Bot {}] ✅ CMSG_MOVE_INIT_ACTIVE_MOVER_COMPLETE sent before vendor discovery", + bot_index + ); + + let expected_runtime_counter = (options.vendor.guid_counter != 0) + .then_some(options.vendor.guid_counter & OBJECT_GUID_COUNTER_MASK); + let mut discovered = login_discovered_target; + let discovery_deadline = tokio::time::Instant::now() + Duration::from_secs(5); + while discovered.is_none() && tokio::time::Instant::now() < discovery_deadline { + let remaining = discovery_deadline.saturating_duration_since(tokio::time::Instant::now()); + let Some((opcode, payload)) = read_encrypted_packet_if_ready( + stream, + crypt, + server_inflater, + Duration::from_millis(250).min(remaining), + Duration::from_secs(5), + "vendor instance login discovery", + ) + .await? + else { + continue; + }; + result.seen_opcodes.push(format!("0x{opcode:04X}")); + if opcode == SMSG_TIME_SYNC_REQUEST { + let sequence = parse_time_sync_request_sequence(&payload)?; + let response = build_time_sync_response_payload(sequence, 0); + send_encrypted_packet(stream, crypt, CMSG_TIME_SYNC_RESPONSE, &response).await?; + } else if opcode == SMSG_UPDATE_OBJECT { + discovered = find_creature_guid_near_position_in_update_object( + &payload, + options.vendor.map_id, + options.vendor.entry, + options.vendor.x as f32, + options.vendor.y as f32, + options.vendor.z as f32, + options.target_match_radius, + expected_runtime_counter, + ); + } + } + let runtime_target = resolve_vendor_runtime_target(&options.vendor, discovered)?; + let runtime_counter = runtime_target.low & OBJECT_GUID_COUNTER_MASK; + let runtime_vendor_guid = build_packed_guid(runtime_target.low, runtime_target.high); + result.vendor_runtime_counter = Some(runtime_counter); + + send_encrypted_packet(stream, crypt, CMSG_LIST_INVENTORY, &runtime_vendor_guid).await?; + info!( + "[Bot {}] ✅ CMSG_LIST_INVENTORY sent to entry={} spawn={} counter={}", + bot_index, options.vendor.entry, options.vendor.spawn_guid, runtime_counter + ); + let vendor_item = wait_for_vendor_inventory_item( + bot_index, + stream, + crypt, + server_inflater, + &runtime_vendor_guid, + options, + result, + ) + .await?; + + let buy_payload = build_vendor_buy_item_payload( + &runtime_vendor_guid, + bot.character_guid, + vendor_item.muid, + options.item_entry, + ); + send_encrypted_packet(stream, crypt, CMSG_BUY_ITEM, &buy_payload).await?; + info!( + "[Bot {}] ✅ CMSG_BUY_ITEM sent item={} muid={} cost={}/currency={}", + bot_index, options.item_entry, vendor_item.muid, options.currency_cost, options.currency_id + ); + wait_for_vendor_purchase_result( + bot_index, + stream, + crypt, + server_inflater, + realm_connection, + &runtime_vendor_guid, + vendor_item.muid, + options, + expected_currency_after, + result, + ) + .await?; + + loot_race::logout_and_wait_routed_like_cpp( + bot_index, + stream, + crypt, + server_inflater, + realm_connection.as_mut(), + bot.character_guid, + result, + ) + .await?; + let bot_for_after = bot.clone(); + let currency_id = options.currency_id; + let item_entry = options.item_entry; + let (currency_after, item_after) = tokio::task::spawn_blocking(move || { + load_vendor_smoke_db_state(&bot_for_after, currency_id, item_entry) + }) + .await + .map_err(|error| anyhow!("Vendor post-purchase DB worker join failed: {error}"))??; + result.vendor_currency_after = Some(currency_after); + result.vendor_item_total_after = Some(item_after); + if currency_after != expected_currency_after || item_after != options.expected_item_total { + bail!( + "vendor purchase persisted currency/item {currency_after}/{item_after}, expected {expected_currency_after}/{}", + options.expected_item_total + ); + } + result.vendor_smoke_passed = Some(true); + Ok(()) +} + +async fn run_inventory_swap_smoke_phase( + bot_index: usize, + bot: &config::BotConfig, + stream: &mut TcpStream, + crypt: &mut WorldCrypt, + server_inflater: &mut ServerPacketInflater, + options: &InventorySwapSmokeOptions, + result: &mut BotRunResult, +) -> Result<()> { + let (expected_before_a, expected_before_b, expected_after_a, expected_after_b) = + match options.phase { + InventorySwapSmokePhase::Forward => ( + options.slot_a, + options.slot_b, + options.slot_b, + options.slot_a, + ), + InventorySwapSmokePhase::Reverse => { + result.inventory_swap_relogin_after_forward = true; + ( + options.slot_b, + options.slot_a, + options.slot_a, + options.slot_b, + ) + } + }; + + let bot_for_before = bot.clone(); + let options_for_before = options.clone(); + let before = tokio::task::spawn_blocking(move || { + verify_inventory_swap_fixture_locations( + &bot_for_before, + &options_for_before, + expected_before_a, + expected_before_b, + ) + }) + .await + .map_err(|e| anyhow!("Inventory swap pre-phase DB worker join failed: {e}"))??; + if !before { + bail!( + "inventory swap fixture was not in expected slots {expected_before_a}/{expected_before_b} before {:?}", + options.phase + ); } let payload = build_swap_inv_item_payload(options.slot_a, options.slot_b); @@ -6436,6 +7151,394 @@ async fn logout_and_wait( bail!("timed out waiting for SMSG_LOGOUT_COMPLETE") } +async fn wait_for_vendor_inventory_item( + bot_index: usize, + stream: &mut TcpStream, + crypt: &mut WorldCrypt, + server_inflater: &mut ServerPacketInflater, + expected_vendor_guid: &[u8], + options: &VendorSmokeOptions, + 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_VENDOR_INVENTORY"); + } + let (opcode, payload) = tokio::time::timeout( + remaining, + read_encrypted_packet(stream, crypt, server_inflater), + ) + .await + .map_err(|_| anyhow!("timed out waiting for SMSG_VENDOR_INVENTORY"))??; + result.seen_opcodes.push(format!("0x{opcode:04X}")); + info!( + "[Bot {}] 📦 vendor-list {}", + bot_index, + parse_packet(opcode, &payload) + ); + if opcode == SMSG_TIME_SYNC_REQUEST { + let sequence = parse_time_sync_request_sequence(&payload)?; + let response = build_time_sync_response_payload(sequence, 0); + send_encrypted_packet(stream, crypt, CMSG_TIME_SYNC_RESPONSE, &response).await?; + continue; + } + if opcode != SMSG_VENDOR_INVENTORY { + continue; + } + + let items = parse_vendor_inventory(&payload, expected_vendor_guid)?; + let item = items + .into_iter() + .find(|item| { + item.item_id == options.item_entry as i32 + && item.extended_cost == options.extended_cost as i32 + }) + .ok_or_else(|| { + anyhow!( + "vendor inventory omitted expected item/cost {}/{}", + options.item_entry, + options.extended_cost + ) + })?; + if item.item_type != 1 || item.muid <= 0 || item.price != 0 || item.stack_count != 1 { + bail!( + "vendor item {} wire row is not the deterministic fixture shape: {item:?}", + options.item_entry + ); + } + result.vendor_inventory_seen = true; + return Ok(item); + } +} + +async fn wait_for_vendor_purchase_result( + bot_index: usize, + stream: &mut TcpStream, + crypt: &mut WorldCrypt, + server_inflater: &mut ServerPacketInflater, + realm_connection: &mut Option, + expected_vendor_guid: &[u8], + expected_muid: i32, + options: &VendorSmokeOptions, + expected_currency_after: u32, + result: &mut BotRunResult, +) -> Result<()> { + let realm = realm_connection + .as_mut() + .context("vendor purchase requires the preserved realm connection")?; + let deadline = tokio::time::Instant::now() + Duration::from_secs(options.timeout_secs); + let mut fence_sent = false; + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + bail!( + "timed out waiting for vendor purchase result (buy={}, currency={}, item_push={}, fence={})", + result.vendor_buy_succeeded_seen, + result.vendor_set_currency_seen, + result.vendor_item_push_seen, + fence_sent + ); + } + // C++ splits this result across both encrypted connections. Poll for + // readiness, then finish one selected frame without cancellation so + // a losing `select!` branch cannot consume a partial encrypted frame. + let routed_packet = 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, + "vendor realm purchase result", + ) + .await? + { + Some(("realm", true, opcode, payload)) + } else { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + read_encrypted_packet_if_ready( + stream, + crypt, + server_inflater, + remaining.min(Duration::from_millis(5)), + remaining, + "vendor instance purchase result", + ) + .await? + .map(|(opcode, payload)| ("instance", false, opcode, payload)) + }; + let Some((connection, on_realm, opcode, payload)) = routed_packet else { + continue; + }; + result.seen_opcodes.push(format!("0x{opcode:04X}")); + info!( + "[Bot {}] 📦 {} vendor-buy {}", + bot_index, + connection, + parse_packet(opcode, &payload) + ); + + match opcode { + SMSG_TIME_SYNC_REQUEST => { + if on_realm { + bail!("SMSG_TIME_SYNC_REQUEST arrived on realm during vendor purchase"); + } + let sequence = parse_time_sync_request_sequence(&payload)?; + let response = build_time_sync_response_payload(sequence, 0); + send_encrypted_packet(stream, crypt, CMSG_TIME_SYNC_RESPONSE, &response).await?; + } + SMSG_BUY_FAILED => { + let reason = payload.last().copied(); + bail!("vendor purchase returned SMSG_BUY_FAILED reason={reason:?}"); + } + SMSG_INVENTORY_CHANGE_FAILURE => { + bail!("vendor purchase returned SMSG_INVENTORY_CHANGE_FAILURE"); + } + SMSG_BUY_SUCCEEDED => { + if !on_realm { + bail!("SMSG_BUY_SUCCEEDED arrived on instance; C++ routes it on realm"); + } + parse_vendor_buy_succeeded(&payload, expected_vendor_guid, expected_muid, 1, -1)?; + result.vendor_buy_succeeded_seen = true; + } + SMSG_SET_CURRENCY => { + if on_realm { + bail!("SMSG_SET_CURRENCY arrived on realm; C++ routes it on instance"); + } + let (currency_id, quantity) = parse_set_currency_identity(&payload)?; + if currency_id == options.currency_id { + if quantity != expected_currency_after { + bail!( + "SMSG_SET_CURRENCY quantity for {} is {}, expected {}", + currency_id, + quantity, + expected_currency_after + ); + } + result.vendor_set_currency_seen = true; + } + } + SMSG_ITEM_PUSH_RESULT => { + if !on_realm { + bail!("SMSG_ITEM_PUSH_RESULT arrived on instance; C++ routes it on realm"); + } + loot_race::validate_vendor_item_push_result_like_cpp( + &payload, + result.character_guid, + options.item_entry, + 1, + realm_id(), + )?; + result.vendor_item_push_seen = true; + } + SMSG_PONG if fence_sent => { + if on_realm { + bail!("vendor capture-fence SMSG_PONG arrived on realm"); + } + if payload != VENDOR_CAPTURE_FENCE_SERIAL.to_le_bytes() { + bail!( + "vendor capture-fence SMSG_PONG mismatch: expected 0x{:08X}, got {:02X?}", + VENDOR_CAPTURE_FENCE_SERIAL, + payload + ); + } + return Ok(()); + } + _ => {} + } + + if !fence_sent + && result.vendor_buy_succeeded_seen + && result.vendor_set_currency_seen + && result.vendor_item_push_seen + { + let ping = build_ping_payload(VENDOR_CAPTURE_FENCE_SERIAL); + send_encrypted_packet(stream, crypt, CMSG_PING, &ping).await?; + fence_sent = true; + info!( + "[Bot {}] ✅ vendor capture-fence CMSG_PING serial=0x{:08X}", + bot_index, VENDOR_CAPTURE_FENCE_SERIAL + ); + } + } +} + +fn parse_set_currency_identity(payload: &[u8]) -> Result<(u32, u32)> { + if payload.len() < 8 { + bail!("SMSG_SET_CURRENCY payload is shorter than type/quantity"); + } + let currency_id = i32::from_le_bytes(payload[0..4].try_into()?); + let quantity = i32::from_le_bytes(payload[4..8].try_into()?); + Ok(( + u32::try_from(currency_id).map_err(|_| anyhow!("negative currency id {currency_id}"))?, + u32::try_from(quantity).map_err(|_| anyhow!("negative currency quantity {quantity}"))?, + )) +} + +fn parse_vendor_buy_succeeded( + payload: &[u8], + expected_vendor_guid: &[u8], + expected_muid: i32, + expected_quantity_bought: u32, + expected_new_quantity: i32, +) -> Result<()> { + let (guid_len, low, high) = parse_packed_guid(payload) + .ok_or_else(|| anyhow!("SMSG_BUY_SUCCEEDED has an invalid packed vendor GUID"))?; + let (expected_guid_len, expected_low, expected_high) = parse_packed_guid(expected_vendor_guid) + .ok_or_else(|| anyhow!("fixture has an invalid packed vendor GUID"))?; + if guid_len != expected_guid_len || low != expected_low || high != expected_high { + bail!("SMSG_BUY_SUCCEEDED names a different vendor GUID"); + } + + let mut cursor = guid_len; + let muid = take_vendor_u32(payload, &mut cursor)?; + let new_quantity = take_vendor_i32(payload, &mut cursor)?; + let quantity_bought = take_vendor_u32(payload, &mut cursor)?; + let expected_muid = u32::try_from(expected_muid) + .map_err(|_| anyhow!("fixture has invalid negative vendor MUID {expected_muid}"))?; + if cursor != payload.len() { + bail!( + "SMSG_BUY_SUCCEEDED has {} trailing bytes", + payload.len() - cursor + ); + } + if muid != expected_muid + || new_quantity != expected_new_quantity + || quantity_bought != expected_quantity_bought + { + bail!( + "SMSG_BUY_SUCCEEDED fields are muid/new_quantity/quantity_bought {muid}/{new_quantity}/{quantity_bought}, expected {expected_muid}/{expected_new_quantity}/{expected_quantity_bought}" + ); + } + Ok(()) +} + +fn parse_vendor_inventory( + payload: &[u8], + expected_vendor_guid: &[u8], +) -> Result> { + let (guid_len, low, high) = parse_packed_guid(payload) + .ok_or_else(|| anyhow!("SMSG_VENDOR_INVENTORY has an invalid packed vendor GUID"))?; + let (expected_len, expected_low, expected_high) = parse_packed_guid(expected_vendor_guid) + .ok_or_else(|| anyhow!("fixture has an invalid packed vendor GUID"))?; + if guid_len != expected_len || low != expected_low || high != expected_high { + bail!("SMSG_VENDOR_INVENTORY names a different vendor GUID"); + } + let mut cursor = guid_len; + let reason = *payload + .get(cursor) + .ok_or_else(|| anyhow!("SMSG_VENDOR_INVENTORY omitted reason"))?; + cursor += 1; + if reason != 0 { + bail!("SMSG_VENDOR_INVENTORY returned reason {reason}"); + } + let count = take_vendor_u32(payload, &mut cursor)?; + let count = usize::try_from(count).map_err(|_| anyhow!("vendor item count overflow"))?; + if count > 300 { + bail!("SMSG_VENDOR_INVENTORY item count {count} exceeds C++ vendor bound"); + } + let mut items = Vec::with_capacity(count); + for _ in 0..count { + let price = take_vendor_u64(payload, &mut cursor)?; + let muid = take_vendor_i32(payload, &mut cursor)?; + let item_type = take_vendor_i32(payload, &mut cursor)?; + let _durability = take_vendor_i32(payload, &mut cursor)?; + let stack_count = take_vendor_i32(payload, &mut cursor)?; + let _quantity = take_vendor_i32(payload, &mut cursor)?; + let extended_cost = take_vendor_i32(payload, &mut cursor)?; + let _player_condition_failed = take_vendor_i32(payload, &mut cursor)?; + cursor = cursor + .checked_add(1) + .filter(|next| *next <= payload.len()) + .ok_or_else(|| anyhow!("SMSG_VENDOR_INVENTORY omitted vendor flags"))?; + let item_id = take_vendor_i32(payload, &mut cursor)?; + let _random_seed = take_vendor_i32(payload, &mut cursor)?; + let _random_property = take_vendor_i32(payload, &mut cursor)?; + let has_bonus_bits = *payload + .get(cursor) + .ok_or_else(|| anyhow!("vendor ItemInstance omitted bonus bit"))?; + cursor += 1; + let mod_count_bits = *payload + .get(cursor) + .ok_or_else(|| anyhow!("vendor ItemInstance omitted modifier count"))?; + cursor += 1; + if has_bonus_bits != 0 || mod_count_bits != 0 { + bail!( + "vendor ItemInstance for item {item_id} has unsupported bonus/modifier bits 0x{has_bonus_bits:02X}/0x{mod_count_bits:02X}" + ); + } + items.push(VendorInventoryItemWire { + muid, + item_id, + item_type, + price, + stack_count, + extended_cost, + }); + } + if cursor != payload.len() { + bail!( + "SMSG_VENDOR_INVENTORY has {} trailing bytes after {} items", + payload.len() - cursor, + count + ); + } + Ok(items) +} + +fn take_vendor_u32(payload: &[u8], cursor: &mut usize) -> Result { + let end = cursor + .checked_add(4) + .ok_or_else(|| anyhow!("vendor packet cursor overflow"))?; + let bytes: [u8; 4] = payload + .get(*cursor..end) + .ok_or_else(|| anyhow!("vendor packet truncated at byte {}", *cursor))? + .try_into()?; + *cursor = end; + Ok(u32::from_le_bytes(bytes)) +} + +fn take_vendor_i32(payload: &[u8], cursor: &mut usize) -> Result { + Ok(i32::from_le_bytes( + take_vendor_u32(payload, cursor)?.to_le_bytes(), + )) +} + +fn take_vendor_u64(payload: &[u8], cursor: &mut usize) -> Result { + let end = cursor + .checked_add(8) + .ok_or_else(|| anyhow!("vendor packet cursor overflow"))?; + let bytes: [u8; 8] = payload + .get(*cursor..end) + .ok_or_else(|| anyhow!("vendor packet truncated at byte {}", *cursor))? + .try_into()?; + *cursor = end; + Ok(u64::from_le_bytes(bytes)) +} + +fn build_vendor_buy_item_payload( + vendor_guid: &[u8], + character_guid: u64, + muid: i32, + item_entry: u32, +) -> Vec { + let (player_low, player_high) = create_player_guid_raw(character_guid, realm_id()); + let mut payload = Vec::with_capacity(vendor_guid.len() + 48); + payload.extend_from_slice(vendor_guid); + payload.extend(build_packed_guid(player_low, player_high)); + payload.extend_from_slice(&1i32.to_le_bytes()); + payload.extend_from_slice(&muid.to_le_bytes()); + payload.extend_from_slice(&i32::from(u8::MAX).to_le_bytes()); + payload.extend_from_slice(&1i32.to_le_bytes()); + payload.extend_from_slice(&(item_entry as i32).to_le_bytes()); + payload.extend_from_slice(&0i32.to_le_bytes()); + payload.extend_from_slice(&0i32.to_le_bytes()); + payload.push(0); + payload.push(0); + payload +} + fn build_auto_bank_item_payload(slot: u8) -> [u8; 5] { // C++ InvUpdate count=1 is two MSB-first bits `01`, followed by the // affected position and then the packet's source bag/slot. @@ -8590,156 +9693,611 @@ fn prepare_inventory_swap_smoke_fixture( .query_first("SELECT COALESCE(MAX(guid), 0) FROM item_instance") .map_err(|e| anyhow!("Load max item guid: {e}"))? .unwrap_or(0); - let item_guid_a = max_item_guid - .checked_add(20_000) - .ok_or_else(|| anyhow!("item guid overflow while reserving inventory swap fixture"))?; - let item_guid_b = item_guid_a - .checked_add(1) - .ok_or_else(|| anyhow!("item guid overflow while reserving inventory swap fixture"))?; + let item_guid_a = max_item_guid + .checked_add(20_000) + .ok_or_else(|| anyhow!("item guid overflow while reserving inventory swap fixture"))?; + let item_guid_b = item_guid_a + .checked_add(1) + .ok_or_else(|| anyhow!("item guid overflow while reserving inventory swap fixture"))?; + + let mut transaction = characters + .start_transaction(mysql::TxOpts::default()) + .map_err(|e| anyhow!("Start inventory swap fixture transaction: {e}"))?; + for (item_guid, item_entry, slot) in [ + (item_guid_a, item_entry_a, slot_a), + (item_guid_b, item_entry_b, slot_b), + ] { + transaction + .exec_drop( + "INSERT INTO item_instance \ + (guid, itemEntry, owner_guid, creatorGuid, giftCreatorGuid, count, durability, \ + enchantments, charges, flags, randomPropertiesId, randomPropertiesSeed, context) \ + VALUES (?, ?, ?, 0, 0, 1, 0, '', '', 0, 0, 0, 0)", + (item_guid, item_entry, bot.character_guid), + ) + .map_err(|e| anyhow!("Insert inventory swap fixture item: {e}"))?; + transaction + .exec_drop( + "INSERT INTO character_inventory (guid, bag, slot, item) VALUES (?, 0, ?, ?)", + (bot.character_guid, slot, item_guid), + ) + .map_err(|e| anyhow!("Insert inventory swap fixture inventory row: {e}"))?; + } + transaction + .commit() + .map_err(|e| anyhow!("Commit inventory swap fixture transaction: {e}"))?; + + info!( + "Inventory swap fixture: character={} items={}/{} entries={}/{} slots={}/{}", + bot.character_guid, item_guid_a, item_guid_b, item_entry_a, item_entry_b, slot_a, slot_b + ); + Ok(InventorySwapSmokeFixture { + options: InventorySwapSmokeOptions { + phase: InventorySwapSmokePhase::Forward, + item_guid_a, + item_guid_b, + item_entry_a, + item_entry_b, + slot_a, + slot_b, + timeout_secs, + }, + }) +} + +fn verify_inventory_swap_fixture_locations( + bot: &config::BotConfig, + options: &InventorySwapSmokeOptions, + expected_slot_a: u8, + expected_slot_b: u8, +) -> Result { + use mysql::prelude::Queryable; + + let characters_url = characters_db_url()?; + let opts = mysql::Opts::from_url(&characters_url) + .map_err(|e| anyhow!("Bad characters DB URL: {e}"))?; + let mut conn = + mysql::Conn::new(opts).map_err(|e| anyhow!("Connect to characters DB failed: {e}"))?; + + let load = |conn: &mut mysql::Conn, item_guid: u64| -> Result> { + conn.exec_first( + "SELECT ci.bag, ci.slot, ii.itemEntry, ii.owner_guid \ + FROM character_inventory ci JOIN item_instance ii ON ii.guid = ci.item \ + WHERE ci.guid = ? AND ci.item = ? AND ii.count = 1", + (bot.character_guid, item_guid), + ) + .map_err(|e| anyhow!("Load inventory swap fixture location: {e}")) + }; + let row_a = load(&mut conn, options.item_guid_a)?; + let row_b = load(&mut conn, options.item_guid_b)?; + Ok(matches!( + row_a, + Some((0, slot, entry, owner)) + if slot == expected_slot_a + && entry == options.item_entry_a + && owner == bot.character_guid + ) && matches!( + row_b, + Some((0, slot, entry, owner)) + if slot == expected_slot_b + && entry == options.item_entry_b + && owner == bot.character_guid + )) +} + +fn cleanup_inventory_swap_smoke_fixture( + bot: &config::BotConfig, + fixture: &InventorySwapSmokeFixture, +) -> Result<()> { + use mysql::prelude::Queryable; + + let characters_url = characters_db_url()?; + let opts = mysql::Opts::from_url(&characters_url) + .map_err(|e| anyhow!("Bad characters DB URL: {e}"))?; + let mut conn = + mysql::Conn::new(opts).map_err(|e| anyhow!("Connect to characters DB failed: {e}"))?; + + let offline_deadline = std::time::Instant::now() + Duration::from_secs(10); + loop { + let online: Option = conn + .exec_first( + "SELECT online FROM characters WHERE guid = ?", + (bot.character_guid,), + ) + .map_err(|e| anyhow!("Check inventory swap bot offline state before cleanup: {e}"))?; + match online { + Some(0) => break, + Some(_) if std::time::Instant::now() < offline_deadline => { + std::thread::sleep(Duration::from_millis(100)); + } + Some(_) => { + bail!( + "character {} remained online; refusing inventory swap cleanup before disconnect save", + bot.character_guid + ); + } + None => bail!( + "No characters row for guid {} during inventory swap cleanup", + bot.character_guid + ), + } + } + + let mut transaction = conn + .start_transaction(mysql::TxOpts::default()) + .map_err(|e| anyhow!("Start inventory swap cleanup transaction: {e}"))?; + for item_guid in [fixture.options.item_guid_a, fixture.options.item_guid_b] { + transaction + .exec_drop( + "DELETE FROM character_inventory WHERE guid = ? AND item = ?", + (bot.character_guid, item_guid), + ) + .map_err(|e| anyhow!("Delete inventory swap fixture inventory row: {e}"))?; + transaction + .exec_drop( + "DELETE FROM item_instance WHERE guid = ? AND owner_guid = ?", + (item_guid, bot.character_guid), + ) + .map_err(|e| anyhow!("Delete inventory swap fixture item: {e}"))?; + } + transaction + .commit() + .map_err(|e| anyhow!("Commit inventory swap cleanup transaction: {e}"))?; + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn prepare_vendor_smoke_fixture( + bot: &config::BotConfig, + vendor_entry: u32, + vendor_spawn_guid: u64, + runtime_counter: Option, + item_entry: u32, + extended_cost: u32, + currency_id: u32, + currency_cost: u32, + currency_quantity: u32, + timeout_secs: u64, +) -> Result { + use mysql::prelude::Queryable; + + if !bot.account.to_ascii_uppercase().ends_with("@BOT.LOCAL") { + bail!( + "refusing destructive vendor fixture setup for non-local account {}", + bot.account + ); + } + if item_entry > i32::MAX as u32 + || extended_cost > i32::MAX as u32 + || currency_id > u16::MAX as u32 + || currency_quantity <= currency_cost + { + bail!("vendor fixture identifiers/quantity do not fit the 3.4.3 wire/database shape"); + } + if runtime_counter.is_some_and(|counter| counter == 0 || counter > OBJECT_GUID_COUNTER_MASK) { + bail!("vendor runtime counter override must fit the nonzero 40-bit ObjectGuid counter"); + } + + let characters_url = characters_db_url()?; + let character_opts = mysql::Opts::from_url(&characters_url) + .map_err(|error| anyhow!("Bad characters DB URL: {error}"))?; + let mut characters = mysql::Conn::new(character_opts) + .map_err(|error| anyhow!("Connect to characters DB failed: {error}"))?; + let character: Option<(u32, u8, u32, u32, u32, f64, f64, f64, f32)> = characters + .exec_first( + "SELECT account, online, map, zone, instance_id, position_x, position_y, position_z, orientation \ + FROM characters WHERE guid = ?", + (bot.character_guid,), + ) + .map_err(|error| anyhow!("Load vendor bot character: {error}"))?; + let (owner, online, map_id, zone_id, instance_id, x, y, z, orientation) = + character.ok_or_else(|| anyhow!("No characters row for guid {}", bot.character_guid))?; + if owner != bot.account_id { + bail!( + "character {} belongs to account {}, expected {}", + bot.character_guid, + owner, + bot.account_id + ); + } + if online != 0 { + bail!( + "character {} is online; log it out before vendor smoke setup", + bot.character_guid + ); + } + let original_position = CharacterPositionSnapshot { + map_id, + zone_id, + instance_id, + x, + y, + z, + orientation, + }; + let original_currency: Option = characters + .exec_first::<(u32, u32, u32, u32, u32, u8), _, _>( + "SELECT Quantity, WeeklyQuantity, TrackedQuantity, IncreasedCapQuantity, EarnedQuantity, Flags \ + FROM character_currency WHERE CharacterGuid = ? AND Currency = ?", + (bot.character_guid, currency_id), + ) + .map_err(|error| anyhow!("Load original vendor currency row: {error}"))? + .map( + |( + quantity, + weekly_quantity, + tracked_quantity, + increased_cap_quantity, + earned_quantity, + flags, + )| VendorCurrencyRowSnapshot { + quantity, + weekly_quantity, + tracked_quantity, + increased_cap_quantity, + earned_quantity, + flags, + }, + ); + let existing_item_total: u64 = characters + .exec_first( + "SELECT COALESCE(SUM(ii.count), 0) FROM character_inventory ci \ + JOIN item_instance ii ON ii.guid = ci.item \ + WHERE ci.guid = ? AND ii.itemEntry = ?", + (bot.character_guid, item_entry), + ) + .map_err(|error| anyhow!("Check existing vendor fixture item: {error}"))? + .unwrap_or(0); + if existing_item_total != 0 { + bail!( + "bot character already owns {} of vendor item {}; choose an isolated fixture item", + existing_item_total, + item_entry + ); + } + let occupied_slots: Vec = characters + .exec_map( + "SELECT slot FROM character_inventory WHERE guid = ? AND bag = 0", + (bot.character_guid,), + |slot: u8| slot, + ) + .map_err(|error| anyhow!("Load vendor bot occupied backpack slots: {error}"))?; + if !(INVENTORY_SLOT_ITEM_START..INVENTORY_SLOT_ITEM_START + 16) + .any(|slot| !occupied_slots.contains(&slot)) + { + bail!("No empty default backpack slot for vendor smoke"); + } + + let world_url = world_db_url()?; + let world_opts = + mysql::Opts::from_url(&world_url).map_err(|error| anyhow!("Bad world DB URL: {error}"))?; + let mut world = mysql::Conn::new(world_opts) + .map_err(|error| anyhow!("Connect to world DB failed: {error}"))?; + let spawn: Option<(u32, u32, f64, f64, f64, f32, f32, u32, u32, String)> = world + .exec_first( + "SELECT c.id, c.map, c.position_x, c.position_y, c.position_z, c.orientation, \ + c.wander_distance, c.phaseId, c.phaseGroup, c.spawnDifficulties \ + FROM creature c WHERE c.guid = ?", + (vendor_spawn_guid,), + ) + .map_err(|error| anyhow!("Load exact vendor spawn: {error}"))?; + let ( + spawn_entry, + vendor_map, + vendor_x, + vendor_y, + vendor_z, + vendor_o, + wander_distance, + phase_id, + phase_group, + spawn_difficulties, + ) = spawn.ok_or_else(|| anyhow!("No world.creature row for guid {vendor_spawn_guid}"))?; + if spawn_entry != vendor_entry { + bail!( + "vendor spawn {} has entry {}, expected {}", + vendor_spawn_guid, + spawn_entry, + vendor_entry + ); + } + if phase_id != 0 || phase_group != 0 || !spawn_difficulties.split(',').any(|id| id == "0") { + bail!( + "vendor spawn {} is not a deterministic base-phase difficulty-0 fixture", + vendor_spawn_guid + ); + } + let target_match_radius = wander_distance.max(0.0) + 2.0; + if runtime_counter.is_none() { + let overlapping_spawn: Option = world + .exec_first( + "SELECT guid FROM creature \ + WHERE id = ? AND map = ? AND guid <> ? \ + AND SQRT(POW(position_x - ?, 2) + POW(position_y - ?, 2) + POW(position_z - ?, 2)) \ + <= ? + GREATEST(wander_distance, 0) \ + ORDER BY guid LIMIT 1", + ( + vendor_entry, + vendor_map, + vendor_spawn_guid, + vendor_x, + vendor_y, + vendor_z, + target_match_radius, + ), + ) + .map_err(|error| anyhow!("Check vendor spawn ambiguity: {error}"))?; + if let Some(overlapping_spawn) = overlapping_spawn { + bail!( + "vendor SQL spawn {vendor_spawn_guid} overlaps same-entry spawn {overlapping_spawn}; supply a trusted live runtime counter or choose an isolated spawn" + ); + } + } + let vendor_row_count: u64 = world + .exec_first( + "SELECT COUNT(*) FROM npc_vendor \ + WHERE entry = ? AND item = ? AND ExtendedCost = ? AND type = 1", + (vendor_entry, item_entry, extended_cost), + ) + .map_err(|error| anyhow!("Validate exact npc_vendor row: {error}"))? + .unwrap_or(0); + if vendor_row_count != 1 { + bail!( + "expected one npc_vendor row for vendor/item/extended-cost {vendor_entry}/{item_entry}/{extended_cost}, found {vendor_row_count}" + ); + } + let vendor_map = u16::try_from(vendor_map) + .map_err(|_| anyhow!("vendor map id does not fit protocol: {vendor_map}"))?; + let guid_counter = runtime_counter.unwrap_or(0); + let packed_guid = if guid_counter == 0 { + Vec::new() + } else { + let (low, high) = create_creature_guid_raw(vendor_map, vendor_entry, guid_counter); + build_packed_guid(low, high) + }; + let vendor = ResolvedCreatureTarget { + entry: vendor_entry, + spawn_guid: vendor_spawn_guid, + guid_counter, + map_id: vendor_map, + x: vendor_x, + y: vendor_y, + z: vendor_z, + orientation: vendor_o, + packed_guid, + }; let mut transaction = characters .start_transaction(mysql::TxOpts::default()) - .map_err(|e| anyhow!("Start inventory swap fixture transaction: {e}"))?; - for (item_guid, item_entry, slot) in [ - (item_guid_a, item_entry_a, slot_a), - (item_guid_b, item_entry_b, slot_b), - ] { - transaction - .exec_drop( - "INSERT INTO item_instance \ - (guid, itemEntry, owner_guid, creatorGuid, giftCreatorGuid, count, durability, \ - enchantments, charges, flags, randomPropertiesId, randomPropertiesSeed, context) \ - VALUES (?, ?, ?, 0, 0, 1, 0, '', '', 0, 0, 0, 0)", - (item_guid, item_entry, bot.character_guid), - ) - .map_err(|e| anyhow!("Insert inventory swap fixture item: {e}"))?; - transaction - .exec_drop( - "INSERT INTO character_inventory (guid, bag, slot, item) VALUES (?, 0, ?, ?)", - (bot.character_guid, slot, item_guid), - ) - .map_err(|e| anyhow!("Insert inventory swap fixture inventory row: {e}"))?; + .map_err(|error| anyhow!("Start vendor fixture transaction: {error}"))?; + transaction + .exec_drop( + "INSERT INTO character_currency \ + (CharacterGuid, Currency, Quantity, WeeklyQuantity, TrackedQuantity, IncreasedCapQuantity, EarnedQuantity, Flags) \ + VALUES (?, ?, ?, 0, 0, 0, 0, 0) \ + ON DUPLICATE KEY UPDATE Quantity = VALUES(Quantity), WeeklyQuantity = 0, \ + TrackedQuantity = 0, IncreasedCapQuantity = 0, EarnedQuantity = 0, Flags = 0", + (bot.character_guid, currency_id, currency_quantity), + ) + .map_err(|error| anyhow!("Seed vendor currency fixture: {error}"))?; + transaction + .exec_drop( + "UPDATE characters SET map = ?, zone = 0, instance_id = 0, position_x = ?, position_y = ?, position_z = ?, orientation = ? \ + WHERE guid = ? AND online = 0", + ( + u32::from(vendor_map), + vendor_x + 2.0, + vendor_y, + vendor_z, + vendor_o, + bot.character_guid, + ), + ) + .map_err(|error| anyhow!("Relocate vendor bot near vendor: {error}"))?; + if transaction.affected_rows() != 1 { + bail!("vendor character relocation lost its offline ownership guard"); } transaction .commit() - .map_err(|e| anyhow!("Commit inventory swap fixture transaction: {e}"))?; + .map_err(|error| anyhow!("Commit vendor fixture transaction: {error}"))?; info!( - "Inventory swap fixture: character={} items={}/{} entries={}/{} slots={}/{}", - bot.character_guid, item_guid_a, item_guid_b, item_entry_a, item_entry_b, slot_a, slot_b + "Vendor fixture: character={} vendor={}/{} counter={} item={} extended_cost={} currency={} quantity/cost={}/{}", + bot.character_guid, + vendor_entry, + vendor_spawn_guid, + guid_counter, + item_entry, + extended_cost, + currency_id, + currency_quantity, + currency_cost ); - Ok(InventorySwapSmokeFixture { - options: InventorySwapSmokeOptions { - phase: InventorySwapSmokePhase::Forward, - item_guid_a, - item_guid_b, - item_entry_a, - item_entry_b, - slot_a, - slot_b, + Ok(VendorSmokeFixture { + options: VendorSmokeOptions { + phase: VendorSmokePhase::Purchase, + vendor, + target_match_radius, + item_entry, + extended_cost, + currency_id, + currency_before: currency_quantity, + currency_cost, + expected_item_total: 1, timeout_secs, }, + original_position, + original_currency, }) } -fn verify_inventory_swap_fixture_locations( +fn load_vendor_smoke_db_state( bot: &config::BotConfig, - options: &InventorySwapSmokeOptions, - expected_slot_a: u8, - expected_slot_b: u8, -) -> Result { + currency_id: u32, + item_entry: u32, +) -> Result<(u32, u64)> { use mysql::prelude::Queryable; let characters_url = characters_db_url()?; let opts = mysql::Opts::from_url(&characters_url) - .map_err(|e| anyhow!("Bad characters DB URL: {e}"))?; - let mut conn = - mysql::Conn::new(opts).map_err(|e| anyhow!("Connect to characters DB failed: {e}"))?; - - let load = |conn: &mut mysql::Conn, item_guid: u64| -> Result> { - conn.exec_first( - "SELECT ci.bag, ci.slot, ii.itemEntry, ii.owner_guid \ - FROM character_inventory ci JOIN item_instance ii ON ii.guid = ci.item \ - WHERE ci.guid = ? AND ci.item = ? AND ii.count = 1", - (bot.character_guid, item_guid), + .map_err(|error| anyhow!("Bad characters DB URL: {error}"))?; + let mut conn = mysql::Conn::new(opts) + .map_err(|error| anyhow!("Connect to characters DB failed: {error}"))?; + let currency = conn + .exec_first( + "SELECT Quantity FROM character_currency WHERE CharacterGuid = ? AND Currency = ?", + (bot.character_guid, currency_id), ) - .map_err(|e| anyhow!("Load inventory swap fixture location: {e}")) - }; - let row_a = load(&mut conn, options.item_guid_a)?; - let row_b = load(&mut conn, options.item_guid_b)?; - Ok(matches!( - row_a, - Some((0, slot, entry, owner)) - if slot == expected_slot_a - && entry == options.item_entry_a - && owner == bot.character_guid - ) && matches!( - row_b, - Some((0, slot, entry, owner)) - if slot == expected_slot_b - && entry == options.item_entry_b - && owner == bot.character_guid - )) + .map_err(|error| anyhow!("Load vendor currency DB state: {error}"))? + .unwrap_or(0); + let item_total = conn + .exec_first( + "SELECT COALESCE(SUM(ii.count), 0) FROM character_inventory ci \ + JOIN item_instance ii ON ii.guid = ci.item \ + WHERE ci.guid = ? AND ii.itemEntry = ?", + (bot.character_guid, item_entry), + ) + .map_err(|error| anyhow!("Load vendor item DB state: {error}"))? + .unwrap_or(0); + Ok((currency, item_total)) } -fn cleanup_inventory_swap_smoke_fixture( +fn cleanup_vendor_smoke_fixture( bot: &config::BotConfig, - fixture: &InventorySwapSmokeFixture, + fixture: &VendorSmokeFixture, ) -> Result<()> { use mysql::prelude::Queryable; let characters_url = characters_db_url()?; let opts = mysql::Opts::from_url(&characters_url) - .map_err(|e| anyhow!("Bad characters DB URL: {e}"))?; - let mut conn = - mysql::Conn::new(opts).map_err(|e| anyhow!("Connect to characters DB failed: {e}"))?; - - let offline_deadline = std::time::Instant::now() + Duration::from_secs(10); + .map_err(|error| anyhow!("Bad characters DB URL: {error}"))?; + let mut conn = mysql::Conn::new(opts) + .map_err(|error| anyhow!("Connect to characters DB failed: {error}"))?; + // Stock C++ may defer its disconnected-session save/offline transition + // substantially longer than Rust. A failed phase already attempts a + // graceful logout, but retain a bounded disconnect fallback as well. + let deadline = std::time::Instant::now() + Duration::from_secs(90); loop { let online: Option = conn .exec_first( "SELECT online FROM characters WHERE guid = ?", (bot.character_guid,), ) - .map_err(|e| anyhow!("Check inventory swap bot offline state before cleanup: {e}"))?; + .map_err(|error| anyhow!("Check vendor bot offline before cleanup: {error}"))?; match online { Some(0) => break, - Some(_) if std::time::Instant::now() < offline_deadline => { + Some(_) if std::time::Instant::now() < deadline => { std::thread::sleep(Duration::from_millis(100)); } - Some(_) => { - bail!( - "character {} remained online; refusing inventory swap cleanup before disconnect save", - bot.character_guid - ); - } + Some(_) => bail!( + "character {} remained online; refusing vendor cleanup", + bot.character_guid + ), None => bail!( - "No characters row for guid {} during inventory swap cleanup", + "No characters row for guid {} during vendor cleanup", bot.character_guid ), } } + let purchased_guids: Vec = conn + .exec_map( + "SELECT ii.guid FROM character_inventory ci JOIN item_instance ii ON ii.guid = ci.item \ + WHERE ci.guid = ? AND ii.itemEntry = ? ORDER BY ii.guid", + (bot.character_guid, fixture.options.item_entry), + |guid: u64| guid, + ) + .map_err(|error| anyhow!("Load purchased vendor fixture item GUIDs: {error}"))?; + if purchased_guids.len() > fixture.options.expected_item_total as usize { + bail!( + "vendor cleanup found {} fixture item stacks, expected at most {}", + purchased_guids.len(), + fixture.options.expected_item_total + ); + } + let mut transaction = conn .start_transaction(mysql::TxOpts::default()) - .map_err(|e| anyhow!("Start inventory swap cleanup transaction: {e}"))?; - for item_guid in [fixture.options.item_guid_a, fixture.options.item_guid_b] { + .map_err(|error| anyhow!("Start vendor cleanup transaction: {error}"))?; + for item_guid in purchased_guids { + transaction + .exec_drop( + "DELETE FROM item_refund_instance WHERE item_guid = ?", + (item_guid,), + ) + .map_err(|error| anyhow!("Delete vendor refund metadata: {error}"))?; transaction .exec_drop( "DELETE FROM character_inventory WHERE guid = ? AND item = ?", (bot.character_guid, item_guid), ) - .map_err(|e| anyhow!("Delete inventory swap fixture inventory row: {e}"))?; + .map_err(|error| anyhow!("Delete vendor inventory row: {error}"))?; transaction .exec_drop( "DELETE FROM item_instance WHERE guid = ? AND owner_guid = ?", (item_guid, bot.character_guid), ) - .map_err(|e| anyhow!("Delete inventory swap fixture item: {e}"))?; + .map_err(|error| anyhow!("Delete vendor item instance: {error}"))?; + } + transaction + .exec_drop( + "DELETE FROM character_currency WHERE CharacterGuid = ? AND Currency = ?", + (bot.character_guid, fixture.options.currency_id), + ) + .map_err(|error| anyhow!("Clear vendor fixture currency row: {error}"))?; + if let Some(currency) = fixture.original_currency { + transaction + .exec_drop( + "INSERT INTO character_currency \ + (CharacterGuid, Currency, Quantity, WeeklyQuantity, TrackedQuantity, IncreasedCapQuantity, EarnedQuantity, Flags) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ( + bot.character_guid, + fixture.options.currency_id, + currency.quantity, + currency.weekly_quantity, + currency.tracked_quantity, + currency.increased_cap_quantity, + currency.earned_quantity, + currency.flags, + ), + ) + .map_err(|error| anyhow!("Restore vendor currency snapshot: {error}"))?; + } + transaction + .exec_drop( + "UPDATE characters SET map = ?, zone = ?, instance_id = ?, position_x = ?, position_y = ?, position_z = ?, orientation = ? \ + WHERE guid = ? AND online = 0", + ( + fixture.original_position.map_id, + fixture.original_position.zone_id, + fixture.original_position.instance_id, + fixture.original_position.x, + fixture.original_position.y, + fixture.original_position.z, + fixture.original_position.orientation, + bot.character_guid, + ), + ) + .map_err(|error| anyhow!("Restore vendor bot position: {error}"))?; + if transaction.affected_rows() != 1 { + bail!("vendor cleanup lost its offline character guard"); } transaction .commit() - .map_err(|e| anyhow!("Commit inventory swap cleanup transaction: {e}"))?; + .map_err(|error| anyhow!("Commit vendor fixture cleanup: {error}"))?; + + let (restored_currency, restored_item_total) = + load_vendor_smoke_db_state(bot, fixture.options.currency_id, fixture.options.item_entry)?; + let expected_currency = fixture + .original_currency + .map(|currency| currency.quantity) + .unwrap_or(0); + if restored_currency != expected_currency || restored_item_total != 0 { + bail!( + "vendor cleanup verification found currency/item {restored_currency}/{restored_item_total}, expected {expected_currency}/0" + ); + } Ok(()) } @@ -10034,6 +11592,39 @@ fn create_creature_guid_raw(map_id: u16, entry: u32, counter: u64) -> (u64, u64) (low, high) } +fn resolve_vendor_runtime_target( + target: &ResolvedCreatureTarget, + discovered: Option, +) -> Result { + let candidate = discovered.ok_or_else(|| { + if target.guid_counter == 0 { + anyhow!( + "vendor entry {} spawn {} was not discovered near its SQL position in login SMSG_UPDATE_OBJECT packets", + target.entry, + target.spawn_guid + ) + } else { + anyhow!( + "vendor runtime counter {} was not discovered near SQL spawn {}; the override cannot be linked safely", + target.guid_counter & OBJECT_GUID_COUNTER_MASK, + target.spawn_guid + ) + } + })?; + if target.guid_counter != 0 { + let expected = create_creature_guid_raw(target.map_id, target.entry, target.guid_counter); + if (candidate.low, candidate.high) != expected { + bail!( + "vendor runtime counter override {} did not match discovered counter {} for SQL spawn {}", + expected.0, + candidate.low & OBJECT_GUID_COUNTER_MASK, + target.spawn_guid + ); + } + } + Ok(candidate) +} + fn resolve_rested_xp_runtime_target( target: &ResolvedCreatureTarget, discovered: Option, @@ -10381,6 +11972,166 @@ mod tests { assert!(result.success(false, false, false)); } + #[test] + fn vendor_result_requires_verified_relog_for_success() { + let mut result = BotRunResult { + world_auth: true, + enum_characters: true, + player_login_verified: true, + vendor_smoke: true, + vendor_smoke_passed: Some(true), + ..BotRunResult::default() + }; + + assert!(!result.success(false, false, false)); + result.vendor_relogin_verified = true; + assert!(result.success(false, false, false)); + } + + fn vendor_inventory_fixture(has_bonus: u8, modifier_count: u8) -> (Vec, Vec) { + let vendor_guid = build_packed_guid(0x1234, 0xF130_0000_485D_0001); + let mut payload = vendor_guid.clone(); + payload.push(0); // VendorInventoryReason::None. + payload.extend_from_slice(&1u32.to_le_bytes()); + payload.extend_from_slice(&0u64.to_le_bytes()); // Price. + payload.extend_from_slice(&37i32.to_le_bytes()); // MUID. + payload.extend_from_slice(&1i32.to_le_bytes()); // Item type. + payload.extend_from_slice(&0i32.to_le_bytes()); // Durability. + payload.extend_from_slice(&1i32.to_le_bytes()); // Stack count. + payload.extend_from_slice(&(-1i32).to_le_bytes()); // Unlimited quantity. + payload.extend_from_slice(&1642i32.to_le_bytes()); + payload.extend_from_slice(&0i32.to_le_bytes()); // Player condition failure. + payload.push(0); // Vendor flags. + payload.extend_from_slice(&30183i32.to_le_bytes()); + payload.extend_from_slice(&0i32.to_le_bytes()); // Random seed. + payload.extend_from_slice(&0i32.to_le_bytes()); // Random property. + payload.push(has_bonus); + payload.push(modifier_count); + (payload, vendor_guid) + } + + #[test] + fn vendor_inventory_parser_reads_cpp_plain_item_row_exactly() { + let (payload, vendor_guid) = vendor_inventory_fixture(0, 0); + let items = parse_vendor_inventory(&payload, &vendor_guid).unwrap(); + + assert_eq!(items.len(), 1); + let item = &items[0]; + assert_eq!(item.muid, 37); + assert_eq!(item.item_id, 30183); + assert_eq!(item.item_type, 1); + assert_eq!(item.price, 0); + assert_eq!(item.stack_count, 1); + assert_eq!(item.extended_cost, 1642); + } + + #[test] + fn vendor_inventory_parser_fails_closed_on_unimplemented_item_instance_shapes() { + let (bonus_payload, vendor_guid) = vendor_inventory_fixture(1, 0); + assert!(parse_vendor_inventory(&bonus_payload, &vendor_guid).is_err()); + + let (modifier_payload, vendor_guid) = vendor_inventory_fixture(0, 1); + assert!(parse_vendor_inventory(&modifier_payload, &vendor_guid).is_err()); + + let (mut trailing_payload, vendor_guid) = vendor_inventory_fixture(0, 0); + trailing_payload.push(0); + assert!(parse_vendor_inventory(&trailing_payload, &vendor_guid).is_err()); + } + + #[test] + fn vendor_buy_payload_uses_cpp_field_order_and_wire_item_instance() { + let vendor_guid = build_packed_guid(0x1234, 0xF130_0000_485D_0001); + let payload = build_vendor_buy_item_payload(&vendor_guid, 15, 37, 30183); + assert!(payload.starts_with(&vendor_guid)); + + let mut cursor = vendor_guid.len(); + let (player_guid_len, player_low, player_high) = + parse_packed_guid(&payload[cursor..]).unwrap(); + let expected_player = create_player_guid_raw(15, realm_id()); + assert_eq!((player_low, player_high), expected_player); + cursor += player_guid_len; + + assert_eq!(take_vendor_i32(&payload, &mut cursor).unwrap(), 1); + assert_eq!(take_vendor_i32(&payload, &mut cursor).unwrap(), 37); + assert_eq!(take_vendor_i32(&payload, &mut cursor).unwrap(), 255); + assert_eq!(take_vendor_i32(&payload, &mut cursor).unwrap(), 1); + assert_eq!(take_vendor_i32(&payload, &mut cursor).unwrap(), 30183); + assert_eq!(take_vendor_i32(&payload, &mut cursor).unwrap(), 0); + assert_eq!(take_vendor_i32(&payload, &mut cursor).unwrap(), 0); + assert_eq!(&payload[cursor..], &[0, 0]); + } + + #[test] + fn vendor_buy_succeeded_parser_requires_exact_cpp_fields_and_no_tail() { + let vendor_guid = build_packed_guid(0x1234, 0xF130_0000_485D_0001); + let mut payload = vendor_guid.clone(); + payload.extend_from_slice(&59u32.to_le_bytes()); + payload.extend_from_slice(&(-1i32).to_le_bytes()); + payload.extend_from_slice(&1u32.to_le_bytes()); + assert!(parse_vendor_buy_succeeded(&payload, &vendor_guid, 59, 1, -1).is_ok()); + + let mut wrong_quantity = payload.clone(); + let quantity_offset = wrong_quantity.len() - 4; + wrong_quantity[quantity_offset..].copy_from_slice(&2u32.to_le_bytes()); + assert!(parse_vendor_buy_succeeded(&wrong_quantity, &vendor_guid, 59, 1, -1).is_err()); + + let mut trailing = payload; + trailing.push(0); + assert!(parse_vendor_buy_succeeded(&trailing, &vendor_guid, 59, 1, -1).is_err()); + } + + #[test] + fn vendor_item_push_validator_requires_exact_cpp_purchase_shape() { + let realm = 1; + let character_guid = 15; + let (player_low, player_high) = create_player_guid_raw(character_guid, realm); + let item_high = (3u64 << 58) | (u64::from(realm) << 42); + let mut payload = build_packed_guid(player_low, player_high); + payload.push(INVENTORY_SLOT_BAG_0); + payload.extend_from_slice(&i32::from(INVENTORY_SLOT_ITEM_START).to_le_bytes()); + payload.extend_from_slice(&0i32.to_le_bytes()); // QuestLogItemID. + payload.extend_from_slice(&1i32.to_le_bytes()); // Quantity. + payload.extend_from_slice(&1i32.to_le_bytes()); // QuantityInInventory. + payload.extend_from_slice(&0i32.to_le_bytes()); // DungeonEncounterID. + payload.extend_from_slice(&[0; 16]); // Battle-pet fields. + payload.extend(build_packed_guid(500, item_high)); + payload.push(0x88); // Pushed + normal display, not created. + payload.extend_from_slice(&30183i32.to_le_bytes()); + payload.extend_from_slice(&0i32.to_le_bytes()); // Random seed. + payload.extend_from_slice(&0i32.to_le_bytes()); // Random property. + payload.push(0); // No ItemBonus. + payload.push(0); // No modifiers. + + assert!(loot_race::validate_vendor_item_push_result_like_cpp( + &payload, + character_guid, + 30183, + 1, + realm, + ) + .is_ok()); + payload.push(0); + assert!(loot_race::validate_vendor_item_push_result_like_cpp( + &payload, + character_guid, + 30183, + 1, + realm, + ) + .is_err()); + } + + #[test] + fn set_currency_parser_rejects_negative_wire_values() { + let mut payload = Vec::new(); + payload.extend_from_slice(&42i32.to_le_bytes()); + payload.extend_from_slice(&15i32.to_le_bytes()); + assert_eq!(parse_set_currency_identity(&payload).unwrap(), (42, 15)); + + payload[4..8].copy_from_slice(&(-1i32).to_le_bytes()); + assert!(parse_set_currency_identity(&payload).is_err()); + } + #[test] fn login_verify_budget_is_time_based_not_packet_count() { let budget = LoginVerifyBudget::new(Duration::from_secs(1)); @@ -10473,7 +12224,7 @@ mod tests { let mut payload = vec![1]; // CreateObject1 payload.extend(build_packed_guid(low, high)); payload.push(5); // TypeId::Unit - payload.extend_from_slice(&[0; 3]); // CreateObjectBits + payload.extend_from_slice(&[0x10, 0, 0]); // MovementUpdate bit 3, MSB-first. payload.extend(build_packed_guid(low, high)); // movement MoverGUID payload.extend_from_slice(&[0; 12]); // movement flags payload.extend_from_slice(&123u32.to_le_bytes()); // MoveTime