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) - - - - - - - - - - - - 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