From bd67eb01270ee570ee08e09b2c2f7e4509fc7a3e Mon Sep 17 00:00:00 2001 From: Joshua Goins Date: Tue, 18 Mar 2025 22:13:28 -0400 Subject: [PATCH] Add support for spawning NPCs, and a debug command to do it Since the structs are so similar, I created a CommonSpawn struct to hold most of the interesting fields. --- resources/tests/npc_spawn.bin | Bin 0 -> 648 bytes src/bin/kawari-world.rs | 63 +++++++++-------- src/lib.rs | 1 + src/world/chat_handler.rs | 115 +++++++++++++++++++++++-------- src/world/ipc/common_spawn.rs | 93 +++++++++++++++++++++++++ src/world/ipc/mod.rs | 38 +++++++++++ src/world/ipc/npc_spawn.rs | 50 ++++++++++++++ src/world/ipc/player_spawn.rs | 124 +++++++--------------------------- 8 files changed, 325 insertions(+), 159 deletions(-) create mode 100644 resources/tests/npc_spawn.bin create mode 100644 src/world/ipc/common_spawn.rs create mode 100644 src/world/ipc/npc_spawn.rs diff --git a/resources/tests/npc_spawn.bin b/resources/tests/npc_spawn.bin new file mode 100644 index 0000000000000000000000000000000000000000..eeb9d25fbba8f94032c406f5e23de24a3fe50cb5 GIT binary patch literal 648 zcmZQzfB;4iB?4wY0Fy)#yG$4uL^U9a#i4q{+@=ZunGXtC85lqq0tD0r)JvIWGjhL! ziC8c)Ffjo++Asl3`T?fUs0g(}U}aRpq3|Ha1J?8F9LY2DantI@&3hg { + // spawn another one of us + { + let ipc = ServerZoneIpcSegment { + unk1: 20, + unk2: 0, + op_code: ServerZoneIpcType::NpcSpawn, + server_id: 0, + timestamp: timestamp_secs(), + data: ServerZoneIpcData::NpcSpawn(NpcSpawn { + common: CommonSpawn { + hp_curr: 100, + hp_max: 100, + mp_curr: 100, + mp_max: 100, + look: CUSTOMIZE_DATA, + spawn_index: connection.get_free_spawn_index(), + bnpc_base: 13498, + bnpc_name: 10261, + spawner_id: connection.player_id, + parent_actor_id: INVALID_OBJECT_ID, // TODO: make default? + object_kind: ObjectKind::BattleNpc, + level: 1, + models: [ + 0, // head + 89, // body + 89, // hands + 89, // legs + 89, // feet + 0, // ears + 0, // neck + 0, // wrists + 0, // left finger + 0, // right finger + ], + pos: Position::default(), + ..Default::default() + }, ..Default::default() }), }; diff --git a/src/world/ipc/common_spawn.rs b/src/world/ipc/common_spawn.rs new file mode 100644 index 0000000..f95ebd9 --- /dev/null +++ b/src/world/ipc/common_spawn.rs @@ -0,0 +1,93 @@ +use binrw::binrw; + +use crate::CHAR_NAME_MAX_LENGTH; +use crate::common::{CustomizeData, read_string, write_string}; + +use super::position::Position; +use super::{CharacterMode, ObjectKind, StatusEffect}; + +#[binrw] +#[brw(little)] +#[derive(Debug, Clone, Default)] +pub struct CommonSpawn { + pub title: u16, + pub u1b: u16, + pub current_world_id: u16, + pub home_world_id: u16, + + pub gm_rank: u8, + pub u3c: u8, + pub u4: u8, + pub online_status: u8, + + pub pose: u8, + pub u5a: u8, + pub u5b: u8, + pub u5c: u8, + + pub target_id: u64, + pub u6: u32, + pub u7: u32, + pub main_weapon_model: u64, + pub sec_weapon_model: u64, + pub craft_tool_model: u64, + + pub u14: u32, + pub u15: u32, + pub bnpc_base: u32, // See BNpcBase Excel sheet + pub bnpc_name: u32, // See BNpcName Excel sheet + pub unk3: [u8; 8], + pub director_id: u32, // FIXME: i think the next three are in the wrong order + pub spawner_id: u32, + pub parent_actor_id: u32, + pub hp_max: u32, + pub hp_curr: u32, + pub display_flags: u32, // assumed + pub fate_id: u16, // assumed + pub mp_curr: u16, + pub mp_max: u16, + pub unk: u16, + pub model_chara: u16, // See ModelChara Excel sheet + pub rotation: u16, // assumed + pub current_mount: u16, // assumed + pub active_minion: u16, // assumed + pub u23: u8, // assumed + pub u24: u8, // assumed + pub u25: u8, // assumed + pub u26: u8, // assumed + pub spawn_index: u8, + pub mode: CharacterMode, + pub persistent_emote: u8, + pub object_kind: ObjectKind, + pub subtype: u8, + pub voice: u8, + pub enemy_type: u8, + pub unk27: u8, + pub level: u8, + pub class_job: u8, + pub unk28: u8, + pub unk29: u8, + pub mount_head: u8, + pub mount_body: u8, + pub mount_feet: u8, + pub mount_color: u8, + pub scale: u8, + pub element_data: [u8; 6], + pub padding2: [u8; 1], + pub effect: [StatusEffect; 30], + pub pos: Position, + pub models: [u32; 10], + pub unknown6_58: [u8; 10], + pub padding3: [u8; 4], + #[br(count = CHAR_NAME_MAX_LENGTH)] + #[bw(pad_size_to = CHAR_NAME_MAX_LENGTH)] + #[br(map = read_string)] + #[bw(map = write_string)] + pub name: String, + pub look: CustomizeData, + #[br(count = 6)] + #[bw(pad_size_to = 6)] + #[br(map = read_string)] + #[bw(map = write_string)] + pub fc_tag: String, +} diff --git a/src/world/ipc/mod.rs b/src/world/ipc/mod.rs index 3cfc755..9c7aee8 100644 --- a/src/world/ipc/mod.rs +++ b/src/world/ipc/mod.rs @@ -34,6 +34,12 @@ pub use actor_control_self::ActorControlType; mod init_zone; pub use init_zone::InitZone; +mod npc_spawn; +pub use npc_spawn::NpcSpawn; + +mod common_spawn; +pub use common_spawn::CommonSpawn; + use crate::common::read_string; use crate::common::write_string; use crate::packet::IpcSegment; @@ -86,6 +92,7 @@ impl ReadWriteIpcSegment for ServerZoneIpcSegment { ServerZoneIpcType::Unk17 => 104, ServerZoneIpcType::SocialList => 1136, ServerZoneIpcType::PrepareZoning => 16, + ServerZoneIpcType::NpcSpawn => 648, } } } @@ -121,6 +128,30 @@ pub enum GameMasterCommandType { ChangeTerritory = 0x58, } +#[binrw] +#[brw(repr = u8)] +#[derive(Clone, PartialEq, Debug, Default)] +pub enum ObjectKind { + #[default] + None = 0, + Player = 1, + BattleNpc = 2, + EventNpc = 3, + Treasure = 4, + Aetheryte = 5, + GatheringPoint = 6, + EventObj = 7, + Mount = 8, + Companion = 9, + Retainer = 10, + AreaObject = 11, + HousingEventObject = 12, + Cutscene = 13, + MjiObject = 14, + Ornament = 15, + CardStand = 16, +} + #[binrw] #[brw(repr = u16)] #[derive(Clone, PartialEq, Debug)] @@ -172,6 +203,8 @@ pub enum ServerZoneIpcType { Unk17 = 0x2A1, // Sent by the server in response to SocialListRequest SocialList = 0x36C, + // Sent by the server to spawn an NPC + NpcSpawn = 0x100, } #[binrw] @@ -287,6 +320,7 @@ pub enum ServerZoneIpcData { unk: [u8; 104], }, SocialList(SocialList), + NpcSpawn(NpcSpawn), } #[binrw] @@ -429,6 +463,10 @@ mod tests { ServerZoneIpcType::ActorSetPos, ServerZoneIpcData::ActorSetPos(ActorSetPos::default()), ), + ( + ServerZoneIpcType::NpcSpawn, + ServerZoneIpcData::NpcSpawn(NpcSpawn::default()), + ), ]; for (opcode, data) in &ipc_types { diff --git a/src/world/ipc/npc_spawn.rs b/src/world/ipc/npc_spawn.rs new file mode 100644 index 0000000..6afb498 --- /dev/null +++ b/src/world/ipc/npc_spawn.rs @@ -0,0 +1,50 @@ +use binrw::binrw; + +use crate::CHAR_NAME_MAX_LENGTH; +use crate::common::{CustomizeData, read_string, write_string}; + +use super::position::Position; +use super::{CharacterMode, CommonSpawn, ObjectKind, StatusEffect}; + +#[binrw] +#[brw(little)] +#[derive(Debug, Clone, Default)] +pub struct NpcSpawn { + pub common: CommonSpawn, + pub padding: [u8; 10], +} + +#[cfg(test)] +mod tests { + use std::{fs::read, io::Cursor, path::PathBuf}; + + use binrw::BinRead; + + use super::*; + + #[test] + fn read_npcspawn() { + let mut d = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + d.push("resources/tests/npc_spawn.bin"); + + let buffer = read(d).unwrap(); + let mut buffer = Cursor::new(&buffer); + + let npc_spawn = NpcSpawn::read_le(&mut buffer).unwrap(); + assert_eq!(npc_spawn.common.hp_max, 1393); + assert_eq!(npc_spawn.common.hp_curr, 1393); + assert_eq!(npc_spawn.common.mp_curr, 10000); + assert_eq!(npc_spawn.common.mp_max, 10000); + assert_eq!(npc_spawn.common.display_flags, 0); + assert_eq!(npc_spawn.common.pos.x, -64.17707); + assert_eq!(npc_spawn.common.pos.y, -2.0206506); + assert_eq!(npc_spawn.common.pos.z, 15.913875); + assert_eq!(npc_spawn.common.model_chara, 411); + assert_eq!(npc_spawn.common.bnpc_base, 13498); + assert_eq!(npc_spawn.common.bnpc_name, 10261); + assert_eq!(npc_spawn.common.spawn_index, 56); + assert_eq!(npc_spawn.common.mode, CharacterMode::Normal); + assert_eq!(npc_spawn.common.object_kind, ObjectKind::BattleNpc); + assert_eq!(npc_spawn.common.subtype, 2); + } +} diff --git a/src/world/ipc/player_spawn.rs b/src/world/ipc/player_spawn.rs index 6c5c907..2f09204 100644 --- a/src/world/ipc/player_spawn.rs +++ b/src/world/ipc/player_spawn.rs @@ -5,6 +5,7 @@ use crate::common::{CustomizeData, read_string, write_string}; use super::position::Position; use super::status_effect::StatusEffect; +use super::{CommonSpawn, ObjectKind}; #[binrw] #[brw(little)] @@ -27,87 +28,8 @@ pub struct PlayerSpawn { #[brw(pad_before = 4)] // always empty? pub content_id: u64, - pub title: u16, - pub u1b: u16, - pub current_world_id: u16, - pub home_world_id: u16, + pub common: CommonSpawn, - pub gm_rank: u8, - pub u3c: u8, - pub u4: u8, - pub online_status: u8, - - pub pose: u8, - pub u5a: u8, - pub u5b: u8, - pub u5c: u8, - - pub target_id: u64, - pub u6: u32, - pub u7: u32, - pub main_weapon_model: u64, - pub sec_weapon_model: u64, - pub craft_tool_model: u64, - - pub u14: u32, - pub u15: u32, - pub b_npc_base: u32, - pub b_npc_name: u32, - pub u18: u32, - pub u19: u32, - pub director_id: u32, - pub owner_id: u32, - pub u22: u32, - pub hp_max: u32, - pub hp_curr: u32, - pub display_flags: u32, - pub fate_id: u16, - pub mp_curr: u16, - pub mp_max: u16, - pub unk: u16, - pub model_chara: u16, - pub rotation: u16, - pub current_mount: u16, - pub active_minion: u16, - pub u23: u8, - pub u24: u8, - pub u25: u8, - pub u26: u8, - pub spawn_index: u8, - pub mode: CharacterMode, - pub persistent_emote: u8, - pub model_type: u8, - pub subtype: u8, - pub voice: u8, - pub enemy_type: u8, - pub unk27: u8, - pub level: u8, - pub class_job: u8, - pub unk28: u8, - pub unk29: u8, - pub mount_head: u8, - pub mount_body: u8, - pub mount_feet: u8, - pub mount_color: u8, - pub scale: u8, - pub element_data: [u8; 6], - pub padding2: [u8; 1], - pub effect: [StatusEffect; 30], - pub pos: Position, - pub models: [u32; 10], - pub unknown6_58: [u8; 10], - pub padding3: [u8; 4], - #[br(count = CHAR_NAME_MAX_LENGTH)] - #[bw(pad_size_to = CHAR_NAME_MAX_LENGTH)] - #[br(map = read_string)] - #[bw(map = write_string)] - pub name: String, - pub look: CustomizeData, - #[br(count = 6)] - #[bw(pad_size_to = 6)] - #[br(map = read_string)] - #[bw(map = write_string)] - pub fc_tag: String, pub padding: [u8; 2], } @@ -128,25 +50,27 @@ mod tests { let mut buffer = Cursor::new(&buffer); let player_spawn = PlayerSpawn::read_le(&mut buffer).unwrap(); - assert_eq!(player_spawn.current_world_id, 0x4F); - assert_eq!(player_spawn.home_world_id, 0x4F); - assert_eq!(player_spawn.hp_curr, 159); - assert_eq!(player_spawn.hp_max, 159); - assert_eq!(player_spawn.mp_curr, 10000); - assert_eq!(player_spawn.mp_max, 10000); - assert_eq!(player_spawn.mode, CharacterMode::Normal); - assert_eq!(player_spawn.spawn_index, 0); - assert_eq!(player_spawn.level, 1); - assert_eq!(player_spawn.class_job, 1); // adventurer - assert_eq!(player_spawn.scale, 36); - assert_eq!(player_spawn.pos.x, 40.519722); - assert_eq!(player_spawn.pos.y, 4.0); - assert_eq!(player_spawn.pos.z, -150.33124); - assert_eq!(player_spawn.name, "Lavenaa Warren"); - assert_eq!(player_spawn.look.race, 1); - assert_eq!(player_spawn.look.gender, 1); - assert_eq!(player_spawn.look.bust, 100); - assert_eq!(player_spawn.fc_tag, ""); - assert_eq!(player_spawn.subtype, 4); + assert_eq!(player_spawn.common.current_world_id, 0x4F); + assert_eq!(player_spawn.common.home_world_id, 0x4F); + assert_eq!(player_spawn.common.hp_curr, 159); + assert_eq!(player_spawn.common.hp_max, 159); + assert_eq!(player_spawn.common.mp_curr, 10000); + assert_eq!(player_spawn.common.mp_max, 10000); + assert_eq!(player_spawn.common.mode, CharacterMode::Normal); + assert_eq!(player_spawn.common.spawn_index, 0); + assert_eq!(player_spawn.common.level, 1); + assert_eq!(player_spawn.common.class_job, 1); // adventurer + assert_eq!(player_spawn.common.scale, 36); + assert_eq!(player_spawn.common.pos.x, 40.519722); + assert_eq!(player_spawn.common.pos.y, 4.0); + assert_eq!(player_spawn.common.pos.z, -150.33124); + assert_eq!(player_spawn.common.name, "Lavenaa Warren"); + assert_eq!(player_spawn.common.look.race, 1); + assert_eq!(player_spawn.common.look.gender, 1); + assert_eq!(player_spawn.common.look.bust, 100); + assert_eq!(player_spawn.common.fc_tag, ""); + assert_eq!(player_spawn.common.subtype, 4); + assert_eq!(player_spawn.common.model_chara, 0); + assert_eq!(player_spawn.common.object_kind, ObjectKind::Player); } }