From b65e080ee051340ad5468277e0406fb927c9a70d Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Thu, 15 Oct 2026 12:44:22 +0200 Subject: [PATCH 1/2] feat(module): store a player's stand state and stand them up on damage gw_set_stand_state writes stand, sit, sleep or kneel into byte 0 of the Character's unit bytes; any other state is a Refusal. Damage stands a seated player who is not mounted, as vmangos DealDamage does. Co-Authored-By: Claude Opus 5.5 --- module/src/gw.rs | 12 ++++++ module/src/spell/control.rs | 1 + module/src/world.rs | 41 ++++++++++++++++++ module/tests/stand_state.rs | 85 +++++++++++++++++++++++++++++++++++++ 4 files changed, 139 insertions(+) create mode 100644 module/tests/stand_state.rs diff --git a/module/src/gw.rs b/module/src/gw.rs index 7aa79afc0..437f01d21 100644 --- a/module/src/gw.rs +++ b/module/src/gw.rs @@ -319,6 +319,18 @@ pub fn gw_set_sheathed( crate::world::apply_set_sheathed(ctx, actor, state) } +#[reducer] +pub fn gw_set_stand_state( + ctx: &ReducerContext, + request_actor: crate::account_ownership::SessionActor, + state: u8, +) -> Result<(), String> { + require_operator(ctx)?; + let actor_guid = crate::account_ownership::require_actor(ctx, request_actor)?; + let actor = actor(ctx, actor_guid)?; + crate::world::apply_set_stand_state(ctx, actor, state) +} + #[reducer] pub fn gw_accept_quest( ctx: &ReducerContext, diff --git a/module/src/spell/control.rs b/module/src/spell/control.rs index e8e304274..2df79948e 100644 --- a/module/src/spell/control.rs +++ b/module/src/spell/control.rs @@ -263,6 +263,7 @@ pub(crate) fn break_auras_on_damage( periodic, }, ); + crate::world::stand_up_on_damage(ctx, target_guid); break_flagged_auras(ctx, target_guid, breaks_on_damage); super::break_stealth(ctx, target_guid); // Vanilla 1.12 periodic damage neither interrupts nor pushes back a cast. diff --git a/module/src/world.rs b/module/src/world.rs index b0ea03828..b796f7070 100644 --- a/module/src/world.rs +++ b/module/src/world.rs @@ -1174,6 +1174,47 @@ pub(crate) fn apply_set_sheathed( Ok(()) } +// The states a client may pick: stand, sit, sleep, kneel. +const PLAYER_STAND_STATES: [u8; 4] = [0, 1, 3, 8]; + +const STAND_STATE_DEAD: u32 = 7; + +// No alive check: the client sits a released ghost. +pub(crate) fn apply_set_stand_state( + ctx: &ReducerContext, + mut actor: WorldEntity, + state: u8, +) -> Result<(), String> { + if !PLAYER_STAND_STATES.contains(&state) { + return Err(format!("invalid stand state {state}")); + } + let packed = (actor.unit_bytes_1 & !0xFF) | u32::from(state); + if packed == actor.unit_bytes_1 { + return Ok(()); + } + actor.unit_bytes_1 = packed; + ctx.db.game_world_entity().guid().update(actor); + Ok(()) +} + +// The client does not stand itself when hit. +pub(crate) fn stand_up_on_damage(ctx: &ReducerContext, target_guid: u64) { + let entities = ctx.db.game_world_entity(); + let Some(mut target) = entities.guid().find(target_guid) else { + return; + }; + let state = target.unit_bytes_1 & 0xFF; + if !target.is_player() + || target.mount_display_id != 0 + || state == 0 + || state == STAND_STATE_DEAD + { + return; + } + target.unit_bytes_1 &= !0xFF; + entities.guid().update(target); +} + pub const RESURRECTION_SICKNESS_SPELL: u32 = 15007; fn spirit_res_vitals(max_health: u32, max_power: u32) -> (u32, u32) { diff --git a/module/tests/stand_state.rs b/module/tests/stand_state.rs new file mode 100644 index 000000000..fb090e16b --- /dev/null +++ b/module/tests/stand_state.rs @@ -0,0 +1,85 @@ +mod support; + +use support::{actor, number, Standalone}; + +const CHARACTER: u64 = 1; + +fn spawned(test_name: &str) -> Standalone { + let mut standalone = Standalone::start(test_name); + standalone.publish_module(); + standalone.assert_call("claim_operator", &[]); + standalone.assert_call("install_guid_range", &["0"]); + standalone.assert_call("debug_spawn_player_entity", &[&CHARACTER.to_string()]); + standalone +} + +fn unit_bytes_1(standalone: &Standalone) -> u32 { + let rows = standalone.query_rows(&format!( + "SELECT unit_bytes_1 FROM game_world_entity WHERE guid = {CHARACTER}" + )); + number(&rows[0], "unit_bytes_1") +} + +fn set_stand_state(standalone: &Standalone, state: &str) { + standalone.assert_call( + "gw_set_stand_state", + &[&actor(&CHARACTER.to_string()), state], + ); +} + +fn refused(standalone: &Standalone, state: &str, tag: &str) { + let result = standalone.call( + "gw_set_stand_state", + &[&actor(&CHARACTER.to_string()), state], + ); + let output = format!( + "{}{}", + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr) + ); + assert!( + !result.status.success() && output.contains(tag), + "gw_set_stand_state({state}) should refuse with {tag}: {output}" + ); +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn a_player_sits_and_stands_but_cannot_pick_a_server_state() { + let standalone = spawned("stand-state"); + let upper_bytes = unit_bytes_1(&standalone) & !0xFF; + + set_stand_state(&standalone, "1"); + assert_eq!(unit_bytes_1(&standalone), upper_bytes | 1, "sitting"); + set_stand_state(&standalone, "8"); + assert_eq!(unit_bytes_1(&standalone), upper_bytes | 8, "kneeling"); + + refused(&standalone, "2", "invalid stand state"); + refused(&standalone, "7", "invalid stand state"); + assert_eq!(unit_bytes_1(&standalone), upper_bytes | 8); + + set_stand_state(&standalone, "0"); + assert_eq!(unit_bytes_1(&standalone), upper_bytes, "standing"); + + standalone.assert_sql(&format!( + "UPDATE game_world_entity SET dead = true WHERE guid = {CHARACTER}" + )); + set_stand_state(&standalone, "1"); + assert_eq!(unit_bytes_1(&standalone), upper_bytes | 1, "a ghost sits"); +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn damage_stands_a_seated_player() { + let standalone = spawned("stand-on-damage"); + let upper_bytes = unit_bytes_1(&standalone) & !0xFF; + set_stand_state(&standalone, "3"); + assert_eq!(unit_bytes_1(&standalone), upper_bytes | 3, "sleeping"); + + standalone.assert_call("debug_apply_damage", &[&CHARACTER.to_string(), "10", "0"]); + assert_eq!( + unit_bytes_1(&standalone), + upper_bytes, + "the hit stands the sleeper" + ); +} From 1162576af422a8c329ab5d100d36b95b17b91eac Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Thu, 15 Oct 2026 12:44:22 +0200 Subject: [PATCH 2/2] feat(gateway): answer character, session and world-state opcodes Stand-state changes reach the Module, and the self relay sends SMSG_STANDSTATE_UPDATE on every stored change; a Refusal sends the stored state back. CMSG_EMOTE plays its animation, a completed bind sends SMSG_PLAYERBOUND, and a first world entry plays the race's opening cinematic. World entry and each zone crossing send an empty SMSG_INIT_WORLD_STATES. Renames, account-data requests and a second login get their protocol Refusals. Debug, GM and Warden requests are explicitly ignored, and requests the protocol library cannot read safely are dropped before decoding. Co-Authored-By: Claude Opus 5.5 --- docs/benilla-compatibility.md | 22 +- gateway/src/codec/char.rs | 169 +++++++++++ gateway/src/codec/entity/subscriptions.rs | 7 +- .../entity/subscriptions/packet_tests.rs | 21 ++ gateway/src/codec/weather/subscriptions.rs | 8 +- .../weather/subscriptions/audience_tests.rs | 29 +- gateway/src/stdb/store/character.rs | 19 ++ gateway/src/world/benilla_tests.rs | 103 ++++++- gateway/src/world/chat_flood.rs | 4 +- gateway/src/world/entry.rs | 30 +- gateway/src/world/handlers/char.rs | 273 +++++++++++++++++- gateway/src/world/handlers/mod.rs | 2 +- gateway/src/world/handlers/query.rs | 25 +- gateway/src/world/handlers/unavailable.rs | 4 - gateway/src/world/mod.rs | 4 +- gateway/src/world/routing.rs | 8 + gateway/src/world/session_tests.rs | 12 + gateway/src/world/test_support/entry.rs | 7 + .../test_support/world_fake/character.rs | 19 ++ .../world/test_support/world_fake/speech.rs | 12 +- gateway/src/world/trainer_tests.rs | 8 + 21 files changed, 742 insertions(+), 44 deletions(-) diff --git a/docs/benilla-compatibility.md b/docs/benilla-compatibility.md index cc566bbc8..a577c7958 100644 --- a/docs/benilla-compatibility.md +++ b/docs/benilla-compatibility.md @@ -234,12 +234,25 @@ The selected innkeeper guid reaches the Module's interaction Gate. `CMSG_LOGOUT_CANCEL` returns its acknowledgement. Logout remains immediate when allowed; there is no twenty-second pending logout to cancel. +Stand, sit, sleep and kneel requests reach the Module, which stores the stand +state on the Character's unit bytes. A ghost can sit too. Damage stands a seated +player who is not mounted. Each stored change sends the Character +`SMSG_STANDSTATE_UPDATE`, and observers see it in the unit bytes. A refused +request sends the stored state back. A completed bind also sends `SMSG_PLAYERBOUND`. +Its confirmation line names the Character's zone; vmangos names the sub-area at +the bind point. World entry and each zone +crossing send an empty `SMSG_INIT_WORLD_STATES`. A first world entry plays the +race's opening cinematic. A second login in one World Session gets +`SMSG_CHARACTER_LOGIN_FAILED`. Debug, GM, Warden and lottery requests get no +answer. The Gateway drops account-data uploads and zone reports before decoding, +because the protocol library cannot read them safely. + ## Missing gameplay and deliberate responses These features remain unavailable. The responses below apply to valid requests while in world. A decoded request or a refusal is not a working gameplay system. -Automatic stand-state, action-bar and tutorial requests announce each missing -feature once per World Session. Repeated requests do not add chat lines. +Automatic action-bar and tutorial requests announce each missing feature once +per World Session. Repeated requests do not add chat lines. | Feature | Protocol behavior | Missing work | | --- | --- | --- | @@ -252,7 +265,10 @@ feature once per World Session. Repeated requests do not add chat lines. | Readable pages | A terminal page names the unavailable text service. | Page-text catalog import and page chains. | | Explicit vendor destination | Inventory Refusal 39. | An atomic purchase into the requested bag and slot. Ordinary vendor purchase uses the existing path. | | Automatic bag placement | Equipment-to-backpack uses the existing path; other requests receive Inventory Refusal 39. | Automatic placement into a chosen bag and movement of nonempty equipped bags. Explicit supported slot moves work. | -| Character presentation | Specific System Messages. | Stand state, mount flourish, helm and cloak visibility preferences. | +| Character presentation | Specific System Messages. | Mount flourish, helm and cloak visibility preferences. | +| Stand-state rules | Stand state is stored and shown. | Auras that sitting or standing cancels, the automatic critical hit on a seated victim, the NOT_STANDING cast failure, 1.5x regeneration while seated, and the UNIT_FLAG_PREVENT_ANIM gate. | +| Character renames | `SMSG_CHAR_RENAME` with CHAR_CREATE_ERROR. | An at-login rename flag and the rename itself. | +| Account data | Every section reports empty at world entry. A section request gets an empty `SMSG_UPDATE_ACCOUNT_DATA`; uploads are dropped. | Durable macros, key bindings and settings per Account and per Character. | | Tutorials and display preferences | Specific System Messages. | Durable tutorial flags, action bar visibility, inactive and watched factions. Action buttons and At War use existing operations. | | Ammunition and skill unlearning | Specific System Messages. | Ammunition selection and removal of learned skills. | | Taxi chains | Activation failure code 1. | Chained flight state and charging. Single-hop taxis use the existing path. | diff --git a/gateway/src/codec/char.rs b/gateway/src/codec/char.rs index 2de24da9d..48db2a14e 100644 --- a/gateway/src/codec/char.rs +++ b/gateway/src/codec/char.rs @@ -1,4 +1,7 @@ use super::*; +use wow_world_messages::vanilla::{ + UnitStandState, SMSG_CHARACTER_LOGIN_FAILED, SMSG_CHAR_RENAME, SMSG_STANDSTATE_UPDATE, +}; #[derive(Clone, Debug, Default)] pub struct CharacterView { @@ -142,6 +145,78 @@ pub fn logout_denied_in_combat() -> SMSG_LOGOUT_RESPONSE { } } +/// The client shows CHAR_RENAME_FAILED for any result except 0 and 0x31. +pub fn build_char_rename_refusal() -> SMSG_CHAR_RENAME { + SMSG_CHAR_RENAME::CharCreateError +} + +pub fn build_character_login_failed() -> SMSG_CHARACTER_LOGIN_FAILED { + SMSG_CHARACTER_LOGIN_FAILED { + result: WorldResult::ResponseFailure, + } +} + +/// u32 section, u32 size 0, no zlib stream. `None` past the client's eight sections. +pub fn build_empty_account_data(section: u32) -> Option<(u16, Vec)> { + const SECTIONS: u32 = 8; + (section < SECTIONS).then(|| { + let mut body = section.to_le_bytes().to_vec(); + body.extend_from_slice(&0u32.to_le_bytes()); + (0x020c, body) + }) +} + +/// ChrRaces.dbc CinematicSequenceID (column 16), build 5875. +pub fn opening_cinematic(race: u8) -> Option { + match race { + 1 => Some(81), // Human + 2 => Some(21), // Orc + 3 => Some(41), // Dwarf + 4 => Some(61), // Night Elf + 5 => Some(2), // Undead + 6 => Some(141), // Tauren + 7 => Some(101), // Gnome + 8 => Some(121), // Troll + _ => None, + } +} + +pub fn build_trigger_cinematic(sequence: u32) -> (u16, Vec) { + (0x00fa, sequence.to_le_bytes().to_vec()) +} + +/// u32 map, u32 zone, u16 count 0: the client clears its world-state table. +pub fn build_init_world_states(map_id: u32, zone_id: u32) -> (u16, Vec) { + let mut body = map_id.to_le_bytes().to_vec(); + body.extend_from_slice(&zone_id.to_le_bytes()); + body.extend_from_slice(&0u16.to_le_bytes()); + (0x02c2, body) +} + +pub fn build_stand_state_update(state: UnitStandState) -> SMSG_STANDSTATE_UPDATE { + SMSG_STANDSTATE_UPDATE { state } +} + +/// `Some` when byte 0 of UNIT_FIELD_BYTES_1, the stand state, changed. +pub fn stand_state_changed( + old_unit_bytes_1: u32, + new_unit_bytes_1: u32, +) -> Option { + let state = (new_unit_bytes_1 & 0xFF) as u8; + if state == (old_unit_bytes_1 & 0xFF) as u8 { + return None; + } + UnitStandState::try_from(state) + .ok() + .map(build_stand_state_update) +} + +pub fn build_player_bound(binder_guid: u64, area_id: u32) -> (u16, Vec) { + let mut body = binder_guid.to_le_bytes().to_vec(); + body.extend_from_slice(&area_id.to_le_bytes()); + (0x0158, body) +} + pub fn build_played_time( played_total_secs: u32, session_start_micros: u64, @@ -163,6 +238,100 @@ pub fn build_played_time( mod tests { use super::*; + use wow_world_messages::vanilla::ServerMessage; + + /// `[size:u16 BE][opcode:u16 LE][body]`, the plaintext frame the World Session writes. + fn typed_frame(message: &impl ServerMessage) -> Vec { + let mut framed = Vec::new(); + message.write_unencrypted_server(&mut framed).unwrap(); + framed + } + + fn raw_frame((opcode, body): (u16, Vec)) -> Vec { + let mut framed = u16::try_from(body.len() + 2) + .unwrap() + .to_be_bytes() + .to_vec(); + framed.extend_from_slice(&opcode.to_le_bytes()); + framed.extend(body); + framed + } + + #[test] + fn rename_refusal_is_the_char_create_error_byte() { + assert_eq!( + typed_frame(&build_char_rename_refusal()), + [0x00, 0x03, 0xc8, 0x02, 0x2f] + ); + } + + #[test] + fn login_failure_is_response_failure() { + assert_eq!( + typed_frame(&build_character_login_failed()), + [0x00, 0x03, 0x41, 0x00, 0x01] + ); + } + + #[test] + fn empty_account_data_is_the_section_and_a_zero_size() { + assert_eq!( + raw_frame(build_empty_account_data(7).unwrap()), + [0x00, 0x0a, 0x0c, 0x02, 7, 0, 0, 0, 0, 0, 0, 0] + ); + assert_eq!(build_empty_account_data(8), None); + } + + #[test] + fn each_playable_race_has_its_opening_cinematic() { + let ids: Vec<_> = (1..=8).map(opening_cinematic).collect(); + assert_eq!(ids, [81, 21, 41, 61, 2, 141, 101, 121].map(Some).to_vec()); + assert_eq!(opening_cinematic(0), None); + assert_eq!(opening_cinematic(9), None); + assert_eq!( + raw_frame(build_trigger_cinematic(141)), + [0x00, 0x06, 0xfa, 0x00, 141, 0, 0, 0] + ); + } + + #[test] + fn init_world_states_names_the_map_and_zone_with_no_states() { + assert_eq!( + raw_frame(build_init_world_states(0, 12)), + [0x00, 0x0c, 0xc2, 0x02, 0, 0, 0, 0, 12, 0, 0, 0, 0, 0] + ); + } + + #[test] + fn stand_state_update_is_the_state_byte() { + assert_eq!( + typed_frame(&build_stand_state_update(UnitStandState::Sit)), + [0x00, 0x03, 0x9d, 0x02, 0x01] + ); + } + + #[test] + fn only_a_changed_stand_byte_tells_the_character() { + let ghost = 0x0100_0000; + assert_eq!( + stand_state_changed(ghost | 1, ghost), + Some(build_stand_state_update(UnitStandState::Stand)) + ); + assert_eq!( + stand_state_changed(0, 3), + Some(build_stand_state_update(UnitStandState::Sleep)) + ); + assert_eq!(stand_state_changed(1, ghost | 1), None); + } + + #[test] + fn player_bound_is_the_innkeeper_guid_then_the_area() { + assert_eq!( + raw_frame(build_player_bound(0xF130_0000_0000_1234, 87)), + [0x00, 0x0e, 0x58, 0x01, 0x34, 0x12, 0, 0, 0, 0, 0x30, 0xf1, 87, 0, 0, 0] + ); + } + #[test] fn char_create_response_maps_every_outcome() { assert_eq!( diff --git a/gateway/src/codec/entity/subscriptions.rs b/gateway/src/codec/entity/subscriptions.rs index 195c0a4b9..cad275a80 100644 --- a/gateway/src/codec/entity/subscriptions.rs +++ b/gateway/src/codec/entity/subscriptions.rs @@ -412,6 +412,11 @@ pub(crate) fn relay_entity_update( &view.auras.on_target(shard, new.guid), ), ); + if let Some(update) = codec::stand_state_changed(old.unit_bytes_1, new.unit_bytes_1) { + out.push(Outbound::One(ServerOpcodeMessage::SMSG_STANDSTATE_UPDATE( + update, + ))); + } if let Some(is_ghost) = ghost_transition(old.player_flags, new.player_flags, GHOST_PLAYER_FLAG) { @@ -802,7 +807,7 @@ crate::stdb::relays::declare_relays! { insert(Snapshot) => |context, _event, row| entity_appeared(&context.view, context.shard, row); update(Snapshot) => |context, _event, old, new| { entity_changed(&context.view, context.shard, old, new); - zone_crossed(&context.view, &context.coord, context.shard, new.guid, old.zone_id, new.zone_id); + zone_crossed(&context.view, &context.coord, context.shard, new.guid, new.map_id, old.zone_id, new.zone_id); }; delete(Snapshot) => |context, _event, row| entity_vanished(&context.view, context.shard, row.guid, row.owner_guid); } diff --git a/gateway/src/codec/entity/subscriptions/packet_tests.rs b/gateway/src/codec/entity/subscriptions/packet_tests.rs index 0fe9d0a1a..1a44cec0b 100644 --- a/gateway/src/codec/entity/subscriptions/packet_tests.rs +++ b/gateway/src/codec/entity/subscriptions/packet_tests.rs @@ -241,6 +241,27 @@ fn levelup_is_exclusive_with_field_diffs() { assert_eq!(entity_update_to_outbound(&old, &new).len(), 1); } +#[test] +fn a_stand_state_change_relays_unit_bytes_1_to_every_viewer() { + let old = player_entity(); + let mut new = old.clone(); + new.unit_bytes_1 = (old.unit_bytes_1 & !0xFF) | 1; + let out = entity_update_to_outbound(&old, &new); + let [ServerOpcodeMessage::SMSG_UPDATE_OBJECT(update)] = out.as_slice() else { + panic!("expected one VALUES update, got {out:?}"); + }; + let mut wire = Vec::new(); + wow_world_messages::vanilla::ServerMessage::write_unencrypted_server(&**update, &mut wire) + .unwrap(); + let values = lyracore_shared::values_mask::parse_values_updates(&wire[4..]); + assert_eq!(values.len(), 1); + assert!( + values[0].fields.contains(&(138, new.unit_bytes_1)), + "UNIT_FIELD_BYTES_1 missing from {:?}", + values[0].fields + ); +} + #[test] fn an_afk_toggle_relays_player_flags_to_every_viewer() { let old = player_entity(); diff --git a/gateway/src/codec/weather/subscriptions.rs b/gateway/src/codec/weather/subscriptions.rs index 880b40ad2..37fbd9c7b 100644 --- a/gateway/src/codec/weather/subscriptions.rs +++ b/gateway/src/codec/weather/subscriptions.rs @@ -55,6 +55,7 @@ pub(crate) fn zone_crossed( store: &St, shard: ShardId, guid: u64, + map_id: u32, old_zone: u32, zone_id: u32, ) where @@ -71,11 +72,14 @@ pub(crate) fn zone_crossed( } let store = store.clone(); enqueue(viewer.clone(), move |_| { - crate::codec::weather::subscriptions::zone_weather_outbound( + let (opcode, body) = codec::build_init_world_states(map_id, zone_id); + let mut out = vec![Outbound::Raw { opcode, body }]; + out.extend(zone_weather_outbound( &store, zone_id, WeatherChangeType::Instant, - ) + )); + out }); } diff --git a/gateway/src/codec/weather/subscriptions/audience_tests.rs b/gateway/src/codec/weather/subscriptions/audience_tests.rs index 33abee428..167cb83c8 100644 --- a/gateway/src/codec/weather/subscriptions/audience_tests.rs +++ b/gateway/src/codec/weather/subscriptions/audience_tests.rs @@ -44,6 +44,22 @@ fn only_weather(out: Vec) -> wow_world_messages::vanilla::SMSG_WEATHER } } +fn crossing_weather(out: Vec, zone_id: u32) -> wow_world_messages::vanilla::SMSG_WEATHER { + match out.as_slice() { + [Outbound::Raw { opcode, body }, Outbound::One(ServerOpcodeMessage::SMSG_WEATHER(m))] => { + assert_eq!( + (*opcode, body.clone()), + crate::codec::build_init_world_states(0, zone_id) + ); + **m + } + other => panic!( + "expected world states then weather, got {} messages", + other.len() + ), + } +} + #[test] fn a_weather_row_reaches_its_own_zone_and_no_other() { let view = WorldView::new(true); @@ -97,16 +113,16 @@ fn crossing_into_a_zone_sends_its_weather_once() { view.add_viewer_on_shard(resident.clone(), anchor, 0); let store = CannedWeather(Some((WESTFALL, 3, 0.9))); - zone_crossed(&view, &store, 0, walker.self_guid, ELWYNN, WESTFALL); - let m = only_weather(queued_job(&walker_rx)); + zone_crossed(&view, &store, 0, walker.self_guid, 0, ELWYNN, WESTFALL); + let m = crossing_weather(queued_job(&walker_rx), WESTFALL); assert_eq!(m.weather_type, WeatherType::Storm); assert_eq!(m.grade, 0.9); assert_eq!(m.sound_id, 8558); assert_eq!(m.change, WeatherChangeType::Instant); - zone_crossed(&view, &store, 0, walker.self_guid, WESTFALL, WESTFALL); - zone_crossed(&view, &store, 0, walker.self_guid, WESTFALL, 0); - zone_crossed(&view, &store, 0, walker.self_guid, 0, WESTFALL); + zone_crossed(&view, &store, 0, walker.self_guid, 0, WESTFALL, WESTFALL); + zone_crossed(&view, &store, 0, walker.self_guid, 0, WESTFALL, 0); + zone_crossed(&view, &store, 0, walker.self_guid, 0, 0, WESTFALL); assert!( walker_rx.try_recv().is_err(), "staying inside a zone sends nothing, the unresolved zone is not a crossing, and a \ @@ -141,11 +157,12 @@ fn crossing_into_a_zone_with_no_weather_row_sends_fine_weather() { &CannedWeather(None), 0, walker.self_guid, + 0, ELWYNN, WESTFALL, ); - let m = only_weather(queued_job(&rx)); + let m = crossing_weather(queued_job(&rx), WESTFALL); assert_eq!(m.weather_type, WeatherType::Fine); assert_eq!(m.grade, 0.0); assert_eq!(m.sound_id, 0); diff --git a/gateway/src/stdb/store/character.rs b/gateway/src/stdb/store/character.rs index 6531f4a58..bcedfaac4 100644 --- a/gateway/src/stdb/store/character.rs +++ b/gateway/src/stdb/store/character.rs @@ -203,6 +203,25 @@ impl CharacterStore for Coordinator { .collect()) } + fn set_stand_state(&self, actor: Actor, state: u8) -> Result<()> { + call_reducer!( + self.0.call_pipe().conn.reducers, + "gw_set_stand_state", + gw_set_stand_state_then(self.session_actor(actor), state) + ) + } + + fn stand_state(&self, guid: u64) -> u8 { + self.0 + .coord() + .conn + .db + .game_world_entity() + .guid() + .find(&guid) + .map_or(0, |entity| (entity.unit_bytes_1 & 0xFF) as u8) + } + fn player_reputations(&self, player_guid: u64) -> Result> { let guard = self.0.coord(); let db = &guard.conn.db; diff --git a/gateway/src/world/benilla_tests.rs b/gateway/src/world/benilla_tests.rs index 6bca35392..c3bba8e98 100644 --- a/gateway/src/world/benilla_tests.rs +++ b/gateway/src/world/benilla_tests.rs @@ -1114,10 +1114,6 @@ fn benilla_missing_gameplay_returns_a_specific_refusal_without_disconnect() { ), (opcode::CMSG_UNLEARN_SKILL, 164u32.to_le_bytes().to_vec()), (opcode::CMSG_SET_AMMO, messages::set_ammo(2512)), - ( - opcode::CMSG_STANDSTATECHANGE, - messages::stand_state_change(1), - ), (opcode::CMSG_MOUNTSPECIAL_ANIM, vec![]), (opcode::CMSG_TOGGLE_PVP, vec![]), (opcode::CMSG_TOGGLE_HELM, vec![]), @@ -1192,10 +1188,6 @@ fn benilla_page_queries_and_explicit_purchase_slots_report_missing_support() { fn benilla_automatic_requests_announce_missing_features_once_per_world_session() { let (mut client, gateway) = client_in_world(gameplay_store(GameplayState::default())); for (opcode, body) in [ - ( - opcode::CMSG_STANDSTATECHANGE, - messages::stand_state_change(1), - ), ( opcode::CMSG_SET_ACTIONBAR_TOGGLES, messages::set_actionbar_toggles(3), @@ -1216,6 +1208,96 @@ fn benilla_automatic_requests_announce_missing_features_once_per_world_session() gateway.join().unwrap().unwrap(); } +#[test] +fn benilla_sitting_down_reaches_the_store() { + let store = gameplay_store(GameplayState::default()); + let (mut client, gateway) = client_in_world(store.clone()); + client.send( + opcode::CMSG_STANDSTATECHANGE, + &messages::stand_state_change(1), + ); + client.ping(1); + assert_eq!(*store.character.stand_states.lock().unwrap(), [(1, 1)]); + client.logout(); + drop(client); + gateway.join().unwrap().unwrap(); +} + +#[test] +fn benilla_requests_without_a_safe_reader_are_dropped_without_disconnect() { + let (mut client, gateway) = client_in_world(gameplay_store(GameplayState::default())); + let mut hostile_account_data = 0u32.to_le_bytes().to_vec(); + hostile_account_data.extend_from_slice(&u32::MAX.to_le_bytes()); + hostile_account_data.extend_from_slice(b"not zlib"); + for (opcode, body) in [ + (0x020b, hostile_account_data), // CMSG_UPDATE_ACCOUNT_DATA + (0x01f4, 0xffff_u32.to_le_bytes().to_vec()), // CMSG_ZONEUPDATE, an unnamed area + (0x000a, vec![]), // CMSG_ZONE_MAP + (0x0020, vec![]), // CMSG_UNDRESSPLAYER + (0x022f, vec![]), // CMSG_GM_REQUEST_PLAYER_INFO + (0x0331, vec![]), // CMSG_CHAT_FILTERED + (0x0334, vec![]), // CMSG_LOTTERY_QUERY_OBSOLETE + ] { + client.send(opcode, &body); + client.ping(u32::from(opcode)); + } + client.logout(); + drop(client); + gateway.join().unwrap().unwrap(); +} + +#[test] +fn benilla_a_second_login_in_the_same_world_session_fails_without_disconnect() { + let (mut client, gateway) = client_in_world(gameplay_store(GameplayState::default())); + client.send(opcode::CMSG_PLAYER_LOGIN, &messages::full_guid(1)); + assert!(matches!( + client.recv(), + ServerPacket::CharacterLoginFailed { result: 1 } + )); + client.ping(1); + client.logout(); + drop(client); + gateway.join().unwrap().unwrap(); +} + +#[test] +fn benilla_a_first_login_plays_the_races_opening_cinematic() { + let mut store = gameplay_store(GameplayState::default()); + Arc::get_mut(&mut store).unwrap().characters[0].first_login = true; + let (socket, gateway_socket) = world_session_socket_pair(); + let gateway = std::thread::spawn(move || run_world_session(gateway_socket, store)); + let mut client = Client::connect(socket, super::K); + client.enter_world(); + client.until("the opening cinematic", |packet| { + matches!(packet, ServerPacket::TriggerCinematic { cinematic_id: 81 }) + }); + client.logout(); + drop(client); + gateway.join().unwrap().unwrap(); +} + +#[test] +fn benilla_a_later_login_plays_no_cinematic() { + let (socket, gateway_socket) = world_session_socket_pair(); + let store = gameplay_store(GameplayState::default()); + let gateway = std::thread::spawn(move || run_world_session(gateway_socket, store)); + let mut client = Client::connect(socket, super::K); + client.enter_world(); + client.send(opcode::CMSG_QUERY_TIME, &messages::query_time()); + loop { + match client.recv() { + ServerPacket::QueryTimeResponse { .. } => break, + ServerPacket::TriggerCinematic { cinematic_id } => { + panic!("a later login played cinematic {cinematic_id}") + } + _ => {} + } + } + client.logout(); + drop(client); + gateway.join().unwrap().unwrap(); +} + #[test] fn benilla_connection_receipts_and_forced_logout_keep_character_selection_usable() { let (mut client, gateway) = client_in_world(gameplay_store(GameplayState::default())); @@ -1264,6 +1346,11 @@ fn benilla_bind_and_respec_confirmations_reach_existing_gameplay() { .home_bound .load(std::sync::atomic::Ordering::SeqCst)); client.send(opcode::CMSG_BINDER_ACTIVATE, &messages::binder_activate(99)); + let area = store.characters[0].zone_id; + assert!(matches!( + client.recv(), + ServerPacket::PlayerBound { binder: 99, area: bound } if bound == area + )); assert!(matches!(client.recv(), ServerPacket::GossipComplete)); assert!(store .npc diff --git a/gateway/src/world/chat_flood.rs b/gateway/src/world/chat_flood.rs index bac41a1bf..c30ed5deb 100644 --- a/gateway/src/world/chat_flood.rs +++ b/gateway/src/world/chat_flood.rs @@ -28,7 +28,9 @@ fn rule(msg: &ClientOpcodeMessage) -> FloodRule { FloodRule::Counted } } - ClientOpcodeMessage::CMSG_TEXT_EMOTE(_) => FloodRule::RefusedWhileMuted, + ClientOpcodeMessage::CMSG_TEXT_EMOTE(_) | ClientOpcodeMessage::CMSG_EMOTE(_) => { + FloodRule::RefusedWhileMuted + } _ => FloodRule::Free, } } diff --git a/gateway/src/world/entry.rs b/gateway/src/world/entry.rs index 4d24b1d7f..340714dec 100644 --- a/gateway/src/world/entry.rs +++ b/gateway/src/world/entry.rs @@ -8,7 +8,12 @@ pub(super) fn login( character: Actor, ) -> Result<()> { if conn.session_claim.is_some() { - return Err(anyhow!("ACCOUNT_IN_USE")); + return send( + tx, + Outbound::One(ServerOpcodeMessage::SMSG_CHARACTER_LOGIN_FAILED( + codec::build_character_login_failed(), + )), + ); } let character_guid = character.guid(); let token = store.claim_session(conn.account_id, character_guid)?; @@ -18,7 +23,26 @@ pub(super) fn login( } conn.route_home(character_guid)?; let home = conn.store.current(); - enter_world(tx, &*home, conn, character, codec::WorldEntry::FreshLogin) + // Read before world entry, which clears the first-login flag. + let cinematic = opening_cinematic(&*home, character_guid); + enter_world(tx, &*home, conn, character, codec::WorldEntry::FreshLogin)?; + if let Some(sequence) = cinematic { + let (opcode, body) = codec::build_trigger_cinematic(sequence); + send(tx, Outbound::Raw { opcode, body })?; + } + Ok(()) +} + +fn opening_cinematic(store: &St, character_guid: u64) -> Option { + match store.character_by_guid(character_guid) { + Ok(character) => character + .filter(|character| character.first_login) + .and_then(|character| codec::opening_cinematic(character.race)), + Err(error) => { + log::warn!("world: first-login read for guid {character_guid} failed: {error:#}"); + None + } + } } pub(super) fn world_port_ack( @@ -133,6 +157,8 @@ fn enter_world( WeatherChangeType::Instant, )); send(tx, Outbound::Batch(batch))?; + let (opcode, body) = codec::build_init_world_states(entity.map_id, entity.zone_id); + send(tx, Outbound::Raw { opcode, body })?; for (opcode, body) in items.iter().filter_map(codec::build_random_property_values) { send(tx, Outbound::Raw { opcode, body })?; } diff --git a/gateway/src/world/handlers/char.rs b/gateway/src/world/handlers/char.rs index e33920247..4c0992681 100644 --- a/gateway/src/world/handlers/char.rs +++ b/gateway/src/world/handlers/char.rs @@ -1,4 +1,5 @@ use super::super::*; +use super::trainer::settle_per_action; pub(crate) trait CharacterStore: Send + Sync { fn characters(&self, account_id: u64) -> Result>; @@ -36,11 +37,28 @@ pub(crate) trait CharacterStore: Send + Sync { fn player_reputations(&self, player_guid: u64) -> Result>; fn player_actions(&self, player_guid: u64) -> Result>; + + /// A state the client may not pick is a Refusal; any other `Err` is a Transport Loss. + fn set_stand_state(&self, actor: Actor, state: u8) -> Result<()>; + + /// 0 when the Character has no live entity. + fn stand_state(&self, guid: u64) -> u8; } pub(crate) struct Character; -impl ProtocolFamily +fn is_ignored(message: &ClientOpcodeMessage) -> bool { + match message { + ClientOpcodeMessage::CMSG_BUG(_) => {} + ClientOpcodeMessage::CMSG_GMSURVEY_SUBMIT(_) => {} + ClientOpcodeMessage::CMSG_WHOIS(_) => {} + ClientOpcodeMessage::CMSG_WARDEN_DATA(_) => {} + _ => return false, + } + true +} + +impl ProtocolFamily for Character { #[allow(clippy::too_many_lines)] @@ -50,7 +68,11 @@ impl ProtocolFami request: ProtocolRequest, ) -> Result { let mut reply = ProtocolReply::default(); - let response = match request.message()? { + let message = request.message()?; + if is_ignored(&message) { + return Ok(reply); + } + let response = match message { ClientOpcodeMessage::CMSG_PING(ping) => Some(ServerOpcodeMessage::SMSG_PONG( wow_world_messages::vanilla::SMSG_PONG { sequence_id: ping.sequence_id, @@ -118,11 +140,19 @@ impl ProtocolFami codec::build_char_delete_response(outcome), )) } + ClientOpcodeMessage::CMSG_CHAR_RENAME(_) => Some( + ServerOpcodeMessage::SMSG_CHAR_RENAME(Box::new(codec::build_char_rename_refusal())), + ), ClientOpcodeMessage::CMSG_PLAYER_LOGIN(request) => { - let character = Actor::new(request.guid.guid()) - .ok_or_else(|| anyhow!("CMSG_PLAYER_LOGIN names no Character"))?; - reply.after_queue = Some(WorldSessionAction::Login(character)); - None + match Actor::new(request.guid.guid()) { + Some(character) => { + reply.after_queue = Some(WorldSessionAction::Login(character)); + None + } + None => Some(ServerOpcodeMessage::SMSG_CHARACTER_LOGIN_FAILED( + codec::build_character_login_failed(), + )), + } } ClientOpcodeMessage::MSG_MOVE_WORLDPORT_ACK => { reply.after_queue = Some(WorldSessionAction::WorldPortAck); @@ -172,6 +202,48 @@ impl ProtocolFami } None } + ClientOpcodeMessage::CMSG_REQUEST_ACCOUNT_DATA(request) => { + if let Some((opcode, body)) = codec::build_empty_account_data(request.data_type) { + reply.outbound.push(Outbound::Raw { opcode, body }); + } + None + } + // Text emote 0 names no chat line, so observers see the animation only. + ClientOpcodeMessage::CMSG_EMOTE(request) => { + use wow_world_messages::vanilla::Emote; + if let (Some(actor), Emote::OneshotNone | Emote::OneshotWave) = + (session.actor(), request.emote) + { + settle_per_action( + "send_emote", + session.account_id, + store.send_emote(actor, 0, request.emote.as_int(), 0), + )?; + } + None + } + // The client applies its request at once, so a Refusal sends the stored state back. + ClientOpcodeMessage::CMSG_STANDSTATECHANGE(request) => match session.actor() { + Some(actor) => { + let requested = request.animation_state.as_int(); + match store.set_stand_state(actor, requested) { + Ok(()) => None, + Err(error) => { + settle_per_action("set_stand_state", session.account_id, Err(error))?; + wow_world_messages::vanilla::UnitStandState::try_from( + store.stand_state(actor.guid()), + ) + .ok() + .map(|stored| { + ServerOpcodeMessage::SMSG_STANDSTATE_UPDATE( + codec::build_stand_state_update(stored), + ) + }) + } + } + } + None => None, + }, message => return Err(anyhow!("Character received an unowned opcode: {message}")), }; if let Some(response) = response { @@ -180,3 +252,192 @@ impl ProtocolFami Ok(reply) } } + +/// Dropped before the protocol library reads them: it has no reader, or its reader is unsafe. +pub(crate) fn is_ignored_raw_request(opcode: u32) -> bool { + const CMSG_ZONE_MAP: u32 = 0x000a; + const CMSG_UNDRESSPLAYER: u32 = 0x0020; + // The Module resolves the zone from terrain; the reader rejects unnamed area ids. + const CMSG_ZONEUPDATE: u32 = 0x01f4; + // The reader unwraps the client's zlib stream and sizes a buffer from its length. + const CMSG_UPDATE_ACCOUNT_DATA: u32 = 0x020b; + const CMSG_GM_REQUEST_PLAYER_INFO: u32 = 0x022f; + const CMSG_CHAT_FILTERED: u32 = 0x0331; + const CMSG_LOTTERY_QUERY_OBSOLETE: u32 = 0x0334; + matches!( + opcode, + CMSG_ZONE_MAP + | CMSG_UNDRESSPLAYER + | CMSG_ZONEUPDATE + | CMSG_UPDATE_ACCOUNT_DATA + | CMSG_GM_REQUEST_PLAYER_INFO + | CMSG_CHAT_FILTERED + | CMSG_LOTTERY_QUERY_OBSOLETE + ) +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use crate::stdb::ReducerCallError; + use crate::world::test_support::{CharacterState, WorldFake}; + use wow_world_messages::vanilla::{ + Emote, Guid, UnitStandState, CMSG_BUG, CMSG_CHAR_RENAME, CMSG_EMOTE, CMSG_PLAYER_LOGIN, + CMSG_REQUEST_ACCOUNT_DATA, CMSG_STANDSTATECHANGE, CMSG_WARDEN_DATA, CMSG_WHOIS, + }; + + fn handle( + store: &WorldFake, + session: &mut ProtocolSession, + message: ClientOpcodeMessage, + ) -> ProtocolReply { + Character::handle(store, session, message.into()).unwrap() + } + + fn sit() -> ClientOpcodeMessage { + ClientOpcodeMessage::CMSG_STANDSTATECHANGE(CMSG_STANDSTATECHANGE { + animation_state: UnitStandState::Sit, + }) + } + + fn failing_stand_state(error: fn() -> anyhow::Error) -> WorldFake { + WorldFake { + character: CharacterState { + stand_state_error: Some(error), + ..Default::default() + }, + ..Default::default() + } + } + + #[test] + fn a_rename_is_refused_at_character_select() { + let store = WorldFake::default(); + let mut session = ProtocolSession::new(7, "TESTER".into()); + let reply = handle( + &store, + &mut session, + ClientOpcodeMessage::CMSG_CHAR_RENAME(Box::new(CMSG_CHAR_RENAME { + character: Guid::new(1), + new_name: "Renamed".into(), + })), + ); + assert!(matches!( + reply.outbound.as_slice(), + [Outbound::One(ServerOpcodeMessage::SMSG_CHAR_RENAME(answer))] + if **answer == codec::build_char_rename_refusal() + )); + } + + #[test] + fn a_login_that_names_no_character_fails_without_entering_the_world() { + let store = WorldFake::default(); + let mut session = ProtocolSession::new(7, "TESTER".into()); + let reply = handle( + &store, + &mut session, + ClientOpcodeMessage::CMSG_PLAYER_LOGIN(CMSG_PLAYER_LOGIN { guid: Guid::new(0) }), + ); + assert!(reply.after_queue.is_none()); + assert!(matches!( + reply.outbound.as_slice(), + [Outbound::One(ServerOpcodeMessage::SMSG_CHARACTER_LOGIN_FAILED(answer))] + if *answer == codec::build_character_login_failed() + )); + } + + #[test] + fn an_account_data_request_gets_the_empty_section() { + let store = WorldFake::default(); + let mut session = ProtocolSession::in_world(7, 1); + let request = |data_type| { + ClientOpcodeMessage::CMSG_REQUEST_ACCOUNT_DATA(CMSG_REQUEST_ACCOUNT_DATA { data_type }) + }; + let reply = handle(&store, &mut session, request(4)); + let (opcode, body) = codec::build_empty_account_data(4).unwrap(); + assert!(matches!( + reply.outbound.as_slice(), + [Outbound::Raw { opcode: sent, body: sent_body }] + if *sent == opcode && *sent_body == body + )); + assert!(handle(&store, &mut session, request(8)).outbound.is_empty()); + } + + #[test] + fn a_wave_plays_its_animation_without_a_text_line() { + let store = WorldFake::default(); + let mut session = ProtocolSession::in_world(7, 1); + for emote in [Emote::OneshotWave, Emote::OneshotNone, Emote::OneshotDance] { + let reply = handle( + &store, + &mut session, + ClientOpcodeMessage::CMSG_EMOTE(CMSG_EMOTE { emote }), + ); + assert!(reply.outbound.is_empty()); + } + assert_eq!(*store.speech.emotes.lock().unwrap(), [(0, 3, 0), (0, 0, 0)]); + } + + #[test] + fn sitting_down_is_stored_without_an_echo() { + let store = WorldFake::default(); + let mut session = ProtocolSession::in_world(7, 1); + let reply = handle(&store, &mut session, sit()); + assert_eq!(*store.character.stand_states.lock().unwrap(), [(1, 1)]); + assert!(reply.outbound.is_empty()); + } + + #[test] + fn a_refused_stand_state_snaps_the_client_back() { + let mut store = failing_stand_state(|| { + ReducerCallError::refused("gw_set_stand_state", "invalid stand state 2").into() + }); + store.character.stored_stand_state = 8; + let mut session = ProtocolSession::in_world(7, 1); + let reply = handle(&store, &mut session, sit()); + assert!(matches!( + reply.outbound.as_slice(), + [Outbound::One(ServerOpcodeMessage::SMSG_STANDSTATE_UPDATE(update))] + if update.state == UnitStandState::Kneel + )); + } + + #[test] + fn a_lost_stand_state_request_ends_the_world_session() { + let store = + failing_stand_state(|| ReducerCallError::transport_lost("gw_set_stand_state").into()); + let mut session = ProtocolSession::in_world(7, 1); + assert!(Character::handle(&store, &mut session, sit().into()).is_err()); + } + + #[test] + fn a_stand_state_before_world_entry_is_not_stored() { + let store = WorldFake::default(); + let mut session = ProtocolSession::new(7, "TESTER".into()); + assert!(handle(&store, &mut session, sit()).outbound.is_empty()); + assert!(store.character.stand_states.lock().unwrap().is_empty()); + } + + #[test] + fn debug_gm_and_warden_requests_get_no_answer() { + let store = WorldFake::default(); + let mut session = ProtocolSession::in_world(7, 1); + for message in [ + ClientOpcodeMessage::CMSG_BUG(Box::new(CMSG_BUG { + suggestion: 0, + content: "bug".into(), + bug_type: "type".into(), + })), + ClientOpcodeMessage::CMSG_WHOIS(Box::new(CMSG_WHOIS { + character: "Tester".into(), + })), + ClientOpcodeMessage::CMSG_WARDEN_DATA(Box::new(CMSG_WARDEN_DATA { + encrypted_data: vec![1, 2, 3], + })), + ] { + let reply = handle(&store, &mut session, message); + assert!(reply.outbound.is_empty()); + assert!(reply.after_queue.is_none()); + } + } +} diff --git a/gateway/src/world/handlers/mod.rs b/gateway/src/world/handlers/mod.rs index 4b2889ab1..03b1fbd43 100644 --- a/gateway/src/world/handlers/mod.rs +++ b/gateway/src/world/handlers/mod.rs @@ -44,7 +44,7 @@ pub(crate) use channel::{resolve_online_character, ResolvedTarget}; pub(crate) use channel::{ Channel, ChannelActionStore, ChannelOutcome, ChannelRequest, ChannelRoster, }; -pub(crate) use char::{Character, CharacterStore}; +pub(crate) use char::{is_ignored_raw_request, Character, CharacterStore}; pub(crate) use chat::{ Chat, ChatActionStore, ChatOutcome, RealmChatRequest, SpeakerFacts, SpeechStore, WhisperRequest, WhisperTargetFacts, diff --git a/gateway/src/world/handlers/query.rs b/gateway/src/world/handlers/query.rs index f38d44a5b..2376fb8f4 100644 --- a/gateway/src/world/handlers/query.rs +++ b/gateway/src/world/handlers/query.rs @@ -144,12 +144,25 @@ impl< let Some(actor) = actor else { return Ok(outbound.into()); }; - if let InteractionOutcome::Refused(reason) = - store.bind_home(actor, request.guid.guid())? - { - outbound.push(Outbound::One(ServerOpcodeMessage::SMSG_MESSAGECHAT( - Box::new(codec::build_gm_system_message(reason)), - ))); + let innkeeper_guid = request.guid.guid(); + match store.bind_home(actor, innkeeper_guid)? { + InteractionOutcome::Refused(reason) => { + outbound.push(Outbound::One(ServerOpcodeMessage::SMSG_MESSAGECHAT( + Box::new(codec::build_gm_system_message(reason)), + ))); + } + InteractionOutcome::Done => match store.character_by_guid(actor.guid()) { + Ok(Some(character)) => { + let (opcode, body) = + codec::build_player_bound(innkeeper_guid, character.zone_id); + outbound.push(Outbound::Raw { opcode, body }); + } + Ok(None) => {} + Err(error) => log::warn!( + "world: bound-home read for guid {} failed: {error:#}", + actor.guid() + ), + }, } outbound.push(Outbound::One(ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE)); } diff --git a/gateway/src/world/handlers/unavailable.rs b/gateway/src/world/handlers/unavailable.rs index bd092ee6c..35334ab3f 100644 --- a/gateway/src/world/handlers/unavailable.rs +++ b/gateway/src/world/handlers/unavailable.rs @@ -9,7 +9,6 @@ fn word(opcode: u16, value: u32) -> Outbound { #[derive(Clone, Copy, PartialEq, Eq, Hash)] pub(crate) enum UnavailableNotice { - StandState, ActionBar, Tutorial, } @@ -19,9 +18,6 @@ pub(crate) fn unavailable_outbound( notices: &mut std::collections::HashSet, ) -> Option> { let notice = match msg { - ClientOpcodeMessage::CMSG_STANDSTATECHANGE(_) => { - Some((UnavailableNotice::StandState, "Stand-state changes")) - } ClientOpcodeMessage::CMSG_SET_ACTIONBAR_TOGGLES(_) => Some(( UnavailableNotice::ActionBar, "Action bar visibility preferences", diff --git a/gateway/src/world/mod.rs b/gateway/src/world/mod.rs index 3cce7f556..01bad5fa1 100644 --- a/gateway/src/world/mod.rs +++ b/gateway/src/world/mod.rs @@ -781,7 +781,9 @@ fn run_world_session_with_queue_and_deadline( )?; continue; } - if handlers::is_silent_movement_request(hdr.opcode) { + if handlers::is_silent_movement_request(hdr.opcode) + || handlers::is_ignored_raw_request(hdr.opcode) + { continue; } if let Some(replies) = handlers::raw_unavailable_outbound(hdr.opcode, &body)? { diff --git a/gateway/src/world/routing.rs b/gateway/src/world/routing.rs index dbc97b881..7cfed2213 100644 --- a/gateway/src/world/routing.rs +++ b/gateway/src/world/routing.rs @@ -30,6 +30,14 @@ protocol_families! { CMSG_LOGOUT_REQUEST, CMSG_PLAYER_LOGOUT, CMSG_PLAYED_TIME, + CMSG_CHAR_RENAME, + CMSG_REQUEST_ACCOUNT_DATA, + CMSG_EMOTE, + CMSG_STANDSTATECHANGE, + CMSG_BUG, + CMSG_GMSURVEY_SUBMIT, + CMSG_WHOIS, + CMSG_WARDEN_DATA, ], Melee => [ CMSG_ATTACKSWING, diff --git a/gateway/src/world/session_tests.rs b/gateway/src/world/session_tests.rs index ac3e9e317..489a4ccd2 100644 --- a/gateway/src/world/session_tests.rs +++ b/gateway/src/world/session_tests.rs @@ -637,6 +637,17 @@ fn player_login_emits_sequence_then_self_create() { tags.push("weather"); weather = Some(*m); } + ServerOpcodeMessage::SMSG_INIT_WORLD_STATES(m) => { + tags.push("world_states"); + assert_eq!( + (m.map, m.area), + ( + Map::EasternKingdoms, + wow_world_messages::vanilla::Area::ElwynnForest + ) + ); + assert!(m.states.is_empty()); + } other => panic!("unexpected message in login sequence: {other}"), } } @@ -657,6 +668,7 @@ fn player_login_emits_sequence_then_self_create() { "bindpoint", "update_object", "weather", + "world_states", ], ); assert_eq!(create_guid, Some(1)); diff --git a/gateway/src/world/test_support/entry.rs b/gateway/src/world/test_support/entry.rs index ead0ccdb9..f680fa5f5 100644 --- a/gateway/src/world/test_support/entry.rs +++ b/gateway/src/world/test_support/entry.rs @@ -16,6 +16,13 @@ pub(crate) fn drain_world_entry( let ends_batch = matches!(message, ServerOpcodeMessage::SMSG_WEATHER(_)); frames.push(message); if ends_batch { + let world_states = ServerOpcodeMessage::read_encrypted(&mut *client, dec) + .unwrap_or_else(|e| panic!("world states did not follow the batch: {e}")); + assert!( + matches!(world_states, ServerOpcodeMessage::SMSG_INIT_WORLD_STATES(_)), + "expected SMSG_INIT_WORLD_STATES after the batch, got {world_states}" + ); + frames.push(world_states); return frames; } } diff --git a/gateway/src/world/test_support/world_fake/character.rs b/gateway/src/world/test_support/world_fake/character.rs index 1f8c63088..bdadf2850 100644 --- a/gateway/src/world/test_support/world_fake/character.rs +++ b/gateway/src/world/test_support/world_fake/character.rs @@ -11,6 +11,9 @@ pub(crate) struct CharacterState { pub(crate) player_actions: Vec<(u8, u32, u8)>, pub(crate) learned_spells: Vec, pub(crate) durable_absence_checks: std::sync::atomic::AtomicUsize, + pub(crate) stand_states: std::sync::Mutex>, + pub(crate) stand_state_error: Option anyhow::Error>, + pub(crate) stored_stand_state: u8, } impl CharacterStore for WorldFake { @@ -140,4 +143,20 @@ impl CharacterStore for WorldFake { fn player_actions(&self, _player_guid: u64) -> Result> { Ok(self.character.player_actions.clone()) } + + fn set_stand_state(&self, actor: Actor, state: u8) -> Result<()> { + if let Some(error) = self.character.stand_state_error { + return Err(error()); + } + self.character + .stand_states + .lock() + .unwrap() + .push((actor.guid(), state)); + Ok(()) + } + + fn stand_state(&self, _guid: u64) -> u8 { + self.character.stored_stand_state + } } diff --git a/gateway/src/world/test_support/world_fake/speech.rs b/gateway/src/world/test_support/world_fake/speech.rs index 1d6a7060f..9838fbe3e 100644 --- a/gateway/src/world/test_support/world_fake/speech.rs +++ b/gateway/src/world/test_support/world_fake/speech.rs @@ -10,6 +10,7 @@ pub(crate) struct SpeechState { pub(crate) gm_authority_results: std::sync::Mutex>, pub(crate) gm_gameplay_changes: std::sync::Mutex>, pub(crate) chats: std::sync::Mutex>, + pub(crate) emotes: std::sync::Mutex>, } impl SpeechStore for WorldFake { @@ -38,11 +39,16 @@ impl SpeechStore for WorldFake { fn send_emote( &self, _actor: Actor, - _text_emote: u32, - _emote_anim: u32, - _target_guid: u64, + text_emote: u32, + emote_anim: u32, + target_guid: u64, ) -> Result<()> { self.rec("send_emote"); + self.speech + .emotes + .lock() + .unwrap() + .push((text_emote, emote_anim, target_guid)); Ok(()) } diff --git a/gateway/src/world/trainer_tests.rs b/gateway/src/world/trainer_tests.rs index 6ec36d040..53138318b 100644 --- a/gateway/src/world/trainer_tests.rs +++ b/gateway/src/world/trainer_tests.rs @@ -93,6 +93,14 @@ impl CharacterStore for TrainerFake { fn player_actions(&self, _player_guid: u64) -> Result> { unimplemented!("player_actions") } + + fn set_stand_state(&self, _actor: Actor, _state: u8) -> Result<()> { + unimplemented!("set_stand_state") + } + + fn stand_state(&self, _guid: u64) -> u8 { + unimplemented!("stand_state") + } } impl TrainerStore for TrainerFake {