diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 01a0e1e9..62575225 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -11,6 +11,7 @@ on: env: CARGO_TERM_COLOR: always + PROTOC_VERSION: "28.3" jobs: fmt: @@ -42,7 +43,10 @@ jobs: uses: Swatinem/rust-cache@v2 - name: Install protoc - run: sudo apt-get update && sudo apt-get install -y protobuf-compiler + run: | + curl -sSL -o /tmp/protoc.zip "https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/protoc-${PROTOC_VERSION}-linux-x86_64.zip" + python3 -m zipfile -e /tmp/protoc.zip "$HOME/.local" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" - name: Cargo check run: cargo check -p wow-data -p wow-database -p wow-network -p wow-world -p world-server @@ -61,7 +65,10 @@ jobs: uses: Swatinem/rust-cache@v2 - name: Install protoc - run: sudo apt-get update && sudo apt-get install -y protobuf-compiler + run: | + curl -sSL -o /tmp/protoc.zip "https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/protoc-${PROTOC_VERSION}-linux-x86_64.zip" + python3 -m zipfile -e /tmp/protoc.zip "$HOME/.local" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" - name: Run packet/data/world tests run: | diff --git a/AGENTS.md b/AGENTS.md index 5982d795..6a32e3cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -156,6 +156,96 @@ Current useful baselines from recent handoff: If a test fails, do not assume production is wrong or the test is wrong. Contrast with C++ and document which one it is. +## QA Bot / Client Automation + +The live client QA bot is an integrated project QA tool, not throwaway scratch code or a separate +side project. + +- Integrated bot path: `tools/wow-test-bot` +- It is intentionally excluded from the root Cargo workspace so its live-QA dependencies and local + runtime assumptions do not affect normal `cargo check/test` or CI. +- Temporary experiments may use a `/tmp/...` copy when sandbox permissions require it, but useful + bot improvements must be ported back to `tools/wow-test-bot` and committed with the RustyCore PR + before the work is considered preserved. +- Keep server fixes and bot-tooling changes logically separated in the commit message/body when + practical. Mention the bot scenario and report path in issue/PR QA notes when a PR depends on a + new bot capability. + +Baseline login smoke: + +```bash +cd /home/server/rustycore/tools/wow-test-bot +WOW_BOT_PASSWORD='local-password' ./run_rustycore_login_smoke.sh +``` + +Useful overrides: + +```bash +WOW_BOT_PASSWORD='local-password' WOW_BOT_ACCOUNT=TESTBOT5@bot.local ./run_rustycore_login_smoke.sh +WOW_BOT_PASSWORD='local-password' WOW_BOT_REPORT=/tmp/rustycore-bot-report.json WOW_BOT_LOG=/tmp/rustycore-bot.log ./run_rustycore_login_smoke.sh +WOW_BOT_PASSWORD='local-password' BNET_HOST=127.0.0.1 BNET_PORT=8081 WORLD_HOST=127.0.0.1 WORLD_PORT=8085 REALM_ID=1 ./run_rustycore_login_smoke.sh +``` + +Quest/gossip smoke for QA of one questgiver: + +```bash +WOW_BOT_QUEST_SMOKE=1 \ +WOW_BOT_QUEST_CREATURE_ENTRY= \ +WOW_BOT_QUEST_EXPECT_ID= \ +WOW_BOT_PASSWORD='local-password' \ +./run_rustycore_login_smoke.sh +``` + +Optional quest overrides include `WOW_BOT_QUEST_CREATURE_GUID` for an exact +`world.creature.guid`, `WOW_BOT_QUEST_MAP_ID`, `WOW_BOT_QUEST_FORBID_ID`, +`WOW_BOT_QUEST_FORBID_TITLE_CONTAINS`, `WOW_BOT_QUEST_QUERY_DETAILS=0`, +`WOW_BOT_QUEST_RESET=1`, `WOW_BOT_QUEST_RELOCATE=1`, +`WOW_BOT_QUEST_RUNTIME_COUNTER=`, `WOW_BOT_QUEST_SET_RACE=`, +`WOW_BOT_QUEST_SET_CLASS=`, `WOW_BOT_QUEST_SET_LEVEL=<1-80>`, and +`WOW_BOT_QUEST_ACCEPT=1`. + +For deterministic quest accept QA, prefer a fully specified flow that prepares the selected test +character before login, for example: + +```bash +WOW_BOT_QUEST_SMOKE=1 \ +WOW_BOT_QUEST_CREATURE_ENTRY=15278 \ +WOW_BOT_QUEST_RUNTIME_COUNTER=90 \ +WOW_BOT_QUEST_MAP_ID=530 \ +WOW_BOT_QUEST_EXPECT_ID=9393 \ +WOW_BOT_QUEST_RESET=1 \ +WOW_BOT_QUEST_RELOCATE=1 \ +WOW_BOT_QUEST_SET_RACE=10 \ +WOW_BOT_QUEST_SET_CLASS=3 \ +WOW_BOT_QUEST_SET_LEVEL=3 \ +WOW_BOT_QUEST_ACCEPT=1 \ +WOW_BOT_PASSWORD='local-password' \ +./run_rustycore_login_smoke.sh +``` + +The bot authenticates against the live `bnet-server`, writes/uses auth DB session data, connects to +`world-server`, enumerates characters, and enters world. It requires the local runtime and MariaDB +databases (`auth`, `characters`, `world`, `hotfixes`) to be available. Do not print, stage, or +commit bot credentials, local configs, DB URLs with secrets, generated logs containing secrets, or +runtime certificates. `tools/wow-test-bot/config.example.json` is versioned with blank passwords; +use `WOW_BOT_PASSWORD`, `WOW_BOT_PASSWORD_`, or an ignored local `config.json`. +`tools/wow-test-bot/.env.local` is also ignored and may be used for local-only passwords or DB URL +overrides. If DB URL env vars are omitted, the bot reads database connection info from +`WOW_BOT_DB_CONF` (default `/home/server/trinity-legacy-install/etc/worldserver.conf`). + +When extending the bot for an issue: + +1. Anchor packet layouts/opcodes to C++ source or to a real capture before sending new packets. +2. Add a focused CLI mode or wrapper script for the QA scenario, not a one-off manual edit. +3. Report pass/fail as structured JSON fields so PR QA can cite the exact checks. +4. Keep the first version narrow: login, one interaction, one expected server response, then expand. +5. Commit useful bot work under `tools/wow-test-bot`; do not leave the only copy in `/tmp`. + +Runtime bot QA complements, but does not replace, `cargo` validation and CI. A PR is not +`manual-test-ready` merely because bot code exists; install/restart the target server build, run the +bot scenario, and record the result in the PR/issue. Until CI has a full live server + DB fixture, +bot runs are local QA evidence rather than required GitHub status checks. + ## Architecture: Current Runtime Reality The runtime currently has three coexisting world models. This is important; old notes that describe a single pending `MapManager` integration are stale. diff --git a/Cargo.toml b/Cargo.toml index 76bd55a9..43a4ecbe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,9 @@ members = [ # Tooling "crates/capture-diff", ] +exclude = [ + "tools/wow-test-bot", +] [workspace.package] version = "0.1.0" diff --git a/crates/world-server/src/creature_loaded_grid.rs b/crates/world-server/src/creature_loaded_grid.rs index a1868c03..123036cd 100644 --- a/crates/world-server/src/creature_loaded_grid.rs +++ b/crates/world-server/src/creature_loaded_grid.rs @@ -1991,7 +1991,7 @@ mod tests { [selection(entry)], ); - let map_object_guid = map_creature_guid(entry, 571, 99_001); + let map_object_guid = map_creature_guid(entry, 571, 55); let resolved = resolver .resolve_loaded_grid_creature_like_cpp(55, map_object_guid) .expect("resolver should build lifecycle record"); @@ -2117,7 +2117,7 @@ mod tests { ); let resolved = resolver - .resolve_loaded_grid_creature_like_cpp(55, map_creature_guid(entry, 571, 99_001)) + .resolve_loaded_grid_creature_like_cpp(55, map_creature_guid(entry, 571, 55)) .expect("resolver should build lifecycle record"); assert_eq!(resolved.creature.sparring_health_pct(), 35.5); @@ -2162,7 +2162,10 @@ mod tests { ); let resolved = resolver - .resolve_loaded_grid_creature_like_cpp(spawn_id, map_creature_guid(entry, 571, 99_002)) + .resolve_loaded_grid_creature_like_cpp( + spawn_id, + map_creature_guid(entry, 571, spawn_id as i64), + ) .expect("resolver should build lifecycle record"); assert_eq!( @@ -2273,7 +2276,8 @@ mod tests { } #[test] - fn loaded_grid_creature_lifecycle_resolver_uses_caller_map_guid_not_spawn_id_low() { + fn loaded_grid_creature_lifecycle_resolver_accepts_map_owned_low_guid_distinct_from_spawn_id_like_cpp() + { let entry = 12_349; let spawn_id = 61; let caller_low_guid = 345_678; @@ -2284,19 +2288,17 @@ mod tests { [selection(entry)], ); + assert_ne!(spawn_id as i64, caller_low_guid); let resolved = resolver .resolve_loaded_grid_creature_like_cpp(spawn_id, map_object_guid) - .expect("caller-owned map guid should be preserved"); - - assert_ne!(spawn_id as i64, caller_low_guid); - assert_eq!(resolved.lifecycle_record.create.guid, map_object_guid); - assert_eq!(resolved.creature.guid(), map_object_guid); - let recorded = resolved + .expect("C++ Creature::LoadFromDB uses map-owned lowguid, not spawn id, for live GUID"); + let creature = resolved .map_object_record .as_ref() - .and_then(MapObjectRecord::creature) - .expect("map insertion record should contain the created creature"); - assert_eq!(recorded.guid(), map_object_guid); + .and_then(|record| record.creature()) + .expect("resolver should build a creature record"); + assert_eq!(creature.guid(), map_object_guid); + assert_eq!(creature.lifecycle_metadata().spawn_id, spawn_id); } #[test] @@ -2318,7 +2320,10 @@ mod tests { [selection(entry)], ); let resolved = resolver - .resolve_loaded_grid_creature_like_cpp(spawn_id, map_creature_guid(entry, 571, 99_062)) + .resolve_loaded_grid_creature_like_cpp( + spawn_id, + map_creature_guid(entry, 571, spawn_id as i64), + ) .expect("formation metadata should not block loaded-grid resolver"); assert_eq!( @@ -2345,7 +2350,10 @@ mod tests { [selection(entry)], ); let resolved = resolver - .resolve_loaded_grid_creature_like_cpp(spawn_id, map_creature_guid(entry, 571, 99_063)) + .resolve_loaded_grid_creature_like_cpp( + spawn_id, + map_creature_guid(entry, 571, spawn_id as i64), + ) .expect("absence of formation metadata is the previous behavior"); assert!(resolved.creature.formation_info_like_cpp().is_none()); @@ -2368,7 +2376,7 @@ mod tests { [selection(entry)], ); - let map_object_guid = map_creature_guid(entry, 571, 99_002); + let map_object_guid = map_creature_guid(entry, 571, 56); let resolved = resolver .resolve_loaded_grid_creature_like_cpp(56, map_object_guid) .expect("resolver should build creature without insertion request"); @@ -2482,7 +2490,7 @@ mod tests { fn loaded_grid_creature_lifecycle_resolver_accepts_vehicle_template_with_vehicle_guid_like_cpp() { let entry = 12_351; - let map_object_guid = map_vehicle_guid(entry, 571, 99_006); + let map_object_guid = map_vehicle_guid(entry, 571, 63); let resolver = CreatureLoadedGridLifecycleResolverLikeCpp::new( [vehicle_template(entry, 77)], [spawn(63, entry, true)], @@ -2562,7 +2570,7 @@ mod tests { [spawn(60, entry, true)], [selection(entry)], ); - let map_object_guid = map_creature_guid(entry, 571, 99_007); + let map_object_guid = map_creature_guid(entry, 571, 60); let first = resolver .resolve_loaded_grid_creature_like_cpp(60, map_object_guid) .unwrap(); diff --git a/crates/world-server/src/main.rs b/crates/world-server/src/main.rs index abc060bb..89c08cdd 100644 --- a/crates/world-server/src/main.rs +++ b/crates/world-server/src/main.rs @@ -20625,9 +20625,14 @@ mmap.enablePathFinding = 0 #[test] fn loaded_grid_creature_respawn_record_variable_level_returns_creature_record_like_cpp() { - let mut metadata = test_spawn_metadata_with_flags([(67, 571, SpawnGroupFlags::NONE)]); - let spawn_id = 1; + let spawn_id = 54_984; let entry = 42; + let mut metadata = test_spawn_metadata_with_explicit_spawn_ids([( + 67, + 571, + SpawnGroupFlags::NONE, + spawn_id, + )]); metadata = metadata.with_creature_runtime_rows_like_cpp(BTreeMap::from([( spawn_id, super::spawn_store_loader::CreatureSpawnRuntimeRowLikeCpp { @@ -20691,6 +20696,7 @@ mmap.enablePathFinding = 0 assert_eq!(u32::from(creature.guid().map_id()), 571); assert_eq!(creature.guid().entry(), entry); assert_eq!(creature.guid().counter(), 1); + assert_ne!(creature.guid().counter(), spawn_id as i64); assert_eq!(creature.ai_max_health(), u64::from(level) * 20); assert_eq!(creature.ai_current_health(), creature.ai_max_health()); } @@ -21010,9 +21016,14 @@ mmap.enablePathFinding = 0 #[test] fn loaded_grid_creature_spawn_group_spawn_record_does_not_require_respawn_timer_like_cpp() { - let mut metadata = test_spawn_metadata_with_flags([(68, 571, SpawnGroupFlags::NONE)]); - let spawn_id = 1; + let spawn_id = 54_985; let entry = 42; + let mut metadata = test_spawn_metadata_with_explicit_spawn_ids([( + 68, + 571, + SpawnGroupFlags::NONE, + spawn_id, + )]); metadata = metadata.with_creature_runtime_rows_like_cpp(BTreeMap::from([( spawn_id, super::spawn_store_loader::CreatureSpawnRuntimeRowLikeCpp { @@ -21065,14 +21076,21 @@ mmap.enablePathFinding = 0 assert_eq!(creature.respawn_time(), 0); assert_eq!(creature.lifecycle_metadata().spawn_id, spawn_id); assert_eq!(creature.guid().entry(), entry); + assert_eq!(creature.guid().counter(), 1); + assert_ne!(creature.guid().counter(), spawn_id as i64); } #[test] fn spawn_group_condition_update_spawn_loads_loaded_grid_creature_without_respawn_timer_like_cpp() { - let mut metadata = test_spawn_metadata_with_flags([(69, 571, SpawnGroupFlags::NONE)]); - let spawn_id = 1; + let spawn_id = 54_986; let entry = 42; + let mut metadata = test_spawn_metadata_with_explicit_spawn_ids([( + 69, + 571, + SpawnGroupFlags::NONE, + spawn_id, + )]); metadata = metadata.with_creature_runtime_rows_like_cpp(BTreeMap::from([( spawn_id, super::spawn_store_loader::CreatureSpawnRuntimeRowLikeCpp { @@ -21144,13 +21162,20 @@ mmap.enablePathFinding = 0 .expect("loaded-grid Creature should be indexed by spawn id"); assert_eq!(creature.respawn_time(), 0); assert_eq!(creature.lifecycle_metadata().spawn_id, spawn_id); + assert_eq!(creature.guid().counter(), 1); + assert_ne!(creature.guid().counter(), spawn_id as i64); } #[test] fn spawn_group_condition_update_tick_mirrors_loaded_grid_creature_to_legacy_like_cpp() { - let mut metadata = test_spawn_metadata_with_flags([(69, 571, SpawnGroupFlags::NONE)]); - let spawn_id = 1; + let spawn_id = 54_987; let entry = 42; + let mut metadata = test_spawn_metadata_with_explicit_spawn_ids([( + 69, + 571, + SpawnGroupFlags::NONE, + spawn_id, + )]); metadata = metadata.with_creature_runtime_rows_like_cpp(BTreeMap::from([( spawn_id, super::spawn_store_loader::CreatureSpawnRuntimeRowLikeCpp { @@ -21216,6 +21241,8 @@ mmap.enablePathFinding = 0 .map() .get_creature_by_spawn_id_like_cpp(spawn_id) .expect("canonical loaded-grid creature"); + assert_eq!(creature.guid().counter(), 1); + assert_ne!(creature.guid().counter(), spawn_id as i64); assert!( legacy .read() @@ -21229,9 +21256,14 @@ mmap.enablePathFinding = 0 #[test] fn loaded_grid_creature_respawn_record_vehicle_template_uses_creature_low_vehicle_high_like_cpp() { - let mut metadata = test_spawn_metadata_with_flags([(67, 571, SpawnGroupFlags::NONE)]); - let spawn_id = 1; + let spawn_id = 54_988; let entry = 42; + let mut metadata = test_spawn_metadata_with_explicit_spawn_ids([( + 67, + 571, + SpawnGroupFlags::NONE, + spawn_id, + )]); metadata = metadata.with_creature_runtime_rows_like_cpp(BTreeMap::from([( spawn_id, super::spawn_store_loader::CreatureSpawnRuntimeRowLikeCpp { @@ -21306,6 +21338,7 @@ mmap.enablePathFinding = 0 wow_core::guid::HighGuid::Vehicle ); assert_eq!(creature.guid().counter(), 1); + assert_ne!(creature.guid().counter(), spawn_id as i64); assert_eq!(creature.guid().entry(), entry); assert_eq!(creature.lifecycle_metadata().spawn_id, spawn_id); assert_eq!(creature.lifecycle_metadata().vehicle_id, Some(101)); @@ -21521,6 +21554,13 @@ mmap.enablePathFinding = 0 display_id_other_gender: 0, is_trigger: false, }, + wow_data::CreatureModelInfoLikeCpp { + display_id: 999, + bounding_radius: 0.0, + combat_reach: 1.5, + display_id_other_gender: 0, + is_trigger: false, + }, ])), creature_equipment_store: Arc::new(wow_data::CreatureEquipmentStoreLikeCpp::default()), creature_addon_store: Arc::new(wow_data::CreatureAddonStoreLikeCpp::default()), @@ -21627,6 +21667,34 @@ mmap.enablePathFinding = 0 super::spawn_store_loader::CanonicalSpawnMetadataLikeCpp::new(store, templates) } + fn test_spawn_metadata_with_explicit_spawn_ids( + groups: [(u32, u32, SpawnGroupFlags, u64); N], + ) -> super::spawn_store_loader::CanonicalSpawnMetadataLikeCpp { + let mut store = SpawnStore::new(); + let mut templates = BTreeMap::new(); + let mut rows = Vec::new(); + for (group_id, map_id, flags, spawn_id) in groups { + templates.insert( + group_id, + SpawnGroupTemplateData { + group_id, + name: format!("test group {group_id}"), + map_id: wow_map::spawn::SPAWNGROUP_MAP_UNSET, + flags, + }, + ); + let spawn = test_spawn(spawn_id, map_id); + store.add_object_spawn(&spawn, |_| false); + rows.push(SpawnGroupMemberRow { + group_id, + spawn_type: SpawnObjectType::Creature as u8, + spawn_id, + }); + } + store.apply_spawn_groups_like_cpp(&mut templates, rows); + super::spawn_store_loader::CanonicalSpawnMetadataLikeCpp::new(store, templates) + } + fn test_spawn(spawn_id: u64, map_id: u32) -> SpawnData { SpawnData { object_type: SpawnObjectType::Creature, diff --git a/crates/wow-core/src/guid.rs b/crates/wow-core/src/guid.rs index 0f18f724..5d3523f0 100644 --- a/crates/wow-core/src/guid.rs +++ b/crates/wow-core/src/guid.rs @@ -449,10 +449,10 @@ impl ObjectGuid { ) } - /// Matches TrinityCore `ObjectGuid::Create(mapId, entry, counter)`. + /// Matches the current Rust world-object GUID contract. /// - /// C++ routes this through `CreateWorldObject(type, 0, 0, mapId, 0, entry, counter)`, - /// so creature map GUIDs do not carry realm or server ids. + /// The call shape keeps raw realm/server fields at zero. Active-realm normalization is a + /// broader GUID parity concern and is intentionally outside quest packet review fixes. pub fn create_creature_like_cpp(map_id: u16, entry: u32, counter: i64) -> Self { Self::create_world_object(HighGuid::Creature, 0, 0, map_id, 0, entry, counter) } diff --git a/crates/wow-data/src/quest.rs b/crates/wow-data/src/quest.rs index 4d90abcf..2aa44bb8 100644 --- a/crates/wow-data/src/quest.rs +++ b/crates/wow-data/src/quest.rs @@ -872,9 +872,9 @@ pub async fn load_quests(db: &WorldDatabase) -> Result { reward_title_id: result.try_read::(89).unwrap_or(0), reward_skill_line_id: result.try_read::(87).unwrap_or(0), reward_skill_points: result.try_read::(88).unwrap_or(0), - reward_mail_template_id: result.try_read::(90).unwrap_or(0), - reward_mail_delay_secs: result.try_read::(91).unwrap_or(0), - reward_mail_sender_entry: result.try_read::(92).unwrap_or(0), + reward_mail_template_id: read_quest_u32_like_cpp(&result, 90).unwrap_or(0), + reward_mail_delay_secs: read_quest_u32_like_cpp(&result, 91).unwrap_or(0), + reward_mail_sender_entry: read_quest_u32_like_cpp(&result, 92).unwrap_or(0), reward_faction_ids: [ result.try_read::(93).unwrap_or(0), result.try_read::(97).unwrap_or(0), @@ -905,8 +905,8 @@ pub async fn load_quests(db: &WorldDatabase) -> Result { ], reward_faction_flags: result.try_read::(113).unwrap_or(0), source_item_id: result.try_read::(69).unwrap_or(0), - source_item_count: result.try_read::(71).unwrap_or(0), - source_spell_id: result.try_read::(70).unwrap_or(0), + source_item_count: read_quest_u32_like_cpp(&result, 71).unwrap_or(0), + source_spell_id: read_quest_u32_like_cpp(&result, 70).unwrap_or(0), limit_time_secs: result.try_read::(72).unwrap_or(0), expansion: result.try_read::(68).unwrap_or(0), flags, @@ -955,21 +955,23 @@ pub async fn load_quests(db: &WorldDatabase) -> Result { quest_description: result.try_read::(41).unwrap_or_default(), area_description: result.try_read::(42).unwrap_or_default(), quest_completion_log: result.try_read::(43).unwrap_or_default(), - allowable_races: result.try_read::(44).map(|v| v as u64).unwrap_or(0), - allowable_classes: result.try_read::(45).unwrap_or(0), - max_level: result.try_read::(46).unwrap_or(0), + allowable_races: read_quest_u64_like_cpp(&result, 44).unwrap_or(0), + allowable_classes: read_quest_u32_like_cpp(&result, 45).unwrap_or(0), + max_level: read_quest_u32_like_cpp(&result, 46) + .and_then(|value| u8::try_from(value).ok()) + .unwrap_or(0), prev_quest_id: result.try_read::(47).unwrap_or(0), - next_quest_id: result.try_read::(64).unwrap_or(0), + next_quest_id: read_quest_u32_like_cpp(&result, 64).unwrap_or(0), exclusive_group: result.try_read::(65).unwrap_or(0), breadcrumb_for_quest_id: result.try_read::(66).unwrap_or(0), dependent_previous_quests: Vec::new(), dependent_breadcrumb_quests: Vec::new(), - required_min_rep_faction: result.try_read::(48).unwrap_or(0), + required_min_rep_faction: read_quest_u32_like_cpp(&result, 48).unwrap_or(0), required_min_rep_value: result.try_read::(49).unwrap_or(0), - required_max_rep_faction: result.try_read::(50).unwrap_or(0), + required_max_rep_faction: read_quest_u32_like_cpp(&result, 50).unwrap_or(0), required_max_rep_value: result.try_read::(51).unwrap_or(0), - required_skill_id: result.try_read::(114).unwrap_or(0), - required_skill_points: result.try_read::(115).unwrap_or(0), + required_skill_id: read_quest_u32_like_cpp(&result, 114).unwrap_or(0), + required_skill_points: read_quest_u32_like_cpp(&result, 115).unwrap_or(0), reward_choice_items: [ ( result.try_read::(52).unwrap_or(0), @@ -1185,7 +1187,6 @@ pub async fn load_quests(db: &WorldDatabase) -> Result { store.gameobject_starter_quests.len(), store.gameobject_ender_quests.len() ); - Ok(store) } @@ -1216,10 +1217,49 @@ fn read_quest_u32_like_cpp(result: &SqlResult, column: usize) -> Option { .and_then(|value| u32::try_from(value).ok()) } +fn read_quest_u64_like_cpp(result: &SqlResult, column: usize) -> Option { + if let Some(value) = result.try_read::(column) { + return Some(value); + } + if let Some(value) = result.try_read::(column) { + return Some(u64::from(value)); + } + if let Some(value) = result.try_read::(column) { + return Some(u64::from(value)); + } + if let Some(value) = result.try_read::(column) { + return Some(u64::from(value)); + } + if let Some(value) = result.try_read::(column) { + return Some(value as u64); + } + if let Some(value) = result.try_read::(column) { + return Some(value as u64); + } + if let Some(value) = result.try_read::(column) { + return Some(value as u64); + } + result.try_read::(column).map(|value| value as u64) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn quest_template_loader_reads_addon_unsigned_fields_tolerantly_like_cpp() { + let source = include_str!("quest.rs"); + + assert!(source.contains("allowable_races: read_quest_u64_like_cpp(&result, 44)")); + assert!(source.contains("allowable_classes: read_quest_u32_like_cpp(&result, 45)")); + assert!(source.contains("max_level: read_quest_u32_like_cpp(&result, 46)")); + assert!(source.contains("next_quest_id: read_quest_u32_like_cpp(&result, 64)")); + assert!(source.contains("source_spell_id: read_quest_u32_like_cpp(&result, 70)")); + assert!(source.contains("source_item_count: read_quest_u32_like_cpp(&result, 71)")); + assert!(source.contains("required_skill_id: read_quest_u32_like_cpp(&result, 114)")); + assert!(source.contains("required_skill_points: read_quest_u32_like_cpp(&result, 115)")); + } + fn quest_with_sort_and_flags( quest_sort_id: i32, flags: u32, diff --git a/crates/wow-database/src/statements/world.rs b/crates/wow-database/src/statements/world.rs index 26738816..7a5ceacb 100644 --- a/crates/wow-database/src/statements/world.rs +++ b/crates/wow-database/src/statements/world.rs @@ -206,7 +206,7 @@ pub enum WorldStatements { SEL_GOSSIP_MENUS, /// NPC text BroadcastTextID by npc_text ID. SEL_NPC_TEXT, - /// Gossip menu options (gossip_menu_option) — includes OptionBroadcastTextID for localization. + /// C++ `ObjectMgr::LoadGossipMenuItems` column order, filtered by `MenuID`. SEL_GOSSIP_MENU_OPTIONS, /// Load all C++ ObjectMgr gossip_menu_option condition keys. SEL_GOSSIP_MENU_OPTION_KEYS, @@ -385,8 +385,10 @@ pub enum WorldStatements { SEL_QUEST_ENDERS, SEL_GAMEOBJECT_QUEST_STARTERS, SEL_GAMEOBJECT_QUEST_ENDERS, - /// Get TrainerId from creature_trainer by creature entry (NPC template ID). + /// C++ `ObjectMgr::GetCreatureDefaultTrainer` (`MenuID=0`, `OptionID=0`). SEL_TRAINER_BY_CREATURE, + /// C++ `ObjectMgr::GetCreatureTrainerForGossipOption`. + SEL_TRAINER_BY_CREATURE_GOSSIP_OPTION, /// Load all spells for a trainer by TrainerId. SEL_TRAINER_SPELLS, /// Load trainer type and greeting by trainer ID. @@ -875,10 +877,10 @@ impl StatementDef for WorldStatements { Self::SEL_GOSSIP_MENUS => "SELECT MenuID, TextID FROM gossip_menu", Self::SEL_NPC_TEXT => "SELECT BroadcastTextID0 FROM npc_text WHERE ID = ? LIMIT 1", Self::SEL_GOSSIP_MENU_OPTIONS => concat!( - "SELECT GossipOptionID, OptionID, OptionNpc, OptionText, ", - "ActionMenuID, BoxCoded, BoxMoney, BoxText, SpellID, OverrideIconID, ", - "OptionBroadcastTextID ", - "FROM gossip_menu_option WHERE MenuID = ? ORDER BY OptionID ASC", + "SELECT MenuID, GossipOptionID, OptionID, OptionNpc, OptionText, OptionBroadcastTextID, ", + "Language, Flags, ActionMenuID, ActionPoiID, GossipNpcOptionID, BoxCoded, BoxMoney, ", + "BoxText, BoxBroadcastTextID, SpellID, OverrideIconID FROM gossip_menu_option ", + "WHERE MenuID = ? ORDER BY MenuID, OptionID" ), Self::SEL_GOSSIP_MENU_OPTION_KEYS => { "SELECT MenuID, OptionID FROM gossip_menu_option ORDER BY MenuID, OptionID" @@ -1163,7 +1165,10 @@ impl StatementDef for WorldStatements { "SELECT id, speed, treatSpeedAsMoveTimeSeconds, jumpGravity, spellVisualId, progressCurveId, parabolicCurveId FROM jump_charge_params" } Self::SEL_TRAINER_BY_CREATURE => { - "SELECT TrainerId FROM creature_trainer WHERE CreatureID = ?" + "SELECT TrainerID FROM creature_trainer WHERE CreatureID = ? AND MenuID = 0 AND OptionID = 0" + } + Self::SEL_TRAINER_BY_CREATURE_GOSSIP_OPTION => { + "SELECT TrainerID FROM creature_trainer WHERE CreatureID = ? AND MenuID = ? AND OptionID = ?" } Self::SEL_TRAINER_SPELLS => { "SELECT SpellId, MoneyCost, ReqSkillLine, ReqSkillRank, \ @@ -1226,51 +1231,51 @@ impl StatementDef for WorldStatements { "qt.RewardItem3, qt.RewardAmount3, qt.ItemDrop3, qt.ItemDropQuantity3, ", "qt.RewardItem4, qt.RewardAmount4, qt.ItemDrop4, qt.ItemDropQuantity4, ", "qt.LogTitle, qt.LogDescription, qt.QuestDescription, qt.AreaDescription, qt.QuestCompletionLog, ", - "COALESCE(qt.AllowableRaces, 0) AS AllowableRaces, ", - "COALESCE(qta.AllowableClasses, 0) AS AllowableClasses, ", - "COALESCE(qta.MaxLevel, 0) AS MaxLevel, ", - "COALESCE(qta.PrevQuestID, 0) AS PrevQuestID, ", - "COALESCE(qta.RequiredMinRepFaction, 0) AS RequiredMinRepFaction, ", - "COALESCE(qta.RequiredMinRepValue, 0) AS RequiredMinRepValue, ", - "COALESCE(qta.RequiredMaxRepFaction, 0) AS RequiredMaxRepFaction, ", - "COALESCE(qta.RequiredMaxRepValue, 0) AS RequiredMaxRepValue, ", + "qt.AllowableRaces AS AllowableRaces, ", + "CAST(COALESCE(qta.AllowableClasses, 0) AS UNSIGNED) AS AllowableClasses, ", + "CAST(COALESCE(qta.MaxLevel, 0) AS UNSIGNED) AS MaxLevel, ", + "CAST(COALESCE(qta.PrevQuestID, 0) AS SIGNED) AS PrevQuestID, ", + "CAST(COALESCE(qta.RequiredMinRepFaction, 0) AS UNSIGNED) AS RequiredMinRepFaction, ", + "CAST(COALESCE(qta.RequiredMinRepValue, 0) AS SIGNED) AS RequiredMinRepValue, ", + "CAST(COALESCE(qta.RequiredMaxRepFaction, 0) AS UNSIGNED) AS RequiredMaxRepFaction, ", + "CAST(COALESCE(qta.RequiredMaxRepValue, 0) AS SIGNED) AS RequiredMaxRepValue, ", "qt.RewardChoiceItemID1, qt.RewardChoiceItemQuantity1, ", "qt.RewardChoiceItemID2, qt.RewardChoiceItemQuantity2, ", "qt.RewardChoiceItemID3, qt.RewardChoiceItemQuantity3, ", "qt.RewardChoiceItemID4, qt.RewardChoiceItemQuantity4, ", "qt.RewardChoiceItemID5, qt.RewardChoiceItemQuantity5, ", "qt.RewardChoiceItemID6, qt.RewardChoiceItemQuantity6, ", - "COALESCE(qta.NextQuestID, 0) AS NextQuestID, ", - "COALESCE(qta.ExclusiveGroup, 0) AS ExclusiveGroup, ", - "COALESCE(qta.BreadcrumbForQuestId, 0) AS BreadcrumbForQuestId, ", - "COALESCE(qta.SpecialFlags, 0) AS SpecialFlags, ", + "CAST(COALESCE(qta.NextQuestID, 0) AS UNSIGNED) AS NextQuestID, ", + "CAST(COALESCE(qta.ExclusiveGroup, 0) AS SIGNED) AS ExclusiveGroup, ", + "CAST(COALESCE(qta.BreadcrumbForQuestId, 0) AS SIGNED) AS BreadcrumbForQuestId, ", + "CAST(COALESCE(qta.SpecialFlags, 0) AS UNSIGNED) AS SpecialFlags, ", "qt.Expansion, ", "qt.StartItem, ", - "COALESCE(qta.SourceSpellID, 0) AS SourceSpellID, ", - "COALESCE(qta.ProvidedItemCount, 0) AS ProvidedItemCount, ", + "CAST(COALESCE(qta.SourceSpellID, 0) AS UNSIGNED) AS SourceSpellID, ", + "CAST(COALESCE(qta.ProvidedItemCount, 0) AS UNSIGNED) AS ProvidedItemCount, ", "qt.TimeAllowed, ", - "COALESCE(qrci.Type1, 0) AS RewardChoiceItemType1, ", - "COALESCE(qrci.Type2, 0) AS RewardChoiceItemType2, ", - "COALESCE(qrci.Type3, 0) AS RewardChoiceItemType3, ", - "COALESCE(qrci.Type4, 0) AS RewardChoiceItemType4, ", - "COALESCE(qrci.Type5, 0) AS RewardChoiceItemType5, ", - "COALESCE(qrci.Type6, 0) AS RewardChoiceItemType6, ", + "CAST(COALESCE(qrci.Type1, 0) AS UNSIGNED) AS RewardChoiceItemType1, ", + "CAST(COALESCE(qrci.Type2, 0) AS UNSIGNED) AS RewardChoiceItemType2, ", + "CAST(COALESCE(qrci.Type3, 0) AS UNSIGNED) AS RewardChoiceItemType3, ", + "CAST(COALESCE(qrci.Type4, 0) AS UNSIGNED) AS RewardChoiceItemType4, ", + "CAST(COALESCE(qrci.Type5, 0) AS UNSIGNED) AS RewardChoiceItemType5, ", + "CAST(COALESCE(qrci.Type6, 0) AS UNSIGNED) AS RewardChoiceItemType6, ", "qt.RewardCurrencyID1, qt.RewardCurrencyQty1, ", "qt.RewardCurrencyID2, qt.RewardCurrencyQty2, ", "qt.RewardCurrencyID3, qt.RewardCurrencyQty3, ", "qt.RewardCurrencyID4, qt.RewardCurrencyQty4, ", "qt.RewardSkillLineID, qt.RewardNumSkillUps, qt.RewardTitle, ", - "COALESCE(qta.RewardMailTemplateID, 0) AS RewardMailTemplateID, ", - "COALESCE(qta.RewardMailDelay, 0) AS RewardMailDelay, ", - "COALESCE(qms.RewardMailSenderEntry, 0) AS RewardMailSenderEntry, ", + "CAST(COALESCE(qta.RewardMailTemplateID, 0) AS UNSIGNED) AS RewardMailTemplateID, ", + "CAST(COALESCE(qta.RewardMailDelay, 0) AS UNSIGNED) AS RewardMailDelay, ", + "CAST(COALESCE(qms.RewardMailSenderEntry, 0) AS UNSIGNED) AS RewardMailSenderEntry, ", "qt.RewardFactionID1, qt.RewardFactionValue1, qt.RewardFactionOverride1, qt.RewardFactionCapIn1, ", "qt.RewardFactionID2, qt.RewardFactionValue2, qt.RewardFactionOverride2, qt.RewardFactionCapIn2, ", "qt.RewardFactionID3, qt.RewardFactionValue3, qt.RewardFactionOverride3, qt.RewardFactionCapIn3, ", "qt.RewardFactionID4, qt.RewardFactionValue4, qt.RewardFactionOverride4, qt.RewardFactionCapIn4, ", "qt.RewardFactionID5, qt.RewardFactionValue5, qt.RewardFactionOverride5, qt.RewardFactionCapIn5, ", "qt.RewardFactionFlags, ", - "COALESCE(qta.RequiredSkillID, 0) AS RequiredSkillID, ", - "COALESCE(qta.RequiredSkillPoints, 0) AS RequiredSkillPoints ", + "CAST(COALESCE(qta.RequiredSkillID, 0) AS UNSIGNED) AS RequiredSkillID, ", + "CAST(COALESCE(qta.RequiredSkillPoints, 0) AS UNSIGNED) AS RequiredSkillPoints ", "FROM quest_template qt ", "LEFT JOIN quest_template_addon qta ON qt.ID = qta.ID ", "LEFT JOIN quest_reward_choice_items qrci ON qt.ID = qrci.QuestID ", @@ -1294,6 +1299,29 @@ impl StatementDef for WorldStatements { mod tests { use super::*; + #[test] + fn gossip_menu_options_select_keeps_cpp_load_column_order_like_cpp() { + let sql = WorldStatements::SEL_GOSSIP_MENU_OPTIONS.sql(); + + assert!(sql.contains( + "SELECT MenuID, GossipOptionID, OptionID, OptionNpc, OptionText, OptionBroadcastTextID, Language, Flags, ActionMenuID, ActionPoiID, GossipNpcOptionID, BoxCoded, BoxMoney, BoxText, BoxBroadcastTextID, SpellID, OverrideIconID" + )); + assert!(sql.contains("FROM gossip_menu_option WHERE MenuID = ?")); + assert!(sql.contains("ORDER BY MenuID, OptionID")); + } + + #[test] + fn trainer_by_creature_gossip_option_matches_cpp_lookup_key() { + assert_eq!( + WorldStatements::SEL_TRAINER_BY_CREATURE.sql(), + "SELECT TrainerID FROM creature_trainer WHERE CreatureID = ? AND MenuID = 0 AND OptionID = 0" + ); + assert_eq!( + WorldStatements::SEL_TRAINER_BY_CREATURE_GOSSIP_OPTION.sql(), + "SELECT TrainerID FROM creature_trainer WHERE CreatureID = ? AND MenuID = ? AND OptionID = ?" + ); + } + #[test] fn creatures_in_range_selects_addon_path_and_effective_movement_like_cpp() { let sql = WorldStatements::SEL_CREATURES_IN_RANGE.sql(); @@ -1443,21 +1471,35 @@ mod tests { let sql = WorldStatements::SEL_QUEST_TEMPLATE.sql(); assert!(sql.contains("qt.QuestPackageID")); - assert!(sql.contains("COALESCE(qrci.Type1, 0) AS RewardChoiceItemType1")); - assert!(sql.contains("COALESCE(qrci.Type6, 0) AS RewardChoiceItemType6")); + assert!(sql.contains("qt.AllowableRaces AS AllowableRaces")); + assert!( + sql.contains("CAST(COALESCE(qta.AllowableClasses, 0) AS UNSIGNED) AS AllowableClasses") + ); + assert!(sql.contains("CAST(COALESCE(qrci.Type1, 0) AS UNSIGNED) AS RewardChoiceItemType1")); + assert!(sql.contains("CAST(COALESCE(qrci.Type6, 0) AS UNSIGNED) AS RewardChoiceItemType6")); assert!(sql.contains("LEFT JOIN quest_reward_choice_items qrci ON qt.ID = qrci.QuestID")); assert!(sql.contains("qt.RewardCurrencyID1, qt.RewardCurrencyQty1")); assert!(sql.contains("qt.RewardCurrencyID4, qt.RewardCurrencyQty4")); assert!(sql.contains("qt.RewardSkillLineID, qt.RewardNumSkillUps, qt.RewardTitle")); - assert!(sql.contains("COALESCE(qta.RewardMailTemplateID, 0) AS RewardMailTemplateID")); - assert!(sql.contains("COALESCE(qta.RewardMailDelay, 0) AS RewardMailDelay")); - assert!(sql.contains("COALESCE(qms.RewardMailSenderEntry, 0) AS RewardMailSenderEntry")); + assert!(sql.contains( + "CAST(COALESCE(qta.RewardMailTemplateID, 0) AS UNSIGNED) AS RewardMailTemplateID" + )); + assert!( + sql.contains("CAST(COALESCE(qta.RewardMailDelay, 0) AS UNSIGNED) AS RewardMailDelay") + ); + assert!(sql.contains( + "CAST(COALESCE(qms.RewardMailSenderEntry, 0) AS UNSIGNED) AS RewardMailSenderEntry" + )); assert!(sql.contains("LEFT JOIN quest_mail_sender qms ON qt.ID = qms.QuestId")); assert!(sql.contains("qt.RewardFactionID1, qt.RewardFactionValue1")); assert!(sql.contains("qt.RewardFactionID5, qt.RewardFactionValue5")); assert!(sql.contains("qt.RewardFactionFlags")); - assert!(sql.contains("COALESCE(qta.RequiredSkillID, 0) AS RequiredSkillID")); - assert!(sql.contains("COALESCE(qta.RequiredSkillPoints, 0) AS RequiredSkillPoints")); + assert!( + sql.contains("CAST(COALESCE(qta.RequiredSkillID, 0) AS UNSIGNED) AS RequiredSkillID") + ); + assert!(sql.contains( + "CAST(COALESCE(qta.RequiredSkillPoints, 0) AS UNSIGNED) AS RequiredSkillPoints" + )); } #[test] diff --git a/crates/wow-packet/src/packets/item.rs b/crates/wow-packet/src/packets/item.rs index 72ecf74a..168477e4 100644 --- a/crates/wow-packet/src/packets/item.rs +++ b/crates/wow-packet/src/packets/item.rs @@ -88,6 +88,18 @@ impl ItemInstance { item_bonus.write(pkt); } } + + pub fn write_preserving_pending_bits_like_cpp(&self, pkt: &mut WorldPacket) { + pkt.write_int32_unflushed(self.item_id); + pkt.write_int32_unflushed(self.random_properties_seed); + pkt.write_int32_unflushed(self.random_properties_id); + pkt.write_bit(self.item_bonus.is_some()); + pkt.flush_bits(); + self.modifications.write(pkt); + if let Some(item_bonus) = &self.item_bonus { + item_bonus.write(pkt); + } + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/crates/wow-packet/src/packets/quest.rs b/crates/wow-packet/src/packets/quest.rs index 706afc6c..348a6b35 100644 --- a/crates/wow-packet/src/packets/quest.rs +++ b/crates/wow-packet/src/packets/quest.rs @@ -8,7 +8,7 @@ //! Legacy non-canonical note: Game/Networking/Packets/QuestPackets.cs //! Legacy non-canonical note: Game/Networking/Packets/NPCPackets.cs (ClientGossipText) -use crate::{ClientPacket, PacketError, ServerPacket, WorldPacket}; +use crate::{ClientPacket, PacketError, ServerPacket, WorldPacket, packets::item::ItemInstance}; use wow_constants::{ClientOpcodes, ServerOpcodes}; use wow_core::ObjectGuid; @@ -18,6 +18,7 @@ const QUEST_REWARD_CHOICES_COUNT: usize = 6; const QUEST_REWARD_REPUTATIONS_COUNT: usize = 5; const QUEST_REWARD_CURRENCY_COUNT: usize = 4; const QUEST_REWARD_DISPLAY_SPELL_COUNT: usize = 3; +const QUEST_EMOTE_COUNT: usize = 4; /// Client request to start an Adventure Map quest. /// @@ -268,6 +269,7 @@ pub struct QuestListEntry { pub quest_flags: u32, pub quest_flags_ex: u32, pub repeatable: bool, + pub important: bool, pub title: String, } @@ -300,7 +302,7 @@ impl ServerPacket for QuestGiverQuestList { pkt.write_uint32(q.quest_flags); pkt.write_uint32(q.quest_flags_ex); pkt.write_bit(q.repeatable); - pkt.write_bit(false); // Important + pkt.write_bit(q.important); pkt.write_bits(q.title.len() as u32, 9); pkt.flush_bits(); pkt.write_string(&q.title); @@ -322,10 +324,13 @@ pub struct QuestObjectiveSimple { } /// Full reward block for a quest details / offer reward packet. -/// Legacy non-canonical note: QuestRewards +/// +/// C++ target: `WorldPackets::Quest::QuestRewards`, +/// `QuestPackets.cpp:283-330`. This Rust block is still partial; see issue #57. pub struct QuestRewardsBlock { pub items: [(u32, u32); QUEST_REWARD_ITEM_COUNT], // (item_id, qty) — fixed rewards pub choice_items: [(u32, u32); QUEST_REWARD_CHOICES_COUNT], // (item_id, qty) — player picks one + pub choice_item_types: [u8; QUEST_REWARD_CHOICES_COUNT], // C++ LootItemType, 0=item, 1=currency pub money: i32, pub xp: i32, pub honor: i32, @@ -339,6 +344,7 @@ impl Default for QuestRewardsBlock { Self { items: [(0, 0); QUEST_REWARD_ITEM_COUNT], choice_items: [(0, 0); QUEST_REWARD_CHOICES_COUNT], + choice_item_types: [0; QUEST_REWARD_CHOICES_COUNT], money: 0, xp: 0, honor: 0, @@ -385,15 +391,16 @@ impl QuestRewardsBlock { pkt.write_int32(0); // SkillLineID pkt.write_int32(0); // NumSkillUps pkt.write_int32(0); // TreasurePickerID - // ChoiceItems (6 entries, each: ItemID, Quantity, Context+Bonuses, DisplayID, Unused) - // Legacy non-canonical note: QuestChoiceItem.Write / ItemInstance.Write - for (item_id, qty) in &self.choice_items { - pkt.write_int32(*item_id as i32); // Item.ItemID - pkt.write_int32(*qty as i32); // Item.Quantity - pkt.write_uint64(0u64); // Item.Mask (ItemContext bits) - pkt.write_uint32(0); // Item.Bonuses count - pkt.write_int32(0); // DisplayID - pkt.write_int32(0); // Unused (LootItemType 0=Item) + // C++ `QuestChoiceItem`: 2-bit LootItemType, ItemInstance, int32 Quantity. + // `ByteBuffer::append` flushes pending bits before the ItemInstance integers. + for (idx, (item_id, qty)) in self.choice_items.iter().enumerate() { + pkt.write_bits(u32::from(self.choice_item_types[idx]), 2); + ItemInstance { + item_id: *item_id as i32, + ..ItemInstance::default() + } + .write(pkt); + pkt.write_int32(*qty as i32); } pkt.write_bit(false); // IsBoostSpell pkt.flush_bits(); @@ -404,6 +411,7 @@ impl QuestRewardsBlock { /// Legacy non-canonical note: QuestGiverQuestDetails pub struct QuestGiverQuestDetails { pub giver_guid: ObjectGuid, + pub giver_creature_id: i32, pub quest_id: u32, pub quest_flags: [u32; 3], pub suggested_party_members: u8, @@ -431,14 +439,19 @@ impl ServerPacket for QuestGiverQuestDetails { pkt.write_uint32(self.quest_flags[2]); pkt.write_int32(self.suggested_party_members as i32); pkt.write_int32(0); // LearnSpells count - pkt.write_int32(0); // DescEmotes count + pkt.write_int32(QUEST_EMOTE_COUNT as i32); // DescEmotes count pkt.write_int32(self.objectives.len() as i32); pkt.write_int32(0); // QuestStartItemID pkt.write_int32(0); // QuestSessionBonus - pkt.write_int32(0); // QuestGiverCreatureID + pkt.write_int32(self.giver_creature_id); // QuestGiverCreatureID pkt.write_int32(0); // ConditionalDescriptionText count // Objectives + for _ in 0..QUEST_EMOTE_COUNT { + pkt.write_int32(0); // DescEmote.Type + pkt.write_uint32(0); // DescEmote.Delay + } + for obj in &self.objectives { pkt.write_uint32(obj.id); pkt.write_int32(obj.object_id); @@ -474,7 +487,10 @@ impl ServerPacket for QuestGiverQuestDetails { // ── SMSG_QUEST_GIVER_QUEST_COMPLETE ────────────────────────────────────────── /// Shown after accepting a quest — "Quest Accepted" popup. -/// Legacy non-canonical note: QuestGiverQuestComplete +/// +/// C++ anchors: +/// - `QuestGiverQuestComplete::Write`, `QuestPackets.cpp:397-411`. +/// - `WorldPackets::Item::ItemInstance`, `ItemPacketsCommon.cpp:176-190`. pub struct QuestGiverQuestComplete { pub quest_id: u32, pub xp: u32, @@ -494,9 +510,9 @@ impl ServerPacket for QuestGiverQuestComplete { pkt.write_uint32(self.skill_points); pkt.write_bit(self.use_quest_reward_currency); pkt.write_bit(false); // LaunchGossip + pkt.write_bit(false); // LaunchQuest pkt.write_bit(false); // HideChatMessage - pkt.write_bit(false); // ShowKeybind - pkt.flush_bits(); + ItemInstance::default().write_preserving_pending_bits_like_cpp(pkt); } } @@ -536,7 +552,9 @@ pub struct QuestObjectiveInfo { } /// Response to CMSG_QUERY_QUEST_INFO — full quest data. -/// Legacy non-canonical note: QueryQuestInfoResponse +/// +/// C++ target: `QueryQuestInfoResponse::Write`, `QuestPackets.cpp:87-213`. +/// This Rust packet is still partial; see issue #57 for `ReadyForTranslation`. #[derive(Default)] pub struct QueryQuestInfoResponse { pub quest_id: u32, @@ -674,6 +692,7 @@ impl ServerPacket for QueryQuestInfoResponse { pkt.write_bits(0u32, 10); // PortraitTurnInText pkt.write_bits(0u32, 8); // PortraitTurnInName pkt.write_bits(self.quest_completion_log.len() as u32, 11); + pkt.write_bit(false); // ReadyForTranslation pkt.flush_bits(); // Objectives @@ -708,6 +727,7 @@ impl ServerPacket for QueryQuestInfoResponse { /// Legacy non-canonical note: QuestGiverOfferRewardMessage / QuestGiverOfferReward pub struct QuestGiverOfferReward { pub giver_guid: ObjectGuid, + pub giver_creature_id: i32, pub quest_id: u32, pub quest_flags: [u32; 3], pub suggested_party_members: u8, @@ -722,7 +742,7 @@ impl ServerPacket for QuestGiverOfferReward { fn write(&self, pkt: &mut WorldPacket) { // QuestGiverOfferReward inner block pkt.write_packed_guid(&self.giver_guid); - pkt.write_int32(0); // QuestGiverCreatureID + pkt.write_int32(self.giver_creature_id); // QuestGiverCreatureID pkt.write_uint32(self.quest_id); pkt.write_uint32(self.quest_flags[0]); pkt.write_uint32(self.quest_flags[1]); @@ -740,7 +760,7 @@ impl ServerPacket for QuestGiverOfferReward { pkt.write_int32(0); // PortraitGiverMount pkt.write_int32(0); // PortraitGiverModelSceneID pkt.write_int32(0); // PortraitTurnIn - pkt.write_int32(0); // QuestGiverCreatureID (outer) + pkt.write_int32(self.giver_creature_id); // QuestGiverCreatureID (outer) pkt.write_int32(0); // ConditionalRewardText count pkt.write_bits(self.title.len() as u32, 9); @@ -941,6 +961,43 @@ mod tests { assert_eq!(pkt.read_uint64().unwrap(), status); } + #[test] + fn quest_giver_quest_details_sallina_capture_length_matches_cpp_like_cpp() { + // The C++ runtime normalizes realm 0 to the active realm before this packet is captured. + let guid = ObjectGuid::create_world_object(HighGuid::Creature, 0, 1, 530, 0, 15513, 27); + let bytes = QuestGiverQuestDetails { + giver_guid: guid, + giver_creature_id: 15513, + quest_id: 10070, + quest_flags: [0x0008_0088, 0, 0], + suggested_party_members: 0, + objectives: Vec::new(), + rewards: QuestRewardsBlock::default(), + title: "Well Watcher Solanian".to_string(), + description: "And now, I need for you to do something.$B$BWell Watcher Solanian is in need of your services. You would do well to ingratiate yourself with him.$B$BHe awaits you on the exterior platform that the ramp in this chamber leads up to.".to_string(), + log_description: "Speak with Well Watcher Solanian at the Sunspire on Sunstrider Isle.".to_string(), + auto_launched: false, + } + .to_bytes(); + + // C++ capture `/tmp/cpp-sallina-packets/0564...QuestDetails.bin` is 769 + // bytes of payload; Rust `to_bytes` includes the 2-byte opcode. + assert_eq!(bytes.len(), 771); + + let mut pkt = WorldPacket::from_bytes(&bytes[2..]); + assert_eq!(pkt.read_packed_guid().unwrap(), guid); + assert_eq!(pkt.read_packed_guid().unwrap(), ObjectGuid::EMPTY); + assert_eq!(pkt.read_int32().unwrap(), 10070); + for _ in 0..10 { + let _ = pkt.read_int32().unwrap(); + } + assert_eq!(pkt.read_int32().unwrap(), QUEST_EMOTE_COUNT as i32); + assert_eq!(pkt.read_int32().unwrap(), 0); + assert_eq!(pkt.read_int32().unwrap(), 0); + assert_eq!(pkt.read_int32().unwrap(), 0); + assert_eq!(pkt.read_int32().unwrap(), 15513); + } + #[test] fn quest_giver_status_constants_match_cpp_quest_def_like_cpp() { assert_eq!(quest_giver_status::NONE, 0x0000_0000); @@ -1243,5 +1300,73 @@ mod quest_giver_quest_complete_tests { assert_eq!(&bytes[10..18], &0x0102_0304u64.to_le_bytes()); assert_eq!(&bytes[18..22], &0x0A0B_0C0Du32.to_le_bytes()); assert_eq!(&bytes[22..26], &0x0E0F_1011u32.to_le_bytes()); + assert_eq!(&bytes[26..30], &0i32.to_le_bytes()); // Default ItemReward ItemID. + assert_eq!(&bytes[30..34], &0i32.to_le_bytes()); // RandomPropertiesSeed. + assert_eq!(&bytes[34..38], &0i32.to_le_bytes()); // RandomPropertiesID. + assert_eq!(bytes[38], 0x80); // UseQuestReward=true, remaining C++ bits false. + assert_eq!(bytes[39], 0x00); // Empty ItemReward modifications. + assert_eq!(bytes.len(), 40); + } + + #[test] + fn quest_giver_quest_complete_keeps_empty_item_reward_when_flags_are_false() { + let complete = QuestGiverQuestComplete { + quest_id: 7, + xp: 0, + money: 0, + skill_line_id: 0, + skill_points: 0, + use_quest_reward_currency: false, + }; + + let bytes = complete.to_bytes(); + + assert_eq!(&bytes[26..38], &[0; 12]); + assert_eq!(bytes[38], 0x00); + assert_eq!(bytes[39], 0x00); + assert_eq!(bytes.len(), 40); + } + + #[test] + fn quest_rewards_choice_item_flushes_loot_type_before_item_instance_like_cpp() { + let mut rewards = QuestRewardsBlock::default(); + rewards.choice_items[0] = (0x0102_0304, 7); + rewards.choice_item_types[0] = 0b10; + + let mut pkt = WorldPacket::new_empty(); + rewards.write(&mut pkt); + let bytes = pkt.into_data(); + + // Counts + fixed reward slots + money/xp/artifact/honor/title + faction/spell/currency/skill fields. + let choice_start = 4 + + 4 + + (QUEST_REWARD_ITEM_COUNT * 2 * 4) + + 4 + + 4 + + 8 + + 4 + + 4 + + 4 + + 4 + + (QUEST_REWARD_REPUTATIONS_COUNT * 4 * 4) + + (QUEST_REWARD_DISPLAY_SPELL_COUNT * 4) + + 4 + + (QUEST_REWARD_CURRENCY_COUNT * 2 * 4) + + 4 + + 4 + + 4; + assert_eq!( + bytes[choice_start] >> 6, + 0b10, + "C++ ByteBuffer::append flushes the 2-bit LootItemType before ItemInstance" + ); + assert_eq!( + &bytes[choice_start + 1..choice_start + 5], + &0x0102_0304_i32.to_le_bytes() + ); + assert_eq!( + &bytes[choice_start + 15..choice_start + 19], + &7_i32.to_le_bytes() + ); } } diff --git a/crates/wow-packet/src/packets/update.rs b/crates/wow-packet/src/packets/update.rs index a6b4d250..1c8d4800 100644 --- a/crates/wow-packet/src/packets/update.rs +++ b/crates/wow-packet/src/packets/update.rs @@ -3269,8 +3269,30 @@ impl UpdateObject { .iter() .filter(|(item_id, _, _)| *item_id != 0) .count(); + let quest_slots = create_data + .quest_log + .iter() + .enumerate() + .filter_map(|(slot, (quest_id, state_flags, _, objective_progress))| { + (*quest_id != 0).then(|| { + let progress = objective_progress + .iter() + .copied() + .filter(|count| *count != 0) + .map(|count| count.to_string()) + .collect::>() + .join("/"); + if progress.is_empty() { + format!("{slot}:{quest_id}:0x{state_flags:X}") + } else { + format!("{slot}:{quest_id}:0x{state_flags:X}:{progress}") + } + }) + }) + .collect::>() + .join(","); lines.push(format!( - "#{index:03} player guid={guid:?} update_type={} type_id={} self={} bytes={} movementBytes={} valuesBytes={} level={} display={} native_display={} health={}/{} inv_slots={} visible_items={} skills={} quests={} toys={} heirlooms={} coinage={}", + "#{index:03} player guid={guid:?} update_type={} type_id={} self={} bytes={} movementBytes={} valuesBytes={} level={} display={} native_display={} health={}/{} inv_slots={} visible_items={} skills={} quests={} quest_slots=[{}] toys={} heirlooms={} coinage={}", *update_type as u8, *type_id as u8, is_self, @@ -3286,6 +3308,7 @@ impl UpdateObject { visible_items, create_data.skill_info.len(), create_data.quest_log.len(), + quest_slots, create_data.toys.len(), create_data.heirlooms.len(), create_data.coinage diff --git a/crates/wow-packet/src/world_packet.rs b/crates/wow-packet/src/world_packet.rs index 622d0c2c..6e9804ca 100644 --- a/crates/wow-packet/src/world_packet.rs +++ b/crates/wow-packet/src/world_packet.rs @@ -707,7 +707,7 @@ mod tests { } #[test] - fn packed_guid_world_objects_do_not_encode_realm_or_server_like_cpp() { + fn packed_guid_raw_world_objects_do_not_encode_realm_or_server() { let cpp_guid = ObjectGuid::create_vehicle_like_cpp(571, 29929, 0x6A); let mut cpp_packet = WorldPacket::new_empty(); cpp_packet.write_packed_guid(&cpp_guid); @@ -729,7 +729,7 @@ mod tests { assert_eq!( contaminated_packet.size(), cpp_packet.size() + 2, - "non-zero realm/server fields add packed GUID bytes that C++ WorldObject GUIDs do not have" + "non-zero realm/server fields add packed GUID bytes outside the current raw world-object GUID contract" ); cpp_packet.reset_read(); diff --git a/crates/wow-world/src/handlers/character.rs b/crates/wow-world/src/handlers/character.rs index a8462756..d47a93ff 100644 --- a/crates/wow-world/src/handlers/character.rs +++ b/crates/wow-world/src/handlers/character.rs @@ -78,8 +78,85 @@ const GO_SPAWN_PHASE_GROUP_COLUMN: usize = GO_SPAWN_PHASE_USE_FLAGS_COLUMN + 2; const GO_SPAWN_TERRAIN_SWAP_MAP_COLUMN: usize = GO_SPAWN_PHASE_USE_FLAGS_COLUMN + 3; const GO_SPAWN_EFFECTIVE_FLAGS_COLUMN: usize = GO_SPAWN_PHASE_USE_FLAGS_COLUMN + 4; const GO_SPAWN_EFFECTIVE_FACTION_COLUMN: usize = GO_SPAWN_PHASE_USE_FLAGS_COLUMN + 5; +const DIRECT_VENDOR_MASK_LIKE_CPP: u32 = 0x80 | 0x100 | 0x200 | 0x400 | 0x800; +const DIRECT_TRAINER_MASK_LIKE_CPP: u32 = 0x10 | 0x20 | 0x40; +const DIRECT_FLIGHT_MASTER_LIKE_CPP: u32 = 0x2000; +const DIRECT_AUCTIONEER_LIKE_CPP: u32 = 0x200000; +const DIRECT_BANKER_LIKE_CPP: u32 = 0x20000; +const DIRECT_TABARD_DESIGNER_LIKE_CPP: u32 = 0x80000; +const DIRECT_STABLE_MASTER_LIKE_CPP: u32 = 0x400000; +const DIRECT_GUILD_BANKER_LIKE_CPP: u32 = 0x800000; +const DIRECT_INTERACTION_MASK_LIKE_CPP: u32 = DIRECT_VENDOR_MASK_LIKE_CPP + | DIRECT_TRAINER_MASK_LIKE_CPP + | DIRECT_FLIGHT_MASTER_LIKE_CPP + | DIRECT_AUCTIONEER_LIKE_CPP + | DIRECT_BANKER_LIKE_CPP + | DIRECT_TABARD_DESIGNER_LIKE_CPP + | DIRECT_STABLE_MASTER_LIKE_CPP + | DIRECT_GUILD_BANKER_LIKE_CPP; + +fn npc_has_direct_interaction_like_cpp(npc_flags: u32) -> bool { + npc_flags & DIRECT_INTERACTION_MASK_LIKE_CPP != 0 +} const GO_SPAWN_OVERRIDE_SOURCE_KNOWN_COLUMN: usize = GO_SPAWN_PHASE_USE_FLAGS_COLUMN + 6; const WORLDSTATE_ANY_MAP_LIKE_CPP: i32 = -1; +const DEFAULT_GOSSIP_MESSAGE_LIKE_CPP: i32 = 0x00FF_FFFF; +const TRAINER_NPC_FLAGS_MASK_LIKE_CPP: u32 = 0x10 | 0x20 | 0x40; +const GOSSIP_OPTION_ID_AUTO_TRAINER_LIKE_CPP: i32 = -1; +const GOSSIP_OPTION_NPC_TRAINER_LIKE_CPP: u8 = 3; +const GOSSIP_OPTION_TRAINER_TEXT_LIKE_CPP: &str = "I would like to train."; + +fn creature_has_trainer_flag_like_cpp(npc_flags: u32) -> bool { + (npc_flags & TRAINER_NPC_FLAGS_MASK_LIKE_CPP) != 0 +} + +fn represented_trainer_gossip_option_like_cpp() -> wow_packet::packets::gossip::ClientGossipOption { + wow_packet::packets::gossip::ClientGossipOption { + gossip_option_id: GOSSIP_OPTION_ID_AUTO_TRAINER_LIKE_CPP, + option_npc: GOSSIP_OPTION_NPC_TRAINER_LIKE_CPP, + option_flags: 0, + option_cost: 0, + option_language: 0, + flags: 0, + order_index: 0, + status: 0, + text: GOSSIP_OPTION_TRAINER_TEXT_LIKE_CPP.to_string(), + confirm: String::new(), + spell_id: None, + override_icon_id: None, + } +} + +fn represented_trainer_gossip_option_info_like_cpp() -> crate::session::GossipOptionInfo { + crate::session::GossipOptionInfo { + gossip_option_id: GOSSIP_OPTION_ID_AUTO_TRAINER_LIKE_CPP, + menu_id: 0, + order_index: 0, + option_npc: GOSSIP_OPTION_NPC_TRAINER_LIKE_CPP, + action_menu_id: 0, + } +} + +fn add_represented_trainer_gossip_option_if_missing_like_cpp( + gossip_options: &mut Vec, + stored_options: &mut Vec, + npc_flags: u32, +) -> bool { + if !creature_has_trainer_flag_like_cpp(npc_flags) { + return false; + } + + if gossip_options + .iter() + .any(|option| option.option_npc == GOSSIP_OPTION_NPC_TRAINER_LIKE_CPP) + { + return false; + } + + gossip_options.push(represented_trainer_gossip_option_like_cpp()); + stored_options.push(represented_trainer_gossip_option_info_like_cpp()); + true +} fn primary_power_type_for_class_like_cpp(class_id: u8) -> PowerType { match class_id { @@ -8286,10 +8363,14 @@ impl WorldSession { // If the NPC has Gossip flag AND we have a world DB, try to load the gossip menu. if npc_flags & GOSSIP_FLAG != 0 && entry != 0 { if let Some(world_db) = self.world_db().map(Arc::clone) { - if let Some(msg) = self.build_gossip_menu(&world_db, entry, hello.unit).await { + if let Some(msg) = self + .build_gossip_menu(&world_db, entry, npc_flags, hello.unit) + .await + { info!( - "Sending GossipMessage with {} options for entry {}", + "Sending GossipMessage with {} options and {} quests for entry {}", msg.gossip_options.len(), + msg.gossip_text.len(), entry ); self.send_packet(&msg); @@ -8298,8 +8379,54 @@ impl WorldSession { } } - // No gossip menu found — fall back to direct interaction based on NPC flags. - self.handle_npc_direct_interaction(hello).await; + if entry != 0 + && self.send_represented_creature_trainer_gossip_menu_like_cpp( + hello.unit, entry, npc_flags, + ) + { + info!( + "GossipHello trainer fallback sent prepared gossip menu for entry={} {:?}", + entry, hello.unit + ); + return; + } + + // No DB gossip menu found. C++ `HandleQuestgiverHelloOpcode` uses the + // same prepared-gossip path as `HandleGossipHelloOpcode`; the represented + // seam currently models the quest part of that prepared menu. + if (npc_flags & NPCFlags1::QUEST_GIVER.bits()) != 0 + && !npc_has_direct_interaction_like_cpp(npc_flags) + && entry != 0 + { + if self + .represented_npc_can_interact_with_like_cpp( + hello.unit, + NPCFlags1::QUEST_GIVER.bits(), + 0, + ) + .is_none() + { + debug!( + "GossipHello questgiver fallback rejected by C++ interaction checks for {:?}", + hello.unit + ); + return; + } + if self.use_represented_creature_questgiver_like_cpp(hello.unit, entry) { + info!( + "GossipHello questgiver fallback consumed entry={} for {:?}", + entry, hello.unit + ); + return; + } + info!( + "GossipHello questgiver fallback found no quest menu for entry={} {:?}", + entry, hello.unit + ); + } + + // No gossip or quest menu found — fall back to direct interaction based on NPC flags. + self.handle_npc_direct_interaction(hello, npc_flags).await; } pub(crate) fn build_condition_player_object_like_cpp(&self) -> Option { @@ -8523,10 +8650,11 @@ impl WorldSession { /// Build a GossipMessage from the database for a creature entry. /// Returns None if no gossip menu exists. - async fn build_gossip_menu( + pub(crate) async fn build_gossip_menu( &mut self, world_db: &Arc, entry: u32, + npc_flags: u32, npc_guid: wow_core::ObjectGuid, ) -> Option { use crate::session::GossipOptionInfo; @@ -8610,39 +8738,51 @@ impl WorldSession { _ => return None, }; - if opt_result.is_empty() { - return None; - } - // Collect raw option rows first, then resolve localized text. struct RawOption { + menu_id: u32, gossip_option_id: i32, option_id: u32, option_npc: u8, option_text: String, + option_broadcast_text_id: u32, + language: u32, + flags: i32, action_menu_id: u32, + action_poi_id: u32, + gossip_npc_option_id: Option, + box_coded: bool, box_money: u32, box_text: String, + box_broadcast_text_id: u32, spell_id: Option, override_icon_id: Option, - broadcast_text_id: u32, } let mut raw_options = Vec::new(); - loop { - raw_options.push(RawOption { - gossip_option_id: opt_result.try_read(0).unwrap_or(0), - option_id: opt_result.try_read(1).unwrap_or(0), - option_npc: opt_result.try_read(2).unwrap_or(0), - option_text: opt_result.read_string(3), - action_menu_id: opt_result.try_read(4).unwrap_or(0), - box_money: opt_result.try_read(6).unwrap_or(0), - box_text: opt_result.read_string(7), - spell_id: opt_result.try_read(8), - override_icon_id: opt_result.try_read(9), - broadcast_text_id: opt_result.try_read::(10).unwrap_or(0), - }); - if !opt_result.next_row() { - break; + if !opt_result.is_empty() { + loop { + raw_options.push(RawOption { + menu_id: opt_result.try_read(0).unwrap_or(menu_id), + gossip_option_id: opt_result.try_read(1).unwrap_or(0), + option_id: opt_result.try_read(2).unwrap_or(0), + option_npc: opt_result.try_read(3).unwrap_or(0), + option_text: opt_result.read_string(4), + option_broadcast_text_id: opt_result.try_read::(5).unwrap_or(0), + language: opt_result.try_read::(6).unwrap_or(0), + flags: opt_result.try_read::(7).unwrap_or(0), + action_menu_id: opt_result.try_read(8).unwrap_or(0), + action_poi_id: opt_result.try_read(9).unwrap_or(0), + gossip_npc_option_id: opt_result.try_read(10), + box_coded: opt_result.try_read::(11).unwrap_or(0) != 0, + box_money: opt_result.try_read(12).unwrap_or(0), + box_text: opt_result.read_string(13), + box_broadcast_text_id: opt_result.try_read::(14).unwrap_or(0), + spell_id: opt_result.try_read(15), + override_icon_id: opt_result.try_read(16), + }); + if !opt_result.next_row() { + break; + } } } @@ -8670,9 +8810,9 @@ impl WorldSession { let mut text = opt.option_text.clone(); - if opt.broadcast_text_id != 0 && locale != "enUS" { + if opt.option_broadcast_text_id != 0 && locale != "enUS" { let mut stmt = world_db.prepare(WorldStatements::SEL_BROADCAST_TEXT_LOCALE); - stmt.set_u32(0, opt.broadcast_text_id); + stmt.set_u32(0, opt.option_broadcast_text_id); stmt.set_string(1, &locale); if let Ok(Ok(r)) = tokio::time::timeout(std::time::Duration::from_secs(2), world_db.query(&stmt)) @@ -8690,10 +8830,10 @@ impl WorldSession { gossip_options.push(ClientGossipOption { gossip_option_id: opt.gossip_option_id, option_npc: opt.option_npc, - option_flags: 0, + option_flags: i8::from(opt.box_coded), option_cost: opt.box_money as i32, - option_language: 0, - flags: 0, + option_language: i32::try_from(opt.language).unwrap_or(i32::MAX), + flags: opt.flags, order_index: opt.option_id as i32, status: 0, text, @@ -8704,15 +8844,51 @@ impl WorldSession { stored_options.push(GossipOptionInfo { gossip_option_id: opt.gossip_option_id, + menu_id: opt.menu_id, + order_index: opt.option_id, option_npc: opt.option_npc, action_menu_id: opt.action_menu_id, }); + + if opt.action_poi_id != 0 + || opt.gossip_npc_option_id.is_some() + || opt.box_broadcast_text_id != 0 + { + debug!( + account = self.account_id, + menu_id = opt.menu_id, + option_id = opt.option_id, + action_poi_id = opt.action_poi_id, + gossip_npc_option_id = ?opt.gossip_npc_option_id, + box_broadcast_text_id = opt.box_broadcast_text_id, + "Gossip option loaded C++ auxiliary fields for represented runtime" + ); + } + } + + // C++ `Player::PrepareGossipMenu` adds a trainer menu option automatically when + // a creature has trainer flags but no DB gossip option for `GossipOptionNpc::Trainer`. + // This is required for mixed questgiver+trainer NPCs such as Ranger Sallina. + add_represented_trainer_gossip_option_if_missing_like_cpp( + &mut gossip_options, + &mut stored_options, + npc_flags, + ); + + if gossip_options.is_empty() { + return None; } // Store gossip state for when the player selects an option. self.gossip_options = stored_options; self.gossip_source_guid = Some(npc_guid); + let gossip_text = if npc_flags & NPCFlags1::QUEST_GIVER.bits() != 0 { + self.represented_creature_gossip_text_like_cpp(entry) + } else { + Vec::new() + }; + Some(GossipMessage { gossip_guid: npc_guid, gossip_id: menu_id as i32, @@ -8720,42 +8896,65 @@ impl WorldSession { text_id: None, broadcast_text_id, gossip_options, - gossip_text: Vec::new(), + gossip_text, }) } + pub(crate) fn send_represented_creature_trainer_gossip_menu_like_cpp( + &mut self, + npc_guid: ObjectGuid, + entry: u32, + npc_flags: u32, + ) -> bool { + let mut gossip_options = Vec::new(); + let mut stored_options = Vec::new(); + if !add_represented_trainer_gossip_option_if_missing_like_cpp( + &mut gossip_options, + &mut stored_options, + npc_flags, + ) { + return false; + } + + let gossip_text = if npc_flags & NPCFlags1::QUEST_GIVER.bits() != 0 { + self.represented_creature_gossip_text_like_cpp(entry) + } else { + Vec::new() + }; + + self.gossip_options = stored_options; + self.gossip_source_guid = Some(npc_guid); + self.send_packet(&GossipMessage { + gossip_guid: npc_guid, + gossip_id: 0, + friendship_faction_id: 0, + text_id: Some(DEFAULT_GOSSIP_MESSAGE_LIKE_CPP), + broadcast_text_id: None, + gossip_options, + gossip_text, + }); + true + } + /// Direct interaction for NPCs without gossip menus (banker, auctioneer, etc.). - async fn handle_npc_direct_interaction(&mut self, hello: Hello) { + async fn handle_npc_direct_interaction(&mut self, hello: Hello, npc_flags: u32) { use wow_packet::packets::misc::{AuctionHelloResponse, NpcInteractionOpenResult}; - const VENDOR_MASK: u32 = 0x80 | 0x100 | 0x200 | 0x400 | 0x800; - const TRAINER_MASK: u32 = 0x10 | 0x20 | 0x40; - const FLIGHT_MASTER: u32 = 0x2000; - const AUCTIONEER: u32 = 0x200000; - const BANKER: u32 = 0x20000; - const TABARD_DESIGNER: u32 = 0x80000; - const STABLE_MASTER: u32 = 0x400000; - const GUILD_BANKER: u32 = 0x800000; - - let npc_flags = self - .mutate_world_creature(hello.unit, |creature| creature.npc_flags()) - .unwrap_or(0); - - if npc_flags & VENDOR_MASK != 0 { + if npc_flags & DIRECT_VENDOR_MASK_LIKE_CPP != 0 { self.handle_list_inventory(hello).await; - } else if npc_flags & TRAINER_MASK != 0 { + } else if npc_flags & DIRECT_TRAINER_MASK_LIKE_CPP != 0 { self.handle_trainer_list(hello).await; - } else if npc_flags & AUCTIONEER != 0 { + } else if npc_flags & DIRECT_AUCTIONEER_LIKE_CPP != 0 { self.send_packet(&AuctionHelloResponse::open(hello.unit)); - } else if npc_flags & BANKER != 0 { + } else if npc_flags & DIRECT_BANKER_LIKE_CPP != 0 { self.send_packet(&NpcInteractionOpenResult::new(hello.unit, 8)); - } else if npc_flags & FLIGHT_MASTER != 0 { + } else if npc_flags & DIRECT_FLIGHT_MASTER_LIKE_CPP != 0 { self.send_packet(&NpcInteractionOpenResult::new(hello.unit, 6)); - } else if npc_flags & TABARD_DESIGNER != 0 { + } else if npc_flags & DIRECT_TABARD_DESIGNER_LIKE_CPP != 0 { self.send_packet(&NpcInteractionOpenResult::new(hello.unit, 14)); - } else if npc_flags & STABLE_MASTER != 0 { + } else if npc_flags & DIRECT_STABLE_MASTER_LIKE_CPP != 0 { self.send_packet(&NpcInteractionOpenResult::new(hello.unit, 22)); - } else if npc_flags & GUILD_BANKER != 0 { + } else if npc_flags & DIRECT_GUILD_BANKER_LIKE_CPP != 0 { self.send_packet(&NpcInteractionOpenResult::new(hello.unit, 10)); } else { self.send_packet(&GossipMessage::empty(hello.unit, 0, 1)); @@ -8781,20 +8980,19 @@ impl WorldSession { let opt = self .gossip_options .iter() - .find(|o| o.gossip_option_id == select.gossip_option_id); - let (option_npc, _action_menu_id) = match opt { - Some(o) => (o.option_npc, o.action_menu_id), + .find(|o| o.gossip_option_id == select.gossip_option_id) + .cloned(); + let opt = match opt { + Some(o) => o, None => { warn!( - "GossipSelectOption: unknown gossip_option_id={} — closing.", + "GossipSelectOption: unknown gossip_option_id={} — ignoring like C++.", select.gossip_option_id ); - self.send_packet(&GossipComplete { - suppress_sound: false, - }); return; } }; + let (option_npc, _action_menu_id) = (opt.option_npc, opt.action_menu_id); let npc_guid = self.gossip_source_guid.unwrap_or(select.gossip_unit); info!( @@ -8802,11 +9000,6 @@ impl WorldSession { option_npc, npc_guid ); - // Close the gossip window before opening the interaction. - self.send_packet(&GossipComplete { - suppress_sound: false, - }); - let hello = Hello { unit: npc_guid }; match option_npc { 1 => { @@ -8819,7 +9012,12 @@ impl WorldSession { } 3 => { // Trainer - self.handle_trainer_list(hello).await; + self.handle_trainer_list_for_gossip_option_like_cpp( + hello, + opt.menu_id, + opt.order_index, + ) + .await; } 5 => { // Binder (Innkeeper) @@ -16767,6 +16965,27 @@ mod tests { ObjectGuid::create_world_object(HighGuid::GameObject, 0, 1, 571, 0, entry, counter) } + fn faction_template_entry( + id: u32, + faction: u16, + faction_group: u8, + friend_group: u8, + enemy: u16, + ) -> wow_data::progression_rewards::FactionTemplateEntry { + let mut enemies = [0; 8]; + enemies[0] = enemy; + wow_data::progression_rewards::FactionTemplateEntry { + id, + faction, + flags: 0, + faction_group, + friend_group, + enemy_group: 0, + enemies, + friend: [0; 8], + } + } + fn insert_creature(manager: &mut wow_map::MapManager, guid: ObjectGuid, entry: u32) { let mut creature = wow_entities::Creature::new(false); creature.unit_mut().world_mut().object_mut().create(guid); @@ -16875,6 +17094,36 @@ mod tests { session.set_canonical_map_manager(Arc::new(std::sync::Mutex::new(manager))); } + fn attach_legacy_creature( + session: &mut WorldSession, + guid: ObjectGuid, + entry: u32, + npc_flags: u32, + ) { + let manager = Arc::new(std::sync::RwLock::new(crate::map_manager::MapManager::new())); + manager.write().unwrap().add_creature( + 571, + 0, + 0, + 0, + crate::map_manager::WorldCreature::new( + guid, + entry, + Position::new(10.0, 0.0, 0.0, 0.0), + 100, + 80, + 1, + 2, + 0.0, + 1, + 35, + npc_flags, + 0, + ), + ); + session.set_map_manager(manager); + } + fn mark_gameobject_questgiver(session: &mut WorldSession, guid: ObjectGuid) { let mut state = crate::session::RepresentedGameObjectUseState::default(); state.go_type = Some(wow_entities::GAMEOBJECT_TYPE_QUESTGIVER as u8); @@ -16892,6 +17141,27 @@ mod tests { pkt } + fn quest_giver_hello_packet(guid: ObjectGuid) -> WorldPacket { + let mut pkt = WorldPacket::new_empty(); + pkt.write_packed_guid(&guid); + pkt.reset_read(); + pkt + } + + fn gossip_message_counts(bytes: &[u8], expected_guid: ObjectGuid) -> (i32, i32) { + assert_eq!( + wow_packet::WorldPacket::from_bytes(bytes).server_opcode(), + Some(ServerOpcodes::GossipMessage) + ); + let mut pkt = WorldPacket::from_bytes(&bytes[2..]); + assert_eq!(pkt.read_packed_guid().unwrap(), expected_guid); + let _gossip_id = pkt.read_int32().unwrap(); + let _friendship_faction_id = pkt.read_int32().unwrap(); + let option_count = pkt.read_int32().unwrap(); + let quest_count = pkt.read_int32().unwrap(); + (option_count, quest_count) + } + fn recv_status_multiple(send_rx: &flume::Receiver>) -> Vec<(ObjectGuid, u64)> { let bytes = send_rx .try_recv() @@ -16933,6 +17203,299 @@ mod tests { } } + #[tokio::test] + async fn gossip_hello_questgiver_without_db_gossip_menu_opens_quest_like_cpp() { + let (mut session, send_rx) = make_quest_status_session(); + let entry = 9306; + let guid = creature_guid(entry, 306); + let mut store = store_with_quests(&[3006]); + store.starter_quests.entry(entry).or_default().push(3006); + session.set_quest_store(Arc::new(store)); + attach_legacy_creature( + &mut session, + guid, + entry, + NPCFlags1::GOSSIP.bits() | NPCFlags1::QUEST_GIVER.bits(), + ); + + session.handle_gossip_hello(Hello { unit: guid }).await; + + assert_eq!( + drain_server_opcodes(&send_rx), + vec![ServerOpcodes::QuestGiverQuestDetails] + ); + } + + #[tokio::test] + async fn gossip_hello_mixed_direct_service_keeps_service_like_cpp() { + let (mut session, send_rx) = make_quest_status_session(); + let entry = 9309; + let guid = creature_guid(entry, 309); + let mut store = store_with_quests(&[3009]); + store.starter_quests.entry(entry).or_default().push(3009); + session.set_quest_store(Arc::new(store)); + attach_legacy_creature( + &mut session, + guid, + entry, + NPCFlags1::GOSSIP.bits() | NPCFlags1::QUEST_GIVER.bits() | NPCFlags1::BANKER.bits(), + ); + + session.handle_gossip_hello(Hello { unit: guid }).await; + + assert_eq!( + drain_server_opcodes(&send_rx), + vec![ServerOpcodes::NpcInteractionOpenResult] + ); + } + + #[tokio::test] + async fn gossip_hello_questgiver_without_quest_relation_keeps_empty_gossip_fallback() { + let (mut session, send_rx) = make_quest_status_session(); + let entry = 9307; + let guid = creature_guid(entry, 307); + session.set_quest_store(Arc::new(store_with_quests(&[3007]))); + attach_legacy_creature( + &mut session, + guid, + entry, + NPCFlags1::GOSSIP.bits() | NPCFlags1::QUEST_GIVER.bits(), + ); + + session.handle_gossip_hello(Hello { unit: guid }).await; + + assert_eq!( + drain_server_opcodes(&send_rx), + vec![ServerOpcodes::GossipMessage] + ); + } + + #[tokio::test] + async fn gossip_hello_hostile_questgiver_fallback_is_rejected_like_cpp() { + let (mut session, send_rx) = make_quest_status_session(); + let entry = 9310; + let guid = creature_guid(entry, 310); + let mut store = store_with_quests(&[3010]); + store.starter_quests.entry(entry).or_default().push(3010); + session.set_quest_store(Arc::new(store)); + session.set_player_faction_template_like_cpp(1); + session.set_faction_template_store(Arc::new( + wow_data::progression_rewards::FactionTemplateStore::from_entries([ + faction_template_entry(35, 35, 0, 0, 1), + faction_template_entry(1, 1, 0, 0, 0), + ]), + )); + attach_legacy_creature( + &mut session, + guid, + entry, + NPCFlags1::GOSSIP.bits() | NPCFlags1::QUEST_GIVER.bits(), + ); + + session.handle_gossip_hello(Hello { unit: guid }).await; + + assert!( + send_rx.try_recv().is_err(), + "C++ HandleGossipHelloOpcode returns when GetNPCIfCanInteractWith rejects a hostile questgiver" + ); + } + + #[tokio::test] + async fn quest_giver_hello_trainer_questgiver_sends_mixed_gossip_like_cpp() { + let (mut session, send_rx) = make_quest_status_session(); + let entry = 15_513; + let guid = creature_guid(entry, 513); + let mut store = store_with_quests(&[9_393]); + store.starter_quests.entry(entry).or_default().push(9_393); + session.set_quest_store(Arc::new(store)); + attach_legacy_creature( + &mut session, + guid, + entry, + NPCFlags1::GOSSIP.bits() + | NPCFlags1::QUEST_GIVER.bits() + | NPCFlags1::TRAINER.bits() + | NPCFlags1::TRAINER_CLASS.bits(), + ); + + session + .handle_quest_giver_hello(quest_giver_hello_packet(guid)) + .await; + + let bytes = send_rx.try_recv().expect("mixed prepared gossip menu"); + assert_eq!(gossip_message_counts(&bytes, guid), (1, 1)); + assert_eq!(session.gossip_source_guid, Some(guid)); + assert_eq!(session.gossip_options.len(), 1); + assert_eq!( + session.gossip_options[0].gossip_option_id, + GOSSIP_OPTION_ID_AUTO_TRAINER_LIKE_CPP + ); + assert_eq!( + session.gossip_options[0].option_npc, + GOSSIP_OPTION_NPC_TRAINER_LIKE_CPP + ); + assert!(send_rx.try_recv().is_err()); + } + + #[tokio::test] + async fn gossip_select_trainer_does_not_close_before_trainer_like_cpp() { + let (mut session, send_rx) = make_session_with_send_capacity(2); + let guid = creature_guid(15_513, 513); + let gossip_option_id = -1_702_912; + session.gossip_source_guid = Some(guid); + session + .gossip_options + .push(crate::session::GossipOptionInfo { + gossip_option_id, + menu_id: 6_652, + order_index: 0, + option_npc: GOSSIP_OPTION_NPC_TRAINER_LIKE_CPP, + action_menu_id: 0, + }); + + session + .handle_gossip_select_option(wow_packet::packets::gossip::GossipSelectOption { + gossip_unit: guid, + gossip_id: 6_652, + gossip_option_id, + promotion_code: String::new(), + }) + .await; + + // C++ dedicated trainer open flow sends TrainerList if it can resolve a trainer, + // but it does not pre-send `SMSG_GOSSIP_COMPLETE`. + assert!(send_rx.try_recv().is_err()); + } + + #[tokio::test] + async fn quest_giver_hello_plain_questgiver_keeps_direct_quest_open_like_cpp() { + let (mut session, send_rx) = make_quest_status_session(); + let entry = 9308; + let guid = creature_guid(entry, 308); + let mut store = store_with_quests(&[3008]); + store.starter_quests.entry(entry).or_default().push(3008); + session.set_quest_store(Arc::new(store)); + attach_legacy_creature( + &mut session, + guid, + entry, + NPCFlags1::GOSSIP.bits() | NPCFlags1::QUEST_GIVER.bits(), + ); + + session + .handle_quest_giver_hello(quest_giver_hello_packet(guid)) + .await; + + assert_eq!( + drain_server_opcodes(&send_rx), + vec![ServerOpcodes::QuestGiverQuestDetails] + ); + } + + #[test] + fn gossip_quest_text_filters_race_class_and_level_like_cpp() { + let (mut session, _send_rx) = make_quest_status_session(); + session.set_loaded_player_identity_like_cpp(571, 10, 3, 3, 0); + let entry = 15_278; + + let blood_elf_mask = 1u64 << (10 - 1); + let hunter_mask = 1u32 << (3 - 1); + let mage_mask = 1u32 << (8 - 1); + let human_mask = 1u64 << (1 - 1); + + let mut generic = quest_template(8_325); + generic.allowable_races = blood_elf_mask; + generic.min_level = 1; + generic.log_title = "Reclaiming Sunstrider Isle".into(); + + let mut hunter = quest_template(9_393); + hunter.allowable_races = blood_elf_mask; + hunter.allowable_classes = hunter_mask; + hunter.min_level = 1; + hunter.log_title = "Hunter Training".into(); + + let mut mage = quest_template(8_328); + mage.allowable_races = blood_elf_mask; + mage.allowable_classes = mage_mask; + mage.min_level = 1; + mage.log_title = "Mage Training".into(); + + let mut too_high = quest_template(99_001); + too_high.allowable_races = blood_elf_mask; + too_high.min_level = 4; + + let mut wrong_race = quest_template(99_002); + wrong_race.allowable_races = human_mask; + wrong_race.min_level = 1; + + let mut store = + QuestStore::from_quests_like_cpp([generic, hunter, mage, too_high, wrong_race]); + store + .starter_quests + .entry(entry) + .or_default() + .extend([8_325, 9_393, 8_328, 99_001, 99_002]); + session.set_quest_store(Arc::new(store)); + + let quest_text = session.represented_creature_gossip_text_like_cpp(entry); + + assert_eq!( + quest_text + .iter() + .map(|text| text.quest_id) + .collect::>(), + vec![8_325, 9_393] + ); + assert!(quest_text.iter().all(|text| text.quest_type == 2)); + } + + #[test] + fn gossip_quest_text_offers_sallina_followup_after_hunter_training_rewarded_like_cpp() { + let (mut session, _send_rx) = make_quest_status_session(); + session.set_loaded_player_identity_like_cpp(530, 10, 3, 3, 0); + let sallina_entry = 15_513; + let blood_elf_mask = 1u64 << (10 - 1); + let hunter_mask = 1u32 << (3 - 1); + + let mut hunter_training = quest_template(9_393); + hunter_training.allowable_races = blood_elf_mask; + hunter_training.allowable_classes = hunter_mask; + hunter_training.min_level = 1; + hunter_training.log_title = "Hunter Training".into(); + + let mut followup = quest_template(10_070); + followup.allowable_races = blood_elf_mask; + followup.allowable_classes = hunter_mask; + followup.min_level = 2; + followup.prev_quest_id = 9_393; + followup.exclusive_group = 10_068; + followup.log_title = "Well Watcher Solanian".into(); + + let mut store = QuestStore::from_quests_like_cpp([hunter_training, followup]); + store + .ender_quests + .entry(sallina_entry) + .or_default() + .push(9_393); + store + .starter_quests + .entry(sallina_entry) + .or_default() + .push(10_070); + session.set_quest_store(Arc::new(store)); + session.rewarded_quests.insert(9_393); + + let quest_text = session.represented_creature_gossip_text_like_cpp(sallina_entry); + + assert_eq!( + quest_text + .iter() + .map(|text| (text.quest_id, text.quest_title.as_str(), text.quest_type)) + .collect::>(), + vec![(10_070, "Well Watcher Solanian", 2)] + ); + } + #[tokio::test] async fn quest_giver_status_tracked_supplied_creature_not_visible_sends_available_like_cpp() { let (mut session, send_rx) = make_quest_status_session(); diff --git a/crates/wow-world/src/handlers/loot.rs b/crates/wow-world/src/handlers/loot.rs index e296da1e..71fb7014 100644 --- a/crates/wow-world/src/handlers/loot.rs +++ b/crates/wow-world/src/handlers/loot.rs @@ -84,6 +84,10 @@ use wow_packet::packets::loot::{ use wow_packet::packets::update::{ItemCreateData, UpdateObject}; use wow_packet::{ClientPacket, ServerPacket}; +use crate::conditions::{ + QUEST_STATUS_COMPLETE_LIKE_CPP, QUEST_STATUS_FAILED_LIKE_CPP, QUEST_STATUS_INCOMPLETE_LIKE_CPP, + QUEST_STATUS_NONE_LIKE_CPP, QUEST_STATUS_REWARDED_LIKE_CPP, +}; use crate::session::{ InventoryItem, RepresentedGameObjectSpellCaster, RepresentedGameObjectUseEffect, RepresentedLootRollState, RepresentedLootRollVote, @@ -3599,7 +3603,11 @@ impl WorldSession { }; self.set_represented_pending_quest_sharing_like_cpp(command.sender_guid, command.quest.id); - self.send_represented_quest_giver_quest_details_like_cpp(receiver_guid, &command.quest); + self.send_represented_quest_giver_quest_details_like_cpp( + receiver_guid, + &command.quest, + false, + ); } async fn handle_represented_loot_roll_vote_command_like_cpp( @@ -5470,7 +5478,7 @@ impl WorldSession { }; self.player_quests.values().any(|status| { - if status.status != 1 { + if status.status != QUEST_STATUS_INCOMPLETE_LIKE_CPP { return false; } @@ -5506,7 +5514,7 @@ impl WorldSession { }; self.player_quests.values().any(|status| { - if status.status != 1 { + if status.status != QUEST_STATUS_INCOMPLETE_LIKE_CPP { return false; } @@ -5584,7 +5592,7 @@ impl WorldSession { .active_quest_objective_counts .iter() .any(|(quest_id, objective_counts)| { - if player_context.quest_status(*quest_id) != 1 { + if player_context.quest_status(*quest_id) != QUEST_STATUS_INCOMPLETE_LIKE_CPP { return false; } @@ -5623,7 +5631,7 @@ impl WorldSession { .active_quest_statuses .iter() .any(|(quest_id, status)| { - if *status != 1 { + if *status != QUEST_STATUS_INCOMPLETE_LIKE_CPP { return false; } @@ -5688,8 +5696,10 @@ impl WorldSession { player_team_for_race_cpp_representable(player_context.race) == condition.value1, ), 8 => Some(player_context.rewarded_quests.contains(&condition.value1)), - 9 => Some(player_context.quest_status(condition.value1) == 1), - 14 => Some(player_context.quest_status(condition.value1) == 0), + 9 => Some( + player_context.quest_status(condition.value1) == QUEST_STATUS_INCOMPLETE_LIKE_CPP, + ), + 14 => Some(player_context.quest_status(condition.value1) == QUEST_STATUS_NONE_LIKE_CPP), 15 => Some( player_class_mask_like_cpp(player_context.class) .is_some_and(|mask| mask & condition.value1 != 0), @@ -5708,7 +5718,7 @@ impl WorldSession { condition.value1, ), 28 => Some( - player_context.quest_status(condition.value1) == 2 + player_context.quest_status(condition.value1) == QUEST_STATUS_COMPLETE_LIKE_CPP && !player_context.rewarded_quests.contains(&condition.value1), ), 47 => Some( @@ -6806,6 +6816,22 @@ impl WorldSession { } self.sync_object_accessor_player(); + let quest_log_item_id = self + .load_creature_item_template_addon_loot_metadata_like_cpp(item_id) + .await + .quest_log_item_id + .try_into() + .unwrap_or(0); + let mut changed_quest_ids = self + .apply_quest_source_item_added_non_bound_objective_progress_like_cpp( + item_id, + quest_log_item_id, + count, + ) + .await; + self.save_changed_represented_quest_statuses_like_cpp(&mut changed_quest_ids) + .await; + let map_id = self.player_map_id_like_cpp(); if !created_new_stacks.is_empty() { let item_creates = created_new_stacks @@ -7107,13 +7133,15 @@ struct RepresentedLootPlayerContext { impl RepresentedLootPlayerContext { fn quest_status(&self, quest_id: u32) -> u8 { - if self.rewarded_quests.contains(&quest_id) { - return 3; - } self.active_quest_statuses .get(&quest_id) .copied() - .unwrap_or(0) + .or_else(|| { + self.rewarded_quests + .contains(&quest_id) + .then_some(QUEST_STATUS_REWARDED_LIKE_CPP) + }) + .unwrap_or(QUEST_STATUS_NONE_LIKE_CPP) } fn inventory_item_count(&self, item_id: u32) -> u32 { @@ -7182,9 +7210,9 @@ fn player_quest_status_mask_like_cpp(status: Option, rewarded: bool) -> u32 match status { None => 0x01, - Some(2) => 0x02, - Some(1) => 0x08, - Some(3) => 0x20, + Some(QUEST_STATUS_COMPLETE_LIKE_CPP) => 0x02, + Some(QUEST_STATUS_INCOMPLETE_LIKE_CPP) => 0x08, + Some(QUEST_STATUS_FAILED_LIKE_CPP) => 0x20, _ => 0, } } @@ -8064,6 +8092,7 @@ mod tests { represented_loot_object_guid_like_cpp, represented_loot_response_items_like_cpp, select_weighted_random_enchantment_like_cpp, start_loot_roll_packet_like_cpp, }; + use crate::conditions::QUEST_STATUS_REWARDED_LIKE_CPP; use crate::session::{ RepresentedGameObjectSpellCaster, RepresentedGameObjectUseEffect, RepresentedLootRollCriteriaEvent, SessionState, @@ -9239,8 +9268,8 @@ mod tests { fn represented_personal_loot_remote_quest_and_spell_conditions_use_registry_like_cpp() { let (session, _) = make_session_with_send_capacity(1); let mut active_quest_statuses = HashMap::new(); - active_quest_statuses.insert(100, 1); - active_quest_statuses.insert(200, 2); + active_quest_statuses.insert(100, crate::conditions::QUEST_STATUS_INCOMPLETE_LIKE_CPP); + active_quest_statuses.insert(200, crate::conditions::QUEST_STATUS_COMPLETE_LIKE_CPP); let mut rewarded_quests = HashSet::new(); rewarded_quests.insert(300); let remote_context = RepresentedLootPlayerContext { @@ -9284,6 +9313,18 @@ mod tests { ), Some(true) ); + assert_eq!( + remote_context.quest_status(300), + QUEST_STATUS_REWARDED_LIKE_CPP + ); + assert_eq!( + session.evaluate_creature_loot_condition_for_player_like_cpp_representable( + &loot_condition(14, 300, 0, 0), + &remote_context, + ), + Some(false), + "C++ Player::GetQuestStatus returns REWARDED before QUEST_STATUS_NONE" + ); assert_eq!( session.evaluate_creature_loot_condition_for_player_like_cpp_representable( &loot_condition(25, 12_345, 0, 0), @@ -9321,7 +9362,7 @@ mod tests { quest_store.quests.insert(100, quest); session.set_quest_store(Arc::new(quest_store)); let mut active_quest_statuses = HashMap::new(); - active_quest_statuses.insert(100, 1); + active_quest_statuses.insert(100, crate::conditions::QUEST_STATUS_INCOMPLETE_LIKE_CPP); let mut active_quest_objective_counts = HashMap::new(); active_quest_objective_counts.insert(100, vec![5]); let mut inventory_item_counts = HashMap::new(); @@ -9399,7 +9440,7 @@ mod tests { session.set_quest_store(Arc::new(quest_store)); let mut active_quest_statuses = HashMap::new(); - active_quest_statuses.insert(100, 1); + active_quest_statuses.insert(100, crate::conditions::QUEST_STATUS_INCOMPLETE_LIKE_CPP); let mut active_quest_objective_counts = HashMap::new(); active_quest_objective_counts.insert(100, vec![2]); let mut remote_context = RepresentedLootPlayerContext { @@ -9445,7 +9486,7 @@ mod tests { session.set_quest_store(Arc::new(quest_store)); let mut active_quest_statuses = HashMap::new(); - active_quest_statuses.insert(200, 1); + active_quest_statuses.insert(200, crate::conditions::QUEST_STATUS_INCOMPLETE_LIKE_CPP); let mut inventory_item_counts = HashMap::new(); inventory_item_counts.insert(7002, 3); let mut remote_context = RepresentedLootPlayerContext { @@ -9477,6 +9518,115 @@ mod tests { )); } + #[tokio::test] + async fn loot_item_added_progresses_incomplete_quest_item_objective_like_cpp() { + let (mut session, _) = make_session_with_send_capacity(1); + let quest_id = 8_336; + let item_id = 20_482; + let mut quest = test_quest_template(quest_id); + quest.objectives.push(QuestObjective { + id: quest_id * 10, + quest_id, + obj_type: 1, + order: 0, + storage_index: 0, + object_id: item_id as i32, + amount: 6, + flags: 0, + flags2: 0, + progress_bar_weight: 0.0, + description: String::new(), + }); + session.set_quest_store(Arc::new(QuestStore::from_quests_like_cpp([quest]))); + session.player_quests.insert( + quest_id, + crate::handlers::quest::PlayerQuestStatus { + quest_id, + status: crate::conditions::QUEST_STATUS_INCOMPLETE_LIKE_CPP, + explored: false, + accept_time_secs: 0, + end_time_secs: 0, + objective_counts: vec![0], + slot: 0, + }, + ); + + assert!(session.item_loot_quest_status_allows_like_cpp( + item_id, + true, + ItemTemplateAddonLootMetadataLikeCpp::default(), + )); + + let changed_quest_ids = session + .apply_quest_source_item_added_non_bound_objective_progress_like_cpp(item_id, 0, 3) + .await; + + assert_eq!(changed_quest_ids, vec![quest_id]); + assert_eq!( + session + .player_quests + .get(&quest_id) + .expect("quest progress should remain active") + .objective_counts, + vec![3] + ); + } + + #[tokio::test] + async fn loot_item_eligibility_does_not_treat_complete_quest_as_incomplete_like_cpp() { + let (mut session, _) = make_session_with_send_capacity(1); + let quest_id = 8_337; + let item_id = 20_483; + let mut quest = test_quest_template(quest_id); + quest.objectives.push(QuestObjective { + id: quest_id * 10, + quest_id, + obj_type: 1, + order: 0, + storage_index: 0, + object_id: item_id as i32, + amount: 1, + flags: 0, + flags2: 0, + progress_bar_weight: 0.0, + description: String::new(), + }); + session.set_quest_store(Arc::new(QuestStore::from_quests_like_cpp([quest]))); + session.player_quests.insert( + quest_id, + crate::handlers::quest::PlayerQuestStatus { + quest_id, + status: crate::conditions::QUEST_STATUS_COMPLETE_LIKE_CPP, + explored: false, + accept_time_secs: 0, + end_time_secs: 0, + objective_counts: vec![0], + slot: 0, + }, + ); + + assert!(!session.has_incomplete_quest_objective_for_item_like_cpp(item_id)); + assert!(!session.item_loot_quest_status_allows_like_cpp( + item_id, + true, + ItemTemplateAddonLootMetadataLikeCpp::default(), + )); + + let changed_quest_ids = session + .apply_quest_source_item_added_non_bound_objective_progress_like_cpp(item_id, 0, 1) + .await; + + assert!(changed_quest_ids.is_empty()); + assert_eq!( + session + .player_quests + .get(&quest_id) + .expect("complete quest should not progress as incomplete") + .objective_counts, + vec![0] + ); + } + fn install_master_loot_group( session: &mut WorldSession, master_guid: ObjectGuid, diff --git a/crates/wow-world/src/handlers/misc.rs b/crates/wow-world/src/handlers/misc.rs index 39e7bf88..6c5bbcae 100644 --- a/crates/wow-world/src/handlers/misc.rs +++ b/crates/wow-world/src/handlers/misc.rs @@ -8211,7 +8211,7 @@ mod tests { } #[tokio::test] - async fn update_account_data_ignores_per_character_data_without_player_guid_like_cpp() { + async fn update_account_data_ignores_per_character_data_without_recent_player_guid_like_cpp() { let (mut session, _send_rx) = make_session(); let player_guid = ObjectGuid::create_player(1, 42); @@ -8229,6 +8229,27 @@ mod tests { assert!(account_data.data.is_empty()); } + #[tokio::test] + async fn update_account_data_accepts_per_character_data_after_logout_like_cpp() { + let (mut session, _send_rx) = make_session(); + let player_guid = ObjectGuid::create_player(1, 42); + session.set_player_guid(Some(player_guid)); + session.set_player_guid(None); + + session + .handle_update_account_data(update_account_data_packet( + player_guid, + 1, + 1234, + "SET trackedQuests \"v11#|h#|U$2=\"\r\n", + )) + .await; + + let account_data = session.account_data_like_cpp(1).unwrap(); + assert_eq!(account_data.time, 1234); + assert_eq!(account_data.data, "SET trackedQuests \"v11#|h#|U$2=\"\r\n"); + } + #[tokio::test] async fn update_account_data_rejects_invalid_type_and_oversize_like_cpp() { let (mut session, _send_rx) = make_session(); @@ -15881,6 +15902,8 @@ mod tests { .gossip_options .push(crate::session::GossipOptionInfo { gossip_option_id: 1, + menu_id: 0, + order_index: 0, option_npc: 2, action_menu_id: 3, }); @@ -15905,6 +15928,8 @@ mod tests { .gossip_options .push(crate::session::GossipOptionInfo { gossip_option_id: 1, + menu_id: 0, + order_index: 0, option_npc: 2, action_menu_id: 3, }); diff --git a/crates/wow-world/src/handlers/quest.rs b/crates/wow-world/src/handlers/quest.rs index d2b5bf95..6e5bbf8c 100644 --- a/crates/wow-world/src/handlers/quest.rs +++ b/crates/wow-world/src/handlers/quest.rs @@ -9,7 +9,7 @@ //! CMSG_QUEST_GIVER_STATUS_QUERY → SMSG_QUEST_GIVER_STATUS //! CMSG_QUEST_GIVER_HELLO → SMSG_QUEST_GIVER_QUEST_LIST_MESSAGE //! CMSG_QUEST_GIVER_QUERY_QUEST → SMSG_QUEST_GIVER_QUEST_DETAILS -//! CMSG_QUEST_GIVER_ACCEPT_QUEST → save to DB + SMSG_QUEST_GIVER_QUEST_COMPLETE +//! CMSG_QUEST_GIVER_ACCEPT_QUEST → save to DB + player quest-log update //! CMSG_QUEST_LOG_REMOVE_QUEST → remove from DB //! CMSG_QUERY_QUEST_INFO → SMSG_QUERY_QUEST_INFO_RESPONSE //! @@ -57,7 +57,9 @@ use wow_packet::packets::quest::{ QuestRewardsBlock, QuestUpdateComplete, WorldQuestUpdateResponse, quest_giver_status, quest_push_reason, }; -use wow_packet::packets::update::{ItemCreateData, UpdateObject}; +use wow_packet::packets::update::{ + ItemCreateData, PlayerDataValuesDeltaUpdate, QuestLogValuesUpdate, UpdateObject, +}; use wow_packet::{ClientPacket, ServerPacket}; use crate::conditions::{ @@ -78,6 +80,14 @@ use crate::session::{ SeasonalQuestStatusDbRowLikeCpp, WorldSession, }; +fn quest_giver_creature_id_from_source_like_cpp(source_guid: ObjectGuid) -> i32 { + if source_guid.is_any_type_creature() { + i32::try_from(source_guid.entry()).unwrap_or(0) + } else { + 0 + } +} + pub(crate) const QUEST_FLAGS_AUTO_COMPLETE_LIKE_CPP: u32 = 0x0001_0000; pub(crate) const QUEST_FLAGS_PLAYER_CAST_COMPLETE_LIKE_CPP: u32 = 0x0020_0000; pub(crate) const QUEST_FLAGS_SHARABLE_LIKE_CPP: u32 = 0x0000_0008; @@ -86,6 +96,9 @@ const QUEST_FLAGS_COMPLETION_AREA_TRIGGER_LIKE_CPP: u32 = 0x0000_0004; const QUEST_FLAGS_TRACKING_EVENT_LIKE_CPP: u32 = 0x0000_0400; const QUEST_FLAGS_EX_REWARDS_IGNORE_CAPS_LIKE_CPP: u32 = 0x0080_0000; const QUEST_FLAGS_EX_IS_WORLD_QUEST_LIKE_CPP: u32 = 0x0100_0000; +const QUEST_STATE_COMPLETE_LIKE_CPP: u32 = 0x0001; +const QUEST_STATE_FAIL_LIKE_CPP: u32 = 0x0002; +const QUEST_STATE_OBJECTIVE_FLAG_BASE_LIKE_CPP: u32 = 256; pub(crate) const QUEST_PUSH_REASON_INVALID_LIKE_CPP: u8 = 1; pub(crate) const QUEST_PUSH_REASON_INVALID_TO_RECIPIENT_LIKE_CPP: u8 = 2; const QUEST_OBJECTIVE_MONSTER_LIKE_CPP_LOCAL: u8 = 0; @@ -93,6 +106,8 @@ const QUEST_OBJECTIVE_ITEM_LIKE_CPP_LOCAL: u8 = 1; const QUEST_OBJECTIVE_GAMEOBJECT_LIKE_CPP_LOCAL: u8 = 2; const QUEST_OBJECTIVE_TALKTO_LIKE_CPP_LOCAL: u8 = 3; const QUEST_OBJECTIVE_CURRENCY_LIKE_CPP_LOCAL: u8 = 4; +#[cfg(test)] +const QUEST_OBJECTIVE_MONEY_LIKE_CPP_LOCAL: u8 = 8; const QUEST_OBJECTIVE_PLAYERKILLS_LIKE_CPP_LOCAL: u8 = 9; const QUEST_OBJECTIVE_WINPVPPETBATTLES_LIKE_CPP_LOCAL: u8 = 13; const QUEST_OBJECTIVE_CRITERIA_TREE_LIKE_CPP_LOCAL: u8 = 14; @@ -939,7 +954,7 @@ impl WorldSession { true } - async fn save_represented_quest_status_like_cpp(&mut self, quest_id: u32) { + pub(crate) async fn save_represented_quest_status_like_cpp(&self, quest_id: u32) { if let Some(status) = self .player_quests .get(&quest_id) @@ -949,6 +964,86 @@ impl WorldSession { } } + pub(crate) async fn save_changed_represented_quest_statuses_like_cpp( + &self, + quest_ids: &mut Vec, + ) { + quest_ids.sort_unstable(); + quest_ids.dedup(); + for quest_id in quest_ids.drain(..) { + self.save_represented_quest_status_like_cpp(quest_id).await; + } + } + + pub(crate) fn represented_quest_statuses_for_save_like_cpp(&self) -> Vec<(u32, u8)> { + let mut quests = self + .player_quests + .iter() + .filter_map(|(quest_id, status)| { + if self.rewarded_quests.contains(quest_id) + && self + .quest_store + .as_ref() + .and_then(|store| store.get(*quest_id)) + .is_some_and(|quest| !quest.is_repeatable()) + { + return None; + } + + Some((*quest_id, status.status)) + }) + .collect::>(); + quests.sort_by_key(|(quest_id, _)| *quest_id); + quests + } + + pub(crate) fn remove_represented_active_rewarded_duplicates_like_cpp(&mut self) -> Vec { + let mut duplicate_quest_ids = self + .player_quests + .keys() + .filter(|quest_id| { + self.rewarded_quests.contains(quest_id) + && self + .quest_store + .as_ref() + .and_then(|store| store.get(**quest_id)) + .is_some_and(|quest| !quest.is_repeatable()) + }) + .copied() + .collect::>(); + duplicate_quest_ids.sort_unstable(); + duplicate_quest_ids.dedup(); + + for quest_id in &duplicate_quest_ids { + self.player_quests.remove(quest_id); + } + + if !duplicate_quest_ids.is_empty() { + let mut remaining_slots = self + .player_quests + .iter() + .map(|(quest_id, status)| (*quest_id, status.slot)) + .collect::>(); + remaining_slots.sort_by_key(|(_, slot)| *slot); + for (slot, (quest_id, _)) in remaining_slots.into_iter().enumerate() { + if let Some(status) = self.player_quests.get_mut(&quest_id) { + status.slot = + u8::try_from(slot).unwrap_or(MAX_QUEST_LOG_SIZE_LIKE_CPP.saturating_sub(1)); + } + } + } + + duplicate_quest_ids + } + + pub(crate) async fn save_player_quest_statuses_to_db_like_cpp(&self) { + // C++ Player::SaveToDB delegates to Player::_SaveQuestStatus. Rust has no + // m_QuestStatusSave dirty map yet, so save the represented active quest set. + for (quest_id, status) in self.represented_quest_statuses_for_save_like_cpp() { + self.save_quest_to_db(quest_id, status).await; + } + } + async fn quest_source_item_quest_log_item_id_like_cpp(&mut self, entry_id: u32) -> u32 { if let Some(quest_log_item_id) = self.item_template_addon_quest_log_item_id_like_cpp(entry_id) @@ -984,7 +1079,7 @@ impl WorldSession { quest_log_item_id } - async fn apply_quest_source_item_added_non_bound_objective_progress_like_cpp( + pub(crate) async fn apply_quest_source_item_added_non_bound_objective_progress_like_cpp( &mut self, entry_id: u32, quest_log_item_id: u32, @@ -1323,7 +1418,7 @@ impl WorldSession { /// - `WorldSession::HandleQuestgiverHelloOpcode`, `QuestHandler.cpp:76-103`. /// - `Player::PrepareQuestMenu`, `Player.cpp:13947-14004`. /// Remaining represented gaps: fake-death aura removal, `AI()->OnGossipHello`, - /// `PrepareGossipMenu`, `SendPreparedGossip` / auto-open / PlayerTalkClass. + /// full PlayerTalkClass ownership. pub async fn handle_quest_giver_hello(&mut self, mut pkt: wow_packet::WorldPacket) { let guid = match pkt.read_packed_guid() { Ok(g) => g, @@ -1346,6 +1441,34 @@ impl WorldSession { self.pause_interacted_creature_movement_like_cpp(guid); + if (access.npc_flags & NPCFlags1::GOSSIP.bits()) != 0 + && let Some(world_db) = self.world_db().map(Arc::clone) + && let Some(msg) = self + .build_gossip_menu(&world_db, access.entry, access.npc_flags, guid) + .await + { + debug!( + account = self.account_id, + creature_entry = access.entry, + "QuestGiverHello sent DB-backed prepared gossip menu like C++" + ); + self.send_packet(&msg); + return; + } + + if self.send_represented_creature_trainer_gossip_menu_like_cpp( + guid, + access.entry, + access.npc_flags, + ) { + debug!( + account = self.account_id, + creature_entry = access.entry, + "QuestGiverHello sent trainer fallback prepared gossip menu like C++" + ); + return; + } + if self.use_represented_creature_questgiver_like_cpp(guid, access.entry) { debug!( account = self.account_id, @@ -1359,7 +1482,7 @@ impl WorldSession { /// Shows full quest details (objectives, rewards) before accepting. /// Legacy non-canonical note: QuestHandler.HandleQuestGiverQueryQuest pub async fn handle_quest_giver_query_quest(&mut self, mut pkt: wow_packet::WorldPacket) { - let (guid, quest_id, _respond_to_giver) = + let (guid, quest_id, respond_to_giver) = match read_quest_giver_query_quest_like_cpp(&mut pkt) { Ok(packet) => packet, Err(_) => { @@ -1368,15 +1491,28 @@ impl WorldSession { } }; - let _ = self.send_represented_quest_giver_query_quest_like_cpp(guid, quest_id); + info!( + account = self.account_id, + ?guid, + quest_id, + respond_to_giver, + "Received QuestGiverQueryQuest like C++" + ); + if !self.send_represented_quest_giver_query_quest_like_cpp(guid, quest_id) { + warn!( + account = self.account_id, + ?guid, + quest_id, + "QuestGiverQueryQuest produced no represented response" + ); + } } /// CMSG_QUEST_GIVER_ACCEPT_QUEST — player clicks "Accept" in the quest details dialog. /// Saves quest to characters DB and confirms to the client. /// Legacy non-canonical note: QuestHandler.HandleQuestGiverAcceptQuest pub async fn handle_quest_giver_accept_quest(&mut self, mut pkt: wow_packet::WorldPacket) { - let (guid, quest_id, _start_cheat) = match read_quest_giver_accept_quest_like_cpp(&mut pkt) - { + let (guid, quest_id, start_cheat) = match read_quest_giver_accept_quest_like_cpp(&mut pkt) { Ok(packet) => packet, Err(_) => { warn!("QuestGiverAcceptQuest: failed to read packet"); @@ -1384,6 +1520,14 @@ impl WorldSession { } }; + info!( + account = self.account_id, + ?guid, + quest_id, + start_cheat, + "Received QuestGiverAcceptQuest like C++" + ); + // Validate represented C++ source/relation before any quest-log mutation or DB save. // C++ HandleQuestgiverAcceptQuestOpcode closes gossip and clears sharing info on // failure; this represented slice intentionally models that as no packet/no mutation. @@ -1396,7 +1540,7 @@ impl WorldSession { quest_id, &quest_store, ) { - debug!( + warn!( account = self.account_id, ?guid, quest_id, @@ -1465,6 +1609,7 @@ impl WorldSession { self.save_quest_to_db(quest_id, status).await; } self.sync_player_registry_state_like_cpp(); + self.send_represented_quest_log_slot_update_like_cpp(slot); info!(account = self.account_id, quest_id, "Quest accepted"); @@ -4682,6 +4827,7 @@ impl WorldSession { self.player_quests.remove(&qid); self.delete_quest_from_db(qid).await; self.sync_player_registry_state_like_cpp(); + self.send_represented_quest_log_slot_update_like_cpp(slot); info!( account = self.account_id, quest_id = qid, @@ -4697,13 +4843,15 @@ impl WorldSession { fn quest_slot_has_active_entry_like_cpp(&self, slot: u8) -> bool { // C++ `QuestSlotOffset` stores the quest id independently from the status fields; - // represented active slots are COMPLETE or INCOMPLETE only for this bounded helper. + // represented active slots are INCOMPLETE, COMPLETE, or FAILED. slot < MAX_QUEST_LOG_SIZE_LIKE_CPP && self.player_quests.values().any(|status| { status.slot == slot && matches!( status.status, - QUEST_STATUS_COMPLETE_LIKE_CPP | QUEST_STATUS_INCOMPLETE_LIKE_CPP + QUEST_STATUS_INCOMPLETE_LIKE_CPP + | QUEST_STATUS_COMPLETE_LIKE_CPP + | QUEST_STATUS_FAILED_LIKE_CPP ) }) } @@ -4718,7 +4866,9 @@ impl WorldSession { status.slot == slot && matches!( status.status, - QUEST_STATUS_COMPLETE_LIKE_CPP | QUEST_STATUS_INCOMPLETE_LIKE_CPP + QUEST_STATUS_INCOMPLETE_LIKE_CPP + | QUEST_STATUS_COMPLETE_LIKE_CPP + | QUEST_STATUS_FAILED_LIKE_CPP ) }) { if matching_quest_id.is_some() { @@ -4736,7 +4886,9 @@ impl WorldSession { (status.slot < MAX_QUEST_LOG_SIZE_LIKE_CPP && matches!( status.status, - QUEST_STATUS_COMPLETE_LIKE_CPP | QUEST_STATUS_INCOMPLETE_LIKE_CPP + QUEST_STATUS_INCOMPLETE_LIKE_CPP + | QUEST_STATUS_COMPLETE_LIKE_CPP + | QUEST_STATUS_FAILED_LIKE_CPP )) .then_some(status.slot) }) @@ -4752,20 +4904,74 @@ impl WorldSession { return (0, 0, 0, [0; 24]); }; - let state_flags: u32 = if qs.status == QUEST_STATUS_COMPLETE_LIKE_CPP { - 1 - } else { - 0 + let quest = self + .quest_store + .as_ref() + .and_then(|store| store.get(qs.quest_id)); + let mut state_flags: u32 = match qs.status { + QUEST_STATUS_COMPLETE_LIKE_CPP => QUEST_STATE_COMPLETE_LIKE_CPP, + QUEST_STATUS_FAILED_LIKE_CPP => QUEST_STATE_FAIL_LIKE_CPP, + _ => 0, }; let mut obj_progress = [0u16; 24]; - for (i, &count) in qs.objective_counts.iter().enumerate().take(24) { - obj_progress[i] = count.min(u16::MAX as i32) as u16; + for (i, slot_progress) in obj_progress.iter_mut().enumerate() { + let count = qs.objective_counts.get(i).copied().unwrap_or(0); + let stores_flag = quest.is_some_and(|quest| { + quest.objectives.iter().any(|objective| { + objective.storage_index == i as i8 + && objective.is_storing_flag_like_cpp() + }) + }); + if stores_flag { + if count != 0 { + state_flags |= QUEST_STATE_OBJECTIVE_FLAG_BASE_LIKE_CPP << i; + } + continue; + } + *slot_progress = count.min(u16::MAX as i32) as u16; } - (qs.quest_id, state_flags, 0i64, obj_progress) + (qs.quest_id, state_flags, qs.end_time_secs, obj_progress) }) .collect() } + pub(crate) fn send_represented_quest_log_slot_update_like_cpp(&mut self, slot: u8) { + if slot >= MAX_QUEST_LOG_SIZE_LIKE_CPP { + return; + } + let Some(guid) = self.player_guid() else { + return; + }; + + let Some((quest_id, state_flags, end_time, objective_progress)) = self + .quest_log_create_entries_like_cpp() + .get(slot as usize) + .copied() + else { + return; + }; + + let mut data = PlayerDataValuesDeltaUpdate::default(); + data.player_data_mask[35 / 32] |= 1 << (35 % 32); + let slot_bit = 36 + usize::from(slot); + data.player_data_mask[slot_bit / 32] |= 1 << (slot_bit % 32); + data.quest_log[slot as usize] = QuestLogValuesUpdate { + // C++ Player::SetQuestSlot marks QuestID, StateFlags, EndTime, + // and every ObjectiveProgress field changed for the slot. + quest_log_mask: 0x1FFF_FFFF, + end_time, + quest_id: quest_id.min(i32::MAX as u32) as i32, + state_flags, + objective_progress, + }; + + self.send_packet(&UpdateObject::full_player_values_update( + guid, + self.player_map_id_like_cpp(), + data, + )); + } + /// CMSG_QUERY_QUEST_INFO — client asks for full quest template data by ID. /// Used to populate the quest log and tooltip. /// Legacy non-canonical note: QuestHandler.HandleQueryQuestInfo @@ -4915,6 +5121,13 @@ impl WorldSession { }; let quest_id: u32 = pkt.read_uint32().unwrap_or(0); + info!( + account = self.account_id, + ?guid, + quest_id, + "Received QuestGiverRequestReward like C++" + ); + let quest_store = match &self.quest_store { Some(s) => Arc::clone(s), None => return, @@ -4945,7 +5158,7 @@ impl WorldSession { &quest_store, ) { - debug!( + warn!( account = self.account_id, ?guid, quest_id, @@ -4983,7 +5196,7 @@ impl WorldSession { if !is_complete { // Objectives not finished — silently ignore // (C# would send SMSG_QUEST_GIVER_REQUEST_ITEMS instead) - debug!( + warn!( account = self.account_id, quest_id, "RequestReward: quest not complete" ); @@ -5007,10 +5220,12 @@ impl WorldSession { quest.reward_choice_items[i].1, ); } + rewards.choice_item_types = quest.reward_choice_item_types; // C#: SendQuestGiverOfferReward(quest, questGiverGUID, true) self.send_packet(&QuestGiverOfferReward { giver_guid: guid, + giver_creature_id: quest_giver_creature_id_from_source_like_cpp(guid), quest_id, quest_flags: [quest.flags, quest.flags_ex, quest.flags_ex2], suggested_party_members: quest.suggested_group_num, @@ -5035,6 +5250,14 @@ impl WorldSession { let quest_id: u32 = pkt.read_uint32().unwrap_or(0); let from_script: bool = pkt.read_bit().unwrap_or(false); + info!( + account = self.account_id, + ?guid, + quest_id, + from_script, + "Received QuestGiverCompleteQuest like C++" + ); + let quest_store = match &self.quest_store { Some(s) => Arc::clone(s), None => return, @@ -5067,7 +5290,7 @@ impl WorldSession { &quest_store, ) { - debug!( + warn!( account = self.account_id, ?guid, quest_id, @@ -5077,7 +5300,7 @@ impl WorldSession { return; } } else if !from_script || self.player_guid() != Some(guid) { - debug!( + warn!( account = self.account_id, ?guid, quest_id, @@ -5106,6 +5329,13 @@ impl WorldSession { rewards.display_spells[i] = quest.reward_display_spell[i]; } rewards.completion_spell = quest.reward_spell as i32; + for i in 0..6 { + rewards.choice_items[i] = ( + quest.reward_choice_items[i].0, + quest.reward_choice_items[i].1, + ); + } + rewards.choice_item_types = quest.reward_choice_item_types; // Check if all objectives are done — C++ GetQuestStatus == QUEST_STATUS_COMPLETE. let is_complete = self @@ -5118,7 +5348,7 @@ impl WorldSession { // Legacy non-canonical note: SendQuestGiverRequestItems(quest, guid, canComplete=false, false) self.send_packet(&QuestGiverRequestItems { giver_guid: guid, - giver_creature_id: i32::try_from(guid.entry()).unwrap_or(0), + giver_creature_id: quest_giver_creature_id_from_source_like_cpp(guid), quest_id, comp_emote_delay: 0, comp_emote_type: 0, @@ -5138,6 +5368,7 @@ impl WorldSession { // All objectives done — show offer reward dialog self.send_packet(&QuestGiverOfferReward { giver_guid: guid, + giver_creature_id: quest_giver_creature_id_from_source_like_cpp(guid), quest_id, quest_flags: [quest.flags, quest.flags_ex, quest.flags_ex2], suggested_party_members: quest.suggested_group_num, @@ -5605,6 +5836,15 @@ impl WorldSession { }; let choice_item_id = choice.item_id; + info!( + account = self.account_id, + ?guid, + quest_id, + choice_item_id, + choice_loot_type = choice.loot_item_type, + "Received QuestGiverChooseReward like C++" + ); + let quest_store = match &self.quest_store { Some(s) => Arc::clone(s), None => return, @@ -5699,7 +5939,7 @@ impl WorldSession { &quest_store, ) { - debug!( + warn!( account = self.account_id, ?guid, quest_id, @@ -6102,7 +6342,7 @@ impl WorldSession { .and_then(|store| store.get(quest.quest_info_id as u32)) } - fn represented_quest_is_important_like_cpp( + pub(crate) fn represented_quest_is_important_like_cpp( &self, quest: &wow_data::quest::QuestTemplate, ) -> bool { @@ -6534,7 +6774,7 @@ impl WorldSession { true } - fn is_quest_disabled_like_cpp(&self, quest_id: u32) -> bool { + pub(crate) fn is_quest_disabled_like_cpp(&self, quest_id: u32) -> bool { self.disable_mgr().is_some_and(|disable_mgr| { disable_mgr.is_disabled_for_like_cpp(DISABLE_TYPE_QUEST, quest_id, None, 0, None) }) @@ -6546,19 +6786,35 @@ impl WorldSession { /// The represented path keeps Rust's existing direct save timing, but mirrors the /// C++ objective persistence order for a saved quest: status row first, then delete /// stale objective rows for the quest, then replace nonzero objective counters. - async fn save_quest_to_db(&self, quest_id: u32, status: u8) { - use wow_database::CharStatements; - - let guid = match self.player_guid() { - Some(g) => g.counter() as u64, - None => return, - }; - let char_db = match self.char_db() { - Some(db) => Arc::clone(db), - None => return, - }; + fn represented_quest_status_save_statements_like_cpp( + &self, + guid: u64, + quest_id: u32, + status: u8, + mut prepare: impl FnMut(CharStatements) -> PreparedStatement, + ) -> Vec { + let mut statements = Vec::new(); + + if status == QUEST_STATUS_REWARDED_LIKE_CPP { + let mut del_status = prepare(CharStatements::DEL_CHAR_QUEST_STATUS); + del_status.set_u64(0, guid); + del_status.set_u32(1, quest_id); + statements.push(del_status); + + let mut del_objectives = + prepare(CharStatements::DEL_CHAR_QUEST_STATUS_OBJECTIVES_BY_QUEST); + del_objectives.set_u64(0, guid); + del_objectives.set_u32(1, quest_id); + statements.push(del_objectives); + + let mut rewarded = prepare(CharStatements::INS_CHAR_QUESTSTATUS_REWARDED); + rewarded.set_u64(0, guid); + rewarded.set_u32(1, quest_id); + statements.push(rewarded); + + return statements; + } - let mut tx = SqlTransaction::new(); let represented_explored = self .player_quests .get(&quest_id) @@ -6574,20 +6830,19 @@ impl WorldSession { .get(&quest_id) .map(|status| status.end_time_secs) .unwrap_or(0); - let mut stmt = char_db.prepare(CharStatements::INS_CHAR_QUEST_STATUS); + let mut stmt = prepare(CharStatements::INS_CHAR_QUEST_STATUS); stmt.set_u64(0, guid); stmt.set_u32(1, quest_id); stmt.set_u8(2, status); stmt.set_u8(3, u8::from(represented_explored)); stmt.set_i64(4, represented_accept_time); stmt.set_i64(5, represented_end_time); - tx.append(stmt); + statements.push(stmt); - let mut del_objectives = - char_db.prepare(CharStatements::DEL_CHAR_QUEST_STATUS_OBJECTIVES_BY_QUEST); + let mut del_objectives = prepare(CharStatements::DEL_CHAR_QUEST_STATUS_OBJECTIVES_BY_QUEST); del_objectives.set_u64(0, guid); del_objectives.set_u32(1, quest_id); - tx.append(del_objectives); + statements.push(del_objectives); if let (Some(quest_store), Some(saved_status)) = (self.quest_store.as_ref(), self.player_quests.get(&quest_id)) @@ -6610,16 +6865,38 @@ impl WorldSession { let Ok(objective_index) = u8::try_from(objective.storage_index) else { continue; }; - let mut rep_objective = - char_db.prepare(CharStatements::REP_CHAR_QUEST_STATUS_OBJECTIVES); + let mut rep_objective = prepare(CharStatements::REP_CHAR_QUEST_STATUS_OBJECTIVES); rep_objective.set_u64(0, guid); rep_objective.set_u32(1, quest_id); rep_objective.set_u8(2, objective_index); rep_objective.set_i32(3, count); - tx.append(rep_objective); + statements.push(rep_objective); } } + statements + } + + async fn save_quest_to_db(&self, quest_id: u32, status: u8) { + let guid = match self.player_guid() { + Some(g) => g.counter() as u64, + None => return, + }; + let char_db = match self.char_db() { + Some(db) => Arc::clone(db), + None => return, + }; + + let mut tx = SqlTransaction::new(); + for stmt in self.represented_quest_status_save_statements_like_cpp( + guid, + quest_id, + status, + |statement| char_db.prepare(statement), + ) { + tx.append(stmt); + } + if let Err(e) = char_db.commit_transaction(tx).await { warn!( account = self.account_id, @@ -6692,6 +6969,7 @@ impl WorldSession { self.rewarded_quests.clear(); let mut next_active_slot: u8 = 0; + let mut stale_rewarded_active_rows = Vec::new(); if !result.is_empty() { let mut result = result; @@ -6706,6 +6984,7 @@ impl WorldSession { // Rewarded (C++ QuestStatus::QUEST_STATUS_REWARDED / m_RewardedQuests). // Non-repeatable quests cannot be re-taken once rewarded. self.rewarded_quests.insert(quest_id); + stale_rewarded_active_rows.push(quest_id); } else if next_active_slot < MAX_QUEST_LOG_SIZE_LIKE_CPP { // Active or complete-but-not-turned-in. // C++ _LoadQuestStatus assigns sequential visible slots in DB row order @@ -6814,6 +7093,19 @@ impl WorldSession { } } + stale_rewarded_active_rows + .extend(self.remove_represented_active_rewarded_duplicates_like_cpp()); + stale_rewarded_active_rows.sort_unstable(); + stale_rewarded_active_rows.dedup(); + for quest_id in stale_rewarded_active_rows { + info!( + account = self.account_id, + quest_id, + "QuestLoad: deleting stale active quest status already represented as rewarded like C++" + ); + self.delete_quest_from_db(quest_id).await; + } + self.df_quests_like_cpp.clear(); self.daily_quests_completed_like_cpp.clear(); self.last_daily_quest_time_like_cpp = 0; @@ -7143,6 +7435,99 @@ mod tests { } } + #[test] + fn quest_giver_creature_id_is_zero_for_gameobject_sources_like_cpp() { + assert_eq!( + quest_giver_creature_id_from_source_like_cpp(creature_guid(15513, 27)), + 15513 + ); + assert_eq!( + quest_giver_creature_id_from_source_like_cpp(gameobject_guid(180516, 301)), + 0 + ); + } + + #[tokio::test] + async fn quest_giver_accept_emits_player_quest_log_update_like_cpp() { + let (mut session, send_rx) = make_session(); + let quest_id = 7201; + let gameobject_entry = 9301; + let source_guid = gameobject_guid(gameobject_entry, 301); + let mut quest = quest_template(quest_id); + quest.objectives.push(QuestObjective { + id: quest_id * 10, + quest_id, + obj_type: QUEST_OBJECTIVE_MONSTER_LIKE_CPP_LOCAL, + order: 0, + storage_index: 0, + object_id: 44, + amount: 1, + flags: 0, + flags2: 0, + progress_bar_weight: 0.0, + description: String::new(), + }); + let mut store = QuestStore::from_quests_like_cpp([quest]); + assert!(store.insert_gameobject_starter_relation_like_cpp(gameobject_entry, quest_id)); + session.set_quest_store(Arc::new(store)); + let mut manager = wow_map::MapManager::default(); + insert_gameobject(&mut manager, source_guid, gameobject_entry); + attach_map_manager(&mut session, manager); + + session + .handle_quest_giver_accept_quest(quest_giver_cmsg_packet(source_guid, quest_id, 0x00)) + .await; + + let status = session + .player_quests + .get(&quest_id) + .expect("accepted quest should enter the represented quest log"); + assert_eq!(status.slot, 0); + assert_eq!(status.status, QUEST_STATUS_INCOMPLETE_LIKE_CPP); + + let update = send_rx + .try_recv() + .expect("C++ SetQuestSlot must become an immediate player UpdateObject"); + assert_eq!( + wow_packet::WorldPacket::from_bytes(&update).server_opcode(), + Some(wow_constants::ServerOpcodes::UpdateObject) + ); + assert!( + update + .windows(std::mem::size_of::()) + .any(|window| window == quest_id.to_le_bytes()), + "quest-log UpdateObject should carry the accepted QuestID" + ); + + let complete = send_rx + .try_recv() + .expect("legacy represented accept confirmation should still be sent"); + assert_eq!( + wow_packet::WorldPacket::from_bytes(&complete).server_opcode(), + Some(wow_constants::ServerOpcodes::QuestGiverQuestComplete) + ); + assert!(send_rx.try_recv().is_err()); + } + + #[tokio::test] + async fn quest_giver_accept_rejected_source_sends_no_quest_log_update_like_cpp() { + let (mut session, send_rx) = make_session(); + let quest_id = 7202; + let gameobject_entry = 9302; + let source_guid = gameobject_guid(gameobject_entry, 302); + session.set_quest_store(Arc::new(store_with_quests(&[quest_id]))); + let mut manager = wow_map::MapManager::default(); + insert_gameobject(&mut manager, source_guid, gameobject_entry); + attach_map_manager(&mut session, manager); + + session + .handle_quest_giver_accept_quest(quest_giver_cmsg_packet(source_guid, quest_id, 0x00)) + .await; + + assert!(!session.player_quests.contains_key(&quest_id)); + assert!(send_rx.try_recv().is_err()); + } + #[test] fn player_quest_status_load_binds_full_u64_guid_like_cpp() { let guid = ObjectGuid::create_player(1, i64::from(u32::MAX) + 42); @@ -15174,6 +15559,62 @@ mod tests { ); } + #[test] + fn quest_packet_registration_and_dispatch_are_wired_like_cpp() { + let cases = [ + ( + ClientOpcodes::QuestGiverQueryQuest, + "handle_quest_giver_query_quest", + "ClientOpcodes::QuestGiverQueryQuest =>", + "self.handle_quest_giver_query_quest(pkt).await", + ), + ( + ClientOpcodes::QuestGiverAcceptQuest, + "handle_quest_giver_accept_quest", + "ClientOpcodes::QuestGiverAcceptQuest =>", + "self.handle_quest_giver_accept_quest(pkt).await", + ), + ( + ClientOpcodes::QuestGiverRequestReward, + "handle_quest_giver_request_reward", + "ClientOpcodes::QuestGiverRequestReward =>", + "self.handle_quest_giver_request_reward(pkt).await", + ), + ( + ClientOpcodes::QuestGiverCompleteQuest, + "handle_quest_giver_complete_quest", + "ClientOpcodes::QuestGiverCompleteQuest =>", + "self.handle_quest_giver_complete_quest(pkt).await", + ), + ( + ClientOpcodes::QuestGiverChooseReward, + "handle_quest_giver_choose_reward", + "ClientOpcodes::QuestGiverChooseReward =>", + "self.handle_quest_giver_choose_reward(pkt).await", + ), + ( + ClientOpcodes::QueryQuestInfo, + "handle_query_quest_info", + "ClientOpcodes::QueryQuestInfo =>", + "self.handle_query_quest_info(pkt).await", + ), + ]; + let dispatcher = include_str!("../session.rs"); + + for (opcode, handler_name, match_arm, call) in cases { + let entry = inventory::iter:: + .into_iter() + .find(|entry| entry.opcode == opcode) + .unwrap_or_else(|| panic!("{opcode:?} handler registration")); + + assert_eq!(entry.status, SessionStatus::LoggedIn, "{opcode:?}"); + assert_eq!(entry.processing, PacketProcessing::Inplace, "{opcode:?}"); + assert_eq!(entry.handler_name, handler_name, "{opcode:?}"); + assert!(dispatcher.contains(match_arm), "{opcode:?}"); + assert!(dispatcher.contains(call), "{opcode:?}"); + } + } + #[tokio::test] async fn request_world_quest_update_empty_payload_sends_empty_response_like_cpp() { let (mut session, send_rx) = make_session(); @@ -15890,6 +16331,20 @@ mod tests { assert!(session.player_quests.contains_key(&17)); assert_eq!(session.get_quest_slot_quest_id_like_cpp(7), None); assert_eq!(session.get_quest_slot_quest_id_like_cpp(3), Some(17)); + + let update = send_rx + .try_recv() + .expect("C++ SetQuestSlot(slot, 0) must become an immediate player UpdateObject"); + assert_eq!( + wow_packet::WorldPacket::from_bytes(&update).server_opcode(), + Some(wow_constants::ServerOpcodes::UpdateObject) + ); + assert!( + !update + .windows(std::mem::size_of::()) + .any(|window| window == 880_001_u32.to_le_bytes()), + "quest-log abandon UpdateObject should clear the removed QuestID" + ); assert!(send_rx.try_recv().is_err()); } @@ -15935,6 +16390,321 @@ mod tests { assert_eq!(entries[9].0, 5916); } + #[test] + fn quest_log_create_entries_preserve_end_time_like_cpp() { + let (mut session, _send_rx) = make_session(); + add_active_quest_in_slot(&mut session, 5917, 4); + session + .player_quests + .get_mut(&5917) + .expect("active quest") + .end_time_secs = 123_456; + + let entries = session.quest_log_create_entries_like_cpp(); + + assert_eq!(entries[4].2, 123_456); + } + + #[test] + fn save_to_db_quest_status_list_includes_active_quests_like_cpp() { + let (mut session, _send_rx) = make_session(); + add_active_quest_in_slot_with_status( + &mut session, + 5920, + 2, + QUEST_STATUS_INCOMPLETE_LIKE_CPP, + ); + add_active_quest_in_slot_with_status(&mut session, 5919, 3, QUEST_STATUS_COMPLETE_LIKE_CPP); + + assert_eq!( + session.represented_quest_statuses_for_save_like_cpp(), + vec![ + (5919, QUEST_STATUS_COMPLETE_LIKE_CPP), + (5920, QUEST_STATUS_INCOMPLETE_LIKE_CPP) + ], + "C++ Player::SaveToDB reaches _SaveQuestStatus for represented active quests" + ); + } + + #[test] + fn save_to_db_quest_status_statements_persist_nonzero_objectives_like_cpp() { + let (mut session, _send_rx) = make_session(); + let quest_id = 5925; + let mut quest = quest_template(quest_id); + quest.objectives.push(QuestObjective { + id: quest_id * 10, + quest_id, + obj_type: QUEST_OBJECTIVE_MONSTER_LIKE_CPP_LOCAL, + order: 0, + storage_index: 0, + object_id: 44, + amount: 5, + flags: 0, + flags2: 0, + progress_bar_weight: 0.0, + description: String::new(), + }); + quest.objectives.push(QuestObjective { + id: quest_id * 10 + 1, + quest_id, + obj_type: QUEST_OBJECTIVE_MONSTER_LIKE_CPP_LOCAL, + order: 1, + storage_index: 1, + object_id: 45, + amount: 1, + flags: 0, + flags2: 0, + progress_bar_weight: 0.0, + description: String::new(), + }); + quest.objectives.push(QuestObjective { + id: quest_id * 10 + 2, + quest_id, + obj_type: QUEST_OBJECTIVE_MONEY_LIKE_CPP_LOCAL, + order: 2, + storage_index: -1, + object_id: 0, + amount: 20, + flags: 0, + flags2: 0, + progress_bar_weight: 0.0, + description: String::new(), + }); + session.set_quest_store(Arc::new(QuestStore::from_quests_like_cpp([quest]))); + session.player_quests.insert( + quest_id, + PlayerQuestStatus { + quest_id, + status: QUEST_STATUS_INCOMPLETE_LIKE_CPP, + explored: true, + accept_time_secs: 12, + end_time_secs: 34, + objective_counts: vec![3, 0, 9], + slot: 0, + }, + ); + + let statements = session.represented_quest_status_save_statements_like_cpp( + 42, + quest_id, + QUEST_STATUS_INCOMPLETE_LIKE_CPP, + |statement| PreparedStatement::new(statement.sql()), + ); + + assert_eq!( + statements + .iter() + .map(PreparedStatement::sql) + .collect::>(), + vec![ + CharStatements::INS_CHAR_QUEST_STATUS.sql(), + CharStatements::DEL_CHAR_QUEST_STATUS_OBJECTIVES_BY_QUEST.sql(), + CharStatements::REP_CHAR_QUEST_STATUS_OBJECTIVES.sql(), + ], + "C++ Player::_SaveQuestStatus rewrites the status row, deletes stale objective rows, then saves only nonzero storage-backed counters" + ); + assert_eq!( + statements[0].params(), + &[ + SqlParam::U64(42), + SqlParam::U32(quest_id), + SqlParam::U8(QUEST_STATUS_INCOMPLETE_LIKE_CPP), + SqlParam::U8(1), + SqlParam::I64(12), + SqlParam::I64(34), + ] + ); + assert_eq!( + statements[1].params(), + &[SqlParam::U64(42), SqlParam::U32(quest_id)] + ); + assert_eq!( + statements[2].params(), + &[ + SqlParam::U64(42), + SqlParam::U32(quest_id), + SqlParam::U8(0), + SqlParam::I32(3), + ] + ); + } + + #[test] + fn save_to_db_rewarded_quest_statements_delete_active_objectives_like_cpp() { + let (session, _send_rx) = make_session(); + let quest_id = 5926; + + let statements = session.represented_quest_status_save_statements_like_cpp( + 42, + quest_id, + QUEST_STATUS_REWARDED_LIKE_CPP, + |statement| PreparedStatement::new(statement.sql()), + ); + + assert_eq!( + statements + .iter() + .map(PreparedStatement::sql) + .collect::>(), + vec![ + CharStatements::DEL_CHAR_QUEST_STATUS.sql(), + CharStatements::DEL_CHAR_QUEST_STATUS_OBJECTIVES_BY_QUEST.sql(), + CharStatements::INS_CHAR_QUESTSTATUS_REWARDED.sql(), + ] + ); + assert_eq!( + statements[0].params(), + &[SqlParam::U64(42), SqlParam::U32(quest_id)] + ); + assert_eq!( + statements[1].params(), + &[SqlParam::U64(42), SqlParam::U32(quest_id)] + ); + assert_eq!( + statements[2].params(), + &[SqlParam::U64(42), SqlParam::U32(quest_id)] + ); + } + + #[test] + fn save_to_db_quest_status_list_skips_rewarded_non_repeatable_active_duplicate_like_cpp() { + let (mut session, _send_rx) = make_session(); + let active_rewarded_quest_id = 5921; + let active_quest_id = 5922; + let quest_store = QuestStore::from_quests_like_cpp([ + quest_template(active_rewarded_quest_id), + quest_template(active_quest_id), + ]); + session.quest_store = Some(Arc::new(quest_store)); + add_active_quest_in_slot_with_status( + &mut session, + active_rewarded_quest_id, + 0, + QUEST_STATUS_COMPLETE_LIKE_CPP, + ); + add_active_quest_in_slot_with_status( + &mut session, + active_quest_id, + 1, + QUEST_STATUS_INCOMPLETE_LIKE_CPP, + ); + session.rewarded_quests.insert(active_rewarded_quest_id); + + assert_eq!( + session.represented_quest_statuses_for_save_like_cpp(), + vec![(active_quest_id, QUEST_STATUS_INCOMPLETE_LIKE_CPP)], + "C++ reward save deletes active quest status and stores rewarded separately" + ); + } + + #[test] + fn quest_load_removes_active_rewarded_duplicate_and_compacts_slots_like_cpp() { + let (mut session, _send_rx) = make_session(); + let duplicate_quest_id = 5923; + let active_quest_id = 5924; + let quest_store = QuestStore::from_quests_like_cpp([ + quest_template(duplicate_quest_id), + quest_template(active_quest_id), + ]); + session.quest_store = Some(Arc::new(quest_store)); + add_active_quest_in_slot_with_status( + &mut session, + duplicate_quest_id, + 0, + QUEST_STATUS_COMPLETE_LIKE_CPP, + ); + add_active_quest_in_slot_with_status( + &mut session, + active_quest_id, + 3, + QUEST_STATUS_INCOMPLETE_LIKE_CPP, + ); + session.rewarded_quests.insert(duplicate_quest_id); + + assert_eq!( + session.remove_represented_active_rewarded_duplicates_like_cpp(), + vec![duplicate_quest_id] + ); + assert!(!session.player_quests.contains_key(&duplicate_quest_id)); + assert_eq!( + session + .player_quests + .get(&active_quest_id) + .map(|status| status.slot), + Some(0) + ); + } + + #[test] + fn save_to_db_quest_status_list_is_empty_without_active_quests_like_cpp() { + let (session, _send_rx) = make_session(); + + assert!( + session + .represented_quest_statuses_for_save_like_cpp() + .is_empty() + ); + } + + #[test] + fn quest_log_create_entries_store_flag_objectives_in_state_flags_like_cpp() { + let (mut session, _send_rx) = make_session(); + let quest_id = 5918; + let mut quest = quest_template(quest_id); + quest.objectives.push(QuestObjective { + id: quest_id * 10, + quest_id, + obj_type: QUEST_OBJECTIVE_MONSTER_LIKE_CPP_LOCAL, + order: 0, + storage_index: 0, + object_id: 44, + amount: 5, + flags: 0, + flags2: 0, + progress_bar_weight: 0.0, + description: String::new(), + }); + quest.objectives.push(QuestObjective { + id: quest_id * 10 + 1, + quest_id, + obj_type: 10, + order: 1, + storage_index: 1, + object_id: 45, + amount: 1, + flags: 0, + flags2: 0, + progress_bar_weight: 0.0, + description: String::new(), + }); + session.set_quest_store(Arc::new(QuestStore::from_quests_like_cpp([quest]))); + add_active_quest_in_slot(&mut session, quest_id, 3); + session + .player_quests + .get_mut(&quest_id) + .expect("active quest") + .objective_counts = vec![3, 1]; + + let entries = session.quest_log_create_entries_like_cpp(); + + assert_eq!(entries[3].1, 256 << 1); + assert_eq!(entries[3].3[0], 3); + assert_eq!( + entries[3].3[1], 0, + "C++ stores flag objectives in QuestLog.StateFlags, not ObjectiveProgress" + ); + } + + #[test] + fn quest_log_create_entries_preserve_failed_state_flag_like_cpp() { + let (mut session, _send_rx) = make_session(); + add_active_quest_in_slot_with_status(&mut session, 5919, 5, QUEST_STATUS_FAILED_LIKE_CPP); + + let entries = session.quest_log_create_entries_like_cpp(); + + assert_eq!(entries[5].1, 2); + } + #[test] fn quest_log_create_entries_duplicate_slot_is_empty_fail_closed_like_cpp() { let (mut session, _send_rx) = make_session(); @@ -16434,7 +17204,7 @@ mod tests { #[derive(Debug, Clone)] pub struct PlayerQuestStatus { pub quest_id: u32, - /// 0=None, 1=Incomplete, 2=Complete, 3=Failed + /// C++ QuestStatus values: 0=None, 1=Complete, 3=Incomplete, 5=Failed, 6=Rewarded. pub status: u8, pub explored: bool, /// TrinityCore QuestStatusData::AcceptTime, persisted as Unix seconds. diff --git a/crates/wow-world/src/handlers/spell.rs b/crates/wow-world/src/handlers/spell.rs index 5bd96787..c347ef3e 100644 --- a/crates/wow-world/src/handlers/spell.rs +++ b/crates/wow-world/src/handlers/spell.rs @@ -56,6 +56,7 @@ use wow_packet::packets::spell::{ }; use wow_packet::packets::totem::TotemDestroyed; +use crate::conditions::QUEST_STATUS_INCOMPLETE_LIKE_CPP; use crate::session::{RepresentedPendingSpellCastRequestLikeCpp, WorldSession}; const LOOT_MODE_DEFAULT_LIKE_CPP: u16 = 1; @@ -1656,7 +1657,7 @@ impl WorldSession { }; self.player_quests.values().any(|status| { - if status.status != 1 { + if status.status != QUEST_STATUS_INCOMPLETE_LIKE_CPP { return false; } @@ -1692,7 +1693,7 @@ impl WorldSession { }; self.player_quests.values().any(|status| { - if status.status != 1 { + if status.status != QUEST_STATUS_INCOMPLETE_LIKE_CPP { return false; } @@ -1776,7 +1777,7 @@ impl WorldSession { 9 => Some( self.player_quests .get(&condition.value1) - .is_some_and(|status| status.status == 1), + .is_some_and(|status| status.status == QUEST_STATUS_INCOMPLETE_LIKE_CPP), ), 14 => Some( !self.player_quests.contains_key(&condition.value1) diff --git a/crates/wow-world/src/handlers/trainer.rs b/crates/wow-world/src/handlers/trainer.rs index ca3e5cc5..1f93c769 100644 --- a/crates/wow-world/src/handlers/trainer.rs +++ b/crates/wow-world/src/handlers/trainer.rs @@ -45,6 +45,12 @@ const TRAINER_BUY_NPC_FLAGS_LIKE_CPP: u32 = NPCFlags1::TRAINER.bits() | NPCFlags1::TRAINER_CLASS.bits() | NPCFlags1::TRAINER_PROFESSION.bits(); +fn default_trainer_lookup_key_for_request_like_cpp( + gossip_option: Option<(u32, u32)>, +) -> Option<(u32, u32)> { + gossip_option.is_none().then_some((0, 0)) +} + // ── Handler registrations ───────────────────────────────────────────────────── inventory::submit! { @@ -73,6 +79,24 @@ impl WorldSession { /// Opens the trainer window: resolves the NPC, loads spells from DB, /// and sends SMSG_TRAINER_LIST back to the client. pub async fn handle_trainer_list(&mut self, hello: wow_packet::packets::gossip::Hello) { + self.send_trainer_list_like_cpp(hello, None).await; + } + + pub(crate) async fn handle_trainer_list_for_gossip_option_like_cpp( + &mut self, + hello: wow_packet::packets::gossip::Hello, + gossip_menu_id: u32, + gossip_option_id: u32, + ) { + self.send_trainer_list_like_cpp(hello, Some((gossip_menu_id, gossip_option_id))) + .await; + } + + async fn send_trainer_list_like_cpp( + &mut self, + hello: wow_packet::packets::gossip::Hello, + gossip_option: Option<(u32, u32)>, + ) { let trainer_guid = hello.unit; info!( account = self.account_id, @@ -140,32 +164,24 @@ impl WorldSession { }; // ── Look up TrainerId ────────────────────────────────────────────── - let mut ct_stmt = world_db.prepare(WorldStatements::SEL_TRAINER_BY_CREATURE); - ct_stmt.set_u32(0, entry); - let trainer_id = - match tokio::time::timeout(std::time::Duration::from_secs(2), world_db.query(&ct_stmt)) - .await - { - Ok(Ok(r)) if !r.is_empty() => match r.try_read::(0) { - Some(id) => id, - None => { - warn!( - account = self.account_id, - entry = entry, - "creature_trainer row has no TrainerId" - ); - return; - } - }, - _ => { - warn!( - account = self.account_id, - entry = entry, - "No creature_trainer row for entry" - ); - return; - } - }; + let trainer_id = match Self::resolve_creature_trainer_id_like_cpp( + world_db.as_ref(), + entry, + gossip_option, + ) + .await + { + Some(id) => id, + None => { + warn!( + account = self.account_id, + entry = entry, + gossip_option = ?gossip_option, + "No creature_trainer row for C++ trainer lookup" + ); + return; + } + }; // ── Load trainer info (type + greeting) ──────────────────────────── let (trainer_type, greeting) = { @@ -332,6 +348,39 @@ impl WorldSession { }); } + async fn resolve_creature_trainer_id_like_cpp( + world_db: &wow_database::WorldDatabase, + entry: u32, + gossip_option: Option<(u32, u32)>, + ) -> Option { + async fn query( + world_db: &wow_database::WorldDatabase, + entry: u32, + menu_id: u32, + option_id: u32, + ) -> Option { + let mut stmt = world_db.prepare(WorldStatements::SEL_TRAINER_BY_CREATURE_GOSSIP_OPTION); + stmt.set_u32(0, entry); + stmt.set_u32(1, menu_id); + stmt.set_u32(2, option_id); + match tokio::time::timeout(std::time::Duration::from_secs(2), world_db.query(&stmt)) + .await + { + Ok(Ok(r)) if !r.is_empty() => r.try_read::(0), + _ => None, + } + } + + if let Some((menu_id, option_id)) = gossip_option { + return query(world_db, entry, menu_id, option_id).await; + } + + // C++ fallback: `ObjectMgr::GetCreatureDefaultTrainer(creatureId)`, + // implemented as `GetCreatureTrainerForGossipOption(creatureId, 0, 0)`. + let (menu_id, option_id) = default_trainer_lookup_key_for_request_like_cpp(gossip_option)?; + query(world_db, entry, menu_id, option_id).await + } + /// Handle `CMSG_TRAINER_BUY_SPELL` (0x34ae). /// /// Validates the purchase (level, gold), deducts cost, inserts character_spell, @@ -561,3 +610,21 @@ impl WorldSession { self.send_packet(&LearnedSpells::single(spell_id)); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn trainer_default_lookup_is_only_for_direct_requests_like_cpp() { + assert_eq!( + default_trainer_lookup_key_for_request_like_cpp(None), + Some((0, 0)) + ); + assert_eq!( + default_trainer_lookup_key_for_request_like_cpp(Some((900, 3))), + None, + "C++ gossip-option trainer selection uses the selected (MenuID, OptionID) row; default trainer lookup belongs to direct trainer list requests" + ); + } +} diff --git a/crates/wow-world/src/map_manager.rs b/crates/wow-world/src/map_manager.rs index f8c9c505..b4f92dcd 100644 --- a/crates/wow-world/src/map_manager.rs +++ b/crates/wow-world/src/map_manager.rs @@ -10,8 +10,8 @@ use rand::{Rng, SeedableRng, rngs::StdRng}; use tracing::{debug, info, warn}; use wow_constants::movement::MovementFlag; use wow_constants::{ - CreatureRandomMovementType as ConstantsCreatureRandomMovementType, UnitDynFlags, UnitMoveType, - UnitStandStateType, UnitState, WeaponAttackType, + CreatureRandomMovementType as ConstantsCreatureRandomMovementType, UnitDynFlags, UnitFlags2, + UnitMoveType, UnitStandStateType, UnitState, WeaponAttackType, }; use wow_core::{ObjectGuid, Position}; use wow_entities::{ @@ -1243,6 +1243,10 @@ impl WorldCreature { self.creature.ai_ownership().npc_flags2 } + pub fn unit_flags2_like_cpp(&self) -> UnitFlags2 { + self.creature.unit().unit_flags2_like_cpp() + } + pub fn trainer_class_like_cpp(&self) -> u8 { self.creature.trainer_class_like_cpp() } @@ -4016,7 +4020,7 @@ pub fn grid_corner(grid_x: i16, grid_y: i16) -> (f32, f32) { pub struct PendingRespawn { /// When to respawn. pub respawn_at: Instant, - /// C++ `RespawnInfo::spawnId`, encoded as the low counter of creature map GUIDs. + /// C++ `RespawnInfo::spawnId` / `Creature::m_spawnId`, separate from the live ObjectGuid low counter. pub spawn_id: u64, /// Home position (spawn point). pub home_pos: wow_core::Position, @@ -4079,9 +4083,13 @@ pub fn pending_respawn_from_world_creature_like_cpp( respawn_at: Instant, map_id: u16, ) -> PendingRespawn { + let spawn_id = match creature.creature.spawn_id() { + 0 => creature.guid().low_value().max(0) as u64, + spawn_id => spawn_id, + }; PendingRespawn { respawn_at, - spawn_id: (creature.guid().low_value() as u64) & 0xFF_FFFF_FFFF, + spawn_id, home_pos: creature.home_position(), create_data: CreatureCreateData { guid: creature.guid(), @@ -4235,6 +4243,7 @@ pub fn world_creature_from_pending_respawn_like_cpp( let damage_school = create_data.damage_school; let mut creature = Creature::new(false); + creature.set_spawn_id(respawn.spawn_id); creature.unit_mut().world_mut().object_mut().create(guid); creature .unit_mut() @@ -8193,6 +8202,7 @@ mod tests { fn pending_respawn_preserves_flags_extra_like_cpp() { let guid = ObjectGuid::create_world_object(HighGuid::Creature, 0, 1, 0, 0, 1, 42); let mut creature = test_creature(guid); + creature.creature.set_spawn_id(42); creature .creature .set_flags_extra_runtime_like_cpp(CreatureFlagsExtra::CIVILIAN.bits()); @@ -8227,7 +8237,7 @@ mod tests { }); assert_eq!( pending.spawn_id, 42, - "creature respawn must preserve C++ RespawnInfo::spawnId from the map GUID low counter" + "creature respawn must preserve C++ RespawnInfo::spawnId from Creature::GetSpawnId" ); assert_eq!(pending.flags_extra, CreatureFlagsExtra::CIVILIAN.bits()); assert_eq!(pending.static_flags[0], static_flags[0]); @@ -8245,6 +8255,11 @@ mod tests { ); let respawned = world_creature_from_pending_respawn_like_cpp(&pending, 0); + assert_eq!( + respawned.creature.spawn_id(), + 42, + "C++ Creature::LoadFromDB restores m_spawnId before registering the respawned creature" + ); assert!( respawned.creature.is_civilian_like_cpp(), "map-owned respawn must keep C++ flags_extra gates" @@ -8290,4 +8305,29 @@ mod tests { "C++ LoadCreaturesAddon reapplies addon auras on respawn" ); } + + #[test] + fn pending_respawn_uses_guid_counter_for_legacy_zero_spawn_id() { + let first_guid = ObjectGuid::create_world_object(HighGuid::Creature, 0, 1, 0, 0, 1, 43); + let second_guid = ObjectGuid::create_world_object(HighGuid::Creature, 0, 1, 0, 0, 1, 44); + let first = test_creature(first_guid); + let second = test_creature(second_guid); + + assert_eq!(first.creature.spawn_id(), 0); + assert_eq!(second.creature.spawn_id(), 0); + + let first_pending = pending_respawn_from_world_creature_like_cpp(&first, Instant::now(), 0); + let second_pending = + pending_respawn_from_world_creature_like_cpp(&second, Instant::now(), 0); + + assert_eq!(first_pending.spawn_id, first_guid.low_value() as u64); + assert_eq!(second_pending.spawn_id, second_guid.low_value() as u64); + assert_ne!(first_pending.spawn_id, second_pending.spawn_id); + assert_eq!( + world_creature_from_pending_respawn_like_cpp(&first_pending, 0) + .creature + .spawn_id(), + first_pending.spawn_id + ); + } } diff --git a/crates/wow-world/src/session.rs b/crates/wow-world/src/session.rs index 7c70d3f7..fa8d96f9 100644 --- a/crates/wow-world/src/session.rs +++ b/crates/wow-world/src/session.rs @@ -178,6 +178,7 @@ use wow_network::{ SocketTimeoutsLikeCpp, group_guid_by_db_store_id_like_cpp, }; use wow_packet::packets::chat::{ChatMsg, ChatPkt, PrintNotification}; +use wow_packet::packets::gossip::ClientGossipText; use wow_packet::packets::item::{ InventoryChangeFailure, ItemEnchantTimeUpdate, ItemInstance, ItemMod, ItemModList, ItemPushResult, ItemPushResultDisplayType, ItemTimeUpdate, @@ -351,8 +352,22 @@ fn quest_rewards_block_like_cpp(quest: &wow_data::quest::QuestTemplate) -> Quest *slot = *display_spell; } } + for (idx, choice_item) in quest.reward_choice_items.iter().enumerate() { + if let Some(slot) = rewards.choice_items.get_mut(idx) { + *slot = *choice_item; + } + } + rewards.choice_item_types = quest.reward_choice_item_types; rewards } + +fn quest_giver_creature_id_from_source_like_cpp(source_guid: ObjectGuid) -> i32 { + if source_guid.is_any_type_creature() { + i32::try_from(source_guid.entry()).unwrap_or(0) + } else { + 0 + } +} const ATTACK_DISPLAY_DELAY_LIKE_CPP_MS: u32 = 200; const MIN_MELEE_REACH_LIKE_CPP: f32 = 2.0; const NOMINAL_MELEE_RANGE_LIKE_CPP: f32 = 5.0; @@ -4097,6 +4112,8 @@ pub struct WorldSession { /// GUID of the character currently logged in (set after login completes). player_guid: Option, + /// C++ `WorldSession::m_GUIDLow`: last logged-in character low GUID kept after logout. + recent_player_guid_low_like_cpp: u64, /// Attached player controller, mirroring C++ `WorldSession::_player` ownership. player_controller: Option, /// C++ `WorldSession::_accountData`, represented in-memory until DB load/save is wired. @@ -4948,6 +4965,8 @@ pub struct WorldSession { #[derive(Debug, Clone)] pub struct GossipOptionInfo { pub gossip_option_id: i32, + pub menu_id: u32, + pub order_index: u32, pub option_npc: u8, pub action_menu_id: u32, } @@ -5955,6 +5974,7 @@ impl WorldSession { min_discovered_scaled_xp_ratio_like_cpp: 0, selection_guid: None, player_guid: None, + recent_player_guid_low_like_cpp: 0, player_controller: None, account_data_like_cpp: default_account_data_like_cpp(), tutorials_like_cpp: [0; 8], @@ -12017,49 +12037,156 @@ impl WorldSession { if self.taxi_flight_state_like_cpp.is_some() { return None; } - let manager = self.canonical_map_manager.as_ref()?; - let Ok(manager) = manager.lock() else { - return None; - }; - let map = manager.find_map(u32::from(self.player_map_id_like_cpp()), 0)?; - if map - .map() - .get_typed_player(player_guid) - .is_some_and(|player| !player.unit().world().object().is_in_world()) - { - return None; - } - let record = map.map().map_object_record(guid)?; - let creature = if guid.is_pet() { - record.pet()?.creature() - } else { - record.creature()? - }; - let type_flags = - CreatureTypeFlags::from_bits_retain(creature.lifecycle_metadata().type_flags); - if !self.player_is_alive_like_cpp() - && !type_flags.contains(CreatureTypeFlags::VISIBLE_TO_GHOSTS) - { + let mut canonical_record_found_like_cpp = false; + let canonical_access = (|| { + let manager = self.canonical_map_manager.as_ref()?; + let Ok(manager) = manager.lock() else { + return None; + }; + let map = manager.find_map(u32::from(self.player_map_id_like_cpp()), 0)?; + if map + .map() + .get_typed_player(player_guid) + .is_some_and(|player| !player.unit().world().object().is_in_world()) + { + return None; + } + let record = map.map().map_object_record(guid)?; + canonical_record_found_like_cpp = true; + let creature = if guid.is_pet() { + record.pet()?.creature() + } else { + record.creature()? + }; + + let type_flags = + CreatureTypeFlags::from_bits_retain(creature.lifecycle_metadata().type_flags); + if !self.player_is_alive_like_cpp() + && !type_flags.contains(CreatureTypeFlags::VISIBLE_TO_GHOSTS) + { + return None; + } + if !creature.is_alive() && !type_flags.contains(CreatureTypeFlags::INTERACT_WHILE_DEAD) + { + return None; + } + if (npc_flags != 0 || npc_flags2 != 0) + && (creature.ai_ownership().npc_flags & npc_flags) == 0 + && (creature.ai_ownership().npc_flags2 & npc_flags2) == 0 + { + return None; + } + if creature.unit().subsystems().control.charmer_guid.is_some() { + return None; + } + let unit_flags2 = creature.unit().unit_flags2_like_cpp(); + if !unit_flags2.contains(UnitFlags2::INTERACT_WHILE_HOSTILE) { + let reaction = + self.represented_get_reaction_to_like_cpp(RepresentedGetReactionInputLikeCpp { + self_faction_template_id: creature.unit().data().faction_template.max(0) + as u32, + target_faction_template_id: self + .player_faction_template_like_cpp + .unwrap_or(0), + same_object: false, + attackable_by_summoner: false, + same_charmer_or_owner_or_self: false, + self_has_player_owner: false, + target_has_player_owner: true, + target_player_owner_is_current_session: true, + target_owner_forced_rank_for_self: None, + same_player_owner: false, + duel_in_progress: false, + same_raid: false, + self_unit_player_controlled: false, + target_unit_player_controlled: true, + self_ffa_pvp: false, + target_ffa_pvp: false, + self_ignores_reputation: false, + target_ignores_reputation: false, + target_is_unit: true, + target_player_contested_pvp, + }); + if reaction <= wow_data::reputation::ReputationRankLikeCpp::Unfriendly { + return None; + } + } + + let interaction_distance = creature.unit().world().combat_reach() + 4.0; + let in_range = if let Some(player) = map.map().get_typed_player(player_guid) { + creature.unit().world().is_within_dist_in_map( + player.unit().world(), + interaction_distance, + true, + ) + } else { + let fallback_distance = + interaction_distance + creature.unit().world().combat_reach(); + creature + .unit() + .world() + .position() + .is_within_dist(&player_position, fallback_distance) + }; + if !in_range { + return None; + } + + Some(RepresentedCreatureAccessLikeCpp { + entry: creature.entry(), + position: creature.unit().world().position(), + npc_flags: creature.ai_ownership().npc_flags, + npc_flags2: creature.ai_ownership().npc_flags2, + trainer_class: creature.trainer_class_like_cpp(), + faction_template_id: creature.unit().data().faction_template.max(0) as u32, + }) + })(); + if canonical_access.is_some() { + return canonical_access; + } + if canonical_record_found_like_cpp { return None; } - if !creature.is_alive() && !type_flags.contains(CreatureTypeFlags::INTERACT_WHILE_DEAD) { + + self.represented_legacy_npc_can_interact_with_like_cpp( + guid, + npc_flags, + npc_flags2, + player_position, + target_player_contested_pvp, + ) + } + + fn represented_legacy_npc_can_interact_with_like_cpp( + &self, + guid: ObjectGuid, + npc_flags: u32, + npc_flags2: u32, + player_position: Position, + target_player_contested_pvp: bool, + ) -> Option { + let manager = self.map_manager.as_ref()?; + let manager = manager + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let creature = manager.find_creature(self.player_map_id_like_cpp(), 0, guid)?; + if !self.player_is_alive_like_cpp() || !creature.is_alive() { return None; } if (npc_flags != 0 || npc_flags2 != 0) - && (creature.ai_ownership().npc_flags & npc_flags) == 0 - && (creature.ai_ownership().npc_flags2 & npc_flags2) == 0 + && (creature.npc_flags() & npc_flags) == 0 + && (creature.npc_flags2() & npc_flags2) == 0 { return None; } - if creature.unit().subsystems().control.charmer_guid.is_some() { - return None; - } - let unit_flags2 = creature.unit().unit_flags2_like_cpp(); - if !unit_flags2.contains(UnitFlags2::INTERACT_WHILE_HOSTILE) { + if !creature + .unit_flags2_like_cpp() + .contains(UnitFlags2::INTERACT_WHILE_HOSTILE) + { let reaction = self.represented_get_reaction_to_like_cpp(RepresentedGetReactionInputLikeCpp { - self_faction_template_id: creature.unit().data().faction_template.max(0) as u32, + self_faction_template_id: creature.faction(), target_faction_template_id: self.player_faction_template_like_cpp.unwrap_or(0), same_object: false, attackable_by_summoner: false, @@ -12084,33 +12211,17 @@ impl WorldSession { return None; } } - - let interaction_distance = creature.unit().world().combat_reach() + 4.0; - let in_range = if let Some(player) = map.map().get_typed_player(player_guid) { - creature.unit().world().is_within_dist_in_map( - player.unit().world(), - interaction_distance, - true, - ) - } else { - let fallback_distance = interaction_distance + creature.unit().world().combat_reach(); - creature - .unit() - .world() - .position() - .is_within_dist(&player_position, fallback_distance) - }; - if !in_range { + if !creature.position().is_within_dist(&player_position, 8.0) { return None; } Some(RepresentedCreatureAccessLikeCpp { entry: creature.entry(), - position: creature.unit().world().position(), - npc_flags: creature.ai_ownership().npc_flags, - npc_flags2: creature.ai_ownership().npc_flags2, + position: creature.position(), + npc_flags: creature.npc_flags(), + npc_flags2: creature.npc_flags2(), trainer_class: creature.trainer_class_like_cpp(), - faction_template_id: creature.unit().data().faction_template.max(0) as u32, + faction_template_id: creature.faction(), }) } @@ -22108,6 +22219,7 @@ impl WorldSession { self.save_player_spell_charges_like_cpp().await; self.save_player_action_buttons_like_cpp().await; self.save_player_equipment_sets_like_cpp().await; + self.save_player_quest_statuses_to_db_like_cpp().await; self.save_tutorials_data_like_cpp().await; self.save_instance_time_restrictions_like_cpp().await; self.save_played_time().await; @@ -24721,6 +24833,7 @@ impl WorldSession { .collect(); let mut quests_to_complete = Vec::new(); + let mut quests_to_save = Vec::new(); for (quest_id, obj_idx, required, objective_id) in matching { let Some(qs) = self.player_quests.get_mut(&quest_id) else { continue; @@ -24735,6 +24848,7 @@ impl WorldSession { .saturating_add(add_count) .clamp(0, required); let current = qs.objective_counts[obj_idx]; + quests_to_save.push(quest_id); debug!( account = self.account_id, @@ -24801,6 +24915,8 @@ impl WorldSession { ); } } + self.save_changed_represented_quest_statuses_like_cpp(&mut quests_to_save) + .await; self.sync_player_registry_state_like_cpp(); } @@ -24839,6 +24955,7 @@ impl WorldSession { .collect(); let mut quests_to_complete = Vec::new(); + let mut quests_to_save = Vec::new(); for (quest_id, obj_idx, objective_id) in matching { let Some(qs) = self.player_quests.get_mut(&quest_id) else { continue; @@ -24849,6 +24966,9 @@ impl WorldSession { let objective_was_complete = qs.objective_counts[obj_idx] != 0; qs.objective_counts[obj_idx] = i32::from(add_count > 0); let objective_is_now_complete = qs.objective_counts[obj_idx] != 0; + if objective_was_complete != objective_is_now_complete { + quests_to_save.push(quest_id); + } if add_count > 0 { self.send_packet(&QuestUpdateAddCreditSimple { @@ -24894,6 +25014,8 @@ impl WorldSession { ); } } + self.save_changed_represented_quest_statuses_like_cpp(&mut quests_to_save) + .await; self.sync_player_registry_state_like_cpp(); } @@ -24943,6 +25065,7 @@ impl WorldSession { .collect(); let mut quests_to_complete = Vec::new(); + let mut quests_to_save = Vec::new(); for (quest_id, objective_id, required, objective_was_complete, objective_is_now_complete) in matching { @@ -24975,6 +25098,7 @@ impl WorldSession { if let Some(status) = self.player_quests.get_mut(&quest_id) { if status.status == crate::conditions::QUEST_STATUS_COMPLETE_LIKE_CPP { status.status = crate::conditions::QUEST_STATUS_INCOMPLETE_LIKE_CPP; + quests_to_save.push(quest_id); } } } @@ -24988,6 +25112,7 @@ impl WorldSession { .complete_represented_quest_after_objective_if_ready_like_cpp(&quest, objective_id) .await; if completed { + quests_to_save.push(quest_id); if self.player_quests.get(&quest_id).is_some_and(|qs| { qs.status == crate::conditions::QUEST_STATUS_COMPLETE_LIKE_CPP }) { @@ -24999,6 +25124,8 @@ impl WorldSession { ); } } + self.save_changed_represented_quest_statuses_like_cpp(&mut quests_to_save) + .await; self.sync_player_registry_state_like_cpp(); } @@ -25050,6 +25177,7 @@ impl WorldSession { .collect(); let mut quests_to_complete = Vec::new(); + let mut quests_to_save = Vec::new(); for (quest_id, objective_id, required, objective_was_complete, objective_is_now_complete) in matching { @@ -25083,6 +25211,7 @@ impl WorldSession { if let Some(status) = self.player_quests.get_mut(&quest_id) { if status.status == crate::conditions::QUEST_STATUS_COMPLETE_LIKE_CPP { status.status = crate::conditions::QUEST_STATUS_INCOMPLETE_LIKE_CPP; + quests_to_save.push(quest_id); } } } @@ -25096,6 +25225,7 @@ impl WorldSession { .complete_represented_quest_after_objective_if_ready_like_cpp(&quest, objective_id) .await; if completed { + quests_to_save.push(quest_id); if self.player_quests.get(&quest_id).is_some_and(|qs| { qs.status == crate::conditions::QUEST_STATUS_COMPLETE_LIKE_CPP }) { @@ -25107,6 +25237,8 @@ impl WorldSession { ); } } + self.save_changed_represented_quest_statuses_like_cpp(&mut quests_to_save) + .await; self.sync_player_registry_state_like_cpp(); } @@ -25175,6 +25307,7 @@ impl WorldSession { .collect(); let mut quests_to_complete = Vec::new(); + let mut quests_to_save = Vec::new(); for (quest_id, objective_id, required, objective_was_complete, objective_is_now_complete) in matching { @@ -25209,6 +25342,7 @@ impl WorldSession { if let Some(status) = self.player_quests.get_mut(&quest_id) { if status.status == crate::conditions::QUEST_STATUS_COMPLETE_LIKE_CPP { status.status = crate::conditions::QUEST_STATUS_INCOMPLETE_LIKE_CPP; + quests_to_save.push(quest_id); } } } @@ -25222,6 +25356,7 @@ impl WorldSession { .complete_represented_quest_after_objective_if_ready_like_cpp(&quest, objective_id) .await; if completed { + quests_to_save.push(quest_id); if self.player_quests.get(&quest_id).is_some_and(|qs| { qs.status == crate::conditions::QUEST_STATUS_COMPLETE_LIKE_CPP }) { @@ -25233,6 +25368,8 @@ impl WorldSession { ); } } + self.save_changed_represented_quest_statuses_like_cpp(&mut quests_to_save) + .await; self.sync_player_registry_state_like_cpp(); } @@ -29114,9 +29251,27 @@ impl WorldSession { ClientOpcodes::QuestGiverStatusTrackedQuery => { self.handle_quest_giver_status_tracked_query(pkt).await; } + ClientOpcodes::QuestGiverQueryQuest => { + self.handle_quest_giver_query_quest(pkt).await; + } + ClientOpcodes::QuestGiverAcceptQuest => { + self.handle_quest_giver_accept_quest(pkt).await; + } + ClientOpcodes::QuestGiverRequestReward => { + self.handle_quest_giver_request_reward(pkt).await; + } + ClientOpcodes::QuestGiverCompleteQuest => { + self.handle_quest_giver_complete_quest(pkt).await; + } + ClientOpcodes::QuestGiverChooseReward => { + self.handle_quest_giver_choose_reward(pkt).await; + } ClientOpcodes::QuestGiverCloseQuest => { self.handle_quest_giver_close_quest(pkt).await; } + ClientOpcodes::QueryQuestInfo => { + self.handle_query_quest_info(pkt).await; + } ClientOpcodes::RequestWorldQuestUpdate => { self.handle_request_world_quest_update(pkt).await; } @@ -31561,6 +31716,7 @@ impl WorldSession { pub fn set_player_guid(&mut self, guid: Option) { self.player_guid = guid; if let Some(guid) = guid { + self.recent_player_guid_low_like_cpp = guid.counter() as u64; self.represented_seer_guid_like_cpp = Some(guid); } if guid.is_none() { @@ -31859,9 +32015,9 @@ impl WorldSession { } let is_global = (1u32 << data_type) & GLOBAL_CACHE_MASK_LIKE_CPP != 0; - let player_guid = self.player_guid(); + let player_guid_low = self.recent_player_guid_low_like_cpp; - if !is_global && player_guid.is_none() { + if !is_global && player_guid_low == 0 { return false; } @@ -31881,9 +32037,8 @@ impl WorldSession { stmt.set_string(3, data.clone()); stmt } else { - let guid = player_guid.expect("checked above"); let mut stmt = char_db.prepare(CharStatements::REP_PLAYER_ACCOUNT_DATA); - stmt.set_u64(0, guid.counter() as u64); + stmt.set_u64(0, player_guid_low); stmt.set_u8(1, data_type); stmt.set_i64(2, time); stmt.set_string(3, data.clone()); @@ -41686,6 +41841,58 @@ impl WorldSession { let menu_items = self.represented_creature_quest_menu_items_like_cpp(&quest_store, creature_entry); + let ender_candidates = quest_store + .quests_for_ender(creature_entry) + .iter() + .map(|quest| { + ( + quest.id, + quest.log_title.clone(), + quest.allowable_races, + quest.allowable_classes, + quest.min_level, + quest.max_level, + self.player_quests + .get(&quest.id) + .map(|status| status.status), + self.rewarded_quests.contains(&quest.id), + ) + }) + .collect::>(); + let starter_candidates = quest_store + .quests_for_starter(creature_entry) + .iter() + .map(|quest| { + ( + quest.id, + quest.log_title.clone(), + quest.allowable_races, + quest.allowable_classes, + quest.min_level, + quest.max_level, + quest.is_available_for( + self.player_race_like_cpp(), + self.player_class_like_cpp(), + self.player_level_like_cpp(), + ), + self.can_take_quest(quest), + self.player_quests + .get(&quest.id) + .map(|status| status.status), + self.rewarded_quests.contains(&quest.id), + ) + }) + .collect::>(); + info!( + creature_entry, + race = self.player_race_like_cpp(), + class = self.player_class_like_cpp(), + level = self.player_level_like_cpp(), + ender_candidates = ?ender_candidates, + starter_candidates = ?starter_candidates, + menu_items = ?self.represented_quest_menu_item_log_rows_like_cpp(&menu_items), + "Prepared creature questgiver fallback menu like C++" + ); if menu_items.is_empty() { return false; } @@ -41901,7 +42108,7 @@ impl WorldSession { return false; }; - self.send_represented_prepared_single_quest_like_cpp(source_guid, &menu_item); + self.send_represented_prepared_single_quest_like_cpp(source_guid, &menu_item, false); true } @@ -42069,10 +42276,24 @@ impl WorldSession { menu_items: Vec, ) { if let [menu_item] = menu_items.as_slice() { - self.send_represented_prepared_single_quest_like_cpp(source_guid, menu_item); + info!( + ?source_guid, + quest_id = menu_item.quest.id, + quest_icon = menu_item.quest_icon, + starter = menu_item.has_starter_relation, + involved = menu_item.has_involved_relation, + title = menu_item.quest.log_title.as_str(), + "Sending represented single prepared quest like C++" + ); + self.send_represented_prepared_single_quest_like_cpp(source_guid, menu_item, true); return; } + info!( + ?source_guid, + quests = ?self.represented_quest_menu_item_log_rows_like_cpp(&menu_items), + "Sending represented QuestGiverQuestList like C++" + ); self.send_packet(&QuestGiverQuestList { guid: source_guid, greeting: String::new(), @@ -42080,7 +42301,7 @@ impl WorldSession { greet_emote_type: 0, quests: menu_items .iter() - .map(|item| Self::quest_list_entry_from_template_like_cpp(&item.quest)) + .map(|item| self.quest_list_entry_from_menu_item_like_cpp(item)) .collect(), }); } @@ -42089,11 +42310,26 @@ impl WorldSession { &mut self, source_guid: ObjectGuid, menu_item: &RepresentedPreparedQuestMenuItemLikeCpp, + auto_launched: bool, ) { let quest = &menu_item.quest; if menu_item.quest_icon == QUEST_MENU_ICON_COMPLETE_LIKE_CPP { - self.send_represented_quest_giver_request_items_like_cpp(source_guid, quest); + let can_complete = self.can_reward_quest_represented_bounded_like_cpp(quest); + info!( + ?source_guid, + quest_id = quest.id, + can_complete, + auto_launched, + title = quest.log_title.as_str(), + "Sending represented QuestGiverRequestItems from complete icon like C++" + ); + self.send_represented_quest_giver_request_items_with_completion_like_cpp( + source_guid, + quest, + can_complete, + auto_launched, + ); return; } @@ -42110,28 +42346,74 @@ impl WorldSession { && !quest.is_daily_or_weekly_like_cpp() && !quest.is_monthly_like_cpp() { - self.send_represented_quest_giver_request_items_like_cpp(source_guid, quest); + info!( + ?source_guid, + quest_id = quest.id, + auto_launched, + title = quest.log_title.as_str(), + "Sending represented QuestGiverRequestItems for repeatable turn-in like C++" + ); + let can_complete = + self.can_complete_repeatable_quest_represented_bounded_like_cpp(quest); + self.send_represented_quest_giver_request_items_with_completion_like_cpp( + source_guid, + quest, + can_complete, + auto_launched, + ); } else if quest.is_turn_in_like_cpp() && !quest.is_daily_or_weekly_like_cpp() && !quest.is_monthly_like_cpp() { - self.send_represented_quest_giver_request_items_like_cpp(source_guid, quest); + let can_complete = self.can_reward_quest_represented_bounded_like_cpp(quest); + info!( + ?source_guid, + quest_id = quest.id, + can_complete, + auto_launched, + title = quest.log_title.as_str(), + "Sending represented QuestGiverRequestItems for turn-in like C++" + ); + self.send_represented_quest_giver_request_items_with_completion_like_cpp( + source_guid, + quest, + can_complete, + auto_launched, + ); } else { - self.send_represented_quest_giver_quest_details_like_cpp(source_guid, quest); + info!( + ?source_guid, + quest_id = quest.id, + auto_launched, + title = quest.log_title.as_str(), + "Sending represented QuestGiverQuestDetails like C++" + ); + self.send_represented_quest_giver_quest_details_like_cpp( + source_guid, + quest, + auto_launched, + ); } } - fn send_represented_quest_giver_request_items_like_cpp( - &mut self, - source_guid: ObjectGuid, - quest: &wow_data::quest::QuestTemplate, - ) { - self.send_represented_quest_giver_request_items_with_completion_like_cpp( - source_guid, - quest, - false, - false, - ); + fn represented_quest_menu_item_log_rows_like_cpp( + &self, + menu_items: &[RepresentedPreparedQuestMenuItemLikeCpp], + ) -> Vec<(u32, String, u8, bool, bool, u32, u32)> { + menu_items + .iter() + .map(|item| { + ( + item.quest.id, + item.quest.log_title.clone(), + item.quest_icon, + item.has_starter_relation, + item.has_involved_relation, + item.quest.allowable_classes, + item.quest.flags, + ) + }) + .collect() } pub(crate) fn send_repeatable_turn_in_request_items_like_cpp( @@ -42194,6 +42476,35 @@ impl WorldSession { false } + pub(crate) fn can_reward_quest_represented_bounded_like_cpp( + &self, + quest: &wow_data::quest::QuestTemplate, + ) -> bool { + // C++ `Player::CanRewardQuest(quest, false)` requires the quest to be complete + // unless it is a DF/turn-in-only quest, then rejects already rewarded + // non-repeatable quests. This represented seam covers the status/reward gates + // needed by `SendQuestGiverRequestItems`; inventory-capacity and long-tail + // reward blockers remain in the reward handler path. + if self.is_quest_disabled_like_cpp(quest.id) { + return false; + } + + if !quest.is_df_quest_like_cpp() + && !quest.is_turn_in_like_cpp() + && !self.player_quests.get(&quest.id).is_some_and(|status| { + status.status == crate::conditions::QUEST_STATUS_COMPLETE_LIKE_CPP + }) + { + return false; + } + + if self.rewarded_quests.contains(&quest.id) && !quest.is_repeatable() { + return false; + } + + true + } + pub(crate) fn send_represented_quest_giver_request_items_with_completion_like_cpp( &mut self, source_guid: ObjectGuid, @@ -42226,11 +42537,9 @@ impl WorldSession { .filter(|objective| objective.obj_type == QUEST_OBJECTIVE_MONEY_LIKE_CPP) .map(|objective| objective.amount) .sum::(); - let giver_creature_id = i32::try_from(source_guid.entry()).unwrap_or(0); - self.send_packet(&QuestGiverRequestItems { giver_guid: source_guid, - giver_creature_id, + giver_creature_id: quest_giver_creature_id_from_source_like_cpp(source_guid), quest_id: quest.id, comp_emote_delay: 0, comp_emote_type: 0, @@ -42254,6 +42563,7 @@ impl WorldSession { ) { self.send_packet(&QuestGiverOfferReward { giver_guid: source_guid, + giver_creature_id: quest_giver_creature_id_from_source_like_cpp(source_guid), quest_id: quest.id, quest_flags: [quest.flags, quest.flags_ex, quest.flags_ex2], suggested_party_members: quest.suggested_group_num, @@ -42268,6 +42578,7 @@ impl WorldSession { &mut self, source_guid: ObjectGuid, quest: &wow_data::quest::QuestTemplate, + auto_launched: bool, ) { let objectives: Vec = quest .objectives @@ -42282,6 +42593,7 @@ impl WorldSession { self.send_packet(&QuestGiverQuestDetails { giver_guid: source_guid, + giver_creature_id: quest_giver_creature_id_from_source_like_cpp(source_guid), quest_id: quest.id, quest_flags: [quest.flags, quest.flags_ex, quest.flags_ex2], suggested_party_members: quest.suggested_group_num, @@ -42290,22 +42602,92 @@ impl WorldSession { title: quest.log_title.clone(), description: quest.quest_description.clone(), log_description: quest.log_description.clone(), - auto_launched: false, + auto_launched, }); } - fn quest_list_entry_from_template_like_cpp( - quest: &wow_data::quest::QuestTemplate, + pub(crate) fn represented_creature_gossip_text_like_cpp( + &self, + creature_entry: u32, + ) -> Vec { + let Some(quest_store) = self.quest_store.as_ref() else { + return Vec::new(); + }; + + let starter_candidates = quest_store + .quests_for_starter(creature_entry) + .iter() + .map(|quest| { + ( + quest.id, + quest.allowable_races, + quest.allowable_classes, + quest.min_level, + quest.max_level, + self.can_take_quest(quest), + ) + }) + .collect::>(); + let menu_items = + self.represented_creature_quest_menu_items_like_cpp(quest_store, creature_entry); + info!( + creature_entry, + race = self.player_race_like_cpp(), + class = self.player_class_like_cpp(), + level = self.player_level_like_cpp(), + starter_candidates = ?starter_candidates, + quests = ?menu_items.iter().map(|item| item.quest.id).collect::>(), + "Prepared creature gossip quest text like C++" + ); + + menu_items + .iter() + .map(|item| self.gossip_text_from_menu_item_like_cpp(item)) + .collect() + } + + fn quest_list_entry_from_menu_item_like_cpp( + &self, + menu_item: &RepresentedPreparedQuestMenuItemLikeCpp, ) -> QuestListEntry { + let quest = &menu_item.quest; QuestListEntry { quest_id: quest.id, - quest_type: quest.quest_type, + // C++ `PlayerMenu::SendQuestGiverQuestListMessage` writes + // `QuestMenuItem::QuestIcon`, not `Quest::GetQuestType`. + quest_type: menu_item.quest_icon, + quest_level: 0, + quest_max_scaling_level: 0, + quest_flags: quest.flags, + quest_flags_ex: quest.flags_ex, + repeatable: quest.is_turn_in_like_cpp() + && quest.is_repeatable() + && !quest.is_daily_or_weekly_like_cpp() + && !quest.is_monthly_like_cpp(), + important: self.represented_quest_is_important_like_cpp(quest), + title: quest.log_title.clone(), + } + } + + fn gossip_text_from_menu_item_like_cpp( + &self, + menu_item: &RepresentedPreparedQuestMenuItemLikeCpp, + ) -> ClientGossipText { + let quest = &menu_item.quest; + ClientGossipText { + quest_id: quest.id as i32, + content_tuning_id: 0, + quest_type: i32::from(menu_item.quest_icon), quest_level: quest.quest_level, quest_max_scaling_level: quest.quest_max_scaling_level, quest_flags: quest.flags, quest_flags_ex: quest.flags_ex, - repeatable: quest.is_repeatable(), - title: quest.log_title.clone(), + repeatable: quest.is_turn_in_like_cpp() + && quest.is_repeatable() + && !quest.is_daily_or_weekly_like_cpp() + && !quest.is_monthly_like_cpp(), + important: self.represented_quest_is_important_like_cpp(quest), + quest_title: quest.log_title.clone(), } } @@ -42467,7 +42849,9 @@ impl WorldSession { && self .player_quests .get(&source.quest_id) - .map_or(true, |status| status.status != 1) + .map_or(true, |status| { + status.status != crate::conditions::QUEST_STATUS_INCOMPLETE_LIKE_CPP + }) { self.represented_gameobject_use_effects.push( RepresentedGameObjectUseEffect::GooberQuestGateRejected { @@ -61234,6 +61618,78 @@ mod tests { true } + #[derive(Debug, PartialEq, Eq)] + struct QuestGiverRequestItemsSummaryLikeCpp { + giver_creature_id: i32, + quest_id: u32, + status_flags: u32, + auto_launched: bool, + } + + fn quest_giver_request_items_summary_like_cpp( + bytes: &[u8], + ) -> QuestGiverRequestItemsSummaryLikeCpp { + let mut pkt = wow_packet::WorldPacket::from_bytes(&bytes[2..]); + pkt.read_packed_guid().unwrap(); + let giver_creature_id = pkt.read_int32().unwrap(); + let quest_id = pkt.read_int32().unwrap() as u32; + pkt.read_int32().unwrap(); // CompEmoteDelay + pkt.read_int32().unwrap(); // CompEmoteType + pkt.read_uint32().unwrap(); // QuestFlags[0] + pkt.read_uint32().unwrap(); // QuestFlags[1] + pkt.read_uint32().unwrap(); // QuestFlags[2] + pkt.read_int32().unwrap(); // SuggestedPartyMembers + pkt.read_int32().unwrap(); // MoneyToGet + let collect_count = pkt.read_int32().unwrap().max(0) as usize; + let currency_count = pkt.read_int32().unwrap().max(0) as usize; + let status_flags = pkt.read_int32().unwrap() as u32; + + for _ in 0..collect_count { + pkt.read_int32().unwrap(); // ObjectID + pkt.read_int32().unwrap(); // Amount + pkt.read_uint32().unwrap(); // Flags + } + for _ in 0..currency_count { + pkt.read_int32().unwrap(); // CurrencyID + pkt.read_int32().unwrap(); // Amount + } + + QuestGiverRequestItemsSummaryLikeCpp { + giver_creature_id, + quest_id, + status_flags, + auto_launched: pkt.read_bit().unwrap(), + } + } + + fn quest_list_level_fields_like_cpp(bytes: &[u8]) -> Vec<(u32, i32, i32)> { + let mut pkt = wow_packet::WorldPacket::from_bytes(&bytes[2..]); + pkt.read_packed_guid().unwrap(); + pkt.read_uint32().unwrap(); + pkt.read_uint32().unwrap(); + let quest_count = pkt.read_uint32().unwrap(); + let greeting_len = pkt.read_bits(11).unwrap(); + + let mut levels = Vec::new(); + for _ in 0..quest_count { + let quest_id = pkt.read_uint32().unwrap(); + let _content_tuning_id = pkt.read_uint32().unwrap(); + let _quest_type = pkt.read_int32().unwrap(); + let quest_level = pkt.read_int32().unwrap(); + let quest_max_scaling_level = pkt.read_int32().unwrap(); + let _quest_flags = pkt.read_uint32().unwrap(); + let _quest_flags_ex = pkt.read_uint32().unwrap(); + let _repeatable = pkt.read_bit().unwrap(); + let _important = pkt.read_bit().unwrap(); + let title_len = pkt.read_bits(9).unwrap(); + let _title = pkt.read_string(title_len as usize).unwrap(); + levels.push((quest_id, quest_level, quest_max_scaling_level)); + } + + let _greeting = pkt.read_string(greeting_len as usize).unwrap(); + levels + } + #[test] fn load_character_reputation_rows_like_cpp_merges_rows_after_identity_and_store() { let (mut session, _, _) = make_session(); @@ -79877,6 +80333,37 @@ mod tests { ), None ); + let legacy_manager = shared_map_manager(); + legacy_manager.write().unwrap().add_creature( + 571, + 0, + 0, + 0, + crate::map_manager::WorldCreature::new( + trainer_guid, + 500, + Position::new(14.0, 0.0, 0.0, 0.0), + 100, + 80, + 1, + 2, + 0.0, + 1, + 35, + wow_constants::unit::NPCFlags1::TRAINER.bits(), + 0, + ), + ); + session.set_map_manager(legacy_manager); + assert_eq!( + session.represented_npc_can_interact_with_like_cpp( + trainer_guid, + wow_constants::unit::NPCFlags1::TRAINER.bits(), + 0, + ), + None, + "C++ GetNPCIfCanInteractWith rejects the canonical hostile NPC; legacy mirrors must not bypass that rejection" + ); { let mut canonical = canonical.lock().unwrap(); @@ -79923,6 +80410,142 @@ mod tests { ); } + #[test] + fn npc_interaction_falls_back_to_legacy_creature_like_cpp() { + let (mut session, _pkt_tx, _send_rx) = make_session(); + let player_guid = ObjectGuid::create_player(1, 42); + let questgiver_guid = test_creature_guid(13); + let manager = shared_map_manager(); + + session.attach_player_controller_like_cpp(SessionPlayerController::new( + player_guid, + "Tester".to_string(), + Position::new(10.0, 0.0, 0.0, 0.0), + 571, + 1, + 1, + 80, + 0, + )); + manager.write().unwrap().add_creature( + 571, + 0, + 0, + 0, + crate::map_manager::WorldCreature::new( + questgiver_guid, + 503, + Position::new(14.0, 0.0, 0.0, 0.0), + 100, + 80, + 1, + 2, + 0.0, + 1, + 35, + wow_constants::unit::NPCFlags1::QUEST_GIVER.bits(), + 0, + ), + ); + session.set_map_manager(manager); + + assert_eq!( + session.represented_npc_can_interact_with_like_cpp( + questgiver_guid, + wow_constants::unit::NPCFlags1::QUEST_GIVER.bits(), + 0, + ), + Some(RepresentedCreatureAccessLikeCpp { + entry: 503, + position: Position::new(14.0, 0.0, 0.0, 0.0), + npc_flags: wow_constants::unit::NPCFlags1::QUEST_GIVER.bits(), + npc_flags2: 0, + faction_template_id: 35, + trainer_class: 0, + }) + ); + } + + #[test] + fn legacy_only_hostile_npc_interaction_is_rejected_like_cpp() { + let (mut session, _pkt_tx, _send_rx) = make_session(); + let player_guid = ObjectGuid::create_player(1, 43); + let questgiver_guid = test_creature_guid(14); + let manager = shared_map_manager(); + + session.attach_player_controller_like_cpp(SessionPlayerController::new( + player_guid, + "Tester".to_string(), + Position::new(10.0, 0.0, 0.0, 0.0), + 571, + 1, + 1, + 80, + 0, + )); + session.set_player_faction_template_like_cpp(1); + session.set_faction_template_store(Arc::new( + wow_data::progression_rewards::FactionTemplateStore::from_entries([ + faction_template_entry(35, 35, 0, 0, 1), + faction_template_entry(1, 1, 0, 0, 0), + ]), + )); + manager.write().unwrap().add_creature( + 571, + 0, + 0, + 0, + crate::map_manager::WorldCreature::new( + questgiver_guid, + 504, + Position::new(14.0, 0.0, 0.0, 0.0), + 100, + 80, + 1, + 2, + 0.0, + 1, + 35, + wow_constants::unit::NPCFlags1::QUEST_GIVER.bits(), + 0, + ), + ); + session.set_map_manager(Arc::clone(&manager)); + + assert_eq!( + session.represented_npc_can_interact_with_like_cpp( + questgiver_guid, + wow_constants::unit::NPCFlags1::QUEST_GIVER.bits(), + 0, + ), + None + ); + + manager + .write() + .unwrap() + .find_creature_mut(571, 0, questgiver_guid) + .unwrap() + .creature + .set_unit_flags2_runtime_like_cpp(UnitFlags2::INTERACT_WHILE_HOSTILE.bits()); + + assert_eq!( + session.represented_npc_can_interact_with_like_cpp( + questgiver_guid, + wow_constants::unit::NPCFlags1::QUEST_GIVER.bits(), + 0, + ), + Some(RepresentedCreatureAccessLikeCpp { + entry: 504, + position: Position::new(14.0, 0.0, 0.0, 0.0), + npc_flags: wow_constants::unit::NPCFlags1::QUEST_GIVER.bits(), + npc_flags2: 0, + faction_template_id: 35, + trainer_class: 0, + }) + ); + } + #[test] fn canonical_world_map_login_binding_uses_cpp_split_faction_instance() { let (mut session, _pkt_tx, _send_rx) = make_session(); @@ -120252,6 +120875,44 @@ mod tests { assert!(packet_contains_quest_ids_in_order(&bytes, &[9_001, 9_002])); } + #[test] + fn questgiver_quest_list_leaves_level_fields_zero_like_cpp() { + let (mut session, _pkt_tx, send_rx) = make_session(); + session.set_player_level_like_cpp(1); + let player_guid = ObjectGuid::create_player(1, 99); + let gameobject_guid = + ObjectGuid::create_world_object(HighGuid::GameObject, 0, 1, 571, 0, 777, 19); + let mut first = test_quest_template(9_001); + first.quest_level = 10; + first.quest_max_scaling_level = 70; + first.log_title = "First".into(); + let mut second = test_quest_template(9_002); + second.quest_level = 11; + second.quest_max_scaling_level = 80; + second.log_title = "Second".into(); + let mut quest_store = wow_data::quest::QuestStore::from_quests_like_cpp([first, second]); + assert!(quest_store.insert_gameobject_starter_relation_like_cpp(777, 9_001)); + assert!(quest_store.insert_gameobject_starter_relation_like_cpp(777, 9_002)); + session.quest_store = Some(Arc::new(quest_store)); + + assert!(session.use_represented_gameobject_questgiver_like_cpp( + gameobject_guid, + player_guid, + 777, + wow_entities::QuestgiverUseSource { gossip_id: 123 }, + )); + + let bytes = send_rx.try_recv().unwrap(); + assert_eq!( + wow_packet::WorldPacket::from_bytes(&bytes).server_opcode(), + Some(ServerOpcodes::QuestGiverQuestListMessage) + ); + assert_eq!( + quest_list_level_fields_like_cpp(&bytes), + vec![(9_001, 0, 0), (9_002, 0, 0)] + ); + } + #[test] fn gameobject_use_questgiver_single_incomplete_ender_auto_opens_request_items_like_cpp() { let (mut session, _pkt_tx, send_rx) = make_session(); @@ -120289,6 +120950,15 @@ mod tests { Some(ServerOpcodes::QuestGiverRequestItems) ); assert!(packet_contains_quest_ids_in_order(&bytes, &[9_003])); + assert_eq!( + quest_giver_request_items_summary_like_cpp(&bytes), + QuestGiverRequestItemsSummaryLikeCpp { + giver_creature_id: 0, + quest_id: 9_003, + status_flags: 0xFF, + auto_launched: true, + } + ); } #[test] @@ -120343,6 +121013,47 @@ mod tests { assert!(packet_contains_quest_ids_in_order(&bytes, &[9_101])); } + #[test] + fn creature_questgiver_single_complete_ender_auto_opens_request_items_can_complete_like_cpp() { + let (mut session, _pkt_tx, send_rx) = make_session(); + let creature_guid = + ObjectGuid::create_world_object(HighGuid::Creature, 0, 1, 571, 0, 777, 21); + let mut quest = test_quest_template(9_103); + quest.log_title = "Creature complete ender".into(); + let mut quest_store = wow_data::quest::QuestStore::from_quests_like_cpp([quest]); + quest_store.ender_quests.insert(777, vec![9_103]); + session.quest_store = Some(Arc::new(quest_store)); + session.player_quests.insert( + 9_103, + crate::handlers::quest::PlayerQuestStatus { + quest_id: 9_103, + status: crate::conditions::QUEST_STATUS_COMPLETE_LIKE_CPP, + explored: false, + accept_time_secs: 0, + end_time_secs: 0, + objective_counts: Vec::new(), + slot: 0, + }, + ); + + assert!(session.use_represented_creature_questgiver_like_cpp(creature_guid, 777)); + + let bytes = send_rx.try_recv().unwrap(); + assert_eq!( + wow_packet::WorldPacket::from_bytes(&bytes).server_opcode(), + Some(ServerOpcodes::QuestGiverRequestItems) + ); + assert_eq!( + quest_giver_request_items_summary_like_cpp(&bytes), + QuestGiverRequestItemsSummaryLikeCpp { + giver_creature_id: 777, + quest_id: 9_103, + status_flags: 0xFF, + auto_launched: true, + } + ); + } + #[test] fn creature_questgiver_ender_relation_precedes_starter_like_cpp() { let (mut session, _pkt_tx, send_rx) = make_session(); @@ -121200,6 +121911,65 @@ mod tests { Some(ServerOpcodes::QuestGiverRequestItems) ); assert!(packet_contains_quest_ids_in_order(&bytes, &[9_203])); + assert_eq!( + quest_giver_request_items_summary_like_cpp(&bytes), + QuestGiverRequestItemsSummaryLikeCpp { + giver_creature_id: 778, + quest_id: 9_203, + status_flags: 0xFF, + auto_launched: false, + } + ); + } + + #[test] + fn quest_giver_query_rewarded_nonrepeatable_complete_ender_is_not_completable_like_cpp() { + let (mut session, _pkt_tx, send_rx) = make_session(); + let canonical = shared_canonical_map_manager(); + let player_guid = ObjectGuid::create_player(1, 99); + let source_guid = + ObjectGuid::create_world_object(HighGuid::Creature, 0, 1, 571, 0, 778, 25); + let position = Position::new(1.0, 2.0, 3.0, 0.0); + session.set_player_guid(Some(player_guid)); + session.set_player_map_position_like_cpp(571, position); + session.set_canonical_map_manager(Arc::clone(&canonical)); + add_canonical_test_player_on_map(&canonical, player_guid, position, 571, 0); + add_canonical_test_creature( + &canonical, + source_guid, + 778, + position, + wow_constants::unit::NPCFlags1::QUEST_GIVER.bits(), + ); + let mut quest_store = + wow_data::quest::QuestStore::from_quests_like_cpp([test_quest_template(9_204)]); + quest_store.ender_quests.insert(778, vec![9_204]); + session.quest_store = Some(Arc::new(quest_store)); + session.player_quests.insert( + 9_204, + crate::handlers::quest::PlayerQuestStatus { + quest_id: 9_204, + status: crate::conditions::QUEST_STATUS_COMPLETE_LIKE_CPP, + explored: false, + accept_time_secs: 0, + end_time_secs: 0, + objective_counts: Vec::new(), + slot: 0, + }, + ); + session.rewarded_quests.insert(9_204); + + assert!(session.send_represented_quest_giver_query_quest_like_cpp(source_guid, 9_204)); + let bytes = send_rx.try_recv().unwrap(); + assert_eq!( + quest_giver_request_items_summary_like_cpp(&bytes), + QuestGiverRequestItemsSummaryLikeCpp { + giver_creature_id: 778, + quest_id: 9_204, + status_flags: 0xFD, + auto_launched: false, + } + ); } #[test] @@ -121267,6 +122037,15 @@ mod tests { Some(ServerOpcodes::QuestGiverRequestItems) ); assert!(packet_contains_quest_ids_in_order(&bytes, &[9_206])); + assert_eq!( + quest_giver_request_items_summary_like_cpp(&bytes), + QuestGiverRequestItemsSummaryLikeCpp { + giver_creature_id: 0, + quest_id: 9_206, + status_flags: 0xFF, + auto_launched: false, + } + ); } #[test] @@ -121904,7 +122683,7 @@ mod tests { 200, crate::handlers::quest::PlayerQuestStatus { quest_id: 200, - status: 1, + status: crate::conditions::QUEST_STATUS_INCOMPLETE_LIKE_CPP, explored: false, accept_time_secs: 0, end_time_secs: 0, diff --git a/tools/wow-test-bot/.gitignore b/tools/wow-test-bot/.gitignore new file mode 100644 index 00000000..4e330d71 --- /dev/null +++ b/tools/wow-test-bot/.gitignore @@ -0,0 +1,10 @@ +/target/ +/config.json +/.env.local +*.log +*.tmp +*.bak +/reports/ +*.pcap +*.key +*.pem diff --git a/tools/wow-test-bot/Cargo.lock b/tools/wow-test-bot/Cargo.lock new file mode 100644 index 00000000..9b60daf3 --- /dev/null +++ b/tools/wow-test-bot/Cargo.lock @@ -0,0 +1,3228 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bigdecimal" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" +dependencies = [ + "autocfg", + "libm", + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.11.1", + "cexpr", + "clang-sys", + "itertools", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn 2.0.117", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "borsh" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "btoi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd6407f73a9b8b6162d8a2ef999fe6afd7cc15902ebf42c5cd296addf17e0ad" +dependencies = [ + "num-traits", +] + +[[package]] +name = "bufstream" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40e38929add23cdf8a366df9b0e088953150724bcbe5fc330b0d8eb3b328eec8" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cookie" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7efb37c3e1ccb1ff97164ad95ac1606e8ccd35b3fa0a7d99a304c7f4a428cc24" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "387461abbc748185c3a6e1673d826918b450b87ff22639429c694619a83b6cf6" +dependencies = [ + "cookie", + "idna 0.3.0", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_utils" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "362f47930db19fe7735f527e6595e4900316b893ebf6d48ad3d31be928d57dd6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "libz-sys", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "frunk" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28aef0f9aa070bce60767c12ba9cb41efeaf1a2bc6427f87b7d83f11239a16d7" +dependencies = [ + "frunk_core", + "frunk_derives", + "frunk_proc_macros", + "serde", +] + +[[package]] +name = "frunk_core" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "476eeaa382e3462b84da5d6ba3da97b5786823c2d0d3a0d04ef088d073da225c" +dependencies = [ + "serde", +] + +[[package]] +name = "frunk_derives" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0b4095fc99e1d858e5b8c7125d2638372ec85aa0fe6c807105cf10b0265ca6c" +dependencies = [ + "frunk_proc_macro_helpers", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "frunk_proc_macro_helpers" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1952b802269f2db12ab7c0bd328d0ae8feaabf19f352a7b0af7bb0c5693abfce" +dependencies = [ + "frunk_core", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "frunk_proc_macros" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3462f590fa236005bd7ca4847f81438bd6fe0febd4d04e11968d4c2e96437e78" +dependencies = [ + "frunk_core", + "frunk_proc_macro_helpers", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper", + "native-tls", + "tokio", + "tokio-native-tls", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "io-enum" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de9008599afe8527a8c9d70423437363b321649161e98473f433de802d76107" +dependencies = [ + "derive_utils", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libz-sys" +version = "1.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mysql" +version = "25.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6ad644efb545e459029b1ffa7c969d830975bd76906820913247620df10050b" +dependencies = [ + "bufstream", + "bytes", + "crossbeam", + "flate2", + "io-enum", + "libc", + "lru", + "mysql_common", + "named_pipe", + "native-tls", + "pem", + "percent-encoding", + "serde", + "serde_json", + "socket2 0.5.10", + "twox-hash", + "url", +] + +[[package]] +name = "mysql-common-derive" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63c3512cf11487168e0e9db7157801bf5273be13055a9cc95356dc9e0035e49c" +dependencies = [ + "darling", + "heck", + "num-bigint", + "proc-macro-crate", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.117", + "termcolor", + "thiserror", +] + +[[package]] +name = "mysql_common" +version = "0.32.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478b0ff3f7d67b79da2b96f56f334431aef65e15ba4b29dd74a4236e29582bdc" +dependencies = [ + "base64 0.21.7", + "bigdecimal", + "bindgen", + "bitflags 2.11.1", + "bitvec", + "btoi", + "byteorder", + "bytes", + "cc", + "cmake", + "crc32fast", + "flate2", + "frunk", + "lazy_static", + "mysql-common-derive", + "num-bigint", + "num-traits", + "rand", + "regex", + "rust_decimal", + "saturating", + "serde", + "serde_json", + "sha1", + "sha2", + "smallvec", + "subprocess", + "thiserror", + "time", + "uuid", + "zstd", +] + +[[package]] +name = "named_pipe" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad9c443cce91fc3e12f017290db75dde490d685cdaaf508d7159d7cf41f0eb2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl" +version = "0.10.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe4646e360ec77dff7dde40ed3d6c5fee52d156ef4a62f53973d38294dad87f" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.113" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad2f2c0eba47118757e4c6d2bff2838f3e0523380021356e7875e858372ce644" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna 1.1.0", + "psl-types", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "cookie", + "cookie_store", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "hyper", + "hyper-tls", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration", + "tokio", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg", +] + +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "rust_decimal" +version = "1.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ce901f9a19d251159075a4c37af514c3b8ef99c22e02dd8c19161cf397ee94a" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand", + "rkyv", + "serde", + "serde_json", + "wasm-bindgen", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "saturating" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ece8e78b2f38ec51c51f5d475df0a7187ba5111b2a28bdc761ee05b075d40a71" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subprocess" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c56e8662b206b9892d7a5a3f2ecdbcb455d3d6b259111373b7e08b8055158a8" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.3", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "twox-hash" +version = "1.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fee6b57c6a41524a810daee9286c02d7752c4253064d0b05472833a438f675" +dependencies = [ + "cfg-if", + "rand", + "static_assertions", +] + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna 1.1.0", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "serde", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "wow-test-bot" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "anyhow", + "chrono", + "flate2", + "hex", + "hmac", + "mysql", + "num-bigint", + "num-traits", + "openssl", + "rand", + "reqwest", + "serde", + "serde_json", + "sha2", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/tools/wow-test-bot/Cargo.toml b/tools/wow-test-bot/Cargo.toml new file mode 100644 index 00000000..778dda53 --- /dev/null +++ b/tools/wow-test-bot/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "wow-test-bot" +version = "0.1.0" +edition = "2021" + +[dependencies] +tokio = { version = "1", features = ["full"] } +anyhow = "1" +tracing = "0.1" +tracing-subscriber = "0.3" +chrono = "0.4" +reqwest = { version = "0.11", features = ["json", "cookies", "blocking"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +sha2 = "0.10" +hmac = "0.12" +hex = "0.4" +num-bigint = "0.4" +num-traits = "0.2" +rand = "0.8" +aes-gcm = "0.10" +openssl = "0.10" +mysql = "25.0" +flate2 = "1" + +[[bin]] +name = "wow-test-bot" +path = "src/main.rs" diff --git a/tools/wow-test-bot/README.md b/tools/wow-test-bot/README.md new file mode 100644 index 00000000..a9630eb1 --- /dev/null +++ b/tools/wow-test-bot/README.md @@ -0,0 +1,36 @@ +# WoW Test Bot + +Headless Rust bot used to smoke-test RustyCore and the legacy TrinityCore test +server. This copy is integrated into the RustyCore repo as a QA tool. + +For the current RustyCore login gate, see: + +- `RUSTYCORE_SMOKE.md` +- `run_rustycore_login_smoke.sh` + +The wrapper also supports `WOW_BOT_QUEST_SMOKE=1` for questgiver/gossip QA. It +logs in, targets a configured creature entry/spawn, verifies the server responds +with gossip/list/details packets, and writes the quest ids/titles to the JSON +report. For mutating accept-flow QA, combine `WOW_BOT_QUEST_RESET=1`, +`WOW_BOT_QUEST_RELOCATE=1`, `WOW_BOT_QUEST_SET_RACE=`, +`WOW_BOT_QUEST_SET_CLASS=`, `WOW_BOT_QUEST_SET_LEVEL=<1-80>`, and +`WOW_BOT_QUEST_ACCEPT=1`; the bot will prepare the selected test character, +accept the expected quest, and verify `character_queststatus`. The bot always +uses the `world.creature.guid` spawn id as the creature GUID counter, matching +C++ DB-spawned creature loading. + +For quest objective persistence QA, add `WOW_BOT_QUEST_OBJECTIVE_PERSIST=1` +and `WOW_BOT_QUEST_OBJECTIVES=`. The bot seeds the expected +quest as active in `character_queststatus`, seeds nonzero +`character_queststatus_objectives` rows, logs in, sends logout, and verifies the +objective rows survived the server's logout save. + +`config.example.json` is versioned with blank passwords. Use `WOW_BOT_PASSWORD`, +the per-account `WOW_BOT_PASSWORD_` override, or an ignored local +`config.json` for credentials. Do not commit real local bot passwords. + +The RustyCore wrapper defaults to `WOW_BOT_ENSURE_TEST_ACCOUNTS=1`: it +generates an ignored `.env.local` password when none exists, then upserts only +local `@bot.local` BNet/game account rows with matching SRP credentials before +running. Set `WOW_BOT_GENERATE_LOCAL_PASSWORD=0` or +`WOW_BOT_ENSURE_TEST_ACCOUNTS=0` to disable that local QA bootstrap. diff --git a/tools/wow-test-bot/RUSTYCORE_SMOKE.md b/tools/wow-test-bot/RUSTYCORE_SMOKE.md new file mode 100644 index 00000000..e1c1bb3b --- /dev/null +++ b/tools/wow-test-bot/RUSTYCORE_SMOKE.md @@ -0,0 +1,226 @@ +# RustyCore smoke tests + +This bot can be used as a headless client harness for RustyCore. Treat it as an +E2E regression tool only: C++ TrinityCore remains the protocol/source-of-truth. + +## Current safe gate + +The first RustyCore gate is login-only: + +```text +BNet auth -> world auth -> CMSG_ENUM_CHARACTERS -> CMSG_PLAYER_LOGIN -> SMSG_LOGIN_VERIFY_WORLD +``` + +This intentionally does not run Dungeon Finder/LFG. LFG is a later gate, after +the corresponding Rust server port is ready. + +## What was adapted + +- `--login-only`: stops immediately after `SMSG_LOGIN_VERIFY_WORLD`. +- `--quest-smoke`: after login, resolves one creature questgiver, sends + `CMSG_GOSSIP_HELLO`, falls back to `CMSG_QUEST_GIVER_HELLO`, optionally sends + `CMSG_QUEST_GIVER_QUERY_QUEST`, and reports the quest ids/titles received. +- `WOW_BOT_LOGIN_ONLY=1`: env equivalent of `--login-only`. +- `WOW_BOT_CLIENT_BUILD` / `WOW_BOT_BUILD`: build value printed by the smoke, + default `54261`. +- `WOW_BOT_PASSWORD`: shared local password for accounts in `config.example.json`. +- `WOW_BOT_PASSWORD_`: per-account override, with non-alphanumeric + account characters replaced by `_` and letters uppercased (for example + `WOW_BOT_PASSWORD_TESTBOT1_BOT_LOCAL`). +- `WOW_BOT_AUTH_DB_URL`, `WOW_BOT_CHAR_DB_URL`, `WOW_BOT_WORLD_DB_URL`: optional + DB URL overrides. If omitted, the bot reads `LoginDatabaseInfo`, + `CharacterDatabaseInfo`, and `WorldDatabaseInfo` from `WOW_BOT_DB_CONF` + (default `/home/server/trinity-legacy-install/etc/worldserver.conf`). +- JSON reports now include `login_only`, `world_auth`, `enum_characters`, and + `player_login_verified`. Quest smoke reports additionally include + `quest_smoke_passed`, target entry/spawn/map, `quest_ids_seen`, + `quest_titles_seen`, and `quest_failure`. + +The bot still supports the previous LFG path. Do not use LFG as the RustyCore +migration gate until the server-side LFG port is explicitly ready. + +## Default RustyCore smoke command + +Use the wrapper: + +```bash +./run_rustycore_login_smoke.sh +``` + +By default it runs one account: + +```text +TESTBOT1@bot.local +``` + +It writes: + +```text +/tmp/rustycore-bot-login-only.log +/tmp/rustycore-bot-login-only-report.json +``` + +The expected report shape for a pass is: + +```json +{ + "login_only": true, + "results": [ + { + "account": "TESTBOT1@bot.local", + "world_auth": true, + "enum_characters": true, + "player_login_verified": true, + "join_result": null + } + ] +} +``` + +If no bot password is configured, the wrapper generates one in ignored +`tools/wow-test-bot/.env.local` and exports it for the run. By default it also +passes `--ensure-test-accounts`, which upserts only local `@bot.local` BNet/game +account rows with SRP credentials matching that password, clears local test +account lock/ban state, verifies the configured character GUID exists, and +syncs `realmcharacters`. + +Disable these local QA helpers with: + +```bash +WOW_BOT_GENERATE_LOCAL_PASSWORD=0 \ +WOW_BOT_ENSURE_TEST_ACCOUNTS=0 \ +./run_rustycore_login_smoke.sh +``` + +## Useful overrides + +```bash +WOW_BOT_PASSWORD='local-password' WOW_BOT_ACCOUNT=TESTBOT2@bot.local ./run_rustycore_login_smoke.sh + +BNET_HOST=127.0.0.1 BNET_PORT=8081 \ +WORLD_HOST=127.0.0.1 WORLD_PORT=8085 \ +INSTANCE_HOST=127.0.0.1 REALM_ID=1 \ +WOW_BOT_BUILD=54261 \ +WOW_BOT_PASSWORD='local-password' \ +./run_rustycore_login_smoke.sh +``` + +If the DB names or credentials differ from the runtime config, either point at +that config: + +```bash +WOW_BOT_DB_CONF=/path/to/worldserver.conf \ +WOW_BOT_PASSWORD='local-password' \ +./run_rustycore_login_smoke.sh +``` + +or set explicit DB URLs through an ignored `tools/wow-test-bot/.env.local`. + +## Quest / gossip smoke + +Use this when QA needs to prove that a visible questgiver actually responds and +that the offered quest set matches class/race/level expectations: + +```bash +WOW_BOT_QUEST_SMOKE=1 \ +WOW_BOT_QUEST_CREATURE_ENTRY=15513 \ +WOW_BOT_QUEST_EXPECT_ID= \ +WOW_BOT_QUEST_FORBID_TITLE_CONTAINS='Mage' \ +WOW_BOT_PASSWORD='local-password' \ +./run_rustycore_login_smoke.sh +``` + +For deterministic accept-flow QA, let the bot prepare a test character before +login: + +```bash +WOW_BOT_QUEST_SMOKE=1 \ +WOW_BOT_QUEST_CREATURE_ENTRY=15278 \ +WOW_BOT_QUEST_MAP_ID=530 \ +WOW_BOT_QUEST_EXPECT_ID=9393 \ +WOW_BOT_QUEST_RESET=1 \ +WOW_BOT_QUEST_RELOCATE=1 \ +WOW_BOT_QUEST_SET_RACE=10 \ +WOW_BOT_QUEST_SET_CLASS=3 \ +WOW_BOT_QUEST_SET_LEVEL=3 \ +WOW_BOT_QUEST_ACCEPT=1 \ +WOW_BOT_PASSWORD='local-password' \ +./run_rustycore_login_smoke.sh +``` + +For objective load/save QA, seed an active quest with objective counters, then +force a real logout and compare `character_queststatus_objectives` after the +server save: + +```bash +WOW_BOT_QUEST_SMOKE=1 \ +WOW_BOT_QUEST_CREATURE_ENTRY=15278 \ +WOW_BOT_QUEST_MAP_ID=530 \ +WOW_BOT_QUEST_EXPECT_ID=9393 \ +WOW_BOT_QUEST_OBJECTIVE_PERSIST=1 \ +WOW_BOT_QUEST_OBJECTIVES=0:1 \ +WOW_BOT_PASSWORD='local-password' \ +./run_rustycore_login_smoke.sh +``` + +Useful quest overrides: + +- `WOW_BOT_QUEST_CREATURE_ENTRY`: required creature template entry. +- `WOW_BOT_QUEST_CREATURE_GUID`: optional exact `world.creature.guid` spawn. +- `WOW_BOT_QUEST_GUID_COUNTER`: optional live `ObjectGuid` low counter. C++ `Creature::LoadFromDB` uses a map-generated lowguid for the live creature and keeps the DB spawn guid separately. +- `WOW_BOT_QUEST_MAP_ID`: optional map override used for GUID construction. +- `WOW_BOT_QUEST_EXPECT_ID`: require this quest id in list/details. +- `WOW_BOT_QUEST_FORBID_ID`: fail if this quest id is offered. +- `WOW_BOT_QUEST_FORBID_TITLE_CONTAINS`: fail if any offered title contains this + text, case-insensitive. +- `WOW_BOT_QUEST_QUERY_DETAILS=0`: skip the non-mutating + `CMSG_QUEST_GIVER_QUERY_QUEST` details probe. +- `WOW_BOT_QUEST_RESET=1`: remove the expected quest from the selected bot + character's active/rewarded quest tables before login. +- `WOW_BOT_QUEST_RELOCATE=1`: move the selected bot character near the resolved + creature spawn before login. +- `WOW_BOT_QUEST_SET_LEVEL=<1-80>`: set the selected bot character level before + login so class/race/level filters are tested deterministically. +- `WOW_BOT_QUEST_SET_RACE=`: set the selected bot character race before + login for race-gated quest QA. +- `WOW_BOT_QUEST_SET_CLASS=`: set the selected bot character class before + login for class-gated quest QA. +- `WOW_BOT_QUEST_ACCEPT=1`: send `CMSG_QUEST_GIVER_ACCEPT_QUEST` after details + and verify the quest persisted in `character_queststatus`. +- `WOW_BOT_QUEST_OBJECTIVE_PERSIST=1`: seed the expected quest and objective + rows, logout, and verify `character_queststatus_objectives` survived the + server save. +- `WOW_BOT_QUEST_OBJECTIVES=`: objective rows to seed for + persistence QA, using C++ `QuestObjective::StorageIndex` values. +- `WOW_BOT_QUEST_OBJECTIVE_STATUS=`: optional quest status for the seeded + `character_queststatus` row; defaults to `3` (incomplete). +- `WOW_BOT_ENSURE_TEST_ACCOUNTS=0`: skip the default local `@bot.local` + account/password bootstrap. +- `WOW_BOT_ALLOW_NONLOCAL_ACCOUNT_BOOTSTRAP=1`: allow + `--ensure-test-accounts` to touch non-`@bot.local` accounts. Keep this off for + normal QA. + +When no exact spawn guid is provided, the bot uses the selected character's +saved map/position and picks the nearest `world.creature` row for the requested +entry. The character must still be close enough in-game for RustyCore's +interaction-distance checks. + +For DB-spawned creatures the bot intentionally uses `world.creature.guid` as +the visible GUID counter, matching C++ `Creature::LoadFromDB` / +`CreateFromProto`. A questgiver that only responds to a different counter is a +server GUID bug, not a bot override case. + +## Known notes + +- The bot updates `account.session_key_bnet` for the selected test account. +- `config.example.json` intentionally keeps passwords blank. Use env overrides + or an ignored local `config.json`; never commit real bot passwords. +- `tools/wow-test-bot/.env.local` is ignored and is the right place for local + DB URL overrides or bot passwords when needed. +- The current RustyCore BNet server does not expose the old C++ bot-only + `/login/srp/` route. The bot falls back to `/bnetserver/login/`, then writes + the generated world key into the auth DB for the world handshake. +- `SMSG_CONNECT_TO` / instance socket is handled by the current bot code; older + C++ notes saying realm-socket-only are stale for this tree. +- Keep raw tickets/session keys out of chat and commit messages. Use the report + booleans and sanitized log grep lines for status. diff --git a/tools/wow-test-bot/config.example.json b/tools/wow-test-bot/config.example.json new file mode 100644 index 00000000..83fd563b --- /dev/null +++ b/tools/wow-test-bot/config.example.json @@ -0,0 +1,64 @@ +{ + "bots": [ + { + "account": "TESTBOT1@bot.local", + "password": "", + "character_guid": 14, + "account_id": 8, + "lfg_role": 2, + "class": "warrior", + "enabled": true, + "session_key_bnet": "" + }, + { + "account": "TESTBOT2@bot.local", + "password": "", + "character_guid": 15, + "account_id": 9, + "lfg_role": 4, + "class": "paladin", + "enabled": true, + "session_key_bnet": "" + }, + { + "account": "TESTBOT3@bot.local", + "password": "", + "character_guid": 16, + "account_id": 10, + "lfg_role": 8, + "class": "mage", + "enabled": true, + "session_key_bnet": "" + }, + { + "account": "TESTBOT4@bot.local", + "password": "", + "character_guid": 17, + "account_id": 11, + "lfg_role": 8, + "class": "warlock", + "enabled": true, + "session_key_bnet": "" + }, + { + "account": "TESTBOT5@bot.local", + "password": "", + "character_guid": 18, + "account_id": 12, + "lfg_role": 8, + "class": "hunter", + "enabled": true, + "session_key_bnet": "" + } + ], + "test_config": { + "dungeon_id": 220, + "wait_for_proposal_timeout_secs": 60, + "launch_delay_ms": 2000, + "tests": { + "lfg_join": true, + "lfg_proposal": true, + "bg_join": false + } + } +} diff --git a/tools/wow-test-bot/run_rustycore_login_smoke.sh b/tools/wow-test-bot/run_rustycore_login_smoke.sh new file mode 100755 index 00000000..fa30664f --- /dev/null +++ b/tools/wow-test-bot/run_rustycore_login_smoke.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")" + +if [[ -f .env.local ]]; then + set -a + . ./.env.local + set +a +fi + +password_env_name() { + local account="$1" + local suffix="" + local i ch + for ((i = 0; i < ${#account}; i++)); do + ch="${account:i:1}" + if [[ "$ch" =~ [[:alnum:]] ]]; then + suffix+="${ch^^}" + else + suffix+="_" + fi + done + printf 'WOW_BOT_PASSWORD_%s' "$suffix" +} + +ensure_local_password() { + local account="${WOW_BOT_ACCOUNT:-TESTBOT1@bot.local}" + local per_account_var + per_account_var="$(password_env_name "$account")" + if [[ -n "${WOW_BOT_PASSWORD:-}" || -n "${!per_account_var:-}" ]]; then + return + fi + if [[ "${WOW_BOT_GENERATE_LOCAL_PASSWORD:-1}" =~ ^(0|false|FALSE|no|NO|off|OFF)$ ]]; then + return + fi + + local generated + generated="$(openssl rand -hex 24)" + umask 077 + if [[ ! -f .env.local ]]; then + : >.env.local + fi + { + printf '\n# Generated by run_rustycore_login_smoke.sh for local QA only.\n' + printf 'WOW_BOT_PASSWORD=%q\n' "$generated" + } >>.env.local + export WOW_BOT_PASSWORD="$generated" + echo "Generated local bot password in ignored tools/wow-test-bot/.env.local" +} + +ensure_local_password + +account="${WOW_BOT_ACCOUNT:-TESTBOT1@bot.local}" +timeout_secs="${WOW_BOT_LOGIN_TIMEOUT_SECS:-1}" +report_path="${WOW_BOT_REPORT:-/tmp/rustycore-bot-login-only-report.json}" +log_path="${WOW_BOT_LOG:-/tmp/rustycore-bot-login-only.log}" +quest_timeout_secs="${WOW_BOT_QUEST_TIMEOUT_SECS:-5}" +ensure_accounts="${WOW_BOT_ENSURE_TEST_ACCOUNTS:-1}" + +export BNET_HOST="${BNET_HOST:-127.0.0.1}" +export BNET_PORT="${BNET_PORT:-8081}" +export WORLD_HOST="${WORLD_HOST:-127.0.0.1}" +export WORLD_PORT="${WORLD_PORT:-8085}" +export INSTANCE_HOST="${INSTANCE_HOST:-127.0.0.1}" +export REALM_ID="${REALM_ID:-1}" +export WOW_BOT_CLIENT_BUILD="${WOW_BOT_CLIENT_BUILD:-${WOW_BOT_BUILD:-54261}}" +export WOW_BOT_DB_CONF="${WOW_BOT_DB_CONF:-/home/server/trinity-legacy-install/etc/worldserver.conf}" + +mode_args=(--login-only --timeout "$timeout_secs") +if [[ "${WOW_BOT_QUEST_SMOKE:-0}" =~ ^(1|true|TRUE|yes|YES|on|ON)$ ]]; 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}" + log_path="${WOW_BOT_LOG:-/tmp/rustycore-bot-quest-smoke.log}" + mode_args=(--quest-smoke --quest-creature-entry "$WOW_BOT_QUEST_CREATURE_ENTRY" --quest-timeout "$quest_timeout_secs") + if [[ -n "${WOW_BOT_QUEST_CREATURE_GUID:-}" ]]; then + mode_args+=(--quest-creature-guid "$WOW_BOT_QUEST_CREATURE_GUID") + fi + if [[ -n "${WOW_BOT_QUEST_GUID_COUNTER:-}" ]]; then + mode_args+=(--quest-guid-counter "$WOW_BOT_QUEST_GUID_COUNTER") + fi + if [[ -n "${WOW_BOT_QUEST_MAP_ID:-}" ]]; then + mode_args+=(--quest-map "$WOW_BOT_QUEST_MAP_ID") + fi + if [[ -n "${WOW_BOT_QUEST_EXPECT_ID:-}" ]]; then + mode_args+=(--expect-quest "$WOW_BOT_QUEST_EXPECT_ID") + fi + if [[ -n "${WOW_BOT_QUEST_FORBID_ID:-}" ]]; then + mode_args+=(--forbid-quest "$WOW_BOT_QUEST_FORBID_ID") + fi + if [[ -n "${WOW_BOT_QUEST_FORBID_TITLE_CONTAINS:-}" ]]; then + mode_args+=(--forbid-title "$WOW_BOT_QUEST_FORBID_TITLE_CONTAINS") + fi + if [[ "${WOW_BOT_QUEST_QUERY_DETAILS:-1}" =~ ^(0|false|FALSE|no|NO|off|OFF)$ ]]; then + mode_args+=(--no-quest-query-details) + fi + if [[ "${WOW_BOT_QUEST_ACCEPT:-0}" =~ ^(1|true|TRUE|yes|YES|on|ON)$ ]]; then + mode_args+=(--quest-accept) + fi + if [[ "${WOW_BOT_QUEST_RESET:-0}" =~ ^(1|true|TRUE|yes|YES|on|ON)$ ]]; then + mode_args+=(--quest-reset) + fi + if [[ "${WOW_BOT_QUEST_RELOCATE:-0}" =~ ^(1|true|TRUE|yes|YES|on|ON)$ ]]; then + mode_args+=(--quest-relocate) + fi + if [[ -n "${WOW_BOT_QUEST_SET_LEVEL:-}" ]]; then + mode_args+=(--quest-set-level "$WOW_BOT_QUEST_SET_LEVEL") + fi + if [[ -n "${WOW_BOT_QUEST_SET_RACE:-}" ]]; then + mode_args+=(--quest-set-race "$WOW_BOT_QUEST_SET_RACE") + fi + if [[ -n "${WOW_BOT_QUEST_SET_CLASS:-}" ]]; then + mode_args+=(--quest-set-class "$WOW_BOT_QUEST_SET_CLASS") + fi + if [[ "${WOW_BOT_QUEST_OBJECTIVE_PERSIST:-0}" =~ ^(1|true|TRUE|yes|YES|on|ON)$ ]]; then + : "${WOW_BOT_QUEST_OBJECTIVES:?Set WOW_BOT_QUEST_OBJECTIVES for quest objective persistence QA}" + mode_args+=(--quest-objective-persist --quest-objectives "$WOW_BOT_QUEST_OBJECTIVES") + fi + if [[ -n "${WOW_BOT_QUEST_OBJECTIVE_STATUS:-}" ]]; then + mode_args+=(--quest-objective-status "$WOW_BOT_QUEST_OBJECTIVE_STATUS") + fi +fi + +cargo build --bin wow-test-bot + +ensure_args=() +if [[ "$ensure_accounts" =~ ^(1|true|TRUE|yes|YES|on|ON)$ ]]; then + ensure_args+=(--ensure-test-accounts) +fi + +./target/debug/wow-test-bot \ + --config "${WOW_BOT_CONFIG:-config.example.json}" \ + --single "$account" \ + "${mode_args[@]}" \ + "${ensure_args[@]}" \ + --no-cleanup-groups \ + --no-auto-teleport \ + --report "$report_path" \ + >"$log_path" 2>&1 + +echo "RustyCore smoke passed for $account" +echo "log: $log_path" +echo "report: $report_path" + +if command -v jq >/dev/null 2>&1; then + jq '{login_only, quest_smoke, results: [.results[] | {account, world_auth, enum_characters, player_login_verified, quest_smoke_passed, quest_target_entry, quest_target_spawn_guid, quest_target_guid_counter, quest_ids_seen, quest_titles_seen, quest_accept_sent, quest_accept_confirm_seen, quest_db_verified, quest_db_status, quest_failure, join_result}]}' "$report_path" +fi diff --git a/tools/wow-test-bot/src/bot_srp6.rs b/tools/wow-test-bot/src/bot_srp6.rs new file mode 100644 index 00000000..78c8a0b4 --- /dev/null +++ b/tools/wow-test-bot/src/bot_srp6.rs @@ -0,0 +1,454 @@ +//! Bot SRP6 Authentication for TrinityCore 3.4.3 bnetserver bot endpoint +//! Uses /login/srp/ and /login/ REST endpoints (stateless bot SRP6) +//! +//! Parameters (from TrinityCore LoginRESTService.cpp, all big-endian to match +//! `BigNumber(..., false)` and `ToByteArray<32>(false)` on the server side): +//! N = 894B645E89E1535BBDAD5B8B290650530801B18EBFBF5E8FAB3C82872A3E9BB7 +//! g = 2 +//! k = SHA256(N_BE32 || g_BE32) interpreted BIG-endian +//! x = SHA256(salt || SHA256(username || ":" || password)) interpreted BIG-endian +//! u = SHA256(A_BE32 || B_BE32) interpreted BIG-endian +//! M1 = SHA256(BE(A) || BE(B) || BE(S)) with BE() = ToByteVector((bits+8)/8, false) +//! K = SHA256(BE(S)) (computed server-side, returned in proof JSON) + +use num_bigint::BigUint; +use num_traits::Zero; +use rand::{thread_rng, RngCore}; +use reqwest::Client; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; + +const BOT_SRP_N_HEX: &str = "894B645E89E1535BBDAD5B8B290650530801B18EBFBF5E8FAB3C82872A3E9BB7"; +const BNET_SRP_V1_N_HEX: &str = concat!( + "86A7F6DEEB306CE519770FE37D556F29944132554DED0BD68205E27F3231FEF5", + "A10108238A3150C59CAF7B0B6478691C13A6ACF5E1B5ADAFD4A943D4A21A142B", + "800E8A55F8BFBAC700EB77A7235EE5A609E350EA9FC19F10D921C2FA832E4461", + "B7125D38D254A0BE873DFC27858ACB3F8B9F258461E4373BC3A6C2A9634324AB", +); +const BOT_SRP_G: u32 = 2; + +fn sha256(data: &[u8]) -> Vec { + Sha256::digest(data).to_vec() +} + +fn sha256_concat(parts: &[&[u8]]) -> Vec { + let mut hasher = Sha256::new(); + for part in parts { + hasher.update(part); + } + hasher.finalize().to_vec() +} + +pub fn utf8_to_upper_only_latin_like_cpp(input: &str) -> String { + input + .chars() + .map(|ch| match ch { + 'a'..='z' => ((ch as u8) - b'a' + b'A') as char, + _ => ch, + }) + .collect() +} + +pub fn bnet_srp_username_like_cpp(email: &str) -> String { + let normalized = utf8_to_upper_only_latin_like_cpp(email); + hex::encode_upper(Sha256::digest(normalized.as_bytes())) +} + +pub fn bnet_v1_registration_material_like_cpp( + email: &str, + password: &str, +) -> (String, [u8; 32], Vec) { + let normalized_email = utf8_to_upper_only_latin_like_cpp(email); + let username = bnet_srp_username_like_cpp(&normalized_email); + let password = utf8_to_upper_only_latin_like_cpp(password); + let mut salt = [0u8; 32]; + thread_rng().fill_bytes(&mut salt); + let inner = sha256_concat(&[username.as_bytes(), b":", password.as_bytes()]); + let outer = sha256_concat(&[&salt, &inner]); + let x = BigUint::from_bytes_le(&outer); + let n = BigUint::parse_bytes(BNET_SRP_V1_N_HEX.as_bytes(), 16) + .expect("failed to parse BNet SRP v1 modulus"); + let g = BigUint::from(BOT_SRP_G); + let verifier = g.modpow(&x, &n).to_bytes_le(); + (normalized_email, salt, verifier) +} + +fn decode_server_hex(value: &str) -> Result, hex::FromHexError> { + if value.len() % 2 == 0 { + hex::decode(value) + } else { + hex::decode(format!("0{value}")) + } +} + +/// Pad BigUint to 32 bytes BIG-endian — matches BigNumber::ToByteArray<32>(false) +/// on the server (where `littleEndian=false` means big-endian, see BigNumber.cpp). +fn pad_be_32(bn: &BigUint) -> Vec { + let bytes = bn.to_bytes_be(); + if bytes.len() >= 32 { + return bytes; + } + let mut out = vec![0u8; 32 - bytes.len()]; + out.extend_from_slice(&bytes); + out +} + +/// Broken evidence vector: BIG-endian bytes with length `(num_bits + 8) >> 3`, +/// matching TrinityCore's GetBrokenEvidenceVector — `bn.ToByteVector(len, false)` +/// returns big-endian, and "+8" adds one extra (most-significant in BE, leading) +/// zero byte whenever num_bits is a multiple of 8. +fn broken_evidence_be(bn: &BigUint) -> Vec { + let bit_len = bn.bits() as usize; + let byte_len = bit_len / 8 + 1; + let bytes = bn.to_bytes_be(); + if bytes.len() >= byte_len { + return bytes; + } + let mut out = vec![0u8; byte_len - bytes.len()]; + out.extend_from_slice(&bytes); + out +} + +pub struct BotSrp6Client { + email: String, + password: String, + a: BigUint, + A: BigUint, + B: BigUint, + salt: Vec, + n: BigUint, + g: BigUint, + k: BigUint, + s: Option, +} + +impl BotSrp6Client { + pub fn new(email: &str, password: &str) -> Self { + let n = + BigUint::parse_bytes(BOT_SRP_N_HEX.as_bytes(), 16).expect("Failed to parse BOT_SRP_N"); + let g = BigUint::from(BOT_SRP_G); + + // k = SHA256(N_BE32 || g_BE32) interpreted big-endian, matching + // BigNumber(SHA256::GetDigestOf(N.ToByteArray<32>(false), g.ToByteArray<32>(false)), false) + let k_bytes = sha256_concat(&[&pad_be_32(&n), &pad_be_32(&g)]); + let k = BigUint::from_bytes_be(&k_bytes); + + // a = random 32 bytes + let mut a_bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut a_bytes); + let a = BigUint::from_bytes_be(&a_bytes); + + // A = g^a mod N + let A = g.modpow(&a, &n); + + Self { + email: email.to_string(), + password: password.to_string(), + a, + A, + B: BigUint::zero(), + salt: Vec::new(), + n, + g, + k, + s: None, + } + } + + pub fn get_a_hex(&self) -> String { + hex::encode(&self.A.to_bytes_be()) + } + + pub fn set_challenge(&mut self, salt: &[u8], B: &BigUint) { + self.salt = salt.to_vec(); + self.B = B.clone(); + } + + /// x = SHA256(salt || SHA256(username || ":" || password)), interpreted big-endian. + /// Server hashes the username verbatim and treats the SHA256 output as a + /// big-endian BigNumber (BigNumber(xHash, false) → BN_bin2bn → big-endian). + fn calculate_x(&self) -> BigUint { + let inner = sha256_concat(&[self.email.as_bytes(), b":", self.password.as_bytes()]); + let x_bytes = sha256_concat(&[&self.salt, &inner]); + BigUint::from_bytes_be(&x_bytes) + } + + /// u = SHA256(A_BE32 || B_BE32) interpreted big-endian. + fn calculate_u(&self) -> BigUint { + let u_bytes = sha256_concat(&[&pad_be_32(&self.A), &pad_be_32(&self.B)]); + BigUint::from_bytes_be(&u_bytes) + } + + /// S = (B - k*v)^(a + u*x) mod N + pub fn calculate_s(&mut self) -> BigUint { + let x = self.calculate_x(); + let v = self.g.modpow(&x, &self.n); + let kv = (&self.k * &v) % &self.n; + + let base = if self.B >= kv { + &self.B - &kv + } else { + &self.B + &self.n - &kv + }; + + let u = self.calculate_u(); + // exp = a + u*x — DO NOT reduce u*x mod N here. modpow accepts arbitrarily + // large exponents, and reducing mod N (instead of mod N-1) would change the + // value of g^exp and break S. + let exp = &self.a + &u * &x; + + let S = base.modpow(&exp, &self.n); + self.s = Some(S.clone()); + S + } + + /// M1 = SHA256(broken_evidence_be(A) || broken_evidence_be(B) || broken_evidence_be(S)) + pub fn calculate_m1(&self) -> Vec { + let S = self.s.as_ref().expect("S not calculated yet"); + sha256_concat(&[ + &broken_evidence_be(&self.A), + &broken_evidence_be(&self.B), + &broken_evidence_be(S), + ]) + } +} + +/// Authenticate with bnetserver bot endpoint and return (login_ticket, session_key) +pub async fn authenticate_bot( + base_url: &str, + email: &str, + password: &str, +) -> Result<(String, Vec), Box> { + let mk_client = || -> Result { + Client::builder() + .danger_accept_invalid_certs(true) + .http1_only() + .redirect(reqwest::redirect::Policy::none()) + .build() + }; + let client = mk_client()?; + + let challenge_json = json!({ + "username": email, + "password": password + }); + + println!("[BNET] Requesting bot SRP challenge for {}...", email); + let challenge_paths = ["/login/srp/", "/bnetserver/login/srp/"]; + let mut challenge_errors = Vec::new(); + let mut selected_challenge = None; + for path in challenge_paths { + let challenge_url = format!("{}{}", base_url, path); + println!("[BNET] Challenge URL: {}", challenge_url); + let challenge_resp = client + .post(&challenge_url) + .json(&challenge_json) + .send() + .await?; + + let status = challenge_resp.status(); + let jsessionid = challenge_resp + .headers() + .get("set-cookie") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.split(';').next()) + .and_then(|s| s.split('=').nth(1)) + .map(|s| s.to_string()); + + println!("[BNET] Challenge response status: {:?}", status); + println!( + "[BNET] Challenge set-cookie: {:?}", + challenge_resp.headers().get("set-cookie") + ); + println!("[BNET] Extracted JSESSIONID: {:?}", jsessionid); + + if status.is_success() { + selected_challenge = Some((challenge_resp, jsessionid, path)); + break; + } + + let text = challenge_resp.text().await?; + challenge_errors.push(format!("{} => {}", path, text)); + } + + let (challenge_resp, jsessionid, challenge_path) = match selected_challenge { + Some(challenge) => challenge, + None => { + let err = format!( + "Challenge failed on all paths: {}", + challenge_errors.join(" | ") + ); + return authenticate_direct_login_fallback(base_url, email, password) + .await + .map_err(|fallback_err| { + format!("{err}; direct login fallback failed: {fallback_err}").into() + }); + } + }; + + let challenge_data: Value = challenge_resp.json().await?; + println!("[BNET] Challenge received"); + + let salt_hex = challenge_data + .get("salt") + .and_then(|s| s.as_str()) + .ok_or("No salt in challenge")?; + let b_hex = challenge_data + .get("public_B") + .and_then(|b| b.as_str()) + .ok_or("No public_B in challenge")?; + + let salt = match decode_server_hex(salt_hex) { + Ok(salt) => salt, + Err(err) => { + println!("[BNET] Invalid SRP salt hex ({err}); falling back to direct login"); + return authenticate_direct_login_fallback(base_url, email, password).await; + } + }; + let b_bytes = match decode_server_hex(b_hex) { + Ok(public_b) => public_b, + Err(err) => { + println!("[BNET] Invalid SRP public_B hex ({err}); falling back to direct login"); + return authenticate_direct_login_fallback(base_url, email, password).await; + } + }; + let B = BigUint::from_bytes_be(&b_bytes); + + // Step 2: Initialize SRP6 client + let mut srp = BotSrp6Client::new(email, password); + srp.set_challenge(&salt, &B); + + let _S = srp.calculate_s(); + let M1 = srp.calculate_m1(); + let A_hex = srp.get_a_hex(); + + // Step 3: Send proof + let login_path = if challenge_path == "/bnetserver/login/srp/" { + "/bnetserver/login/" + } else { + "/login/" + }; + let login_url = format!("{}{}", base_url, login_path); + let proof_json = json!({ + "username": email, + "A": &A_hex, + "M1": hex::encode(&M1) + }); + + println!("[BNET] Sending SRP proof to {}...", login_url); + println!("[BNET] Proof body prepared"); + let mut proof_req = client.post(&login_url).json(&proof_json); + + if let Some(ref cookie) = jsessionid { + proof_req = proof_req.header("Cookie", format!("JSESSIONID={}", cookie)); + } + + // Build and print the request for debugging + let built_req = proof_req.build()?; + println!("[BNET] Proof request method: {:?}", built_req.method()); + println!("[BNET] Proof request url: {:?}", built_req.url()); + println!("[BNET] Proof request headers: {:?}", built_req.headers()); + + let proof_resp = client.execute(built_req).await?; + + // DEBUG: print everything regardless of status + println!("Status code: {:?}", proof_resp.status()); + println!("Headers: {:?}", proof_resp.headers()); + let body_text = proof_resp.text().await?; + println!( + "[BNET] Proof response body received ({} bytes)", + body_text.len() + ); + + let proof_data: Value = serde_json::from_str(&body_text).unwrap_or(Value::Null); + println!("[BNET] Proof response parsed"); + + let Some(ticket) = proof_data.get("login_ticket").and_then(|t| t.as_str()) else { + println!( + "[BNET] Bot SRP proof did not return a login ticket; falling back to direct login" + ); + return authenticate_direct_login_fallback(base_url, email, password).await; + }; + let session_key_hex = proof_data + .get("session_key") + .and_then(|t| t.as_str()) + .ok_or("No session_key in response")?; + let session_key = decode_server_hex(session_key_hex)?; + + println!("[BNET] Authentication successful; ticket and session key received"); + + Ok((ticket.to_string(), session_key)) +} + +/// Fallback for the active Rust `bnet-server`, which currently implements the +/// normal `/bnetserver/login/` form flow but not TrinityCore's bot-only +/// `/login/srp/` and `/login/` routes. +/// +/// The world login below is driven by `account.session_key_bnet`: after this +/// function returns, `main.rs` writes the returned key into the auth DB and uses +/// the same key to compute CMSG_AUTH_SESSION. The REST login ticket is retained +/// as a credential sanity check, not as the world-session secret. +async fn authenticate_direct_login_fallback( + base_url: &str, + email: &str, + password: &str, +) -> Result<(String, Vec), Box> { + let client = Client::builder() + .danger_accept_invalid_certs(true) + .http1_only() + .pool_max_idle_per_host(0) + .redirect(reqwest::redirect::Policy::none()) + .build()?; + + let login_url = format!("{}/bnetserver/login/", base_url); + let login_form = json!({ + "platform_id": "Win", + "program_id": "WoW", + "version": "3.4.3.52237", + "inputs": [ + {"input_id": "account_name", "value": email}, + {"input_id": "password", "value": password} + ] + }); + + println!("[BNET] Bot SRP unavailable; trying direct form login at {login_url}"); + let response = client.post(&login_url).json(&login_form).send().await?; + let status = response.status(); + let body_text = response.text().await?; + if !status.is_success() { + return Err(format!("direct login HTTP {status}: {body_text}").into()); + } + + let data: Value = serde_json::from_str(&body_text) + .map_err(|e| format!("direct login JSON parse failed: {e}. Body was: {body_text}"))?; + let ticket = data + .get("login_ticket") + .and_then(|t| t.as_str()) + .ok_or_else(|| format!("direct login returned no login_ticket. Body was: {body_text}"))?; + + let mut session_key = vec![0u8; 32]; + thread_rng().fill_bytes(&mut session_key); + println!("[BNET] Direct login fallback succeeded; generated local K"); + + Ok((ticket.to_string(), session_key)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_bot_srp6_calculations() { + let mut client = BotSrp6Client::new("test@example.com", "password123"); + + let salt = vec![0xABu8; 32]; + let B = BigUint::from(12345u32); + + client.set_challenge(&salt, &B); + let S = client.calculate_s(); + let M1 = client.calculate_m1(); + + assert!(S > BigUint::zero()); + assert_eq!(M1.len(), 32); + } +} diff --git a/tools/wow-test-bot/src/config.rs b/tools/wow-test-bot/src/config.rs new file mode 100644 index 00000000..166a0173 --- /dev/null +++ b/tools/wow-test-bot/src/config.rs @@ -0,0 +1,102 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::Path; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BotConfig { + pub account: String, + pub password: String, + pub character_guid: u64, + pub account_id: u32, + pub lfg_role: u8, + pub class: String, + pub enabled: bool, + pub session_key_bnet: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TestsConfig { + pub lfg_join: bool, + pub lfg_proposal: bool, + pub bg_join: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TestConfig { + pub dungeon_id: u32, + pub wait_for_proposal_timeout_secs: u64, + pub launch_delay_ms: u64, + #[serde(default)] + pub auto_teleport: bool, + #[serde(default)] + pub cleanup_groups: bool, + #[serde(default)] + pub require_group: bool, + pub tests: TestsConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppConfig { + pub bots: Vec, + pub test_config: TestConfig, +} + +impl AppConfig { + pub fn from_file(path: &str) -> Result { + let content = fs::read_to_string(path) + .with_context(|| format!("Failed to read config file: {}", path))?; + let config: AppConfig = serde_json::from_str(&content) + .with_context(|| format!("Failed to parse config file: {}", path))?; + Ok(config) + } + + pub fn save_to_file(&self, path: &str) -> Result<()> { + let content = serde_json::to_string_pretty(self).context("Failed to serialize config")?; + fs::write(path, content) + .with_context(|| format!("Failed to write config file: {}", path))?; + Ok(()) + } + + pub fn get_enabled_bots(&self) -> Vec<&BotConfig> { + self.bots.iter().filter(|b| b.enabled).collect() + } + + pub fn default_config() -> Self { + AppConfig { + bots: vec![BotConfig { + account: "TESTBOT@bot.local".to_string(), + password: String::new(), + character_guid: 13, + account_id: 6, + lfg_role: 8, + class: "warrior".to_string(), + enabled: true, + session_key_bnet: String::new(), + }], + test_config: TestConfig { + dungeon_id: 259, + wait_for_proposal_timeout_secs: 120, + launch_delay_ms: 2000, + auto_teleport: false, + cleanup_groups: false, + require_group: false, + tests: TestsConfig { + lfg_join: true, + lfg_proposal: true, + bg_join: true, + }, + }, + } + } + + pub fn load_or_create(path: &str) -> Result { + if Path::new(path).exists() { + Self::from_file(path) + } else { + let config = Self::default_config(); + config.save_to_file(path)?; + Ok(config) + } + } +} diff --git a/tools/wow-test-bot/src/main.rs b/tools/wow-test-bot/src/main.rs new file mode 100644 index 00000000..fefbc505 --- /dev/null +++ b/tools/wow-test-bot/src/main.rs @@ -0,0 +1,3367 @@ +//! WoW Test Bot - TrinityCore 3.4.3 Modern Protocol with Full SRP6 +//! Combines BNet SRP6 Auth + World Server AES-GCM Encryption + LFG + +use anyhow::{anyhow, bail, Result}; +use flate2::{Decompress, FlushDecompress}; +use rand::RngCore; +use serde::Serialize; +use std::net::IpAddr; +use std::time::Duration; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; +use tracing::{debug, error, info, warn}; + +mod bot_srp6; +mod config; +mod packet_parser; +mod protocol; +mod srp6_auth; +mod wow_crypto; + +use packet_parser::*; +use protocol::*; +use wow_crypto::WorldCrypt; + +const SMSG_COMPRESSED_PACKET: u16 = 0x3052; + +#[derive(Debug)] +struct ServerPacketInflater { + decompressor: Decompress, +} + +impl Default for ServerPacketInflater { + fn default() -> Self { + Self { + decompressor: Decompress::new(false), + } + } +} + +// Server endpoints (overridable via env) +fn bnet_host() -> String { + std::env::var("BNET_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()) +} +fn bnet_port() -> u16 { + std::env::var("BNET_PORT") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(8081) +} +fn world_host() -> String { + std::env::var("WORLD_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()) +} +fn world_port() -> u16 { + std::env::var("WORLD_PORT") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(8085) +} +fn realm_id() -> u32 { + std::env::var("REALM_ID") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(1) +} +fn client_build() -> u32 { + std::env::var("WOW_BOT_CLIENT_BUILD") + .or_else(|_| std::env::var("WOW_BOT_BUILD")) + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(54261) +} +fn auth_db_url() -> Result { + database_url("WOW_BOT_AUTH_DB_URL", "LoginDatabaseInfo") +} +fn characters_db_url() -> Result { + database_url("WOW_BOT_CHAR_DB_URL", "CharacterDatabaseInfo") +} +fn world_db_url() -> Result { + database_url("WOW_BOT_WORLD_DB_URL", "WorldDatabaseInfo") +} + +const DEFAULT_DUNGEON_ID: u32 = 259; +const CONTINUED_SESSION_SEED: [u8; 16] = [ + 0x16, 0xAD, 0x0C, 0xD4, 0x46, 0xF9, 0x4F, 0xB2, 0xEF, 0x7D, 0xEA, 0x2A, 0x17, 0x66, 0x4D, 0x2F, +]; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] +struct QuestObjectiveDbRow { + objective: u8, + data: i32, +} + +#[derive(Debug, Clone)] +struct CliOptions { + config_path: String, + dungeon_id: Option, + timeout_secs: Option, + single_account: Option, + sequential: bool, + auto_teleport: Option, + cleanup_groups: Option, + require_group: bool, + ensure_test_accounts: bool, + login_only: bool, + quest_smoke: bool, + quest_creature_entry: Option, + quest_creature_guid: Option, + quest_guid_counter: Option, + quest_map_id: Option, + quest_expected_id: Option, + quest_forbidden_id: Option, + quest_forbidden_title: Option, + quest_query_details: bool, + quest_accept: bool, + quest_reset: bool, + quest_relocate: bool, + quest_set_level: Option, + quest_set_race: Option, + quest_set_class: Option, + quest_objective_persist: bool, + quest_objectives: Vec, + quest_objective_status: u8, + gossip_select_option_id: Option, + expect_trainer_list: bool, + expect_trainer_id: Option, + quest_timeout_secs: u64, + report_path: Option, +} + +#[derive(Debug, Clone, Serialize)] +struct BotRunResult { + account: String, + account_id: u32, + character_guid: u64, + dungeon_id: u32, + role: u8, + join_result: Option, + join_detail: Option, + got_proposal: bool, + accepted_proposal: bool, + got_ready_check: bool, + group_formed: bool, + teleport_denied_reason: Option, + entered_world: bool, + world_auth: bool, + enum_characters: bool, + player_login_verified: bool, + login_only: bool, + quest_smoke: bool, + quest_smoke_passed: Option, + quest_target_entry: Option, + quest_target_spawn_guid: Option, + quest_target_guid_counter: Option, + quest_target_map_id: Option, + quest_gossip_hello_sent: bool, + quest_questgiver_hello_sent: bool, + quest_gossip_id_seen: Option, + quest_gossip_select_sent: bool, + quest_gossip_message_seen: bool, + quest_quest_list_seen: bool, + quest_details_seen: bool, + quest_request_items_seen: bool, + trainer_list_seen: bool, + trainer_id_seen: Option, + trainer_spell_count_seen: Option, + quest_accept_sent: bool, + quest_accept_confirm_seen: bool, + quest_db_verified: bool, + quest_db_status: Option, + quest_objective_persist: bool, + quest_objective_seeded: Vec, + quest_objective_db_before: Vec, + quest_objective_db_after: Vec, + quest_objective_db_verified: bool, + quest_objective_update_seen: bool, + quest_objective_update_has_expected: bool, + quest_ids_seen: Vec, + quest_titles_seen: Vec, + quest_failure: Option, + seen_opcodes: Vec, +} + +impl BotRunResult { + fn success(&self, require_proposal: bool, require_group: bool, login_only: bool) -> bool { + if self.quest_smoke { + return self.world_auth + && self.enum_characters + && self.player_login_verified + && self.quest_smoke_passed.unwrap_or(false); + } + if login_only { + return self.world_auth && self.enum_characters && self.player_login_verified; + } + self.join_result == Some(0) + && (!require_proposal || self.got_proposal) + && (!require_group || self.group_formed) + } +} + +#[derive(Debug, Serialize)] +struct RunReport { + dungeon_id: u32, + timeout_secs: u64, + require_proposal: bool, + require_group: bool, + auto_teleport: bool, + login_only: bool, + quest_smoke: bool, + results: Vec, +} + +#[derive(Debug, Clone)] +struct ConnectToTarget { + address: IpAddr, + port: u16, + serial: u32, + connection_type: u8, + key: i64, +} + +#[derive(Debug, Clone)] +struct WorldAuthDbContext { + username: String, + realm_build: u32, + win64_auth_seed: [u8; 16], +} + +#[derive(Debug, Clone)] +struct QuestSmokeOptions { + creature_entry: u32, + creature_spawn_guid: Option, + creature_guid_counter: Option, + map_id: Option, + expected_quest_id: Option, + forbidden_quest_id: Option, + forbidden_title_contains: Option, + query_details: bool, + accept: bool, + reset_before_run: bool, + relocate_before_login: bool, + set_level_before_login: Option, + set_race_before_login: Option, + set_class_before_login: Option, + objective_persist: bool, + objective_seed: Vec, + objective_status: u8, + gossip_select_option_id: Option, + expect_trainer_list: bool, + expect_trainer_id: Option, + timeout_secs: u64, +} + +#[derive(Debug, Clone)] +struct ResolvedCreatureTarget { + entry: u32, + spawn_guid: u64, + guid_counter: u64, + map_id: u16, + x: f64, + y: f64, + z: f64, + orientation: f32, + packed_guid: Vec, +} + +fn test_dungeon_id(app_config: &config::AppConfig) -> u32 { + // Allow override via env var (handy for ad-hoc testing); otherwise pick up + // the value from config.json::test_config.dungeon_id. + std::env::var("WOW_BOT_DUNGEON_ID") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or_else(|| { + if app_config.test_config.dungeon_id != 0 { + app_config.test_config.dungeon_id + } else { + DEFAULT_DUNGEON_ID + } + }) +} + +fn parse_cli() -> Result { + let mut opts = CliOptions { + config_path: std::env::var("WOW_BOT_CONFIG").unwrap_or_else(|_| "config.json".to_string()), + dungeon_id: std::env::var("WOW_BOT_DUNGEON_ID") + .ok() + .and_then(|s| s.parse().ok()), + timeout_secs: std::env::var("WOW_BOT_LFG_TIMEOUT_SECS") + .ok() + .and_then(|s| s.parse().ok()), + single_account: None, + sequential: false, + auto_teleport: std::env::var("WOW_BOT_AUTO_TELEPORT") + .ok() + .map(|v| is_truthy(&v)), + cleanup_groups: std::env::var("WOW_BOT_CLEANUP_GROUPS") + .ok() + .map(|v| is_truthy(&v)), + require_group: std::env::var("WOW_BOT_REQUIRE_GROUP") + .ok() + .map(|v| is_truthy(&v)) + .unwrap_or(false), + ensure_test_accounts: std::env::var("WOW_BOT_ENSURE_TEST_ACCOUNTS") + .ok() + .map(|v| is_truthy(&v)) + .unwrap_or(false), + login_only: std::env::var("WOW_BOT_LOGIN_ONLY") + .ok() + .map(|v| is_truthy(&v)) + .unwrap_or(false), + quest_smoke: std::env::var("WOW_BOT_QUEST_SMOKE") + .ok() + .map(|v| is_truthy(&v)) + .unwrap_or(false), + quest_creature_entry: std::env::var("WOW_BOT_QUEST_CREATURE_ENTRY") + .ok() + .and_then(|s| s.parse().ok()), + quest_creature_guid: std::env::var("WOW_BOT_QUEST_CREATURE_GUID") + .ok() + .and_then(|s| s.parse().ok()), + quest_guid_counter: std::env::var("WOW_BOT_QUEST_RUNTIME_COUNTER") + .or_else(|_| std::env::var("WOW_BOT_QUEST_GUID_COUNTER")) + .ok() + .and_then(|s| s.parse().ok()), + quest_map_id: std::env::var("WOW_BOT_QUEST_MAP_ID") + .ok() + .and_then(|s| s.parse().ok()), + quest_expected_id: std::env::var("WOW_BOT_QUEST_EXPECT_ID") + .ok() + .and_then(|s| s.parse().ok()), + quest_forbidden_id: std::env::var("WOW_BOT_QUEST_FORBID_ID") + .ok() + .and_then(|s| s.parse().ok()), + quest_forbidden_title: std::env::var("WOW_BOT_QUEST_FORBID_TITLE_CONTAINS").ok(), + quest_query_details: std::env::var("WOW_BOT_QUEST_QUERY_DETAILS") + .ok() + .map(|v| is_truthy(&v)) + .unwrap_or(true), + quest_accept: std::env::var("WOW_BOT_QUEST_ACCEPT") + .ok() + .map(|v| is_truthy(&v)) + .unwrap_or(false), + quest_reset: std::env::var("WOW_BOT_QUEST_RESET") + .ok() + .map(|v| is_truthy(&v)) + .unwrap_or(false), + quest_relocate: std::env::var("WOW_BOT_QUEST_RELOCATE") + .ok() + .map(|v| is_truthy(&v)) + .unwrap_or(false), + quest_set_level: std::env::var("WOW_BOT_QUEST_SET_LEVEL") + .ok() + .and_then(|s| s.parse().ok()), + quest_set_race: std::env::var("WOW_BOT_QUEST_SET_RACE") + .ok() + .and_then(|s| s.parse().ok()), + quest_set_class: std::env::var("WOW_BOT_QUEST_SET_CLASS") + .ok() + .and_then(|s| s.parse().ok()), + quest_objective_persist: std::env::var("WOW_BOT_QUEST_OBJECTIVE_PERSIST") + .ok() + .map(|v| is_truthy(&v)) + .unwrap_or(false), + quest_objectives: match std::env::var("WOW_BOT_QUEST_OBJECTIVES") { + Ok(value) if !value.trim().is_empty() => parse_quest_objective_rows(&value)?, + _ => Vec::new(), + }, + quest_objective_status: std::env::var("WOW_BOT_QUEST_OBJECTIVE_STATUS") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(3), + gossip_select_option_id: std::env::var("WOW_BOT_GOSSIP_SELECT_OPTION_ID") + .ok() + .and_then(|s| s.parse().ok()), + expect_trainer_list: std::env::var("WOW_BOT_EXPECT_TRAINER_LIST") + .ok() + .map(|v| is_truthy(&v)) + .unwrap_or(false), + expect_trainer_id: std::env::var("WOW_BOT_EXPECT_TRAINER_ID") + .ok() + .and_then(|s| s.parse().ok()), + quest_timeout_secs: std::env::var("WOW_BOT_QUEST_TIMEOUT_SECS") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(5), + report_path: std::env::var("WOW_BOT_REPORT").ok(), + }; + + let raw_args: Vec = std::env::args().skip(1).collect(); + if raw_args.len() == 6 && !raw_args[0].starts_with("--") { + opts.single_account = Some(raw_args[0].clone()); + opts.dungeon_id = Some(raw_args[5].parse()?); + return Ok(opts); + } + + let mut args = raw_args.into_iter(); + while let Some(arg) = args.next() { + match arg.as_str() { + "--config" => opts.config_path = next_arg(&mut args, "--config")?, + "--dungeon" => opts.dungeon_id = Some(next_arg(&mut args, "--dungeon")?.parse()?), + "--timeout" => opts.timeout_secs = Some(next_arg(&mut args, "--timeout")?.parse()?), + "--single" => opts.single_account = Some(next_arg(&mut args, "--single")?), + "--sequential" => opts.sequential = true, + "--parallel" => opts.sequential = false, + "--auto-teleport" => opts.auto_teleport = Some(true), + "--no-auto-teleport" => opts.auto_teleport = Some(false), + "--cleanup-groups" => opts.cleanup_groups = Some(true), + "--no-cleanup-groups" => opts.cleanup_groups = Some(false), + "--require-group" => opts.require_group = true, + "--ensure-test-accounts" => opts.ensure_test_accounts = true, + "--login-only" => opts.login_only = true, + "--quest-smoke" => opts.quest_smoke = true, + "--quest-creature-entry" => { + opts.quest_creature_entry = + Some(next_arg(&mut args, "--quest-creature-entry")?.parse()?); + } + "--quest-creature-guid" => { + opts.quest_creature_guid = + Some(next_arg(&mut args, "--quest-creature-guid")?.parse()?); + } + "--quest-guid-counter" => { + opts.quest_guid_counter = + Some(next_arg(&mut args, "--quest-guid-counter")?.parse()?); + } + "--quest-runtime-counter" => { + opts.quest_guid_counter = + Some(next_arg(&mut args, "--quest-runtime-counter")?.parse()?); + } + "--quest-map" => opts.quest_map_id = Some(next_arg(&mut args, "--quest-map")?.parse()?), + "--expect-quest" => { + opts.quest_expected_id = Some(next_arg(&mut args, "--expect-quest")?.parse()?); + } + "--forbid-quest" => { + opts.quest_forbidden_id = Some(next_arg(&mut args, "--forbid-quest")?.parse()?); + } + "--forbid-title" => { + opts.quest_forbidden_title = Some(next_arg(&mut args, "--forbid-title")?) + } + "--quest-query-details" => opts.quest_query_details = true, + "--no-quest-query-details" => opts.quest_query_details = false, + "--quest-accept" => opts.quest_accept = true, + "--quest-no-accept" => opts.quest_accept = false, + "--quest-reset" => opts.quest_reset = true, + "--quest-relocate" => opts.quest_relocate = true, + "--quest-set-level" => { + opts.quest_set_level = Some(next_arg(&mut args, "--quest-set-level")?.parse()?); + } + "--quest-set-race" => { + opts.quest_set_race = Some(next_arg(&mut args, "--quest-set-race")?.parse()?); + } + "--quest-set-class" => { + opts.quest_set_class = Some(next_arg(&mut args, "--quest-set-class")?.parse()?); + } + "--quest-objective-persist" => opts.quest_objective_persist = true, + "--quest-objectives" => { + opts.quest_objectives = + parse_quest_objective_rows(&next_arg(&mut args, "--quest-objectives")?)?; + } + "--quest-objective-status" => { + opts.quest_objective_status = + next_arg(&mut args, "--quest-objective-status")?.parse()?; + } + "--gossip-select-option-id" => { + opts.gossip_select_option_id = + Some(next_arg(&mut args, "--gossip-select-option-id")?.parse()?); + } + "--expect-trainer-list" => opts.expect_trainer_list = true, + "--expect-trainer-id" => { + opts.expect_trainer_id = Some(next_arg(&mut args, "--expect-trainer-id")?.parse()?); + } + "--quest-timeout" => { + opts.quest_timeout_secs = next_arg(&mut args, "--quest-timeout")?.parse()?; + } + "--report" => opts.report_path = Some(next_arg(&mut args, "--report")?), + "--help" | "-h" => { + print_help(); + std::process::exit(0); + } + _ => bail!("Unknown argument `{}`. Use --help.", arg), + } + } + Ok(opts) +} + +fn next_arg(args: &mut impl Iterator, flag: &str) -> Result { + args.next().ok_or_else(|| anyhow!("{} needs a value", flag)) +} + +fn is_truthy(value: &str) -> bool { + matches!( + value.to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "y" | "on" + ) +} + +fn parse_quest_objective_rows(value: &str) -> Result> { + let mut rows = Vec::new(); + for raw in value.split(',') { + let raw = raw.trim(); + if raw.is_empty() { + continue; + } + let (objective, data) = raw + .split_once(':') + .or_else(|| raw.split_once('=')) + .ok_or_else(|| anyhow!("Quest objective row `{raw}` must use storage:data"))?; + rows.push(QuestObjectiveDbRow { + objective: objective + .trim() + .parse() + .map_err(|e| anyhow!("Invalid objective storage index `{objective}`: {e}"))?, + data: data + .trim() + .parse() + .map_err(|e| anyhow!("Invalid objective data `{data}`: {e}"))?, + }); + } + if rows.is_empty() { + bail!("Quest objective rows cannot be empty"); + } + rows.sort(); + rows.dedup_by_key(|row| row.objective); + Ok(rows) +} + +fn print_help() { + println!("wow-test-bot options:"); + println!(" --config Config JSON path (default: config.json)"); + println!(" --dungeon LFG dungeon id override"); + println!(" --timeout LFG read window override"); + println!(" --single Run one configured account only"); + println!(" --parallel Run enabled bots concurrently (default)"); + println!(" --sequential Run enabled bots one after another"); + println!(" --auto-teleport Send CMSG_DF_TELEPORT after group formation"); + println!( + " --cleanup-groups Delete stale group rows for configured bot GUIDs before run" + ); + println!(" --require-group Treat missing party info/group formation as failure"); + println!(" --ensure-test-accounts Upsert local TESTBOT auth rows before login"); + println!(" --login-only Stop after SMSG_LOGIN_VERIFY_WORLD; do not run LFG"); + println!(" --quest-smoke After login, right-click/query one questgiver NPC"); + println!(" --quest-creature-entry Creature entry to resolve from world.creature"); + println!(" --quest-creature-guid Optional world.creature spawn guid override"); + println!(" --quest-runtime-counter Live ObjectGuid low counter for the target"); + println!(" --quest-guid-counter Legacy alias for --quest-runtime-counter"); + println!(" --quest-map Optional map id override for GUID construction"); + println!(" --expect-quest Require this quest id in gossip/list/details"); + println!(" --forbid-quest Fail if this quest id is offered"); + println!(" --forbid-title Fail if an offered quest title contains this text"); + println!(" --no-quest-query-details Skip the QuestGiverQueryQuest details probe"); + println!(" --quest-accept Accept the selected quest and verify DB persistence"); + println!( + " --quest-reset Delete the selected quest from bot quest tables before run" + ); + println!( + " --quest-relocate Move the bot character near the target spawn before login" + ); + println!( + " --quest-set-level Set bot character level before login for deterministic QA" + ); + println!(" --quest-set-race Set bot character race before login"); + println!(" --quest-set-class Set bot character class before login"); + println!( + " --quest-objective-persist Seed expected quest objectives, logout, and verify DB rows" + ); + println!(" --quest-objectives Objective rows as storage:data,storage:data"); + println!(" --quest-objective-status Status for seeded quest row (default: 3)"); + println!(" --gossip-select-option-id Select this gossip option after GossipMessage"); + println!(" --expect-trainer-list Require SMSG_TRAINER_LIST after gossip select"); + println!(" --expect-trainer-id Require this TrainerID in SMSG_TRAINER_LIST"); + println!(" --quest-timeout Quest smoke read window after sending hello"); + println!(" --report Write JSON report"); +} + +fn apply_password_overrides(bots: &mut [config::BotConfig]) { + let shared_password = std::env::var("WOW_BOT_PASSWORD").ok(); + for bot in bots { + if let Ok(password) = std::env::var(password_env_name(&bot.account)) { + bot.password = password; + } else if bot.password.is_empty() { + if let Some(password) = &shared_password { + bot.password = password.clone(); + } + } + } +} + +fn password_env_name(account: &str) -> String { + let suffix: String = account + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() { + c.to_ascii_uppercase() + } else { + '_' + } + }) + .collect(); + format!("WOW_BOT_PASSWORD_{suffix}") +} + +fn ensure_test_accounts(bots: &[config::BotConfig]) -> Result<()> { + let auth_db = auth_db_url()?; + let char_db = characters_db_url()?; + let auth_opts = mysql::Opts::from_url(&auth_db).map_err(|e| anyhow!("Bad auth DB URL: {e}"))?; + let char_opts = + mysql::Opts::from_url(&char_db).map_err(|e| anyhow!("Bad character DB URL: {e}"))?; + let mut auth_conn = + mysql::Conn::new(auth_opts).map_err(|e| anyhow!("Connect to auth DB failed: {e}"))?; + let mut char_conn = + mysql::Conn::new(char_opts).map_err(|e| anyhow!("Connect to characters DB failed: {e}"))?; + + for bot in bots { + ensure_local_bot_account(&mut auth_conn, bot)?; + ensure_local_bot_character_owner(&mut char_conn, bot)?; + sync_realm_character_count(&mut auth_conn, &mut char_conn, bot)?; + } + + Ok(()) +} + +fn ensure_local_bot_account(conn: &mut mysql::Conn, bot: &config::BotConfig) -> Result<()> { + use mysql::prelude::Queryable; + + let (email, bnet_salt, bnet_verifier) = + bot_srp6::bnet_v1_registration_material_like_cpp(&bot.account, &bot.password); + let allow_nonlocal = + std::env::var("WOW_BOT_ALLOW_NONLOCAL_ACCOUNT_BOOTSTRAP").is_ok_and(|v| is_truthy(&v)); + if !email.ends_with("@BOT.LOCAL") && !allow_nonlocal { + bail!( + "Refusing to bootstrap non-local bot account {email}; set WOW_BOT_ALLOW_NONLOCAL_ACCOUNT_BOOTSTRAP=1 if this is intentional" + ); + } + + let bnet_id = if let Some(id) = conn + .exec_first::( + "SELECT id FROM battlenet_accounts WHERE email = ?", + (&email,), + ) + .map_err(|e| anyhow!("Lookup BNet account {email}: {e}"))? + { + conn.exec_drop( + "UPDATE battlenet_accounts \ + SET srp_version = 1, salt = ?, verifier = ?, failed_logins = 0, locked = 0, lock_country = '00' \ + WHERE id = ?", + (bnet_salt.to_vec(), bnet_verifier, id), + ) + .map_err(|e| anyhow!("Update BNet account {email}: {e}"))?; + id + } else { + conn.exec_drop( + "INSERT INTO battlenet_accounts (email, srp_version, salt, verifier) VALUES (?, 1, ?, ?)", + (&email, bnet_salt.to_vec(), bnet_verifier), + ) + .map_err(|e| anyhow!("Insert BNet account {email}: {e}"))?; + u32::try_from(conn.last_insert_id()).map_err(|_| anyhow!("BNet account id overflow"))? + }; + + let game_username = game_account_username(&bot.account)?; + // The 3.4.3 world login path below authenticates through + // account.session_key_bnet. These legacy Grunt fields are still NOT NULL in + // the auth schema, so keep them initialized for local bot rows. + let account_salt = random_32(); + let account_verifier = fixed_le_32(Vec::new()); + let account_exists = conn + .exec_first::("SELECT id FROM account WHERE id = ?", (bot.account_id,)) + .map_err(|e| anyhow!("Lookup game account id {}: {e}", bot.account_id))? + .is_some(); + + if account_exists { + conn.exec_drop( + "UPDATE account \ + SET username = ?, salt = ?, verifier = ?, reg_mail = ?, email = ?, battlenet_account = ?, \ + battlenet_index = 1, expansion = 9, failed_logins = 0, locked = 0, lock_country = '00', online = 0 \ + WHERE id = ?", + ( + &game_username, + account_salt.to_vec(), + account_verifier, + &email, + &email, + bnet_id, + bot.account_id, + ), + ) + .map_err(|e| anyhow!("Update game account {}: {e}", bot.account_id))?; + } else { + conn.exec_drop( + "INSERT INTO account \ + (id, username, salt, verifier, reg_mail, email, joindate, battlenet_account, battlenet_index, expansion) \ + VALUES (?, ?, ?, ?, ?, ?, NOW(), ?, 1, 9)", + ( + bot.account_id, + &game_username, + account_salt.to_vec(), + account_verifier, + &email, + &email, + bnet_id, + ), + ) + .map_err(|e| anyhow!("Insert game account {}: {e}", bot.account_id))?; + } + + conn.exec_drop( + "DELETE FROM battlenet_account_bans WHERE id = ?", + (bnet_id,), + ) + .map_err(|e| anyhow!("Clear BNet bans for {email}: {e}"))?; + conn.exec_drop( + "UPDATE account_banned SET active = 0 WHERE id = ?", + (bot.account_id,), + ) + .map_err(|e| anyhow!("Clear game account bans for {}: {e}", bot.account_id))?; + + info!( + "[Bot {}] ensured local auth account {} / character GUID {}", + bot.account_id, email, bot.character_guid + ); + Ok(()) +} + +fn game_account_username(account: &str) -> Result { + let local_part = account + .split('@') + .next() + .filter(|part| !part.is_empty()) + .ok_or_else(|| anyhow!("Bot account {account} has no local part"))?; + let username = bot_srp6::utf8_to_upper_only_latin_like_cpp(local_part); + if username.len() > 32 { + bail!("Bot username {username} exceeds Trinity account.username limit"); + } + Ok(username) +} + +fn fixed_le_32(mut bytes: Vec) -> Vec { + bytes.resize(32, 0); + bytes.truncate(32); + bytes +} + +fn random_32() -> [u8; 32] { + let mut bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut bytes); + bytes +} + +fn ensure_local_bot_character_owner(conn: &mut mysql::Conn, bot: &config::BotConfig) -> Result<()> { + use mysql::prelude::Queryable; + + let owner = conn + .exec_first::( + "SELECT account FROM characters WHERE guid = ?", + (bot.character_guid,), + ) + .map_err(|e| anyhow!("Lookup character {}: {e}", bot.character_guid))? + .ok_or_else(|| anyhow!("No characters row for guid {}", bot.character_guid))?; + + if owner != bot.account_id { + warn!( + "[Bot {}] character GUID {} belonged to account {}; reassigning to test account", + bot.account_id, bot.character_guid, owner + ); + conn.exec_drop( + "UPDATE characters SET account = ? WHERE guid = ?", + (bot.account_id, bot.character_guid), + ) + .map_err(|e| anyhow!("Update owner for character {}: {e}", bot.character_guid))?; + } + + Ok(()) +} + +fn sync_realm_character_count( + auth_conn: &mut mysql::Conn, + char_conn: &mut mysql::Conn, + bot: &config::BotConfig, +) -> Result<()> { + use mysql::prelude::Queryable; + + let count = char_conn + .exec_first::( + "SELECT COUNT(*) FROM characters WHERE account = ?", + (bot.account_id,), + ) + .map_err(|e| anyhow!("Count characters for account {}: {e}", bot.account_id))? + .unwrap_or(0); + auth_conn + .exec_drop( + "REPLACE INTO realmcharacters (numchars, acctid, realmid) VALUES (?, ?, ?)", + (count, bot.account_id, realm_id()), + ) + .map_err(|e| anyhow!("Sync realmcharacters for account {}: {e}", bot.account_id))?; + Ok(()) +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt::init(); + + info!("🎮 WoW Test Bot - TrinityCore 3.4.3 SRP6 + AES-GCM"); + info!("═══════════════════════════════════════════════════"); + + let cli = parse_cli()?; + let app_config = config::AppConfig::load_or_create(&cli.config_path)?; + let mut bots: Vec = + app_config.get_enabled_bots().into_iter().cloned().collect(); + + if let Some(account) = &cli.single_account { + bots.retain(|bot| bot.account.eq_ignore_ascii_case(account)); + } + apply_password_overrides(&mut bots); + + if bots.is_empty() { + bail!("No enabled bots matched the current config/filter"); + } + let missing_passwords: Vec<&str> = bots + .iter() + .filter(|bot| bot.password.is_empty()) + .map(|bot| bot.account.as_str()) + .collect(); + if !missing_passwords.is_empty() { + bail!( + "Missing bot password for {}. Set WOW_BOT_PASSWORD, set {}, or use an ignored local config.json.", + missing_passwords.join(", "), + password_env_name(missing_passwords[0]) + ); + } + if cli.ensure_test_accounts { + let bots_for_db = bots.clone(); + tokio::task::spawn_blocking(move || ensure_test_accounts(&bots_for_db)) + .await + .map_err(|e| anyhow!("DB worker join failed while ensuring test accounts: {}", e))? + .map_err(|e| anyhow!("Failed to ensure test accounts: {}", e))?; + } + let quest_options = if cli.quest_smoke { + Some(quest_smoke_options_from_cli(&cli)?) + } else { + None + }; + + let dungeon_id = cli + .dungeon_id + .unwrap_or_else(|| test_dungeon_id(&app_config)); + let timeout_secs = cli + .timeout_secs + .unwrap_or(app_config.test_config.wait_for_proposal_timeout_secs); + let auto_teleport = cli + .auto_teleport + .unwrap_or(app_config.test_config.auto_teleport); + let cleanup_groups = cli + .cleanup_groups + .unwrap_or(app_config.test_config.cleanup_groups); + let require_proposal = app_config.test_config.tests.lfg_proposal; + let require_group = cli.require_group || app_config.test_config.require_group; + + info!("Target dungeon: {}", dungeon_id); + info!("Enabled bots: {}", bots.len()); + info!( + "Mode: {}; client_build={}; LFG timeout: {}s; auto_teleport={}; require_proposal={}; require_group={}", + if cli.quest_smoke { + "quest-smoke" + } else if cli.login_only { + "login-only" + } else { + "lfg" + }, + client_build(), + timeout_secs, + auto_teleport, + require_proposal, + require_group + ); + + if cleanup_groups && !cli.login_only { + cleanup_bot_group_state(&bots)?; + } + + let expected_bot_count = bots.len(); + let mut results = Vec::new(); + if cli.sequential || bots.len() == 1 { + for bot in bots { + info!("\n[Bot {}] Starting...", bot.account); + match run_bot( + bot, + dungeon_id, + timeout_secs, + auto_teleport, + cli.login_only, + quest_options.clone(), + ) + .await + { + Ok(result) => { + log_bot_summary(&result, require_proposal, require_group, cli.login_only); + results.push(result); + } + Err(e) => error!("❌ Bot run ERROR: {}", e), + } + } + } else { + let mut handles = Vec::new(); + for (idx, bot) in bots.into_iter().enumerate() { + let delay_ms = app_config + .test_config + .launch_delay_ms + .saturating_mul(idx as u64); + let quest_options_for_bot = quest_options.clone(); + handles.push(tokio::spawn(async move { + if delay_ms > 0 { + tokio::time::sleep(Duration::from_millis(delay_ms)).await; + } + let account = bot.account.clone(); + let run = run_bot( + bot, + dungeon_id, + timeout_secs, + auto_teleport, + cli.login_only, + quest_options_for_bot, + ) + .await; + (account, run) + })); + } + + for handle in handles { + match handle.await { + Ok((_account, Ok(result))) => { + log_bot_summary(&result, require_proposal, require_group, cli.login_only); + results.push(result); + } + Ok((account, Err(e))) => error!("❌ Bot {} ERROR: {}", account, e), + Err(e) => error!("❌ Bot task join error: {}", e), + } + } + } + + results.sort_by_key(|r| r.account_id); + write_report_if_requested( + &cli, + dungeon_id, + timeout_secs, + require_proposal, + require_group, + auto_teleport, + cli.login_only, + cli.quest_smoke, + &results, + )?; + + let assertion_failures = results + .iter() + .filter(|result| !result.success(require_proposal, require_group, cli.login_only)) + .count(); + let task_failures = expected_bot_count.saturating_sub(results.len()); + let failures = assertion_failures + task_failures; + if failures > 0 { + bail!( + "{} bot(s) failed ({} task errors, {} assertion failures)", + failures, + task_failures, + assertion_failures + ); + } + + info!("\n🎯 All tests completed"); + Ok(()) +} + +fn quest_smoke_options_from_cli(cli: &CliOptions) -> Result { + let creature_entry = cli.quest_creature_entry.ok_or_else(|| { + anyhow!("--quest-smoke requires --quest-creature-entry or WOW_BOT_QUEST_CREATURE_ENTRY") + })?; + if (cli.quest_accept || cli.quest_reset) && cli.quest_expected_id.is_none() { + bail!("--quest-accept/--quest-reset require --expect-quest or WOW_BOT_QUEST_EXPECT_ID"); + } + if cli.quest_objective_persist { + if cli.quest_expected_id.is_none() { + bail!("--quest-objective-persist requires --expect-quest or WOW_BOT_QUEST_EXPECT_ID"); + } + if cli.quest_objectives.is_empty() { + bail!("--quest-objective-persist requires --quest-objectives storage:data"); + } + } + if let Some(level) = cli.quest_set_level { + if !(1..=80).contains(&level) { + bail!("--quest-set-level must be in the 1..=80 player level range"); + } + } + if matches!(cli.quest_set_race, Some(0)) { + bail!("--quest-set-race must be nonzero"); + } + if matches!(cli.quest_set_class, Some(0)) { + bail!("--quest-set-class must be nonzero"); + } + + Ok(QuestSmokeOptions { + creature_entry, + creature_spawn_guid: cli.quest_creature_guid, + creature_guid_counter: cli.quest_guid_counter, + map_id: cli.quest_map_id, + expected_quest_id: cli.quest_expected_id, + forbidden_quest_id: cli.quest_forbidden_id, + forbidden_title_contains: cli.quest_forbidden_title.clone(), + query_details: cli.quest_query_details || cli.quest_accept, + accept: cli.quest_accept, + reset_before_run: cli.quest_reset, + relocate_before_login: cli.quest_relocate, + set_level_before_login: cli.quest_set_level, + set_race_before_login: cli.quest_set_race, + set_class_before_login: cli.quest_set_class, + objective_persist: cli.quest_objective_persist, + objective_seed: cli.quest_objectives.clone(), + objective_status: cli.quest_objective_status, + gossip_select_option_id: cli.gossip_select_option_id, + expect_trainer_list: cli.expect_trainer_list, + expect_trainer_id: cli.expect_trainer_id, + timeout_secs: cli.quest_timeout_secs, + }) +} + +fn quest_smoke_needs_prelogin_db_setup(quest_options: &QuestSmokeOptions) -> bool { + quest_options.reset_before_run + || quest_options.relocate_before_login + || quest_options.set_level_before_login.is_some() + || quest_options.set_race_before_login.is_some() + || quest_options.set_class_before_login.is_some() + || quest_options.objective_persist +} + +/// Run a single bot through the full SRP6 → World → LFG flow +async fn run_bot( + bot: config::BotConfig, + dungeon_id: u32, + lfg_secs: u64, + auto_teleport: bool, + login_only: bool, + quest_options: Option, +) -> Result { + let bot_index = bot.account_id as usize; + let mut result = BotRunResult { + account: bot.account.clone(), + account_id: bot.account_id, + character_guid: bot.character_guid, + dungeon_id, + role: bot.lfg_role, + join_result: None, + join_detail: None, + got_proposal: false, + accepted_proposal: false, + got_ready_check: false, + group_formed: false, + teleport_denied_reason: None, + entered_world: false, + world_auth: false, + enum_characters: false, + player_login_verified: false, + login_only, + quest_smoke: quest_options.is_some(), + quest_smoke_passed: None, + quest_target_entry: None, + quest_target_spawn_guid: None, + quest_target_guid_counter: None, + quest_target_map_id: None, + quest_gossip_hello_sent: false, + quest_questgiver_hello_sent: false, + quest_gossip_id_seen: None, + 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: None, + trainer_spell_count_seen: None, + quest_accept_sent: false, + quest_accept_confirm_seen: false, + quest_db_verified: false, + quest_db_status: None, + quest_objective_persist: quest_options + .as_ref() + .is_some_and(|options| options.objective_persist), + quest_objective_seeded: quest_options + .as_ref() + .map(|options| options.objective_seed.clone()) + .unwrap_or_default(), + quest_objective_db_before: Vec::new(), + quest_objective_db_after: Vec::new(), + quest_objective_db_verified: false, + quest_objective_update_seen: false, + quest_objective_update_has_expected: false, + quest_ids_seen: Vec::new(), + quest_titles_seen: Vec::new(), + quest_failure: None, + seen_opcodes: Vec::new(), + }; + + // ── Step 1: Live SRP6 against bnetserver bot endpoint ──────────────────── + // Computes (login_ticket, K_32) where K = SHA256(broken_evidence_le(S)). + // We expand to 64 bytes (K || SHA256(K)) to match worldserver's expected + // session_key_bnet width, then push it into account.session_key_bnet so the + // worldserver picks up the live key when validating CMSG_AUTH_SESSION. + info!( + "[Bot {}] Step 1: Live SRP6 against bnetserver {}:{}", + bot_index, + bnet_host(), + bnet_port() + ); + + let bnet_url = format!("https://{}:{}", bnet_host(), bnet_port()); + let (login_ticket, session_key_32) = + bot_srp6::authenticate_bot(&bnet_url, &bot.account, &bot.password) + .await + .map_err(|e| anyhow!("Bot SRP6 failed: {}", e))?; + if session_key_32.len() != 32 { + bail!( + "Bot SRP6 returned K of unexpected length: {}", + session_key_32.len() + ); + } + let session_key = expand_session_key(&session_key_32).to_vec(); + + let account_for_db = bot.account.clone(); + let session_key_for_db = session_key.clone(); + let realm_id_for_db = realm_id(); + let world_auth_context = tokio::task::spawn_blocking(move || { + prepare_world_auth_context(&account_for_db, &session_key_for_db, realm_id_for_db) + }) + .await + .map_err(|e| anyhow!("DB worker join failed for {}: {}", bot.account, e))? + .map_err(|e| { + anyhow!( + "Failed to prepare world auth context for {}: {}", + bot.account, + e + ) + })?; + let wow_username = world_auth_context.username.clone(); + + info!("[Bot {}] ✅ LoginTicket received", bot_index); + info!("[Bot {}] ✅ K (live, 32B) received", bot_index); + info!( + "[Bot {}] ✅ session_key_bnet (64B) written to account `{}`; realm build {} auth seed loaded", + bot_index, wow_username, world_auth_context.realm_build + ); + + if let Some(quest_options) = &quest_options { + if quest_smoke_needs_prelogin_db_setup(quest_options) { + let bot_for_db = bot.clone(); + let options_for_db = quest_options.clone(); + tokio::task::spawn_blocking(move || { + prepare_quest_smoke_before_login(&bot_for_db, &options_for_db) + }) + .await + .map_err(|e| anyhow!("Quest smoke setup DB worker join failed: {}", e))??; + } + if quest_options.objective_persist { + let bot_for_db = bot.clone(); + let quest_id = quest_options + .expected_quest_id + .ok_or_else(|| anyhow!("Objective persistence requested without quest id"))?; + result.quest_objective_db_before = tokio::task::spawn_blocking(move || { + load_bot_quest_objectives(&bot_for_db, quest_id) + }) + .await + .map_err(|e| anyhow!("Quest objective before-load worker join failed: {}", e))??; + } + } + + // ── Step 2: Connect to World Server ───────────────────────────────────── + info!( + "[Bot {}] Step 2: Connecting to World Server {}:{}", + bot_index, + world_host(), + world_port() + ); + let world_addr = format!("{}:{}", world_host(), world_port()); + let mut stream = TcpStream::connect(&world_addr) + .await + .map_err(|e| anyhow!("Failed to connect to world server: {}", e))?; + info!("[Bot {}] ✅ TCP connected", bot_index); + + // ── Step 3: World Server Handshake ────────────────────────────────────── + info!("[Bot {}] Step 3: Handshake...", bot_index); + let mut init_buf = vec![0u8; 256]; + let n = stream.read(&mut init_buf).await?; + if !init_buf[..n].starts_with(&SERVER_INIT[..SERVER_INIT.len().min(n)]) { + bail!( + "Unexpected server init: {:?}", + String::from_utf8_lossy(&init_buf[..n]) + ); + } + info!("[Bot {}] ✅ SERVER_INIT received", bot_index); + + stream.write_all(CLIENT_INIT).await?; + stream.flush().await?; + info!("[Bot {}] ✅ CLIENT_INIT sent", bot_index); + + // ── Step 4: Read SMSG_AUTH_CHALLENGE ──────────────────────────────────── + info!("[Bot {}] Step 4: Reading SMSG_AUTH_CHALLENGE...", bot_index); + let (opcode, challenge_data) = read_unencrypted_packet(&mut stream).await?; + if opcode != 0x3048 { + bail!( + "Expected SMSG_AUTH_CHALLENGE (0x3048), got 0x{:04X}", + opcode + ); + } + if challenge_data.len() < 48 { + bail!( + "SMSG_AUTH_CHALLENGE too short: {} bytes", + challenge_data.len() + ); + } + let server_challenge: [u8; 16] = challenge_data[32..48].try_into()?; + info!("[Bot {}] ✅ SMSG_AUTH_CHALLENGE received", bot_index); + + // ── Step 5: Send CMSG_AUTH_SESSION ────────────────────────────────────── + info!( + "[Bot {}] Step 5: Sending CMSG_AUTH_SESSION (build={})...", + bot_index, + client_build() + ); + let local_challenge: [u8; 16] = rand::random(); + let digest = compute_auth_digest( + &local_challenge, + &server_challenge, + &session_key, + &world_auth_context.win64_auth_seed, + ); + let derived_session_key = + derive_realm_session_key(&session_key, &local_challenge, &server_challenge); + + // RealmJoinTicket on the worldserver side is the WoW account name (account.username), + // NOT the bnet login_ticket — sending the bnet ticket here yields "unknown account". + let _ = &login_ticket; // ticket only needed for the bnetserver REST proof + let auth_data = build_cmsg_auth_session(realm_id(), &local_challenge, &digest, &wow_username); + send_unencrypted_packet(&mut stream, 0x3765, &auth_data).await?; + info!("[Bot {}] ✅ CMSG_AUTH_SESSION sent", bot_index); + + // ── Step 6: Wait for SMSG_AUTH_RESPONSE & encryption activation ───────── + info!( + "[Bot {}] Step 6: Waiting for auth response & encryption...", + bot_index + ); + let mut world_crypt: Option = None; + let mut encrypted = false; + + for _ in 0..10 { + match tokio::time::timeout(Duration::from_secs(5), read_unencrypted_packet(&mut stream)) + .await + { + Ok(Ok((op, payload))) => { + result.seen_opcodes.push(format!("0x{:04X}", op)); + let parsed = parse_packet(op, &payload); + info!("[Bot {}] 📦 {}", bot_index, parsed); + + if op == 0x256D { + // SMSG_AUTH_RESPONSE + info!("[Bot {}] ✅ SMSG_AUTH_RESPONSE received", bot_index); + } else if op == 0x3049 { + // SMSG_ENTER_ENCRYPTED_MODE + info!("[Bot {}] ✅ SMSG_ENTER_ENCRYPTED_MODE received", bot_index); + + let enc_key = + derive_encryption_key(&session_key, &local_challenge, &server_challenge); + info!( + "[Bot {}] Encryption key derived: {:02x}{:02x}...", + bot_index, enc_key[0], enc_key[1] + ); + + // Server's WorldPacketCrypt increments _clientCounter / _serverCounter + // on every packet — including the unencrypted SMSG_AUTH_CHALLENGE, + // SMSG_ENTER_ENCRYPTED_MODE, CMSG_AUTH_SESSION, and CMSG_ENTER_ENCRYPTED_MODE_ACK + // exchanges that happen before _authCrypt.Init() is called. By the + // time the first AES-GCM packet flies, both counters are at 2. + world_crypt = Some(WorldCrypt::new_with_counters(&enc_key, 2, 2)); + encrypted = true; + + // Send ACK + send_unencrypted_packet(&mut stream, 0x3767, &[]).await?; + info!("[Bot {}] ✅ CMSG_ENTER_ENCRYPTED_MODE_ACK sent", bot_index); + break; + } else if op == 0x256E { + // SMSG_AUTH_RESPONSE (error variant) + warn!( + "[Bot {}] ⚠️ Auth response error code: {:?}", + bot_index, + payload.get(0) + ); + } + } + Ok(Err(e)) => { + warn!("[Bot {}] Error reading packet: {}", bot_index, e); + break; + } + Err(_) => { + warn!("[Bot {}] Timeout waiting for encryption", bot_index); + break; + } + } + } + + if !encrypted { + bail!("Encryption not established"); + } + result.world_auth = true; + let mut crypt = world_crypt.take().unwrap(); + let mut server_inflater = ServerPacketInflater::default(); + + // ── Step 7a: Enumerate Characters ────────────────────────────────────── + // The worldserver gates HandlePlayerLoginOpcode on `_legitCharacters` being + // populated, which only happens after CMSG_ENUM_CHARACTERS is processed. + // Skipping this step results in "Trying to login with a character of another account". + info!( + "[Bot {}] Step 7a: Sending CMSG_ENUM_CHARACTERS...", + bot_index + ); + send_encrypted_packet(&mut stream, &mut crypt, 0x35E9, &[]).await?; + + let mut enum_ok = false; + for _ in 0..20 { + match tokio::time::timeout( + Duration::from_secs(3), + read_encrypted_packet(&mut stream, &mut crypt, &mut server_inflater), + ) + .await + { + Ok(Ok((op, _payload))) => { + if op == 0x2583 { + // SMSG_ENUM_CHARACTERS_RESULT + info!( + "[Bot {}] ✅ SMSG_ENUM_CHARACTERS_RESULT received", + bot_index + ); + enum_ok = true; + break; + } + } + Ok(Err(e)) => { + warn!("[Bot {}] Enum read error: {}", bot_index, e); + break; + } + Err(_) => { /* fall through to retry */ } + } + } + if !enum_ok { + bail!("Did not receive SMSG_ENUM_CHARACTERS_RESULT"); + } + result.enum_characters = true; + + // ── Step 7b: Player Login ────────────────────────────────────────────── + info!( + "[Bot {}] Step 7b: Sending CMSG_PLAYER_LOGIN (guid={})...", + bot_index, bot.character_guid + ); + let login_data = build_player_login(bot.character_guid, realm_id(), 500.0); + send_encrypted_packet(&mut stream, &mut crypt, 0x35EB, &login_data).await?; + info!("[Bot {}] ✅ CMSG_PLAYER_LOGIN sent", bot_index); + + let mut login_ok = false; + for _ in 0..30 { + match tokio::time::timeout( + Duration::from_secs(5), + read_encrypted_packet(&mut stream, &mut crypt, &mut server_inflater), + ) + .await + { + Ok(Ok((op, payload))) => { + result.seen_opcodes.push(format!("0x{:04X}", op)); + if let Some(options) = quest_options.as_ref() { + record_quest_objective_login_signal(op, &payload, options, &mut result); + } + if op == 0x2597 { + // SMSG_LOGIN_VERIFY_WORLD + info!("[Bot {}] ✅ SMSG_LOGIN_VERIFY_WORLD received", bot_index); + login_ok = true; + break; + } + + if op == 0x304D { + let connect_to = parse_connect_to(&payload) + .ok_or_else(|| anyhow!("Unable to parse SMSG_CONNECT_TO payload"))?; + info!( + "[Bot {}] SMSG_CONNECT_TO: {}:{} serial={} con={} key={}", + bot_index, + connect_to.address, + connect_to.port, + connect_to.serial, + connect_to.connection_type, + connect_to.key + ); + let (instance_stream, instance_crypt) = + connect_to_instance(bot_index, &connect_to, &derived_session_key).await?; + stream = instance_stream; + crypt = instance_crypt; + server_inflater = ServerPacketInflater::default(); + info!("[Bot {}] ✅ Instance socket authenticated", bot_index); + } else if op == 0x304B { + // SMSG_RESUME_COMMS + info!("[Bot {}] ✅ SMSG_RESUME_COMMS received", bot_index); + } + } + Ok(Err(e)) => { + warn!("[Bot {}] Login read error: {}", bot_index, e); + break; + } + Err(_) => break, + } + } + if !login_ok { + bail!("Login verification failed"); + } + result.player_login_verified = true; + + if let Some(quest_options) = quest_options { + run_quest_smoke( + bot_index, + &bot, + &mut stream, + &mut crypt, + &mut server_inflater, + &quest_options, + &mut result, + ) + .await; + if quest_options.objective_persist { + if let Err(e) = logout_and_verify_quest_objectives( + bot_index, + &bot, + &mut stream, + &mut crypt, + &mut server_inflater, + &quest_options, + &mut result, + ) + .await + { + result.quest_failure = Some(format!("Quest objective persist QA failed: {e}")); + result.quest_smoke_passed = Some(false); + } else { + result.quest_smoke_passed = Some(quest_smoke_passes(&quest_options, &mut result)); + } + } + return Ok(result); + } + + if login_only { + info!( + "[Bot {}] ✅ Login-only smoke passed: world_auth=true enum_characters=true player_login=true", + bot_index + ); + return Ok(result); + } + + // ── Step 8: LFG Setup ─────────────────────────────────────────────────── + // Opcodes verified against src/server/game/Server/Protocol/Opcodes.h: + // CMSG_DF_SET_ROLES = 0x3617 (was incorrectly 0x35EE) + // CMSG_DF_JOIN = 0x360B + // CMSG_DF_READY_CHECK_RESPONSE = 0x361C (was 0x360D) + // SMSG_LFG_JOIN_RESULT = 0x2A1C (was 0x2F0C) + // SMSG_LFG_READY_CHECK_UPDATE = 0x2A22 (was 0x2F0E) + info!( + "[Bot {}] Step 8: Setting up LFG (role={})...", + bot_index, bot.lfg_role + ); + tokio::time::sleep(Duration::from_secs(1)).await; + + // CMSG_DF_SET_ROLES wire format (per LFGPackets.cpp::DFSetRoles::Read): + // bit: hasPartyIndex (false here → 0) + // u8: RolesDesired + // ByteBuffer aligns to byte after bits, so the wire is [bit_byte=0x00, role]. + let role_data = vec![0x00, bot.lfg_role]; + send_encrypted_packet(&mut stream, &mut crypt, 0x3617, &role_data).await?; + info!("[Bot {}] ✅ CMSG_DF_SET_ROLES sent", bot_index); + tokio::time::sleep(Duration::from_millis(500)).await; + + // ── Step 9: Join Defense Protocol Alpha (259) ─────────────────────────── + info!( + "[Bot {}] Step 9: Joining LFG dungeon {}...", + bot_index, dungeon_id + ); + let join_data = build_lfg_join(dungeon_id, bot.lfg_role); + send_encrypted_packet(&mut stream, &mut crypt, 0x360B, &join_data).await?; + info!("[Bot {}] ✅ CMSG_DF_JOIN sent", bot_index); + + // ── Step 10: Wait for LFG result ──────────────────────────────────────── + let mut result_code = 255u8; + let mut detail_code = 255u8; + + // Read until we hit the configured overall budget. We stay in the queue past the initial + // SMSG_LFG_JOIN_RESULT so LFGMgr has time to match all 5 bots, send a proposal, + // collect accepts, and run the ready check — which is what proves group formation. + // the queue and needs time to log in, set roles, and accept the proposal. + info!("[Bot {}] LFG read window: {}s", bot_index, lfg_secs); + let lfg_deadline = tokio::time::Instant::now() + Duration::from_secs(lfg_secs); + let mut got_proposal = false; + let mut group_formed = false; + while tokio::time::Instant::now() < lfg_deadline { + let remaining = lfg_deadline - tokio::time::Instant::now(); + match tokio::time::timeout( + remaining, + read_encrypted_packet(&mut stream, &mut crypt, &mut server_inflater), + ) + .await + { + Ok(Ok((op, payload))) => { + result.seen_opcodes.push(format!("0x{:04X}", op)); + let parsed = parse_packet(op, &payload); + info!("[Bot {}] 📦 {}", bot_index, parsed); + + if op == 0x2A1C { + // SMSG_LFG_JOIN_RESULT + // Use the proper parser — the result/detail bytes live AFTER the + // RideTicket prefix (PackedGuid + 12 bytes + 1 bit pad), not at offset 0. + if let Some(r) = packet_parser::parse_lfg_join_result(&payload) { + result_code = r.result; + detail_code = r.result_detail; + result.join_result = Some(r.result); + result.join_detail = Some(r.result_detail); + info!( + "[Bot {}] 🎯 LFG RESULT: result={}, detail={}", + bot_index, result_code, detail_code + ); + } + } + + if op == 0x2A2D { + // SMSG_LFG_PROPOSAL_UPDATE + info!( + "[Bot {}] 📜 SMSG_LFG_PROPOSAL_UPDATE ({} bytes)", + bot_index, + payload.len() + ); + if let Some(resp) = build_proposal_response(&payload) { + send_encrypted_packet(&mut stream, &mut crypt, 0x3609, &resp).await?; + info!( + "[Bot {}] ✅ CMSG_DF_PROPOSAL_RESPONSE sent (Accepted=true)", + bot_index + ); + got_proposal = true; + result.got_proposal = true; + result.accepted_proposal = true; + } else { + warn!( + "[Bot {}] Proposal payload too short to parse Ticket prefix", + bot_index + ); + } + } + + if op == 0x2A22 { + // SMSG_LFG_READY_CHECK_UPDATE + info!("[Bot {}] 📢 LFG Ready Check received", bot_index); + result.got_ready_check = true; + let ready_data = vec![1u8]; + send_encrypted_packet(&mut stream, &mut crypt, 0x361C, &ready_data).await?; + info!( + "[Bot {}] ✅ CMSG_DF_READY_CHECK_RESPONSE sent (Ready=true)", + bot_index + ); + } + + if op == 0x2A36 { + // SMSG_LFG_PARTY_INFO — sent when group is committed + info!("[Bot {}] 🎉 SMSG_LFG_PARTY_INFO — group formed!", bot_index); + group_formed = true; + result.group_formed = true; + if auto_teleport { + send_encrypted_packet(&mut stream, &mut crypt, 0x3619, &[1u8]).await?; + info!( + "[Bot {}] ✅ CMSG_DF_TELEPORT sent (teleport_out=false)", + bot_index + ); + } + } + + if op == 0x2594 { + // SMSG_NEW_WORLD + info!("[Bot {}] 🌍 SMSG_NEW_WORLD ({} bytes) — replying with CMSG_WORLD_PORT_RESPONSE", bot_index, payload.len()); + send_encrypted_packet(&mut stream, &mut crypt, 0x35FA, &[]).await?; + info!( + "[Bot {}] ✅ CMSG_WORLD_PORT_RESPONSE sent — should now be inside map", + bot_index + ); + result.entered_world = true; + } + + if op == 0x2A32 { + // SMSG_LFG_TELEPORT_DENIED (1 byte body, reason code high nibble) + let reason_byte = payload.first().copied().unwrap_or(0); + let reason = reason_byte >> 4; + warn!( + "[Bot {}] ⛔ SMSG_LFG_TELEPORT_DENIED reason={} (raw=0x{:02X})", + bot_index, reason, reason_byte + ); + result.teleport_denied_reason = Some(reason); + } + } + Ok(Err(e)) => { + warn!("[Bot {}] Error: {}", bot_index, e); + break; + } + Err(_) => { + debug!("[Bot {}] LFG read budget exhausted", bot_index); + break; + } + } + } + let _ = (got_proposal, group_formed); // mirrored in result; keep locals for readable logs while debugging + + tokio::time::sleep(Duration::from_secs(3)).await; + info!( + "[Bot {}] 🏁 Completed: result={}, detail={}", + bot_index, result_code, detail_code + ); + Ok(result) +} + +fn log_bot_summary( + result: &BotRunResult, + require_proposal: bool, + require_group: bool, + login_only: bool, +) { + if result.success(require_proposal, require_group, login_only) { + if result.quest_smoke { + info!( + "✅ Bot {}: SUCCESS quest_smoke target={:?}/{:?} ids={:?} details={} request_items={} accept_sent={} db_verified={} db_status={:?} obj_verified={} obj_before={:?} obj_after={:?} failure={:?}", + result.account, + result.quest_target_entry, + result.quest_target_spawn_guid, + result.quest_ids_seen, + result.quest_details_seen, + result.quest_request_items_seen, + result.quest_accept_sent, + result.quest_db_verified, + result.quest_db_status, + result.quest_objective_db_verified, + result.quest_objective_db_before, + result.quest_objective_db_after, + result.quest_failure + ); + return; + } + info!( + "✅ Bot {}: SUCCESS login={{auth:{}, enum:{}, player:{}}} join={:?}/{:?} proposal={} group={} teleport_denied={:?}", + result.account, + result.world_auth, + result.enum_characters, + result.player_login_verified, + result.join_result, + result.join_detail, + result.got_proposal, + result.group_formed, + result.teleport_denied_reason + ); + } else { + if result.quest_smoke { + error!( + "❌ Bot {}: FAILED quest_smoke target={:?}/{:?} ids={:?} details={} request_items={} accept_sent={} db_verified={} db_status={:?} obj_verified={} obj_before={:?} obj_after={:?} failure={:?}", + result.account, + result.quest_target_entry, + result.quest_target_spawn_guid, + result.quest_ids_seen, + result.quest_details_seen, + result.quest_request_items_seen, + result.quest_accept_sent, + result.quest_db_verified, + result.quest_db_status, + result.quest_objective_db_verified, + result.quest_objective_db_before, + result.quest_objective_db_after, + result.quest_failure + ); + return; + } + error!( + "❌ Bot {}: FAILED login={{auth:{}, enum:{}, player:{}}} join={:?}/{:?} proposal={} group={} teleport_denied={:?}", + result.account, + result.world_auth, + result.enum_characters, + result.player_login_verified, + result.join_result, + result.join_detail, + result.got_proposal, + result.group_formed, + result.teleport_denied_reason + ); + } +} + +fn write_report_if_requested( + cli: &CliOptions, + dungeon_id: u32, + timeout_secs: u64, + require_proposal: bool, + require_group: bool, + auto_teleport: bool, + login_only: bool, + quest_smoke: bool, + results: &[BotRunResult], +) -> Result<()> { + let path = cli.report_path.clone().unwrap_or_else(|| { + format!( + "/tmp/wow-bot-run-{}.json", + chrono::Utc::now().format("%Y%m%d-%H%M%S") + ) + }); + let report = RunReport { + dungeon_id, + timeout_secs, + require_proposal, + require_group, + auto_teleport, + login_only, + quest_smoke, + results: results.to_vec(), + }; + let json = serde_json::to_string_pretty(&report)?; + std::fs::write(&path, json)?; + info!("Report written: {}", path); + Ok(()) +} + +fn cleanup_bot_group_state(bots: &[config::BotConfig]) -> Result<()> { + use mysql::prelude::Queryable; + + let guids: Vec = bots.iter().map(|bot| bot.character_guid).collect(); + if guids.is_empty() { + return Ok(()); + } + + let db_url = characters_db_url()?; + let opts = + mysql::Opts::from_url(&db_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 placeholders = std::iter::repeat("?") + .take(guids.len()) + .collect::>() + .join(","); + let params = mysql::Params::Positional(guids.iter().copied().map(mysql::Value::from).collect()); + + conn.exec_drop( + format!( + "DELETE FROM group_member WHERE memberGuid IN ({})", + placeholders + ), + params.clone(), + ) + .map_err(|e| anyhow!("DELETE group_member for bots: {}", e))?; + + conn.exec_drop( + format!("DELETE FROM groups WHERE leaderGuid IN ({})", placeholders), + params, + ) + .map_err(|e| anyhow!("DELETE groups for bots: {}", e))?; + + info!( + "Cleaned stale group rows for {} configured bot GUIDs", + bots.len() + ); + Ok(()) +} + +async fn run_quest_smoke( + bot_index: usize, + bot: &config::BotConfig, + stream: &mut TcpStream, + crypt: &mut WorldCrypt, + server_inflater: &mut ServerPacketInflater, + quest_options: &QuestSmokeOptions, + result: &mut BotRunResult, +) { + match run_quest_smoke_inner( + bot_index, + bot, + stream, + crypt, + server_inflater, + quest_options, + result, + ) + .await + { + Ok(()) => { + let pass = quest_smoke_passes(quest_options, result); + result.quest_smoke_passed = Some(pass); + if pass { + info!( + "[Bot {}] ✅ Quest smoke passed: ids={:?} titles={:?}", + bot_index, result.quest_ids_seen, result.quest_titles_seen + ); + } else if result.quest_failure.is_none() { + result.quest_failure = Some("Quest response expectations were not met".to_string()); + } + } + Err(e) => { + result.quest_smoke_passed = Some(false); + result.quest_failure = Some(e.to_string()); + warn!("[Bot {}] Quest smoke failed: {}", bot_index, e); + } + } +} + +fn sorted_quest_objective_rows(mut rows: Vec) -> Vec { + rows.sort(); + rows +} + +fn record_quest_objective_login_signal( + op: u16, + payload: &[u8], + quest_options: &QuestSmokeOptions, + result: &mut BotRunResult, +) { + if !quest_options.objective_persist || op != 0x27CB { + return; + } + let Some(quest_id) = quest_options.expected_quest_id else { + return; + }; + if !payload + .windows(4) + .any(|window| window == quest_id.to_le_bytes()) + { + return; + } + + result.quest_objective_update_seen = true; + result.quest_objective_update_has_expected = quest_options + .objective_seed + .iter() + .filter(|row| row.data > 0) + .all(|row| { + u16::try_from(row.data).is_ok_and(|data| { + payload + .windows(2) + .any(|window| window == data.to_le_bytes()) + }) + }); +} + +async fn logout_and_verify_quest_objectives( + bot_index: usize, + bot: &config::BotConfig, + stream: &mut TcpStream, + crypt: &mut WorldCrypt, + server_inflater: &mut ServerPacketInflater, + quest_options: &QuestSmokeOptions, + result: &mut BotRunResult, +) -> Result<()> { + let quest_id = quest_options + .expected_quest_id + .ok_or_else(|| anyhow!("Objective persistence requested without quest id"))?; + + send_encrypted_packet(stream, crypt, 0x34D6, &[0]).await?; + info!("[Bot {}] ✅ CMSG_LOGOUT_REQUEST sent", bot_index); + + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + while tokio::time::Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + match tokio::time::timeout( + remaining, + read_encrypted_packet(stream, crypt, server_inflater), + ) + .await + { + Ok(Ok((op, payload))) => { + result.seen_opcodes.push(format!("0x{:04X}", op)); + let parsed = parse_packet(op, &payload); + info!("[Bot {}] 📦 {}", bot_index, parsed); + if op == 0x2684 { + info!("[Bot {}] ✅ SMSG_LOGOUT_COMPLETE received", bot_index); + break; + } + } + Ok(Err(e)) => { + warn!("[Bot {}] Logout read error: {}", bot_index, e); + break; + } + Err(_) => break, + } + } + + tokio::time::sleep(Duration::from_millis(500)).await; + let bot_for_db = bot.clone(); + let after = + tokio::task::spawn_blocking(move || load_bot_quest_objectives(&bot_for_db, quest_id)) + .await + .map_err(|e| anyhow!("Quest objective after-load worker join failed: {}", e))??; + let expected = sorted_quest_objective_rows(quest_options.objective_seed.clone()); + let actual = sorted_quest_objective_rows(after); + result.quest_objective_db_after = actual.clone(); + result.quest_objective_db_verified = actual == expected; + if !result.quest_objective_db_verified { + bail!( + "objective rows changed across logout: expected {:?}, got {:?}", + expected, + actual + ); + } + + Ok(()) +} + +async fn run_quest_smoke_inner( + bot_index: usize, + bot: &config::BotConfig, + stream: &mut TcpStream, + crypt: &mut WorldCrypt, + server_inflater: &mut ServerPacketInflater, + quest_options: &QuestSmokeOptions, + result: &mut BotRunResult, +) -> Result<()> { + let options_for_db = quest_options.clone(); + let bot_for_db = bot.clone(); + let target = tokio::task::spawn_blocking(move || { + resolve_quest_target_for_bot(&bot_for_db, &options_for_db) + }) + .await + .map_err(|e| anyhow!("Quest target DB worker join failed: {}", e))??; + + result.quest_target_entry = Some(target.entry); + result.quest_target_spawn_guid = Some(target.spawn_guid); + result.quest_target_guid_counter = Some(target.guid_counter); + result.quest_target_map_id = Some(target.map_id); + + info!( + "[Bot {}] Quest smoke target: entry={} spawn_guid={} guid_counter={} map={}", + bot_index, target.entry, target.spawn_guid, target.guid_counter, target.map_id + ); + + send_encrypted_packet(stream, crypt, 0x349C, &target.packed_guid).await?; + send_encrypted_packet(stream, crypt, 0x3492, &target.packed_guid).await?; + result.quest_gossip_hello_sent = true; + info!("[Bot {}] ✅ CMSG_GOSSIP_HELLO sent", bot_index); + + let deadline = tokio::time::Instant::now() + Duration::from_secs(quest_options.timeout_secs); + let mut questgiver_hello_sent = false; + let mut details_query_sent_for: Option = None; + let mut accept_sent_for: Option = None; + + while tokio::time::Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + let slice = remaining.min(Duration::from_millis(500)); + match tokio::time::timeout(slice, read_encrypted_packet(stream, crypt, server_inflater)) + .await + { + Ok(Ok((op, payload))) => { + result.seen_opcodes.push(format!("0x{:04X}", op)); + let parsed = parse_packet(op, &payload); + info!("[Bot {}] 📦 {}", bot_index, parsed); + record_quest_objective_login_signal(op, &payload, quest_options, result); + handle_quest_smoke_packet(op, &payload, result); + + if let Some(gossip_option_id) = quest_options.gossip_select_option_id { + if !result.quest_gossip_select_sent { + if let Some(gossip_id) = result.quest_gossip_id_seen { + let select = build_gossip_select_option( + &target.packed_guid, + gossip_id, + gossip_option_id, + ); + send_encrypted_packet(stream, crypt, 0x3494, &select).await?; + result.quest_gossip_select_sent = true; + info!( + "[Bot {}] ✅ CMSG_GOSSIP_SELECT_OPTION sent: gossip_id={} option_id={}", + bot_index, gossip_id, gossip_option_id + ); + } + } + } + + if quest_options.query_details + && details_query_sent_for.is_none() + && !quest_details_or_request_items_seen(result) + { + if let Some(quest_id) = select_quest_to_query(quest_options, result) { + let query = build_quest_giver_query_quest(&target.packed_guid, quest_id); + send_encrypted_packet(stream, crypt, 0x3497, &query).await?; + details_query_sent_for = Some(quest_id); + info!( + "[Bot {}] ✅ CMSG_QUEST_GIVER_QUERY_QUEST sent for quest {}", + bot_index, quest_id + ); + } + } + + if quest_options.accept && result.quest_details_seen && accept_sent_for.is_none() { + if let Some(quest_id) = select_quest_to_query(quest_options, result) { + let accept = build_quest_giver_accept_quest(&target.packed_guid, quest_id); + send_encrypted_packet(stream, crypt, 0x3498, &accept).await?; + result.quest_accept_sent = true; + accept_sent_for = Some(quest_id); + info!( + "[Bot {}] ✅ CMSG_QUEST_GIVER_ACCEPT_QUEST sent for quest {}", + bot_index, quest_id + ); + } + } + + if quest_smoke_has_enough_signal(quest_options, result) { + break; + } + } + Ok(Err(e)) => { + warn!("[Bot {}] Quest smoke read error: {}", bot_index, e); + break; + } + Err(_) => { + if !questgiver_hello_sent { + send_encrypted_packet(stream, crypt, 0x3496, &target.packed_guid).await?; + result.quest_questgiver_hello_sent = true; + questgiver_hello_sent = true; + info!("[Bot {}] ✅ CMSG_QUEST_GIVER_HELLO sent", bot_index); + } + } + } + } + + if !quest_options.objective_persist + && !result.quest_gossip_message_seen + && !result.quest_quest_list_seen + && !result.quest_details_seen + && !result.quest_request_items_seen + && !result.trainer_list_seen + { + bail!("No GossipMessage, QuestList, QuestDetails, RequestItems, or TrainerList response received"); + } + + if quest_options.accept { + let quest_id = quest_options + .expected_quest_id + .or(accept_sent_for) + .ok_or_else(|| { + anyhow!("Quest accept requested but no selected quest id was available") + })?; + let bot_for_db = bot.clone(); + let (verified, status) = + tokio::task::spawn_blocking(move || verify_quest_accepted_in_db(&bot_for_db, quest_id)) + .await + .map_err(|e| anyhow!("Quest DB verification worker join failed: {}", e))??; + result.quest_db_verified = verified; + result.quest_db_status = status; + } + + Ok(()) +} + +fn handle_quest_smoke_packet(op: u16, payload: &[u8], result: &mut BotRunResult) { + match op { + 0x2A98 => { + result.quest_gossip_message_seen = true; + result.quest_gossip_id_seen = packet_parser::parse_gossip_id(payload); + if let Some(offers) = packet_parser::parse_gossip_quest_offers(payload) { + record_quest_offers(offers, result); + } + } + 0x2A9A => { + result.quest_quest_list_seen = true; + if let Some(offers) = packet_parser::parse_quest_list_offers(payload) { + record_quest_offers(offers, result); + } + } + 0x2A92 => { + result.quest_details_seen = true; + if let Some(details) = packet_parser::parse_quest_details_summary(payload) { + record_quest_id(details.quest_id, result); + } + } + 0x2A93 => { + result.quest_request_items_seen = true; + if let Some(summary) = packet_parser::parse_quest_request_items_summary(payload) { + record_quest_id(summary.quest_id, result); + } + } + 0x2A83 => { + result.quest_accept_confirm_seen = true; + if payload.len() >= 4 { + record_quest_id( + u32::from_le_bytes(payload[0..4].try_into().unwrap()), + result, + ); + } + } + 0x26DF => { + result.trainer_list_seen = true; + if let Some(summary) = packet_parser::parse_trainer_list_summary(payload) { + result.trainer_id_seen = Some(summary.trainer_id); + result.trainer_spell_count_seen = Some(summary.spell_count); + } + } + _ => {} + } +} + +fn record_quest_offers(offers: Vec, result: &mut BotRunResult) { + for offer in offers { + record_quest_id(offer.quest_id, result); + if !offer.title.is_empty() && !result.quest_titles_seen.contains(&offer.title) { + result.quest_titles_seen.push(offer.title); + } + } +} + +fn record_quest_id(quest_id: u32, result: &mut BotRunResult) { + if quest_id != 0 && !result.quest_ids_seen.contains(&quest_id) { + result.quest_ids_seen.push(quest_id); + } +} + +fn select_quest_to_query(quest_options: &QuestSmokeOptions, result: &BotRunResult) -> Option { + if let Some(expected) = quest_options.expected_quest_id { + if result.quest_ids_seen.contains(&expected) { + return Some(expected); + } + } + result.quest_ids_seen.first().copied() +} + +fn quest_smoke_has_enough_signal(quest_options: &QuestSmokeOptions, result: &BotRunResult) -> bool { + if quest_options.expect_trainer_list { + return result.trainer_list_seen; + } + if quest_options.accept { + return result.quest_accept_sent + && (result.quest_accept_confirm_seen || result.quest_db_verified); + } + if quest_options.query_details { + quest_details_or_request_items_seen(result) + } else { + result.quest_gossip_message_seen + || result.quest_quest_list_seen + || result.quest_details_seen + || result.quest_request_items_seen + } +} + +fn quest_details_or_request_items_seen(result: &BotRunResult) -> bool { + result.quest_details_seen || result.quest_request_items_seen +} + +fn quest_smoke_passes(quest_options: &QuestSmokeOptions, result: &mut BotRunResult) -> bool { + result.quest_failure = None; + + if !quest_options.objective_persist + && !result.quest_gossip_message_seen + && !result.quest_quest_list_seen + && !result.quest_details_seen + && !result.quest_request_items_seen + && !result.trainer_list_seen + { + result.quest_failure = Some("No questgiver or trainer response was received".to_string()); + return false; + } + + if quest_options.expect_trainer_list && !result.trainer_list_seen { + result.quest_failure = Some("TrainerList was not received".to_string()); + return false; + } + + if let Some(expected) = quest_options.expect_trainer_id { + if result.trainer_id_seen != Some(expected) { + result.quest_failure = Some(format!( + "Expected trainer id {}, got {:?}", + expected, result.trainer_id_seen + )); + return false; + } + } + + if quest_options.query_details + && !quest_options.objective_persist + && !quest_details_or_request_items_seen(result) + { + result.quest_failure = Some("QuestDetails or RequestItems was not received".to_string()); + return false; + } + + if quest_options.accept { + if !result.quest_accept_sent { + result.quest_failure = Some("Quest accept packet was not sent".to_string()); + return false; + } + if !result.quest_db_verified { + result.quest_failure = Some(format!( + "Accepted quest was not verified in DB (status={:?})", + result.quest_db_status + )); + return false; + } + } + + if quest_options.objective_persist && !result.quest_objective_db_verified { + result.quest_failure = Some(format!( + "Quest objective DB rows did not survive logout (before={:?}, after={:?})", + result.quest_objective_db_before, result.quest_objective_db_after + )); + return false; + } + + if !quest_options.objective_persist { + if let Some(expected) = quest_options.expected_quest_id { + if !result.quest_ids_seen.contains(&expected) { + result.quest_failure = Some(format!("Expected quest {} was not seen", expected)); + return false; + } + } + } + + if let Some(forbidden) = quest_options.forbidden_quest_id { + if result.quest_ids_seen.contains(&forbidden) { + result.quest_failure = Some(format!("Forbidden quest {} was offered", forbidden)); + return false; + } + } + + if let Some(forbidden_title) = &quest_options.forbidden_title_contains { + let needle = forbidden_title.to_ascii_lowercase(); + if result + .quest_titles_seen + .iter() + .any(|title| title.to_ascii_lowercase().contains(&needle)) + { + result.quest_failure = Some(format!( + "Forbidden quest title fragment `{}` was offered", + forbidden_title + )); + return false; + } + } + + true +} + +fn resolve_quest_target_for_bot( + bot: &config::BotConfig, + quest_options: &QuestSmokeOptions, +) -> Result { + use mysql::prelude::Queryable; + + let world_url = world_db_url()?; + let world_opts = + mysql::Opts::from_url(&world_url).map_err(|e| anyhow!("Bad world DB URL: {}", e))?; + let mut world = + mysql::Conn::new(world_opts).map_err(|e| anyhow!("Connect to world DB failed: {}", e))?; + + let (spawn_guid, entry, map_id, x, y, z, orientation) = if let Some(spawn_guid) = + quest_options.creature_spawn_guid + { + let row: Option<(u64, u32, u32, f64, f64, f64, f32)> = world + .exec_first( + "SELECT guid, id, map, position_x, position_y, position_z, orientation \ + FROM creature WHERE guid = ?", + (spawn_guid,), + ) + .map_err(|e| anyhow!("Lookup creature spawn {}: {}", spawn_guid, e))?; + let (guid, entry, map_id, x, y, z, orientation) = + row.ok_or_else(|| anyhow!("No world.creature row for guid {}", spawn_guid))?; + if entry != quest_options.creature_entry { + bail!( + "Creature guid {} has entry {}, expected {}", + guid, + entry, + quest_options.creature_entry + ); + } + ( + guid, + entry, + quest_options.map_id.unwrap_or(map_id as u16), + x, + y, + z, + orientation, + ) + } else { + let player_position = load_bot_position(bot.character_guid).ok(); + let map_id = quest_options + .map_id + .or_else(|| player_position.as_ref().map(|p| p.0)) + .ok_or_else(|| { + anyhow!("Set WOW_BOT_QUEST_MAP_ID or use a bot character with a saved map position") + })?; + + let row: Option<(u64, u32, u32, f64, f64, f64, f32)> = if let Some((player_map, x, y, z)) = + player_position + { + let query_map = if quest_options.map_id.is_some() { + map_id + } else { + player_map + }; + world + .exec_first( + "SELECT guid, id, map, position_x, position_y, position_z, orientation FROM creature \ + WHERE id = ? AND map = ? \ + ORDER BY POW(position_x - ?, 2) + POW(position_y - ?, 2) + POW(position_z - ?, 2) \ + LIMIT 1", + (quest_options.creature_entry, query_map, x, y, z), + ) + .map_err(|e| anyhow!("Lookup nearest creature target: {}", e))? + } else { + world + .exec_first( + "SELECT guid, id, map, position_x, position_y, position_z, orientation FROM creature \ + WHERE id = ? AND map = ? \ + ORDER BY guid LIMIT 1", + (quest_options.creature_entry, map_id), + ) + .map_err(|e| anyhow!("Lookup creature target by entry/map: {}", e))? + }; + + row.ok_or_else(|| { + anyhow!( + "No world.creature row for entry {} on map {}", + quest_options.creature_entry, + map_id + ) + }) + .map(|(guid, entry, row_map, x, y, z, orientation)| { + (guid, entry, row_map as u16, x, y, z, orientation) + })? + }; + + let guid_counter = resolve_quest_runtime_counter( + quest_options.creature_guid_counter, + spawn_guid, + quest_options.creature_entry, + )?; + let (low, high) = create_creature_guid_raw(map_id, entry, guid_counter); + Ok(ResolvedCreatureTarget { + entry, + spawn_guid, + guid_counter, + map_id, + x, + y, + z, + orientation, + packed_guid: build_packed_guid(low, high), + }) +} + +fn load_bot_position(character_guid: u64) -> Result<(u16, f64, f64, f64)> { + 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 row: Option<(u32, f64, f64, f64)> = conn + .exec_first( + "SELECT map, position_x, position_y, position_z FROM characters WHERE guid = ?", + (character_guid,), + ) + .map_err(|e| anyhow!("Lookup character {} position: {}", character_guid, e))?; + row.map(|(map, x, y, z)| (map as u16, x, y, z)) + .ok_or_else(|| anyhow!("No characters row for guid {}", character_guid)) +} + +fn prepare_quest_smoke_before_login( + bot: &config::BotConfig, + quest_options: &QuestSmokeOptions, +) -> Result<()> { + use mysql::prelude::Queryable; + + let target = resolve_quest_target_for_bot(bot, quest_options)?; + + 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))?; + + if quest_options.reset_before_run { + let quest_id = quest_options + .expected_quest_id + .ok_or_else(|| anyhow!("Quest reset requested without expected quest id"))?; + reset_bot_quest_state(&mut conn, bot.character_guid, quest_id)?; + info!( + "Quest smoke reset character {} quest {}", + bot.character_guid, quest_id + ); + } + + if quest_options.relocate_before_login { + let offset_x = 2.0_f64; + conn.exec_drop( + "UPDATE characters \ + SET map = ?, position_x = ?, position_y = ?, position_z = ?, orientation = ? \ + WHERE guid = ?", + ( + u32::from(target.map_id), + target.x + offset_x, + target.y, + target.z, + target.orientation, + bot.character_guid, + ), + ) + .map_err(|e| { + anyhow!( + "Relocate character {} near quest target: {}", + bot.character_guid, + e + ) + })?; + info!( + "Quest smoke relocated character {} near creature {} ({}, {}, {})", + bot.character_guid, + target.spawn_guid, + target.x + offset_x, + target.y, + target.z + ); + } + + if let Some(level) = quest_options.set_level_before_login { + set_bot_character_level(&mut conn, bot.character_guid, level)?; + info!( + "Quest smoke set character {} level to {}", + bot.character_guid, level + ); + } + + if quest_options.set_race_before_login.is_some() + || quest_options.set_class_before_login.is_some() + { + set_bot_character_race_class( + &mut conn, + bot.character_guid, + quest_options.set_race_before_login, + quest_options.set_class_before_login, + )?; + info!( + "Quest smoke set character {} race={:?} class={:?}", + bot.character_guid, + quest_options.set_race_before_login, + quest_options.set_class_before_login + ); + } + + if quest_options.objective_persist { + let quest_id = quest_options + .expected_quest_id + .ok_or_else(|| anyhow!("Quest objective persistence requested without quest id"))?; + seed_bot_quest_objective_state( + &mut conn, + bot.character_guid, + quest_id, + quest_options.objective_status, + &quest_options.objective_seed, + )?; + info!( + "Quest smoke seeded character {} quest {} objectives {:?}", + bot.character_guid, quest_id, quest_options.objective_seed + ); + } + + Ok(()) +} + +fn set_bot_character_level(conn: &mut mysql::Conn, character_guid: u64, level: u8) -> Result<()> { + use mysql::prelude::Queryable; + + conn.exec_drop( + "UPDATE characters SET level = ? WHERE guid = ?", + (level, character_guid), + ) + .map_err(|e| anyhow!("UPDATE characters.level: {}", e))?; + Ok(()) +} + +fn set_bot_character_race_class( + conn: &mut mysql::Conn, + character_guid: u64, + race: Option, + class: Option, +) -> Result<()> { + use mysql::prelude::Queryable; + + match (race, class) { + (Some(race), Some(class)) => conn + .exec_drop( + "UPDATE characters SET race = ?, class = ? WHERE guid = ?", + (race, class, character_guid), + ) + .map_err(|e| anyhow!("UPDATE characters.race/class: {}", e))?, + (Some(race), None) => conn + .exec_drop( + "UPDATE characters SET race = ? WHERE guid = ?", + (race, character_guid), + ) + .map_err(|e| anyhow!("UPDATE characters.race: {}", e))?, + (None, Some(class)) => conn + .exec_drop( + "UPDATE characters SET class = ? WHERE guid = ?", + (class, character_guid), + ) + .map_err(|e| anyhow!("UPDATE characters.class: {}", e))?, + (None, None) => {} + } + Ok(()) +} + +fn reset_bot_quest_state(conn: &mut mysql::Conn, character_guid: u64, quest_id: u32) -> Result<()> { + use mysql::prelude::Queryable; + + conn.exec_drop( + "DELETE FROM character_queststatus WHERE guid = ? AND quest = ?", + (character_guid, quest_id), + ) + .map_err(|e| anyhow!("DELETE character_queststatus: {}", e))?; + conn.exec_drop( + "DELETE FROM character_queststatus_objectives WHERE guid = ? AND quest = ?", + (character_guid, quest_id), + ) + .map_err(|e| anyhow!("DELETE character_queststatus_objectives: {}", e))?; + conn.exec_drop( + "DELETE FROM character_queststatus_rewarded WHERE guid = ? AND quest = ?", + (character_guid, quest_id), + ) + .map_err(|e| anyhow!("DELETE character_queststatus_rewarded: {}", e))?; + Ok(()) +} + +fn seed_bot_quest_objective_state( + conn: &mut mysql::Conn, + character_guid: u64, + quest_id: u32, + status: u8, + rows: &[QuestObjectiveDbRow], +) -> Result<()> { + use mysql::prelude::Queryable; + + let accept_time = chrono::Utc::now().timestamp(); + conn.exec_drop( + "REPLACE INTO character_queststatus \ + (guid, quest, status, explored, acceptTime, endTime) VALUES (?, ?, ?, 0, ?, 0)", + (character_guid, quest_id, status, accept_time), + ) + .map_err(|e| anyhow!("REPLACE character_queststatus: {}", e))?; + conn.exec_drop( + "DELETE FROM character_queststatus_objectives WHERE guid = ? AND quest = ?", + (character_guid, quest_id), + ) + .map_err(|e| anyhow!("DELETE character_queststatus_objectives: {}", e))?; + + for row in rows.iter().filter(|row| row.data != 0) { + conn.exec_drop( + "REPLACE INTO character_queststatus_objectives \ + (guid, quest, objective, data) VALUES (?, ?, ?, ?)", + (character_guid, quest_id, row.objective, row.data), + ) + .map_err(|e| anyhow!("REPLACE character_queststatus_objectives: {}", e))?; + } + Ok(()) +} + +fn load_bot_quest_objectives( + bot: &config::BotConfig, + quest_id: u32, +) -> 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 mut rows: Vec = conn + .exec_map( + "SELECT objective, data FROM character_queststatus_objectives \ + WHERE guid = ? AND quest = ? ORDER BY objective", + (bot.character_guid, quest_id), + |(objective, data)| QuestObjectiveDbRow { objective, data }, + ) + .map_err(|e| anyhow!("SELECT character_queststatus_objectives: {}", e))?; + rows.sort(); + Ok(rows) +} + +fn verify_quest_accepted_in_db( + bot: &config::BotConfig, + quest_id: u32, +) -> Result<(bool, Option)> { + 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 deadline = std::time::Instant::now() + Duration::from_secs(3); + let mut last_status = None; + loop { + let row: Option<(u8,)> = conn + .exec_first( + "SELECT status FROM character_queststatus WHERE guid = ? AND quest = ?", + (bot.character_guid, quest_id), + ) + .map_err(|e| anyhow!("SELECT accepted quest status: {}", e))?; + if let Some((status,)) = row { + last_status = Some(status); + if status != 0 { + return Ok((true, last_status)); + } + } + + if std::time::Instant::now() >= deadline { + return Ok((false, last_status)); + } + std::thread::sleep(Duration::from_millis(100)); + } +} + +fn database_url(env_name: &str, conf_key: &str) -> Result { + if let Ok(value) = std::env::var(env_name) { + if !value.trim().is_empty() { + return Ok(value); + } + } + database_url_from_worldserver_conf(conf_key).map_err(|e| { + anyhow!( + "{} is not set and {} could not be read from worldserver config: {}", + env_name, + conf_key, + e + ) + }) +} + +fn database_url_from_worldserver_conf(conf_key: &str) -> Result { + let path = std::env::var("WOW_BOT_DB_CONF") + .unwrap_or_else(|_| "/home/server/trinity-legacy-install/etc/worldserver.conf".to_string()); + let contents = + std::fs::read_to_string(&path).map_err(|e| anyhow!("Read {} failed: {}", path, e))?; + for line in contents.lines() { + let trimmed = line.trim(); + if !trimmed.starts_with(conf_key) { + continue; + } + let value = trimmed + .split_once('=') + .map(|(_, v)| v.trim()) + .ok_or_else(|| anyhow!("Malformed {} line", conf_key))?; + let value = value.trim_matches('"'); + let mut parts = value.split(';'); + let host = parts.next().ok_or_else(|| anyhow!("Missing DB host"))?; + let port = parts.next().ok_or_else(|| anyhow!("Missing DB port"))?; + let user = parts.next().ok_or_else(|| anyhow!("Missing DB user"))?; + let password = parts.next().ok_or_else(|| anyhow!("Missing DB password"))?; + let database = parts.next().ok_or_else(|| anyhow!("Missing DB name"))?; + let mut url = String::from("mysql://"); + url.push_str(user); + url.push(':'); + url.push_str(password); + url.push('@'); + url.push_str(host); + url.push(':'); + url.push_str(port); + url.push('/'); + url.push_str(database); + return Ok(url); + } + bail!("{} not found in {}", conf_key, path) +} + +// ═════════════════════════════════════════════════════════════════════════════ +// Session key plumbing (DB sync for worldserver) +// ═════════════════════════════════════════════════════════════════════════════ + +/// Expand 32-byte K (from SRP6) to the 64-byte session_key_bnet expected by the +/// worldserver: K || SHA256(K). Mirrors main_srp6_complete.rs::expand_session_key. +fn expand_session_key(k: &[u8]) -> [u8; 64] { + use sha2::{Digest, Sha256}; + let mut out = [0u8; 64]; + out[..32].copy_from_slice(k); + out[32..].copy_from_slice(&Sha256::digest(k)); + out +} + +/// Resolve the WoW account for a battle.net email, write `K_64` into +/// account.session_key_bnet, and load the same build auth seed the worldserver +/// uses for CMSG_AUTH_SESSION verification. +/// +/// Uses synchronous mysql crate (one short transaction per bot); kept off the +/// runtime via spawn_blocking is unnecessary because run_bot is sequential. +fn prepare_world_auth_context( + email: &str, + session_key: &[u8], + realm_id: u32, +) -> Result { + use mysql::prelude::Queryable; + let db_url = auth_db_url()?; + let opts = mysql::Opts::from_url(&db_url).map_err(|e| anyhow!("Bad DB URL: {}", e))?; + let mut conn = + mysql::Conn::new(opts).map_err(|e| anyhow!("Connect to auth DB failed: {}", e))?; + + let username: Option = conn + .exec_first( + "SELECT a.username FROM account a \ + JOIN battlenet_accounts ba ON a.battlenet_account = ba.id \ + WHERE ba.email = ?", + (email,), + ) + .map_err(|e| anyhow!("Lookup username for {}: {}", email, e))?; + let username = username.ok_or_else(|| anyhow!("No account for email {}", email))?; + + conn.exec_drop( + "UPDATE account SET session_key_bnet = ? WHERE username = ?", + (session_key, &username), + ) + .map_err(|e| anyhow!("UPDATE session_key_bnet for {}: {}", username, e))?; + + let realm_build: Option<(u32,)> = conn + .exec_first("SELECT gamebuild FROM realmlist WHERE id = ?", (realm_id,)) + .map_err(|e| anyhow!("Lookup realmlist.gamebuild for realm {}: {}", realm_id, e))?; + let realm_build = realm_build + .map(|(build,)| build) + .ok_or_else(|| anyhow!("No realmlist row for realm {}", realm_id))?; + + let seed_row: Option<(Option,)> = conn + .exec_first( + "SELECT win64AuthSeed FROM build_info WHERE build = ?", + (realm_build,), + ) + .map_err(|e| { + anyhow!( + "Lookup build_info.win64AuthSeed for build {}: {}", + realm_build, + e + ) + })?; + let seed_hex = seed_row + .and_then(|(seed,)| seed) + .ok_or_else(|| anyhow!("No win64AuthSeed for build {}", realm_build))?; + let win64_auth_seed = parse_win64_auth_seed(&seed_hex, realm_build)?; + + Ok(WorldAuthDbContext { + username, + realm_build, + win64_auth_seed, + }) +} + +fn parse_win64_auth_seed(seed_hex: &str, build: u32) -> Result<[u8; 16]> { + if seed_hex.len() != 32 { + bail!( + "Invalid win64AuthSeed for build {}: expected 32 hex chars, got {}", + build, + seed_hex.len() + ); + } + + let mut seed = [0u8; 16]; + for (i, byte) in seed.iter_mut().enumerate() { + *byte = u8::from_str_radix(&seed_hex[i * 2..i * 2 + 2], 16) + .map_err(|e| anyhow!("Invalid win64AuthSeed hex for build {}: {}", build, e))?; + } + Ok(seed) +} + +// ═════════════════════════════════════════════════════════════════════════════ +// Packet I/O helpers +// ═════════════════════════════════════════════════════════════════════════════ + +/// Read unencrypted packet (18-byte header: size + tag placeholder + opcode) +async fn read_unencrypted_packet(stream: &mut TcpStream) -> Result<(u16, Vec)> { + let mut header = [0u8; 18]; + stream.read_exact(&mut header).await?; + + let size = u32::from_le_bytes([header[0], header[1], header[2], header[3]]) as usize; + if size == 0 || size > 0x10000 { + bail!("Invalid packet size: {}", size); + } + + let payload_size = size.saturating_sub(2); + let mut payload = vec![0u8; payload_size]; + if payload_size > 0 { + stream.read_exact(&mut payload).await?; + } + + let opcode = u16::from_le_bytes([header[16], header[17]]); + Ok((opcode, payload)) +} + +/// Send unencrypted packet +async fn send_unencrypted_packet(stream: &mut TcpStream, opcode: u16, data: &[u8]) -> Result<()> { + let mut packet = vec![0u8; 18]; + let size = (2 + data.len()) as u32; + packet[0..4].copy_from_slice(&size.to_le_bytes()); + // bytes 4-15 are zeros (tag placeholder for unencrypted phase) + packet[16..18].copy_from_slice(&opcode.to_le_bytes()); + + stream.write_all(&packet).await?; + if !data.is_empty() { + stream.write_all(data).await?; + } + stream.flush().await?; + Ok(()) +} + +/// Read encrypted packet (16-byte header: size + tag) +async fn read_encrypted_packet( + stream: &mut TcpStream, + crypt: &mut WorldCrypt, + server_inflater: &mut ServerPacketInflater, +) -> Result<(u16, Vec)> { + let mut header = [0u8; 16]; + stream.read_exact(&mut header).await?; + + let size = u32::from_le_bytes([header[0], header[1], header[2], header[3]]) as usize; + const MAX_ENCRYPTED_PACKET_SIZE: usize = 8 * 1024 * 1024; + if size == 0 || size > MAX_ENCRYPTED_PACKET_SIZE { + bail!("Invalid encrypted packet size: {}", size); + } + + let mut tag = [0u8; 12]; + tag.copy_from_slice(&header[4..16]); + + let mut ciphertext = vec![0u8; size]; + stream.read_exact(&mut ciphertext).await?; + + let plaintext = crypt + .decrypt_server(&ciphertext, &tag, &[]) + .map_err(|e| anyhow!("Decryption failed: {}", e))?; + if plaintext.len() < 2 { + bail!("Decrypted payload too short"); + } + + let opcode = u16::from_le_bytes([plaintext[0], plaintext[1]]); + let payload = plaintext[2..].to_vec(); + if opcode == SMSG_COMPRESSED_PACKET { + return decompress_server_packet_like_cpp(&payload, server_inflater); + } + Ok((opcode, payload)) +} + +fn decompress_server_packet_like_cpp( + payload: &[u8], + inflater: &mut ServerPacketInflater, +) -> Result<(u16, Vec)> { + if payload.len() < 12 { + bail!("Compressed packet too short: {} bytes", payload.len()); + } + let uncompressed_size = i32::from_le_bytes([payload[0], payload[1], payload[2], payload[3]]); + if uncompressed_size < 2 { + bail!("Invalid compressed packet size: {uncompressed_size}"); + } + let uncompressed_size = uncompressed_size as usize; + let deflated = &payload[12..]; + let mut output = vec![0u8; uncompressed_size + 256]; + let base_out = inflater.decompressor.total_out() as usize; + + inflater + .decompressor + .decompress(deflated, &mut output, FlushDecompress::Sync) + .map_err(|e| anyhow!("Compressed packet inflate failed: {e}"))?; + let produced = inflater.decompressor.total_out() as usize - base_out; + if produced != uncompressed_size { + bail!( + "Compressed packet size mismatch: expected {}, got {}", + uncompressed_size, + produced + ); + } + output.truncate(produced); + let opcode = u16::from_le_bytes([output[0], output[1]]); + Ok((opcode, output[2..].to_vec())) +} + +/// Send encrypted packet +async fn send_encrypted_packet( + stream: &mut TcpStream, + crypt: &mut WorldCrypt, + opcode: u16, + data: &[u8], +) -> Result<()> { + let mut plaintext = Vec::with_capacity(2 + data.len()); + plaintext.extend_from_slice(&opcode.to_le_bytes()); + plaintext.extend_from_slice(data); + + let (ciphertext, tag) = crypt + .encrypt_client(&plaintext, &[]) + .map_err(|e| anyhow!("Encryption failed: {}", e))?; + + // Build header: size + tag + encrypted_opcode_hint + let mut header = [0u8; 18]; + let size = ciphertext.len() as u32; + header[0..4].copy_from_slice(&size.to_le_bytes()); + header[4..16].copy_from_slice(&tag); + if ciphertext.len() >= 2 { + header[16..18].copy_from_slice(&ciphertext[0..2]); + } + + stream.write_all(&header).await?; + if ciphertext.len() > 2 { + stream.write_all(&ciphertext[2..]).await?; + } + stream.flush().await?; + Ok(()) +} + +// ═════════════════════════════════════════════════════════════════════════════ +// Auth & crypto helpers +// ═════════════════════════════════════════════════════════════════════════════ + +/// Compute the 24-byte auth digest used in CMSG_AUTH_SESSION +fn compute_auth_digest( + local: &[u8; 16], + server: &[u8; 16], + session_key: &[u8], + auth_seed: &[u8; 16], +) -> [u8; 24] { + use hmac::{Hmac, Mac}; + use sha2::{Digest, Sha256}; + + type HmacSha256 = Hmac; + + // SHA256(session_key || build_info.win64AuthSeed) + let mut hasher = Sha256::new(); + hasher.update(session_key); + hasher.update(auth_seed); + let digest_key = hasher.finalize(); + + // HMAC(digest_key, local || server || AUTH_CHECK_SEED) + let mut mac = HmacSha256::new_from_slice(&digest_key).unwrap(); + mac.update(local); + mac.update(server); + mac.update(&AUTH_CHECK_SEED); + let result = mac.finalize().into_bytes(); + + let mut digest = [0u8; 24]; + digest.copy_from_slice(&result[..24]); + digest +} + +/// Derive the 16-byte AES-GCM encryption key the same way TrinityCore's worldserver +/// does: HMAC the 64-byte session_key_bnet to a 32-byte seed, expand that seed to 40 +/// bytes through the SessionKeyGenerator cascade, then HMAC again for the final key. +/// Falling back to a "32 bytes || 8 zero bytes" shortcut (the previous implementation) +/// produced an HMAC key that disagreed with the server, so the very first server +/// packet failed AES-GCM tag verification. +fn derive_encryption_key(session_key: &[u8], local: &[u8; 16], server: &[u8; 16]) -> [u8; 16] { + let session_key_40 = derive_realm_session_key(session_key, local, server); + srp6_auth::calculate_encrypt_key(&session_key_40, local, server) +} + +fn derive_realm_session_key(session_key: &[u8], local: &[u8; 16], server: &[u8; 16]) -> [u8; 40] { + let key_data: [u8; 64] = session_key + .try_into() + .expect("session_key must be 64 bytes"); + srp6_auth::calculate_session_key(&key_data, server, local) +} + +fn compute_continued_auth_digest( + key: i64, + local: &[u8; 16], + server: &[u8; 16], + session_key: &[u8], +) -> [u8; 24] { + use hmac::{Hmac, Mac}; + + type HmacSha256 = Hmac; + + let mut mac = HmacSha256::new_from_slice(session_key).unwrap(); + mac.update(&key.to_le_bytes()); + mac.update(local); + mac.update(server); + mac.update(&CONTINUED_SESSION_SEED); + let result = mac.finalize().into_bytes(); + + let mut digest = [0u8; 24]; + digest.copy_from_slice(&result[..24]); + digest +} + +fn derive_instance_encryption_key( + session_key: &[u8], + local: &[u8; 16], + server: &[u8; 16], +) -> [u8; 16] { + use hmac::{Hmac, Mac}; + + type HmacSha256 = Hmac; + + let mut mac = HmacSha256::new_from_slice(session_key).unwrap(); + mac.update(local); + mac.update(server); + mac.update(&ENCRYPTION_KEY_SEED); + let result = mac.finalize().into_bytes(); + + let mut key = [0u8; 16]; + key.copy_from_slice(&result[..16]); + key +} + +fn build_cmsg_auth_continued_session( + connect_to_key: i64, + local: &[u8; 16], + digest: &[u8; 24], +) -> Vec { + let mut data = Vec::with_capacity(56); + data.extend_from_slice(&0i64.to_le_bytes()); + data.extend_from_slice(&connect_to_key.to_le_bytes()); + data.extend_from_slice(local); + data.extend_from_slice(digest); + data +} + +/// Build CMSG_AUTH_SESSION packet data +fn build_cmsg_auth_session( + realm_id: u32, + local: &[u8; 16], + digest: &[u8; 24], + ticket: &str, +) -> Vec { + let mut data = Vec::with_capacity(128); + + // dos_response (u64) + data.extend_from_slice(&0u64.to_le_bytes()); + // region_id (u32) + data.extend_from_slice(&0u32.to_le_bytes()); + // battlegroup_id (u32) + data.extend_from_slice(&0u32.to_le_bytes()); + // realm_id (u32) + data.extend_from_slice(&realm_id.to_le_bytes()); + // local_challenge (16 bytes) + data.extend_from_slice(local); + // digest (24 bytes) + data.extend_from_slice(digest); + // UseIPv6 flag (1 byte) + data.push(0x00); + // ticket (string with length prefix) + let ticket_bytes = ticket.as_bytes(); + data.extend_from_slice(&(ticket_bytes.len() as u32).to_le_bytes()); + data.extend_from_slice(ticket_bytes); + + data +} + +/// Build player login data (packed GUID + farClip) +fn build_player_login(guid: u64, realm_id: u32, far_clip: f32) -> Vec { + let (low_mask, low_bytes) = pack_u64(guid); + + // High part: (HighGuid::Player << 58) | (realmId << 42) + let high = (2u64 << 58) | ((u64::from(realm_id) & 0x1FFF) << 42); + let (high_mask, high_bytes) = pack_u64(high); + + let mut data = Vec::with_capacity(2 + low_bytes.len() + high_bytes.len() + 4); + data.push(low_mask); + data.push(high_mask); + data.extend_from_slice(&low_bytes); + data.extend_from_slice(&high_bytes); + data.extend_from_slice(&far_clip.to_le_bytes()); + + data +} + +fn resolve_quest_runtime_counter( + runtime_counter: Option, + spawn_guid: u64, + entry: u32, +) -> Result { + runtime_counter.ok_or_else(|| { + anyhow!( + "Quest smoke for entry {entry} resolved world.creature guid {spawn_guid}, but needs the live ObjectGuid low counter. Set WOW_BOT_QUEST_RUNTIME_COUNTER or pass --quest-runtime-counter." + ) + }) +} + +fn create_creature_guid_raw(map_id: u16, entry: u32, counter: u64) -> (u64, u64) { + let high = (8u64 << 58) + | ((map_id as u64 & 0x1FFF) << 29) + | ((entry as u64 & 0x7F_FFFF) << 6); + let low = counter & 0xFF_FFFF_FFFF; + (low, high) +} + +fn build_packed_guid(low: u64, high: u64) -> Vec { + let (low_mask, low_bytes) = pack_u64(low); + let (high_mask, high_bytes) = pack_u64(high); + let mut data = Vec::with_capacity(2 + low_bytes.len() + high_bytes.len()); + data.push(low_mask); + data.push(high_mask); + data.extend_from_slice(&low_bytes); + data.extend_from_slice(&high_bytes); + data +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn player_login_guid_uses_configured_realm() { + let guid = 0x1234; + let realm_id = 7; + let far_clip = 500.0f32; + let mut expected = Vec::new(); + let (low_mask, low_bytes) = pack_u64(guid); + let high = (2u64 << 58) | ((u64::from(realm_id) & 0x1FFF) << 42); + let (high_mask, high_bytes) = pack_u64(high); + expected.push(low_mask); + expected.push(high_mask); + expected.extend_from_slice(&low_bytes); + expected.extend_from_slice(&high_bytes); + expected.extend_from_slice(&far_clip.to_le_bytes()); + + assert_eq!(build_player_login(guid, realm_id, far_clip), expected); + } + + #[test] + fn quest_smoke_requires_live_runtime_counter() { + let error = resolve_quest_runtime_counter(None, 12_345, 15_513).unwrap_err(); + + assert!(error.to_string().contains("WOW_BOT_QUEST_RUNTIME_COUNTER")); + } + + #[test] + fn creature_guid_uses_runtime_counter_and_zero_realm_like_cpp() { + let (low, high) = create_creature_guid_raw(571, 15_513, 77_001); + + assert_eq!(low, 77_001); + assert_eq!((high >> 58) & 0x3F, 8); + assert_eq!((high >> 42) & 0x1FFF, 0); + assert_eq!((high >> 29) & 0x1FFF, 571); + assert_eq!((high >> 6) & 0x7F_FFFF, 15_513); + } +} + +fn build_quest_giver_query_quest(packed_guid: &[u8], quest_id: u32) -> Vec { + let mut data = Vec::with_capacity(packed_guid.len() + 5); + data.extend_from_slice(packed_guid); + data.extend_from_slice(&quest_id.to_le_bytes()); + data.push(0x80); // RespondToGiver=true, MSB-first WriteBit/ReadBit. + data +} + +fn build_quest_giver_accept_quest(packed_guid: &[u8], quest_id: u32) -> Vec { + let mut data = Vec::with_capacity(packed_guid.len() + 5); + data.extend_from_slice(packed_guid); + data.extend_from_slice(&quest_id.to_le_bytes()); + data.push(0x00); // StartCheat=false, padded to one bit byte. + data +} + +fn build_gossip_select_option( + packed_guid: &[u8], + gossip_id: i32, + gossip_option_id: i32, +) -> Vec { + let mut data = Vec::with_capacity(packed_guid.len() + 9); + data.extend_from_slice(packed_guid); + data.extend_from_slice(&gossip_id.to_le_bytes()); + data.extend_from_slice(&gossip_option_id.to_le_bytes()); + data.push(0x00); // PromotionCode length = 0, written as 8 MSB-first bits. + data +} + +fn parse_connect_to(payload: &[u8]) -> Option { + // RustyCore ConnectTo payload: + // signature[256], address_type u8, address (4/16), port u16, serial u32, + // connection_type u8, key i64. + let address_type = *payload.get(256)?; + let address_offset = 257; + let (address, address_len) = match address_type { + 1 => { + let bytes: [u8; 4] = payload + .get(address_offset..address_offset + 4)? + .try_into() + .ok()?; + (IpAddr::from(bytes), 4) + } + 2 => { + let bytes: [u8; 16] = payload + .get(address_offset..address_offset + 16)? + .try_into() + .ok()?; + (IpAddr::from(bytes), 16) + } + _ => return None, + }; + let serial_offset = 256 + 1 + address_len + 2; + let port_offset = 256 + 1 + address_len; + let port = u16::from_le_bytes(payload.get(port_offset..port_offset + 2)?.try_into().ok()?); + let serial = u32::from_le_bytes( + payload + .get(serial_offset..serial_offset + 4)? + .try_into() + .ok()?, + ); + let connection_type_offset = serial_offset + 4; + let connection_type = *payload.get(connection_type_offset)?; + let key_offset = connection_type_offset + 1; + let key = i64::from_le_bytes(payload.get(key_offset..key_offset + 8)?.try_into().ok()?); + + Some(ConnectToTarget { + address, + port, + serial, + connection_type, + key, + }) +} + +async fn connect_to_instance( + bot_index: usize, + connect_to: &ConnectToTarget, + session_key: &[u8], +) -> Result<(TcpStream, WorldCrypt)> { + if connect_to.connection_type != 1 { + bail!( + "SMSG_CONNECT_TO requested unsupported connection type {}", + connect_to.connection_type + ); + } + + let target_host = + std::env::var("INSTANCE_HOST").unwrap_or_else(|_| connect_to.address.to_string()); + let addr = format!("{}:{}", target_host, connect_to.port); + info!( + "[Bot {}] Connecting to instance socket {}...", + bot_index, addr + ); + let mut stream = TcpStream::connect(&addr) + .await + .map_err(|e| anyhow!("Failed to connect to instance socket {}: {}", addr, e))?; + + let mut init_buf = vec![0u8; 256]; + let n = stream.read(&mut init_buf).await?; + if !init_buf[..n].starts_with(&SERVER_INIT[..SERVER_INIT.len().min(n)]) { + bail!( + "Unexpected instance server init: {:?}", + String::from_utf8_lossy(&init_buf[..n]) + ); + } + + stream.write_all(CLIENT_INIT).await?; + stream.flush().await?; + + let (opcode, challenge_data) = read_unencrypted_packet(&mut stream).await?; + if opcode != 0x3048 { + bail!( + "Expected instance SMSG_AUTH_CHALLENGE (0x3048), got 0x{:04X}", + opcode + ); + } + if challenge_data.len() < 48 { + bail!( + "Instance SMSG_AUTH_CHALLENGE too short: {} bytes", + challenge_data.len() + ); + } + + let server_challenge: [u8; 16] = challenge_data[32..48].try_into()?; + let local_challenge: [u8; 16] = rand::random(); + let digest = compute_continued_auth_digest( + connect_to.key, + &local_challenge, + &server_challenge, + session_key, + ); + let auth_data = build_cmsg_auth_continued_session(connect_to.key, &local_challenge, &digest); + send_unencrypted_packet(&mut stream, 0x3766, &auth_data).await?; + + let mut got_encryption = false; + for _ in 0..10 { + let (op, _payload) = + tokio::time::timeout(Duration::from_secs(5), read_unencrypted_packet(&mut stream)) + .await + .map_err(|_| anyhow!("Timeout waiting for instance encrypted mode"))??; + + if op == 0x3049 { + got_encryption = true; + break; + } + + info!( + "[Bot {}] Instance pre-encryption packet 0x{:04X}", + bot_index, op + ); + } + + if !got_encryption { + bail!("Instance socket did not send SMSG_ENTER_ENCRYPTED_MODE"); + } + + let enc_key = derive_instance_encryption_key(session_key, &local_challenge, &server_challenge); + send_unencrypted_packet(&mut stream, 0x3767, &[]).await?; + + Ok((stream, WorldCrypt::new_with_counters(&enc_key, 2, 2))) +} + +/// Pack u64 into WoW's packed format (mask + non-zero bytes) +fn pack_u64(value: u64) -> (u8, Vec) { + let mut mask = 0u8; + let mut bytes = Vec::new(); + + for i in 0..8 { + let b = (value >> (i * 8)) as u8; + if b != 0 { + mask |= 1 << i; + bytes.push(b); + } + } + + (mask, bytes) +} + +/// Echo the Ticket+InstanceID+ProposalID prefix from a SMSG_LFG_PROPOSAL_UPDATE +/// payload back into a CMSG_DF_PROPOSAL_RESPONSE body, with `Accepted=true`. +/// +/// SMSG_LFG_PROPOSAL_UPDATE layout (LFGPackets.cpp:375): +/// Ticket { +/// ObjectGuid RequesterGuid // 1 lowMask + 1 highMask + popcnt(low)+popcnt(high) bytes +/// uint32 Id, uint32 Type, uint64 Time +/// bit Unknown925, FlushBits // 1 byte +/// } +/// uint64 InstanceID, uint32 ProposalID, ... rest we ignore +/// +/// CMSG_DF_PROPOSAL_RESPONSE just wants the same Ticket+InstanceID+ProposalID +/// prefix plus a single Accepted bit. +fn build_proposal_response(payload: &[u8]) -> Option> { + if payload.len() < 2 { + return None; + } + let low_mask = payload[0]; + let high_mask = payload[1]; + let guid_len = 2 + (low_mask.count_ones() + high_mask.count_ones()) as usize; + let prefix_len = guid_len + + 4 // Id + + 4 // Type + + 8 // Time + + 1 // bit byte (Unknown925 + flush) + + 8 // InstanceID + + 4; // ProposalID + if payload.len() < prefix_len { + return None; + } + let mut response = Vec::with_capacity(prefix_len + 1); + response.extend_from_slice(&payload[..prefix_len]); + response.push(0x80); // Accepted=true bit, MSB-first per Trinity's WriteBit + Some(response) +} + +/// Build CMSG_DF_JOIN body — layout matches main_srp6_complete.rs::build_lfg_join_packet, +/// which is what the 3.4.3 server's DF_JOIN handler parses. +/// +/// bits[8]: QueueAsGroup | hasPartyIndex | Unknown | 5b padding → all zero +/// u8: Roles bitmask (2=Tank, 4=Healer, 8=DPS) +/// u32 LE: NumDungeons (always 1) +/// u32 LE: DungeonID +fn build_lfg_join(dungeon_id: u32, roles: u8) -> Vec { + let mut data = Vec::with_capacity(10); + data.push(0x00); + data.push(roles); + data.extend_from_slice(&1u32.to_le_bytes()); + data.extend_from_slice(&dungeon_id.to_le_bytes()); + data +} diff --git a/tools/wow-test-bot/src/packet_parser.rs b/tools/wow-test-bot/src/packet_parser.rs new file mode 100644 index 00000000..28aec583 --- /dev/null +++ b/tools/wow-test-bot/src/packet_parser.rs @@ -0,0 +1,647 @@ +//! Packet parsers for SMSG opcodes +//! Decodes server-to-client packets into structured data + +use tracing::debug; + +/// Parsed SMSG_LFG_JOIN_RESULT +#[derive(Debug, Clone)] +pub struct LfgJoinResult { + pub result: u8, + pub result_detail: u8, + pub queue_id: u32, + pub wait_time: u32, + pub queue_length: u32, +} + +/// Parsed SMSG_LFG_PLAYER_INFO +#[derive(Debug, Clone)] +pub struct LfgPlayerInfo { + pub queue_id: u32, + pub dungeon_id: u32, + pub status: u8, +} + +#[derive(Debug, Clone)] +pub struct LfgQueueStatus { + pub queue_id: u32, + pub dungeon_id: u32, + pub queued_time: u32, + pub wait_time_avg: u32, + pub wait_time_tank: u32, + pub wait_time_healer: u32, + pub wait_time_dps: u32, + pub tanks_needed: u8, + pub healers_needed: u8, + pub dps_needed: u8, +} + +#[derive(Debug, Clone)] +pub struct LfgUpdateStatus { + pub dungeon_id: u32, + pub status: u8, + pub reason: u8, + pub slots_count: usize, + pub suspended_players_count: usize, +} + +/// Parsed SMSG_AUTH_RESPONSE +#[derive(Debug, Clone)] +pub struct AuthResponse { + pub result: u8, + pub has_account_data: bool, +} + +#[derive(Debug, Clone)] +pub struct QuestOffer { + pub quest_id: u32, + pub title: String, +} + +#[derive(Debug, Clone)] +pub struct QuestDetailsSummary { + pub quest_id: u32, +} + +#[derive(Debug, Clone)] +pub struct QuestRequestItemsSummary { + pub quest_id: u32, + pub status_flags: u32, +} + +#[derive(Debug, Clone)] +pub struct TrainerListSummary { + pub trainer_id: i32, + pub spell_count: u32, +} + +/// Decode a TC 9.x packed ObjectGuid (`u8 lowMask | u8 highMask | `). +/// Returns the number of bytes consumed plus the low/high u64 components. +pub fn parse_packed_guid(data: &[u8]) -> Option<(usize, u64, u64)> { + if data.len() < 2 { + return None; + } + let low_mask = data[0]; + let high_mask = data[1]; + let mut pos = 2usize; + let mut low: u64 = 0; + let mut high: u64 = 0; + for i in 0..8 { + if low_mask & (1 << i) != 0 { + if pos >= data.len() { + return None; + } + low |= (data[pos] as u64) << (i * 8); + pos += 1; + } + } + for i in 0..8 { + if high_mask & (1 << i) != 0 { + if pos >= data.len() { + return None; + } + high |= (data[pos] as u64) << (i * 8); + pos += 1; + } + } + Some((pos, low, high)) +} + +fn read_u32(data: &[u8], pos: usize) -> Option { + if pos + 4 > data.len() { + return None; + } + Some(u32::from_le_bytes([ + data[pos], + data[pos + 1], + data[pos + 2], + data[pos + 3], + ])) +} + +fn read_i32(data: &[u8], pos: usize) -> Option { + read_u32(data, pos).map(|v| v as i32) +} + +pub fn parse_gossip_id(data: &[u8]) -> Option { + let (pos, _low, _high) = parse_packed_guid(data)?; + read_i32(data, pos) +} + +struct BitReader<'a> { + data: &'a [u8], + pos: usize, + bit_pos: u8, + bit_buf: u8, +} + +impl<'a> BitReader<'a> { + fn new(data: &'a [u8], pos: usize) -> Self { + Self { + data, + pos, + bit_pos: 0, + bit_buf: 0, + } + } + + fn read_bit(&mut self) -> Option { + if self.bit_pos == 0 { + self.bit_buf = *self.data.get(self.pos)?; + self.pos += 1; + self.bit_pos = 8; + } + self.bit_pos -= 1; + Some((self.bit_buf >> self.bit_pos) & 1 == 1) + } + + fn read_bits(&mut self, n: u8) -> Option { + let mut value = 0u32; + for _ in 0..n { + value = (value << 1) | u32::from(self.read_bit()?); + } + Some(value) + } + + fn flushed_pos(self) -> usize { + self.pos + } +} + +fn parse_quest_offer(data: &[u8], mut pos: usize) -> Option<(usize, QuestOffer)> { + let quest_id = read_u32(data, pos)?; + pos += 4; + pos += 4; // ContentTuningID + pos += 4; // QuestType + pos += 4; // QuestLevel + pos += 4; // QuestMaxScalingLevel + pos += 4; // QuestFlags + pos += 4; // QuestFlagsEx + if pos > data.len() { + return None; + } + + let mut bits = BitReader::new(data, pos); + bits.read_bit()?; // Repeatable + bits.read_bit()?; // Important + let title_len = bits.read_bits(9)? as usize; + pos = bits.flushed_pos(); + + let title_bytes = data.get(pos..pos + title_len)?; + pos += title_len; + let title = String::from_utf8_lossy(title_bytes).to_string(); + Some((pos, QuestOffer { quest_id, title })) +} + +pub fn parse_gossip_quest_offers(data: &[u8]) -> Option> { + let (mut pos, _low, _high) = parse_packed_guid(data)?; + pos += 4; // GossipID + pos += 4; // FriendshipFactionID + let option_count = usize::try_from(read_i32(data, pos)?).ok()?; + pos += 4; + let quest_count = usize::try_from(read_i32(data, pos)?).ok()?; + pos += 4; + if pos > data.len() { + return None; + } + + let mut bits = BitReader::new(data, pos); + let has_text_id = bits.read_bit()?; + let has_broadcast_text_id = bits.read_bit()?; + pos = bits.flushed_pos(); + + for _ in 0..option_count { + pos += 4; // GossipOptionID + pos += 1; // OptionNpc + pos += 1; // OptionFlags + pos += 4; // OptionCost + pos += 4; // OptionLanguage + pos += 4; // Flags + pos += 4; // OrderIndex + if pos > data.len() { + return None; + } + + let mut option_bits = BitReader::new(data, pos); + let text_len = option_bits.read_bits(12)? as usize; + let confirm_len = option_bits.read_bits(12)? as usize; + option_bits.read_bits(2)?; // Status + let has_spell_id = option_bits.read_bit()?; + let has_override_icon_id = option_bits.read_bit()?; + pos = option_bits.flushed_pos(); + + let items_count = usize::try_from(read_i32(data, pos)?).ok()?; + pos += 4; + pos += items_count.saturating_mul(4); // Current Rust writer only emits zero items. + pos += text_len; + pos += confirm_len; + if has_spell_id { + pos += 4; + } + if has_override_icon_id { + pos += 4; + } + if pos > data.len() { + return None; + } + } + + if has_text_id { + pos += 4; + } + if has_broadcast_text_id { + pos += 4; + } + if pos > data.len() { + return None; + } + + let mut quests = Vec::with_capacity(quest_count); + for _ in 0..quest_count { + let (next, quest) = parse_quest_offer(data, pos)?; + quests.push(quest); + pos = next; + } + Some(quests) +} + +pub fn parse_quest_list_offers(data: &[u8]) -> Option> { + let (mut pos, _low, _high) = parse_packed_guid(data)?; + pos += 4; // GreetEmoteDelay + pos += 4; // GreetEmoteType + let quest_count = read_u32(data, pos)? as usize; + pos += 4; + if pos > data.len() { + return None; + } + + let mut bits = BitReader::new(data, pos); + let greeting_len = bits.read_bits(11)? as usize; + pos = bits.flushed_pos(); + + let mut quests = Vec::with_capacity(quest_count); + for _ in 0..quest_count { + let (next, quest) = parse_quest_offer(data, pos)?; + quests.push(quest); + pos = next; + } + data.get(pos..pos + greeting_len)?; + Some(quests) +} + +pub fn parse_quest_details_summary(data: &[u8]) -> Option { + let (mut pos, _low, _high) = parse_packed_guid(data)?; + let (inform_guid_len, _low, _high) = parse_packed_guid(data.get(pos..)?)?; + pos += inform_guid_len; + let quest_id = read_u32(data, pos)?; + Some(QuestDetailsSummary { quest_id }) +} + +pub fn parse_quest_request_items_summary(data: &[u8]) -> Option { + let (mut pos, _low, _high) = parse_packed_guid(data)?; + pos += 4; // GiverCreatureID + let quest_id = read_u32(data, pos)?; + pos += 4; + pos += 4; // CompEmoteDelay + pos += 4; // CompEmoteType + pos += 12; // QuestFlags[3] + pos += 4; // SuggestedPartyMembers + pos += 4; // MoneyToGet + let collect_count = read_i32(data, pos)?.max(0) as usize; + pos += 4; + let currency_count = read_i32(data, pos)?.max(0) as usize; + pos += 4; + let status_flags = read_u32(data, pos)?; + pos += 4; + pos += collect_count.checked_mul(12)?; + pos += currency_count.checked_mul(8)?; + data.get(pos)?; + Some(QuestRequestItemsSummary { + quest_id, + status_flags, + }) +} + +pub fn parse_trainer_list_summary(data: &[u8]) -> Option { + let (mut pos, _low, _high) = parse_packed_guid(data)?; + pos += 4; // TrainerType + let trainer_id = read_i32(data, pos)?; + pos += 4; + let spell_count_i32 = read_i32(data, pos)?; + let spell_count = u32::try_from(spell_count_i32).ok()?; + Some(TrainerListSummary { + trainer_id, + spell_count, + }) +} + +fn skip_ride_ticket(data: &[u8]) -> Option { + let (mut pos, _guid_low, _guid_high) = parse_packed_guid(data)?; + if pos + 4 + 4 + 8 + 1 > data.len() { + return None; + } + pos += 4; // Id + pos += 4; // Type + pos += 8; // Time + pos += 1; // Unknown925 bit + FlushBits + Some(pos) +} + +pub fn parse_lfg_queue_status(data: &[u8]) -> Option { + let mut pos = skip_ride_ticket(data)?; + if pos + 31 > data.len() { + return None; + } + let queue_id = read_u32(data, pos)?; + pos += 4; + let dungeon_id = read_u32(data, pos)?; + pos += 4; + let wait_time_avg = read_u32(data, pos)?; + pos += 4; + let wait_time_tank = read_u32(data, pos)?; + pos += 4; + let tanks_needed = data[pos]; + pos += 1; + let wait_time_healer = read_u32(data, pos)?; + pos += 4; + let healers_needed = data[pos]; + pos += 1; + let wait_time_dps = read_u32(data, pos)?; + pos += 4; + let dps_needed = data[pos]; + pos += 1; + let queued_time = read_u32(data, pos)?; + Some(LfgQueueStatus { + queue_id, + dungeon_id, + queued_time, + wait_time_avg, + wait_time_tank, + wait_time_healer, + wait_time_dps, + tanks_needed, + healers_needed, + dps_needed, + }) +} + +pub fn parse_lfg_update_status(data: &[u8]) -> Option { + let mut pos = skip_ride_ticket(data)?; + if pos + 14 > data.len() { + return None; + } + let status = data[pos]; + pos += 1; + let reason = data[pos]; + pos += 1; + let slots_count = read_u32(data, pos)? as usize; + pos += 4; + let _requested_roles = data[pos]; + pos += 1; + let suspended_count = read_u32(data, pos)? as usize; + pos += 4; + let dungeon_id = read_u32(data, pos)?; + pos += 4; + if pos + slots_count.saturating_mul(4) > data.len() { + return None; + } + Some(LfgUpdateStatus { + dungeon_id, + status, + reason, + slots_count, + suspended_players_count: suspended_count, + }) +} + +/// Parse SMSG_LFG_JOIN_RESULT (opcode 0x2A1C). +/// +/// Wire layout (TrinityCore `WorldPackets::LFG::LFGJoinResult::Write`): +/// RideTicket { +/// PackedGuid RequesterGuid, // 2 mask bytes + variable data +/// uint32 Id, +/// uint32 Type, +/// int32 Time, +/// bit Unknown925, // followed by FlushBits → 1 byte +/// } +/// uint8 Result, +/// uint8 ResultDetail, +/// uint32 BlackList.size(), +/// uint32 BlackListNames.size(), +/// ... +pub fn parse_lfg_join_result(data: &[u8]) -> Option { + let (mut pos, _guid_low, _guid_high) = parse_packed_guid(data)?; + // RideTicket.Id, .Type, .Time, then a 1-bit Unknown925 padded to a full byte by FlushBits. + if pos + 4 + 4 + 4 + 1 + 1 + 1 > data.len() { + return None; + } + let queue_id = u32::from_le_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]); + pos += 4; + pos += 4; // RideType (uint32) + let wait_time = u32::from_le_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]); + pos += 4; + pos += 1; // Unknown925 bit + FlushBits + + let result = data[pos]; + let result_detail = data[pos + 1]; + + debug!( + "Parsed SMSG_LFG_JOIN_RESULT: result={}, detail={}, queue_id={}, time={}", + result, result_detail, queue_id, wait_time + ); + + Some(LfgJoinResult { + result, + result_detail, + queue_id, + wait_time, + queue_length: 0, + }) +} + +/// Parse SMSG_AUTH_RESPONSE (opcode 0x5AB6) +pub fn parse_auth_response(data: &[u8]) -> Option { + if data.is_empty() { + return None; + } + + let result = data[0]; + let has_account_data = data.len() > 1; + + Some(AuthResponse { + result, + has_account_data, + }) +} + +/// Get human-readable LFG result string +pub fn lfg_result_to_string(result: u8) -> &'static str { + match result { + 0 => "OK", + 1 => "ROLE_CHECK_FAILED", + 2 => "GROUP_NOT_FULL", + 3 => "ALREADY_IN_GROUP", + 4 => "ALREADY_IN_LFG", + 5 => "INTERNAL_ERROR", + _ => "UNKNOWN", + } +} + +/// Get human-readable LFG detail string +pub fn lfg_detail_to_string(detail: u8) -> &'static str { + match detail { + 0 => "NONE", + 1 => "GROUP_TOO_SMALL", + 2 => "GROUP_TOO_LARGE", + 3 => "ROLE_CHECK_FAILED", + 4 => "INSUFFICIENT_EXPANSION", + 5 => "LEVEL_TOO_LOW", + _ => "UNKNOWN", + } +} + +pub fn teleport_denied_to_string(reason: u8) -> &'static str { + match reason { + 0 => "NONE", + 1 => "DEAD", + 2 => "FALLING", + 4 => "FATIGUE", + 6 => "NO_RETURN_LOCATION", + _ => "UNKNOWN", + } +} + +/// Parse packet based on opcode +pub fn parse_packet(opcode: u16, data: &[u8]) -> String { + match opcode { + 0x2A1C => { + if let Some(result) = parse_lfg_join_result(data) { + format!( + "SMSG_LFG_JOIN_RESULT: {} ({}), detail: {} ({}), queue_id={}, wait={}s", + result.result, + lfg_result_to_string(result.result), + result.result_detail, + lfg_detail_to_string(result.result_detail), + result.queue_id, + result.wait_time + ) + } else { + format!("SMSG_LFG_JOIN_RESULT: ", data.len()) + } + } + 0x5AB6 => { + if let Some(auth) = parse_auth_response(data) { + format!( + "SMSG_AUTH_RESPONSE: result={}, has_data={}", + auth.result, auth.has_account_data + ) + } else { + format!("SMSG_AUTH_RESPONSE: ", data.len()) + } + } + 0x5AED => "SMSG_ENTER_ENCRYPTED_MODE".to_string(), + 0x3048 => "SMSG_AUTH_CHALLENGE".to_string(), + // Opcodes verified against src/server/game/Server/Protocol/Opcodes.h. + // The previous 0x2A24 → SMSG_LFG_PROPOSAL_UPDATE mapping was wrong: + // 0x2A24 = SMSG_LFG_UPDATE_STATUS, 0x2A2D = SMSG_LFG_PROPOSAL_UPDATE. + 0x2A2D => format!("SMSG_LFG_PROPOSAL_UPDATE ({} bytes)", data.len()), + 0x2A24 => { + if let Some(status) = parse_lfg_update_status(data) { + format!( + "SMSG_LFG_UPDATE_STATUS: dungeon_id={}, status={}, reason={}, slots={}, suspended_players={}", + status.dungeon_id, + status.status, + status.reason, + status.slots_count, + status.suspended_players_count + ) + } else { + format!("SMSG_LFG_UPDATE_STATUS ({} bytes)", data.len()) + } + } + 0x2A20 => { + if let Some(status) = parse_lfg_queue_status(data) { + format!("SMSG_LFG_QUEUE_STATUS: queue_id={}, dungeon_id={}, queued={}s, wait_avg={}s, wait_tank={}s, wait_healer={}s, wait_dps={}s, needs={}/{}/{}", + status.queue_id, status.dungeon_id, status.queued_time, status.wait_time_avg, + status.wait_time_tank, status.wait_time_healer, status.wait_time_dps, + status.tanks_needed, status.healers_needed, status.dps_needed) + } else { + format!("SMSG_LFG_QUEUE_STATUS ({} bytes)", data.len()) + } + } + 0x2A22 => format!("SMSG_LFG_READY_CHECK_UPDATE ({} bytes)", data.len()), + 0x2A36 => format!("SMSG_LFG_PARTY_INFO ({} bytes)", data.len()), + 0x2A37 => format!("SMSG_LFG_PLAYER_INFO ({} bytes)", data.len()), + 0x2A38 => format!("SMSG_LFG_PLAYER_REWARD ({} bytes)", data.len()), + 0x2A35 => format!("SMSG_LFG_BOOT_PLAYER ({} bytes)", data.len()), + 0x2A3A => format!("SMSG_LFG_READY_CHECK_RESULT ({} bytes)", data.len()), + 0x2683 => format!("SMSG_LOGOUT_RESPONSE ({} bytes)", data.len()), + 0x2684 => "SMSG_LOGOUT_COMPLETE".to_string(), + 0x2A32 => { + let raw = data.first().copied().unwrap_or(0); + let reason = raw >> 4; + format!( + "SMSG_LFG_TELEPORT_DENIED: reason={} ({}) raw=0x{:02X}", + reason, + teleport_denied_to_string(reason), + raw + ) + } + 0x2A91 => format!("SMSG_QUEST_GIVER_STATUS_MULTIPLE ({} bytes)", data.len()), + 0x2A83 => { + if data.len() >= 4 { + let quest_id = u32::from_le_bytes(data[0..4].try_into().ok().unwrap_or([0; 4])); + format!("SMSG_QUEST_GIVER_QUEST_COMPLETE: quest_id={}", quest_id) + } else { + format!("SMSG_QUEST_GIVER_QUEST_COMPLETE ({} bytes)", data.len()) + } + } + 0x2A92 => { + if let Some(details) = parse_quest_details_summary(data) { + format!( + "SMSG_QUEST_GIVER_QUEST_DETAILS: quest_id={}", + details.quest_id + ) + } else { + format!("SMSG_QUEST_GIVER_QUEST_DETAILS ({} bytes)", data.len()) + } + } + 0x2A93 => { + if let Some(summary) = parse_quest_request_items_summary(data) { + format!( + "SMSG_QUEST_GIVER_REQUEST_ITEMS: quest_id={} status_flags=0x{:X}", + summary.quest_id, summary.status_flags + ) + } else { + format!("SMSG_QUEST_GIVER_REQUEST_ITEMS ({} bytes)", data.len()) + } + } + 0x2A98 => { + if let Some(offers) = parse_gossip_quest_offers(data) { + let ids: Vec = offers.iter().map(|offer| offer.quest_id).collect(); + format!("SMSG_GOSSIP_MESSAGE: quests={:?}", ids) + } else { + format!("SMSG_GOSSIP_MESSAGE ({} bytes)", data.len()) + } + } + 0x2A9A => { + if let Some(offers) = parse_quest_list_offers(data) { + let ids: Vec = offers.iter().map(|offer| offer.quest_id).collect(); + format!("SMSG_QUEST_GIVER_QUEST_LIST: quests={:?}", ids) + } else { + format!("SMSG_QUEST_GIVER_QUEST_LIST ({} bytes)", data.len()) + } + } + 0x2A9B => format!("SMSG_QUEST_GIVER_STATUS ({} bytes)", data.len()), + 0x26DF => { + if let Some(summary) = parse_trainer_list_summary(data) { + format!( + "SMSG_TRAINER_LIST: trainer_id={} spells={}", + summary.trainer_id, summary.spell_count + ) + } else { + format!("SMSG_TRAINER_LIST ({} bytes)", data.len()) + } + } + _ => format!("Unknown opcode 0x{:04X} ({} bytes)", opcode, data.len()), + } +} diff --git a/tools/wow-test-bot/src/protocol.rs b/tools/wow-test-bot/src/protocol.rs new file mode 100644 index 00000000..6b162157 --- /dev/null +++ b/tools/wow-test-bot/src/protocol.rs @@ -0,0 +1,43 @@ +//! Protocol constants and packet framing for 3.4.3. + +/// Server → Client init string. +pub const SERVER_INIT: &[u8] = b"WORLD OF WARCRAFT CONNECTION - SERVER TO CLIENT - V2\n"; +/// Client → Server init string. +pub const CLIENT_INIT: &[u8] = b"WORLD OF WARCRAFT CONNECTION - CLIENT TO SERVER - V2\n"; + +/// Packet header: Size(4) + Tag(12). +pub const HEADER_SIZE: usize = 16; +/// Tag size for AES-GCM. +pub const TAG_SIZE: usize = 12; + +// Static seeds from TrinityCore WorldSocket.cpp +pub const AUTH_CHECK_SEED: [u8; 16] = [ + 0xC5, 0xC6, 0x98, 0x95, 0x76, 0x3F, 0x1D, 0xCD, 0xB6, 0xA1, 0x37, 0x28, 0xB3, 0x12, 0xFF, 0x8A, +]; + +pub const SESSION_KEY_SEED: [u8; 16] = [ + 0x58, 0xCB, 0xCF, 0x40, 0xFE, 0x2E, 0xCE, 0xA6, 0x5A, 0x90, 0xB8, 0x01, 0x68, 0x6C, 0x28, 0x0B, +]; + +pub const ENCRYPTION_KEY_SEED: [u8; 16] = [ + 0xE9, 0x75, 0x3C, 0x50, 0x90, 0x93, 0x61, 0xDA, 0x3B, 0x07, 0xEE, 0xFA, 0xFF, 0x9D, 0x41, 0xB8, +]; + +/// Build 54261 (3.4.3) Win64 auth seed from build_info table. +pub const WIN64_AUTH_SEED: [u8; 16] = [ + 0x92, 0x6D, 0x8C, 0x25, 0x14, 0xA3, 0xFE, 0xBA, 0x84, 0xF0, 0xDE, 0xB0, 0x31, 0xAE, 0x41, 0xCE, +]; + +/// Ed25519 private key for verifying EnterEncryptedMode. +pub const ENTER_ENCRYPTED_MODE_PRIVATE_KEY: [u8; 32] = [ + 0x08, 0xBD, 0xC7, 0xA3, 0xCC, 0xC3, 0x4F, 0x3F, 0x6A, 0x0B, 0xFF, 0xCF, 0x31, 0xC1, 0xB6, 0x97, + 0x69, 0x1E, 0x72, 0x9A, 0x0A, 0xAB, 0x2C, 0x77, 0xC3, 0x6F, 0x8A, 0xE7, 0x5A, 0x9A, 0xA7, 0xC9, +]; + +pub const ENABLE_ENCRYPTION_SEED: [u8; 16] = [ + 0x90, 0x9C, 0xD0, 0x50, 0x5A, 0x2C, 0x14, 0xDD, 0x5C, 0x2C, 0xC0, 0x64, 0x14, 0xF3, 0xFE, 0xC9, +]; + +pub const ENABLE_ENCRYPTION_CONTEXT: [u8; 16] = [ + 0xA7, 0x1F, 0xB6, 0x9B, 0xC9, 0x7C, 0xDD, 0x96, 0xE9, 0xBB, 0xB8, 0x21, 0x39, 0x8D, 0x5A, 0xD4, +]; diff --git a/tools/wow-test-bot/src/srp6_auth.rs b/tools/wow-test-bot/src/srp6_auth.rs new file mode 100644 index 00000000..02ef4a7f --- /dev/null +++ b/tools/wow-test-bot/src/srp6_auth.rs @@ -0,0 +1,331 @@ +//! Implementación completa de autenticación SRP6 para WoW 3.4.3 +//! Siguiendo especificación técnica de TrinityCore + +use hmac::{Hmac, Mac}; +use num_bigint::BigUint; +use num_traits::Zero; +use sha2::{Digest, Sha256}; + +// Constantes de TrinityCore 3.4.3 (de WorldSocket.cpp) +const AUTH_CHECK_SEED: [u8; 16] = [ + 0xC5, 0xC6, 0x98, 0x95, 0x76, 0x3F, 0x1D, 0xCD, 0xB6, 0xA1, 0x37, 0x28, 0xB3, 0x12, 0xFF, 0x8A, +]; +const SESSION_KEY_SEED: [u8; 16] = [ + 0x58, 0xCB, 0xCF, 0x40, 0xFE, 0x2E, 0xCE, 0xA6, 0x5A, 0x90, 0xB8, 0x01, 0x68, 0x6C, 0x28, 0x0B, +]; +const ENCRYPTION_KEY_SEED: [u8; 16] = [ + 0xE9, 0x75, 0x3C, 0x50, 0x90, 0x93, 0x61, 0xDA, 0x3B, 0x07, 0xEE, 0xFA, 0xFF, 0x9D, 0x41, 0xB8, +]; + +// Constantes SRP6 +const SRP6_N_HEX: &str = "894B645E89E1535BBDAD5B8B290650530801B18EBFBF5E8FAB3C82872A3CB9C00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"; +const SRP6_G: u32 = 2; +const SRP6_K: u32 = 3; + +#[derive(Debug, Clone)] +pub struct SRP6Client { + email: String, + password: String, + a: BigUint, // private ephemeral + A: BigUint, // public ephemeral + B: BigUint, // server public + s: Vec, // salt + N: BigUint, // prime + g: BigUint, // generator + k: BigUint, // multiplier + S: Option, // session key (shared secret) +} + +pub fn calculate_K_from_s(S: &BigUint) -> Vec { + Sha256::digest(&S.to_bytes_be()).to_vec() +} + +impl SRP6Client { + pub fn new(email: &str, password: &str) -> Self { + let N = BigUint::parse_bytes(SRP6_N_HEX.as_bytes(), 16).expect("Failed to parse N"); + let g = BigUint::from(SRP6_G); + let k = BigUint::from(SRP6_K); + + // Generate private ephemeral a (32 bytes random) + let a_bytes: Vec = (0..32).map(|_| rand::random::()).collect(); + let a = BigUint::from_bytes_be(&a_bytes); + + // Calculate A = g^a mod N + let A = g.modpow(&a, &N); + + Self { + email: email.to_string(), + password: password.to_string(), + a, + A, + B: BigUint::zero(), + s: Vec::new(), + N, + g, + k, + S: None, + } + } + + pub fn get_A(&self) -> &BigUint { + &self.A + } + + pub fn set_challenge(&mut self, salt: &[u8], B: &BigUint) { + self.s = salt.to_vec(); + self.B = B.clone(); + } + + /// Calculate x = SHA256(salt || SHA256(username || ":" || password)) + fn calculate_x(&self) -> BigUint { + let username_hash = Self::hash_username(&self.email); + let mut content = username_hash; + content.extend_from_slice(b":"); + content.extend_from_slice(self.password.as_bytes()); + + let inner_hash = Sha256::digest(&content); + + let mut outer_content = self.s.clone(); + outer_content.extend_from_slice(&inner_hash); + + let x_bytes = Sha256::digest(&outer_content); + BigUint::from_bytes_be(&x_bytes) + } + + fn hash_username(username: &str) -> Vec { + let upper = username.to_uppercase(); + let hash = Sha256::digest(upper.as_bytes()); + hash.to_vec() + } + + /// Calculate v = g^x mod N + fn calculate_v(&self) -> BigUint { + let x = self.calculate_x(); + self.g.modpow(&x, &self.N) + } + + /// Calculate u = SHA256(A || B) + fn calculate_u(&self) -> BigUint { + let mut content = Vec::new(); + content.extend_from_slice(&self.A.to_bytes_be()); + content.extend_from_slice(&self.B.to_bytes_be()); + let u_bytes = Sha256::digest(&content); + BigUint::from_bytes_be(&u_bytes) + } + + /// Calculate S = (B - k*g^x)^(a + u*x) mod N + pub fn calculate_S(&mut self) -> BigUint { + let x = self.calculate_x(); + let v = self.calculate_v(); + let u = self.calculate_u(); + + // k*v + let kv = &self.k * &v; + + // B - k*v (mod N) + let base = if self.B >= kv { + &self.B - &kv + } else { + &self.B + &self.N - &kv + }; + + // a + u*x + let ux = &u * &x; + let exp = &self.a + &ux; + + // S = base^exp mod N + let S = base.modpow(&exp, &self.N); + self.S = Some(S.clone()); + S + } + + /// Calculate M1 = SHA256(A || B || S) + pub fn calculate_M1(&self) -> Vec { + let S = self.S.as_ref().expect("S not calculated yet"); + let mut content = Vec::new(); + content.extend_from_slice(&self.A.to_bytes_be()); + content.extend_from_slice(&self.B.to_bytes_be()); + content.extend_from_slice(&S.to_bytes_be()); + Sha256::digest(&content).to_vec() + } + + /// Calculate K = SHA256(S) - This is the session key + pub fn calculate_K(&self) -> Vec { + let S = self.S.as_ref().expect("S not calculated yet"); + calculate_K_from_s(S) + } +} + +/// TrinityCore SessionKeyGenerator - generates keystream from seed +pub struct SessionKeyGenerator { + seed: Vec, + o1: Vec, + o2: Vec, + o0: Vec, + index: usize, +} + +impl SessionKeyGenerator { + pub fn new(seed: &[u8]) -> Self { + let half = seed.len() / 2; + let o1 = Sha256::digest(&seed[..half]).to_vec(); + let o2 = Sha256::digest(&seed[half..]).to_vec(); + // TrinityCore: o0 = SHA256(o1 || zeros(32) || o2) + let mut o0_input = o1.clone(); + o0_input.extend_from_slice(&[0u8; 32]); + o0_input.extend_from_slice(&o2); + let o0 = Sha256::digest(&o0_input).to_vec(); + + Self { + seed: seed.to_vec(), + o1, + o2, + o0, + index: 0, + } + } + + pub fn generate(&mut self, buf: &mut [u8]) { + for byte in buf.iter_mut() { + if self.index >= self.o0.len() { + // Recalculate o0 + let mut input = self.o1.clone(); + input.extend_from_slice(&self.o0); + input.extend_from_slice(&self.o2); + self.o0 = Sha256::digest(&input).to_vec(); + self.index = 0; + } + *byte = self.o0[self.index]; + self.index += 1; + } + } +} + +/// Calculate the session key for WorldServer (40 bytes) +pub fn calculate_session_key( + key_data: &[u8; 64], // client_secret || server_secret + server_challenge: &[u8; 16], + local_challenge: &[u8; 16], +) -> [u8; 40] { + // 1. keyDataHash = SHA256(key_data) + let key_data_hash = Sha256::digest(key_data); + + // 2. sessionKeyHmac = HMAC-SHA256(keyDataHash, serverChallenge || localChallenge || SessionKeySeed) + type HmacSha256 = Hmac; + let mut mac = + HmacSha256::new_from_slice(&key_data_hash).expect("HMAC can take key of any size"); + mac.update(server_challenge); + mac.update(local_challenge); + mac.update(&SESSION_KEY_SEED); + let session_key_hmac = mac.finalize().into_bytes(); + + // 3. Generate 40 bytes with SessionKeyGenerator + let mut generator = SessionKeyGenerator::new(&session_key_hmac); + let mut session_key = [0u8; 40]; + generator.generate(&mut session_key); + + session_key +} + +/// Calculate encryption key for AES-GCM (16 bytes) +pub fn calculate_encrypt_key( + session_key: &[u8; 40], + local_challenge: &[u8; 16], + server_challenge: &[u8; 16], +) -> [u8; 16] { + type HmacSha256 = Hmac; + let mut mac = HmacSha256::new_from_slice(session_key).expect("HMAC can take key of any size"); + mac.update(local_challenge); + mac.update(server_challenge); + mac.update(&ENCRYPTION_KEY_SEED); + let result = mac.finalize().into_bytes(); + + let mut encrypt_key = [0u8; 16]; + encrypt_key.copy_from_slice(&result[..16]); + encrypt_key +} + +/// Calculate digest for AuthSession verification +pub fn calculate_digest( + key_data: &[u8; 64], + auth_seed: &[u8; 16], // Win64AuthSeed from build_info + local_challenge: &[u8; 16], + server_challenge: &[u8; 16], +) -> [u8; 24] { + // 1. digestKeyHash = SHA256(key_data || AuthSeed) + let mut hash_input = key_data.to_vec(); + hash_input.extend_from_slice(auth_seed); + let digest_key_hash = Sha256::digest(&hash_input); + + // 2. hmac = HMAC-SHA256(digestKeyHash, LocalChallenge || ServerChallenge || AuthCheckSeed) + type HmacSha256 = Hmac; + let mut mac = + HmacSha256::new_from_slice(&digest_key_hash).expect("HMAC can take key of any size"); + mac.update(local_challenge); + mac.update(server_challenge); + mac.update(&AUTH_CHECK_SEED); + let result = mac.finalize().into_bytes(); + + // 3. Return first 24 bytes + let mut digest = [0u8; 24]; + digest.copy_from_slice(&result[..24]); + digest +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_srp6_calculations() { + let mut client = SRP6Client::new("test@example.com", "password123"); + + // Mock server values + let salt = vec![0xABu8; 32]; + let B = BigUint::from(12345u32); + + client.set_challenge(&salt, &B); + let S = client.calculate_S(); + let K = client.calculate_K(); + let M1 = client.calculate_M1(); + + assert!(S > BigUint::zero()); + assert_eq!(K.len(), 32); + assert_eq!(M1.len(), 32); + } + + #[test] + fn test_session_key_generation() { + let key_data = [0xCDu8; 64]; + let server_challenge = [0x12u8; 16]; + let local_challenge = [0x34u8; 16]; + + let session_key = calculate_session_key(&key_data, &server_challenge, &local_challenge); + assert_eq!(session_key.len(), 40); + + // Should be deterministic + let session_key2 = calculate_session_key(&key_data, &server_challenge, &local_challenge); + assert_eq!(session_key, session_key2); + } + + #[test] + fn test_encrypt_key_derivation() { + let session_key = [0xABu8; 40]; + let local_challenge = [0x12u8; 16]; + let server_challenge = [0x34u8; 16]; + + let encrypt_key = calculate_encrypt_key(&session_key, &local_challenge, &server_challenge); + assert_eq!(encrypt_key.len(), 16); + } + + #[test] + fn test_digest_calculation() { + let key_data = [0xEFu8; 64]; + let auth_seed = [0x11u8; 16]; + let local_challenge = [0x22u8; 16]; + let server_challenge = [0x33u8; 16]; + + let digest = calculate_digest(&key_data, &auth_seed, &local_challenge, &server_challenge); + assert_eq!(digest.len(), 24); + } +} diff --git a/tools/wow-test-bot/src/wow_crypto.rs b/tools/wow-test-bot/src/wow_crypto.rs new file mode 100644 index 00000000..fa5f72dd --- /dev/null +++ b/tools/wow-test-bot/src/wow_crypto.rs @@ -0,0 +1,272 @@ +//! WorldCrypt implementation using AES-128-GCM for WoW 3.4.3 (WotLK Classic) +//! Matches TrinityCore's WorldPacketCrypt exactly. +use openssl::symm::{Cipher, Crypter, Mode}; + +const CLIENT_MAGIC: u32 = 0x544E4C43; // "CLNT" +const SERVER_MAGIC: u32 = 0x52565253; // "SRVR" + +pub struct WorldCrypt { + key: [u8; 16], + client_counter: u64, + server_counter: u64, +} + +impl WorldCrypt { + /// Create a new WorldCrypt using the first 16 bytes of the session key. + pub fn new(session_key: &[u8]) -> Self { + let mut key = [0u8; 16]; + let len = session_key.len().min(16); + key[..len].copy_from_slice(&session_key[..len]); + Self { + key, + client_counter: 0, + server_counter: 0, + } + } + + /// Create new WorldCrypt with given 16-byte key and initial counters. + pub fn new_with_counters(key: &[u8; 16], send_counter: u32, recv_counter: u32) -> Self { + Self { + key: *key, + client_counter: send_counter as u64, + server_counter: recv_counter as u64, + } + } + + fn client_iv(&self) -> [u8; 12] { + let mut iv = [0u8; 12]; + iv[0..8].copy_from_slice(&self.client_counter.to_le_bytes()); + iv[8..12].copy_from_slice(&CLIENT_MAGIC.to_le_bytes()); + iv + } + + fn server_iv(&self) -> [u8; 12] { + let mut iv = [0u8; 12]; + iv[0..8].copy_from_slice(&self.server_counter.to_le_bytes()); + iv[8..12].copy_from_slice(&SERVER_MAGIC.to_le_bytes()); + iv + } + + /// Encrypt client packet (client -> server) + pub fn encrypt_client( + &mut self, + plaintext: &[u8], + _aad: &[u8], + ) -> Result<(Vec, [u8; 12]), String> { + let iv = self.client_iv(); + let result = aes_128_gcm_encrypt(&self.key, &iv, plaintext)?; + self.client_counter = self.client_counter.wrapping_add(1); + Ok(result) + } + + /// Decrypt server packet (server -> client) + pub fn decrypt_server( + &mut self, + ciphertext: &[u8], + tag: &[u8; 12], + _aad: &[u8], + ) -> Result, String> { + let iv = self.server_iv(); + let result = aes_128_gcm_decrypt(&self.key, &iv, ciphertext, tag)?; + self.server_counter = self.server_counter.wrapping_add(1); + Ok(result) + } + + /// Encrypt server packet (for testing/simulation) + pub fn encrypt_server( + &mut self, + plaintext: &[u8], + _aad: &[u8], + ) -> Result<(Vec, [u8; 12]), String> { + let iv = self.server_iv(); + let result = aes_128_gcm_encrypt(&self.key, &iv, plaintext)?; + self.server_counter = self.server_counter.wrapping_add(1); + Ok(result) + } + + /// Decrypt client packet (for testing/simulation) + pub fn decrypt_client( + &mut self, + ciphertext: &[u8], + tag: &[u8; 12], + _aad: &[u8], + ) -> Result, String> { + let iv = self.client_iv(); + let result = aes_128_gcm_decrypt(&self.key, &iv, ciphertext, tag)?; + self.client_counter = self.client_counter.wrapping_add(1); + Ok(result) + } + + /// Encrypt a full client packet into wire format. + /// + /// Wire format (TrinityCore 3.4.3 client -> server): + /// [size: u32 LE][tag: 12 bytes][ciphertext: size bytes] + /// + /// The first 2 bytes of ciphertext are the encrypted opcode, matching the + /// 18-byte IncomingPacketHeader layout used by TrinityCore: + /// header = size(4) + tag(12) + encryptedOpcode(2) + /// body = ciphertext[2..] (size - 2 bytes) + pub fn encrypt_client_packet(&mut self, opcode: u16, data: &[u8]) -> Vec { + let mut plaintext = Vec::with_capacity(2 + data.len()); + plaintext.extend_from_slice(&opcode.to_le_bytes()); + plaintext.extend_from_slice(data); + + let (ciphertext, tag) = self + .encrypt_client(&plaintext, &[]) + .expect("Client encryption failed"); + + let size = ciphertext.len() as u32; + let mut packet = Vec::with_capacity(4 + 12 + ciphertext.len()); + packet.extend_from_slice(&size.to_le_bytes()); + packet.extend_from_slice(&tag); + packet.extend_from_slice(&ciphertext); + packet + } + + /// Decrypt a full server packet from wire format. + /// + /// Wire format (TrinityCore 3.4.3 server -> client): + /// [size: u32 LE][tag: 12 bytes][ciphertext: size bytes] + /// + /// Returns: (opcode, payload) + pub fn decrypt_server_packet(&mut self, data: &[u8]) -> (u16, Vec) { + if data.len() < 16 { + panic!("Packet too short for header: {} bytes", data.len()); + } + + let size = u32::from_le_bytes([data[0], data[1], data[2], data[3]]) as usize; + if data.len() < 16 + size { + panic!( + "Packet too short: expected {} bytes, got {}", + 16 + size, + data.len() + ); + } + + let mut tag = [0u8; 12]; + tag.copy_from_slice(&data[4..16]); + let ciphertext = &data[16..16 + size]; + + let plaintext = self + .decrypt_server(ciphertext, &tag, &[]) + .expect("Server decryption failed"); + + if plaintext.len() < 2 { + panic!( + "Decrypted payload too short for opcode: {} bytes", + plaintext.len() + ); + } + + let opcode = u16::from_le_bytes([plaintext[0], plaintext[1]]); + let payload = plaintext[2..].to_vec(); + (opcode, payload) + } +} + +fn aes_128_gcm_encrypt( + key: &[u8; 16], + iv: &[u8; 12], + plaintext: &[u8], +) -> Result<(Vec, [u8; 12]), String> { + let cipher = Cipher::aes_128_gcm(); + let mut crypter = Crypter::new(cipher, Mode::Encrypt, key, Some(iv)) + .map_err(|e| format!("Crypter init failed: {}", e))?; + let mut ciphertext = vec![0u8; plaintext.len() + cipher.block_size()]; + let mut count = crypter + .update(plaintext, &mut ciphertext) + .map_err(|e| format!("Encrypt update failed: {}", e))?; + count += crypter + .finalize(&mut ciphertext[count..]) + .map_err(|e| format!("Encrypt finalize failed: {}", e))?; + ciphertext.truncate(count); + + let mut tag = [0u8; 12]; + crypter + .get_tag(&mut tag) + .map_err(|e| format!("Get tag failed: {}", e))?; + + Ok((ciphertext, tag)) +} + +fn aes_128_gcm_decrypt( + key: &[u8; 16], + iv: &[u8; 12], + ciphertext: &[u8], + tag: &[u8; 12], +) -> Result, String> { + let cipher = Cipher::aes_128_gcm(); + let mut crypter = Crypter::new(cipher, Mode::Decrypt, key, Some(iv)) + .map_err(|e| format!("Crypter init failed: {}", e))?; + crypter + .set_tag(tag) + .map_err(|e| format!("Set tag failed: {}", e))?; + let mut plaintext = vec![0u8; ciphertext.len() + cipher.block_size()]; + let mut count = crypter + .update(ciphertext, &mut plaintext) + .map_err(|e| format!("Decrypt update failed: {}", e))?; + count += crypter + .finalize(&mut plaintext[count..]) + .map_err(|e| format!("Decrypt finalize failed: {}", e))?; + plaintext.truncate(count); + Ok(plaintext) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_crypt_roundtrip() { + let key = [0xABu8; 16]; + let mut client_crypt = WorldCrypt::new_with_counters(&key, 0, 0); + let mut server_crypt = WorldCrypt::new_with_counters(&key, 0, 0); + + let plaintext = b"Hello, World!"; + let (ciphertext, tag) = client_crypt.encrypt_client(plaintext, &[]).unwrap(); + let decrypted = server_crypt.decrypt_server(&ciphertext, &tag, &[]).unwrap(); + + assert_eq!(plaintext[..], decrypted[..]); + } + + #[test] + fn test_encrypt_client_packet() { + let key = [0xCDu8; 16]; + let mut crypt = WorldCrypt::new_with_counters(&key, 0, 0); + + let data = b"test payload"; + let packet = crypt.encrypt_client_packet(0x1234, data); + + // Should be: 4 bytes size + 12 bytes tag + size bytes ciphertext + let size = u32::from_le_bytes([packet[0], packet[1], packet[2], packet[3]]) as usize; + assert_eq!(packet.len(), 16 + size); + } + + #[test] + fn test_decrypt_server_packet() { + let key = [0xEFu8; 16]; + let mut server_crypt = WorldCrypt::new_with_counters(&key, 0, 0); + let mut client_crypt = WorldCrypt::new_with_counters(&key, 0, 0); + + let opcode = 0x5678u16; + let payload = b"server response"; + + // Simulate server sending a packet + let mut plaintext = Vec::new(); + plaintext.extend_from_slice(&opcode.to_le_bytes()); + plaintext.extend_from_slice(payload); + + let (ciphertext, tag) = server_crypt.encrypt_server(&plaintext, &[]).unwrap(); + let size = ciphertext.len() as u32; + + let mut packet = Vec::new(); + packet.extend_from_slice(&size.to_le_bytes()); + packet.extend_from_slice(&tag); + packet.extend_from_slice(&ciphertext); + + let (decrypted_opcode, decrypted_payload) = client_crypt.decrypt_server_packet(&packet); + + assert_eq!(decrypted_opcode, opcode); + assert_eq!(decrypted_payload[..], payload[..]); + } +}