diff --git a/crates/benilla-app/assets/ui/ChatFrame.xml b/crates/benilla-app/assets/ui/ChatFrame.xml
deleted file mode 100644
index feb5d139..00000000
--- a/crates/benilla-app/assets/ui/ChatFrame.xml
+++ /dev/null
@@ -1,2216 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- if this:GetID() == 1 then
- this:RegisterEvent("EXECUTE_CHAT_LINE")
- this:RegisterEvent("UPDATE_CHAT_WINDOWS")
- end
- -- `isDocked` is NOT set here, and that is the reference's own shape: the flag is
- -- `FCF_DockFrame`'s to write (FloatingChatFrame.lua:1153), and the chat-cache
- -- load is what calls it — this file's trailing seeding loop, which reads
- -- `GetChatWindowInfo`'s `docked` position exactly as `FloatingChatFrame_Update`
- -- (l.34-70) does.
- --
- -- It briefly lived here (1712), gated to ids 1 and 2, while there was no dock
- -- model to own it. There is one now (1714), and leaving the assignment here would
- -- break it outright: `FCF_DockFrame` early-returns on an already-docked frame, so
- -- a pre-set flag would keep both windows out of `DOCKED_CHAT_FRAMES` — docked
- -- according to the flag, absent from the list every dock verb walks.
-
-
- if event == "EXECUTE_CHAT_LINE" then
- SubmitChatInput(arg1)
- elseif event == "UPDATE_CHAT_WINDOWS" then
- BenillaFCF_LoadWindowLooks()
- end
-
-
- ChatFrame_OnUpdate(arg1)
-
-
- -- WoW wheel convention: +1 per notch up, -1 down. Up = older messages.
- if arg1 > 0 then this:ScrollUp() else this:ScrollDown() end
-
-
- BenillaChatFrame_DismissCursor(arg1)
-
-
- SetItemRef(arg1, arg2, arg3)
-
-
-
- FCF_StartMove(this)
-
-
- FCF_StopMove(this)
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- FCF_OnUpdate(arg1)
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- BenillaOnEmoteMenuLoad(BenillaEmoteMenuList)
-
-
-
-
-
-
- BenillaOnEmoteMenuLoad(BenillaVoiceMacroList)
-
-
-
-
-
-
- ChatMenu_OnLoad()
-
-
- UIMenu_OnShow()
- EmoteMenu:Hide()
- VoiceMacroMenu:Hide()
- this:SetBackdropBorderColor(TOOLTIP_DEFAULT_COLOR.r, TOOLTIP_DEFAULT_COLOR.g, TOOLTIP_DEFAULT_COLOR.b)
- this:SetBackdropColor(TOOLTIP_DEFAULT_BACKGROUND_COLOR.r, TOOLTIP_DEFAULT_BACKGROUND_COLOR.g, TOOLTIP_DEFAULT_BACKGROUND_COLOR.b)
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- SubmitChatInput(this:GetText())
- this:SetText("")
- this:ClearFocus()
- this:Hide()
-
-
- this:SetText("")
- this:ClearFocus()
- this:Hide()
-
-
- BenillaChatTabPressed()
-
-
-
-
-
-
-
-
diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml
index 2de6e879..532d863f 100644
--- a/crates/benilla-app/assets/ui/OptionsFrame.xml
+++ b/crates/benilla-app/assets/ui/OptionsFrame.xml
@@ -220,6 +220,23 @@
RegisterForSave("QUEST_FADING_DISABLE")
AUTO_QUEST_WATCH = "1"
RegisterForSave("AUTO_QUEST_WATCH")
+ -- CHAT_LOCKED / REMOVE_CHAT_DELAY — the chat window's two uvars, the reference's own
+ -- UIOptionsFrame.lua l.96-99 defaults ("0" and "0"), read every frame by
+ -- FloatingChatFrame.lua's FCF_Get_ChatLocked and FCF_OnUpdate since the chat window went
+ -- stock (1948). Declared here because this is where the reference declares them.
+ CHAT_LOCKED = "0"
+ RegisterForSave("CHAT_LOCKED")
+ REMOVE_CHAT_DELAY = "0"
+ RegisterForSave("REMOVE_CHAT_DELAY")
+
+ -- The Remove Chat Hover Delay row's applyFunc: the reference's own line
+ -- (UIOptionsFrame.lua l.216, `SetChatMouseOverDelay(REMOVE_CHAT_DELAY)`) under the name the
+ -- row resolves at apply time. `SetChatMouseOverDelay` is FloatingChatFrame.lua's.
+ function ChatFrame_ApplyMouseOverDelay()
+ if SetChatMouseOverDelay then -- a VM that loaded this file without the chat files — a test's
+ SetChatMouseOverDelay(REMOVE_CHAT_DELAY)
+ end
+ end
UIOptionsFrameCheckButtons = { };
------------------- Basic Options -------------------
@@ -3977,4 +3994,11 @@
+
+
diff --git a/crates/benilla-app/assets/ui/ScriptLogFrame.xml b/crates/benilla-app/assets/ui/ScriptLogFrame.xml
index 21c9a628..89a0cab0 100644
--- a/crates/benilla-app/assets/ui/ScriptLogFrame.xml
+++ b/crates/benilla-app/assets/ui/ScriptLogFrame.xml
@@ -377,4 +377,31 @@
BenillaScriptLogFrame_OnUpdate()
+
+
diff --git a/crates/benilla-app/assets/ui/SpellBookAdapters.xml b/crates/benilla-app/assets/ui/SpellBookAdapters.xml
new file mode 100644
index 00000000..d37fa3be
--- /dev/null
+++ b/crates/benilla-app/assets/ui/SpellBookAdapters.xml
@@ -0,0 +1,55 @@
+
+
+
+
diff --git a/crates/benilla-app/assets/ui/SpellBookFrame.xml b/crates/benilla-app/assets/ui/SpellBookFrame.xml
deleted file mode 100644
index eb295a13..00000000
--- a/crates/benilla-app/assets/ui/SpellBookFrame.xml
+++ /dev/null
@@ -1,965 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- SpellBookSkillLineTab_OnClick()
-
- GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
- if self.tooltip then
- GameTooltip:SetText(self.tooltip)
- GameTooltip:Show()
- end
-
- GameTooltip:Hide()
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- SpellButton_OnLoad()
- SpellButton_OnEvent(event)
- SpellButton_OnClick()
- BenillaSpellButton_UpdateButton(self)
-
- SpellButton_OnClick(1)
- SpellButton_OnClick(1)
- BenillaSpellButton_OnEnter(self)
- GameTooltip:Hide()
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- SpellBookFrame_OnLoad()
- SpellBookFrame_OnEvent()
- SpellBookFrame_OnShow()
- SpellBookFrame_OnHide()
-
-
-
diff --git a/crates/benilla-app/assets/ui/UIParent.xml b/crates/benilla-app/assets/ui/UIParent.xml
index ebfead66..1ae63d3c 100644
--- a/crates/benilla-app/assets/ui/UIParent.xml
+++ b/crates/benilla-app/assets/ui/UIParent.xml
@@ -40,6 +40,12 @@
this:RegisterEvent("CONFIRM_LOOT_ROLL")
UIParent_OnEvent(event)
+
+ if FCF_OnUpdate then FCF_OnUpdate(arg1) end
diff --git a/crates/benilla-app/assets/ui/benilla.toc b/crates/benilla-app/assets/ui/benilla.toc
index 6f087c4c..3201901f 100644
--- a/crates/benilla-app/assets/ui/benilla.toc
+++ b/crates/benilla-app/assets/ui/benilla.toc
@@ -614,7 +614,7 @@ Interface\FrameXML\DurabilityFrame.xml
# shadowed name with no caller is building against a guess" rule (1222). That rule is about names
# WE would invent; this one is the reference's own declaration, so there is nothing left to guess.
#
-# `ChatTypeInfo["RAID_WARNING"]` comes from ChatFrame.xml far below — read at call time, so the
+# `ChatTypeInfo["RAID_WARNING"]` comes from the reference's ChatFrame.xml far below — read at call time, so the
# order does not bind.
Interface\FrameXML\UIErrorsFrame.xml
Interface\FrameXML\RaidWarning.xml
@@ -798,11 +798,18 @@ Interface\AddOns\Blizzard_InspectUI\InspectHonorFrame.xml
# is a global resolved at call time. Needs UiPanels (the panel manager, its left-slot row) and
# Fonts; its booth pane needs nothing loaded at all.
DressUpFrame.xml
-# The spellbook window (decision 0216 §8, slice 5): the P-key window, the spell SOURCE
-# for the cursor-payload arc. Loaded right after CharacterFrame (the same "player-state
-# windows grouped" posture, same Fonts/UiPanels/GameTooltip-only dependency set); the
-# 'P' binding (ui_script/input.rs) calls its bare ToggleSpellBook(BOOKTYPE_SPELL).
-SpellBookFrame.xml
+# ══ THE SPELLBOOK — the reference's own, off the player's chain (1751; decision 1952) ══
+# SpellBookFrame.xml + SpellBookFrame.lua: the book, the twelve page buttons, the skill-line tabs,
+# the Spell/Pet toggle tabs. Its whole engine surface was already ours (1924's UpdateSpells, 1931's
+# GetSpellTabInfo tuple); what stopped it in 1926 — MultiActionBar_ShowAllGrids/HideAllGrids on
+# show/hide — has been the reference's MultiActionBars.lua since 1938. Loaded here, after the
+# action-bar files it calls into and after MicroMenu (UpdateMicroButtons on show). The 'P' binding
+# calls its own ToggleSpellBook(BOOKTYPE_SPELL). 0216's transcription is gone.
+Interface\FrameXML\SpellBookFrame.xml
+# The one adapter the reference's file needs from this engine: its pet-page autocast shine is a
+# (UI-AutoCastButton.mdx) and this engine's draws no pixels; the shine benilla
+# draws is its own token texture (1383/1392/1393). BENILLA_ALLOW_OWN_UI=1.
+SpellBookAdapters.xml
# ══ THE TALENT WINDOW — the reference's own, off the player's chain (1751, window 25) ══
# The second LoadOnDemand addon (1832 was the first, and its header carries the mechanics).
# `Blizzard_TalentUI` is FIVE files behind one manifest line: the `.xml` sources its own `.lua`
@@ -1020,7 +1027,18 @@ Interface\FrameXML\QuestLogFrame.xml
# and SecondsToTime), GameTooltip (its row hover), and QuestLogFrame — whose window a row
# click opens, and whose MAX_QUESTS it shares.
Interface\FrameXML\QuestTimerFrame.xml
-ChatFrame.xml
+# ══ THE CHAT WINDOW — the reference's own, off the player's chain (1751; decision 1948) ══
+# ChatFrame.xml (ChatFrameEditBox, ChatMenu/EmoteMenu/LanguageMenu/VoiceMacroMenu, ChatFrameMenuButton,
+# the frame/tab/edit-box templates, ChatFrame.lua) then FloatingChatFrame.xml (ChatFrame1..7, their tabs
+# and dock regions, the tab dropdown, FloatingChatFrame.lua) — the reference's pair in its own order
+# (FrameXML.toc l.48-49). What they call and we built for them: the window record verbs and the colour
+# registry (benilla-ui script/chat_window.rs, chat_types.rs), the channel verbs (channel.rs), the
+# language walk (chat_send.rs), the slash-handler verbs (chat_misc.rs), and UpdateColorByID. Our own
+# transcription (0288's ChatFrame.xml) is gone; benilla's instrument commands (/chattest, /shot, …)
+# register through SlashCmdList from ScriptLogFrame.xml's dev block, and the app's chat feed FIRES the
+# CHAT_MSG_* events the reference's ChatFrame_OnEvent prints from — it no longer prints them itself.
+Interface\FrameXML\ChatFrame.xml
+Interface\FrameXML\FloatingChatFrame.xml
# ══ THE MACRO WINDOW — the reference's own, off the player's chain (1751, window 32) ══
# The fourth LoadOnDemand addon (1832's header carries the mechanics). ONE manifest line: its toc
# lists `Blizzard_MacroUI.xml`, which sources its own `.lua`, plus the empty `Localization.lua`.
diff --git a/crates/benilla-app/src/bindings/commands.rs b/crates/benilla-app/src/bindings/commands.rs
index 8e44e55f..253505d4 100644
--- a/crates/benilla-app/src/bindings/commands.rs
+++ b/crates/benilla-app/src/bindings/commands.rs
@@ -129,10 +129,6 @@ pub(crate) mod cmd {
pub(crate) const TOGGLE_SHEATH: Cmd = by_name("TOGGLESHEATH");
pub(crate) const TOGGLE_AUTORUN: Cmd = by_name("TOGGLEAUTORUN");
pub(crate) const TOGGLE_RUN: Cmd = by_name("TOGGLERUN");
- pub(crate) const OPEN_CHAT: Cmd = by_name("OPENCHAT");
- pub(crate) const OPEN_CHAT_SLASH: Cmd = by_name("OPENCHATSLASH");
- pub(crate) const REPLY: Cmd = by_name("REPLY");
- pub(crate) const REPLY2: Cmd = by_name("REPLY2");
pub(crate) const TARGET_NEAREST_ENEMY: Cmd = by_name("TARGETNEARESTENEMY");
pub(crate) const TARGET_PREVIOUS_ENEMY: Cmd = by_name("TARGETPREVIOUSENEMY");
pub(crate) const NAMEPLATES: Cmd = by_name("NAMEPLATES");
@@ -191,34 +187,89 @@ const TABLE: &[Spec] = &[
None
),
// ── Chat (BINDING_HEADER_CHAT) ──────────────────────────────────────────────────────
- spec!("OPENCHAT", CHAT, Kind::Host, Some("ENTER"), None),
- spec!("OPENCHATSLASH", CHAT, Kind::Host, Some("/"), None),
+ spec!(
+ "OPENCHAT",
+ CHAT,
+ Kind::Edge("ChatFrame_OpenChat(\"\")"),
+ Some("ENTER"),
+ None
+ ),
+ spec!(
+ "OPENCHATSLASH",
+ CHAT,
+ Kind::Edge("ChatFrame_OpenChat(\"/\")"),
+ Some("/"),
+ None
+ ),
spec!(
"CHATPAGEUP",
CHAT,
- Kind::Edge(r#"getglobal("ChatFrame" .. BenillaFCF.selected):PageUp()"#),
+ Kind::Edge("ChatFrame_ChatPageUp()"),
Some("PAGEUP"),
None
),
spec!(
"CHATPAGEDOWN",
CHAT,
- Kind::Edge(r#"getglobal("ChatFrame" .. BenillaFCF.selected):PageDown()"#),
+ Kind::Edge("ChatFrame_ChatPageDown()"),
Some("PAGEDOWN"),
None
),
spec!(
"CHATBOTTOM",
CHAT,
- Kind::Edge(r#"getglobal("ChatFrame" .. BenillaFCF.selected):ScrollToBottom()"#),
+ Kind::Edge("ChatFrame_ScrollToBottom()"),
Some("SHIFT-PAGEDOWN"),
None
),
- spec!("REPLY", CHAT, Kind::Host, Some("R"), None),
+ spec!(
+ "REPLY",
+ CHAT,
+ Kind::Edge("ChatFrame_ReplyTell()"),
+ Some("R"),
+ None
+ ),
// The other reply: the last person YOU told, not the last who told you
// (`ChatEdit_GetLastToldTarget`, ChatFrame.lua l.1650). The memory was already being kept by
// the send path and read by nothing — 1745.
- spec!("REPLY2", CHAT, Kind::Host, Some("SHIFT-R"), None),
+ spec!(
+ "REPLY2",
+ CHAT,
+ Kind::Edge("ChatFrame_ReplyTell2()"),
+ Some("SHIFT-R"),
+ None
+ ),
+ // The combat-log four (Bindings.xml l.108-119): ChatFrame2's own paging and the reference's
+ // `ToggleCombatLog`, both FloatingChatFrame.lua's since the chat window became the
+ // reference's (1948). Chords from `bindings-cache.wtf`, account ONE.
+ spec!(
+ "COMBATLOGPAGEUP",
+ CHAT,
+ Kind::Edge("ChatFrame2:PageUp()"),
+ Some("CTRL-PAGEUP"),
+ None
+ ),
+ spec!(
+ "COMBATLOGPAGEDOWN",
+ CHAT,
+ Kind::Edge("ChatFrame2:PageDown()"),
+ Some("CTRL-PAGEDOWN"),
+ None
+ ),
+ spec!(
+ "COMBATLOGBOTTOM",
+ CHAT,
+ Kind::Edge("ChatFrame2:ScrollToBottom()"),
+ Some("CTRL-SHIFT-PAGEDOWN"),
+ None
+ ),
+ spec!(
+ "TOGGLECOMBATLOG",
+ CHAT,
+ Kind::Edge("ToggleCombatLog()"),
+ Some("SHIFT-C"),
+ None
+ ),
// ── Action bar (BINDING_HEADER_ACTIONBAR) ───────────────────────────────────────────
// The ref's runOnUp pair (Bindings.xml:121: DOWN shows the pushed visual, UP fires) —
// exactly what the old hardcoded number-row table sent.
@@ -1776,28 +1827,6 @@ pub(crate) static ABSENT: &[Absent] = &[
["PitchDownStart", "PitchDownStop"],
"no keyboard pitch — see PITCHUP"
),
- // ── Chat ────────────────────────────────────────────────────────────────────────────
- absent!(
- "COMBATLOGPAGEUP",
- ["ToggleCombatLog"],
- "ChatFrame2 is a real frame that nothing writes into — the combat-log line pipeline does \
- not exist, which is why 1.12's own ToggleCombatLog has no home here"
- ),
- absent!(
- "COMBATLOGPAGEDOWN",
- ["ToggleCombatLog"],
- "nothing writes the combat log — see COMBATLOGPAGEUP"
- ),
- absent!(
- "COMBATLOGBOTTOM",
- ["ToggleCombatLog"],
- "nothing writes the combat log — see COMBATLOGPAGEUP"
- ),
- absent!(
- "TOGGLECOMBATLOG",
- ["ToggleCombatLog"],
- "nothing writes the combat log — see COMBATLOGPAGEUP"
- ),
// ── Action bar ──────────────────────────────────────────────────────────────────────
// ── Interface ───────────────────────────────────────────────────────────────────────
absent!(
diff --git a/crates/benilla-app/src/capture/fixtures.rs b/crates/benilla-app/src/capture/fixtures.rs
index 576bc935..7cc40392 100644
--- a/crates/benilla-app/src/capture/fixtures.rs
+++ b/crates/benilla-app/src/capture/fixtures.rs
@@ -1254,9 +1254,9 @@ pub(super) fn seed_ui_fixture(
"ChatFrameEditBox:SetText(\"hello northshire\")\n\
ChatFrameEditBox:HighlightText(6, 16)\n\
ChatFrame1:SetScript('OnUpdate', function()\n\
- BenillaFCF_TabResize(ChatFrame1Tab)\n\
+ PanelTemplates_TabResize(10, ChatFrame1Tab)\n\
ChatFrame1Tab:SetAlpha(1.0)\n\
- for _, t in ipairs(BenillaFCF_Textures(1)) do t:SetAlpha(0.25) end\n\
+ FCF_SetWindowAlpha(ChatFrame1, 0.25, 1)\n\
end)",
) {
warn!("capture: ui-chatedit seed failed: {e}");
@@ -1293,7 +1293,7 @@ pub(super) fn seed_ui_fixture(
// a tab quad is measured against
let mode = std::env::var("WOW_TABHOVER").unwrap_or_else(|_| "1".into());
let select = if mode == "3" { 1 } else { 2 };
- if let Err(e) = script.run(&format!("BenillaFCF_TabClick({select})")) {
+ if let Err(e) = script.run(&format!("FCF_SelectDockFrame(ChatFrame{select})")) {
warn!("capture: ui-chat-tabhover select failed: {e}");
}
script.resolve();
diff --git a/crates/benilla-app/src/capture/probes/live_fps.rs b/crates/benilla-app/src/capture/probes/live_fps.rs
index a066fd3b..454a7a56 100644
--- a/crates/benilla-app/src/capture/probes/live_fps.rs
+++ b/crates/benilla-app/src/capture/probes/live_fps.rs
@@ -57,6 +57,7 @@ impl Plugin for LiveFpsPlugin {
decodes_prev: 0,
load_samples: Vec::new(),
paced_samples: Vec::new(),
+ threads_at_start: None,
cpu_at_start: None,
sys_at_start: None,
occluded_now: false,
@@ -108,6 +109,9 @@ struct LiveFps {
/// snaps a cadenced delta to whole refreshes; a paced delta of two periods is a frame the
/// display actually skipped.
paced_samples: Vec,
+ /// Per-thread CPU at the window's first frame (`perf::thread_cpu_table`), keyed by pthread
+ /// identity — subtracted at the window's end for `thr=[name:ms/frame,…]`.
+ threads_at_start: Option>,
/// Process CPU seconds at the first sampled frame ([`crate::perf::process_cpu_secs`]) — the
/// baseline for the window's `cpu_ms`/`cpu_pct`.
cpu_at_start: Option,
@@ -169,6 +173,7 @@ type ScreenParams<'w, 's> = (
MessageReader<'w, 's, AssetEvent>,
MessageReader<'w, 's, AssetEvent>,
Res<'w, Time>,
+ ResMut<'w, crate::perf::MainThreadSplit>,
);
#[derive(SystemParam)]
@@ -223,6 +228,7 @@ fn drive_live_fps(
);
let (mesh_events, mat_events) = (&mut screen.7, &mut screen.8);
let paced_ms = screen.9.delta_secs() * 1000.0;
+ let main_split = &mut screen.10;
// Read every frame (a reader that only reads inside the window would report the whole
// backlog on its first sampled frame).
let mesh_added = mesh_events
@@ -295,6 +301,8 @@ fn drive_live_fps(
LiveFpsPhase::Sampling => {
if probe.samples.is_empty() {
probe.cpu_at_start = crate::perf::process_cpu_secs();
+ probe.threads_at_start = crate::perf::thread_cpu_table();
+ main_split.restart();
probe.sys_at_start = crate::perf::system_cpu_ticks();
// The churn census restarts with the window — warmup noise (streaming, shader
// warms) would otherwise read as steady-state ratchets.
@@ -427,6 +435,7 @@ fn drive_live_fps(
};
// CPU cost per frame across every thread — the load-robust half of the measurement
// (`perf::process_cpu_secs`), and directly comparable with a reporter's CPU %.
+ let v_len = v.len();
let cpu = match (probe.cpu_at_start, crate::perf::process_cpu_secs()) {
(Some(t0), Some(t1)) => {
let per_frame_ms = (t1 - t0) * 1000.0 / v.len() as f64;
@@ -463,9 +472,45 @@ fn drive_live_fps(
})
.collect::>()
.join(",");
+ // Per thread, ms per frame over the window — the split that says which
+ // thread a tax landed on; threads sharing a name (a pool) are summed.
+ let threads = match (&probe.threads_at_start, crate::perf::thread_cpu_table()) {
+ (Some(t0), Some(t1)) => {
+ let start: std::collections::HashMap =
+ t0.iter().map(|(k, _, u, s)| (*k, (*u, *s))).collect();
+ let mut by_name: std::collections::HashMap =
+ std::collections::HashMap::new();
+ for (k, name, u1, s1) in &t1 {
+ let (u0, s0) = start.get(k).copied().unwrap_or((0.0, 0.0));
+ let name = match name.rfind(" (") {
+ Some(i) if name.ends_with(')') => name[..i].to_string(),
+ _ => name.clone(),
+ };
+ let e = by_name.entry(name).or_default();
+ e.0 += u1 - u0;
+ e.1 += s1 - s0;
+ }
+ let mut v: Vec<(String, (f64, f64))> = by_name.into_iter().collect();
+ v.sort_by(|a, b| (b.1 .0 + b.1 .1).total_cmp(&(a.1 .0 + a.1 .1)));
+ let per = |secs: f64| secs * 1000.0 / v_len as f64;
+ // `name:user+sys`, ms per frame.
+ let list = v
+ .iter()
+ .take(8)
+ .map(|(n, (u, s))| format!("{n}:{:.2}+{:.2}", per(*u), per(*s)))
+ .collect::>()
+ .join(",");
+ let inside = main_split
+ .inside_ms()
+ .map(|m| format!(" main_in={m:.2}"))
+ .unwrap_or_default();
+ format!(" thr=[{list}]{inside}")
+ }
+ _ => String::new(),
+ };
format!(
" cpu_ms={per_frame_ms:.2} cpu_pct={:.0} cpu_p95={:.2} cpu_p99={:.2} \
- frames_over={over} dropped={dropped} paced_max={paced_max:.1} worst=[{worst}]",
+ frames_over={over} dropped={dropped} paced_max={paced_max:.1} worst=[{worst}]{threads}",
per_frame_ms / mean as f64 * 100.0,
cat(0.95),
cat(0.99),
diff --git a/crates/benilla-app/src/local_state.rs b/crates/benilla-app/src/local_state.rs
index 381cb650..7087fcfb 100644
--- a/crates/benilla-app/src/local_state.rs
+++ b/crates/benilla-app/src/local_state.rs
@@ -272,6 +272,12 @@ pub(crate) fn name_cache_path(realm: &str) -> Option {
/// `benilla-config/shots.txt` — the framing instrument's appended camera poses (decision 0600). A dev
/// affordance (`/shot`, compiled out by `--no-default-features` since 1179), but it persists on a
/// real machine, so it resolves here like everything else rather than through a private path.
+/// `Logs/` — `WoWChatLog.txt` and `WoWCombatLog.txt`, the reference's two names beside its
+/// `WTF`, kept here beside ours ([`crate::ui_chat`]'s logging).
+pub(crate) fn logs_dir() -> Option {
+ home().map(|h| h.join("Logs"))
+}
+
pub(crate) fn shots_path() -> Option {
home().map(|h| h.join("shots.txt"))
}
diff --git a/crates/benilla-app/src/net.rs b/crates/benilla-app/src/net.rs
index b61c533a..1832a497 100644
--- a/crates/benilla-app/src/net.rs
+++ b/crates/benilla-app/src/net.rs
@@ -1762,6 +1762,60 @@ pub(crate) enum ClientCommand {
min: u32,
max: u32,
},
+ /// `DisplayChannelOwner` — `CMSG_CHANNEL_OWNER`.
+ ChannelOwner {
+ name: String,
+ },
+ /// `SetChannelOwner` — `CMSG_CHANNEL_SET_OWNER`.
+ ChannelSetOwner {
+ name: String,
+ player: String,
+ },
+ /// `SetChannelPassword` — `CMSG_CHANNEL_PASSWORD` (an empty password clears it).
+ ChannelPassword {
+ name: String,
+ password: String,
+ },
+ ChannelModerator {
+ name: String,
+ player: String,
+ },
+ ChannelUnmoderator {
+ name: String,
+ player: String,
+ },
+ ChannelMute {
+ name: String,
+ player: String,
+ },
+ ChannelUnmute {
+ name: String,
+ player: String,
+ },
+ ChannelInvite {
+ name: String,
+ player: String,
+ },
+ ChannelKick {
+ name: String,
+ player: String,
+ },
+ ChannelBan {
+ name: String,
+ player: String,
+ },
+ ChannelUnban {
+ name: String,
+ player: String,
+ },
+ /// `ChannelToggleAnnouncements` — `CMSG_CHANNEL_ANNOUNCEMENTS`.
+ ChannelAnnouncements {
+ name: String,
+ },
+ /// `ChannelModerate` — `CMSG_CHANNEL_MODERATE`.
+ ChannelModerate {
+ name: String,
+ },
/// `/played` (`CMSG_PLAYED_TIME`).
PlayedTime,
/// Acknowledge a triggered cinematic as finished (`CMSG_COMPLETE_CINEMATIC`) — sent by
diff --git a/crates/benilla-app/src/net/io.rs b/crates/benilla-app/src/net/io.rs
index c7ebd7de..d7cde2a1 100644
--- a/crates/benilla-app/src/net/io.rs
+++ b/crates/benilla-app/src/net/io.rs
@@ -908,6 +908,33 @@ fn writer_loop(
ClientCommand::LeaveChannel { name } => w.leave_channel(&name),
ClientCommand::ChannelList { name } => w.channel_list(&name),
ClientCommand::RandomRoll { min, max } => w.random_roll(min, max),
+ ClientCommand::ChannelOwner { name } => w.channel_owner(&name),
+ ClientCommand::ChannelSetOwner { name, player } => {
+ w.channel_set_owner(&name, &player)
+ }
+ ClientCommand::ChannelPassword { name, password } => {
+ w.channel_password(&name, &password)
+ }
+ ClientCommand::ChannelModerator { name, player } => {
+ w.channel_moderator(&name, &player)
+ }
+ ClientCommand::ChannelUnmoderator { name, player } => {
+ w.channel_unmoderator(&name, &player)
+ }
+ ClientCommand::ChannelMute { name, player } => w.channel_mute(&name, &player),
+ ClientCommand::ChannelUnmute { name, player } => {
+ w.channel_unmute(&name, &player)
+ }
+ ClientCommand::ChannelInvite { name, player } => {
+ w.channel_invite(&name, &player)
+ }
+ ClientCommand::ChannelKick { name, player } => w.channel_kick(&name, &player),
+ ClientCommand::ChannelBan { name, player } => w.channel_ban(&name, &player),
+ ClientCommand::ChannelUnban { name, player } => {
+ w.channel_unban(&name, &player)
+ }
+ ClientCommand::ChannelAnnouncements { name } => w.channel_announcements(&name),
+ ClientCommand::ChannelModerate { name } => w.channel_moderate(&name),
ClientCommand::PlayedTime => w.played_time(),
ClientCommand::NameQuery { guid } => w.name_query(guid),
ClientCommand::CreatureQuery { entry, guid } => w.creature_query(entry, guid),
diff --git a/crates/benilla-app/src/perf/clock.rs b/crates/benilla-app/src/perf/clock.rs
index ddfd2893..1eb5324e 100644
--- a/crates/benilla-app/src/perf/clock.rs
+++ b/crates/benilla-app/src/perf/clock.rs
@@ -130,3 +130,88 @@ pub(crate) fn system_cpu_ticks() -> Option<(u64, u64)> {
None
}
}
+
+/// CPU seconds consumed so far by **every thread of this process, by name** — the split of
+/// [`process_cpu_secs`] that says *which* thread a tax landed on. A frame's `cpu_ms` rose by
+/// ~1.8 ms under vsync with no system growing on a sampled profile and no thread spinning
+/// (decision 1947): the number that resolves that is per-thread CPU across a window, which
+/// no sampler reports and this call does.
+///
+/// macOS only (`task_threads` + `thread_info(THREAD_BASIC_INFO)`, names through
+/// `pthread_getname_np`); elsewhere `None`, like the twins above. Threads are keyed by their
+/// pthread identity so two snapshots subtract, user and system time apart — the vsync tax
+/// turned out to be the main thread's kernel time, which a user-mode sampler never sees; a
+/// thread without a name reports as `"?"`.
+/// Cost: one kernel round-trip per thread — tens of microseconds, taken twice per probe
+/// window, never per frame.
+pub(crate) fn thread_cpu_table() -> Option> {
+ // `libc` deprecates its mach port bindings in favour of a crate this module does not need
+ // for two symbols; both are libSystem's, the import every mach caller links.
+ #[cfg(target_os = "macos")]
+ extern "C" {
+ static mach_task_self_: libc::mach_port_t;
+ fn mach_port_deallocate(
+ task: libc::mach_port_t,
+ name: libc::mach_port_t,
+ ) -> libc::kern_return_t;
+ }
+ #[cfg(target_os = "macos")]
+ {
+ use std::ffi::CStr;
+ let mut out = Vec::new();
+ // SAFETY: mach's own out-param protocol — `task_threads` hands back a kernel-allocated
+ // array of thread ports and its count, each port is inspected with a stack-allocated,
+ // count-checked `thread_basic_info`, and every port and the array are released after.
+ unsafe {
+ let task = mach_task_self_;
+ let mut list: libc::thread_act_array_t = std::ptr::null_mut();
+ let mut count: libc::mach_msg_type_number_t = 0;
+ if libc::task_threads(task, &mut list, &mut count) != libc::KERN_SUCCESS {
+ return None;
+ }
+ for i in 0..count as usize {
+ let port = *list.add(i);
+ let mut info: libc::thread_basic_info = std::mem::zeroed();
+ let mut n = libc::THREAD_BASIC_INFO_COUNT;
+ let kr = libc::thread_info(
+ port,
+ libc::THREAD_BASIC_INFO as libc::thread_flavor_t,
+ (&mut info as *mut libc::thread_basic_info).cast(),
+ &mut n,
+ );
+ if kr == libc::KERN_SUCCESS {
+ let secs = |t: libc::time_value_t| {
+ f64::from(t.seconds) + f64::from(t.microseconds) * 1e-6
+ };
+ let (user, system) = (secs(info.user_time), secs(info.system_time));
+ let pthread = libc::pthread_from_mach_thread_np(port);
+ let mut name = [0 as libc::c_char; 64];
+ let label = if pthread != 0
+ && libc::pthread_getname_np(pthread, name.as_mut_ptr(), name.len()) == 0
+ {
+ let s = CStr::from_ptr(name.as_ptr()).to_string_lossy();
+ if s.is_empty() {
+ "?".to_string()
+ } else {
+ s.into_owned()
+ }
+ } else {
+ "?".to_string()
+ };
+ out.push((pthread as usize, label, user, system));
+ }
+ mach_port_deallocate(task, port);
+ }
+ libc::vm_deallocate(
+ task,
+ list as libc::vm_address_t,
+ (count as usize * std::mem::size_of::()) as libc::vm_size_t,
+ );
+ }
+ Some(out)
+ }
+ #[cfg(not(target_os = "macos"))]
+ {
+ None
+ }
+}
diff --git a/crates/benilla-app/src/perf/main_split.rs b/crates/benilla-app/src/perf/main_split.rs
new file mode 100644
index 00000000..af5eead9
--- /dev/null
+++ b/crates/benilla-app/src/perf/main_split.rs
@@ -0,0 +1,49 @@
+//! **The main thread's CPU, inside the app's frame** — `First` to `Last`, per frame, so the
+//! probe can subtract it from the thread's whole-frame CPU (`thr=[main:…]`) and name what is
+//! left: the event loop, AppKit's redraw path, the pipelined-render handshake. The vsync tax
+//! (decision 1947/1949) is +1.2 ms of main-thread user time per frame that no system grew and
+//! no sampler placed; whether it is spent inside the schedules or around them is this split.
+//!
+//! Two pinned systems, one `clock_gettime` each — nothing a frame can feel.
+
+use bevy::prelude::*;
+
+use super::clock::main_thread_cpu_secs;
+
+#[derive(Resource, Default)]
+pub(crate) struct MainThreadSplit {
+ at_first: Option,
+ /// Sum of per-frame main-thread CPU inside `First..Last`, seconds, and the frames summed.
+ pub inside_secs: f64,
+ pub frames: u32,
+}
+
+impl MainThreadSplit {
+ /// Restart the accumulation (the probe does this at its window's first frame).
+ pub fn restart(&mut self) {
+ self.inside_secs = 0.0;
+ self.frames = 0;
+ }
+
+ /// Mean main-thread CPU inside the app's frame, ms per frame, over the accumulation.
+ pub fn inside_ms(&self) -> Option {
+ (self.frames > 0).then(|| self.inside_secs * 1000.0 / f64::from(self.frames))
+ }
+}
+
+fn stamp_first(_pin: bevy::ecs::system::NonSendMarker, mut split: ResMut) {
+ split.at_first = main_thread_cpu_secs();
+}
+
+fn stamp_last(_pin: bevy::ecs::system::NonSendMarker, mut split: ResMut) {
+ if let (Some(t0), Some(t1)) = (split.at_first, main_thread_cpu_secs()) {
+ split.inside_secs += t1 - t0;
+ split.frames += 1;
+ }
+}
+
+pub(crate) fn plugin(app: &mut App) {
+ app.init_resource::()
+ .add_systems(First, stamp_first)
+ .add_systems(Last, stamp_last);
+}
diff --git a/crates/benilla-app/src/perf/mod.rs b/crates/benilla-app/src/perf/mod.rs
index d1ae6c7d..23d8c7e4 100644
--- a/crates/benilla-app/src/perf/mod.rs
+++ b/crates/benilla-app/src/perf/mod.rs
@@ -44,6 +44,7 @@ mod clock;
mod gpu;
mod hud;
mod journal;
+mod main_split;
mod phases;
#[cfg(target_os = "macos")]
mod stall;
@@ -53,10 +54,11 @@ mod trace;
use bevy::prelude::*;
use bevy::render::diagnostic::RenderDiagnosticsPlugin;
-pub(crate) use clock::{process_cpu_secs, system_cpu_ticks};
+pub(crate) use clock::{process_cpu_secs, system_cpu_ticks, thread_cpu_table};
pub(crate) use gpu::{GpuMsShared, WgpuCensusShared};
pub(crate) use hud::PerfHud;
pub(crate) use journal::FpsJournalPlugin;
+pub(crate) use main_split::MainThreadSplit;
/// The frame budget: a 60 fps floor. No frame should exceed this.
pub const FRAME_BUDGET_MS: f32 = 1000.0 / 60.0;
@@ -67,6 +69,7 @@ impl Plugin for PerfPlugin {
fn build(&self, app: &mut App) {
// Render-pass timing (CPU-only on Apple Silicon — see the module header). Harmless if
// the backend can't record it; also the hook Tracy GPU uses on Vulkan/DX12.
+ main_split::plugin(app);
app.add_plugins(RenderDiagnosticsPlugin)
.init_resource::()
.init_resource::()
diff --git a/crates/benilla-app/src/sound/output/mod.rs b/crates/benilla-app/src/sound/output/mod.rs
index 5a8b25af..db7be143 100644
--- a/crates/benilla-app/src/sound/output/mod.rs
+++ b/crates/benilla-app/src/sound/output/mod.rs
@@ -707,7 +707,18 @@ fn render_loop(
) {
// Held for the thread's life and dropped with it: on Windows that hands the MMCSS
// registration back, everywhere else it is inert.
- let _realtime = match device::set_realtime(period_ns) {
+ //
+ // `WOW_AUDIO_NO_RT=1` — the A/B lever (decision 1947): a raid with sound on reads a
+ // `cpu_p99` several ms over its mean while the annotation names no churn, and a realtime
+ // render thread preempting the compute pools on four performance cores is the standing
+ // suspect. Off, the thread takes the user-interactive QoS fallback below.
+ let no_rt = std::env::var_os("WOW_AUDIO_NO_RT").is_some();
+ let realtime = if no_rt {
+ Err(anyhow::anyhow!("WOW_AUDIO_NO_RT=1"))
+ } else {
+ device::set_realtime(period_ns)
+ };
+ let _realtime = match realtime {
Ok(realtime) => {
debug!(
"audio: render thread scheduled realtime, period {} µs",
diff --git a/crates/benilla-app/src/target/click.rs b/crates/benilla-app/src/target/click.rs
index 14554cb1..a1c786b0 100644
--- a/crates/benilla-app/src/target/click.rs
+++ b/crates/benilla-app/src/target/click.rs
@@ -1436,6 +1436,7 @@ pub(super) fn selection_requests(
// the classification too, which is why this drain no longer states one: hand-stating it here
// is exactly how a `false` that the binary refutes got written down.
mut commit: super::by_name::SelectCommit,
+ mut assist_by_name: MessageWriter,
) {
let Some(mut script) = script else {
return;
@@ -1461,6 +1462,11 @@ pub(super) fn selection_requests(
Some((basis, _)) => commit.assist(basis, "AssistUnit"),
None => info!("assist (AssistUnit): \"{token}\" names nothing; silent no-op"),
},
+ // `0x489c40` — the `/assist Bob` handler's verb: a player NAME, resolved by the
+ // app's name scan (prefix-ok, players only) the way the slash line always was.
+ SelectionRequest::AssistByName(name) => {
+ assist_by_name.write(super::by_name::AssistRequest { name: Some(name) });
+ }
// `0x489b45` reads the last-attackable pair and hands it to the same `0x489a40`.
// **Empty memory is a no-op, and that is derived now**: the shim `0x489b40` is
// thirteen bytes with no emptiness test at all, where `TargetLastTarget 0x489b00`
@@ -2053,6 +2059,7 @@ mod tests {
world.init_resource::();
world.init_resource::();
world.init_resource::();
+ world.init_resource::>();
world.insert_non_send_resource(UiScript::new().expect("a bare VM"));
world.spawn((SelfPlayer, Guid(ME), store(&[(22, 100), (28, 100)])));
diff --git a/crates/benilla-app/src/ui_chat.rs b/crates/benilla-app/src/ui_chat.rs
index 217e1143..efe07edd 100644
--- a/crates/benilla-app/src/ui_chat.rs
+++ b/crates/benilla-app/src/ui_chat.rs
@@ -31,6 +31,7 @@ mod input;
mod language;
/// The chat windows' saved look (B246, decision 1589) — where the tab menu's tint/alpha/font-size
/// picks are read from at login and written back at logout.
+mod logging;
mod settings;
#[cfg(test)]
mod tests;
@@ -54,7 +55,6 @@ impl Plugin for UiChatPlugin {
fn build(&self, app: &mut App) {
app.init_resource::()
.init_resource::()
- .init_resource::()
.init_resource::()
.init_resource::()
.init_resource::()
@@ -127,14 +127,6 @@ impl Plugin for UiChatPlugin {
.add_systems(
Update,
(
- edit::open_chat_keys,
- edit::open_tell_requests,
- // An addon's `ChatFrame_OpenChat` — before the live parse in the same chain,
- // so a box opened prefilled `/w Bob ` has its type switched on the very next
- // frame rather than showing the raw slash to the user first.
- edit::open_chat_requests,
- edit::chat_edit_live,
- edit::chat_tab_cycle,
input::drain_chat_input,
// An addon's own line into the wire (decision 1199). AFTER the box's drain
// and in the same chain, so a `SendChatMessage` fired from a slash handler
@@ -171,6 +163,7 @@ impl Plugin for UiChatPlugin {
);
// The per-character saved look (B246) — its own load/watch/save edges.
settings::plugin(app);
+ logging::plugin(app);
}
}
@@ -196,31 +189,18 @@ impl Plugin for UiChatPlugin {
/// its scrollback is the whole point.
fn end_session_chat(
script: Option>,
- mut windows: ResMut,
- mut edit: ResMut,
mut log: ResMut,
) {
- end_chat_session(
- script.map(NonSendMut::into_inner),
- &mut windows,
- &mut edit,
- &mut log,
- );
+ end_chat_session(script.map(NonSendMut::into_inner), &mut log);
}
/// [`end_session_chat`]'s body, callable without a `World` — the clear is the law, the system is
/// the wiring.
pub(crate) fn end_chat_session(
script: Option<&mut benilla_ui::script::UiScript>,
- windows: &mut frames::ChatWindows,
- edit: &mut edit::ChatEditState,
log: &mut ChatLog,
) {
*log = ChatLog::default();
- windows.tell_alert_left = 0.0;
- // The channel target/number are [`channels::end_session_channels`]'s (1284); everything else
- // the box remembers across an open is this session's too.
- *edit = edit::ChatEditState::default();
if let Some(script) = script {
for frame in ["ChatFrame1", "ChatFrame2"] {
crate::ui_script::run_or_warn(script, &format!("{frame}:Clear()"));
diff --git a/crates/benilla-app/src/ui_chat/channels.rs b/crates/benilla-app/src/ui_chat/channels.rs
index baee9100..6a5e4dfa 100644
--- a/crates/benilla-app/src/ui_chat/channels.rs
+++ b/crates/benilla-app/src/ui_chat/channels.rs
@@ -125,12 +125,9 @@ fn end_channel_session(
script: Option<&mut benilla_ui::script::UiScript>,
channels: &mut ChannelState,
walk: &mut ZoneChannelWalk,
- edit: &mut super::edit::ChatEditState,
) {
walk.clear_session();
channels.joined.clear();
- edit.channel_target.clear();
- edit.channel_number = 0;
if let Some(script) = script {
script.set_joined_channels(Vec::new());
}
@@ -162,14 +159,8 @@ pub(super) fn end_session_channels(
script: Option>,
mut channels: ResMut,
mut walk: ResMut,
- mut edit: ResMut,
) {
- end_channel_session(
- script.map(NonSendMut::into_inner),
- &mut channels,
- &mut walk,
- &mut edit,
- );
+ end_channel_session(script.map(NonSendMut::into_inner), &mut channels, &mut walk);
}
/// The other end: a socket that died. A **recoverable** drop never leaves `InWorld` (0065 keeps the
@@ -181,18 +172,12 @@ pub(super) fn end_session_channels_on_disconnect(
script: Option>,
mut channels: ResMut,
mut walk: ResMut,
- mut edit: ResMut,
mut disconnects: MessageReader,
) {
if disconnects.read().next().is_none() {
return;
}
- end_channel_session(
- script.map(NonSendMut::into_inner),
- &mut channels,
- &mut walk,
- &mut edit,
- );
+ end_channel_session(script.map(NonSendMut::into_inner), &mut channels, &mut walk);
}
/// Startup: read `ChatChannels.dbc` into [`ChannelState`], which owns it because the two things
@@ -285,6 +270,35 @@ fn zone_is_settled(player: Option<&crate::player::Player>) -> bool {
/// file** (`0x4997fc`, gated `0x4997e8`). We persist no chat cache, so every session is that
/// fresh-character path and reading the DBC bit directly is exactly right. It stops being right
/// the day per-window channel settings are persisted.
+/// Every `ChatChannels.dbc` row as the VM needs it (decision 1908; wow-re chat-cache-grammar.md
+/// §5-6): the id, the Shortcut the verbs compare a typed name against, the name composed for
+/// `zone_name` — `None` when the composition has nothing to substitute (a zone-dependent row with
+/// no zone, a city row with no city word), which is the verbs' nil leg — and whether
+/// `EnumerateServerChannels` lists it here (a city-only row, `flags & 0x10`, only in a city).
+pub(crate) fn zone_channel_catalog(
+ catalog: &ChatChannelsCatalog,
+ zone_name: &str,
+ in_city: bool,
+ city_word: Option<&str>,
+) -> Vec {
+ catalog
+ .rows()
+ .iter()
+ .map(|r| {
+ let resolvable = (!r.is_zone_dependent() || !zone_name.is_empty())
+ && (!r.takes_city_name() || city_word.is_some());
+ benilla_ui::script::ZoneChannelRow {
+ id: r.id,
+ shortcut: r.shortcut.clone(),
+ resolved: resolvable
+ .then(|| r.joinable_name(zone_name, city_word.unwrap_or_default())),
+ listed: !r.is_city_only() || in_city,
+ }
+ })
+ .collect()
+}
+
+#[allow(clippy::too_many_arguments)] // a Bevy system's param list IS its dependency set
pub(super) fn auto_join_zone_channels(
commands: Res,
channels: Res,
@@ -299,6 +313,8 @@ pub(super) fn auto_join_zone_channels(
),
mut walk: ResMut,
mut entered: MessageReader,
+ script: Option>,
+ mut catalog_fed: Local>>,
) {
let (player, cinematic) = (&body.0, &body.1);
// World entry arms the walk; the session-end clears disarm it ([`end_session_channels`] and its
@@ -343,6 +359,21 @@ pub(super) fn auto_join_zone_channels(
zone_row.name.clone(),
zone_row.flags & AREA_FLAG_TRADE_CHANNEL != 0,
);
+ // The VM's zone-channel catalog — what `JoinChannelByName` resolves a typed name against and
+ // `EnumerateServerChannels` lists — is a function of the same zone; refed on a zone change
+ // and on a fresh VM, keyed like every other feed (1290).
+ if let Some(mut script) = script {
+ let fed = catalog_fed.get(&script);
+ if fed.as_ref() != Some(&at) {
+ script.set_zone_channel_catalog(zone_channel_catalog(
+ &channels.channels,
+ &at.0,
+ at.1,
+ city_word(&areas.0),
+ ));
+ *fed = Some(at.clone());
+ }
+ }
if walk.at.as_ref() == Some(&at) {
return; // same zone as last frame — the common case, and free
}
@@ -496,19 +527,17 @@ mod tests {
#[test]
fn leaving_the_world_ends_the_channel_session() {
let mut channels = ChannelState::default();
- let mut walk = ZoneChannelWalk::default();
- let mut edit = super::super::edit::ChatEditState::default();
-
- // A live session: two zone channels asked for, both confirmed, `/2` targeted.
- walk.held = vec!["General - Tanaris".into(), "LocalDefense - Tanaris".into()];
- walk.at = Some(("Tanaris".into(), false));
+ // A live session: two zone channels asked for, both confirmed.
+ let mut walk = ZoneChannelWalk {
+ held: vec!["General - Tanaris".into(), "LocalDefense - Tanaris".into()],
+ at: Some(("Tanaris".into(), false)),
+ ..Default::default()
+ };
channels.joined = walk.held.iter().cloned().map(Some).collect();
- edit.channel_target = "LocalDefense - Tanaris".into();
- edit.channel_number = 2;
// No VM in a unit test; the mirror leg is the one line this cannot reach, and
// `end_session_channels` is a two-line wrapper over exactly this call.
- end_channel_session(None, &mut channels, &mut walk, &mut edit);
+ end_channel_session(None, &mut channels, &mut walk);
assert!(walk.held.is_empty(), "nothing is held");
assert_eq!(walk.at, None, "and the next entry re-walks from scratch");
@@ -517,8 +546,6 @@ mod tests {
"the confirmed list is the server's, and the server just destroyed it — a survivor \
here is what renumbers the next character's channels"
);
- assert_eq!(edit.channel_target, "");
- assert_eq!(edit.channel_number, 0, "a `/2` now targets nothing");
}
/// No city word (a locale whose sentinel row ships blank) ⇒ the city-named rows are skipped,
diff --git a/crates/benilla-app/src/ui_chat/edit.rs b/crates/benilla-app/src/ui_chat/edit.rs
index 6ef0c011..f55ae4ad 100644
--- a/crates/benilla-app/src/ui_chat/edit.rs
+++ b/crates/benilla-app/src/ui_chat/edit.rs
@@ -1,28 +1,18 @@
-//! The `ChatEdit_*` state machine, transcribed (decision 0288 P5; ref ChatFrame.lua l.1782-2242):
-//! the sticky type law (SAY/PARTY/RAID/GUILD/BATTLEGROUND commit on send — `ChatTypeInfo`'s
-//! sticky column), the **live parse** (typing `/g hi` or `/w Bob hi` converts the box in place,
-//! remainder preserved — `ChatEdit_ParseText(send=0)` on every text change), the header
-//! (`CHAT__SEND` text + the type's color on header AND typed text, insets past the header —
-//! `ChatEdit_UpdateHeader`), the 10-deep `lastTell` ring with Tab cycling in whisper mode, the
-//! invalid-type downgrade on open (PARTY with no party → SAY — `ChatEdit_OnShow`), and the R /
-//! `/` bindings (OPENCHAT-family, Bindings.xml).
+//! The chat SEND types and the joined-channel roster — what the app keeps beside the reference's
+//! own `ChatEdit_*` machine (ChatFrame.lua l.1782-2242), which owns the edit box since the chat
+//! window became the reference's (decision 1948): the sticky type, the live parse, the header,
+//! the tell ring, the Tab cycle and the R/`/` bindings are all its Lua now.
//!
-//! Division of labor: this module owns the STATE + the live-parse/header systems; [`super::input`]
-//! owns the submitted-line routing (send-by-current-type + the action-command grammar). The box
-//! widget itself (focus, history, insets mechanics) is the engine's.
-
-use std::collections::VecDeque;
+//! [`SendType`] names the wire kind an addon's `SendChatMessage` token maps to
+//! ([`super::input::drain_addon_chat_sends`]); [`ChannelState`] is the client-side mirror of the
+//! joined channels (the `/N` numbering the reference keeps C-side).
use bevy::prelude::*;
-use crate::names::NameCache;
-use crate::net::{ChatKind, NetCommands, SelfGuid};
-use crate::ui_script::{run_or_warn, UiKeyboardCapture};
+use crate::net::ChatKind;
-use super::event::{default_color, ChatEventKind};
-
-/// The box's send type — `ChatTypeInfo`'s sendable keys ([`SendType::sticky`] marks the sticky
-/// column's 1-entries). `Whisper`/`Channel` carry their target in [`ChatEditState`].
+/// The sendable chat types — `ChatTypeInfo`'s sendable keys, as the wire kind an addon's
+/// `SendChatMessage` token maps to. `Whisper`/`Channel` carry their target in the call.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[allow(dead_code)] // RaidLeader/BgLeader have no slash of their own (server-promoted sends);
// Channel is the P6 wiring's target — the enum is the full sendable law.
@@ -43,19 +33,6 @@ pub(crate) enum SendType {
}
impl SendType {
- /// `ChatTypeInfo[type].sticky == 1` (ref ChatFrame.lua l.10-114): SAY, PARTY, RAID, GUILD,
- /// BATTLEGROUND only.
- pub(crate) fn sticky(self) -> bool {
- matches!(
- self,
- SendType::Say
- | SendType::Party
- | SendType::Raid
- | SendType::Guild
- | SendType::Battleground
- )
- }
-
/// The chat-type TOKEN an addon passes to `SendChatMessage` (decision 1199) — the reference's
/// own `ChatTypeInfo` keys, uppercase.
///
@@ -100,59 +77,6 @@ impl SendType {
SendType::Channel => ChatKind::Channel,
}
}
-
- /// The header's display color = the matching receive kind's table color
- /// (`ChatEdit_UpdateHeader` reads `ChatTypeInfo[type]`).
- ///
- /// CHANNEL takes the same override the render path does — `info = ChatTypeInfo["CHANNEL"..
- /// channel]` (l.1902), the number from `GetChannelName`. It lands on the identical FFC0C0
- /// while the extras carry CHANNEL's seed color (1275), so the CHANNEL arm below is right
- /// today; a per-number recolor is what would make the box's number load-bearing here.
- fn color(self) -> [u8; 3] {
- use ChatEventKind as K;
- default_color(match self {
- SendType::Say => K::Say,
- SendType::Yell => K::Yell,
- SendType::Emote => K::Emote,
- SendType::Whisper => K::Whisper,
- SendType::Party => K::Party,
- SendType::Raid => K::Raid,
- SendType::RaidLeader => K::RaidLeader,
- SendType::RaidWarning => K::RaidWarning,
- SendType::Guild => K::Guild,
- SendType::Officer => K::Officer,
- SendType::Battleground => K::Battleground,
- SendType::BattlegroundLeader => K::BattlegroundLeader,
- SendType::Channel => K::Channel,
- })
- }
-
- /// The primary slash alias (`SLASH_1`) — the canonical form history recall stores
- /// (the ref's `ChatEdit_AddHistory` header). `None` for the two leader types 1.12 gives no
- /// ChatEdit slash.
- pub(super) fn canonical_slash(self) -> Option<&'static str> {
- self.aliases().first().copied()
- }
-
- /// The slash aliases that switch the box to this type (`SLASH_n`, GlobalStrings
- /// 3490-3801 — the full quoted sets).
- fn aliases(self) -> &'static [&'static str] {
- match self {
- SendType::Say => &["s", "say"],
- SendType::Yell => &["y", "yell", "sh", "shout"],
- SendType::Emote => &["e", "em", "emote", "me"],
- SendType::Whisper => &["w", "whisper", "t", "tell", "send"],
- SendType::Party => &["p", "party"],
- SendType::Raid => &["raid", "ra", "rsay"],
- SendType::RaidLeader => &[], // no ChatEdit slash in 1.12 (leader auto via /raid)
- SendType::RaidWarning => &["rw"],
- SendType::Guild => &["g", "gc", "gu", "guild"],
- SendType::Officer => &["o", "osay"],
- SendType::Battleground => &["bg", "battleground"],
- SendType::BattlegroundLeader => &[],
- SendType::Channel => &["c", "csay"],
- }
- }
}
/// How many channels the client can hold at once — its allocator refuses the eleventh
@@ -201,13 +125,6 @@ impl ChannelState {
.map(|i| i as u32 + 1)
}
- /// The channel occupying slot `number` (1-based), if any.
- pub(crate) fn name_of(&self, number: u32) -> Option<&str> {
- self.joined
- .get(usize::try_from(number.checked_sub(1)?).ok()?)?
- .as_deref()
- }
-
/// Give `name` a slot: **the first free one**, else a new one while under [`MAX_CHANNELS`] —
/// the reference's allocator `0x49b980` (see [`ChannelState`]). Already-joined answers its own
/// number rather than taking a second slot. `None` = all ten are taken.
@@ -272,434 +189,3 @@ impl ChannelState {
event.channel = format!("{n}. {}", event.channel_base);
}
}
-
-/// The chat edit box's cross-open state (the fields `ChatEdit_OnLoad` seeds on the box).
-#[derive(Resource)]
-pub(crate) struct ChatEditState {
- pub chat_type: SendType,
- /// `stickyType` — what Escape/close reverts to and what the box opens as.
- pub sticky: SendType,
- /// The current whisper target (`editBox.tellTarget`).
- pub tell_target: String,
- /// The current channel's WIRE name (the ref's `editBox.channelTarget` is the number; the
- /// name is what the send body carries either way).
- pub channel_target: String,
- /// The current channel's 1-based number (the joined-order slot), for the header.
- pub channel_number: u32,
- /// The `lastTell` ring (`NUM_REMEMBERED_TELLS = 10`), most recent first.
- pub last_tell: VecDeque,
- /// `toldTarget` — who WE last whispered (the ctrl-R / ReplyTell2 memory).
- pub last_told: Option,
- /// The header/insets need a repaint (type or target changed).
- pub header_dirty: bool,
- /// The last text the live parse saw (skip re-parsing an unchanged box).
- last_text: String,
-}
-
-impl Default for ChatEditState {
- fn default() -> Self {
- ChatEditState {
- chat_type: SendType::Say,
- sticky: SendType::Say,
- tell_target: String::new(),
- channel_target: String::new(),
- channel_number: 0,
- last_tell: VecDeque::new(),
- last_told: None,
- header_dirty: true,
- last_text: String::new(),
- }
- }
-}
-
-impl ChatEditState {
- /// `ChatEdit_SetLastTellTarget`: move-to-front dedup (case-insensitive), cap 10.
- pub(crate) fn remember_tell(&mut self, target: &str) {
- self.last_tell.retain(|t| !t.eq_ignore_ascii_case(target));
- self.last_tell.push_front(target.to_string());
- self.last_tell.truncate(10);
- }
-
- /// `ChatEdit_GetNextTellTarget`: the ring entry after `current` (wrapping to the most
- /// recent), for Tab cycling in whisper mode.
- pub(crate) fn next_tell(&self, current: &str) -> Option {
- if self.last_tell.is_empty() {
- return None;
- }
- if current.is_empty() {
- return self.last_tell.front().cloned();
- }
- let pos = self
- .last_tell
- .iter()
- .position(|t| t.eq_ignore_ascii_case(current));
- match pos {
- Some(i) if i + 1 < self.last_tell.len() => self.last_tell.get(i + 1).cloned(),
- _ => self.last_tell.front().cloned(),
- }
- }
-
- /// The header's text for the current type (`ChatEdit_UpdateHeader`'s strings, quoted).
- /// Channel shows the stripped name (the `[%d. %s]:` number form is the P6 wiring).
- fn header_text(&self, own_name: &str) -> String {
- match self.chat_type {
- SendType::Say => "Say: ".into(),
- SendType::Yell => "Yell: ".into(),
- SendType::Emote => format!("{own_name} "),
- SendType::Whisper => format!("Tell {}: ", self.tell_target),
- SendType::Party => "Party: ".into(),
- SendType::Raid => "Raid: ".into(),
- SendType::RaidLeader => "Raid: ".into(),
- SendType::RaidWarning => "Raid Warning: ".into(),
- SendType::Guild => "Guild: ".into(),
- SendType::Officer => "Officer: ".into(),
- SendType::Battleground => "Battleground: ".into(),
- SendType::BattlegroundLeader => "Battleground: ".into(),
- SendType::Channel => {
- // CHAT_CHANNEL_SEND = "[%d. %s]: " (the zone tail stripped like the display law).
- let name = self.channel_target.split(" - ").next().unwrap_or("");
- format!("[{}. {name}]: ", self.channel_number)
- }
- }
- }
-}
-
-/// The live parse (`ChatEdit_ParseText(send=0)`, run per frame while the box is focused): a
-/// leading `/ ` converts the box's TYPE in place, keeping the remainder as the draft —
-/// typing `/g hi` flips to Guild with "hi" in the box. `/w`/`/t` wait for the target word to
-/// complete (`ChatEdit_ExtractTellTarget` grabs the first word once a space follows it); `/r`
-/// loads the last teller (`ChatEdit_GetLastTellTarget`). Action commands (`/join`, `/wave`, …)
-/// are NOT consumed here — they execute on Enter ([`super::input`]).
-pub(super) fn chat_edit_live(
- script: Option>,
- mut state: ResMut,
- channels: Res,
- ui_capture: Res,
- mut names: ResMut,
- self_guid: Res,
- commands: Res,
-) {
- let Some(script) = script else {
- return;
- };
- if !ui_capture.typing {
- return; // box not focused — nothing to live-parse
- }
- let text: String = script
- .eval("return (ChatFrameEditBox and ChatFrameEditBox:GetText()) or ''")
- .unwrap_or_default();
- if text != state.last_text {
- state.last_text = text.clone();
- if let Some((new_type, remainder)) = parse_type_switch(&state, &channels, &text) {
- match new_type {
- TypeSwitch::Plain(t) => {
- state.chat_type = t;
- }
- TypeSwitch::Whisper(target) => {
- state.chat_type = SendType::Whisper;
- state.tell_target = target;
- }
- TypeSwitch::Channel { name, number } => {
- state.chat_type = SendType::Channel;
- state.channel_target = name;
- state.channel_number = number;
- }
- }
- state.header_dirty = true;
- state.last_text = remainder.clone();
- let lua_text = remainder.replace('\\', "\\\\").replace('"', "\\\"");
- run_or_warn(
- &script,
- &format!("ChatFrameEditBox:SetText(\"{lua_text}\")"),
- );
- }
- }
- if state.header_dirty {
- // The Emote header is the player's own name (CHAT_EMOTE_SEND = "%s "), resolved through
- // the same ask-once cache as everyone else's.
- let own = self_guid
- .0
- .and_then(|g| names.resolve(g, &commands).map(str::to_string))
- .unwrap_or_else(|| "You".to_string());
- let header = state.header_text(&own);
- let [r, g, b] = state.chat_type.color();
- let (rf, gf, bf) = (
- f32::from(r) / 255.0,
- f32::from(g) / 255.0,
- f32::from(b) / 255.0,
- );
- let lua_header = header.replace('\\', "\\\\").replace('"', "\\\"");
- // Header text+color, typed-text color, and the insets past the measured header
- // (`SetTextInsets(15 + header:GetWidth(), 13, 0, 0)` — ref ChatEdit_UpdateHeader l.1912).
- // The width is the measure round-trip's, one frame late for a fresh string — so we re-run
- // while dirty until a nonzero width lands. GetWidth serves ONLY a measure of the CURRENT
- // text (region.rs key-checks it): after a type switch (Say → "Tell Alice:") the old
- // header's width reads 0, not stale — the settle below waits for the RIGHT measure
- // instead of latching the previous header's insets (the /w cursor-in-the-header bug).
- run_or_warn(
- &script,
- &format!(
- "ChatFrameEditBoxHeader:SetText(\"{lua_header}\")\n\
- ChatFrameEditBoxHeader:SetTextColor({rf:.4}, {gf:.4}, {bf:.4})\n\
- ChatFrameEditBox:SetTextColor({rf:.4}, {gf:.4}, {bf:.4})\n\
- local w = ChatFrameEditBoxHeader:GetWidth()\n\
- if w and w > 1 then\n\
- ChatFrameEditBox:SetTextInsets(15 + w, 13, 0, 0)\n\
- BenillaChatHeaderSettled = true\n\
- end"
- ),
- );
- // Stay dirty until the measured width landed (the round-trip is a frame late on a fresh
- // header string).
- if script
- .eval::("local s = BenillaChatHeaderSettled; BenillaChatHeaderSettled = false; return s or false")
- .unwrap_or(false)
- {
- state.header_dirty = false;
- }
- }
-}
-
-/// A live type switch the parse found.
-pub(super) enum TypeSwitch {
- Plain(SendType),
- Whisper(String),
- Channel { name: String, number: u32 },
-}
-
-/// The `/N` / `/c ` channel switch against the joined list. `cmd_lower` is the
-/// first word (no slash); returns the switch + remaining draft.
-pub(super) fn channel_switch(
- channels: &ChannelState,
- cmd_lower: &str,
- args: &str,
-) -> Option<(TypeSwitch, String)> {
- if let Ok(n) = cmd_lower.parse::() {
- let name = channels.name_of(n)?;
- return Some((
- TypeSwitch::Channel {
- name: name.to_string(),
- number: n,
- },
- args.to_string(),
- ));
- }
- if ["c", "csay"].contains(&cmd_lower) {
- let (chan, remainder) = args.split_once(' ').unwrap_or((args, ""));
- if chan.is_empty() {
- return None;
- }
- let number = if let Ok(n) = chan.parse::() {
- n
- } else {
- channels.number_of(chan)?
- };
- let name = channels.name_of(number)?.to_string();
- return Some((TypeSwitch::Channel { name, number }, remainder.to_string()));
- }
- None
-}
-
-/// Match `text` against the type-switch grammar. Returns the switch + the box's remaining draft.
-pub(super) fn parse_type_switch(
- state: &ChatEditState,
- channels: &ChannelState,
- text: &str,
-) -> Option<(TypeSwitch, String)> {
- let rest = text.strip_prefix('/')?;
- let (cmd, args) = rest.split_once(' ').unwrap_or((rest, ""));
- if cmd.is_empty() {
- return None;
- }
- let lower = cmd.to_ascii_lowercase();
- // `/` — the numbered-channel switch (`ChatEdit_ParseText` l.2110-2121: a live
- // GetChannelName hit converts immediately, remainder kept) and `/c `
- // (`ChatEdit_ExtractChannel`). Both wait for the delimiting space like every live switch.
- if rest.contains(' ') {
- if let Some(switch) = channel_switch(channels, &lower, args) {
- return Some(switch);
- }
- }
- // `/r` — reply: load the last teller (only when one exists; else leave the text alone).
- if (lower == "r" || lower == "reply") && rest.contains(' ') {
- let target = state.last_tell.front()?.clone();
- return Some((TypeSwitch::Whisper(target), args.to_string()));
- }
- // Whisper family: wait for the completed target word ("/w Bob " — the space after the name
- // is the ref's extract trigger).
- if SendType::Whisper.aliases().contains(&lower.as_str()) {
- let (target, remainder) = args.split_once(' ')?;
- if target.is_empty() || target.starts_with('|') {
- return None; // the ref rejects a link-leading "name"
- }
- return Some((
- TypeSwitch::Whisper(target.to_string()),
- remainder.to_string(),
- ));
- }
- // The plain type families — the switch fires as soon as the command word is delimited
- // (a trailing space), so "/g" alone doesn't convert while you're still typing "/gc".
- if !rest.contains(' ') {
- return None;
- }
- for t in [
- SendType::Say,
- SendType::Yell,
- SendType::Emote,
- SendType::Party,
- SendType::Raid,
- SendType::RaidWarning,
- SendType::Guild,
- SendType::Officer,
- SendType::Battleground,
- ] {
- if t.aliases().contains(&lower.as_str()) {
- return Some((TypeSwitch::Plain(t), args.to_string()));
- }
- }
- None
-}
-
-/// The open commands through the binding table (0997; 1.12 defaults OPENCHAT = Enter,
-/// OPENCHATSLASH = `/`, REPLY = R, REPLY2 = Shift-R): OPENCHAT opens with the sticky type,
-/// downgrading an invalid PARTY/RAID to SAY against the live group state (the ref's
-/// `ChatEdit_UpdateHeader` invalid-type law; the sticky itself keeps its value, so rejoining a
-/// group restores it — GUILD/BG downgrades wait on their arcs' state); OPENCHATSLASH opens
-/// pre-slashed; REPLY opens as a reply to the last teller, and **REPLY2 to the last person YOU
-/// told** — the reference's own split, `ChatEdit_GetLastTellTarget` against
-/// `ChatEdit_GetLastToldTarget` (ChatFrame.lua l.1627/1645), and the reason they are two
-/// commands rather than one. The dispatch already gated on nothing owning the keyboard
-/// (a focused box's Enter is the box's), and Shift-Enter no longer opens chat — `SHIFT-ENTER`
-/// is a different chord than `ENTER`, the exact-modifier law the reference applies.
-pub(super) fn open_chat_keys(
- script: Option>,
- binds: Res,
- mut state: ResMut,
- group: Res,
-) {
- let Some(mut script) = script else {
- return;
- };
- use crate::bindings::cmd;
- let open_plain = binds.fired(cmd::OPEN_CHAT);
- let open_slash = binds.fired(cmd::OPEN_CHAT_SLASH);
- let open_reply = binds.fired(cmd::REPLY) && !state.last_tell.is_empty();
- // REPLY2 rides `last_told`, which the send path has been keeping all along
- // ([`super::input::send`]) with nothing reading it. Same guard shape as REPLY's: with nobody
- // told yet the reference falls into its own empty-string else-branch and opens nothing.
- let open_reply2 = binds.fired(cmd::REPLY2) && state.last_told.is_some();
- if !(open_plain || open_slash || open_reply || open_reply2) {
- return;
- }
- if open_reply || open_reply2 {
- state.chat_type = SendType::Whisper;
- // REPLY wins a same-frame tie, which is the order the reference's two bindings can only
- // be pressed in anyway (they are different chords); stated rather than left to the `if`.
- state.tell_target = if open_reply {
- state.last_tell.front().cloned().unwrap_or_default()
- } else {
- state.last_told.clone().unwrap_or_default()
- };
- } else {
- state.chat_type = sticky_on_open(state.sticky, &group);
- }
- state.header_dirty = true;
- state.last_text.clear();
- script.focus_editbox("ChatFrameEditBox");
- if open_slash {
- run_or_warn(&script, "ChatFrameEditBox:SetText(\"/\")");
- state.last_text = "/".into();
- }
-}
-
-/// The type a freshly opened box starts in: the sticky, **downgraded to SAY when the group it
-/// names is gone** (ref `ChatFrame_OpenChat` l.1554-1565 — a sticky PARTY with an empty party opens
-/// as SAY, and so does a sticky RAID outside a raid). The sticky itself is untouched, so rejoining
-/// restores it. One function because two callers need the identical law: the ENTER binding
-/// ([`open_chat_keys`]) and an addon's `ChatFrame_OpenChat` ([`open_chat_requests`]).
-pub(super) fn sticky_on_open(sticky: SendType, group: &crate::ui_party::GroupState) -> SendType {
- match sticky {
- SendType::Party if !group.in_group => SendType::Say,
- SendType::Raid | SendType::RaidWarning if !(group.in_group && group.group_type == 1) => {
- SendType::Say
- }
- sticky => sticky,
- }
-}
-
-/// `ChatFrame_OpenChat(text[, chatFrame])` — an addon asking for the chat box, prefilled
-/// (`benilla_ui::script::chat_window` registers the verb and queues the text).
-///
-/// The reference shows the box, stashes the text on it, and lets `ChatEdit_OnUpdate` type it in
-/// (`this.setText == 1` → `this:SetText(this.text)`, ChatFrame.lua l.1795) — the fill is a frame
-/// late there too. Ours focuses the box (which shows it) and writes the text now; `last_text` is
-/// deliberately left EMPTY rather than mirroring what we just wrote, so the next frame's
-/// [`chat_edit_live`] sees a change and runs the live parse over it. That is the whole point for
-/// two of the three corpus callers: they prefill `"/w "`, and it is the live parse — not
-/// this function — that turns those characters into whisper mode with the target extracted,
-/// exactly as it would for a human typing them.
-pub(super) fn open_chat_requests(
- script: Option>,
- mut state: ResMut,
- group: Res,
-) {
- let Some(mut script) = script else {
- return;
- };
- // Last request wins — two opens in one frame are one box, and the later caller is the one
- // whose text the user is about to see.
- let Some(text) = script.take_open_chat_requests().pop() else {
- return;
- };
- state.chat_type = sticky_on_open(state.sticky, &group);
- state.header_dirty = true;
- state.last_text.clear();
- script.focus_editbox("ChatFrameEditBox");
- let lua_text = text.replace('\\', "\\\\").replace('"', "\\\"");
- run_or_warn(
- &script,
- &format!("ChatFrameEditBox:SetText(\"{lua_text}\")"),
- );
-}
-
-/// The unit popup's WHISPER action (`ChatFrame_SendTell` → the engine's tell queue, decision
-/// 0434 §5): open the edit box in whisper mode to the named player — the ref fills its box with
-/// "/w name "; our box's whisper form is chat_type + tell_target, the reply key's exact shape.
-pub(super) fn open_tell_requests(
- script: Option>,
- mut state: ResMut,
-) {
- let Some(mut script) = script else {
- return;
- };
- let Some(name) = script.take_tell_requests().pop() else {
- return;
- };
- state.chat_type = SendType::Whisper;
- state.tell_target = name;
- state.header_dirty = true;
- state.last_text.clear();
- script.focus_editbox("ChatFrameEditBox");
-}
-
-/// Tab in the box (the engine's `OnTabPressed` → `BenillaChatTabPressed` queue): whisper mode
-/// cycles the `lastTell` ring (`ChatEdit_OnTabPressed` l.1983-1991). The slash tab-COMPLETION
-/// walk (cycling `SLASH_*` prefix matches) is deferred with the P8 polish — flagged, not silent.
-pub(super) fn chat_tab_cycle(
- script: Option>,
- mut state: ResMut,
-) {
- let Some(mut script) = script else {
- return;
- };
- if !script.take_chat_tab() {
- return;
- }
- if state.chat_type == SendType::Whisper {
- let current = state.tell_target.clone();
- if let Some(next) = state.next_tell(¤t) {
- state.tell_target = next;
- state.header_dirty = true;
- }
- }
-}
diff --git a/crates/benilla-app/src/ui_chat/event.rs b/crates/benilla-app/src/ui_chat/event.rs
index 4477c62e..dcb75866 100644
--- a/crates/benilla-app/src/ui_chat/event.rs
+++ b/crates/benilla-app/src/ui_chat/event.rs
@@ -616,93 +616,8 @@ pub(crate) fn notice_token(byte: u8) -> Option<&'static str> {
})
}
-/// The message groups a window registers — `ChatTypeGroup`'s keys (transcribed; the chat-cache
-/// WINDOW blocks list these names). Only the groups the current kind set can carry.
-///
-/// **Most of `ChatTypeGroup` is 1:1 with a chat type**, and the whole combat-log block is: ref
-/// `ChatFrame.lua` l.181-330 is thirty-odd `ChatTypeGroup["X"] = { "CHAT_MSG_X" }` one-liners.
-/// [`Self::Own`] is that shape, so those groups cost one variant between them instead of one each —
-/// the enum below carries only the genuinely *multi*-kind groups, which is the information a reader
-/// wants from it.
-#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
-pub(crate) enum ChatGroup {
- /// A group whose `ChatTypeGroup` list is exactly the one kind it is named for — every
- /// `COMBAT_*`/`SPELL_*` group, and the two `COMBAT_*_GAIN` ones that used to be spelled out.
- Own(ChatEventKind),
- System,
- Say,
- Yell,
- Whisper,
- Party,
- Guild,
- Creature,
- Channel,
- Skill,
- Loot,
- Money,
-}
-
-/// Does this group registration subscribe a window to `kind`? The one predicate the router asks,
-/// so that [`ChatGroup::Own`] — whose "list" is its own kind and needs no slice to live in — reads
-/// the same as every multi-kind group.
-pub(crate) fn group_wants(group: ChatGroup, kind: ChatEventKind) -> bool {
- match group {
- ChatGroup::Own(k) => k == kind,
- multi => group_kinds(multi).contains(&kind),
- }
-}
-
-/// `ChatTypeGroup` transcribed (ref ChatFrame.lua l.116-174): which kinds a group registration
-/// subscribes a window to. `MONEY` rides both the LOOT group (l.171-174) and window 2's explicit
-/// MONEY registration (chat-cache), hence its own group here.
-pub(crate) fn group_kinds(group: ChatGroup) -> &'static [ChatEventKind] {
- use ChatEventKind as K;
- match group {
- // Answered by [`group_wants`] without a slice — the kind is the group.
- ChatGroup::Own(_) => &[],
- ChatGroup::System => &[
- K::System,
- K::Afk,
- K::Dnd,
- K::Ignored,
- K::ChannelList,
- K::BgSystemNeutral,
- K::BgSystemAlliance,
- K::BgSystemHorde,
- ],
- ChatGroup::Say => &[K::Say, K::Emote, K::TextEmote],
- ChatGroup::Yell => &[K::Yell],
- ChatGroup::Whisper => &[K::Whisper, K::WhisperInform],
- ChatGroup::Party => &[
- K::Party,
- K::Raid,
- K::RaidLeader,
- K::RaidWarning,
- K::Battleground,
- K::BattlegroundLeader,
- ],
- ChatGroup::Guild => &[K::Guild, K::Officer],
- ChatGroup::Creature => &[
- K::MonsterSay,
- K::MonsterYell,
- K::MonsterEmote,
- K::MonsterWhisper,
- K::RaidBossEmote,
- ],
- ChatGroup::Channel => &[
- K::ChannelJoin,
- K::ChannelLeave,
- K::ChannelNotice,
- K::ChannelNoticeUser,
- ],
- ChatGroup::Skill => &[K::Skill],
- ChatGroup::Loot => &[K::Loot, K::Money],
- ChatGroup::Money => &[K::Money],
- }
-}
-
-/// The kind's row in the color table — NOT necessarily the color a line renders in. The channel
-/// family's row is looked up and then *replaced*; [`resolved_color`] is the law that renders.
+/// The kind's row in the shipped colour table — what the chat bubble and the edit box's header
+/// tint with. The window's own line colour is `ChatTypeInfo`'s, in the reference's Lua (1948).
///
/// The complete shipped table (chat-cache COLORS ≡ wow-re `chat-color-table.md`, both quoted in
/// 0288's pin; entries this kind set carries).
@@ -803,48 +718,6 @@ pub(crate) fn default_color(kind: ChatEventKind) -> [u8; 3] {
}
}
-/// `ChatTypeInfo["CHANNEL"..n]` — the color of the numbered channel in slot `n` (arg8).
-///
-/// These are not part of the 94-entry static table: the boot seed creates ten *extra* registry
-/// entries named `CHANNEL1`…`CHANNEL10` and colors every one of them from the **live CHANNEL
-/// entry**, so they all start at CHANNEL's FFC0C0 (wow-re `chat-color-table.md`, "Seeding" —
-/// `0x4982c0`, and `ResetChatColors 0x4a09e0` re-does exactly that). Per-number recolor is a
-/// `ChangeChatColor` away, so this stays a function of `n` even though nothing varies by it yet.
-fn channel_row_color(_number: u32) -> [u8; 3] {
- [255, 192, 192]
-}
-
-/// The color a line actually renders in — `ChatFrame_OnEvent`'s `info` resolution, which is not
-/// simply [`default_color`] of the kind (ref ChatFrame.lua l.1371-1386).
-///
-/// The handler looks up `ChatTypeInfo[type]` and then, for every `CHANNEL*` type, **replaces** it
-/// with the numbered channel's own row — `info = ChatTypeInfo["CHANNEL"..arg8]` (l.1381). Its
-/// guard is `strsub(type,1,7) == "CHANNEL" and type ~= "CHANNEL_LIST" and (arg1 ~= "INVITE" or
-/// type ~= "CHANNEL_NOTICE_USER")`, transcribed below. So the grey CHANNEL_NOTICE row (C0C0C0) is
-/// looked up and then thrown away: a join/leave notice renders in the channel's FFC0C0, which is
-/// what makes those lines read warm rather than white in the real client (1275).
-///
-/// **KNOWN DIVERGENCE — arg8 == 0.** The reference reaches its override only after finding the
-/// channel in `ChatFrame1.channelList`; a miss `return`s and the line never renders at all. That
-/// list is FrameXML's own (`ChatFrame_AddChannel`), which we do not model — our channel list is
-/// the *client-side* one ([`super::edit::ChannelState`]) — so implementing the drop would gate on
-/// the wrong list and silently eat notices about channels we are not in ("Not on channel %s."
-/// being the sharpest case). We render those, in the row the extras are seeded from. 1275.
-pub(crate) fn resolved_color(event: &ChatEvent, kind: ChatEventKind) -> [u8; 3] {
- use ChatEventKind as K;
- let invite_notice = kind == K::ChannelNoticeUser
- && event.notice_byte() == Some(benilla_protocol::messages::channel_notice::INVITE);
- match kind {
- K::Channel | K::ChannelJoin | K::ChannelLeave | K::ChannelNotice | K::ChannelNoticeUser
- if !invite_notice =>
- {
- channel_row_color(event.channel_number)
- }
- // CHANNEL_LIST and the INVITE notice keep the row they were looked up in.
- _ => default_color(kind),
- }
-}
-
/// Map a wire `ChatMsg` byte (`SMSG_MESSAGECHAT.chat_type`) to its event kind. `None` = a type
/// vmangos never emits as wire chat (the combat-log block) or one we don't model — the router
/// drops it loudly.
@@ -875,6 +748,9 @@ pub(crate) fn kind_of_wire(chat_type: u8) -> Option {
m::CHAT_MSG_RAID_BOSS_EMOTE => K::RaidBossEmote,
m::CHAT_MSG_BATTLEGROUND => K::Battleground,
m::CHAT_MSG_BATTLEGROUND_LEADER => K::BattlegroundLeader,
+ m::CHAT_MSG_BG_SYSTEM_NEUTRAL => K::BgSystemNeutral,
+ m::CHAT_MSG_BG_SYSTEM_ALLIANCE => K::BgSystemAlliance,
+ m::CHAT_MSG_BG_SYSTEM_HORDE => K::BgSystemHorde,
_ => return None,
})
}
diff --git a/crates/benilla-app/src/ui_chat/feed.rs b/crates/benilla-app/src/ui_chat/feed.rs
index f2750c47..22a19b37 100644
--- a/crates/benilla-app/src/ui_chat/feed.rs
+++ b/crates/benilla-app/src/ui_chat/feed.rs
@@ -287,12 +287,9 @@ impl ChatLog {
tries: 0,
});
} else {
- self.pending.push(Pending::Event(notice_event(
- notice_byte,
- channel,
- name,
- None,
- )));
+ if let Some(event) = notice_event(notice_byte, channel, name, None) {
+ self.pending.push(Pending::Event(event));
+ }
}
}
@@ -578,20 +575,55 @@ fn notice_event(
channel: String,
a: Option,
b: Option,
-) -> ChatEvent {
+) -> Option {
+ // The display type per notice byte — wow-re `chat-msg-event-args.md`'s notice table, read
+ // off each arm's `mov edi`: 0x00/0x01 are the join/leave lines, MODE_CHANGE (0x0c) fires no
+ // chat event at all, a byte past THROTTLED fires SYSTEM with an empty token, and the rest
+ // split between CHANNEL_NOTICE (0x12) and CHANNEL_NOTICE_USER (0x13) exactly as listed.
+ use channel_notice as n;
let kind = match notice_byte {
- channel_notice::JOINED => ChatEventKind::ChannelJoin,
- channel_notice::LEFT => ChatEventKind::ChannelLeave,
- _ => ChatEventKind::ChannelNotice,
+ n::JOINED => ChatEventKind::ChannelJoin,
+ n::LEFT => ChatEventKind::ChannelLeave,
+ n::MODE_CHANGE => return None,
+ n::YOU_JOINED
+ | n::YOU_LEFT
+ | n::WRONG_PASSWORD
+ | n::NOT_MEMBER
+ | n::NOT_MODERATOR
+ | n::NOT_OWNER
+ | n::MUTED
+ | n::BANNED
+ | n::THROTTLED => ChatEventKind::ChannelNotice,
+ n::PASSWORD_CHANGED
+ | n::OWNER_CHANGED
+ | n::PLAYER_NOT_FOUND
+ | n::CHANNEL_OWNER
+ | n::ANNOUNCEMENTS_ON
+ | n::ANNOUNCEMENTS_OFF
+ | n::MODERATION_ON
+ | n::MODERATION_OFF
+ | n::PLAYER_KICKED
+ | n::PLAYER_BANNED
+ | n::PLAYER_UNBANNED
+ | n::PLAYER_NOT_BANNED
+ | n::PLAYER_ALREADY_MEMBER
+ | n::INVITE
+ | n::INVITE_WRONG_FACTION
+ | n::WRONG_FACTION
+ | n::INVALID_NAME
+ | n::NOT_MODERATED
+ | n::PLAYER_INVITED
+ | n::PLAYER_INVITE_BANNED => ChatEventKind::ChannelNoticeUser,
+ _ => ChatEventKind::System,
};
- ChatEvent {
+ Some(ChatEvent {
kind: Some(kind),
sender: a.unwrap_or_default(),
target: b.unwrap_or_default(),
channel,
notice: notice_byte.to_string(),
..Default::default()
- }
+ })
}
/// Deliver one built event: the joined-list upkeep on **both sides** of the render, with the
@@ -683,12 +715,10 @@ pub(super) fn feed_chat(
script: Option>,
mut log: ResMut,
mut windows: ResMut,
- mut edit: ResMut,
mut channels: ResMut,
mut names: ResMut,
mut speaker: SpeakerEffects,
commands: Res,
- time: Res