From e7fb661244f19730dad083b690ae4b91280ea8cc Mon Sep 17 00:00:00 2001 From: Joshua Goins Date: Fri, 2 May 2025 15:36:22 -0400 Subject: [PATCH] Implement Fantasia and remaking your character Everyone's favorite copying mechanism/purchasable item is now functional in Kawari. The item doesn't disappear once you use it, because there's no API for that yet. --- resources/scripts/Global.lua | 5 ++ resources/scripts/items/Fantasia.lua | 8 +++ src/bin/kawari-world.rs | 41 +++++++++++++- .../workdefinitions/client_select_data.rs | 20 +++++-- src/ipc/kawari/mod.rs | 17 ++++++ src/ipc/zone/action_request.rs | 1 + src/lobby/connection.rs | 55 ++++++++++++++++++- src/world/connection.rs | 9 ++- src/world/database.rs | 42 +++++++++++--- src/world/lua.rs | 20 +++++-- 10 files changed, 192 insertions(+), 26 deletions(-) create mode 100644 resources/scripts/items/Fantasia.lua diff --git a/resources/scripts/Global.lua b/resources/scripts/Global.lua index da8b71e..9a919fa 100644 --- a/resources/scripts/Global.lua +++ b/resources/scripts/Global.lua @@ -3,5 +3,10 @@ function onBeginLogin(player) player:send_message("Welcome to Kawari!") end +-- Actions registerAction(3, "actions/Sprint.lua") registerAction(9, "actions/FastBlade.lua") + +-- Items +registerAction(6221, "items/Fantasia.lua") + diff --git a/resources/scripts/items/Fantasia.lua b/resources/scripts/items/Fantasia.lua new file mode 100644 index 0000000..f0f3879 --- /dev/null +++ b/resources/scripts/items/Fantasia.lua @@ -0,0 +1,8 @@ +function doAction(player) + effects = EffectsBuilder() + + -- TODO: match retail fantasia behavior + player:set_remake_mode("EditAppearance") + + return effects +end diff --git a/src/bin/kawari-world.rs b/src/bin/kawari-world.rs index 4780345..3038897 100644 --- a/src/bin/kawari-world.rs +++ b/src/bin/kawari-world.rs @@ -3,7 +3,7 @@ use std::net::SocketAddr; use std::sync::{Arc, Mutex}; use kawari::RECEIVE_BUFFER_SIZE; -use kawari::common::workdefinitions::CharaMake; +use kawari::common::workdefinitions::{CharaMake, RemakeMode}; use kawari::common::{GameData, ObjectId, timestamp_secs}; use kawari::common::{Position, determine_initial_starting_zone}; use kawari::config::get_config; @@ -712,8 +712,9 @@ async fn client_loop( let lua = lua.lock().unwrap(); let state = lua.app_data_ref::().unwrap(); + let key = request.action_key; if let Some(action_script) = - state.action_scripts.get(&request.action_key) + state.action_scripts.get(&key) { lua.scope(|scope| { let connection_data = scope @@ -745,6 +746,8 @@ async fn client_loop( Ok(()) }) .unwrap(); + } else { + tracing::warn!("Action {key} isn't scripted yet! Ignoring..."); } } @@ -1056,6 +1059,40 @@ async fn client_loop( CustomIpcData::ImportCharacter { service_account_id, path } => { database.import_character(*service_account_id, path); } + CustomIpcData::RemakeCharacter { content_id, chara_make_json } => { + // overwrite it in the database + database.set_chara_make(*content_id, chara_make_json); + + // reset flag + database.set_remake_mode(*content_id, RemakeMode::None); + + // send response + { + send_packet::( + &mut connection.socket, + &mut connection.state, + ConnectionType::None, + CompressionType::Uncompressed, + &[PacketSegment { + segment_type: SegmentType::KawariIpc, + data: SegmentData::KawariIpc { + data: CustomIpcSegment { + unk1: 0, + unk2: 0, + op_code: CustomIpcType::CharacterRemade, + option: 0, + timestamp: 0, + data: CustomIpcData::CharacterRemade { + content_id: *content_id, + }, + }, + }, + ..Default::default() + }], + ) + .await; + } + } _ => { panic!("The server is recieving a response or unknown custom IPC!") } diff --git a/src/common/workdefinitions/client_select_data.rs b/src/common/workdefinitions/client_select_data.rs index 9650e3e..1b8d870 100644 --- a/src/common/workdefinitions/client_select_data.rs +++ b/src/common/workdefinitions/client_select_data.rs @@ -1,9 +1,10 @@ +use serde::{Deserialize, Serialize}; use serde_json::json; use crate::common::CustomizeData; // TODO: this isn't really an enum in the game, nor is it a flag either. it's weird! -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, Deserialize, Serialize)] #[repr(i32)] pub enum RemakeMode { /// No remake options are available. @@ -14,6 +15,19 @@ pub enum RemakeMode { EditAppearance = 4, } +impl TryFrom for RemakeMode { + type Error = (); + + fn try_from(value: i32) -> Result { + match value { + 0 => Ok(Self::None), + 1 => Ok(Self::EditAppearanceName), + 4 => Ok(Self::EditAppearance), + _ => Err(()), + } + } +} + /// See https://github.com/aers/FFXIVClientStructs/blob/main/FFXIVClientStructs/FFXIV/Application/Network/WorkDefinitions/ClientSelectData.cs #[derive(Debug)] pub struct ClientSelectData { @@ -38,8 +52,6 @@ pub struct ClientSelectData { pub model_ids: [u32; 10], pub equip_stain: [u32; 10], pub glasses: [u32; 2], - pub unk15: i32, - pub unk16: i32, pub remake_mode: RemakeMode, // TODO: upstream a comment about this to FFXIVClientStructs /// If above 0, then a message warns the user that they have X minutes left to remake their character. pub remake_minutes_remaining: i32, @@ -71,8 +83,6 @@ impl ClientSelectData { self.model_ids.map(|x| x.to_string()), self.equip_stain.map(|x| x.to_string()), self.glasses.map(|x| x.to_string()), - self.unk15.to_string(), - self.unk16.to_string(), (self.remake_mode as i32).to_string(), self.remake_minutes_remaining.to_string(), self.voice_id.to_string(), diff --git a/src/ipc/kawari/mod.rs b/src/ipc/kawari/mod.rs index 9dde60c..28b2cda 100644 --- a/src/ipc/kawari/mod.rs +++ b/src/ipc/kawari/mod.rs @@ -23,6 +23,8 @@ impl ReadWriteIpcSegment for CustomIpcSegment { CustomIpcType::DeleteCharacter => 4, CustomIpcType::CharacterDeleted => 1, CustomIpcType::ImportCharacter => 132, + CustomIpcType::RemakeCharacter => 1024 + 8, + CustomIpcType::CharacterRemade => 8, } } } @@ -54,6 +56,10 @@ pub enum CustomIpcType { CharacterDeleted = 0x10, /// Request to import a character backup ImportCharacter = 0x11, + /// Remake a character + RemakeCharacter = 0x12, + // Character has been remade + CharacterRemade = 0x13, } #[binrw] @@ -117,6 +123,17 @@ pub enum CustomIpcData { #[bw(map = write_string)] path: String, }, + #[br(pre_assert(*magic == CustomIpcType::RemakeCharacter))] + RemakeCharacter { + content_id: u64, + #[bw(pad_size_to = 1024)] + #[br(count = 1024)] + #[br(map = read_string)] + #[bw(map = write_string)] + chara_make_json: String, + }, + #[br(pre_assert(*magic == CustomIpcType::CharacterRemade))] + CharacterRemade { content_id: u64 }, } impl Default for CustomIpcData { diff --git a/src/ipc/zone/action_request.rs b/src/ipc/zone/action_request.rs index cf8c50d..78044ce 100644 --- a/src/ipc/zone/action_request.rs +++ b/src/ipc/zone/action_request.rs @@ -9,6 +9,7 @@ pub enum ActionKind { #[default] Nothing = 0x0, Normal = 0x1, + Item = 0x2, } #[binrw] diff --git a/src/lobby/connection.rs b/src/lobby/connection.rs index 0bd494c..4feb916 100644 --- a/src/lobby/connection.rs +++ b/src/lobby/connection.rs @@ -417,8 +417,6 @@ impl LobbyConnection { let our_actor_id; let our_content_id; - dbg!(CharaMake::from_json(&character_action.json)); - // tell the world server to create this character { let ipc_segment = CustomIpcSegment { @@ -537,7 +535,58 @@ impl LobbyConnection { } LobbyCharacterActionKind::Move => todo!(), LobbyCharacterActionKind::RemakeRetainer => todo!(), - LobbyCharacterActionKind::RemakeChara => todo!(), + LobbyCharacterActionKind::RemakeChara => { + // tell the world server to turn this guy into a catgirl + { + let ipc_segment = CustomIpcSegment { + unk1: 0, + unk2: 0, + op_code: CustomIpcType::RemakeCharacter, + option: 0, + timestamp: 0, + data: CustomIpcData::RemakeCharacter { + content_id: character_action.content_id, + chara_make_json: character_action.json.clone(), + }, + }; + + let _ = send_custom_world_packet(ipc_segment).await.unwrap(); + + // we intentionally don't care about the response right now, it's not expected to fail + } + + // send a confirmation that the remakewas successful + { + let ipc = ServerLobbyIpcSegment { + unk1: 0, + unk2: 0, + op_code: ServerLobbyIpcType::CharaMakeReply, + option: 0, + timestamp: 0, + data: ServerLobbyIpcData::CharaMakeReply { + sequence: character_action.sequence + 1, + unk1: 0x1, + unk2: 0x1, + action: LobbyCharacterActionKind::RemakeChara, + details: CharacterDetails { + actor_id: 0, // TODO: fill maybe? + content_id: character_action.content_id, + character_name: character_action.name.clone(), + origin_server_name: self.world_name.clone(), + current_server_name: self.world_name.clone(), + ..Default::default() + }, + }, + }; + + self.send_segment(PacketSegment { + segment_type: SegmentType::Ipc, + data: SegmentData::Ipc { data: ipc }, + ..Default::default() + }) + .await; + } + } LobbyCharacterActionKind::SettingsUploadBegin => todo!(), LobbyCharacterActionKind::SettingsUpload => todo!(), LobbyCharacterActionKind::WorldVisit => todo!(), diff --git a/src/world/connection.rs b/src/world/connection.rs index d35e484..9b0121b 100644 --- a/src/world/connection.rs +++ b/src/world/connection.rs @@ -28,7 +28,7 @@ use crate::{ use super::{ Actor, CharacterData, Event, Inventory, Item, LuaPlayer, StatusEffects, WorldDatabase, Zone, - inventory::Container, + inventory::Container, lua::Task, }; #[derive(Debug, Default, Clone)] @@ -623,7 +623,12 @@ impl ZoneConnection { player.queued_segments.clear(); for task in &player.queued_tasks { - self.change_zone(task.zone_id).await; + match task { + Task::ChangeTerritory { zone_id } => self.change_zone(*zone_id).await, + Task::SetRemakeMode(remake_mode) => self + .database + .set_remake_mode(player.player_data.content_id, *remake_mode), + } } player.queued_tasks.clear(); } diff --git a/src/world/database.rs b/src/world/database.rs index 1073d05..216e1c6 100644 --- a/src/world/database.rs +++ b/src/world/database.rs @@ -43,7 +43,7 @@ impl WorldDatabase { // Create characters data table { - let query = "CREATE TABLE IF NOT EXISTS character_data (content_id INTEGER PRIMARY KEY, name STRING, chara_make STRING, city_state INTEGER, zone_id INTEGER, pos_x REAL, pos_y REAL, pos_z REAL, rotation REAL, inventory STRING);"; + let query = "CREATE TABLE IF NOT EXISTS character_data (content_id INTEGER PRIMARY KEY, name STRING, chara_make STRING, city_state INTEGER, zone_id INTEGER, pos_x REAL, pos_y REAL, pos_z REAL, rotation REAL, inventory STRING, remake_mode INTEGER);"; connection.execute(query, ()).unwrap(); } @@ -239,16 +239,22 @@ impl WorldDatabase { for (index, (content_id, actor_id)) in content_actor_ids.iter().enumerate() { let mut stmt = connection .prepare( - "SELECT name, chara_make, zone_id, inventory FROM character_data WHERE content_id = ?1", + "SELECT name, chara_make, zone_id, inventory, remake_mode FROM character_data WHERE content_id = ?1", ) .unwrap(); - let result: Result<(String, String, u16, String), rusqlite::Error> = stmt + let result: Result<(String, String, u16, String, i32), rusqlite::Error> = stmt .query_row((content_id,), |row| { - Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)) + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + )) }); - if let Ok((name, chara_make, zone_id, inventory_json)) = result { + if let Ok((name, chara_make, zone_id, inventory_json, remake_mode)) = result { let chara_make = CharaMake::from_json(&chara_make); let inventory: Inventory = serde_json::from_str(&inventory_json).unwrap(); @@ -273,9 +279,7 @@ impl WorldDatabase { model_ids: inventory.get_model_ids(game_data), equip_stain: [0; 10], glasses: [0; 2], - unk15: 0, - unk16: 0, - remake_mode: RemakeMode::None, + remake_mode: RemakeMode::try_from(remake_mode).unwrap(), remake_minutes_remaining: 0, voice_id: chara_make.voice_id, unk20: 0, @@ -338,7 +342,7 @@ impl WorldDatabase { // insert char data connection .execute( - "INSERT INTO character_data VALUES (?1, ?2, ?3, ?4, ?5, 0.0, 0.0, 0.0, 0.0, ?6);", + "INSERT INTO character_data VALUES (?1, ?2, ?3, ?4, ?5, 0.0, 0.0, 0.0, 0.0, ?6, 0);", ( content_id, name, @@ -416,4 +420,24 @@ impl WorldDatabase { .unwrap(); stmt.execute((content_id,)).unwrap(); } + + /// Sets the remake mode for a character + pub fn set_remake_mode(&self, content_id: u64, mode: RemakeMode) { + let connection = self.connection.lock().unwrap(); + + let mut stmt = connection + .prepare("UPDATE character_data SET remake_mode=?1 WHERE content_id = ?2") + .unwrap(); + stmt.execute((mode as i32, content_id)).unwrap(); + } + + /// Sets the chara make JSON for a character + pub fn set_chara_make(&self, content_id: u64, chara_make_json: &str) { + let connection = self.connection.lock().unwrap(); + + let mut stmt = connection + .prepare("UPDATE character_data SET chara_make=?1 WHERE content_id = ?2") + .unwrap(); + stmt.execute((chara_make_json, content_id)).unwrap(); + } } diff --git a/src/world/lua.rs b/src/world/lua.rs index 7308599..157b87b 100644 --- a/src/world/lua.rs +++ b/src/world/lua.rs @@ -1,7 +1,7 @@ use mlua::{FromLua, Lua, LuaSerdeExt, UserData, UserDataMethods, Value}; use crate::{ - common::{ObjectId, ObjectTypeId, Position, timestamp_secs}, + common::{ObjectId, ObjectTypeId, Position, timestamp_secs, workdefinitions::RemakeMode}, ipc::zone::{ ActionEffect, ActorSetPos, DamageElement, DamageKind, DamageType, EffectKind, EventPlay, ServerZoneIpcData, ServerZoneIpcSegment, @@ -12,8 +12,9 @@ use crate::{ use super::{PlayerData, StatusEffects, Zone}; -pub struct ChangeTerritoryTask { - pub zone_id: u16, +pub enum Task { + ChangeTerritory { zone_id: u16 }, + SetRemakeMode(RemakeMode), } #[derive(Default)] @@ -21,7 +22,7 @@ pub struct LuaPlayer { pub player_data: PlayerData, pub status_effects: StatusEffects, pub queued_segments: Vec>, - pub queued_tasks: Vec, + pub queued_tasks: Vec, } impl LuaPlayer { @@ -100,7 +101,11 @@ impl LuaPlayer { } fn change_territory(&mut self, zone_id: u16) { - self.queued_tasks.push(ChangeTerritoryTask { zone_id }); + self.queued_tasks.push(Task::ChangeTerritory { zone_id }); + } + + fn set_remake_mode(&mut self, mode: RemakeMode) { + self.queued_tasks.push(Task::SetRemakeMode(mode)); } } @@ -132,6 +137,11 @@ impl UserData for LuaPlayer { this.change_territory(zone_id); Ok(()) }); + methods.add_method_mut("set_remake_mode", |lua, this, mode: Value| { + let mode: RemakeMode = lua.from_value(mode).unwrap(); + this.set_remake_mode(mode); + Ok(()) + }); } }