the player picks the realm, and a harmful spell flinches its victim
- the player picks the realm: benilla connected to whichever realm the auth server listed first — not a policy but the absence of one — with the reference's Change Realm button drawn permanently disabled and the character screen's banner reading "Connecting…", a string 1.12 does not have, invented because the realm only ever arrived as a passenger on the character list (`CharacterSelect.lua` l.66 ends that ladder with `CharSelectRealmName:Hide()` — no name, no banner); `read_realm_list` had read the flags byte, the category byte and the realm id into `_` locals and stringified the population on arrival, and all four are load-bearing; the IO thread gains a third park — logon → which realm? → world handshake → which character — publishing `SessionEvent::RealmList` and blocking on `RealmRequest::{Enter, Refresh, Abandon}` exactly as at the other two, `Enter` carrying the realm's name and not its index since the list is re-requested every 5 s (`RealmList.lua`'s `REALM_LIST_REFRESH_TIME`) off a realmd socket `Logon` now keeps; Change Realm hands the logon forward (`HeldLogon`) so a different realm costs a world dial, not a re-authentication, and a deliberate ask arms `Realms::forced` for exactly one list, because the realm remembered from last time is precisely the realm you are standing on and asking to leave — without it the button re-entered the same realm and appeared to do nothing; the auto-answer ladder is a pure `auto_answer` — `WOW_REALM`, then the remembered `realmName` CVar (which `set_realm_name` had poked into its slot in place, never reaching `cvar_changes`, so the persisted CVar whose whole job is "the last realm connected to" was absent from disk forever; it writes through `set_from_engine` now), then an unattended run takes the first realm that is up, every arm requiring the realm to be up — and the screen is `RealmList.xml` rebuilt on the same 640×512 HelpFrame plate the AddOns list uses, eighteen rows at the authored 20 px pitch, four sortable columns; the Population word is a BAND, not the float: `realm_load_stats` (0x46e510) takes the mean and `sqrt(Σ(pop − mean)² / (n − 1)) · 0.6`, `realm_load_classify` (0x46ec60) reads the sentinels first (0x20 → Recommended, 0x40 → New, 0x80 → Full) then strict `pop < mean − stddev` → Low, `pop > mean + stddev` → High, else Medium — so the same realm reads High on a quiet list and Medium on a busy one, and a lone realm is banded against the `n ≤ 1` short circuit's literal 1.0, so a local vmangos realm at 0.06 reads Low, not Medium; then wow-re's cross-check of `RealmList.cpp [0x46e3bf, 0x46fa05)` confirmed 0x01 = invalid / 0x02 = offline and that the real client does auto-connect by `realmName` without raising the list (`0x5ab800` resolves the stored name against the fresh list, its success leg calling no `SetStatus`), and corrected four things the first cut shipped: the statistics are GLOBAL — `0x46e510` walks the flat all-categories array unconditionally (`0x46e5ee fadd [esi+0x12c]`), the per-category bucketing in the same loop a side effect and never a bound, so scoping them to the tab made the same realm read a different word per tab; Recommended/New/Full never travel as flag bits at all — the parser recognises magic populations by bit pattern and rewrites them, 600.0 → 0.0 + 0x20, 200.0 → 0.001 + 0x40, 400.0 → 8.0 + 0x80, and it has to happen in `read_realm_list` before anything averages, since one realm advertised Recommended arriving as 600.0 drags the global mean past every other row; pvp/rp is a `Cfg_Configs.dbc` join on the type dword at `[realm+0x04]`, and the shipped rows make types 3 and 5 PvP and 7 RP where the 1/6/8 match had all three Normal; and the default sort is `[characters, load, name, mode]`, all ascending, written once per process by `0x46e300` — a four-key fallthrough where "ascending" characters means higher count first, a column click being move-to-front (`0x46e9b0`) that keeps the column's direction unless it was already in front — with the reference's non-antisymmetric characters comparator (its tie-breaking `strcmp` result clobbered one instruction later at `0x46e846`) deliberately not reproduced; `RealmWizard`, the `REALM_IS_FULL` confirm and the category tab strip are deliberately not built, each for a stated reason (2056, 2062)
- a re-played effect REPLACES the same record at the same attach point: the director's report that the reference draws far fewer impact effects when many mobs beat on one warrior — and named the star burst on both hands on every crit — is not a limiter but a replace, recorded in wow-re for weeks: `CEffect::AddEffect` (0x61fdd0), the path every kit slot and the melee blood spurt take, opens with `0x6208e0(owner, rec, tag)`, which walks the owner's `+0xb4` list and destroys every node carrying the same `SpellVisualEffectName` record at the same attach tag (bounded `[0x6208e0, 0x62092c]`), skipping `tag == -1`; benilla replaced only persistent instances, on the narrower `(spell_id, class)` key of the spell-id reap `0x614150`, and pushed every one-shot unconditionally — a gap seen and scoped to one caller four times (0850, 0927, 1149, 1518) without being generalised — so five attackers landing swings on one body held five blood spurts at `(UnitBlood, 0xf)` where the reference holds one, linear in the attacker count on additively-blended emitters where the reference's is flat, and near-identical in a 1v1, which is why it survived; the sharpest case is Flurry (12966–12970), applied on every critical strike, whose visual 2759 hangs record 416 — `Spells\Strike_Impact_Chest.mdx`, the gold chest-impact flare reused at the hand tags 0x15/0x16 — on both hands via cast kit 3010 over a persistent `BloodLust_State_Hand` glow from state kit 3009, and every crit stacked another pair; `spell_fx::replace_same_slot` now runs per slot at every Begin for persistent and self-terminating instances alike, keyed on the RECORD id threaded through the fx lane (`FxSlot { tag, effect, path }`, the hardcoded map becoming name → (id, path)) and not the path, because two rows may name one .mdx and the reference lets those coexist, with both exclusions kept — the world plant and an already-decaying node — and four tests pinning the key's two halves and both; the other half of the report, that the burst is too large and too long, is a separate mechanism and is not touched — and the instrument that would have measured it was lying: `m2part` read a burst emitter's `peak_rate()` as its particle count, while the reference fires on the rising edge of `enabled != 0 && rate > 0` sampled on one clock, and `Strike_Impact_Chest.m2`'s gold flare steps its gate 1 → 0 on the very keyframe its rate steps 0 → 50, so the two are never true together and that emitter emits NOTHING; the tool printed "burst of ~50 particles, life 0.50s" and a diagnosis started 2.7× over on count and 1.7× over on duration off that one line; `EmitTiming::first_burst` walks the slot's keyed span at 60 Hz and returns the first instant the predicate holds or `None`, the dump prints the real count and time or `NEVER FIRES`, and a real-asset test pins that model's emitter #0 as never and #1 as exactly 30 (2057)
- a harmful spell's impact flinches its victim: benilla had one spell-side wound path, `play_kit`'s `[8,10]` branch — the kit player's own `0x60f3ad` test — carrying the KIT's anim id, so a spell whose impact kit names no wound anim, which is nearly every one (Fireball, Frostbolt, Shadow Bolt, Sunder Armor, a mob's Strike), twitched nothing, and a kit naming CombatCritical played 10 where the client plays 9 or 8; `0x60ea70` has six callers, not the three an earlier sweep found (`grep "call 0x60ea70"` over the full objdump was the one command it never ran): the missile hand-off `0x61dc50` lays the flinch before the impact kit for every living target (`0x61dc74`, HEALTH > 0; the already-dead arm `0x60ea30` plays Death/SwimDeath instead), the instant-hit ImpactKit loop `0x6e8bf0` lays it after the kit when `0x6ea280(SpellRec) == 2` (`0x6e8c89`), the reflect kit `0x6e8cb0` does the same on the caster, and every spell-side call pushes severity 0, so the id is always `0x60ea70`'s own `(severity, engaged)` pick — CombatWound(9) engaged, StandWound(8) otherwise — never the kit column; `0x6ea280` is the spell's hostility class and `== 2` ⇔ the spell targets enemies: Targets bit `0x100` → never, `0x80` → yes, else `EffectImplicitTargetA[i]` then `B[i]` for effects 0..3 indexed into the byte tables `0x6ea338`/`0x6ea378`, whose case-0 ids `{2, 6, 15, 16, 24, 28, 53, 54}` are exactly the modern enum's `*_ENEMY` set; so `WoundAnim { entity }` loses its id and the driver resolves every spell edge as `wound_anim(0, engaged)`, `play_impact` takes an `ImpactPlay` with `flinch` — the instant hits pass `SpellDisplay::is_harmful()`, a missile arrival passes true — `SpellDisplay` gains `effect_implicit_target_a/b` (columns 82–84 / 85–87) and `is_harmful()`, and the flinch is laid as the same secondary-blend overlay as a melee wound, over a cast, a swing or a run, never replacing them; pinned against the real 5875 `Spell.dbc` — Fireball, Charge and Sunder Armor harmful through A, Frost Nova through B alone, Renew, Healing Touch, Arcane Intellect and Battle Shout not — and the driver never picks CombatCritical for a spell edge; `WOW_MOVE_TRACE` gains `fct wound trigger/evict/expire` lines, since the whole flinch lifecycle was invisible in a trace; and the report this was chased under — the warrior's swings reading as cut short by incoming impacts in a many-mob brawl — stays open, with one reference gate found on the way that benilla does not model: `0x60ea70` aborts while any attached CEffect on the victim carries `+0x2c & 0x4` (`0x60eab6`), and which kit-spawned nodes carry that bit is the question dispatched to wow-re (2058)
- other players' roots, levitation and blinks now relay to onlookers: a report that a nearby player getting rooted, water-walking or Blinking slid or stood in the wrong place until their next movement packet was exactly right — a movement mode has three legs and benilla carried two, the mover's own acked handshake (`SMSG_FORCE_MOVE_ROOT → CMSG_FORCE_MOVE_ROOT_ACK`, 0866) and the server-driven `SMSG_SPLINE_MOVE_*` twelve (1780), while the third, what observers are told once the mover acks (vmangos `MovementPacketSender.h:30-60`, broadcast by `SendMovementFlagChangeToObservers` / `SendTeleportToObservers`), fell through `parse_server` to `ServerPacket::Other` and was dropped — so a rooted player kept sliding for the ~500 ms to their next heartbeat, a Levitate on someone else was never rendered, and a Blink left them standing at the old spot; six opcodes — `MSG_MOVE_ROOT`/`UNROOT` (236/237), `MSG_MOVE_HOVER` (247), `MSG_MOVE_FEATHER_FALL` (688), `MSG_MOVE_WATER_WALK` (689), `MSG_MOVE_TELEPORT` (197) — all the ordinary `[packed guid][MovementInfo]` shape and all sharing the client's one relay handler `0x603bb0` with the 22 already carried (wow-re's opcode-handler table has thirty rows on it; the remaining two are cheat opcodes vmangos never constructs), so admitting them to `is_movement_relay` is the whole fix for five: apply/unapply rides the flags word rather than the opcode, since vmangos runs `SetHoverReal` &co. before the broadcast and the word on the wire already carries the bit's new state, and `apply_move` already commits it wholesale into the union the ground resolve reads for hover's offset and the water-walk plane — the pose the packet carries beside the mode is half the value, a rooted player's stopping place being news too, and the root arrives already post-wipe with `MOVEFLAG_ROOT` forced back on, so no wipe of ours is invented; the teleport is a relay in shape and a discontinuity in meaning, so `RelayMove::reconciles()` excludes it from the pre-fire reconcile beside the heartbeat, for the opposite reason — the heartbeat because the dead-reckon has already converged, the teleport because there is nothing to converge to, and lerping toward a Blink's destination would drag the mover across the twenty yards the Blink exists to skip, sweeping the shared capsule through every wall between — while it still rides the unit's relay chain rather than snapping at arrival, because replay is paced against the sender's own stamps and applying it early would put the player at the destination while their walk to the launch point was still replaying; a `tp` tag joins `hb`/`tr` on the `rly` trace line; and parse and decode of all six are pinned against golden bodies asserted four bytes shorter than the counter-carrying `MSG_MOVE_TELEPORT_ACK` form, so a counter is provably not being read (2061)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -261,7 +261,32 @@ pub fn write_realm_list_request(w: &mut impl Write) -> std::io::Result<()> {
|
||||
w.write_all(&packet)
|
||||
}
|
||||
|
||||
/// The three **magic populations** the reference's realm-list parser rewrites, and the flag bit
|
||||
/// each one stands in for: `(population as sent, population rewritten to, flag OR'd)`.
|
||||
///
|
||||
/// Only bits `0x01`/`0x02`(/`0x04`) are real server flags on the wire. Recommended, New and Full
|
||||
/// travel as *populations* — the parser recognises the exact float, replaces it, and ORs in the bit
|
||||
/// the load band later reads. Compared by bit pattern because that is what the binary compares, and
|
||||
/// because it says plainly that these are sentinels rather than a numeric range.
|
||||
///
|
||||
/// VERIFIED, wow-re `system/glue/scratch/realm-list-bindings.md` §5 (the parser is `0x5b2230`).
|
||||
pub(crate) const MAGIC_POPULATIONS: [(u32, u32, u8); 3] = [
|
||||
(0x4416_0000, 0x0000_0000, 0x20), // 600.0 → 0.0 — Recommended
|
||||
(0x4348_0000, 0x3a83_126f, 0x40), // 200.0 → 0.001 — New
|
||||
(0x43c8_0000, 0x4100_0000, 0x80), // 400.0 → 8.0 — Full
|
||||
];
|
||||
|
||||
/// Read `CMD_REALM_LIST_Server` into the advertised realms.
|
||||
///
|
||||
/// **Every per-realm field is kept.** The flags byte, the category byte and the realm id were
|
||||
/// read-and-dropped for as long as benilla connected to `realms.first()` without ever drawing a
|
||||
/// list; the realm-list screen displays or groups on all three, and the population is the input to
|
||||
/// the load band rather than a string to print.
|
||||
///
|
||||
/// **And the population is rewritten on the way in** — see [`MAGIC_POPULATIONS`]. This has to
|
||||
/// happen *here*, in the parser, exactly as the reference does it: the realm-list screen computes a
|
||||
/// mean over every realm's population, and a realm advertised as Recommended arrives carrying
|
||||
/// `600.0`, which would drag that mean far enough to mislabel every other row on the list.
|
||||
pub fn read_realm_list(r: &mut impl Read) -> Result<Vec<RealmInfo>> {
|
||||
let opcode = read_u8(r)?;
|
||||
if opcode != CMD_REALM_LIST {
|
||||
@@ -273,19 +298,30 @@ pub fn read_realm_list(r: &mut impl Read) -> Result<Vec<RealmInfo>> {
|
||||
let mut realms = Vec::with_capacity(number_of_realms as usize);
|
||||
for _ in 0..number_of_realms {
|
||||
let realm_type = read_u32_le(r)?;
|
||||
let _flag = read_u8(r)?;
|
||||
let mut flags = read_u8(r)?;
|
||||
let name = read_cstring(r)?;
|
||||
let address = read_cstring(r)?;
|
||||
let population = read_f32_le(r)?;
|
||||
let mut population = read_f32_le(r)?;
|
||||
let characters = read_u8(r)?;
|
||||
let _category = read_u8(r)?;
|
||||
let _realm_id = read_u8(r)?;
|
||||
let category = read_u8(r)?;
|
||||
let id = read_u8(r)?;
|
||||
// The sentinel swap, before anyone can average this number.
|
||||
if let Some(&(_, rewritten, bit)) = MAGIC_POPULATIONS
|
||||
.iter()
|
||||
.find(|(magic, _, _)| *magic == population.to_bits())
|
||||
{
|
||||
population = f32::from_bits(rewritten);
|
||||
flags |= bit;
|
||||
}
|
||||
realms.push(RealmInfo {
|
||||
name,
|
||||
address,
|
||||
population: format!("{population}"),
|
||||
population,
|
||||
characters,
|
||||
realm_type,
|
||||
flags,
|
||||
category,
|
||||
id,
|
||||
});
|
||||
}
|
||||
let _footer_padding = read_u16_le(r)?; // consume so the stream stays aligned (see challenge reply)
|
||||
@@ -371,4 +407,86 @@ mod tests {
|
||||
);
|
||||
assert_eq!(&packet[73..], &[0, 0]);
|
||||
}
|
||||
|
||||
/// Every per-realm field the wire carries survives the read.
|
||||
///
|
||||
/// The flags byte, the category byte and the realm id were read into `_`-prefixed locals and
|
||||
/// dropped for as long as nothing drew a realm list; the population was stringified on the way
|
||||
/// in. All four are load-bearing for the realm-list screen, so pin the byte layout *and* the
|
||||
/// capture — a field silently going back to `_` is the failure this catches.
|
||||
#[test]
|
||||
fn the_realm_list_keeps_every_field_the_wire_carries() {
|
||||
let mut body = Vec::new();
|
||||
body.extend_from_slice(&8u32.to_le_bytes()); // realm_type: RPPVP
|
||||
body.push(0x42); // flags: 0x02 offline | 0x40 sentinel
|
||||
body.extend_from_slice(b"Onyxia\0");
|
||||
body.extend_from_slice(b"127.0.0.1:8085\0");
|
||||
body.extend_from_slice(&1.75f32.to_le_bytes()); // population
|
||||
body.push(3); // characters
|
||||
body.push(2); // category
|
||||
body.push(9); // realm id
|
||||
|
||||
let mut packet = vec![CMD_REALM_LIST];
|
||||
packet.extend_from_slice(&(body.len() as u16 + 3).to_le_bytes()); // size
|
||||
packet.extend_from_slice(&0u32.to_le_bytes()); // header padding
|
||||
packet.push(1); // number_of_realms
|
||||
packet.extend_from_slice(&body);
|
||||
packet.extend_from_slice(&0u16.to_le_bytes()); // footer padding
|
||||
|
||||
let realms = read_realm_list(&mut packet.as_slice()).unwrap();
|
||||
assert_eq!(realms.len(), 1);
|
||||
let r = &realms[0];
|
||||
assert_eq!(r.name, "Onyxia");
|
||||
assert_eq!(r.address, "127.0.0.1:8085");
|
||||
assert_eq!(r.realm_type, 8);
|
||||
assert_eq!(r.flags, 0x42);
|
||||
assert_eq!(r.population, 1.75);
|
||||
assert_eq!(r.characters, 3);
|
||||
assert_eq!(r.category, 2);
|
||||
assert_eq!(r.id, 9);
|
||||
}
|
||||
|
||||
/// **Recommended / New / Full arrive as populations, not as flags**, and the parser swaps them
|
||||
/// before anyone can average the number.
|
||||
///
|
||||
/// This is the one place it can be done. The realm list's load band is computed from a mean
|
||||
/// over every realm's population, so a single realm advertised as Recommended (600.0) sitting
|
||||
/// unswapped in a list of realms at ~1.0 drags the mean past every other row and relabels the
|
||||
/// whole screen. The rewritten values are the reference's own, bit for bit.
|
||||
#[test]
|
||||
fn the_three_magic_populations_become_flags_and_are_rewritten() {
|
||||
let one = |pop: f32| {
|
||||
let mut body = Vec::new();
|
||||
body.extend_from_slice(&0u32.to_le_bytes());
|
||||
body.push(0x02); // a real wire flag, which must survive the OR
|
||||
body.extend_from_slice(b"R\0");
|
||||
body.extend_from_slice(b"h:1\0");
|
||||
body.extend_from_slice(&pop.to_le_bytes());
|
||||
body.extend_from_slice(&[0, 1, 0]);
|
||||
let mut packet = vec![CMD_REALM_LIST];
|
||||
packet.extend_from_slice(&(body.len() as u16 + 3).to_le_bytes());
|
||||
packet.extend_from_slice(&0u32.to_le_bytes());
|
||||
packet.push(1);
|
||||
packet.extend_from_slice(&body);
|
||||
packet.extend_from_slice(&0u16.to_le_bytes());
|
||||
read_realm_list(&mut packet.as_slice()).unwrap().remove(0)
|
||||
};
|
||||
|
||||
let recommended = one(600.0);
|
||||
assert_eq!(recommended.flags, 0x02 | 0x20, "the wire flag survives");
|
||||
assert_eq!(recommended.population, 0.0);
|
||||
|
||||
let new = one(200.0);
|
||||
assert_eq!(new.flags, 0x02 | 0x40);
|
||||
assert_eq!(new.population.to_bits(), 0x3a83_126f, "0.001f exactly");
|
||||
|
||||
let full = one(400.0);
|
||||
assert_eq!(full.flags, 0x02 | 0x80);
|
||||
assert_eq!(full.population, 8.0);
|
||||
|
||||
// An ordinary population is left alone, flags and all.
|
||||
let plain = one(1.5);
|
||||
assert_eq!(plain.flags, 0x02);
|
||||
assert_eq!(plain.population, 1.5);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,13 +32,20 @@ fn main() -> Result<()> {
|
||||
println!("no realms advertised");
|
||||
}
|
||||
for (i, realm) in logon.realms.iter().enumerate() {
|
||||
// Every field the wire carries: the three that used to be dropped (flags, category,
|
||||
// realm id) are what the realm-list screen greys rows out and groups tabs by, and the
|
||||
// population is the raw float the load band is computed FROM, not the word it shows.
|
||||
println!(
|
||||
"[Realm {}] {} — {} @ {} ({} characters)",
|
||||
"[Realm {}] {} @ {} — type {} flags {:#04x} pop {} chars {} category {} id {}",
|
||||
i + 1,
|
||||
realm.name,
|
||||
realm.population,
|
||||
realm.address,
|
||||
realm.characters
|
||||
realm.realm_type,
|
||||
realm.flags,
|
||||
realm.population,
|
||||
realm.characters,
|
||||
realm.category,
|
||||
realm.id
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -151,6 +151,18 @@ pub enum SessionEvent {
|
||||
/// A login attempt progressed to `stage` (decision 0539) — IO-thread-emitted, like
|
||||
/// [`Self::CharacterList`], never wire-decoded.
|
||||
LoginStage { stage: LoginStage },
|
||||
/// **The realms this account may enter** (`CMD_REALM_LIST`), and the IO thread's third park.
|
||||
///
|
||||
/// Emitted the moment the SRP6 logon succeeds, *before* any world socket is dialed: which
|
||||
/// realm to dial is the answer this park is waiting for. Like [`Self::CharacterList`] it is a
|
||||
/// pure IO-thread emit and the thread blocks on its channel until the app answers — the app
|
||||
/// owns the policy (a remembered realm, `WOW_REALM`, or the player on the realm-list screen),
|
||||
/// exactly as it owns the character pick.
|
||||
///
|
||||
/// Re-emitted on every refresh while the screen is up (the reference re-requests the list
|
||||
/// every 5 s) and again when the player asks to change realm, so the app never has to cache a
|
||||
/// list across parks.
|
||||
RealmList { realms: Vec<crate::RealmInfo> },
|
||||
/// **We are queued for a full realm** (`SMSG_AUTH_RESPONSE(AUTH_WAIT_QUEUE)`) — a wait, not
|
||||
/// an outcome. Emitted once per queue packet while the world handshake is parked; the attempt
|
||||
/// is still live and ends normally with a roster (admitted) or a failure. `position` is `None`
|
||||
@@ -321,6 +333,13 @@ pub enum SessionEvent {
|
||||
/// lerp is armed only for NON-heartbeat events (`0x619090` excludes tag `0x26`); a
|
||||
/// heartbeat applies as an outright snap (decision 0601).
|
||||
heartbeat: bool,
|
||||
/// True for `MSG_MOVE_TELEPORT` — somebody else **blinked** (decision 2061): the observer
|
||||
/// leg of the near-teleport, broadcast by `MovementPacketSender::SendTeleportToObservers`
|
||||
/// once the mover acked its own `MSG_MOVE_TELEPORT_ACK`. Same body as any relay, but
|
||||
/// `position` is a **discontinuity, not a step** — so it applies as an outright snap and is
|
||||
/// excluded from the pre-fire reconcile, which exists to make a *continuous* pose land
|
||||
/// smoothly and would otherwise glide a blinking mage across the intervening 20 yards.
|
||||
teleport: bool,
|
||||
fall_time: u32,
|
||||
jump: Option<JumpInfo>,
|
||||
transport: Option<TransportPose>,
|
||||
|
||||
@@ -703,6 +703,7 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
|
||||
pitch,
|
||||
time,
|
||||
heartbeat: opcode == crate::messages::opcode::MSG_MOVE_HEARTBEAT,
|
||||
teleport: opcode == crate::messages::opcode::MSG_MOVE_TELEPORT,
|
||||
fall_time,
|
||||
jump,
|
||||
transport,
|
||||
@@ -973,6 +974,7 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
|
||||
pitch,
|
||||
time,
|
||||
heartbeat: false,
|
||||
teleport: false,
|
||||
fall_time,
|
||||
jump,
|
||||
transport,
|
||||
|
||||
@@ -121,23 +121,97 @@ fn dial(host: &str, port: u16) -> Result<TcpStream> {
|
||||
})
|
||||
}
|
||||
|
||||
/// A realm as advertised by the auth server's realm list.
|
||||
/// A realm as advertised by the auth server's realm list — **every** field the wire carries.
|
||||
///
|
||||
/// Three of them used to be dropped on the floor (`_flag`, `_category`, `_realm_id`) and
|
||||
/// `population` was stringified on arrival, which was survivable only while benilla connected to
|
||||
/// `realms.first()` and never drew a list. The realm-list screen needs all of them, and it needs
|
||||
/// the population as the float it is.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RealmInfo {
|
||||
pub name: String,
|
||||
/// `host:port` of the world server, as the client would connect to it.
|
||||
pub address: String,
|
||||
pub population: String,
|
||||
/// The population float — **not** what the realm list displays. The displayed word
|
||||
/// (`Low`/`Medium`/`High`/`Full`/…) is a *band* computed against the mean and standard
|
||||
/// deviation of **every** realm; see `realm_select::load` on the app side.
|
||||
///
|
||||
/// Already **rewritten** if the server sent one of the three sentinel values — see
|
||||
/// [`auth::MAGIC_POPULATIONS`]. What is stored here is what the reference's own parser would
|
||||
/// hold, which is the only value it is safe to average.
|
||||
pub population: f32,
|
||||
/// How many characters this account has on that realm — a genuine count, printed by the list as
|
||||
/// `"(3)"`. (The client's realm record keeps it at `[realm+0x130]`; wow-re's earlier band sweep
|
||||
/// had read that offset as the realm type, which is really the dword at `[+0x04]` below.)
|
||||
pub characters: u8,
|
||||
/// The wire's `realm_type` (the list "icon"): 0 Normal, 1 PvP, 6 RP, 8 RPPvP — the select
|
||||
/// screen's `(PVP)`/`(RP)` realm-name suffix keys on it.
|
||||
/// The realm **type** — the join key for the game-type columns, *not* an enumeration to match
|
||||
/// on directly. `GetRealmInfo` resolves `(pvp, rp)` by scanning `Cfg_Configs.dbc` for the row
|
||||
/// whose `RealmType` equals this; `realm_select::load::pvp_rp` is that table.
|
||||
pub realm_type: u32,
|
||||
/// The realm-flags byte — the client's realm record `[realm+0x08]`.
|
||||
///
|
||||
/// **Only `0x01` (invalid) and `0x02` (offline) — plus `0x04` — actually travel on the wire.**
|
||||
/// The `0x20`/`0x40`/`0x80` sentinels that mean Recommended / New / Full are *synthesized on
|
||||
/// arrival* from the magic populations the server sends instead, exactly as the reference's
|
||||
/// parser does it ([`auth::MAGIC_POPULATIONS`]) — so this byte is the client's view of the
|
||||
/// flags, not the server's.
|
||||
pub flags: u8,
|
||||
/// The wire's category (timezone) byte — the realm list's category tabs group on it. Matched
|
||||
/// against a category id by **equality**; it is not an index into anything.
|
||||
pub category: u8,
|
||||
/// The wire's realm id.
|
||||
pub id: u8,
|
||||
}
|
||||
|
||||
/// Result of a successful logon: the SRP6 session key (carried into the world server) + realms.
|
||||
/// Result of a successful logon: the SRP6 session key (carried into the world server), the realms,
|
||||
/// and the **still-open realmd socket** the list was read from.
|
||||
///
|
||||
/// The socket is kept because the realm list is not a one-shot: the reference's `RealmList.lua`
|
||||
/// re-requests it every `REALM_LIST_REFRESH_TIME` (5 s) for as long as its window is open, which
|
||||
/// is how a realm going offline or filling up shows up without a re-login. [`Logon::refresh_realms`]
|
||||
/// is that request. It is private so the only thing anyone can do with the connection is ask it
|
||||
/// the one question it still answers.
|
||||
pub struct Logon {
|
||||
pub session_key: [u8; SESSION_KEY_LENGTH],
|
||||
pub realms: Vec<RealmInfo>,
|
||||
/// `None` once a refresh has failed — the list we hold is then the last word, and we stop
|
||||
/// asking rather than retrying a dead socket every five seconds.
|
||||
stream: Option<TcpStream>,
|
||||
}
|
||||
|
||||
impl Logon {
|
||||
/// Re-request the realm list on the realmd connection — the reference's `RequestRealmList`.
|
||||
///
|
||||
/// Returns whether the list was refreshed. A failed refresh **drops the connection and keeps
|
||||
/// the realms we already have**: a stale list the player can still pick from beats an empty
|
||||
/// one, and realmd closing an idle socket is ordinary. Bounded by `timeout` so a park that
|
||||
/// calls this can never block on a server that accepted the request and said nothing.
|
||||
pub fn refresh_realms(&mut self, timeout: std::time::Duration) -> bool {
|
||||
let Some(stream) = self.stream.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
let refreshed = (|| -> Result<Vec<RealmInfo>> {
|
||||
stream.set_read_timeout(Some(timeout))?;
|
||||
auth::write_realm_list_request(stream).context("requesting realm list")?;
|
||||
auth::read_realm_list(stream).context("reading realm list")
|
||||
})();
|
||||
match refreshed {
|
||||
Ok(realms) => {
|
||||
self.realms = realms;
|
||||
true
|
||||
}
|
||||
Err(_) => {
|
||||
self.stream = None;
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the realmd connection is still up, i.e. whether [`Self::refresh_realms`] can do
|
||||
/// anything. Lets a caller stop scheduling refreshes rather than calling into a no-op.
|
||||
pub fn realmd_live(&self) -> bool {
|
||||
self.stream.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform the full SRP6 logon against a vanilla `realmd` and fetch the realm list.
|
||||
@@ -216,6 +290,7 @@ pub fn logon(host: &str, username: &str, password: &str) -> Result<Logon> {
|
||||
Ok(Logon {
|
||||
session_key,
|
||||
realms,
|
||||
stream: Some(stream),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1307,6 +1307,45 @@ pub const SMSG_MOVE_UNSET_HOVER: u16 = 0x00F5; // 245
|
||||
pub const CMSG_MOVE_HOVER_ACK: u16 = 0x00F6; // 246
|
||||
pub const CMSG_MOVE_FEATHER_FALL_ACK: u16 = 0x02CF; // 719
|
||||
|
||||
// **The OBSERVER leg of that same family** — what everyone *else* is told once the mover acks
|
||||
// (decision 2061). The ack'd family above and the `SMSG_SPLINE_MOVE_*` twelve below are only two
|
||||
// of the three legs, and this is the one that was missing: `SMSG_SPLINE_MOVE_*` reaches a unit the
|
||||
// **server** drives (a creature), and the ack'd leg reaches only the mover's **own** client — so a
|
||||
// *player* being rooted, levitated or blinked had no leg at all, and every such change was invisible
|
||||
// until that player's next ordinary pose packet.
|
||||
//
|
||||
// vmangos names the three legs itself, in one table (`Movement/MovementPacketSender.h:30-60`):
|
||||
// `{ SMSG_FORCE_MOVE_ROOT → CMSG_FORCE_MOVE_ROOT_ACK → MSG_MOVE_ROOT }` and its three siblings,
|
||||
// plus `{ MSG_MOVE_TELEPORT_ACK → MSG_MOVE_TELEPORT_ACK → MSG_MOVE_TELEPORT }`. `SendMovementFlagChange
|
||||
// ToObservers` / `SendTeleportToObservers` (`MovementPacketSender.cpp:221-239`, `:368-397`) write
|
||||
// `[packed guid][MovementInfo]` and stop — **the ordinary relay shape**, decoded by
|
||||
// [`super::parse`]'s relay arm like a heartbeat.
|
||||
//
|
||||
// **The client agrees they are one family.** All six register the *same* handler as the 22 relay
|
||||
// opcodes we already carry — `0x603bb0` (wow-re `re/net/opcode-handlers.tsv`; 30 rows, one handler),
|
||||
// which reads a packed guid, resolves it under `TYPEMASK_UNIT` and hands the body to
|
||||
// `OnUnitMoveEvent 0x601580`. There is nothing to ack: the ack already happened, on the mover's own
|
||||
// client.
|
||||
//
|
||||
// **Apply/unapply is in the flags word, not the opcode.** Only root splits into two opcodes; hover,
|
||||
// feather-fall and water-walk each use ONE for both directions, because the server has already
|
||||
// written the bit into the `m_movementInfo` it is about to broadcast (`SetHoverReal` &co. run
|
||||
// *before* the send, `Handlers/MovementHandler.cpp:626-638`/`:743-744`). So the receiver folds the
|
||||
// whole word and asks nothing of the opcode — which is what our relay arm already does.
|
||||
//
|
||||
// **`MSG_MOVE_TELEPORT` carries no counter** (unlike `MSG_MOVE_TELEPORT_ACK`, 0x00C7, whose
|
||||
// `[packed guid][u32 counter][MovementInfo]` is the *mover's* handshake): an observer has nothing to
|
||||
// ack, so the dword isn't there. It is sent **twice** per near-teleport — once before the relocation
|
||||
// and once after (`Player::ExecuteTeleportNear`) — so observers around both the old and the new
|
||||
// position hear it; both carry the destination, so applying both is idempotent. It is also how a
|
||||
// **creature** blink arrives (`Unit::NearTeleportTo`, which `DisableSpline()`s first).
|
||||
pub const MSG_MOVE_TELEPORT: u16 = 0x00C5; // 197
|
||||
pub const MSG_MOVE_ROOT: u16 = 0x00EC; // 236
|
||||
pub const MSG_MOVE_UNROOT: u16 = 0x00ED; // 237
|
||||
pub const MSG_MOVE_HOVER: u16 = 0x00F7; // 247
|
||||
pub const MSG_MOVE_FEATHER_FALL: u16 = 0x02B0; // 688
|
||||
pub const MSG_MOVE_WATER_WALK: u16 = 0x02B1; // 689
|
||||
|
||||
// **The knockback handshake** — the server aims a launch at our own mover, the mover flies it, and
|
||||
// the ack is what the server relays onward (decision 1702). Numbers VERIFIED vmangos
|
||||
// `Opcodes_1_11_2.h:242-244` (unchanged into 1.12.1) and cross-checked against the client's own name
|
||||
|
||||
@@ -122,10 +122,21 @@ fn read_move_set_speed(kind: SpeedKind, r: &mut impl Read) -> io::Result<ServerP
|
||||
})
|
||||
}
|
||||
|
||||
/// True for a relayed player-movement opcode — one the server rebroadcasts as
|
||||
/// `[packed guid][MovementInfo]` (every opcode bound to vmangos `HandleMovementOpcodes`, VERIFIED
|
||||
/// `Opcodes.cpp`). Excludes `MSG_MOVE_TELEPORT_ACK` / `MSG_MOVE_WORLDPORT_ACK`, which share the family
|
||||
/// but carry different bodies and are decoded by their own arms.
|
||||
/// True for a relayed movement opcode — one the server rebroadcasts as `[packed guid][MovementInfo]`.
|
||||
/// Two groups, one wire shape and one client handler (`0x603bb0`, wow-re `re/net/opcode-handlers.tsv`
|
||||
/// — 30 rows pointing at it, of which these are 23):
|
||||
///
|
||||
/// - the **echoed input stream**, every opcode bound to vmangos `HandleMovementOpcodes` (VERIFIED
|
||||
/// `Opcodes.cpp`): another player's walking, turning, jumping, swimming, facing;
|
||||
/// - the **observer leg of the movement-mode family** (decision 2061): root/unroot, hover,
|
||||
/// feather-fall, water-walk and the near-teleport, broadcast by
|
||||
/// `MovementPacketSender::Send{MovementFlagChange,Teleport}ToObservers` once the mover's own
|
||||
/// client has acked. Same body, no ack, and the apply/unapply direction rides the `MovementInfo`
|
||||
/// flags word rather than the opcode (see [`super::opcode`]'s block).
|
||||
///
|
||||
/// Excludes `MSG_MOVE_TELEPORT_ACK` / `MSG_MOVE_WORLDPORT_ACK`, which share the family but carry
|
||||
/// different bodies and are decoded by their own arms — note `MSG_MOVE_TELEPORT` (197) is the
|
||||
/// observer's teleport and is NOT `MSG_MOVE_TELEPORT_ACK` (199): no counter dword, so it parses here.
|
||||
const fn is_movement_relay(o: u16) -> bool {
|
||||
matches!(
|
||||
o,
|
||||
@@ -155,6 +166,16 @@ const fn is_movement_relay(o: u16) -> bool {
|
||||
// need to: the server built that `MovementInfo` from the victim's own ack, so its jump
|
||||
// tail already IS the quad, and the arc replays from it like any other relayed jump.
|
||||
| opcode::MSG_MOVE_KNOCK_BACK
|
||||
// The observer leg of the movement-mode family (decision 2061): somebody else was
|
||||
// rooted, levitated (hover + feather fall + water walk), or blinked. The mode lands in
|
||||
// the `MovementInfo` flags word this body already carries, so no arm of its own is
|
||||
// needed — only the opcode's admission to the family.
|
||||
| opcode::MSG_MOVE_ROOT
|
||||
| opcode::MSG_MOVE_UNROOT
|
||||
| opcode::MSG_MOVE_HOVER
|
||||
| opcode::MSG_MOVE_FEATHER_FALL
|
||||
| opcode::MSG_MOVE_WATER_WALK
|
||||
| opcode::MSG_MOVE_TELEPORT
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user