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

Implement walk-in trigger events in S9 (#133)

Implement walk-in trigger events, and more specifically, the teleporter pads in Solution Nine.

Include a buildfix in kawari-world.rs as well.
This commit is contained in:
thedax 2025-07-22 23:30:01 -04:00 committed by GitHub
parent 2acbfe6df8
commit 468ca97257
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 334 additions and 45 deletions

View file

@ -334,6 +334,11 @@
"name": "SetSearchComment",
"opcode": 512,
"size": 216
},
{
"name": "WalkInEvent",
"opcode": 316,
"size": 24
}
],
"ClientZoneIpcType": [
@ -496,6 +501,11 @@
"name": "EquipGearset",
"opcode": 101,
"size": 72
},
{
"name": "StartWalkInEvent",
"opcode": 607,
"size": 24
}
],
"ServerLobbyIpcType": [

View file

@ -375,6 +375,15 @@ generic_currency_exchange = {
-- 3539075, -- Dibourdier <Mahjong Vendor> doesn't respond when interacted with right now, probably needs special handling
}
solution_nine_teleporters = {
4194305, -- Teleporter from eastern Aetheryte Plaza to Recreation Zone
4194306, -- Teleporter from Recreation Zone to eastern Aetheryte Plaza
4194307, -- Teleporter from northern Aetheryte Plaza to Government Sector
4194308, -- Teleporter from Government Sector to northern Aetheryte Plaza
4194309, -- Teleporter from Nexus Arcade ground floor to upper balcony
4194310, -- Teleporter from upper balcony to Nexus Arcade ground floor
}
-- Not custom in the sense of non-SQEX content, just going based off the directory name
custom0_events = {
[720916] = "cmndefinnbed_00020.lua",
@ -397,6 +406,7 @@ TOSORT_DIR = "events/tosort/"
OPENING_DIR = "events/quest/opening/"
CUSTOM0_DIR = "events/custom/000/"
CUSTOM1_DIR = "events/custom/001/"
TRIGGER_DIR = "events/walkin_trigger/"
for _, event_id in pairs(generic_warps) do
registerEvent(event_id, "events/common/GenericWarp.lua")
@ -441,3 +451,7 @@ end
for event_id, script_file in pairs(quests) do
registerEvent(event_id, OPENING_DIR..script_file)
end
for _, event_id in pairs(solution_nine_teleporters) do
registerEvent(event_id, TRIGGER_DIR.."SolutionNineTeleporter.lua")
end

View file

@ -0,0 +1,25 @@
-- TODO: Do any of the sheets contain any of this info so we don't have to hardcode it?
-- Yes, this table is ugly. Currently these values are complete unknowns.
TELEPORTER_INFO = {
[4194305] = { 10114730, 1965, 17743871, 2619867137, 2158200772 }, -- Teleporter from eastern Aetheryte Plaza to Recreation Zone
[4194306] = { 10114817, 1966, 17711103, 2462253057, 2136702993 }, -- Teleporter from Recreation Zone to eastern Aetheryte Plaza
[4194307] = { 10114878, 1967, 17694720, 2158166017, 1782416562 }, -- Teleporter from northern Aetheryte Plaza to Government Sector
[4194308] = { 10114891, 1968, 17727487, 2136670209, 1896185871 }, -- Teleporter from Government Sector to northern Aetheryte Plaza
[4194309] = { 10114905, 1969, 11804671, 1665204225, 1989510302 }, -- Teleporter from Nexus Arcade ground floor to upper balcony
[4194310] = { 10114944, 1970, 11837439, 1670053889, 2009628707 }, -- Teleporter from upper balcony to Nexus Arcade ground floor
}
EVENT_ARG = {
[4194305] = 10611851,
[4194306] = 10611861,
[4194307] = 10611862,
[4194308] = 10611864,
[4194309] = 10611868,
[4194310] = 10611881,
}
function onEnterTrigger(player)
player:do_solnine_teleporter(EVENT_ID, table.unpack(TELEPORTER_INFO[EVENT_ID]))
-- TODO: We should probably take the event arg in Event::new on the rust side, but this works for now.
player:finish_event(EVENT_ID, EVENT_ARG[EVENT_ID], 1)
end

View file

@ -3,15 +3,17 @@ use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use kawari::common::Position;
use kawari::common::{GameData, INVALID_OBJECT_ID, ItemInfoQuery, timestamp_secs};
use kawari::common::{
GameData, INVALID_OBJECT_ID, ItemInfoQuery, ObjectId, ObjectTypeId, timestamp_secs,
};
use kawari::config::get_config;
use kawari::inventory::{
BuyBackItem, ContainerType, CurrencyKind, Item, ItemOperationKind, get_container_type,
};
use kawari::ipc::chat::{ServerChatIpcData, ServerChatIpcSegment};
use kawari::ipc::zone::{
ActorControlCategory, ActorControlSelf, ItemOperation, PlayerEntry, PlayerSpawn, PlayerStatus,
SocialList,
ActorControl, ActorControlCategory, ActorControlSelf, ItemOperation, PlayerEntry, PlayerSpawn,
PlayerStatus, SocialList,
};
use kawari::ipc::zone::{
@ -27,8 +29,8 @@ use kawari::world::{
ChatHandler, ExtraLuaState, LuaZone, ObsfucationData, Zone, ZoneConnection, load_init_script,
};
use kawari::world::{
ClientHandle, FromServer, LuaPlayer, PlayerData, ServerHandle, StatusEffects, ToServer,
WorldDatabase, handle_custom_ipc, server_main_loop,
ClientHandle, EventFinishType, FromServer, LuaPlayer, PlayerData, ServerHandle, StatusEffects,
ToServer, WorldDatabase, handle_custom_ipc, server_main_loop,
};
use kawari::{
ERR_INVENTORY_ADD_FAILED, LogMessageType, RECEIVE_BUFFER_SIZE, TITLE_UNLOCK_BITMASK_SIZE,
@ -463,9 +465,6 @@ async fn client_loop(
}
}
}
ClientZoneIpcData::Unk2 { .. } => {
// no-op
}
ClientZoneIpcData::Unk3 { .. } => {
// no-op
}
@ -916,15 +915,15 @@ async fn client_loop(
} else {
tracing::error!(ERR_INVENTORY_ADD_FAILED);
connection.send_message(ERR_INVENTORY_ADD_FAILED).await;
connection.event_finish(*event_id, 0).await;
connection.event_finish(*event_id, 0, EventFinishType::Normal).await;
}
} else {
connection.send_message("Insufficient gil to buy item. Nice try bypassing the client-side check!").await;
connection.event_finish(*event_id, 0).await;
connection.event_finish(*event_id, 0, EventFinishType::Normal).await;
}
} else {
connection.send_message("Unable to find shop item, this is a bug in Kawari!").await;
connection.event_finish(*event_id, 0).await;
connection.event_finish(*event_id, 0, EventFinishType::Normal).await;
}
} else if *buy_sell_mode == SELL {
let storage = get_container_type(*item_index).unwrap();
@ -1033,11 +1032,11 @@ async fn client_loop(
connection.event_scene(&target_id, *event_id, 10, 8193, params).await;
} else {
connection.send_message("Unable to find shop item, this is a bug in Kawari!").await;
connection.event_finish(*event_id, 0).await;
connection.event_finish(*event_id, 0, EventFinishType::Normal).await;
}
} else {
tracing::error!("Received unknown transaction mode {buy_sell_mode}!");
connection.event_finish(*event_id, 0).await;
connection.event_finish(*event_id, 0, EventFinishType::Normal).await;
}
}
ClientZoneIpcData::StartTalkEvent { actor_id, event_id } => {
@ -1319,6 +1318,22 @@ async fn client_loop(
tracing::info!("Client tried to equip a gearset!");
connection.send_message("Gearsets are not yet implemented.").await;
}
ClientZoneIpcData::StartWalkInEvent { event_arg, event_id, .. } => {
// Yes, an ActorControl is sent here, not an ActorControlSelf!
connection.actor_control(connection.player_data.actor_id, ActorControl {
category: ActorControlCategory::ToggleWeapon {
shown: false,
}
}).await;
connection.send_unk18([64, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]).await;
let actor_id = ObjectTypeId { object_id: ObjectId(connection.player_data.actor_id), object_type: 0 };
connection.start_event(actor_id, *event_id, 10, *event_arg).await;
// begin walk-in trigger function if it exists
if let Some(event) = connection.event.as_mut() {
event.enter_trigger(&mut lua_player);
}
}
ClientZoneIpcData::Unknown { .. } => {
tracing::warn!("Unknown packet {:?} recieved, this should be handled!", data.op_code);
}
@ -1391,6 +1406,7 @@ async fn client_loop(
FromServer::ActorEquip(actor_id, main_weapon_id, model_ids) => connection.update_equip(actor_id, main_weapon_id, model_ids).await,
FromServer::ReplayPacket(segment) => connection.send_segment(segment).await,
FromServer::LoseEffect(effect_id, effect_param, effect_source_actor_id) => connection.lose_effect(effect_id, effect_param, effect_source_actor_id, &mut lua_player).await,
FromServer::Unk18(unk) => connection.send_unk18(unk).await,
},
None => break,
}

View file

@ -72,6 +72,11 @@ pub enum ActorControlCategory {
#[bw(map = write_bool_as::<u32>)]
unlocked: bool,
},
#[brw(magic = 0x8Au16)]
EventRelatedUnk3 {
#[brw(pad_before = 2)] //padding
event_id: u32,
},
#[brw(magic = 0xCBu16)]
TeleportStart {
#[brw(pad_before = 2)] //padding
@ -83,6 +88,11 @@ pub enum ActorControlCategory {
#[brw(pad_before = 2)] // padding
speed: u16,
},
#[brw(magic = 0xECu16)]
WalkInTriggerRelatedUnk3 {
#[brw(pad_before = 2)] // padding
unk1: u32,
},
#[brw(magic = 0x386u16)]
SetFestival {
#[brw(pad_before = 2)] // padding
@ -177,6 +187,20 @@ pub enum ActorControlCategory {
unk2: u32,
unk3: u32,
},
#[brw(magic = 0x104u16)]
WalkInTriggerRelatedUnk2 {
#[brw(pad_before = 2)] // padding
unk1: u32,
unk2: u32,
unk3: u32,
/// Usually 7?
unk4: u32,
},
#[brw(magic = 0x107u16)]
WalkInTriggerRelatedUnk1 {
#[brw(pad_before = 2)] // padding
unk1: u32,
},
Unknown {
category: u16,
#[brw(pad_before = 2)] // padding

View file

@ -50,6 +50,11 @@ pub enum ClientTriggerCommand {
aetheryte_id: u32,
// TODO: fill out the rest
},
#[brw(magic = 0x25eu16)]
WalkInTriggerFinished {
#[brw(pad_before = 2)]
unk1: u32,
},
#[brw(magic = 0x033Eu16)]
EventRelatedUnk {
// seen in haircut event

View file

@ -586,6 +586,18 @@ pub enum ServerZoneIpcData {
// TODO: fill this out
unk1: [u8; 968],
},
/// Sent by the server when walking over a trigger (e.g. the teleport pads in Solution Nine).
/// All of these fields are currently unknown in meaning.
#[br(pre_assert(*magic == ServerZoneIpcType::WalkInEvent))]
WalkInEvent {
unk1: u32,
unk2: u16,
#[brw(pad_before = 2)]
unk3: u32,
unk4: u32,
#[brw(pad_after = 4)]
unk5: u32,
},
Unknown {
#[br(count = size - 32)]
unk: Vec<u8>,
@ -791,6 +803,13 @@ pub enum ClientZoneIpcData {
#[brw(pad_after = 2)]
unk2: u16,
},
#[br(pre_assert(*magic == ClientZoneIpcType::StartWalkInEvent))]
StartWalkInEvent {
event_arg: u32,
event_id: u32,
#[brw(pad_after = 4)]
pos: Position,
},
#[br(pre_assert(*magic == ClientZoneIpcType::ContentFinderAction))]
ContentFinderAction { unk1: [u8; 8] },
Unknown {
@ -1161,6 +1180,16 @@ mod tests {
ServerZoneIpcType::Unk17,
ServerZoneIpcData::Unk17 { unk1: [0; 968] },
),
(
ServerZoneIpcType::WalkInEvent,
ServerZoneIpcData::WalkInEvent {
unk1: 0,
unk2: 0,
unk3: 0,
unk4: 0,
unk5: 0,
},
),
];
for (opcode, data) in &ipc_types {
@ -1339,6 +1368,18 @@ mod tests {
ClientZoneIpcType::UnkCall2,
ClientZoneIpcData::UnkCall2 { unk1: [0; 8] },
),
(
ClientZoneIpcType::StartWalkInEvent,
ClientZoneIpcData::StartWalkInEvent {
event_arg: 0,
event_id: 0,
pos: Position {
x: 0.0,
y: 0.0,
z: 0.0,
},
},
),
];
for (opcode, data) in &ipc_types {

View file

@ -3,7 +3,7 @@ use crate::{
common::ItemInfoQuery,
inventory::{Item, Storage},
ipc::zone::{ChatMessage, GameMasterRank},
world::ToServer,
world::{EventFinishType, ToServer},
};
use super::ZoneConnection;
@ -110,7 +110,9 @@ impl ChatHandler {
}
"!finishevent" => {
if let Some(event) = &connection.event {
connection.event_finish(event.id, 0).await;
connection
.event_finish(event.id, 0, EventFinishType::Normal)
.await;
connection
.send_message("Current event forcefully finished.")
.await;

View file

@ -50,6 +50,7 @@ pub enum FromServer {
ReplayPacket(PacketSegment<ServerZoneIpcSegment>),
/// The player should lose this effect.
LoseEffect(u16, u16, ObjectId),
Unk18([u8; 16]),
}
#[derive(Debug, Clone)]

View file

@ -39,8 +39,8 @@ use crate::{
};
use super::{
Actor, CharacterData, EffectsBuilder, Event, LuaPlayer, StatusEffects, ToServer, WorldDatabase,
Zone,
Actor, CharacterData, EffectsBuilder, Event, EventFinishType, LuaPlayer, StatusEffects,
ToServer, WorldDatabase, Zone,
common::{ClientId, ServerHandle},
load_init_script,
lua::Task,
@ -860,7 +860,11 @@ impl ZoneConnection {
self.warp(*warp_id).await;
}
Task::BeginLogOut => self.begin_log_out().await,
Task::FinishEvent { handler_id, arg } => self.event_finish(*handler_id, *arg).await,
Task::FinishEvent {
handler_id,
arg,
finish_type,
} => self.event_finish(*handler_id, *arg, *finish_type).await,
Task::SetClassJob { classjob_id } => {
self.player_data.classjob_id = *classjob_id;
self.update_class_info().await;
@ -1081,11 +1085,12 @@ impl ZoneConnection {
"Unable to play event {event_id}, scene {:?}, scene_flags {scene_flags}!",
scene
);
self.event_finish(event_id, 0).await;
self.event_finish(event_id, 0, EventFinishType::Normal)
.await;
}
}
pub async fn event_finish(&mut self, handler_id: u32, arg: u32) {
pub async fn event_finish(&mut self, handler_id: u32, arg: u32, finish_type: EventFinishType) {
self.player_data.target_actorid = ObjectTypeId::default();
// sent event finish
{
@ -1111,22 +1116,29 @@ impl ZoneConnection {
}
// give back control to the player
{
let ipc = ServerZoneIpcSegment {
op_code: ServerZoneIpcType::Unk18,
timestamp: timestamp_secs(),
data: ServerZoneIpcData::Unk18 { unk: [0; 16] },
..Default::default()
};
let unk = match finish_type {
EventFinishType::Normal => [0; 16],
EventFinishType::Jumping => [0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
};
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.send_unk18(unk).await;
}
pub async fn send_unk18(&mut self, unk: [u8; 16]) {
let ipc = ServerZoneIpcSegment {
op_code: ServerZoneIpcType::Unk18,
timestamp: timestamp_secs(),
data: ServerZoneIpcData::Unk18 { unk },
..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_inventory_ack(&mut self, sequence: u32, action_type: u16) {
@ -1843,7 +1855,8 @@ impl ZoneConnection {
if should_cancel {
// give control back to the player so they aren't stuck
self.event_finish(event_id, 0).await;
self.event_finish(event_id, 0, EventFinishType::Normal)
.await;
self.send_message(&format!(
"Event {event_id} tried to start, but it doesn't have a script associated with it!"
))

View file

@ -10,6 +10,12 @@ pub struct Event {
pub id: u32,
}
#[derive(Copy, Clone)]
pub enum EventFinishType {
Normal,
Jumping,
}
impl Event {
pub fn new(id: u32, path: &str) -> Self {
let lua = Lua::new();
@ -52,6 +58,24 @@ impl Event {
}
}
pub fn enter_trigger(&mut self, player: &mut LuaPlayer) {
let mut run_script = || {
self.lua.scope(|scope| {
let player = scope.create_userdata_ref_mut(player)?;
let func: Function = self.lua.globals().get("onEnterTrigger")?;
func.call::<()>(player)?;
Ok(())
})
};
if let Err(err) = run_script() {
tracing::warn!("Syntax error in {}: {:?}", self.file_name, err);
}
}
pub fn scene_finished(&mut self, player: &mut LuaPlayer, scene: u16) {
let mut run_script = || {
self.lua.scope(|scope| {

View file

@ -16,7 +16,7 @@ use crate::{
},
opcodes::ServerZoneIpcType,
packet::{PacketSegment, SegmentData, SegmentType},
world::ExtraLuaState,
world::{EventFinishType, ExtraLuaState},
};
use mlua::{FromLua, Lua, LuaSerdeExt, UserData, UserDataFields, UserDataMethods, Value};
@ -35,6 +35,7 @@ pub enum Task {
FinishEvent {
handler_id: u32,
arg: u32,
finish_type: EventFinishType,
},
SetClassJob {
classjob_id: u8,
@ -210,7 +211,7 @@ impl LuaPlayer {
let error_message = "Unsupported amount of parameters in play_scene! This is likely a bug in your script! Cancelling event...".to_string();
tracing::warn!(error_message);
self.send_message(&error_message, 0);
self.finish_event(event_id, 0);
self.finish_event(event_id, 0, EventFinishType::Normal);
}
}
@ -284,9 +285,12 @@ impl LuaPlayer {
self.queued_tasks.push(Task::BeginLogOut);
}
fn finish_event(&mut self, handler_id: u32, arg: u32) {
self.queued_tasks
.push(Task::FinishEvent { handler_id, arg });
fn finish_event(&mut self, handler_id: u32, arg: u32, finish_type: EventFinishType) {
self.queued_tasks.push(Task::FinishEvent {
handler_id,
arg,
finish_type,
});
}
fn set_classjob(&mut self, classjob_id: u8) {
@ -455,6 +459,53 @@ impl LuaPlayer {
}
}
fn do_solnine_teleporter(
&mut self,
event_id: u32,
unk1: u32,
unk2: u16,
unk3: u32,
unk4: u32,
unk5: u32,
) {
let packets_to_send = [
(
ServerZoneIpcType::ActorControlSelf,
ServerZoneIpcData::ActorControlSelf(ActorControlSelf {
category: ActorControlCategory::EventRelatedUnk3 { event_id },
}),
),
(
ServerZoneIpcType::WalkInEvent,
ServerZoneIpcData::WalkInEvent {
unk1,
unk2,
unk3,
unk4,
unk5,
},
),
(
ServerZoneIpcType::ActorControlSelf,
ServerZoneIpcData::ActorControlSelf(ActorControlSelf {
category: ActorControlCategory::WalkInTriggerRelatedUnk3 {
unk1: 1, // Sometimes the server sends 2 for this, but it's still completely unknown what it means.
},
}),
),
(
ServerZoneIpcType::ActorControlSelf,
ServerZoneIpcData::ActorControlSelf(ActorControlSelf {
category: ActorControlCategory::WalkInTriggerRelatedUnk1 { unk1: 1 },
}),
),
];
for (op_code, data) in packets_to_send {
self.create_segment_self(op_code, data);
}
}
fn add_exp(&mut self, amount: u32) {
self.queued_tasks.push(Task::AddExp { amount });
}
@ -564,10 +615,20 @@ impl UserData for LuaPlayer {
this.begin_log_out();
Ok(())
});
methods.add_method_mut("finish_event", |_, this, (handler_id, arg): (u32, u32)| {
this.finish_event(handler_id, arg);
Ok(())
});
methods.add_method_mut(
"finish_event",
|lua, this, (handler_id, arg, finish_type): (u32, u32, Value)| {
// It's desirable for finish_type to be optional since we do normal finishes 99% of the time.
let finish_type: u32 = lua.from_value(finish_type).unwrap_or(0);
let finish_type = match finish_type {
0 => EventFinishType::Normal,
1 => EventFinishType::Jumping,
_ => EventFinishType::Normal,
};
this.finish_event(handler_id, arg, finish_type);
Ok(())
},
);
methods.add_method_mut("set_classjob", |_, this, classjob_id: u8| {
this.set_classjob(classjob_id);
Ok(())
@ -642,6 +703,13 @@ impl UserData for LuaPlayer {
this.set_inn_wakeup(watched);
Ok(())
});
methods.add_method_mut(
"do_solnine_teleporter",
|_, this, (event_id, unk1, unk2, unk3, unk4, unk5): (u32, u32, u16, u32, u32, u32)| {
this.do_solnine_teleporter(event_id, unk1, unk2, unk3, unk4, unk5);
Ok(())
},
);
}
fn add_fields<F: UserDataFields<Self>>(fields: &mut F) {

View file

@ -15,6 +15,7 @@ pub use lua::{EffectsBuilder, LuaPlayer, LuaZone, load_init_script};
mod event;
pub use event::Event;
pub use event::EventFinishType;
mod actor;
pub use actor::Actor;

View file

@ -568,6 +568,51 @@ pub async fn server_main_loop(mut recv: Receiver<ToServer>) -> Result<(), std::i
to_remove.push(id);
}
}
if let ClientTriggerCommand::WalkInTriggerFinished { .. } = &trigger.trigger
{
// This is where we finally release the client after the walk-in trigger.
let msg = FromServer::Unk18([0; 16]);
if handle.send(msg).is_err() {
to_remove.push(id);
}
let msg = FromServer::ActorControlSelf(ActorControlSelf {
category: ActorControlCategory::WalkInTriggerRelatedUnk1 {
unk1: 0,
},
});
if handle.send(msg).is_err() {
to_remove.push(id);
}
// Yes, this is actually sent every time the trigger event finishes...
let msg = FromServer::ActorControlSelf(ActorControlSelf {
category: ActorControlCategory::CompanionUnlock {
unk1: 0,
unk2: 1,
},
});
if handle.send(msg).is_err() {
to_remove.push(id);
}
let msg = FromServer::ActorControlSelf(ActorControlSelf {
category: ActorControlCategory::WalkInTriggerRelatedUnk2 {
unk1: 0,
unk2: 0,
unk3: 0,
unk4: 7,
},
});
if handle.send(msg).is_err() {
to_remove.push(id);
}
}
continue;
}