diff --git a/resources/opcodes.json b/resources/opcodes.json index 1f37cff..14c4a09 100644 --- a/resources/opcodes.json +++ b/resources/opcodes.json @@ -241,12 +241,12 @@ "size": 8 }, { - "name": "InventorySlotDiscard", + "name": "InventoryTransaction", "opcode": 851, "size": 48 }, { - "name": "InventorySlotDiscardFin", + "name": "InventoryTransactionFinish", "opcode": 854, "size": 16 } @@ -396,6 +396,11 @@ "name": "UnkCall2", "opcode": 632, "size": 8 + }, + { + "name": "StandardControlsPivot", + "opcode": 836, + "size": 8 } ], "ServerLobbyIpcType": [ diff --git a/resources/tests/inventory_modify.bin b/resources/tests/inventory_modify.bin index d117db0..4ecda10 100644 Binary files a/resources/tests/inventory_modify.bin and b/resources/tests/inventory_modify.bin differ diff --git a/src/bin/kawari-world.rs b/src/bin/kawari-world.rs index e9eac6e..b3553dc 100644 --- a/src/bin/kawari-world.rs +++ b/src/bin/kawari-world.rs @@ -6,7 +6,7 @@ use kawari::RECEIVE_BUFFER_SIZE; use kawari::common::Position; use kawari::common::{GameData, timestamp_secs}; use kawari::config::get_config; -use kawari::inventory::Item; +use kawari::inventory::{Item, ItemOperationKind}; use kawari::ipc::chat::{ServerChatIpcData, ServerChatIpcSegment}; use kawari::ipc::zone::{ ActorControlCategory, ActorControlSelf, PlayerEntry, PlayerSpawn, PlayerStatus, SocialList, @@ -36,9 +36,7 @@ use tokio::sync::mpsc::{Receiver, UnboundedReceiver, UnboundedSender, channel, u use tokio::sync::oneshot; use tokio::task::JoinHandle; -use kawari::{ - INVENTORY_ACTION_ACK_GENERAL, /*INVENTORY_ACTION_ACK_SHOP,*/ INVENTORY_ACTION_DISCARD, -}; +use kawari::INVENTORY_ACTION_ACK_GENERAL; fn spawn_main_loop() -> (ServerHandle, JoinHandle<()>) { let (send, recv) = channel(64); @@ -810,24 +808,27 @@ async fn client_loop( data: SegmentData::Ipc { data: ipc }, }) .await; - if action.operation_type == INVENTORY_ACTION_DISCARD { + + connection.player_data.inventory.process_action(action); + + if action.operation_type == ItemOperationKind::Discard { tracing::info!("Player is discarding from their inventory!"); - let sequence = 0; // TODO: How is this decided? It seems to be a sequence value but it's not sent by the client! Perhaps it's a 'lifetime-of-the-character' value that simply gets increased for every inventory action ever taken? + let ipc = ServerZoneIpcSegment { - op_code: ServerZoneIpcType::InventorySlotDiscard, + op_code: ServerZoneIpcType::InventoryTransaction, timestamp: timestamp_secs(), - data: ServerZoneIpcData::InventorySlotDiscard { - unk1: sequence, + data: ServerZoneIpcData::InventoryTransaction { + unk1: connection.player_data.item_sequence, operation_type: action.operation_type, - src_actor_id: action.src_actor_id, + src_actor_id: connection.player_data.actor_id, src_storage_id: action.src_storage_id, src_container_index: action.src_container_index, - src_stack: action.src_stack, - src_catalog_id: action.src_catalog_id, - dst_actor_id: 0xE0000000, - dst_storage_id: 0xFFFF, - dst_container_index: 0xFFFF, - dst_catalog_id: 0x0000FFFF, + unk2: action.src_stack, + unk3: action.src_catalog_id, // TODO: unk2 and unk3 are not being set accurately here, but the client doesn't seem to care what they are + dst_actor_id: 0, + dst_storage_id: 0xE000, + dst_container_index: u16::MAX, + dst_catalog_id: u32::MAX, }, ..Default::default() }; @@ -842,11 +843,11 @@ async fn client_loop( .await; let ipc = ServerZoneIpcSegment { - op_code: ServerZoneIpcType::InventorySlotDiscard, + op_code: ServerZoneIpcType::InventoryTransactionFinish, timestamp: timestamp_secs(), - data: ServerZoneIpcData::InventorySlotDiscardFin { - unk1: sequence, - unk2: sequence, // yes, this repeats, it's not a copy paste error + data: ServerZoneIpcData::InventoryTransactionFinish { + unk1: connection.player_data.item_sequence, + unk2: connection.player_data.item_sequence, // yes, this repeats, it's not a copy paste error unk3: 0x90, unk4: 0x200, }, @@ -863,10 +864,7 @@ async fn client_loop( .await; } - connection.player_data.inventory.process_action(action); - - // TODO: This seems incorrect, the server wasn't observed to send updates here, but if we don't then the client doesn't realize the items have been modified - connection.send_inventory(true).await; + connection.player_data.item_sequence += 1; } // TODO: Likely rename this opcode if non-gil shops also use this same opcode ClientZoneIpcData::GilShopTransaction { event_id, unk1: _, buy_sell_mode, item_index, item_quantity, unk2: _ } => { @@ -989,6 +987,10 @@ async fn client_loop( )) .await; } + ClientZoneIpcData::StandardControlsPivot { .. } => { + /* No-op because we already seem to handle this, other nearby clients can see the sending player + * pivoting anyway. */ + } ClientZoneIpcData::EventUnkRequest { event_id, unk1, unk2, unk3 } => { let ipc = ServerZoneIpcSegment { op_code: ServerZoneIpcType::EventUnkReply, diff --git a/src/inventory/mod.rs b/src/inventory/mod.rs index 40d2364..6c07834 100644 --- a/src/inventory/mod.rs +++ b/src/inventory/mod.rs @@ -1,4 +1,5 @@ use crate::common::GameData; +use binrw::binrw; use icarus::{ClassJob::ClassJobSheet, Race::RaceSheet}; use physis::common::Language; use serde::{Deserialize, Serialize}; @@ -20,11 +21,46 @@ pub use storage::{ContainerType, Storage}; mod currency; pub use currency::CurrencyStorage; -use crate::INVENTORY_ACTION_DISCARD; +use crate::{ + INVENTORY_ACTION_COMBINE_STACK, INVENTORY_ACTION_DISCARD, INVENTORY_ACTION_EXCHANGE, + INVENTORY_ACTION_MOVE, INVENTORY_ACTION_SPLIT_STACK, +}; const MAX_NORMAL_STORAGE: usize = 35; const MAX_LARGE_STORAGE: usize = 50; +#[binrw] +#[derive(Debug, Clone, Default, Copy, PartialEq)] +#[brw(repr = u8)] +#[repr(u8)] +pub enum ItemOperationKind { + /// The operation opcode/type when discarding an item from the inventory. + Discard = INVENTORY_ACTION_DISCARD, + #[default] + /// The operation opcode/type when moving an item to an emtpy slot in the inventory. + Move = INVENTORY_ACTION_MOVE, + /// The operation opcode/type when moving an item to a slot occupied by another in the inventory. + Exchange = INVENTORY_ACTION_EXCHANGE, + /// The operation opcode/type when splitting stacks of identical items. + SplitStack = INVENTORY_ACTION_SPLIT_STACK, + /// The operation opcode/type when combining stacks of identical items. + CombineStack = INVENTORY_ACTION_COMBINE_STACK, +} + +impl TryFrom for ItemOperationKind { + type Error = (); + fn try_from(value: u8) -> Result { + match value { + x if x == ItemOperationKind::Discard as u8 => Ok(ItemOperationKind::Discard), + x if x == ItemOperationKind::Move as u8 => Ok(ItemOperationKind::Move), + x if x == ItemOperationKind::Exchange as u8 => Ok(ItemOperationKind::Exchange), + x if x == ItemOperationKind::SplitStack as u8 => Ok(ItemOperationKind::SplitStack), + x if x == ItemOperationKind::CombineStack as u8 => Ok(ItemOperationKind::CombineStack), + _ => Err(()), + } + } +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Inventory { pub equipped: EquippedStorage, @@ -171,36 +207,67 @@ impl Inventory { } } + /// Helper functions to reduce boilerplate + fn get_item_mut(&mut self, storage_id: ContainerType, storage_index: u16) -> &mut Item { + let container = self.get_container_mut(&storage_id); + container.get_slot_mut(storage_index) + } + + fn get_item(&self, storage_id: ContainerType, storage_index: u16) -> Item { + let container = self.get_container(&storage_id); + *container.get_slot(storage_index) + } + pub fn process_action(&mut self, action: &ItemOperation) { - if action.operation_type == INVENTORY_ACTION_DISCARD { - let src_container = self.get_container_mut(&action.src_storage_id); - let src_slot = src_container.get_slot_mut(action.src_container_index); - *src_slot = Item::default(); - } else { - // NOTE: only swaps items for now - - let src_item; - // get the source item - { - let src_container = self.get_container_mut(&action.src_storage_id); - let src_slot = src_container.get_slot_mut(action.src_container_index); - src_item = *src_slot; + match action.operation_type { + ItemOperationKind::Discard => { + let src_item = self.get_item_mut(action.src_storage_id, action.src_container_index); + *src_item = Item::default(); } + ItemOperationKind::CombineStack => { + let src_item; + { + let original_item = + self.get_item_mut(action.src_storage_id, action.src_container_index); + src_item = *original_item; + *original_item = Item::default(); + } - let dst_item; - // move into dst item - { - let dst_container = self.get_container_mut(&action.dst_storage_id); - let dst_slot = dst_container.get_slot_mut(action.dst_container_index); + let dst_item = self.get_item_mut(action.dst_storage_id, action.dst_container_index); + // TODO: We ought to check the max stack size for a given item id and disallow overflow + dst_item.quantity += src_item.quantity; + } + ItemOperationKind::SplitStack => { + let mut src_item; + { + let original_item = + self.get_item_mut(action.src_storage_id, action.src_container_index); + if original_item.quantity >= action.dst_stack { + original_item.quantity -= action.dst_stack; + src_item = *original_item; + src_item.quantity = action.dst_stack + } else { + tracing::warn!( + "Client sent a bogus split amount: {}! Rejecting item operation!", + action.dst_stack + ); + return; + } + } - dst_item = *dst_slot; + let dst_item = self.get_item_mut(action.dst_storage_id, action.dst_container_index); + dst_item.clone_from(&src_item); + } + ItemOperationKind::Exchange | ItemOperationKind::Move => { + let src_item = self.get_item(action.src_storage_id, action.src_container_index); + + // move src item into dst slot + let dst_slot = self.get_item_mut(action.dst_storage_id, action.dst_container_index); + let dst_item = *dst_slot; dst_slot.clone_from(&src_item); - } - // move dst item into src slot - { - let src_container = self.get_container_mut(&action.src_storage_id); - let src_slot = src_container.get_slot_mut(action.src_container_index); + // move dst item into src slot + let src_slot = self.get_item_mut(action.src_storage_id, action.src_container_index); src_slot.clone_from(&dst_item); } } diff --git a/src/ipc/zone/item_operation.rs b/src/ipc/zone/item_operation.rs index 1599325..a33bf4f 100644 --- a/src/ipc/zone/item_operation.rs +++ b/src/ipc/zone/item_operation.rs @@ -1,12 +1,13 @@ use binrw::binrw; use crate::inventory::ContainerType; +use crate::inventory::ItemOperationKind; #[binrw] #[derive(Debug, Clone, Default)] pub struct ItemOperation { pub context_id: u32, - pub operation_type: u8, + pub operation_type: ItemOperationKind, #[brw(pad_before = 3)] pub src_actor_id: u32, @@ -44,7 +45,7 @@ mod tests { let modify_inventory = ItemOperation::read_le(&mut buffer).unwrap(); assert_eq!(modify_inventory.context_id, 0x10000000); - assert_eq!(modify_inventory.operation_type, 60); + assert_eq!(modify_inventory.operation_type, ItemOperationKind::Move); assert_eq!(modify_inventory.src_actor_id, 0); assert_eq!(modify_inventory.src_storage_id, ContainerType::Equipped); assert_eq!(modify_inventory.src_container_index, 3); diff --git a/src/ipc/zone/mod.rs b/src/ipc/zone/mod.rs index 416be88..f86fa00 100644 --- a/src/ipc/zone/mod.rs +++ b/src/ipc/zone/mod.rs @@ -93,7 +93,7 @@ use crate::common::ObjectTypeId; use crate::common::Position; use crate::common::read_string; use crate::common::write_string; -use crate::inventory::ContainerType; +use crate::inventory::{ContainerType, ItemOperationKind}; use crate::opcodes::ClientZoneIpcType; use crate::opcodes::ServerZoneIpcType; use crate::packet::IPC_HEADER_SIZE; @@ -384,24 +384,26 @@ pub enum ServerZoneIpcData { #[brw(pad_after = 7)] unk1: u8, }, - #[br(pre_assert(*magic == ServerZoneIpcType::InventorySlotDiscard))] - InventorySlotDiscard { - /// This is later reused in InventorySlotDiscardFin, so it might be some sort of sequence or context id, but it's not the one sent by the client + #[br(pre_assert(*magic == ServerZoneIpcType::InventoryTransaction))] + InventoryTransaction { + /// This is later reused in InventoryTransactionFinish, so it might be some sort of sequence or context id, but it's not the one sent by the client unk1: u32, - /// Same as the one sent by the client, not the one that the server responds with in inventoryactionack! - operation_type: u8, + /// Same as the one sent by the client, not the one that the server responds with in InventoryActionAck! + operation_type: ItemOperationKind, #[brw(pad_before = 3)] src_actor_id: u32, src_storage_id: ContainerType, src_container_index: u16, #[brw(pad_before = 2)] - src_stack: u32, - src_catalog_id: u32, + /// On retail, this contains very strange values that don't make sense (in regards to stack size). It's possible this field is a different size or isn't intended for stack size. + unk2: u32, + /// On retail, this contains very strange values that don't make sense (in regards to catalog id). It's possible this field is a different size or isn't intended for catalog id. + unk3: u32, /// This is all static as far as I can tell, across two captures and a bunch of discards these never changed - /// seems to always be 3758096384 / E0 00 00 00 + /// seems to always be 0 dst_actor_id: u32, - /// seems to always be 65535/0xFFFF + /// seems to always be 57344/0xE000 dst_storage_id: u16, /// seems to always be 65535/0xFFFF dst_container_index: u16, @@ -409,15 +411,15 @@ pub enum ServerZoneIpcData { #[brw(pad_after = 10)] dst_catalog_id: u32, }, - #[br(pre_assert(*magic == ServerZoneIpcType::InventorySlotDiscardFin))] - InventorySlotDiscardFin { - /// Same value as unk1 in InventorySlotDiscard + #[br(pre_assert(*magic == ServerZoneIpcType::InventoryTransactionFinish))] + InventoryTransactionFinish { + /// Same value as unk1 in InventoryTransaction. unk1: u32, - /// Repeated unk1 value? + /// Repeated unk1 value. unk2: u32, - /// Unknown, seems to always be 0x00000090 + /// Unknown, seems to always be 0x00000090. unk3: u32, - /// Unknown, seems to always be 0x00000200 + /// Unknown, seems to always be 0x00000200. unk4: u32, }, Unknown { @@ -569,6 +571,15 @@ pub enum ClientZoneIpcData { /// Observed values so far: 0xDDDDDDDD (when buying 99 of a stackable item), 0xFFFFFFFF, 0xFFE0FFD0, 0xfffefffe, 0x0000FF64 unk2: u32, }, + /// This packet is sent by the client when they pivot left or right on standard controls. + /// It is sent once when beginning to pivot, and once when pivoting ends. + #[br(pre_assert(*magic == ClientZoneIpcType::StandardControlsPivot))] + StandardControlsPivot { + /// Set to 4 when beginning to pivot. + /// Set to 0 when pivoting ends. + #[brw(pad_after = 4)] + is_pivoting: u32, + }, #[br(pre_assert(*magic == ClientZoneIpcType::EventYieldHandler))] EventYieldHandler(EventYieldHandler<2>), #[br(pre_assert(*magic == ClientZoneIpcType::EventYieldHandler8))] @@ -821,15 +832,15 @@ mod tests { ServerZoneIpcData::UnkResponse2 { unk1: 0 }, ), ( - ServerZoneIpcType::InventorySlotDiscard, - ServerZoneIpcData::InventorySlotDiscard { + ServerZoneIpcType::InventoryTransaction, + ServerZoneIpcData::InventoryTransaction { unk1: 0, - operation_type: 0, + operation_type: ItemOperationKind::Move, src_actor_id: 0, src_storage_id: ContainerType::Inventory0, src_container_index: 0, - src_stack: 0, - src_catalog_id: 0, + unk2: 0, + unk3: 0, dst_actor_id: 0, dst_storage_id: 0, dst_container_index: 0, @@ -837,8 +848,8 @@ mod tests { }, ), ( - ServerZoneIpcType::InventorySlotDiscardFin, - ServerZoneIpcData::InventorySlotDiscardFin { + ServerZoneIpcType::InventoryTransactionFinish, + ServerZoneIpcData::InventoryTransactionFinish { unk1: 0, unk2: 0, unk3: 0, diff --git a/src/lib.rs b/src/lib.rs index 8ea7f2c..feb0d3e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -82,6 +82,8 @@ pub const CLASSJOB_ARRAY_SIZE: usize = 32; /// The maximum durability of an item. pub const ITEM_CONDITION_MAX: u16 = 30000; +// These operation codes/types change regularly, so update them when needed! + /// The operation opcode/type when discarding an item from the inventory. pub const INVENTORY_ACTION_DISCARD: u8 = 145; @@ -91,6 +93,12 @@ pub const INVENTORY_ACTION_MOVE: u8 = 146; /// The operation opcode/type when moving an item to a slot occupied by another in the inventory. pub const INVENTORY_ACTION_EXCHANGE: u8 = 147; +/// The operation opcode/type when splitting stacks of identical items. +pub const INVENTORY_ACTION_SPLIT_STACK: u8 = 148; + +/// The operation opcode/type when combining stacks of identical items. +pub const INVENTORY_ACTION_COMBINE_STACK: u8 = 150; + /// The server's acknowledgement of a shop item being purchased. pub const INVENTORY_ACTION_ACK_SHOP: u8 = 6; diff --git a/src/world/connection.rs b/src/world/connection.rs index 83c49a1..6951209 100644 --- a/src/world/connection.rs +++ b/src/world/connection.rs @@ -83,6 +83,7 @@ pub struct PlayerData { pub unlocks: Vec, pub aetherytes: Vec, pub completed_quests: Vec, + pub item_sequence: u32, } /// Various obsfucation-related bits like the seeds and keys for this connection. @@ -178,6 +179,7 @@ impl ZoneConnection { self.player_data.max_hp = 100; self.player_data.curr_mp = 10000; self.player_data.max_mp = 10000; + self.player_data.item_sequence = 0; tracing::info!("Client {actor_id} is initializing zone session...");