1
Fork 0
mirror of https://github.com/redstrate/Kawari.git synced 2025-04-24 08:07:45 +00:00

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.
This commit is contained in:
Joshua Goins 2025-03-18 22:13:28 -04:00
parent 039f4d7f95
commit bd67eb0127
8 changed files with 325 additions and 159 deletions

Binary file not shown.

View file

@ -1,8 +1,8 @@
use kawari::oodle::OodleNetwork; use kawari::oodle::OodleNetwork;
use kawari::packet::{ConnectionType, PacketSegment, PacketState, SegmentType, send_keep_alive}; use kawari::packet::{ConnectionType, PacketSegment, PacketState, SegmentType, send_keep_alive};
use kawari::world::ipc::{ use kawari::world::ipc::{
ClientZoneIpcData, GameMasterCommandType, ServerZoneIpcData, ServerZoneIpcSegment, ClientZoneIpcData, CommonSpawn, GameMasterCommandType, ObjectKind, ServerZoneIpcData,
ServerZoneIpcType, SocialListRequestType, ServerZoneIpcSegment, ServerZoneIpcType, SocialListRequestType,
}; };
use kawari::world::{ use kawari::world::{
ChatHandler, Zone, ZoneConnection, ChatHandler, Zone, ZoneConnection,
@ -282,34 +282,37 @@ async fn main() {
timestamp: timestamp_secs(), timestamp: timestamp_secs(),
data: ServerZoneIpcData::PlayerSpawn(PlayerSpawn { data: ServerZoneIpcData::PlayerSpawn(PlayerSpawn {
content_id: CONTENT_ID, content_id: CONTENT_ID,
current_world_id: WORLD_ID, common: CommonSpawn {
home_world_id: WORLD_ID, current_world_id: WORLD_ID,
title: 1, home_world_id: WORLD_ID,
class_job: 35, title: 1,
name: CHAR_NAME.to_string(), class_job: 35,
hp_curr: 100, name: CHAR_NAME.to_string(),
hp_max: 100, hp_curr: 100,
mp_curr: 100, hp_max: 100,
mp_max: 100, mp_curr: 100,
model_type: 1, mp_max: 100,
gm_rank: 3, object_kind: ObjectKind::Player,
look: CUSTOMIZE_DATA, gm_rank: 3,
fc_tag: "LOCAL".to_string(), look: CUSTOMIZE_DATA,
subtype: 4, fc_tag: "LOCAL".to_string(),
models: [ subtype: 4,
0, // head models: [
89, // body 0, // head
89, // hands 89, // body
89, // legs 89, // hands
89, // feet 89, // legs
0, // ears 89, // feet
0, // neck 0, // ears
0, // wrists 0, // neck
0, // left finger 0, // wrists
0, // right finger 0, // left finger
], 0, // right finger
pos: exit_position ],
.unwrap_or(Position::default()), pos: exit_position
.unwrap_or(Position::default()),
..Default::default()
},
..Default::default() ..Default::default()
}), }),
..Default::default() ..Default::default()

View file

@ -35,6 +35,7 @@ pub const WORLD_NAME: &str = "KAWARI";
pub const ZONE_ID: u16 = 132; pub const ZONE_ID: u16 = 132;
pub const CONTENT_ID: u64 = 11111111111111111; pub const CONTENT_ID: u64 = 11111111111111111;
pub const INVALID_OBJECT_ID: u32 = 0xE0000000;
pub const CUSTOMIZE_DATA: CustomizeData = CustomizeData { pub const CUSTOMIZE_DATA: CustomizeData = CustomizeData {
race: 4, race: 4,

View file

@ -1,8 +1,11 @@
use crate::{ use crate::{
CHAR_NAME, CUSTOMIZE_DATA, WORLD_ID, CHAR_NAME, CUSTOMIZE_DATA, INVALID_OBJECT_ID, WORLD_ID,
common::timestamp_secs, common::timestamp_secs,
packet::{PacketSegment, SegmentType}, packet::{PacketSegment, SegmentType},
world::ipc::{PlayerSpawn, ServerZoneIpcData, ServerZoneIpcSegment, ServerZoneIpcType}, world::ipc::{
CommonSpawn, NpcSpawn, ObjectKind, PlayerSpawn, ServerZoneIpcData, ServerZoneIpcSegment,
ServerZoneIpcType,
},
}; };
use super::{ use super::{
@ -45,33 +48,87 @@ impl ChatHandler {
data: ServerZoneIpcData::PlayerSpawn(PlayerSpawn { data: ServerZoneIpcData::PlayerSpawn(PlayerSpawn {
some_unique_id: 1, some_unique_id: 1,
content_id: 1, content_id: 1,
current_world_id: WORLD_ID, common: CommonSpawn {
home_world_id: WORLD_ID, current_world_id: WORLD_ID,
title: 1, home_world_id: WORLD_ID,
class_job: 35, title: 1,
name: CHAR_NAME.to_string(), class_job: 35,
hp_curr: 100, name: CHAR_NAME.to_string(),
hp_max: 100, hp_curr: 100,
mp_curr: 100, hp_max: 100,
mp_max: 100, mp_curr: 100,
model_type: 1, mp_max: 100,
gm_rank: 3, object_kind: ObjectKind::Player,
spawn_index: connection.get_free_spawn_index(), gm_rank: 3,
look: CUSTOMIZE_DATA, spawn_index: connection.get_free_spawn_index(),
fc_tag: "LOCAL".to_string(), look: CUSTOMIZE_DATA,
models: [ fc_tag: "LOCAL".to_string(),
0, // head models: [
89, // body 0, // head
89, // hands 89, // body
89, // legs 89, // hands
89, // feet 89, // legs
0, // ears 89, // feet
0, // neck 0, // ears
0, // wrists 0, // neck
0, // left finger 0, // wrists
0, // right finger 0, // left finger
], 0, // right finger
pos: Position::default(), ],
pos: Position::default(),
..Default::default()
},
..Default::default()
}),
};
connection
.send_segment(PacketSegment {
source_actor: 0x106ad804,
target_actor: connection.player_id,
segment_type: SegmentType::Ipc { data: ipc },
})
.await;
}
}
"!spawnnpc" => {
// 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() ..Default::default()
}), }),
}; };

View file

@ -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,
}

View file

@ -34,6 +34,12 @@ pub use actor_control_self::ActorControlType;
mod init_zone; mod init_zone;
pub use init_zone::InitZone; 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::read_string;
use crate::common::write_string; use crate::common::write_string;
use crate::packet::IpcSegment; use crate::packet::IpcSegment;
@ -86,6 +92,7 @@ impl ReadWriteIpcSegment for ServerZoneIpcSegment {
ServerZoneIpcType::Unk17 => 104, ServerZoneIpcType::Unk17 => 104,
ServerZoneIpcType::SocialList => 1136, ServerZoneIpcType::SocialList => 1136,
ServerZoneIpcType::PrepareZoning => 16, ServerZoneIpcType::PrepareZoning => 16,
ServerZoneIpcType::NpcSpawn => 648,
} }
} }
} }
@ -121,6 +128,30 @@ pub enum GameMasterCommandType {
ChangeTerritory = 0x58, 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] #[binrw]
#[brw(repr = u16)] #[brw(repr = u16)]
#[derive(Clone, PartialEq, Debug)] #[derive(Clone, PartialEq, Debug)]
@ -172,6 +203,8 @@ pub enum ServerZoneIpcType {
Unk17 = 0x2A1, Unk17 = 0x2A1,
// Sent by the server in response to SocialListRequest // Sent by the server in response to SocialListRequest
SocialList = 0x36C, SocialList = 0x36C,
// Sent by the server to spawn an NPC
NpcSpawn = 0x100,
} }
#[binrw] #[binrw]
@ -287,6 +320,7 @@ pub enum ServerZoneIpcData {
unk: [u8; 104], unk: [u8; 104],
}, },
SocialList(SocialList), SocialList(SocialList),
NpcSpawn(NpcSpawn),
} }
#[binrw] #[binrw]
@ -429,6 +463,10 @@ mod tests {
ServerZoneIpcType::ActorSetPos, ServerZoneIpcType::ActorSetPos,
ServerZoneIpcData::ActorSetPos(ActorSetPos::default()), ServerZoneIpcData::ActorSetPos(ActorSetPos::default()),
), ),
(
ServerZoneIpcType::NpcSpawn,
ServerZoneIpcData::NpcSpawn(NpcSpawn::default()),
),
]; ];
for (opcode, data) in &ipc_types { for (opcode, data) in &ipc_types {

View file

@ -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);
}
}

View file

@ -5,6 +5,7 @@ use crate::common::{CustomizeData, read_string, write_string};
use super::position::Position; use super::position::Position;
use super::status_effect::StatusEffect; use super::status_effect::StatusEffect;
use super::{CommonSpawn, ObjectKind};
#[binrw] #[binrw]
#[brw(little)] #[brw(little)]
@ -27,87 +28,8 @@ pub struct PlayerSpawn {
#[brw(pad_before = 4)] // always empty? #[brw(pad_before = 4)] // always empty?
pub content_id: u64, pub content_id: u64,
pub title: u16, pub common: CommonSpawn,
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 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], pub padding: [u8; 2],
} }
@ -128,25 +50,27 @@ mod tests {
let mut buffer = Cursor::new(&buffer); let mut buffer = Cursor::new(&buffer);
let player_spawn = PlayerSpawn::read_le(&mut buffer).unwrap(); let player_spawn = PlayerSpawn::read_le(&mut buffer).unwrap();
assert_eq!(player_spawn.current_world_id, 0x4F); assert_eq!(player_spawn.common.current_world_id, 0x4F);
assert_eq!(player_spawn.home_world_id, 0x4F); assert_eq!(player_spawn.common.home_world_id, 0x4F);
assert_eq!(player_spawn.hp_curr, 159); assert_eq!(player_spawn.common.hp_curr, 159);
assert_eq!(player_spawn.hp_max, 159); assert_eq!(player_spawn.common.hp_max, 159);
assert_eq!(player_spawn.mp_curr, 10000); assert_eq!(player_spawn.common.mp_curr, 10000);
assert_eq!(player_spawn.mp_max, 10000); assert_eq!(player_spawn.common.mp_max, 10000);
assert_eq!(player_spawn.mode, CharacterMode::Normal); assert_eq!(player_spawn.common.mode, CharacterMode::Normal);
assert_eq!(player_spawn.spawn_index, 0); assert_eq!(player_spawn.common.spawn_index, 0);
assert_eq!(player_spawn.level, 1); assert_eq!(player_spawn.common.level, 1);
assert_eq!(player_spawn.class_job, 1); // adventurer assert_eq!(player_spawn.common.class_job, 1); // adventurer
assert_eq!(player_spawn.scale, 36); assert_eq!(player_spawn.common.scale, 36);
assert_eq!(player_spawn.pos.x, 40.519722); assert_eq!(player_spawn.common.pos.x, 40.519722);
assert_eq!(player_spawn.pos.y, 4.0); assert_eq!(player_spawn.common.pos.y, 4.0);
assert_eq!(player_spawn.pos.z, -150.33124); assert_eq!(player_spawn.common.pos.z, -150.33124);
assert_eq!(player_spawn.name, "Lavenaa Warren"); assert_eq!(player_spawn.common.name, "Lavenaa Warren");
assert_eq!(player_spawn.look.race, 1); assert_eq!(player_spawn.common.look.race, 1);
assert_eq!(player_spawn.look.gender, 1); assert_eq!(player_spawn.common.look.gender, 1);
assert_eq!(player_spawn.look.bust, 100); assert_eq!(player_spawn.common.look.bust, 100);
assert_eq!(player_spawn.fc_tag, ""); assert_eq!(player_spawn.common.fc_tag, "");
assert_eq!(player_spawn.subtype, 4); assert_eq!(player_spawn.common.subtype, 4);
assert_eq!(player_spawn.common.model_chara, 0);
assert_eq!(player_spawn.common.object_kind, ObjectKind::Player);
} }
} }