- a rider does not loot: a corpse was looted from horseback, and the client-side mounted gate built for casts and attack-start had left interaction out of scope — `0x60bf98` reads the PLAYER's mount field and skips the loot leg into skin, a silent fall-through rather than a refusal; the stand-state gate (`ERR_LOOT_NOTSTANDING`), player bones, skin/insignia/key-turn (all through TryCast's 0x39 block, asked after the reagent check) and GameObjects (gated only when NO `Lock.dbc` row exists — nodes and herbs refuse loudly via their cast, signs and chairs in silence, the mailbox exempt) follow the RE; and the world right-click shows no attack-refusal text under any condition, since `0x5ecb70`'s extent holds no `DisplayError` at all — a correction to the earlier record; the cursor is untouched, so the pouch still lights from the saddle - 595 creature displays were dropping their blood spurt: the `UnitBloodLevels` row resolve has a THIRD tier — display `BloodLevel`, else model `BloodID`, else the records base (`0x60b117`'s branchless `setle/dec/and`: file row 0, RED) — and `BloodID == -1` (122 of 430 shipped models: crocolisks, quilboar, gnolls, murlocs, pigs, deer) is a tier-2 MISS, not a bloodless marker; replayed over the shipped tables here and in the wow-re round with the same numbers (36 · 9903 · 595 of 10534 displays); the ground-splat textures are read (four per row) and the RE verdict on their trigger is dead code — `"BloodSplat"` occurs nowhere in the image, though all twelve `.blp` ship - other swimmers tilt on arrival: `SMSG_UPDATE_OBJECT`'s LIVING block carried the mover's live flags and swim pitch and benilla threw both away (`let _pitch = read_f32_le(r)?;` — the director's own citation, exact), so a player who swam into view rendered LEVEL until their next relay packet, and an idle floater — who sends none while unmoving — stayed level as long as they floated; the create apply `0x619320` merges the block's flags under the same mask a relayed move uses and copies the pitch unconditionally, before the model is even built; one render law (`swim_body_rotation`) now serves both pose owners, the relay trace prints the pitch beside a guid that joins, and `WOW_PROBE_PITCH` can aim a scripted swimmer so a two-client probe of the lane can be written at last Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
739 lines
33 KiB
Rust
739 lines
33 KiB
Rust
//! `SMSG_UPDATE_OBJECT` / `SMSG_DESTROY_OBJECT` wire tests (mirrors `src/messages/update_object.rs`):
|
||
//! object creates (unit/gameobject/item/container), out-of-range removal, destroy, and the sparse
|
||
//! `ObjectFields` Values-update decode (unit state/power/customization, virtual items + sheath,
|
||
//! inventory slots). Split out of the former `tests/messages.rs` — see `tests/common` for the shared
|
||
//! fixtures and methodology note.
|
||
|
||
mod common;
|
||
|
||
use benilla_protocol::events::{decode, EntityKind, SessionEvent};
|
||
use benilla_protocol::messages;
|
||
use benilla_protocol::wire::write_packed_guid;
|
||
use common::hx;
|
||
|
||
#[test]
|
||
fn update_object_fixture_decodes() {
|
||
// A real serialized SMSG_UPDATE_OBJECT body: Unit create (guid 0xAA, displayid 50, scale 1.5, a
|
||
// living position) + GameObject create (guid 0xBB, displayid 99, scale 2.0, pos 100/200/300) +
|
||
// out-of-range [0x10, 0x20].
|
||
let body = hx("03000000000301aa03200000000000000000cdd70bc6357e04c3f90fa74200000040000000000000803f0000e040000090400000000000000000db0f4940051700000000000000000000000000000008000000aa00000000000000090000000000c03f320000000301bb05000117810700bb000000000000002100000000000040630000000000c84200004843000096430000803f040200000001100120");
|
||
let packet = messages::parse_server(messages::opcode::SMSG_UPDATE_OBJECT, &body).unwrap();
|
||
let events = decode(packet);
|
||
|
||
let unit = events
|
||
.iter()
|
||
.find_map(|e| match e {
|
||
SessionEvent::ObjectCreate {
|
||
guid: 0xAA,
|
||
kind,
|
||
display_id,
|
||
position,
|
||
scale,
|
||
speeds,
|
||
..
|
||
} => Some((*kind, *display_id, *position, *scale, *speeds)),
|
||
_ => None,
|
||
})
|
||
.expect("unit create");
|
||
assert_eq!(unit.0, EntityKind::Unit);
|
||
assert_eq!(unit.1, Some(50));
|
||
assert_eq!(unit.2, [-8949.95, -132.493, 83.5312]);
|
||
assert_eq!(unit.3, 1.5);
|
||
assert_eq!(unit.4.map(|s| s.walk), Some(1.0)); // speeds[0] = walk, from the LIVING block
|
||
|
||
let go = events
|
||
.iter()
|
||
.find_map(|e| match e {
|
||
SessionEvent::ObjectCreate {
|
||
guid: 0xBB,
|
||
kind,
|
||
display_id,
|
||
position,
|
||
scale,
|
||
speeds,
|
||
..
|
||
} => Some((*kind, *display_id, *position, *scale, *speeds)),
|
||
_ => None,
|
||
})
|
||
.expect("gameobject create");
|
||
assert_eq!(go.0, EntityKind::GameObject);
|
||
assert_eq!(go.1, Some(99));
|
||
assert_eq!(go.2, [100.0, 200.0, 300.0]);
|
||
assert_eq!(go.3, 2.0);
|
||
assert_eq!(go.4, None); // GameObject HAS_POSITION block carries no speeds
|
||
|
||
let removed = events
|
||
.iter()
|
||
.find_map(|e| match e {
|
||
SessionEvent::ObjectsRemoved(g) => Some(g.clone()),
|
||
_ => None,
|
||
})
|
||
.expect("out-of-range");
|
||
assert_eq!(removed, vec![0x10, 0x20]);
|
||
}
|
||
|
||
#[test]
|
||
fn destroy_object_decodes_to_object_destroyed() {
|
||
// SMSG_DESTROY_OBJECT: a plain u64 guid (vmangos `DestroyObject::AppendBodyTo` streams the raw
|
||
// ObjectGuid) — the corpse-decay / despawn teardown. Its own event, distinct from the OutOfRange
|
||
// update block: destroyed objects pop instantly, out-of-range ones fade.
|
||
let body = hx("efbeadde01000000");
|
||
let packet = messages::parse_server(messages::opcode::SMSG_DESTROY_OBJECT, &body).unwrap();
|
||
assert_eq!(packet.name(), "SMSG_DESTROY_OBJECT");
|
||
let destroyed = decode(packet)
|
||
.into_iter()
|
||
.find_map(|e| match e {
|
||
SessionEvent::ObjectDestroyed(guid) => Some(guid),
|
||
_ => None,
|
||
})
|
||
.expect("destroy");
|
||
assert_eq!(destroyed, 0x1_DEAD_BEEF);
|
||
}
|
||
|
||
#[test]
|
||
fn values_update_decodes_unit_state() {
|
||
// A hand-built Values-only update (update_type 0) for guid 0xAA carrying the unit descriptor
|
||
// fields HEALTH=22 (=100), MAXHEALTH=28 (=120), LEVEL=34 (=5), BYTES_0=36 (race 1/class 1/gender 0)
|
||
// — bits in 2 mask blocks, values in ascending field order. Verifies the `Object::Values` path
|
||
// (previously dropped) emits an ObjectValues delta whose fields decode via the named accessors.
|
||
let body = hx("01000000000001aa02000040101400000064000000780000000500000001010001");
|
||
let packet = messages::parse_server(messages::opcode::SMSG_UPDATE_OBJECT, &body).unwrap();
|
||
let fields = decode(packet)
|
||
.into_iter()
|
||
.find_map(|e| match e {
|
||
SessionEvent::ObjectValues { guid: 0xAA, fields } => Some(fields),
|
||
_ => None,
|
||
})
|
||
.expect("object values");
|
||
assert_eq!(
|
||
(
|
||
fields.unit_health(),
|
||
fields.unit_max_health(),
|
||
fields.unit_level(),
|
||
fields.unit_race(),
|
||
fields.unit_class(),
|
||
fields.unit_gender(),
|
||
),
|
||
(Some(100), Some(120), Some(5), Some(1), Some(1), Some(0))
|
||
);
|
||
// A non-player unit carries no PLAYER block, so the customization accessors stay `None`.
|
||
assert_eq!(fields.player_skin(), None);
|
||
assert_eq!(fields.player_facial_hair(), None);
|
||
}
|
||
|
||
#[test]
|
||
fn values_update_decodes_player_customization() {
|
||
// A Values-only update for player guid 0xBB carrying PLAYER_BYTES=193 (skin 0x10 / face 0x11 /
|
||
// hairStyle 0x12 / hairColor 0x13, packed b0..b3) and PLAYER_BYTES_2=194 (facialHair 0x14, b0).
|
||
// Field 193 lives in mask block 6 (bit 1), 194 in block 6 (bit 2) → 7 blocks, the first six empty.
|
||
// Pins the RF-0056 field indices + byte order against the decode.
|
||
let body = hx(
|
||
"01000000000001bb07000000000000000000000000000000000000000000000000060000001011121314000000",
|
||
);
|
||
let packet = messages::parse_server(messages::opcode::SMSG_UPDATE_OBJECT, &body).unwrap();
|
||
let fields = decode(packet)
|
||
.into_iter()
|
||
.find_map(|e| match e {
|
||
SessionEvent::ObjectValues { guid: 0xBB, fields } => Some(fields),
|
||
_ => None,
|
||
})
|
||
.expect("player object values");
|
||
assert_eq!(
|
||
(
|
||
fields.player_skin(),
|
||
fields.player_face(),
|
||
fields.player_hair_style(),
|
||
fields.player_hair_color(),
|
||
fields.player_facial_hair(),
|
||
),
|
||
(Some(0x10), Some(0x11), Some(0x12), Some(0x13), Some(0x14))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn values_update_decodes_unit_power() {
|
||
// A Values-only update for guid 0xAA carrying POWER1=23 (40), MAXPOWER1=29 (60), BYTES_0=36
|
||
// (race 1 / class 1 / gender 0 / power type 0 = mana). Pins the 1.12.1 power field indices
|
||
// (vmangos UpdateFields_1_12_1.h) + the bytes_0 power-type byte against the decode.
|
||
let body = hx("01000000000001aa020000802010000000280000003c00000001010000");
|
||
let packet = messages::parse_server(messages::opcode::SMSG_UPDATE_OBJECT, &body).unwrap();
|
||
let fields = decode(packet)
|
||
.into_iter()
|
||
.find_map(|e| match e {
|
||
SessionEvent::ObjectValues { guid: 0xAA, fields } => Some(fields),
|
||
_ => None,
|
||
})
|
||
.expect("object values");
|
||
assert_eq!(fields.unit_power_type(), 0);
|
||
assert_eq!(fields.unit_power(0), Some(40));
|
||
assert_eq!(fields.unit_max_power(0), Some(60));
|
||
// Other slots are absent; out-of-range slots are None by contract.
|
||
assert_eq!(fields.unit_power(1), None);
|
||
assert_eq!(fields.unit_max_power(5), None);
|
||
}
|
||
|
||
#[test]
|
||
fn values_update_decodes_unit_virtual_items_and_sheath() {
|
||
// A Values-only update for creature guid 0xCC carrying UNIT_VIRTUAL_ITEM_SLOT_DISPLAY slot 0
|
||
// (field 37) = 1234, UNIT_VIRTUAL_ITEM_INFO slot 0's pair (fields 40/41): dword0 packs
|
||
// class=2/subclass=7/material=1/invType=21, dword1 byte0 = sheath 3; and UNIT_FIELD_BYTES_2
|
||
// (field 164) byte0 = sheath state 1 (melee drawn). Field -> (block, bit): 37 -> (1,5),
|
||
// 40 -> (1,8), 41 -> (1,9), 164 -> (5,4) -- 6 mask blocks.
|
||
let mut body = hx("01000000000001cc06"); // count 1, no transport, VALUES, packed guid 0xCC, 6 blocks
|
||
let mut masks = [0u32; 6];
|
||
masks[1] = (1 << 5) | (1 << 8) | (1 << 9);
|
||
masks[5] = 1 << 4;
|
||
for m in masks {
|
||
body.extend_from_slice(&m.to_le_bytes());
|
||
}
|
||
let info_dword0 = 2u32 | (7 << 8) | (1 << 16) | (21 << 24); // class/subclass/material/invType
|
||
for v in [1234u32, info_dword0, 3u32, 1u32] {
|
||
body.extend_from_slice(&v.to_le_bytes());
|
||
}
|
||
let packet = messages::parse_server(messages::opcode::SMSG_UPDATE_OBJECT, &body).unwrap();
|
||
let fields = decode(packet)
|
||
.into_iter()
|
||
.find_map(|e| match e {
|
||
SessionEvent::ObjectValues { guid: 0xCC, fields } => Some(fields),
|
||
_ => None,
|
||
})
|
||
.expect("object values");
|
||
|
||
assert_eq!(fields.unit_virtual_item_display(0), Some(1234));
|
||
assert_eq!(fields.unit_virtual_item_display(1), None, "slot 1 unsent");
|
||
assert_eq!(
|
||
fields.unit_virtual_item_display(3),
|
||
None,
|
||
"slot out of range"
|
||
);
|
||
assert_eq!(fields.unit_virtual_item_info(0), Some((2, 7, 1, 21)));
|
||
assert_eq!(fields.unit_virtual_item_info(3), None, "slot out of range");
|
||
assert_eq!(fields.unit_virtual_item_sheath(0), Some(3));
|
||
assert_eq!(
|
||
fields.unit_virtual_item_sheath(3),
|
||
None,
|
||
"slot out of range"
|
||
);
|
||
assert_eq!(fields.unit_sheath_state(), Some(1));
|
||
}
|
||
|
||
#[test]
|
||
fn values_update_decodes_inventory_slots() {
|
||
// A Values-only update for guid 1 carrying PLAYER_FIELD_INV_SLOT 16 (the offhand, fields
|
||
// 518/519 — live-verified: One's shield guid landed exactly there) = guid 0xAB and
|
||
// PLAYER_FIELD_PACK_SLOT 0 (fields 532/533, live-verified) = guid 0xCD. Pins the CORRECTED
|
||
// 1.12.1 bases (vmangos enum arithmetic, NOT its stale hex comments: INV_SLOT_HEAD 486,
|
||
// PACK 532). 17 mask blocks; block 16 = bits 6,7 (518,519) + 20,21 (532,533).
|
||
let mut body = hx("010000000000010111"); // count 1, no transport, VALUES, packed guid 1, 17 blocks
|
||
for block in 0..17u32 {
|
||
let mask: u32 = if block == 16 { 0x0030_00C0 } else { 0 };
|
||
body.extend_from_slice(&mask.to_le_bytes());
|
||
}
|
||
for v in [0xABu32, 0, 0xCD, 0] {
|
||
body.extend_from_slice(&v.to_le_bytes());
|
||
}
|
||
let packet = messages::parse_server(messages::opcode::SMSG_UPDATE_OBJECT, &body).unwrap();
|
||
let fields = decode(packet)
|
||
.into_iter()
|
||
.find_map(|e| match e {
|
||
SessionEvent::ObjectValues { guid: 1, fields } => Some(fields),
|
||
_ => None,
|
||
})
|
||
.expect("values event");
|
||
assert_eq!(fields.player_inv_slot(16), Some(0xAB));
|
||
assert_eq!(fields.player_pack_slot(0), Some(0xCD));
|
||
assert_eq!(fields.player_pack_slot(3), None, "unsent slot reads None");
|
||
assert_eq!(fields.player_bank_slot(0), None);
|
||
assert_eq!(fields.container_slot(36), None, "out of range");
|
||
}
|
||
|
||
#[test]
|
||
fn item_and_container_creates_decode() {
|
||
// Two position-less creates in one packet — an item and a bag — exactly as vmangos streams our
|
||
// inventory at login: CREATE_OBJECT, packed guid, TypeId 1/2, movement block = UPDATEFLAG_ALL
|
||
// (0x10) + its constant u32 1 (wire-verified `Object::BuildMovementUpdate`), then the mask.
|
||
// These used to be dropped whole by the create placement gate; now they're ItemCreate events.
|
||
// Container fields pin the CORRECTED bases (enum arithmetic, not the stale hex comments —
|
||
// the same 6-low drift as the PLAYER block): NUM_SLOTS = ITEM_END = 48, SLOT_1 = 50.
|
||
let mut body = hx("0200000000");
|
||
// Item guid 0x42, entry 4660 (field 3), stack count 5 (field 14): 1 mask block, bits 3+14.
|
||
body.extend_from_slice(&hx("0201420110010000000108400000"));
|
||
body.extend_from_slice(&4660u32.to_le_bytes());
|
||
body.extend_from_slice(&5u32.to_le_bytes());
|
||
// Container guid 0x43, entry 828 (field 3), num-slots 6 (field 48), slot-0 guid 0x42
|
||
// (fields 50/51): 2 mask blocks — block0 bit 3, block1 bits 16/18/19.
|
||
body.extend_from_slice(&hx("020143021001000000020800000000000d00"));
|
||
for v in [828u32, 6, 0x42, 0] {
|
||
body.extend_from_slice(&v.to_le_bytes());
|
||
}
|
||
let packet = messages::parse_server(messages::opcode::SMSG_UPDATE_OBJECT, &body).unwrap();
|
||
let events = decode(packet);
|
||
assert_eq!(events.len(), 2, "both creates decode, nothing spatial");
|
||
match &events[0] {
|
||
SessionEvent::ItemCreate {
|
||
guid: 0x42,
|
||
container: false,
|
||
fields,
|
||
} => {
|
||
assert_eq!(fields.object_entry(), Some(4660));
|
||
assert_eq!(fields.item_stack_count(), Some(5));
|
||
}
|
||
other => panic!("item create, got {other:?}"),
|
||
}
|
||
match &events[1] {
|
||
SessionEvent::ItemCreate {
|
||
guid: 0x43,
|
||
container: true,
|
||
fields,
|
||
} => {
|
||
assert_eq!(fields.object_entry(), Some(828));
|
||
assert_eq!(fields.container_num_slots(), Some(6));
|
||
assert_eq!(fields.container_slot(0), Some(0x42));
|
||
assert_eq!(fields.container_slot(1), None, "unsent slot reads None");
|
||
}
|
||
other => panic!("container create, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// A transport GameObject's create (decision 0438 "the wire"): `UPDATE_FLAG_HAS_POSITION` (0x40) +
|
||
/// `UPDATE_FLAG_TRANSPORT` (0x02), the latter's tail being the server's pathProgress ms anchor
|
||
/// (vmangos `Object.cpp:590-605`). The mask carries `GAMEOBJECT_DISPLAYID` only — **no**
|
||
/// `GAMEOBJECT_POS_*` fields, exactly what vmangos sends for a boat/zeppelin (it puts the stationary
|
||
/// spawn point in the movement block's `HAS_POSITION` slot instead) — pinning both the new
|
||
/// `transport_progress` surfacing AND the create-placement fallback to the movement block's position
|
||
/// (`create_placement`, `events/decode.rs`) in one fixture.
|
||
#[test]
|
||
fn gameobject_create_surfaces_transport_progress_and_position_fallback() {
|
||
// HIGH_MO_TRANSPORT (0x1FC0) with the Menethil-Theramore ferry's real template entry (176495)
|
||
// riding the full low 32 bits (guid.rs `entry()` — MO_TRANSPORT has no 24-bit entry slot).
|
||
let guid: u64 = 176_495 | (0x1FC0u64 << 48);
|
||
|
||
let mut body = 1u32.to_le_bytes().to_vec(); // amount_of_objects
|
||
body.push(0); // has_transport (unused by the parser)
|
||
body.push(2); // update_type: CREATE_OBJECT
|
||
write_packed_guid(guid, &mut body).unwrap();
|
||
body.push(5); // TypeId::GameObject
|
||
|
||
// Movement block: HAS_POSITION | TRANSPORT.
|
||
body.push(0x40 | 0x02);
|
||
body.extend_from_slice(&5000.0f32.to_le_bytes()); // stationary pos.x
|
||
body.extend_from_slice(&6000.0f32.to_le_bytes()); // stationary pos.y
|
||
body.extend_from_slice(&15.0f32.to_le_bytes()); // stationary pos.z
|
||
body.extend_from_slice(&2.0f32.to_le_bytes()); // orientation
|
||
body.extend_from_slice(&123_456u32.to_le_bytes()); // UPDATE_FLAG_TRANSPORT tail: pathProgress ms
|
||
|
||
// Mask: 1 block, GAMEOBJECT_DISPLAYID (field 8) only.
|
||
body.push(1);
|
||
body.extend_from_slice(&(1u32 << 8).to_le_bytes());
|
||
body.extend_from_slice(&3015u32.to_le_bytes()); // transportship.wmo's displayId
|
||
|
||
let packet = messages::parse_server(messages::opcode::SMSG_UPDATE_OBJECT, &body).unwrap();
|
||
let events = decode(packet);
|
||
match events.as_slice() {
|
||
[SessionEvent::ObjectCreate {
|
||
guid: g,
|
||
kind,
|
||
display_id,
|
||
position,
|
||
orientation,
|
||
speeds,
|
||
transport_progress,
|
||
transport,
|
||
..
|
||
}] => {
|
||
assert_eq!(*g, guid);
|
||
assert_eq!(*kind, EntityKind::GameObject);
|
||
assert_eq!(*display_id, Some(3015));
|
||
assert_eq!(
|
||
*position,
|
||
[5000.0, 6000.0, 15.0],
|
||
"falls back to the movement block's HAS_POSITION pose — GAMEOBJECT_POS_* is absent"
|
||
);
|
||
assert_eq!(*orientation, 2.0);
|
||
assert_eq!(
|
||
*speeds, None,
|
||
"a GameObject's HAS_POSITION block carries no speeds"
|
||
);
|
||
assert_eq!(
|
||
*transport_progress,
|
||
Some(123_456),
|
||
"UPDATE_FLAG_TRANSPORT's u32 tail is the pathProgress ms anchor"
|
||
);
|
||
assert_eq!(
|
||
*transport, None,
|
||
"a HAS_POSITION block (not LIVING) carries no ON_TRANSPORT rider tail"
|
||
);
|
||
}
|
||
other => panic!("expected one ObjectCreate, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// A unit's `LIVING` block with `MOVEFLAG_ON_TRANSPORT` (0x0200_0000): the rider pose tail
|
||
/// `[u64 transport guid][local x,y,z][local o]` (`update_object/movement.rs`'s `MovementBlock::read`,
|
||
/// mirroring `read_movement_info`'s relay parse). VERIFIES the tail lands in `ObjectCreate::transport`
|
||
/// and the 6 movement speeds that follow still parse from the right offset (the alignment the old
|
||
/// discard-to-stay-aligned code depended on, now proven by consuming the values instead of dropping
|
||
/// them).
|
||
#[test]
|
||
fn unit_living_block_surfaces_on_transport_rider_pose() {
|
||
// The Org-UC zeppelin's template entry (176244), same MO_TRANSPORT composition as the ferry above.
|
||
let transport_guid: u64 = 176_244 | (0x1FC0u64 << 48);
|
||
|
||
let mut body = 1u32.to_le_bytes().to_vec();
|
||
body.push(0); // has_transport
|
||
body.push(2); // update_type: CREATE_OBJECT
|
||
write_packed_guid(0xAA, &mut body).unwrap();
|
||
body.push(3); // TypeId::Unit
|
||
|
||
body.push(0x20); // movement block: LIVING only
|
||
let flags: u32 = 0x0200_0000; // MOVEFLAG_ON_TRANSPORT (1.12 bit 25, vmangos MovementInfo.h)
|
||
body.extend_from_slice(&flags.to_le_bytes());
|
||
body.extend_from_slice(&0u32.to_le_bytes()); // timestamp
|
||
body.extend_from_slice(&100.0f32.to_le_bytes()); // living pos.x (world, NOT local)
|
||
body.extend_from_slice(&200.0f32.to_le_bytes()); // living pos.y
|
||
body.extend_from_slice(&5.0f32.to_le_bytes()); // living pos.z
|
||
body.extend_from_slice(&1.0f32.to_le_bytes()); // living orientation
|
||
body.extend_from_slice(&transport_guid.to_le_bytes()); // ON_TRANSPORT tail: FULL u64 guid
|
||
body.extend_from_slice(&2.0f32.to_le_bytes()); // local x
|
||
body.extend_from_slice(&3.0f32.to_le_bytes()); // local y
|
||
body.extend_from_slice(&0.5f32.to_le_bytes()); // local z
|
||
body.extend_from_slice(&0.25f32.to_le_bytes()); // local o
|
||
body.extend_from_slice(&0.0f32.to_le_bytes()); // fall_time (not swimming/jumping, no other tails)
|
||
for v in [2.5f32, 7.0, 4.5, 4.722_222_3, 2.5, std::f32::consts::PI] {
|
||
body.extend_from_slice(&v.to_le_bytes()); // the 6 speeds — must still land right after the tail
|
||
}
|
||
|
||
body.push(0); // mask: 0 blocks (nothing to interpret beyond the pose)
|
||
|
||
let packet = messages::parse_server(messages::opcode::SMSG_UPDATE_OBJECT, &body).unwrap();
|
||
let events = decode(packet);
|
||
match events.as_slice() {
|
||
[SessionEvent::ObjectCreate {
|
||
guid: 0xAA,
|
||
kind: EntityKind::Unit,
|
||
position,
|
||
orientation,
|
||
speeds,
|
||
transport_progress,
|
||
transport,
|
||
..
|
||
}] => {
|
||
assert_eq!(*position, [100.0, 200.0, 5.0], "the unit's own world pose");
|
||
assert_eq!(*orientation, 1.0);
|
||
assert_eq!(
|
||
transport,
|
||
&Some(benilla_protocol::messages::TransportPose {
|
||
guid: transport_guid,
|
||
pos: benilla_protocol::wire::Vector3d {
|
||
x: 2.0,
|
||
y: 3.0,
|
||
z: 0.5,
|
||
},
|
||
orientation: 0.25,
|
||
}),
|
||
"the ON_TRANSPORT tail surfaces as the rider's local pose"
|
||
);
|
||
assert_eq!(
|
||
transport_progress, &None,
|
||
"no UPDATE_FLAG_TRANSPORT on a rider create"
|
||
);
|
||
assert_eq!(
|
||
speeds.map(|s| s.walk),
|
||
Some(2.5),
|
||
"the 6 speeds still parse right after the transport tail"
|
||
);
|
||
}
|
||
other => panic!("expected one ObjectCreate, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// A player who streams in **already swimming**: the `LIVING` block's `MOVEFLAG_SWIMMING` word and
|
||
/// the swim-pitch float that rides it both reach [`SessionEvent::ObjectCreate::mover`], and the six
|
||
/// speeds after the tail still parse from the right offset.
|
||
///
|
||
/// This is the byte the client threw away for as long as observed swimming has existed
|
||
/// (`let _pitch = read_f32_le(r)?;`). Its cost was the whole body-pitch render law being
|
||
/// unreachable at create: the app's only source of a mover's flags + pitch was a relayed
|
||
/// `MSG_MOVE_*`, so a player who swam into view rendered LEVEL until their next packet — and an
|
||
/// idle floater, who sends none while unmoving, stayed level for as long as they floated. The
|
||
/// reference re-authors `CMovement`'s live flags word from this same block (wow-5875-re
|
||
/// `system/collision/collision.md`, the create-block apply's `0x75a07dff` merge).
|
||
#[test]
|
||
fn unit_living_block_surfaces_the_swim_it_is_already_in() {
|
||
let mut body = 1u32.to_le_bytes().to_vec();
|
||
body.push(0); // has_transport
|
||
body.push(2); // update_type: CREATE_OBJECT
|
||
write_packed_guid(0xAA, &mut body).unwrap();
|
||
body.push(4); // TypeId::Player
|
||
|
||
body.push(0x20); // movement block: LIVING only
|
||
let flags: u32 = 0x20_0000 | 0x1; // MOVEFLAG_SWIMMING | MOVEFLAG_FORWARD
|
||
body.extend_from_slice(&flags.to_le_bytes());
|
||
body.extend_from_slice(&0u32.to_le_bytes()); // timestamp
|
||
body.extend_from_slice(&(-812.5f32).to_le_bytes()); // living pos.x
|
||
body.extend_from_slice(&(-566.0f32).to_le_bytes()); // living pos.y
|
||
body.extend_from_slice(&(-3.25f32).to_le_bytes()); // living pos.z (under the surface)
|
||
body.extend_from_slice(&2.0f32.to_le_bytes()); // living orientation
|
||
// The SWIMMING tail: one f32, no ON_TRANSPORT pose before it, `fall_time` right after.
|
||
body.extend_from_slice(&(-0.4f32).to_le_bytes()); // swim pitch — nose DOWN
|
||
body.extend_from_slice(&0.0f32.to_le_bytes()); // fall_time
|
||
for v in [2.5f32, 7.0, 4.5, 4.722_222_3, 2.5, std::f32::consts::PI] {
|
||
body.extend_from_slice(&v.to_le_bytes()); // the 6 speeds — must land right after the tail
|
||
}
|
||
|
||
body.push(0); // mask: 0 blocks
|
||
|
||
let packet = messages::parse_server(messages::opcode::SMSG_UPDATE_OBJECT, &body).unwrap();
|
||
match decode(packet).as_slice() {
|
||
[SessionEvent::ObjectCreate {
|
||
guid: 0xAA,
|
||
kind: EntityKind::Player,
|
||
orientation,
|
||
mover,
|
||
speeds,
|
||
..
|
||
}] => {
|
||
assert_eq!(*orientation, 2.0);
|
||
assert_eq!(
|
||
mover,
|
||
&Some(benilla_protocol::MoverState { flags, pitch: -0.4 }),
|
||
"the live flags word and its swim-pitch tail both reach the app"
|
||
);
|
||
assert_eq!(
|
||
speeds.map(|s| s.walk),
|
||
Some(2.5),
|
||
"the 6 speeds still parse right after the swim-pitch tail"
|
||
);
|
||
}
|
||
other => panic!("expected one ObjectCreate, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// A **dry** create carries no pitch tail at all — the four bytes are absent, so a parse that read
|
||
/// one unconditionally would swallow `fall_time` and misalign every speed after it. The mover state
|
||
/// still surfaces (the flags word is not conditional); its pitch is a level `0.0`, which is what the
|
||
/// render law's own SWIMMING gate then reads.
|
||
#[test]
|
||
fn a_dry_living_block_has_no_pitch_tail_and_reads_level() {
|
||
let mut body = 1u32.to_le_bytes().to_vec();
|
||
body.push(0);
|
||
body.push(2);
|
||
write_packed_guid(0xAB, &mut body).unwrap();
|
||
body.push(3); // TypeId::Unit
|
||
|
||
body.push(0x20); // LIVING
|
||
let flags: u32 = 0x1; // FORWARD, walking on dry land
|
||
body.extend_from_slice(&flags.to_le_bytes());
|
||
body.extend_from_slice(&0u32.to_le_bytes()); // timestamp
|
||
for v in [10.0f32, 20.0, 30.0, 0.5] {
|
||
body.extend_from_slice(&v.to_le_bytes()); // pos + orientation
|
||
}
|
||
body.extend_from_slice(&0.0f32.to_le_bytes()); // fall_time — NO pitch tail before it
|
||
for v in [2.5f32, 7.0, 4.5, 4.722_222_3, 2.5, std::f32::consts::PI] {
|
||
body.extend_from_slice(&v.to_le_bytes());
|
||
}
|
||
body.push(0);
|
||
|
||
let packet = messages::parse_server(messages::opcode::SMSG_UPDATE_OBJECT, &body).unwrap();
|
||
match decode(packet).as_slice() {
|
||
[SessionEvent::ObjectCreate { mover, speeds, .. }] => {
|
||
assert_eq!(
|
||
mover,
|
||
&Some(benilla_protocol::MoverState { flags, pitch: 0.0 }),
|
||
"no tail on the wire reads as a level pitch, not a misparse"
|
||
);
|
||
assert_eq!(
|
||
speeds.map(|s| s.walk),
|
||
Some(2.5),
|
||
"the speeds land right after fall_time — the tail really was absent"
|
||
);
|
||
}
|
||
other => panic!("expected one ObjectCreate, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// A unit that is **already walking** when it streams in: the `LIVING` block's
|
||
/// `MOVEFLAG_SPLINE_ENABLED` (0x0040_0000) tail, byte-shaped exactly as vmangos writes it
|
||
/// (`PacketBuilder::WriteCreate`, `packet_builder.cpp:152`). Two things are verified, and both are
|
||
/// what decision 0708 turns on:
|
||
///
|
||
/// 1. The node array is the server's **internal** control array — `[phantom, p₀…pₙ, tail]`, since
|
||
/// vmangos builds even a linear spline through `InitCatmullRom` (`spline.cpp:52`) — so the decoded
|
||
/// `path` must be that array with its two virtual points trimmed, not the raw nodes.
|
||
/// 2. The descriptor mask *after* the spline still parses: a mis-sized spline read (a missed node, a
|
||
/// forgotten final destination) silently shifts every field that follows, which is precisely how a
|
||
/// parse-and-discard block hides its own bugs.
|
||
#[test]
|
||
fn unit_living_block_surfaces_the_walk_it_is_already_on() {
|
||
// The real path: an L, 10 yd east then 20 yd north. What the server actually serializes is that
|
||
// path wrapped in its two virtual points: a phantom head at `p₀.lerp(p₁, -1)` = 2p₀ − p₁ (one
|
||
// segment *behind* the start) and a tail duplicating the destination.
|
||
let path = [
|
||
[10.0f32, 20.0, 30.0],
|
||
[20.0, 20.0, 30.0],
|
||
[20.0, 40.0, 30.0],
|
||
];
|
||
let nodes = [
|
||
[0.0f32, 20.0, 30.0], // phantom: 2·p₀ − p₁
|
||
path[0],
|
||
path[1],
|
||
path[2],
|
||
path[2], // tail: the destination again
|
||
];
|
||
|
||
let mut body = 1u32.to_le_bytes().to_vec();
|
||
body.push(0); // has_transport
|
||
body.push(2); // update_type: CREATE_OBJECT
|
||
write_packed_guid(0xAA, &mut body).unwrap();
|
||
body.push(3); // TypeId::Unit
|
||
|
||
body.push(0x20); // movement block: LIVING only
|
||
let flags: u32 = 0x0040_0000; // MOVEFLAG_SPLINE_ENABLED (vmangos MovementInfo.h:53)
|
||
body.extend_from_slice(&flags.to_le_bytes());
|
||
body.extend_from_slice(&0u32.to_le_bytes()); // timestamp
|
||
// The unit's stored pose — vmangos re-syncs it to the spline only every 400 ms, so it trails the
|
||
// true position; the spline below is what actually places the unit.
|
||
for v in [14.0f32, 20.0, 30.0, 0.0] {
|
||
body.extend_from_slice(&v.to_le_bytes()); // living pos + orientation
|
||
}
|
||
body.extend_from_slice(&0.0f32.to_le_bytes()); // fall_time
|
||
for v in [2.5f32, 7.0, 4.5, 4.722_222_3, 2.5, std::f32::consts::PI] {
|
||
body.extend_from_slice(&v.to_le_bytes()); // the 6 speeds
|
||
}
|
||
// The spline tail: flags, time_passed, duration, id, node count, the nodes, final destination.
|
||
body.extend_from_slice(&0u32.to_le_bytes()); // spline flags: ground walk, no dictated facing
|
||
body.extend_from_slice(&3_000u32.to_le_bytes()); // time_passed: a quarter of the ride is done
|
||
body.extend_from_slice(&12_000u32.to_le_bytes()); // duration
|
||
body.extend_from_slice(&42u32.to_le_bytes()); // spline id
|
||
body.extend_from_slice(&(nodes.len() as u32).to_le_bytes());
|
||
for n in nodes {
|
||
for f in n {
|
||
body.extend_from_slice(&f.to_le_bytes());
|
||
}
|
||
}
|
||
for f in path[2] {
|
||
body.extend_from_slice(&f.to_le_bytes()); // FinalDestination
|
||
}
|
||
|
||
// A descriptor field behind the spline: UNIT_FIELD_DISPLAYID (131) = block 4, bit 3.
|
||
body.push(5); // 5 mask blocks
|
||
for m in [0u32, 0, 0, 0, 1 << 3] {
|
||
body.extend_from_slice(&m.to_le_bytes());
|
||
}
|
||
body.extend_from_slice(&1234u32.to_le_bytes());
|
||
|
||
let packet = messages::parse_server(messages::opcode::SMSG_UPDATE_OBJECT, &body).unwrap();
|
||
let events = decode(packet);
|
||
match events.as_slice() {
|
||
[SessionEvent::ObjectCreate {
|
||
guid: 0xAA,
|
||
kind: EntityKind::Unit,
|
||
display_id,
|
||
position,
|
||
spline,
|
||
..
|
||
}] => {
|
||
let s = spline.as_ref().expect("the create block's live spline");
|
||
assert_eq!(
|
||
s.path, path,
|
||
"the two virtual control points are the server's, not waypoints"
|
||
);
|
||
assert_eq!(s.time_passed_ms, 3_000);
|
||
assert_eq!(s.duration_ms, 12_000);
|
||
assert_eq!(s.id, 42);
|
||
assert!(!s.flying, "no Flying bit ⇒ a ground walk");
|
||
assert!(!s.cyclic);
|
||
assert_eq!(
|
||
*position,
|
||
[14.0, 20.0, 30.0],
|
||
"the block's own pose is the server's last 400 ms sync, kept as the spawn pose"
|
||
);
|
||
assert_eq!(
|
||
*display_id,
|
||
Some(1234),
|
||
"the descriptor mask behind the spline still parses — the block was sized right"
|
||
);
|
||
}
|
||
other => panic!("expected one ObjectCreate, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// A unit standing still carries no spline tail at all — the flag gates it, so `spline` is `None` and
|
||
/// nothing downstream tries to walk it.
|
||
#[test]
|
||
fn unit_living_block_without_the_spline_flag_carries_no_walk() {
|
||
let mut body = 1u32.to_le_bytes().to_vec();
|
||
body.push(0); // has_transport
|
||
body.push(2); // CREATE_OBJECT
|
||
write_packed_guid(0xAB, &mut body).unwrap();
|
||
body.push(3); // TypeId::Unit
|
||
body.push(0x20); // LIVING
|
||
body.extend_from_slice(&0u32.to_le_bytes()); // no move flags
|
||
body.extend_from_slice(&0u32.to_le_bytes()); // timestamp
|
||
for v in [1.0f32, 2.0, 3.0, 0.5] {
|
||
body.extend_from_slice(&v.to_le_bytes());
|
||
}
|
||
body.extend_from_slice(&0.0f32.to_le_bytes()); // fall_time
|
||
for v in [2.5f32, 7.0, 4.5, 4.722_222_3, 2.5, std::f32::consts::PI] {
|
||
body.extend_from_slice(&v.to_le_bytes());
|
||
}
|
||
body.push(0); // no descriptor fields
|
||
|
||
let packet = messages::parse_server(messages::opcode::SMSG_UPDATE_OBJECT, &body).unwrap();
|
||
match decode(packet).as_slice() {
|
||
[SessionEvent::ObjectCreate { spline, .. }] => assert!(spline.is_none()),
|
||
other => panic!("expected one ObjectCreate, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// `PLAYER_VISIBLE_ITEM_<n>_0` across the slot range — the PUBLIC entry the inspect window's whole
|
||
/// paper doll is built from (decision 0631) and the same field equipment rendering resolves other
|
||
/// players' gear through. Untested until now, and an off-by-one in its `258 + 2 + 12i` stride would
|
||
/// put an inspected player's rings on their feet: the stride is exercised at both ends and in the
|
||
/// middle, not just at slot 0 (which any wrong-but-anchored formula would also get right).
|
||
///
|
||
/// Field index = `PLAYER_VISIBLE_ITEM_1_CREATOR` (258, a 2-field guid) + 2 + 12·slot, so
|
||
/// slot 0 → 260 (block 8 bit 4), slot 1 → 272 (block 8 bit 16), slot 14 → 428 (block 13 bit 12),
|
||
/// slot 18 → 476 (block 14 bit 28). 15 mask blocks cover it.
|
||
#[test]
|
||
fn values_update_decodes_player_visible_item_entries() {
|
||
let mut body: Vec<u8> = Vec::new();
|
||
body.extend_from_slice(&1u32.to_le_bytes()); // count 1
|
||
body.push(0); // no transport
|
||
body.push(0); // UPDATETYPE_VALUES
|
||
body.extend_from_slice(&hx("01dd")); // packed guid 0xDD
|
||
body.push(15); // 15 mask blocks
|
||
let mut masks = [0u32; 15];
|
||
masks[8] = (1 << 4) | (1 << 16);
|
||
masks[13] = 1 << 12;
|
||
masks[14] = 1 << 28;
|
||
for m in masks {
|
||
body.extend_from_slice(&m.to_le_bytes());
|
||
}
|
||
// Ascending field order: 260, 272, 428, 476.
|
||
for v in [7365u32, 2196u32, 15406u32, 2504u32] {
|
||
body.extend_from_slice(&v.to_le_bytes());
|
||
}
|
||
let packet = messages::parse_server(messages::opcode::SMSG_UPDATE_OBJECT, &body).unwrap();
|
||
let fields = decode(packet)
|
||
.into_iter()
|
||
.find_map(|e| match e {
|
||
SessionEvent::ObjectValues { guid: 0xDD, fields } => Some(fields),
|
||
_ => None,
|
||
})
|
||
.expect("object values");
|
||
|
||
assert_eq!(fields.player_visible_item_entry(0), Some(7365), "HEAD");
|
||
assert_eq!(fields.player_visible_item_entry(1), Some(2196), "NECK");
|
||
assert_eq!(fields.player_visible_item_entry(14), Some(15406), "BACK");
|
||
assert_eq!(fields.player_visible_item_entry(18), Some(2504), "TABARD");
|
||
// An unsent slot and the sentinel are both None — an empty equipment slot must not read as
|
||
// "item 0" (the feed keys "is there gear here" on exactly this).
|
||
assert_eq!(fields.player_visible_item_entry(2), None, "slot 2 unsent");
|
||
assert_eq!(
|
||
fields.player_visible_item_entry(19),
|
||
None,
|
||
"slot out of range (19 slots, 0..=18)"
|
||
);
|
||
}
|