diff --git a/resources/opcodes.json b/resources/opcodes.json index 80825fa..9cac43d 100644 --- a/resources/opcodes.json +++ b/resources/opcodes.json @@ -294,6 +294,16 @@ "name": "LevequestCompleteList", "opcode": 371, "size": 232 + }, + { + "name": "GilShopTransactionAck", + "opcode": 974, + "size": 32 + }, + { + "name": "GilShopRelatedUnk", + "opcode": 435, + "size": 64 } ], "ClientZoneIpcType": [ diff --git a/resources/scripts/events/common/GenericShopkeeper.lua b/resources/scripts/events/common/GenericShopkeeper.lua index f4397bc..ecbcbfd 100644 --- a/resources/scripts/events/common/GenericShopkeeper.lua +++ b/resources/scripts/events/common/GenericShopkeeper.lua @@ -4,24 +4,34 @@ -- Scene 00010: Displays shop interface -- Scene 00255: Unknown, but this was also observed when capturing gil shop transaction packets. When used standalone it softlocks. +SCENE_GREETING = 00000 +SCENE_SHOW_SHOP = 00010 +SCENE_SHOP_END = 00255 function onTalk(target, player) - --[[ Params observed: + --[[ Params observed for SCENE_GREETING: Gil shops: [0, 1] Non- shops: [1, 0] MGP shops: [1, 100] It's unclear what these mean since shops seem to open fine without these. ]] - player:play_scene(target, EVENT_ID, 00000, 8192, {0}) + player:play_scene(target, EVENT_ID, SCENE_GREETING, 8192, {0}) end function onReturn(scene, results, player) - if scene == 0 then --[[ Retail sends 221 zeroes as u32s as the params to the shop cutscene, but it opens fine with a single zero u32. Perhaps they are leftovers from earlier expansions? According to Sapphire, the params used to be significantly more complex. Historically, it also seems cutscene 00040 was used instead of 00010 as it is now. + When the shop scene finishes and returns control to the server, the server will then have the client play scene 255 with no params. ]] - player:play_scene(player.id, EVENT_ID, 00010, 1 | 0x2000, {0}) - elseif scene == 10 then + if scene == SCENE_GREETING then + params = {} + for i=1,221 do + params[i] = 0 + end + player:play_scene(player.id, EVENT_ID, SCENE_SHOW_SHOP, 1 | 0x2000, params) + elseif scene == SCENE_SHOW_SHOP then + player:play_scene(player.id, EVENT_ID, SCENE_SHOP_END, 1 | 0x2000, {}) + else player:finish_event(EVENT_ID) end end diff --git a/src/bin/kawari-world.rs b/src/bin/kawari-world.rs index 737e4f2..75d4b5c 100644 --- a/src/bin/kawari-world.rs +++ b/src/bin/kawari-world.rs @@ -36,7 +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; +use kawari::{INVENTORY_ACTION_ACK_GENERAL, INVENTORY_ACTION_ACK_SHOP}; fn spawn_main_loop() -> (ServerHandle, JoinHandle<()>) { let (send, recv) = channel(64); @@ -910,29 +910,42 @@ async fn client_loop( } if let Some(item_info) = result { - if connection.player_data.inventory.currency.gil.quantity >= item_info.price_mid { - // TODO: send the proper response packets! - connection.player_data.inventory.currency.gil.quantity -= item_info.price_mid; - connection.player_data.inventory.add_in_next_free_slot(Item::new(1, item_info.id)); - connection.send_inventory(false).await; - // TODO: send an actual system notice, this is just a placeholder to provide feedback that the player actually bought something. - connection.send_message(&format!("You obtained one or more items: {} (id: {})!", item_info.name, item_info.id)).await; + if connection.player_data.inventory.currency.gil.quantity >= *item_quantity * item_info.price_mid { + connection.player_data.inventory.currency.gil.quantity -= *item_quantity * item_info.price_mid; + // TODO: We need to obtain information on where the item was added, as the client needs to be told. + // For now we hardcode it to the very first inventory slot. + //connection.player_data.inventory.add_in_next_free_slot(Item::new(*item_quantity, item_info.id)); + connection.player_data.inventory.add_in_slot(Item::new(*item_quantity, item_info.id), &ContainerType::Inventory0, 0); + + connection.send_gilshop_unk(0x07D0, 0, connection.player_data.inventory.currency.gil.quantity, 1).await; + + connection.send_inventory_ack(u32::MAX, INVENTORY_ACTION_ACK_SHOP as u16).await; + + // TODO: This is hardcoded to the first item slot in the inventory, fix this + connection.send_gilshop_unk(0, 0, *item_quantity, item_info.id).await; + connection.send_gilshop_ack(*event_id, item_info.id, *item_quantity, item_info.price_mid).await; + + let target_id = connection.player_data.target_actorid; + connection.event_scene(&target_id, *event_id, 10, 8193, vec![1, 100]).await; } else { connection.send_message("Insufficient gil to buy item. Nice try bypassing the client-side check!").await; + connection.event_finish(*event_id).await; } } else { connection.send_message("Unable to find shop item, this is a bug in Kawari!").await; + connection.event_finish(*event_id).await; } } else if *buy_sell_mode == SELL { // TODO: Implement selling items back to shops connection.send_message("Selling items to shops is not yet implemented. Cancelling event...").await; + connection.event_finish(*event_id).await; } else { tracing::error!("Received unknown transaction mode {buy_sell_mode}!"); + connection.event_finish(*event_id).await; } - // Cancel the event for now so the client doesn't get stuck - connection.event_finish(*event_id).await; } ClientZoneIpcData::StartTalkEvent { actor_id, event_id } => { + connection.player_data.target_actorid = *actor_id; // load event { let ipc = ServerZoneIpcSegment { diff --git a/src/inventory/mod.rs b/src/inventory/mod.rs index 5e0ec1b..112a712 100644 --- a/src/inventory/mod.rs +++ b/src/inventory/mod.rs @@ -284,6 +284,12 @@ impl Inventory { } } + pub fn add_in_slot(&mut self, item: Item, container_type: &ContainerType, index: u16) { + let container = self.get_container_mut(container_type); + let slot = container.get_slot_mut(index); + slot.clone_from(&item); + } + fn get_container_mut(&mut self, container_type: &ContainerType) -> &mut dyn Storage { match container_type { ContainerType::Inventory0 => &mut self.pages[0], diff --git a/src/ipc/zone/mod.rs b/src/ipc/zone/mod.rs index 71d9a19..e6d6566 100644 --- a/src/ipc/zone/mod.rs +++ b/src/ipc/zone/mod.rs @@ -483,6 +483,37 @@ pub enum ServerZoneIpcData { #[bw(pad_size_to = 6)] unk2: Vec, }, + #[brw(little)] + #[br(pre_assert(*magic == ServerZoneIpcType::GilShopTransactionAck))] + GilShopTransactionAck { + event_id: u32, + /// Always 0x697 when buying? + unk1: u32, + /// Always 3 when buying? + unk2: u32, + item_id: u32, + item_quantity: u32, + #[brw(pad_after = 8)] + total_sale_cost: u32, + }, + #[brw(little)] + #[br(pre_assert(*magic == ServerZoneIpcType::GilShopRelatedUnk))] + GilShopRelatedUnk { + /// Starts from zero and increases by one for each of these packets during this gameplay session + sequence: u32, + #[brw(pad_before = 4)] + /// On the first pass, it's 0x07D0, and on the second pass it's the destination storage id + unk1_and_dst_storage_id: u16, + /// On the first pass it's 0x0000, and on the second pass it's the slot number within the destination storage + unk2_and_dst_slot: u16, + /// On the first pass, it's the player's remaining gil after the purchase, and on the second pass it's the number of items in that slot after purchase + gil_and_item_quantity: u32, + /// On the first pass it's 0x0000_0001 and on the second pass it's the item id + unk3_and_item_id: u32, + #[brw(pad_before = 12, pad_after = 28)] + /// Always 0x7530_0000 + unk4: u32, + }, Unknown { #[br(count = size - 32)] unk: Vec, @@ -970,6 +1001,28 @@ mod tests { unk2: Vec::default(), }, ), + ( + ServerZoneIpcType::GilShopTransactionAck, + ServerZoneIpcData::GilShopTransactionAck { + event_id: 0, + unk1: 0, + unk2: 0, + item_id: 0, + item_quantity: 0, + total_sale_cost: 0, + }, + ), + ( + ServerZoneIpcType::GilShopRelatedUnk, + ServerZoneIpcData::GilShopRelatedUnk { + sequence: 0, + unk1_and_dst_storage_id: 0, + unk2_and_dst_slot: 0, + gil_and_item_quantity: 0, + unk3_and_item_id: 0, + unk4: 0, + }, + ), ]; for (opcode, data) in &ipc_types { diff --git a/src/world/connection.rs b/src/world/connection.rs index fa30cad..fc4f87c 100644 --- a/src/world/connection.rs +++ b/src/world/connection.rs @@ -20,10 +20,10 @@ use crate::{ zone::{ ActionEffect, ActionRequest, ActionResult, ActorControl, ActorControlCategory, ActorControlSelf, ActorControlTarget, ClientZoneIpcSegment, CommonSpawn, Config, - ContainerInfo, CurrencyInfo, DisplayFlag, EffectKind, Equip, GameMasterRank, InitZone, - ItemInfo, Move, NpcSpawn, ObjectKind, PlayerStats, PlayerSubKind, QuestActiveList, - ServerZoneIpcData, ServerZoneIpcSegment, StatusEffect, StatusEffectList, - UpdateClassInfo, Warp, WeatherChange, + ContainerInfo, CurrencyInfo, DisplayFlag, EffectKind, Equip, EventScene, + GameMasterRank, InitZone, ItemInfo, Move, NpcSpawn, ObjectKind, PlayerStats, + PlayerSubKind, QuestActiveList, ServerZoneIpcData, ServerZoneIpcSegment, StatusEffect, + StatusEffectList, UpdateClassInfo, Warp, WeatherChange, }, }, opcodes::ServerZoneIpcType, @@ -84,6 +84,9 @@ pub struct PlayerData { pub aetherytes: Vec, pub completed_quests: Vec, pub item_sequence: u32, + pub shop_sequence: u32, + /// Store the target actor id for the purpose of chaining cutscenes. + pub target_actorid: ObjectTypeId, } /// Various obsfucation-related bits like the seeds and keys for this connection. @@ -888,7 +891,49 @@ impl ZoneConnection { } } + pub async fn event_scene( + &mut self, + target: &ObjectTypeId, + event_id: u32, + scene: u16, + scene_flags: u32, + params: Vec, + ) { + let scene = EventScene { + actor_id: *target, + event_id, + scene, + scene_flags, + params_count: params.len() as u8, + params, + ..Default::default() + }; + if let Some((op_code, data)) = scene.package_scene() { + let ipc = ServerZoneIpcSegment { + op_code, + timestamp: timestamp_secs(), + data, + ..Default::default() + }; + + self.send_segment(PacketSegment { + source_actor: self.player_data.actor_id, + target_actor: self.player_data.actor_id, + segment_type: SegmentType::Ipc, + data: SegmentData::Ipc { data: ipc }, + }) + .await; + } else { + tracing::error!( + "Unable to play event {event_id}, scene {:?}, scene_flags {scene_flags}!", + scene + ); + self.event_finish(event_id).await; + } + } + pub async fn event_finish(&mut self, handler_id: u32) { + self.player_data.target_actorid = ObjectTypeId::default(); // sent event finish { let ipc = ServerZoneIpcSegment { @@ -931,6 +976,89 @@ impl ZoneConnection { } } + pub async fn send_inventory_ack(&mut self, sequence: u32, action_type: u16) { + let ipc = ServerZoneIpcSegment { + op_code: ServerZoneIpcType::InventoryActionAck, + timestamp: timestamp_secs(), + data: ServerZoneIpcData::InventoryActionAck { + sequence, + action_type, + }, + ..Default::default() + }; + self.send_segment(PacketSegment { + source_actor: self.player_data.actor_id, + target_actor: self.player_data.actor_id, + segment_type: SegmentType::Ipc, + data: SegmentData::Ipc { data: ipc }, + }) + .await; + + self.player_data.item_sequence += 1; + } + + pub async fn send_gilshop_ack( + &mut self, + event_id: u32, + item_id: u32, + item_quantity: u32, + price_per_item: u32, + ) { + let ipc = ServerZoneIpcSegment { + op_code: ServerZoneIpcType::GilShopTransactionAck, + timestamp: timestamp_secs(), + data: ServerZoneIpcData::GilShopTransactionAck { + event_id, + unk1: 0x697, + unk2: 3, + item_id, + item_quantity, + total_sale_cost: item_quantity * price_per_item, + }, + ..Default::default() + }; + + self.send_segment(PacketSegment { + source_actor: self.player_data.actor_id, + target_actor: self.player_data.actor_id, + segment_type: SegmentType::Ipc, + data: SegmentData::Ipc { data: ipc }, + }) + .await; + } + + pub async fn send_gilshop_unk( + &mut self, + unk1_and_dst_storage_id: u16, + unk2_and_dst_slot: u16, + gil_and_item_quantity: u32, + unk3_and_item_id: u32, + ) { + let ipc = ServerZoneIpcSegment { + op_code: ServerZoneIpcType::GilShopRelatedUnk, + timestamp: timestamp_secs(), + data: ServerZoneIpcData::GilShopRelatedUnk { + sequence: self.player_data.shop_sequence, + unk1_and_dst_storage_id, + unk2_and_dst_slot, + gil_and_item_quantity, + unk3_and_item_id, + unk4: 0x7530_0000, + }, + ..Default::default() + }; + + self.send_segment(PacketSegment { + source_actor: self.player_data.actor_id, + target_actor: self.player_data.actor_id, + segment_type: SegmentType::Ipc, + data: SegmentData::Ipc { data: ipc }, + }) + .await; + + self.player_data.shop_sequence += 1; + } + pub async fn begin_log_out(&mut self) { self.gracefully_logged_out = true;