Files
benilla-octo/crates/benilla-app/src/ui_chat/frames.rs
T
gromandClaude Opus 5 3b6e91be35 superwow: RAW_COMBATLOG and CombatLogAdd, the only two names his addons call
Of SuperWoW's whole surface these were the two names grom's corpus calls
that this client lacked (`scripts/dll-surface.py --wanted`), and the first
is load-bearing out of proportion to its size: `SuperCleveRoidMacros`
funnels FIVE subsystems through one `RAW_COMBATLOG` frame — immunity and
resist learning, hidden-CC detection, debuff-duration learning, and the
reactive-proc window that lights Overpower and Revenge. Its own comment
says why the last of those can come from nowhere else: "SPELL_GO only
provides hit/miss COUNTS, not miss TYPES".

`route` is the fire site: the one funnel every combat line already passes
on its way to a window and to WoWCombatLog.txt. `arg1` is the printed
sentence; `arg2` is the same sentence with each unit name spelled as its
GUID, composed a second time through the same `compose_line` so the two
can only differ in the name slots. Fired before the `CHAT_MSG_*`, which is
what "raw" means upstream. Both the fire and the `arg2` composition are
gated on `has_listener` — a raid's combat log is tens of lines a second.

`CombatLogAdd` is also BigWigs' SuperWoW detection (`if CombatLogAdd
then`), so defining it turns that module's path on.

Three corrections came out of reading the DLL's own usage strings, which
it publishes beside the names — the arity and argument meaning of every
remaining SuperWoW function is now measured rather than inferred, and
`docs/ADDON_COMPAT.md` carries the manifest:

- `CombatLogAdd("text" [, addToRawLog])` has a SECOND argument. Nothing
  in the corpus passes it; it would have been missed.
- `SUPERWOW_COMBAT_LOG_EVENT` does not exist. The DLL registers exactly
  three events, and that name is in no part of its string pool.
- `IsIndoors` is capital-I beside `IsSwimming`/`IsMounted`, not the
  lowercase `isIndoors` two files guessed at — all three are already live
  off one feed, so that clause of the "blocked" row was already answered.
- The detection pair is `SUPERWOW_STRING="SuperWoW 2.2 by Balake"` and
  `SUPERWOW_VERSION="2.2"`, both quoted. Ours had the string without its
  author clause and the version as a number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-25 12:30:55 -06:00

213 lines
9.4 KiB
Rust

//! The chat router: [`route`] fires each [`ChatEvent`] as its `CHAT_MSG_*` event, which the stock
//! `ChatFrame_OnEvent` composes and prints, and tees the composed line to the log files.
//! [`compose`] transcribes that composition for the log, every format resolved by key from the
//! player's `GlobalStrings.lua` as `ChatFrame_OnEvent` resolves it.
use bevy::prelude::*;
use benilla_ui::strings::{fill, Arg};
use super::event::{event_name, notice_token, ChatEvent, ChatEventKind};
/// What the app keeps beside the stock chat frames: the default language and the log files.
#[derive(Resource, Default)]
pub(crate) struct ChatWindows {
/// The frame's `this.defaultLanguage`, the faction tongue `GetDefaultLanguage()` answers;
/// empty until the player and `Languages.dbc` are loaded.
pub default_language: String,
/// The `LoggingChat`/`LoggingCombat` files, teed in [`route`].
pub logs: super::logging::ChatLogFiles,
}
/// Fire one event's `CHAT_MSG_*` at the VM, where the stock `ChatFrame_OnEvent` prints it, and
/// tee its composed line to the log files, which the reference writes C-side, not from Lua.
pub(crate) fn route(
script: &mut benilla_ui::script::UiScript,
windows: &mut ChatWindows,
event: &ChatEvent,
) {
let Some(kind) = event.kind else {
warn!("chat: unroutable event (no kind): {:?}", event.text);
return;
};
let default_language = windows.default_language.clone();
// The VM's own globals, so the log line matches the window's.
if let Some(line) = compose(event, kind, &default_language, &|key| {
script.lua().globals().get::<String>(key).ok()
}) {
windows.logs.record(kind.is_combat_log(), &line);
}
// SuperWoW's `RAW_COMBATLOG`, fired for every combat-log line and nothing else — the
// `COMBAT_`/`SPELL_` prefix set [`ChatEventKind::is_combat_log`] already names, which is the
// same set this function tees to `WoWCombatLog.txt`. Two arguments, as its one consumer in
// grom's corpus destructures them (`SuperCleveRoidMacros/Utility.lua:8195`, "arg1=formatted,
// arg2=raw"): the printed sentence, and the same sentence with GUIDs where the names are.
//
// **Before** the `CHAT_MSG_*` fire, which is what "raw" means — upstream hooks the combat-log
// parse, ahead of the client's own formatting and printing. An addon that keys state off this
// and also handles the `CHAT_MSG_*` therefore sees its own state already moved.
//
// Gated on a listener, the gate nampower's combat-log family already uses: a raid's combat log
// is tens of lines a second and this is a Lua fan-out per line. Nothing registered, nothing
// composed (the `arg2` form is built under the same test in [`super::feed`]).
if kind.is_combat_log() && script.has_listener(RAW_COMBATLOG) {
// The literal, not the const: `reference_ui.rs`'s event gate reads the string AT the
// `fire_event` call, so a name reaching it through a variable is invisible to the one
// test that asks whether this client fires an event 1.12 never had.
script.fire_event(
"RAW_COMBATLOG",
vec![
benilla_ui::script::ScriptValue::Str(event.text.clone()),
// No GUID form means no unit name was substituted into this line, so the printed
// text *is* its raw form. See [`ChatEvent::raw`].
benilla_ui::script::ScriptValue::Str(
event.raw.clone().unwrap_or_else(|| event.text.clone()),
),
],
);
}
script.fire_event(event_name(kind), event.script_args());
}
/// SuperWoW's combat-log event, named once for the two *listener tests* — [`route`]'s and
/// [`super::feed`]'s, which skips composing `arg2` when nothing is registered. The fire itself
/// spells the name out; see the comment there.
pub(crate) const RAW_COMBATLOG: &str = "RAW_COMBATLOG";
/// `ChatFrame_OnEvent`'s composition (`ChatFrame.lua:1369-1468`); `None` for a notice the 1.12 UI
/// does not print (MODE_CHANGE).
pub(crate) fn compose(
event: &ChatEvent,
kind: ChatEventKind,
default_language: &str,
get: &dyn Fn(&str) -> Option<String>,
) -> Option<String> {
use ChatEventKind as K;
Some(match kind {
// Verbatim (`ChatFrame.lua:1395-1402`), the XP and honor gains by their `COMBAT_` prefix.
K::System
| K::TextEmote
| K::Skill
| K::Loot
| K::Money
| K::CombatXpGain
| K::CombatHonorGain
| K::BgSystemNeutral
| K::BgSystemAlliance
| K::BgSystemHorde => event.text.clone(),
// The combat log is verbatim too, by prefix: `COMBAT_` and `SPELL_` (`:1397-1400`).
k if k.is_combat_log() => event.text.clone(),
// `format(TEXT(CHAT_IGNORED), arg2)` (`:1404`): `CHAT_IGNORED`, not the same-worded
// `ERR_IGNORING_YOU_S`.
K::Ignored => fill(
&get("CHAT_IGNORED").unwrap_or_default(),
&[Arg::S(&event.sender)],
),
// `format(CHAT_CHANNEL_LIST_GET .. arg1, arg4)` (`:1409`): arg4 whole, arg1 unescaped.
K::ChannelList => fill(
&format!(
"{}{}",
get("CHAT_CHANNEL_LIST_GET").unwrap_or_default(),
event.text
),
&[Arg::S(&event.channel)],
),
K::ChannelNotice | K::ChannelNoticeUser => {
return compose_notice(event, kind, get);
}
// Everything else is the player and monster line branch (`:1425-1467`).
_ => {
// `pflag = TEXT(getglobal("CHAT_FLAG_"..arg6))` (`:1431`); an empty flag is "".
let pflag = if event.flag.is_empty() {
String::new()
} else {
get(&format!("CHAT_FLAG_{}", event.flag)).unwrap_or_default()
};
let monster = matches!(
kind,
K::MonsterSay
| K::MonsterYell
| K::MonsterEmote
| K::MonsterWhisper
| K::RaidBossEmote
);
// The sender: a `[Name]` link (`:1451`), bare for monster lines, RAID_BOSS_EMOTE
// (`:1437-1438`) and EMOTE (`:1450`).
let named = if event.sender.is_empty() {
String::new()
} else if monster || kind == K::Emote {
format!("{pflag}{}", event.sender)
} else {
format!("{pflag}|Hplayer:{0}|h[{0}]|h", event.sender)
};
// The language header (`:1442-1448`) shows when arg3 is not `this.defaultLanguage`,
// the faction tongue (`0x5ec890`), understood or not. Its `~= "Universal"` test is
// left out: no 1.12 data names "Universal", and language 0 arrives as "".
let header = if !event.language.is_empty() && event.language != default_language {
format!("[{}] ", event.language)
} else {
String::new()
};
// `gsub(arg1, "%%", "%%%%")` (`:1440`) before one `format` over pattern, header and
// text, so a typed `%s` cannot take the name. Monster lines and RAID_BOSS_EMOTE skip
// it (`:1437-1438`): their own `%s` is the name's slot.
let text = if monster {
event.text.clone()
} else {
event.text.replace('%', "%%")
};
let body = fill(
&format!("{}{header}{text}", get_pattern(kind, get)),
&[Arg::S(&named)],
);
// The channel prefix (`:1462-1466`): arg4, numbered ("2. Trade - City"), zone cut.
if !event.channel.is_empty() {
format!("[{}] {body}", strip_zone(&event.channel))
} else {
body
}
}
})
}
/// `getglobal("CHAT_"..type.."_GET")` (`ChatFrame.lua:1445-1453`), `type` being the event name
/// after `CHAT_MSG_`; a missing string is `""`.
fn get_pattern(kind: ChatEventKind, get: &dyn Fn(&str) -> Option<String>) -> String {
let ty = event_name(kind)
.strip_prefix("CHAT_MSG_")
.unwrap_or_default();
get(&format!("CHAT_{ty}_GET")).unwrap_or_default()
}
/// `gsub(arg4, "%s%-%s.*", "")`: "2. Trade - City" → "2. Trade". Only speech lines strip it
/// (`ChatFrame.lua:1463`); the notice and list arms print arg4 whole, as in
/// "Joined Channel: [1. General - Elwynn Forest]".
fn strip_zone(channel: &str) -> &str {
match channel.find(" - ") {
Some(i) => &channel[..i],
None => channel,
}
}
/// A channel notice: the notice byte's token ([`notice_token`], the jump table at `0x49c60c`)
/// names `CHAT_<TOKEN>_NOTICE` (`ChatFrame.lua:1416-1424`), filled with arg4, then for
/// CHANNEL_NOTICE_USER arg2 and any arg5; a string such as `CHAT_INVITE_NOTICE` reorders them with
/// `%2$s`. `None` for MODE_CHANGE and bytes past the table, which print nothing.
pub(crate) fn compose_notice(
event: &ChatEvent,
kind: ChatEventKind,
get: &dyn Fn(&str) -> Option<String>,
) -> Option<String> {
let token = notice_token(event.notice_byte()?, event.slot_state)?;
let template = get(&format!("CHAT_{token}_NOTICE"))?;
let mut args = vec![Arg::S(event.channel.as_str())];
if kind == ChatEventKind::ChannelNoticeUser {
args.push(Arg::S(&event.sender));
if !event.target.is_empty() {
args.push(Arg::S(&event.target));
}
}
let line = fill(&template, &args);
(!line.is_empty()).then_some(line)
}