- Other players walk the world. A watched player's motion between server updates now runs through the same grounded movement resolve as your own character — same capsule, same colliders, same step-up and ground election — so they follow terrain every frame instead of every packet, and stop at a wall instead of walking into it and being snapped back. Their jumps are swept against the world too, and a standstill jump steered mid-air reports that steer, the one mid-air press the reference client broadcasts rather than defers. - The move relay rebuilt: relayed moves ride a per-unit chain on real time, a due move never jumps ahead of its unit's queue, and SMSG_COMPRESSED_MOVES — the batched carrier the server sends under load — is decoded rather than skipped. Our own send path now matches the reference client's: facing streams while moving, Nagle is off so each packet is one write, and the strafe axis nets so left and right can never ride the same packet. - NPC and quest text speaks the whole $-macro grammar, carved from the reference bytes — your name, class and race, the gendered forms, and the server's live world states — and the same substitution feeds gossip, quest text, the quest log and unit frames. Greetings are a weighted draw, rolled per NPC per open, and a quest giver with no quest to give is no longer clickable, where it used to open a dialog holding nothing but a placeholder greeting. - Spell tooltips finish the reference's line law: cast time and cooldown behind the real passive gate, the required tool or form, the reagents block, the chance-to-X line, a multi-bit equipped-item requirement named properly instead of by raw bits, and a buff's dispel class. - The UI layout engine got a dense, reusable anchor solver, and a change gate that skips the resolve entirely on frames where no layout input moved — in practice, almost every idle frame. Pets are named by their pet number rather than a creature-template entry, so the client asks the query the server can actually answer. And the asset extractor grew a stray-geometry census for tracking down geometry that renders where it should not. This snapshot replaces the six dated `sync` commits that stood here before it, whose subjects said nothing but the date. If you cloned before now: `git fetch && git reset --hard origin/main`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
109 lines
5.3 KiB
Rust
109 lines
5.3 KiB
Rust
//! World states — the server's key/value scoreboard table (`SMSG_INIT_WORLD_STATES` `0x2C2`,
|
||
//! `SMSG_UPDATE_WORLD_STATE` `0x2C3`). It is what the NPC-text `$<n>w`/`$<n>e` tokens read, and it
|
||
//! is what a battleground scoreboard is built from; benilla uses the first today.
|
||
//!
|
||
//! Both wires are settled from two independent sources that agree:
|
||
//!
|
||
//! - **The reference binary** (wow-re `system/ui/scratch/questtext-macro-expander.md` §3): the one
|
||
//! handler `0x48f690` holds the only two callers of the table setter `0x4c5870`, registered
|
||
//! against exactly these two opcodes at `0x48f515-0x48f52f`. `0x2C2` is *two leading dwords*
|
||
//! (passed to the unexamined `0x4c5650`), a `u16` count, then `count × (id:u32, value:u32)`;
|
||
//! `0x2C3` is a single such pair.
|
||
//! - **vmangos's writers** (`Player::SendInitWorldStates`, `Player.cpp:8219-8293`, and
|
||
//! `WorldPackets::Misc::UpdateWorldState::AppendBodyTo`, `Misc.cpp:685-694`): `u32 mapId`,
|
||
//! `u32 zoneId` (the zone dword is `#if SUPPORTED_CLIENT_BUILD > CLIENT_BUILD_1_11_2`, so 1.12.1
|
||
//! gets it), the `u16` count back-patched at the end, then the pairs; and `u32 field, u32 value`
|
||
//! respectively (also build-gated — a pre-1.8.4 client would read `u16`s).
|
||
//!
|
||
//! **The count includes a `(0, 0)` terminator.** vmangos starts `count = 1` and appends
|
||
//! `uint32(0) << uint32(0)` last, *"to prevent repeating audio bug"* — so the pair run is read
|
||
//! verbatim, terminator and all, exactly as the reference's count loop does. There is no
|
||
//! terminator special case on either side and we add none: id 0 simply lands in the table as 0.
|
||
//!
|
||
//! Ids and values are **raw dwords** on the wire and stay raw here. That matters: the reference's
|
||
//! `$<n>e` token reads the table at the *negated* key, so a text asking for `$2077e` looks up
|
||
//! `0xFFFFF7E3` — a key only reachable if nothing has re-interpreted the field on the way in.
|
||
|
||
use std::io;
|
||
|
||
use crate::wire::{read_u16_le, read_u32_le};
|
||
|
||
/// `SMSG_INIT_WORLD_STATES` — the whole table for a zone, sent on login and on every zone change
|
||
/// (vmangos `Player::UpdateZone` → `SendInitWorldStates`).
|
||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||
pub struct InitWorldStates {
|
||
/// The map the states are scoped to. The reference hands this and `zone` to `0x4c5650` and
|
||
/// nothing else; what that call does with them is **not recorded** (see [`read_init_world_states`]).
|
||
pub map: u32,
|
||
pub zone: u32,
|
||
/// `(id, value)` verbatim, including the trailing `(0, 0)` terminator.
|
||
pub states: Vec<(u32, u32)>,
|
||
}
|
||
|
||
/// Read `SMSG_INIT_WORLD_STATES`: `u32 map, u32 zone, u16 count`, then `count` `(id, value)` pairs.
|
||
///
|
||
/// The count is trusted only as far as the body allows — a truncated run stops at the short read
|
||
/// like every other reader here, rather than pre-allocating on a hostile length.
|
||
pub(in crate::messages) fn read_init_world_states(r: &mut &[u8]) -> io::Result<InitWorldStates> {
|
||
let map = read_u32_le(r)?;
|
||
let zone = read_u32_le(r)?;
|
||
let count = read_u16_le(r)?;
|
||
let mut states = Vec::new();
|
||
for _ in 0..count {
|
||
states.push((read_u32_le(r)?, read_u32_le(r)?));
|
||
}
|
||
Ok(InitWorldStates { map, zone, states })
|
||
}
|
||
|
||
/// Read `SMSG_UPDATE_WORLD_STATE`: one `(id, value)` dword pair.
|
||
pub(in crate::messages) fn read_update_world_state(r: &mut &[u8]) -> io::Result<(u32, u32)> {
|
||
Ok((read_u32_le(r)?, read_u32_le(r)?))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// A vmangos-shaped `SMSG_INIT_WORLD_STATES`: map, zone, a count that *includes* the trailing
|
||
/// `(0, 0)` terminator, then the pairs. The terminator is data, not a sentinel we strip.
|
||
#[test]
|
||
fn init_reads_every_pair_including_the_terminator() {
|
||
let mut body = Vec::new();
|
||
body.extend_from_slice(&0u32.to_le_bytes()); // map 0 (Eastern Kingdoms)
|
||
body.extend_from_slice(&1519u32.to_le_bytes()); // zone (Stormwind)
|
||
body.extend_from_slice(&3u16.to_le_bytes()); // 2 real pairs + the terminator
|
||
for (id, value) in [(2264u32, 7u32), (2263u32, 0u32), (0, 0)] {
|
||
body.extend_from_slice(&id.to_le_bytes());
|
||
body.extend_from_slice(&value.to_le_bytes());
|
||
}
|
||
let got = read_init_world_states(&mut body.as_slice()).unwrap();
|
||
assert_eq!(got.map, 0);
|
||
assert_eq!(got.zone, 1519);
|
||
assert_eq!(got.states, vec![(2264, 7), (2263, 0), (0, 0)]);
|
||
}
|
||
|
||
/// A count longer than the body is a short read, not a panic or a giant allocation.
|
||
#[test]
|
||
fn init_truncated_run_errors() {
|
||
let mut body = Vec::new();
|
||
body.extend_from_slice(&0u32.to_le_bytes());
|
||
body.extend_from_slice(&0u32.to_le_bytes());
|
||
body.extend_from_slice(&u16::MAX.to_le_bytes());
|
||
body.extend_from_slice(&1u32.to_le_bytes());
|
||
assert!(read_init_world_states(&mut body.as_slice()).is_err());
|
||
}
|
||
|
||
/// The update wire is the bare pair, and both halves stay raw dwords — a value with the top bit
|
||
/// set survives as-is, so the `%d` render downstream is the only place a sign appears.
|
||
#[test]
|
||
fn update_reads_a_raw_dword_pair() {
|
||
let mut body = Vec::new();
|
||
body.extend_from_slice(&2264u32.to_le_bytes());
|
||
body.extend_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
|
||
assert_eq!(
|
||
read_update_world_state(&mut body.as_slice()).unwrap(),
|
||
(2264, 0xFFFF_FFFF)
|
||
);
|
||
}
|
||
}
|