- every build off macOS has been silent since the 09-02 snapshot (B356): the non-macOS device layer was a stub that opened nothing, and nothing had ever compiled the client for another target; that layer is now the same output stream over cpal — one seam, not two stacks: the ring, the render thread and the rebuild-on-device-change loop are one code on every platform, only the device file differs; the audio thread never allocates, a lost or changed default device is noticed at kira's own cadence, and realtime is `SCHED_FIFO` on unix and MMCSS "Pro Audio" on Windows, either refusable, the ring turning a refusal into a degradation; and the two instruments this bug says were missing exist — a Linux and Windows cross-compile under `clippy -D warnings`, and a live output test that opens the real device and asserts the ring never ran dry, because compiling is not sounding (1920) - an emote that requires standing still now refuses on the move: `CheckEmoteEligible`'s `0x4000` arm writes an out-param that `DoEmote` turns into a red "You can't do that while moving!" and aborts; 20 emotes of 78 can raise the line, read off the shipped `Emotes.dbc`; SWIMMING is not in the mask, so a swimmer holding still emotes fine, and `IsSelfControlled` is TRUE for an ordinary player, so a feared or confused player emotes away happily (1904); and the cast path gains its equipped-item rung — the button greyed and pressing it still sent: the reference short-circuits to "fits" on four gates, not one, the miss being a zero `EquippedItemSubClassMask`, which means no item required at all, and the rung sits above the crowd-control validator, so a player both stunned and missing the weapon is told about the weapon; that ladder has six arms, not three — CHARMED, FLEEING and CONFUSED join (1925) - the quest reward's money is the reference's own widget: the tail of the shared `QuestFrameItems_Update` drives `questState.."MoneyFrame"` through `MoneyFrame_Update`, and the two hand-built coin trios become one `MoneyFrameTemplate` instance each at the reference's anchors, which our coins already used; the progress panel stays on coins, being the required-money flow, not the reward one (1928); and the flight master's `!` joins the questgiver sweep, because the green `!` and the gold `!` are the SAME slot, `unit+0xb2c`, torn down once before both queries re-issue; the mouseover re-ask is refuted — the only taxi-status sender has one live caller — and the two queries are not symmetric: the questgiver's goes out on every sweep, the taxi one only when the unit's key moves or on a full sweep, as the wire shows at Dungar Longdrink: 68 questgiver status queries against 1 taxi-node one (1918) - `GetSpellTabInfo` out of range answers `nil, nil, 0, 0` — the reference returns four values on every path that returns (`0x4b3ce0`), and ours answered a single nil copied from another binding's shape; it matters because stock `SpellBookFrame_OnLoad` calls it unconditionally and then does `id > (offset + numSpells)`, which a nil raises on and (0+0) makes true, hiding every button on the page — the behaviour to match; the argument raises the reference's usage string when absent, and the index truncates toward zero THEN decrements (1931); the perf probe gains a `WOW_PROBE_UNCAP=vsync` arm, because the uncapped present mode is a request, not a schedule — measured, it taxes the number it was meant to pin (11.80 `cpu_ms` uncapped against 14.65 under it), so the default stays uncapped; and the last three test temp paths are keyed on the pid, after concurrent runs raced on one fixed shared file Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1545 lines
75 KiB
Rust
1545 lines
75 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)
|
||
}
|
||
|
||
/// The target guid a `CMSG_TEXT_EMOTE` carries — the current selection, **except that emoting at
|
||
/// yourself goes out untargeted** (decision 1282).
|
||
///
|
||
/// That exception is `DoEmote`'s last act before it builds the packet, VERIFIED at `0x5ef611`:
|
||
///
|
||
/// ```text
|
||
/// 5ef611: mov eax,[edi+8] ; &ownGuid
|
||
/// 5ef614: mov ecx,[ebp+0xc] ; the target guid low ... cmp / jne past
|
||
/// 5ef61e: cmp edx,[eax+4] ; ... and high ... cmp / jne past
|
||
/// 5ef623: mov [ebp+0xc],ebx ; ebx = 0 (xor at 0x5ef56b, never reloaded)
|
||
/// 5ef626: mov [ebp+0x10],ebx ; -> the packet's target guid is ZERO
|
||
/// ```
|
||
///
|
||
/// **This is why vanilla has no self-emote sentence.** 1274 read the receive-side composer
|
||
/// correctly and then asserted an outcome — "You wave at ⟨YourName⟩." — from an input this gate
|
||
/// makes unreachable: the server never echoes your own name back as the target, so the untargeted
|
||
/// column is what you *and* everyone in range read ("You wave." / "Sam waves."). Without the gate
|
||
/// we would emit "Sam waves at Sam." to the whole zone.
|
||
///
|
||
/// Compared by **entity** rather than guid, which is the same predicate and costs no 17th system
|
||
/// param: `SelfPlayer` marks the entity whose guid *is* `SelfGuid` (`net.rs`), and [`Selection`]'s
|
||
/// only writer (`target::scan`) sets `target` and `guid` together from one streamed entity — so
|
||
/// "the selection is my entity" and "the selection is my guid" cannot disagree.
|
||
pub(super) fn emote_target(selection: &Selection, me: Option<Entity>) -> u64 {
|
||
match selection.guid {
|
||
Some(guid) if !(me.is_some() && selection.target == me) => guid,
|
||
_ => 0,
|
||
}
|
||
}
|
||
|
||
#[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).
|
||
/// - `world` feeds **`/liquid`**, the swim diagnostic (decision 0634 follow-up): the interior
|
||
/// claim is what decides which liquid surfaces the swim query may see, and it arrives with the
|
||
/// query rather than beside it.
|
||
/// - `stores`/`self_store`/`factions`/`reputations`/`kinds` 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 — and
|
||
/// with them the V-plate CATEGORY, which turns on a different predicate entirely
|
||
/// ([`crate::target::ring::plate_is_friendly`]) and so cannot be read off the rank.
|
||
#[derive(bevy::ecs::system::SystemParam)]
|
||
pub(super) struct ChatProbes<'w, 's> {
|
||
camera: Query<
|
||
'w,
|
||
's,
|
||
&'static GlobalTransform,
|
||
(With<benilla_world::view::WorldCamera>, Without<SelfPlayer>),
|
||
>,
|
||
clock: Option<Res<'w, benilla_world::lighting::GameClock>>,
|
||
/// The `/liquid` instrument's whole world side — the claims, the verdict, the candidates.
|
||
world: benilla_world::world_point::WorldPoint<'w, 's>,
|
||
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>,
|
||
/// The subject's [`benilla_protocol::EntityKind`] — the plate gate's own player test, so the
|
||
/// diagnostic reports the branch the gate actually took rather than a second guess at it.
|
||
kinds: Query<'w, 's, &'static crate::net::NetEntity>,
|
||
/// **`/partytest raid`** (decision 1549): the synthetic raid seats US as its leader, and
|
||
/// "leader" on this wire is a guid. Here rather than as a 17th drain parameter for the
|
||
/// reason this struct exists at all — the drain is at Bevy's 16-param ceiling.
|
||
self_guid: Res<'w, crate::net::SelfGuid>,
|
||
/// The map `/partytest ping` stamps its synthetic ping with — a ping carries no map on the
|
||
/// wire either, so "the one we are standing on" is the client's answer in both paths.
|
||
map: Option<Res<'w, benilla_world::world_map::CurrentMap>>,
|
||
}
|
||
|
||
/// 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>,
|
||
/// **`/partytest ping`** (decision 1596) — the minimap ping's setter. The LOCAL leg needs no
|
||
/// instrument (click the map), but a *group member's* ping otherwise needs a second client
|
||
/// logged in and standing somewhere else; this seats one as if Alice had sent it, through the
|
||
/// same `seat` the wire arm calls, so the remote leg — the `partyN` token, the marker, the
|
||
/// pin holding while you walk — is exercisable solo.
|
||
ping: ResMut<'w, crate::minimap::MinimapPing>,
|
||
/// The red UIErrorsFrame line by GlobalStrings key — the emote-while-moving refusal is its one
|
||
/// tenant here (decision 1904). It rides this bundle rather than the system's own parameter
|
||
/// list because that list is at Bevy's arity limit; checked first against every other param,
|
||
/// since a resource reachable twice from one system is a `B0002` panic on the first live frame
|
||
/// (1903). Nothing else in this system touches `UiErrorKeys`.
|
||
ui_errors: ResMut<'w, crate::ui_action::UiErrorKeys>,
|
||
}
|
||
|
||
// 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,
|
||
world,
|
||
stores,
|
||
self_store,
|
||
factions,
|
||
reputations,
|
||
guids,
|
||
kinds,
|
||
self_guid,
|
||
map,
|
||
} = &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
|
||
// `benilla-config/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(path) = crate::local_state::shots_path() {
|
||
if let Some(dir) = path.parent() {
|
||
let _ = std::fs::create_dir_all(dir);
|
||
}
|
||
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 = world.claim(benilla_world::world_point::Subject::Player);
|
||
let eye = world.claim(benilla_world::world_point::Subject::Eye);
|
||
let verdict = world.liquid_at(benilla_world::world_point::Subject::Player, wow);
|
||
let mut lines = vec![
|
||
format!(
|
||
"liquid: feet [{:.2}, {:.2}, {:.2}] · claim {claim:?} ({})",
|
||
wow[0],
|
||
wow[1],
|
||
wow[2],
|
||
match world.interior() {
|
||
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 =
|
||
world.describe_liquid_at(benilla_world::world_point::Subject::Player, wow);
|
||
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 subject_entity = subject_guid.and_then(|g| guids.0.get(&g).copied());
|
||
let target_store = subject_entity.and_then(|e| stores.get(e).ok());
|
||
let own_store = self_store.iter().next();
|
||
// The plate gate's own player test, read the same way it reads it.
|
||
let is_player = subject_entity
|
||
.and_then(|e| kinds.get(e).ok())
|
||
.is_some_and(|n| n.kind == benilla_protocol::EntityKind::Player);
|
||
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
|
||
)
|
||
));
|
||
// **The at-war bit, named** (decision 1674). It is the entire content of the
|
||
// player→unit reaction's leg 3, it is what the world cursor, the plate category and
|
||
// `UnitCanAttack` all turn on for a reputation-slot faction, and it is printed
|
||
// nowhere else — so "why does this friendly NPC take the sword?" is unanswerable
|
||
// from any other line here.
|
||
let war = (|| {
|
||
let catalog = factions.as_deref()?.catalog();
|
||
let tpl = catalog.template(target_store?.0.unit_faction_template()?)?;
|
||
let at_war =
|
||
crate::target::ring::at_war_with(catalog, reputations, tpl.faction);
|
||
Some(match at_war {
|
||
Some(at_war) => format!(
|
||
"faction {} {:?} owns reputation slot {} → leg 3 answers with AT WAR \
|
||
= {at_war} (reaction {}); the standing is never read",
|
||
tpl.faction,
|
||
catalog.faction_name(tpl.faction).unwrap_or("<unnamed>"),
|
||
catalog
|
||
.reputation_faction(tpl.faction)
|
||
.map_or(-1, |i| i.rep_index),
|
||
if at_war { "hostile" } else { "friendly" },
|
||
),
|
||
None => format!(
|
||
"faction {} {:?} has NO reputation slot → the template comparator \
|
||
answers; at-war does not apply",
|
||
tpl.faction,
|
||
catalog.faction_name(tpl.faction).unwrap_or("<unnamed>"),
|
||
),
|
||
})
|
||
})()
|
||
.unwrap_or_else(|| "at-war not evaluated (no catalog, store or template)".into());
|
||
lines.push(format!("reaction: {war}"));
|
||
// The world cursor's two branch predicates, in the order `0x482200` runs them —
|
||
// `CanInteract` picks the service ladder or the loot/skin/attack block, then
|
||
// `CanAttack` decides the sword. Neither is a reaction threshold, so neither can
|
||
// be read off RANK above.
|
||
let interactable = crate::target::can_interact(
|
||
target_store,
|
||
factions.as_deref(),
|
||
reputations,
|
||
own_store,
|
||
);
|
||
let attackable = crate::target::can_attack(
|
||
target_store,
|
||
factions.as_deref(),
|
||
reputations,
|
||
own_store,
|
||
);
|
||
lines.push(format!(
|
||
"reaction: CURSOR npc_flags 0x{:04x} · can_interact {interactable} · \
|
||
can_attack {attackable} → {}",
|
||
target_store.map_or(0, |s| s.0.unit_npc_flags()),
|
||
if interactable {
|
||
"the NPC service ladder (a bit it consults → its cursor; none → Point)"
|
||
} else if attackable {
|
||
"the ATTACK sword (grayed past 10.45 yd)"
|
||
} else {
|
||
"nothing matched → Point"
|
||
},
|
||
));
|
||
// The V-plate CATEGORY, which is a **different predicate** from the rank above
|
||
// (`CanAttack` player→unit, plus `CanCooperate` for a player subject — decision
|
||
// 1530) and so cannot be read off it. Both legs are printed, with the faction-group
|
||
// masks they compare: a player in the wrong bucket is a mask disagreement roughly
|
||
// every time, and the mask is invisible everywhere else in the client. A GM-mode
|
||
// character is mask 0 on a vmangos realm (`.gm on` → faction template 35), which is
|
||
// exactly how a friendly player ends up with an enemy plate.
|
||
let mask = |s| {
|
||
crate::target::ring::faction_group_mask(factions.as_deref(), s)
|
||
.map_or("none".to_string(), |m| m.to_string())
|
||
};
|
||
let friendly = crate::target::ring::plate_is_friendly(
|
||
factions.as_deref(),
|
||
reputations,
|
||
target_store,
|
||
own_store,
|
||
is_player,
|
||
);
|
||
lines.push(format!(
|
||
"reaction: plate is_player {is_player} (OBJECT_FIELD_TYPE says {:?}; the two \
|
||
must agree — the predicates read the field, 1674) · faction-group mask self {} \
|
||
target {} · can_cooperate {} · can_attack(player→unit) {}",
|
||
target_store.and_then(|s| s.0.object_type()),
|
||
mask(own_store),
|
||
mask(target_store),
|
||
crate::target::ring::can_cooperate_with_player(
|
||
factions.as_deref(),
|
||
target_store,
|
||
own_store
|
||
),
|
||
crate::target::ring::can_attack_from_player(
|
||
factions.as_deref(),
|
||
reputations,
|
||
target_store,
|
||
own_store,
|
||
is_player,
|
||
),
|
||
));
|
||
lines.push(format!(
|
||
"reaction: PLATE CATEGORY {} — this unit plates under {}",
|
||
if friendly { "FRIENDLY" } else { "ENEMY" },
|
||
if friendly { "SHIFT-V" } else { "V" },
|
||
));
|
||
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(),
|
||
// The raid grid's instrument (decision 1549) — 25 synthetic rows, us leading.
|
||
"raid" => {
|
||
for line in crate::ui_party::synthetic_raid(&mut group, &mut names, self_guid.0)
|
||
{
|
||
chat_log.push_event(super::event::ChatEvent::text_only(
|
||
super::event::ChatEventKind::System,
|
||
line,
|
||
));
|
||
}
|
||
}
|
||
"invite" => group.pending_invite = Some("Partner".to_string()),
|
||
// A group member's ping, without the group member (decision 1596). 35 yd
|
||
// north-east: off both axes so a mirrored sign is obvious, and inside every
|
||
// outdoor view radius (the tightest is 66.7 yd) — indoors at the two tightest
|
||
// zooms it is off the disc, which is itself worth seeing (the marker hides and
|
||
// the ping survives, so walking back brings it into view).
|
||
"ping" => {
|
||
let line = if let Ok((_, _, tf)) = self_player.single() {
|
||
let w = benilla_assets::coords::bevy_to_wow(tf.translation());
|
||
// +x is north, −y is east (0203's north-up mapping).
|
||
let at = (w[0] + 24.75, w[1] - 24.75);
|
||
chat_out
|
||
.ping
|
||
.seat(at, map.as_ref().map_or(0, |m| m.0), 0xF001);
|
||
format!(
|
||
"partytest: Alice pinged ({:.0}, {:.0}) — 35 yd NE, party1, 5 s",
|
||
at.0, at.1
|
||
)
|
||
} else {
|
||
"partytest: no player position — cannot place a ping".to_string()
|
||
};
|
||
chat_log.push_event(super::event::ChatEvent::text_only(
|
||
super::event::ChatEventKind::System,
|
||
line,
|
||
));
|
||
}
|
||
// 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 });
|
||
}
|
||
}
|
||
// Fire-and-forget: the server judges leader/group state and answers with either a
|
||
// raid-typed SMSG_GROUP_LIST (the "joined a raid group" line rides the apply) or a
|
||
// SMSG_PARTY_COMMAND_RESULT error, both already handled.
|
||
ParsedChat::ConvertRaid => {
|
||
let _ = commands.0.send(ClientCommand::GroupRaidConvert);
|
||
}
|
||
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",
|
||
"Spells: /cast <name> [(Rank N)]",
|
||
"Macros: /macro (/m) opens the window, /macrohelp explains them",
|
||
"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(),
|
||
));
|
||
}
|
||
}
|
||
// `ChatFrame_DisplayMacroHelpText` (ChatFrame.lua): the five shipped lines, read off
|
||
// the VM's own GlobalStrings so they are the install's text, never a transcription
|
||
// (decision 0983; the 0881 posture — the strings are data, the handler is ours).
|
||
ParsedChat::MacroHelp => {
|
||
for i in 1..=5 {
|
||
let key = format!("MACRO_HELP_TEXT_LINE{i}");
|
||
let Ok(line) = script.lua().globals().get::<String>(key.as_str()) else {
|
||
continue;
|
||
};
|
||
if !line.is_empty() {
|
||
chat_log.push_event(super::event::ChatEvent::text_only(
|
||
super::event::ChatEventKind::System,
|
||
line,
|
||
));
|
||
}
|
||
}
|
||
}
|
||
// `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 —
|
||
// except its `0x4000` arm, which refuses out loud instead (decision 1904).
|
||
let gate = match emotes.as_deref() {
|
||
Some(e) => emote_id
|
||
.and_then(|id| e.emote_flags(id))
|
||
.map_or(EmoteGate::Send, |f| {
|
||
emote_send_eligible(f, stand_state, swimming, flags)
|
||
}),
|
||
None => EmoteGate::Send,
|
||
};
|
||
// The `0x4000` arm's own half of the law, which lives in `DoEmote` (`0x5ef5d0`)
|
||
// and not in the eligibility check: the red line fires only while the caster is
|
||
// acting under its OWN control, so a feared or confused player emotes normally.
|
||
if gate == EmoteGate::Moving {
|
||
let controlled = self_store
|
||
.single()
|
||
.is_ok_and(|s| crate::player::self_controlled(s.0.unit_flags()));
|
||
if controlled {
|
||
debug!("chat: emote {text_id} refused — moving (ERR_NOEMOTEWHILERUNNING)");
|
||
chat_out
|
||
.ui_errors
|
||
.0
|
||
.push(crate::ui_action::UiError::key("ERR_NOEMOTEWHILERUNNING"));
|
||
continue;
|
||
}
|
||
}
|
||
let eligible = gate != EmoteGate::Suppressed;
|
||
// 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 = emote_target(
|
||
&selection,
|
||
self_player.single().ok().map(|(entity, _, _)| entity),
|
||
);
|
||
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,
|
||
ground,
|
||
} => {
|
||
// 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:?} ground={ground} 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. `ground` instead sends the pure DEST
|
||
// shape — empty lists, a point 15 yd ahead of the player at the player's
|
||
// own height (a dev stand-in for the terrain pick; flat enough to watch a
|
||
// Flare fly) — which is the only shape that reaches the location fallback.
|
||
if kind == crate::creature_anim::CastEventKind::Go {
|
||
let dest = ground.then(|| {
|
||
self_player.single().ok().map(|(_, _, tf)| {
|
||
tf.translation() + tf.forward().as_vec3() * 15.0
|
||
})
|
||
});
|
||
if let Some(dest) = dest {
|
||
go_targets.write(crate::creature_anim::SpellGoTargets {
|
||
caster: entity,
|
||
spell_id,
|
||
hits: Vec::new(),
|
||
misses: Vec::new(),
|
||
dest,
|
||
ammo_display_id: None,
|
||
seq: play_seq.next(),
|
||
});
|
||
} else 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)
|
||
}
|
||
// `/reload` and `/console reloadUI` — the same deferred rebuild `ReloadUI()` queues
|
||
// (decision 1291), through the same session seam as the exit verbs above.
|
||
ParsedChat::ReloadUi => {
|
||
script.queue_session_request(benilla_ui::script::SessionRequest::ReloadUi)
|
||
}
|
||
ParsedChat::ConsoleUnknown { cmd } => {
|
||
let text = if cmd.is_empty() {
|
||
"console: no command given (this client implements: reloadUI)".to_string()
|
||
} else {
|
||
format!(
|
||
"console: '{cmd}' is not implemented (this client implements: reloadUI)"
|
||
)
|
||
};
|
||
chat_log.push_event(super::event::ChatEvent::text_only(
|
||
super::event::ChatEventKind::System,
|
||
text,
|
||
));
|
||
}
|
||
// 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 => {
|
||
// Before the help line: an ADDON may claim it (decision 1195). The reference
|
||
// resolves `SlashCmdList` in the same pass as its own commands; ours runs after
|
||
// the boot table misses, which gives the same precedence — a shipped command can
|
||
// never be shadowed — without moving our handlers into Lua.
|
||
if let Some(rest) = msg.strip_prefix('/') {
|
||
let rest = rest.trim();
|
||
let (cmd, args) = rest.split_once(char::is_whitespace).unwrap_or((rest, ""));
|
||
if script.run_slash_command(cmd, args.trim()) {
|
||
continue;
|
||
}
|
||
}
|
||
// 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);
|
||
}
|
||
combat_log_battery(log);
|
||
info!("chattest: battery queued");
|
||
}
|
||
|
||
/// The combat-log half of `/chattest` (B297): one synthetic line of every family, through the real
|
||
/// [`super::combat`] composer and the real drain — so the whole `COMBAT_*`/`SPELL_*` block's
|
||
/// wording, colours and window routing verify in one screen without a fight.
|
||
///
|
||
/// It exists because the alternative is a live combat probe, and unattended ones are out (the
|
||
/// director's standing rule). The lines are driven with **guid 0 on both endpoints and literal
|
||
/// names in the fills**, so the drain's name resolve is a no-op and nothing here touches the wire;
|
||
/// everything downstream of that — the family's template lookup, the slot fill, the chat type, the
|
||
/// route, the `CHAT_MSG_*` fire an addon sees — is the production path exactly.
|
||
///
|
||
/// The chat TYPES are picked to show the block's spread rather than one row: your own melee and
|
||
/// spells, your pet, a hostile player, and a creature hitting you (which is the one that is red).
|
||
fn combat_log_battery(log: &mut super::feed::ChatLog) {
|
||
use super::combat::{self, Family, Fills, PendingCombat, Variant};
|
||
use super::event::ChatEventKind as K;
|
||
|
||
let fills = |amount: i64, school: Option<u8>, power: Option<u32>| Fills {
|
||
attacker: "Gnoll Brute".into(),
|
||
victim: "Target Dummy".into(),
|
||
spell: "Fireball".into(),
|
||
school,
|
||
power,
|
||
amount,
|
||
amount2: amount / 3,
|
||
power2: power,
|
||
named: "Copper Bar".into(),
|
||
trailers: None,
|
||
};
|
||
let line = |kind: K, family: Family, variant: Variant, f: Fills| PendingCombat {
|
||
kind,
|
||
family,
|
||
variant,
|
||
subject: 0,
|
||
object: 0,
|
||
named: super::combat::Named::Ready,
|
||
fills: f,
|
||
tries: 0,
|
||
};
|
||
let battery = [
|
||
// Your own melee, plain and crit, and the school form.
|
||
line(
|
||
K::CombatSelfHits,
|
||
combat::COMBATHIT,
|
||
Variant::SelfOther,
|
||
fills(120, None, None),
|
||
),
|
||
line(
|
||
K::CombatSelfHits,
|
||
combat::COMBATHITCRIT,
|
||
Variant::SelfOther,
|
||
fills(240, None, None),
|
||
),
|
||
line(
|
||
K::CombatSelfHits,
|
||
combat::COMBATHITSCHOOL,
|
||
Variant::SelfOther,
|
||
fills(35, Some(2), None),
|
||
),
|
||
line(
|
||
K::CombatSelfMisses,
|
||
combat::MISSED,
|
||
Variant::SelfOther,
|
||
fills(0, None, None),
|
||
),
|
||
// A creature hitting you — the red rows, and the ones a player notices first.
|
||
line(
|
||
K::CombatCreatureVsSelfHits,
|
||
combat::COMBATHIT,
|
||
Variant::OtherSelf,
|
||
fills(87, None, None),
|
||
),
|
||
line(
|
||
K::CombatCreatureVsSelfMisses,
|
||
combat::VSDODGE,
|
||
Variant::OtherSelf,
|
||
fills(0, None, None),
|
||
),
|
||
line(
|
||
K::CombatCreatureVsSelfMisses,
|
||
combat::VSBLOCK,
|
||
Variant::OtherSelf,
|
||
fills(0, None, None),
|
||
),
|
||
line(
|
||
K::CombatCreatureVsSelfMisses,
|
||
combat::VSPARRY,
|
||
Variant::OtherSelf,
|
||
fills(0, None, None),
|
||
),
|
||
// Your own spells: the gold pair, and the outcomes that are not damage.
|
||
line(
|
||
K::SpellSelfDamage,
|
||
combat::SPELLLOGSCHOOL,
|
||
Variant::SelfOther,
|
||
fills(412, Some(2), None),
|
||
),
|
||
line(
|
||
K::SpellSelfDamage,
|
||
combat::SPELLLOGCRITSCHOOL,
|
||
Variant::SelfOther,
|
||
fills(830, Some(2), None),
|
||
),
|
||
line(
|
||
K::SpellSelfDamage,
|
||
combat::SPELLMISS,
|
||
Variant::SelfOther,
|
||
fills(0, None, None),
|
||
),
|
||
line(
|
||
K::SpellSelfDamage,
|
||
combat::SPELLRESIST,
|
||
Variant::SelfOther,
|
||
fills(0, None, None),
|
||
),
|
||
line(
|
||
K::SpellSelfBuff,
|
||
combat::HEALED,
|
||
Variant::SelfOther,
|
||
fills(560, None, None),
|
||
),
|
||
line(
|
||
K::SpellSelfBuff,
|
||
combat::POWERGAIN,
|
||
Variant::SelfSelf,
|
||
fills(90, None, Some(0)),
|
||
),
|
||
// Your pet, a hostile player, and the periodic + shield rows.
|
||
line(
|
||
K::CombatPetHits,
|
||
combat::COMBATHIT,
|
||
Variant::OtherOther,
|
||
fills(64, None, None),
|
||
),
|
||
line(
|
||
K::SpellHostilePlayerDamage,
|
||
combat::SPELLLOGSCHOOL,
|
||
Variant::OtherSelf,
|
||
fills(305, Some(5), None),
|
||
),
|
||
line(
|
||
K::SpellPeriodicSelfDamage,
|
||
combat::PERIODICAURADAMAGE,
|
||
Variant::SelfOther,
|
||
fills(48, Some(5), None),
|
||
),
|
||
line(
|
||
K::SpellPeriodicSelfBuffs,
|
||
combat::PERIODICAURAHEAL,
|
||
Variant::SelfSelf,
|
||
fills(75, None, None),
|
||
),
|
||
line(
|
||
K::SpellDamageShieldsOnSelf,
|
||
combat::DAMAGESHIELD,
|
||
Variant::SelfOther,
|
||
fills(22, Some(1), None),
|
||
),
|
||
line(
|
||
K::SpellSelfBuff,
|
||
combat::SPELLPOWERLEECH,
|
||
Variant::SelfOther,
|
||
fills(150, None, Some(0)),
|
||
),
|
||
// ── the completeness block (1703) ────────────────────────────────────────────────
|
||
// One line per family that was absent until now, in the order a player meets them.
|
||
// The `Named` slot's text is already in the fills, so these drive the same drain the
|
||
// wire does with the same composer — only the resolve hops are short-circuited.
|
||
line(
|
||
K::CombatHostileDeath,
|
||
combat::UNITDIES,
|
||
Variant::OtherOther,
|
||
fills(0, None, None),
|
||
),
|
||
line(
|
||
K::CombatHostileDeath,
|
||
combat::SELFKILLOTHER,
|
||
Variant::OtherOther,
|
||
fills(0, None, None),
|
||
),
|
||
line(
|
||
K::CombatFriendlyDeath,
|
||
combat::UNITDESTROYEDOTHER,
|
||
Variant::OtherOther,
|
||
fills(0, None, None),
|
||
),
|
||
line(
|
||
K::SpellPeriodicSelfDamage,
|
||
combat::AURAADDED_HARMFUL,
|
||
Variant::SelfOther,
|
||
fills(0, None, None),
|
||
),
|
||
line(
|
||
K::SpellPeriodicSelfBuffs,
|
||
combat::AURAADDED_HELPFUL,
|
||
Variant::SelfOther,
|
||
fills(0, None, None),
|
||
),
|
||
line(
|
||
K::SpellPeriodicSelfDamage,
|
||
combat::AURAAPPLICATIONADDED_HARMFUL,
|
||
Variant::SelfOther,
|
||
fills(3, None, None),
|
||
),
|
||
line(
|
||
K::SpellAuraGoneSelf,
|
||
combat::AURAREMOVED,
|
||
Variant::SelfOther,
|
||
fills(0, None, None),
|
||
),
|
||
line(
|
||
K::SpellBreakAura,
|
||
combat::AURADISPEL,
|
||
Variant::SelfOther,
|
||
fills(0, None, None),
|
||
),
|
||
line(
|
||
K::SpellItemEnchantments,
|
||
combat::ITEMENCHANTMENTADD,
|
||
Variant::SelfSelf,
|
||
fills(0, None, None),
|
||
),
|
||
line(
|
||
K::SpellTradeskills,
|
||
combat::TRADESKILL_LOG,
|
||
Variant::SelfOther,
|
||
fills(0, None, None),
|
||
),
|
||
line(
|
||
K::SpellSelfDamage,
|
||
combat::SPELLINTERRUPT,
|
||
Variant::SelfOther,
|
||
fills(0, None, None),
|
||
),
|
||
line(
|
||
K::SpellSelfDamage,
|
||
combat::SPELLEXTRAATTACKS_SINGULAR,
|
||
Variant::SelfOther,
|
||
fills(1, None, None),
|
||
),
|
||
line(
|
||
K::SpellSelfDamage,
|
||
combat::SPELLSPLITDAMAGE,
|
||
Variant::SelfOther,
|
||
fills(66, None, None),
|
||
),
|
||
line(
|
||
K::SpellSelfDamage,
|
||
combat::PROCRESIST,
|
||
Variant::SelfOther,
|
||
fills(0, None, None),
|
||
),
|
||
line(
|
||
K::CombatCreatureVsSelfHits,
|
||
combat::VSENV_FALLING,
|
||
Variant::SelfOther,
|
||
fills(94, None, None),
|
||
),
|
||
line(
|
||
K::CombatMiscInfo,
|
||
combat::DURABILITYDAMAGE_DEATH,
|
||
Variant::OtherOther,
|
||
fills(0, None, None),
|
||
),
|
||
// The two families whose `Named` slot is not an item name: a faction and a failure
|
||
// reason. Overridden here so the battery reads as the sentences a player will see.
|
||
line(
|
||
K::CombatFactionChange,
|
||
combat::FACTION_STANDING_INCREASED,
|
||
Variant::OtherOther,
|
||
Fills {
|
||
named: "Stormwind".into(),
|
||
..fills(250, None, None)
|
||
},
|
||
),
|
||
line(
|
||
K::SpellFailedLocalPlayer,
|
||
combat::SPELLFAILCAST,
|
||
Variant::SelfOther,
|
||
Fills {
|
||
named: "Not enough mana".into(),
|
||
..fills(0, None, None)
|
||
},
|
||
),
|
||
// The trailer pass, on the one family that can show all six.
|
||
line(
|
||
K::CombatSelfHits,
|
||
combat::COMBATHIT,
|
||
Variant::SelfOther,
|
||
Fills {
|
||
trailers: Some(combat::Trailers {
|
||
absorbed: 12,
|
||
resisted: 7,
|
||
blocked: 30,
|
||
hit_info: 0x4000,
|
||
}),
|
||
..fills(210, None, None)
|
||
},
|
||
),
|
||
];
|
||
for l in battery {
|
||
log.push_combat(l);
|
||
}
|
||
}
|
||
|
||
/// 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. `true` = eligible (send + play); `false` =
|
||
/// suppress both.
|
||
///
|
||
/// # The fifth flag, `0x4000` — NOT built, and the reason recorded here was WRONG
|
||
///
|
||
/// What `CheckEmoteEligible 0x47db40` decides about one emote — three outcomes, not two, which is
|
||
/// why this replaced a `bool` (decision 1904).
|
||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||
pub(super) enum EmoteGate {
|
||
/// Send it: packet, posture, animation.
|
||
Send,
|
||
/// **Silently** dropped — no packet, no animation, and no line. A seated `/bow` is this.
|
||
Suppressed,
|
||
/// The `0x4000` "requires standing still" arm tripped. The reference does NOT suppress here:
|
||
/// `CheckEmoteEligible` writes an out-param and `DoEmote` reads it at `0x5ef5d0`, raising
|
||
/// `ERR_NOEMOTEWHILERUNNING` and aborting **only when `IsSelfControlled 0x5fa550` is
|
||
/// non-zero** — so the toast fires for an ordinary moving player and a **feared** one emotes
|
||
/// away happily. That caster-state test is the caller's, exactly as it is the reference's.
|
||
Moving,
|
||
}
|
||
|
||
/// This doc used to say `0x4000` ("requires standing still") *"only sets an out param the client
|
||
/// acts on while fear/confuse-controlled, which benilla doesn't model"*. A §5 trio carve of the
|
||
/// neighbouring stand-state gate re-read the leg and **inverted that polarity**
|
||
/// (wow-re `standstate-movement-trigger.md` §5.6, 2026-08-23; decision 1582). The bytes:
|
||
///
|
||
/// - `0x47dbab` tests `EmoteFlags & 0x4000`, and if set, `0x47dbb3` tests the live `CMovement`
|
||
/// word against **`0x20ff`** — the four direction bits, the two turn bits, the two pitch bits and
|
||
/// `FALLING` — writing `*out = 1`. Pointedly **not** `SWIMMING`.
|
||
/// - `DoEmote` then reads that out-param at `0x5ef5d0` and, when `0x5fa550` returns non-zero,
|
||
/// raises message `0x139` = **`ERR_NOEMOTEWHILERUNNING`** ("You can't do that while moving!") and
|
||
/// **aborts**: no emote packet, no `SetStandState`.
|
||
/// - `0x5fa550` is `IsSelfControlled`, not "is fear/confuse-controlled" — it returns **1** for an
|
||
/// ordinary player and **0** while `UNIT_FIELD_FLAGS & 0xc00004` (DISABLE_MOVE / CONFUSED /
|
||
/// FLEEING). So the toast fires for the **ordinary moving player** and is *suppressed* while
|
||
/// feared. That is the exact reversal of what this comment claimed.
|
||
///
|
||
/// It stays unbuilt — deliberately, and as a named gap rather than a settled reading: implementing
|
||
/// it puts a red error line on screen for every emote typed while moving, turning, pitching or
|
||
/// falling, which is a visible behaviour change for the director to weigh rather than a bug fix to
|
||
/// slip in. What it is **not** is a water gate: `0x20ff` carries no `SWIMMING`, so it has nothing
|
||
/// to do with B155 and could never have covered it (`super::super::tests::
|
||
/// the_posture_emotes_carry_no_swim_suppression_flag` is the data half of that same negative). The
|
||
/// posture path's water refusal lives in [`crate::player::state`]'s `stand_state_refused`.
|
||
pub(super) fn emote_send_eligible(
|
||
emote_flags: u32,
|
||
stand_state: u8,
|
||
swimming: bool,
|
||
move_flags: u32,
|
||
) -> EmoteGate {
|
||
// `0x0400`: unconditional suppress (client `0x47db58`).
|
||
if emote_flags & 0x0400 != 0 {
|
||
return EmoteGate::Suppressed;
|
||
}
|
||
// `0x0001` + non-zero stand-state: "requires STAND" (client `0x47db65`..`0x47db74`).
|
||
if emote_flags & 0x0001 != 0 && stand_state != 0 {
|
||
return EmoteGate::Suppressed;
|
||
}
|
||
// `0x0080` while swimming (client `0x47db76`..`0x47db7d`).
|
||
if emote_flags & 0x0080 != 0 && swimming {
|
||
return EmoteGate::Suppressed;
|
||
}
|
||
// `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 EmoteGate::Suppressed;
|
||
}
|
||
// `0x4000` "requires standing still" (client `0x47dbab`), tested against the live CMovement
|
||
// word at `0x47dbb3` — the reference's own `0x20ff`, which is exactly
|
||
// [`crate::creature_anim::move_flags::INTEGRATED`]: the four direction bits, the two turn
|
||
// bits, the two pitch bits and FALLING. Pointedly **not** SWIMMING.
|
||
//
|
||
// This arm does not suppress: it writes `*out = 1`, and DoEmote turns that into a red line
|
||
// (or not) on a caster-state test the caller owns — see [`EmoteGate::Moving`].
|
||
if emote_flags & 0x4000 != 0 && move_flags & crate::creature_anim::move_flags::INTEGRATED != 0 {
|
||
return EmoteGate::Moving;
|
||
}
|
||
EmoteGate::Send
|
||
}
|
||
|
||
/// Turn an addon's `SendChatMessage` calls into sends (decision 1199).
|
||
///
|
||
/// Its own system rather than a branch inside [`drain_chat_input`], for the reason
|
||
/// `benilla_ui::script::chat_send`'s module doc gives: the box's drain runs the **slash grammar**
|
||
/// and this path must not. An addon announcing `"/dance"` is saying six characters.
|
||
///
|
||
/// An unknown chat-type token is **reported, not guessed**. `SendChatMessage(msg, "RAID_WARNING")`
|
||
/// silently going to /say is worse than not going: the addon believes it warned the raid.
|
||
pub(super) fn drain_addon_chat_sends(
|
||
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
|
||
commands: Res<NetCommands>,
|
||
mut chat_log: ResMut<super::feed::ChatLog>,
|
||
) {
|
||
let Some(mut script) = script else {
|
||
return;
|
||
};
|
||
for send in script.take_chat_sends() {
|
||
let Some(kind) = super::edit::SendType::from_token(&send.chat_type) else {
|
||
warn!(
|
||
"chat: SendChatMessage with unknown type {:?}",
|
||
send.chat_type
|
||
);
|
||
chat_log.push_event(super::event::ChatEvent::text_only(
|
||
super::event::ChatEventKind::System,
|
||
format!("Unknown chat type \"{}\".", send.chat_type),
|
||
));
|
||
continue;
|
||
};
|
||
let cmd = ClientCommand::Chat {
|
||
kind: kind.wire(),
|
||
target: send.target,
|
||
text: send.text,
|
||
};
|
||
if commands.0.send(cmd).is_err() {
|
||
warn!("chat: not connected; addon line dropped");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Turn an addon's `SendAddonMessage` calls into sends (decision 1235) — the addon-to-addon lane,
|
||
/// not speech.
|
||
///
|
||
/// Its own drain rather than a branch in [`drain_addon_chat_sends`] because the two queues carry
|
||
/// different things: a `SendChatMessage` line still needs a chat-type token resolved and may be
|
||
/// refused here, while a `SendAddonMessage` broadcast arrives **already validated** — the binding
|
||
/// owns the four-value whitelist, the `prefix` TAB `message` composition and the outside-a-raid
|
||
/// downgrade, exactly as the reference's `0x49f920` does, so nothing is left for this side to
|
||
/// decide. That is why there is no "unknown distribution" arm here and no way to add one: the
|
||
/// queue's own type is the enum.
|
||
///
|
||
/// **The lane is traced in both directions, under one tag.** An addon channel is invisible by
|
||
/// construction — it never reaches a chat frame, which is the entire point of `LANG_ADDON` — so
|
||
/// without a line here a broadcast that went out and one that never happened look identical from
|
||
/// our own logs, which is exactly how a silently-discarded wire body survives (method.md's rule
|
||
/// that new wire bodies are proved, not assumed). `net::apply::chat` already writes inbound addon
|
||
/// traffic to the `addon` trace tag; this writes the outbound half to the same tag, so
|
||
/// `WOW_MOVE_TRACE=<path> WOW_MOVE_TRACE_TAGS=addon` on a live run is the whole conversation in
|
||
/// one file, in order.
|
||
pub(super) fn drain_addon_message_sends(
|
||
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
|
||
commands: Res<NetCommands>,
|
||
) {
|
||
let Some(mut script) = script else {
|
||
return;
|
||
};
|
||
for send in script.take_addon_sends() {
|
||
debug!(
|
||
"chat: addon broadcast on {} — {:?}",
|
||
send.distribution.token(),
|
||
send.text
|
||
);
|
||
if benilla_assets::trace::enabled() {
|
||
benilla_assets::trace::line(
|
||
"addon",
|
||
&format!("-> {} {:?}", send.distribution.token(), send.text),
|
||
);
|
||
}
|
||
let cmd = ClientCommand::AddonMessage {
|
||
distribution: send.distribution,
|
||
text: send.text,
|
||
};
|
||
if commands.0.send(cmd).is_err() {
|
||
warn!("chat: not connected; addon broadcast dropped");
|
||
}
|
||
}
|
||
}
|