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
This commit is contained in:
2026-10-25 12:30:55 -06:00
co-authored by Claude Opus 5
parent 5a824f1625
commit 3b6e91be35
12 changed files with 515 additions and 18 deletions
+31
View File
@@ -1571,6 +1571,37 @@ pub(crate) struct PendingCombat {
pub tries: u16,
}
impl PendingCombat {
/// This line's [`Fills`] with every unit name spelled as its **GUID** instead: SuperWoW's
/// `RAW_COMBATLOG` `arg2`, which is the same sentence in the same grammar with the one
/// substitution that makes two mobs of one name distinguishable.
///
/// Only slots whose guid the line actually recorded move. A variant that says "you" keeps
/// saying it — that word is in the *template*, not in a fill, and its one consumer branches
/// on exactly that (`SuperCleveRoidMacros/Utility.lua:10096`: `lower(targetGUID) == "you"`
/// then `UnitExists("player")`). [`guid_token`](benilla_ui::script::guid_token) is the
/// realm's own `0x%016X` spelling, the one `UnitExists`'s second return answers in, so an
/// addon can compare the two without normalising either.
///
/// Call it only when something is listening: it costs a `Fills` clone and a second
/// [`compose_line`], per combat line.
pub(crate) fn guid_fills(&self) -> Fills {
let mut fills = self.fills.clone();
if self.subject != 0 {
fills.attacker = benilla_ui::script::guid_token(self.subject);
}
if self.object != 0 {
fills.victim = benilla_ui::script::guid_token(self.object);
}
// The `Named` slot is a name too where it is a unit's (a pet, a summoned object); an
// item's or a faction's is not a unit and has no guid to spell.
if let Named::Unit(guid) = self.named {
fills.named = benilla_ui::script::guid_token(guid);
}
fills
}
}
/// What still has to be looked up before a line's [`Slot::Named`] can be filled: an item through
/// the item cache (`0x55ba30`), a unit through `GetObjectName` (`0x6264e0`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+8
View File
@@ -334,6 +334,14 @@ pub(crate) struct ChatEvent {
/// [`super::feed::deliver`] changes it: the reference's notice arms read `slot+0x9c` for the
/// token (`0x49c0c2`) before they write it (`0x49bb20`).
pub slot_state: Option<super::edit::SlotState>,
/// SuperWoW's `RAW_COMBATLOG` `arg2`: this same sentence with each named endpoint spelled as
/// its **GUID** instead of its name. Set only where the composing site knows both (the
/// combat-log drain in [`super::feed`]); `None` elsewhere, and [`super::frames::route`] then
/// sends the printed text, which is what a line with no unit name in it says anyway.
///
/// Outside `arg1..arg10`: the `CHAT_MSG_*` surface is the reference's and does not grow a
/// field. This rides along because `route` is the one funnel every combat line passes.
pub raw: Option<String>,
}
impl ChatEvent {
+25 -1
View File
@@ -1067,10 +1067,34 @@ pub(super) fn feed_chat(
let composed =
super::combat::compose_line(&script, line.family, line.variant, &line.fills);
if let Some(text) = composed {
// SuperWoW's `RAW_COMBATLOG` `arg2`: the same sentence with every unit name
// spelled as its GUID. This is the only site that holds both — the names were
// just resolved from the very guids the line queued — so it composes a second
// time with the guid in each name slot and hands the result to `route`.
//
// Which slots move, and why "you" stays "you":
// [`super::combat::PendingCombat::guid_fills`].
//
// Paid for only while something is listening: a second `compose_line` per
// combat line is real work in a raid, and the fire is gated the same way.
let raw = script
.has_listener(super::frames::RAW_COMBATLOG)
.then(|| {
super::combat::compose_line(
&script,
line.family,
line.variant,
&line.guid_fills(),
)
})
.flatten();
route(
&mut script,
&mut windows,
&ChatEvent::text_only(line.kind, text),
&ChatEvent {
raw,
..ChatEvent::text_only(line.kind, text)
},
);
}
}
+34
View File
@@ -37,9 +37,43 @@ pub(crate) fn route(
}) {
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(
+15 -6
View File
@@ -103,18 +103,27 @@ fn civil_from_days(z: i64) -> (i64, u32, u32) {
(if m <= 2 { y + 1 } else { y }, m, d)
}
/// The VM's two flags → the two files, on the frame either flag moves.
/// The VM's two flags → the two files, on the frame either flag moves; and SuperWoW's
/// `CombatLogAdd` lines into the combat log.
///
/// The flags are read first, so a line added on the same frame the log opened still lands — the
/// order a player sees in `/combatlog` followed by a macro that writes one.
pub(super) fn sync_chat_logging(
script: Option<NonSendMut<UiScript>>,
mut windows: ResMut<super::frames::ChatWindows>,
) {
let Some(mut script) = script else { return };
if !script.take_logging_changes() {
return;
if script.take_logging_changes() {
let (chat, combat) = script.logging_flags();
windows.logs.set(false, chat);
windows.logs.set(true, combat);
}
// SuperWoW's `CombatLogAdd(text)`: the addon's own line, stamped and appended exactly as a
// routed combat line is. Dropped while the log is closed, by the same rule every routed line
// follows — `record` writes only to an open file.
for line in script.take_combat_log_adds() {
windows.logs.record(true, &line);
}
let (chat, combat) = script.logging_flags();
windows.logs.set(false, chat);
windows.logs.set(true, combat);
}
pub(super) fn plugin(app: &mut App) {
+150
View File
@@ -485,6 +485,156 @@ fn an_addon_registering_our_own_chat_frame_does_not_double_print() {
assert!(s.errors().is_empty(), "handler errors: {:?}", s.errors());
}
/// SuperWoW's `RAW_COMBATLOG`, fired for a combat-log line and nothing else: `arg1` the sentence
/// as the window prints it, `arg2` the same sentence with its units named by GUID.
///
/// The assertions are the one real consumer's own reads, not a shape of my choosing.
/// `SuperCleveRoidMacros/Utility.lua:10206` registers one frame for this event and triages five
/// subsystems off it; the two patterns below are `HandleDebuffFade`'s verbatim (`:10095-10096`),
/// which is what learns a debuff's real duration, and the dispatcher's keyword find on `arg1` is
/// what lights Overpower's and Revenge's proc window (`:10243`).
#[test]
fn a_combat_line_fires_raw_combatlog_with_its_units_named_by_guid() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = chat_vm();
let mut windows = super::frames::ChatWindows::default();
s.run(
r#"
RawN, RawA1, RawA2, Order = 0, "", "", ""
Spy = CreateFrame("Frame", "BenillaRawSpy")
Spy:SetScript("OnEvent", function()
Order = Order..event..";"
if event == "RAW_COMBATLOG" then
RawN, RawA1, RawA2 = RawN + 1, arg1, arg2
end
end)
Spy:RegisterEvent("RAW_COMBATLOG")
Spy:RegisterEvent("CHAT_MSG_SPELL_AURA_GONE_OTHER")
"#,
)
.unwrap();
// The fade line, as `AURAREMOVEDOTHER` composes it ("%s fades from %s.") and as its GUID form.
let guid = benilla_ui::script::guid_token(0xF130_0000_0000_1234);
let mut e = ChatEvent::text_only(K::SpellAuraGoneOther, "Rend fades from Ragged Wolf.".into());
e.raw = Some(format!("Rend fades from {guid}."));
super::frames::route(&mut s, &mut windows, &e);
assert_eq!(s.eval::<i64>("return RawN").unwrap(), 1, "fired once");
assert_eq!(
s.eval::<String>("return RawA1").unwrap(),
"Rend fades from Ragged Wolf.",
"arg1 is the printed sentence"
);
assert_eq!(
s.eval::<String>("return RawA2").unwrap(),
format!("Rend fades from {guid}."),
"arg2 names the unit by GUID — the whole point of the event"
);
assert_eq!(
s.eval::<String>("return Order").unwrap(),
"RAW_COMBATLOG;CHAT_MSG_SPELL_AURA_GONE_OTHER;",
"RAW first: upstream hooks the combat-log parse, ahead of the client's own formatting, so \
a handler of the CHAT_MSG sees state this event already moved"
);
// `HandleDebuffFade`'s own two patterns, run against our `arg2`.
assert_eq!(
s.eval::<String>(r#"local _,_,n = string.find(RawA2, "^(.-) fades from ") return n"#)
.unwrap(),
"Rend",
"the spell name stays a NAME in the raw form; only units are spelled as guids"
);
assert_eq!(
s.eval::<String>(
r#"local _,_,g = string.find(RawA2, "from (.-).$")
return string.gsub(g, "^0x", "")"#
)
.unwrap(),
format!("{:016X}", 0xF130_0000_0000_1234_u64),
"and the guid survives the addon's own capture-and-strip, which it then compares against \
UnitExists(\"player\")'s second return"
);
// A line that is not combat raises nothing: the gate is `is_combat_log`, the same set this
// router tees to `WoWCombatLog.txt`.
super::frames::route(&mut s, &mut windows, &ev(K::Say, "hi there", "Bob"));
assert_eq!(
s.eval::<i64>("return RawN").unwrap(),
1,
"a SAY line is not a combat-log line"
);
// No GUID form (a synthetic line, `/chattest`): `arg2` is the printed text rather than nil,
// which is what a line with no unit name in it says anyway.
super::frames::route(
&mut s,
&mut windows,
&ChatEvent::text_only(K::SpellAuraGoneOther, "Rend fades from you.".into()),
);
assert_eq!(
s.eval::<String>("return RawA2").unwrap(),
"Rend fades from you."
);
assert!(s.errors().is_empty(), "handler errors: {:?}", s.errors());
}
/// The GUID form itself: [`super::combat::PendingCombat::guid_fills`] over the shipped
/// `AURAREMOVED` templates, which is where the sentence `arg2` carries is actually built.
#[test]
fn the_guid_form_substitutes_the_endpoints_and_leaves_you_alone() {
let _data = benilla_formats::wow_data_or_skip!();
use super::combat::{Fills, Named, PendingCombat, Variant, AURAREMOVED};
let bearer = 0xF130_0000_0000_1234_u64;
let line = PendingCombat {
kind: K::SpellAuraGoneOther,
family: AURAREMOVED,
variant: Variant::OtherOther,
// `AURAREMOVED`'s slots are [Spell, Attacker]: the subject is the aura's BEARER.
subject: bearer,
object: 0,
fills: Fills {
attacker: "Ragged Wolf".into(),
spell: "Rend".into(),
..Default::default()
},
named: Named::Ready,
tries: 0,
};
GLOBAL_STRINGS.with(|s| {
let named = super::combat::compose_line(s, line.family, line.variant, &line.fills);
let raw = super::combat::compose_line(s, line.family, line.variant, &line.guid_fills());
assert_eq!(named.as_deref(), Some("Rend fades from Ragged Wolf."));
assert_eq!(
raw,
Some(format!(
"Rend fades from {}.",
benilla_ui::script::guid_token(bearer)
)),
"one substitution, in the slot whose guid the line recorded"
);
// The SELF variant's "you" is in the template, not in a fill, so it cannot be replaced —
// and its consumer depends on that, reading the word and asking for the player's own guid.
let me = PendingCombat {
variant: Variant::SelfOther,
subject: 0,
fills: Fills {
attacker: String::new(),
spell: "Rend".into(),
..Default::default()
},
..line
};
assert_eq!(
super::combat::compose_line(s, me.family, me.variant, &me.guid_fills()).as_deref(),
Some("Rend fades from you."),
);
});
}
/// Listeners run in registration order, as the reference's do, and `ChatFrame1` registered first.
#[test]
fn an_addons_handler_sees_the_line_already_in_the_window() {
@@ -2634,6 +2634,17 @@ mod tests {
"SuperWoW's own event, the one every cast-tracking addon on this realm registers; \
1.12 reports another unit's casts nowhere at all",
),
(
"RAW_COMBATLOG",
"SuperWoW: every combat-log line twice over — as the player reads it, and with \
each unit name spelled as its GUID. 1.12 publishes the combat log only as \
localized sentences naming units by name, so an addon cannot tell two mobs of \
one name apart. SuperCleveRoidMacros funnels FIVE subsystems through this one \
event (`Utility.lua:10206`): immunity and resist learning, hidden-CC detection, \
debuff-duration learning off the GUID half, and the reactive-proc window \
Overpower and Revenge are lit from — which its own comment says ALWAYS comes \
from this text, because SPELL_GO carries hit COUNTS and not miss TYPES",
),
(
"DEBUFF_ADDED_OTHER",
"SuperWoW: another unit's debuff gained, which 1.12's aura surface cannot express",
+174 -7
View File
@@ -21,13 +21,20 @@ fn position_values(position: Option<(f32, f32, f32)>) -> (Value, Value, Value) {
}
}
/// The wiki's documented detection convention (`Changelog.md`, 1.2): two globals, version info
/// for an addon to feature-detect. Neither's exact value format is published; a released SuperWoW
/// version number and a matching free-text string, in the spirit other version pairs use, is
/// this client's best-effort stand-in — an addon is only ever shown to compare or display these,
/// never to parse a specific layout.
const SUPERWOW_VERSION: f64 = 2.2;
const SUPERWOW_STRING: &str = "SuperWoW 2.2";
/// The detection pair, **measured off the shipped `SuperWoWhook.dll` rather than guessed**: it
/// carries `superwowInfo.lua` and the two assignments that file makes, verbatim —
/// `SUPERWOW_STRING="SuperWoW 2.2 by Balake"` and `SUPERWOW_VERSION="2.2"`.
///
/// Two things that correction fixed. The string had been `"SuperWoW 2.2"`, missing the author
/// clause. And the version had been the Lua **number** `2.2` where upstream assigns a quoted
/// **string**: every reader in grom's corpus goes through `tonumber(SUPERWOW_VERSION)` or plain
/// truthiness (pfUI `api/api.lua:13`, `modules/superwow.lua:80`, SuperAPI `:2`, Atlas-CFM
/// `Interactions.lua:342`), and one of them defaults with `tonumber(SUPERWOW_VERSION or "0.0")`
/// (`Gromissingcrafts/src/Compatibility.lua:10`) — a string default, which is the author telling
/// us what they expect. A number works for all of those; it differs under `type()`, and the point
/// of a detection value is that it is indistinguishable from the real one.
const SUPERWOW_VERSION: &str = "2.2";
const SUPERWOW_STRING: &str = "SuperWoW 2.2 by Balake";
pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
@@ -198,6 +205,68 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// CombatLogAdd(text): append one line to `WoWCombatLog.txt`, the file `/combatlog` opens —
// not a chat window, and not an event. BigWigs is both of its readers in grom's corpus and
// they use it for the two things it is for: `MageTools.lua:761` writes its own debug trace
// into the combat log under a `fileDebug` profile flag, and `SpellRequests.lua:6` tests the
// name's mere existence as its SuperWoW detection (`if CombatLogAdd then hasSuperWow = true`),
// which is the whole module's gate.
//
// Queued rather than written here: the file lives on the app's `ChatWindows`
// (`ui_chat/logging.rs`), where every routed line already goes, so one writer owns it and a
// `CombatLogAdd` line is stamped exactly as a combat line is. A line while the log is closed
// is dropped by the same rule every routed line follows, which is upstream's behaviour too —
// the function appends to an open file.
//
// The second argument is the DLL's own: its usage string reads `CombatLogAdd("text" [,
// addToRawLog])`, printed beside the `Logs\WoWCombatLog.txt` literal it writes to. Truthy,
// the line is ALSO fired as `RAW_COMBATLOG`, so an addon listening there sees it beside the
// engine's own lines. That reading is an inference from the name and the two destinations
// SuperWoW has (the file, and the event) — the DLL publishes no prose. It is off by default,
// which is the half that matters: nothing starts feeding addon text into a combat-log parser
// unless its caller asks for it by name.
//
// Fired from here rather than queued, because upstream's is synchronous: the DLL fires the
// event inside the call. `arg2` is the same text — an addon's line has no unit name in it to
// spell as a guid, which is exactly the case `ui_chat/frames.rs`'s `route` sends `arg1` for.
//
// Deviation, deliberate: upstream also accepts no argument (and writes nothing). Anything
// that is not a string or a number is ignored rather than raising, because the one real
// caller builds its argument by concatenation and a raise inside a debug-trace helper would
// take the addon down for a reason the player cannot act on.
g.set(
"CombatLogAdd",
lua.create_function(|lua, (line, raw): (Option<Value>, Option<Value>)| {
let text = match line {
Some(Value::String(s)) => s.to_str().map(|s| s.to_string()).ok(),
Some(Value::Integer(n)) => Some(n.to_string()),
Some(Value::Number(n)) => Some(n.to_string()),
_ => None,
};
let Some(text) = text.filter(|t| !t.is_empty()) else {
return Ok(());
};
let also_raw = !matches!(raw, None | Some(Value::Nil) | Some(Value::Boolean(false)))
&& !matches!(&raw, Some(Value::Integer(0)))
&& !matches!(&raw, Some(Value::Number(n)) if *n == 0.0);
lua.app_data_mut::<Model>()
.expect("model app_data")
.combat_log_adds
.push(text.clone());
if also_raw {
crate::script::tick::fire_event_into(
lua,
"RAW_COMBATLOG",
vec![
crate::script::ScriptValue::Str(text.clone()),
crate::script::ScriptValue::Str(text),
],
);
}
Ok(())
})?,
)?;
Ok(())
}
@@ -371,6 +440,11 @@ impl crate::script::UiScript {
pub fn clear_mouseover_override(&mut self) {
self.model_mut().mouseover_override = None;
}
/// `CombatLogAdd` lines since the last drain, for the app to append to the combat log.
pub fn take_combat_log_adds(&mut self) -> Vec<String> {
std::mem::take(&mut self.model_mut().combat_log_adds)
}
}
#[cfg(test)]
@@ -589,4 +663,97 @@ mod tests {
"no pet snapshot pushed at all"
);
}
/// The detection pair, exactly as the shipped `SuperWoWhook.dll`'s own `superwowInfo.lua`
/// assigns it — read off the binary, not inferred from the wiki.
///
/// Both are **strings**. The version had been a Lua number here, which every reader in grom's
/// corpus survives (they all `tonumber` it or test truthiness) and `type()` does not — and a
/// detection value whose type differs from the real one is a detection value that can be
/// caught out. pfUI's own gate is the numeric one, so it is asserted here too.
#[test]
fn the_detection_pair_is_the_dlls_own_two_assignments() {
let s = UiScript::new().unwrap();
assert_eq!(
s.eval::<(String, String)>("return SUPERWOW_VERSION, SUPERWOW_STRING")
.unwrap(),
("2.2".into(), "SuperWoW 2.2 by Balake".into())
);
assert_eq!(
s.eval::<(String, String)>("return type(SUPERWOW_VERSION), type(SUPERWOW_STRING)")
.unwrap(),
("string".into(), "string".into()),
"quoted in `superwowInfo.lua`, so quoted here"
);
assert!(
s.eval::<bool>("return tonumber(SUPERWOW_VERSION) >= 1.5")
.unwrap(),
"pfUI's own gate (`modules/superwow.lua:80`), which enables its GUID-to-name hook \
over the combat text"
);
}
/// `CombatLogAdd(text)` queues one line per call, in order, for the app to append to
/// `WoWCombatLog.txt`; the drain empties it.
///
/// The shapes are its two real callers': BigWigs builds its argument by concatenation
/// (`MageTools.lua:762`, `"MageTools: " .. msg`), and `SpellRequests.lua:6` only ever tests
/// that the name exists.
#[test]
fn combat_log_add_queues_each_line_and_the_drain_empties_it() {
let mut s = UiScript::new().unwrap();
assert!(
s.eval::<bool>("return CombatLogAdd ~= nil").unwrap(),
"the name's mere existence is BigWigs' SuperWoW detection"
);
assert!(s.take_combat_log_adds().is_empty());
s.run(
r#"RawN, RawA1, RawA2 = 0, "", ""
Spy = CreateFrame("Frame", "BenillaRawSpy")
Spy:SetScript("OnEvent", function()
RawN, RawA1, RawA2 = RawN + 1, arg1, arg2
end)
Spy:RegisterEvent("RAW_COMBATLOG")
CombatLogAdd("MageTools: " .. "Scorch 3 on Boss")
CombatLogAdd(42)
CombatLogAdd("")
CombatLogAdd()
CombatLogAdd({})"#,
)
.unwrap();
assert_eq!(
s.eval::<i64>("return RawN").unwrap(),
0,
"no second argument fires nothing: addon text does not reach a combat-log parser \
unless its caller asks for that by name"
);
assert_eq!(
s.take_combat_log_adds(),
vec!["MageTools: Scorch 3 on Boss".to_string(), "42".to_string()],
"a number stringifies; an empty line, no argument and a table each add nothing — and \
none of them raises, because the one real caller is a debug-trace helper"
);
assert!(
s.take_combat_log_adds().is_empty(),
"the drain takes the queue rather than copying it"
);
// `addToRawLog`, the DLL's own second argument: the line is written AND fired. A `0` is
// false, as every 1.12 flag argument is.
s.run(r#"CombatLogAdd("BigWigs: pull in 5", 1) CombatLogAdd("quiet", 0)"#)
.unwrap();
assert_eq!(s.eval::<i64>("return RawN").unwrap(), 1);
assert_eq!(
s.eval::<(String, String)>("return RawA1, RawA2").unwrap(),
("BigWigs: pull in 5".into(), "BigWigs: pull in 5".into()),
"both args are the text: an addon's line has no unit name in it to spell as a guid"
);
assert_eq!(
s.take_combat_log_adds(),
vec!["BigWigs: pull in 5".to_string(), "quiet".to_string()],
"the file gets both lines either way — the flag adds the event, it does not divert"
);
assert!(s.errors().is_empty(), "handler errors: {:?}", s.errors());
}
}
+4
View File
@@ -790,6 +790,9 @@ pub(crate) struct Model {
pub(crate) uninvite_requests: Vec<String>,
/// `ConsoleExec` lines that were not CVar writes.
pub(crate) console_lines: Vec<String>,
/// SuperWoW's `CombatLogAdd` lines, queued for the app to append to `WoWCombatLog.txt`
/// (`compat/superwow.rs`). Not chat: nothing is printed to a window.
pub(crate) combat_log_adds: Vec<String>,
/// `LoggingChat` and `LoggingCombat`'s flags, and their change cue.
pub(crate) logging_chat: bool,
pub(crate) logging_combat: bool,
@@ -1941,6 +1944,7 @@ impl Model {
roll_requests: Vec::new(),
uninvite_requests: Vec::new(),
console_lines: Vec::new(),
combat_log_adds: Vec::new(),
logging_chat: false,
logging_combat: false,
logging_changed: false,
@@ -1,7 +1,12 @@
//! The self player's posture ([`SelfMovement`]): not 1.12 data at all, fed once per tick for
//! ClassicAPI's `State` namespace (`IsSwimming`/`IsMounted`/`IsIndoors`/`IsOutdoors`/
//! `GetShapeshiftFormID`) and, eventually, SuperWoW's own lowercase-initial siblings of the same
//! facts.
//! `GetShapeshiftFormID`).
//!
//! **SuperWoW publishes three of these under the same names, not lowercase ones.** Its own string
//! pool lists `IsSwimming`, `IsMounted` and `IsIndoors` with that exact capitalisation (an earlier
//! note here guessed at `isIndoors`, which would have been a second global nothing calls), so this
//! one feed answers both mods and there is nothing to alias. `GetSpeed` is SuperWoW's alone and is
//! the one posture fact not here — see `docs/ADDON_COMPAT.md`.
use super::SelfMovement;
@@ -274,6 +274,11 @@ fn allowed_beyond_1_12() -> HashSet<&'static str> {
"Clickthrough",
"SetMouseoverUnit",
"SpellInfo",
// SuperWoW's `CombatLogAdd(text)` (script/compat/superwow.rs): one line appended to
// `WoWCombatLog.txt`, which 1.12 writes C-side off its own chat router and gives Lua no
// way into. It is also a detection name in its own right — BigWigs' Spell Requests gates
// its whole SuperWoW path on `if CombatLogAdd then` (`SpellRequests.lua:6`).
"CombatLogAdd",
// ClassicAPI's detection pair (script/compat/classic_api/mod.rs): pfUI gates its whole
// load on `CLASSIC_API_VERSION`, so this one is load-bearing, not cosmetic.
"CLASSIC_API_VERSION",
+51 -2
View File
@@ -33,10 +33,12 @@ nothing behind it.
| Nameplate `frame:GetName(1)` → GUID | done | `script/object/frame_state.rs`'s shared `GetName`, `NamePlates::guid_for_frame` (`script/nameplate.rs`) |
| `GetWeaponEnchantInfo([unit])` + `GetWeaponEnchantID` | todo, blocked | needs per-unit weapon-enchant tracking (today only `"player"`'s is fed at all) and no item-template data distinguishing an on-use charge slot from an equip/proc slot — would need new app-side state, not a binding tweak |
| `GetContainerItemInfo` charges (negative stack count) | todo, blocked | the raw signed field exists (`ObjectFields::item_spell_charges`, benilla-protocol), but nothing in `benilla-formats`' item template says *which* of an item's five spell-trigger slots (if any) is a consumable charge rather than an equip/proc effect — exposing it without that would be a guess, not a port |
| New functions (`SpellInfo`, `TrackUnit`/`UntrackUnit`, `SetMouseoverUnit`, `Clickthrough`, `SetAutoloot`, `UnitNameplate`, `CanLootUnit`, `CombatLogAdd`, Import/Export/AppendText, map/world coord conversions, `IsSwimming`/`IsMounted`/`isIndoors`/`GetSpeed`, mail/quest-link getters) | todo, blocked | each needs new app-side state, not a binding tweak: `IsSwimming`/`IsMounted`/`isIndoors`/`GetSpeed` need a self-movement snapshot (`move_flags() & SWIMMING`, `unit_mount_display_id()`, `area.rs`'s `indoor` bool, and a `MoveSpeeds` component — none currently reach a `feed_*` system that can push them, and `ui_unit.rs`'s `feed_units` is already at Bevy's 16-param ceiling); **`SpellInfo` is now done** — `compat/superwow.rs`, over `script/spellbook.rs`'s `spell_display`: the player's book first, then the host's `Spell.dbc` probe ([`SpellProbe`], installed by `ui_script::lifecycle::install_spell_probe` on every VM build), so it reaches every shipped spell as the real mod does. The range pair stays nil — `SpellRange.dbc` is not on the probe; the rest (`TrackUnit`, `SetMouseoverUnit`, `Clickthrough`, `SetAutoloot`, `CanLootUnit`, `UnitNameplate`, file I/O, map/world coords, quest links) are each plausible but unstarted. **The two mail link getters are now done**, built for ClassicAPI's own `Mail` namespace (`compat/classic_api/merchant_frame.rs`, see its row below) — SuperWoW specifies the same `GetSendMailItemLink`/`GetInboxItemLink`, so that half of this row is closed |
| New functions (`SpellInfo`, `TrackUnit`/`UntrackUnit`, `SetMouseoverUnit`, `Clickthrough`, `SetAutoloot`, `UnitNameplate`, `CanLootUnit`, Import/Export/AppendText, map/world coord conversions, `IsSwimming`/`IsMounted`/`isIndoors`/`GetSpeed`, mail/quest-link getters) | todo, blocked | each needs new app-side state, not a binding tweak: `IsSwimming`/`IsMounted`/`isIndoors`/`GetSpeed` need a self-movement snapshot (`move_flags() & SWIMMING`, `unit_mount_display_id()`, `area.rs`'s `indoor` bool, and a `MoveSpeeds` component — none currently reach a `feed_*` system that can push them, and `ui_unit.rs`'s `feed_units` is already at Bevy's 16-param ceiling); **`SpellInfo` is now done** — `compat/superwow.rs`, over `script/spellbook.rs`'s `spell_display`: the player's book first, then the host's `Spell.dbc` probe ([`SpellProbe`], installed by `ui_script::lifecycle::install_spell_probe` on every VM build), so it reaches every shipped spell as the real mod does. The range pair stays nil — `SpellRange.dbc` is not on the probe; the rest (`TrackUnit`, `SetMouseoverUnit`, `Clickthrough`, `SetAutoloot`, `CanLootUnit`, `UnitNameplate`, file I/O, map/world coords, quest links) are each plausible but unstarted. **`CombatLogAdd` is done** and has its own row below. **The two mail link getters are now done**, built for ClassicAPI's own `Mail` namespace (`compat/classic_api/merchant_frame.rs`, see its row below) — SuperWoW specifies the same `GetSendMailItemLink`/`GetInboxItemLink`, so that half of this row is closed |
| `UnitPosition(unitid)`, `CursorPosition()` | done | `UnitState.position` (`script/unit/mod.rs`), fed by `Transform` from `ui_unit.rs`'s `feed_units` (player/target/targettarget/npc) and `ui_tooltip.rs`'s `drive_mouseover_tooltip` (mouseover); functions in `compat/superwow.rs`. Party/raid/pet aren't wired (their own `snapshot()` call sites in `ui_party/feed.rs`/`ui_pet/unit.rs` pass `None` — each needs its own `Entity`→`Transform` lookup threaded through, not attempted this pass). Reading `Transform` broadly past the usual one-frame-stale tolerance raised `game_plugins.rs`'s `UPDATE_ACTIONABLE_CEILING` by 30 (cited there) — `Transform` is written by enough movement/animation/physics systems that `With<Guid>` alone doesn't shrink the ambiguity count |
| `UNIT_CASTEVENT` | done | `crates/benilla-app/src/superwow_events.rs`: a `CastEventFeed` queue the cast handlers push into, drained once per tick by one VM-holding system that fires the event — the pattern `ui_mirror.rs` already uses, and the only way across, since `spell/net.rs`'s `Lifecycle`/`Scene` bundles deliberately hold no `UiScript` (`game_plugins.rs`'s `every_vm_holder_in_update_declares_its_side_of_the_tick` gates that). Edges wired: `START` from `SMSG_SPELL_START` with its wire cast time as `arg5` (an instant raises none — its only edge is `CAST`, which is what the wiki's own START/CAST split means), `CAST` from `SMSG_SPELL_GO`, `FAIL` from `SMSG_SPELL_FAILED_OTHER`, `CHANNEL` from `MSG_CHANNEL_START` with its duration. GUIDs go out in the same 16-hex-digit spelling every other `compat/` surface uses, so `arg1`/`arg2` compare directly against a `UnitExists`/`UnitGUID` return. Not wired: `MAINHAND`/`OFFHAND` — benilla's melee-swing path raises no cast edge at all, so there is nothing to forward. `MSG_CHANNEL_START` names no caster (1.12 sends it for the local player's own channel only), so `CHANNEL`'s `arg1` is self and its `arg2` is 0 |
| `CREATE_CHATBUBBLE`, `RAW_COMBATLOG`, `SUPERWOW_COMBAT_LOG_EVENT` | todo | `CREATE_CHATBUBBLE` needs the chat-bubble creation site in `chat_bubble.rs` (its `push`/`drive_bubbles`); now that `superwow_events.rs` exists with the queue-plus-drain pattern established, this is a small addition rather than new architecture. `RAW_COMBATLOG`'s funnel is `ui_chat/feed.rs`'s `ChatLog::push_combat`, where (almost) every combat line lands as a `combat::PendingCombat` from two call sites in `combat_log/chat.rs` — but that's a *queued* value, not rendered text, and the sentence `arg2` wants is built later at drain time, which this pass didn't trace into. Firing at `push_combat` with the structured fields in place of `arg2` would be a different (arguably more useful, but non-conforming) shape than the wiki's "text with GUID". `SUPERWOW_COMBAT_LOG_EVENT`'s full prefix/suffix param schema is its own sub-project |
| `RAW_COMBATLOG` | **done** | `ui_chat/frames.rs`'s `route` — the one funnel every combat line already passes on its way to a window and to `WoWCombatLog.txt`. Two arguments, as its one consumer in grom's corpus destructures them: the printed sentence, and the same sentence with each unit name spelled as its GUID (`PendingCombat::guid_fills`, composed a second time through the same `compose_line`, so the two can only ever differ in the name slots). Fired **before** the `CHAT_MSG_*`, which is what "raw" means upstream — it hooks the combat-log parse, ahead of the client's own formatting. Gated on `has_listener`, the gate nampower's combat-log family already uses, and the `arg2` composition is skipped under the same test: a raid's log is tens of lines a second. A variant whose template says "you" keeps saying it, because that word is in the *template* and not in a fill — and its consumer depends on exactly that, reading the word and then asking `UnitExists("player")` for its own guid. **Why this row came first:** of SuperWoW's whole surface, it and `CombatLogAdd` are the only two names grom's addons call that this client lacked (`scripts/dll-surface.py --wanted`), and `SuperCleveRoidMacros/Utility.lua:10206` funnels **five** subsystems through this single event — immunity and resist learning, hidden-CC ("afflicted by") detection, debuff-duration learning off the GUID half, and the reactive-proc window that lights **Overpower** and **Revenge**. Its own comment there is why the last of those can come from nowhere else: "SPELL_GO only provides hit/miss COUNTS, not miss TYPES, so it cannot distinguish dodge (Overpower proc) from parry/block/resist". This does **not** by itself fix the Overpower complaint: that needs the dodged-*ability* sentence to be emitted at all, which is its own open item (`benilla-overpower-aura-spelldodged`) |
| `CombatLogAdd(text)` | **done** | `compat/superwow.rs` queues the line; `ui_chat/logging.rs`'s `sync_chat_logging` appends it to `WoWCombatLog.txt` through the same `record` every routed line uses, so it is stamped identically and one writer owns the file. Dropped while the log is closed, which is upstream's behaviour too (it appends to an open file). A non-string argument is ignored rather than raising: its one real caller is a debug-trace helper (`BigWigs/Plugins/MageTools.lua:761`, under a `fileDebug` profile flag) and a raise there would take the addon down for something the player cannot act on. **It is also a detection name**: `BigWigs/Plugins/SpellRequests.lua:6` is `if CombatLogAdd then hasSuperWow = true`, which gates that whole module's SuperWoW path |
| `CREATE_CHATBUBBLE` | todo | needs the chat-bubble creation site in `chat_bubble.rs` (its `push`/`drive_bubbles`); now that `superwow_events.rs` exists with the queue-plus-drain pattern established, this is a small addition rather than new architecture. It is the **last** of SuperWoW's three events — `SUPERWOW_COMBAT_LOG_EVENT`, which this row used to carry, is a ghost: see the manifest below |
| `NameplateRange` | done | one CVar, two readers: `nameplates.rs`'s `NameConfig::range_sq` (the overhead name — introduces a cull where the reference has none at all) and `vplates.rs`'s `VPlateMode::range_sq` (replaces the reference's own hardcoded 20 yd V-key-plate range). Default `80`, the top of the mod's documented 10-80 range — no shipped default is published, so this is the least-restrictive honest choice, not a confirmed number |
| `ChatBubbleRange` | done | `chat_bubble.rs`'s `BubbleConfig::range_sq`, replacing the reference's hardcoded 20 yd. Default `20` (today's exact value) — no shipped default published for this one either, within the mod's documented 10-200 yd range |
| `NameplateMotion`, `NameplateVerticalSpacing` | todo | needs locating whatever (if anything) already staggers overlapping nameplates, not investigated |
@@ -49,6 +51,53 @@ nothing behind it.
| Friendly-player item-visibility range limit removed | todo | not investigated this session — need to find where benilla currently gates item-row/tooltip visibility by range for a friendly player, if it does at all |
| `ShowVKeyCastbar` | **registered, stored only** | pfUI writes `0` at `modules/nameplates.lua:3` ("disable original castbars") under `pcall`, and that write was one of the session log's six `unknown CVar '…' (not host-registered) — ignored` warnings. **Provenance, measured not assumed**: the name appears *nowhere* in this OctoWoW install except that one pfUI line — `grep -rli ShowVKeyCastbar` over the whole tree hits one file, and neither `WoW.exe` nor any of its eighteen DLLs (SuperWoW's included) carries it. So no mod here registers it and **there is no published default to match**; pfUI writes it defensively for whichever client does. Registered in `cvars.rs` as `ours("ShowVKeyCastbar", "0", …)` and **stored, not wired**: benilla's V-key plates have no cast bar to suppress (`benilla-ui`'s `script/nameplate.rs` header says so outright, and `vplates.rs` draws name, level, health bar and raid mark and nothing else), so `0` *is* this client's behaviour rather than a chosen value, shipping `1` would advertise a bar that does not exist, and an observer would give a knob two positions with one picture. If a plate cast bar is ever built this row becomes its switch |
### SuperWoW's own manifest, read off the DLL — the remaining work is now a closed list
`scripts/dll-surface.py` finds the names a DLL points at; `SuperWoWhook.dll` goes further and
**publishes its own usage strings**, in one contiguous block beside the names. So the arity and the
argument *meaning* of every remaining function are measured here, not inferred from the wiki — the
same trick that read `VanillaHelpers`' two blips. Three corrections came straight out of it:
- **`SUPERWOW_COMBAT_LOG_EVENT` does not exist.** The DLL registers exactly three events:
`UNIT_CASTEVENT`, `RAW_COMBATLOG`, `CREATE_CHATBUBBLE`. That name appears nowhere in the pool, so
the row above that called its parameter schema "its own sub-project" was a sub-project for a
ghost. Deleted rather than carried.
- **`IsIndoors` is capital-I**, beside `IsSwimming` and `IsMounted` — not the lowercase `isIndoors`
this file and `self_movement.rs` both guessed at. All three are already live off one feed
(`SelfMovement`, built for ClassicAPI's `State` namespace), so that whole clause of the "needs a
self-movement snapshot" row was answered before it was written. `GetSpeed` is the only posture
fact still missing, and `current_speed` (`net.rs`, the reference's own `CMovement::GetCurrentSpeed
0x7c4c90`) already computes it for every unit.
- **The detection pair is `SUPERWOW_STRING="SuperWoW 2.2 by Balake"` and `SUPERWOW_VERSION="2.2"`**,
both quoted, assigned by a `superwowInfo.lua` the DLL carries. Ours had the string without its
author clause and the version as a *number*. Fixed, and welded by a test.
The usage strings, verbatim, for what is left:
| Usage string, as the DLL prints it | Status |
|---|---|
| `CombatLogAdd("text" [, addToRawLog])` | **done** — the second argument is why this row is here: nothing in the corpus passes it, and it would have been missed |
| `TrackUnit("unit")` / `UntrackUnit("unit")` | todo — pfUI calls `TrackUnit` for every party and raid member under `C.unitframes.track_group` (`modules/superwow.lua:96-127`, inside `pcall`), to put the group on the minimap |
| `UnitPosition("unit")` | done |
| `GetPlayerBuffID(buffIndex)` | done |
| `UnitNameplate("unit")` | todo — the plate frame for a unit; `NamePlates::guid_for_frame` already keys the other direction |
| `CanlootUnit("unit")` | todo. **Note the lowercase `l`**: the *usage* string says `CanlootUnit` where the registered name is `CanLootUnit`. The registered name is what an addon calls; the typo is in the error message only |
| `GetMapPositionWorldLoc(continentIndex, zoneIndex, mapX, mapY)` | todo — map fraction to world XY, which `benilla-world`'s own map maths already does for the world map |
| `GetMapBoundaries(continentIndex, zoneIndex)` | todo — the zone's world extent, the same table that conversion reads |
| `GetInboxItemLink(messageIndex)` | done (built for ClassicAPI's `Mail`) |
| `GetSoundDriverName(driverIndex)` | todo — with `GetSoundDevices`, `GetNumSoundDrivers`, `GetLoadedSoundDriver`: four getters over the audio backend. benilla has one backend and no enumeration, so these answer a one-row list honestly or not at all; no addon in the corpus calls any of them |
| `GetQuestIDForLogIndex(questIndex)` / `GetQuestLinkForLogIndex(questIndex)` | done |
| `GetQuestLinkForID(questID)` | todo — the same link for a quest the log does not hold, which is a server round trip (`Requesting quest ID %d from server` is the DLL's own next string) |
| `ImportFile("filename")` / `ExportFile("filename","text")` | done, under nampower's fence. **Upstream's own folder is `Imports\<name>.txt`** (two more of its literals), where ours is `benilla-config/CustomFiles/` — the deviation is written up in the file-and-credential section below |
And the CVars it registers, as names: `NameplateRange`, `NameplateMotion`, `ChatBubbleRange`,
`ChatBubblesRaid`, `ChatBubblesBattleground`, `ChatBubblesWhisper`, `ChatBubblesCreatures`,
`BackgroundSound`, `UncapSounds`, `InterfaceVolume`, `LootSparkle`, `HealingText`,
`SelectionCircleStyle`. Three of the bubble four are already live under nampower's `NP_*` spellings
on the same fields (`chat_bubble.rs`'s `BubbleConfig`), so those are aliases to add rather than
behaviour to build — the same "two mods, one knob" the nameplate reach already is. `HealingText`
carries its own description in the pool: *"Toggle display of in-world healing feedback"*.
## nampower — **two different mods share this name; this bundle ships the second**
A correction worth stating plainly, because it was wrong here for most of this project's life. The