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
25 changes: 24 additions & 1 deletion crates/lyracore-shared/src/opcodes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,20 @@ pub mod movement {
pub const MSG_MOVE_START_TURN_LEFT: u32 = 0x0BC;
pub const MSG_MOVE_START_TURN_RIGHT: u32 = 0x0BD;
pub const MSG_MOVE_STOP_TURN: u32 = 0x0BE;
pub const MSG_MOVE_START_PITCH_UP: u32 = 0x0BF;
pub const MSG_MOVE_START_PITCH_DOWN: u32 = 0x0C0;
pub const MSG_MOVE_STOP_PITCH: u32 = 0x0C1;
pub const MSG_MOVE_SET_RUN_MODE: u32 = 0x0C2;
pub const MSG_MOVE_SET_WALK_MODE: u32 = 0x0C3;
pub const MSG_MOVE_FALL_LAND: u32 = 0x0C9;
pub const MSG_MOVE_START_SWIM: u32 = 0x0CA;
pub const MSG_MOVE_STOP_SWIM: u32 = 0x0CB;
pub const MSG_MOVE_SET_FACING: u32 = 0x0DA;
pub const MSG_MOVE_SET_PITCH: u32 = 0x0DB;
pub const MSG_MOVE_ROOT: u32 = 0x0EC;
pub const MSG_MOVE_UNROOT: u32 = 0x0ED;
pub const MSG_MOVE_HEARTBEAT: u32 = 0x0EE;
pub const MSG_MOVE_WATER_WALK: u32 = 0x2B1;

pub const SLICE_MOVE_OPCODES: &[u32] = &[
MSG_MOVE_START_FORWARD,
Expand All @@ -67,18 +74,30 @@ pub mod movement {
MSG_MOVE_START_TURN_LEFT,
MSG_MOVE_START_TURN_RIGHT,
MSG_MOVE_STOP_TURN,
MSG_MOVE_START_PITCH_UP,
MSG_MOVE_START_PITCH_DOWN,
MSG_MOVE_STOP_PITCH,
MSG_MOVE_SET_RUN_MODE,
MSG_MOVE_SET_WALK_MODE,
MSG_MOVE_FALL_LAND,
MSG_MOVE_START_SWIM,
MSG_MOVE_STOP_SWIM,
MSG_MOVE_SET_FACING,
MSG_MOVE_SET_PITCH,
MSG_MOVE_HEARTBEAT,
];

/// Observer relays recorded after a client acknowledgement; the client never sends them.
pub const ACKNOWLEDGED_MOVE_OPCODES: &[u32] =
&[MSG_MOVE_ROOT, MSG_MOVE_UNROOT, MSG_MOVE_WATER_WALK];

pub fn is_slice_move(opcode: u32) -> bool {
SLICE_MOVE_OPCODES.contains(&opcode)
}

pub fn is_relayed_move(opcode: u32) -> bool {
is_slice_move(opcode) || ACKNOWLEDGED_MOVE_OPCODES.contains(&opcode)
}
}

#[cfg(test)]
Expand All @@ -93,7 +112,11 @@ mod tests {
"0x{opcode:03X} should be a slice move opcode"
);
}
assert_eq!(movement::SLICE_MOVE_OPCODES.len(), 17);
assert_eq!(movement::SLICE_MOVE_OPCODES.len(), 21);
for &opcode in movement::ACKNOWLEDGED_MOVE_OPCODES {
assert!(movement::is_relayed_move(opcode));
assert!(!movement::is_slice_move(opcode));
}
assert!(!movement::is_slice_move(world::CMSG_PLAYER_LOGIN));
assert!(!movement::is_slice_move(world::SMSG_UPDATE_OBJECT as u32));
assert!(!movement::is_slice_move(world::CMSG_AUTH_SESSION));
Expand Down
60 changes: 49 additions & 11 deletions docs/benilla-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,21 +97,59 @@ enabled in the normal suite.

The real Module scenario also checks late-observer transport attachment. Speed,
root, hover, feather-fall, water-walk, knockback, teleport and spline completion
acknowledgements keep the World Session usable. This does not establish boat
simulation, transport membership or acknowledgement counter enforcement.
acknowledgements keep the World Session usable. The Gateway reads the speed,
root, knockback, hover, feather-fall and water-walk acknowledgements with the
build-5875 layout too, so a transport tail does not end the session. This does
not establish boat simulation, transport membership or acknowledgement counter
enforcement.

## Movement controls

Pitch changes (`MSG_MOVE_START_PITCH_UP`, `_DOWN`, `MSG_MOVE_STOP_PITCH` and
`MSG_MOVE_SET_PITCH`) relay to observers like other movement.

A stun, root or polymorph aura, and a dead body before release, root the
Character's own client with `SMSG_FORCE_MOVE_ROOT`. The last of them to end sends
`SMSG_FORCE_MOVE_UNROOT`. A ghost walks on water. World entry restores both
states. When the client acknowledges a root, unroot or water walk, observers
receive `MSG_MOVE_ROOT`, `MSG_MOVE_UNROOT` or `MSG_MOVE_WATER_WALK` with the
acknowledged pose. Observers see a creature's stun, root or polymorph as
`SMSG_SPLINE_MOVE_ROOT` and its end as `SMSG_SPLINE_MOVE_UNROOT`.

An acknowledgement that arrives after a same-map teleport and before the
client's `MSG_MOVE_TELEPORT_ACK` is not relayed. Its pose may be the old
position: a release acknowledges the unroot at the corpse.

Root, stun and polymorph follow `lyracore_shared::aura::holds_in_place`, the
rule the Module's movement gate also uses. The importer gives confuse, pacify
and charm the polymorph mechanic, so they root the client too. Telling them
apart needs a distinct mechanic in the imported spell data.

A slow also lowers backward running, through
`SMSG_FORCE_RUN_BACK_SPEED_CHANGE`, at world entry and in a peer's CREATE.
Observers receive another unit's run, backward run and swim speeds as
`SMSG_SPLINE_SET_*_SPEED`, players included. cmangos relays a player's
`MSG_MOVE_SET_*_SPEED` after the owner's acknowledgement instead. The motion
row holds only a MovementInfo body, so it cannot carry that relay's speed.

Cheat, Game Master and minigame movement requests and `CMSG_MOVE_FALL_RESET`
are ignored without decoding. They never move the Character.

### Root receipts

The client moves independently of the Module. The Gateway sends a root packet for a root, stun or
polymorph and sends an unroot packet only after the last hold ends. The Module drops steering
samples while held, but accepts stop receipts, turns and landings so fall damage still applies.

`gateway/src/world/mod.rs::root_receipt_stop` relays the current Character's root or unroot receipt
as a stop. Peers otherwise retain the last run. A root receipt clears steering flags so the Module
accepts the reported stop position. The pinned library's `clear_*` methods mask with
`reverse_bits`, so the Gateway clears the bits in the encoded bytes instead. The typed reader skips
the build-5875 transport tail; the encoder refuses that incomplete value, so receipts on a
transport are not relayed.
polymorph and sends an unroot packet only after the last hold ends. The library writes
`SMSG_FORCE_MOVE_ROOT` and `SMSG_SPLINE_MOVE_ROOT` with a full guid, which the client misreads, so
the Gateway writes the packed guid itself. The Module drops steering samples while held, but accepts
root receipts, stops, turns and landings so fall damage still applies.

`gateway/src/codec/movement.rs::acknowledged_move` turns the current Character's root, unroot and
water-walk receipts into `MSG_MOVE_ROOT`, `MSG_MOVE_UNROOT` and `MSG_MOVE_WATER_WALK` relays for
peers, as cmangos does. Peers otherwise retain the last run. A root receipt clears steering flags
so the Module accepts the reported position. The pinned library's `clear_*` methods mask with
`reverse_bits`, so the Gateway clears the bits in the encoded bytes instead. The Gateway reads the
receipts with the build-5875 layout, so receipts on a transport relay too. A receipt between a
same-map teleport and the client's `MSG_MOVE_TELEPORT_ACK` is not relayed.

Movement interruption uses client flags, including jumping, rather than a change in stored
position. A delayed stop can update the stored position after Shadowmeld begins without ending
Expand Down
17 changes: 16 additions & 1 deletion gateway/examples/benilla_inventory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,13 @@ fn row(
let dispatch = lyra_name.and_then(|name| lyra.dispatch.get(name));
let status = if direction == "gateway_to_client" {
"source_evidence_only"
} else if lyra_name.is_some_and(|name| lyra.ignored.contains(name)) {
} else if lyra_name.is_some_and(|name| lyra.ignored.contains(name))
// The reader drops these by number before decoding: `is_silent_movement_request`.
|| matches!(
opcode,
0x0008 | 0x0009 | 0x00c6 | 0x00d9 | 0x00e1 | 0x02ad | 0x02ca | 0x02f8
)
{
"explicitly_ignored"
} else if dispatch.is_some() {
"dispatch_pattern_present"
Expand Down Expand Up @@ -383,6 +389,15 @@ mod tests {
);
}

#[test]
fn requests_dropped_by_number_are_explicitly_ignored() {
let none = SourceScan::default();
for opcode in [0x0008, 0x02ca, 0x02f8] {
let row = row("client_to_gateway", opcode, &[], &none, &none);
assert_eq!(row["handling"], "explicitly_ignored", "0x{opcode:04X}");
}
}

#[test]
fn inventory_finds_movement_relay_selector_patterns() {
let syntax = syn::parse_file(
Expand Down
100 changes: 28 additions & 72 deletions gateway/src/codec/combat/subscriptions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ use spacetimedb_sdk::Table;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage;
use wow_world_messages::vanilla::{SMSG_FORCE_MOVE_ROOT, SMSG_FORCE_MOVE_UNROOT};

pub(crate) use lyracore_shared::spell::taxonomy::A_STEALTH;

Expand Down Expand Up @@ -440,9 +439,7 @@ pub(crate) fn aura_insert_outbound(
out.push(o);
}
if let Some(current) = current {
if let Some(o) = root_packet(current.iter().cloned(), row, self_guid) {
out.push(o);
}
out.extend(movement_outbound(coord, &current, row, self_guid));
if let Some(o) = swim_speed_packet(current.iter().cloned(), row, self_guid) {
out.push(o);
}
Expand Down Expand Up @@ -492,9 +489,7 @@ pub(crate) fn aura_update_outbound(
}
}
if let Some(current) = current {
if let Some(o) = root_packet(current.iter().cloned(), row, self_guid) {
out.push(o);
}
out.extend(movement_outbound(coord, &current, row, self_guid));
if let Some(o) = swim_speed_packet(current.iter().cloned(), row, self_guid) {
out.push(o);
}
Expand Down Expand Up @@ -529,9 +524,7 @@ pub(crate) fn aura_delete_outbound(
if visible {
let current = view.auras.on_target(shard, row.target_guid);
out.push(aura_sync(current.iter().cloned(), row.target_guid));
if let Some(o) = root_packet(current.iter().cloned(), row, self_guid) {
out.push(o);
}
out.extend(movement_outbound(coord, &current, row, self_guid));
if let Some(o) = swim_speed_packet(current.iter().cloned(), row, self_guid) {
out.push(o);
}
Expand Down Expand Up @@ -598,6 +591,16 @@ pub(crate) fn aura_snapshot_outbound(
return out;
}
out.push(aura_sync(current.iter().cloned(), target_guid));
let changed: Vec<&Aura> = current.iter().chain(previous).collect();
out.extend(
crate::codec::movement::subscriptions::aura_movement_from_cache(
coord,
&current,
&changed,
target_guid,
viewer.self_guid,
),
);
if target_guid != viewer.self_guid {
return out;
}
Expand All @@ -606,12 +609,6 @@ pub(crate) fn aura_snapshot_outbound(
.iter()
.filter_map(|row| aura_duration_packet(row, viewer.self_guid)),
);
out.extend(
current
.iter()
.chain(previous)
.find_map(|row| root_packet(current.iter().cloned(), row, viewer.self_guid)),
);
out.extend(
current
.iter()
Expand Down Expand Up @@ -701,6 +698,21 @@ pub(crate) fn aura_duration_packet(row: &Aura, self_guid: u64) -> Option<Outboun
))
}

fn movement_outbound(
coord: &Coordinator,
current: &[Aura],
changed: &Aura,
self_guid: u64,
) -> Vec<Outbound> {
crate::codec::movement::subscriptions::aura_movement_from_cache(
coord,
current,
&[changed],
changed.target_guid,
self_guid,
)
}

pub(crate) fn run_speed_packet(
auras: impl Iterator<Item = crate::stdb::bindings::Aura>,
changed: &Aura,
Expand Down Expand Up @@ -730,35 +742,6 @@ pub(crate) fn run_speed_packet(
))
}

/// The client moves independently; overlapping holds must not send an early unroot.
pub(crate) fn root_packet(
mut auras: impl Iterator<Item = crate::stdb::bindings::Aura>,
changed: &Aura,
self_guid: u64,
) -> Option<Outbound> {
if changed.target_guid != self_guid
|| !lyracore_shared::aura::holds_in_place(changed.eff_kind, changed.eff_p_0)
{
return None;
}
let guid = wow_world_messages::Guid::new(self_guid);
let held = auras.any(|aura| {
aura.target_guid == self_guid
&& lyracore_shared::aura::holds_in_place(aura.eff_kind, aura.eff_p_0)
});
Some(Outbound::One(if held {
ServerOpcodeMessage::SMSG_FORCE_MOVE_ROOT(Box::new(SMSG_FORCE_MOVE_ROOT {
guid,
counter: 0,
}))
} else {
ServerOpcodeMessage::SMSG_FORCE_MOVE_UNROOT(Box::new(SMSG_FORCE_MOVE_UNROOT {
guid,
counter: 0,
}))
}))
}

pub(crate) fn swim_speed_packet(
auras: impl Iterator<Item = crate::stdb::bindings::Aura>,
changed: &Aura,
Expand Down Expand Up @@ -1135,33 +1118,6 @@ pub(crate) mod tests {
assert!(aura_duration_packet(&expired, self_guid).is_none());
}

#[test]
fn root_packet_holds_the_owner_until_the_last_hold_ends() {
use lyracore_shared::spell::taxonomy::{A_CONTROL, M_FEAR, M_ROOT, M_STUN};
let guid = 1;
let root = aura(guid, A_CONTROL, M_ROOT, 0, 0, 8_000_000);
let stun = aura(guid, A_CONTROL, M_STUN, 0, 0, 4_000_000);
let rooted = |current: Vec<Aura>, changed: &Aura| match root_packet(
current.into_iter(),
changed,
guid,
) {
Some(Outbound::One(ServerOpcodeMessage::SMSG_FORCE_MOVE_ROOT(_))) => true,
Some(Outbound::One(ServerOpcodeMessage::SMSG_FORCE_MOVE_UNROOT(_))) => false,
_ => panic!("expected a root or unroot packet"),
};
assert!(rooted(vec![root.clone()], &root));
assert!(
rooted(vec![root.clone()], &stun),
"the root outlives the stun"
);
assert!(!rooted(vec![], &root));

let fear = aura(guid, A_CONTROL, M_FEAR, 0, 0, 8_000_000);
assert!(root_packet(vec![fear.clone()].into_iter(), &fear, guid).is_none());
assert!(root_packet(vec![root.clone()].into_iter(), &root, guid + 1).is_none());
}

#[test]
fn run_speed_packet_uses_the_strongest_move_bonus_on_self_only() {
let self_guid = 1u64;
Expand Down
Loading
Loading