From 1ac0cd27899a1e0456fe60645e736026bf46c93c Mon Sep 17 00:00:00 2001 From: cDmonio Date: Wed, 1 Jul 2026 22:13:23 +0000 Subject: [PATCH 1/5] fix(world): reload item enchants and random props --- .../wow-database/src/statements/character.rs | 36 +++- crates/wow-packet/src/packets/update.rs | 31 +++- crates/wow-world/src/handlers/character.rs | 170 +++++++++++++++++- crates/wow-world/src/handlers/loot.rs | 3 +- crates/wow-world/src/handlers/quest.rs | 4 +- docs/migration/EXISTING-CODE-DEFECTS.md | 7 + 6 files changed, 234 insertions(+), 17 deletions(-) diff --git a/crates/wow-database/src/statements/character.rs b/crates/wow-database/src/statements/character.rs index 9596859d..f53890cf 100644 --- a/crates/wow-database/src/statements/character.rs +++ b/crates/wow-database/src/statements/character.rs @@ -1877,7 +1877,8 @@ impl StatementDef for CharStatements { Self::SEL_MAX_GUID => "SELECT MAX(guid) FROM characters", Self::SEL_CHAR_EQUIPMENT => { "SELECT ci.slot, ii.itemEntry, ci.item, ii.count, ii.durability, ii.context, \ - ii.flags, ii.playedTime, ir.paidMoney, ir.paidExtendedCost \ + ii.flags, ii.playedTime, ii.enchantments, ii.randomPropertiesId, \ + ii.randomPropertiesSeed, ir.paidMoney, ir.paidExtendedCost \ FROM character_inventory ci \ JOIN item_instance ii ON ci.item = ii.guid \ LEFT JOIN item_refund_instance ir \ @@ -3106,7 +3107,8 @@ impl StatementDef for CharStatements { } Self::SEL_CHAR_BAG_CONTENTS => { "SELECT bag_ci.slot, ci.slot, ii.itemEntry, ci.item, ii.count, ii.durability, ii.context, \ - ii.flags, ii.playedTime, ir.paidMoney, ir.paidExtendedCost \ + ii.flags, ii.playedTime, ii.enchantments, ii.randomPropertiesId, \ + ii.randomPropertiesSeed, ir.paidMoney, ir.paidExtendedCost \ FROM character_inventory ci \ JOIN character_inventory bag_ci \ ON bag_ci.guid = ci.guid AND bag_ci.item = ci.bag \ @@ -6014,6 +6016,21 @@ mod tests { .count(), 1 ); + assert!( + CharStatements::SEL_CHAR_EQUIPMENT + .sql() + .contains("ii.enchantments") + ); + assert!( + CharStatements::SEL_CHAR_EQUIPMENT + .sql() + .contains("ii.randomPropertiesId") + ); + assert!( + CharStatements::SEL_CHAR_EQUIPMENT + .sql() + .contains("ii.randomPropertiesSeed") + ); assert_eq!( CharStatements::INS_ITEM_INSTANCE_WITH_RANDOM_CONTEXT .sql() @@ -6061,6 +6078,21 @@ mod tests { .count(), 1 ); + assert!( + CharStatements::SEL_CHAR_BAG_CONTENTS + .sql() + .contains("ii.enchantments") + ); + assert!( + CharStatements::SEL_CHAR_BAG_CONTENTS + .sql() + .contains("ii.randomPropertiesId") + ); + assert!( + CharStatements::SEL_CHAR_BAG_CONTENTS + .sql() + .contains("ii.randomPropertiesSeed") + ); assert_eq!( CharStatements::DEL_ITEM_REFUND_INSTANCE .sql() diff --git a/crates/wow-packet/src/packets/update.rs b/crates/wow-packet/src/packets/update.rs index a6b4d250..cccb39fe 100644 --- a/crates/wow-packet/src/packets/update.rs +++ b/crates/wow-packet/src/packets/update.rs @@ -1369,6 +1369,7 @@ pub struct ItemCreateData { pub max_durability: u32, pub random_properties_seed: i32, pub random_properties_id: i32, + pub enchantments: [ItemEnchantmentValuesUpdate; 13], pub context: u8, /// Non-zero for `Bag` objects. C++ writes those as TYPEID_CONTAINER /// and appends `ContainerData::WriteCreate` after `ItemData::WriteCreate`. @@ -5317,13 +5318,13 @@ fn write_item_create_block( // DynamicFlags val_buf.write_uint32(data.dynamic_flags); - // 13 x ItemEnchantment (all zeros) - for _ in 0..13 { - val_buf.write_int32(0); // ID - val_buf.write_int32(0); // Duration - val_buf.write_int16(0); // Charges - val_buf.write_uint8(0); // Field_A - val_buf.write_uint8(0); // Field_B + // 13 x ItemEnchantment + for enchantment in data.enchantments { + val_buf.write_int32(enchantment.id); + val_buf.write_uint32(enchantment.duration); + val_buf.write_int16(enchantment.charges); + val_buf.write_uint8(enchantment.field_a); + val_buf.write_uint8(enchantment.field_b); } // PropertySeed, RandomPropertiesID @@ -9546,6 +9547,16 @@ mod tests { max_durability: 20, random_properties_seed: 456, random_properties_id: -77, + enchantments: { + let mut enchantments = [ItemEnchantmentValuesUpdate::default(); 13]; + enchantments[0] = ItemEnchantmentValuesUpdate { + id: 2673, + duration: 0, + charges: 0, + ..Default::default() + }; + enchantments + }, context: 2, container_slots: 0, container_item_guids: [ObjectGuid::EMPTY; 36], @@ -9577,6 +9588,11 @@ mod tests { .any(|window| window == (-77i32).to_le_bytes()) ); assert!(bytes.windows(4).any(|window| window == 2i32.to_le_bytes())); + assert!( + bytes + .windows(4) + .any(|window| window == 2673i32.to_le_bytes()) + ); } #[test] @@ -9599,6 +9615,7 @@ mod tests { max_durability: 0, random_properties_seed: 0, random_properties_id: 0, + enchantments: [ItemEnchantmentValuesUpdate::default(); 13], context: 0, container_slots: 16, container_item_guids, diff --git a/crates/wow-world/src/handlers/character.rs b/crates/wow-world/src/handlers/character.rs index a8462756..dcb47793 100644 --- a/crates/wow-world/src/handlers/character.rs +++ b/crates/wow-world/src/handlers/character.rs @@ -80,6 +80,7 @@ const GO_SPAWN_EFFECTIVE_FLAGS_COLUMN: usize = GO_SPAWN_PHASE_USE_FLAGS_COLUMN + const GO_SPAWN_EFFECTIVE_FACTION_COLUMN: usize = GO_SPAWN_PHASE_USE_FLAGS_COLUMN + 5; const GO_SPAWN_OVERRIDE_SOURCE_KNOWN_COLUMN: usize = GO_SPAWN_PHASE_USE_FLAGS_COLUMN + 6; const WORLDSTATE_ANY_MAP_LIKE_CPP: i32 = -1; +const ITEM_ENCHANTMENT_DB_FIELDS: usize = 3; fn primary_power_type_for_class_like_cpp(class_id: u8) -> PowerType { match class_id { @@ -98,6 +99,57 @@ fn primary_max_power_for_class_like_cpp(class_id: u8, max_mana: i64) -> i32 { } } +fn apply_loaded_item_instance_fields_like_cpp( + item: &mut wow_entities::Item, + enchantments: Option<&[ItemEnchantmentValuesUpdate; wow_entities::MAX_ENCHANTMENT_SLOT]>, + random_properties_id: i32, + random_properties_seed: i32, +) { + if random_properties_id != 0 { + item.set_random_properties_id(random_properties_id); + item.set_property_seed(random_properties_seed); + } + + if let Some(enchantments) = enchantments { + for (slot_index, enchantment) in enchantments.iter().enumerate() { + let Some(slot) = ::from_usize(slot_index) + else { + continue; + }; + item.set_enchantment( + slot, + enchantment.id, + enchantment.duration, + enchantment.charges, + ); + } + } +} + +fn loaded_item_enchantments_like_cpp( + enchantments: &str, +) -> Option<[ItemEnchantmentValuesUpdate; wow_entities::MAX_ENCHANTMENT_SLOT]> { + let mut values = [ItemEnchantmentValuesUpdate::default(); wow_entities::MAX_ENCHANTMENT_SLOT]; + let tokens: Vec<&str> = enchantments.split_whitespace().collect(); + if tokens.len() != wow_entities::MAX_ENCHANTMENT_SLOT * ITEM_ENCHANTMENT_DB_FIELDS { + return None; + } + + for slot_index in 0..wow_entities::MAX_ENCHANTMENT_SLOT { + let base = slot_index * ITEM_ENCHANTMENT_DB_FIELDS; + values[slot_index] = ItemEnchantmentValuesUpdate { + item_enchantment_mask: 0, + id: tokens[base].parse::().unwrap_or(0), + duration: tokens[base + 1].parse::().unwrap_or(0), + charges: tokens[base + 2].parse::().unwrap_or(0), + field_a: 0, + field_b: 0, + }; + } + + Some(values) +} + #[derive(Debug, Clone, PartialEq, Eq)] struct LoginWorldStateTemplateLikeCpp { id: i32, @@ -4589,11 +4641,21 @@ impl WorldSession { .unwrap_or(ItemContext::None); let item_flags = eq_result.try_read::(6).unwrap_or(0); let item_played_time = eq_result.try_read::(7).unwrap_or(0); + let item_enchantments = + eq_result.try_read::(8).unwrap_or_default(); + let item_enchantment_values = + loaded_item_enchantments_like_cpp(&item_enchantments); + let item_create_enchantments = item_enchantment_values + .as_ref() + .copied() + .unwrap_or([ItemEnchantmentValuesUpdate::default(); 13]); + let random_properties_id = eq_result.try_read::(9).unwrap_or(0); + let random_properties_seed = eq_result.try_read::(10).unwrap_or(0); let refund_decision = loaded_item_refund_decision( item_flags, item_played_time, - eq_result.try_read::(8), - eq_result.try_read::(9), + eq_result.try_read::(11), + eq_result.try_read::(12), ); if item_entry > 0 && (slot as usize) < 141 { let item_max_durability = self @@ -4650,8 +4712,9 @@ impl WorldSession { dynamic_flags: stored_flags, durability: item_durability, max_durability: item_max_durability, - random_properties_seed: 0, - random_properties_id: 0, + random_properties_seed, + random_properties_id, + enchantments: item_create_enchantments, context: item_context as u8, container_slots, container_item_guids: [ObjectGuid::EMPTY; 36], @@ -4680,6 +4743,12 @@ impl WorldSession { slot, ); item_object.set_create_played_time(item_played_time); + apply_loaded_item_instance_fields_like_cpp( + &mut item_object, + item_enchantment_values.as_ref(), + random_properties_id, + random_properties_seed, + ); item_object.replace_all_item_flags( ItemFieldFlags::from_bits_retain(stored_flags), ); @@ -4745,6 +4814,18 @@ impl WorldSession { .unwrap_or(ItemContext::None); let item_flags = bag_result.try_read::(7).unwrap_or(0); let item_played_time = bag_result.try_read::(8).unwrap_or(0); + let item_enchantments = + bag_result.try_read::(9).unwrap_or_default(); + let item_enchantment_values = + loaded_item_enchantments_like_cpp(&item_enchantments); + let item_create_enchantments = item_enchantment_values + .as_ref() + .copied() + .unwrap_or([ItemEnchantmentValuesUpdate::default(); 13]); + let random_properties_id = + bag_result.try_read::(10).unwrap_or(0); + let random_properties_seed = + bag_result.try_read::(11).unwrap_or(0); if item_entry > 0 && is_represented_bag_slot(bag_slot) { if let Some(bag_item_guid) = self .inventory_items_like_cpp() @@ -4766,6 +4847,12 @@ impl WorldSession { inner_slot, ); item_object.set_create_played_time(item_played_time); + apply_loaded_item_instance_fields_like_cpp( + &mut item_object, + item_enchantment_values.as_ref(), + random_properties_id, + random_properties_seed, + ); item_object.replace_all_item_flags( ItemFieldFlags::from_bits_retain(item_flags), ); @@ -4793,8 +4880,9 @@ impl WorldSession { dynamic_flags: item_flags, durability: item_durability, max_durability: item_max_durability, - random_properties_seed: 0, - random_properties_id: 0, + random_properties_seed, + random_properties_id, + enchantments: item_create_enchantments, context: item_context as u8, container_slots: 0, container_item_guids: [ObjectGuid::EMPTY; 36], @@ -10515,6 +10603,7 @@ impl WorldSession { max_durability, random_properties_seed: 0, random_properties_id: 0, + enchantments: [ItemEnchantmentValuesUpdate::default(); 13], context: 0, container_slots: 0, container_item_guids: [ObjectGuid::EMPTY; 36], @@ -11064,6 +11153,7 @@ impl WorldSession { max_durability, random_properties_seed: 0, random_properties_id: 0, + enchantments: [ItemEnchantmentValuesUpdate::default(); 13], context: 0, container_slots: 0, container_item_guids: [ObjectGuid::EMPTY; 36], @@ -11563,6 +11653,7 @@ impl WorldSession { max_durability: stack.max_durability, random_properties_seed: 0, random_properties_id: 0, + enchantments: [ItemEnchantmentValuesUpdate::default(); 13], context: 0, container_slots: 0, container_item_guids: [ObjectGuid::EMPTY; 36], @@ -14130,6 +14221,73 @@ mod tests { }; use wow_packet::packets::quest::quest_giver_status; + fn test_item_enchantments_db_string(entries: &[(usize, i32, u32, i16)]) -> String { + let mut fields = vec!["0".to_string(); wow_entities::MAX_ENCHANTMENT_SLOT * 3]; + for &(slot, id, duration, charges) in entries { + let base = slot * 3; + fields[base] = id.to_string(); + fields[base + 1] = duration.to_string(); + fields[base + 2] = charges.to_string(); + } + fields.join(" ") + } + + #[test] + fn loaded_item_instance_fields_preserve_enchantments_and_random_suffix_like_cpp() { + let mut item = wow_entities::Item::default(); + let enchantments = test_item_enchantments_db_string(&[ + (EnchantmentSlot::EnhancementPermanent as usize, 2673, 0, 0), + ( + EnchantmentSlot::EnhancementTemporary as usize, + 3826, + 30_000, + 3, + ), + (EnchantmentSlot::Property2 as usize, 901, 0, 0), + ]); + let enchantments = + loaded_item_enchantments_like_cpp(&enchantments).expect("valid C++ enchantment string"); + + apply_loaded_item_instance_fields_like_cpp(&mut item, Some(&enchantments), -77, 456); + + assert_eq!(item.data().random_properties_id, -77); + assert_eq!(item.data().property_seed, 456); + assert_eq!( + item.data().enchantments[EnchantmentSlot::EnhancementPermanent as usize].id, + 2673 + ); + assert_eq!( + item.data().enchantments[EnchantmentSlot::EnhancementTemporary as usize].duration, + 30_000 + ); + assert_eq!( + item.data().enchantments[EnchantmentSlot::EnhancementTemporary as usize].charges, + 3 + ); + assert_eq!( + item.data().enchantments[EnchantmentSlot::Property2 as usize].id, + 901 + ); + } + + #[test] + fn loaded_item_instance_fields_ignore_short_enchantment_string_like_cpp() { + let mut item = wow_entities::Item::default(); + let enchantments = loaded_item_enchantments_like_cpp("2673 0 0"); + assert!(enchantments.is_none()); + + apply_loaded_item_instance_fields_like_cpp(&mut item, enchantments.as_ref(), 0, 0); + + assert!( + item.data() + .enchantments + .iter() + .all(|enchantment| *enchantment == wow_entities::ItemEnchantment::default()) + ); + assert_eq!(item.data().random_properties_id, 0); + assert_eq!(item.data().property_seed, 0); + } + #[test] fn sql_creature_template_speed_defaults_match_cpp_check_creature_template() { assert_eq!(normalize_creature_template_speed_walk_like_cpp(0.0), 1.0); diff --git a/crates/wow-world/src/handlers/loot.rs b/crates/wow-world/src/handlers/loot.rs index e296da1e..fd8e0c87 100644 --- a/crates/wow-world/src/handlers/loot.rs +++ b/crates/wow-world/src/handlers/loot.rs @@ -81,7 +81,7 @@ use wow_packet::packets::loot::{ LootResponse, LootRoll, LootRollBroadcast, LootRollWon, LootUnit, MasterLootCandidateList, MasterLootItem, NotNormalLootItem, SLootRelease, SetLootSpecialization, StartLootRoll, }; -use wow_packet::packets::update::{ItemCreateData, UpdateObject}; +use wow_packet::packets::update::{ItemCreateData, ItemEnchantmentValuesUpdate, UpdateObject}; use wow_packet::{ClientPacket, ServerPacket}; use crate::session::{ @@ -6821,6 +6821,7 @@ impl WorldSession { max_durability: stack.max_durability, random_properties_seed: stack.random_properties_seed, random_properties_id: stack.random_properties_id, + enchantments: [ItemEnchantmentValuesUpdate::default(); 13], context: stack.item_context, container_slots: 0, container_item_guids: [ObjectGuid::EMPTY; 36], diff --git a/crates/wow-world/src/handlers/quest.rs b/crates/wow-world/src/handlers/quest.rs index d2b5bf95..f5956e07 100644 --- a/crates/wow-world/src/handlers/quest.rs +++ b/crates/wow-world/src/handlers/quest.rs @@ -57,7 +57,7 @@ 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, ItemEnchantmentValuesUpdate, UpdateObject}; use wow_packet::{ClientPacket, ServerPacket}; use crate::conditions::{ @@ -1869,6 +1869,7 @@ impl WorldSession { max_durability: stack.max_durability, random_properties_seed: 0, random_properties_id: 0, + enchantments: [ItemEnchantmentValuesUpdate::default(); 13], context: ItemContext::None as u8, container_slots: 0, container_item_guids: [ObjectGuid::EMPTY; 36], @@ -2228,6 +2229,7 @@ impl WorldSession { max_durability: stack.max_durability, random_properties_seed: 0, random_properties_id: 0, + enchantments: [ItemEnchantmentValuesUpdate::default(); 13], context: ItemContext::QuestReward as u8, container_slots: 0, container_item_guids: [ObjectGuid::EMPTY; 36], diff --git a/docs/migration/EXISTING-CODE-DEFECTS.md b/docs/migration/EXISTING-CODE-DEFECTS.md index 07048063..625913e9 100644 --- a/docs/migration/EXISTING-CODE-DEFECTS.md +++ b/docs/migration/EXISTING-CODE-DEFECTS.md @@ -22,8 +22,15 @@ mutates DB" ≠ "computes the right result / can't lose or dupe data." - [ ] **D-C1 Item enchantments not loaded on relog.** `SEL_CHAR_EQUIPMENT`/`SEL_CHAR_BAG_CONTENTS` select enchantment cols but the load hardcodes 0 → equipped/bagged enchants vanish on logout. `handlers/character.rs:4617-4618,4760-4761`. C++ `Player::_LoadInventory`. + - 2026-07-01 issue #20 local slice: Rust now selects `item_instance.enchantments` for the + specialized equipment/bag login queries, parses the 13x `(id,duration,charges)` fields like + C++ `Item::LoadFromDB`, applies them to runtime `Item` objects, and includes them in item + `CREATE_OBJECT` blocks. Kept open until capture-diff/live relog QA is run. - [ ] **D-C2 Item random properties not loaded on relog.** Same query gap → magical items become non-magical. `handlers/character.rs:4617`. + - 2026-07-01 issue #20 local slice: the same login path now loads `randomPropertiesId` and + `randomPropertiesSeed` for equipped and bagged items into runtime item state and login create + data. Kept open until capture-diff/live relog QA is run. - [ ] **D-C3 Bank contents never persisted.** Bank moves recorded in-memory only (`represented_bank_item_moves`), no DB write → 100% bank loss on logout. `session.rs:31575`. - [ ] **D-C4 Inventory swap not transactional.** Two separate `execute()` calls; mid-fail From e4d1f670ced9d58dcc8175b0c5231516c45167e3 Mon Sep 17 00:00:00 2001 From: cDmonio Date: Sat, 4 Jul 2026 08:34:28 +0000 Subject: [PATCH 2/5] Apply loaded equipped enchantments --- crates/wow-world/src/handlers/character.rs | 19 ++- crates/wow-world/src/session.rs | 173 ++++++++++++++++++++- 2 files changed, 181 insertions(+), 11 deletions(-) diff --git a/crates/wow-world/src/handlers/character.rs b/crates/wow-world/src/handlers/character.rs index dcb47793..40021a35 100644 --- a/crates/wow-world/src/handlers/character.rs +++ b/crates/wow-world/src/handlers/character.rs @@ -36,12 +36,12 @@ use wow_database::{ }; use wow_entities::{ BANK_SLOT_BAG_END, BANK_SLOT_BAG_START, BUYBACK_SLOT_START, - CreatureAddonLifecycleRecordLikeCpp, GAMEOBJECT_TYPE_FISHING_HOLE, GAMEOBJECT_TYPE_QUESTGIVER, - GameObjectTemplateData, INVENTORY_DEFAULT_SIZE, INVENTORY_SLOT_BAG_0, INVENTORY_SLOT_BAG_END, - INVENTORY_SLOT_BAG_START, INVENTORY_SLOT_ITEM_START, MAX_BAG_SIZE, MAX_GAMEOBJECT_DATA, - MovementGeneratorType, NULL_BAG, NULL_SLOT, REAGENT_BAG_SLOT_END, REAGENT_BAG_SLOT_START, - WorldObject, is_equipment_pos, is_inventory_pos, - normalize_creature_chase_movement_type_like_cpp, + CreatureAddonLifecycleRecordLikeCpp, EQUIPMENT_SLOT_END, GAMEOBJECT_TYPE_FISHING_HOLE, + GAMEOBJECT_TYPE_QUESTGIVER, GameObjectTemplateData, INVENTORY_DEFAULT_SIZE, + INVENTORY_SLOT_BAG_0, INVENTORY_SLOT_BAG_END, INVENTORY_SLOT_BAG_START, + INVENTORY_SLOT_ITEM_START, MAX_BAG_SIZE, MAX_GAMEOBJECT_DATA, MovementGeneratorType, NULL_BAG, + NULL_SLOT, REAGENT_BAG_SLOT_END, REAGENT_BAG_SLOT_START, WorldObject, is_equipment_pos, + is_inventory_pos, normalize_creature_chase_movement_type_like_cpp, normalize_creature_random_movement_type_like_cpp, }; use wow_handler::{PacketHandlerEntry, PacketProcessing, SessionStatus}; @@ -4619,6 +4619,7 @@ impl WorldSession { let mut inv_slots = [ObjectGuid::EMPTY; 141]; let mut item_creates: Vec = Vec::new(); let mut login_bag_create_index_by_slot: HashMap = HashMap::new(); + let mut loaded_equipped_item_guids: Vec = Vec::new(); let realm_id = self.realm_id(); self.clear_inventory_items_and_objects_like_cpp(); self.clear_player_currencies_like_cpp(); @@ -4767,6 +4768,9 @@ impl WorldSession { ); item_object.set_state(ItemUpdateState::Unchanged); self.insert_inventory_item_object(item_object); + if slot < EQUIPMENT_SLOT_END { + loaded_equipped_item_guids.push(item_guid); + } // Slots 0-18 also populate VisibleItems for character model if (slot as usize) < 19 { visible_items[slot as usize] = (item_entry as i32, 0, 0); @@ -4914,6 +4918,9 @@ impl WorldSession { // inventory_type is now loaded from the canonical ItemTemplate bridge. // No SQL cache needed. } + for item_guid in loaded_equipped_item_guids { + let _ = self.apply_loaded_equipped_item_enchantments_like_cpp(item_guid); + } self.sync_player_inventory_like_cpp(); // ── Load equipment sets / transmog outfits ── diff --git a/crates/wow-world/src/session.rs b/crates/wow-world/src/session.rs index 7c70d3f7..bfcff9a0 100644 --- a/crates/wow-world/src/session.rs +++ b/crates/wow-world/src/session.rs @@ -129,11 +129,12 @@ use wow_database::{ SqlTransaction, StatementDef, WorldDatabase, }; use wow_entities::{ - AccessorObjectKind, ActiveState, ApplyEnchantmentArgs, ApplyEnchantmentEffectAction, - ApplyEnchantmentEffectRef, ApplyEnchantmentPlan, ApplyEnchantmentRandomSuffixRef, - ApplyEnchantmentTemplateRef, BANK_SLOT_BAG_END, BANK_SLOT_BAG_START, BUYBACK_SLOT_COUNT, - BUYBACK_SLOT_END, BUYBACK_SLOT_START, BagTemplateRef, CanEquipItemArgs, CanEquipUniqueItemArgs, - CanStoreItemArgs, CanUnequipItemArgs, CanUseItemArgs, CanUseItemTemplateArgs, + AccessorObjectKind, ActiveState, ApplyEnchantmentArgs, ApplyEnchantmentDurationAction, + ApplyEnchantmentEffectAction, ApplyEnchantmentEffectRef, ApplyEnchantmentPlan, + ApplyEnchantmentRandomSuffixRef, ApplyEnchantmentResult, ApplyEnchantmentTemplateRef, + BANK_SLOT_BAG_END, BANK_SLOT_BAG_START, BUYBACK_SLOT_COUNT, BUYBACK_SLOT_END, + BUYBACK_SLOT_START, BagTemplateRef, CanEquipItemArgs, CanEquipUniqueItemArgs, CanStoreItemArgs, + CanUnequipItemArgs, CanUseItemArgs, CanUseItemTemplateArgs, CreatureAddonLifecycleRecordLikeCpp, EQUIPMENT_SLOT_BACK, EQUIPMENT_SLOT_BODY, EQUIPMENT_SLOT_CHEST, EQUIPMENT_SLOT_END, EQUIPMENT_SLOT_FEET, EQUIPMENT_SLOT_FINGER1, EQUIPMENT_SLOT_FINGER2, EQUIPMENT_SLOT_HANDS, EQUIPMENT_SLOT_HEAD, EQUIPMENT_SLOT_LEGS, @@ -20312,6 +20313,54 @@ impl WorldSession { plan } + /// C++ `Player::_LoadInventory` finishes by `_ApplyAllItemMods`, which in + /// turn calls `ApplyEnchantment(m_items[i], true)` for equipped items. + pub(crate) fn apply_loaded_equipped_item_enchantments_like_cpp( + &mut self, + item_guid: ObjectGuid, + ) -> Vec { + let slots = self + .inventory_item_objects_like_cpp() + .get(&item_guid) + .map(|item| { + item.data() + .enchantments + .iter() + .enumerate() + .filter_map(|(slot_index, enchantment)| { + if enchantment.id == 0 { + return None; + } + ::from_usize(slot_index) + }) + .collect::>() + }) + .unwrap_or_default(); + + let mut plans = Vec::new(); + let mut duration_updates = Vec::new(); + for slot in slots { + if let Some(plan) = self.apply_current_player_item_enchantment_plan_like_cpp( + item_guid, + slot, + ApplyEnchantmentArgs::apply(), + ) { + if let ApplyEnchantmentResult::Applied { + duration_action: Some(ApplyEnchantmentDurationAction::Added(duration_update)), + .. + } = plan.result + { + duration_updates.push(duration_update); + } + plans.push(plan); + } + } + if let Some(owner_guid) = self.player_guid() { + self.send_item_enchant_time_update_plans(owner_guid, &duration_updates); + } + plans + } + /// Set the hotfix blob cache for this session. pub fn set_hotfix_blob_cache(&mut self, cache: Arc) { self.hotfix_blob_cache = Some(cache); @@ -116239,6 +116288,120 @@ mod tests { ); } + #[test] + fn loaded_equipped_item_enchantments_apply_and_send_durations_like_cpp() { + let (mut session, _, send_rx) = make_session(); + let canonical = shared_canonical_map_manager(); + let player_guid = ObjectGuid::create_player(1, 90_520); + let player_position = Position::new(1.0, 2.0, 3.0, 0.0); + let item_guid = ObjectGuid::create_item(1, 90_521); + session.set_player_guid(Some(player_guid)); + session.set_loaded_player_identity_like_cpp(571, 1, 1, 80, 0); + session.set_canonical_map_manager(Arc::clone(&canonical)); + add_canonical_test_player_on_map(&canonical, player_guid, player_position, 571, 0); + session + .mutate_canonical_player_like_cpp(|player| player.unit_mut().set_level(80)) + .unwrap(); + session.set_spell_item_enchantment_store(Arc::new( + SpellItemEnchantmentStore::from_entries([ + SpellItemEnchantmentEntry { + id: 903, + effect_arg: [0; 3], + effect_points_min: [0; 3], + item_visual: 0, + flags: SpellItemEnchantmentFlags::empty(), + required_skill_id: 0, + required_skill_rank: 0, + item_level: 1, + charges: 0, + effect: [ItemEnchantmentType::None as u8; 3], + condition_id: 0, + min_level: 1, + max_level: 0, + }, + SpellItemEnchantmentEntry { + id: 904, + effect_arg: [0; 3], + effect_points_min: [0; 3], + item_visual: 0, + flags: SpellItemEnchantmentFlags::empty(), + required_skill_id: 0, + required_skill_rank: 0, + item_level: 1, + charges: 0, + effect: [ItemEnchantmentType::None as u8; 3], + condition_id: 0, + min_level: 1, + max_level: 0, + }, + ]), + )); + + let mut item = session.make_inventory_item_object( + item_guid, + 700, + player_guid, + 1, + 0, + ItemContext::None, + EQUIPMENT_SLOT_MAINHAND, + ); + item.set_enchantment(EnchantmentSlot::EnhancementPermanent, 904, 0, 0); + item.set_enchantment(EnchantmentSlot::EnhancementTemporary, 903, 6_000, 0); + session.insert_inventory_item_object(item); + + let plans = session.apply_loaded_equipped_item_enchantments_like_cpp(item_guid); + + assert_eq!(plans.len(), 2); + assert!(plans.iter().any(|plan| matches!( + plan.result, + ApplyEnchantmentResult::Applied { + item_guid: applied_item_guid, + slot: EnchantmentSlot::EnhancementPermanent, + enchantment_id: 904, + apply: true, + update_permanent_visible_item: true, + .. + } if applied_item_guid == item_guid + ))); + assert!(plans.iter().any(|plan| matches!( + plan.result, + ApplyEnchantmentResult::Applied { + item_guid: applied_item_guid, + slot: EnchantmentSlot::EnhancementTemporary, + enchantment_id: 903, + apply: true, + duration_action: Some(ApplyEnchantmentDurationAction::Added( + PlayerEnchantTimeUpdate { + item_guid: duration_item_guid, + slot: EnchantmentSlot::EnhancementTemporary, + duration_secs: 6, + } + )), + .. + } if applied_item_guid == item_guid && duration_item_guid == item_guid + ))); + assert_eq!( + session + .canonical_player_snapshot_like_cpp(|player| player.enchant_durations().to_vec()), + Some(vec![PlayerEnchantDuration { + item_guid, + slot: EnchantmentSlot::EnhancementTemporary, + left_duration_ms: 6_000, + }]) + ); + assert_eq!( + send_rx.try_recv().unwrap(), + ItemEnchantTimeUpdate { + owner_guid: player_guid, + item_guid, + duration_left: 6, + slot: EnchantmentSlot::EnhancementTemporary as u32, + } + .to_bytes() + ); + } + #[test] fn send_new_item_plan_maps_entity_fields_to_item_push_result_like_cpp() { let plan = send_new_item_plan(SendNewItemDelivery::Direct); From 21ad0de4ded49a9e819b7f9bf34a26a5b40ad3bb Mon Sep 17 00:00:00 2001 From: cDmonio Date: Sat, 4 Jul 2026 09:23:01 +0000 Subject: [PATCH 3/5] Fix loaded item enchant review gaps --- crates/wow-world/src/handlers/character.rs | 192 ++++++++++++++++++--- crates/wow-world/src/session.rs | 161 ++++++++++++++++- 2 files changed, 326 insertions(+), 27 deletions(-) diff --git a/crates/wow-world/src/handlers/character.rs b/crates/wow-world/src/handlers/character.rs index 40021a35..c10c479f 100644 --- a/crates/wow-world/src/handlers/character.rs +++ b/crates/wow-world/src/handlers/character.rs @@ -101,7 +101,7 @@ fn primary_max_power_for_class_like_cpp(class_id: u8, max_mana: i64) -> i32 { fn apply_loaded_item_instance_fields_like_cpp( item: &mut wow_entities::Item, - enchantments: Option<&[ItemEnchantmentValuesUpdate; wow_entities::MAX_ENCHANTMENT_SLOT]>, + enchantments: &[ItemEnchantmentValuesUpdate; wow_entities::MAX_ENCHANTMENT_SLOT], random_properties_id: i32, random_properties_seed: i32, ) { @@ -110,20 +110,53 @@ fn apply_loaded_item_instance_fields_like_cpp( item.set_property_seed(random_properties_seed); } - if let Some(enchantments) = enchantments { - for (slot_index, enchantment) in enchantments.iter().enumerate() { - let Some(slot) = ::from_usize(slot_index) - else { - continue; - }; - item.set_enchantment( - slot, - enchantment.id, - enchantment.duration, - enchantment.charges, - ); + for (slot_index, enchantment) in enchantments.iter().enumerate() { + let Some(slot) = ::from_usize(slot_index) + else { + continue; + }; + item.set_enchantment( + slot, + enchantment.id, + enchantment.duration, + enchantment.charges, + ); + } +} + +fn loaded_item_effective_enchantments_like_cpp( + loaded_enchantments: Option<&[ItemEnchantmentValuesUpdate; wow_entities::MAX_ENCHANTMENT_SLOT]>, + random_properties_id: i32, + item_random_properties_store: Option<&wow_data::ItemRandomPropertiesStore>, + item_random_suffix_store: Option<&wow_data::ItemRandomSuffixStore>, +) -> [ItemEnchantmentValuesUpdate; wow_entities::MAX_ENCHANTMENT_SLOT] { + let mut values = [ItemEnchantmentValuesUpdate::default(); wow_entities::MAX_ENCHANTMENT_SLOT]; + + if random_properties_id > 0 { + if let Some(entry) = + item_random_properties_store.and_then(|store| store.get(random_properties_id as u32)) + { + for (offset, enchantment_id) in entry.enchantments.iter().take(3).enumerate() { + values[EnchantmentSlot::Property2 as usize + offset].id = + i32::from(*enchantment_id); + } } + } else if random_properties_id < 0 { + if let Some(entry) = item_random_suffix_store + .and_then(|store| store.get(random_properties_id.unsigned_abs())) + { + for (offset, enchantment_id) in entry.enchantments.iter().take(3).enumerate() { + values[EnchantmentSlot::Property0 as usize + offset].id = + i32::from(*enchantment_id); + } + } + } + + if let Some(loaded_enchantments) = loaded_enchantments { + values = *loaded_enchantments; } + + values } fn loaded_item_enchantments_like_cpp( @@ -4646,12 +4679,16 @@ impl WorldSession { eq_result.try_read::(8).unwrap_or_default(); let item_enchantment_values = loaded_item_enchantments_like_cpp(&item_enchantments); - let item_create_enchantments = item_enchantment_values - .as_ref() - .copied() - .unwrap_or([ItemEnchantmentValuesUpdate::default(); 13]); let random_properties_id = eq_result.try_read::(9).unwrap_or(0); let random_properties_seed = eq_result.try_read::(10).unwrap_or(0); + let item_create_enchantments = + loaded_item_effective_enchantments_like_cpp( + item_enchantment_values.as_ref(), + random_properties_id, + self.item_random_properties_store() + .map(|store| store.as_ref()), + self.item_random_suffix_store().map(|store| store.as_ref()), + ); let refund_decision = loaded_item_refund_decision( item_flags, item_played_time, @@ -4746,7 +4783,7 @@ impl WorldSession { item_object.set_create_played_time(item_played_time); apply_loaded_item_instance_fields_like_cpp( &mut item_object, - item_enchantment_values.as_ref(), + &item_create_enchantments, random_properties_id, random_properties_seed, ); @@ -4822,14 +4859,18 @@ impl WorldSession { bag_result.try_read::(9).unwrap_or_default(); let item_enchantment_values = loaded_item_enchantments_like_cpp(&item_enchantments); - let item_create_enchantments = item_enchantment_values - .as_ref() - .copied() - .unwrap_or([ItemEnchantmentValuesUpdate::default(); 13]); let random_properties_id = bag_result.try_read::(10).unwrap_or(0); let random_properties_seed = bag_result.try_read::(11).unwrap_or(0); + let item_create_enchantments = + loaded_item_effective_enchantments_like_cpp( + item_enchantment_values.as_ref(), + random_properties_id, + self.item_random_properties_store() + .map(|store| store.as_ref()), + self.item_random_suffix_store().map(|store| store.as_ref()), + ); if item_entry > 0 && is_represented_bag_slot(bag_slot) { if let Some(bag_item_guid) = self .inventory_items_like_cpp() @@ -4853,7 +4894,7 @@ impl WorldSession { item_object.set_create_played_time(item_played_time); apply_loaded_item_instance_fields_like_cpp( &mut item_object, - item_enchantment_values.as_ref(), + &item_create_enchantments, random_properties_id, random_properties_seed, ); @@ -14254,8 +14295,10 @@ mod tests { ]); let enchantments = loaded_item_enchantments_like_cpp(&enchantments).expect("valid C++ enchantment string"); + let effective_enchantments = + loaded_item_effective_enchantments_like_cpp(Some(&enchantments), -77, None, None); - apply_loaded_item_instance_fields_like_cpp(&mut item, Some(&enchantments), -77, 456); + apply_loaded_item_instance_fields_like_cpp(&mut item, &effective_enchantments, -77, 456); assert_eq!(item.data().random_properties_id, -77); assert_eq!(item.data().property_seed, 456); @@ -14282,8 +14325,10 @@ mod tests { let mut item = wow_entities::Item::default(); let enchantments = loaded_item_enchantments_like_cpp("2673 0 0"); assert!(enchantments.is_none()); + let effective_enchantments = + loaded_item_effective_enchantments_like_cpp(enchantments.as_ref(), 0, None, None); - apply_loaded_item_instance_fields_like_cpp(&mut item, enchantments.as_ref(), 0, 0); + apply_loaded_item_instance_fields_like_cpp(&mut item, &effective_enchantments, 0, 0); assert!( item.data() @@ -14295,6 +14340,103 @@ mod tests { assert_eq!(item.data().property_seed, 0); } + #[test] + fn loaded_item_instance_fields_rebuild_random_suffix_slots_like_cpp() { + let mut item = wow_entities::Item::default(); + let suffixes = + wow_data::ItemRandomSuffixStore::from_entries([wow_data::ItemRandomSuffixEntry { + id: 77, + enchantments: [901, 902, 903, 904, 905], + allocation_pct: [1000, 2000, 3000, 4000, 5000], + }]); + let effective_enchantments = + loaded_item_effective_enchantments_like_cpp(None, -77, None, Some(&suffixes)); + + apply_loaded_item_instance_fields_like_cpp(&mut item, &effective_enchantments, -77, 456); + + assert_eq!(item.data().random_properties_id, -77); + assert_eq!(item.data().property_seed, 456); + assert_eq!( + item.data().enchantments[EnchantmentSlot::Property0 as usize].id, + 901 + ); + assert_eq!( + item.data().enchantments[EnchantmentSlot::Property1 as usize].id, + 902 + ); + assert_eq!( + item.data().enchantments[EnchantmentSlot::Property2 as usize].id, + 903 + ); + assert_eq!( + item.data().enchantments[EnchantmentSlot::Property3 as usize].id, + 0 + ); + } + + #[test] + fn loaded_item_instance_fields_rebuild_random_property_slots_like_cpp() { + let mut item = wow_entities::Item::default(); + let properties = wow_data::ItemRandomPropertiesStore::from_entries([ + wow_data::ItemRandomPropertiesEntry { + id: 77, + enchantments: [1001, 1002, 1003, 1004, 1005], + }, + ]); + let effective_enchantments = + loaded_item_effective_enchantments_like_cpp(None, 77, Some(&properties), None); + + apply_loaded_item_instance_fields_like_cpp(&mut item, &effective_enchantments, 77, 0); + + assert_eq!(item.data().random_properties_id, 77); + assert_eq!( + item.data().enchantments[EnchantmentSlot::Property0 as usize].id, + 0 + ); + assert_eq!( + item.data().enchantments[EnchantmentSlot::Property2 as usize].id, + 1001 + ); + assert_eq!( + item.data().enchantments[EnchantmentSlot::Property3 as usize].id, + 1002 + ); + assert_eq!( + item.data().enchantments[EnchantmentSlot::Property4 as usize].id, + 1003 + ); + } + + #[test] + fn loaded_item_db_enchantments_override_random_property_slots_like_cpp() { + let properties = wow_data::ItemRandomPropertiesStore::from_entries([ + wow_data::ItemRandomPropertiesEntry { + id: 77, + enchantments: [1001, 1002, 1003, 0, 0], + }, + ]); + let enchantments = + test_item_enchantments_db_string(&[(EnchantmentSlot::Property2 as usize, 555, 0, 0)]); + let enchantments = + loaded_item_enchantments_like_cpp(&enchantments).expect("valid C++ enchantment string"); + + let effective_enchantments = loaded_item_effective_enchantments_like_cpp( + Some(&enchantments), + 77, + Some(&properties), + None, + ); + + assert_eq!( + effective_enchantments[EnchantmentSlot::Property2 as usize].id, + 555 + ); + assert_eq!( + effective_enchantments[EnchantmentSlot::Property3 as usize].id, + 0 + ); + } + #[test] fn sql_creature_template_speed_defaults_match_cpp_check_creature_template() { assert_eq!(normalize_creature_template_speed_walk_like_cpp(0.0), 1.0); diff --git a/crates/wow-world/src/session.rs b/crates/wow-world/src/session.rs index bfcff9a0..98eb66fc 100644 --- a/crates/wow-world/src/session.rs +++ b/crates/wow-world/src/session.rs @@ -16439,6 +16439,26 @@ impl WorldSession { true } + fn represented_item_bonus_action_updates_stats_like_cpp( + action: ApplyEnchantmentEffectAction, + ) -> bool { + matches!( + action, + ApplyEnchantmentEffectAction::UnitModifier { .. } + | ApplyEnchantmentEffectAction::UpdateStatBuffMod(_) + | ApplyEnchantmentEffectAction::RatingModifier { .. } + | ApplyEnchantmentEffectAction::ManaRegenBonus { .. } + | ApplyEnchantmentEffectAction::SpellPowerBonus { .. } + | ApplyEnchantmentEffectAction::HealthRegenBonus { .. } + | ApplyEnchantmentEffectAction::SpellPenetrationBonus { .. } + | ApplyEnchantmentEffectAction::BaseModFlatValue { .. } + | ApplyEnchantmentEffectAction::SetShieldBlockValue { .. } + | ApplyEnchantmentEffectAction::SetBaseWeaponDamage { .. } + | ApplyEnchantmentEffectAction::SetBaseAttackTime { .. } + | ApplyEnchantmentEffectAction::UpdateDamagePhysical { .. } + ) + } + fn apply_represented_item_bonus_action_state_like_cpp( &mut self, action: ApplyEnchantmentEffectAction, @@ -20339,6 +20359,7 @@ impl WorldSession { let mut plans = Vec::new(); let mut duration_updates = Vec::new(); + let mut send_stat_update = false; for slot in slots { if let Some(plan) = self.apply_current_player_item_enchantment_plan_like_cpp( item_guid, @@ -20346,11 +20367,27 @@ impl WorldSession { ApplyEnchantmentArgs::apply(), ) { if let ApplyEnchantmentResult::Applied { - duration_action: Some(ApplyEnchantmentDurationAction::Added(duration_update)), + enchantment_id, + apply, + effects_allowed, + duration_action, .. } = plan.result { - duration_updates.push(duration_update); + if let Some(ApplyEnchantmentDurationAction::Added(duration_update)) = + duration_action + { + duration_updates.push(duration_update); + } + if effects_allowed { + send_stat_update |= self + .apply_loaded_equipped_item_enchantment_effects_like_cpp( + item_guid, + slot, + enchantment_id, + apply, + ); + } } plans.push(plan); } @@ -20358,9 +20395,57 @@ impl WorldSession { if let Some(owner_guid) = self.player_guid() { self.send_item_enchant_time_update_plans(owner_guid, &duration_updates); } + if send_stat_update { + self.send_represented_item_bonus_player_stat_update_like_cpp(); + } plans } + fn apply_loaded_equipped_item_enchantment_effects_like_cpp( + &mut self, + item_guid: ObjectGuid, + slot: EnchantmentSlot, + enchantment_id: i32, + apply: bool, + ) -> bool { + let Some(item) = self + .inventory_item_objects_like_cpp() + .get(&item_guid) + .cloned() + else { + return false; + }; + let Some(effects) = u32::try_from(enchantment_id) + .ok() + .and_then(|enchantment_id| self.apply_enchantment_effect_refs(enchantment_id)) + else { + return false; + }; + let item_template = self.item_storage_template(item.object().entry()); + let random_suffix = + self.apply_enchantment_random_suffix_ref(item.data().random_properties_id); + let actions = self + .mutate_canonical_player_like_cpp(|player| { + player.apply_enchantment_effect_actions_for_enchantment( + &item, + item_template.as_ref(), + slot, + enchantment_id, + random_suffix, + apply, + &effects, + ) + }) + .unwrap_or_default(); + + let mut changed_stats = false; + for action in actions { + changed_stats |= Self::represented_item_bonus_action_updates_stats_like_cpp(action); + self.apply_represented_item_bonus_action_state_like_cpp(action); + } + changed_stats + } + /// Set the hotfix blob cache for this session. pub fn set_hotfix_blob_cache(&mut self, cache: Arc) { self.hotfix_blob_cache = Some(cache); @@ -116402,6 +116487,78 @@ mod tests { ); } + #[test] + fn loaded_equipped_item_enchantments_apply_effect_actions_like_cpp() { + let (mut session, _, send_rx) = make_session(); + let canonical = shared_canonical_map_manager(); + let player_guid = ObjectGuid::create_player(1, 90_522); + let player_position = Position::new(1.0, 2.0, 3.0, 0.0); + let item_guid = ObjectGuid::create_item(1, 90_523); + session.set_player_guid(Some(player_guid)); + session.set_loaded_player_identity_like_cpp(571, 1, 1, 80, 0); + session.set_canonical_map_manager(Arc::clone(&canonical)); + add_canonical_test_player_on_map(&canonical, player_guid, player_position, 571, 0); + session + .mutate_canonical_player_like_cpp(|player| player.unit_mut().set_level(80)) + .unwrap(); + session.set_spell_item_enchantment_store(Arc::new( + SpellItemEnchantmentStore::from_entries([SpellItemEnchantmentEntry { + id: 906, + effect_arg: [ItemModType::Health as u32, 0, 0], + effect_points_min: [17, 0, 0], + item_visual: 0, + flags: SpellItemEnchantmentFlags::empty(), + required_skill_id: 0, + required_skill_rank: 0, + item_level: 1, + charges: 0, + effect: [ + ItemEnchantmentType::Stat as u8, + ItemEnchantmentType::None as u8, + ItemEnchantmentType::None as u8, + ], + condition_id: 0, + min_level: 1, + max_level: 0, + }]), + )); + + let mut item = session.make_inventory_item_object( + item_guid, + 701, + player_guid, + 1, + 0, + ItemContext::None, + EQUIPMENT_SLOT_CHEST, + ); + item.set_enchantment(EnchantmentSlot::EnhancementPermanent, 906, 0, 0); + session.insert_inventory_item_object(item); + + let plans = session.apply_loaded_equipped_item_enchantments_like_cpp(item_guid); + + assert!(matches!( + plans.first().map(|plan| plan.result), + Some(ApplyEnchantmentResult::Applied { + enchantment_id: 906, + apply: true, + effects_allowed: true, + .. + }) + )); + assert_eq!( + session.represented_item_bonus_state_like_cpp().health_base, + 17 + ); + assert!( + drain_server_packet_bytes(&send_rx) + .iter() + .any(|bytes| WorldPacket::from_bytes(bytes).server_opcode() + == Some(ServerOpcodes::UpdateObject)), + "loaded enchantment effects send a represented player stat update" + ); + } + #[test] fn send_new_item_plan_maps_entity_fields_to_item_push_result_like_cpp() { let plan = send_new_item_plan(SendNewItemDelivery::Direct); From 36068b5ee5f16682c7b0e9e10460492529f12b34 Mon Sep 17 00:00:00 2001 From: cDmonio Date: Sat, 4 Jul 2026 09:53:14 +0000 Subject: [PATCH 4/5] Fix loaded enchant login replay order --- crates/wow-world/src/handlers/character.rs | 43 +++++-- crates/wow-world/src/session.rs | 131 ++++++++++++++++++--- 2 files changed, 150 insertions(+), 24 deletions(-) diff --git a/crates/wow-world/src/handlers/character.rs b/crates/wow-world/src/handlers/character.rs index c10c479f..52e40906 100644 --- a/crates/wow-world/src/handlers/character.rs +++ b/crates/wow-world/src/handlers/character.rs @@ -40,8 +40,9 @@ use wow_entities::{ GAMEOBJECT_TYPE_QUESTGIVER, GameObjectTemplateData, INVENTORY_DEFAULT_SIZE, INVENTORY_SLOT_BAG_0, INVENTORY_SLOT_BAG_END, INVENTORY_SLOT_BAG_START, INVENTORY_SLOT_ITEM_START, MAX_BAG_SIZE, MAX_GAMEOBJECT_DATA, MovementGeneratorType, NULL_BAG, - NULL_SLOT, REAGENT_BAG_SLOT_END, REAGENT_BAG_SLOT_START, WorldObject, is_equipment_pos, - is_inventory_pos, normalize_creature_chase_movement_type_like_cpp, + NULL_SLOT, PROFESSION_SLOT_END, PROFESSION_SLOT_START, REAGENT_BAG_SLOT_END, + REAGENT_BAG_SLOT_START, WorldObject, is_equipment_pos, is_inventory_pos, + normalize_creature_chase_movement_type_like_cpp, normalize_creature_random_movement_type_like_cpp, }; use wow_handler::{PacketHandlerEntry, PacketProcessing, SessionStatus}; @@ -64,7 +65,8 @@ use crate::session::{ ALL_ACCOUNT_DATA_CACHE_MASK_LIKE_CPP, CharacterPetAuraEffectRowLikeCpp, CharacterPetAuraRowLikeCpp, CharacterPetDeclinedNamesRowLikeCpp, CharacterPetSpellChargeRowLikeCpp, CharacterPetSpellCooldownRowLikeCpp, - CharacterPetSpellRowLikeCpp, CharacterPetStableRowLikeCpp, RepresentedAlterAppearanceLikeCpp, + CharacterPetSpellRowLikeCpp, CharacterPetStableRowLikeCpp, + LoadedEquippedItemEnchantmentsOutcomeLikeCpp, RepresentedAlterAppearanceLikeCpp, RepresentedBankItemMoveLikeCpp, RepresentedConfirmBarbersChoiceLikeCpp, RepresentedGameObjectUseState, }; @@ -124,6 +126,10 @@ fn apply_loaded_item_instance_fields_like_cpp( } } +fn loaded_item_slot_applies_equipped_enchantments_like_cpp(slot: u8) -> bool { + slot < EQUIPMENT_SLOT_END || (PROFESSION_SLOT_START..PROFESSION_SLOT_END).contains(&slot) +} + fn loaded_item_effective_enchantments_like_cpp( loaded_enchantments: Option<&[ItemEnchantmentValuesUpdate; wow_entities::MAX_ENCHANTMENT_SLOT]>, random_properties_id: i32, @@ -4804,13 +4810,16 @@ impl WorldSession { &item_object, ); item_object.set_state(ItemUpdateState::Unchanged); + let visible_item_fields = ((slot as usize) < 19).then(|| { + self.loaded_inventory_item_visible_fields_like_cpp(&item_object) + }); self.insert_inventory_item_object(item_object); - if slot < EQUIPMENT_SLOT_END { + if loaded_item_slot_applies_equipped_enchantments_like_cpp(slot) { loaded_equipped_item_guids.push(item_guid); } // Slots 0-18 also populate VisibleItems for character model - if (slot as usize) < 19 { - visible_items[slot as usize] = (item_entry as i32, 0, 0); + if let Some(fields) = visible_item_fields { + visible_items[slot as usize] = fields; } } if !eq_result.next_row() { @@ -4959,8 +4968,11 @@ impl WorldSession { // inventory_type is now loaded from the canonical ItemTemplate bridge. // No SQL cache needed. } + let mut loaded_enchantment_updates = + LoadedEquippedItemEnchantmentsOutcomeLikeCpp::default(); for item_guid in loaded_equipped_item_guids { - let _ = self.apply_loaded_equipped_item_enchantments_like_cpp(item_guid); + loaded_enchantment_updates + .append(self.apply_loaded_equipped_item_enchantments_like_cpp(item_guid)); } self.sync_player_inventory_like_cpp(); @@ -5914,6 +5926,7 @@ impl WorldSession { self.account_mount_rows_like_cpp(), ) .await; + self.send_loaded_equipped_item_enchantment_updates_like_cpp(&loaded_enchantment_updates); self.apply_represented_login_spell_reset_if_needed_like_cpp(); self.apply_represented_login_talent_reset_if_needed_like_cpp(); if self.apply_represented_first_login_flag_if_needed_like_cpp() { @@ -14437,6 +14450,22 @@ mod tests { ); } + #[test] + fn loaded_item_slots_apply_equipped_enchantments_for_equipment_and_profession_like_cpp() { + assert!(loaded_item_slot_applies_equipped_enchantments_like_cpp( + EQUIPMENT_SLOT_END - 1 + )); + assert!(loaded_item_slot_applies_equipped_enchantments_like_cpp( + PROFESSION_SLOT_START + )); + assert!(loaded_item_slot_applies_equipped_enchantments_like_cpp( + PROFESSION_SLOT_END - 1 + )); + assert!(!loaded_item_slot_applies_equipped_enchantments_like_cpp( + INVENTORY_SLOT_BAG_START + )); + } + #[test] fn sql_creature_template_speed_defaults_match_cpp_check_creature_template() { assert_eq!(normalize_creature_template_speed_walk_like_cpp(0.0), 1.0); diff --git a/crates/wow-world/src/session.rs b/crates/wow-world/src/session.rs index 98eb66fc..b5173579 100644 --- a/crates/wow-world/src/session.rs +++ b/crates/wow-world/src/session.rs @@ -3242,6 +3242,24 @@ impl RepresentedCharacterSpellChargeLikeCpp { } } +#[derive(Debug, Clone, Default)] +pub(crate) struct LoadedEquippedItemEnchantmentsOutcomeLikeCpp { + pub plans: Vec, + pub duration_updates: Vec, + pub send_stat_update: bool, + pub visible_item_changes: Vec<(u8, i32, u16, u16)>, +} + +impl LoadedEquippedItemEnchantmentsOutcomeLikeCpp { + pub(crate) fn append(&mut self, mut other: Self) { + self.plans.append(&mut other.plans); + self.duration_updates.append(&mut other.duration_updates); + self.send_stat_update |= other.send_stat_update; + self.visible_item_changes + .append(&mut other.visible_item_changes); + } +} + #[allow(dead_code)] fn represented_skill_records_from_values_like_cpp( skill_values: &HashMap, @@ -20338,7 +20356,7 @@ impl WorldSession { pub(crate) fn apply_loaded_equipped_item_enchantments_like_cpp( &mut self, item_guid: ObjectGuid, - ) -> Vec { + ) -> LoadedEquippedItemEnchantmentsOutcomeLikeCpp { let slots = self .inventory_item_objects_like_cpp() .get(&item_guid) @@ -20357,9 +20375,7 @@ impl WorldSession { }) .unwrap_or_default(); - let mut plans = Vec::new(); - let mut duration_updates = Vec::new(); - let mut send_stat_update = false; + let mut outcome = LoadedEquippedItemEnchantmentsOutcomeLikeCpp::default(); for slot in slots { if let Some(plan) = self.apply_current_player_item_enchantment_plan_like_cpp( item_guid, @@ -20370,6 +20386,7 @@ impl WorldSession { enchantment_id, apply, effects_allowed, + update_permanent_visible_item, duration_action, .. } = plan.result @@ -20377,10 +20394,10 @@ impl WorldSession { if let Some(ApplyEnchantmentDurationAction::Added(duration_update)) = duration_action { - duration_updates.push(duration_update); + outcome.duration_updates.push(duration_update); } if effects_allowed { - send_stat_update |= self + outcome.send_stat_update |= self .apply_loaded_equipped_item_enchantment_effects_like_cpp( item_guid, slot, @@ -20388,17 +20405,67 @@ impl WorldSession { apply, ); } + if update_permanent_visible_item + && let Some(visible_item_update) = + self.loaded_inventory_item_visible_update_like_cpp(item_guid) + { + outcome.visible_item_changes.push(visible_item_update); + } } - plans.push(plan); + outcome.plans.push(plan); } } + outcome + } + + pub(crate) fn send_loaded_equipped_item_enchantment_updates_like_cpp( + &self, + outcome: &LoadedEquippedItemEnchantmentsOutcomeLikeCpp, + ) { if let Some(owner_guid) = self.player_guid() { - self.send_item_enchant_time_update_plans(owner_guid, &duration_updates); + self.send_item_enchant_time_update_plans(owner_guid, &outcome.duration_updates); + } + if !outcome.visible_item_changes.is_empty() { + self.send_player_values_update_from_entity_bridge( + &[], + &outcome.visible_item_changes, + &[], + &[], + None, + ); } - if send_stat_update { + if outcome.send_stat_update { self.send_represented_item_bonus_player_stat_update_like_cpp(); } - plans + } + + pub(crate) fn loaded_inventory_item_visible_fields_like_cpp( + &self, + item: &Item, + ) -> (i32, u16, u16) { + ( + item.object().entry() as i32, + 0, + item.visible_item_visual(0, |enchantment_id| { + self.spell_item_enchantment_store() + .and_then(|store| store.get(enchantment_id)) + .map(|entry| entry.item_visual) + }), + ) + } + + fn loaded_inventory_item_visible_update_like_cpp( + &self, + item_guid: ObjectGuid, + ) -> Option<(u8, i32, u16, u16)> { + let item = self.inventory_item_objects_like_cpp().get(&item_guid)?; + let slot = item.slot(); + if slot >= EQUIPMENT_SLOT_END { + return None; + } + let (item_id, appearance_mod_id, item_visual) = + self.loaded_inventory_item_visible_fields_like_cpp(item); + Some((slot, item_id, appearance_mod_id, item_visual)) } fn apply_loaded_equipped_item_enchantment_effects_like_cpp( @@ -116408,7 +116475,7 @@ mod tests { id: 904, effect_arg: [0; 3], effect_points_min: [0; 3], - item_visual: 0, + item_visual: 44, flags: SpellItemEnchantmentFlags::empty(), required_skill_id: 0, required_skill_rank: 0, @@ -116435,10 +116502,10 @@ mod tests { item.set_enchantment(EnchantmentSlot::EnhancementTemporary, 903, 6_000, 0); session.insert_inventory_item_object(item); - let plans = session.apply_loaded_equipped_item_enchantments_like_cpp(item_guid); + let outcome = session.apply_loaded_equipped_item_enchantments_like_cpp(item_guid); - assert_eq!(plans.len(), 2); - assert!(plans.iter().any(|plan| matches!( + assert_eq!(outcome.plans.len(), 2); + assert!(outcome.plans.iter().any(|plan| matches!( plan.result, ApplyEnchantmentResult::Applied { item_guid: applied_item_guid, @@ -116449,7 +116516,7 @@ mod tests { .. } if applied_item_guid == item_guid ))); - assert!(plans.iter().any(|plan| matches!( + assert!(outcome.plans.iter().any(|plan| matches!( plan.result, ApplyEnchantmentResult::Applied { item_guid: applied_item_guid, @@ -116466,6 +116533,18 @@ mod tests { .. } if applied_item_guid == item_guid && duration_item_guid == item_guid ))); + assert_eq!( + outcome.visible_item_changes, + vec![(EQUIPMENT_SLOT_MAINHAND, 700, 0, 44)] + ); + assert_eq!( + outcome.duration_updates, + vec![PlayerEnchantTimeUpdate { + item_guid, + slot: EnchantmentSlot::EnhancementTemporary, + duration_secs: 6, + }] + ); assert_eq!( session .canonical_player_snapshot_like_cpp(|player| player.enchant_durations().to_vec()), @@ -116475,6 +116554,11 @@ mod tests { left_duration_ms: 6_000, }]) ); + assert!( + send_rx.try_recv().is_err(), + "loaded enchant replay queues packets until after login CREATE" + ); + session.send_loaded_equipped_item_enchantment_updates_like_cpp(&outcome); assert_eq!( send_rx.try_recv().unwrap(), ItemEnchantTimeUpdate { @@ -116485,6 +116569,13 @@ mod tests { } .to_bytes() ); + assert!( + drain_server_packet_bytes(&send_rx) + .iter() + .any(|bytes| WorldPacket::from_bytes(bytes).server_opcode() + == Some(ServerOpcodes::UpdateObject)), + "permanent enchant visual update is emitted after login CREATE" + ); } #[test] @@ -116535,10 +116626,10 @@ mod tests { item.set_enchantment(EnchantmentSlot::EnhancementPermanent, 906, 0, 0); session.insert_inventory_item_object(item); - let plans = session.apply_loaded_equipped_item_enchantments_like_cpp(item_guid); + let outcome = session.apply_loaded_equipped_item_enchantments_like_cpp(item_guid); assert!(matches!( - plans.first().map(|plan| plan.result), + outcome.plans.first().map(|plan| plan.result), Some(ApplyEnchantmentResult::Applied { enchantment_id: 906, apply: true, @@ -116550,6 +116641,12 @@ mod tests { session.represented_item_bonus_state_like_cpp().health_base, 17 ); + assert!(outcome.send_stat_update); + assert!( + send_rx.try_recv().is_err(), + "loaded enchant stat update is queued until after login CREATE" + ); + session.send_loaded_equipped_item_enchantment_updates_like_cpp(&outcome); assert!( drain_server_packet_bytes(&send_rx) .iter() From d0fad19d5554ff380909c8a699e5db38b6d4e619 Mon Sep 17 00:00:00 2001 From: cDmonio Date: Sat, 4 Jul 2026 10:26:05 +0000 Subject: [PATCH 5/5] Fix loaded enchant review follow-ups --- crates/wow-world/src/handlers/character.rs | 51 ++++- crates/wow-world/src/session.rs | 211 +++++++++++++++++++-- 2 files changed, 237 insertions(+), 25 deletions(-) diff --git a/crates/wow-world/src/handlers/character.rs b/crates/wow-world/src/handlers/character.rs index 52e40906..bee5b281 100644 --- a/crates/wow-world/src/handlers/character.rs +++ b/crates/wow-world/src/handlers/character.rs @@ -158,7 +158,11 @@ fn loaded_item_effective_enchantments_like_cpp( } } - if let Some(loaded_enchantments) = loaded_enchantments { + if let Some(loaded_enchantments) = loaded_enchantments + && loaded_enchantments.iter().any(|enchantment| { + enchantment.id != 0 || enchantment.duration != 0 || enchantment.charges != 0 + }) + { values = *loaded_enchantments; } @@ -4968,12 +4972,6 @@ impl WorldSession { // inventory_type is now loaded from the canonical ItemTemplate bridge. // No SQL cache needed. } - let mut loaded_enchantment_updates = - LoadedEquippedItemEnchantmentsOutcomeLikeCpp::default(); - for item_guid in loaded_equipped_item_guids { - loaded_enchantment_updates - .append(self.apply_loaded_equipped_item_enchantments_like_cpp(item_guid)); - } self.sync_player_inventory_like_cpp(); // ── Load equipment sets / transmog outfits ── @@ -5387,6 +5385,12 @@ impl WorldSession { if loaded_skill_records_like_cpp { self.set_player_skill_records_like_cpp(skill_records); } + let mut loaded_enchantment_updates = + LoadedEquippedItemEnchantmentsOutcomeLikeCpp::default(); + for item_guid in loaded_equipped_item_guids { + loaded_enchantment_updates + .append(self.apply_loaded_equipped_item_enchantments_like_cpp(item_guid)); + } // ── Load talents from character_talent ── // C++ `Player::LoadFromDB` calls `_LoadTalents` before `_LoadSpells`; @@ -14420,6 +14424,39 @@ mod tests { ); } + #[test] + fn loaded_item_instance_fields_rebuild_random_property_slots_for_zero_db_string_like_cpp() { + let properties = wow_data::ItemRandomPropertiesStore::from_entries([ + wow_data::ItemRandomPropertiesEntry { + id: 77, + enchantments: [1001, 1002, 1003, 0, 0], + }, + ]); + let enchantments = test_item_enchantments_db_string(&[]); + let enchantments = + loaded_item_enchantments_like_cpp(&enchantments).expect("valid C++ enchantment string"); + + let effective_enchantments = loaded_item_effective_enchantments_like_cpp( + Some(&enchantments), + 77, + Some(&properties), + None, + ); + + assert_eq!( + effective_enchantments[EnchantmentSlot::Property2 as usize].id, + 1001 + ); + assert_eq!( + effective_enchantments[EnchantmentSlot::Property3 as usize].id, + 1002 + ); + assert_eq!( + effective_enchantments[EnchantmentSlot::Property4 as usize].id, + 1003 + ); + } + #[test] fn loaded_item_db_enchantments_override_random_property_slots_like_cpp() { let properties = wow_data::ItemRandomPropertiesStore::from_entries([ diff --git a/crates/wow-world/src/session.rs b/crates/wow-world/src/session.rs index b5173579..1c740bc8 100644 --- a/crates/wow-world/src/session.rs +++ b/crates/wow-world/src/session.rs @@ -3248,6 +3248,8 @@ pub(crate) struct LoadedEquippedItemEnchantmentsOutcomeLikeCpp { pub duration_updates: Vec, pub send_stat_update: bool, pub visible_item_changes: Vec<(u8, i32, u16, u16)>, + pub effect_actions: Vec, + pub unrepresented_effect_actions: Vec, } impl LoadedEquippedItemEnchantmentsOutcomeLikeCpp { @@ -3257,6 +3259,9 @@ impl LoadedEquippedItemEnchantmentsOutcomeLikeCpp { self.send_stat_update |= other.send_stat_update; self.visible_item_changes .append(&mut other.visible_item_changes); + self.effect_actions.append(&mut other.effect_actions); + self.unrepresented_effect_actions + .append(&mut other.unrepresented_effect_actions); } } @@ -20397,13 +20402,18 @@ impl WorldSession { outcome.duration_updates.push(duration_update); } if effects_allowed { - outcome.send_stat_update |= self - .apply_loaded_equipped_item_enchantment_effects_like_cpp( + let (changed_stats, mut effect_actions, mut unrepresented_effect_actions) = + self.apply_loaded_equipped_item_enchantment_effects_like_cpp( item_guid, slot, enchantment_id, apply, ); + outcome.send_stat_update |= changed_stats; + outcome.effect_actions.append(&mut effect_actions); + outcome + .unrepresented_effect_actions + .append(&mut unrepresented_effect_actions); } if update_permanent_visible_item && let Some(visible_item_update) = @@ -20474,19 +20484,23 @@ impl WorldSession { slot: EnchantmentSlot, enchantment_id: i32, apply: bool, - ) -> bool { + ) -> ( + bool, + Vec, + Vec, + ) { let Some(item) = self .inventory_item_objects_like_cpp() .get(&item_guid) .cloned() else { - return false; + return (false, Vec::new(), Vec::new()); }; let Some(effects) = u32::try_from(enchantment_id) .ok() .and_then(|enchantment_id| self.apply_enchantment_effect_refs(enchantment_id)) else { - return false; + return (false, Vec::new(), Vec::new()); }; let item_template = self.item_storage_template(item.object().entry()); let random_suffix = @@ -20506,11 +20520,43 @@ impl WorldSession { .unwrap_or_default(); let mut changed_stats = false; + let mut represented_actions = Vec::new(); + let mut unrepresented_actions = Vec::new(); for action in actions { + if matches!(action, ApplyEnchantmentEffectAction::Noop) { + continue; + } changed_stats |= Self::represented_item_bonus_action_updates_stats_like_cpp(action); + let represented_action = RepresentedItemBonusActionLikeCpp { + item_guid, + slot: slot as u8, + action, + }; + self.represented_item_bonus_actions_like_cpp + .push(represented_action.clone()); + if Self::loaded_enchantment_effect_action_is_unrepresented_like_cpp(action) { + unrepresented_actions.push(represented_action.clone()); + } + represented_actions.push(represented_action); self.apply_represented_item_bonus_action_state_like_cpp(action); } - changed_stats + (changed_stats, represented_actions, unrepresented_actions) + } + + fn loaded_enchantment_effect_action_is_unrepresented_like_cpp( + action: ApplyEnchantmentEffectAction, + ) -> bool { + matches!( + action, + ApplyEnchantmentEffectAction::DeferredCombatSpell + | ApplyEnchantmentEffectAction::DeferredUseSpell + | ApplyEnchantmentEffectAction::UpdateDamageDoneMods { .. } + | ApplyEnchantmentEffectAction::CastEquipSpell { .. } + | ApplyEnchantmentEffectAction::RemoveEquipSpellAura { .. } + | ApplyEnchantmentEffectAction::UnhandledStatModifier { .. } + | ApplyEnchantmentEffectAction::MissingItemTemplateForAttack { .. } + | ApplyEnchantmentEffectAction::Unknown { .. } + ) } /// Set the hotfix blob cache for this session. @@ -26199,12 +26245,7 @@ impl WorldSession { let class = self.player_class_like_cpp(); let gender = self.player_gender_like_cpp(); let level = self.player_level_like_cpp(); - let mut visible_items = [(0i32, 0u16, 0u16); 19]; - for (slot, item) in self.inventory_items_like_cpp() { - if (*slot as usize) < 19 { - visible_items[*slot as usize] = (item.entry_id as i32, 0u16, 0u16); - } - } + let visible_items = self.loaded_player_visible_items_for_create_like_cpp(); // Fallback to 0 (world/default instance) when no canonical map key is // available — mirrors C++ world-map phase where instance_id == 0. let instance_id = self @@ -48897,6 +48938,22 @@ impl WorldSession { .unwrap_or(RuntimeTickOwner::Session) } + fn loaded_player_visible_items_for_create_like_cpp(&self) -> [(i32, u16, u16); 19] { + let mut visible_items = [(0i32, 0u16, 0u16); 19]; + for (slot, item) in self.inventory_items_like_cpp() { + if (*slot as usize) < 19 { + visible_items[*slot as usize] = self + .inventory_item_objects_like_cpp() + .get(&item.guid) + .map(|item_object| { + self.loaded_inventory_item_visible_fields_like_cpp(item_object) + }) + .unwrap_or((item.entry_id as i32, 0u16, 0u16)); + } + } + visible_items + } + /// Broadcast the newly logged-in player's CREATE block to nearby players on the same map. /// /// Called after login is complete. Iterates through all players in the registry @@ -48931,12 +48988,7 @@ impl WorldSession { let level = self.player_level_like_cpp(); // Build visible_items from this player's equipped inventory. - let mut visible_items = [(0i32, 0u16, 0u16); 19]; - for (slot, item) in self.inventory_items_like_cpp() { - if (*slot as usize) < 19 { - visible_items[*slot as usize] = (item.entry_id as i32, 0u16, 0u16); - } - } + let visible_items = self.loaded_player_visible_items_for_create_like_cpp(); let empty_inv_slots = [ObjectGuid::EMPTY; 141]; let empty_skills = Vec::new(); @@ -84502,6 +84554,58 @@ mod tests { } } + #[test] + fn loaded_player_visible_items_for_create_includes_loaded_enchant_visual_like_cpp() { + let (mut session, _, _) = make_session(); + let player_guid = ObjectGuid::create_player(1, 90_526); + let item_guid = ObjectGuid::create_item(1, 90_527); + session.set_player_guid(Some(player_guid)); + session.set_spell_item_enchantment_store(Arc::new( + SpellItemEnchantmentStore::from_entries([SpellItemEnchantmentEntry { + id: 908, + effect_arg: [0; 3], + effect_points_min: [0; 3], + item_visual: 44, + flags: SpellItemEnchantmentFlags::empty(), + required_skill_id: 0, + required_skill_rank: 0, + item_level: 1, + charges: 0, + effect: [ItemEnchantmentType::None as u8; 3], + condition_id: 0, + min_level: 1, + max_level: 0, + }]), + )); + session.insert_inventory_item_like_cpp( + EQUIPMENT_SLOT_MAINHAND, + InventoryItem { + guid: item_guid, + entry_id: 700, + db_guid: 90_527, + inventory_type: Some(InventoryType::Weapon as u8), + }, + ); + let mut item = session.make_inventory_item_object( + item_guid, + 700, + player_guid, + 1, + 0, + ItemContext::None, + EQUIPMENT_SLOT_MAINHAND, + ); + item.set_enchantment(EnchantmentSlot::EnhancementPermanent, 908, 0, 0); + session.insert_inventory_item_object(item); + + let visible_items = session.loaded_player_visible_items_for_create_like_cpp(); + + assert_eq!( + visible_items[EQUIPMENT_SLOT_MAINHAND as usize], + (700, 0, 44) + ); + } + #[test] fn create_player_broadcast_skips_out_of_visibility_range_like_cpp() { let (mut session, _, _) = make_session(); @@ -116641,6 +116745,12 @@ mod tests { session.represented_item_bonus_state_like_cpp().health_base, 17 ); + assert_eq!(outcome.effect_actions.len(), 1); + assert!(matches!( + outcome.effect_actions[0].action, + ApplyEnchantmentEffectAction::UnitModifier { .. } + )); + assert!(outcome.unrepresented_effect_actions.is_empty()); assert!(outcome.send_stat_update); assert!( send_rx.try_recv().is_err(), @@ -116656,6 +116766,71 @@ mod tests { ); } + #[test] + fn loaded_equipped_item_enchantments_records_unrepresented_effect_actions_like_cpp() { + let (mut session, _, _) = make_session(); + let canonical = shared_canonical_map_manager(); + let player_guid = ObjectGuid::create_player(1, 90_524); + let player_position = Position::new(1.0, 2.0, 3.0, 0.0); + let item_guid = ObjectGuid::create_item(1, 90_525); + session.set_player_guid(Some(player_guid)); + session.set_loaded_player_identity_like_cpp(571, 1, 1, 80, 0); + session.set_canonical_map_manager(Arc::clone(&canonical)); + add_canonical_test_player_on_map(&canonical, player_guid, player_position, 571, 0); + session + .mutate_canonical_player_like_cpp(|player| player.unit_mut().set_level(80)) + .unwrap(); + session.set_spell_item_enchantment_store(Arc::new( + SpellItemEnchantmentStore::from_entries([SpellItemEnchantmentEntry { + id: 907, + effect_arg: [1234, 0, 0], + effect_points_min: [0, 0, 0], + item_visual: 0, + flags: SpellItemEnchantmentFlags::empty(), + required_skill_id: 0, + required_skill_rank: 0, + item_level: 1, + charges: 0, + effect: [ + ItemEnchantmentType::EquipSpell as u8, + ItemEnchantmentType::None as u8, + ItemEnchantmentType::None as u8, + ], + condition_id: 0, + min_level: 1, + max_level: 0, + }]), + )); + + let mut item = session.make_inventory_item_object( + item_guid, + 702, + player_guid, + 1, + 0, + ItemContext::None, + EQUIPMENT_SLOT_CHEST, + ); + item.set_enchantment(EnchantmentSlot::EnhancementPermanent, 907, 0, 0); + session.insert_inventory_item_object(item); + + let outcome = session.apply_loaded_equipped_item_enchantments_like_cpp(item_guid); + + assert!(matches!( + outcome.effect_actions.first().map(|action| action.action), + Some(ApplyEnchantmentEffectAction::CastEquipSpell { + spell_id: 1234, + item_guid: action_item_guid, + }) if action_item_guid == item_guid + )); + assert_eq!(outcome.unrepresented_effect_actions, outcome.effect_actions); + assert!(!outcome.send_stat_update); + assert_eq!( + session.represented_item_bonus_actions_like_cpp(), + outcome.effect_actions.as_slice() + ); + } + #[test] fn send_new_item_plan_maps_entity_fields_to_item_push_result_like_cpp() { let plan = send_new_item_plan(SendNewItemDelivery::Direct);