From 7fab0b470c44496ddd3620475e9cdebdff561385 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sun, 18 Oct 2026 12:23:46 -0400 Subject: [PATCH 1/4] L159: pet equipment rules on the server Pet equips are worn at -(100+part), on the part for the pet they belong to by list index: 14 and 21-29 and 46 for the first pet, 30-37 and 47 for the second, 38-45 and 48 for the third, with the HP and MP pouches (24, 25) shared. The server now checks them as the v83 client does (FUN_004f1c2d) instead of with the regular stat rules. Clothes need their pet out, made for its kind and at its level; label and quote rings need a pet whose kind has a name tag or chat balloon style; item pouches, meso magnets and ignore pendants need their pet out; the potion pouches, Wing Boots, Binocular and Magic Scales need no pet. Item data now reads the nameTag and chatBalloon flags of pets and the kinds each piece of clothing suits. --- game-data/src/items.rs | 38 +++++++++++ game/src/character.rs | 16 +++-- game/src/inventory.rs | 62 +++++++++++++++++ net/src/packet/handle/world/item_move.rs | 84 +++++++++++++++++++++++- 4 files changed, 193 insertions(+), 7 deletions(-) diff --git a/game-data/src/items.rs b/game-data/src/items.rs index e3aea5edd..e4c62d2b9 100644 --- a/game-data/src/items.rs +++ b/game-data/src/items.rs @@ -39,6 +39,12 @@ pub struct ItemInfo { pub pet_commands: Vec, /// The fullness a pet loses each time it grows hungry (`info/hungry`). pub pet_hunger: i32, + /// Whether a pet has a name tag style (`info/nameTag`), which lets it + /// wear a label ring. + pub pet_name_tag: bool, + /// Whether a pet has a chat balloon style (`info/chatBalloon`), which + /// lets it wear a quote ring. + pub pet_chat_balloon: bool, } /// A skill book (228xxxx) or mastery book (229xxxx). @@ -129,6 +135,9 @@ pub struct EquipInfo { /// `info/recovery`: how much faster the wearer recovers on a map whose /// own recovery is above 1 (the sauna robes, 1.5). pub recovery: Option, + /// The pets that pet wear (180xxxx) suits: the image's children named + /// for pet item ids. + pub pets: Vec, } /// Top-level `Item.nx` directory for a non-equipment item id. @@ -234,6 +243,8 @@ impl ItemData { info.recovery_mp = nx.int_child(meta, "recoveryMP")?.unwrap_or(0); if pet { info.pet_hunger = nx.int_child(meta, "hungry")?.unwrap_or(1); + info.pet_name_tag = nx.int_child(meta, "nameTag")?.unwrap_or(0) != 0; + info.pet_chat_balloon = nx.int_child(meta, "chatBalloon")?.unwrap_or(0) != 0; } if let Some(skills) = nx.child_by_name(meta, "skill")? { let mut book = BookInfo { @@ -321,6 +332,14 @@ impl ItemData { let Some(meta) = nx.child_by_name(node, "info")? else { return Ok(None); }; + let mut pets = Vec::new(); + if item_id / 10_000 == 180 { + for child in nx.child_indices(node)? { + if let Ok(pet) = nx.node_name(child)?.parse::() { + pets.push(pet); + } + } + } let stat = |key: &str| -> Result { Ok(nx .int_child(meta, key)? @@ -351,6 +370,7 @@ impl ItemData { req_luk: stat("reqLUK")?, cash: nx.int_child(meta, "cash")? == Some(1), recovery: nx.real_child(meta, "recovery")?, + pets, }; Ok(Some(ItemInfo { item_id, @@ -368,6 +388,8 @@ impl ItemData { equip: Some(equip), pet_commands: Vec::new(), pet_hunger: 0, + pet_name_tag: false, + pet_chat_balloon: false, })) } } @@ -511,6 +533,9 @@ mod tests { let tiger = data.info(5000066).unwrap().unwrap(); assert_eq!(tiger.slot_max, 1); assert_eq!(tiger.pet_hunger, 3); + assert!(tiger.pet_name_tag && tiger.pet_chat_balloon); + let kitty = data.info(5000000).unwrap().unwrap(); + assert!(!kitty.pet_name_tag && !kitty.pet_chat_balloon); assert_eq!(data.info(5000000).unwrap().unwrap().pet_hunger, 2); let food = data.info(2120000).unwrap().unwrap().spec.unwrap(); assert_eq!(food.inc, 30); @@ -617,4 +642,17 @@ mod tests { let robe = data.info(1050018).unwrap().unwrap().equip.unwrap(); assert_eq!(robe.recovery, Some(1.5)); } + + #[test] + fn reads_which_pets_pet_wear_suits() { + let Some(data) = data() else { + return; + }; + let wings = data.info(1802063).unwrap().unwrap().equip.unwrap(); + assert_eq!((wings.pets, wings.cash), (vec![5000066], true)); + let hat = data.info(1802000).unwrap().unwrap().equip.unwrap(); + assert!(hat.pets.contains(&5000000) && !hat.pets.contains(&5000066)); + let magnet = data.info(1812000).unwrap().unwrap().equip.unwrap(); + assert!(magnet.pets.is_empty()); + } } diff --git a/game/src/character.rs b/game/src/character.rs index bab432599..53e31824a 100644 --- a/game/src/character.rs +++ b/game/src/character.rs @@ -242,12 +242,16 @@ impl Player { /// The summoned pets' items, in list order. pub fn summoned_pets(&self) -> impl Iterator + '_ { - self.pets.iter().filter_map(|&serial| { - self.inventory - .items(InventoryType::Cash) - .map(|(_, item)| item) - .find(|item| item.cash_serial == Some(serial) && item.pet.is_some()) - }) + (0..self.pets.len()).filter_map(|index| self.summoned_pet(index)) + } + + /// The item of the pet at `index` in the list, if one is out there. + pub fn summoned_pet(&self, index: usize) -> Option<&Item> { + let serial = *self.pets.get(index)?; + self.inventory + .items(InventoryType::Cash) + .map(|(_, item)| item) + .find(|item| item.cash_serial == Some(serial) && item.pet.is_some()) } /// The summoned pets' cash serials, 0 past the last. diff --git a/game/src/inventory.rs b/game/src/inventory.rs index 3a244fe4f..b37a4290d 100644 --- a/game/src/inventory.rs +++ b/game/src/inventory.rs @@ -109,6 +109,42 @@ pub mod equip_slot { } } + /// The slots a pet equip may be worn in, one for each summoned pet in + /// list order (v83 FUN_00460358). The HP and MP pouches serve every pet + /// from one slot. + pub fn pet_slots(item_id: i32) -> Option<[i16; 3]> { + let parts = match item_id { + // The Pet Collar goes where a label ring does. + 1_802_100 => [21, 31, 39], + 1_812_000 => [23, 34, 42], + 1_812_001 => [22, 33, 41], + 1_812_002 => [24; 3], + 1_812_003 => [25; 3], + 1_812_004 => [26, 35, 43], + 1_812_005 => [27, 36, 44], + 1_812_006 => [28, 37, 45], + 1_812_007 => [46, 47, 48], + _ => match item_id / 10_000 { + 180 => [14, 30, 38], + 182 => [21, 31, 39], + 183 => [29, 32, 40], + _ => return None, + }, + }; + Some(parts.map(|part| -part)) + } + + /// The list index of the pet a pet equip slot serves, or `None` for the + /// pouches every pet shares (v83 FUN_00705e2c). + pub fn pet_index(slot: i16) -> Option { + match -slot { + 14 | 21..=23 | 26..=29 | 46 => Some(0), + 30..=37 | 47 => Some(1), + 38..=45 | 48 => Some(2), + _ => None, + } + } + /// Whether `item_id` may be worn at `position`. As in v83, an item with /// a cash serial is worn at its slot less 100, and any other at its slot. pub fn fits(item_id: i32, cash: bool, position: i16) -> bool { @@ -117,6 +153,9 @@ pub mod equip_slot { (false, -99..=-1) => position, _ => return false, }; + if let Some(slots) = pet_slots(item_id) { + return slots.contains(&position); + } match for_item(item_id) { Some(slot) if RINGS.contains(&slot) => RINGS.contains(&position), Some(slot) => slot == position, @@ -1008,6 +1047,29 @@ mod tests { assert_eq!(inventory.equipped_cash().count(), 1); } + #[test] + fn pet_equips_go_in_their_pets_slots() { + // Wings for the second pet, a meso magnet for the third, and an HP + // pouch, which every pet shares. + for (item_id, position, index) in [ + (1802063, -130, Some(1)), + (1812000, -142, Some(2)), + (1812002, -124, None), + ] { + assert!(equip_slot::fits(item_id, true, position), "{item_id}"); + assert_eq!(equip_slot::pet_index(position + 100), index); + } + assert!(!equip_slot::fits(1802063, true, -129), "a quote ring's"); + assert!(!equip_slot::fits(1812002, true, -133)); + assert!(!equip_slot::fits(1812008, true, -128), "no such ability"); + assert!(!equip_slot::fits(1832000, true, equip_slot::CAP - 100)); + assert!(equip_slot::fits(1832000, true, -132)); + assert!( + equip_slot::fits(1802100, true, -139), + "the collar's a label" + ); + } + #[test] fn overalls_and_pants_take_each_other_off() { let mut inventory = Inventory::default(); diff --git a/net/src/packet/handle/world/item_move.rs b/net/src/packet/handle/world/item_move.rs index 97495daf4..9aa7c00e2 100644 --- a/net/src/packet/handle/world/item_move.rs +++ b/net/src/packet/handle/world/item_move.rs @@ -4,7 +4,9 @@ use crate::handler::{ use crate::items; use crate::loot::Loot; use crate::packet::build::world::{inventory, stat}; +use game::inventory::equip_slot; use game::{InventoryType, Item, Player}; +use game_data::items::EquipInfo; use packet::{io::read::PktRead, Packet}; use std::io::Cursor; use tracing::debug; @@ -144,7 +146,12 @@ fn move_item( let template = items::info(item_id) .and_then(|info| info.equip) .ok_or("unknown equipment")?; - if !items::can_wear(&template, player) { + let allowed = match equip_slot::pet_slots(item_id) { + // Cash items are worn 100 below their slot. + Some(_) => pet_can_wear(player, &template, to % 100), + None => items::can_wear(&template, player), + }; + if !allowed { return Err("requirements not met".into()); } let changes = player @@ -165,6 +172,29 @@ fn move_item( Ok((changes, false)) } +/// Whether a pet equip may go in pet equip `slot`, as the client checks +/// (v83 FUN_004f1c2d). Wear, label and quote rings, item pouches, meso +/// magnets and ignore pendants need the pet they are for out. Wear must +/// suit that pet's kind and level, a label ring its name tag style and a +/// quote ring its chat balloon style. The rest need no pet. +fn pet_can_wear(player: &Player, equip: &EquipInfo, slot: i16) -> bool { + let pet = equip_slot::pet_index(slot).and_then(|index| player.summoned_pet(index)); + let kind = |pet: &Item| items::info(pet.item_id).unwrap_or_default(); + match -slot { + 14 | 30 | 38 => pet.is_some_and(|pet| { + equip.pets.contains(&pet.item_id) + && pet + .pet + .as_ref() + .is_some_and(|data| i16::from(data.level) >= equip.req_level) + }), + 21 | 31 | 39 => pet.is_some_and(|pet| kind(pet).pet_name_tag), + 29 | 32 | 40 => pet.is_some_and(|pet| kind(pet).pet_chat_balloon), + 22 | 23 | 46 | 33 | 34 | 47 | 41 | 42 | 48 => pet.is_some(), + _ => true, + } +} + #[cfg(test)] mod tests { use super::*; @@ -332,6 +362,58 @@ mod tests { assert!(player.inventory.get(InventoryType::Equip, 5).is_some()); } + /// A player with a Brown Kitty out first and a Baby Tiger second, and + /// `equips` in the Equip bag from slot 1, bought in the Cash Shop. + fn pet_owner(equips: &[i32]) -> Player { + let mut player = crate::codec::stats::sample_player(); + for (slot, (serial, item_id)) in (1..).zip([(76, 5000000), (77, 5000066)]) { + let mut pet = Item::stack(item_id, 1); + pet.cash_serial = Some(serial); + pet.pet = Some(game::PetData::new("Pet")); + player.inventory.insert(InventoryType::Cash, slot, pet); + player.pets.push(serial); + } + for (slot, &item_id) in (1..).zip(equips) { + let mut equip = Item::equipment(item_id, EquipStats::default()); + equip.cash_serial = Some(100 + i64::from(slot)); + player.inventory.insert(InventoryType::Equip, slot, equip); + } + player + } + + fn worn(actions: &[WorldAction]) -> bool { + matches!(actions, [WorldAction::Reply(p), WorldAction::LookChanged(_)] + if p.opcode() == SendOpcode::InventoryOperation as i16) + } + + #[test] + fn pet_equips_need_the_pet_they_are_for() { + if items::data().is_err() { + return; + } + // Tiger wings, a label ring, a meso magnet, an HP pouch and a sweeper. + let mut player = pet_owner(&[1802063, 1822000, 1812000, 1812002, 1812004]); + // The kitty can't wear the tiger's wings, nor show a label. + assert!(!worn(&handle(&mut player, request(1, 1, -114)))); + assert!(!worn(&handle(&mut player, request(1, 2, -121)))); + assert!(worn(&handle(&mut player, request(1, 1, -130)))); + assert!(worn(&handle(&mut player, request(1, 2, -131)))); + // There is no third pet for the magnet, but the pouch and the + // sweeper need none. + assert!(!worn(&handle(&mut player, request(1, 3, -142)))); + assert!(worn(&handle(&mut player, request(1, 4, -124)))); + assert!(worn(&handle(&mut player, request(1, 5, -143)))); + let worn_ids: Vec = [-130, -131, -124, -143] + .map(|position| { + let item = player.inventory.get(InventoryType::Equipped, position); + item.map_or(0, |item| item.item_id) + }) + .into(); + assert_eq!(worn_ids, [1802063, 1822000, 1812002, 1812004]); + let look = player.look(); + assert_eq!(look.visible.get(&30), Some(&1802063)); + } + #[test] fn refused_moves_only_unlock_the_client() { let mut player = crate::codec::stats::sample_player(); From 45e356d645dbfd695c90076a6efa67fd6e37d845 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sun, 18 Oct 2026 12:40:26 -0400 Subject: [PATCH 2/4] L159: pets pick up loot with their Meso Magnet and Item Pouch The v83 client asks for loot on its pet's behalf with PET_LOOT (0xAA): the pet's cash serial, where it stands and the drop. The server now honours it when that pet wears the Meso Magnet (mesos) or the Item Pouch (items) at its own parts, and never for a drop a player threw down. The field takes the drop out of its set without telling anyone, and the owner's actor tries to keep it. Kept, the field shows it flying to the pet (REMOVE_ITEM_FROM_MAP mode 5 with the owner and the pet's place in the list); refused for a full inventory, it goes back in the set with no packet, so other players never see it blink. A player's own pickup goes through the same keep-or-return step as before. tests/pet_loot.rs puts a Meso Magnet on a summoned pet straight from the database (new grant_pet and wear_cash_equip helpers), and checks that the pet takes its owner's mesos but leaves a Monster Book card alone. --- integration-harness/src/database.rs | 50 +++++++++++ integration-harness/tests/pet_loot.rs | 116 ++++++++++++++++++++++++++ net/src/handler.rs | 12 +++ net/src/packet/build/world/drop.rs | 12 +++ net/src/packet/handle/world/mod.rs | 2 +- net/src/packet/handle/world/pet.rs | 49 +++++++++++ net/src/packet/op/recv.rs | 1 + runtime/src/actor/client.rs | 1 + runtime/src/actor/client/loot.rs | 35 +++++++- runtime/src/actor/field.rs | 36 +++++++- runtime/src/actor/field_drops.rs | 74 ++++++++++++++++ runtime/src/message.rs | 14 ++++ 12 files changed, 396 insertions(+), 6 deletions(-) create mode 100644 integration-harness/tests/pet_loot.rs diff --git a/integration-harness/src/database.rs b/integration-harness/src/database.rs index b28fd11bb..7af3e1ae5 100644 --- a/integration-harness/src/database.rs +++ b/integration-harness/src/database.rs @@ -110,6 +110,56 @@ pub async fn set_job(character_id: i32, job: i16, level: i16, mp: i16) -> Result .await } +/// Put the pet `item_id` named `name`, with cash serial `serial`, in slot +/// `slot` of the logged-out character `character_id`'s Cash inventory. +pub async fn grant_pet( + character_id: i32, + slot: i16, + item_id: i32, + serial: i64, + name: &str, +) -> Result<(), HarnessError> { + let name = name.to_string(); + change_one_row("grant pet", move |conn| { + diesel::sql_query( + "WITH pet AS (INSERT INTO pets (serial, name) VALUES ($4, $5)) \ + INSERT INTO inventory_items (character_id, inventory_type, slot, item_id, quantity, owner, flags, cash_serial) \ + VALUES ($1, 5, $2, $3, 1, '', 0, $4)", + ) + .bind::(character_id) + .bind::(slot) + .bind::(item_id) + .bind::(serial) + .bind::(name) + .execute(conn) + }) + .await +} + +/// Dress the logged-out character `character_id` in the cash equip +/// `item_id`, with cash serial `serial`, at `position` (negative), such as +/// a pet's equipment. +pub async fn wear_cash_equip( + character_id: i32, + position: i16, + item_id: i32, + serial: i64, +) -> Result<(), HarnessError> { + change_one_row("wear cash equip", move |conn| { + diesel::sql_query( + "WITH item AS (INSERT INTO inventory_items (character_id, inventory_type, slot, item_id, quantity, owner, flags, cash_serial) \ + VALUES ($1, -1, $2, $3, 1, '', 0, $4) RETURNING id) \ + INSERT INTO equipment_stats (item_row_id) SELECT id FROM item", + ) + .bind::(character_id) + .bind::(position) + .bind::(item_id) + .bind::(serial) + .execute(conn) + }) + .await +} + /// Set the fullness of the pet `serial`, whose owner is logged out. pub async fn set_pet_fullness(serial: i64, fullness: i16) -> Result<(), HarnessError> { change_one_row("set pet fullness", move |conn| { diff --git a/integration-harness/tests/pet_loot.rs b/integration-harness/tests/pet_loot.rs new file mode 100644 index 000000000..55b00ceb2 --- /dev/null +++ b/integration-harness/tests/pet_loot.rs @@ -0,0 +1,116 @@ +//! Pet loot: a pet wearing a Meso Magnet picks up its owner's mesos, which +//! fly to the pet, and without an Item Pouch it leaves items where they lie. + +use integration_harness::connection::MapleTestConnection; +use integration_harness::database::{grant_pet, wear_cash_equip}; +use integration_harness::hunting::attack; +use integration_harness::preconditions::load_harness_config_or_fail; +use integration_harness::trace::{read, read_for, send, stat, until, warp, Trace}; +use integration_harness::{login_to_world_session, relogin}; +use net::packet::op::{RecvOpcode, SendOpcode}; +use packet::io::write::PktWrite; +use packet::Packet; + +const FOREST: i32 = 50_000; +const SNAIL: i32 = 100100; +const BABY_TIGER: i32 = 5_000_066; +const MESO_MAGNET: i32 = 1_812_000; +/// Where the first pet out wears its Meso Magnet. +const FIRST_PET_MAGNET: i16 = -123; + +/// SPAWN_PET: the pet in Cash slot `slot` comes out. +fn spawn_pet(slot: i16) -> Packet { + let mut p = Packet::new_empty(); + p.write_short(RecvOpcode::SpawnPet as i16).unwrap(); + p.write_int(0).unwrap(); // time + p.write_short(slot).unwrap(); + p.write_byte(0).unwrap(); // not leading + p +} + +/// PET_LOOT: the pet `serial`, standing at (x, y), reaches for a drop. +fn pet_loot(serial: i64, object_id: i32, x: i16, y: i16) -> Packet { + let mut p = Packet::new_empty(); + p.write_short(RecvOpcode::PetLoot as i16).unwrap(); + p.write_long(serial).unwrap(); + p.write_byte(0).unwrap(); // field key + p.write_int(0).unwrap(); // time + p.write_short(x).unwrap(); + p.write_short(y).unwrap(); + p.write_int(object_id).unwrap(); + p.write_int(0).unwrap(); // checksum + p.write_bytes(&[0, 0, 0]).unwrap(); // others' drops, sweep, long range + p +} + +/// Read until the drop `object_id` leaves the map, and return the body. +async fn removal(c: &mut MapleTestConnection, t: &mut Trace, object_id: i32) -> Vec { + loop { + let p = read(c, t).await; + if p.opcode() == SendOpcode::RemoveItemFromMap as i16 + && p.bytes[3..7] == object_id.to_le_bytes() + { + return p.bytes[2..].to_vec(); + } + } +} + +#[tokio::test] +async fn a_pet_with_a_meso_magnet_takes_mesos_but_not_items() { + let config = load_harness_config_or_fail().await; + let session = login_to_world_session(&config).await.unwrap(); + let owner_id = session.character_id; + drop(session); + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + let serial = 9_159_000_000 + 2 * i64::from(owner_id); + grant_pet(owner_id, 1, BABY_TIGER, serial, "Baby Tiger") + .await + .expect("grant a pet"); + wear_cash_equip(owner_id, FIRST_PET_MAGNET, MESO_MAGNET, serial + 1) + .await + .expect("put the magnet on the pet"); + let mut owner = relogin(&config).await; + let (o, mut t) = (&mut owner.connection, Trace::default()); + assert_eq!(warp(o, FOREST, "", &mut t).await, FOREST); + until(o, &mut t, |t| t.field.mobs.len() >= 17).await; + send(o, spawn_pet(1)).await; + until(o, &mut t, |t| !t.pets.is_empty()).await; + read_for(o, &mut t, 300).await; + // Earlier tests may have left drops on this map. + let old_drops = t.field.drops.len(); + + // Kill snails until mesos drop; every kill drops a Monster Book card + // as well (tests/monster_book.rs). + for oid in t.field.alive(SNAIL) { + send(o, attack(oid, 8)).await; + until(o, &mut t, |t| t.field.killed.contains(&oid)).await; + read_for(o, &mut t, 200).await; + if t.field.drops[old_drops..].iter().any(|d| d.meso) { + break; + } + } + let ours = &t.field.drops[old_drops..]; + let mesos = *ours + .iter() + .find(|d| d.meso) + .expect("seventeen snails dropped no mesos"); + let card = *ours + .iter() + .find(|d| !d.meso && d.value / 10_000 == 238) + .expect("a card dropped"); + + // Without an Item Pouch the pet leaves the card. + send(o, pet_loot(serial, card.object_id, card.x, card.y)).await; + read_for(o, &mut t, 400).await; + assert!(t.field.removals(card.object_id).is_empty()); + + // The mesos fly to the first pet, and are its owner's. + send(o, pet_loot(serial, mesos.object_id, mesos.x, mesos.y)).await; + let mut taken = vec![5]; + taken.extend(mesos.object_id.to_le_bytes()); + taken.extend(owner_id.to_le_bytes()); + taken.push(0); + assert_eq!(removal(o, &mut t, mesos.object_id).await, taken); + until(o, &mut t, |t| t.stat(stat::MESO).is_some()).await; + assert_eq!(t.stat(stat::MESO), Some(i64::from(mesos.value))); +} diff --git a/net/src/handler.rs b/net/src/handler.rs index d8b8f2f41..358e8f1f1 100644 --- a/net/src/handler.rs +++ b/net/src/handler.rs @@ -515,6 +515,17 @@ pub enum FieldRequest { skill_id: Option, why: crate::packet::build::world::summon::Gone, }, + /// The player's pet at `index` in their pet list, standing at (x, y), + /// would pick up the drop `object_id`. It may take mesos with `mesos` + /// and items with `items`. Answered with `ServerMessage::PetPickedUp`. + PetPickup { + index: u8, + object_id: i32, + x: i16, + y: i16, + mesos: bool, + items: bool, + }, /// The player's client moved their pet at `index` in their pet list. PetMove { index: u8, @@ -672,6 +683,7 @@ fn world_routes() -> &'static HashMap { (Op::PetChat, InGame(&world::PetChatHandler)), (Op::PetCommand, InGame(&world::PetCommandHandler)), (Op::PetFood, InGame(&world::PetFoodHandler)), + (Op::PetLoot, InGame(&world::PetLootHandler)), (Op::UseCashItem, InGame(&world::UseCashItemHandler)), (Op::CloseChalkboard, InGame(&world::CloseChalkboardHandler)), ( diff --git a/net/src/packet/build/world/drop.rs b/net/src/packet/build/world/drop.rs index 5450d9dc7..7be9e5948 100644 --- a/net/src/packet/build/world/drop.rs +++ b/net/src/packet/build/world/drop.rs @@ -60,6 +60,18 @@ pub fn build_drop(drop: &DropView, appearance: DropAppearance) -> Result Result { + let mut p = Packet::new_empty(); + p.write_short(SendOpcode::RemoveItemFromMap as i16)?; + p.write_byte(5)?; + p.write_int(object_id)?; + p.write_int(owner)?; + p.write_byte(pet)?; + Ok(p) +} + /// REMOVE_ITEM_FROM_MAP: 0 expired, 2 picked up by `looter`. pub fn build_remove_drop(object_id: i32, looter: Option) -> Result { let mut p = Packet::new_empty(); diff --git a/net/src/packet/handle/world/mod.rs b/net/src/packet/handle/world/mod.rs index fc12aead2..8ef0084f8 100644 --- a/net/src/packet/handle/world/mod.rs +++ b/net/src/packet/handle/world/mod.rs @@ -74,7 +74,7 @@ pub use self::party::{DenyPartyRequestHandler, MultiChatHandler, PartyOperationH pub use self::party_search_update::PartySearchUpdateHandler; pub use self::pet::{ pets_grow_hungry, Hungry, MovePetHandler, PetChatHandler, PetCommandHandler, PetFoodHandler, - SpawnPetHandler, + PetLootHandler, SpawnPetHandler, }; pub use self::pickup::ItemPickupHandler; pub use self::player_interaction::PlayerInteractionHandler; diff --git a/net/src/packet/handle/world/pet.rs b/net/src/packet/handle/world/pet.rs index a60cb3bcb..a00736890 100644 --- a/net/src/packet/handle/world/pet.rs +++ b/net/src/packet/handle/world/pet.rs @@ -10,6 +10,7 @@ use crate::packet::build::world::pet::{ build_cash_pet_food, build_pet_chat, build_pet_command, build_pet_food, build_pet_level_up, }; use crate::packet::build::world::stat::{build_stat_update, StatUpdate}; +use game::inventory::equip_slot; use game::pet::{PetChange, PetCommand}; use game::{InventoryChange, InventoryType, Item, Player}; use game_data::items::ConsumeSpec; @@ -77,6 +78,54 @@ impl WorldHandler for MovePetHandler { } } +/// PET_LOOT (0xAA, FUN_00705c7c): the pet's cash serial, the field key, +/// the tick, where the pet stands, the drop, its CRC and the pet's sweep, +/// range and pick-up-others flags. A pet takes mesos with a meso magnet +/// and items with an item pouch worn for it (FUN_00705e2c). The client +/// doesn't wait for an answer. +pub struct PetLootHandler; + +impl WorldHandler for PetLootHandler { + fn handle(&self, packet: &mut Packet, ctx: &mut WorldContext) -> WorldResult { + let mut r = Cursor::new(&packet.bytes); + r.read_short()?; + let serial = r.read_long()?; + r.read_byte()?; + r.read_int()?; + let x = r.read_short()?; + let y = r.read_short()?; + let object_id = r.read_int()?; + let player = &*ctx.player; + let index = player.pets.iter().position(|&pet| pet == serial); + let Some(index) = index.filter(|_| player.stats.hp > 0) else { + return Ok(HandlerResult::empty()); + }; + let worn = |item_id| { + equip_slot::pet_slots(item_id).is_some_and(|slots| { + let position = slots[index] - 100; + player + .inventory + .get(InventoryType::Equipped, position) + .is_some() + }) + }; + let (mesos, items) = (worn(1_812_000), worn(1_812_001)); + if !mesos && !items { + return Ok(HandlerResult::empty()); + } + Ok( + HandlerResult::empty().with(WorldAction::Field(FieldRequest::PetPickup { + index: index as u8, + object_id, + x, + y, + mesos, + items, + })), + ) + } +} + /// PET_COMMAND (0xA9, FUN_00704d5d): the pet's cash serial, whether the /// words began with its name, and the command (`interact/`) they /// matched. The server rolls whether the pet obeys; obeying earns diff --git a/net/src/packet/op/recv.rs b/net/src/packet/op/recv.rs index 1a28a2cb9..1bace5802 100644 --- a/net/src/packet/op/recv.rs +++ b/net/src/packet/op/recv.rs @@ -83,6 +83,7 @@ pub enum RecvOpcode { MovePet = 0xA7, PetChat = 0xA8, PetCommand = 0xA9, + PetLoot = 0xAA, MoveSummon = 0xAF, SummonAttack = 0xB0, DamageSummon = 0xB1, diff --git a/runtime/src/actor/client.rs b/runtime/src/actor/client.rs index 2480853db..f84e80fbe 100644 --- a/runtime/src/actor/client.rs +++ b/runtime/src/actor/client.rs @@ -1050,6 +1050,7 @@ impl ClientActor { y, } => self.drop_reactor_loot(reactor_id, object_id, x, y).await?, ServerMessage::PickedUp { drop } => self.picked_up(drop).await?, + ServerMessage::PetPickedUp { drop, pet } => self.pet_picked_up(drop, pet).await?, ServerMessage::Locate { reply_to } => self.locate(&reply_to), ServerMessage::RockLocate { reply_to, rock } => { let found = self diff --git a/runtime/src/actor/client/loot.rs b/runtime/src/actor/client/loot.rs index 7513c1e73..4e52000b2 100644 --- a/runtime/src/actor/client/loot.rs +++ b/runtime/src/actor/client/loot.rs @@ -58,9 +58,34 @@ impl ClientActor { /// The field handed over a drop: put it in the inventory, or give it back /// if it doesn't fit. pub(super) async fn picked_up(&mut self, drop: Drop) -> Result<(), RuntimeError> { + if !self.take_loot(&drop).await? { + return self.send_to_field(FieldMessage::ReturnDrop { drop }).await; + } + Ok(()) + } + + /// The field handed this player's pet at `pet` a drop it still shows: + /// put it in the inventory, and have the field show the pet taking it or + /// leave it lying. + pub(super) async fn pet_picked_up(&mut self, drop: Drop, pet: u8) -> Result<(), RuntimeError> { + let taken = self.take_loot(&drop).await?; + let looter = self.in_game()?.player.id(); + self.send_to_field(FieldMessage::PetLooted { + looter, + pet, + drop, + taken, + }) + .await + } + + /// Put a drop in the inventory. Returns false, having told the player, + /// when it doesn't fit. + async fn take_loot(&mut self, drop: &Drop) -> Result { if let Loot::Item { item_id, .. } = drop.loot { if monster_book::is_card(item_id) { - return self.collect_card(item_id).await; + self.collect_card(item_id).await?; + return Ok(true); } } let mut packets = Vec::new(); @@ -72,7 +97,8 @@ impl ClientActor { let meso = player.meso; packets.push(stat::build_stat_update(true, &[StatUpdate::Meso(meso)])?); packets.push(drop::build_meso_gain(*amount)?); - return self.send_all(&mut packets).await; + self.send_all(&mut packets).await?; + return Ok(true); } Loot::Item { item_id, quantity, .. @@ -100,10 +126,11 @@ impl ClientActor { packets.push(inventory::build_inventory_full()?); packets.push(stat::build_stat_update(true, &[])?); self.send_all(&mut packets).await?; - return self.send_to_field(FieldMessage::ReturnDrop { drop }).await; + return Ok(false); } } - self.send_all(&mut packets).await + self.send_all(&mut packets).await?; + Ok(true) } /// A picked-up card goes straight into the Monster Book; once its page diff --git a/runtime/src/actor/field.rs b/runtime/src/actor/field.rs index 70ab7a6da..369bb88cd 100644 --- a/runtime/src/actor/field.rs +++ b/runtime/src/actor/field.rs @@ -11,7 +11,9 @@ use game::{FieldAvatar, MobStat}; use net::conti::{self, Happening}; use net::packet::build::world::conti::build_conti_move; use net::packet::build::world::door::{build_remove_door, build_spawn_door}; -use net::packet::build::world::drop::{build_drop, build_remove_drop, DropAppearance}; +use net::packet::build::world::drop::{ + build_drop, build_pet_loot_drop, build_remove_drop, DropAppearance, +}; use net::packet::build::world::field::{ build_player_enter_field, build_player_leave_field, build_show_chair, parse_movement, parse_movement_state, @@ -426,6 +428,24 @@ impl FieldActor { start, movement, } => self.handle_pet_move(from, index, start, movement), + FieldRequest::PetPickup { + index, + object_id, + x, + y, + mesos, + items, + } => { + let now = Instant::now(); + let taken = self + .drops + .take_for_pet(from, object_id, (x, y), now, mesos, items); + if let (Ok(drop), Some(occupant)) = (taken, self.occupants.get(&from)) { + occupant + .handle + .send(ServerMessage::PetPickedUp { drop, pet: index }); + } + } FieldRequest::SummonAttack { attack, limits } => { self.handle_summon_attack(from, attack, &limits) } @@ -443,6 +463,20 @@ impl FieldActor { y, dropper, } => self.spawn_loot(owner, loot, x, y, dropper), + FieldMessage::PetLooted { + looter, + pet, + drop, + taken, + } => { + // The pet's drop was never taken off the field, so it only + // goes back in the set when it didn't fit. + if !taken { + self.drops.restore(drop); + } else if let Ok(packet) = build_pet_loot_drop(drop.object_id, looter, pet) { + self.send_drop_packet(&drop, packet); + } + } FieldMessage::ReturnDrop { drop } => { let now = Instant::now(); if let Ok(packet) = build_drop(&drop.view(now), DropAppearance::Lying) { diff --git a/runtime/src/actor/field_drops.rs b/runtime/src/actor/field_drops.rs index b5cc680d0..956920050 100644 --- a/runtime/src/actor/field_drops.rs +++ b/runtime/src/actor/field_drops.rs @@ -66,6 +66,8 @@ pub enum PickupError { Gone, TooFar, NotYours, + /// A pet without the item for it, or a drop a player threw away. + NotForPets, } pub struct DropSet { @@ -174,6 +176,29 @@ impl DropSet { self.drops.remove(&object_id).ok_or(PickupError::Gone) } + /// Take a drop for the pet of `client` standing at (x, y), which takes + /// mesos with `mesos` and items with `items`. As in v83 (FUN_0050483a), + /// pets leave what players threw away. + pub fn take_for_pet( + &mut self, + client: ClientId, + object_id: i32, + (x, y): (i16, i16), + now: Instant, + mesos: bool, + items: bool, + ) -> Result { + let drop = self.drops.get(&object_id).ok_or(PickupError::Gone)?; + let wanted = match drop.loot { + Loot::Mesos(_) => mesos, + _ => items, + }; + if drop.player_drop || !wanted { + return Err(PickupError::NotForPets); + } + self.take(client, object_id, x, y, now) + } + /// Put back a drop that couldn't be picked up (a full inventory). pub fn restore(&mut self, drop: Drop) { self.drops.insert(drop.object_id, drop); @@ -242,6 +267,55 @@ mod tests { assert!(drops.take(2, ids[1], 100, 0, now + OWNERSHIP).is_ok()); } + #[test] + fn pets_take_what_their_items_allow_but_not_what_players_threw() { + let now = Instant::now(); + let mut drops = DropSet::default(); + let ids: Vec = drops + .spawn( + vec![ + Loot::Mesos(5), + Loot::Item { + item_id: 4000019, + quantity: 1, + quest: None, + }, + ], + 1, + 100, + 0, + 9, + now, + ) + .iter() + .map(|d| d.object_id) + .collect(); + let thrown = drops.throw(Loot::Mesos(7), 1, 100, 0, now).object_id; + let at = (100, 0); + // A meso magnet alone leaves items, and no pet takes what was thrown. + assert_eq!( + drops + .take_for_pet(1, ids[1], at, now, true, false) + .unwrap_err(), + PickupError::NotForPets + ); + assert_eq!( + drops + .take_for_pet(1, thrown, at, now, true, true) + .unwrap_err(), + PickupError::NotForPets + ); + // Someone else's pet waits for the owner's time to pass. + assert_eq!( + drops + .take_for_pet(2, ids[0], at, now, true, true) + .unwrap_err(), + PickupError::NotYours + ); + let drop = drops.take_for_pet(1, ids[0], at, now, true, false).unwrap(); + assert_eq!(drop.loot, Loot::Mesos(5)); + } + #[test] fn thrown_drops_are_anyones_at_once() { let now = Instant::now(); diff --git a/runtime/src/message.rs b/runtime/src/message.rs index 33d23362e..fb66585fc 100644 --- a/runtime/src/message.rs +++ b/runtime/src/message.rs @@ -96,6 +96,12 @@ pub enum ServerMessage { PickedUp { drop: crate::actor::field_drops::Drop, }, + /// The field gave this player's pet at `pet` a drop it still shows; + /// answer with `FieldMessage::PetLooted`. + PetPickedUp { + drop: crate::actor::field_drops::Drop, + pet: u8, + }, /// Whether a quest NPC is on the player's map and within reach. QuestNpcChecked { request_id: u64, @@ -292,6 +298,14 @@ pub enum FieldMessage { ReturnDrop { drop: crate::actor::field_drops::Drop, }, + /// The pet at `pet` of `looter` took the drop it was given, or with + /// `taken` false left it where it lies. + PetLooted { + looter: ClientId, + pet: u8, + drop: crate::actor::field_drops::Drop, + taken: bool, + }, /// Check that an NPC with this template id is near the player before a /// quest action; answered with `ServerMessage::QuestNpcChecked`. QuestNpc { From d77ff1ea741e8240346c781ee5b0a0fb05727202 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sun, 18 Oct 2026 12:45:46 -0400 Subject: [PATCH 3/4] L159: pet equipment in the client: the pet panel, wearing and choosing The equipment window's pet panel now works as in v83. Its tabs open for each pet out, and its eleven cells show what each pet wears, with red overlays where nothing can go. Pet equipment can be dragged onto a cell of the pet whose tab is open, with v83's refusals: clothes need a pet of their kind at their level, label and quote rings a kind that shows them, and the ability items a pet out (FUN_004f1c2d). Clothes are drawn on the pet, frame by frame, for its owner and for everyone else. Double-clicking pet equipment in the item window puts it on a pet that can wear it (FUN_004efd25). With one pet out it goes on that pet, and the HP and MP pouches never choose. Otherwise clothes go on the pets of their kind, ability items on any pet, and of the rings only the Pet Label Ring and the Pet Quote Ring choose. When more than one pet qualifies, v83's pet chooser (UtilDlgEx_Pet) shows them one at a time with their name, level and closeness, and OK or Enter picks the one shown. A successful double-click equip now plays DragEnd, as v83's shared tail does. Item pouches, magnets and the other 181 to 183 items were invisible in the inventory, since only 180 was treated as pet equipment. --- HeavenClient/CMakeLists.txt | 2 + HeavenClient/Character/Char.cpp | 6 +- HeavenClient/Character/Look/CharLook.cpp | 24 +++ HeavenClient/Character/Look/CharLook.h | 8 + HeavenClient/Character/Look/PetLook.cpp | 86 ++++++-- HeavenClient/Character/Look/PetLook.h | 15 ++ HeavenClient/Character/Pets.h | 194 +++++++++++++++++++ HeavenClient/Character/Player.cpp | 10 + HeavenClient/Data/ItemData.cpp | 2 +- HeavenClient/Data/PetData.cpp | 6 + HeavenClient/Data/PetData.h | 5 + HeavenClient/Graphics/Animation.cpp | 5 + HeavenClient/Graphics/Animation.h | 3 + HeavenClient/IO/UI.cpp | 2 + HeavenClient/IO/UIElement.h | 1 + HeavenClient/IO/UITypes/UIEquipInventory.cpp | 139 +++++++++++-- HeavenClient/IO/UITypes/UIEquipInventory.h | 7 + HeavenClient/IO/UITypes/UIItemInventory.cpp | 133 ++++++++++++- HeavenClient/IO/UITypes/UIItemInventory.h | 16 ++ HeavenClient/IO/UITypes/UIPetChooser.cpp | 159 +++++++++++++++ HeavenClient/IO/UITypes/UIPetChooser.h | 76 ++++++++ HeavenClient/tests/pets.cpp | 56 ++++++ 22 files changed, 922 insertions(+), 33 deletions(-) create mode 100644 HeavenClient/IO/UITypes/UIPetChooser.cpp create mode 100644 HeavenClient/IO/UITypes/UIPetChooser.h diff --git a/HeavenClient/CMakeLists.txt b/HeavenClient/CMakeLists.txt index 0cc868728..d69b53049 100644 --- a/HeavenClient/CMakeLists.txt +++ b/HeavenClient/CMakeLists.txt @@ -606,6 +606,8 @@ add_executable(HeavenClient IO/UITypes/UITeleportRock.h IO/UITypes/UIMegaphone.cpp IO/UITypes/UIMegaphone.h + IO/UITypes/UIPetChooser.cpp + IO/UITypes/UIPetChooser.h IO/UITypes/UIItemMegaphone.cpp IO/UITypes/UIItemMegaphone.h IO/UITypes/UIInviteCard.cpp diff --git a/HeavenClient/Character/Char.cpp b/HeavenClient/Character/Char.cpp index 276f39c96..355e144f7 100644 --- a/HeavenClient/Character/Char.cpp +++ b/HeavenClient/Character/Char.cpp @@ -180,11 +180,15 @@ namespace ms } } - for (auto &pet : pets) + for (size_t i = 0; i < pets.size(); i++) { + PetLook &pet = pets[i]; + if (!pet.get_itemid()) continue; + pet.set_wear(look.get_pet_wear(i)); + // Another's pet goes where its owner's client says. if (pet.is_local()) { diff --git a/HeavenClient/Character/Look/CharLook.cpp b/HeavenClient/Character/Look/CharLook.cpp index f46e3cffe..7bbd3055d 100644 --- a/HeavenClient/Character/Look/CharLook.cpp +++ b/HeavenClient/Character/Look/CharLook.cpp @@ -19,6 +19,8 @@ #include "AttackStances.h" +#include "../Pets.h" + #include "../../Data/WeaponData.h" namespace ms @@ -32,7 +34,18 @@ namespace ms set_face(entry.faceid); for (auto &equip : entry.equips) + { + // Pet equipment is the pets', not the character's. + if (pets::is_equip_part(equip.first)) + { + if (int8_t index = pets::wear_owner(equip.first); index >= 0) + pet_wear[index] = equip.second; + + continue; + } + add_equip(equip.second); + } } CharLook::CharLook() @@ -658,6 +671,17 @@ namespace ms return face; } + int32_t CharLook::get_pet_wear(size_t index) const + { + return index < pet_wear.size() ? pet_wear[index] : 0; + } + + void CharLook::set_pet_wear(size_t index, int32_t itemid) + { + if (index < pet_wear.size()) + pet_wear[index] = itemid; + } + const CharEquips &CharLook::get_equips() const { return equips; diff --git a/HeavenClient/Character/Look/CharLook.h b/HeavenClient/Character/Look/CharLook.h index 31cacfa6b..77b4d4bae 100644 --- a/HeavenClient/Character/Look/CharLook.h +++ b/HeavenClient/Character/Look/CharLook.h @@ -17,6 +17,7 @@ ////////////////////////////////////////////////////////////////////////////////// #pragma once +#include #include #include @@ -59,6 +60,12 @@ namespace ms void add_equip(int32_t equipid); void remove_equip(EquipSlot::Id slot); + // The clothes the pet at `index` in the list wears (body parts 14, + // 30 and 38), 0 for none. + int32_t get_pet_wear(size_t index) const; + + void set_pet_wear(size_t index, int32_t itemid); + void attack(bool degenerate); // Change to a regular attack stance from get_attackstance. @@ -154,6 +161,7 @@ namespace ms const Hair *hair; const Face *face; CharEquips equips; + std::array pet_wear = {}; Randomizer randomizer; TimedBool alerted; diff --git a/HeavenClient/Character/Look/PetLook.cpp b/HeavenClient/Character/Look/PetLook.cpp index 5be66acb1..62e641976 100644 --- a/HeavenClient/Character/Look/PetLook.cpp +++ b/HeavenClient/Character/Look/PetLook.cpp @@ -22,6 +22,7 @@ #include "../../Constants.h" #include "../../Audio/Audio.h" #include "../../Data/PetData.h" +#include "../../Util/Misc.h" #include "../../Util/Randomizer.h" #include @@ -41,6 +42,40 @@ namespace ms return { type, pets::action(reaction.act, customs), text }; } + + // The animations of the stances, as Item.nx Pet/.img names them. + const char *STANCE_NAMES[PetLook::Stance::LENGTH] = { + "move", "stand0", "jump", "alert", "prone", "fly", "hang", "" + }; + + // The frames of `src`, by their number. + std::vector frames(nl::node src) + { + std::vector textures; + + for (nl::node sub : src) + { + if (sub.data_type() != nl::node::type::bitmap) + continue; + + int16_t index = string_conversion::or_default(sub.name(), -1); + + if (index < 0 || index >= 64) + continue; + + if (static_cast(index) >= textures.size()) + textures.resize(index + 1); + + textures[index] = sub; + } + + return textures; + } + + nl::node clothes(int32_t wear, int32_t itemid) + { + return nl::nx::character["PetEquip"]["0" + std::to_string(wear) + ".img"][std::to_string(itemid)]; + } } PetLook::Stance PetLook::stancebyvalue(uint8_t value) @@ -66,7 +101,7 @@ namespace ms shown({ static_cast(entry.position.x()), static_cast(entry.position.y()), 0.0, 0.0, entry.fh, entry.stance }), - action(-1), effecting(false), pending(false), still(0) + action(-1), wear(0), effecting(false), pending(false), still(0) { set_position(entry.position.x(), entry.position.y()); set_stance(entry.stance); @@ -78,13 +113,8 @@ namespace ms std::string strid = std::to_string(itemid); nl::node src = nl::nx::item["Pet"][strid + ".img"]; - animations[Stance::MOVE] = src["move"]; - animations[Stance::STAND] = src["stand0"]; - animations[Stance::JUMP] = src["jump"]; - animations[Stance::ALERT] = src["alert"]; - animations[Stance::PRONE] = src["prone"]; - animations[Stance::FLY] = src["fly"]; - animations[Stance::HANG] = src["hang"]; + for (size_t i = 0; i < Stance::WARP; i++) + animations[static_cast(i)] = src[STANCE_NAMES[i]]; nl::node effsrc = nl::nx::effect["PetEff.img"][strid]; @@ -92,18 +122,24 @@ namespace ms } PetLook::PetLook() : itemid(0), serial(0), local(false), stance(Stance::STAND), flip(false), - shown({ 0.0, 0.0, 0.0, 0.0, 0, 0 }), action(-1), effecting(false), pending(false), + shown({ 0.0, 0.0, 0.0, 0.0, 0, 0 }), action(-1), wear(0), effecting(false), pending(false), still(0) {} void PetLook::draw(double viewx, double viewy, float alpha) const { Point absp = phobj.get_absolute(viewx, viewy, alpha); + DrawArgument args = DrawArgument(absp, flip); if (action >= 0) - acting.draw(DrawArgument(absp, flip), alpha); - else - animations[stance].draw(DrawArgument(absp, flip), alpha); + { + acting.draw(args, alpha); + draw_dressing(acting_dressing, acting, args, alpha); + } else + { + animations[stance].draw(args, alpha); + draw_dressing(dressing[stance], animations[stance], args, alpha); + } if (effecting) effect.draw(DrawArgument(absp), alpha); @@ -288,6 +324,8 @@ namespace ms { acting = src; action = said.action; + acting_name = actname; + acting_dressing = wear ? frames(clothes(wear, itemid)[actname]) : std::vector(); } // A command's answer plays the pet's sound for its own actions. @@ -456,6 +494,30 @@ namespace ms namelabel.change_text(name); } + void PetLook::set_wear(int32_t w) + { + if (wear == w) + return; + + wear = w; + + nl::node src = wear ? clothes(wear, itemid) : nl::node(); + + for (size_t i = 0; i < Stance::WARP; i++) + dressing[static_cast(i)] = frames(src[STANCE_NAMES[i]]); + + acting_dressing = action >= 0 ? frames(src[acting_name]) : std::vector(); + } + + void PetLook::draw_dressing(const std::vector &textures, const Animation &animation, + const DrawArgument &args, float alpha) const + { + size_t index = animation.get_frame_index(alpha); + + if (index < textures.size()) + textures[index].draw(args); + } + bool PetLook::is_local() const { return local; diff --git a/HeavenClient/Character/Look/PetLook.h b/HeavenClient/Character/Look/PetLook.h index 901340924..29c1e57c7 100644 --- a/HeavenClient/Character/Look/PetLook.h +++ b/HeavenClient/Character/Look/PetLook.h @@ -117,6 +117,10 @@ namespace ms // PET_NAMECHANGE (172): the pet answers to its new name. void set_name(const std::string &name); + // Dress it in the clothes `wear` (180xxxx) its owner's look gives + // its place in the list, or none for 0 (FUN_00703d15). + void set_wear(int32_t wear); + bool is_local() const; Stance get_stance() const; @@ -130,6 +134,10 @@ namespace ms // once it moves, and now and then act on its own. void update_acting(Point before); + // Draw the clothes' frame for the one `animation` shows. + void draw_dressing(const std::vector &textures, const Animation &animation, const DrawArgument &args, + float alpha) const; + int32_t itemid; std::string name; int64_t serial; @@ -151,6 +159,13 @@ namespace ms // pet moves. int16_t action; Animation acting; + std::string acting_name; + + // The clothes it wears, and their frames over each of its own + // (Character/PetEquip/.img//). + int32_t wear; + EnumMap> dressing; + std::vector acting_dressing; ChatBalloon balloon; Animation effect; bool effecting; diff --git a/HeavenClient/Character/Pets.h b/HeavenClient/Character/Pets.h index c78404708..cb5ed9163 100644 --- a/HeavenClient/Character/Pets.h +++ b/HeavenClient/Character/Pets.h @@ -386,5 +386,199 @@ namespace ms return {}; } + + // Pet equipment goes on body parts 14 and 21 to 48, which the + // three pets in the list share out between them. + inline bool is_equip_part(int16_t part) + { + return part == 14 || (part >= 21 && part <= 48); + } + + // The parts pet equipment `itemid` goes on for each pet in the list + // (FUN_00460358), the same for all three for the HP and MP pouches. + // None for an item that isn't pet equipment. + inline std::optional> equip_parts(int32_t itemid) + { + switch (itemid) + { + case 1802100: + return std::array{ 21, 31, 39 }; + case 1812000: + return std::array{ 23, 34, 42 }; + case 1812001: + return std::array{ 22, 33, 41 }; + case 1812002: + return std::array{ 24, 24, 24 }; + case 1812003: + return std::array{ 25, 25, 25 }; + case 1812004: + return std::array{ 26, 35, 43 }; + case 1812005: + return std::array{ 27, 36, 44 }; + case 1812006: + return std::array{ 28, 37, 45 }; + case 1812007: + return std::array{ 46, 47, 48 }; + default: + break; + } + + switch (itemid / 10000) + { + case 180: + return std::array{ 14, 30, 38 }; + case 182: + return std::array{ 21, 31, 39 }; + case 183: + return std::array{ 29, 32, 40 }; + default: + return std::nullopt; + } + } + + // The pet in the list wearing what is on `part`, -1 for the shared + // pouches (FUN_00705e2c). + inline int8_t equip_owner(int16_t part) + { + if (part == 14 || (part >= 21 && part <= 29 && part != 24 && part != 25) || part == 46) + return 0; + + if ((part >= 30 && part <= 37) || part == 47) + return 1; + + if ((part >= 38 && part <= 45) || part == 48) + return 2; + + return -1; + } + + // The pet whose clothes are on `part` (14, 30 and 38), or -1. + inline int8_t wear_owner(int16_t part) + { + switch (part) + { + case 14: + return 0; + case 30: + return 1; + case 38: + return 2; + default: + return -1; + } + } + + // A pet in the list, as far as putting equipment on it goes. + struct Wearer + { + bool out = false; + // The item is clothing made for its kind + // (Character/PetEquip/.img/). + bool suits = false; + // Its kind shows a label ring's name tag, and a quote ring's + // balloon (Item.nx Pet/.img/info/nameTag, chatBalloon). + bool name_tag = false; + bool chat_balloon = false; + int16_t level = 0; + }; + + // StringPool 0x36f, 0x370 and 0x364. + constexpr const char *NO_PET = "You do not currently have a pet that is alive."; + constexpr const char *CANNOT_WEAR = "The pet is unable to wear the equipment."; + constexpr const char *STATS_TOO_LOW = "Your stats are too low to equip this item."; + + // Why equipment needing level `req_level` can't go on `part` of + // `pet`, or null if it can (FUN_004f1c2d). Clothes need a pet of + // the kind they were made for, at the level, and the rings a kind + // that shows them. The potion pouches, the Wing Boots, the Binocular + // and the Magic Scales need no pet at all. + inline const char *refuse_equip(int16_t part, int16_t req_level, const Wearer &pet) + { + switch (part) + { + case 14: + case 30: + case 38: + if (!pet.out || !pet.suits) + return CANNOT_WEAR; + + // FUN_004f2cee compares the pet's level, not the owner's. + if (pet.level < req_level) + return STATS_TOO_LOW; + + return nullptr; + case 21: + case 31: + case 39: + if (!pet.out) + return NO_PET; + + return pet.name_tag ? nullptr : CANNOT_WEAR; + case 29: + case 32: + case 40: + if (!pet.out) + return NO_PET; + + return pet.chat_balloon ? nullptr : CANNOT_WEAR; + case 22: + case 23: + case 46: + case 33: + case 34: + case 47: + case 41: + case 42: + case 48: + return pet.out ? nullptr : NO_PET; + default: + return nullptr; + } + } + + // A pet that pet equipment can go on: its place in the list, and + // the part. + struct EquipChoice + { + uint8_t pet; + int16_t part; + + bool operator==(const EquipChoice &other) const + { + return pet == other.pet && part == other.part; + } + }; + + // The pets a double-click in the item window can put pet equipment + // `itemid` on (FUN_004efd25), given the three places in the list. + // With one pet out or none, and for the potion pouches, it is the + // first pet's part whether it can wear it or not. Otherwise clothes + // go on the pets of their kind, ability items on any, and only the + // Pet Label Ring and the Pet Quote Ring of the rings may choose. + inline std::vector equip_choices(int32_t itemid, const std::array &pets) + { + std::optional> parts = equip_parts(itemid); + + if (!parts) + return {}; + + int16_t first = (*parts)[0]; + + if (first == 24 || first == 25 || !pets[1].out) + return { { 0, first } }; + + std::vector choices; + + for (uint8_t i = 0; i < pets.size(); i++) + { + bool fits = itemid / 10000 == 180 ? pets[i].suits + : itemid / 10000 == 181 || itemid == 1822000 || itemid == 1832000; + + if (pets[i].out && fits) + choices.push_back({ i, (*parts)[i] }); + } + + return choices; + } } } diff --git a/HeavenClient/Character/Player.cpp b/HeavenClient/Character/Player.cpp index 47e50f82c..54c473cfb 100644 --- a/HeavenClient/Character/Player.cpp +++ b/HeavenClient/Character/Player.cpp @@ -198,6 +198,16 @@ namespace ms if (part == EquipSlot::Id::NONE) return; + // The pets wear their own equipment, and the look carries only + // their clothes. + if (pets::is_equip_part(part)) + { + if (int8_t index = pets::wear_owner(part); index >= 0) + look.set_pet_wear(index, inventory.get_item_id(InventoryType::Id::EQUIPPED, inventory.shown_equip(part))); + + return; + } + // The look wears a cash item over the regular one, but has no cash // weapons (v83 draws them over the regular weapon's stances). int16_t shown = part == EquipSlot::Id::WEAPON ? part : inventory.shown_equip(part); diff --git a/HeavenClient/Data/ItemData.cpp b/HeavenClient/Data/ItemData.cpp index 2181a0247..59a43aee2 100644 --- a/HeavenClient/Data/ItemData.cpp +++ b/HeavenClient/Data/ItemData.cpp @@ -131,7 +131,7 @@ namespace ms return categorynames[index]; else if (index >= 30 && index <= 70) return "Weapon"; - else if (index == 80) + else if (index >= 80 && index <= 83) return "PetEquip"; else return ""; diff --git a/HeavenClient/Data/PetData.cpp b/HeavenClient/Data/PetData.cpp index ea4fd5967..e5b77470f 100644 --- a/HeavenClient/Data/PetData.cpp +++ b/HeavenClient/Data/PetData.cpp @@ -54,6 +54,7 @@ namespace ms kind = nl::nx::string["Pet.img"][strid]["name"].get_string(); balloon = static_cast(src["info"]["chatBalloon"].get_integer()); + name_tag = static_cast(src["info"]["nameTag"].get_integer()); nl::node interact = src["interact"]; @@ -95,6 +96,11 @@ namespace ms return balloon; } + int8_t PetData::get_name_tag() const + { + return name_tag; + } + const std::vector &PetData::get_commands() const { return commands; diff --git a/HeavenClient/Data/PetData.h b/HeavenClient/Data/PetData.h index 5462cee83..a397e26ef 100644 --- a/HeavenClient/Data/PetData.h +++ b/HeavenClient/Data/PetData.h @@ -37,6 +37,10 @@ namespace ms // The style of its chat balloon when called by name (info/chatBalloon). int8_t get_balloon() const; + // The style of its name tag with a label ring on (info/nameTag), 0 + // for a kind that shows none. + int8_t get_name_tag() const; + // Its commands, by the index sent for them. const std::vector &get_commands() const; @@ -57,6 +61,7 @@ namespace ms std::string kind; int8_t balloon; + int8_t name_tag; std::vector commands; std::vector food; std::vector idle; diff --git a/HeavenClient/Graphics/Animation.cpp b/HeavenClient/Graphics/Animation.cpp index aadbf6ffd..79cb77e3d 100644 --- a/HeavenClient/Graphics/Animation.cpp +++ b/HeavenClient/Graphics/Animation.cpp @@ -334,6 +334,11 @@ namespace ms return get_frame().get_bounds(); } + int16_t Animation::get_frame_index(float alpha) const + { + return frame.get(alpha); + } + const Frame &Animation::get_frame() const { return frames[frame.get()]; diff --git a/HeavenClient/Graphics/Animation.h b/HeavenClient/Graphics/Animation.h index e35cc4e21..b38f4132e 100644 --- a/HeavenClient/Graphics/Animation.h +++ b/HeavenClient/Graphics/Animation.h @@ -89,6 +89,9 @@ namespace ms uint16_t get_delay(int16_t frame) const; + // The frame drawn at `alpha` between updates. + int16_t get_frame_index(float alpha) const; + uint16_t getdelayuntil(int16_t frame) const; // How long one play through every frame takes. diff --git a/HeavenClient/IO/UI.cpp b/HeavenClient/IO/UI.cpp index b7e6a8763..4725beab1 100644 --- a/HeavenClient/IO/UI.cpp +++ b/HeavenClient/IO/UI.cpp @@ -352,6 +352,7 @@ namespace ms types.emplace_back(UIElement::Type::TELEPORTROCK); types.emplace_back(UIElement::Type::MEGAPHONE); types.emplace_back(UIElement::Type::ITEMMEGAPHONE); + types.emplace_back(UIElement::Type::PETCHOOSER); } else if (enter) { @@ -363,6 +364,7 @@ namespace ms types.emplace_back(UIElement::Type::ENTERNUMBER); types.emplace_back(UIElement::Type::TELEPORTROCK); types.emplace_back(UIElement::Type::MEGAPHONE); + types.emplace_back(UIElement::Type::PETCHOOSER); } else if (tab) { // Game diff --git a/HeavenClient/IO/UIElement.h b/HeavenClient/IO/UIElement.h index cf322c978..4ace4c85b 100644 --- a/HeavenClient/IO/UIElement.h +++ b/HeavenClient/IO/UIElement.h @@ -88,6 +88,7 @@ namespace ms TELEPORTROCK, MEGAPHONE, ITEMMEGAPHONE, + PETCHOOSER, NUM_TYPES }; diff --git a/HeavenClient/IO/UITypes/UIEquipInventory.cpp b/HeavenClient/IO/UITypes/UIEquipInventory.cpp index 93284e628..357c20b46 100644 --- a/HeavenClient/IO/UITypes/UIEquipInventory.cpp +++ b/HeavenClient/IO/UITypes/UIEquipInventory.cpp @@ -31,6 +31,8 @@ #include +#include + namespace ms { namespace @@ -43,12 +45,13 @@ namespace ms constexpr int16_t SLOT_SIZE = 32; // Where an icon is drawn from, relative to its cell. constexpr Point ICON = Point(0, 0); - constexpr Point PET_SHOW = Point(115, 279); - // The pet panel hangs off the right edge, flush with the bottom. - constexpr Point PET_PANEL = Point(173, 123); - constexpr Point PET_TAB = Point(9, 151); + constexpr Point PET_SHOW = Point(102, 280); + // The pet panel hangs off the right edge, flush with the bottom + // (FUN_007fe02e), with a tab for each pet (FUN_007ffe73). + constexpr Point PET_PANEL = Point(172, 123); + constexpr Point PET_TAB = Point(10, 151); constexpr int16_t PET_TAB_PITCH = 34; - constexpr Point PET_HIDE = Point(154, 159); + constexpr Point PET_HIDE = Point(155, 159); struct Cell { @@ -84,6 +87,43 @@ namespace ms constexpr Point MOB_EQUIP = Point(2, 6); + struct PetCell + { + // The part it holds on each pet's tab. + std::array parts; + Point position; + }; + + // The pet panel's cells (0x00be2260), from its top left. + constexpr PetCell PET_CELLS[] = { + { { 24, 24, 24 }, { 13, 11 } }, + { { 25, 25, 25 }, { 79, 11 } }, + { { 22, 33, 41 }, { 13, 44 } }, + { { 23, 34, 42 }, { 46, 44 } }, + { { 46, 47, 48 }, { 13, 77 } }, + { { 28, 37, 45 }, { 46, 77 } }, + { { 26, 35, 43 }, { 79, 77 } }, + { { 14, 30, 38 }, { 112, 77 } }, + { { 27, 36, 44 }, { 13, 110 } }, + { { 21, 31, 39 }, { 79, 110 } }, + { { 29, 32, 40 }, { 112, 110 } } + }; + + // The first pet's clothes, label and quote ring cells, and the auto + // potions' slots beside the HP and MP pouches. + constexpr Point PET_WEAR = Point(112, 77); + constexpr Point PET_LABEL = Point(79, 110); + constexpr Point PET_QUOTE = Point(112, 110); + constexpr Point HP_POTION = Point(46, 11); + constexpr Point MP_POTION = Point(112, 11); + constexpr int16_t HP_POUCH = 24; + constexpr int16_t MP_POUCH = 25; + + EquipSlot::Id part_slot(int16_t part) + { + return static_cast(part); + } + constexpr Point cell_position(int16_t column, int16_t row) { return SLOT + Point(column * SLOT_PITCH, row * SLOT_PITCH); @@ -94,11 +134,15 @@ namespace ms } UIEquipInventory::UIEquipInventory(const Inventory &invent) - : UIDragElement(), inventory(invent), pet_enabled(false) + : UIDragElement(), inventory(invent), pet_enabled(false), pet_tab(0) { for (auto cell : CELLS) iconpositions[cell.slot] = cell_position(cell.column, cell.row); + for (const PetCell &cell : PET_CELLS) + for (int16_t part : cell.parts) + iconpositions[part_slot(part)] = PET_PANEL + cell.position; + nl::node Equip = NxFiles::ui_classic["UIWindow.img"]["Equip"]; backgrnd = Equip["backgrnd"]; @@ -114,15 +158,13 @@ namespace ms buttons[Buttons::BT_PET_HIDE] = std::make_unique(hide, PET_PANEL + PET_HIDE + Texture(hide["normal"]["0"]).get_origin()); - // Each tab shows a pet's equipment, which RustMS doesn't have yet, so they - // stay disabled. + // Each tab shows a pet's equipment, and is there while the pet is out. for (uint16_t i = 0; i < 3; i++) { - nl::node tab = Equip["BtPet" + std::to_string(i + 1)]; + nl::node tab = NxFiles::ui_classic["UIWindow.img"]["UserInfo"]["BtPet" + std::to_string(i + 1)]; Point pos = PET_PANEL + PET_TAB + Point(PET_TAB_PITCH * i, 0); buttons[Buttons::BT_PET1 + i] = std::make_unique(tab, pos + Texture(tab["normal"]["0"]).get_origin()); - buttons[Buttons::BT_PET1 + i]->set_state(Button::State::DISABLED); } Point bg_dimensions = backgrnd.get_dimensions(); @@ -137,10 +179,33 @@ namespace ms { UIElement::draw_sprites(alpha); - backgrnd.draw(position); - + // The panel slides out from under the window (FUN_00801474). if (pet_enabled) - pet.draw(position + PET_PANEL); + { + Point panel = position + PET_PANEL; + + pet.draw(panel); + + if (!has_pet(0)) + { + unavailable.draw(panel + PET_WEAR); + unavailable.draw(panel + PET_LABEL); + unavailable.draw(panel + PET_QUOTE); + } + + // No pouch, no auto potion. + if (!has_pet(0) || !is_worn(HP_POUCH)) + unavailable.draw(panel + HP_POTION); + + if (!has_pet(0) || !is_worn(MP_POUCH)) + unavailable.draw(panel + MP_POTION); + + for (const PetCell &cell : PET_CELLS) + if (auto &icon = icons[part_slot(cell.parts[pet_tab])]) + icon->draw(panel + cell.position + ICON); + } + + backgrnd.draw(position); for (auto cell : CELLS) if (is_unavailable(cell.slot)) @@ -169,6 +234,12 @@ namespace ms case Buttons::BT_PET_HIDE: show_pet(false); break; + case Buttons::BT_PET1: + case Buttons::BT_PET2: + case Buttons::BT_PET3: + pet_tab = id - Buttons::BT_PET1; + clear_tooltip(); + break; default: break; } @@ -176,6 +247,32 @@ namespace ms return Button::State::NORMAL; } + void UIEquipInventory::update() + { + UIElement::update(); + + for (uint8_t i = 0; i < 3; i++) + { + Button &tab = *buttons[Buttons::BT_PET1 + i]; + bool out = has_pet(i); + + if (!out) + tab.set_state(Button::State::DISABLED); + else if (tab.get_state() == Button::State::DISABLED) + tab.set_state(Button::State::NORMAL); + } + } + + bool UIEquipInventory::has_pet(uint8_t index) const + { + return static_cast(Stage::get().get_player().get_pet(index)); + } + + bool UIEquipInventory::is_worn(int16_t part) const + { + return inventory.get_item_id(InventoryType::Id::EQUIPPED, inventory.shown_equip(part_slot(part))) != 0; + } + void UIEquipInventory::show_pet(bool show) { pet_enabled = show; @@ -244,6 +341,10 @@ namespace ms for (auto cell : CELLS) update_slot(cell.slot); + + for (const PetCell &cell : PET_CELLS) + for (int16_t part : cell.parts) + update_slot(part_slot(part)); } Cursor::State UIEquipInventory::send_cursor(bool pressed, Point cursorpos) @@ -374,6 +475,18 @@ namespace ms return cell.slot; } + if (!pet_enabled) + return EquipSlot::Id::NONE; + + for (const PetCell &cell : PET_CELLS) + { + Point cellpos = position + PET_PANEL + cell.position; + Rectangle cellrect = Rectangle(cellpos, cellpos + Point(SLOT_SIZE, SLOT_SIZE)); + + if (cellrect.contains(cursorpos)) + return part_slot(cell.parts[pet_tab]); + } + return EquipSlot::Id::NONE; } diff --git a/HeavenClient/IO/UITypes/UIEquipInventory.h b/HeavenClient/IO/UITypes/UIEquipInventory.h index 5b42ce749..3a5f5507c 100644 --- a/HeavenClient/IO/UITypes/UIEquipInventory.h +++ b/HeavenClient/IO/UITypes/UIEquipInventory.h @@ -37,6 +37,8 @@ namespace ms void draw(float inter) const override; + void update() override; + void toggle_active() override; bool send_icon(const Icon& icon, Point position) override; @@ -63,6 +65,9 @@ namespace ms bool is_unavailable(EquipSlot::Id slot) const; EquipSlot::Id slot_by_position(Point position) const; void show_pet(bool show); + // Whether the pet at `index` in the list is out. + bool has_pet(uint8_t index) const; + bool is_worn(int16_t part) const; class EquipIcon : public Icon::Type { @@ -101,5 +106,7 @@ namespace ms ColorBox unavailable; bool pet_enabled; + // The pet whose equipment the panel shows. + uint8_t pet_tab; }; } diff --git a/HeavenClient/IO/UITypes/UIItemInventory.cpp b/HeavenClient/IO/UITypes/UIItemInventory.cpp index 12e4054db..b4d7c9b03 100644 --- a/HeavenClient/IO/UITypes/UIItemInventory.cpp +++ b/HeavenClient/IO/UITypes/UIItemInventory.cpp @@ -22,6 +22,7 @@ #include "UIMegaphone.h" #include "UINotice.h" #include "UIPersonalShop.h" +#include "UIPetChooser.h" #include "UITeleportRock.h" #include "UITradingRoom.h" @@ -37,6 +38,7 @@ #include "../../Character/Inventory/ScrollRules.h" #include "../../Data/CurseData.h" #include "../../Data/EquipData.h" +#include "../../Data/PetData.h" #include "../../Gameplay/Stage.h" #include "../../Util/Chalkboard.h" #include "../../Util/Megaphones.h" @@ -49,6 +51,8 @@ #include +#include + namespace ms { namespace @@ -675,11 +679,15 @@ namespace ms switch (tab) { case InventoryType::Id::EQUIP: - if (can_wear_equip(slot)) + if (pets::equip_parts(item_id)) + { + wear_pet_equip(slot, item_id); + } else if (can_wear_equip(slot)) { int16_t worn = inventory.equip_position(slot, inventory.find_equipslot(item_id)); Stage::get().get_player().request(EquipItemPacket(slot, worn), ExclRequest::WAIT); + Sound(Sound::Name::DRAGEND).play(); } break; @@ -969,13 +977,11 @@ namespace ms return first_slot() + (full_enabled ? MAXFULLSLOTS : MAXSLOTS) - 1; } - bool UIItemInventory::can_wear_equip(int16_t slot) const + bool UIItemInventory::can_change_equips() const { const Player &player = Stage::get().get_player(); - const CharStats &stats = player.get_stats(); - const CharLook &look = player.get_look(); - if (look.get_alerted()) + if (player.get_look().get_alerted()) { UI::get().emplace(CATCH_BREATH, [](bool) {}); @@ -989,6 +995,17 @@ namespace ms return false; } + return true; + } + + bool UIItemInventory::can_wear_equip(int16_t slot) const + { + const Player &player = Stage::get().get_player(); + const CharStats &stats = player.get_stats(); + + if (!can_change_equips()) + return false; + const int32_t item_id = inventory.get_item_id(InventoryType::Id::EQUIP, slot); const EquipData &equipdata = EquipData::get(item_id); const ItemData &itemdata = equipdata.get_itemdata(); @@ -1060,6 +1077,99 @@ namespace ms return true; } + bool UIItemInventory::can_wear_pet_equip(int16_t slot, int16_t part) const + { + if (!can_change_equips()) + return false; + + int32_t item_id = inventory.get_item_id(InventoryType::Id::EQUIP, slot); + int8_t owner = pets::equip_owner(part); + pets::Wearer wearer = owner >= 0 ? pet_wearer(owner, item_id) : pets::Wearer(); + int16_t req_level = EquipData::get(item_id).get_reqstat(MapleStat::Id::LEVEL); + + if (const char *refusal = pets::refuse_equip(part, req_level, wearer)) + { + UI::get().emplace(refusal, [](bool) + {}); + return false; + } + + return true; + } + + pets::Wearer UIItemInventory::pet_wearer(uint8_t index, int32_t item_id) const + { + pets::Wearer wearer; + Optional pet = Stage::get().get_player().get_pet(index); + + if (!pet) + return wearer; + + int32_t kind = pet->get_itemid(); + const PetData &data = PetData::get(kind); + + wearer.out = true; + // Clothes are drawn for the kinds they were made for. + wearer.suits = item_id / 10000 == 180 + && nl::nx::character["PetEquip"]["0" + std::to_string(item_id) + ".img"][std::to_string(kind)]; + wearer.name_tag = data.get_name_tag() != 0; + wearer.chat_balloon = data.get_balloon() != 0; + wearer.level = inventory.get_pet_level(pet->get_serial()); + + return wearer; + } + + void UIItemInventory::wear_pet_equip(int16_t slot, int32_t item_id) const + { + Player &player = Stage::get().get_player(); + std::array wearers; + + for (uint8_t i = 0; i < wearers.size(); i++) + wearers[i] = pet_wearer(i, item_id); + + std::vector choices = pets::equip_choices(item_id, wearers); + + auto wear = [slot, item_id](int16_t part) + { + auto window = UI::get().get_element(); + + // The item may have moved while the pet was chosen. + if (!window || window->inventory.get_item_id(InventoryType::Id::EQUIP, slot) != item_id + || !window->can_wear_pet_equip(slot, part)) + return; + + int16_t worn = window->inventory.equip_position(slot, static_cast(part)); + + Stage::get().get_player().request(EquipItemPacket(slot, worn), ExclRequest::WAIT); + Sound(Sound::Name::DRAGEND).play(); + }; + + if (choices.size() == 1) + { + wear(choices[0].part); + } else if (choices.size() > 1) + { + std::vector shown; + + for (const pets::EquipChoice &choice : choices) + { + Optional look = player.get_pet(choice.pet); + Optional pet = inventory.get_pet(look->get_serial()); + + if (pet) + shown.push_back({ look->get_itemid(), pet->get_name(), pet->get_level(), + static_cast(pet->get_closeness()) }); + else + shown.push_back({ look->get_itemid(), "", 0, 0 }); + } + + UI::get().emplace(std::move(shown), [wear, choices](size_t index) + { + wear(choices[index].part); + }); + } + } + int16_t UIItemInventory::slot_by_position(Point cursorpos) const { Point offset = cursorpos - (full_enabled ? FULL_SLOT : SLOT); @@ -1272,7 +1382,18 @@ namespace ms switch (sourcetab) { case InventoryType::Id::EQUIP: - if (eqsource == eqslot) + if (std::optional> parts = pets::equip_parts(item_id)) + { + // Pet equipment goes on the cell it was dropped on, of + // the pet whose tab is open (FUN_004f1b67). + if (std::find(parts->begin(), parts->end(), eqslot) != parts->end() + && parent.can_wear_pet_equip(source, eqslot)) + { + int16_t worn = Stage::get().get_player().get_inventory().equip_position(source, eqslot); + + Stage::get().get_player().request(EquipItemPacket(source, worn), ExclRequest::WAIT); + } + } else if (eqsource == eqslot) { int16_t worn = Stage::get().get_player().get_inventory().equip_position(source, eqslot); diff --git a/HeavenClient/IO/UITypes/UIItemInventory.h b/HeavenClient/IO/UITypes/UIItemInventory.h index 6dab7707c..09f04a56a 100644 --- a/HeavenClient/IO/UITypes/UIItemInventory.h +++ b/HeavenClient/IO/UITypes/UIItemInventory.h @@ -24,6 +24,8 @@ #include "../Components/ClassicTabs.h" #include "../Components/Slider.h" +#include "../../Character/Pets.h" + namespace ms { // The Item inventory @@ -81,8 +83,22 @@ namespace ms bool is_visible(int16_t slot) const; + // Whether the player may change equipment now, with a notice if not. + bool can_change_equips() const; + bool can_wear_equip(int16_t slot) const; + // Whether the pet equipment in `slot` may go on `part`, with a + // notice if not (FUN_004f1c2d). + bool can_wear_pet_equip(int16_t slot, int16_t part) const; + + // The pet at `index` in the list, as putting `item_id` on it goes. + pets::Wearer pet_wearer(uint8_t index, int32_t item_id) const; + + // Put the pet equipment `item_id` in `slot` on a pet it can go on, + // asking which when there are several (FUN_004efd25). + void wear_pet_equip(int16_t slot, int32_t item_id) const; + int16_t first_slot() const; int16_t last_slot() const; diff --git a/HeavenClient/IO/UITypes/UIPetChooser.cpp b/HeavenClient/IO/UITypes/UIPetChooser.cpp new file mode 100644 index 000000000..66f4346ff --- /dev/null +++ b/HeavenClient/IO/UITypes/UIPetChooser.cpp @@ -0,0 +1,159 @@ +#include "UIPetChooser.h" + +#include "../KeyAction.h" + +#include "../Components/MapleButton.h" + +#include "../../Constants.h" +#include "../../Util/NxFiles.h" + +#include + +#include + +namespace ms +{ + namespace + { + // FUN_009a3cae and FUN_009a3ced; FUN_009a3e38 centres it on the screen. + constexpr int16_t WIDTH = 419, HEIGHT = 297; + // The rest is from the window's top left (FUN_009a9b25 and FUN_009a3e9f). v83 draws a line's cap top + // about 3 below where it is drawn, we 9, so text goes up by the difference. + constexpr int16_t TEXT_RAISE = 6; + const Point PREV_AT(15, 191); + const Point NEXT_AT(341, 191); + const Point OK_AT(314, 272); + const Point CANCEL_AT(363, 272); + const Point EXIT_AT(9, 272); + const Point NAME_TAG_AT(123, 220); + const Point PET_AT(209, 218); + constexpr int16_t NAME_X = 210, NAME_Y = 224; + constexpr int16_t LEVEL_X = 194, CLOSENESS_X = 280, NUMBERS_Y = 243; + // The caption's box (FUN_009acc20): its text starts 120 across, and is centred on 100 rows from 28 down + // when it fits. + constexpr int16_t CAPTION_X = 120, CAPTION_TOP = 28, CAPTION_ROWS = 100; + constexpr uint16_t CAPTION_WIDTH = 280; + + // StringPool 0x182. + constexpr const char *CAPTION = "Please select the pet you would like to put the pet equipment on."; + } + + UIPetChooser::UIPetChooser(std::vector p, std::function oc) + : UIElement(Point(), Point(WIDTH, HEIGHT)), pets(std::move(p)), onchoose(std::move(oc)), + shown(0) + { + nl::node window = NxFiles::ui_classic["UIWindow.img"]; + nl::node avatar = window["UtilDlgEx_Avatar"]; + nl::node basic = NxFiles::ui_classic["Basic.img"]; + + sprites.emplace_back(window["UtilDlgEx_Pet"]["backgrnd"]); + name_tag = avatar["nameTag"]; + + buttons[Buttons::PREV] = std::make_unique(avatar["BtPrev"], PREV_AT); + buttons[Buttons::NEXT] = std::make_unique(avatar["BtNext"], NEXT_AT); + buttons[Buttons::OK] = std::make_unique(basic["BtOK2"], OK_AT); + buttons[Buttons::CANCEL] = std::make_unique(basic["BtCancel2"], CANCEL_AT); + buttons[Buttons::EXIT] = std::make_unique(avatar["BtExit"], EXIT_AT); + + caption = Text(Text::Font::A12M, Text::Alignment::LEFT, Color::Name::BLACK, CAPTION, CAPTION_WIDTH); + name = Text(Text::Font::A12M, Text::Alignment::CENTER, Color::Name::BLACK); + level = Text(Text::Font::A12M, Text::Alignment::CENTER, Color::Name::BLACK); + closeness = Text(Text::Font::A12M, Text::Alignment::CENTER, Color::Name::BLACK); + + show(0); + update_screen(Constants::Constants::get().get_viewwidth(), Constants::Constants::get().get_viewheight()); + } + + void UIPetChooser::draw(float alpha) const + { + UIElement::draw_sprites(alpha); + + int16_t height = caption.height(); + int16_t top = height <= CAPTION_ROWS ? CAPTION_TOP + (CAPTION_ROWS - 6 - height) / 2 : CAPTION_TOP - 6; + + caption.draw(position + Point(CAPTION_X, top + 2 - TEXT_RAISE)); + name_tag.draw(position + NAME_TAG_AT); + name.draw(position + Point(NAME_X, NAME_Y - TEXT_RAISE)); + level.draw(position + Point(LEVEL_X, NUMBERS_Y - TEXT_RAISE)); + closeness.draw(position + Point(CLOSENESS_X, NUMBERS_Y - TEXT_RAISE)); + standing.draw(position + PET_AT, alpha); + + UIElement::draw_buttons(alpha); + } + + void UIPetChooser::update() + { + UIElement::update(); + + standing.update(); + } + + void UIPetChooser::update_screen(int16_t new_width, int16_t new_height) + { + int16_t x = std::clamp(new_width / 2 - WIDTH / 2, 0, std::max(new_width - WIDTH, 0)); + int16_t y = std::clamp(new_height / 2 - HEIGHT / 2, 0, std::max(new_height - HEIGHT, 0)); + + position = Point(x, y); + } + + void UIPetChooser::send_key(int32_t keycode, bool pressed, bool escape) + { + if (!pressed) + return; + + // FUN_009ac0b6: Escape is Cancel, and FUN_004edd21 makes Enter OK. The arrows do nothing here. + if (escape) + deactivate(); + else if (keycode == KeyAction::Id::RETURN) + choose(); + } + + UIElement::Type UIPetChooser::get_type() const + { + return TYPE; + } + + Button::State UIPetChooser::button_pressed(uint16_t buttonid) + { + size_t count = pets.size(); + + switch (buttonid) + { + case Buttons::OK: + choose(); + break; + case Buttons::CANCEL: + case Buttons::EXIT: + deactivate(); + break; + case Buttons::PREV: + show((shown + count - 1) % count); + break; + case Buttons::NEXT: + show((shown + 1) % count); + break; + default: + break; + } + + return Button::State::NORMAL; + } + + void UIPetChooser::show(size_t index) + { + shown = index; + + const Pet &pet = pets[index]; + + standing = nl::nx::item["Pet"][std::to_string(pet.itemid) + ".img"]["stand0"]; + name.change_text(pet.name); + level.change_text(std::to_string(pet.level)); + closeness.change_text(std::to_string(pet.closeness)); + } + + void UIPetChooser::choose() + { + deactivate(); + onchoose(shown); + } +} diff --git a/HeavenClient/IO/UITypes/UIPetChooser.h b/HeavenClient/IO/UITypes/UIPetChooser.h new file mode 100644 index 000000000..2a58ec12f --- /dev/null +++ b/HeavenClient/IO/UITypes/UIPetChooser.h @@ -0,0 +1,76 @@ +#pragma once + +#include "../UIElement.h" + +#include "../../Graphics/Animation.h" +#include "../../Graphics/Text.h" + +#include +#include +#include +#include + +namespace ms +{ + // Which pet a double-clicked piece of pet equipment goes on, when more than one can wear it (v83 CUtilDlgEx + // mode 6, FUN_009a9b25 on UIWindow.img/UtilDlgEx_Pet). It shows one pet at a time, standing, with its name, + // level and closeness, and Prev and Next go round them. It is modal: OK or Enter picks the pet shown, and + // Cancel, the corner button or Escape picks none. + class UIPetChooser : public UIElement + { + public: + static constexpr Type TYPE = UIElement::Type::PETCHOOSER; + static constexpr bool FOCUSED = true; + static constexpr bool TOGGLED = false; + + struct Pet + { + // Its kind (500xxxx). + int32_t itemid; + std::string name; + int16_t level; + int16_t closeness; + }; + + // `onchoose` gets the index in `pets` of the pet picked. + UIPetChooser(std::vector pets, std::function onchoose); + + void draw(float alpha) const override; + + void update() override; + + void update_screen(int16_t new_width, int16_t new_height) override; + + void send_key(int32_t keycode, bool pressed, bool escape) override; + + UIElement::Type get_type() const override; + + protected: + Button::State button_pressed(uint16_t buttonid) override; + + private: + // Show the pet `index` steps round from the first. + void show(size_t index); + + void choose(); + + enum Buttons : uint16_t + { + OK, + CANCEL, + EXIT, + PREV, + NEXT + }; + + std::vector pets; + std::function onchoose; + size_t shown; + Animation standing; + Texture name_tag; + Text caption; + Text name; + Text level; + Text closeness; + }; +} diff --git a/HeavenClient/tests/pets.cpp b/HeavenClient/tests/pets.cpp index b4246c2d4..69d701626 100644 --- a/HeavenClient/tests/pets.cpp +++ b/HeavenClient/tests/pets.cpp @@ -257,6 +257,61 @@ void naming() require(check("tigger", "Tigger").first == Naming::OK, "its name in another case"); } +void equipment() +{ + using namespace ms::pets; + + require(equip_parts(1802063) == std::array{ 14, 30, 38 }, "clothes"); + require(equip_parts(1802100) == std::array{ 21, 31, 39 }, "the collar goes with the labels"); + require(equip_parts(1822000) == std::array{ 21, 31, 39 }, "label ring"); + require(equip_parts(1832000) == std::array{ 29, 32, 40 }, "quote ring"); + require(equip_parts(1812000) == std::array{ 23, 34, 42 }, "meso magnet"); + require(equip_parts(1812002) == std::array{ 24, 24, 24 }, "the HP pouch is shared"); + require(equip_parts(1812007) == std::array{ 46, 47, 48 }, "ignore pendant"); + require(!equip_parts(1812008), "an unknown ability item"); + require(!equip_parts(1002008), "a cap"); + + require(equip_owner(14) == 0 && equip_owner(29) == 0 && equip_owner(46) == 0, "the first pet's parts"); + require(equip_owner(30) == 1 && equip_owner(37) == 1 && equip_owner(47) == 1, "the second pet's parts"); + require(equip_owner(38) == 2 && equip_owner(45) == 2 && equip_owner(48) == 2, "the third pet's parts"); + require(equip_owner(24) == -1 && equip_owner(25) == -1, "the pouches are no one's"); + require(wear_owner(30) == 1 && wear_owner(31) == -1, "clothes by pet"); + require(is_equip_part(14) && is_equip_part(48) && !is_equip_part(15) && !is_equip_part(49), "pet parts"); + + Wearer none; + Wearer tiger{ true, true, true, true, 5 }; + Wearer kitty{ true, false, false, false, 5 }; + + require(refuse_equip(14, 0, none) == CANNOT_WEAR, "clothes need a pet"); + require(refuse_equip(30, 0, kitty) == CANNOT_WEAR, "clothes made for another kind"); + require(refuse_equip(38, 0, tiger) == nullptr, "clothes for its kind"); + require(refuse_equip(14, 10, tiger) == STATS_TOO_LOW, "clothes above the pet's level"); + require(refuse_equip(21, 0, none) == NO_PET, "a label needs a pet"); + require(refuse_equip(31, 0, kitty) == CANNOT_WEAR, "a kind without a name tag"); + require(refuse_equip(39, 0, tiger) == nullptr, "a label on a kind with one"); + require(refuse_equip(32, 0, kitty) == CANNOT_WEAR, "a kind without a balloon"); + require(refuse_equip(40, 0, none) == NO_PET, "a quote ring needs a pet"); + require(refuse_equip(47, 0, none) == NO_PET, "the pendant needs a pet"); + require(refuse_equip(23, 0, kitty) == nullptr, "a magnet on any pet"); + require(refuse_equip(35, 0, none) == nullptr, "boots need none"); + require(refuse_equip(24, 0, none) == nullptr, "nor do the pouches"); + + using Choices = std::vector; + std::array alone{ kitty, none, none }; + std::array pair{ kitty, tiger, none }; + std::array three{ tiger, kitty, tiger }; + + require(equip_choices(1802063, alone) == Choices{ { 0, 14 } }, "one pet out: its part, suited or not"); + require(equip_choices(1822000, { none, none, none }) == Choices{ { 0, 21 } }, "none out: the first pet's part"); + require(equip_choices(1812002, three) == Choices{ { 0, 24 } }, "the pouches don't choose"); + require(equip_choices(1802063, pair) == Choices{ { 1, 30 } }, "clothes for the one pet of their kind"); + require(equip_choices(1802063, three) == Choices{ { 0, 14 }, { 2, 38 } }, "clothes for the pets of their kind"); + require(equip_choices(1812000, three) == Choices{ { 0, 23 }, { 1, 34 }, { 2, 42 } }, "a magnet for any"); + require(equip_choices(1832000, pair) == Choices{ { 0, 29 }, { 1, 32 } }, "the quote ring for any"); + require(equip_choices(1822001, three).empty(), "other rings don't choose"); + require(equip_choices(1002008, three).empty(), "not pet equipment"); +} + int main() { try @@ -270,6 +325,7 @@ int main() actions(); food(); naming(); + equipment(); std::cout << "Pets passed\n"; return 0; From 0756d9dcfa4316176449c0b4b12d54b7f2023c3a Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sun, 18 Oct 2026 12:52:31 -0400 Subject: [PATCH 4/4] L159: pets in the client reach for loot, and loot flies to the pet Each pet out that wears a Meso Magnet or an Item Pouch now looks for a drop at its feet every tick and asks for it with PET_LOOT, as v83 does (FUN_00705c5d, FUN_0050483a, FUN_00705c7c). It reaches as far as its owner does, 25 px to either side and from 50 px above to 10 px below, and leaves drops that players dropped, mesos without a magnet and items without a pouch. Unlike its owner a pet never takes another character's or party's drop, unless it wears Magic Scales, and then only after 15 s. A drop is tried by the pets at most once every 3 s, apart from the owner's own tries. Pets don't loot for a dead owner, or in the Extra Frosty Snow Zone. When the server says a pet took a drop (remove mode 5), the drop now flies to that pet's feet instead of to its owner, and vanishes if the pet is no longer out (FUN_004416db). Not done yet: the item checksum (the server doesn't read it), an item's pickUpBlock flag, the drop's source id (RustMS always sends one), the exception list, and the sweep that walks a pet toward drops. --- HeavenClient/Character/Look/PetLook.cpp | 15 +++++++ HeavenClient/Character/Look/PetLook.h | 6 +++ HeavenClient/Character/Pets.h | 7 ++++ HeavenClient/Gameplay/MapleMap/Drop.cpp | 33 ++++++++++++++- HeavenClient/Gameplay/MapleMap/Drop.h | 14 +++++++ HeavenClient/Gameplay/MapleMap/ItemDrop.cpp | 5 +++ HeavenClient/Gameplay/MapleMap/ItemDrop.h | 2 + HeavenClient/Gameplay/MapleMap/MapDrops.cpp | 18 ++++++++ HeavenClient/Gameplay/MapleMap/MapDrops.h | 6 +++ HeavenClient/Gameplay/MapleMap/Pickup.h | 12 ++++++ HeavenClient/Gameplay/Stage.cpp | 41 +++++++++++++++++++ HeavenClient/Gameplay/Stage.h | 4 ++ .../Net/Handlers/MapObjectHandlers.cpp | 23 +++++++---- HeavenClient/Net/OutPacket.h | 1 + HeavenClient/Net/Packets/GameplayPackets.h | 23 +++++++++++ HeavenClient/tests/pickup.cpp | 7 ++++ 16 files changed, 209 insertions(+), 8 deletions(-) diff --git a/HeavenClient/Character/Look/PetLook.cpp b/HeavenClient/Character/Look/PetLook.cpp index 62e641976..f78504339 100644 --- a/HeavenClient/Character/Look/PetLook.cpp +++ b/HeavenClient/Character/Look/PetLook.cpp @@ -488,6 +488,21 @@ namespace ms return serial; } + Point PetLook::get_position() const + { + return phobj.get_position(); + } + + bool PetLook::is_on_ground() const + { + return phobj.onground; + } + + const PhysicsObject &PetLook::get_phobj() const + { + return phobj; + } + void PetLook::set_name(const std::string &n) { name = n; diff --git a/HeavenClient/Character/Look/PetLook.h b/HeavenClient/Character/Look/PetLook.h index 29c1e57c7..b01374573 100644 --- a/HeavenClient/Character/Look/PetLook.h +++ b/HeavenClient/Character/Look/PetLook.h @@ -114,6 +114,12 @@ namespace ms int64_t get_serial() const; + Point get_position() const; + + bool is_on_ground() const; + + const PhysicsObject &get_phobj() const; + // PET_NAMECHANGE (172): the pet answers to its new name. void set_name(const std::string &name); diff --git a/HeavenClient/Character/Pets.h b/HeavenClient/Character/Pets.h index cb5ed9163..78be87249 100644 --- a/HeavenClient/Character/Pets.h +++ b/HeavenClient/Character/Pets.h @@ -397,6 +397,13 @@ namespace ms // The parts pet equipment `itemid` goes on for each pet in the list // (FUN_00460358), the same for all three for the HP and MP pouches. // None for an item that isn't pet equipment. + // The ability items that change what a pet picks up (FUN_00705e2c). + constexpr int32_t MESO_MAGNET = 1812000; + constexpr int32_t ITEM_POUCH = 1812001; + constexpr int32_t SWEEPER = 1812004; + constexpr int32_t LONG_RANGE = 1812005; + constexpr int32_t MAGIC_SCALES = 1812006; + inline std::optional> equip_parts(int32_t itemid) { switch (itemid) diff --git a/HeavenClient/Gameplay/MapleMap/Drop.cpp b/HeavenClient/Gameplay/MapleMap/Drop.cpp index da587a976..4ae5ce95c 100644 --- a/HeavenClient/Gameplay/MapleMap/Drop.cpp +++ b/HeavenClient/Gameplay/MapleMap/Drop.cpp @@ -106,10 +106,12 @@ namespace ms elapsed = 0; age = 0; sincetry = pickup::RETRY; + sincepet = pickup::RETRY; delay = std::max(dly, 0); flighttime = flight_time(start.y(), dest.y(), pickuptype == EXPLOSIVE); phase = 0.0; looter = nullptr; + topet = false; if (mode == 2) { @@ -129,6 +131,7 @@ namespace ms elapsed += Constants::TIMESTEP; age += Constants::TIMESTEP; sincetry = std::min(sincetry + Constants::TIMESTEP, pickup::RETRY); + sincepet = std::min(sincepet + Constants::TIMESTEP, pickup::RETRY); if (state == Drop::State::WAITING) { @@ -222,7 +225,7 @@ namespace ms double t = elapsed; double targetx = looter->get_x(); - double targety = looter->get_y() - 4 - LOOTERHALFHEIGHT; + double targety = topet ? looter->get_y() : looter->get_y() - 4 - LOOTERHALFHEIGHT; double x = ((PICKUPTIME - t) * pickupstart.x() + t * targetx) / PICKUPTIME; double y = ((PICKUPTIME - t) * pickupstart.y() + t * targety) / PICKUPTIME - PICKUPARC + PICKUPARC * (t - PICKUPTIME / 2) * (t - PICKUPTIME / 2) / (PICKUPTIME * PICKUPTIME / 4); @@ -268,6 +271,7 @@ namespace ms break; default: looter = lt; + topet = type == 5; pick_up(); break; } @@ -303,4 +307,31 @@ namespace ms { sincetry = 0; } + + bool Drop::pet_lootable(Point feet, int32_t self, int32_t party, bool mesos, bool items, + bool others) const + { + // Pets leave what players dropped, and whatever their equipment + // doesn't pick up. + if (playerdrop || !(get_itemid() ? items : mesos)) + return false; + + if (pickup::pet_owned_by_other(pickuptype, owner, self, party, age - delay, others)) + return false; + + if (state != Drop::State::FLOATING || sincepet < pickup::RETRY) + return false; + + return pickup::in_reach(feet, spot); + } + + void Drop::try_pet_loot() + { + sincepet = 0; + } + + int32_t Drop::get_itemid() const + { + return 0; + } } diff --git a/HeavenClient/Gameplay/MapleMap/Drop.h b/HeavenClient/Gameplay/MapleMap/Drop.h index a29b5c90c..6e6b8fb12 100644 --- a/HeavenClient/Gameplay/MapleMap/Drop.h +++ b/HeavenClient/Gameplay/MapleMap/Drop.h @@ -40,6 +40,17 @@ namespace ms // Note a pickup request, so the drop isn't tried again for a while. void try_loot(); + // Whether a pet at `feet` could take the drop now, if it takes mesos, + // items and others' drops as given (FUN_0050483a). + bool pet_lootable(Point feet, int32_t self, int32_t party, bool mesos, bool items, + bool others) const; + + // Note a pet's request, so no pet tries the drop again for a while. + void try_pet_loot(); + + // The item, or 0 for mesos. + virtual int32_t get_itemid() const; + protected: Drop(int32_t oid, int32_t owner, Point start, Point dest, int8_t type, int8_t mode, int16_t delay, bool playerdrop, bool spins); @@ -74,6 +85,8 @@ namespace ms bool spins; const PhysicsObject *looter; + // It flies to a pet's feet rather than to a character's middle. + bool topet; State state; Point start; @@ -86,6 +99,7 @@ namespace ms // request for it. int32_t age; int32_t sincetry; + int32_t sincepet; int32_t delay; int32_t flighttime; double phase; diff --git a/HeavenClient/Gameplay/MapleMap/ItemDrop.cpp b/HeavenClient/Gameplay/MapleMap/ItemDrop.cpp index 128f91330..cd6bfb19d 100644 --- a/HeavenClient/Gameplay/MapleMap/ItemDrop.cpp +++ b/HeavenClient/Gameplay/MapleMap/ItemDrop.cpp @@ -32,4 +32,9 @@ namespace ms Point absp = centred(phobj.get_absolute(viewx, viewy, alpha), icon.get_origin(), icon.get_dimensions()); icon.draw({angle.get(alpha), absp, opacity.get(alpha)}); } + + int32_t ItemDrop::get_itemid() const + { + return itemid; + } } \ No newline at end of file diff --git a/HeavenClient/Gameplay/MapleMap/ItemDrop.h b/HeavenClient/Gameplay/MapleMap/ItemDrop.h index aa11b08e2..74913ba78 100644 --- a/HeavenClient/Gameplay/MapleMap/ItemDrop.h +++ b/HeavenClient/Gameplay/MapleMap/ItemDrop.h @@ -33,6 +33,8 @@ namespace ms void draw(double viewx, double viewy, float alpha) const override; + int32_t get_itemid() const override; + private: const Texture &icon; int32_t itemid; diff --git a/HeavenClient/Gameplay/MapleMap/MapDrops.cpp b/HeavenClient/Gameplay/MapleMap/MapDrops.cpp index a51913772..8f7182e2d 100644 --- a/HeavenClient/Gameplay/MapleMap/MapDrops.cpp +++ b/HeavenClient/Gameplay/MapleMap/MapDrops.cpp @@ -121,4 +121,22 @@ namespace ms return {0, {}}; } + + int32_t MapDrops::find_pet_loot_at(Point petpos, int32_t self, int32_t party, bool mesos, bool items, + bool others) + { + for (auto &mmo : drops) + { + Optional drop = mmo.second.get(); + + if (drop && drop->pet_lootable(petpos, self, party, mesos, items, others)) + { + drop->try_pet_loot(); + + return mmo.first; + } + } + + return 0; + } } \ No newline at end of file diff --git a/HeavenClient/Gameplay/MapleMap/MapDrops.h b/HeavenClient/Gameplay/MapleMap/MapDrops.h index 607e621b6..aea9024f0 100644 --- a/HeavenClient/Gameplay/MapleMap/MapDrops.h +++ b/HeavenClient/Gameplay/MapleMap/MapDrops.h @@ -59,6 +59,12 @@ namespace ms Loot find_loot_at(Point playerpos, int32_t self, int32_t party); + // The first drop a pet at `petpos` would reach for, if it takes + // mesos, items and others' drops as given, or 0 for none + // (FUN_0050483a). + int32_t find_pet_loot_at(Point petpos, int32_t self, int32_t party, bool mesos, bool items, + bool others); + private: MapObjects drops; diff --git a/HeavenClient/Gameplay/MapleMap/Pickup.h b/HeavenClient/Gameplay/MapleMap/Pickup.h index 79edd7cc5..8026f5542 100644 --- a/HeavenClient/Gameplay/MapleMap/Pickup.h +++ b/HeavenClient/Gameplay/MapleMap/Pickup.h @@ -34,5 +34,17 @@ namespace ms return (type == 0 && owner != self) || (type == 1 && owner != party); } + + // Whether a pet leaves a drop to someone else (FUN_0050483a). Unlike + // its owner it never takes another's drop, unless it wears Magic + // Scales (`others`), and then only once 15 s have passed. + inline bool pet_owned_by_other(int8_t type, int32_t owner, int32_t self, int32_t party, int32_t age, + bool others) + { + if (others && age > OWNERSHIP) + return false; + + return (type == 0 && owner != self) || (type == 1 && owner != party); + } } } diff --git a/HeavenClient/Gameplay/Stage.cpp b/HeavenClient/Gameplay/Stage.cpp index ece3f1a04..71d35fac0 100644 --- a/HeavenClient/Gameplay/Stage.cpp +++ b/HeavenClient/Gameplay/Stage.cpp @@ -18,6 +18,7 @@ #include "Stage.h" #include "../Configuration.h" +#include "../Character/Pets.h" #include "QuestConversation.h" #include "Timer.h" #include "MapleMap/CharClick.h" @@ -283,6 +284,7 @@ namespace ms // The player's summons follow where the player now is. summons.update(physics, player.get_position(), player.is_climbing()); + check_pet_loot(); portals.update(player.get_position()); check_touch_portals(drawn); @@ -650,6 +652,45 @@ namespace ms player.request(PickupItemPacket(loot.first, loot.second), ExclRequest::PICKUP_WAIT); } + void Stage::check_pet_loot() + { + // Pets don't loot for a dead owner, or in the Extra Frosty Snow Zone + // (FUN_0070403d). + if (player.is_dead() || mapid == 209080000) + return; + + const Inventory &inventory = player.get_inventory(); + // Whether the pet `index` wears `itemid` (FUN_00705e2c). + auto wears = [&](uint8_t index, int32_t itemid) { + auto part = static_cast((*pets::equip_parts(itemid))[index]); + + return inventory.get_item_id(InventoryType::Id::EQUIPPED, inventory.shown_equip(part)) == itemid; + }; + + for (uint8_t index = 0; index < 3; index++) + { + Optional pet = player.get_pet(index); + + if (!pet || !pet->is_on_ground()) + continue; + + bool mesos = wears(index, pets::MESO_MAGNET); + bool items = wears(index, pets::ITEM_POUCH); + + if (!mesos && !items) + continue; + + bool others = wears(index, pets::MAGIC_SCALES); + Point position = pet->get_position(); + + if (int32_t oid = drops.find_pet_loot_at(position, player.get_oid(), player.get_party().get_id(), mesos, + items, others)) + PetLootPacket(pet->get_serial(), position, oid, others, wears(index, pets::SWEEPER), + wears(index, pets::LONG_RANGE)) + .dispatch(); + } + } + void Stage::send_key(KeyType::Id type, int32_t action, bool down) { if (state != State::ACTIVE || !playable || player.is_dead()) diff --git a/HeavenClient/Gameplay/Stage.h b/HeavenClient/Gameplay/Stage.h index d8e4f3d79..bbb45bead 100644 --- a/HeavenClient/Gameplay/Stage.h +++ b/HeavenClient/Gameplay/Stage.h @@ -217,6 +217,10 @@ namespace ms void check_drops(); + // Each pet out with a Meso Magnet or an Item Pouch reaches for a + // drop at its feet (FUN_00705c5d). + void check_pet_loot(); + // Fear's darkness, all but the player's narrowed sight (FUN_0053868d). void draw_fear(double viewx, double viewy, float alpha) const; diff --git a/HeavenClient/Net/Handlers/MapObjectHandlers.cpp b/HeavenClient/Net/Handlers/MapObjectHandlers.cpp index 4a879e42e..56ea5eada 100644 --- a/HeavenClient/Net/Handlers/MapObjectHandlers.cpp +++ b/HeavenClient/Net/Handlers/MapObjectHandlers.cpp @@ -661,24 +661,33 @@ namespace ms int8_t mode = recv.read_byte(); int32_t oid = recv.read_int(); - Optional looter; + const PhysicsObject *looter = nullptr; if (mode > 1) { int32_t cid = recv.read_int(); - // A pet's loot flies to its owner. - if (recv.length() > 0) - recv.read_byte(); // pet - if (auto character = Stage::get().get_character(cid)) - looter = character->get_phobj(); + { + // Mode 5 names the pet in its owner's list that took it, and + // it flies to that pet, or vanishes if the pet isn't out + // (FUN_004416db). + if (mode == 5) + { + if (auto pet = character->get_pet(recv.read_byte())) + looter = &pet->get_phobj(); + } + else + { + looter = &character->get_phobj(); + } + } if (Stage::get().is_player(cid)) Sound(Sound::Name::PICKUP).play(); } - Stage::get().get_drops().remove(oid, mode, looter.get()); + Stage::get().get_drops().remove(oid, mode, looter); } void HitReactorHandler::handle(InPacket& recv) const diff --git a/HeavenClient/Net/OutPacket.h b/HeavenClient/Net/OutPacket.h index a61803e6e..e8226e1b8 100644 --- a/HeavenClient/Net/OutPacket.h +++ b/HeavenClient/Net/OutPacket.h @@ -133,6 +133,7 @@ namespace ms MOVE_PET = 167, PET_CHAT = 168, PET_COMMAND = 169, + PET_LOOT = 170, MOVE_SUMMON = 175, SUMMON_ATTACK = 176, DAMAGE_SUMMON = 177, diff --git a/HeavenClient/Net/Packets/GameplayPackets.h b/HeavenClient/Net/Packets/GameplayPackets.h index ffbdb0cdb..cd62eec1e 100644 --- a/HeavenClient/Net/Packets/GameplayPackets.h +++ b/HeavenClient/Net/Packets/GameplayPackets.h @@ -669,6 +669,29 @@ namespace ms } }; + // A pet standing at `position` reaches for a drop (v83 FUN_00705c7c). + // The flags say whether its equipment lets it take others' drops, sweep + // for drops and reach far. We send no checksum for an item; the server + // doesn't read it. + // Opcode: PET_LOOT(170) + class PetLootPacket : public OutPacket + { + public: + PetLootPacket(int64_t serial, Point position, int32_t oid, bool others, bool sweep, bool longrange) + : OutPacket(OutPacket::Opcode::PET_LOOT) + { + write_long(serial); + write_byte(0); + write_time(); + write_point(position); + write_int(oid); + write_int(0); + write_byte(others); + write_byte(sweep); + write_byte(longrange); + } + }; + // Hits a reactor (v83 FUN_007356c7). The kind is 0 for a swing and 1 for // a skill, the flags bit 0 for facing left and bit 1 for standing, and // the delay the ms until the hit lands, for others to see it then. diff --git a/HeavenClient/tests/pickup.cpp b/HeavenClient/tests/pickup.cpp index b321cbd6d..ca9e95be8 100644 --- a/HeavenClient/tests/pickup.cpp +++ b/HeavenClient/tests/pickup.cpp @@ -33,6 +33,13 @@ int main() require(!owned_by_other(2, 7, 8, 0, 0), "a free-for-all drop is anyone's"); require(!owned_by_other(3, 7, 8, 0, 0), "an explosive drop is anyone's"); + require(pet_owned_by_other(0, 7, 8, 0, 60000, false), "a pet never takes another's drop"); + require(!pet_owned_by_other(0, 8, 8, 0, 0, false), "a pet takes its owner's drop at once"); + require(!pet_owned_by_other(1, 4, 8, 4, 0, false), "and its owner's party's"); + require(!pet_owned_by_other(2, 7, 8, 0, 0, false), "and a free-for-all drop"); + require(pet_owned_by_other(0, 7, 8, 0, OWNERSHIP, true), "Magic Scales wait out the 15 s"); + require(!pet_owned_by_other(0, 7, 8, 0, OWNERSHIP + 1, true), "then take another's drop"); + std::cout << "Pickup passed\n"; return 0; }