creatures keep their first corner, and characters never share files
- a creature walking a path no longer cuts its first corner: the path's first waypoint was dropped at decode (2342) - two characters whose names differ only in accented or non-Latin letters no longer share macros, bindings and SavedVariables; a file that failed to load is not overwritten at logout (2342) - custom chat channels survive a logout, a collapsed quest header keeps its quests watched, and a trackpad swipe scrolls as far as it adds up to (2342) - a hearth off a zeppelin no longer lands back on its deck, and an opened lockbox with loot left in it unlocks (2342) - every snapshot runs on Vulkan, in a container, before it ships (2344) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -560,6 +560,8 @@ pub(super) fn control(
|
||||
if !player.server_riding {
|
||||
movement_net::park_mover(&net.0 .0, &mut player);
|
||||
}
|
||||
// After the park, so a fear's ack carries the stopped word the park just reported.
|
||||
movement_net::ack_speeds_undriven(&net.0 .0, &player, &speed_acks);
|
||||
return;
|
||||
}
|
||||
// This frame's netted movement axes, the mouselook/turn modes they imply, and the autorun
|
||||
@@ -1182,22 +1184,8 @@ pub(super) fn control(
|
||||
// jump/fall lifecycle, and a ~500 ms heartbeat, each carrying the live `MovementInfo` (decisions
|
||||
// 0052 + 0053). vmangos relays it to nearby players, who extrapolate from the flags. See the
|
||||
// [`movement_net`] module (the outbound mirror of `net::motion`'s remote integration).
|
||||
// The rider's local pose for the wire's ON_TRANSPORT tail: `bevy_to_wow` is a pure basis
|
||||
// rotation, so the boat-local Bevy vector converts directly, and the local orientation is
|
||||
// `face_yaw − boat_yaw` (the GetAbsoluteFacing law in reverse), normalized like any wire
|
||||
// orientation.
|
||||
let wire_transport = player.ride.as_ref().map(|r| {
|
||||
let local = benilla_assets::coords::bevy_to_wow(r.local_pos);
|
||||
benilla_protocol::TransportPose {
|
||||
guid: r.guid,
|
||||
pos: benilla_protocol::wire::Vector3d {
|
||||
x: local[0],
|
||||
y: local[1],
|
||||
z: local[2],
|
||||
},
|
||||
orientation: (player.face_yaw - r.boat_yaw).rem_euclid(std::f32::consts::TAU),
|
||||
}
|
||||
});
|
||||
// The rider's local pose for the wire's ON_TRANSPORT tail.
|
||||
let wire_transport = movement_net::wire_transport(&player);
|
||||
// The skipped-time clock (decision 1935): a held frame is a frame of movement simulation
|
||||
// we advanced through without integrating, and the mover we name is the one we are
|
||||
// actually driving — a possessed unit while we hold its reins. Read out before the call
|
||||
|
||||
@@ -580,6 +580,64 @@ pub(super) fn park_mover(sender: &Sender<ClientCommand>, player: &mut Player) {
|
||||
player.last_facing = facing;
|
||||
}
|
||||
|
||||
/// The rider's boat-local pose for the wire's `ON_TRANSPORT` tail, or `None` off a deck.
|
||||
/// `bevy_to_wow` is a pure basis rotation, so the boat-local Bevy vector converts directly, and the
|
||||
/// local orientation is `face_yaw − boat_yaw` (the GetAbsoluteFacing law in reverse), normalized
|
||||
/// like any wire orientation.
|
||||
pub(super) fn wire_transport(player: &Player) -> Option<TransportPose> {
|
||||
player.ride.as_ref().map(|r| {
|
||||
let local = bevy_to_wow(r.local_pos);
|
||||
TransportPose {
|
||||
guid: r.guid,
|
||||
pos: benilla_protocol::wire::Vector3d {
|
||||
x: local[0],
|
||||
y: local[1],
|
||||
z: local[2],
|
||||
},
|
||||
orientation: (player.face_yaw - r.boat_yaw).rem_euclid(std::f32::consts::TAU),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// **The forced-speed acks owed on a frame the controller does not drive** — a server spline
|
||||
/// (`server_riding`), a fear (`control_lost`) or a mover hand-off (`reseat`). Those frames return
|
||||
/// before [`stream_self_movement`], which is where a controlled frame's acks go out, so until this
|
||||
/// the change was applied locally and never answered: vmangos holds an unacked change for
|
||||
/// `PendingAckResponseTime` (4 s) before enforcing it, counts it in `OnFailedToAckChange` (a kick
|
||||
/// under the default anticheat penalty) and blocks the graveyard repop while one is pending. The
|
||||
/// reference acks from its per-mover drain whoever is driving (`0x616142`/`0x61812d`).
|
||||
///
|
||||
/// The payload is the honest state we last reported: our streamed flags (a ride's deliberate
|
||||
/// FORWARD included) with the transport tail when we are on a deck, minus the airborne pair — no
|
||||
/// arc is being integrated on these frames, so there is no jump tail to send with them.
|
||||
pub(super) fn ack_speeds_undriven(
|
||||
sender: &Sender<ClientCommand>,
|
||||
player: &Player,
|
||||
acks: &[crate::net::SpeedChangeMessage],
|
||||
) {
|
||||
let transport = wire_transport(player);
|
||||
let mut flags =
|
||||
player.move_flags & OUTBOUND_FLAG_MASK & !(move_flags::FALLING | move_flags::FALLING_FAR);
|
||||
if transport.is_none() {
|
||||
flags &= !move_flags::ON_TRANSPORT; // flag and tail travel together
|
||||
}
|
||||
for ack in acks {
|
||||
let _ = sender.send(ClientCommand::ForceSpeedAck {
|
||||
kind: ack.kind,
|
||||
guid: ack.guid,
|
||||
counter: ack.counter,
|
||||
speed: ack.speed,
|
||||
flags,
|
||||
pos: bevy_to_wow(player.pos),
|
||||
orientation: player.face_yaw.rem_euclid(std::f32::consts::TAU),
|
||||
pitch: 0.0,
|
||||
fall_time: 0,
|
||||
jump: None,
|
||||
transport: transport.filter(|_| flags & move_flags::ON_TRANSPORT != 0),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// A frame that integrated everything it was given — the skipped-time report's no-op input,
|
||||
@@ -1440,6 +1498,73 @@ mod tests {
|
||||
assert_eq!(player.move_flags, 0, "bookkeeping is zeroed after parking");
|
||||
}
|
||||
|
||||
/// **A forced speed change is acked on a frame nobody is driving** — a fear, a server spline,
|
||||
/// a mover hand-off. Those frames return before the stream, which is where a controlled
|
||||
/// frame's acks go out, so the change was applied and never answered (vmangos enforces it 4 s
|
||||
/// late, counts the miss toward its anticheat kick, and blocks the graveyard repop meanwhile).
|
||||
/// The payload is the honest reported word: the ride's FORWARD kept, the airborne pair dropped
|
||||
/// (no arc tail exists here), and `ON_TRANSPORT` only with the tail that must travel with it.
|
||||
#[test]
|
||||
fn a_speed_change_is_acked_while_not_driving() {
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
let ack = crate::net::SpeedChangeMessage {
|
||||
guid: 7,
|
||||
kind: benilla_protocol::SpeedKind::Run,
|
||||
counter: 3,
|
||||
speed: 3.5,
|
||||
};
|
||||
|
||||
// A spline ride reporting FORWARD, off any deck, with a stale ON_TRANSPORT bit.
|
||||
let player = Player {
|
||||
move_flags: move_flags::FORWARD | move_flags::FALLING | move_flags::ON_TRANSPORT,
|
||||
face_yaw: -1.0,
|
||||
..Default::default()
|
||||
};
|
||||
ack_speeds_undriven(&tx, &player, &[ack]);
|
||||
let Ok(ClientCommand::ForceSpeedAck {
|
||||
counter,
|
||||
speed,
|
||||
flags,
|
||||
orientation,
|
||||
transport,
|
||||
jump,
|
||||
..
|
||||
}) = rx.try_recv()
|
||||
else {
|
||||
panic!("the change is acked");
|
||||
};
|
||||
assert_eq!((counter, speed), (3, 3.5), "the ack echoes the change");
|
||||
assert_eq!(
|
||||
flags,
|
||||
move_flags::FORWARD,
|
||||
"FORWARD kept; FALLING and a tailless ON_TRANSPORT dropped"
|
||||
);
|
||||
assert!(transport.is_none() && jump.is_none());
|
||||
assert!((0.0..TAU).contains(&orientation));
|
||||
assert!(rx.try_recv().is_err(), "one ack per change");
|
||||
|
||||
// On a deck: the transport bit rides with its tail.
|
||||
let player = Player {
|
||||
move_flags: move_flags::ON_TRANSPORT,
|
||||
ride: Some(super::super::state::PlayerRide {
|
||||
entity: bevy::ecs::entity::Entity::PLACEHOLDER,
|
||||
guid: 0x1F,
|
||||
local_pos: bevy::math::Vec3::ZERO,
|
||||
boat_yaw: 0.0,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
ack_speeds_undriven(&tx, &player, &[ack]);
|
||||
let Ok(ClientCommand::ForceSpeedAck {
|
||||
flags, transport, ..
|
||||
}) = rx.try_recv()
|
||||
else {
|
||||
panic!("the change is acked");
|
||||
};
|
||||
assert_eq!(flags, move_flags::ON_TRANSPORT);
|
||||
assert_eq!(transport.map(|t| t.guid), Some(0x1F));
|
||||
}
|
||||
|
||||
/// **A knockback launch acks, and sends no `MSG_MOVE_JUMP`** (decision 1702).
|
||||
///
|
||||
/// Two laws in one frame, and they are the same law read from both ends. `MSG_MOVE_JUMP` has a
|
||||
|
||||
@@ -299,23 +299,7 @@ pub(super) fn apply_server_moves(
|
||||
// Same-map teleport (the bridge only emits ours). Snap + echo the ack — without it the server
|
||||
// freezes our movement until relog.
|
||||
for t in teleports.read() {
|
||||
player.pos = wow_to_bevy(t.position);
|
||||
player.face_yaw = t.orientation;
|
||||
cam.yaw = t.orientation;
|
||||
// Stop any in-progress walk — server now sees us at the new spot.
|
||||
player.move_flags = 0;
|
||||
player.airborne_since = None; // a snap ends any in-progress jump arc (no phantom FALL_LAND)
|
||||
player.settling = true; // hold (gravity off) until the destination's ground/buildings load
|
||||
player.settle_since = time.elapsed_secs();
|
||||
player.settle_deadline = time.elapsed_secs() + SETTLE_TIMEOUT;
|
||||
// A far same-map teleport relocates us over ground that has not streamed either — the
|
||||
// tiles we were standing on are still the resident ones. See [`Player::world_stale`].
|
||||
player.world_stale = true;
|
||||
// The relocation voids any in-progress self server-ride (the taxi flight-end teleport
|
||||
// beats our own spline end by ~latency): `drive_self_ride` takes this flag next frame
|
||||
// and drops the ride instead of mirroring the stale flight pose back over this snap
|
||||
// (decision 0501 — the 4-yd hover + full-6s settle at every taxi landing).
|
||||
player.ride_abort = true;
|
||||
snap_near_teleport(player, &mut cam.yaw, t, time.elapsed_secs());
|
||||
// The echo goes now, and it is the WHOLE near-teleport handshake (decision 1340). The
|
||||
// real client echoes on the very next movement tick (wow-re: the 0xC7 drain applies the
|
||||
// snap, then `0x60e0a0` sends guid+counter+time) and sends nothing else — no
|
||||
@@ -523,6 +507,34 @@ pub(super) fn apply_server_moves(
|
||||
speed_acks
|
||||
}
|
||||
|
||||
/// **Apply one same-map teleport's snap** (`MSG_MOVE_TELEPORT_ACK` inbound) — everything but the
|
||||
/// echo, which the caller sends. `cam_yaw` is the fly-cam's yaw, carried to the new facing.
|
||||
fn snap_near_teleport(player: &mut Player, cam_yaw: &mut f32, t: &TeleportMessage, now: f32) {
|
||||
// A near teleport always arrives detached: vmangos takes this path only with no `m_transport`
|
||||
// (`Player::TeleportTo`, after `RemovePassenger`), and the reference's apply (`0x6186b0`)
|
||||
// re-parents to the packet's transport guid — zero here. A ride left standing let this same
|
||||
// frame's `ride::carry` compose the old deck pose back over the snap, and the airborne settle
|
||||
// kept it attached, so a hearth from a zeppelin or a lift landed us back on the deck.
|
||||
player.ride = None;
|
||||
player.pos = wow_to_bevy(t.position);
|
||||
player.face_yaw = t.orientation;
|
||||
*cam_yaw = t.orientation;
|
||||
// Stop any in-progress walk — server now sees us at the new spot.
|
||||
player.move_flags = 0;
|
||||
player.airborne_since = None; // a snap ends any in-progress jump arc (no phantom FALL_LAND)
|
||||
player.settling = true; // hold (gravity off) until the destination's ground/buildings load
|
||||
player.settle_since = now;
|
||||
player.settle_deadline = now + SETTLE_TIMEOUT;
|
||||
// A far same-map teleport relocates us over ground that has not streamed either — the tiles we
|
||||
// were standing on are still the resident ones. See [`Player::world_stale`].
|
||||
player.world_stale = true;
|
||||
// The relocation voids any in-progress self server-ride (the taxi flight-end teleport beats our
|
||||
// own spline end by ~latency): `drive_self_ride` takes this flag next frame and drops the ride
|
||||
// instead of mirroring the stale flight pose back over this snap (decision 0501 — the 4-yd
|
||||
// hover + full-6s settle at every taxi landing).
|
||||
player.ride_abort = true;
|
||||
}
|
||||
|
||||
/// Merge a server-authored packet's `MOVEMENTFLAGS` into our own — the reference's masked merge
|
||||
/// (`0x618c30 @0x618deb`: `new = old ^ ((old ^ wire) & 0x75a07dff)`), not an assignment. Pure, so
|
||||
/// the omission that actually bites is pinned by test: `ON_TRANSPORT` sits **outside** the mask, so
|
||||
@@ -983,3 +995,38 @@ mod control_tests {
|
||||
assert_eq!(control_verdict(ME, true, None), ControlVerdict::Granted(ME));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod teleport_tests {
|
||||
use super::*;
|
||||
|
||||
/// **A near teleport lands us where the server says, not back on the deck.** Hearthing off a
|
||||
/// zeppelin or a lift is a same-map teleport, and vmangos removes the passenger before it
|
||||
/// takes that path; the snap has to drop the ride too, or the same frame's platform carry
|
||||
/// recomposes the old deck pose over it.
|
||||
#[test]
|
||||
fn a_near_teleport_leaves_the_transport() {
|
||||
let mut player = Player {
|
||||
active: true,
|
||||
ride: Some(super::super::state::PlayerRide {
|
||||
entity: Entity::PLACEHOLDER,
|
||||
guid: 0x1F,
|
||||
local_pos: Vec3::new(1.0, 0.0, 2.0),
|
||||
boat_yaw: 0.5,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let mut cam_yaw = 0.0;
|
||||
let t = TeleportMessage {
|
||||
guid: 0x45,
|
||||
counter: 1,
|
||||
position: [-8_913.0, -136.0, 81.0],
|
||||
orientation: 1.25,
|
||||
};
|
||||
snap_near_teleport(&mut player, &mut cam_yaw, &t, 10.0);
|
||||
assert!(player.ride.is_none(), "the snap deboards");
|
||||
assert_eq!(player.pos, wow_to_bevy(t.position));
|
||||
assert_eq!((player.face_yaw, cam_yaw), (1.25, 1.25));
|
||||
assert!(player.settling && player.ride_abort && player.world_stale);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user