From 2fed24925258f1193fe4e1ccf501e518bf167f14 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 4 Sep 2026 20:55:55 +0700 Subject: [PATCH] the chat and spellbook go stock, and hidden props park their meshes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the chat window is the reference's own `ChatFrame.xml` and `FloatingChatFrame.xml`, run off the player's chain; our 2216-line transcription is deleted, the feed fires `CHAT_MSG_*` and the reference's `ChatFrame_OnEvent` prints — no app-side composition into the windows, no routing table, no whisper chime or flash of ours, and the transcribed edit-box machine (sticky type, tell ring, Tab cycle) retires with it; the verbs the two files call are built to the bytes — the whole `0x98` chat-window record and its eight verbs, the 94-entry chat-type colour registry, the language walk, the channel verbs as a command queue, the slash handlers' emote/roll/assist/uninvite/console/logging verbs; `chat-cache.txt` is written and read in the reference's whole grammar, the 68-entry message-group table in flag order and the no-file boot included, and the chat bindings and macro lines run through the reference's own arms (1948) - the spellbook is the reference's own `SpellBookFrame.xml`; our 965-line transcription is deleted, and two quirks it reproduced by hand (the pet page ignores its page number, its arrows write the spell book's counter) now come from the reference's lines; one adapter remains because the reference's autocast shine is a `` that draws no pixels in this engine — the script puts the token-texture shine beside each button's model and carries it with the model's shown state; the nine spell-slot bindings (`GetSpellName`, `CastSpell`, `PickupSpell` and their kin) now parse their arguments the way the reference's shared marshaller `0x4b3ec0` does — a numeric arg 1, a mandatory arg 2, the index gated to [0, 0x400) with "Invalid spell slot in " on a miss, "pet" matched without case — where ours raised mlua's own message on a negative id, answered nil for 0 and above 1024, and read the wrong book for "PET" (1952) - a long-hidden world model part parks its `Mesh3d`: the entity path keeps the batches the retained pass declines resident — 5.3k hidden submeshes at any instant at the Stormwind auction house — and Bevy's sweeps walk every `Mesh3d` whether it draws or not, so a part parks after 30 consecutive hidden frames and unparks the frame its verdict turns visible; every world model part takes part, units' bodies included, while a booth's mirror parts stay out; interleaved pairs read an alone Stormwind 0.1–0.4 ms lighter, and `WOW_NO_MESH_PARK=1` is the A/B; the audio thread's realtime class was the standing suspect for the raid's `cpu_p99` spikes with sound on, and `WOW_AUDIO_NO_RT=1` across two interleaved Blackrock pairs read no difference, so the lever stays as an instrument (1949); and a no-merge prop batch now says which rule declined it — animated (1551 at the auction house) or transparent, not order-free (340) (1951) - the probe reads CPU per thread across its window (user and system split, because a user-mode sampler never sees kernel time) and the main thread's CPU inside the app's frame beside its whole-frame CPU; together they place the vsync tax every player pays by default — cpu_ms 10.7 uncapped against 12.5 under vsync, alone in Stormwind, with no system growing and no thread spinning: it lands on the main thread, in user time, inside the schedules, the same systems each running slower, and a low-priority spinner holding one core busy erases it to the last decimal (vsync + spinner 10.18 against uncapped 10.17), which is frequency scaling — under vsync the cores idle deeper between frames and the next burst starts at a lower clock; nothing in the client is the cause and nothing in the client changes for it (1950) Co-Authored-By: Claude Fable 5.1 --- crates/benilla-app/assets/ui/ChatFrame.xml | 2216 ----------------- crates/benilla-app/assets/ui/OptionsFrame.xml | 24 + .../benilla-app/assets/ui/ScriptLogFrame.xml | 27 + .../assets/ui/SpellBookAdapters.xml | 55 + .../benilla-app/assets/ui/SpellBookFrame.xml | 965 ------- crates/benilla-app/assets/ui/UIParent.xml | 6 + crates/benilla-app/assets/ui/benilla.toc | 32 +- crates/benilla-app/src/bindings/commands.rs | 95 +- crates/benilla-app/src/capture/fixtures.rs | 6 +- .../src/capture/probes/live_fps.rs | 47 +- crates/benilla-app/src/local_state.rs | 6 + crates/benilla-app/src/net.rs | 54 + crates/benilla-app/src/net/io.rs | 27 + crates/benilla-app/src/perf/clock.rs | 85 + crates/benilla-app/src/perf/main_split.rs | 49 + crates/benilla-app/src/perf/mod.rs | 5 +- crates/benilla-app/src/sound/output/mod.rs | 13 +- crates/benilla-app/src/target/click.rs | 7 + crates/benilla-app/src/ui_chat.rs | 26 +- crates/benilla-app/src/ui_chat/channels.rs | 83 +- crates/benilla-app/src/ui_chat/edit.rs | 534 +--- crates/benilla-app/src/ui_chat/event.rs | 134 +- crates/benilla-app/src/ui_chat/feed.rs | 71 +- crates/benilla-app/src/ui_chat/frames.rs | 190 +- crates/benilla-app/src/ui_chat/input/mod.rs | 206 +- crates/benilla-app/src/ui_chat/input/parse.rs | 44 +- crates/benilla-app/src/ui_chat/logging.rs | 148 ++ crates/benilla-app/src/ui_chat/settings.rs | 790 ++++-- crates/benilla-app/src/ui_chat/tests.rs | 351 +-- crates/benilla-app/src/ui_macro/tests.rs | 72 +- .../benilla-app/src/ui_script/cast_tests.rs | 11 +- .../src/ui_script/chat_options_tests.rs | 182 +- .../src/ui_script/chat_resize_tests.rs | 289 ++- .../benilla-app/src/ui_script/chat_tests.rs | 205 +- .../src/ui_script/dressup_tests.rs | 21 +- .../benilla-app/src/ui_script/escape_tests.rs | 18 +- .../src/ui_script/frame_flag_gate.rs | 96 +- .../src/ui_script/group_loot_tests.rs | 9 +- .../benilla-app/src/ui_script/loot_tests.rs | 9 +- .../src/ui_script/merchant_tests.rs | 9 +- crates/benilla-app/src/ui_script/mod.rs | 27 + .../src/ui_script/options_tests.rs | 7 +- .../src/ui_script/petition_tests.rs | 5 +- .../benilla-app/src/ui_script/quest_tests.rs | 6 +- .../src/ui_script/questlog_tests.rs | 20 +- .../benilla-app/src/ui_script/reference_ui.rs | 180 +- .../src/ui_script/shipped_xml_tests.rs | 48 +- .../src/ui_script/spellbook_tests.rs | 163 +- crates/benilla-app/src/ui_script/test_ui.rs | 6 +- crates/benilla-app/src/ui_unit.rs | 105 +- crates/benilla-formats/src/languages.rs | 54 + crates/benilla-formats/src/lib.rs | 3 +- crates/benilla-ui/src/script/channel.rs | 395 +++ crates/benilla-ui/src/script/chat_misc.rs | 285 +++ crates/benilla-ui/src/script/chat_send.rs | 84 + crates/benilla-ui/src/script/chat_types.rs | 379 +++ crates/benilla-ui/src/script/chat_window.rs | 640 ++++- crates/benilla-ui/src/script/cvars.rs | 50 +- .../benilla-ui/src/script/editbox/methods.rs | 20 + .../src/script/messageframe/scrolling.rs | 68 +- crates/benilla-ui/src/script/mod.rs | 9 +- crates/benilla-ui/src/script/model.rs | 37 + crates/benilla-ui/src/script/spellbook.rs | 146 +- crates/benilla-ui/src/script/unit/mod.rs | 4 + .../src/widget/kinds/messageframe.rs | 31 + crates/benilla-world/src/model_render.rs | 6 + crates/benilla-world/src/model_render/park.rs | 157 ++ .../src/terrain_stream/spawn/assemble.rs | 15 +- 68 files changed, 4709 insertions(+), 5458 deletions(-) delete mode 100644 crates/benilla-app/assets/ui/ChatFrame.xml create mode 100644 crates/benilla-app/assets/ui/SpellBookAdapters.xml delete mode 100644 crates/benilla-app/assets/ui/SpellBookFrame.xml create mode 100644 crates/benilla-app/src/perf/main_split.rs create mode 100644 crates/benilla-app/src/ui_chat/logging.rs create mode 100644 crates/benilla-ui/src/script/chat_misc.rs create mode 100644 crates/benilla-ui/src/script/chat_types.rs create mode 100644 crates/benilla-world/src/model_render/park.rs 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