Files
benilla-octo/crates/benilla/src/ui_chat/input/mod.rs
T
SamandClaude Fable 5 f68c0cd958 /follow works end to end, and a parried swing rings out
- follow a player and the screen names who you're following, the right-click menu grows its Follow row, and your own movement is what cancels it — creatures are refused like the real client
- a locked lockbox click asks the server instead of refusing silently, a quest-starter chest offers its quest before opening, and an item on cooldown hides its green "open" line
- a parried swing plays its clang like the real client keys it
- the README's What works / Not built yet caught up on a week of shipping

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 17:51:08 +07:00

805 lines
41 KiB
Rust

//! The **submitted-line** side of the chat (decision 0288 P5) — what we DO about a line.
//! [`drain_chat_input`] routes each Entered line: a plain line sends as the box's CURRENT type (the
//! edit machine's law, [`super::edit`]); a `/`-line runs the Enter-path type switch, the reply arm,
//! then the action table. What a line *means* is [`parse`]'s ([`parse_line`] → [`ParsedChat`]);
//! which strings reach which arm is [`super::commands`]'s table.
//!
//! The emote arm is the client's `DoEmote` (`0x5ef560`) in full: the eligibility gate
//! ([`emote_send_eligible`]), the asleep gate, the stow, the **posture** branch that makes `/sit`
//! sit, then `CMSG_TEXT_EMOTE`. Opening keys + live parsing live in [`super::edit`]; the inbound
//! half is [`super::feed`]. Own sends are never echoed locally — the wire echoes back (vanilla
//! behavior).
use bevy::prelude::*;
mod parse;
pub(super) use parse::{parse_enter_type_switch, parse_line, ParsedChat};
use crate::creature_anim::{move_flags, MovementState};
use crate::net::{ClientCommand, NetCommands, SelfPlayer};
use crate::target::Selection;
/// Send `text` as the box's CURRENT type (`ChatEdit_SendText`): whisper/channel targets ride
/// along; a sent whisper remembers its target (`ChatEdit_SetLastToldTarget`); a sticky type
/// commits (`ChatEdit_OnEnterPressed`'s `stickyType = type`).
fn send_current(state: &mut super::edit::ChatEditState, commands: &NetCommands, text: String) {
use super::edit::SendType;
if text.trim().is_empty() {
if state.chat_type.sticky() {
state.sticky = state.chat_type;
}
return;
}
let target = match state.chat_type {
SendType::Whisper => Some(state.tell_target.clone()),
SendType::Channel => Some(state.channel_target.clone()),
_ => None,
};
if state.chat_type == SendType::Whisper {
state.last_told = Some(state.tell_target.clone());
}
if state.chat_type.sticky() {
state.sticky = state.chat_type;
}
let cmd = ClientCommand::Chat {
kind: state.chat_type.wire(),
target,
text,
};
match commands.0.send(cmd) {
Ok(()) => {}
Err(_) => warn!("chat: not connected; line dropped"),
}
}
/// The canonical recallable form of a submitted line — the ref's `ChatEdit_AddHistory`
/// (ChatFrame.lua 1916-1938: `SLASH_<type>1`, plus the whisper target / the channel number,
/// then the typed text) — so a recalled line re-Entered reproduces the send even after the
/// box's mode has moved on. A typed slash line recalls exactly as typed; that also stores
/// command lines (`/join …`), which the ref never recalls — a deliberate, useful divergence
/// (decision 0301).
fn history_line(state: &super::edit::ChatEditState, msg: &str) -> String {
use super::edit::SendType;
if msg.starts_with('/') {
return msg.to_string();
}
match state.chat_type {
SendType::Whisper => format!("/w {} {}", state.tell_target, msg),
SendType::Channel => format!("/{} {}", state.channel_number, msg),
t => match t.canonical_slash() {
Some(a) => format!("/{a} {msg}"),
None => msg.to_string(), // the leader types: no 1.12 slash — recall raw
},
}
}
#[allow(clippy::too_many_arguments)] // a Bevy system's param list IS its dependency set
/// The target half of the ref's `GetSlashCmdTarget` (ChatFrame.lua:650-658): a bare party
/// command falls back to the current selection iff it's a PLAYER; anything else is `None` (the
/// ref's silent no-op). The name is cache-resolved — a streamed player target is always cached.
fn target_player_name(
selection: &Selection,
names: &mut crate::names::NameCache,
commands: &NetCommands,
) -> Option<String> {
let guid = selection.guid?;
if !benilla_protocol::guid::is_player(guid) {
return None;
}
names.resolve(guid, commands).map(str::to_string)
}
#[allow(clippy::too_many_arguments)]
/// The client-local diagnostics' inputs, as one [`SystemParam`] — [`drain_chat_input`] is at the
/// 16-parameter ceiling, and a named struct beats a nested tuple nobody can read.
///
/// - `camera`/`clock` feed **`/shot`**, the framing instrument (decision 0600).
/// - `liquids`/`interior` feed **`/liquid`**, the swim diagnostic (decision 0634 follow-up): the
/// interior claim is what decides which liquid surfaces the swim query is allowed to see, so the
/// two only mean anything together.
/// - `stores`/`self_store`/`factions`/`reputations` feed **`/reaction`**, the attackability
/// diagnostic (decision 0637): the exact inputs [`crate::target::ring_reaction`] judges on, so
/// "why is this unit not attackable" is one command instead of a guess.
#[derive(bevy::ecs::system::SystemParam)]
pub(super) struct ChatProbes<'w, 's> {
camera: Query<
'w,
's,
&'static GlobalTransform,
(With<crate::player::WorldCamera>, Without<SelfPlayer>),
>,
clock: Option<Res<'w, crate::lighting::GameClock>>,
liquids: Query<'w, 's, &'static crate::liquid::WaterChunkInfo>,
interior: Res<'w, crate::wmo_portal::CurrentWmoInterior>,
player_room: Res<'w, crate::wmo_portal::PlayerWmoRoom>,
camera_claim: Res<'w, crate::wmo_portal::CameraInteriorClaim>,
stores: Query<'w, 's, &'static crate::net::ObjectStore>,
self_store: Query<'w, 's, &'static crate::net::ObjectStore, With<SelfPlayer>>,
factions: Option<Res<'w, crate::target::Factions>>,
reputations: Res<'w, crate::net::Reputations>,
/// `/reaction <name>`'s resolve — so a scripted probe can ask about a player it has not
/// clicked (the two-client duel run has no way to select the other side).
guids: Res<'w, crate::net::GuidIndex>,
}
/// Everything the drain **queues into another subsystem's one setter** rather than applying itself,
/// bundled (the drain is at Bevy's 16-param ceiling).
///
/// - `stand`/`sheath` — the two setters `DoEmote` drives besides the packet: the **posture**
/// (`EmoteSpecProc == 1` → `SetStandState`) and the **stow** (`SetSheatheState(0, SNAP)`,
/// unconditional on every emote that passes the gates — wow-re `sheath-policy.md` §1, site
/// `0x5ef630`).
/// - `target`/`assist` — the by-name selection asks (decision 0886), answered by
/// [`crate::target`]'s shared resolver so they commit through the same SetSelection path a click
/// does. Chat never writes [`Selection`] itself.
#[derive(bevy::ecs::system::SystemParam)]
pub(super) struct ChatOut<'w> {
stand: MessageWriter<'w, crate::player::StandStateRequest>,
sheath: MessageWriter<'w, crate::creature_anim::SheathRequest>,
target: MessageWriter<'w, crate::target::TargetByNameRequest>,
assist: MessageWriter<'w, crate::target::AssistRequest>,
follow: MessageWriter<'w, crate::player::FollowRequest>,
}
// One parameter per concern — the chat drain fans out to every command's consumer.
#[allow(clippy::too_many_arguments)]
pub(super) fn drain_chat_input(
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
mut chat_log: ResMut<super::feed::ChatLog>,
mut state: ResMut<super::edit::ChatEditState>,
channels: Res<super::edit::ChannelState>,
commands: Res<NetCommands>,
emotes: Option<Res<crate::sound::EmoteSounds>>,
selection: Res<Selection>,
// The party slash commands (decision 0434): the roster for /promote's name→guid resolve, the
// name cache for the bare-command player-target fallback (`GetSlashCmdTarget`).
mut group: ResMut<crate::ui_party::GroupState>,
mut names: ResMut<crate::names::NameCache>,
// Our own live stand-state + move flags for the posture-eligibility gate (the pending-aware
// component the player controller writes each frame); the entity doubles as `/castvis`'s
// fallback subject.
self_player: Query<(Entity, &MovementState, &GlobalTransform), With<SelfPlayer>>,
mut cast_events: MessageWriter<crate::creature_anim::CastEvent>,
mut play_seq: ResMut<crate::creature_anim::PlaySeq>,
mut go_targets: MessageWriter<crate::creature_anim::SpellGoTargets>,
// The command table (decision 0881) — the reference's own aliases, resolved at boot.
table: Res<super::commands::SlashCommands>,
mut chat_out: ChatOut,
probes: ChatProbes,
) {
let ChatProbes {
camera: world_camera,
clock,
liquids,
interior,
player_room,
camera_claim,
stores,
self_store,
factions,
reputations,
guids,
} = &probes;
let Some(mut script) = script else {
return;
};
for raw in script.take_chat_input() {
let msg = raw.trim();
if msg.is_empty() {
continue; // empty Enter = cancel (sticky already committed on prior sends)
}
// Every non-blank submit becomes recallable (Up/Down) in its canonical form — the ref's
// `ChatEdit_AddHistory` slot. Plain lines canonicalize from the same state the send
// below uses, so this pre-branch placement equals the ref's post-parse one.
script.editbox_add_history("ChatFrameEditBox", &history_line(&state, msg));
// A plain line sends as the box's CURRENT type — the whole point of the edit machine
// (the v1 always-SAY default dies here).
if !msg.starts_with('/') {
send_current(&mut state, &commands, msg.to_string());
continue;
}
// A slash line typed whole + Entered (never live-parsed — no trailing space): the type
// switch still applies on the send path (`ChatEdit_ParseText(send=1)` runs the same
// conversion first), then the remainder sends as the new type.
if let Some((switch, remainder)) = parse_enter_type_switch(&channels, msg) {
match switch {
super::edit::TypeSwitch::Plain(t) => state.chat_type = t,
super::edit::TypeSwitch::Whisper(target) => {
state.chat_type = super::edit::SendType::Whisper;
state.tell_target = target;
}
super::edit::TypeSwitch::Channel { name, number } => {
state.chat_type = super::edit::SendType::Channel;
state.channel_target = name;
state.channel_number = number;
}
}
state.header_dirty = true;
send_current(&mut state, &commands, remainder);
continue;
}
match parse_line(&table, msg) {
ParsedChat::Reply { text } => match state.last_tell.front().cloned() {
Some(target) => {
state.chat_type = super::edit::SendType::Whisper;
state.tell_target = target;
state.header_dirty = true;
send_current(&mut state, &commands, text);
}
None => {
// ERR_NO_REPLY_TARGET (GlobalStrings:1748).
chat_log.push_event(super::event::ChatEvent::text_only(
super::event::ChatEventKind::System,
"You have nobody to reply to yet.".to_string(),
));
}
},
ParsedChat::Join { name, password } => {
let _ = commands
.0
.send(ClientCommand::JoinChannel { name, password });
}
ParsedChat::Leave { name } => {
let _ = commands.0.send(ClientCommand::LeaveChannel { name });
}
ParsedChat::ChatList { name } => {
let _ = commands.0.send(ClientCommand::ChannelList { name });
}
ParsedChat::AfkDnd { kind, msg } => {
let _ = commands.0.send(ClientCommand::Chat {
kind,
target: None,
text: msg,
});
}
ParsedChat::Random { min, max } => {
let _ = commands.0.send(ClientCommand::RandomRoll { min, max });
}
ParsedChat::Played => {
let _ = commands.0.send(ClientCommand::PlayedTime);
}
ParsedChat::Shot => {
// The framing instrument (decision 0600): the CURRENT camera pose, in the raw WoW
// coords a capture `Scenario` takes, echoed to chat and appended to
// `config_base()`/shots.txt so a chosen spot survives the session.
let Ok(cam) = world_camera.single() else {
continue;
};
let (_, rot, eye_bevy) = cam.to_scale_rotation_translation();
let eye = benilla_assets::coords::bevy_to_wow(eye_bevy);
let look = benilla_assets::coords::bevy_to_wow(eye_bevy + rot * Vec3::NEG_Z * 50.0);
let minute = clock.as_deref().map(|c| c.minute).unwrap_or(720);
let snippet = format!(
"eye: [{:.1}, {:.1}, {:.1}], look: [{:.1}, {:.1}, {:.1}], minute: {minute}",
eye[0], eye[1], eye[2], look[0], look[1], look[2]
);
chat_log.push_event(super::event::ChatEvent::text_only(
super::event::ChatEventKind::System,
format!("shot: {snippet}"),
));
if let Some(base) = crate::login::config_base() {
let _ = std::fs::create_dir_all(&base);
let path = base.join("shots.txt");
let line = format!("{snippet}\n");
let appended = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.and_then(|mut f| std::io::Write::write_all(&mut f, line.as_bytes()));
if appended.is_ok() {
chat_log.push_event(super::event::ChatEvent::text_only(
super::event::ChatEventKind::System,
format!("shot: appended to {}", path.display()),
));
}
}
}
ParsedChat::Liquid => {
// What the swim query actually sees here, and why. Prints the interior claim, the
// resolved verdict, and EVERY candidate footprint — a surface that should not be
// claiming shows up beside the one that should.
let Ok((_, feet)) = self_player.single().map(|(_, _, t)| ((), t.translation()))
else {
chat_log.push_event(super::event::ChatEvent::text_only(
super::event::ChatEventKind::System,
"liquid: no player yet".into(),
));
continue;
};
let wow = benilla_assets::coords::bevy_to_wow(feet);
let claim = crate::liquid::player_claim(player_room);
let eye = crate::liquid::camera_claim(camera_claim);
let verdict = crate::liquid::liquid_at(liquids.iter(), wow, claim);
let mut lines = vec![
format!(
"liquid: feet [{:.2}, {:.2}, {:.2}] · claim {claim:?} ({})",
wow[0],
wow[1],
wow[2],
match interior.0 {
Some(k) => format!(
"wmo {} nameSet {} group {}",
k.wmo_id, k.name_set, k.group_area_id
),
None => "no WMO interior claim".into(),
}
),
// The camera EYE is a separate subject with its own claim (the reference's
// `[0xc7b748]`), and it is the one that decides the underwater filter. Printing
// only the player's is what let the two disagree unseen for a whole bug.
format!("liquid: EYE claim {eye:?}"),
match verdict {
Some(h) => format!(
"liquid: VERDICT {:?} surface z {:.2} ({:+.2} over feet)",
h.kind,
h.surface_z,
h.surface_z - wow[2]
),
None => "liquid: VERDICT none — not in liquid".into(),
},
];
let candidates = crate::liquid::describe_at(liquids.iter(), wow, claim);
if candidates.is_empty() {
lines.push("liquid: no footprint covers this XY at all".into());
}
lines.extend(candidates.into_iter().map(|c| format!("liquid: {c}")));
for line in lines {
chat_log.push_event(super::event::ChatEvent::text_only(
super::event::ChatEventKind::System,
line,
));
}
}
ParsedChat::Reaction { name } => {
// Everything the reaction ladder judges the subject on, in the order it judges:
// the PvP rung's three duel gates first (naming the one that refused), then the
// faction work, then the `can_attack` verdict the cast/attack paths use.
let subject_guid = match &name {
Some(n) => crate::ui_duel::streamed_player_named(n, guids, &names),
None => selection.guid,
};
let target_store = subject_guid
.and_then(|g| guids.0.get(&g).copied())
.and_then(|e| stores.get(e).ok());
let own_store = self_store.iter().next();
let mut lines = Vec::new();
let describe = |label: &str, s: Option<&crate::net::ObjectStore>| match s {
Some(s) => format!(
"reaction: {label} flags 0x{:08x} (player-controlled {}) faction_tpl {:?} \
duel_team {} duel_arbiter 0x{:016x}",
s.0.unit_flags(),
s.0.unit_flags() & (1 << 3) != 0,
s.0.unit_faction_template(),
s.0.player_duel_team(),
s.0.player_duel_arbiter(),
),
None => format!("reaction: {label} — no ObjectStore"),
};
lines.push(format!(
"reaction: subject {} guid {} · self store present {}",
name.as_deref().unwrap_or("<current target>"),
subject_guid.map_or("none".to_string(), |g| format!("0x{g:016x}")),
own_store.is_some(),
));
lines.push(describe("target", target_store));
lines.push(describe("self ", own_store));
match (target_store, own_store) {
(Some(t), Some(o)) => {
lines.push(format!(
"reaction: duel rung {:?}",
crate::target::duel_rung(&t.0, &o.0)
));
}
_ => {
lines.push("reaction: duel rung not evaluated (a store is missing)".into())
}
}
let rank = crate::target::ring_reaction(
factions.as_deref(),
reputations,
target_store,
own_store,
);
lines.push(format!(
"reaction: RANK {rank} → can_attack {}",
crate::target::can_attack(
target_store,
factions.as_deref(),
reputations,
own_store
)
));
for line in lines {
// Also to the log: a scripted two-client run reads stdout, not the feed.
info!("{line}");
chat_log.push_event(super::event::ChatEvent::text_only(
super::event::ChatEventKind::System,
line,
));
}
}
ParsedChat::PartyTest { arg } => match arg.as_str() {
"off" => group.clear_session(),
"invite" => group.pending_invite = Some("Partner".to_string()),
// Serverless mark eyeball: skull the current target on the LOCAL board (the
// real send round-trips through the server's echo, which /partytest lacks).
"mark" => {
if let Some(guid) = selection.guid {
group.apply_raid_target(7, guid);
}
}
arg => {
// The live position seats the synthetic members' blips around us.
let player_xy = self_player.single().ok().map(|(_, _, tf)| {
let w = benilla_assets::coords::bevy_to_wow(tf.translation());
(w[0], w[1])
});
for line in crate::ui_party::synthetic_roster(&mut group, player_xy) {
chat_log.push_event(super::event::ChatEvent::text_only(
super::event::ChatEventKind::System,
line,
));
}
if arg == "lead" {
// The leader-view variant: an unmatched leader guid resolves to
// leader_index 0 in the feed — "we lead" — so the leader-only popup
// rows (promote/uninvite/the loot submenus) are eyeballable serverless.
group.leader = 0xF000;
}
}
},
ParsedChat::Invite { name } => {
if let Some(name) =
name.or_else(|| target_player_name(&selection, &mut names, &commands))
{
let _ = commands.0.send(ClientCommand::GroupInvite { name });
}
}
ParsedChat::Uninvite { name } => {
if let Some(name) =
name.or_else(|| target_player_name(&selection, &mut names, &commands))
{
let _ = commands.0.send(ClientCommand::GroupUninvite { name });
}
}
ParsedChat::Promote { name } => {
if let Some(name) =
name.or_else(|| target_player_name(&selection, &mut names, &commands))
{
// The 1.12 wire form is a guid (CMSG_GROUP_SET_LEADER) — resolve against the
// roster; a miss answers with the server's own would-be error string
// (INTERIM: the ref's PromoteByName miss behavior is a 0434-dispatch item).
match group
.members
.iter()
.find(|m| m.name.eq_ignore_ascii_case(&name))
{
Some(m) => {
let _ = commands
.0
.send(ClientCommand::GroupSetLeader { guid: m.guid });
}
None => {
// ERR_TARGET_NOT_IN_GROUP_S (GlobalStrings:1861).
chat_log.push_event(super::event::ChatEvent::text_only(
super::event::ChatEventKind::System,
format!("{name} is not in your party."),
));
}
}
}
}
// The duel verbs (decision 0633) enter the SAME intent queue the Era globals feed —
// the reference's own slash handlers are one-liners over `StartDuel`/`CancelDuel`, so
// routing through the queue keeps a single resolution path (spell lookup, streamed-
// player gate, arbiter echo) instead of a second one here.
ParsedChat::Duel { name } => {
if let Some(name) =
name.or_else(|| target_player_name(&selection, &mut names, &commands))
{
script.queue_duel_request(benilla_ui::script::DuelRequest::StartByName(name));
}
}
ParsedChat::Forfeit => {
script.queue_duel_request(benilla_ui::script::DuelRequest::Cancel);
}
// The by-name selection pair (decision 0886). Both hand the name to `crate::target`'s
// shared resolver — the reference's own `0x493aa0`, parameterised per caller — so the
// commit goes through the one SetSelection path a click, TAB and `TargetUnit` share.
//
// A bare `/target` reproduces `GetSlashCmdTarget`'s fallback (your current target's
// name, iff it is a player) and is therefore a no-op re-select; a bare `/target` with a
// creature selected resolves to nothing, exactly as the reference's `if
// GetSlashCmdTarget(msg)` guard does.
ParsedChat::Target { name } => {
if let Some(name) =
name.or_else(|| target_player_name(&selection, &mut names, &commands))
{
chat_out
.target
.write(crate::target::TargetByNameRequest { name });
}
}
// A bare `/assist` is the ref's `AssistUnit("target")` — assist whoever is selected,
// creature or player — so it passes `None` straight through rather than taking the
// player-name fallback. The ref reaches the same unit by resolving its NAME first
// (`AssistByName`), which can pick a different same-named player; going by the live
// selection is the same intent without that ambiguity (recorded in 0886).
ParsedChat::Assist { name } => {
chat_out.assist.write(crate::target::AssistRequest { name });
}
// `/follow` (decision 0890) — the subject is resolved by `crate::target` and the motion
// is `crate::player`'s; chat only carries the ask. The two arms are the ref handler's
// own two calls: bare is `FollowUnit("target")`, named is `FollowByName(name)` with no
// second argument, i.e. prefix matching live.
ParsedChat::Follow { name } => {
chat_out.follow.write(match name {
Some(name) => crate::player::FollowRequest::Name { name, exact: false },
None => crate::player::FollowRequest::Unit("target".into()),
});
}
// The ref's handler is `SlashCmdList["PVP"] = function() TogglePVP() end` — one line
// over the same binding the popup row calls, so it enters the same intent queue
// (decision 0646 §3; the `/duel` reasoning above, verbatim).
ParsedChat::Pvp => script.queue_pvp_toggle(),
// Run the reference's own slash body (see the variant's doc). The argument is a Lua
// string literal, so it is escaped: a `/who` filter legitimately contains quotes
// (`z-"Elwynn Forest"`), and a newline would end the statement.
ParsedChat::Social { verb, arg } => {
let lua = format!("{}(\"{}\")", verb.lua_fn(), parse::escape_lua_string(&arg));
if let Err(e) = script.run(&lua) {
warn!("ui_chat(social): {e}");
}
}
ParsedChat::Help => {
// An honest benilla summary (the ref's HELP_TEXT_LINE pages are a settings-era
// nicety; this stays useful and never stale-quotes them).
for line in [
"Chat: /s /y /p /g /o /raid /rw /bg, /w <name>, /r, /e",
"Channels: /join <name> [pw], /leave <name>, /chatlist <name>",
"Party: /invite /uninvite /promote [name — bare uses your target]",
"Loot: /ffa /roundrobin /master <name>",
"Duel: /duel [name — bare uses your target], /forfeit (/concede /yield)",
"Social: /who [filter], /friends, /ignore, /trade, /inspect",
"Emotes: /sit /stand /sleep /kneel and every /wave-style emote",
"Misc: /afk, /dnd, /random [min] [max], /played, /logout, /quit",
"Instruments: /shot, /liquid, /reaction, /castvis, /chattest, /partytest",
] {
chat_log.push_event(super::event::ChatEvent::text_only(
super::event::ChatEventKind::System,
line.to_string(),
));
}
}
// `DoEmote` (`0x5ef560`) end to end — wow-re `object-layer/scratch/emote-posture-
// gate.md` §1. The gates in the client's own order, then the two things it DOES: set a
// posture (the `/sit` family) and send the packet.
ParsedChat::TextEmote(text_id) => {
let (stand_state, flags) = self_player
.single()
.map_or((0, 0), |(_, m, _)| (m.stand_state, m.flags));
let swimming = flags & move_flags::SWIMMING != 0;
let emote_id = emotes.as_deref().and_then(|e| e.text_emote(text_id));
// A chat-only text emote (no Emotes.dbc row) has no EmoteFlags to test and no
// posture to set — it always sends.
let posture = emote_id.and_then(|id| emotes.as_deref()?.posture_state(id));
// GATE A (`CheckEmoteEligible` `0x47db40`): suppresses the anim AND the packet.
let eligible = match emotes.as_deref() {
Some(e) => emote_id
.and_then(|id| e.emote_flags(id))
.is_none_or(|f| emote_send_eligible(f, stand_state, swimming)),
None => true,
};
// GATE B (`0x5ef5f3`): asleep, only a POSTURE emote gets through — which is how
// `/stand` (and `/sit`) is the way out of `/sleep`, while a `/wave` in bed does
// nothing at all.
let awake_or_posture = stand_state != 3 || posture.is_some();
if !eligible || !awake_or_posture {
// Byte-verified whole-send suppression: a seated /bow sends nothing (no packet,
// no anim) — the client's DoEmote returns before building the packet.
debug!(
"chat: emote {text_id} suppressed (eligible {eligible}, \
awake-or-posture {awake_or_posture}, stand {stand_state})"
);
continue;
}
// The stow (`0x5ef630`, VERIFIED unconditional at this point in the flow): every
// emote that reaches here puts the weapons away, instantly — a `/wave` mid-fight
// sheathes in the reference too. The anim layer's one setter owns the idempotency
// refusal, so an already-stowed body costs nothing.
if let Ok((entity, _, _)) = self_player.single() {
chat_out.sheath.write(crate::creature_anim::SheathRequest {
entity,
state: 0,
ceremony: false,
});
}
// The posture branch (`EmoteSpecProc == 1` → `SetStandState(param)`): the emote's
// whole visible effect, since the SERVER deliberately does nothing for a STATE text
// emote (vmangos `HandleTextEmoteOpcode` breaks out for SIT/SLEEP/KNEEL). Through
// the one setter in `crate::player`, which sends `CMSG_STANDSTATECHANGE`, holds the
// local commit until the echo lands, and runs the sit-stow rider.
if let Some(state) = posture {
chat_out
.stand
.write(crate::player::StandStateRequest { state: state as u8 });
}
let target = selection.guid.unwrap_or(0);
match commands
.0
.send(ClientCommand::TextEmote { text_id, target })
{
Ok(()) => info!("chat: sent {msg:?}"),
Err(_) => warn!("chat: not connected; dropped {msg:?}"),
}
}
ParsedChat::CastVis { spell_id, kind } => {
// The dev instrument: fire the synthesized cast edge at the selection, else self —
// the same message the net bridge writes, so it exercises the whole resolve/hold/
// release path (decision 0099's iteration loop).
let me = self_player.single().ok().map(|(e, _, _)| e);
let subject = selection.target.or(me);
match subject {
Some(entity) => {
info!("castvis: spell {spell_id} {kind:?} on {entity}");
cast_events.write(crate::creature_anim::CastEvent {
entity,
spell_id,
kind,
seq: play_seq.next(),
});
// A selected caster's GO also fires its target list at *us* — the one
// live unit the dev loop always has — so a Speed>0 spell's missile and
// impact run end to end (0099 phase 4). A self-cast has no target: no
// missile, matching a hit-less GO.
if kind == crate::creature_anim::CastEventKind::Go {
if let Some(me) = me.filter(|&m| m != entity) {
go_targets.write(crate::creature_anim::SpellGoTargets {
caster: entity,
spell_id,
hits: vec![me],
misses: Vec::new(),
dest: None,
// Dev stand-in only: a real GO's ammo block carries the
// caster's actual ammo; `/castvis` has none, so shot
// spells (`75 go`, `2480 go`) fly the Rough Arrow
// display (5996).
ammo_display_id: Some(5996),
seq: play_seq.next(),
});
}
}
}
None => warn!("castvis: no selection and no self avatar — dropped"),
}
}
ParsedChat::ChatTest => chattest_battery(&mut chat_log),
// `/logout` (and `/camp`) is the reference's own `SlashCmdList["LOGOUT"]` → `Logout()`,
// so it takes the SAME route the game menu's Logout button does (decision 0674): the
// request queues on the script seam, `crate::ui_logout` sends it and narrates the
// server's answer with the CAMP countdown. It used to send `CMSG_LOGOUT_REQUEST`
// straight from here, which meant a field logout looked like nothing happening for 20 s.
ParsedChat::Logout => {
script.queue_session_request(benilla_ui::script::SessionRequest::Logout)
}
// `/quit` `/exit` — the ref's `Quit()`, same queue as the game menu's Exit button, so
// the field countdown and the confirmation are the ones already built (decision 0674).
ParsedChat::Quit => {
script.queue_session_request(benilla_ui::script::SessionRequest::Quit)
}
// The reference's own handler body, run in the VM (the 0668 posture) — `/trade`,
// `/inspect`, the loot-method trio, and `/script`'s raw chunk.
ParsedChat::Lua { body } => {
if let Err(e) = script.run(&body) {
// `/script` hands the player's own typo back to them; a built-in body failing
// is ours to see in the log.
warn!("ui_chat: {body:?}: {e}");
chat_log.push_event(super::event::ChatEvent::text_only(
super::event::ChatEventKind::System,
format!("{e}"),
));
}
}
ParsedChat::Unknown => {
// HELP_TEXT_SIMPLE (the ref's unknown-command reply, ChatEdit_ParseText l.2203).
chat_log.push_event(super::event::ChatEvent::text_only(
super::event::ChatEventKind::System,
"Type '/help' for a listing of a few commands.".to_string(),
));
}
}
}
}
/// `/chattest` (the 0288 instrument): one synthetic line of every renderable form through the
/// real event pipeline — kinds, flags, the language header, channel prefixes, notices, and both
/// link forms (item + player), so formats/colors/links verify in one screen.
fn chattest_battery(log: &mut super::feed::ChatLog) {
use super::event::{ChatEvent, ChatEventKind as K};
let player = |kind: K, text: &str, sender: &str| {
let mut e = ChatEvent::text_only(kind, text.into());
e.sender = sender.into();
e
};
let mut battery: Vec<ChatEvent> = vec![
player(K::Say, "the quick brown fox — say white.", "Testa"),
player(K::Yell, "yell red!", "Testa"),
player(
K::Whisper,
"whisper pink (chime + flash if Combat Log is selected).",
"Testa",
),
player(K::WhisperInform, "the To-echo.", "Testa"),
player(K::Emote, "dances — bare name, orange.", "Testa"),
player(K::Party, "party blue.", "Testa"),
player(K::Guild, "guild green.", "Testa"),
player(K::Officer, "officer deep green.", "Testa"),
player(K::RaidWarning, "raid warning salmon.", "Testa"),
player(K::MonsterSay, "monster say pale yellow.", "Grunt"),
player(K::MonsterYell, "monster yell red.", "Grunt"),
player(K::MonsterWhisper, "monster whisper gray.", "Grunt"),
player(K::MonsterEmote, "%s looks around — emote orange.", "Grunt"),
ChatEvent::text_only(K::System, "system yellow.".into()),
ChatEvent::text_only(
K::Skill,
"Your skill in Testing has increased to 300.".into(),
),
ChatEvent::text_only(
K::Loot,
"You receive loot: |cff1eff00|Hitem:2000:0:0:0|h[Test Blade]|h|r — click me.".into(),
),
ChatEvent::text_only(K::Money, "You loot 1 Gold, 23 Silver, 45 Copper.".into()),
];
// A GM-flagged line, an Orcish header, a numbered channel line, and the join/kick notices.
let mut gm = player(K::Say, "a GM-tagged line.", "Testa");
gm.flag = "GM".into();
battery.push(gm);
let mut orc = player(K::Say, "an Orcish-headered line.", "Grunk");
orc.language = "Orcish".into();
battery.push(orc);
let mut chan = player(K::Channel, "channel pink.", "Testa");
chan.channel = "1. General - Elwynn Forest".into();
battery.push(chan);
let mut join = player(K::ChannelJoin, "", "Testa");
join.channel = "1. General - Elwynn Forest".into();
battery.push(join);
let mut notice = ChatEvent::text_only(K::ChannelNotice, String::new());
notice.channel = "General - Elwynn Forest".into();
notice.notice = "2".into(); // YOU_JOINED
battery.push(notice);
for e in battery {
log.push_event(e);
}
info!("chattest: battery queued");
}
/// The send-side emote **posture-eligibility gate** (wow-re `object-layer/scratch/emote-posture-
/// gate.md`, commit `f9584b45`, §0): the real client's `CheckEmoteEligible` (`0x47db40`), the *only*
/// site that reads an `Emotes.dbc` `EmoteFlags` — called from `DoEmote` (`0x5ef560`) *before*
/// `CMSG_TEXT_EMOTE` is built, so a suppressed emote sends no packet and plays no local anim at all
/// (a seated `/bow` self-censors; the server round-trip never happens). Byte-verified predicate,
/// exactly these four tests in the note's site order — the fifth flag the note decodes (`0x4000`,
/// "requires standing still") only sets an *out* param the client acts on while fear/confuse-
/// controlled, which benilla doesn't model, so it's deliberately not implemented here. `true` =
/// eligible (send + play); `false` = suppress both.
pub(super) fn emote_send_eligible(emote_flags: u32, stand_state: u8, swimming: bool) -> bool {
// `0x0400`: unconditional suppress (client `0x47db58`).
if emote_flags & 0x0400 != 0 {
return false;
}
// `0x0001` + non-zero stand-state: "requires STAND" (client `0x47db65`..`0x47db74`).
if emote_flags & 0x0001 != 0 && stand_state != 0 {
return false;
}
// `0x0080` while swimming (client `0x47db76`..`0x47db7d`).
if emote_flags & 0x0080 != 0 && swimming {
return false;
}
// `0x0200` ABSENT at SLEEP(3)/DEAD(7): the bit means "allowed while asleep/dead" (client
// `0x47db8e`..`0x47db9f`).
if emote_flags & 0x0200 == 0 && matches!(stand_state, 3 | 7) {
return false;
}
true
}