Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions docs/benilla-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| --- | --- | --- |
Expand All @@ -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. |
Expand Down
169 changes: 169 additions & 0 deletions gateway/src/codec/char.rs
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<u8>)> {
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<u32> {
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<u8>) {
(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<u8>) {
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<SMSG_STANDSTATE_UPDATE> {
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<u8>) {
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,
Expand All @@ -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<u8> {
let mut framed = Vec::new();
message.write_unencrypted_server(&mut framed).unwrap();
framed
}

fn raw_frame((opcode, body): (u16, Vec<u8>)) -> Vec<u8> {
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!(
Expand Down
7 changes: 6 additions & 1 deletion gateway/src/codec/entity/subscriptions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down Expand Up @@ -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);
}
Expand Down
21 changes: 21 additions & 0 deletions gateway/src/codec/entity/subscriptions/packet_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
8 changes: 6 additions & 2 deletions gateway/src/codec/weather/subscriptions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ pub(crate) fn zone_crossed<St>(
store: &St,
shard: ShardId,
guid: u64,
map_id: u32,
old_zone: u32,
zone_id: u32,
) where
Expand All @@ -71,11 +72,14 @@ pub(crate) fn zone_crossed<St>(
}
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
});
}

Expand Down
29 changes: 23 additions & 6 deletions gateway/src/codec/weather/subscriptions/audience_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,22 @@ fn only_weather(out: Vec<Outbound>) -> wow_world_messages::vanilla::SMSG_WEATHER
}
}

fn crossing_weather(out: Vec<Outbound>, 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);
Expand Down Expand Up @@ -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 \
Expand Down Expand Up @@ -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);
Expand Down
19 changes: 19 additions & 0 deletions gateway/src/stdb/store/character.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<(i32, i32, bool)>> {
let guard = self.0.coord();
let db = &guard.conn.db;
Expand Down
Loading
Loading