Files
benilla-octo/crates/benilla-app/src/net/apply/chat.rs
T
SamandClaude Fable 5 b8758d0b21 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>
2026-08-31 20:19:32 +07:00

427 lines
22 KiB
Rust

//! Chat-window arm bodies for [`super::apply_net_updates`]'s dispatch match — the spoken line
//! itself, plus the server notices and query answers that render as chat lines (the channel
//! roster, whisper refusals, `/played`). Each `pub(super)` fn here is exactly one arm's body; the
//! match at the call site stays the dispatcher, one call per arm.
//!
//! Two arms here do NOT render as chat: `SMSG_NOTIFICATION` and `SMSG_AREA_TRIGGER_MESSAGE` are
//! the reference's UIErrorsFrame toasts, and queue onto [`UiErrorTexts`]. They live in this file
//! because they are text-carrying server notices, not because they share a sink.
use benilla_protocol::messages::{ChatMessage, CHAT_MSG_WHISPER};
use bevy::prelude::*;
use crate::ui_action::UiErrorTexts;
use crate::ui_chat::{Broadcast, ChatEvent, ChatEventKind, ChatLog};
use crate::ui_social::SocialState;
use super::super::{ClientCommand, NetCommands, ServerSaidMessage};
/// A spoken line (`SMSG_MESSAGECHAT`) — the chat window's own feed (decision 0084):
/// [`crate::ui_chat`] formats + colors per type, resolves the sender name ask-once, and
/// AddMessages it into ChatFrame1.
///
/// System lines (`CHAT_MSG_SYSTEM` 0x0A, vmangos `SharedDefines.h`) are the SERVER'S ANSWER to a GM
/// dot-command — "Premade gear template N applied", "No matching premade player template found",
/// "There is no such command". For a headless probe that is the only channel the server has to say
/// *why* something did not happen, so it rides at `info!`: at `debug!` it was in the log but
/// invisible at the default level, and a refused command read exactly like an applied one
/// (decision 0651 — the rig's whole batch silently no-op'd on a too-low GM level and nothing said
/// so). Ordinary chat stays at `debug!`: conversation, not diagnosis, and high volume.
pub(super) fn chat(
m: ChatMessage,
chat_log: &mut ChatLog,
social: &SocialState,
net_commands: &NetCommands,
server_said: &mut MessageWriter<ServerSaidMessage>,
) {
// The ADDON gate (decision 1029, B215): a line whose `language` is `LANG_ADDON` is not speech
// at all — it is one addon talking to another over the party/raid/guild/channel lane, because
// 1.12.1 has no addon opcode and no addon `ChatMsg` type. The real client never renders it; it
// fires `CHAT_MSG_ADDON` instead. Rendering it printed a partied real-client player's
// `[Party] [Soreen]: Quiver VERSION:3.1.4` version ping as ordinary party chat, and a
// mixed-client party makes that constant background noise.
//
// VERIFIED in `WoW.exe` (5875) — wow-re `system/ui/scratch/addon-chat-law.md`: the test is
// `0x49a89b`/`0x49a89e cmp edi,-0x1`, in the DISPLAY function `0x49a870`, and every branch of
// the addon arm `[0x49a8a7, 0x49a96d)` jumps below the normal render path at `0x49a970` — the
// chat frame is unreachable for such a line by construction. The **language field is the whole
// discriminator**: the type byte takes no part in the comparison, and the wire parser
// `0x49d560` never inspects language at all.
//
// **Ignore beats addon**, so that check comes first even though it costs a duplicated call:
// the reference drops an ignored sender's line at `0x49d72d`, *upstream* of the language test,
// so an ignored player's addon traffic fires nothing at all. Unobservable while both paths
// merely drop, but it is the precedence that has to already be right the day there is
// something to fire. (`CMSG_CHAT_IGNORED` is not owed here — the server refuses `LANG_ADDON`
// on WHISPER, the only type that answers on the wire.)
//
// **One divergence, named:** the reference suppresses *after* name resolution — an uncached
// sender parks the line in `__AUPENDINGCHAT__` (`0x49d7ba`) behind `CMSG_NAME_QUERY`, and
// `CHAT_MSG_ADDON` fires late from the callback `0x49ccc0`. We drop here instead, ahead of the
// name query [`ChatLog::push_wire`] would queue, which saves a name query per addon sender.
//
// **THE CHANNEL IS NOW HALF-OPEN, and that is the state of it — not a claim that it works.**
// Decision 1235 landed the SEND half: `SendAddonMessage` composes, validates against the
// client's four-lane whitelist and puts a real `CMSG_MESSAGECHAT`/`LANG_ADDON` on the wire.
// Nothing on THIS side changed, so an addon can broadcast and **cannot hear** — including its
// own echo (vmangos's `Group::BroadcastPacket` takes no ignore-guid, so the party lane returns
// a sender's own line, and an addon that sequences on that echo sees nothing).
//
// Opening the receive half is NOT deleting this `return`, which is why it is separate work:
//
// - `CHAT_MSG_ADDON` (event 227, `0x49a95f push 0xe3`) is **not a `ChatTypeInfo` key** and
// cannot ride [`crate::ui_chat::event`]'s pipeline — it has its own format string and its
// own 4-argument shape `(prefix, message, distribution, sender)`, against the chat
// family's ten. `every_fired_event_name_is_a_chat_type_info_key` would rightly fail it.
// - `prefix`/`message` come from splitting the text on its **FIRST** tab (`0x49a8d0`); with
// no tab the whole text is the *prefix* and the message is `""`, not the other way round.
// - `distribution` is the remap table `0x49aff4` → jump table `0x49afe0`: only
// PARTY/RAID/GUILD/BATTLEGROUND get names, every other type byte reports `"UNKNOWN"`.
// - the *fire* must move downstream of the name resolve so `sender` is a name, not a guid.
// The drop can stay here; the fire cannot.
//
// All four are recorded byte-exact in wow-re `system/ui/scratch/addon-chat-law.md` §3/§4/§6.
//
// The payload rides `debug!` and its own `addon` trace tag rather than vanishing, so
// mixed-client traffic stays diagnosable: invisible in chat, never invisible to us.
if m.is_addon() {
if !social.is_ignored(m.sender_guid) {
debug!(
"net: addon chat [{:#04x}] from {:#x}: {:?} (suppressed — not a chat line)",
m.chat_type, m.sender_guid, m.text
);
if benilla_assets::trace::enabled() {
// `<-` inbound, matching the `->` the outbound drain writes to this same tag
// (`ui_chat::input::drain_addon_message_sends`): one tag, both directions, so a
// live run's trace reads as the conversation it is.
benilla_assets::trace::line(
"addon",
&format!("<- [{:#04x}] {:?}", m.chat_type, m.text),
);
}
// **The channel is whole now.** Parked for the sender-name resolve rather than fired
// here, because the reference fires `CHAT_MSG_ADDON` DOWNSTREAM of the name query
// (`0x49ccc0`) so `sender` is a name and never a guid — the one requirement of the
// four that is about WHERE rather than what. `push_addon` carries the other three: the
// first-tab split, the no-tab-means-all-prefix direction, and the four-lane
// distribution remap.
chat_log.push_addon(&m.text, m.chat_type, m.sender_guid);
}
// The line still never reaches a chat window: an addon message is not speech, and the
// reference's addon arm jumps below the render path (`0x49a970`). An IGNORED sender fires
// nothing at all — ignore beats addon, which is why that check still wraps this.
return;
}
if m.chat_type == 0x0A {
info!("net: server says — {}", m.text);
// …and as a message, for the senders that must know whether their command landed. Server
// state with no descriptor field (god mode) has no other tell.
server_said.write(ServerSaidMessage {
text: m.text.clone(),
});
} else {
debug!("net: chat [{:#04x}] {}", m.chat_type, m.text);
}
// …and on the trace clock too (decision 0624). A GM dot-command is the only way to ask the
// SERVER what it believes — `.gps` reads back the server-side position of a mover whose packets
// may or may not be reaching it — and its answer is only usable if it lands on the same
// timeline as the `snd`/`rly`/`run` lines it must be read against. `debug!` timestamps are
// wall-clock in a different format; this is one clock, one file.
if benilla_assets::trace::enabled() {
benilla_assets::trace::line(
"sys",
&format!("[{:#04x}] {}", m.chat_type, m.text.replace('\n', " ⏎ ")),
);
}
// The ignore gate (decision 0668): an ignored speaker is dropped SILENTLY — no line at all —
// which is the client's own `FriendList::IsIgnored 0x5ae5a0` check, VERIFIED for the sibling
// text-emote path (wow-re `system/ui/scratch/text-emote-composition.md`). A dropped WHISPER
// additionally tells the server, so the sender gets the "is ignoring you" answer: that is what
// `CMSG_CHAT_IGNORED` is for, and only the client can send it.
if social.is_ignored(m.sender_guid) {
if m.chat_type == CHAT_MSG_WHISPER {
let _ = net_commands.0.send(ClientCommand::ChatIgnored {
guid: m.sender_guid,
});
}
return;
}
chat_log.push_wire(m);
}
/// The `/chatlist` roster (`SMSG_CHANNEL_LIST`) — CHAT_CHANNEL_LIST_GET "[%s] " + the roster.
/// Names arrive as guids; v1 renders the count (the per-member resolve fan-out lands with the
/// P6 channel wiring — /chatlist is rare enough that a count is honest, never wrong).
pub(super) fn channel_list(channel: String, members: &[(u64, u8)], chat_log: &mut ChatLog) {
let mut ev = ChatEvent::text_only(
ChatEventKind::ChannelList,
format!("{} member(s)", members.len()),
);
ev.channel = channel;
chat_log.push_event(ev);
}
/// A whisper target wasn't online — ERR_CHAT_PLAYER_NOT_FOUND_S (GlobalStrings:1534).
pub(super) fn chat_player_not_found(name: &str, chat_log: &mut ChatLog) {
chat_log.push_event(ChatEvent::text_only(
ChatEventKind::System,
format!("No player named '{name}' is currently playing."),
));
}
/// A cross-faction whisper was refused — ERR_CHAT_WRONG_FACTION (GlobalStrings:1537).
pub(super) fn chat_wrong_faction(chat_log: &mut ChatLog) {
chat_log.push_event(ChatEvent::text_only(
ChatEventKind::System,
"You can only whisper to members of your alliance.".to_string(),
));
}
/// A server notice (`SMSG_NOTIFICATION`) — the **red UIErrorsFrame line**, never a chat line.
///
/// Byte-verified in the reference: opcode `0x1cb`'s handler is `0x401800` (registered at
/// `0x40172f`, torn down at `0x401f09`), whose whole body is "read the cstring, then
/// `mov edx,1; lea ecx,[buf]; call 0x4945b0`" plus a console log (`0x63cd00(…, 3, buf)` — our
/// `info!` below). `0x4945b0(text, 1)` fires FrameScript event `0xe0` = `UI_ERROR_MESSAGE`
/// (wow-re `system/ui/ui.md` l.2459). Nothing on this path touches the chat composer.
///
/// **This used to push into the chat feed**, as a stand-in from before benilla had an errors
/// frame. It has had a real one for a long time (`assets/ui/ErrorsFrame.xml`, the ref
/// `UIErrorsFrame` as a genuine `MessageFrame`), and the stand-in outlived its reason: vmangos
/// `Player::SetGameMaster` answers `.gm on|off` with **both** `SendSysMessage` and
/// `SendNotification` (`Objects/Player.cpp:2676-2677`/`2701-2702`), so a client that sinks the
/// notification into chat prints "GM mode is ON" **twice** where the reference prints it once.
pub(super) fn notification(text: String, errors: &mut UiErrorTexts) {
// The handler's own console leg, and the same reason 0651 logs the dot-command answers: a
// toast that flashed for five seconds and one that never arrived look identical afterwards.
info!("net: notification — {text}");
errors.error(text);
}
/// An area trigger's refusal (`SMSG_AREA_TRIGGER_MESSAGE`) — "You must be at least level 58 to
/// enter…", "You cannot enter … while in ghost form." The same frame as [`notification`], the
/// **yellow** arm: `0x2b8` shares the multi-opcode handler `0x48f690`, whose arm at `0x48f8ff`
/// ends `xor edx,edx; call 0x4945b0` — flag 0, so event `0xe1` = `UI_INFO_MESSAGE`. (The older
/// comment here called it the same sink as the notification and left it at that; the sink is the
/// same, the arm is not.) Without it, a refused portal is silent, which reads exactly like a
/// portal that is still broken.
pub(super) fn area_trigger_message(text: String, errors: &mut UiErrorTexts) {
// Logged for the same reason 0651 logs the server's dot-command answers: a trigger that
// refused and a trigger the client never noticed look identical from outside.
info!("net: area-trigger message — {text}");
errors.info(text);
}
/// The four **world broadcasts** — parked on [`ChatLog`]'s broadcast queue for
/// [`crate::ui_chat`]'s resolve pass, which holds the AreaTable/ServerMessages catalogs and the
/// joined-channel walk this site does not (`ui_chat::broadcast` carries the whole mechanism).
///
/// Only the parking happens here, deliberately: half-resolving at the packet — naming the area
/// here and picking the channels there — would put one mechanism in two files.
///
/// They are logged at `info!` for the same reason [`notification`] is, and more so: three of the
/// four are things the *world* did, so "the server is restarting in 15 minutes" and "nothing
/// arrived" have to be distinguishable in a log afterwards. A defense broadcast that reaches a
/// character in neither defense channel prints nothing on screen and is faithful in doing so — the
/// log line is the only trace it happened at all.
pub(super) fn broadcast(b: Broadcast, chat_log: &mut ChatLog) {
info!("net: world broadcast — {b:?}");
chat_log.push_broadcast(b);
}
/// The `/played` answer (`SMSG_PLAYED_TIME`) — TIME_PLAYED_TOTAL/LEVEL over
/// TIME_DAYHOURMINUTESECOND (GlobalStrings:4243-4247; the ref's
/// ChatFrame_DisplayTimePlayed breakdown).
pub(super) fn played_time(total: u32, level: u32, chat_log: &mut ChatLog) {
for (label, secs) in [
("Total time played", total),
("Time played this level", level),
] {
let (d, rem) = (secs / 86_400, secs % 86_400);
let (h, rem) = (rem / 3_600, rem % 3_600);
let (m, sec) = (rem / 60, rem % 60);
chat_log.push_event(ChatEvent::text_only(
ChatEventKind::System,
format!("{label}: {d} days, {h} hours, {m} minutes, {sec} seconds"),
));
}
}
#[cfg(test)]
mod tests {
use bevy::ecs::system::RunSystemOnce;
use super::*;
use benilla_protocol::messages::{CHAT_MSG_PARTY, CHAT_MSG_SYSTEM, LANGUAGE_ADDON};
fn a_party_line(language: u32, text: &str) -> ChatMessage {
ChatMessage {
chat_type: CHAT_MSG_PARTY,
language,
sender_guid: 0x21,
target_guid: 0x21,
sender_name: None,
channel: None,
text: text.to_string(),
chat_tag: 0,
}
}
/// Run one inbound line through the real arm body and report whether it reached the chat log.
fn reaches_the_chat_window(m: ChatMessage) -> bool {
chat_lines(m) > 0
}
/// How many lines one inbound message put in the chat window.
fn chat_lines(m: ChatMessage) -> usize {
let mut world = World::new();
world.init_resource::<Messages<ServerSaidMessage>>();
let (tx, _rx) = crossbeam_channel::unbounded();
world.insert_resource(NetCommands(tx));
world.insert_resource(SocialState::default());
world.insert_resource(ChatLog::default());
world
.run_system_once(
move |mut chat_log: ResMut<ChatLog>,
social: Res<SocialState>,
net_commands: Res<NetCommands>,
mut server_said: MessageWriter<ServerSaidMessage>| {
chat(
m.clone(),
&mut chat_log,
&social,
&net_commands,
&mut server_said,
);
},
)
.unwrap();
world.resource::<ChatLog>().pending_len()
}
/// B215 / decision 1029: an addon broadcast never reaches the chat window. It arrives as an
/// ordinary `CHAT_MSG_PARTY` — the lane a mixed-client party's hunter addon talks over — so the
/// `language` sentinel is the only thing standing between it and a rendered `[Party]` line.
#[test]
fn addon_chat_never_reaches_the_chat_window() {
assert!(
!reaches_the_chat_window(a_party_line(LANGUAGE_ADDON, "Quiver\tVERSION:3.1.4")),
"an addon broadcast must be dropped before the chat log"
);
// A payload with no tab is still addon traffic — the gate reads the language field, never
// the shape of the text.
assert!(!reaches_the_chat_window(a_party_line(
LANGUAGE_ADDON,
"nopayloadseparator"
)));
// An empty addon payload likewise: nothing about the text can un-addon a line.
assert!(!reaches_the_chat_window(a_party_line(LANGUAGE_ADDON, "")));
}
/// ...and since the receive half opened, "never reaches the chat window" must not be allowed to
/// mean "vanished". The same line is PARKED for `CHAT_MSG_ADDON`, which is the distinction that
/// stopped being free the moment `Pending::Addon` existed — `pending_len` excludes addon items
/// precisely so the test above keeps asserting what it says, and this one covers the other half.
#[test]
fn an_addon_line_is_parked_for_the_addon_event_not_discarded() {
let mut world = World::new();
world.init_resource::<Messages<ServerSaidMessage>>();
let (tx, _rx) = crossbeam_channel::unbounded();
world.insert_resource(NetCommands(tx));
world.insert_resource(SocialState::default());
world.insert_resource(ChatLog::default());
let m = a_party_line(LANGUAGE_ADDON, "Quiver\tVERSION:3.1.4");
world
.run_system_once(
move |mut chat_log: ResMut<ChatLog>,
social: Res<SocialState>,
net_commands: Res<NetCommands>,
mut server_said: MessageWriter<ServerSaidMessage>| {
chat(
m.clone(),
&mut chat_log,
&social,
&net_commands,
&mut server_said,
);
},
)
.unwrap();
let log = world.resource::<ChatLog>();
assert_eq!(
log.pending_addons(),
vec![(
"Quiver".to_string(),
"VERSION:3.1.4".to_string(),
"PARTY".to_string()
)],
"the line must be queued for CHAT_MSG_ADDON, not dropped"
);
assert_eq!(log.pending_len(), 0, "and still never headed for a window");
}
/// The control the gate must not swallow: real speech on the same lane, in every tongue a
/// character can speak. A gate that keyed on "unknown language" instead of the sentinel would
/// silently eat Demonic and Gutterspeak.
#[test]
fn speech_on_the_addon_lane_still_renders() {
for language in [0, 1, 2, 3, 6, 7, 8, 9, 10, 11, 12, 13, 14, 33] {
assert!(
reaches_the_chat_window(a_party_line(language, "party control line")),
"language {language} is a tongue — the line must render"
);
}
}
/// **The GM-mode double line** — the director's report, pinned end to end.
///
/// vmangos answers `.gm on|off` with **two** packets, not one: `Player::SetGameMaster` calls
/// `SendSysMessage(LANG_GM_ON)` *and* `GetSession()->SendNotification(LANG_GM_ON)`
/// (`src/game/Objects/Player.cpp:2676-2677` and `2701-2702`). They carry the same words down
/// different roads, and only the first is a chat line: the reference's `SMSG_NOTIFICATION`
/// handler `0x401800` is "read the cstring, `mov edx,1`, `call 0x4945b0`" — event `0xe0`
/// `UI_ERROR_MESSAGE`, the UIErrorsFrame toast — plus a console log, and it never reaches the
/// chat composer. Benilla used to park the notice in the chat feed for want of an errors
/// frame; it has had one since `assets/ui/ErrorsFrame.xml`, and the stand-in was what printed
/// "GM mode is ON" twice.
///
/// The pair is the test: BOTH halves of one toggle, one chat line, one toast.
#[test]
fn toggling_gm_mode_prints_one_chat_line_and_one_toast() {
let sys_line = ChatMessage {
chat_type: CHAT_MSG_SYSTEM,
..a_party_line(0, "GM mode is ON")
};
assert_eq!(
chat_lines(sys_line),
1,
"the SendSysMessage half is the chat line, and it stays"
);
let mut errors = UiErrorTexts::default();
notification("GM mode is ON".to_string(), &mut errors);
assert_eq!(
errors.0,
[("GM mode is ON".to_string(), false)],
"the SendNotification half is the RED toast (0x4945b0's flag 1), never a second line"
);
}
/// The notification's sibling arm shares the frame and **not** the colour: `0x2b8`
/// `SMSG_AREA_TRIGGER_MESSAGE` runs the multi-opcode handler `0x48f690`, whose arm at
/// `0x48f8ff` ends `xor edx,edx; call 0x4945b0` — flag 0, so event `0xe1` `UI_INFO_MESSAGE`,
/// the yellow line. This is the half the old "same sink as the notification" comment missed.
#[test]
fn an_area_trigger_refusal_takes_the_yellow_arm() {
let mut errors = UiErrorTexts::default();
area_trigger_message(
"You must be at least level 58 to enter.".to_string(),
&mut errors,
);
assert_eq!(
errors.0,
[("You must be at least level 58 to enter.".to_string(), true)]
);
}
}