From 6c8b9d955f175a82a525fd90d8552445c2445164 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sat, 17 Oct 2026 18:23:05 -0400 Subject: [PATCH] L176: consumable cures, timed item buffs and CANCEL_ITEM_EFFECT Item specs now carry their duration, the stats they raise and the diseases they cure. USE_ITEM cures those diseases (All Cure and the single cures) and gives the stats as a buff of id -item_id for the spec's time, which the client lists with the item's icon. CANCEL_ITEM_EFFECT (0x49) ends it, and HeavenClient now sends it when an item buff's icon is right-clicked, as v83 does (FUN_0045b9fc / FUN_00a096af). --- HeavenClient/IO/UITypes/UIBuffList.cpp | 11 +- HeavenClient/Net/OutPacket.h | 1 + .../Net/Packets/AttackAndSkillPackets.h | 12 ++ game-data/src/items.rs | 54 +++++++ integration-harness/src/hunting.rs | 9 ++ integration-harness/tests/item_buffs.rs | 48 ++++++ net/src/handler.rs | 3 +- net/src/packet/handle/world/use_item.rs | 150 +++++++++++++++--- net/src/packet/handle/world/use_skill.rs | 8 +- net/src/packet/op/recv.rs | 1 + 10 files changed, 270 insertions(+), 27 deletions(-) create mode 100644 integration-harness/tests/item_buffs.rs diff --git a/HeavenClient/IO/UITypes/UIBuffList.cpp b/HeavenClient/IO/UITypes/UIBuffList.cpp index cbc5773d4..8af0c8d96 100644 --- a/HeavenClient/IO/UITypes/UIBuffList.cpp +++ b/HeavenClient/IO/UITypes/UIBuffList.cpp @@ -125,13 +125,18 @@ namespace ms void UIBuffList::rightclick(Point cursorpos) { // FUN_0045b9fc. The icon goes when the server cancels the buff. - // Dragon Roar's can't be cancelled. Item buffs would send - // CANCEL_ITEM_EFFECT, which the server doesn't handle. + // Dragon Roar's can't be cancelled. An item buff (negative id) sends + // CANCEL_ITEM_EFFECT; v83 instead refuses a morph item's while + // morphed (notice 0x10fc), which this client doesn't track. constexpr int32_t DRAGON_ROAR = 1311006; if (auto entry = entry_at(cursorpos)) - if (entry->id >= 0 && entry->id != DRAGON_ROAR) + { + if (entry->id < 0) + CancelItemEffectPacket(entry->id).dispatch(); + else if (entry->id != DRAGON_ROAR) CancelBuffPacket(entry->id).dispatch(); + } } UIElement::Type UIBuffList::get_type() const diff --git a/HeavenClient/Net/OutPacket.h b/HeavenClient/Net/OutPacket.h index 1fb19781f..c08c91bd0 100644 --- a/HeavenClient/Net/OutPacket.h +++ b/HeavenClient/Net/OutPacket.h @@ -97,6 +97,7 @@ namespace ms SORT_ITEMS = 70, MOVE_ITEM = 71, USE_ITEM = 72, + CANCEL_ITEM_EFFECT = 73, USE_SKILL_BOOK = 82, MESO_DROP = 94, HEAL_OVER_TIME = 89, diff --git a/HeavenClient/Net/Packets/AttackAndSkillPackets.h b/HeavenClient/Net/Packets/AttackAndSkillPackets.h index 9120dbb70..8570d450e 100644 --- a/HeavenClient/Net/Packets/AttackAndSkillPackets.h +++ b/HeavenClient/Net/Packets/AttackAndSkillPackets.h @@ -387,4 +387,16 @@ namespace ms write_int(skillid); } }; + + // Ends one of the player's item buffs, as right-clicking its icon does + // (FUN_00a096af). The id is the buff's, minus the item id. + // Opcode: CANCEL_ITEM_EFFECT(73) + class CancelItemEffectPacket : public OutPacket + { + public: + CancelItemEffectPacket(int32_t buffid) : OutPacket(OutPacket::Opcode::CANCEL_ITEM_EFFECT) + { + write_int(buffid); + } + }; } \ No newline at end of file diff --git a/game-data/src/items.rs b/game-data/src/items.rs index 4f2cf320d..c4cc4e091 100644 --- a/game-data/src/items.rs +++ b/game-data/src/items.rs @@ -3,6 +3,7 @@ //! mapped and each lookup is a few binary searches. use crate::{GameDataError, NxDataFile}; +use game::buff::BuffStat; use std::path::Path; /// Stack size when `info/slotMax` is absent (v83: 1 for equipment, else 100). @@ -52,8 +53,37 @@ pub struct ConsumeSpec { /// Percent of max HP/MP. pub hp_r: i32, pub mp_r: i32, + /// How long `stats` last, in ms (`time`). + pub time: i32, + /// The stats it raises for `time` (`pad`, `pdd`, `mad`, `mdd`, `acc`, + /// `eva`, `speed`, `jump`). + pub stats: Vec<(BuffStat, i16)>, + /// The diseases it cures (`poison`, `darkness`, `weakness`, `seal`, + /// `curse`). + pub cures: Vec, } +/// The `spec` keys of the stats a consumable raises. +const SPEC_STATS: [(&str, BuffStat); 8] = [ + ("pad", BuffStat::Pad), + ("pdd", BuffStat::Pdd), + ("mad", BuffStat::Mad), + ("mdd", BuffStat::Mdd), + ("acc", BuffStat::Acc), + ("eva", BuffStat::Eva), + ("speed", BuffStat::Speed), + ("jump", BuffStat::Jump), +]; + +/// The `spec` keys of the diseases a consumable cures. +const SPEC_CURES: [(&str, BuffStat); 5] = [ + ("poison", BuffStat::Poison), + ("darkness", BuffStat::Darkness), + ("weakness", BuffStat::Weakness), + ("seal", BuffStat::Seal), + ("curse", BuffStat::Curse), +]; + /// An equipment template (`Character.nx//.img/info`). #[derive(Clone, Debug, Default, PartialEq)] pub struct EquipInfo { @@ -201,6 +231,23 @@ impl ItemData { mp: nx.int_child(spec, "mp")?.unwrap_or(0), hp_r: nx.int_child(spec, "hpR")?.unwrap_or(0), mp_r: nx.int_child(spec, "mpR")?.unwrap_or(0), + time: nx.int_child(spec, "time")?.unwrap_or(0), + stats: SPEC_STATS + .iter() + .filter_map(|&(key, stat)| match nx.int_child(spec, key) { + Ok(Some(0)) | Ok(None) => None, + Ok(Some(value)) => Some(Ok((stat, value as i16))), + Err(error) => Some(Err(error)), + }) + .collect::>()?, + cures: SPEC_CURES + .iter() + .filter_map(|&(key, stat)| match nx.int_child(spec, key) { + Ok(Some(0)) | Ok(None) => None, + Ok(Some(_)) => Some(Ok(stat)), + Err(error) => Some(Err(error)), + }) + .collect::>()?, }); } Ok(Some(info)) @@ -312,6 +359,13 @@ mod tests { (subi.price, subi.unit_price, subi.slot_max), (250, 0.3, 500) ); + let all_cure = data.info(2050004).unwrap().unwrap().spec.unwrap(); + assert_eq!(all_cure.cures.len(), 5); + let warrior_pill = data.info(2002004).unwrap().unwrap().spec.unwrap(); + assert_eq!( + (warrior_pill.stats, warrior_pill.time), + (vec![(BuffStat::Pad, 5)], 180_000) + ); let relaxer = data.info(3010000).unwrap().unwrap(); assert_eq!((relaxer.recovery_hp, relaxer.recovery_mp), (50, 0)); } diff --git a/integration-harness/src/hunting.rs b/integration-harness/src/hunting.rs index fce27647e..1c824134e 100644 --- a/integration-harness/src/hunting.rs +++ b/integration-harness/src/hunting.rs @@ -218,6 +218,15 @@ pub fn cancel_buff(skill_id: i32) -> Packet { p } +/// CANCEL_ITEM_EFFECT: right-clicking an item buff's icon (its id is the +/// buff's, `-item_id`). +pub fn cancel_item_effect(buff_id: i32) -> Packet { + let mut p = Packet::new_empty(); + p.write_short(RecvOpcode::CancelItemEffect as i16).unwrap(); + p.write_int(buff_id).unwrap(); + p +} + /// MOVE_PLAYER: one absolute movement to (x, y), standing. pub fn walk_to(x: i16, y: i16) -> Packet { let mut movement = vec![1u8, 0]; diff --git a/integration-harness/tests/item_buffs.rs b/integration-harness/tests/item_buffs.rs new file mode 100644 index 000000000..6ce8f77f0 --- /dev/null +++ b/integration-harness/tests/item_buffs.rs @@ -0,0 +1,48 @@ +//! A pill raises a stat for its `time` as a buff of id `-item_id`, which +//! right-clicking its icon (CANCEL_ITEM_EFFECT) ends; All Cure is spent +//! with nothing to cure. + +use integration_harness::database::grant_stack; +use integration_harness::hunting::{cancel_item_effect, use_item}; +use integration_harness::preconditions::load_harness_config_or_fail; +use integration_harness::trace::step; +use integration_harness::{login_to_world_session, relogin}; +use tokio::time::Duration; + +const USE: i16 = 2; +/// Warrior Pill: +5 weapon attack for three minutes. +const WARRIOR_PILL: i32 = 2_002_004; +const ALL_CURE: i32 = 2_050_004; +/// Weapon attack's CANCEL_BUFF bit (second mask). +const PAD_MASK: i64 = 0x1_0000_0000; + +#[tokio::test] +async fn a_pill_buffs_until_its_icon_is_right_clicked() { + let config = load_harness_config_or_fail().await; + let id = login_to_world_session(&config) + .await + .expect("first login failed") + .character_id; + drop(relogin(&config).await); + tokio::time::sleep(Duration::from_millis(500)).await; + grant_stack(id, USE, 20, WARRIOR_PILL, 2) + .await + .expect("grant pills"); + grant_stack(id, USE, 21, ALL_CURE, 1) + .await + .expect("grant All Cure"); + let mut player = relogin(&config).await; + let c = &mut player.connection; + + let t = step(c, "pill", use_item(20, WARRIOR_PILL)).await; + assert_eq!(t.buffs, [(-WARRIOR_PILL, 5, 180_000)]); + + let t = step(c, "cancel", cancel_item_effect(-WARRIOR_PILL)).await; + assert_eq!(t.cancelled_buffs, [PAD_MASK]); + let t = step(c, "cancel again", cancel_item_effect(-WARRIOR_PILL)).await; + assert!(t.cancelled_buffs.is_empty()); + + let t = step(c, "all cure", use_item(21, ALL_CURE)).await; + assert!(t.buffs.is_empty() && t.cancelled_buffs.is_empty()); + assert_eq!(t.removed, [(USE as u8, 21)]); +} diff --git a/net/src/handler.rs b/net/src/handler.rs index 3631685d6..3767eaacd 100644 --- a/net/src/handler.rs +++ b/net/src/handler.rs @@ -577,6 +577,7 @@ fn world_routes() -> &'static HashMap { (Op::SpendSp, InGame(&world::SpendSpHandler)), (Op::UseSkill, InGame(&world::UseSkillHandler)), (Op::CancelBuff, InGame(&world::CancelBuffHandler)), + (Op::CancelItemEffect, InGame(&world::CancelBuffHandler)), (Op::SkillEffect, InGame(&world::SkillEffectHandler)), (Op::ThrowGrenade, InGame(&world::ThrowGrenadeHandler)), (Op::MesoDrop, InGame(&world::MesoDropHandler)), @@ -617,7 +618,7 @@ mod tests { fn every_registered_opcode_is_unique_per_server() { // The maps would silently drop duplicates; check the table sizes match. assert_eq!(login_routes().len(), 13); - assert_eq!(world_routes().len(), 55); + assert_eq!(world_routes().len(), 56); } #[test] diff --git a/net/src/packet/handle/world/use_item.rs b/net/src/packet/handle/world/use_item.rs index e8971395b..f3f276927 100644 --- a/net/src/packet/handle/world/use_item.rs +++ b/net/src/packet/handle/world/use_item.rs @@ -1,22 +1,26 @@ +use super::use_skill::show_given; use crate::handler::{ FieldRequest, HandlerResult, WorldAction, WorldContext, WorldHandler, WorldResult, }; use crate::items; use crate::packet::build::world::skill::{self, BookResult}; use crate::packet::build::world::stat::StatUpdate; -use crate::packet::build::world::{inventory, stat}; +use crate::packet::build::world::{buff, inventory, stat}; use crate::skills; use game::buff::BuffStat; use game::inventory::equip_slot; -use game::{InventoryChange, InventoryType, Player, SkillRecord}; +use game::{Buff, InventoryChange, InventoryType, Player, SkillRecord}; use packet::{io::read::PktRead, Packet}; use rand::Rng; use std::io::Cursor; +use std::time::{Duration, Instant}; use tracing::debug; /// `USE_ITEM`: tick, slot in the USE bag, item id. Applies the item's `spec` -/// (HP/MP, flat and percent) and consumes one. A v83 client sends none -/// while Stop Potion or Seduce holds (FUN_00a092fb). +/// and consumes one: HP/MP (flat and percent), then the diseases it cures, +/// then the stats it raises for `time` ms as a buff of id `-item_id`, which +/// the client lists with the item's icon. A v83 client sends none while +/// Stop Potion or Seduce holds (FUN_00a092fb). pub struct UseItemHandler; impl WorldHandler for UseItemHandler { @@ -26,26 +30,46 @@ impl WorldHandler for UseItemHandler { reader.read_int()?; // client tick let slot = reader.read_short()?; let item_id = reader.read_int()?; - match use_item(ctx.player, slot, item_id) { - Some(changes) => { - let stats = &ctx.player.stats; - Ok( - HandlerResult::reply(inventory::build_inventory_operation(true, &changes)?) - .with_reply(stat::build_stat_update( - true, - &[StatUpdate::Hp(stats.hp), StatUpdate::Mp(stats.mp)], - )?), - ) + let Some(used) = use_item(ctx.player, slot, item_id, Instant::now()) else { + debug!(slot, item_id, "Item use refused"); + return Ok(HandlerResult::reply(stat::build_stat_update(true, &[])?)); + }; + let stats = &ctx.player.stats; + let mut result = + HandlerResult::reply(inventory::build_inventory_operation(true, &used.changes)?) + .with_reply(stat::build_stat_update( + true, + &[StatUpdate::Hp(stats.hp), StatUpdate::Mp(stats.mp)], + )?); + if !used.cured.is_empty() { + result = result.with_reply(buff::build_cancel_buff(&used.cured)?); + let unseen: Vec<_> = used.cured.into_iter().filter(|s| s.shown()).collect(); + if !unseen.is_empty() { + let foreign = buff::build_cancel_foreign_buff(ctx.player.id(), &unseen)?; + result = result.with(WorldAction::BuffsChanged(foreign)); } - None => { - debug!(slot, item_id, "Item use refused"); - Ok(HandlerResult::reply(stat::build_stat_update(true, &[])?)) + } + if !used.given.is_empty() { + result = result.with_reply(buff::build_give_buff(-item_id, used.time, &used.given)?); + if let Some(foreign) = show_given(ctx.player, &used.given)? { + result = result.with(WorldAction::BuffsChanged(foreign)); } } + Ok(result) } } -fn use_item(player: &mut Player, slot: i16, item_id: i32) -> Option> { +/// What using an item did. +struct Used { + changes: Vec, + /// The diseases it cured. + cured: Vec, + /// The stats it raised, for `time` ms. + given: Vec<(BuffStat, i16)>, + time: i32, +} + +fn use_item(player: &mut Player, slot: i16, item_id: i32, now: Instant) -> Option { if player.stats.hp <= 0 || player.buffs.has(BuffStat::StopPotion) { return None; } @@ -61,7 +85,24 @@ fn use_item(player: &mut Player, slot: i16, item_id: i32) -> Option 0 { + spec.stats + } else { + Vec::new() + }; + let lasts = Duration::from_millis(spec.time.max(0) as u64); + for &(stat, value) in &given { + player + .buffs + .give(Buff::new(-item_id, stat, value, now, lasts)); + } + Some(Used { + changes, + cured, + given, + time: spec.time, + }) } /// `USE_SKILL_BOOK` (0x52, v83 FUN_00a0a1b2): tick, slot in the USE bag, @@ -288,7 +329,7 @@ mod tests { now, lasts, )); - assert!(use_item(&mut player, 3, 2000000).is_none()); + assert!(use_item(&mut player, 3, 2000000, now).is_none()); assert_eq!(player.stats.hp, 100); // A Red Potion heals 50; undead, 25. @@ -300,10 +341,77 @@ mod tests { now, lasts, )); - assert!(use_item(&mut player, 3, 2000000).is_some()); + assert!(use_item(&mut player, 3, 2000000, now).is_some()); assert_eq!(player.stats.hp, 125); } + #[test] + fn a_pill_buffs_for_its_time_and_all_cure_cures() { + if items::data().is_err() { + return; + } + let mut player = crate::codec::stats::sample_player(); + player + .inventory + .insert(InventoryType::Use, 1, Item::stack(2002004, 1)); + player + .inventory + .insert(InventoryType::Use, 2, Item::stack(2050004, 1)); + let now = std::time::Instant::now(); + // Warrior Pill: +5 weapon attack for three minutes. + let used = use_item(&mut player, 1, 2002004, now).unwrap(); + assert_eq!((used.given, used.time), (vec![(BuffStat::Pad, 5)], 180_000)); + let buff = &player.buffs.active()[0]; + assert_eq!( + (buff.skill_id, buff.stat, buff.value), + (-2002004, BuffStat::Pad, 5) + ); + let ended = player.buffs.cancel(-2002004); + assert_eq!(ended, [BuffStat::Pad]); + + // All Cure ends Poison and Seal but not Stun. + for (n, stat) in [BuffStat::Poison, BuffStat::Seal, BuffStat::Stun] + .into_iter() + .enumerate() + { + player.buffs.give(game::Buff::new( + (120 + n as i32) | 1 << 16, + stat, + 1, + now, + std::time::Duration::from_secs(10), + )); + } + let mut packet = request(2, 2050004); + let mut ctx = WorldContext { + client_id: 1, + channel_id: 0, + player: &mut player, + }; + let actions = UseItemHandler + .handle(&mut packet, &mut ctx) + .unwrap() + .actions; + let opcodes: Vec<_> = actions + .iter() + .map(|action| match action { + WorldAction::Reply(p) | WorldAction::BuffsChanged(p) => p.opcode(), + _ => 0, + }) + .collect(); + assert_eq!( + opcodes, + [ + SendOpcode::InventoryOperation as i16, + SendOpcode::StatChange as i16, + SendOpcode::CancelBuff as i16, + SendOpcode::CancelForeignBuff as i16, + ] + ); + assert!(player.buffs.has(BuffStat::Stun)); + assert!(!player.buffs.has(BuffStat::Poison) && !player.buffs.has(BuffStat::Seal)); + } + fn hero_with_book(slot: i16, item_id: i32) -> Player { let mut player = crate::codec::stats::sample_player(); player.stats.job = 112; diff --git a/net/src/packet/handle/world/use_skill.rs b/net/src/packet/handle/world/use_skill.rs index bb0dc5b31..89a1d63df 100644 --- a/net/src/packet/handle/world/use_skill.rs +++ b/net/src/packet/handle/world/use_skill.rs @@ -200,7 +200,8 @@ impl WorldHandler for ThrowGrenadeHandler { /// `CANCEL_BUFF`: the player right-clicked a skill's buff icon, or let go /// of a held skill without using it. End the buffs and tell the client, /// which then drops the icon; or show the rest of the map the skill was -/// let go. +/// let go. Also `CANCEL_ITEM_EFFECT` (0x49, v83 FUN_00a096af), whose int +/// is an item buff's id, `-item_id`. pub struct CancelBuffHandler; impl WorldHandler for CancelBuffHandler { @@ -549,7 +550,10 @@ fn give( /// The GIVE_FOREIGN_BUFF showing the others the buffs among `stats` they /// see, if any. -fn show_given(player: &Player, stats: &[(BuffStat, i16)]) -> Result, NetworkError> { +pub(super) fn show_given( + player: &Player, + stats: &[(BuffStat, i16)], +) -> Result, NetworkError> { let given: Vec<_> = player .buffs .shown() diff --git a/net/src/packet/op/recv.rs b/net/src/packet/op/recv.rs index 6d7a266dc..7500f2d23 100644 --- a/net/src/packet/op/recv.rs +++ b/net/src/packet/op/recv.rs @@ -55,6 +55,7 @@ pub enum RecvOpcode { SortItems = 0x46, ItemMove = 0x47, UseItem = 0x48, + CancelItemEffect = 0x49, UseSkillBook = 0x52, SpendAp = 0x57, HealOverTime = 0x59,