the server's broadcasts land, and a ghost walks through closed doors

- the four world broadcasts decode and land — a shutdown countdown, the civilian-kill alarm, a defense-tower capture and the whisper-cap refusal all reached benilla as unhandled opcodes; `SMSG_SERVER_MESSAGE` carries a row id and a fill, never a finished line, so `ServerMessages.dbc` ships (the five sentences a shutdown has, plus the client's own `[%d]: %s` row-missing fallback, which is mechanism, not an error path); the two defense broadcasts are CHANNEL lines, not system lines — delivered once per joined channel whose row carries DEFENSE, the LocalDefense copy zone-matched against the PARENT area while the text names the packet's own — so a character in neither channel sees nothing, faithfully, and since nothing auto-joins WorldDefense in 1.12 an Eastern Plaguelands tower capture reaches only players standing there unless they typed /join; the wow-re cross-check then made the length prefix load-bearing — the client borrows a pointer into the receive buffer and composes NULL on an overrun, so an overrunning length is a refusal here too: the packet drops with a log line, nothing displayed
- `BankButtonIDToInvSlotID` is carved: `isBag` is a TYPE test, not truthiness — `lua_isnumber` refuses every non-number tag without ever loading the payload, so `true` takes the same arm as `nil` while `0` and `"1"` take the bag arm — the function is TOTAL (our out-of-range 0 was an invention; the only compares in all 118 bytes are on `lua_isnumber`'s return), and the +59/+39 arithmetic stands confirmed, byte-identical to `ContainerIDToInventoryID`'s second arm
- a deliberate right-drag turn no longer stands you while seated — the observation was right and our attribution was wrong: the body-facing commit is refused by two independent gates and the stand arm is skipped outright while the button is held; what stands you is the RELEASE, a press under 200 ms with under 2.25 degrees of yaw dispatched as a right-CLICK whose interact reaches `SetStandState(0)` — this lands the deletion half, with the click's own stand held for one more wow-re answer
- a ghost walks through closed doors — the whole mechanism is one instruction image-wide, reading bit 15 through the sign flag to answer "not a collision candidate" for a DOOR when the traced body is a player in ghost form; the bit is a property of whose trace it is, not of the door, so the exclusion rides the two mover-only casts and the set is empty on every living frame — the particle snap, the precipitation probe, the mouse pick and the creature conform keep the door's collider untouched, exactly as the binary leaves them

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-31 20:19:32 +07:00
co-authored by Claude Fable 5
parent 14bd5ea1a4
commit b8758d0b21
27 changed files with 1457 additions and 70 deletions
+13
View File
@@ -641,6 +641,19 @@ pub enum SessionEvent {
/// cannot enter … while in ghost form."). The reference sends it to the same system-message
/// sink as [`Self::Notification`].
AreaTriggerMessage { text: String },
/// A shutdown/restart countdown or an operator broadcast (`SMSG_SERVER_MESSAGE`): the
/// `ServerMessages.dbc` row id and the text that fills its `%s`. The reference resolves the row
/// itself and shows the formatted line as `CHAT_MSG_SYSTEM`.
ServerMessage { message_type: u32, text: String },
/// An area is under attack by enemy players (`SMSG_ZONE_UNDER_ATTACK`): one `AreaTable.dbc`
/// id. Becomes a `CHAT_MSG_CHANNEL` line on the joined **defense** channels, not a system line.
ZoneUnderAttack { area_id: u32 },
/// A world-defense broadcast (`SMSG_DEFENSE_MESSAGE` — the EPL tower captures): the zone it is
/// about, and the server-composed text. [`Self::ZoneUnderAttack`]'s destination exactly.
DefenseMessage { zone_id: u32, text: String },
/// A trial account hit its whisper cap (`SMSG_CHAT_RESTRICTED`); empty body. The reference
/// answers it with `DisplayError(0x1c3)` and reads nothing off the wire.
ChatRestricted,
/// Answers `/played` (`SMSG_PLAYED_TIME`, our `CMSG_PLAYED_TIME`): total played time + time
/// since the last level-up, both in seconds.
PlayedTime { total: u32, level: u32 },
@@ -162,6 +162,16 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
ServerPacket::AreaTriggerMessage { text } => {
vec![SessionEvent::AreaTriggerMessage { text }]
}
ServerPacket::ServerMessage { message_type, text } => {
vec![SessionEvent::ServerMessage { message_type, text }]
}
ServerPacket::ZoneUnderAttack { area_id } => {
vec![SessionEvent::ZoneUnderAttack { area_id }]
}
ServerPacket::DefenseMessage { zone_id, text } => {
vec![SessionEvent::DefenseMessage { zone_id, text }]
}
ServerPacket::ChatRestricted => vec![SessionEvent::ChatRestricted],
ServerPacket::PlayedTime { total, level } => {
vec![SessionEvent::PlayedTime { total, level }]
}
@@ -0,0 +1,195 @@
//! The four **world broadcasts** — the packets the server sends to everybody, or to everybody in a
//! zone, rather than to one player about their own doing.
//!
//! They have no shared wire family (two carry a zone id, one a DBC index, one nothing at all), and
//! they are grouped here because they share a *destination*: all four end at a chat line or a UI
//! toast the player did not ask for. The client's own handlers are three adjacent functions plus
//! one arm of a shared dispatcher, and two of them are the same function twice over.
//!
//! | opcode | handler | what it becomes |
//! |---|---|---|
//! | `SMSG_ZONE_UNDER_ATTACK` `0x254` | `0x49dcc0` | `CHAT_MSG_CHANNEL` on the defense channels |
//! | `SMSG_DEFENSE_MESSAGE` `0x33B` | `0x49de30` | `CHAT_MSG_CHANNEL` on the defense channels |
//! | `SMSG_SERVER_MESSAGE` `0x291` | `0x49df80` | `CHAT_MSG_SYSTEM` |
//! | `SMSG_CHAT_RESTRICTED` `0x2FD` | `0x5e38c0` arm `0x5e4a09` | `DisplayError(451)` → `CHAT_MSG_SYSTEM` |
//!
//! **The two defense broadcasts do not go where a reimplementation guesses.** Neither is a system
//! line: the client walks its own **joined-channel list** (`[0xb4fe04]`, count `[0xb4fe00]`, stride
//! `0xa0` — the record wow-re's `chat-msg-event-args.md` §7 owns) and delivers the text on every
//! joined channel whose `ChatChannels.dbc` row carries the `DEFENSE` flag `0x10000`, as chat type
//! `0xE` = `CHAT_MSG_CHANNEL` with **no sender** and language `0`. A `ZONE_DEP` (`0x2`) channel —
//! LocalDefense — additionally requires the broadcast's zone to be the player's current one; a
//! GLOBAL one — WorldDefense — always passes. So a player who has joined neither channel sees
//! nothing at all, which is the faithful answer and not a dropped packet.
//!
//! The zone test is on the **parent** zone, not the packet's area: both handlers resolve the id
//! through `AreaTable.dbc` and replace it with the row's parent (`[rec+0x8]`) when that is nonzero.
//! `SMSG_ZONE_UNDER_ATTACK`'s *text* still names the packet's own area — the name is read off the
//! row before the remap — so "Sentinel Hill is under attack!" is matched against Westfall.
//!
//! The wire shapes below are VERIFIED against vmangos source with a file:line cite per field, and
//! confirmed against the client's own reads in the handlers above. The goldens
//! (`tests/broadcast.rs`) are built the way the server builds each body.
use std::io;
use crate::wire::{read_cstring, read_u32_le};
/// Read `SMSG_SERVER_MESSAGE` (VERIFIED vmangos `WorldPackets::Misc::ServerMessage::AppendBodyTo`,
/// `Server/Packets/Misc.cpp:341-345`): `u32 messageType` + one cstring.
///
/// `messageType` is a **`ServerMessages.dbc` row id**, not a free enum — the client indexes the
/// store at `[0xc0d990]` by it directly (`0x49dfbb`) and takes the row's localized text as a
/// **format string**, which the packet's own text fills. vmangos's producers are the five rows the
/// shipped table has (`World.h:62`, whose comment says "ServerMessages.dbc"):
///
/// | type | `ServerMessages.dbc` text (enUS) | vmangos sends |
/// |---|---|---|
/// | 1 | `[SERVER] Shutdown in %s` | `secsToTimeString(remaining)` |
/// | 2 | `[SERVER] Restart in %s` | `secsToTimeString(remaining)` |
/// | 3 | `%s` | the operator's own text (`SERVER_MSG_CUSTOM`) |
/// | 4 | `[SERVER] Shutdown cancelled` | `""` |
/// | 5 | `[SERVER] Restart cancelled` | `""` |
///
/// The text is carried whole rather than being resolved here: the DBC lives on the *client* side
/// of this crate's boundary (`benilla_formats::ServerMessagesCatalog`), and the row-missing
/// fallback the client itself uses — `"[%d]: %s"` at `0x844864` — needs the raw type to render.
pub(super) fn read_server_message(r: &mut &[u8]) -> io::Result<(u32, String)> {
let message_type = read_u32_le(r)?;
let text = read_cstring(r)?;
Ok((message_type, text))
}
/// Read `SMSG_ZONE_UNDER_ATTACK` (VERIFIED vmangos
/// `WorldPackets::Misc::ZoneUnderAttack::AppendBodyTo`, `Server/Packets/Misc.cpp:451-454`): one
/// `u32`, the `AreaTable.dbc` id of the area under attack.
///
/// vmangos sends it from `Creature::SendZoneUnderAttackMessage`
/// (`src/game/Objects/Creature.cpp:2889`), whose sole caller is the creature-death path
/// (`Objects/Unit.cpp:1200`) when the victim `IsGuard()` or carries
/// `CREATURE_STATIC_FLAG_PVP_ENABLING` and the killer was a player. It goes **map-wide to the
/// opposing team**, throttled to one per area per 10 s — and the function's own comment reads
/// *"Send a message to LocalDefense channel for players opposition team in the zone"*, which is the
/// server independently naming the destination the client's handler picks.
pub(super) fn read_zone_under_attack(r: &mut &[u8]) -> io::Result<u32> {
read_u32_le(r)
}
/// Read `SMSG_DEFENSE_MESSAGE` (VERIFIED vmangos `Map::SendDefenseMessage`,
/// `src/game/Maps/Map.cpp:1868-1884`): `u32 zoneId`, `u32 length`, then `length` bytes of text.
///
/// **Built raw, not through a packet class** — the whole body is three `ByteBuffer <<` writes
/// inside a `#if SUPPORTED_CLIENT_BUILD > CLIENT_BUILD_1_11_2`, so this packet exists for 1.12 and
/// no earlier build. The length is written as `strlen(text) + 1` and the `char const*` write
/// appends the NUL, so it counts the terminator.
///
/// **The length is load-bearing here, unlike [`super::area_trigger::read_area_trigger_message`]'s.**
/// The client does not read a C string at all: `0x49de5c` calls the `CDataStore` **borrow-and-skip**
/// `0x419ac0`, which copies nothing, hands back a live pointer into the receive buffer and advances
/// the cursor by exactly `length` — and which **zeroes its out-parameter before any bounds check**,
/// so a `length` that overruns the packet reaches the chat composer as a NULL text and
/// `0x49a870`'s NULL guard at `0x49a889` drops the line entirely (wow-re
/// `system/net/scratch/cdatastore-get-primitives.md` §5, `system/ui/scratch/world-broadcast-opcodes.md`
/// §3 — a §5 cross-check that corrected two committed notes calling `0x419ac0` a copy).
///
/// So an overrunning length is a **refusal**, not a hint to ignore: it errors here, and the session
/// drops the packet, which is the same nothing-displayed the reference produces. Within the bounds
/// the text is the borrowed window up to its first NUL — the reference reads past `length` when
/// there is no NUL in it (an unmitigated OOB read, wow-re's words), which is the one place a
/// faithful reimplementation stops short on purpose.
///
/// The Eastern Plaguelands tower captures are its only vmangos sender.
pub(super) fn read_defense_message(r: &mut &[u8]) -> io::Result<(u32, String)> {
let zone_id = read_u32_le(r)?;
let length = read_u32_le(r)? as usize;
if length > r.len() {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
format!(
"SMSG_DEFENSE_MESSAGE: length {length} overruns the {} body bytes left",
r.len()
),
));
}
let (window, rest) = r.split_at(length);
*r = rest;
let text = window.split(|b| *b == 0).next().unwrap_or(window);
Ok((zone_id, String::from_utf8_lossy(text).into_owned()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_server_message_is_a_type_and_a_string() {
let mut body = 2u32.to_le_bytes().to_vec();
body.extend_from_slice(b"15 Minutes\0");
assert_eq!(
read_server_message(&mut &body[..]).unwrap(),
(2, "15 Minutes".to_string())
);
}
/// The cancel messages carry an EMPTY text, and the row's own string is the whole line — so an
/// empty string here is a value, never a parse failure.
#[test]
fn a_cancel_message_carries_an_empty_text() {
let mut body = 4u32.to_le_bytes().to_vec();
body.push(0);
assert_eq!(
read_server_message(&mut &body[..]).unwrap(),
(4, String::new())
);
}
#[test]
fn zone_under_attack_is_the_bare_area_id() {
let body = 40u32.to_le_bytes();
assert_eq!(read_zone_under_attack(&mut &body[..]).unwrap(), 40);
}
/// The length prefix is skipped and the text read whole — built the way `Map.cpp` builds it,
/// terminator counted.
#[test]
fn a_defense_message_skips_its_length_and_reads_the_text() {
let text = b"The Eastern Plaguelands tower has been taken!";
let mut body = 139u32.to_le_bytes().to_vec();
body.extend_from_slice(&(text.len() as u32 + 1).to_le_bytes());
body.extend_from_slice(text);
body.push(0);
let (zone, out) = read_defense_message(&mut &body[..]).unwrap();
assert_eq!(zone, 139);
assert_eq!(out, String::from_utf8_lossy(text));
}
/// A truncated body is an error, not a silently empty line — and it is the *declared length*
/// that decides, because that is what the reference bounds-checks. An overrun leaves its
/// out-pointer NULL and the chat composer drops the line; erroring here reproduces the same
/// nothing-displayed, with a log line instead of silence.
#[test]
fn a_length_that_overruns_the_body_is_refused() {
let mut body = 139u32.to_le_bytes().to_vec();
body.extend_from_slice(&8u32.to_le_bytes());
assert!(read_defense_message(&mut &body[..]).is_err());
// Even with bytes present: 8 declared, 4 supplied.
let mut body = 139u32.to_le_bytes().to_vec();
body.extend_from_slice(&8u32.to_le_bytes());
body.extend_from_slice(b"abc\0");
assert!(read_defense_message(&mut &body[..]).is_err());
}
/// A window with no NUL in it is the whole window — the reference would read on past `length`
/// (an OOB read it mitigates nowhere); stopping at the bound is the deliberate divergence.
#[test]
fn a_window_without_a_terminator_is_taken_whole() {
let mut body = 139u32.to_le_bytes().to_vec();
body.extend_from_slice(&3u32.to_le_bytes());
body.extend_from_slice(b"abcTRAILING");
assert_eq!(
read_defense_message(&mut &body[..]).unwrap(),
(139, "abc".to_string())
);
}
}
@@ -22,6 +22,7 @@ mod attack;
mod auction;
mod bank;
mod binder;
mod broadcast;
mod channel;
mod chat;
mod client;
@@ -447,6 +447,32 @@ pub const SMSG_FISH_ESCAPED: u16 = 0x01C9; // 457
/// `Opcodes_1_12_1.h`: 459): one cstring (`WorldSession::SendNotification`,
/// `Server/WorldSession.cpp:900-915`) — "You do not know that language", trade refusals, and kin.
pub const SMSG_NOTIFICATION: u16 = 0x01CB; // 459
// The **world broadcasts** — sent to everyone, or to everyone in a zone, rather than to one player
// about their own doing. Bodies and the client's own handlers: [`super::broadcast`].
/// An area is under attack by enemy players (VERIFIED vmangos `Opcodes_1_12_1.h`: 596): one `u32`
/// `AreaTable.dbc` id (`WorldPackets::Misc::ZoneUnderAttack::AppendBodyTo`,
/// `Server/Packets/Misc.cpp:451-454`). The client (`0x49dcc0`) formats FrameXML's
/// `ZONE_UNDER_ATTACK` global string with the area's name and delivers it as `CHAT_MSG_CHANNEL` on
/// the joined defense channels — **not** as a system line.
pub const SMSG_ZONE_UNDER_ATTACK: u16 = 0x0254; // 596
/// A shutdown/restart countdown or an operator's broadcast (VERIFIED vmangos `Opcodes_1_12_1.h`:
/// 657): `u32 messageType` + one cstring (`WorldPackets::Misc::ServerMessage::AppendBodyTo`,
/// `Server/Packets/Misc.cpp:341-345`; layout in [`super::broadcast::read_server_message`]). The
/// type indexes `ServerMessages.dbc`, whose text is the format string the packet's text fills; the
/// client (`0x49df80`) shows the result as `CHAT_MSG_SYSTEM`.
pub const SMSG_SERVER_MESSAGE: u16 = 0x0291; // 657
/// A trial account hit its whisper cap (VERIFIED vmangos `Opcodes_1_12_1.h`: 765); **empty body**
/// (`WorldPackets::Chat::ChatRestricted::AppendBodyTo`, `Server/Packets/Chat.cpp:21-23`), and the
/// client's arm (`0x5e4a09`) reads none — it is three instructions, `DisplayError(0x1c3)`.
pub const SMSG_CHAT_RESTRICTED: u16 = 0x02FD; // 765
/// A world-defense broadcast — the Eastern Plaguelands tower captures (VERIFIED vmangos
/// `Opcodes_1_12_1.h`: 827): `u32 zoneId`, `u32 length`, then the text (`Map::SendDefenseMessage`,
/// `src/game/Maps/Map.cpp:1868-1884`, built raw rather than through a packet class and
/// **1.12-only**). Same destination as [`SMSG_ZONE_UNDER_ATTACK`] and the same handler shape
/// (`0x49de30`), but the text rides the wire instead of a global string.
pub const SMSG_DEFENSE_MESSAGE: u16 = 0x033B; // 827
/// `/played` (VERIFIED vmangos `Opcodes_1_12_1.h`: 460/461): empty CMSG body
/// (`NullClientPacket`), SMSG body `u32 total + u32 level` seconds
/// (`WorldPackets::Misc::PlayedTime::AppendBodyTo`, `Server/Packets/Misc.cpp:278-282`; layout in
@@ -482,6 +482,27 @@ pub enum ServerPacket {
AreaTriggerMessage {
text: String,
},
/// `SMSG_SERVER_MESSAGE` — a shutdown/restart countdown or an operator broadcast: the
/// `ServerMessages.dbc` row id and the text that fills its `%s` (layout in
/// [`super::broadcast::read_server_message`]).
ServerMessage {
message_type: u32,
text: String,
},
/// `SMSG_ZONE_UNDER_ATTACK` — an area is under attack by enemy players; one `AreaTable.dbc` id
/// (layout in [`super::broadcast::read_zone_under_attack`]).
ZoneUnderAttack {
area_id: u32,
},
/// `SMSG_DEFENSE_MESSAGE` — a world-defense broadcast (the EPL tower captures): the zone it is
/// about and the server-composed text (layout in [`super::broadcast::read_defense_message`]).
DefenseMessage {
zone_id: u32,
text: String,
},
/// `SMSG_CHAT_RESTRICTED` — a trial account hit its whisper cap; empty body (vmangos
/// `Server/Packets/Chat.cpp:21-23`). The [`Self::ChatWrongFaction`] shape exactly.
ChatRestricted,
/// `SMSG_PLAYED_TIME` — answers our `CMSG_PLAYED_TIME` (`/played`): total played time + time
/// since the last level-up, both in seconds (layout in [`super::chat::read_played_time`]).
PlayedTime {
@@ -1504,6 +1525,10 @@ impl ServerPacket {
ServerPacket::ChatWrongFaction => "SMSG_CHAT_WRONG_FACTION".into(),
ServerPacket::Notification { .. } => "SMSG_NOTIFICATION".into(),
ServerPacket::AreaTriggerMessage { .. } => "SMSG_AREA_TRIGGER_MESSAGE".into(),
ServerPacket::ServerMessage { .. } => "SMSG_SERVER_MESSAGE".into(),
ServerPacket::ZoneUnderAttack { .. } => "SMSG_ZONE_UNDER_ATTACK".into(),
ServerPacket::DefenseMessage { .. } => "SMSG_DEFENSE_MESSAGE".into(),
ServerPacket::ChatRestricted => "SMSG_CHAT_RESTRICTED".into(),
ServerPacket::PlayedTime { .. } => "SMSG_PLAYED_TIME".into(),
ServerPacket::RandomRoll { .. } => "MSG_RANDOM_ROLL".into(),
ServerPacket::InitialSpells { .. } => "SMSG_INITIAL_SPELLS".into(),
+17 -5
View File
@@ -11,11 +11,11 @@ use crate::wire::{
};
use super::{
action_bar, area_trigger, attack, auction, bank, binder, channel, chat, combat_log, death,
duel, gameobject, gm_ticket, gossip, group, guild, instance, items, loot, mail, mirror_timer,
monster_move, movement, opcode, page_text, pet, petition, progression, pvp, quest, social,
spellbook, spells, stable, summon, taxi, trade, trainer, update_object, vendor, world_state,
Character, CreatureQueryInfo, JumpInfo, MoveMode, ServerPacket, SpeedKind,
action_bar, area_trigger, attack, auction, bank, binder, broadcast, channel, chat, combat_log,
death, duel, gameobject, gm_ticket, gossip, group, guild, instance, items, loot, mail,
mirror_timer, monster_move, movement, opcode, page_text, pet, petition, progression, pvp,
quest, social, spellbook, spells, stable, summon, taxi, trade, trainer, update_object, vendor,
world_state, Character, CreatureQueryInfo, JumpInfo, MoveMode, ServerPacket, SpeedKind,
};
/// Read one `SMSG_FORCE_*_SPEED_CHANGE` body — `[packed mover guid][u32 counter][f32 speed]`,
@@ -456,6 +456,18 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
opcode::SMSG_AREA_TRIGGER_MESSAGE => ServerPacket::AreaTriggerMessage {
text: area_trigger::read_area_trigger_message(&mut r)?,
},
opcode::SMSG_SERVER_MESSAGE => {
let (message_type, text) = broadcast::read_server_message(&mut r)?;
ServerPacket::ServerMessage { message_type, text }
}
opcode::SMSG_ZONE_UNDER_ATTACK => ServerPacket::ZoneUnderAttack {
area_id: broadcast::read_zone_under_attack(&mut r)?,
},
opcode::SMSG_DEFENSE_MESSAGE => {
let (zone_id, text) = broadcast::read_defense_message(&mut r)?;
ServerPacket::DefenseMessage { zone_id, text }
}
opcode::SMSG_CHAT_RESTRICTED => ServerPacket::ChatRestricted,
opcode::SMSG_PLAYED_TIME => {
let (total, level) = chat::read_played_time(&mut r)?;
ServerPacket::PlayedTime { total, level }