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:
Sam
2026-09-23 12:19:22 +07:00
co-authored by Claude Opus 5.5
parent 9c49f10882
commit 293b8e57fb
60 changed files with 1876 additions and 425 deletions
@@ -92,18 +92,14 @@ pub(super) fn read_monster_move(r: &mut &[u8], on_transport: bool) -> io::Result
let duration_ms = read_u32_le(r)?;
let flying = spline_flags & SPLINE_FLAG_FLYING != 0;
let run_mode = spline_flags & SPLINE_FLAG_RUNMODE != 0;
// The decoded waypoints *after* the start (see [`read_monster_move_spline`]): a ground path's
// first packed point re-encodes `start` (quantized), so drop it and anchor the path with the
// exact wire `start`; a flying path sends only post-start points, kept whole. The result is the
// full travel-order polyline `[start, …waypoints…, endpoint]`.
// The decoded waypoints *after* the start (see [`read_monster_move_spline`]) — both layouts
// ship only post-start points, so the wire `start` anchors the full travel-order polyline
// `[start, …waypoints…, endpoint]`.
let tail = read_monster_move_spline(r, flying)?;
let path = if tail.is_empty() {
Vec::new()
} else {
let skip = usize::from(!flying && tail.len() >= 2);
std::iter::once(start)
.chain(tail.into_iter().skip(skip))
.collect()
std::iter::once(start).chain(tail).collect()
};
ServerPacket::MonsterMove {
guid,
@@ -124,18 +120,22 @@ pub(super) fn read_monster_move(r: &mut &[u8], on_transport: bool) -> io::Result
/// travel order (the point *after* the start … the endpoint). Two wire layouts, keyed by `catmull_rom`
/// (the `Mask_CatmullRom` = `Flying` spline flag), both from vmangos `PacketBuilder`:
///
/// - **Ground (linear, `WriteLinearPath`):** a `u32` count, then the endpoint as an absolute `Vector3d`,
/// then `count − 1` packed `i32` offsets — `endpoint − waypoint` per point (see [`packed_to_vector3d`]),
/// in travel order. We invert each (`waypoint = endpoint − offset`) and append the endpoint, so the
/// returned list is `[waypoint₀ ≈ start, waypoint₁, …, endpoint]`. `waypoint₀` re-encodes the packet's
/// `start` (¼-yd quantized); the caller drops it in favour of the exact `start`.
/// **`count == 2` carries no offsets at all**: vmangos guards the offset loop with `last_idx > 1`
/// (`packet_builder.cpp:92`) while still writing `last_idx + 1` as the count, so a plain two-point
/// `MoveTo` announces 2 points and ships only the destination. Reading the phantom offset anyway
/// over-runs the body and the whole packet is skipped — i.e. that creature stops dead until its next
/// move, the same freeze decision 0708 is about. Never observed on this deploy (its pathfinder emits
/// 3+ point paths, verified over four live runs: no `nodes=2` in any `csp`/`mmv` trace), but a map
/// without mmaps would hit it on every step.
/// - **Ground (linear, `WriteLinearPath`):** a `u32` count of the points **after** the start, then the
/// endpoint as an absolute `Vector3d`, then `count − 1` packed `i32` offsets — `endpoint − waypoint`
/// for each intermediate waypoint (see [`packed_to_vector3d`]), in travel order. We invert each
/// (`waypoint = endpoint − offset`) and append the endpoint, so the returned list is
/// `[waypoint₁, …, endpoint]`; the start is the packet head's.
/// **The start is not among the offsets.** vmangos calls the builder with `firstPoint = 1`
/// (`packet_builder.h:34`'s default, `MoveSplineInit.cpp:169`) over a spline laid out
/// `[phantom, c₀ … cₙ₋₁, cₙ₋₁]` — the Catmull-Rom initializer even for a linear path
/// (`spline.cpp:52`) — so `last_idx = n − 1`, the count is `n − 1`, and the offset loop runs
/// `c₁ … cₙ₋₂`; the head's start is `getPoint(first())` = `c₀`. The reference decoder
/// (`0x6018f0`, wow-re `net/scratch/rf49-inbound-wire-parses.md` rows 9–11) reads the same
/// `count − 1` intermediates, and its curve is `[start, points…]` (`curvemath/…/rf52-curve-construction.md`).
/// Until the sweep that found it, this read assumed `firstPoint = 0` (0097, 0708): it treated the
/// first offset as a re-encoded start and dropped it — every ground path lost its first corner —
/// and read none at `count == 2`, leaving the one real waypoint unread (the decode-length
/// instrument's `SMSG_MONSTER_MOVE … left 4 trailing byte(s)` on a live run).
/// - **Flying (Catmull-Rom, `WriteCatmullRomPath`):** a `u32` count, then `count` **absolute** `Vector3d`s
/// (the control points from `getPoint(2)` on — the post-start waypoints through the endpoint). Returned
/// verbatim. (Reading these as packed offsets — the old single-layout path — mis-sized the body and
@@ -162,10 +162,9 @@ fn read_monster_move_spline(r: &mut &[u8], catmull_rom: bool) -> io::Result<Vec<
}
let endpoint = Vector3d::read(r)?;
let mut points = Vec::with_capacity(capacity_hint(count, 0xFFFF));
// `count == 2` ⇒ the producer skipped its offset loop entirely (`last_idx > 1`); the destination is
// the whole payload, and the caller pairs it with the packet's exact `start`.
let offsets = if count > 2 { count - 1 } else { 0 };
for _ in 0..offsets {
// `count == 1` ⇒ a straight hop: the producer's `last_idx > 1` guard skips the loop, and
// `count − 1` is already zero.
for _ in 1..count {
let off = packed_to_vector3d(read_i32_le(r)?);
points.push(Vector3d {
x: endpoint.x - off.x,
@@ -180,7 +179,7 @@ fn read_monster_move_spline(r: &mut &[u8], catmull_rom: bool) -> io::Result<Vec<
#[cfg(test)]
mod tests {
use super::*;
use crate::messages::{opcode, parse_server};
use crate::messages::{opcode, parse_server, parse_server_with_tail};
use crate::wire::write_packed_guid;
/// The fixed head of a `SMSG_MONSTER_MOVE`: packed guid, start pos, splineId, moveType.
@@ -195,22 +194,25 @@ mod tests {
b
}
/// The producer's linear encoder, transcribed from vmangos `PacketBuilder::WriteLinearPath`:
/// count = number of points, the **endpoint** written absolute, then each earlier point as a packed
/// `endpoint − point` offset in travel order. `path` is the full `[start, …mids…, endpoint]`.
/// The `last_idx > 1` guard is transcribed too: a **two**-point path writes the count and the
/// destination and stops — no offsets at all, not even for the start.
/// The producer's linear encoder, transcribed from vmangos `PacketBuilder::WriteLinearPath` as
/// `WriteMonsterMove` calls it (`firstPoint = 1`). `path` is the full `[c₀ = start, …, cₙ₋₁]`
/// (the head carries `c₀` separately); over the spline's `[phantom, c₀ … cₙ₋₁, cₙ₋₁]` layout
/// `last_idx = n − 1`, so it writes count `n − 1`, the **endpoint** absolute, then — under
/// `if (last_idx > 1)` — `c₁ … cₙ₋₂` as packed `endpoint − point` offsets. The start is never
/// among them.
fn append_linear_path(body: &mut Vec<u8>, path: &[[f32; 3]]) {
let (&endpoint, leading) = path.split_last().expect("a path has an endpoint");
body.extend_from_slice(&(path.len() as u32).to_le_bytes());
let last_idx = path.len() - 1;
body.extend_from_slice(&(last_idx as u32).to_le_bytes()); // last_idx − start + 1
for f in endpoint {
body.extend_from_slice(&f.to_le_bytes());
}
if path.len() <= 2 {
if last_idx <= 1 {
return; // vmangos `packet_builder.cpp:92` — `if (last_idx > 1)`
}
// `leading` = [start, …mids…]; each packed as endpoint − point (¼-yd quantized).
for &p in leading {
// `for (i = start; i < last_idx; ++i)` with `start = 1`: the intermediates, packed as
// endpoint − point (¼-yd quantized).
for &p in &leading[1..] {
let pack = |v: f32, shift: u32, mask: i32| ((v * 4.0).round() as i32 & mask) << shift;
let off = [endpoint[0] - p[0], endpoint[1] - p[1], endpoint[2] - p[2]];
let packed = pack(off[0], 0, 0x7FF) | pack(off[1], 11, 0x7FF) | pack(off[2], 22, 0x3FF);
@@ -314,17 +316,17 @@ mod tests {
}
}
/// The plain two-point hop — a `MoveTo` with no intermediate waypoints. vmangos announces `count = 2`
/// but ships **only** the destination (its offset loop is guarded by `last_idx > 1`), so a decoder
/// that trusts the count and reads `count − 1` offsets over-runs the body and the packet is skipped
/// entirely — the creature freezes where it stands. Encoded here by the transcribed producer above.
/// The plain two-point hop — a `MoveTo` with no intermediate waypoints: `count = 1`, the
/// destination, no offsets. Encoded here by the transcribed producer above.
#[test]
fn monster_move_two_point_path_carries_no_offsets() {
let mut body = head(0x77, [4.0, 8.0, 0.0], 0);
body.extend_from_slice(&0u32.to_le_bytes()); // spline flags: ground
body.extend_from_slice(&1_000u32.to_le_bytes()); // duration
append_linear_path(&mut body, &[[4.0, 8.0, 0.0], [12.0, 8.0, 0.0]]);
let p = parse_server(opcode::SMSG_MONSTER_MOVE, &body).expect("a two-point hop parses");
let (p, tail) = parse_server_with_tail(opcode::SMSG_MONSTER_MOVE, &body)
.expect("a two-point hop parses");
assert_eq!(tail, 0, "the body is exactly the count and the destination");
match p {
ServerPacket::MonsterMove { path, .. } => {
assert_eq!(path.len(), 2, "start + destination, got {path:?}");
@@ -334,12 +336,42 @@ mod tests {
}
}
/// **The one-corner path, byte for byte as vmangos writes it** — hand-assembled, not through the
/// transcribed encoder, so the two cannot share a misreading. A pathfinder route `c₀ → c₁ → c₂`
/// is `count = 2`, the endpoint `c₂`, and ONE packed offset `c₂ − c₁`. The read this replaced
/// took `count == 2` for an offset-free hop: it read nothing, cut the corner (`[c₀, c₂]`) and
/// left four bytes unread — the live run's `SMSG_MONSTER_MOVE … left 4 trailing byte(s)`.
#[test]
fn monster_move_one_corner_path_keeps_its_corner() {
let mut body = head(0x77, [0.0, 0.0, 0.0], 0); // c₀ in the head
body.extend_from_slice(&0u32.to_le_bytes()); // spline flags: ground
body.extend_from_slice(&2_000u32.to_le_bytes()); // duration
body.extend_from_slice(&2u32.to_le_bytes()); // count = last_idx − start + 1 = 2
for f in [10.0f32, 10.0, 0.0] {
body.extend_from_slice(&f.to_le_bytes()); // endpoint c₂
}
// c₂ − c₁ = (0, 10, 0): x 0, y 10·4 = 40 at bit 11, z 0 (`appendPackXYZ`'s ¼-yd fields).
body.extend_from_slice(&(40i32 << 11).to_le_bytes());
let (p, tail) =
parse_server_with_tail(opcode::SMSG_MONSTER_MOVE, &body).expect("a corner path parses");
assert_eq!(tail, 0, "the one offset is read, not left behind");
match p {
ServerPacket::MonsterMove { path, .. } => {
let got: Vec<[f32; 3]> = path.iter().map(|v| [v.x, v.y, v.z]).collect();
assert_eq!(
got,
vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [10.0, 10.0, 0.0]]
);
}
_ => panic!("expected MonsterMove"),
}
}
#[test]
fn monster_move_ground_path_decodes_every_waypoint() {
// A four-waypoint ground patrol, encoded exactly as vmangos `WriteLinearPath` ships it, must
// round-trip to the full travel-order polyline — not collapse to `start → endpoint`. Points are
// ¼-yd multiples so the packed quantization is exact. The start is anchored from the wire `start`
// (the redundant quantized first packed point is dropped).
// round-trip to the full travel-order polyline — not collapse to `start → endpoint`, and not
// lose its first corner. Points are ¼-yd multiples so the packed quantization is exact.
let want = [
[0.0f32, 0.0, 0.0], // start
[10.0, 0.0, 0.0], // corner east
+21 -3
View File
@@ -42,10 +42,12 @@ fn read_force_speed(kind: SpeedKind, r: &mut impl Read) -> io::Result<ServerPack
/// packet, but silence here is what cost days: an unhandled inner opcode has to be loud. Only the
/// `MSG_MOVE_*` relays vmangos routes through `ObjectViewersMovementDeliverer` can appear, and we
/// model all of them (`the_batch_carries_every_relayed_move_opcode`).
fn read_compressed_moves(r: &mut impl Read) -> io::Result<Vec<ServerPacket>> {
fn read_compressed_moves(r: &mut &[u8]) -> io::Result<Vec<ServerPacket>> {
let uncompressed = read_u32_le(r)? as usize;
let mut buf = Vec::with_capacity(uncompressed.min(64 * 1024));
flate2::read::ZlibDecoder::new(r).read_to_end(&mut buf)?;
// `bufread`, not `read`: see the compressed update object's arm — the cursor must stop at
// the stream's end for the tail to see what follows it.
flate2::bufread::ZlibDecoder::new(r).read_to_end(&mut buf)?;
let mut rest = buf.as_slice();
let mut packets = Vec::new();
while !rest.is_empty() {
@@ -353,7 +355,12 @@ fn parse_server_body(
// as unconsumed — 803 bytes on the decode-length instrument's first live login
// (2266 §B1). The tail that means drift here is the inflated stream's, reported
// through `inner_tail`.
let mut decoder = flate2::read::ZlibDecoder::new(&mut r);
//
// And through `bufread`, not `read` (2343): `read::ZlibDecoder` wraps its source in a
// 32 KiB buffer and fills it from the slice, so the outer cursor always ended at the
// BODY's end and a byte after the zlib stream could never show as a tail. The
// `bufread` decoder reads the slice as its own buffer and consumes exactly the stream.
let mut decoder = flate2::bufread::ZlibDecoder::new(&mut r);
let mut decompressed = Vec::new();
decoder.read_to_end(&mut decompressed)?;
drop(decoder);
@@ -1785,6 +1792,17 @@ mod tests {
parse_server_with_tail(opcode::SMSG_COMPRESSED_UPDATE_OBJECT, &body(&longer))
.expect("a trailing inflated byte is not a failure");
assert_eq!(tail, 1, "the inflated stream's leftover is the tail");
// A byte AFTER the zlib stream, in the packet body: the outer tail (2343). The `read`
// decoder's 32 KiB buffer swallowed it and this read 0.
let mut outer = body(&empty);
outer.extend([0xAB, 0xCD]);
let (_, tail) = parse_server_with_tail(opcode::SMSG_COMPRESSED_UPDATE_OBJECT, &outer)
.expect("bytes after the stream are not a failure");
assert_eq!(
tail, 2,
"the body's bytes after the zlib stream are the tail"
);
}
/// An unknown opcode consumes nothing by definition; reporting its whole body as a tail would