the chat and spellbook go stock, and hidden props park their meshes

- 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 `<Model>` 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 <Verb>" 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 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-04 20:55:55 +07:00
co-authored by Claude Fable 5.1
parent fd595b7119
commit 2fed249252
68 changed files with 4709 additions and 5458 deletions
File diff suppressed because it is too large Load Diff
@@ -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 @@
</OnHide>
</Scripts>
</Frame>
<!-- The reference names its options window UIOptionsFrame, and its FloatingChatFrame.lua reads
`UIOptionsFrame:IsShown()` every frame (FCF_OnUpdate's hover gate). Ours is OptionsFrame
until the options window migrates (1751); this alias is the adapter, and it retires with
the file. BENILLA_ALLOW_OWN_UI=1 -->
<Script><![CDATA[
UIOptionsFrame = OptionsFrame
]]></Script>
</Ui>
@@ -377,4 +377,31 @@
<OnUpdate>BenillaScriptLogFrame_OnUpdate()</OnUpdate>
</Scripts>
</Frame>
<!-- benilla's own commands over the reference's SlashCmdList (decision 1948). The stock
ChatEdit_ParseText walks SlashCmdList after its own built-ins, so each of benilla's commands
(ui_chat::commands — the dev instruments and the three additions) hands its line to
SubmitChatInput, where ui_chat::input's parser still owns it. This file loads after the
reference's ChatFrame.xml, which is what declares the table. -->
<Script><![CDATA[
local BENILLA_COMMANDS = {
{ "CHATTEST", "/chattest" },
{ "SHOT", "/shot" },
{ "LIQUID", "/liquid" },
{ "CASTVIS", "/castvis" },
{ "PARTYTEST", "/partytest" },
{ "REACTION", "/reaction", "/react" },
{ "RELOAD", "/reload" },
{ "ERRORS", "/errors", "/err" },
{ "CONVERTRAID", "/convertraid" },
}
for _, row in ipairs(BENILLA_COMMANDS) do
local key = "BENILLA_" .. row[1]
for i = 2, table.getn(row) do
setglobal("SLASH_" .. key .. (i - 1), row[i])
end
SlashCmdList[key] = function(msg)
SubmitChatInput(row[2] .. " " .. (msg or ""))
end
end
]]></Script>
</Ui>
@@ -0,0 +1,55 @@
<Ui>
<!--
BENILLA_ALLOW_OWN_UI=1 — the one adapter the reference's own SpellBookFrame.xml needs from
this engine (decision 1952; 1751 §2 keeps an adapter for exactly a genuine engine difference).
The pet page's autocast shine. The reference's `$parentAutoCast` on every SpellButton is a
<Model> playing `Interface\Buttons\UI-AutoCastButton.mdx` (36x36, CENTER (1,1), scale 1.22),
shown and hidden by its own `SpellButton_UpdateButton` (SpellBookFrame.lua l.367-377). This
engine's <Model> holds the scene the Lua API reads and writes and draws no pixels
(`script/modelframe.rs`); the shine benilla draws is its own lane — the
`benilla:autocast-shine:<scale>` token texture (decisions 1383/1392, `autocast_shine.rs`),
which reads the model's OWN tracks out of the shipped .m2. So each SpellButton gets a Shine
texture beside the model, at the numbers 1393 squared onto this button (37x37 CENTER (0,0),
scale 1.48 — the reference's 36x36 CENTER (1,1) at 1.22, one ratio each, recorded there), and
the reference's SpellButton_UpdateButton is wrapped to carry the shine with the model.
1393's other number rides along for the same reason: the bracket overlay is 71.53 rather
than the reference's 60, so the art — which fills 33/64 of UI-AutoCastableOverlay.blp —
lands on this button's corners at the pet button's own 0.997x.
Nothing else. The reference's file is sourced unedited: its Models exist and are harmless.
-->
<Script><![CDATA[
for i = 1, 12 do
local button = getglobal("SpellButton" .. i)
local overlay = getglobal("SpellButton" .. i .. "AutoCastable")
overlay:SetWidth(71.53)
overlay:SetHeight(71.53)
local shine = button:CreateTexture("SpellButton" .. i .. "Shine", "OVERLAY")
shine:SetTexture("benilla:autocast-shine:1.48")
shine:SetWidth(37)
shine:SetHeight(37)
shine:SetPoint("CENTER", button, "CENTER", 0, 0)
shine:Hide()
end
local reference_SpellButton_UpdateButton = SpellButton_UpdateButton
function SpellButton_UpdateButton()
reference_SpellButton_UpdateButton()
if not this then
return
end
local name = this:GetName()
local model = getglobal(name .. "AutoCast")
local shine = getglobal(name .. "Shine")
if model and shine then
if model:IsShown() then
shine:Show()
else
shine:Hide()
end
end
end
]]></Script>
</Ui>
@@ -1,965 +0,0 @@
<Ui>
<!--
benilla's spellbook window (decision 0216 §8, slice 5 — the P-key window, the spell
SOURCE for the cursor-payload arc): the 384x512 book + the 12-spell page grid + the
right-edge skill-line tabs + the page-turn pair, dressed from the real 1.12
`SpellBookFrame.xml`/`.lua` — extracted from the MPQ chain to session scratch this
session (director-local reference artifacts, never committed — the 0068/0138 pattern;
line cites below are into that extraction's own `ref-SpellBookFrame.xml`/`.lua`).
Docks the left UIPanel slot exactly like Character/Merchant/Gossip/Quest/QuestLog
(`UiPanels.xml`'s `UIPanelWindows["SpellBookFrame"] = { area = "left", pushable =
0 }`, mirroring the ref's own `SpellBookFrame` row) — so it competes for/replaces those
windows, never stacks with them. `ToggleSpellBook(bookType)` (ref `SpellBookFrame.lua:
9-50`) is the show/hide entry point, and it is addon-facing: it wears the reference's bare
name for the same reason `ToggleCharacter`/`ToggleQuestLog` do. The app's 'P' binding calls
it with `BOOKTYPE_SPELL` (`ui_script/input.rs`, ref `Bindings.xml:582-583`).
Every frame this file defines wears the REFERENCE's name (`SpellBookFrame`, `SpellButton1`,
…), because addons address them by it — the `Benilla` prefix these carried until the
reference-naming pass is gone. A child with no counterpart in the 1.12 namespace keeps the
bare stripped name so `$parent`/`GetName()..` composition inside the family stays coherent.
The book MODEL (tabs = skill lines, the flat slot list, offsets) is built entirely by the
app from `PlayerActions.spells` through the `Spell.dbc`/`SkillLine.dbc` join
(`crate::ui_spellbook`) and pushed through `benilla_ui::script::spellbook`'s snapshot seam
— this file only ever reads it through `GetNumSpellTabs`/`GetSpellTabInfo`/`GetSpellName`/
`GetSpellTexture`/`IsSpellPassive`, exactly the ref's own Era API shape. `BOOKTYPE_SPELL`/
`BOOKTYPE_PET` are installed by the ENGINE (`spellbook.rs`'s `install()`), not this file's
own `<Script>` — the one deliberate exception to the ref's own home for them
(`SpellBookFrame.lua:5-6`), so the engine's OWN tests can exercise the pet-deferral path
without this XML (see that module's doc for the full reasoning). `SPELLS_PER_PAGE`/
`MAX_SKILLLINE_TABS`/`SPELLBOOK_PAGENUMBERS` stay here — genuine per-window UI state, the
ref's own home for them.
THE PET BOOK IS LIVE (decision 1032) — the second tab, its 12-slot page, the autocast
overlay + sparkle, the right-click autocast toggle, and the cast/pickup pair, all over
`crate::ui_pet_book`'s feed of `SMSG_PET_SPELLS`' own spell list. Two reference QUIRKS are
reproduced on purpose here and each carries a do-not-fix note at its site (the standing
rule from decision 1030): the pet page **ignores its page number**, and its page arrows
write the *spell* book's page counter. See `SpellBook_GetSpellID` and the two
page-turn handlers.
Named deferrals/OMITs (each re-cited at its own site below):
- `LEARNED_SPELL_IN_TAB`'s tab flash (`SpellBookTabFlashFrame`, `CheckButtonGlow`): no
such event exists yet (no "just learned a spell" signal split out of the general
`SPELLS_CHANGED` diff). The ref returns early on it for the pet book anyway.
- The CRAFT_SHOW/_CLOSE/TRADE_SKILL_SHOW/_CLOSE legs of the checked ring: the ring
itself is LIVE (`BenillaSpellButton_UpdateSelection`/`IsCurrentCast` over the
app-resolved slot verdict — the shapeshift arm makes Ghost Wolf's/a druid form's
slot glow while its form is up), but the trade-skill-window arm of the engine
predicate is unmodeled (the same named gap `ui_action`'s IsCurrentAction carries),
so those four events would re-read an arm that never fires — omitted with it.
- The `this.updateTooltip`/`OnUpdate` re-poll loop around the hover: `GameTooltip:SetSpell`
itself is LIVE (the engine channel, decision 0276's line law; it reads whichever book
`bookType` names — 1050), but its per-frame re-poll is dropped, since nothing in the
rendered lines changes per-frame the way a live cooldown would. The ref's own
`if ( GameTooltip:SetSpell(…) )` return value exists only to arm that loop.
- `maxLines="3"` on `$parentSpellName` (word-wrap line cap): no such FontString
attribute is modeled (grepped `benilla-ui/src/loader/widgets.rs`) — the name just
wraps unbounded inside its box width, a harmless cosmetic gap on the rare long name.
- `MultiActionBar_ShowAllGrids/_HideAllGrids()` (ref `SpellBookFrame_OnShow`/`_OnHide`):
no multi-bar grid-show mechanism exists in this engine yet — dropped, the same class of
gap CharacterFrame's own OnShow/OnHide already carry. Its neighbour in that pair,
`UpdateMicroButtons()`, is LIVE since the micro-menu slice (`MicroMenu.xml`) — nil-
guarded at the call sites only because several test harnesses load this window without
the action bar behind it.
Sounds (decision 0090): `igSpellBookOpen`/`igSpellBookClose` on show/hide (ref
`SpellBookFrame_Update`/`_OnHide`, VERIFIED present in the real `SoundEntries.dbc` this
session), `igAbiliityPageTurn` (the ref's own literal spelling — VERIFIED as the real
registered kit name, id 836, `Sound\interface\iAbilitiesTurnPage{A,B,C}.wav`; not a typo
to "fix") on both page-turn clicks.
-->
<Script><![CDATA[
-- ── GlobalStrings values this window uses (ref-GlobalStrings.lua, cited per line — the
-- `X = X or "..."` idiom matches CastingBar.xml/CharacterFrame.xml's own convention) ────
SPELLBOOK = SPELLBOOK or "Spellbook" -- :3820
PAGE_NUMBER = PAGE_NUMBER or "Page %d" -- :2981
-- The pet tab's label AND the window title while the pet book is up. Keyed, not chosen:
-- HasPetSpells()'s second return is ChrClasses.dbc field 4 ("PET"/"DEMON") and the ref
-- concatenates it (`getglobal("PET_TYPE_"..token)`, ref SpellBookFrame.lua:173).
PET_TYPE_PET = PET_TYPE_PET or "Pet" -- :3058
PET_TYPE_DEMON = PET_TYPE_DEMON or "Demon" -- :3057
-- ── This window's own constants (ref SpellBookFrame.lua:1-7, Benilla-prefixed where the
-- ref's own name would collide with the engine's BOOKTYPE_SPELL/BOOKTYPE_PET globals —
-- this file's header comment on that one deviation) ─────────────────────────────────────
SPELLS_PER_PAGE = 12
MAX_SKILLLINE_TABS = 8
SPELLBOOK_PAGENUMBERS = {}
-- ── The window: toggle (ref ToggleSpellBook, l.9-50) ─────────────────────────────────────
-- Kept BARE (not Benilla-prefixed) — the addon-facing entry point, matching this
-- codebase's own ToggleCharacter/ToggleQuestLog precedent. The ref's own DUPLICATED
-- prev/next enable/disable (once inside the `currentPage > maxPages` clamp branch, once
-- again unconditionally right after — both produce the identical end state) collapses to
-- the ONE SpellBook_UpdatePageArrows() call already shared by every other
-- page-turn site below — a redundant-code cleanup, not a behavior change.
function ToggleSpellBook(bookType)
-- ref l.11-14: asking for a pet book you haven't got does NOTHING — not even close a
-- spellbook that is already open. The TOGGLEPETBOOK key binding is the live caller.
if bookType == BOOKTYPE_PET and not HasPetSpells() then
return
end
local isVisible = SpellBookFrame:IsVisible()
HideUIPanel(SpellBookFrame)
if (not isVisible or (SpellBookFrame.bookType ~= bookType)) then
SpellBookFrame.bookType = bookType
ShowUIPanel(SpellBookFrame)
end
BenillaSpellBook_ClampPage()
SpellBook_UpdatePageArrows()
SpellBookPageText:SetText(format(PAGE_NUMBER, SpellBook_GetCurrentPage()))
end
-- Clamp the selected tab's page back onto range (ref's own inline logic inside
-- ToggleSpellBook, l.22-26) — split out so both ToggleSpellBook and a future re-entry
-- share it, rather than inlining it twice.
function BenillaSpellBook_ClampPage()
local currentPage, maxPages = SpellBook_GetCurrentPage()
if currentPage > maxPages then
SPELLBOOK_PAGENUMBERS[SpellBookFrame.selectedSkillLine] = maxPages
BenillaUpdateSpells()
end
end
function SpellBookFrame_OnLoad()
local frame = this
frame:RegisterEvent("SPELLS_CHANGED")
-- LEARNED_SPELL_IN_TAB OMITTED — this file's header comment.
frame.bookType = BOOKTYPE_SPELL
for i = 1, MAX_SKILLLINE_TABS do
SPELLBOOK_PAGENUMBERS[i] = 1
end
SPELLBOOK_PAGENUMBERS["pet"] = 1 -- ref l.66; read by GetCurrentPage, written by nothing
SpellBookSkillLineTab_OnClick(1)
end
-- ref SpellBookFrame_OnEvent (l.75-92), the SPELLS_CHANGED branch only (LEARNED_SPELL_IN_TAB
-- OMITTED, header comment).
function SpellBookFrame_OnEvent()
local frame = this
if event == "SPELLS_CHANGED" and frame:IsVisible() then
SpellBookFrame_Update()
SpellBook_UpdatePageArrows()
end
end
-- ref SpellBookFrame_OnShow (l.94-105): MultiActionBar_ShowAllGrids OMITTED (header
-- comment); the tab-flash UIFrameFlash call goes with LEARNED_SPELL_IN_TAB.
-- UpdateMicroButtons is LIVE as of the micro-menu slice (MicroMenu.xml).
function SpellBookFrame_OnShow()
if UpdateMicroButtons then UpdateMicroButtons() end
SpellBookFrame_Update(1)
end
-- ref SpellBookFrame_Update (l.107-164), whole. Three things happen here and they are
-- easy to conflate: the skill-line strip is gated on the book TYPE (the pet book has no
-- tabs at all — GetSpellTabInfo never sees it), the Spell/Pet toggle row is gated on
-- HasPetSpells(), and the title + open sound fork on which book is up.
function SpellBookFrame_Update(showing)
if showing then
SpellBookSkillLineTab_OnClick(SpellBookFrame.selectedSkillLine)
BenillaUpdateSpells()
end
local isPet = SpellBookFrame.bookType == BOOKTYPE_PET
local numSkillLineTabs = GetNumSpellTabs()
for i = 1, MAX_SKILLLINE_TABS do
local skillLineTab = getglobal("SpellBookSkillLineTab" .. i)
if i <= numSkillLineTabs and not isPet then
local name, texture = GetSpellTabInfo(i)
skillLineTab:SetNormalTexture(texture)
skillLineTab.tooltip = name
skillLineTab:Show()
skillLineTab:SetChecked(SpellBookFrame.selectedSkillLine == i)
else
skillLineTab:Hide()
end
end
-- ref l.108-111 + l.141-151: hide the row first, then raise exactly the tabs that
-- exist. Tab 3 never appears in 1.12 (no third book) and is hidden by the template's
-- own `hidden="true"`; it is kept so the anchor chain reads like the ref's.
SpellBookFrameTabButton1:Hide()
SpellBookFrameTabButton2:Hide()
local hasPetSpells, petToken = HasPetSpells()
SpellBookFrame.petTitle = nil
if hasPetSpells then
SpellBookFrame_SetTabType(SpellBookFrameTabButton1, BOOKTYPE_SPELL)
SpellBookFrame_SetTabType(SpellBookFrameTabButton2, BOOKTYPE_PET, petToken)
elseif isPet then
-- The pet died/was dismissed while its book was open: the ref closes the window
-- and reverts, rather than showing an empty pet page (l.147-151).
HideUIPanel(SpellBookFrame)
SpellBookFrame.bookType = BOOKTYPE_SPELL
isPet = false
end
if not isPet then
SpellBookTitleText:SetText(SPELLBOOK)
if showing then PlaySound("igSpellBookOpen") end
else
SpellBookTitleText:SetText(SpellBookFrame.petTitle)
-- ref l.161: the pet book opens on the ABILITY kit, not the spellbook one. Both
-- ship in the real SoundEntries.dbc.
if showing then PlaySound("igAbilityOpen") end
end
end
-- ref SpellBookFrame_SetTabType (l.166-183). `binding` is what the tab's tooltip appends
-- the bound key to (MicroButtonTooltipText); `petTitle` is set here as a side effect,
-- exactly as the ref does, because this is the only place the token is in hand.
function SpellBookFrame_SetTabType(tabButton, bookType, token)
if bookType == BOOKTYPE_SPELL then
tabButton.bookType = BOOKTYPE_SPELL
tabButton:SetText(SPELLBOOK)
tabButton.binding = "TOGGLESPELLBOOK"
else
tabButton.bookType = BOOKTYPE_PET
local label = getglobal("PET_TYPE_" .. (token or "PET")) or PET_TYPE_PET
tabButton:SetText(label)
tabButton.binding = "TOGGLEPETBOOK"
SpellBookFrame.petTitle = label
end
-- The tab for the book you are ON is disabled, which is also what draws it as the
-- selected (Tab1-Selected) plate — the ref uses <DisabledTexture> for the "current"
-- look rather than a checked state.
if SpellBookFrame.bookType == bookType then
tabButton:Disable()
else
tabButton:Enable()
end
tabButton:Show()
end
-- ref SpellBookFrame_OnHide (l.186-204): MultiActionBar_HideAllGrids and the tab-flash
-- cleanup OMITTED (header comment); always the spell-book close kit (no pet book to branch
-- on). UpdateMicroButtons is LIVE as of the micro-menu slice (MicroMenu.xml).
function SpellBookFrame_OnHide()
if UpdateMicroButtons then UpdateMicroButtons() end
if SpellBookFrame.bookType == BOOKTYPE_PET then
PlaySound("igAbilityClose") -- ref l.191
else
PlaySound("igSpellBookClose")
end
end
-- UpdateSpells(): the ref's OWN global (`SpellBookFrame.lua:26/116/411/426/450` — never
-- DEFINED in this session's extraction, a base-UI/C-level helper outside this file)
-- transcribed here as BenillaUpdateSpells(): refresh every spell button on the current
-- page (this file's own convention: explicit button params, not ambient `this`).
function BenillaUpdateSpells()
for i = 1, SPELLS_PER_PAGE do
BenillaSpellButton_UpdateButton(getglobal("SpellButton" .. i))
end
end
-- ── The spell button (ref SpellButton_*, l.206-404) ──────────────────────────────────────
-- CRAFT_SHOW/_CLOSE/TRADE_SKILL_SHOW/_CLOSE are OMITTED (header comment: no craft or
-- trade-skill window arm); SPELLS_CHANGED and SPELL_UPDATE_COOLDOWN drive the full refresh
-- (the ref's own shared branch, l.221) and CURRENT_SPELL_CAST_CHANGED the checked ring.
-- PET_BAR_UPDATE is LIVE (ref l.214 + l.227-231) and it is the pet page's whole repaint
-- wire: the pet book's cooldowns, autocast bits and active ring all move without the BOOK
-- changing, so SPELLS_CHANGED alone would leave a stale page.
function SpellButton_OnLoad()
local button = this
button:RegisterEvent("SPELLS_CHANGED")
button:RegisterEvent("CURRENT_SPELL_CAST_CHANGED")
button:RegisterEvent("SPELL_UPDATE_COOLDOWN")
button:RegisterEvent("PET_BAR_UPDATE")
button:RegisterForDrag("LeftButton")
button:RegisterForClicks("LeftButtonUp", "RightButtonUp")
button.autocastable = getglobal(button:GetName() .. "AutoCastable")
-- The ref's `$parentAutoCast` is a <Model> playing UI-AutoCastButton.mdx; ours is
-- the native shine lane (decision 1383 — PetActionBar.xml's sparkle header has the
-- story, `autocast_shine` the numbers): $parentShine is the token marker, and
-- showing it is the whole enable.
button.shine = getglobal(button:GetName() .. "Shine")
BenillaSpellButton_UpdateButton(button)
end
function SpellButton_OnEvent(event)
local button = this
if event == "SPELLS_CHANGED" or event == "SPELL_UPDATE_COOLDOWN" then
BenillaSpellButton_UpdateButton(button)
elseif event == "CURRENT_SPELL_CAST_CHANGED" then
BenillaSpellButton_UpdateSelection(button)
elseif event == "PET_BAR_UPDATE" then
if SpellBookFrame.bookType == BOOKTYPE_PET then
BenillaSpellButton_UpdateButton(button)
end
end
end
-- ref SpellButton_UpdateSelection (l.292-306): the checked ring — `IsCurrentCast` over
-- the app-resolved verdict (the ACTIVE shapeshift form's own spell — Ghost Wolf glows
-- while the shaman is the wolf, a druid form likewise; the engine predicate `0x4b3600`
-- deliberately has NO casting-now arm, so an ordinary cast never lights a book slot).
-- The ref's out-of-tab bounds fork collapses into the one SetChecked: IsCurrentCast
-- answers nil past the book's end already.
function BenillaSpellButton_UpdateSelection(button)
local id = SpellBook_GetSpellID(button:GetID())
button:SetChecked(IsCurrentCast(id, SpellBookFrame.bookType))
end
-- ref SpellButton_OnEnter (l.235-244): the engine's SetSpell — the byte-verified spell
-- line law (decision 0276: cost|range one line, casttime|cooldown one line, passives
-- omit it, the $-substituted gold description; no rank column from the book — the
-- verified showRank=0).
function BenillaSpellButton_OnEnter(button)
if not GameTooltip then return end -- engine tests load file subsets
local id = SpellBook_GetSpellID(button:GetID())
if not GetSpellName(id, SpellBookFrame.bookType) then
return
end
GameTooltip:SetOwner(button, "ANCHOR_RIGHT")
GameTooltip:SetSpell(id, SpellBookFrame.bookType)
end
-- ref SpellButton_OnClick (l.263-290): the MacroFrame branch and the right-click pet
-- autocast fork are both OMITTED (header comment) — shift-click always picks up (the
-- ref's own "no MacroFrame visible" fallthrough), every other click casts. The
-- `id > MAX_SPELLS` guard is dropped too: GetSpellName/CastSpell/PickupSpell already
-- answer nil/no-op for any id past the book's real length, so the extra guard checks
-- nothing a MAX_SPELLS constant would add here.
function SpellButton_OnClick(drag)
local button = this
-- The mouse button is the ambient `arg1`, exactly as the reference reads it (l.284) —
-- its XML passes nothing on OnClick and a bare `1` on the two drag slots. Only the
-- `drag` falsy arm below reaches it, so the drag slots never read a stale arg1.
local mouseButton = arg1
local id = SpellBook_GetSpellID(button:GetID())
button:SetChecked("false")
if drag then
PickupSpell(id, SpellBookFrame.bookType)
elseif not IsShiftKeyDown() and mouseButton and mouseButton ~= "LeftButton"
and SpellBookFrame.bookType == BOOKTYPE_PET then
-- ref l.284-285: on the PET page a non-left click flips autocast instead of
-- casting. Shift still outranks it — the ref tests shift first, so a shift
-- right-click on a pet spell picks it up.
ToggleSpellAutocast(id, SpellBookFrame.bookType)
elseif IsShiftKeyDown() then
-- ref SpellButton_OnClick l.271-283 (LIVE since decision 0983): with the macro
-- editor open a shift-click APPENDS `/cast <name>[(<rank>)]` to the body instead of
-- picking the spell up — the behaviour MACRO_HELP_TEXT_LINE5 advertises ("Shift
-- click a spell in your spellbook with the macro UI open to add it to your macro").
-- The `/cast` alias comes from the shipped strings (SLASH_CAST1), never typed here.
--
-- DO NOT "FIX" THIS TO INSERT THE BARE NAME (B248, decision 1328). The whole
-- `/cast` line is what 1.12.1 writes; the bare-name-at-the-cursor insert people
-- remember is a LATER client's (`ChatEdit_InsertLink`). And the gate is SHIFT,
-- not the mouse button: a plain right click on this page casts, same as a left one
-- — pinned both ways in `ui_script::spellbook_tests`.
local macroFrame = getglobal("MacroFrame")
if macroFrame and macroFrame:IsVisible() then
local spellName, subSpellName = GetSpellName(id, SpellBookFrame.bookType)
if spellName and not IsSpellPassive(id, SpellBookFrame.bookType) then
local line = (SLASH_CAST1 or "/cast") .. " " .. spellName
if subSpellName and string.len(subSpellName) > 0 then
line = line .. "(" .. subSpellName .. ")"
end
MacroFrame_AddMacroLine(line)
end
else
PickupSpell(id, SpellBookFrame.bookType)
end
else
CastSpell(id, SpellBookFrame.bookType)
BenillaSpellButton_UpdateSelection(button) -- ref l.288: the ring re-reads at once
end
end
-- ref SpellButton_UpdateButton (l.307-404): the autocast overlay/model is OMITTED
-- (header comment); the cooldown sweep (l.359-365) and the checked-ring tail call are
-- LIVE. Passive gates CastSpell engine-side (spellbook.rs), not here — this only grays
-- the presentation.
function BenillaSpellButton_UpdateButton(button)
if not button:IsVisible() then
return
end
if GameTooltip:IsOwned(button) then
BenillaSpellButton_OnEnter(button)
end
if not SpellBookFrame.selectedSkillLine then
SpellBookFrame.selectedSkillLine = 1
end
local isPet = SpellBookFrame.bookType == BOOKTYPE_PET
local temp, tabTexture, offset, numSpells = GetSpellTabInfo(SpellBookFrame.selectedSkillLine)
offset = offset or 0
numSpells = numSpells or 0
SpellBookFrame.selectedSkillLineOffset = offset
local id = SpellBook_GetSpellID(button:GetID())
local name = button:GetName()
local iconTexture = getglobal(name .. "IconTexture")
local spellString = getglobal(name .. "SpellName")
local subSpellString = getglobal(name .. "SubSpellName")
local cooldown = getglobal(name .. "Cooldown")
local autocastable = getglobal(name .. "AutoCastable")
local highlightTexture = getglobal(name .. "Highlight")
local normalTexture = getglobal(name .. "NormalTexture")
-- TWO WAYS A SLOT COMES UP EMPTY, one dress for both. Past the end of the tab the
-- button also goes DEAD; a slot the book counts but has no icon for stays live and
-- only puts its highlight kit back to the square. Everything else the two do — the
-- five regions down, the autocast bit cleared, the ring unchecked, the button's own
-- ring back to plain white — is the same blanking, and it is written once here rather
-- than once per arm.
--
-- ref l.328: the out-of-tab test is `(bookType ~= BOOKTYPE_PET) and ...` — the pet
-- page has no tab to be out of, so a pet button past the end of the book falls to the
-- no-icon arm instead and stays ENABLED. Faithful, and harmless: an enabled button
-- with no spell behind it clicks into GetSpellName's nil and does nothing.
local outOfTab = (not isPet) and id > (offset + numSpells)
if outOfTab then
button:Disable()
else
button:Enable()
end
local texture
if not outOfTab then
texture = GetSpellTexture(id, SpellBookFrame.bookType)
end
if outOfTab or (not texture) or (texture == "") then
iconTexture:Hide()
spellString:Hide()
subSpellString:Hide()
cooldown:Hide()
autocastable:Hide()
button.shine:Hide()
if not outOfTab then
highlightTexture:SetTexture("Interface\\Buttons\\ButtonHilight-Square")
end
button:SetChecked(0)
normalTexture:SetVertexColor(1.0, 1.0, 1.0)
return
end
-- The cooldown sweep + the enable-keyed icon dim (ref l.359-365): enable == 0 is an
-- on-hold cooldown (Stealth/Feign Death parked until SMSG_COOLDOWN_EVENT) — the
-- widget stays hidden (CooldownFrame_SetTimer's own gate) and the icon reads 40%.
local start, duration, enable = GetSpellCooldown(id, SpellBookFrame.bookType)
CooldownFrame_SetTimer(cooldown, start, duration, enable)
local brightness = (enable == 1) and 1.0 or 0.4
iconTexture:SetVertexColor(brightness, brightness, brightness)
-- ref l.367-377: the autocast pair. `GetSpellAutocast` answers (nil, nil) for the
-- player book by the engine's own short-circuit, so this needs no isPet guard — the
-- overlay and the sparkle simply never come on outside the pet page. Showing the
-- shine marker IS the sparkle (decision 1383 — the native lane draws at it).
local autoCastAllowed, autoCastEnabled = GetSpellAutocast(id, SpellBookFrame.bookType)
if autoCastAllowed then autocastable:Show() else autocastable:Hide() end
if autoCastEnabled then button.shine:Show() else button.shine:Hide() end
local spellName, subSpellName = GetSpellName(id, SpellBookFrame.bookType)
local isPassive = IsSpellPassive(id, SpellBookFrame.bookType)
-- A passive slot wears a whole different dress — the button ring blacked right out,
-- the round passive highlight in place of the square one, and the name in the passive
-- grey. Three values that always travel together, so they are chosen once and applied
-- once instead of each verb appearing in both arms.
local ringLevel, highlightFile, nameColor
if isPassive then
ringLevel, highlightFile, nameColor =
0, "Interface\\Buttons\\UI-PassiveHighlight", PASSIVE_SPELL_FONT_COLOR
else
ringLevel, highlightFile, nameColor =
1.0, "Interface\\Buttons\\ButtonHilight-Square", NORMAL_FONT_COLOR
end
normalTexture:SetVertexColor(ringLevel, ringLevel, ringLevel)
highlightTexture:SetTexture(highlightFile)
spellString:SetTextColor(nameColor.r, nameColor.g, nameColor.b)
iconTexture:SetTexture(texture)
spellString:SetText(spellName)
subSpellString:SetText(subSpellName)
if subSpellName and subSpellName ~= "" then
spellString:SetPoint("LEFT", button, "RIGHT", 4, 4)
else
spellString:SetPoint("LEFT", button, "RIGHT", 4, 2)
end
iconTexture:Show()
spellString:Show()
subSpellString:Show()
BenillaSpellButton_UpdateSelection(button) -- ref l.403: every full refresh re-reads the ring
end
-- ── Page turn (ref PrevPageButton_OnClick/NextPageButton_OnClick, l.406-434) ─────────────
--
-- DO NOT "FIX" THE KEY (decision 1032, the standing rule from 1030). The reference writes
-- `SPELLBOOK_PAGENUMBERS[selectedSkillLine]` on BOTH books, while `SpellBook_GetCurrentPage`
-- reads `SPELLBOOK_PAGENUMBERS["pet"]` for the pet one — a key nothing writes. So on the
-- pet page the arrows turn nothing, the page text says a number the page does not honour,
-- and the *spell* book's page counter moves behind your back. All three are the reference's
-- and all three are reproduced; `SpellBook_GetSpellID`'s note is the other half.
function BenillaSpellBookPrevPageButton_OnClick()
local pageNum = SpellBook_GetCurrentPage() - 1
SPELLBOOK_PAGENUMBERS[SpellBookFrame.selectedSkillLine] = pageNum
SpellBook_UpdatePageArrows()
SpellBookPageText:SetText(format(PAGE_NUMBER, pageNum))
BenillaUpdateSpells()
-- ref l.414-417: the pet page re-asserts its own title, because the spell book's page
-- text handler is shared and the title is not.
if SpellBookFrame.bookType == BOOKTYPE_PET then
SpellBookTitleText:SetText(SpellBookFrame.petTitle)
end
PlaySound("igAbiliityPageTurn")
end
function BenillaSpellBookNextPageButton_OnClick()
local pageNum = SpellBook_GetCurrentPage() + 1
SPELLBOOK_PAGENUMBERS[SpellBookFrame.selectedSkillLine] = pageNum
SpellBook_UpdatePageArrows()
SpellBookPageText:SetText(format(PAGE_NUMBER, pageNum))
BenillaUpdateSpells()
if SpellBookFrame.bookType == BOOKTYPE_PET then
SpellBookTitleText:SetText(SpellBookFrame.petTitle)
end
PlaySound("igAbiliityPageTurn")
end
-- ── The skill-line tabs (ref SpellBookSkillLineTab_OnClick, l.436-457) ───────────────────
function SpellBookSkillLineTab_OnClick(id)
local update
if not id then
update = 1
id = this:GetID()
end
SpellBookFrame.selectedSkillLine = id
local name, texture, offset, numSpells = GetSpellTabInfo(id)
SpellBookFrame.selectedSkillLineOffset = offset or 0
SpellBookFrame.selectedSkillLineNumSpells = numSpells or 0
SpellBook_UpdatePageArrows()
SpellBookFrame_Update()
SpellBookPageText:SetText(format(PAGE_NUMBER, SpellBook_GetCurrentPage()))
if update then
BenillaUpdateSpells()
end
end
-- ref SpellBook_GetSpellID (l.459-465). This is the book id the engine's own byte-verified
-- seam expects verbatim (`benilla-ui/src/script/spellbook.rs`'s module doc).
--
-- DO NOT "FIX" THE PET ARM (decision 1032, the standing rule from 1030). The reference's
-- pet branch is a bare `return id` — no tab offset, and **no page term** — so the pet page
-- always shows book entries 1..12 no matter what page number the window is displaying. A
-- well-trained hunter pet can know more than twelve spells, so this is visible, and it is
-- still the reference's behaviour. Its sibling quirk is in the two page-turn handlers.
function SpellBook_GetSpellID(id)
if SpellBookFrame.bookType == BOOKTYPE_PET then
return id
end
return id + (SpellBookFrame.selectedSkillLineOffset or 0)
+ (SPELLS_PER_PAGE * ((SPELLBOOK_PAGENUMBERS[SpellBookFrame.selectedSkillLine] or 1) - 1))
end
-- ref SpellBook_UpdatePageArrows (l.467-479). An arrow is live exactly while the page it
-- points at is a DIFFERENT page. Stated as an inequality rather than a range test on
-- purpose: the pet book's arrows write a counter its own page ignores (the do-not-fix
-- notes above), so a page number can sit outside 1..maxPages, and when it does BOTH arrows
-- stay live — which is the reference's answer, not an accident of this spelling.
function SpellBook_UpdatePageArrows()
local currentPage, maxPages = SpellBook_GetCurrentPage()
if currentPage ~= 1 then SpellBookPrevPageButton:Enable() else SpellBookPrevPageButton:Disable() end
if currentPage ~= maxPages then SpellBookNextPageButton:Enable() else SpellBookNextPageButton:Disable() end
end
-- ref SpellBook_GetCurrentPage (l.481-493), both branches. `maxPages` is clamped to at
-- least 1 (a defensive addition this file's own — an empty/not-yet-fed book must still
-- show ONE (empty) page rather than disabling both page buttons into a currentPage=1 >
-- maxPages=0 dead state).
--
-- The pet branch reads `SPELLBOOK_PAGENUMBERS["pet"]`, which is the counter NOTHING ever
-- writes — see the page-turn handlers. So it is always 1, and with it `maxPages > 1` the
-- Next arrow stays enabled forever on a big pet book. Reference behaviour; see the
-- do-not-fix note on `SpellBook_GetSpellID`.
function SpellBook_GetCurrentPage()
local numPetSpells = HasPetSpells()
if numPetSpells and SpellBookFrame.bookType == BOOKTYPE_PET then
return SPELLBOOK_PAGENUMBERS["pet"] or 1,
max(ceil(numPetSpells / SPELLS_PER_PAGE), 1)
end
local currentPage = SPELLBOOK_PAGENUMBERS[SpellBookFrame.selectedSkillLine] or 1
local temp, texture, offset, numSpells = GetSpellTabInfo(SpellBookFrame.selectedSkillLine)
local maxPages = max(ceil((numSpells or 0) / SPELLS_PER_PAGE), 1)
return currentPage, maxPages
end
]]></Script>
<!--
The skill-line tab (ref SpellBookSkillLineTabTemplate, l.4-39): 32x32, the
SpellBook-SkillLineTab 64x64 backdrop offset (-3,11), an empty NormalTexture placeholder
(filled at runtime by SetNormalTexture — SetNormalTexture creates the region lazily on
first call, `benilla-ui/src/script/button.rs`'s `ensure_slot`, verified this session), the
ADD highlight + CheckedTexture ring for the SELECTED tab.
-->
<CheckButton name="BenillaSpellBookSkillLineTabTemplate" virtual="true">
<Size><AbsDimension x="32" y="32"/></Size>
<Layers>
<Layer level="BACKGROUND">
<Texture file="Interface\SpellBook\SpellBook-SkillLineTab">
<Size><AbsDimension x="64" y="64"/></Size>
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="-3" y="11"/></Offset></Anchor>
</Anchors>
</Texture>
</Layer>
</Layers>
<NormalTexture/>
<HighlightTexture file="Interface\Buttons\ButtonHilight-Square" alphaMode="ADD"/>
<CheckedTexture file="Interface\Buttons\CheckButtonHilight" alphaMode="ADD"/>
<Scripts>
<OnClick>SpellBookSkillLineTab_OnClick()</OnClick>
<OnEnter>
GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
if self.tooltip then
GameTooltip:SetText(self.tooltip)
GameTooltip:Show()
end
</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
</CheckButton>
<!--
The spell button (ref SpellButtonTemplate, l.74-201): 37x37, the UI-Spellbook-SpellBackground
64x64 backdrop offset (-3,3), the icon (setAllPoints, hidden until fed), the name/rank
FontStrings anchored off the button's own RIGHT edge, the UI-Quickslot2 ring + Depress push +
ADD highlight + the CheckedTexture ring (`IsCurrentCast`'s glow), and the Cooldown child
(ref l.136: $parentCooldown inherits CooldownFrameTemplate — ours is the native Cooldown
widget at the template's own 36x36 CENTER (0,-1), the ActionBar/BagFrame transcription).
The AutoCast overlay/model is OMITTED (this file's header comment — pet-only autocast).
-->
<CheckButton name="BenillaSpellButtonTemplate" virtual="true">
<Size><AbsDimension x="37" y="37"/></Size>
<Layers>
<Layer level="BACKGROUND">
<Texture name="$parentBackground" file="Interface\Spellbook\UI-Spellbook-SpellBackground">
<Size><AbsDimension x="64" y="64"/></Size>
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="-3" y="3"/></Offset></Anchor>
</Anchors>
</Texture>
</Layer>
<Layer level="OVERLAY">
<!-- The corner brackets that say a pet spell CAN autocast. The art fills only the
middle 33/64 = 0.5156 of UI-AutoCastableOverlay.blp, so where the brackets land
is set entirely by how big the texture is drawn:
pet button ref 58 -> 58 x 0.5156 = 29.9 on a 30 button. Dead in the corners.
spell book ref 60 -> 60 x 0.5156 = 30.9 on a 37 button. THREE UNITS INSIDE
each edge, floating — the ref barely grew the overlay (58->60)
while the button grew 30->37.
DEVIATION 2 of 3 in this widget (decision 1393, extending 1392). 71.53 is the
pet button's own ratio carried over — 37 x 58/30 — so the bracket square lands
on this button's corners exactly as it does on that one. Write 60 to go back to
the reference. -->
<Texture name="$parentAutoCastable" file="Interface\Buttons\UI-AutoCastableOverlay" hidden="true">
<Size><AbsDimension x="71.53" y="71.53"/></Size>
<Anchors><Anchor point="CENTER"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors>
</Texture>
<!-- The animated sibling: the ref's $parentAutoCast <Model>, which on this
template carries its own 36x36 CENTER (1,1) rect at scale="1.22".
DEVIATION 3 of 3 (decision 1393, extending 1392), and both halves are the same
idea — borrow the pet button's proportions so this button reads like that one:
* rect 36 CENTER (1,1) -> 37 CENTER (0,0). The ref's +1,+1 puts the glow a
unit up-and-right of the brackets, which sit dead centre; measured on the
director's capture, the glow's centre really was ~1 unit right of the
button's. Concentric now, so top/right stop reading loose.
* scale 1.22 -> 1.48. 0.02 x 1280 x 1.48 = 37.888 = 1.024 x 37 — the same
1.024 rim/viewport ratio the ref writes on the pet button, which is what
puts the rim ON the viewport edge so the widget's own scissor halves every
star (1387). At the ref's 0.87x the rim floats clear and nothing clips it.
To go back to the reference exactly: 36x36, CENTER (1,1), and 1.22. -->
<Texture name="$parentShine" file="benilla:autocast-shine:1.48" hidden="true">
<Size><AbsDimension x="37" y="37"/></Size>
<Anchors><Anchor point="CENTER"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors>
</Texture>
</Layer>
<Layer level="BORDER">
<Texture name="$parentIconTexture" setAllPoints="true" hidden="true"/>
<!-- maxLines="3" unmodeled (this file's header comment) — wraps unbounded instead. -->
<FontString name="$parentSpellName" inherits="GameFontNormal" hidden="true" justifyH="LEFT">
<Size><AbsDimension x="103" y="0"/></Size>
<Anchors>
<Anchor point="LEFT" relativePoint="RIGHT"><Offset><AbsDimension x="4" y="0"/></Offset></Anchor>
</Anchors>
</FontString>
<FontString name="$parentSubSpellName" inherits="SubSpellFont" hidden="true" justifyH="LEFT">
<Size><AbsDimension x="79" y="18"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentSpellName" relativePoint="BOTTOMLEFT">
<Offset><AbsDimension x="0" y="4"/></Offset>
</Anchor>
</Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<Cooldown name="$parentCooldown" hidden="true">
<Size><AbsDimension x="36" y="36"/></Size>
<Anchors>
<Anchor point="CENTER" relativePoint="CENTER"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor>
</Anchors>
</Cooldown>
</Frames>
<Scripts>
<OnLoad>SpellButton_OnLoad()</OnLoad>
<OnEvent>SpellButton_OnEvent(event)</OnEvent>
<OnClick>SpellButton_OnClick()</OnClick>
<OnShow>BenillaSpellButton_UpdateButton(self)</OnShow>
<!-- The reference routes both drag entries to the plain click path with drag=1
(SpellButtonTemplate l.169-174) — the bag/doll/action slots' own precedent. -->
<OnDragStart>SpellButton_OnClick(1)</OnDragStart>
<OnReceiveDrag>SpellButton_OnClick(1)</OnReceiveDrag>
<OnEnter>BenillaSpellButton_OnEnter(self)</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
<NormalTexture name="$parentNormalTexture" file="Interface\Buttons\UI-Quickslot2">
<Size><AbsDimension x="64" y="64"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
</NormalTexture>
<PushedTexture file="Interface\Buttons\UI-Quickslot-Depress"/>
<HighlightTexture name="$parentHighlight" file="Interface\Buttons\ButtonHilight-Square" alphaMode="ADD"/>
<CheckedTexture file="Interface\Buttons\CheckButtonHilight" alphaMode="ADD"/>
</CheckButton>
<!--
The Spell/Pet toggle tab (ref SpellBookFrameTabButtonTemplate, l.40-71): a 128x64 plate
that hangs off the window's bottom-left, drawn UNSELECTED normally and swapping to the
selected plate through its <DisabledTexture> — the ref uses Disable() for "this is the
book you are on", not a checked state, which is why this is a plain Button and not a
CheckButton. Hidden by default; SpellBookFrame_Update raises exactly the ones that exist.
-->
<Button name="BenillaSpellBookFrameTabButtonTemplate" virtual="true" hidden="true">
<Size><AbsDimension x="128" y="64"/></Size>
<HitRectInsets><AbsInset left="15" right="14" top="13" bottom="15"/></HitRectInsets>
<Scripts>
<OnClick>ToggleSpellBook(self.bookType)</OnClick>
<OnEnter>
GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
-- ref l.53: the label plus its bound key. MicroButtonTooltipText is MicroMenu.xml's
-- (loaded before this file); nil-guarded like every other cross-file call here.
if MicroButtonTooltipText then
GameTooltip:SetText(MicroButtonTooltipText(self:GetText(), self.binding), 1.0, 1.0, 1.0)
else
GameTooltip:SetText(self:GetText(), 1.0, 1.0, 1.0)
end
GameTooltip:Show()
</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
<ButtonText>
<Anchors><Anchor point="CENTER"><Offset><AbsDimension x="0" y="3"/></Offset></Anchor></Anchors>
</ButtonText>
<NormalFont inherits="GameFontNormalSmall"/>
<HighlightFont inherits="GameFontHighlightSmall"/>
<DisabledFont inherits="GameFontHighlightSmall"/>
<NormalTexture file="Interface\SpellBook\UI-SpellBook-Tab-Unselected"/>
<HighlightTexture file="Interface\SpellBook\UI-SpellbookPanel-Tab-Highlight" alphaMode="ADD"/>
</Button>
<Frame name="SpellBookFrame" enableMouse="true" toplevel="true" parent="UIParent" hidden="true">
<!-- The real 384x512 window (ref l.202-205). No <Anchors> — ShowUIPanel/SetLeftFrame
SetPoints this to the left slot on open (UiPanels.xml's own convention). -->
<Size><AbsDimension x="384" y="512"/></Size>
<!-- HitRectInsets: transcribed for fidelity; no loader wiring exists yet (CharacterFrame's
own precedent note) — a harmless no-op today. -->
<HitRectInsets><AbsInset left="0" right="30" top="0" bottom="70"/></HitRectInsets>
<Layers>
<Layer level="BACKGROUND">
<Texture file="Interface\Spellbook\Spellbook-Icon">
<Size><AbsDimension x="58" y="58"/></Size>
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="10" y="-8"/></Offset></Anchor>
</Anchors>
</Texture>
</Layer>
<Layer level="ARTWORK">
<!-- The four quadrant plates, anchored off the frame's own corners exactly like
the ref (l.231-263) — simpler than CharacterFrame's TOPLEFT-offset scheme
since the ref itself uses the corner anchors directly here. -->
<Texture file="Interface\Spellbook\UI-SpellbookPanel-TopLeft">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
</Texture>
<Texture file="Interface\Spellbook\UI-SpellbookPanel-TopRight">
<Size><AbsDimension x="128" y="256"/></Size>
<Anchors><Anchor point="TOPRIGHT"/></Anchors>
</Texture>
<Texture file="Interface\Spellbook\UI-SpellbookPanel-BotLeft">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="BOTTOMLEFT"/></Anchors>
</Texture>
<Texture file="Interface\Spellbook\UI-SpellbookPanel-BotRight">
<Size><AbsDimension x="128" y="256"/></Size>
<Anchors><Anchor point="BOTTOMRIGHT"/></Anchors>
</Texture>
<FontString name="SpellBookTitleText" inherits="GameFontNormal" text="SPELLBOOK">
<Anchors>
<Anchor point="CENTER"><Offset><AbsDimension x="6" y="230"/></Offset></Anchor>
</Anchors>
</FontString>
<FontString name="SpellBookPageText" inherits="GameFontNormal">
<Size><AbsDimension x="102" y="0"/></Size>
<Anchors>
<Anchor point="BOTTOM"><Offset><AbsDimension x="-14" y="96"/></Offset></Anchor>
</Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<!-- Close: the house minimize-button art (no UIPanelCloseButton template ships —
Gossip/Bag/Merchant/CharacterFrame's own precedent), the same TOPRIGHT(-44,-25)
position as every other window's close button. -->
<Button name="SpellBookFrameCloseButton">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors><Anchor point="CENTER" relativePoint="TOPRIGHT"><Offset><AbsDimension x="-44" y="-25"/></Offset></Anchor></Anchors>
<NormalTexture file="Interface\Buttons\UI-Panel-MinimizeButton-Up"/>
<PushedTexture file="Interface\Buttons\UI-Panel-MinimizeButton-Down"/>
<HighlightTexture file="Interface\Buttons\UI-Panel-MinimizeButton-Highlight" alphaMode="ADD"/>
<Scripts>
<OnClick>HideUIPanel(SpellBookFrame)</OnClick>
</Scripts>
</Button>
<!-- ref l.288-317. Tab 3 exists in the ref's own XML and is never raised in 1.12
(there is no third book); kept so the anchor chain reads like the ref's. -->
<Button name="SpellBookFrameTabButton1" inherits="BenillaSpellBookFrameTabButtonTemplate">
<Anchors>
<Anchor point="CENTER" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="79" y="61"/></Offset></Anchor>
</Anchors>
<DisabledTexture file="Interface\SpellBook\UI-SpellBook-Tab1-Selected"/>
</Button>
<Button name="SpellBookFrameTabButton2" inherits="BenillaSpellBookFrameTabButtonTemplate">
<Anchors>
<Anchor point="LEFT" relativeTo="SpellBookFrameTabButton1" relativePoint="RIGHT">
<Offset><AbsDimension x="-20" y="0"/></Offset>
</Anchor>
</Anchors>
<DisabledTexture file="Interface\SpellBook\UI-SpellBook-Tab1-Selected"/>
</Button>
<Button name="SpellBookFrameTabButton3" inherits="BenillaSpellBookFrameTabButtonTemplate">
<Anchors>
<Anchor point="LEFT" relativeTo="SpellBookFrameTabButton2" relativePoint="RIGHT">
<Offset><AbsDimension x="-20" y="0"/></Offset>
</Anchor>
</Anchors>
<DisabledTexture file="Interface\SpellBook\UI-SpellBook-Tab3-Selected"/>
</Button>
<Button name="SpellBookPrevPageButton">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors>
<Anchor point="CENTER" relativeTo="SpellBookFrame" relativePoint="BOTTOMLEFT">
<Offset><AbsDimension x="50" y="105"/></Offset>
</Anchor>
</Anchors>
<Layers>
<Layer level="BACKGROUND">
<FontString inherits="GameFontNormal" justifyH="LEFT" text="PREV">
<Anchors><Anchor point="LEFT" relativeTo="SpellBookPrevPageButton" relativePoint="RIGHT"/></Anchors>
</FontString>
</Layer>
</Layers>
<Scripts><OnClick>BenillaSpellBookPrevPageButton_OnClick()</OnClick></Scripts>
<NormalTexture file="Interface\Buttons\UI-SpellbookIcon-PrevPage-Up"/>
<PushedTexture file="Interface\Buttons\UI-SpellbookIcon-PrevPage-Down"/>
<DisabledTexture file="Interface\Buttons\UI-SpellbookIcon-PrevPage-Disabled"/>
<HighlightTexture file="Interface\Buttons\UI-Common-MouseHilight" alphaMode="ADD"/>
</Button>
<Button name="SpellBookNextPageButton">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors>
<Anchor point="CENTER" relativeTo="SpellBookFrame" relativePoint="BOTTOMLEFT">
<Offset><AbsDimension x="314" y="105"/></Offset>
</Anchor>
</Anchors>
<Layers>
<Layer level="BACKGROUND">
<FontString inherits="GameFontNormal" justifyH="RIGHT" text="NEXT">
<Anchors><Anchor point="RIGHT" relativeTo="SpellBookNextPageButton" relativePoint="LEFT"/></Anchors>
</FontString>
</Layer>
</Layers>
<Scripts><OnClick>BenillaSpellBookNextPageButton_OnClick()</OnClick></Scripts>
<NormalTexture file="Interface\Buttons\UI-SpellbookIcon-NextPage-Up"/>
<PushedTexture file="Interface\Buttons\UI-SpellbookIcon-NextPage-Down"/>
<DisabledTexture file="Interface\Buttons\UI-SpellbookIcon-NextPage-Disabled"/>
<HighlightTexture file="Interface\Buttons\UI-Common-MouseHilight" alphaMode="ADD"/>
</Button>
<!-- The 12-spell page grid (ref l.387-494): 2 columns x 6 rows, ids assigned the
ref's own column-major pattern (col1 ids 1-6, col2 ids 7-12) — SpellBook_GetSpellID
reads a button's `id=` as its within-page/within-tab 1-based position, not its
screen order. -->
<CheckButton name="SpellButton1" inherits="BenillaSpellButtonTemplate" id="1">
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="34" y="-85"/></Offset></Anchor></Anchors>
</CheckButton>
<CheckButton name="SpellButton2" inherits="BenillaSpellButtonTemplate" id="7">
<Anchors><Anchor point="TOPLEFT" relativeTo="SpellButton1"><Offset><AbsDimension x="157" y="0"/></Offset></Anchor></Anchors>
</CheckButton>
<CheckButton name="SpellButton3" inherits="BenillaSpellButtonTemplate" id="2">
<Anchors><Anchor point="TOPLEFT" relativeTo="SpellButton1" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-14"/></Offset></Anchor></Anchors>
</CheckButton>
<CheckButton name="SpellButton4" inherits="BenillaSpellButtonTemplate" id="8">
<Anchors><Anchor point="TOPLEFT" relativeTo="SpellButton3"><Offset><AbsDimension x="157" y="0"/></Offset></Anchor></Anchors>
</CheckButton>
<CheckButton name="SpellButton5" inherits="BenillaSpellButtonTemplate" id="3">
<Anchors><Anchor point="TOPLEFT" relativeTo="SpellButton3" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-14"/></Offset></Anchor></Anchors>
</CheckButton>
<CheckButton name="SpellButton6" inherits="BenillaSpellButtonTemplate" id="9">
<Anchors><Anchor point="TOPLEFT" relativeTo="SpellButton5"><Offset><AbsDimension x="157" y="0"/></Offset></Anchor></Anchors>
</CheckButton>
<CheckButton name="SpellButton7" inherits="BenillaSpellButtonTemplate" id="4">
<Anchors><Anchor point="TOPLEFT" relativeTo="SpellButton5" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-14"/></Offset></Anchor></Anchors>
</CheckButton>
<CheckButton name="SpellButton8" inherits="BenillaSpellButtonTemplate" id="10">
<Anchors><Anchor point="TOPLEFT" relativeTo="SpellButton7"><Offset><AbsDimension x="157" y="0"/></Offset></Anchor></Anchors>
</CheckButton>
<CheckButton name="SpellButton9" inherits="BenillaSpellButtonTemplate" id="5">
<Anchors><Anchor point="TOPLEFT" relativeTo="SpellButton7" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-14"/></Offset></Anchor></Anchors>
</CheckButton>
<CheckButton name="SpellButton10" inherits="BenillaSpellButtonTemplate" id="11">
<Anchors><Anchor point="TOPLEFT" relativeTo="SpellButton9"><Offset><AbsDimension x="157" y="0"/></Offset></Anchor></Anchors>
</CheckButton>
<CheckButton name="SpellButton11" inherits="BenillaSpellButtonTemplate" id="6">
<Anchors><Anchor point="TOPLEFT" relativeTo="SpellButton9" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-14"/></Offset></Anchor></Anchors>
</CheckButton>
<CheckButton name="SpellButton12" inherits="BenillaSpellButtonTemplate" id="12">
<Anchors><Anchor point="TOPLEFT" relativeTo="SpellButton11"><Offset><AbsDimension x="157" y="0"/></Offset></Anchor></Anchors>
</CheckButton>
<!-- The right-edge skill-line tabs, up to MAX_SKILLLINE_TABS (8) — ref l.495-566. -->
<CheckButton name="SpellBookSkillLineTab1" inherits="BenillaSpellBookSkillLineTabTemplate" id="1">
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPRIGHT"><Offset><AbsDimension x="-32" y="-65"/></Offset></Anchor></Anchors>
</CheckButton>
<CheckButton name="SpellBookSkillLineTab2" inherits="BenillaSpellBookSkillLineTabTemplate" id="2">
<Anchors><Anchor point="TOPLEFT" relativeTo="SpellBookSkillLineTab1" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-17"/></Offset></Anchor></Anchors>
</CheckButton>
<CheckButton name="SpellBookSkillLineTab3" inherits="BenillaSpellBookSkillLineTabTemplate" id="3">
<Anchors><Anchor point="TOPLEFT" relativeTo="SpellBookSkillLineTab2" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-17"/></Offset></Anchor></Anchors>
</CheckButton>
<CheckButton name="SpellBookSkillLineTab4" inherits="BenillaSpellBookSkillLineTabTemplate" id="4">
<Anchors><Anchor point="TOPLEFT" relativeTo="SpellBookSkillLineTab3" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-17"/></Offset></Anchor></Anchors>
</CheckButton>
<CheckButton name="SpellBookSkillLineTab5" inherits="BenillaSpellBookSkillLineTabTemplate" id="5">
<Anchors><Anchor point="TOPLEFT" relativeTo="SpellBookSkillLineTab4" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-17"/></Offset></Anchor></Anchors>
</CheckButton>
<CheckButton name="SpellBookSkillLineTab6" inherits="BenillaSpellBookSkillLineTabTemplate" id="6">
<Anchors><Anchor point="TOPLEFT" relativeTo="SpellBookSkillLineTab5" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-17"/></Offset></Anchor></Anchors>
</CheckButton>
<CheckButton name="SpellBookSkillLineTab7" inherits="BenillaSpellBookSkillLineTabTemplate" id="7">
<Anchors><Anchor point="TOPLEFT" relativeTo="SpellBookSkillLineTab6" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-17"/></Offset></Anchor></Anchors>
</CheckButton>
<CheckButton name="SpellBookSkillLineTab8" inherits="BenillaSpellBookSkillLineTabTemplate" id="8">
<Anchors><Anchor point="TOPLEFT" relativeTo="SpellBookSkillLineTab7" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-17"/></Offset></Anchor></Anchors>
</CheckButton>
</Frames>
<Scripts>
<OnLoad>SpellBookFrame_OnLoad()</OnLoad>
<OnEvent>SpellBookFrame_OnEvent()</OnEvent>
<OnShow>SpellBookFrame_OnShow()</OnShow>
<OnHide>SpellBookFrame_OnHide()</OnHide>
</Scripts>
</Frame>
</Ui>
@@ -40,6 +40,12 @@
this:RegisterEvent("CONFIRM_LOOT_ROLL")
</OnLoad>
<OnEvent>UIParent_OnEvent(event)</OnEvent>
<!-- The reference's own UIParent OnUpdate line (its UIParent.xml l.19): the floating
chat frames' hover reveal, the tab fades and the dock-drag driver run from here,
once a frame, since the chat window became the reference's (1948). The fade and
flash kits keep their own tick sources in UiPanels.xml. The guard is for a VM that
loaded this file without the chat files — a test's, never the manifest's. -->
<OnUpdate>if FCF_OnUpdate then FCF_OnUpdate(arg1) end</OnUpdate>
</Scripts>
</Frame>
+25 -7
View File
@@ -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
# <Model> (UI-AutoCastButton.mdx) and this engine's <Model> 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`.
+62 -33
View File
@@ -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!(
+3 -3
View File
@@ -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();
@@ -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<f32>,
/// 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<Vec<(usize, String, f64, f64)>>,
/// 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<f64>,
@@ -169,6 +173,7 @@ type ScreenParams<'w, 's> = (
MessageReader<'w, 's, AssetEvent<Mesh>>,
MessageReader<'w, 's, AssetEvent<benilla_assets::materials::WowModelMaterial>>,
Res<'w, Time<bevy::time::Virtual>>,
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::<Vec<_>>()
.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<usize, (f64, f64)> =
t0.iter().map(|(k, _, u, s)| (*k, (*u, *s))).collect();
let mut by_name: std::collections::HashMap<String, (f64, f64)> =
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::<Vec<_>>()
.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),
+6
View File
@@ -272,6 +272,12 @@ pub(crate) fn name_cache_path(realm: &str) -> Option<PathBuf> {
/// `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<PathBuf> {
home().map(|h| h.join("Logs"))
}
pub(crate) fn shots_path() -> Option<PathBuf> {
home().map(|h| h.join("shots.txt"))
}
+54
View File
@@ -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
+27
View File
@@ -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),
+85
View File
@@ -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<Vec<(usize, String, f64, f64)>> {
// `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::<libc::thread_act_t>()) as libc::vm_size_t,
);
}
Some(out)
}
#[cfg(not(target_os = "macos"))]
{
None
}
}
+49
View File
@@ -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<f64>,
/// 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<f64> {
(self.frames > 0).then(|| self.inside_secs * 1000.0 / f64::from(self.frames))
}
}
fn stamp_first(_pin: bevy::ecs::system::NonSendMarker, mut split: ResMut<MainThreadSplit>) {
split.at_first = main_thread_cpu_secs();
}
fn stamp_last(_pin: bevy::ecs::system::NonSendMarker, mut split: ResMut<MainThreadSplit>) {
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::<MainThreadSplit>()
.add_systems(First, stamp_first)
.add_systems(Last, stamp_last);
}
+4 -1
View File
@@ -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::<stats::FrameStats>()
.init_resource::<PerfHud>()
+12 -1
View File
@@ -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",
+7
View File
@@ -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<super::by_name::AssistRequest>,
) {
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::<crate::net::Reputations>();
world.init_resource::<Selection>();
world.init_resource::<scan::LastEnemy>();
world.init_resource::<Messages<super::by_name::AssistRequest>>();
world.insert_non_send_resource(UiScript::new().expect("a bare VM"));
world.spawn((SelfPlayer, Guid(ME), store(&[(22, 100), (28, 100)])));
+3 -23
View File
@@ -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::<ChatLog>()
.init_resource::<frames::ChatWindows>()
.init_resource::<edit::ChatEditState>()
.init_resource::<edit::ChannelState>()
.init_resource::<channels::ZoneChannelWalk>()
.init_resource::<language::ChatLanguages>()
@@ -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<NonSendMut<benilla_ui::script::UiScript>>,
mut windows: ResMut<frames::ChatWindows>,
mut edit: ResMut<edit::ChatEditState>,
mut log: ResMut<ChatLog>,
) {
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()"));
+55 -28
View File
@@ -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<NonSendMut<benilla_ui::script::UiScript>>,
mut channels: ResMut<ChannelState>,
mut walk: ResMut<ZoneChannelWalk>,
mut edit: ResMut<super::edit::ChatEditState>,
) {
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<NonSendMut<benilla_ui::script::UiScript>>,
mut channels: ResMut<ChannelState>,
mut walk: ResMut<ZoneChannelWalk>,
mut edit: ResMut<super::edit::ChatEditState>,
mut disconnects: MessageReader<crate::net::DisconnectedMessage>,
) {
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<benilla_ui::script::ZoneChannelRow> {
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<NetCommands>,
channels: Res<ChannelState>,
@@ -299,6 +313,8 @@ pub(super) fn auto_join_zone_channels(
),
mut walk: ResMut<ZoneChannelWalk>,
mut entered: MessageReader<crate::net::EnteredWorldMessage>,
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
mut catalog_fed: Local<crate::ui_script::VmMemo<Option<(String, bool)>>>,
) {
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,
+10 -524
View File
@@ -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_<T>_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_<TYPE>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_<TYPE>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<String>,
/// `toldTarget` — who WE last whispered (the ctrl-R / ReplyTell2 memory).
pub last_told: Option<String>,
/// 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<String> {
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 `/<alias> ` 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<NonSendMut<benilla_ui::script::UiScript>>,
mut state: ResMut<ChatEditState>,
channels: Res<ChannelState>,
ui_capture: Res<UiKeyboardCapture>,
mut names: ResMut<NameCache>,
self_guid: Res<SelfGuid>,
commands: Res<NetCommands>,
) {
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::<bool>("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 <name-or-number>` 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::<u32>() {
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::<u32>() {
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();
// `/<digits>` — the numbered-channel switch (`ChatEdit_ParseText` l.2110-2121: a live
// GetChannelName hit converts immediately, remainder kept) and `/c <name-or-number>`
// (`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<NonSendMut<benilla_ui::script::UiScript>>,
binds: Res<crate::bindings::BindingsState>,
mut state: ResMut<ChatEditState>,
group: Res<crate::ui_party::GroupState>,
) {
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 <name> "`, 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<NonSendMut<benilla_ui::script::UiScript>>,
mut state: ResMut<ChatEditState>,
group: Res<crate::ui_party::GroupState>,
) {
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<NonSendMut<benilla_ui::script::UiScript>>,
mut state: ResMut<ChatEditState>,
) {
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<NonSendMut<benilla_ui::script::UiScript>>,
mut state: ResMut<ChatEditState>,
) {
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(&current) {
state.tell_target = next;
state.header_dirty = true;
}
}
}
+5 -129
View File
@@ -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<ChatEventKind> {
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,
})
}
+48 -23
View File
@@ -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<String>,
b: Option<String>,
) -> ChatEvent {
) -> Option<ChatEvent> {
// 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<NonSendMut<benilla_ui::script::UiScript>>,
mut log: ResMut<ChatLog>,
mut windows: ResMut<ChatWindows>,
mut edit: ResMut<super::edit::ChatEditState>,
mut channels: ResMut<ChannelState>,
mut names: ResMut<NameCache>,
mut speaker: SpeakerEffects,
commands: Res<NetCommands>,
time: Res<Time>,
// The text-emote sentence seam (decision 1274): the tables, plus the guid the composer's
// "are you the performer?" test compares against.
emote_texts: Option<Res<EmoteTexts>>,
@@ -708,7 +738,6 @@ pub(super) fn feed_chat(
let Some(mut script) = script else {
return;
};
windows.tell_alert_left = (windows.tell_alert_left - time.delta_secs()).max(0.0);
if log.pending.is_empty() {
return;
}
@@ -838,11 +867,6 @@ pub(super) fn feed_chat(
..Default::default()
};
channels.stamp_channel(&mut event);
// A received whisper remembers its sender (`ChatEdit_SetLastTellTarget`,
// ChatFrame_OnEvent l.1471) — the `/r` + Tab-cycle ring.
if event.kind == Some(ChatEventKind::Whisper) && !event.sender.is_empty() {
edit.remember_tell(&event.sender);
}
route(&mut script, &mut windows, &event);
// The speech bubble spawns the moment the line routes — the reference's
// `0x49acd9` sits in the same SMSG display path ([`crate::chat_bubble`]).
@@ -918,13 +942,14 @@ pub(super) fn feed_chat(
// changes what the player sees, which this pass is not allowed to do. Left for the
// director's call; the cost of leaving it is that arg7/arg8/arg9 are 0/0/empty on
// these events alone.
let event = notice_event(
if let Some(event) = notice_event(
notice,
channel,
Some(a.unwrap_or_else(|| "Unknown".into())),
b,
);
route(&mut script, &mut windows, &event);
) {
route(&mut script, &mut windows, &event);
}
}
Pending::Addon {
prefix,
+22 -168
View File
@@ -12,23 +12,14 @@ use bevy::prelude::*;
use benilla_protocol::messages::channel_notice as notice;
use super::event::{event_name, group_wants, resolved_color, ChatEvent, ChatEventKind, ChatGroup};
use super::event::{event_name, ChatEvent, ChatEventKind};
/// How long after a received whisper the `TellMessage` alert stays silent
/// (`CHAT_TELL_ALERT_TIME = 300` — ref ChatFrame.lua l.4: only a tell arriving ≥5 min after the
/// previous one chimes).
const TELL_ALERT_SECS: f32 = 300.0;
/// The docked windows' registrations — the shipped defaults: window 1 "General" and window 2
/// "Combat Log", exactly the ref client's own chat-cache WINDOW blocks (0288 pin §6b). Window 2's
/// combat/spell groups beyond MONEY have no sources yet (the combat-log content arc); it renders
/// what its groups receive.
#[derive(Resource)]
/// What the app keeps beside the reference's chat frames: the default language its composer
/// needs for the log-file line, and the log files themselves. The per-window registration that
/// lived here (0288 §1) is the record's MESSAGES set now, read by the reference's own
/// `ChatFrame_RegisterForMessages` (decision 1948).
#[derive(Resource, Default)]
pub(crate) struct ChatWindows {
/// `groups[i]` = window `i+1`'s registered message groups.
pub groups: [Vec<ChatGroup>; 2],
/// Seconds left before the next received whisper chimes again ([`TELL_ALERT_SECS`]).
pub tell_alert_left: f32,
/// The frame's own `this.defaultLanguage` — the name `GetDefaultLanguage()` answers, which is
/// the **faction** tongue (Common for every Alliance race, Orcish for every Horde one).
///
@@ -38,129 +29,19 @@ pub(crate) struct ChatWindows {
/// suppresses no header the reference would show — an empty default only ever makes the test
/// *more* likely to print one.
pub default_language: String,
/// `LoggingChat`/`LoggingCombat`'s files ([`super::logging`]) — here because every
/// rendered line passes [`route`], which is the one place to tee them.
pub logs: super::logging::ChatLogFiles,
}
impl Default for ChatWindows {
fn default() -> Self {
ChatWindows {
groups: [
// WINDOW 1 "General": MESSAGES = SYSTEM SAY YELL WHISPER PARTY GUILD CREATURE
// CHANNEL SKILL LOOT (chat-cache verbatim).
vec![
ChatGroup::System,
ChatGroup::Say,
ChatGroup::Yell,
ChatGroup::Whisper,
ChatGroup::Party,
ChatGroup::Guild,
ChatGroup::Creature,
ChatGroup::Channel,
ChatGroup::Skill,
ChatGroup::Loot,
],
// WINDOW 2 "Combat Log" — the chat-cache WINDOW 2 MESSAGES block, in its own
// order (read off the reference install's own
// `WTF/Account/<A>/<realm>/<char>/chat-cache.txt`).
//
// **The default Combat Log is self-relevant only**, and that is a deliberate
// shape, not an omission: the shipped registration takes SELF, PET,
// HOSTILEPLAYER and CREATURE_VS_SELF and leaves PARTY, FRIENDLYPLAYER,
// CREATURE_VS_PARTY and CREATURE_VS_CREATURE off. Those types are still produced
// and still reach addons — the *event* fire is unconditional (see [`route`]) —
// they simply land in no window until a player registers them. A damage meter
// reads them either way.
//
// The block is now registered WHOLE (1703): the eight names 1571 had to leave
// out — COMBAT_MISC_INFO, the death pair, SPELL_TRADESKILLS, SPELL_AURA_GONE_SELF,
// SPELL_ITEM_ENCHANTMENTS, SPELL_BREAK_AURA and COMBAT_FACTION_CHANGE — all have
// producers now and sit in the shipped file's own order. `SPELL_FAILED_LOCALPLAYER`
// is deliberately absent: the reference's own chat-cache registers it in NO window,
// so a failed cast lands in the log only for a player who adds it.
vec![
ChatGroup::Own(ChatEventKind::CombatMiscInfo),
ChatGroup::Own(ChatEventKind::CombatSelfHits),
ChatGroup::Own(ChatEventKind::CombatSelfMisses),
ChatGroup::Own(ChatEventKind::CombatPetHits),
ChatGroup::Own(ChatEventKind::CombatPetMisses),
ChatGroup::Own(ChatEventKind::CombatHostilePlayerHits),
ChatGroup::Own(ChatEventKind::CombatHostilePlayerMisses),
ChatGroup::Own(ChatEventKind::CombatCreatureVsSelfHits),
ChatGroup::Own(ChatEventKind::CombatCreatureVsSelfMisses),
ChatGroup::Own(ChatEventKind::CombatFriendlyDeath),
ChatGroup::Own(ChatEventKind::CombatHostileDeath),
// XP joined with the ding arc (0304) and honour with the honor arc (1512);
// the reference registers the two one line apart (ChatFrame.lua l.2428-2429).
ChatGroup::Own(ChatEventKind::CombatXpGain),
ChatGroup::Own(ChatEventKind::CombatHonorGain),
ChatGroup::Own(ChatEventKind::SpellSelfDamage),
ChatGroup::Own(ChatEventKind::SpellSelfBuff),
ChatGroup::Own(ChatEventKind::SpellPetDamage),
ChatGroup::Own(ChatEventKind::SpellPetBuff),
ChatGroup::Own(ChatEventKind::SpellHostilePlayerDamage),
ChatGroup::Own(ChatEventKind::SpellHostilePlayerBuff),
ChatGroup::Own(ChatEventKind::SpellCreatureVsSelfDamage),
ChatGroup::Own(ChatEventKind::SpellCreatureVsSelfBuff),
ChatGroup::Own(ChatEventKind::SpellTradeskills),
ChatGroup::Own(ChatEventKind::SpellDamageShieldsOnSelf),
ChatGroup::Own(ChatEventKind::SpellAuraGoneSelf),
ChatGroup::Own(ChatEventKind::SpellItemEnchantments),
ChatGroup::Own(ChatEventKind::SpellBreakAura),
ChatGroup::Own(ChatEventKind::SpellPeriodicSelfDamage),
ChatGroup::Own(ChatEventKind::SpellPeriodicSelfBuffs),
ChatGroup::Own(ChatEventKind::SpellPeriodicHostilePlayerDamage),
ChatGroup::Own(ChatEventKind::SpellPeriodicHostilePlayerBuffs),
ChatGroup::Own(ChatEventKind::SpellPeriodicCreatureDamage),
ChatGroup::Own(ChatEventKind::SpellPeriodicCreatureBuffs),
ChatGroup::Own(ChatEventKind::CombatFactionChange),
ChatGroup::Money,
],
],
tell_alert_left: 0.0,
default_language: String::new(),
}
}
}
impl ChatWindows {
/// Whether window `idx` (0-based) subscribes to `kind`. CHANNEL speech (the numbered
/// channels) routes by the window's channel wiring — until 0288 P6 lands those lists, it
/// rides window 1 (the chat-cache ZONECHANNELS mask wires exactly window 1 anyway).
pub(super) fn wants(&self, idx: usize, kind: ChatEventKind) -> bool {
if kind == ChatEventKind::Channel {
return idx == 0;
}
self.groups[idx].iter().any(|&g| group_wants(g, kind))
}
}
/// Route one event: compose it once and AddMessage it into every subscribed window, then fire the
/// real `CHAT_MSG_*` at the VM. Whisper receipt side-effects (the throttled `TellMessage` chime +
/// the unselected-tab flash — ref ChatFrame_OnEvent l.1470-1477) ride the render half. A kind-less
/// event (an unmodeled wire type) drops with a warn — never silently.
/// Route one event: fire the real `CHAT_MSG_*` at the VM — the reference's own `ChatFrame_OnEvent`
/// composes and prints it, in every window whose MESSAGES set carries the type, with
/// `ChatTypeInfo`'s colour, the whisper chime and the tab flash (decision 1948) — and tee the
/// rendered line to the log files. A kind-less event (an unmodeled wire type) drops with a warn,
/// never silently.
///
/// **Two consumers, one event, no double-print.** In the reference, C fires `CHAT_MSG_<TYPE>` and
/// *Lua* — `ChatFrame_OnEvent` — is what turns it into a line; here the composer below IS that
/// handler, transcribed into Rust (0288 §1). So the fire is additive: it exists for **addons**,
/// and our windows keep rendering exactly as they did. Nothing prints twice because our shipped
/// `ChatFrame.xml` handles exactly one event, `EXECUTE_CHAT_LINE` (assets/ui/ChatFrame.xml, its
/// `<OnEvent>`) — an addon may `ChatFrame1:RegisterEvent("CHAT_MSG_SAY")` for its own reasons and
/// our frame's handler will simply not match it.
/// `ui_chat::tests::an_addon_registering_our_own_chat_frame_does_not_double_print` is the guard.
///
/// **Render first, then fire — that ordering is the reference's, not a convenience.** The client
/// dispatches an event to its listeners in registration order (FIFO, wow-re
/// `event-dispatch-order.md`, and [`benilla_ui::script::UiScript::fire_event`] transcribes it), and
/// ChatFrame1 registers at FrameXML load — before any addon exists. So in the real client the line
/// is already in the window by the time an addon's handler runs, and an addon that reads
/// `GetNumMessages()` or re-reads the last line from its own `CHAT_MSG_*` handler depends on that.
/// Our Rust composer stands in for ChatFrame1's handler, so it has to go first for the same reason.
///
/// **The fire is unconditional; the render is not.** The client's `SignalEvent` does not consult
/// any window's message-group registration — that is `ChatFrame_OnEvent`'s job, per frame — so an
/// event reaches Lua even when neither of our windows wants it. What does *not* reach here is a
/// notice the reference declines to make an event out of at all: MODE_CHANGE is dropped upstream,
/// at the feed, exactly as the client's `0x0C` arm returns without firing
/// (`ui_chat::tests::a_mode_change_notice_never_becomes_an_event`).
/// The composer that used to print here survives for the log line only: `LoggingChat`'s file
/// wants the text the window shows, and the reference writes it C-side, not from Lua.
pub(crate) fn route(
script: &mut benilla_ui::script::UiScript,
windows: &mut ChatWindows,
@@ -170,45 +51,18 @@ pub(crate) fn route(
warn!("chat: unroutable event (no kind): {:?}", event.text);
return;
};
// ── our own window: the transcribed ChatFrame_OnEvent, i.e. the first-registered listener ──
// Cloned once rather than borrowed, because `windows` goes on to be used mutably below.
// The window shows what the reference's `ChatFrame_OnEvent` prints from the event — its
// own composition, `ChatTypeInfo`'s colours, the per-window registration
// (`ChatFrame_RegisterForMessages` over the record's MESSAGES set), the tell chime and the
// tab flash. The app's transcription of that composition survives only for the log files,
// which want the rendered line the window will show.
let default_language = windows.default_language.clone();
if let Some(line) = compose(event, kind, &default_language) {
let color = resolved_color(event, kind);
for idx in 0..2 {
if windows.wants(idx, kind) {
add(script, &format!("ChatFrame{}", idx + 1), &line, color);
}
}
if kind == ChatEventKind::Whisper {
// The tell chime, throttled to one per 5 minutes (CHAT_TELL_ALERT_TIME), and the tab
// flash when the receiving window (1) isn't the selected dock tab. Inside this half
// because the reference does them inside ChatFrame_OnEvent too (l.1470-1477).
if windows.tell_alert_left <= 0.0 {
crate::ui_script::run_or_warn(script, "PlaySound(\"TellMessage\")");
}
windows.tell_alert_left = TELL_ALERT_SECS;
crate::ui_script::run_or_warn(
script,
"if BenillaFCF.selected ~= 1 then BenillaFCF_FlashTab(1) end",
);
}
windows.logs.record(kind.is_combat_log(), &line);
}
// ── everyone else: the addons, after the default UI, exactly as registration order says ──
script.fire_event(event_name(kind), event.script_args());
}
/// Add one composed line, converting the `0..255` table color to the seam's `0..1` floats.
fn add(script: &mut benilla_ui::script::UiScript, frame: &str, text: &str, color: [u8; 3]) {
script.add_chat_message(
frame,
text,
f32::from(color[0]) / 255.0,
f32::from(color[1]) / 255.0,
f32::from(color[2]) / 255.0,
);
}
/// `ChatFrame_OnEvent`'s composition, transcribed (ref ChatFrame.lua l.1369-1468 + the quoted
/// GlobalStrings). Returns `None` for a notice the 1.12 UI renders silently (MODE_CHANGE).
pub(crate) fn compose(
+104 -102
View File
@@ -13,66 +13,12 @@
use bevy::prelude::*;
mod parse;
pub(super) use parse::{parse_enter_type_switch, parse_line, ParsedChat};
pub(super) use parse::{parse_line, ParsedChat};
use crate::creature_anim::{move_flags, MovementState};
use crate::net::{ClientCommand, NetCommands, SelfPlayer};
use crate::target::Selection;
/// Send `text` as the box's CURRENT type (`ChatEdit_SendText`): whisper/channel targets ride
/// along; a sent whisper remembers its target (`ChatEdit_SetLastToldTarget`); a sticky type
/// commits (`ChatEdit_OnEnterPressed`'s `stickyType = type`).
fn send_current(state: &mut super::edit::ChatEditState, commands: &NetCommands, text: String) {
use super::edit::SendType;
if text.trim().is_empty() {
if state.chat_type.sticky() {
state.sticky = state.chat_type;
}
return;
}
let target = match state.chat_type {
SendType::Whisper => Some(state.tell_target.clone()),
SendType::Channel => Some(state.channel_target.clone()),
_ => None,
};
if state.chat_type == SendType::Whisper {
state.last_told = Some(state.tell_target.clone());
}
if state.chat_type.sticky() {
state.sticky = state.chat_type;
}
let cmd = ClientCommand::Chat {
kind: state.chat_type.wire(),
target,
text,
};
match commands.0.send(cmd) {
Ok(()) => {}
Err(_) => warn!("chat: not connected; line dropped"),
}
}
/// The canonical recallable form of a submitted line — the ref's `ChatEdit_AddHistory`
/// (ChatFrame.lua 1916-1938: `SLASH_<type>1`, plus the whisper target / the channel number,
/// then the typed text) — so a recalled line re-Entered reproduces the send even after the
/// box's mode has moved on. A typed slash line recalls exactly as typed; that also stores
/// command lines (`/join …`), which the ref never recalls — a deliberate, useful divergence
/// (decision 0301).
fn history_line(state: &super::edit::ChatEditState, msg: &str) -> String {
use super::edit::SendType;
if msg.starts_with('/') {
return msg.to_string();
}
match state.chat_type {
SendType::Whisper => format!("/w {} {}", state.tell_target, msg),
SendType::Channel => format!("/{} {}", state.channel_number, msg),
t => match t.canonical_slash() {
Some(a) => format!("/{a} {msg}"),
None => msg.to_string(), // the leader types: no 1.12 slash — recall raw
},
}
}
#[allow(clippy::too_many_arguments)] // a Bevy system's param list IS its dependency set
/// The target half of the ref's `GetSlashCmdTarget` (ChatFrame.lua:650-658): a bare party
/// command falls back to the current selection iff it's a PLAYER; anything else is `None` (the
@@ -194,11 +140,53 @@ pub(super) struct ChatOut<'w> {
}
// One parameter per concern — the chat drain fans out to every command's consumer.
/// The chat verbs the stock `ChatFrame.lua` slash handlers call once *they* have parsed the
/// line — `DoEmote`, `RandomRoll`, `UninviteByName`, `ConsoleExec`, the channel verbs — drained
/// from the VM into the same [`ParsedChat`] values our own parser produces, so one executor
/// below serves both the reference's Lua and the lines it never sees.
fn engine_verbs(
script: &mut benilla_ui::script::UiScript,
emotes: Option<&crate::sound::EmoteSounds>,
) -> Vec<(String, ParsedChat)> {
let mut out = Vec::new();
for e in script.take_emote_requests() {
// The token is the `EmotesText.dbc` NAME (`EMOTE<i>_TOKEN`, "WAVE"), which is how the
// slash table resolved `/wave` before the Lua did the resolving.
match emotes.and_then(|c| c.text_id(&e.token)) {
Some(id) => out.push((String::new(), ParsedChat::TextEmote(id))),
None => warn!(
"chat: DoEmote({:?}): no EmotesText row for that token",
e.token
),
}
}
for (min, max) in script.take_roll_requests() {
out.push((String::new(), ParsedChat::Random { min, max }));
}
for name in script.take_uninvite_requests() {
out.push((String::new(), ParsedChat::Uninvite { name: Some(name) }));
}
for line in script.take_console_lines() {
// `ConsoleExec` already wrote the CVar lines to the store; what reaches here is a
// console COMMAND, of which this client implements the one the reference's `/reload`
// alias spells out.
let cmd = line.trim().to_string();
if cmd.eq_ignore_ascii_case("reloadui") {
out.push((line, ParsedChat::ReloadUi));
} else {
out.push((line, ParsedChat::ConsoleUnknown { cmd }));
}
}
for cmd in script.take_channel_commands() {
out.push((String::new(), ParsedChat::Channel(cmd)));
}
out
}
#[allow(clippy::too_many_arguments)]
pub(super) fn drain_chat_input(
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
mut chat_log: ResMut<super::feed::ChatLog>,
mut state: ResMut<super::edit::ChatEditState>,
channels: Res<super::edit::ChannelState>,
commands: Res<NetCommands>,
emotes: Option<Res<crate::sound::EmoteSounds>>,
@@ -235,57 +223,24 @@ pub(super) fn drain_chat_input(
let Some(mut script) = script else {
return;
};
let mut queue = engine_verbs(&mut script, emotes.as_deref());
// What `take_chat_input` carries now is benilla's own commands, handed over by the
// `SlashCmdList` shim in ScriptLogFrame.xml once the reference's `ChatEdit_ParseText` has
// found no built-in for the line — the history line, the type switch and the send are the
// reference's Lua before the line ever reaches here (1948).
for raw in script.take_chat_input() {
let msg = raw.trim();
if msg.is_empty() {
continue; // empty Enter = cancel (sticky already committed on prior sends)
}
// Every non-blank submit becomes recallable (Up/Down) in its canonical form — the ref's
// `ChatEdit_AddHistory` slot. Plain lines canonicalize from the same state the send
// below uses, so this pre-branch placement equals the ref's post-parse one.
script.editbox_add_history("ChatFrameEditBox", &history_line(&state, msg));
// A plain line sends as the box's CURRENT type — the whole point of the edit machine
// (the v1 always-SAY default dies here).
if !msg.starts_with('/') {
send_current(&mut state, &commands, msg.to_string());
continue;
}
// A slash line typed whole + Entered (never live-parsed — no trailing space): the type
// switch still applies on the send path (`ChatEdit_ParseText(send=1)` runs the same
// conversion first), then the remainder sends as the new type.
if let Some((switch, remainder)) = parse_enter_type_switch(&channels, msg) {
match switch {
super::edit::TypeSwitch::Plain(t) => state.chat_type = t,
super::edit::TypeSwitch::Whisper(target) => {
state.chat_type = super::edit::SendType::Whisper;
state.tell_target = target;
}
super::edit::TypeSwitch::Channel { name, number } => {
state.chat_type = super::edit::SendType::Channel;
state.channel_target = name;
state.channel_number = number;
}
}
state.header_dirty = true;
send_current(&mut state, &commands, remainder);
continue;
}
match parse_line(&table, msg) {
ParsedChat::Reply { text } => match state.last_tell.front().cloned() {
Some(target) => {
state.chat_type = super::edit::SendType::Whisper;
state.tell_target = target;
state.header_dirty = true;
send_current(&mut state, &commands, text);
}
None => {
// ERR_NO_REPLY_TARGET (GlobalStrings:1748).
chat_log.push_event(super::event::ChatEvent::text_only(
super::event::ChatEventKind::System,
"You have nobody to reply to yet.".to_string(),
));
}
},
queue.push((msg.to_string(), parse_line(&table, msg)));
}
for (msg, parsed) in queue {
let msg = msg.as_str();
match parsed {
// `/r` is `SLASH_REPLY`, one of the reference's own built-ins (`ChatEdit_ParseText`'s
// REPLY arm over the box's tell ring) — the line never reaches this queue.
ParsedChat::Reply { .. } => {}
ParsedChat::Join { name, password } => {
let _ = commands
.0
@@ -982,6 +937,53 @@ pub(super) fn drain_chat_input(
));
}
}
ParsedChat::Channel(cmd) => {
use benilla_ui::script::ChannelCommand as C;
let cmd = match cmd {
C::Join { name, password } => ClientCommand::JoinChannel { name, password },
C::Leave { name } => ClientCommand::LeaveChannel { name },
C::List { name } => ClientCommand::ChannelList { name },
// `ListChannels()` — the joined roster, numbered the way `/N` addresses it.
C::ListAll => {
let roster: Vec<String> = channels
.joined
.iter()
.enumerate()
.filter_map(|(i, c)| c.as_ref().map(|c| format!("{}. {c}", i + 1)))
.collect();
let text = if roster.is_empty() {
"You are not in any channels.".to_string()
} else {
format!("Channels: {}", roster.join(", "))
};
chat_log.push_event(super::event::ChatEvent::text_only(
super::event::ChatEventKind::System,
text,
));
continue;
}
C::DisplayOwner { name } => ClientCommand::ChannelOwner { name },
C::SetOwner { name, player } => ClientCommand::ChannelSetOwner { name, player },
C::SetPassword { name, password } => {
ClientCommand::ChannelPassword { name, password }
}
C::Ban { name, player } => ClientCommand::ChannelBan { name, player },
C::Invite { name, player } => ClientCommand::ChannelInvite { name, player },
C::Kick { name, player } => ClientCommand::ChannelKick { name, player },
C::Moderator { name, player } => {
ClientCommand::ChannelModerator { name, player }
}
C::Unmoderator { name, player } => {
ClientCommand::ChannelUnmoderator { name, player }
}
C::Mute { name, player } => ClientCommand::ChannelMute { name, player },
C::Unmute { name, player } => ClientCommand::ChannelUnmute { name, player },
C::Unban { name, player } => ClientCommand::ChannelUnban { name, player },
C::Moderate { name } => ClientCommand::ChannelModerate { name },
C::ToggleAnnouncements { name } => ClientCommand::ChannelAnnouncements { name },
};
let _ = commands.0.send(cmd);
}
ParsedChat::Unknown => {
// Before the help line: an ADDON may claim it (decision 1195). The reference
// resolves `SlashCmdList` in the same pass as its own commands; ours runs after
+4 -40
View File
@@ -98,6 +98,10 @@ pub(in crate::ui_chat) enum ParsedChat {
/// A `/name` line that resolved in `EmotesText` (`/wave` → text id 101) — sent as
/// `CMSG_TEXT_EMOTE` targeted at the current selection.
TextEmote(u32),
/// A channel verb the stock `ChatFrame.lua` handler already parsed and the VM queued
/// (`JoinChannelByName`, `ChannelKick`, … — `benilla_ui::script::ChannelCommand`). Never
/// produced by [`parse_line`]; it enters the executor from the engine's queue.
Channel(benilla_ui::script::ChannelCommand),
/// The `/castvis` **dev instrument** (decision 0099 phase 2): synthesize a cast edge locally
/// — the exact [`crate::creature_anim::CastEvent`] the wire would produce — on the selection
/// (else self), no server round-trip. Runtime-available like every current instrument
@@ -478,43 +482,3 @@ fn dev_command(dev: DevCmd, args: &str) -> ParsedChat {
},
}
}
/// The Enter-path type switch (`ChatEdit_ParseText(send=1)` runs the same conversion the live
/// parse does, but without the live parse's trailing-space requirement — "/g hi" + Enter
/// converts and sends in one stroke; "/g" alone converts and commits the sticky).
pub(in crate::ui_chat) fn parse_enter_type_switch(
channels: &crate::ui_chat::edit::ChannelState,
text: &str,
) -> Option<(crate::ui_chat::edit::TypeSwitch, String)> {
use crate::ui_chat::edit::{SendType, TypeSwitch};
let rest = text.strip_prefix('/')?;
let (cmd, args) = rest.split_once(' ').unwrap_or((rest, ""));
let lower = cmd.to_ascii_lowercase();
// `/2 hi` and `/c world hi` convert-and-send on the enter path too.
if let Some(switch) = crate::ui_chat::edit::channel_switch(channels, &lower, args) {
return Some(switch);
}
if ["w", "whisper", "t", "tell", "send"].contains(&lower.as_str()) {
let (target, remainder) = args.split_once(' ').unwrap_or((args, ""));
if target.is_empty() || target.starts_with('|') || remainder.trim().is_empty() {
return None; // needs a name AND a message on the enter path
}
return Some((
TypeSwitch::Whisper(target.to_string()),
remainder.to_string(),
));
}
let t = match lower.as_str() {
"s" | "say" => SendType::Say,
"y" | "yell" | "sh" | "shout" => SendType::Yell,
"e" | "em" | "emote" | "me" => SendType::Emote,
"p" | "party" => SendType::Party,
"raid" | "ra" | "rsay" => SendType::Raid,
"rw" => SendType::RaidWarning,
"g" | "gc" | "gu" | "guild" => SendType::Guild,
"o" | "osay" => SendType::Officer,
"bg" | "battleground" => SendType::Battleground,
_ => return None,
};
Some((TypeSwitch::Plain(t), args.to_string()))
}
+148
View File
@@ -0,0 +1,148 @@
//! `LoggingChat` / `LoggingCombat` — the two log files the `/chatlog` and `/combatlog` handlers
//! toggle (stock `ChatFrame.lua` l.677-693: the handler reads the flag, flips it, and prints
//! `CHATLOGENABLED`/`COMBATLOGENABLED` itself, so the engine's whole job is the flag and the
//! file).
//!
//! The flags live in the VM (`chat_misc`); this is the file end. The reference writes
//! `Logs\WoWChatLog.txt` and `Logs\WoWCombatLog.txt` beside `WTF`; ours are the same two names
//! under `benilla-config/Logs/` ([`crate::local_state::logs_dir`]) — the install is read-only
//! (decision 1486), so the folder is ours, not the game's. Lines are appended as the chat window
//! shows them, each stamped `M/D HH:MM:SS.mmm` the way the reference's log reads, in UTC (this
//! process has no local-zone source and would rather be honest than guess an offset).
//!
//! A file that cannot be opened logs once and the flag stays set — the Lua printed "enabled",
//! and a silent flip back would make the next `/chatlog` do the opposite of what it says.
use std::io::Write as _;
use bevy::prelude::*;
use benilla_ui::script::UiScript;
/// The open log files, held by the chat windows because that is where every rendered line
/// passes ([`super::frames::route`]).
#[derive(Default)]
pub(crate) struct ChatLogFiles {
chat: Option<std::fs::File>,
combat: Option<std::fs::File>,
}
impl ChatLogFiles {
/// One rendered line — to the combat log when the kind is a combat-log kind, else the chat
/// log; either only when that file is open.
pub(super) fn record(&mut self, combat: bool, line: &str) {
let slot = if combat {
&mut self.combat
} else {
&mut self.chat
};
if let Some(file) = slot.as_mut() {
if writeln!(file, "{} {line}", stamp()).is_err() {
*slot = None;
}
}
}
fn set(&mut self, combat: bool, on: bool) {
let name = if combat {
"WoWCombatLog.txt"
} else {
"WoWChatLog.txt"
};
let slot = if combat {
&mut self.combat
} else {
&mut self.chat
};
if !on {
*slot = None;
return;
}
if slot.is_some() {
return;
}
let Some(dir) = crate::local_state::logs_dir() else {
return; // hermetic capture, or no state folder
};
let path = dir.join(name);
let opened = std::fs::create_dir_all(&dir).and_then(|()| {
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
});
match opened {
Ok(file) => {
info!("chat: logging to {}", path.display());
*slot = Some(file);
}
Err(e) => warn!("chat: cannot open {}: {e}", path.display()),
}
}
}
/// `M/D HH:MM:SS.mmm` of now, UTC.
fn stamp() -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let secs = now.as_secs();
let (_, month, day) = civil_from_days((secs / 86_400) as i64);
let of_day = secs % 86_400;
format!(
"{month}/{day} {:02}:{:02}:{:02}.{:03}",
of_day / 3600,
(of_day / 60) % 60,
of_day % 60,
now.subsec_millis()
)
}
/// Days since 1970-01-01 → `(year, month, day)`, the proleptic Gregorian calendar (Howard
/// Hinnant's `civil_from_days`).
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
(if m <= 2 { y + 1 } else { y }, m, d)
}
/// The VM's two flags → the two files, on the frame either flag moves.
pub(super) fn sync_chat_logging(
script: Option<NonSendMut<UiScript>>,
mut windows: ResMut<super::frames::ChatWindows>,
) {
let Some(mut script) = script else { return };
if !script.take_logging_changes() {
return;
}
let (chat, combat) = script.logging_flags();
windows.logs.set(false, chat);
windows.logs.set(true, combat);
}
pub(super) fn plugin(app: &mut App) {
app.add_systems(
Update,
sync_chat_logging.run_if(in_state(crate::char_select::ClientState::InWorld)),
);
}
#[cfg(test)]
mod tests {
use super::civil_from_days;
#[test]
fn the_civil_conversion_lands_on_known_dates() {
assert_eq!(civil_from_days(0), (1970, 1, 1));
assert_eq!(civil_from_days(19_723), (2024, 1, 1));
assert_eq!(civil_from_days(20_700), (2026, 9, 4));
assert_eq!(civil_from_days(-1), (1969, 12, 31));
}
}
+592 -198
View File
@@ -1,49 +1,80 @@
//! The chat windows' **saved state** — the background tint, the background alpha and the font size
//! the player picks off a chat tab's right-click menu (decision 1589, fixing **B246**: *"no chat
//! options at all — background transparency has no home, and chat can be hard to read"*), plus the
//! window's **lock**, which joined them when the windows became movable and resizable.
//! The chat windows' **saved state** — the whole `chat-cache.txt` record: the per-type colour
//! table and, per window, the tint, alpha, font size, lock, dock, shown flag, name, the message
//! types it shows and the channels it carries (decisions 1589 → 1714 → 1948).
//!
//! The state itself lives in the VM ([`benilla_ui::script::ChatWindowLook`], written by the
//! reference's own `SetChatWindowColor`/`SetChatWindowAlpha`/`SetChatWindowSize` and read straight
//! back out of `GetChatWindowInfo`); this module is the two ends the VM cannot own — **where it
//! comes from at login and where it goes at logout**.
//! The state itself lives in the VM ([`benilla_ui::script::ChatWindowLook`] and the chat-type
//! registry, written by the reference's own `SetChatWindow*`, `Add/RemoveChatWindowMessages`,
//! `Add/RemoveChatWindowChannel`, `ChangeChatColor`, and read straight back out of
//! `GetChatWindowInfo`/`GetChatWindowMessages`/`GetChatWindowChannels`/`GetChatTypeIndex`); this
//! module is the two ends the VM cannot own — **where it comes from at login and where it goes at
//! logout**.
//!
//! ## Character-scoped, like the reference
//! ## The grammar is the reference's
//!
//! 1.12 keeps these in `WTF/Account/<ACC>/<REALM>/<CHAR>/chat-cache.txt`, whose per-window block a
//! stock client writes as
//! 1.12 keeps these in `WTF/Account/<ACC>/<REALM>/<CHAR>/chat-cache.txt`, written whole by
//! `0x499a80` and read by `0x498a60` (wow-re `system/ui/scratch/chat-cache-grammar.md`, §1 and
//! §3 — every line below is that note's):
//!
//! ```text
//! WINDOW 1 SIZE 0 COLOR 0 0 0 0 LOCKED 1 DOCKED 1 SHOWN 1
//! VERSION 2
//! ADDEDVERSION 2
//! OPTION_GUILD_RECRUITMENT_CHANNEL AUTO
//! CHANNELS ← the custom channels the client is in, re-joined at login
//! MyChannel
//! END
//! ZONECHANNELS 18874371 ← the joined zone channels, as bits 1<<(ChannelID-1)
//! COLORS ← the chat-type registry, R G B bytes
//! SAY 255 255 255
//! …
//! END
//!
//! WINDOW 1
//! NAME General ← only when a name was stored (name[0] != 0)
//! SIZE 0
//! COLOR 0 0 0 0 ← R G B A bytes, from the record's packed BGRA quad
//! LOCKED 1
//! DOCKED 1
//! SHOWN 1
//!
//! MESSAGES ← the enabled groups of the 68-entry CHATMSGGROUP table, table order
//! SYSTEM
//! …
//! END
//! CHANNELS ← this window's CUSTOM channels only — a zone channel is never a name
//! END
//! ZONECHANNELS 18874371 ← this window's zone channels, as bits, masked by the joined set
//! END
//! ```
//!
//! Two consequences worth stating. **The file is the sole source**: the loader zeroes every
//! window's flags before parsing, so a `MESSAGES` block is the set, not an addition — and a
//! window the file does not mention keeps the boot init ([`ChatWindowLook::stock`]). **Zone
//! channels are bits, not names**: the record stores the DBC Shortcut (`General`) with its id,
//! the file stores the bit, and the loader's in-window `ZONECHANNELS` arm turns bits back into
//! `(Shortcut, id)` rows. A file older than `ADDEDVERSION 2` gets the groups added since
//! back-filled (`COMBAT_FACTION_CHANGE`, `MONEY` — into window 2), exactly as the loader does.
//!
//! Ours is `benilla-config/chat/<realm>-<character>.txt`
//! ([`crate::local_state::chat_character_path`]) in that grammar, so a stock file reads here and
//! ours reads there. The reader is as lenient as the reference's — case-insensitive keys, an
//! unknown key skipped rather than failing the line, blank lines nothing — and still accepts the
//! one-line `WINDOW 1 SIZE 0 COLOR …` rows the files written before 1948 hold, so no player's
//! saved looks are lost to the grammar change.
//!
//! `LOCKED` is an `i32` in the record (`CHATWINDOW+0x8c`) but the cache writer booleanises it
//! through `setne`, so only `{0,1}` round-trip there — which is why ours is a `bool` and writes the
//! same two digits.
//! through `setne`, so only `{0,1}` round-trip there — which is why ours is a `bool`.
//!
//! `COLOR` there is **R G B A**, written from the record's packed BGRA quad and parsed straight
//! back as bytes, so the reference's own file round-trip is bit-exact (§5,
//! wow-re `system/ui/scratch/chat-window-record.md`). Ours writes the same four in the same order.
//! **The loader's `WINDOW` bound is off by one in the reference** — `0x498d1c` uses `ja` where the
//! array wants `jae`. Ours cannot: the index is bounds-checked at the seam that consumes it
//! (`set_chat_window_looks` uses `get_mut`), and an out-of-range window is simply dropped.
//!
//! **Its loader's `WINDOW` bound is off by one** — `0x498d1c` uses `ja` where the array wants
//! `jae`, so a hand-edited `WINDOW 11` writes a full record past the end at `0xb50440` (the same
//! §5 found it, incidentally). Ours cannot: the index is bounds-checked at the seam that consumes
//! it (`set_chat_window_looks` uses `get_mut`), and an out-of-range window is simply dropped.
//! ## The login events
//!
//! (the real file from the pin's install, quoted in
//! [`benilla_ui::script`]'s `chat_window` module docs). Ours is
//! `benilla-config/chat/<realm>-<character>.txt` ([`crate::local_state::chat_character_path`]) —
//! the camera pose's shape one folder over (decision 1131/1138), with the reference's own key
//! spellings and its own byte-valued `COLOR r g b a`, so the file reads as a narrower relative of
//! its ancestor rather than as an invention.
//!
//! **It is deliberately a SUBSET and says so in its header.** The reference's cache also carries
//! `DOCKED`/`SHOWN`, the per-window `MESSAGES`/`CHANNELS` registration and the whole `COLORS`
//! table; benilla has no rename, no undock and no per-type colour editor (0288 §2), so writing
//! those keys would persist state nothing can move — the honest-tree rule (1134 §4) at the
//! persistence layer. Each joins the file the day something can change it, **which is exactly what
//! `LOCKED` just did**: the tab menu's *Lock/Unlock Window* row moves it, so a file that dropped it
//! would reset the player's unlocked window at every login.
//! The loader fires `UPDATE_CHAT_WINDOWS` once and then `UPDATE_CHAT_COLOR` for **every** registry
//! entry — file or no file (§8). The first is what `FloatingChatFrame_Update` docks, hides and
//! colours the windows from; the second is how a saved colour reaches `ChatTypeInfo` and repaints
//! the lines already in the window (`ChatFrame_OnEvent`'s arm). So does ours, once per character
//! per VM.
//!
//! ## Why per character, and not a CVar
//!
@@ -66,21 +97,24 @@ use std::path::PathBuf;
use bevy::prelude::*;
use benilla_ui::script::{ChatWindowLook, UiScript};
use benilla_ui::script::{ChatTypeColor, ChatWindowLook, UiScript, MESSAGE_GROUPS};
use crate::net::{ClientCommand, NetCommands};
use crate::ui_script::VmMemo;
/// How long a dirty look sits before the save fires. [`crate::cvars`]'s own constant and its own
/// reasoning — an opacity drag is exactly the gesture it was sized for.
const SAVE_QUIET: std::time::Duration = std::time::Duration::from_secs(1);
/// The file's header — where these values come from and where the law lives.
/// The file's header — where these values come from and where the law lives. Comment lines; the
/// reference's reader has none, ours skips them.
const HEADER: &str = "\
# benilla chat window state (decision 1589) — the tint, alpha, font size and lock a chat tab's
# right-click menu sets. A SUBSET of the reference's chat-cache.txt: benilla has no rename,
# undock or per-type colour editor yet, so those keys are absent rather than invented.
# COLOR is four bytes (0-255), the engine's own storage; SIZE is 0 for \"the font's own height\";
# LOCKED is 1 or 0, and 1 is the stock row (a fresh window cannot be dragged until you unlock it).
# benilla chat cache (decisions 1589, 1948) — the reference's chat-cache.txt grammar (wow-re
# chat-cache-grammar.md): the custom channels to re-join, the joined zone channels as bits, the
# per-type COLORS table, then one WINDOW block per chat frame — NAME (when one was set), SIZE,
# COLOR r g b a as bytes, LOCKED, DOCKED, SHOWN, the MESSAGES … END list of the groups the window
# shows, its CHANNELS … END list of custom channels, and its zone channels as ZONECHANNELS bits.
# Written whole; the tab menu, /join and ChangeChatColor are what move it.
";
/// Which character's file we are on, where it lives, and whether it is owed a write.
@@ -100,36 +134,109 @@ pub(super) struct ChatWindowFile {
last_change: Option<std::time::Instant>,
}
/// Render the table exactly as the loader reads it: one `WINDOW` line per window, in window order.
fn render(looks: &[ChatWindowLook]) -> String {
/// What a file parses to: the windows it names (0-based index, the record), the `COLORS` rows
/// it carries in file order, and the custom channels its header lists for re-joining.
#[derive(Debug, Default, PartialEq, Eq)]
pub(super) struct Parsed {
pub(super) looks: Vec<(usize, ChatWindowLook)>,
pub(super) colors: Vec<(String, [u8; 3])>,
pub(super) joined: Vec<String>,
}
/// The bit a zone channel's id occupies in a `ZONECHANNELS` word.
fn zone_bit(id: u32) -> u32 {
if id == 0 || id > 32 {
0
} else {
1 << (id - 1)
}
}
/// Render the file exactly as the writer does (§1), window order. `joined` is the client's
/// current channel roster — the custom names go to the header's `CHANNELS`, the zone ids to the
/// two `ZONECHANNELS` words.
fn render(looks: &[ChatWindowLook], colors: &[ChatTypeColor], joined: &[(String, u32)]) -> String {
let zone_mask = joined.iter().fold(0, |m, (_, id)| m | zone_bit(*id));
let mut out = String::from(HEADER);
for (i, l) in looks.iter().enumerate() {
out.push_str(
"\nVERSION 2\n\nADDEDVERSION 2\n\nOPTION_GUILD_RECRUITMENT_CHANNEL AUTO\n\nCHANNELS\n",
);
for (name, id) in joined {
if *id == 0 {
out.push_str(name);
out.push('\n');
}
}
out.push_str(&format!("END\n\nZONECHANNELS {zone_mask}\n\nCOLORS\n"));
for c in colors {
out.push_str(&format!(
"WINDOW {} SIZE {} COLOR {} {} {} {} LOCKED {} DOCKED {}\n",
i + 1,
"{} {} {} {}\n",
c.name, c.rgb[0], c.rgb[1], c.rgb[2]
));
}
out.push_str("END\n\n");
for (i, l) in looks.iter().enumerate() {
out.push_str(&format!("WINDOW {}\n", i + 1));
if !l.name.is_empty() {
out.push_str(&format!("NAME {}\n", l.name));
}
out.push_str(&format!(
"SIZE {}\nCOLOR {} {} {} {}\nLOCKED {}\nDOCKED {}\nSHOWN {}\n\nMESSAGES\n",
l.font_size,
l.r,
l.g,
l.b,
l.a,
i32::from(l.locked),
// The reference's own spelling: the dock POSITION, and `0` for a window that is not
// docked (the shipped rows for windows 3..10 are `DOCKED 0`).
l.docked.unwrap_or(0)
l.docked.unwrap_or(0),
i32::from(l.shown),
));
for m in &l.messages {
out.push_str(m);
out.push('\n');
}
out.push_str("END\n\nCHANNELS\n");
let mut window_mask = 0;
for (name, id) in &l.channels {
if *id == 0 {
out.push_str(name);
out.push('\n');
} else {
window_mask |= zone_bit(*id);
}
}
out.push_str(&format!(
"END\n\nZONECHANNELS {}\n\nEND\n\n",
window_mask & zone_mask
));
}
out
}
/// Parse `WINDOW <n> SIZE <s> COLOR <r> <g> <b> <a>` lines into `(index, look)` pairs.
///
/// Permissive in the three ways the reference's own cache reader is, and for the same reason — a
/// hand edit and a later build's extra key must both cost at most the line they are on:
/// keys match case-insensitively, an unknown key is skipped rather than failing the parse, and a
/// `WINDOW` line missing a field keeps whatever the field's default is. A window number this build
/// has no frame for is dropped by the seam that consumes this, not here.
fn parse(text: &str) -> Vec<(usize, ChatWindowLook)> {
let mut out = Vec::new();
#[derive(Clone, Copy, PartialEq, Eq)]
enum Block {
/// Top level, or inside a `WINDOW` whose keys arrive one per line.
Top,
/// The header's `CHANNELS … END` — the custom channels to re-join.
Joined,
Colors,
Messages,
Channels,
}
/// Parse a file. `rows` is `ChatChannels.dbc` as `(id, Shortcut)`, which the in-window
/// `ZONECHANNELS` arm needs to turn bits back into `(Shortcut, id)` channel rows.
fn parse(text: &str, rows: &[(u32, String)]) -> Parsed {
let mut out = Parsed::default();
let mut current: Option<(usize, ChatWindowLook)> = None;
let mut block = Block::Top;
let mut added_version: u8 = 0;
let byte = |s: Option<&str>| -> u8 { s.and_then(|v| v.parse::<u8>().ok()).unwrap_or(0) };
let flush = |current: &mut Option<(usize, ChatWindowLook)>, out: &mut Parsed| {
if let Some(w) = current.take() {
out.looks.push(w);
}
};
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
@@ -137,66 +244,191 @@ fn parse(text: &str) -> Vec<(usize, ChatWindowLook)> {
}
let mut it = line.split_whitespace();
let Some(head) = it.next() else { continue };
if !head.eq_ignore_ascii_case("WINDOW") {
let is_end = head.eq_ignore_ascii_case("END");
match block {
Block::Colors => {
if is_end {
block = Block::Top;
} else {
let rgb = [byte(it.next()), byte(it.next()), byte(it.next())];
out.colors.push((head.to_ascii_uppercase(), rgb));
}
continue;
}
Block::Joined => {
if is_end {
block = Block::Top;
} else {
out.joined.push(line.to_string());
}
continue;
}
Block::Messages => {
if is_end {
block = Block::Top;
} else if let Some((_, look)) = current.as_mut() {
look.messages.push(head.to_ascii_uppercase());
}
continue;
}
Block::Channels => {
if is_end {
block = Block::Top;
} else if let Some((_, look)) = current.as_mut() {
// The loader takes the first word, id 0.
look.channels.push((head.to_string(), 0));
}
continue;
}
Block::Top => {}
}
if head.eq_ignore_ascii_case("COLORS") {
block = Block::Colors;
continue;
}
let Some(index) = it.next().and_then(|n| n.parse::<usize>().ok()) else {
warn!("chat looks: WINDOW line with no number ignored: {line}");
if head.eq_ignore_ascii_case("WINDOW") {
flush(&mut current, &mut out);
let Some(index) = it.next().and_then(|n| n.parse::<usize>().ok()) else {
warn!("chat cache: WINDOW line with no number ignored: {line}");
continue;
};
if index == 0 {
continue;
}
let mut look = ChatWindowLook::stock(index - 1);
// The pre-1948 one-line row: the keys follow on the same line.
while let Some(key) = it.next() {
apply_key(&mut look, key, &mut it, rows);
}
current = Some((index - 1, look));
continue;
}
let Some((_, look)) = current.as_mut() else {
if head.eq_ignore_ascii_case("CHANNELS") {
block = Block::Joined;
} else if head.eq_ignore_ascii_case("ADDEDVERSION") {
added_version = it.next().and_then(|v| v.parse().ok()).unwrap_or(0);
}
// VERSION, OPTION_*, and the header's ZONECHANNELS (the joined mask — ours is
// whatever the zone walk joins) are the reference's and not read here.
continue;
};
if index == 0 {
continue;
if is_end {
flush(&mut current, &mut out);
} else if head.eq_ignore_ascii_case("MESSAGES") {
look.messages.clear();
block = Block::Messages;
} else if head.eq_ignore_ascii_case("CHANNELS") {
look.channels.retain(|(_, id)| *id != 0);
block = Block::Channels;
} else if head.eq_ignore_ascii_case("NAME") {
look.name = line[4..].trim().to_string();
} else {
apply_key(look, head, &mut it, rows);
}
// Seeded from the STOCK row for this window, not one flat default: `DOCKED` is the one
// field whose shipped value differs per window (1 and 2 are the dock), so a file written
// by a build that knew nothing about the key must leave the dock where it shipped rather
// than silently undocking both windows — the same leniency `LOCKED` gets below, applied
// where a flat default could not express it.
let mut look = ChatWindowLook::stock(index - 1);
let byte = |s: Option<&str>| -> u8 { s.and_then(|v| v.parse::<u8>().ok()).unwrap_or(0) };
while let Some(key) = it.next() {
if key.eq_ignore_ascii_case("SIZE") {
// `i32`, like the record's field (`+0x84`) — and never negative: the setter
// drops `<= 0`, so a hand-edited one reads as "no size stored".
look.font_size = it
.next()
.and_then(|v| v.parse::<i32>().ok())
.unwrap_or(0)
.max(0);
} else if key.eq_ignore_ascii_case("COLOR") {
look.r = byte(it.next());
look.g = byte(it.next());
look.b = byte(it.next());
look.a = byte(it.next());
} else if key.eq_ignore_ascii_case("LOCKED") {
// Anything that is not the literal `0` is locked — the reference's own `setne`
// read, and the lenient half of it: a file from a build that wrote nothing here
// keeps the stock `LOCKED 1` default rather than silently unlocking the window.
look.locked = it.next().is_none_or(|v| v.trim() != "0");
} else if key.eq_ignore_ascii_case("DOCKED") {
// The dock POSITION, 1-based; `0` (and anything unparseable) is "not docked".
look.docked = it
.next()
.and_then(|v| v.trim().parse::<u8>().ok())
.filter(|p| *p > 0);
}
flush(&mut current, &mut out);
// The loader's EOF back-fill (§3): a file older than the groups added since gets them, into
// window 1 for the first ten groups and window 2 otherwise — the two `addedVersion` rows are
// both window 2's.
if added_version < 2 {
for (i, (name, on, ver)) in MESSAGE_GROUPS.iter().enumerate() {
if *on && *ver > added_version {
let target = usize::from(i >= 10);
if let Some((_, look)) = out.looks.iter_mut().find(|(w, _)| *w == target) {
if !look.messages.iter().any(|m| m == name) {
look.messages.push((*name).to_string());
}
}
}
// Anything else is a key from a build that knows more than this one — skip it and the
// value it would have consumed cannot be told from the next key, so skip only the key.
}
out.push((index - 1, look));
}
for (_, look) in &mut out.looks {
look.normalize_messages();
}
out
}
/// Seed the VM's look table from disk, once per character per VM. Absent file = the shipped stock
/// row (`COLOR 0 0 0 0`, `SIZE 0`), which is the normal first run.
/// One `KEY value…` of a window block, whichever line it arrived on.
fn apply_key<'a>(
look: &mut ChatWindowLook,
key: &str,
it: &mut impl Iterator<Item = &'a str>,
rows: &[(u32, String)],
) {
let byte = |s: Option<&str>| -> u8 { s.and_then(|v| v.parse::<u8>().ok()).unwrap_or(0) };
let flag = |s: Option<&str>| -> bool { s.is_none_or(|v| v.trim() != "0") };
if key.eq_ignore_ascii_case("SIZE") {
look.font_size = it
.next()
.and_then(|v| v.parse::<i32>().ok())
.unwrap_or(0)
.max(0);
} else if key.eq_ignore_ascii_case("COLOR") {
look.r = byte(it.next());
look.g = byte(it.next());
look.b = byte(it.next());
look.a = byte(it.next());
} else if key.eq_ignore_ascii_case("LOCKED") {
look.locked = flag(it.next());
} else if key.eq_ignore_ascii_case("DOCKED") {
look.docked = it
.next()
.and_then(|v| v.trim().parse::<u8>().ok())
.filter(|p| *p > 0);
} else if key.eq_ignore_ascii_case("SHOWN") {
look.shown = flag(it.next());
} else if key.eq_ignore_ascii_case("ZONECHANNELS") {
// The in-window arm: every DBC row whose bit is set joins the window as (Shortcut, id).
let mask = it
.next()
.and_then(|v| v.trim().parse::<u32>().ok())
.unwrap_or(0);
for (id, shortcut) in rows {
if mask & zone_bit(*id) != 0
&& !look
.channels
.iter()
.any(|(c, _)| c.eq_ignore_ascii_case(shortcut))
{
look.channels.push((shortcut.clone(), *id));
}
}
}
}
/// The `(id, Shortcut)` rows the parser needs, off the loaded `ChatChannels.dbc`.
fn shortcut_rows(channels: &super::edit::ChannelState) -> Vec<(u32, String)> {
channels
.channels
.rows()
.iter()
.map(|r| (r.id, r.shortcut.clone()))
.collect()
}
/// The client's channel roster as `(name, zone id)` — what the writer's header and masks are made
/// of.
fn roster(channels: &super::edit::ChannelState) -> Vec<(String, u32)> {
channels
.joined
.iter()
.flatten()
.map(|name| (name.clone(), channels.channels.zone_channel_id(name)))
.collect()
}
/// Restore the file into a fresh VM — once per character per VM — and fire the loader's two
/// events, file or no file.
fn load_chat_looks(
script: Option<NonSendMut<UiScript>>,
roster: Res<crate::char_select::Roster>,
roster_res: Res<crate::char_select::Roster>,
channels: Res<super::edit::ChannelState>,
commands: Res<NetCommands>,
mut file: ResMut<ChatWindowFile>,
) {
let Some(mut script) = script else { return };
let Some(id) = crate::ui_macro::identity(&roster) else {
let Some(id) = crate::ui_macro::identity(&roster_res) else {
return;
};
if file.identity.get(&script).as_ref() == Some(&id) {
@@ -204,52 +436,98 @@ fn load_chat_looks(
}
file.path = crate::local_state::chat_character_path(&id.0, &id.1);
*file.identity.get(&script) = Some(id);
// A fresh VM starts at the stock row, so a character with no file needs nothing pushed — and
// the drain below must not read a change this load never made.
*file.dirty.get(&script) = false;
file.last_change = None;
let Some(path) = file.path.clone() else {
return; // hermetic capture, or no state folder — session-only, the stock row stands
};
let text = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
Err(e) => {
warn!("chat looks: cannot read {}: {e}", path.display());
return;
}
};
let looks = parse(&text);
if looks.is_empty() {
return;
let text = file
.path
.as_ref()
.and_then(|path| match std::fs::read_to_string(path) {
Ok(t) => Some(t),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => {
warn!("chat cache: cannot read {}: {e}", path.display());
None
}
});
let had_file = text.is_some();
let mut parsed = text
.map(|t| parse(&t, &shortcut_rows(&channels)))
.unwrap_or_default();
if !had_file {
// The loader's no-file path (§3, `0x4997ad`): window 1's channel slots get every
// `ChatChannels.dbc` row the client joins by itself (`flags & 1`) as `(Shortcut, id)` —
// the rows `ChatFrame_RegisterForChannels` will match zone speech against by id. The
// rest of the record is the boot init the VM already holds.
let mut general = ChatWindowLook::stock(0);
general.channels = channels
.channels
.auto_join_rows()
.map(|r| (r.shortcut.clone(), r.id))
.collect();
parsed.looks.push((0, general));
}
info!(
"chat looks: {} windows restored from {}",
looks.len(),
path.display()
);
script.set_chat_window_looks(looks);
// The reference's own event for "a chat window's stored settings moved" — `FloatingChatFrame`
// is registered for it and re-reads `GetChatWindowInfo` on every window (its
// `FloatingChatFrame_OnEvent`), which is exactly what has to happen after this push.
if !parsed.looks.is_empty() || !parsed.colors.is_empty() {
info!(
"chat cache: {} windows, {} colour rows, {} custom channels restored",
parsed.looks.len(),
parsed.colors.len(),
parsed.joined.len()
);
}
script.set_chat_colors(parsed.colors);
script.set_chat_window_looks(parsed.looks);
// §8: UPDATE_CHAT_WINDOWS once, then UPDATE_CHAT_COLOR for every registry entry, on the file
// path and the no-file path alike.
script.fire_event("UPDATE_CHAT_WINDOWS", vec![]);
let renorm = |b: u8| f64::from(b as f32 * (1.0f32 / 255.0f32));
for entry in script.chat_colors() {
script.fire_event(
"UPDATE_CHAT_COLOR",
vec![
benilla_ui::script::ScriptValue::Str(entry.name),
benilla_ui::script::ScriptValue::Number(renorm(entry.rgb[0])),
benilla_ui::script::ScriptValue::Number(renorm(entry.rgb[1])),
benilla_ui::script::ScriptValue::Number(renorm(entry.rgb[2])),
],
);
}
// The header's CHANNELS: the custom channels the character was in, re-joined the way the
// reference re-joins them at login (the zone channels are the zone walk's, not the file's).
for name in parsed.joined {
let _ = commands.0.send(ClientCommand::JoinChannel {
name,
password: String::new(),
});
}
}
/// Drain the VM's writes into the dirty flag. Cheap on a steady frame — the drain is a `take` of
/// an empty set.
/// A Lua-side write landed — arm the debounce.
fn watch_chat_looks(script: Option<NonSendMut<UiScript>>, mut file: ResMut<ChatWindowFile>) {
let Some(mut script) = script else { return };
if script.take_chat_window_changes().is_empty() {
let moved = !script.take_chat_window_changes().is_empty();
let coloured = script.take_chat_color_changes();
if !(moved || coloured) {
return;
}
*file.dirty.get(&script) = true;
file.last_change = Some(std::time::Instant::now());
}
/// Dirty + one quiet second (or the app exiting) → rewrite the file atomically.
fn write(script: &UiScript, channels: &super::edit::ChannelState, path: &std::path::Path) {
let body = render(
&script.chat_window_looks(),
&script.chat_colors(),
&roster(channels),
);
if let Err(e) = crate::local_state::write_atomic(path, &body) {
warn!("chat cache: cannot write {}: {e}", path.display());
}
}
/// The debounced save, and the `AppExit` flush.
fn save_chat_looks(
script: Option<NonSendMut<UiScript>>,
channels: Res<super::edit::ChannelState>,
mut file: ResMut<ChatWindowFile>,
mut exits: MessageReader<AppExit>,
) {
@@ -262,30 +540,25 @@ fn save_chat_looks(
return;
}
let Some(path) = file.path.clone() else {
// hermetic/session-only: nothing to write, stop retrying
*file.dirty.get(&script) = false;
return;
};
let body = render(&script.chat_window_looks());
if let Err(e) = crate::local_state::write_atomic(&path, &body) {
// …and don't retry every frame into the same error.
warn!("chat looks: cannot write {}: {e}", path.display());
}
write(&script, &channels, &path);
*file.dirty.get(&script) = false;
}
/// `OnExit(InWorld)` — a `/logout` back to the glue, or a disconnect. The same edge the camera
/// pose and the saved variables flush on, and it must not wait for the quiet second.
fn save_on_session_end(script: Option<NonSendMut<UiScript>>, mut file: ResMut<ChatWindowFile>) {
/// The session-end flush — `OnExit(InWorld)`.
fn save_on_session_end(
script: Option<NonSendMut<UiScript>>,
channels: Res<super::edit::ChannelState>,
mut file: ResMut<ChatWindowFile>,
) {
let Some(script) = script else { return };
if !*file.dirty.get(&script) {
return;
}
if let Some(path) = file.path.clone() {
let body = render(&script.chat_window_looks());
if let Err(e) = crate::local_state::write_atomic(&path, &body) {
warn!("chat looks: cannot write {}: {e}", path.display());
}
write(&script, &channels, &path);
}
*file.dirty.get(&script) = false;
}
@@ -298,6 +571,10 @@ pub(super) fn plugin(app: &mut App) {
.chain()
.run_if(in_state(crate::char_select::ClientState::InWorld)),
)
.add_systems(
Update,
save_chat_looks.run_if(in_state(crate::char_select::ClientState::InWorld)),
)
.add_systems(
OnExit(crate::char_select::ClientState::InWorld),
save_on_session_end,
@@ -312,9 +589,10 @@ pub(super) fn plugin(app: &mut App) {
mod tests {
use super::*;
/// A look for WINDOW `n` (1-based). The window number is a parameter because `DOCKED` is the
/// one field whose stock value differs per window — the dock is windows 1 and 2 — so an
/// expectation that does not say which window it is cannot be right for all of them.
/// A record for WINDOW `n` (1-based) — the window's own boot-init row with the look fields
/// set. The window number is a parameter because `DOCKED`, `SHOWN` and `MESSAGES` differ per
/// window at init, so an expectation that does not say which window it is cannot be right
/// for all of them.
fn look(n: usize, r: u8, g: u8, b: u8, a: u8, font_size: i32) -> ChatWindowLook {
ChatWindowLook {
r,
@@ -323,55 +601,165 @@ mod tests {
a,
font_size,
locked: true,
docked: ChatWindowLook::stock(n - 1).docked,
..ChatWindowLook::stock(n - 1)
}
}
/// The file round-trips — what `render` writes is exactly what `parse` reads back, indices
/// included.
fn rows() -> Vec<(u32, String)> {
vec![
(1, "General".to_string()),
(2, "Trade".to_string()),
(24, "LookingForGroup".to_string()),
]
}
fn colors(rows: &[(&str, [u8; 3])]) -> Vec<ChatTypeColor> {
rows.iter()
.map(|(n, rgb)| ChatTypeColor {
name: (*n).to_string(),
rgb: *rgb,
})
.collect()
}
/// The file round-trips — every field of the record, the colour rows, the header's custom
/// channels, the zone bits back into `(Shortcut, id)` rows — indices included.
#[test]
fn the_file_round_trips() {
let mut named = look(3, 9, 8, 7, 6, 12);
named.name = "Loot & Trade".into();
named.shown = true;
named.messages = vec!["LOOT".into(), "MONEY".into()];
named.channels = vec![("MyChan".into(), 0), ("Trade".into(), 2)];
let looks = vec![
look(1, 0, 0, 0, 64, 14),
look(2, 255, 128, 0, 255, 0),
ChatWindowLook::default(),
named,
];
let parsed = parse(&render(&looks));
let colors = colors(&[("SAY", [1, 2, 3]), ("CHANNEL7", [4, 5, 6])]);
let joined = vec![
("General - Elwynn Forest".to_string(), 1),
("Trade - City".to_string(), 2),
("MyChan".to_string(), 0),
];
let text = render(&looks, &colors, &joined);
assert!(
text.contains("\nCHANNELS\nMyChan\nEND\n\nZONECHANNELS 3\n"),
"{text}"
);
let parsed = parse(&text, &rows());
let mut expect = looks.clone();
// The zone channel comes back by its Shortcut row, after the custom names.
expect[2].channels = vec![("MyChan".into(), 0), ("Trade".into(), 2)];
assert_eq!(
parsed,
vec![(0, looks[0]), (1, looks[1]), (2, looks[2])],
parsed.looks,
vec![
(0, expect[0].clone()),
(1, expect[1].clone()),
(2, expect[2].clone())
],
"0-based indices, values intact"
);
assert_eq!(
parsed.colors,
vec![
("SAY".to_string(), [1, 2, 3]),
("CHANNEL7".to_string(), [4, 5, 6])
]
);
assert_eq!(parsed.joined, vec!["MyChan".to_string()]);
}
/// A window's zone bits are masked by the joined set — a zone channel the client has left
/// is not written for the window either, the way the writer ANDs the two words.
#[test]
fn a_windows_zone_bits_are_masked_by_the_joined_set() {
let mut w = look(1, 0, 0, 0, 0, 0);
w.channels = vec![("General".into(), 1), ("Trade".into(), 2)];
let text = render(&[w], &[], &[("General - Elwynn Forest".to_string(), 1)]);
let windows: Vec<&str> = text.split("WINDOW 1").collect();
assert!(windows[1].contains("ZONECHANNELS 1\n"), "{text}");
}
/// The header is a comment block and survives the round trip as one — a reader that choked on
/// its own header would lose the player's settings on the second launch.
#[test]
fn the_header_is_skipped_not_parsed() {
assert!(render(&[ChatWindowLook::default()]).starts_with('#'));
assert_eq!(parse(HEADER), vec![]);
assert!(render(&[ChatWindowLook::default()], &[], &[]).starts_with('#'));
assert_eq!(parse(HEADER, &rows()), Parsed::default());
}
/// Permissive in the three ways the reference's own reader is: case-insensitive keys, an
/// unknown key skipped rather than failing the line, and a missing field left at its default.
/// The rows the files written before 1948 hold — keys on the `WINDOW` line, no
/// `ADDEDVERSION` — still read, case-insensitively, an unknown key skipped and a missing
/// field left at its init value; and the loader's back-fill lands `COMBAT_FACTION_CHANGE` and
/// `MONEY` in window 2 as it would for any pre-`ADDEDVERSION 2` file.
#[test]
fn the_parse_is_permissive_the_way_the_reference_cache_is() {
fn the_pre_1948_one_line_rows_still_parse() {
let got = parse(
"window 1 size 16 color 10 20 30 40\n\
WINDOW 2 SHOWN 1 COLOR 1 2 3 4 DOCKED 2\n\
WINDOW 2 SHOWN 0 COLOR 1 2 3 4 DOCKED 2 BOGUS 9\n\
WINDOW 3 SIZE 12\n",
&rows(),
);
let mut w2 = look(2, 1, 2, 3, 4, 0);
w2.shown = false;
w2.docked = Some(2);
assert_eq!(
got,
got.looks,
vec![
(0, look(1, 10, 20, 30, 40, 16)),
(1, look(2, 1, 2, 3, 4, 0)),
(1, w2),
(2, look(3, 0, 0, 0, 0, 12)),
]
);
assert!(got.looks[1].1.messages.iter().any(|m| m == "MONEY"));
}
/// `LOCKED` round-trips, and a file that never mentions it keeps the stock **locked** row —
/// A file in the reference's own layout — the one a stock client writes — parses: the global
/// blocks are read for what they are, the colours land, both dock windows carry their sets,
/// and the window's `ZONECHANNELS` word comes back as `(Shortcut, id)` rows.
#[test]
fn a_stock_reference_file_parses() {
let text = "VERSION 2\n\nADDEDVERSION 2\n\nOPTION_GUILD_RECRUITMENT_CHANNEL AUTO\n\n\
CHANNELS\nMyGuildChat\nEND\n\nZONECHANNELS 8388611\n\n\
COLORS\nSAY 255 255 255\nSYSTEM 200 200 0\nEND\n\n\
WINDOW 1\nSIZE 0\nCOLOR 0 0 0 0\nLOCKED 1\nDOCKED 1\nSHOWN 1\n\n\
MESSAGES\nSYSTEM\nSAY\nEND\n\nCHANNELS\nMyGuildChat\nEND\n\n\
ZONECHANNELS 8388611\n\nEND\n\n\
WINDOW 2\nNAME Combat Log\nSIZE 0\nCOLOR 0 0 0 0\nLOCKED 1\nDOCKED 2\nSHOWN 0\n\n\
MESSAGES\nCOMBAT_XP_GAIN\nEND\n\nCHANNELS\nEND\n\nZONECHANNELS 0\n\nEND\n";
let got = parse(text, &rows());
assert_eq!(
got.colors,
vec![
("SAY".to_string(), [255, 255, 255]),
("SYSTEM".to_string(), [200, 200, 0])
]
);
assert_eq!(got.joined, vec!["MyGuildChat".to_string()]);
assert_eq!(got.looks.len(), 2);
let (i, w1) = &got.looks[0];
assert_eq!(*i, 0);
assert!(w1.shown && w1.name.is_empty());
assert_eq!(w1.messages, vec!["SYSTEM".to_string(), "SAY".to_string()]);
assert_eq!(
w1.channels,
vec![
("MyGuildChat".to_string(), 0),
("General".to_string(), 1),
("Trade".to_string(), 2),
("LookingForGroup".to_string(), 24),
],
"bits 0, 1 and 23 of 8388611 — the shortcut rows, in DBC order"
);
let (_, w2) = &got.looks[1];
assert!(!w2.shown);
assert_eq!(w2.name, "Combat Log");
assert_eq!(w2.docked, Some(2));
assert_eq!(w2.messages, vec!["COMBAT_XP_GAIN".to_string()]);
}
/// `LOCKED` round-trips, and a file that never mentions it keeps the init's **locked** row —
/// the lenient default that matters, because reading it as "unlocked" would hand every
/// pre-existing player's chat window to a stray drag on their next login.
#[test]
@@ -380,53 +768,59 @@ mod tests {
locked: false,
..look(1, 0, 0, 0, 64, 14)
};
assert!(render(&[unlocked]).contains("LOCKED 0"));
assert_eq!(parse(&render(&[unlocked])), vec![(0, unlocked)]);
let text = render(std::slice::from_ref(&unlocked), &[], &[]);
assert!(text.contains("LOCKED 0"));
assert_eq!(parse(&text, &rows()).looks, vec![(0, unlocked.clone())]);
assert_eq!(
parse("WINDOW 1 SIZE 14 COLOR 0 0 0 64\n"),
parse("WINDOW 1\nSIZE 14\nCOLOR 0 0 0 64\nEND\n", &rows()).looks,
vec![(0, look(1, 0, 0, 0, 64, 14))],
"no LOCKED key = the stock LOCKED 1"
"no LOCKED key = the init's LOCKED 1"
);
}
/// **`DOCKED` round-trips, and an absent key keeps the window's own stock position** — the
/// leniency `LOCKED` gets, in the one field where a single flat default could not express it.
/// A file written by a build that knew nothing about the key must leave the dock where it
/// shipped; seeding every window from one default would have undocked ChatFrame1 and
/// ChatFrame2 on the next login of every existing player (1714).
/// **`DOCKED` round-trips, and an absent key keeps the window's own init position** — the
/// leniency `LOCKED` gets, in the one field where a single flat default could not express it
/// (1714). `SHOWN` and the message sets get the same treatment for the same reason.
#[test]
fn dock_positions_round_trip_and_an_absent_key_keeps_the_stock() {
let undocked = ChatWindowLook {
docked: None,
..look(1, 0, 0, 0, 64, 14)
};
assert!(render(&[undocked]).contains("DOCKED 0"));
assert_eq!(parse(&render(&[undocked])), vec![(0, undocked)]);
// No DOCKED key at all: window 1 keeps position 1, window 2 keeps 2, window 3 stays out.
let got = parse(
"WINDOW 1 SIZE 0
WINDOW 2 SIZE 0
WINDOW 3 SIZE 0
",
);
assert_eq!(
got.iter().map(|(_, l)| l.docked).collect::<Vec<_>>(),
vec![Some(1), Some(2), None]
);
// And a moved window round-trips at its new position.
fn dock_positions_round_trip_and_an_absent_key_keeps_the_init() {
let moved = ChatWindowLook {
docked: Some(3),
..look(1, 0, 0, 0, 0, 0)
};
assert_eq!(parse(&render(&[moved]))[0].1.docked, Some(3));
let text = render(std::slice::from_ref(&moved), &[], &[]);
assert!(text.contains("DOCKED 3"));
assert_eq!(parse(&text, &rows()).looks, vec![(0, moved.clone())]);
// No DOCKED/SHOWN/MESSAGES at all: the boot init stands — window 1 shown and undocked
// with the ten General groups, window 2 shown at dock index 1 with the 34, window 3 out.
let got = parse(
"WINDOW 1 SIZE 0\nWINDOW 2 SIZE 0\nWINDOW 3 SIZE 0\n",
&rows(),
);
assert_eq!(
got.looks
.iter()
.map(|(_, l)| (l.docked, l.shown, l.messages.len()))
.collect::<Vec<_>>(),
vec![(None, true, 10), (Some(1), true, 34), (None, false, 0)]
);
}
/// Junk costs the line it is on and nothing more.
#[test]
fn junk_costs_only_its_own_line() {
let got = parse("WINDOW\nnot a window line\nWINDOW 0 SIZE 1\nWINDOW 2 SIZE 18\n");
assert_eq!(got, vec![(1, look(2, 0, 0, 0, 0, 18))]);
let got = parse(
"WINDOW\nnot a window line\nWINDOW 0 SIZE 1\nWINDOW 2 SIZE 18\n",
&rows(),
);
assert_eq!(got.looks, vec![(1, look(2, 0, 0, 0, 0, 18))]);
}
/// A `MESSAGES` block replaces the init set rather than adding to it — an empty block is a
/// window that shows nothing, which is what the writer means by it (the loader zeroes every
/// flag before it reads).
#[test]
fn an_empty_messages_block_is_an_empty_set() {
let got = parse("ADDEDVERSION 2\nWINDOW 1\nMESSAGES\nEND\nEND\n", &rows());
assert!(got.looks[0].1.messages.is_empty());
}
}
+28 -323
View File
@@ -445,10 +445,17 @@ fn chat_vm() -> benilla_ui::script::UiScript {
// The UIMenu kit is the reference's own file since 1751 window 21, so this reads both
// stores through the one loader that speaks them.
"Interface\\FrameXML\\UIMenu.xml",
"ChatFrame.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\ChatFrame.xml",
"Interface\\FrameXML\\UIPanelTemplates.lua",
"Interface\\FrameXML\\UIPanelTemplates.xml",
"UiPanels.xml",
"Interface\\FrameXML\\FloatingChatFrame.xml",
] {
crate::ui_script::load_ui_for_test(&s, file);
}
crate::ui_script::fire_chat_login(&mut s);
s.set_screen_size(1600.0, 900.0);
s.resolve();
s
@@ -598,6 +605,11 @@ fn a_channel_notice_fires_its_token_and_the_reference_reads_arg7_and_arg10_bare(
"#,
)
.unwrap();
// The reference prints a channel-family line only for a channel the WINDOW carries
// (`ChatFrame_OnEvent` l.1374-1391 walks `this.channelList`; a miss returns) — the list
// `/join`'s handler fills through `ChatFrame_AddChannel`.
s.run("ChatFrame_AddChannel(ChatFrame1, 'General - Elwynn Forest')")
.unwrap();
let mut e = ChatEvent::text_only(K::ChannelNotice, String::new());
e.notice = "2".into(); // YOU_JOINED
@@ -734,6 +746,10 @@ fn a_channel_notice_renders_in_the_channels_color_not_the_notice_row() {
let mut channels = super::edit::ChannelState::default();
channels.claim_slot("General - Elwynn Forest");
// The window has to carry the channel for the reference's handler to print the line at all
// (`ChatFrame_OnEvent` l.1374-1391) — `/join`'s own bookkeeping, done here by hand.
s.run("ChatFrame_AddChannel(ChatFrame1, 'General - Elwynn Forest')")
.unwrap();
let mut e = ChatEvent::text_only(K::ChannelNotice, String::new());
e.channel = "General - Elwynn Forest".into();
e.notice = "2".into(); // YOU_JOINED
@@ -760,46 +776,6 @@ fn a_channel_notice_renders_in_the_channels_color_not_the_notice_row() {
);
}
/// The two rows the reference's own guard exempts from that override, and the family it covers.
///
/// The condition is `strsub(type,1,7) == "CHANNEL" and type ~= "CHANNEL_LIST" and (arg1 ~= "INVITE"
/// or type ~= "CHANNEL_NOTICE_USER")` — so CHANNEL_LIST keeps C08080 and an INVITE notice keeps the
/// CHANNEL_NOTICE_USER grey, while everything else in the family takes the channel's color even
/// though their own rows differ (CHANNEL_JOIN/LEAVE are C08080, the notices C0C0C0).
#[test]
fn the_channel_color_override_covers_the_family_but_not_its_two_exemptions() {
let _data = benilla_formats::wow_data_or_skip!();
use super::event::resolved_color;
let chan = [255, 192, 192];
let mut joined = ev(K::ChannelJoin, "", "Ann");
joined.channel_number = 2;
assert_eq!(resolved_color(&joined, K::ChannelJoin), chan);
assert_eq!(resolved_color(&joined, K::ChannelLeave), chan);
assert_eq!(resolved_color(&joined, K::Channel), chan);
let mut notice = ChatEvent::text_only(K::ChannelNotice, String::new());
notice.notice = "3".into(); // YOU_LEFT
notice.channel_number = 2;
assert_eq!(resolved_color(&notice, K::ChannelNotice), chan);
assert_ne!(resolved_color(&notice, K::ChannelNotice), [192, 192, 192]);
// CHANNEL_LIST: exempt, keeps its own C08080.
let list = ChatEvent::text_only(K::ChannelList, "Ann, Bob".into());
assert_eq!(resolved_color(&list, K::ChannelList), [192, 128, 128]);
// INVITE (0x18) on CHANNEL_NOTICE_USER: exempt, keeps the notice grey — and the exemption is
// arg1's, so the same kind carrying any other token does take the channel color.
let mut invite = ChatEvent::text_only(K::ChannelNoticeUser, String::new());
invite.notice = "24".into();
assert_eq!(
resolved_color(&invite, K::ChannelNoticeUser),
[192, 192, 192]
);
invite.notice = "23".into(); // PLAYER_ALREADY_MEMBER
assert_eq!(resolved_color(&invite, K::ChannelNoticeUser), chan);
}
/// **The leave line still knows its number, because the record dies after the line** (1275).
///
/// [`super::feed::deliver`] is the ordering under test: we used to drop the channel from the joined
@@ -869,7 +845,7 @@ fn a_freed_slot_is_reused_and_the_others_keep_their_numbers() {
// Leaving the city drops Trade; the hole it leaves is what the next join takes.
assert_eq!(c.free_slot("Trade - City"), Some(2));
assert_eq!(c.name_of(2), None, "a hole answers 'not joined'");
assert!(c.joined[1].is_none(), "a hole answers 'not joined'");
assert_eq!(c.number_of("General - The Barrens"), Some(1), "still 1");
assert_eq!(
c.claim_slot("Trade - City"),
@@ -901,13 +877,10 @@ fn a_session_end_empties_the_window_and_the_boxs_memory() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = chat_vm();
let mut windows = super::frames::ChatWindows::default();
let mut edit = super::edit::ChatEditState::default();
let mut log = super::ChatLog::default();
super::frames::route(&mut s, &mut windows, &ev(K::Say, "hi there", "Bob"));
super::frames::route(&mut s, &mut windows, &ev(K::Whisper, "psst", "Ann"));
edit.remember_tell("Ann");
edit.sticky = super::edit::SendType::Guild;
log.push_event(ChatEvent::text_only(K::System, "queued".into()));
assert_eq!(
lines_in_window(&s),
@@ -915,23 +888,13 @@ fn a_session_end_empties_the_window_and_the_boxs_memory() {
"the window has this session's lines"
);
super::end_chat_session(Some(&mut s), &mut windows, &mut edit, &mut log);
super::end_chat_session(Some(&mut s), &mut log);
assert_eq!(
lines_in_window(&s),
0,
"and the next character starts clean"
);
assert!(
edit.last_tell.is_empty(),
"the tell ring was that character's"
);
assert_eq!(
edit.sticky,
super::edit::SendType::Say,
"sticky back to SAY"
);
assert_eq!(windows.tell_alert_left, 0.0, "the chime throttle resets");
}
/// Every notice byte the composer renders has a token to fire, and vice versa — the two tables are
@@ -1024,98 +987,6 @@ fn stub_table() -> super::commands::SlashCommands {
}
/// The Enter-path type switch (send path — no trailing-space requirement).
fn enter_switch(text: &str) -> Option<(super::edit::TypeSwitch, String)> {
super::input::parse_enter_type_switch(&super::edit::ChannelState::default(), text)
}
#[test]
fn enter_path_type_switch_converts_and_keeps_the_remainder() {
let _data = benilla_formats::wow_data_or_skip!();
use super::edit::{SendType, TypeSwitch};
for (cmd, want) in [
("s", SendType::Say),
("say", SendType::Say),
("y", SendType::Yell),
("sh", SendType::Yell),
("g", SendType::Guild),
("gc", SendType::Guild),
("p", SendType::Party),
("rw", SendType::RaidWarning),
("bg", SendType::Battleground),
("o", SendType::Officer),
("e", SendType::Emote),
] {
let (sw, rest) = enter_switch(&format!("/{cmd} hi there")).expect(cmd);
match sw {
TypeSwitch::Plain(t) => assert_eq!(t, want, "/{cmd}"),
_ => panic!("/{cmd} is a plain type switch"),
}
assert_eq!(rest, "hi there");
}
// Case-insensitive; a bare "/g" still converts (empty remainder = the sticky commit path).
assert!(enter_switch("/YELL loud").is_some());
let (_, rest) = enter_switch("/g").unwrap();
assert_eq!(rest, "");
}
#[test]
fn enter_path_whisper_takes_name_then_message() {
let _data = benilla_formats::wow_data_or_skip!();
use super::edit::TypeSwitch;
for cmd in ["w", "whisper", "t", "tell", "send"] {
let (sw, rest) = enter_switch(&format!("/{cmd} Bob hi there")).expect(cmd);
match sw {
TypeSwitch::Whisper(target) => assert_eq!(target, "Bob"),
_ => panic!("expected whisper"),
}
assert_eq!(rest, "hi there");
}
// Needs a name AND a message on the enter path; a link-leading "name" is rejected.
assert!(enter_switch("/w").is_none());
assert!(enter_switch("/w Bob").is_none());
assert!(enter_switch("/w |Hitem:1|h[x]|h hi").is_none());
}
#[test]
fn live_parse_waits_for_the_delimiting_space() {
let _data = benilla_formats::wow_data_or_skip!();
use super::edit::{parse_type_switch, ChannelState, ChatEditState, TypeSwitch};
let mut state = ChatEditState::default();
let chans = ChannelState::default();
// "/g" alone: still typing (could become /gc) — no switch until the space lands.
assert!(parse_type_switch(&state, &chans, "/g").is_none());
let (sw, rest) = parse_type_switch(&state, &chans, "/g hi").expect("switch on space");
assert!(matches!(
sw,
TypeSwitch::Plain(super::edit::SendType::Guild)
));
assert_eq!(rest, "hi");
// "/w Bob" waits for the space AFTER the name (the ref's extract trigger).
assert!(parse_type_switch(&state, &chans, "/w Bob").is_none());
let (sw, rest) = parse_type_switch(&state, &chans, "/w Bob ").expect("extract on space");
assert!(matches!(sw, TypeSwitch::Whisper(t) if t == "Bob"));
assert_eq!(rest, "");
// "/r " loads the last teller only when one exists.
assert!(parse_type_switch(&state, &chans, "/r hi").is_none());
state.remember_tell("Ann");
let (sw, _) = parse_type_switch(&state, &chans, "/r hi").expect("reply with a teller");
assert!(matches!(sw, TypeSwitch::Whisper(t) if t == "Ann"));
}
#[test]
fn tell_ring_dedups_and_cycles() {
let _data = benilla_formats::wow_data_or_skip!();
let mut state = super::edit::ChatEditState::default();
state.remember_tell("Ann");
state.remember_tell("Bob");
state.remember_tell("ann"); // move-to-front dedup, case-insensitive
assert_eq!(state.last_tell.len(), 2);
assert_eq!(state.last_tell.front().map(String::as_str), Some("ann"));
// Tab cycle: current → next, wrapping to the most recent.
assert_eq!(state.next_tell("ann").as_deref(), Some("Bob"));
assert_eq!(state.next_tell("Bob").as_deref(), Some("ann"));
assert_eq!(state.next_tell("").as_deref(), Some("ann"));
}
#[test]
fn action_commands_parse() {
@@ -1613,39 +1484,6 @@ fn unconditional_and_sleep_dead_rules() {
// ── The open-the-box law, shared by the ENTER key and an addon's ChatFrame_OpenChat ──────────
/// A sticky type whose group is gone opens as SAY (ref `ChatFrame_OpenChat` l.1554-1565), and the
/// sticky itself survives. The point of the test is that there is **one** implementation of that
/// law: `ChatFrame_OpenChat` (3 corpus callers) and the ENTER binding both call this, so an addon
/// opening the box lands in the same type the player's own keypress would.
#[test]
fn a_sticky_whose_group_is_gone_opens_as_say() {
let _data = benilla_formats::wow_data_or_skip!();
use super::edit::{sticky_on_open, SendType};
use crate::ui_party::GroupState;
let solo = GroupState::default();
let party = GroupState {
in_group: true,
group_type: 0,
..GroupState::default()
};
let raid = GroupState {
in_group: true,
group_type: 1,
..GroupState::default()
};
assert_eq!(sticky_on_open(SendType::Party, &solo), SendType::Say);
assert_eq!(sticky_on_open(SendType::Party, &party), SendType::Party);
// RAID needs an actual raid — a plain party is not one.
assert_eq!(sticky_on_open(SendType::Raid, &party), SendType::Say);
assert_eq!(sticky_on_open(SendType::Raid, &raid), SendType::Raid);
assert_eq!(sticky_on_open(SendType::RaidWarning, &party), SendType::Say);
// Everything ungated passes through untouched.
assert_eq!(sticky_on_open(SendType::Guild, &solo), SendType::Guild);
assert_eq!(sticky_on_open(SendType::Say, &solo), SendType::Say);
}
/// **The inbound addon split, and the direction a reimplementation gets backwards.**
///
/// `CHAT_MSG_ADDON` (event 227) carries `(prefix, message, distribution, sender)`. The text divides
@@ -2020,35 +1858,6 @@ fn a_combat_log_line_renders_verbatim() {
}
}
/// Window 2 "Combat Log" receives the self-relevant block and window 1 "General" receives none of
/// it — the chat-cache default registration, which is what makes the two docked tabs mean
/// something. The types the default leaves unregistered (PARTY, FRIENDLYPLAYER, the two other
/// CREATURE_VS rows) still *fire*; they just land in no window, which is the reference's own
/// behaviour and the reason a damage meter works without touching the windows.
#[test]
fn the_combat_log_lands_in_window_two_only() {
let _data = benilla_formats::wow_data_or_skip!();
let windows = super::frames::ChatWindows::default();
for kind in [
K::CombatSelfHits,
K::CombatSelfMisses,
K::CombatPetHits,
K::CombatCreatureVsSelfHits,
K::SpellSelfDamage,
K::SpellSelfBuff,
K::SpellPeriodicSelfDamage,
K::SpellDamageShieldsOnSelf,
] {
assert!(!windows.wants(0, kind), "{kind:?} must not reach General");
assert!(windows.wants(1, kind), "{kind:?} must reach the Combat Log");
}
// Registered by neither, deliberately — the default is self-relevant only.
for kind in [K::CombatPartyHits, K::CombatCreatureVsCreatureHits] {
assert!(!windows.wants(0, kind));
assert!(!windows.wants(1, kind));
}
}
/// **Both dock tabs exist, and clicking one selects its window.**
///
/// This is the test that was missing when 1571 shipped the combat log's chat lines: the lines
@@ -2073,13 +1882,19 @@ fn both_dock_tabs_exist_and_select_their_window() {
);
}
// The default dock: General selected and shown, Combat Log hidden behind its tab.
assert_eq!(s.eval::<i64>("return BenillaFCF.selected").unwrap(), 1);
assert_eq!(
s.eval::<i64>("return SELECTED_DOCK_FRAME:GetID()").unwrap(),
1
);
assert!(s.eval::<bool>("return ChatFrame1:IsShown()").unwrap());
assert!(!s.eval::<bool>("return ChatFrame2:IsShown()").unwrap());
// Clicking the Combat Log tab swaps them — the path a player takes to read the combat log.
s.run("BenillaFCF_TabClick(2)").unwrap();
assert_eq!(s.eval::<i64>("return BenillaFCF.selected").unwrap(), 2);
s.run("FCF_SelectDockFrame(ChatFrame2)").unwrap();
assert_eq!(
s.eval::<i64>("return SELECTED_DOCK_FRAME:GetID()").unwrap(),
2
);
assert!(s.eval::<bool>("return ChatFrame2:IsShown()").unwrap());
assert!(!s.eval::<bool>("return ChatFrame1:IsShown()").unwrap());
assert!(s.errors().is_empty(), "handler errors: {:?}", s.errors());
@@ -2219,116 +2034,6 @@ fn both_dock_windows_carry_the_same_chrome() {
);
}
}
assert_eq!(
s.eval::<i64>("return table.getn(BenillaFCF_Textures(2))")
.unwrap(),
s.eval::<i64>("return table.getn(BenillaFCF_Textures(1))")
.unwrap(),
);
}
/// **All SEVEN chat windows must come from ONE declaration.** The structural guard that ends the
/// class (decision 1588), widened to the whole set by 1712.
///
/// Three rounds of the same bug: window 2 shipped with no tab (1575), then with no rect and no
/// border art (1579), then with no `OnUpdate` and no scroll column (1588) — each time because it
/// was a hand-mirrored partial copy of window 1 and the mirroring missed a part. The behavioural
/// tests each catch ONE missed part after someone notices it; this catches the mirroring itself,
/// which is the only check that can fire before a part is missing.
///
/// **Windows 3..7 were the fourth round of it**, and the longest-lived: they were not mirrored
/// partially, they were declared bare — no tab, no background, no border art, no scroll column —
/// on the argument that nothing reads a hidden, undocked window's furniture. pfUI reads all of it,
/// for all seven, unguarded (`chat.lua:588`, `chat.lua:321-419`). This test covered exactly the two
/// windows the argument had already exempted, so it could not fire; it now covers the set the
/// reference declares.
///
/// So: every window inherits the same virtual template (the reference's own shape —
/// `FloatingChatFrameTemplate`), and none declares any content of its own beyond its seat. A
/// `<Layers>`, `<Frames>` or `<Scripts>` block on any instance is the defect, whatever is in it.
#[test]
fn the_seven_chat_windows_are_one_declaration() {
let _data = benilla_formats::wow_data_or_skip!();
use benilla_ui::framexml::TopLevel;
let xml = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui/ChatFrame.xml"),
)
.expect("ChatFrame.xml");
let doc = benilla_ui::framexml::parse(&xml).expect("parse");
let mut seen = 0;
for item in &doc.items {
let TopLevel::Instance(el) = item else {
continue;
};
let Some(name) = el.attr("name") else {
continue;
};
if !(1..=7).any(|i| name == format!("ChatFrame{i}")) {
continue;
}
seen += 1;
assert_eq!(
el.attr("inherits"),
Some("BenillaChatFrameTemplate"),
"{name} must inherit the window template, not restate it"
);
for child in &el.children {
assert!(
matches!(child.tag.as_str(), "Size" | "Anchors"),
"{name} declares its own <{}> — that is the mirroring this test exists to stop; \
it belongs in BenillaChatFrameTemplate where EVERY window gets it",
child.tag
);
}
}
assert_eq!(
seen,
7, // NUM_CHAT_WINDOWS — ChatFrame.lua:5
"all seven chat windows must be declared in ChatFrame.xml"
);
}
/// The dock's driver may not ride a frame the dock hides — the root cause of 1588 stated at the
/// level it lived at. `FCF_OnUpdate` drives dock-GLOBAL state (one `reveal`, one selected tab,
/// both windows' tabs) and it rode `ChatFrame1`'s `OnUpdate`; selecting the Combat Log hides
/// ChatFrame1, and a hidden frame gets no `OnUpdate`, so the whole dock froze on first use.
#[test]
fn the_dock_driver_does_not_ride_a_dock_window() {
let _data = benilla_formats::wow_data_or_skip!();
let xml = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui/ChatFrame.xml"),
)
.expect("ChatFrame.xml");
let doc = benilla_ui::framexml::parse(&xml).expect("parse");
// The one element whose OnUpdate calls it, found by walking rather than by name.
fn driver<'a>(el: &'a benilla_ui::framexml::Element, out: &mut Vec<&'a str>) {
if el.tag == "OnUpdate" && el.body.contains("FCF_OnUpdate") {
out.push("hit");
}
for c in &el.children {
driver(c, out);
}
}
let mut hosts = Vec::new();
for item in &doc.items {
let el = match item {
benilla_ui::framexml::TopLevel::Instance(el)
| benilla_ui::framexml::TopLevel::Template(el) => el,
_ => continue,
};
let mut hits = Vec::new();
driver(el, &mut hits);
if !hits.is_empty() {
hosts.push(el.attr("name").unwrap_or("<unnamed>"));
}
}
assert_eq!(
hosts,
["BenillaChatDockDriver"],
"FCF_OnUpdate is dock-global — it must ride the one frame the dock cannot hide"
);
}
/// **No docked chat window may appear in `UIParent.xml`'s managed-position table.**
+50 -22
View File
@@ -94,27 +94,39 @@ fn ui() -> UiScript {
}
/// The runner delivers every body line through the reference's own door — one
/// `EXECUTE_CHAT_LINE` event per non-empty line, in order, landing in the chat-input queue via
/// ChatFrame1's handler (0996, wow-re `macro-execution-law.md` §4). That is what makes `/cast`,
/// `/target`, `/script`, the chat types and the 225 emotes all work in a macro without the runner
/// knowing any of them.
/// `EXECUTE_CHAT_LINE` event per non-empty line, in order (0996, wow-re `macro-execution-law.md`
/// §4) — and the reference's own `ChatFrame_OnEvent` arm does the rest: `SetText(arg1)`,
/// `ChatEdit_SendText`, `ChatEdit_OnEscapePressed` (ChatFrame.lua l.1343-1347). So a macro line
/// lands wherever a typed line lands — a chat type in the send queue, an emote in the emote
/// queue, a roll in the roll queue — without the runner knowing any of them.
#[test]
fn running_a_macro_queues_its_lines_as_chat_input() {
fn running_a_macro_runs_its_lines_through_the_references_edit_box() {
let mut s = ui();
s.set_macros(MacroState {
account: vec![macro_view(
"Ambush",
"/cast Ambush\n\n /say pew \n/target Bob",
)],
account: vec![macro_view("Greet", "/wave\n\n /say pew \n/roll 1 100")],
character: Vec::new(),
});
assert!(super::run_macro(&mut s, 1));
let sends = s.take_chat_sends();
assert_eq!(
s.take_chat_input(),
vec!["/cast Ambush", "/say pew", "/target Bob"],
"blank lines dropped, each line trimmed, order kept"
sends
.iter()
.map(|c| (c.text.as_str(), c.chat_type.as_str()))
.collect::<Vec<_>>(),
vec![("pew", "SAY")],
"the line was trimmed before the parse, and sent as the type its slash named"
);
assert_eq!(
s.take_emote_requests()
.iter()
.map(|e| e.token.as_str())
.collect::<Vec<_>>(),
vec!["WAVE"],
"the emote line reached DoEmote through the reference's emote table"
);
assert_eq!(s.take_roll_requests(), vec![(1, 100)]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// An empty macro and an empty slot both run nothing and queue nothing.
s.set_macros(MacroState {
@@ -123,7 +135,7 @@ fn running_a_macro_queues_its_lines_as_chat_input() {
});
assert!(!super::run_macro(&mut s, 1));
assert!(!super::run_macro(&mut s, 7));
assert!(s.take_chat_input().is_empty());
assert!(s.take_chat_sends().is_empty());
}
/// A CHARACTER-range macro runs by its own index — the second half of the space is not a special
@@ -133,10 +145,16 @@ fn a_character_macro_runs_by_its_own_index() {
let mut s = ui();
s.set_macros(MacroState {
account: Vec::new(),
character: vec![macro_view("Charge", "/cast Charge")],
character: vec![macro_view("Charge", "/say Charge")],
});
assert!(super::run_macro(&mut s, 19));
assert_eq!(s.take_chat_input(), vec!["/cast Charge"]);
assert_eq!(
s.take_chat_sends()
.iter()
.map(|c| c.text.clone())
.collect::<Vec<_>>(),
vec!["Charge".to_string()]
);
}
/// The seed→dirty→save contract the plugin's two systems rest on: the app's own load must not look
@@ -195,7 +213,7 @@ fn a_registered_frame_sees_every_macro_line_as_an_event() {
)
.unwrap();
s.set_macros(MacroState {
account: vec![macro_view("Pull", "/cast Charge\n/say Incoming!")],
account: vec![macro_view("Pull", "/wave\n/say Incoming!")],
character: Vec::new(),
});
@@ -205,11 +223,18 @@ fn a_registered_frame_sees_every_macro_line_as_an_event() {
"return MacroSpy.seen[1], MacroSpy.seen[2], table.getn(MacroSpy.seen)"
)
.unwrap(),
("/cast Charge".into(), "/say Incoming!".into(), 2),
("/wave".into(), "/say Incoming!".into(), 2),
"each line arrives as arg1 of its own event, in body order"
);
// …and the same lines still reach the chat grammar: the spy is an observer, not a diversion.
assert_eq!(s.take_chat_input(), vec!["/cast Charge", "/say Incoming!"]);
assert_eq!(
s.take_chat_sends()
.iter()
.map(|c| c.text.clone())
.collect::<Vec<_>>(),
vec!["Incoming!".to_string()]
);
assert_eq!(s.take_emote_requests().len(), 1);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -220,14 +245,17 @@ fn a_registered_frame_sees_every_macro_line_as_an_event() {
fn either_line_ending_splits_a_body() {
let mut s = ui();
s.set_macros(MacroState {
account: vec![macro_view("Mixed", "/cast Charge\r/say a\r\n/say b")],
account: vec![macro_view("Mixed", "/say Charge\r/say a\r\n/say b")],
character: Vec::new(),
});
assert!(super::run_macro(&mut s, 1));
assert_eq!(
s.take_chat_input(),
vec!["/cast Charge", "/say a", "/say b"],
"\\r alone splits; \\r\\n does not leave an empty token"
s.take_chat_sends()
.iter()
.map(|c| c.text.clone())
.collect::<Vec<_>>(),
vec!["Charge".to_string(), "a".to_string(), "b".to_string()],
"\r alone splits; \r\n does not leave an empty token"
);
}
+10 -1
View File
@@ -389,7 +389,16 @@ fn managed_positions_track_the_bottom_bar_stack() {
load_xml(&s, "UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\CastingBarFrame.xml");
load_xml(&s, "Interface\\FrameXML\\UIMenu.xml"); // the kit the chat menus build from
load_xml(&s, "ChatFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\ChatFrame.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "GameTooltip.xml"); // the dropdown kit's MenuBackdrop reads TOOLTIP_DEFAULT_COLOR
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.lua");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Interface\\FrameXML\\FloatingChatFrame.xml");
// The loader's post-load bootstrap, replayed with no bars in existence: the bare bases.
s.run("UIParent_ManageFramePositions()").unwrap();
@@ -50,17 +50,29 @@ fn chat_with_menu() -> UiScript {
r"Interface\FrameXML\UIPanelTemplates.xml",
"Interface\\FrameXML\\ColorPickerFrame.xml",
"Interface\\FrameXML\\UIMenu.xml", // the kit ChatMenu/EmoteMenu/VoiceMacroMenu build from
"ChatFrame.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\ChatFrame.xml",
"Interface\\FrameXML\\UIPanelTemplates.lua",
"Interface\\FrameXML\\UIPanelTemplates.xml",
"Interface\\FrameXML\\FloatingChatFrame.xml",
] {
load_xml(&s, file);
}
bake_strings(&s);
super::fire_chat_login(&mut s);
s.set_screen_size(1600.0, 900.0);
s.resolve();
s
}
/// Right-click a frame's centre through the real hit path.
/// The tabs ship hidden and the dock reveals them on a stationary hover (`FCF_OnUpdate`); a
/// click on a tab needs that reveal first.
fn reveal_then(s: &mut UiScript) {
reveal_dock(s);
}
fn right_click(s: &mut UiScript, frame: &str) {
let (x, y) = s
.eval::<(f64, f64)>(&format!("return {frame}:GetCenter()"))
@@ -89,7 +101,7 @@ fn reveal_dock(s: &mut UiScript) {
)
.unwrap();
s.mouse_move(x, y);
for _ in 0..25 {
for _ in 0..45 {
s.tick(0.016);
s.resolve();
}
@@ -119,41 +131,29 @@ fn right_clicking_a_chat_tab_opens_its_options_menu() {
.unwrap(),
"no menu before any click"
);
reveal_then(&mut s);
right_click(&mut s, "ChatFrame1Tab");
assert!(
s.eval::<bool>("return DropDownList1:IsVisible()").unwrap(),
"the tab's right-click opens the options menu"
);
// The window verb, then the DISPLAY block, in the reference's own order
// (FloatingChatFrame.lua l.236-307).
assert_eq!(
s.eval::<i64>("return DropDownList1.numButtons").unwrap(),
4,
"Lock Window + Display title + Font Size + Background"
);
// **Unlock**, not Lock: both dock windows ship LOCKED (the stock chat-cache `LOCKED 1`), so
// the row offers the way out of it. `chat_resize_tests` drives the toggle itself.
assert_eq!(
s.eval::<String>("return DropDownList1Button1:GetText()")
.unwrap(),
"Unlock Window"
);
assert_eq!(
s.eval::<String>("return DropDownList1Button2:GetText()")
.unwrap(),
"Display"
);
assert_eq!(
s.eval::<String>("return DropDownList1Button3:GetText()")
.unwrap(),
"Font Size"
);
assert_eq!(
s.eval::<String>("return DropDownList1Button4:GetText()")
.unwrap(),
"Background"
);
// The reference's first six rows for the default window (FloatingChatFrame.lua
// `FCFOptionsDropDown_Initialize`, level 1): the lock verb, Rename, New Window, the Display
// title, Font Size and Background — a docked non-default window gets Close before Display.
for (n, key) in [
(1, "UNLOCK_WINDOW"),
(2, "RENAME_CHAT_WINDOW"),
(3, "NEW_CHAT_WINDOW"),
(4, "DISPLAY"),
(5, "FONT_SIZE"),
(6, "BACKGROUND"),
] {
assert!(
s.eval::<bool>(&format!("return DropDownList1Button{n}:GetText() == {key}"))
.unwrap(),
"row {n} is {key}"
);
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -163,9 +163,10 @@ fn right_clicking_a_chat_tab_opens_its_options_menu() {
fn a_left_click_still_selects_the_tab_and_opens_no_menu() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = chat_with_menu();
reveal_then(&mut s);
left_click(&mut s, "ChatFrame2Tab");
assert_eq!(
s.eval::<i64>("return BenillaFCF.selected").unwrap(),
s.eval::<i64>("return SELECTED_DOCK_FRAME:GetID()").unwrap(),
2,
"left-click selects"
);
@@ -174,7 +175,6 @@ fn a_left_click_still_selects_the_tab_and_opens_no_menu() {
.unwrap(),
"a left click opens no menu"
);
// …and it closes one that is open (ref FCF_Tab_OnClick's `CloseDropDownMenus()`).
right_click(&mut s, "ChatFrame1Tab");
assert!(s.eval::<bool>("return DropDownList1:IsVisible()").unwrap());
left_click(&mut s, "ChatFrame1Tab");
@@ -197,28 +197,32 @@ fn a_left_click_still_selects_the_tab_and_opens_no_menu() {
fn the_background_row_opens_the_picker_and_its_opacity_slider_drives_the_window() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = chat_with_menu();
reveal_then(&mut s);
right_click(&mut s, "ChatFrame1Tab");
// The shipped window is fully transparent at rest — the classic "text over the world".
assert_eq!(
s.eval::<f64>("local _,_,_,_,_,a = GetChatWindowInfo(1) return a")
.unwrap(),
0.0
);
// …so the reversed slider is seeded at its far end.
assert_eq!(
s.eval::<f64>("return DropDownList1Button4.opacity")
s.eval::<f64>("return DropDownList1Button6.opacity")
.unwrap(),
1.0,
"info.opacity is 1 - a (the ref's own 'the slider is reversed')"
);
left_click(&mut s, "DropDownList1Button4ColorSwatch");
left_click(&mut s, "DropDownList1Button6ColorSwatch");
assert!(
s.eval::<bool>("return ColorPickerFrame:IsVisible()")
.unwrap(),
"the Background swatch opens the colour picker"
);
// The player's hand is on the picker now, off the chat frame: the hover's fade-out runs to
// its end before the slider is touched, exactly as it does in real time.
s.mouse_move(1500.0, 850.0);
for _ in 0..45 {
s.tick(0.016);
s.resolve();
}
assert!(
s.eval::<bool>("return OpacitySliderFrame:IsVisible()")
.unwrap(),
@@ -230,11 +234,7 @@ fn the_background_row_opens_the_picker_and_its_opacity_slider_drives_the_window(
1.0,
"the slider opens seeded from the window"
);
// Drag the thumb to 80% opaque. The slider's OnValueChanged is the live-preview hop.
s.run("OpacitySliderFrame:SetValue(1 - 0.8)").unwrap();
// **203, not 204** — and that is the reference, not a rounding bug of ours. Its setter is
// `__ftol(x · 255.0)`, which TRUNCATES, and `0.8 × 255` is `203.99999999999997` in binary.
let stored: f64 = s
.eval("local _,_,_,_,_,a = GetChatWindowInfo(1) return a")
.unwrap();
@@ -242,15 +242,11 @@ fn the_background_row_opens_the_picker_and_its_opacity_slider_drives_the_window(
(stored - 203.0 / 255.0).abs() < 1e-9,
"the drag reached the engine store, quantized to its byte: {stored}"
);
// And the pixels follow: one FCF_OnUpdate tick with the cursor nowhere near the dock, and the
// background wears the new base rather than fading back to nothing.
//
// **The paint takes the FLOAT the slider produced, not the byte the store kept** — that split
// is the reference's, not ours: its `FCF_SetWindowAlpha` calls `SetAlpha(alpha)` with the raw
// value and `SetChatWindowAlpha(id, alpha)` with the same one, and only the second quantizes.
// The two reconcile at the next `FloatingChatFrame_Update`, i.e. the next login.
s.run("FCF_OnUpdate(1.0)").unwrap();
s.mouse_move(1500.0, 850.0);
for _ in 0..45 {
s.tick(0.016);
s.resolve();
}
let painted: f64 = s.eval("return ChatFrame1Background:GetAlpha()").unwrap();
assert!(
(painted - 0.8).abs() < 1e-6,
@@ -278,11 +274,10 @@ fn the_shipped_window_still_fades_zero_to_a_quarter() {
"at rest: invisible"
);
reveal_dock(&mut s);
assert_eq!(
s.eval::<f64>("return ChatFrame1Background:GetAlpha()")
.unwrap(),
0.25,
"hovered: DEFAULT_CHATFRAME_ALPHA, exactly as before 1589"
let alpha: f64 = s.eval("return ChatFrame1Background:GetAlpha()").unwrap();
assert!(
(alpha - 0.25).abs() < 1e-6,
"hovered: DEFAULT_CHATFRAME_ALPHA — got {alpha}"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -294,29 +289,40 @@ fn the_shipped_window_still_fades_zero_to_a_quarter() {
fn the_font_size_submenu_resizes_the_window_and_stores_the_pick() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = chat_with_menu();
reveal_then(&mut s);
right_click(&mut s, "ChatFrame1Tab");
// Open the submenu the way a player does — hover the `hasArrow` row (UIDropDownMenu.xml's
// row OnEnter → `UIDropDownMenuRow_OpenSubmenu`).
hover(&mut s, "DropDownList1Button3");
hover(&mut s, "DropDownList1Button5");
assert_eq!(
s.eval::<i64>("return DropDownList2.numButtons").unwrap(),
4,
"CHAT_FONT_HEIGHTS is 12, 14, 16, 18"
);
// The rows come from `for index, value in CHAT_FONT_HEIGHTS` — a `next` walk over a table
// built with explicit `[n] =` keys, whose order is the VM's hash order and not necessarily
// ascending; the test reads the rows by their values rather than assuming one.
let row_with = |s: &UiScript, pt: i64| -> String {
s.eval::<String>(&format!(
"for i = 1, DropDownList2.numButtons do \
local b = getglobal('DropDownList2Button'..i) \
if b.value == {pt} then return b:GetName() end \
end return ''"
))
.unwrap()
};
assert_eq!(
s.eval::<String>("return DropDownList2Button1:GetText()")
s.eval::<String>(&format!("return {}:GetText()", row_with(&s, 12)))
.unwrap(),
"12 pt"
"12 pt",
"FONT_SIZE_TEMPLATE over the value"
);
// ChatFontNormal is 14 px, so the second row is the one already ticked.
let ticked = row_with(&s, 14);
assert!(
s.eval::<bool>("return DropDownList2Button2Check:IsVisible()")
s.eval::<bool>(&format!("return {ticked}Check:IsVisible()"))
.unwrap(),
"the tick follows the font the frame is actually wearing"
"the tick follows the font the frame is actually wearing (ChatFontNormal, 14)"
);
left_click(&mut s, "DropDownList2Button3"); // 16 pt
let sixteen = row_with(&s, 16);
left_click(&mut s, &sixteen);
let (_, height): (String, f64) = s
.eval("local f, h = ChatFrame1:GetFont() return f, h")
.unwrap();
@@ -337,12 +343,17 @@ fn the_font_size_submenu_resizes_the_window_and_stores_the_pick() {
fn each_tabs_menu_writes_its_own_window() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = chat_with_menu();
reveal_then(&mut s);
right_click(&mut s, "ChatFrame2Tab");
assert_eq!(
s.eval::<i64>("return FCF_GetCurrentChatFrameID()").unwrap(),
2
);
left_click(&mut s, "DropDownList1Button4ColorSwatch");
// A docked non-default window's menu carries Close before Display, so Background is row 7.
assert!(s
.eval::<bool>("return DropDownList1Button7:GetText() == BACKGROUND")
.unwrap());
left_click(&mut s, "DropDownList1Button7ColorSwatch");
s.run("OpacitySliderFrame:SetValue(0)").unwrap(); // reversed: 0 = fully opaque
assert_eq!(
s.eval::<f64>("local _,_,_,_,_,a = GetChatWindowInfo(2) return a")
@@ -365,15 +376,14 @@ fn each_tabs_menu_writes_its_own_window() {
fn cancelling_the_picker_puts_the_window_back() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = chat_with_menu();
// Start from a non-default look so "restored" is distinguishable from "reset".
s.run("FCF_SetWindowAlpha(ChatFrame1, 0.4) FCF_SetWindowColor(ChatFrame1, 0.2, 0.4, 0.6)")
.unwrap();
let before: f64 = s
.eval("local _,_,_,_,_,a = GetChatWindowInfo(1) return a")
.unwrap();
reveal_then(&mut s);
right_click(&mut s, "ChatFrame1Tab");
left_click(&mut s, "DropDownList1Button4ColorSwatch");
left_click(&mut s, "DropDownList1Button6ColorSwatch");
s.run("OpacitySliderFrame:SetValue(0)").unwrap();
assert_eq!(
s.eval::<f64>("local _,_,_,_,_,a = GetChatWindowInfo(1) return a")
@@ -381,7 +391,6 @@ fn cancelling_the_picker_puts_the_window_back() {
1.0,
"the drag previewed live"
);
left_click(&mut s, "ColorPickerCancelButton");
let after: f64 = s
.eval("local _,_,_,_,_,a = GetChatWindowInfo(1) return a")
@@ -411,10 +420,20 @@ fn the_restore_event_repaints_the_window_from_the_store() {
font_size: 18,
locked: true,
docked: Some(1),
..Default::default()
},
)]);
// The reference paints colour, alpha and lock from the record on the FIRST
// `UPDATE_CHAT_WINDOWS` a frame sees (`FloatingChatFrame_Update`'s `not isInitialized`
// gate) — a login, which is when the app restores the file. The helper already fired that
// one, so this is the frame meeting the event fresh, as at the next login.
s.run("ChatFrame1.isInitialized = nil").unwrap();
s.fire_event("UPDATE_CHAT_WINDOWS", vec![]);
s.run("BenillaFCF.hover = false FCF_OnUpdate(1.0)").unwrap();
s.mouse_move(1500.0, 850.0);
for _ in 0..45 {
s.tick(0.016);
s.resolve();
}
assert_eq!(
s.eval::<f64>("return ChatFrame1Background:GetAlpha()")
.unwrap(),
@@ -425,14 +444,9 @@ fn the_restore_event_repaints_the_window_from_the_store() {
.eval("return ChatFrame1Background:GetVertexColor()")
.unwrap();
assert_eq!((r, g, b), (1.0, 0.0, 0.0), "and the restored tint");
let (_, height): (String, f64) = s
.eval("local f, h = ChatFrame1:GetFont() return f, h")
.unwrap();
assert_eq!(height, 18.0, "and the restored font size");
// Restoring is not a player edit: nothing was queued back at the host.
assert!(
s.take_chat_window_changes().is_empty(),
"the restore path does not dirty the file it came from"
);
// The restore paints through the `doNotSave` arms, but the reference's dock pass that follows
// (`FCF_DockFrame` → `FCF_SaveDock` → `SetChatWindowDocked`) is a real write — the DOCKED 1 /
// DOCKED 2 every stock file carries are FrameXML's, not the loader's.
assert_eq!(s.take_chat_window_changes(), vec![0, 1]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -28,10 +28,23 @@ fn chat_ui() -> UiScript {
r"Interface\FrameXML\UIPanelTemplates.xml",
"Interface\\FrameXML\\ColorPickerFrame.xml",
"Interface\\FrameXML\\UIMenu.xml", // the kit ChatMenu/EmoteMenu/VoiceMacroMenu build from
"ChatFrame.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml",
// `FCF_ValidateChatFramePosition` (a tab-drag stop) reads `MainMenuBar:GetHeight()`; the
// bar's own load-time chain precedes it, as in the action-bar harness.
"Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\MainMenuBar.xml",
"Interface\\FrameXML\\ChatFrame.xml",
"Interface\\FrameXML\\UIPanelTemplates.lua",
"Interface\\FrameXML\\UIPanelTemplates.xml",
"Interface\\FrameXML\\FloatingChatFrame.xml",
] {
load_xml(&s, file);
}
super::fire_chat_login(&mut s);
s.set_screen_size(1600.0, 900.0);
s.resolve();
s
@@ -78,6 +91,29 @@ fn left_click(s: &mut UiScript, frame: &str) {
}
/// Press a grip and walk the cursor to `(x, y)` — the real gesture, without releasing.
/// The tabs ship hidden; a stationary hover over the dock reveals them (`FCF_OnUpdate`), and
/// the reference moves a window by dragging its TAB (ChatTabTemplate's OnDragStart →
/// `StartMoving`), so every drag here starts with the reveal.
fn reveal(s: &mut UiScript) {
let (cx, cy) = centre(s, "ChatFrame1");
s.mouse_move(cx, cy);
for _ in 0..45 {
s.tick(0.016);
s.resolve();
}
}
/// Drag `ChatFrame1Tab` by `dx, dy` — the reference's move gesture — and release.
fn drag_tab(s: &mut UiScript, dx: f32, dy: f32) {
reveal(s);
let (tx, ty) = centre(s, "ChatFrame1Tab");
s.mouse_button(tx, ty, "LeftButton", true);
s.mouse_move(tx + 5.0, ty); // past the 4px threshold: OnDragStart -> StartMoving
s.mouse_move(tx + dx, ty + dy);
s.mouse_button(tx + dx, ty + dy, "LeftButton", false);
s.resolve();
}
fn grab_grip(s: &mut UiScript, grip: &str) -> (f32, f32) {
let (x, y) = centre(s, grip);
s.mouse_button(x, y, "LeftButton", true);
@@ -98,17 +134,14 @@ fn both_dock_windows_ship_locked() {
))
.unwrap(),
1,
"window {i} ships locked (the stock chat-cache row)"
"window {i} ships locked (the boot init's LOCKED 1)"
);
assert!(
s.eval::<bool>(&format!("return ChatFrame{i}.isLocked ~= nil"))
.unwrap(),
"…and the frame was seated from it"
"…and the frame was seated from it by FloatingChatFrame_Update"
);
}
// The blanket switch ships OFF — the reference's own initialiser, not its checkbox table's
// disagreeing `default = "1"`.
assert_eq!(s.eval::<String>("return CHAT_LOCKED").unwrap(), "0");
assert!(s
.eval::<Option<i64>>("return FCF_Get_ChatLocked()")
.unwrap()
@@ -123,20 +156,17 @@ fn both_dock_windows_ship_locked() {
fn the_tab_menus_lock_row_toggles_the_window_and_flips_its_label() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = chat_ui();
reveal(&mut s);
right_click(&mut s, "ChatFrame1Tab");
// Shipped locked, so the row offers the way OUT of it.
assert_eq!(
s.eval::<String>("return DropDownList1Button1:GetText()")
.unwrap(),
"Unlock Window"
);
assert!(s
.eval::<bool>("return DropDownList1Button1:GetText() == UNLOCK_WINDOW")
.unwrap());
assert_eq!(
s.eval::<i64>("return DropDownList1Button1.notCheckable")
.unwrap(),
1,
"a verb, not a tick"
);
left_click(&mut s, "DropDownList1Button1");
assert!(
s.eval::<Option<i64>>("return ChatFrame1.isLocked")
@@ -150,14 +180,10 @@ fn the_tab_menus_lock_row_toggles_the_window_and_flips_its_label() {
.is_none(),
"…and wrote it through SetChatWindowLocked, so it can be persisted"
);
// Re-open: the same row is now the other verb.
right_click(&mut s, "ChatFrame1Tab");
assert_eq!(
s.eval::<String>("return DropDownList1Button1:GetText()")
.unwrap(),
"Lock Window"
);
assert!(s
.eval::<bool>("return DropDownList1Button1:GetText() == LOCK_WINDOW")
.unwrap());
left_click(&mut s, "DropDownList1Button1");
assert_eq!(
s.eval::<i64>("return ChatFrame1.isLocked").unwrap(),
@@ -176,39 +202,21 @@ fn a_locked_window_refuses_a_grip_drag() {
let mut s = chat_ui();
let before = width(&mut s);
assert_eq!(before, 430.0, "the authored width");
// The grip is where it should be — this is not a test that passes because nothing is there.
// The reference's grips take the mouse whatever the lock says — the refusal is
// `FCF_Resize`'s `isLocked` gate, not a disabled button.
assert!(s
.eval::<bool>("return ChatFrame1ResizeBottomRight ~= nil")
.eval::<bool>("return ChatFrame1ResizeBottomRight:IsMouseEnabled()")
.unwrap());
assert!(
!s.eval::<bool>("return ChatFrame1ResizeBottomRight:IsMouseEnabled()")
.unwrap(),
"a locked window's grips are click-through"
);
// The press falls past the disabled button — to whatever is behind it, which at this corner
// is the world (the grip straddles the window's edge and its centre sits just outside).
let (x, y) = centre(&mut s, "ChatFrame1ResizeBottomRight");
assert_ne!(
s.hit_test_name(x, y).as_deref(),
Some("ChatFrame1ResizeBottomRight"),
"a disabled grip does not eat the click"
);
grab_grip(&mut s, "ChatFrame1ResizeBottomRight");
let (x, y) = grab_grip(&mut s, "ChatFrame1ResizeBottomRight");
s.mouse_move(x + 60.0, y - 40.0);
assert_eq!(width(&mut s), before, "a locked window does not resize");
// …and the direct call refuses too, which is the reference's own gate rather than ours.
s.run("ChatFrame1ResizeBottomRight:EnableMouse(true)")
.unwrap();
grab_grip(&mut s, "ChatFrame1ResizeBottomRight");
s.mouse_move(x + 60.0, y - 40.0);
assert_eq!(
width(&mut s),
before,
"FCF_Resize's isLocked gate refuses even a live grip"
assert!(
s.eval::<Option<i64>>("return ChatFrame1.resizing")
.unwrap()
.is_none(),
"FCF_Resize returned before StartSizing"
);
s.mouse_button(x + 60.0, y - 40.0, "LeftButton", false);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -299,35 +307,29 @@ fn the_release_ends_the_resize_from_anywhere_on_screen() {
/// gesture has to cross the drag threshold before anything moves, which is what keeps 0843's
/// held-spell dismissal working on the same button.
#[test]
fn the_body_drag_moves_an_unlocked_window_and_a_locked_one_stays_put() {
fn the_tab_drag_moves_an_unlocked_window_and_a_locked_one_stays_put() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = chat_ui();
let (cx, cy) = centre(&mut s, "ChatFrame1");
let start = left(&mut s);
// Locked: not drag-registered at all, so the gesture never arms.
s.mouse_button(cx, cy, "LeftButton", true);
s.mouse_move(cx + 80.0, cy);
s.mouse_button(cx + 80.0, cy, "LeftButton", false);
assert_eq!(left(&mut s), start, "a locked window does not drag");
drag_tab(&mut s, 80.0, 0.0);
assert_eq!(
left(&mut s),
start,
"a locked window does not drag — ChatTabTemplate's OnDragStart returns on isLocked"
);
s.run("FCF_SetLocked(ChatFrame1, nil)").unwrap();
let (cx, cy) = centre(&mut s, "ChatFrame1");
s.mouse_button(cx, cy, "LeftButton", true);
s.mouse_move(cx + 5.0, cy); // past the 4px threshold: OnDragStart -> StartMoving
s.mouse_move(cx + 80.0, cy);
// **75, not 80.** The gesture's first 5px are spent crossing the drag threshold, and
// `StartMoving` samples the cursor where it is when `OnDragStart` fires — so the window follows
// from *there*, not from the press. That is the engine's own shape (`cursor::maybe_start_drag`
// runs after the move pump in the same `mouse_move`), and it is what every drag-to-move frame
// in the client does.
reveal(&mut s);
let (tx, ty) = centre(&mut s, "ChatFrame1Tab");
s.mouse_button(tx, ty, "LeftButton", true);
s.mouse_move(tx + 5.0, ty);
s.mouse_move(tx + 80.0, ty);
assert_eq!(
left(&mut s),
start + 75.0,
"the window followed the cursor from where the drag started"
);
s.mouse_button(cx + 80.0, cy, "LeftButton", false);
s.mouse_move(cx + 400.0, cy);
s.mouse_button(tx + 80.0, ty, "LeftButton", false);
s.mouse_move(tx + 400.0, ty);
assert_eq!(
left(&mut s),
start + 75.0,
@@ -343,20 +345,14 @@ fn the_body_drag_moves_an_unlocked_window_and_a_locked_one_stays_put() {
fn a_moved_window_is_user_placed_and_the_managed_pass_skips_it() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = chat_ui();
// The managed pass owns the seat to begin with — the control for the assertion below.
s.run("UIParent_ManageFramePositions()").unwrap();
let seated = left(&mut s);
assert!(
!s.eval::<bool>("return ChatFrame1:IsUserPlaced()").unwrap(),
"nothing has placed it yet"
);
s.run("FCF_SetLocked(ChatFrame1, nil)").unwrap();
let (cx, cy) = centre(&mut s, "ChatFrame1");
s.mouse_button(cx, cy, "LeftButton", true);
s.mouse_move(cx + 5.0, cy);
s.mouse_move(cx + 120.0, cy);
s.mouse_button(cx + 120.0, cy, "LeftButton", false);
drag_tab(&mut s, 120.0, 0.0);
let moved = left(&mut s);
assert_eq!(
moved,
@@ -367,7 +363,6 @@ fn a_moved_window_is_user_placed_and_the_managed_pass_skips_it() {
s.eval::<bool>("return ChatFrame1:IsUserPlaced()").unwrap(),
"the drag set the userPlaced bit"
);
s.run("UIParent_ManageFramePositions()").unwrap();
assert_eq!(
left(&mut s),
@@ -390,12 +385,7 @@ fn the_geometry_round_trips_through_the_save_file() {
let (gx, gy) = grab_grip(&mut s, "ChatFrame1ResizeBottomRight");
s.mouse_move(gx + 70.0, gy - 40.0);
s.mouse_button(gx + 70.0, gy - 40.0, "LeftButton", false);
let (cx, cy) = centre(&mut s, "ChatFrame1");
s.mouse_button(cx, cy, "LeftButton", true);
s.mouse_move(cx + 5.0, cy + 5.0);
s.mouse_move(cx + 90.0, cy + 60.0);
s.mouse_button(cx + 90.0, cy + 60.0, "LeftButton", false);
s.resolve();
drag_tab(&mut s, 90.0, 60.0);
let want = (
width(&mut s),
@@ -452,8 +442,11 @@ fn the_geometry_round_trips_through_the_save_file() {
fn the_grip_art_follows_the_windows_reveal_and_tint() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = chat_ui();
// Off-hover, at the shipped alpha-0 base: everything in the set is invisible.
s.run("BenillaFCF.hover = false FCF_OnUpdate(1.0)").unwrap();
s.mouse_move(1500.0, 850.0);
for _ in 0..4 {
s.tick(0.016);
s.resolve();
}
for piece in ["Background", "ResizeBottomRightTexture", "ResizeTopTexture"] {
assert_eq!(
s.eval::<f64>(&format!("return ChatFrame1{piece}:GetAlpha()"))
@@ -462,24 +455,16 @@ fn the_grip_art_follows_the_windows_reveal_and_tint() {
"{piece} is invisible at rest"
);
}
// Park the cursor in the window and let the stationary delay and the ramp run out.
let (cx, cy) = centre(&mut s, "ChatFrame1");
s.mouse_move(cx, cy);
for _ in 0..25 {
s.tick(0.016);
s.resolve();
}
reveal(&mut s);
for piece in ["Background", "ResizeBottomRightTexture", "ResizeTopTexture"] {
assert_eq!(
s.eval::<f64>(&format!("return ChatFrame1{piece}:GetAlpha()"))
.unwrap(),
0.25,
"{piece} reveals with the box (DEFAULT_CHATFRAME_ALPHA)"
let alpha: f64 = s
.eval(&format!("return ChatFrame1{piece}:GetAlpha()"))
.unwrap();
assert!(
(alpha - 0.25).abs() < 1e-6,
"{piece} reveals with the box (DEFAULT_CHATFRAME_ALPHA): {alpha}"
);
}
// And the tab menu's tint reaches them too — one list, one colour.
s.run("FCF_SetWindowColor(ChatFrame1, 1, 0, 0)").unwrap();
let (r, g, b): (f64, f64, f64) = s
.eval("return ChatFrame1ResizeTopTexture:GetVertexColor()")
@@ -496,24 +481,38 @@ fn a_drag_in_flight_holds_the_chrome_visible() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = chat_ui();
s.run("FCF_SetLocked(ChatFrame1, nil)").unwrap();
reveal(&mut s);
grab_grip(&mut s, "ChatFrame1ResizeBottomRight");
// Drag the cursor right out of the window's hover rect. Everything the fade normally keys on
// now says "conceal": the cursor is nowhere near the dock, so only `chatFrame.resizing` can
// hold the grips on screen.
s.mouse_move(1500.0, 820.0);
s.run("FCF_OnUpdate(1.0)").unwrap();
assert_eq!(
s.eval::<f64>("return BenillaFCF.reveal").unwrap(),
1.0,
"the resize holds the reveal open with the cursor off the window"
s.eval::<i64>("return ChatFrame1.resizing").unwrap(),
1,
"FCF_Resize marked the frame"
);
s.mouse_move(1500.0, 820.0);
for _ in 0..45 {
s.tick(0.016);
s.resolve();
}
let alpha: f64 = s.eval("return ChatFrame1Background:GetAlpha()").unwrap();
assert!(
(alpha - 0.25).abs() < 1e-6,
"the resize holds the reveal open with the cursor off the window (`or chatFrame.resizing`): {alpha}"
);
// The falsifier is the release: with the drag over, the same cursor position conceals.
s.mouse_button(1500.0, 820.0, "LeftButton", false);
s.run("FCF_OnUpdate(1.0)").unwrap();
assert_eq!(
s.eval::<f64>("return BenillaFCF.reveal").unwrap(),
0.0,
"and lets go when the drag ends"
assert!(
s.eval::<Option<i64>>("return ChatFrame1.resizing")
.unwrap()
.is_none(),
"FCF_StopResize cleared it"
);
for _ in 0..45 {
s.tick(0.016);
s.resolve();
}
let alpha: f64 = s.eval("return ChatFrame1Background:GetAlpha()").unwrap();
assert!(
alpha < 1e-6,
"and the box fades once the drag ends: {alpha}"
);
}
@@ -522,30 +521,24 @@ fn a_drag_in_flight_holds_the_chrome_visible() {
#[test]
fn the_chat_locked_global_overrides_the_per_window_lock() {
let _data = benilla_formats::wow_data_or_skip!();
let s = chat_ui();
let mut s = chat_ui();
s.run("FCF_SetLocked(ChatFrame1, nil)").unwrap();
assert!(s
.eval::<bool>("return FCF_CanMove(ChatFrame1) == 1")
.unwrap());
let before = width(&mut s);
s.run("FCF_Set_ChatLocked(1)").unwrap();
assert_eq!(s.eval::<String>("return CHAT_LOCKED").unwrap(), "1");
assert!(
s.eval::<Option<i64>>("return FCF_CanMove(ChatFrame1)")
.unwrap()
.is_none(),
"the blanket switch wins over an unlocked window"
let (x, y) = grab_grip(&mut s, "ChatFrame1ResizeBottomRight");
s.mouse_move(x + 50.0, y - 30.0);
assert_eq!(
width(&mut s),
before,
"the blanket switch wins over an unlocked window — FCF_Resize's first gate"
);
assert!(
!s.eval::<bool>("return ChatFrame1ResizeBottomRight:IsMouseEnabled()")
.unwrap(),
"…and takes the edges back"
);
s.mouse_button(x + 50.0, y - 30.0, "LeftButton", false);
s.run("FCF_Set_ChatLocked(nil)").unwrap();
assert!(s
.eval::<bool>("return FCF_CanMove(ChatFrame1) == 1")
.unwrap());
let (x, y) = grab_grip(&mut s, "ChatFrame1ResizeBottomRight");
s.mouse_move(x + 50.0, y - 30.0);
assert_eq!(width(&mut s), before + 50.0, "and lets go when cleared");
s.mouse_button(x + 50.0, y - 30.0, "LeftButton", false);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -556,24 +549,22 @@ fn the_chat_locked_global_overrides_the_per_window_lock() {
fn the_docked_combat_log_cannot_be_moved_or_resized_on_its_own() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = chat_ui();
s.run("FCF_SetLocked(ChatFrame2, nil)").unwrap();
assert!(
s.eval::<Option<i64>>("return FCF_CanMove(ChatFrame2)")
.unwrap()
.is_none(),
"a docked window that is not the default one owns no geometry"
);
assert!(!s
.eval::<bool>("return ChatFrame2ResizeBottomRight:IsMouseEnabled()")
.unwrap());
// …and it still follows window 1 when window 1 does move.
s.run("FCF_SetLocked(ChatFrame1, nil)").unwrap();
let (cx, cy) = centre(&mut s, "ChatFrame1");
s.mouse_button(cx, cy, "LeftButton", true);
s.mouse_move(cx + 5.0, cy);
s.mouse_move(cx + 60.0, cy);
s.mouse_button(cx + 60.0, cy, "LeftButton", false);
s.run("FCF_SelectDockFrame(ChatFrame2) FCF_SetLocked(ChatFrame2, nil)")
.unwrap();
s.resolve();
let before: f64 = s.eval("return ChatFrame2:GetWidth()").unwrap();
let (x, y) = grab_grip(&mut s, "ChatFrame2ResizeBottomRight");
s.mouse_move(x + 50.0, y - 30.0);
s.resolve();
assert_eq!(
s.eval::<f64>("return ChatFrame2:GetWidth()").unwrap(),
before,
"a docked window that is not the default one owns no geometry — FCF_Resize's third gate"
);
s.mouse_button(x + 50.0, y - 30.0, "LeftButton", false);
s.run("FCF_SelectDockFrame(ChatFrame1) FCF_SetLocked(ChatFrame1, nil)")
.unwrap();
drag_tab(&mut s, 60.0, 0.0);
let one: f64 = s.eval("return ChatFrame1:GetLeft()").unwrap();
let two: f64 = s.eval("return ChatFrame2:GetLeft()").unwrap();
assert_eq!(one, two, "the dock moved as one");
+112 -93
View File
@@ -23,7 +23,25 @@ fn chat_frame() -> UiScript {
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "Interface\\FrameXML\\UIMenu.xml"); // the kit the chat menus build from
load_xml(&s, "ChatFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
// The UIParent slice (RaiseFrameLevel, MouseIsOver, the fade and flash kits) ahead of the
// bar and the chat files; `FCF_OnUpdate` — the dock's hover, fade and drag driver — runs
// from UIParent's OnUpdate, as in the reference.
load_xml(&s, "UiPanels.xml");
load_xml(&s, "UIParent.xml");
// `FCF_ValidateChatFramePosition` (a tab-drag stop) reads `MainMenuBar:GetHeight()`; the bar's
// own load-time chain precedes it, as in the action-bar harness.
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.xml");
load_xml(&s, "Interface\\FrameXML\\MainMenuBar.xml");
load_xml(&s, "Interface\\FrameXML\\ChatFrame.xml");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.lua");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\FloatingChatFrame.xml");
super::fire_chat_login(&mut s);
s.set_screen_size(1600.0, 900.0);
s.resolve();
s
@@ -110,24 +128,19 @@ fn newest_line_sits_at_the_bottom() {
#[test]
fn wheel_scroll_re_arms_the_fade_then_freezes_it() {
let mut s = chat_frame();
// Short fade so a tick visibly ramps: straight into phase 2 (timeVisible 0), 4s ramp.
s.run("ChatFrame1:SetTimeVisible(0); ChatFrame1:SetFadeDuration(4)")
.unwrap();
for t in ["L0", "L1", "L2"] {
s.add_chat_message("ChatFrame1", t, 1.0, 1.0, 1.0);
}
s.resolve();
// One tick at the bottom ramps every line's alpha down.
s.tick(1.0);
s.resolve();
let a1 = text_color(&s.extract(), "L1").expect("L1 visible")[3];
assert!(a1 < 1.0 && a1 > 0.0, "the line faded partway: {a1}");
// Scroll up via the wheel (a point inside the docked frame: BOTTOMLEFT (32,85), 430x160). The
// Lua OnMouseWheel handler calls ScrollUp, which re-arms every displayed line to full alpha —
// and, no longer AtBottom, the tick then holds it there.
s.mouse_wheel(100.0, 150.0, 1.0);
// The scroll verb is what the reference's scroll buttons call; the 1.12 chat frame takes
// no wheel of its own (`enableMouse="false"`, no OnMouseWheel).
s.run("ChatFrame1:ScrollUp()").unwrap();
s.resolve();
let a2 = text_color(&s.extract(), "L1").expect("L1 still visible")[3];
assert!(close(a2, 1.0), "the scroll brought the line back: {a2}");
@@ -135,9 +148,7 @@ fn wheel_scroll_re_arms_the_fade_then_freezes_it() {
s.resolve();
let a3 = text_color(&s.extract(), "L1").expect("L1 still visible")[3];
assert!(close(a3, 1.0), "frozen while scrolled up: {a3}");
// Wheel back down to the bottom — that scroll re-arms too, so the ramp restarts from full.
s.mouse_wheel(100.0, 150.0, -1.0);
s.run("ChatFrame1:ScrollToBottom()").unwrap();
s.tick(1.0);
s.resolve();
let a4 = text_color(&s.extract(), "L1").expect("L1 visible")[3];
@@ -148,18 +159,25 @@ fn wheel_scroll_re_arms_the_fade_then_freezes_it() {
#[test]
fn input_editbox_enter_drains_the_typed_line() {
let mut s = chat_frame();
// ENTER opens (the app calls focus_editbox); typing goes to the focused box.
assert!(s.focus_editbox("ChatFrameEditBox"), "the edit box focuses");
assert!(s.has_keyboard_focus(), "focus gates the world's keys");
s.char_input("/yell hi");
// ENTER → OnEnterPressed → SubmitChatInput(GetText()) + SetText('') + ClearFocus + Hide.
assert!(s.key_input("ENTER"), "the box consumes ENTER");
assert_eq!(s.take_chat_input(), vec!["/yell hi".to_string()]);
// The reference's own path: `ChatEdit_OnEnterPressed` → `ChatEdit_SendText` →
// `ChatEdit_ParseText` → `SendChatMessage("hi", "YELL", …)`.
let sends = s.take_chat_sends();
assert_eq!(
sends
.iter()
.map(|c| (c.text.as_str(), c.chat_type.as_str()))
.collect::<Vec<_>>(),
vec![("hi", "YELL")]
);
assert!(
!s.has_keyboard_focus(),
"submit closes the box (ClearFocus + Hide)"
"submit closes the box (ChatEdit_OnEscapePressed: ClearFocus + Hide)"
);
assert!(s.take_chat_input().is_empty(), "drained");
assert!(s.take_chat_sends().is_empty(), "drained");
}
#[test]
@@ -182,12 +200,13 @@ fn chat_box_arrows_edit_and_history_recalls() {
let mut s = chat_frame();
assert!(s.focus_editbox("ChatFrameEditBox"));
s.char_input("ab");
// Plain LEFT must MOVE the caret (not be swallowed): a backspace after it deletes 'a', not 'b'.
s.editbox_action(EditAction::Move {
unit: EditUnit::Char,
back: true,
extend: false,
});
// The reference's ChatFrameEditBoxTemplate is `ignoreArrows="true"` — the engine's
// AltArrowKeyMode, which the app's key routing reads to hand a plain arrow to the bindings
// (the character turns while you type) rather than to the box. The box itself still edits.
assert!(
s.editbox_alt_arrow_mode(),
"the stock template's ignoreArrows landed as AltArrowKeyMode on the focused box"
);
s.editbox_action(EditAction::Delete {
unit: EditUnit::Char,
back: true,
@@ -195,30 +214,38 @@ fn chat_box_arrows_edit_and_history_recalls() {
assert_eq!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
.unwrap(),
"b",
"plain arrows move the caret in the shipped chat box"
"a"
);
// Submit, then push the canonical line the router would (ui_chat::input::history_line) — the
// asynchronous ChatEdit_AddHistory slot.
s.run("ChatFrameEditBox:SetText('')").unwrap();
s.char_input("/yell hi");
assert!(s.key_input("ENTER"));
assert_eq!(s.take_chat_input(), vec!["/yell hi".to_string()]);
assert!(
s.editbox_add_history("ChatFrameEditBox", "/yell hi"),
"the seam finds the shipped box by name after it closed"
// `ChatEdit_SendText` → `ChatEdit_ParseText` → the YELL type's send, and the reference's
// `AddHistoryLine` on the way.
let sends = s.take_chat_sends();
assert_eq!(
sends
.iter()
.map(|c| (c.text.as_str(), c.chat_type.as_str()))
.collect::<Vec<_>>(),
vec![("hi", "YELL")]
);
// Reopen (the app refocuses on the next Enter): Up recalls, Down restores the draft.
assert!(s.focus_editbox("ChatFrameEditBox"));
s.char_input("dra");
s.editbox_action(EditAction::HistoryPrev);
assert_eq!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
// `ChatEdit_AddHistory` filed `SLASH_YELL1 .. " " .. text` = "/y hi" (ChatFrame.lua
// l.1913-1937); the recall lands it in the box, and the box's own `OnTextChanged` live parse
// (`ChatEdit_ParseText(this, 0)`) turns it straight into the YELL type with "hi" — which is
// what the player sees: a "Yell:" header and the text.
assert!(
s.eval::<bool>(
"return ChatFrameEditBox.chatType == 'YELL' and ChatFrameEditBox:GetText() == 'hi'"
)
.unwrap(),
"Up recalls the filed line, live-parsed: {:?} {:?}",
s.eval::<String>("return ChatFrameEditBox.chatType")
.unwrap(),
"/yell hi",
"Up recalls the submitted line"
s.eval::<String>("return ChatFrameEditBox:GetText()")
.unwrap()
);
s.editbox_action(EditAction::HistoryNext);
assert_eq!(
@@ -239,19 +266,13 @@ fn chat_click_dismisses_a_stuck_spell_but_not_an_item() {
use benilla_ui::script::{
ContainerSlot, ContainerState, SpellBookState, SpellSlotView, SpellTabView,
};
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The reference's spellbook wants the action-bar chain beneath it (1952); the chat window
// loads after it, as in the manifest.
let mut s = super::spellbook_tests::spellbook_ui(1024.0, 768.0);
for f in [
"Interface\\FrameXML\\Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"GameTooltip.xml",
"Cooldown.xml",
"Interface\\FrameXML\\UIMenu.xml", // the kit ChatMenu/EmoteMenu/VoiceMacroMenu build from
"ChatFrame.xml",
"SpellBookFrame.xml",
"Interface\\FrameXML\\ChatFrame.xml",
"Interface\\FrameXML\\FloatingChatFrame.xml",
] {
load_xml(&s, f);
}
@@ -742,11 +763,9 @@ fn hovering_the_dock_still_reveals_the_tabs() {
s.resolve();
}
assert!(
s.eval::<f64>("return ChatFrame1Tab:GetAlpha()").unwrap() < 1e-6,
"the dock starts concealed"
!s.eval::<bool>("return ChatFrame1Tab:IsVisible()").unwrap(),
"the dock starts concealed — the tab template ships hidden"
);
// The dock's own centre, asked of the layout rather than hardcoded.
let (x, y): (f32, f32) = s
.eval(
"return (ChatFrame1:GetLeft() + ChatFrame1:GetRight()) / 2, \
@@ -754,17 +773,15 @@ fn hovering_the_dock_still_reveals_the_tabs() {
)
.unwrap();
s.mouse_move(x, y);
// 0.2 s stationary to arm the reveal, then the 0.15 s ramp — 25 frames covers both with room.
for _ in 0..25 {
for _ in 0..45 {
s.tick(0.016);
s.resolve();
}
assert!(s.errors().is_empty(), "{:?}", s.errors());
let alpha: f64 = s.eval("return ChatFrame1Tab:GetAlpha()").unwrap();
assert!(
(alpha - 1.0).abs() < 1e-6,
"a stationary hover reveals the selected tab at full alpha — got {alpha}"
s.eval::<bool>("return ChatFrame1Tab:IsVisible()").unwrap() && (alpha - 1.0).abs() < 1e-6,
"a stationary hover past CHAT_TAB_SHOW_DELAY reveals the selected tab at full alpha — got {alpha}"
);
}
@@ -787,8 +804,6 @@ fn a_chat_view_at_the_bottom_stops_rewriting_the_flash() {
s.tick(0.016);
s.resolve();
}
// At the bottom, flash hidden: the sentinel must survive.
s.run("ChatFrame1.flashTimer = 0.42").unwrap();
for _ in 0..10 {
s.tick(0.016);
@@ -805,20 +820,17 @@ fn a_chat_view_at_the_bottom_stops_rewriting_the_flash() {
.unwrap(),
"the flash stays hidden at rest"
);
// Control 1: scrolled up, the blink still runs — the timer accumulates past 0.5 and shows
// the flash (a point inside the docked frame, as the wheel test uses).
s.mouse_wheel(100.0, 150.0, 1.0);
s.tick(0.3); // 0.42 + 0.3 = 0.72 >= 0.5 -> toggle on
// The reference's ChatFrameTemplate takes no mouse and has no wheel script (1.12 scrolls its
// chat by the buttons), so the scroll is the frame's own verb.
s.run("ChatFrame1:ScrollUp()").unwrap();
s.tick(0.3); // 0.42 + 0.3 = 0.72 >= CHAT_BUTTON_FLASH_TIME -> toggle on
s.resolve();
assert!(
s.eval::<bool>("return ChatFrame1BottomButtonFlash:IsVisible()")
.unwrap(),
"scrolled up, the bottom-button blink still lights"
"scrolled up, the bottom-button blink lights"
);
// Control 2: back to the bottom while the flash is LIT — the transition still writes.
s.mouse_wheel(100.0, 150.0, -1.0);
s.run("ChatFrame1:ScrollToBottom()").unwrap();
s.tick(0.016);
s.resolve();
assert!(
@@ -826,10 +838,12 @@ fn a_chat_view_at_the_bottom_stops_rewriting_the_flash() {
.unwrap(),
"returning to the bottom hides a lit flash"
);
assert_eq!(
s.eval::<f64>("return ChatFrame1.flashTimer").unwrap(),
0.0,
"and zeroes the timer with it (our reset rides inside the reference's gate)"
// …and leaves the residual phase where it was: the reference's at-bottom arm hides the flash
// and returns, it never zeroes the timer (ChatFrame.lua `ChatFrame_OnUpdate`).
let timer: f64 = s.eval("return ChatFrame1.flashTimer").unwrap();
assert!(
(timer - 0.22).abs() < 1e-6,
"the residual phase stands: {timer}"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -851,7 +865,6 @@ fn a_chat_view_at_the_bottom_stops_rewriting_the_flash() {
#[test]
fn selecting_the_combat_log_keeps_the_dock_driver_running() {
let mut s = chat_frame();
// Hover the dock and let the reveal settle: General at full, Combat Log at half.
let (x, y): (f32, f32) = s
.eval(
"return (ChatFrame1:GetLeft() + ChatFrame1:GetRight()) / 2, \
@@ -859,7 +872,7 @@ fn selecting_the_combat_log_keeps_the_dock_driver_running() {
)
.unwrap();
s.mouse_move(x, y);
for _ in 0..25 {
for _ in 0..45 {
s.tick(0.016);
s.resolve();
}
@@ -873,19 +886,16 @@ fn selecting_the_combat_log_keeps_the_dock_driver_running() {
),
"the hovered dock starts with General selected — this test must not pass vacuously"
);
// Click the Combat Log tab, then advance the clock WITHOUT touching FCF_OnUpdate.
s.run("BenillaFCF_TabClick(2)").unwrap();
s.run("FCF_SelectDockFrame(ChatFrame2)").unwrap();
for _ in 0..10 {
s.tick(0.016);
s.resolve();
}
assert!(s.errors().is_empty(), "{:?}", s.errors());
assert!(
s.eval::<bool>("return ChatFrame2:IsShown() and not ChatFrame1:IsShown()")
.unwrap(),
"the click swapped the windows"
"the selection swapped the windows"
);
let (a1, a2): (f32, f32) = s
.eval("return ChatFrame1Tab:GetAlpha(), ChatFrame2Tab:GetAlpha()")
@@ -908,11 +918,11 @@ fn the_dock_still_fades_out_with_the_combat_log_selected() {
)
.unwrap();
s.mouse_move(x, y);
for _ in 0..25 {
for _ in 0..45 {
s.tick(0.016);
s.resolve();
}
s.run("BenillaFCF_TabClick(2)").unwrap();
s.run("FCF_SelectDockFrame(ChatFrame2)").unwrap();
for _ in 0..10 {
s.tick(0.016);
s.resolve();
@@ -924,17 +934,16 @@ fn the_dock_still_fades_out_with_the_combat_log_selected() {
),
"revealed before the cursor leaves"
);
s.mouse_move(1500.0, 850.0); // away from the dock
for _ in 0..20 {
for _ in 0..45 {
s.tick(0.016);
s.resolve();
}
assert!(s.errors().is_empty(), "{:?}", s.errors());
let a2: f32 = s.eval("return ChatFrame2Tab:GetAlpha()").unwrap();
// The reference fades the tab out and `FCF_ChatTabFadeFinished` hides it.
assert!(
a2 < 1e-6,
"the dock conceals itself again on leave — got Combat Log tab alpha {a2}"
!s.eval::<bool>("return ChatFrame2Tab:IsVisible()").unwrap(),
"the dock conceals itself again on leave"
);
}
@@ -945,7 +954,7 @@ fn the_dock_still_fades_out_with_the_combat_log_selected() {
#[test]
fn the_combat_log_window_runs_its_own_bottom_button_blink() {
let mut s = chat_frame();
s.run("BenillaFCF_TabClick(2)").unwrap();
s.run("FCF_SelectDockFrame(ChatFrame2)").unwrap();
for i in 0..40 {
s.add_chat_message("ChatFrame2", &format!("line {i}"), 1.0, 1.0, 1.0);
}
@@ -987,7 +996,14 @@ fn the_chat_menu_builds_its_rows_on_the_references_kit() {
r"Interface\FrameXML\UIPanelTemplates.xml",
"GameTooltip.xml",
r"Interface\FrameXML\UIMenu.xml",
"ChatFrame.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\UIMenu.xml",
"Interface\\FrameXML\\ChatFrame.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"Interface\\FrameXML\\UIPanelTemplates.lua",
"Interface\\FrameXML\\UIPanelTemplates.xml",
"Interface\\FrameXML\\FloatingChatFrame.xml",
] {
load_xml(&s, f);
}
@@ -1023,10 +1039,13 @@ fn the_chat_menu_builds_its_rows_on_the_references_kit() {
.unwrap(),
"the reference's row click hides the menu"
);
// `ChatMenu_Say` → `ChatMenu_SetChatType(chatFrame, "SAY")`: the box opens and its type is
// set directly — no slash is typed into it (ChatFrame.lua l.2245-2255).
assert!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
.unwrap()
.starts_with("/s"),
"and it seeded the edit box with the row's slash"
s.eval::<bool>(
"return ChatFrameEditBox:IsVisible() and ChatFrameEditBox.chatType == 'SAY'"
)
.unwrap(),
"and it opened the edit box as SAY"
);
}
@@ -40,7 +40,13 @@ const ROOM_UI: &[&str] = &[
"Interface\\FrameXML\\StackSplitFrame.xml",
"DressUpFrame.xml",
"Interface\\FrameXML\\UIMenu.xml", // the kit ChatMenu/EmoteMenu/VoiceMacroMenu build from
"ChatFrame.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\ChatFrame.xml",
"Interface\\FrameXML\\UIPanelTemplates.lua",
"Interface\\FrameXML\\UIPanelTemplates.xml",
"UiPanels.xml",
"Interface\\FrameXML\\FloatingChatFrame.xml",
];
/// The shipped files the dressing room's click sites need, in manifest order — **with no bag
@@ -99,7 +105,18 @@ fn shown_paper_doll() -> UiScript {
for file in CHARACTER_UI {
super::test_ui::load_ui_strict(&s, file);
}
for file in ["DressUpFrame.xml", "ChatFrame.xml"] {
for file in [
"DressUpFrame.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\UIMenu.xml",
"Interface\\FrameXML\\ChatFrame.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"Interface\\FrameXML\\UIPanelTemplates.lua",
"Interface\\FrameXML\\UIPanelTemplates.xml",
"UiPanels.xml",
"Interface\\FrameXML\\FloatingChatFrame.xml",
] {
super::test_ui::load_ui_strict(&s, file);
}
s.set_unit(
@@ -148,7 +148,14 @@ fn escape_is_consumed_by_a_focused_editbox_and_leaves_windows_open() {
}
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
load_xml(&s, "Interface\\FrameXML\\UIMenu.xml"); // the kit the chat menus build from
load_xml(&s, "ChatFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\ChatFrame.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.lua");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Interface\\FrameXML\\FloatingChatFrame.xml");
s.set_money(0);
s.set_container(0, Some(one_item_backpack()));
@@ -237,7 +244,14 @@ fn escape_closes_an_open_stack_split_frame() {
load_xml(&s, file);
}
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); // ContainerFrameItemButton_OnClick reads MerchantFrame
load_xml(&s, "ChatFrame.xml"); // …and ChatFrameEditBox, the shift fork's first test
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\UIMenu.xml");
load_xml(&s, "Interface\\FrameXML\\ChatFrame.xml"); // …and ChatFrameEditBox, the shift fork's first test
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.lua");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Interface\\FrameXML\\FloatingChatFrame.xml");
load_xml(&s, "Interface\\FrameXML\\StackSplitFrame.xml");
s.set_money(0);
@@ -180,97 +180,6 @@ const KNOWN: &[Known] = &[
// because OUR `ScrollFrame` ctor took the mouse and the reference's does not. Correcting the
// ctor list to the client's made both sides agree, and an accepted divergence that has been
// fixed is documentation claiming a defect we do not have.
Known {
frame: "ChatFrame1",
flag: Flag::Mouse,
why: "our chat window takes the mouse and the reference's does not. It was believed a \
`<ScrollingMessageFrame>` takes it BY CONSTRUCTION; wow-re's follow-up round \
(`68987021`) proved that ctor leaves the bit clear, and that a 1.12 chat link is \
clickable through one synthesised `CSimpleHyperlinkButton` child per link span. Our \
hit test now carries that law as a span disjunct, so the LINKS no longer need the \
flag — but ChatFrame.xml still sets it, and a documented chain of choices hangs off \
the old premise (no `SetFrameLevel-1` OnLoad because it would sink the resize grips \
under a mouse-enabled parent; `OnClick` dismisses a held spell, 0843). Unwinding \
that is the chat window's own piece of work, not this flag's.",
},
Known {
frame: "ChatFrame2",
flag: Flag::Mouse,
why: "our chat window takes the mouse and the reference's does not. It was believed a \
`<ScrollingMessageFrame>` takes it BY CONSTRUCTION; wow-re's follow-up round \
(`68987021`) proved that ctor leaves the bit clear, and that a 1.12 chat link is \
clickable through one synthesised `CSimpleHyperlinkButton` child per link span. Our \
hit test now carries that law as a span disjunct, so the LINKS no longer need the \
flag — but ChatFrame.xml still sets it, and a documented chain of choices hangs off \
the old premise (no `SetFrameLevel-1` OnLoad because it would sink the resize grips \
under a mouse-enabled parent; `OnClick` dismisses a held spell, 0843). Unwinding \
that is the chat window's own piece of work, not this flag's.",
},
Known {
frame: "ChatFrame3",
flag: Flag::Mouse,
why: "our chat window takes the mouse and the reference's does not. It was believed a \
`<ScrollingMessageFrame>` takes it BY CONSTRUCTION; wow-re's follow-up round \
(`68987021`) proved that ctor leaves the bit clear, and that a 1.12 chat link is \
clickable through one synthesised `CSimpleHyperlinkButton` child per link span. Our \
hit test now carries that law as a span disjunct, so the LINKS no longer need the \
flag — but ChatFrame.xml still sets it, and a documented chain of choices hangs off \
the old premise (no `SetFrameLevel-1` OnLoad because it would sink the resize grips \
under a mouse-enabled parent; `OnClick` dismisses a held spell, 0843). Unwinding \
that is the chat window's own piece of work, not this flag's.",
},
Known {
frame: "ChatFrame4",
flag: Flag::Mouse,
why: "our chat window takes the mouse and the reference's does not. It was believed a \
`<ScrollingMessageFrame>` takes it BY CONSTRUCTION; wow-re's follow-up round \
(`68987021`) proved that ctor leaves the bit clear, and that a 1.12 chat link is \
clickable through one synthesised `CSimpleHyperlinkButton` child per link span. Our \
hit test now carries that law as a span disjunct, so the LINKS no longer need the \
flag — but ChatFrame.xml still sets it, and a documented chain of choices hangs off \
the old premise (no `SetFrameLevel-1` OnLoad because it would sink the resize grips \
under a mouse-enabled parent; `OnClick` dismisses a held spell, 0843). Unwinding \
that is the chat window's own piece of work, not this flag's.",
},
Known {
frame: "ChatFrame5",
flag: Flag::Mouse,
why: "our chat window takes the mouse and the reference's does not. It was believed a \
`<ScrollingMessageFrame>` takes it BY CONSTRUCTION; wow-re's follow-up round \
(`68987021`) proved that ctor leaves the bit clear, and that a 1.12 chat link is \
clickable through one synthesised `CSimpleHyperlinkButton` child per link span. Our \
hit test now carries that law as a span disjunct, so the LINKS no longer need the \
flag — but ChatFrame.xml still sets it, and a documented chain of choices hangs off \
the old premise (no `SetFrameLevel-1` OnLoad because it would sink the resize grips \
under a mouse-enabled parent; `OnClick` dismisses a held spell, 0843). Unwinding \
that is the chat window's own piece of work, not this flag's.",
},
Known {
frame: "ChatFrame6",
flag: Flag::Mouse,
why: "our chat window takes the mouse and the reference's does not. It was believed a \
`<ScrollingMessageFrame>` takes it BY CONSTRUCTION; wow-re's follow-up round \
(`68987021`) proved that ctor leaves the bit clear, and that a 1.12 chat link is \
clickable through one synthesised `CSimpleHyperlinkButton` child per link span. Our \
hit test now carries that law as a span disjunct, so the LINKS no longer need the \
flag — but ChatFrame.xml still sets it, and a documented chain of choices hangs off \
the old premise (no `SetFrameLevel-1` OnLoad because it would sink the resize grips \
under a mouse-enabled parent; `OnClick` dismisses a held spell, 0843). Unwinding \
that is the chat window's own piece of work, not this flag's.",
},
Known {
frame: "ChatFrame7",
flag: Flag::Mouse,
why: "our chat window takes the mouse and the reference's does not. It was believed a \
`<ScrollingMessageFrame>` takes it BY CONSTRUCTION; wow-re's follow-up round \
(`68987021`) proved that ctor leaves the bit clear, and that a 1.12 chat link is \
clickable through one synthesised `CSimpleHyperlinkButton` child per link span. Our \
hit test now carries that law as a span disjunct, so the LINKS no longer need the \
flag — but ChatFrame.xml still sets it, and a documented chain of choices hangs off \
the old premise (no `SetFrameLevel-1` OnLoad because it would sink the resize grips \
under a mouse-enabled parent; `OnClick` dismisses a held spell, 0843). Unwinding \
that is the chat window's own piece of work, not this flag's.",
},
// The two `TargetofTarget*Bar` entries RETIRED here (the unit-frame migration), for the same
// reason their twin the pet page's XP bar went: they said our bars were plain StatusBars where
// the reference inherits `TextStatusBar` — true of our transcription, and no longer true of
@@ -694,7 +603,10 @@ fn the_shipped_frames_carry_the_references_flags() {
}
assert!(
compared > 400,
// 1948 retired our ChatFrame.xml and 1952 our SpellBookFrame.xml (their frames are the
// reference's own now), which took the paired count from the low 400s to the low 360s;
// the floor guards the pairing, not the census.
compared > 350,
"only {compared} frames compared — the pairing broke, and the sweep guards nothing"
);
assert!(
@@ -611,7 +611,14 @@ fn ctrl_and_shift_on_the_roll_icon_preview_and_post_its_link() {
load_xml(&s, "UIParent.xml"); // BenillaChatEdit_InsertLink, the shared shift-insert helper
load_xml(&s, "DressUpFrame.xml");
load_xml(&s, "Interface\\FrameXML\\UIMenu.xml"); // the kit the chat menus build from
load_xml(&s, "ChatFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\ChatFrame.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.lua");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Interface\\FrameXML\\FloatingChatFrame.xml");
s.set_loot_rolls(rolls());
// Roll 8 (BoE Worn Shortsword) claims frame 1 — a non-BoP roll, so the dice below can land a
@@ -613,7 +613,14 @@ fn ctrl_and_shift_on_a_loot_row_preview_and_post_without_looting() {
"Interface\\FrameXML\\LootFrame.xml",
"DressUpFrame.xml",
"Interface\\FrameXML\\UIMenu.xml", // the kit ChatMenu/EmoteMenu/VoiceMacroMenu build from
"ChatFrame.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\ChatFrame.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"Interface\\FrameXML\\UIPanelTemplates.lua",
"Interface\\FrameXML\\UIPanelTemplates.xml",
"UiPanels.xml",
"Interface\\FrameXML\\FloatingChatFrame.xml",
] {
load_xml(&s, file);
}
@@ -1140,7 +1140,14 @@ fn ctrl_and_shift_on_a_vendor_row_preview_and_post_without_buying() {
"Interface\\FrameXML\\MerchantFrame.xml",
"DressUpFrame.xml",
"Interface\\FrameXML\\UIMenu.xml", // the kit ChatMenu/EmoteMenu/VoiceMacroMenu build from
"ChatFrame.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\ChatFrame.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"Interface\\FrameXML\\UIPanelTemplates.lua",
"Interface\\FrameXML\\UIPanelTemplates.xml",
"UiPanels.xml",
"Interface\\FrameXML\\FloatingChatFrame.xml",
] {
load_xml(&s, file);
}
+27
View File
@@ -771,6 +771,33 @@ impl benilla_ui::script::TextMeasure for FixedWidthFont {
#[cfg(test)]
mod test_ui;
/// The chat loader's two login events (wow-re chat-cache-grammar.md §8; `ui_chat::settings`):
/// `UPDATE_CHAT_WINDOWS` once, then `UPDATE_CHAT_COLOR` for every registry entry. The reference's
/// `FloatingChatFrame_Update` docks, hides and colours the windows on the first and
/// `ChatFrame_OnEvent` fills `ChatTypeInfo` from the second, so a test VM that loads the
/// reference's chat files needs both before the windows look or route like a client's.
#[cfg(test)]
pub(crate) fn fire_chat_login(s: &mut benilla_ui::script::UiScript) {
// `FCF_OnUpdate` reads `UIOptionsFrame:IsShown()` every frame — the reference's options
// window, which OptionsFrame.xml aliases and a chat test VM does not load. A hidden stand-in
// is what a closed options window answers.
s.run("if not UIOptionsFrame then UIOptionsFrame = CreateFrame('Frame') UIOptionsFrame:Hide() end")
.expect("the options stand-in");
s.fire_event("UPDATE_CHAT_WINDOWS", vec![]);
let renorm = |b: u8| f64::from(b as f32 * (1.0f32 / 255.0f32));
for entry in s.chat_colors() {
s.fire_event(
"UPDATE_CHAT_COLOR",
vec![
benilla_ui::script::ScriptValue::Str(entry.name),
benilla_ui::script::ScriptValue::Number(renorm(entry.rgb[0])),
benilla_ui::script::ScriptValue::Number(renorm(entry.rgb[1])),
benilla_ui::script::ScriptValue::Number(renorm(entry.rgb[2])),
],
);
}
}
/// [`test_ui::load_ui`] for a test module OUTSIDE `ui_script` — `ui_action::feed_tests` drives the
/// real `UIErrorsFrame` end to end and needs the same both-stores reader everything else uses.
#[cfg(test)]
@@ -796,7 +796,12 @@ fn chat_harness() -> UiScript {
"GameTooltip.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"Interface\\FrameXML\\UIMenu.xml", // the kit ChatMenu/EmoteMenu/VoiceMacroMenu build from
"ChatFrame.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\ChatFrame.xml",
"Interface\\FrameXML\\UIPanelTemplates.lua",
"Interface\\FrameXML\\UIPanelTemplates.xml",
"Interface\\FrameXML\\FloatingChatFrame.xml",
],
);
harness_on(s)
@@ -112,7 +112,10 @@ fn setup() -> UiScript {
load_xml(&s, "GameTooltip.xml"); // TOOLTIP_DEFAULT_COLOR, read by the dropdown backdrops
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml"); // ChatFrame's seven dropdowns inherit its template
load_xml(&s, "Interface\\FrameXML\\UIMenu.xml"); // the kit the chat menus build from
load_xml(&s, "ChatFrame.xml");
load_xml(&s, "Interface\\FrameXML\\ChatFrame.xml");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.lua");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\FloatingChatFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GuildRegistrarFrame.xml");
load_xml(&s, "Interface\\FrameXML\\PetitionFrame.xml");
s
@@ -801,7 +801,11 @@ fn reward_rows_preview_and_post_without_selecting_the_choice() {
load_xml(&s, "UIParent.xml"); // BenillaChatEdit_InsertLink lives here
load_xml(&s, "DressUpFrame.xml"); // DressUpItemLink lives here
load_xml(&s, "Interface\\FrameXML\\UIMenu.xml"); // the kit its menus build from
load_xml(&s, "ChatFrame.xml"); // ChatFrameEditBox lives here
load_xml(&s, "Interface\\FrameXML\\ChatFrame.xml"); // ChatFrameEditBox lives here
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.lua");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\FloatingChatFrame.xml");
const SWORD: &str = "|cffffffff|Hitem:2299:0:0:0|h[Worn Sword]|h|r";
const MACE: &str = "|cffffffff|Hitem:2300:0:0:0|h[Worn Mace]|h|r";
@@ -343,7 +343,10 @@ fn shift_click_toggles_the_watch_checkbox_and_the_tracker_hud() {
load_xml(&s, "UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "Interface\\FrameXML\\UIMenu.xml");
load_xml(&s, "ChatFrame.xml");
load_xml(&s, "Interface\\FrameXML\\ChatFrame.xml");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.lua");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\FloatingChatFrame.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
s.set_quest_log(eight_entries());
@@ -485,7 +488,10 @@ fn watch_guards_no_op_without_erroring() {
load_xml(&s, "UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "Interface\\FrameXML\\UIMenu.xml");
load_xml(&s, "ChatFrame.xml");
load_xml(&s, "Interface\\FrameXML\\ChatFrame.xml");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.lua");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\FloatingChatFrame.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
s.set_quest_log(eight_entries());
@@ -1416,7 +1422,10 @@ fn reward_rows_preview_and_post_and_a_plain_click_stays_inert() {
load_xml(&s, "UIParent.xml"); // BenillaChatEdit_InsertLink lives here
load_xml(&s, "DressUpFrame.xml"); // DressUpItemLink lives here
load_xml(&s, "Interface\\FrameXML\\UIMenu.xml"); // the kit its menus build from
load_xml(&s, "ChatFrame.xml"); // ChatFrameEditBox lives here
load_xml(&s, "Interface\\FrameXML\\ChatFrame.xml"); // ChatFrameEditBox lives here
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.lua");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\FloatingChatFrame.xml");
s.set_quest_log(eight_entries());
s.run("ToggleQuestLog()").unwrap();
@@ -1507,7 +1516,10 @@ fn shift_click_on_a_title_posts_the_quest_name_with_chat_open_and_watches_with_i
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
load_xml(&s, "UIParent.xml"); // BenillaChatEdit_InsertLink lives here
load_xml(&s, "Interface\\FrameXML\\UIMenu.xml"); // the kit its menus build from
load_xml(&s, "ChatFrame.xml"); // ChatFrameEditBox lives here
load_xml(&s, "Interface\\FrameXML\\ChatFrame.xml"); // ChatFrameEditBox lives here
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.lua");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\FloatingChatFrame.xml");
s.set_quest_log(eight_entries());
s.run("ToggleQuestLog()").unwrap();
+129 -51
View File
@@ -268,6 +268,47 @@ mod tests {
/// block), Lua `--[[ … ]]` blocks, and XML `<!-- … -->` blocks. It does not track string
/// literals, so a `"--"` inside a string truncates that line — which can only ever cause an
/// UNDER-report, the safe direction for every reader of it.
/// Blank the contents of every Lua string literal (`"…"`, `'…'`, `[[…]]`), keeping the quotes,
/// so a pattern like `"/([^%s]+)%s(.*)"` cannot read as a call to `s`.
fn strip_strings(text: &str) -> String {
let b: Vec<char> = text.chars().collect();
let mut out = String::with_capacity(text.len());
let mut i = 0;
while i < b.len() {
let c = b[i];
if c == '"' || c == '\'' {
out.push(c);
i += 1;
while i < b.len() && b[i] != c && b[i] != '\n' {
if b[i] == '\\' {
i += 1;
}
i += 1;
}
if i < b.len() && b[i] == c {
out.push(c);
i += 1;
}
continue;
}
if c == '[' && i + 1 < b.len() && b[i + 1] == '[' {
out.push_str("[[");
i += 2;
while i + 1 < b.len() && !(b[i] == ']' && b[i + 1] == ']') {
i += 1;
}
if i + 1 < b.len() {
out.push_str("]]");
i += 2;
}
continue;
}
out.push(c);
i += 1;
}
out
}
fn strip_comments(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut in_xml = false;
@@ -1455,6 +1496,13 @@ mod tests {
update; our `inventory_alerts` snapshot is recomputed on every inventory push \
instead, so the recompute exists and only the Lua verb that forces one does not.",
),
(
"ChatFrame.xml",
"value",
"not a global: `for index, value in SlashCmdList do … value(msg)` \
(ChatFrame.lua l.2168-2171) calls the addon's handler through the table's loop \
variable. The scan has no scopes, so the name reads as a call.",
),
];
let toc = &super::super::addons::Addon::builtin().toc.files;
@@ -1481,7 +1529,7 @@ mod tests {
text.push('\n');
}
}
let text = strip_comments(&text);
let text = strip_strings(&strip_comments(&text));
let defines: std::collections::HashSet<String> = text
.lines()
.filter_map(|l| l.trim_start().strip_prefix("function "))
@@ -2022,7 +2070,6 @@ mod tests {
// built; none is 1819-shaped, because no PAIR is split (a half-fired pair is the tell).
const UNPRODUCED: &[(&str, &str)] = &[
("BAG_OPEN", "ContainerFrame.lua"),
("CHAT_MSG_RAID_WARNING", "RaidWarning.lua"),
(
"CVAR_UPDATE",
"TextStatusBar.lua — a CVar change does not repaint the bar text",
@@ -2036,6 +2083,12 @@ mod tests {
flow works without it",
),
("ITEM_TEXT_TRANSLATION", "ItemTextFrame.lua"),
(
"LEARNED_SPELL_IN_TAB",
"SpellBookFrame.lua — the tab flash when a new spell lands in it; the reference \
fires it with the tab index on spell add, which benilla's spellbook feed does not \
derive yet (1952)",
),
("PET_UI_CLOSE", "PetPaperDollFrame.lua"),
("PET_UI_UPDATE", "PetPaperDollFrame.lua"),
("PLAYER_DAMAGE_DONE_MODS", "PaperDollFrame.lua"),
@@ -2058,6 +2111,11 @@ mod tests {
"four files — the paperdoll model refresh",
),
("UNIT_PORTRAIT_UPDATE", "three files — the portrait refresh"),
(
"ZONE_UNDER_ATTACK",
"ChatFrame.lua — the reference's `SMSG_ZONE_UNDER_ATTACK` line (\"%s is under \
attack!\"); the wire handler is not built (1948)",
),
];
let mut fired: std::collections::HashSet<String> =
@@ -2067,6 +2125,50 @@ mod tests {
.and_then(|p| p.parent())
.expect("workspace root")
.join("crates");
let caps = |lit: &str| {
!lit.is_empty()
&& lit
.chars()
.all(|c| c.is_ascii_uppercase() || c == '_' || c.is_ascii_digit())
};
// The two literal shapes an indirect fire's file can carry: `"EVENT", vec!` and a match
// arm `=> "EVENT",` — the event-name table a `fire_event(name_of(kind), …)` reads.
let scan_arms = |text: &str, fired: &mut std::collections::HashSet<String>| {
const SHAPE: &str = "\", vec!";
for (i, _) in text.match_indices(SHAPE) {
let before = &text[..i];
let Some(q) = before.rfind('"') else { continue };
let lit = &before[q + 1..];
if caps(lit) {
fired.insert(lit.to_string());
}
}
let lines: Vec<&str> = text.lines().map(str::trim).collect();
for (i, t) in lines.iter().enumerate() {
let (t, arm_value) = match t.find("=> \"") {
Some(k) => (t[k + 3..].trim_end_matches(','), true),
None => (*t, false),
};
let Some(lit) = t.strip_prefix('"').and_then(|x| x.strip_suffix('"')) else {
continue;
};
if arm_value && caps(lit) {
fired.insert(lit.to_string());
continue;
}
let arm = i > 0
&& lines[i - 1].ends_with('{')
&& lines.get(i + 1).is_some_and(|n| n.starts_with('}'));
if arm && caps(lit) {
fired.insert(lit.to_string());
}
}
};
// An indirect fire — `fire_event(EXECUTE_CHAT_LINE, …)`, `fire_event(event_name(kind), …)`
// — names a const or a function; the literal lives where THAT is defined, which may be
// another file (1948: `ui_chat::event::event_name` answers for `frames::route`'s fire).
let mut indirect: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut texts: Vec<String> = Vec::new();
let mut stack = vec![root];
while let Some(dir) = stack.pop() {
for e in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
@@ -2075,11 +2177,6 @@ mod tests {
stack.push(p);
} else if p.extension().is_some_and(|x| x == "rs") {
let text = std::fs::read_to_string(&p).unwrap_or_default();
// Split so this file cannot match its own walker (see the sibling gate).
// Two producer shapes: `UiScript::fire_event(` and its `&Lua` twin
// `tick::fire_event_into(` (1924), which an engine verb fires from inside a
// binding — `UpdateSpells` → SPELLS_CHANGED, `ChangeActionBarPage` →
// ACTIONBAR_PAGE_CHANGED (1938).
const CALLS: [&str; 2] =
[concat!("fire_event", "("), concat!("fire_event_into", "(")];
let mut fires_indirectly = false;
@@ -2090,59 +2187,40 @@ mod tests {
from = at;
let rest = text[at..].trim_start();
let rest = rest.strip_prefix("lua,").map_or(rest, str::trim_start);
if !rest.starts_with('"') {
fires_indirectly = true;
}
if let Some(body) = rest.strip_prefix('"') {
if let Some(end) = body.find('"') {
fired.insert(body[..end].to_string());
}
} else {
fires_indirectly = true;
let ident: String = rest
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect();
if !ident.is_empty() {
indirect.insert(ident);
}
}
}
}
if fires_indirectly {
// An indirect fire names its event either as `"NAME", vec![…]` or as one
// arm of an `if … { "A" } else { "B" }` chosen before the call — a literal
// standing alone on its line (`ui_action/state.rs`'s START/STOP_AUTOREPEAT).
const SHAPE: &str = "\", vec!";
let caps = |lit: &str| {
!lit.is_empty()
&& lit.chars().all(|c| {
c.is_ascii_uppercase() || c == '_' || c.is_ascii_digit()
})
};
for (i, _) in text.match_indices(SHAPE) {
let before = &text[..i];
let Some(q) = before.rfind('"') else { continue };
let lit = &before[q + 1..];
if caps(lit) {
fired.insert(lit.to_string());
}
}
let lines: Vec<&str> = text.lines().map(str::trim).collect();
for (i, t) in lines.iter().enumerate() {
// …or the value of a `match` arm (`QuestPanel::Progress =>
// "QUEST_PROGRESS",` — `ui_quest.rs`'s `panel_event`).
let (t, arm_value) = match t.find("=> \"") {
Some(k) => (t[k + 3..].trim_end_matches(','), true),
None => (*t, false),
};
let Some(lit) = t.strip_prefix('"').and_then(|x| x.strip_suffix('"'))
else {
continue;
};
if arm_value && caps(lit) {
fired.insert(lit.to_string());
continue;
}
let arm = i > 0
&& lines[i - 1].ends_with('{')
&& lines.get(i + 1).is_some_and(|n| n.starts_with('}'));
if arm && caps(lit) {
fired.insert(lit.to_string());
}
}
scan_arms(&text, &mut fired);
}
texts.push(text);
}
}
}
for text in &texts {
for ident in &indirect {
let decl = format!("const {ident}: &str = \"");
if let Some(k) = text.find(&decl) {
let body = &text[k + decl.len()..];
if let Some(end) = body.find('"') {
fired.insert(body[..end].to_string());
}
}
if text.contains(&format!("fn {ident}(")) {
scan_arms(text, &mut fired);
}
}
}
@@ -192,6 +192,13 @@ fn the_autocast_brackets_reach_each_buttons_corners() {
}
}
}
// The spell book's overlay is the reference's own template resized by SpellBookAdapters.xml's
// script (1952), so its number is measured off a VM, not read off an XML.
let s = super::spellbook_tests::spellbook_ui(1024.0, 768.0);
let (overlay, button): (f32, f32) = s
.eval("return SpellButton1AutoCastable:GetWidth(), SpellButton1:GetWidth()")
.unwrap();
found.push(("SpellButton1AutoCastable".into(), overlay * ART, button));
assert_eq!(found.len(), 2, "expected two autocast overlays: {found:?}");
for (name, brackets, button) in &found {
assert!(
@@ -213,8 +220,10 @@ fn the_autocast_brackets_reach_each_buttons_corners() {
/// The spell book is **one deliberate deviation** (decision 1392). The reference writes
/// `scale="1.22"` into a 36-unit viewport — a 0.87x rim that floats clear of the edge, is never
/// clipped, and washes the icon; the real 1.12 client looks the same way (director-checked), so
/// this is taste, not fidelity. We write 1.44 to borrow the pet button's ratio. This test is what
/// stops that drifting, or being "corrected" back to 1.22 by someone who only read the ref.
/// this is taste, not fidelity. We write 1.48 on a 37-unit rim (1393's numbers, set by
/// SpellBookAdapters.xml over the reference's own template since 1952) to borrow the pet button's
/// ratio. This test is what stops that drifting, or being "corrected" back to 1.22 by someone
/// who only read the ref.
#[test]
fn the_shine_tokens_ask_for_the_rims_we_meant() {
use benilla_ui::framexml::{Element, TopLevel};
@@ -272,6 +281,41 @@ fn the_shine_tokens_ask_for_the_rims_we_meant() {
}
}
}
// The spell book's shine is created by SpellBookAdapters.xml's script beside the reference's
// own Model (1952), so its token is read off a VM's extract — where the renderer reads it —
// not an XML. (Not `GetTexture`, which answers the path up to its last dot, the reference's
// own shape, and so would cut the token's scale in half.)
let mut s = super::spellbook_tests::spellbook_ui(1024.0, 768.0);
s.run("ToggleSpellBook(BOOKTYPE_SPELL)").unwrap();
s.tick(0.05);
s.resolve();
s.run("SpellButton1Shine:Show()").unwrap();
s.resolve();
let quads = s.extract();
let token = quads
.iter()
.find_map(|q| match &q.content {
benilla_ui::script::QuadContent::Texture { path: Some(p), .. }
if p.starts_with(crate::autocast_shine::SHINE_TOKEN) =>
{
Some(p.clone())
}
_ => None,
})
.unwrap_or_else(|| {
let owned: Vec<_> = quads
.iter()
.filter(|q| {
s.quad_owner_name(q.target)
.is_some_and(|n| n.starts_with("SpellButton1"))
})
.map(|q| (s.quad_owner_name(q.target), format!("{:?}", q.content)))
.collect();
panic!("no shine token among SpellButton1's quads: {owned:?}")
});
let scale = crate::autocast_shine::token_model_scale(&token).expect("a shine token");
let view: f32 = s.eval("return SpellButton1Shine:GetWidth()").unwrap();
found.push(("SpellButton1Shine".into(), 0.02 * 1280.0 * scale, view));
assert_eq!(
found.len(),
2,
@@ -80,9 +80,49 @@ fn click(s: &mut UiScript, name: &str, button: &str) {
s.mouse_button(x, y, button, false);
}
/// The reference's spellbook needs the action-bar chain beneath it: `SpellBookFrame_OnShow` and
/// `_OnHide` call `MultiActionBar_ShowAllGrids`/`HideAllGrids` (MultiActionBars.lua, whose file
/// wants the options window's uvars), `OnShow` calls `UpdateMicroButtons`, and the window docks
/// in UiPanels' left slot. One chain, in the manifest's order, for every test that opens it.
pub(super) fn spellbook_ui(w: f32, h: f32) -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(w, h);
for f in [
"Interface\\FrameXML\\Fonts.xml",
"UIParent.xml",
"Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\MainMenuBar.xml",
"MoneyFrame.xml",
"GameTooltip.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
r"Interface\FrameXML\OptionsFrameTemplates.xml",
r"Interface\FrameXML\ReputationFrame.xml",
"UiPanels.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"ScrollTemplates.xml",
"KeyBindingsPage.xml",
"OptionsFrame.xml",
"Interface\\FrameXML\\MultiActionBars.xml",
"MicroMenu.xml",
"Interface\\FrameXML\\SpellBookFrame.xml",
"SpellBookAdapters.xml",
] {
load_xml(&s, f);
}
s
}
/// The loader itself: every file the window depends on parses and materializes with no errors —
/// the window + close + prev/next page buttons + 12 spell buttons (each with a Cooldown child)
/// + 8 skill-line tabs + the 3 Spell/Pet toggle tabs.
/// the window + close + prev/next page buttons + 12 spell buttons (each with a Cooldown and an
/// AutoCast Model child) + 8 skill-line tabs + the 3 Spell/Pet toggle tabs + the tab flash frame.
#[test]
fn shipped_spellbook_loads_clean() {
let s = UiScript::new().unwrap();
@@ -92,22 +132,28 @@ fn shipped_spellbook_loads_clean() {
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "GameTooltip.xml");
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui/SpellBookFrame.xml"),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"loader errors: {:?}",
report.errors
);
// The reference's own file, off the chain, with the one adapter it needs from this engine.
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
let frames = load_xml(&s, "Interface\\FrameXML\\SpellBookFrame.xml");
load_xml(&s, "SpellBookAdapters.xml");
assert!(s.errors().is_empty(), "loader errors: {:?}", s.errors());
assert_eq!(
report.frames, 39,
"window + close + prev/next + 12 spell buttons (each with a Cooldown child) + 8 \
skill-line tabs + the 3 Spell/Pet toggle tabs (decision 1032)"
frames, 52,
"window + close + prev/next + 12 spell buttons (each with its Cooldown and AutoCast \
Model children) + 8 skill-line tabs + the 3 Spell/Pet toggle tabs + the tab flash frame"
);
for name in [
"SpellBookFrame",
"SpellButton12",
"SpellButton1AutoCast",
"SpellButton1Shine",
] {
assert!(
s.eval::<bool>(&format!("return {name} ~= nil")).unwrap(),
"{name} exists"
);
}
}
/// The whole contract in one end-to-end drive: `ToggleSpellBook` (the 'P' binding's entry point)
@@ -117,32 +163,7 @@ fn shipped_spellbook_loads_clean() {
/// bar-to-bar drag uses — packing kind 0x00 (SPELL) with the spell id.
#[test]
fn shipped_spellbook_drives_end_to_end() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.xml");
load_xml(&s, "UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\MainMenuBar.xml");
load_xml(&s, "Interface\\FrameXML\\ActionBarFrame.xml");
load_xml(&s, "Interface\\FrameXML\\BonusActionBarFrame.xml");
// The reference declares the reputation WATCH BAR in `ReputationFrame.xml`, and
// `ExhaustionTick_Update` reads `ReputationWatchBar:IsShown()` twice — the reference's own
// coupling of MainMenuBar to that pane. So an action-bar harness loads it, and with it the
// two template files its check boxes inherit through (1875).
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\OptionsFrameTemplates.xml");
load_xml(&s, r"Interface\FrameXML\ReputationFrame.xml");
load_xml(&s, "SpellBookFrame.xml");
let mut s = spellbook_ui(1024.0, 768.0);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.set_spellbook(book());
@@ -234,20 +255,7 @@ fn shipped_spellbook_drives_end_to_end() {
fn shipped_spellbook_shows_the_cooldown_pie() {
use benilla_ui::script::QuadContent;
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for f in [
"Interface\\FrameXML\\Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"GameTooltip.xml",
"Cooldown.xml",
"SpellBookFrame.xml",
] {
load_xml(&s, f);
}
let mut s = spellbook_ui(1024.0, 768.0);
s.set_spellbook(book());
s.run("ToggleSpellBook(BOOKTYPE_SPELL)").unwrap();
s.tick(10.0); // GetTime = 10
@@ -314,16 +322,15 @@ fn shipped_spellbook_shows_the_cooldown_pie() {
/// putting a gold ring on all 12 slots — the director's "spellbook looks very wrong".
#[test]
fn shipped_spellbook_empty_slot_draws_only_the_background() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(640.0, 700.0);
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "SpellBookFrame.xml");
// An EMPTY book: every slot takes the `id > offset + numSpells` disable path.
let mut s = spellbook_ui(640.0, 700.0);
// A one-spell book: slot 5 takes the reference's `id > offset + numSpells` disable path. (A
// book with NO spells is a state no character is ever in — the reference's own
// `ToggleSpellBook` clamps the page to 0 for it and every button id goes negative.)
let mut b = book();
b.tabs.truncate(1);
b.tabs[0].num_spells = 1;
b.slots.truncate(1);
s.set_spellbook(b);
s.run("ToggleSpellBook(BOOKTYPE_SPELL)").unwrap();
s.tick(0.05);
s.resolve();
@@ -352,9 +359,7 @@ fn shipped_spellbook_empty_slot_draws_only_the_background() {
/// l.132/134/268/296-303/336): 1/"true"/true check; 0/"false"/nil/non-numeric strings uncheck.
#[test]
fn set_checked_uses_blizzard_bool_coercion() {
let s = UiScript::new().unwrap();
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, "SpellBookFrame.xml");
let s = spellbook_ui(1024.0, 768.0);
for (arg, want) in [
("1", true),
("0", false),
@@ -421,16 +426,7 @@ fn pet_book() -> benilla_ui::script::PetBookState {
/// the class token's label, and a right-click flips autocast instead of casting.
#[test]
fn the_pet_tab_switches_books_and_renders_the_pets_spells() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "SpellBookFrame.xml");
let mut s = spellbook_ui(1024.0, 768.0);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.set_spellbook(book());
@@ -713,7 +709,14 @@ fn the_macro_editor_takes_a_shift_click_and_only_a_shift_click() {
r"Interface\FrameXML\OptionsFrameTemplates.xml",
r"Interface\FrameXML\ReputationFrame.xml",
r"Interface\AddOns\Blizzard_MacroUI\Blizzard_MacroUI.xml",
"SpellBookFrame.xml",
// The reference's spellbook shows and hides the multibar grids (MultiActionBars.lua),
// whose file wants the options window's uvars — the same tail `spellbook_ui` carries.
"Interface\\FrameXML\\UIDropDownMenu.xml",
"KeyBindingsPage.xml",
"OptionsFrame.xml",
"Interface\\FrameXML\\MultiActionBars.xml",
"Interface\\FrameXML\\SpellBookFrame.xml",
"SpellBookAdapters.xml",
] {
load_xml(&s, file);
}
+5 -1
View File
@@ -353,7 +353,11 @@ pub(super) const BAG_UI: &[&str] = &[
// `if ( ChatFrameEditBox:IsShown() )` (ContainerFrame.lua l.569) to decide between posting the
// item's link and splitting the stack, so a VM without it raises before either.
"Interface\\FrameXML\\UIMenu.xml", // the kit ChatMenu/EmoteMenu/VoiceMacroMenu build from
"ChatFrame.xml",
"Interface\\FrameXML\\ChatFrame.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"Interface\\FrameXML\\UIPanelTemplates.lua",
"Interface\\FrameXML\\UIPanelTemplates.xml",
"Interface\\FrameXML\\FloatingChatFrame.xml",
// Our adapters over the reference's container files — the keyring tooltip wrapper, the three
// bag verbs 0561 shadows (`OpenBackpack`/`CloseBackpack`/`CloseAllBags`), and the item-push
// card the reference draws with a `<Model>` this engine does not render (0887). It has to be
+104 -1
View File
@@ -199,10 +199,11 @@ impl Plugin for UiUnitPlugin {
.add_systems(Update, drain_worn_display_toggles.after(UiInput))
.add_systems(Update, drain_action_bar_toggles.after(UiInput))
.add_systems(Update, feed_default_language.in_set(UnitFeed))
.add_systems(Update, feed_known_languages.in_set(UnitFeed))
// `load_exhaustion_rows` pushes into the VM, so it runs per VM in `Update` (1290);
// `load_default_languages` only builds a Bevy resource and stays a one-shot.
.add_systems(Update, load_exhaustion_rows)
.add_systems(PostStartup, load_default_languages);
.add_systems(PostStartup, (load_default_languages, load_languages));
}
}
@@ -213,6 +214,108 @@ impl Plugin for UiUnitPlugin {
pub(crate) struct DefaultLanguagesRes(pub(crate) benilla_formats::DefaultLanguages);
/// Load `Languages.dbc` × `ChrRaces.dbc` once at startup ([`load_exhaustion_rows`]'s shape).
/// `Languages.dbc` in row order — the walk behind `GetNumLaguages`/`GetLanguageByIndex`.
#[derive(Resource)]
pub(crate) struct LanguagesRes(pub(crate) benilla_formats::Languages);
fn load_languages(mut commands: Commands, assets: Option<Res<benilla_assets::WorldAssets>>) {
let Some(assets) = assets else { return };
let loaded = {
use benilla_assets::LockRecover;
let mut chain = assets.chain.lock_recover();
benilla_formats::load_languages(&mut chain)
};
match loaded {
Ok(langs) => {
info!("ui_unit: {} Languages.dbc rows", langs.len());
commands.insert_resource(LanguagesRes(langs));
}
Err(e) => warn!("ui_unit: Languages.dbc unavailable — {e:#}"),
}
}
/// The languages this character **knows**, folded the reference's way and fed to the VM for
/// `GetNumLaguages`/`GetLanguageByIndex` (wow-re `chat-language-scramble.md` §8, C6):
///
/// 1. `0x4b25b0` runs on spell add and stores `[languageId] = spellId` for a spell whose
/// `Effect_1 == 39` — so the table holds only languages *this character's known spells*
/// declare ([`benilla_formats::SpellCatalog::declared_language`], spell → language, never the
/// other way round: five shipped language spells declare Common, and that anomaly is the
/// reference's).
/// 2. `GetNumLaguages` walks `Languages.dbc` rows and keeps those `0x5ec720` answers non-zero
/// for: the language's spell resolves to a `SkillLine` (`0x6de040`, race/class, spell) that is
/// present in the player's `PLAYER_SKILL_INFO` block. Presence, not value: a found line
/// returns 1 whatever its value.
///
/// One knowingly-unreproduced detail: a later learn overwrites an earlier spell on the same
/// language id in the reference's table. The known-spell set here is unordered, so when two
/// known spells declare one language, either's skill line passes — unobservable on shipped data
/// (every language skill a character holds is 300, and the only shared id is Common's).
pub(crate) fn known_languages(
known: impl IntoIterator<Item = u32>,
spells: &benilla_formats::SpellCatalog,
skill_lines: Option<&benilla_formats::SkillLineCatalog>,
has_skill_line: impl Fn(u32) -> bool,
languages: &benilla_formats::Languages,
) -> Vec<String> {
let mut declared: std::collections::HashMap<u32, Vec<u32>> = Default::default();
for spell in known {
if let Some(lang) = spells.declared_language(spell) {
declared.entry(lang).or_default().push(spell);
}
}
languages
.names(0)
.filter(|(id, _)| {
declared.get(id).is_some_and(|spells| {
spells.iter().any(|&spell| {
skill_lines
.and_then(|sl| sl.spell_to_line(spell))
.is_some_and(&has_skill_line)
})
})
})
.map(|(_, name)| name.to_string())
.collect()
}
fn feed_known_languages(
script: Option<NonSendMut<UiScript>>,
actions: Option<Res<crate::ui_action::PlayerActions>>,
spells: Option<Res<crate::ui_action::Spells>>,
skill_lines: Option<Res<crate::ui_spellbook::SkillLines>>,
languages: Option<Res<LanguagesRes>>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
mut pushed: Local<crate::ui_script::VmMemo<Option<Vec<String>>>>,
) {
let Some(mut script) = script else {
return;
};
let (Some(actions), Some(spells), Some(languages)) = (actions, spells, languages) else {
return;
};
let pushed = pushed.get(&script);
let store = self_q.iter().next();
let has_skill_line = |line: u32| {
store.is_some_and(|s| {
(0..benilla_protocol::messages::PLAYER_SKILL_SLOTS)
.filter_map(|i| s.0.player_skill(i))
.any(|slot| u32::from(slot.skill_id) == line)
})
};
let names = known_languages(
actions.spells.iter().copied(),
&spells.catalog,
skill_lines.as_deref().map(|s| &s.catalog),
has_skill_line,
&languages.0,
);
if pushed.as_ref() != Some(&names) {
script.set_known_languages(names.clone());
*pushed = Some(names);
}
}
fn load_default_languages(
mut commands: Commands,
assets: Option<Res<benilla_assets::WorldAssets>>,
+54
View File
@@ -86,6 +86,60 @@ fn chr_races_schema() -> Schema {
/// The field 8 the binding reads at `record + 0x20` (module doc).
const CHR_RACES_BASE_LANGUAGE: usize = 8;
/// `Languages.dbc` as the client walks it — **row order**, `(ID, Name_lang[8])`. Row order is the
/// order `GetNumLaguages`/`GetLanguageByIndex` count in (`0x49fb30`/`0x49fbe0` walk the store's
/// rows and filter, wow-re `chat-language-scramble.md` §8), so this keeps it rather than keying
/// by id.
#[derive(Debug, Default, Clone)]
pub struct Languages(Vec<(u32, [Option<String>; LOCALES])>);
impl Languages {
/// `(id, name)` per row in row order, for `locale`; a row with no name in that locale is
/// skipped (the binding pushes `Name_lang[locale]` verbatim and a NULL would be `""`).
pub fn names(&self, locale: usize) -> impl Iterator<Item = (u32, &str)> + '_ {
self.0
.iter()
.filter_map(move |(id, names)| Some((*id, names.get(locale)?.as_deref()?)))
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
/// A hand-built table for tests: `(id, enUS name)` rows.
pub fn from_rows(rows: &[(u32, &str)]) -> Self {
Self(
rows.iter()
.map(|(id, name)| {
let mut names: [Option<String>; LOCALES] = Default::default();
names[0] = Some((*name).to_string());
(*id, names)
})
.collect(),
)
}
}
pub fn load_languages(chain: &mut Chain) -> Result<Languages> {
let bytes = chain
.read_file(LANGUAGES)
.with_context(|| format!("reading {LANGUAGES}"))?;
let langs = parse(&bytes, languages_schema(), "Languages.dbc")?;
let mut rows = Vec::with_capacity(langs.records().len());
for r in langs.records() {
let Some(id) = u32_at(r, 0) else { continue };
let mut names: [Option<String>; LOCALES] = Default::default();
for (locale, slot) in names.iter_mut().enumerate() {
*slot = str_at(&langs, r, 1 + locale);
}
rows.push((id, names));
}
Ok(Languages(rows))
}
/// Load and join both tables into the race → language-name map.
pub fn load_default_languages(chain: &mut Chain) -> Result<DefaultLanguages> {
let lang_bytes = chain
+2 -1
View File
@@ -75,7 +75,8 @@ mod garble;
pub use garble::{garble, garble_chat, Garble, FLUENT_SKILL};
mod languages;
pub use languages::{
load_default_languages, load_language_words, DefaultLanguages, LanguagePool, LanguageWords,
load_default_languages, load_language_words, load_languages, DefaultLanguages, LanguagePool,
LanguageWords, Languages,
};
mod creature_families;
pub use creature_families::{
+395
View File
@@ -31,6 +31,118 @@ use mlua::{Lua, MultiValue, Value};
use super::Model;
/// The 1-based slot of `name`, case-insensitively — `GetChannelName`'s first return.
/// One `ChatChannels.dbc` row as the VM needs it for `JoinChannelByName` (decision 1908),
/// `Add/RemoveChatWindowChannel` and `EnumerateServerChannels` (wow-re chat-cache-grammar.md
/// §5-6): the id, the **Shortcut** (`General`, `Trade`, … — what every one of those verbs compares
/// a typed name against, whole and case-folded), the name composed for the zone the player is in
/// (`General - Elwynn Forest`; `None` while the zone text is empty, which is the verbs' nil leg),
/// and whether the row is listed here at all (a `flags & 0x10` city row is, only in a city).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ZoneChannelRow {
pub id: u32,
pub shortcut: String,
pub resolved: Option<String>,
pub listed: bool,
}
/// A channel verb's ask, drained by the app into its `CMSG_*` (all of which
/// `benilla-protocol::messages::client` already builds). The verbs are the stock
/// `ChatFrame.lua` slash handlers' calls, one variant each.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ChannelCommand {
/// `JoinChannelByName(name, password)` — sent on both of 1908's non-nil legs.
Join {
name: String,
password: String,
},
/// `LeaveChannelByName(name)`.
Leave {
name: String,
},
/// `ListChannelByName(name)` — `CMSG_CHANNEL_LIST`.
List {
name: String,
},
/// `ListChannels()` — the roster of every joined channel.
ListAll,
/// `DisplayChannelOwner(name)` — `CMSG_CHANNEL_OWNER`.
DisplayOwner {
name: String,
},
/// `SetChannelOwner(name, player)`.
SetOwner {
name: String,
player: String,
},
/// `SetChannelPassword(name, password)`.
SetPassword {
name: String,
password: String,
},
Ban {
name: String,
player: String,
},
Invite {
name: String,
player: String,
},
Kick {
name: String,
player: String,
},
Moderator {
name: String,
player: String,
},
Unmoderator {
name: String,
player: String,
},
Mute {
name: String,
player: String,
},
Unmute {
name: String,
player: String,
},
Unban {
name: String,
player: String,
},
/// `ChannelModerate(name)` — toggles moderation.
Moderate {
name: String,
},
/// `ChannelToggleAnnouncements(name)`.
ToggleAnnouncements {
name: String,
},
}
impl super::UiScript {
/// The zone-channel catalog for the player's current zone — fed whenever the zone (and so
/// every resolved name) changes.
pub fn set_zone_channel_catalog(&mut self, rows: Vec<ZoneChannelRow>) {
self.model_mut().zone_channel_catalog = rows;
}
/// Channel verbs called since the last drain, in call order.
pub fn take_channel_commands(&mut self) -> Vec<ChannelCommand> {
std::mem::take(&mut self.model_mut().channel_commands)
}
}
fn push(lua: &Lua, cmd: ChannelCommand) {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.channel_commands.push(cmd);
}
fn non_empty(s: Option<String>) -> Option<String> {
s.map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
}
fn slot_of(model: &Model, name: &str) -> Option<usize> {
model
.joined_channels
@@ -167,6 +279,169 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// JoinChannelByName(name [, password [, frameId]]) — `0x49ff00` → `0x49eb70`, the contract
// decision 1908 carved:
//
// matched DBC row → (ChannelID, resolvedName)
// custom channel (no row) → (0, nil) — and 0 is truthy in Lua
// a space in the name, or a matched row → nil — no send
// whose zone substitution is empty
//
// `CMSG_JOIN_CHANNEL` goes out on both non-nil legs. The third argument is the window the
// stock handler wants the channel in; that bookkeeping is `ChatFrame_AddChannel`'s (Lua), so
// the verb ignores it — exactly as the reference does.
g.set(
"JoinChannelByName",
lua.create_function(
|lua, (name, password, _frame): (Option<String>, Option<String>, Value)| {
let Some(name) = non_empty(name) else {
return Ok(MultiValue::from_vec(vec![Value::Nil]));
};
if name.contains(' ') {
return Ok(MultiValue::from_vec(vec![Value::Nil]));
}
let row = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model
.zone_channel_catalog
.iter()
.find(|r| r.shortcut.eq_ignore_ascii_case(&name))
.cloned()
};
let (id, resolved) = match row {
None => (0, None),
Some(ZoneChannelRow { resolved: None, .. }) => {
return Ok(MultiValue::from_vec(vec![Value::Nil]))
}
Some(ZoneChannelRow { id, resolved, .. }) => (id, resolved),
};
push(
lua,
ChannelCommand::Join {
name: resolved.clone().unwrap_or_else(|| name.clone()),
password: password.unwrap_or_default(),
},
);
Ok(MultiValue::from_vec(vec![
Value::Integer(i64::from(id)),
match resolved {
Some(r) => Value::String(lua.create_string(&r)?),
None => Value::Nil,
},
]))
},
)?,
)?;
// EnumerateServerChannels() — `0x4a1790` (chat-cache-grammar.md §6): the `ChatChannels.dbc`
// **shortcuts** in row order, a `flags & 0x10` row only when the zone is a city
// (`AreaTable.Flags & 0x8`); 0 values while the zone is unresolvable (an empty catalog here).
// Never the composed `<name> - <zone>`: `FCFDropDown_LoadServerChannels` shows these bare.
g.set(
"EnumerateServerChannels",
lua.create_function(|lua, _ignored: MultiValue| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let mut out = Vec::new();
for row in model.zone_channel_catalog.iter().filter(|r| r.listed) {
out.push(Value::String(lua.create_string(&row.shortcut)?));
}
Ok(MultiValue::from_vec(out))
})?,
)?;
// The one-name verbs: each queues its `CMSG_*` and returns nothing.
for (verb, make) in [
(
"LeaveChannelByName",
(|name| ChannelCommand::Leave { name }) as fn(String) -> ChannelCommand,
),
("ListChannelByName", |name| ChannelCommand::List { name }),
("DisplayChannelOwner", |name| ChannelCommand::DisplayOwner {
name,
}),
("ChannelModerate", |name| ChannelCommand::Moderate { name }),
("ChannelToggleAnnouncements", |name| {
ChannelCommand::ToggleAnnouncements { name }
}),
] {
g.set(
verb,
lua.create_function(move |lua, name: Option<String>| {
if let Some(name) = non_empty(name) {
push(lua, make(name));
}
Ok(())
})?,
)?;
}
g.set(
"ListChannels",
lua.create_function(|lua, _ignored: MultiValue| {
push(lua, ChannelCommand::ListAll);
Ok(())
})?,
)?;
// The two-name verbs: (channel, player) — or (channel, password) for the password.
for (verb, make) in [
(
"SetChannelOwner",
(|name, player| ChannelCommand::SetOwner { name, player })
as fn(String, String) -> ChannelCommand,
),
("SetChannelPassword", |name, password| {
ChannelCommand::SetPassword { name, password }
}),
("ChannelBan", |name, player| ChannelCommand::Ban {
name,
player,
}),
("ChannelInvite", |name, player| ChannelCommand::Invite {
name,
player,
}),
("ChannelKick", |name, player| ChannelCommand::Kick {
name,
player,
}),
("ChannelModerator", |name, player| {
ChannelCommand::Moderator { name, player }
}),
("ChannelUnmoderator", |name, player| {
ChannelCommand::Unmoderator { name, player }
}),
("ChannelMute", |name, player| ChannelCommand::Mute {
name,
player,
}),
("ChannelUnmute", |name, player| ChannelCommand::Unmute {
name,
player,
}),
("ChannelUnban", |name, player| ChannelCommand::Unban {
name,
player,
}),
] {
g.set(
verb,
lua.create_function(
move |lua, (name, second): (Option<String>, Option<String>)| {
if let Some(name) = non_empty(name) {
// A password may legitimately be empty (`/password General` clears it); a
// player name may not.
let second = second.unwrap_or_default();
if verb == "SetChannelPassword" || !second.trim().is_empty() {
push(lua, make(name, second.trim().to_string()));
}
}
Ok(())
},
)?,
)?;
}
Ok(())
}
@@ -181,3 +456,123 @@ impl super::UiScript {
self.model_mut().joined_channels = joined;
}
}
#[cfg(test)]
mod command_tests {
use super::{ChannelCommand, ZoneChannelRow};
use crate::script::UiScript;
fn catalog() -> Vec<ZoneChannelRow> {
vec![
ZoneChannelRow {
id: 1,
shortcut: "General".into(),
resolved: Some("General - Elwynn Forest".into()),
listed: true,
},
ZoneChannelRow {
id: 2,
shortcut: "Trade".into(),
resolved: None,
listed: false,
},
]
}
/// Decision 1908's three legs, and that 0 is truthy.
#[test]
fn join_channel_by_name_answers_the_three_legs_of_1908() {
let mut s = UiScript::new().unwrap();
s.set_zone_channel_catalog(catalog());
s.run(
"A = {JoinChannelByName('General', 'pw', 1)} \
B = {JoinChannelByName('MyChan', nil, 1)} \
C = {JoinChannelByName('Trade', nil, 1)} \
D = {JoinChannelByName('two words', nil, 1)}",
)
.unwrap();
assert_eq!(
s.eval::<(i64, String)>("return A[1], A[2]").unwrap(),
(1, "General - Elwynn Forest".to_string())
);
assert!(s
.eval::<bool>("return B[1] == 0 and B[2] == nil and table.getn(B) == 1")
.unwrap());
assert!(
s.eval::<bool>("return C[1] == nil").unwrap(),
"empty substitution"
);
assert!(s.eval::<bool>("return D[1] == nil").unwrap(), "a space");
assert_eq!(
s.take_channel_commands(),
vec![
ChannelCommand::Join {
name: "General - Elwynn Forest".into(),
password: "pw".into()
},
ChannelCommand::Join {
name: "MyChan".into(),
password: String::new()
},
],
"sent on both non-nil legs, the resolved name for a row"
);
}
#[test]
fn enumerate_server_channels_lists_the_shortcuts_the_zone_admits() {
let mut s = UiScript::new().unwrap();
assert_eq!(
s.eval::<i64>("return select('#', EnumerateServerChannels())")
.unwrap(),
0,
"no zone yet — 0 values"
);
s.set_zone_channel_catalog(catalog());
assert_eq!(
s.eval::<Vec<String>>("return {EnumerateServerChannels()}")
.unwrap(),
vec!["General".to_string()],
"the shortcut, never the composed name; a city row only in a city"
);
}
#[test]
fn the_management_verbs_queue_in_call_order_and_drop_empty_names() {
let mut s = UiScript::new().unwrap();
s.run(
"LeaveChannelByName('General') ListChannelByName('') ListChannels() \
SetChannelPassword('General', '') ChannelKick('General', 'Bob') \
ChannelKick('General', '') ChannelModerate('General') \
ChannelToggleAnnouncements('General') SetChannelOwner('General', 'Ann')",
)
.unwrap();
assert_eq!(
s.take_channel_commands(),
vec![
ChannelCommand::Leave {
name: "General".into()
},
ChannelCommand::ListAll,
ChannelCommand::SetPassword {
name: "General".into(),
password: String::new()
},
ChannelCommand::Kick {
name: "General".into(),
player: "Bob".into()
},
ChannelCommand::Moderate {
name: "General".into()
},
ChannelCommand::ToggleAnnouncements {
name: "General".into()
},
ChannelCommand::SetOwner {
name: "General".into(),
player: "Ann".into()
},
]
);
}
}
+285
View File
@@ -0,0 +1,285 @@
//! The chat slash handlers' engine verbs that are not sends, channels, or window state —
//! `DoEmote`, `RandomRoll`, `AssistByName`, `UninviteByName`, `ConsoleExec`, `LoggingChat`,
//! `LoggingCombat`. Each is what a stock `ChatFrame.lua` built-in handler calls once it has
//! parsed the line (`SlashCmdList` walk, decision 1195); the app used to parse these lines
//! itself (`ui_chat/input/parse.rs`) and now drains what the reference's own Lua decided.
//!
//! Every verb is a queue or a flag — the engine-free seam (0068 §3): the VM never sees the wire,
//! the app drains and sends. Registrar addresses from wow-re `ui/scratch/bindings.md`:
//! `0x49fd30 DoEmote`, `0x48c7b0 RandomRoll`, `0x489c40 AssistByName`, `0x48a610 UninviteByName`.
use mlua::{Lua, MultiValue, Value};
use super::Model;
/// `DoEmote(token [, target])` — the `EmotesText.dbc` **name token** (`"WAVE"`, not `/wave`) and
/// the optional target name the handler split off the line.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EmoteRequest {
pub token: String,
pub target: Option<String>,
}
/// Lua number coercion the way the reference's `lua_tonumber` does it: an integer, a float, or a
/// string that parses; anything else is 0.
fn to_u32(v: &Value) -> u32 {
match v {
Value::Integer(i) => u32::try_from(*i).unwrap_or(0),
Value::Number(n) if n.is_finite() && *n >= 0.0 => *n as u32,
Value::String(s) => s
.to_str()
.ok()
.and_then(|s| s.trim().parse::<f64>().ok())
.filter(|n| n.is_finite() && *n >= 0.0)
.map_or(0, |n| n as u32),
_ => 0,
}
}
fn flag_arg(v: Option<Value>) -> Option<bool> {
match v {
None | Some(Value::Nil) => None,
Some(Value::Boolean(b)) => Some(b),
Some(Value::Integer(n)) => Some(n != 0),
Some(Value::Number(n)) => Some(n != 0.0),
Some(Value::String(s)) => Some(s.to_str().is_ok_and(|s| s != "0" && !s.is_empty())),
Some(_) => Some(true),
}
}
impl super::UiScript {
/// `DoEmote` calls since the last drain.
pub fn take_emote_requests(&mut self) -> Vec<EmoteRequest> {
std::mem::take(&mut self.model_mut().emote_requests)
}
/// `RandomRoll(min, max)` calls since the last drain, as the app sends them
/// (`CMSG_RANDOM_ROLL`).
pub fn take_roll_requests(&mut self) -> Vec<(u32, u32)> {
std::mem::take(&mut self.model_mut().roll_requests)
}
/// `UninviteByName` calls since the last drain.
pub fn take_uninvite_requests(&mut self) -> Vec<String> {
std::mem::take(&mut self.model_mut().uninvite_requests)
}
/// `ConsoleExec` lines whose first word is not a registered CVar — the console commands the
/// app owns (`reloadui`, …). A CVar line is written to the store directly and never appears
/// here.
pub fn take_console_lines(&mut self) -> Vec<String> {
std::mem::take(&mut self.model_mut().console_lines)
}
/// `(chat, combat)` — the two logging flags as `LoggingChat`/`LoggingCombat` last set them.
pub fn logging_flags(&self) -> (bool, bool) {
let model = self.model_mut();
(model.logging_chat, model.logging_combat)
}
/// Whether either logging flag moved since the last drain — the app's cue to open or close
/// its log files.
pub fn take_logging_changes(&mut self) -> bool {
std::mem::take(&mut self.model_mut().logging_changed)
}
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
g.set(
"DoEmote",
lua.create_function(|lua, (token, target): (Option<String>, Option<String>)| {
let Some(token) = token.filter(|t| !t.is_empty()) else {
return Ok(());
};
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.emote_requests.push(EmoteRequest {
token: token.to_ascii_uppercase(),
target: target.filter(|t| !t.trim().is_empty()),
});
Ok(())
})?,
)?;
g.set(
"RandomRoll",
lua.create_function(|lua, (min, max): (Value, Value)| {
let (min, max) = (to_u32(&min), to_u32(&max));
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.roll_requests.push((min, max));
Ok(())
})?,
)?;
g.set(
"AssistByName",
lua.create_function(|lua, name: Option<String>| {
let Some(name) = name.filter(|n| !n.trim().is_empty()) else {
return Ok(());
};
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model
.selection_requests
.push(super::SelectionRequest::AssistByName(name));
Ok(())
})?,
)?;
g.set(
"UninviteByName",
lua.create_function(|lua, name: Option<String>| {
let Some(name) = name.filter(|n| !n.trim().is_empty()) else {
return Ok(());
};
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.uninvite_requests.push(name);
Ok(())
})?,
)?;
// ConsoleExec("name value") — the console line `/console` hands over. A registered CVar is
// written the way `SetCVar` writes it (same store, same change queue, no CVAR_UPDATE token);
// anything else is a console command the app owns.
g.set(
"ConsoleExec",
lua.create_function(|lua, line: Option<String>| {
let Some(line) = line.map(|l| l.trim().to_string()).filter(|l| !l.is_empty()) else {
return Ok(());
};
let (name, value) = line
.split_once(char::is_whitespace)
.map_or((line.as_str(), ""), |(n, v)| (n, v.trim()));
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let is_cvar = model.cvars.contains_key(&name.to_ascii_lowercase());
if is_cvar {
if !value.is_empty() {
super::cvars::write_cvar(&mut model, name, value.to_string(), None);
}
} else {
model.console_lines.push(line.clone());
}
Ok(())
})?,
)?;
for (name, chat) in [("LoggingChat", true), ("LoggingCombat", false)] {
g.set(
name,
lua.create_function(move |lua, flag: Option<Value>| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let slot = if chat {
&mut model.logging_chat
} else {
&mut model.logging_combat
};
match flag_arg(flag) {
None => Ok(MultiValue::from_vec(vec![if *slot {
Value::Integer(1)
} else {
Value::Nil
}])),
Some(on) => {
if *slot != on {
*slot = on;
model.logging_changed = true;
}
Ok(MultiValue::new())
}
}
})?,
)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::script::{SelectionRequest, UiScript};
#[test]
fn do_emote_queues_the_uppercased_token_and_a_non_empty_target() {
let mut s = UiScript::new().unwrap();
s.run("DoEmote('wave', 'Bob') DoEmote('DANCE', '') DoEmote('') DoEmote()")
.unwrap();
assert_eq!(
s.take_emote_requests(),
vec![
EmoteRequest {
token: "WAVE".into(),
target: Some("Bob".into())
},
EmoteRequest {
token: "DANCE".into(),
target: None
},
]
);
assert!(s.take_emote_requests().is_empty(), "drained");
}
#[test]
fn random_roll_coerces_the_strings_the_stock_handler_passes() {
let mut s = UiScript::new().unwrap();
s.run("RandomRoll('1', '100') RandomRoll(5, 10) RandomRoll('x', nil)")
.unwrap();
assert_eq!(s.take_roll_requests(), vec![(1, 100), (5, 10), (0, 0)]);
}
#[test]
fn assist_and_uninvite_by_name_queue_their_names() {
let mut s = UiScript::new().unwrap();
s.run("AssistByName('Alice') AssistByName('') UninviteByName('Carol') UninviteByName()")
.unwrap();
assert_eq!(
s.take_selection_requests(),
vec![SelectionRequest::AssistByName("Alice".into())]
);
assert_eq!(s.take_uninvite_requests(), vec!["Carol".to_string()]);
}
#[test]
fn console_exec_writes_a_registered_cvar_and_queues_anything_else() {
let mut s = UiScript::new().unwrap();
s.register_cvars([("uiScale", "1")]);
s.run("ConsoleExec('uiscale 0.8') ConsoleExec('reloadui') ConsoleExec(' ') ConsoleExec('uiScale')")
.unwrap();
assert_eq!(
s.eval::<String>("return GetCVar('uiScale')").unwrap(),
"0.8",
"the CVar store is the same one SetCVar writes, matched case-insensitively"
);
assert_eq!(s.take_console_lines(), vec!["reloadui".to_string()]);
assert_eq!(
s.take_cvar_changes(),
vec![("uiScale".to_string(), "0.8".to_string())],
"a bare name writes nothing"
);
}
#[test]
fn the_logging_flags_read_as_one_or_nil_and_set_from_any_truthy_arg() {
let mut s = UiScript::new().unwrap();
assert!(s.eval::<bool>("return LoggingChat() == nil").unwrap());
assert!(s.eval::<bool>("return LoggingCombat() == nil").unwrap());
assert_eq!(
s.eval::<i64>("return select('#', LoggingChat(true))")
.unwrap(),
0,
"the setter returns nothing"
);
assert_eq!(s.eval::<i64>("return LoggingChat()").unwrap(), 1);
assert_eq!(s.logging_flags(), (true, false));
assert!(s.take_logging_changes());
s.run("LoggingCombat(1) LoggingChat(false)").unwrap();
assert_eq!(s.logging_flags(), (false, true));
assert!(s.take_logging_changes());
s.run("LoggingCombat(true)").unwrap();
assert!(
!s.take_logging_changes(),
"a write that moves nothing cues nothing"
);
}
}
+84
View File
@@ -65,7 +65,57 @@ pub struct ChatSend {
pub target: Option<String>,
}
impl super::UiScript {
/// The languages this character **knows**, in `Languages.dbc` row order — what
/// `GetNumLaguages`/`GetLanguageByIndex` walk. The app folds it the reference's way:
/// `0x4b25b0` stores `[languageId] = spellId` for every known spell whose `Effect_1 == 39`,
/// and `0x5ec720` answers non-zero only when that spell's skill line is in the player's
/// skill block (wow-re `chat-language-scramble.md` §8, C6). Fires `LANGUAGE_LIST_CHANGED`
/// (`0x49b970`, event 0x102) when the list moves.
pub fn set_known_languages(&mut self, names: Vec<String>) {
let changed = {
let mut model = self.model_mut();
if model.known_languages == names {
false
} else {
model.known_languages = names;
true
}
};
if changed {
self.fire_event("LANGUAGE_LIST_CHANGED", vec![]);
}
}
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// GetNumLaguages() — `0x49fb30`, the binary's own spelling (wow-re
// `bag-language-combat-action-bindings.md` §2). Walks `Languages.dbc` and counts the rows
// `0x5ec720` answers non-zero for; **one number**.
lua.globals().set(
"GetNumLaguages",
lua.create_function(|lua, _ignored: MultiValue| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(model.known_languages.len() as i64)
})?,
)?;
// GetLanguageByIndex(i) — `0x49fbe0`: the same walk, pushing **one string** — the
// `Name_lang` of the i-th (1-based) *known* language. Positional among the known rows,
// never a row number or a language id; past the end it pushes nothing.
lua.globals().set(
"GetLanguageByIndex",
lua.create_function(|lua, index: Option<i64>| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let name = index
.and_then(|i| usize::try_from(i).ok())
.and_then(|i| i.checked_sub(1))
.and_then(|i| model.known_languages.get(i));
Ok(match name {
Some(n) => MultiValue::from_vec(vec![Value::String(lua.create_string(n)?)]),
None => MultiValue::new(),
})
})?,
)?;
// GetDefaultLanguage() → **exactly ONE value, a string** — or **zero values**, which is not
// the same thing (`0x49fcd0`, wow-re `bag-language-combat-action-bindings.md` §2).
//
@@ -242,3 +292,37 @@ mod tests {
);
}
}
#[cfg(test)]
mod language_tests {
use crate::script::UiScript;
#[test]
fn the_language_walk_is_positional_over_the_known_rows() {
let mut s = UiScript::new().unwrap();
assert_eq!(s.eval::<i64>("return GetNumLaguages()").unwrap(), 0);
assert_eq!(
s.eval::<i64>("return select('#', GetLanguageByIndex(1))")
.unwrap(),
0,
"past the end pushes nothing, not nil"
);
s.run(
"local f = CreateFrame('Frame') f:RegisterEvent('LANGUAGE_LIST_CHANGED') \
f:SetScript('OnEvent', function() FIRED = (FIRED or 0) + 1 end)",
)
.unwrap();
s.set_known_languages(vec!["Orcish".into(), "Common".into()]);
s.set_known_languages(vec!["Orcish".into(), "Common".into()]);
assert_eq!(s.eval::<i64>("return GetNumLaguages()").unwrap(), 2);
assert_eq!(
s.eval::<String>("return GetLanguageByIndex(2)").unwrap(),
"Common"
);
assert_eq!(
s.eval::<i64>("return FIRED").unwrap(),
1,
"the list changing fires once; the same list again is silent"
);
}
}
+379
View File
@@ -0,0 +1,379 @@
//! The chat-type colour registry — `GetChatTypeIndex`, `ChangeChatColor`, `ResetChatColors`.
//!
//! The reference keeps one runtime table of chat types (`0xb4e518`, stride 0x43: a 0x40-byte
//! name and three RGB bytes), seeded at boot from the 94-entry static table at `0x804710` and
//! then extended with ten extras `CHANNEL1`…`CHANNEL10`, each coloured from the live `CHANNEL`
//! entry (wow-re `system/ui/scratch/chat-color-table.md`, "Seeding"). The `chat-cache.txt`
//! `COLORS` block overwrites matched entries in place at load — absent names keep the compiled
//! defaults — and the app owns that file; here the table is state the app feeds and drains
//! ([`super::UiScript::set_chat_colors`], [`super::UiScript::take_chat_color_changes`]).
//!
//! The Lua surface, from the same node:
//!
//! - `GetChatTypeIndex(name)` — a case-insensitive linear scan of the 94 fixed entries, **1-based**
//! (`4a0adf: inc ebx` before the match test); no fixed match → the extras, numbered **95, 96, …**
//! (`count_before_match + 0x5f`); no match at all → **0**.
//! - `ChangeChatColor(name, r, g, b)` — `r,g,b` are 0.0–1.0 floats, each `fmul 255.0` then
//! `__ftol` (**truncate**, no rounding, bytes `4a085f–4a08a3`), written into the matched entry.
//! On success fires `UPDATE_CHAT_COLOR` with `"%s%f%f%f"`: the name and the three
//! **just-written bytes re-normalised** by `f32 1/255` — so an addon that passed `0.5` hears
//! `127/255`, not `0.5`. (The event's symbolic name is the node's one INFERRED item — it is
//! `0xe2` in a runtime-populated listener table; `ChatFrame.lua:1349` registers and reads it
//! as `UPDATE_CHAT_COLOR` with exactly those four args.)
//! - `ResetChatColors()` — the boot seed again: the 94 defaults copied back, then every extra
//! recoloured from the live `CHANNEL` entry.
//!
//! What the line colours *mean* to a message frame is `ScrollingMessageFrame:UpdateColorByID`,
//! which lives with the frame (`messageframe/scrolling.rs`); this file is only the registry.
use mlua::{Lua, MultiValue, Value};
use super::Model;
/// The fixed segment: 94 entries, in the reference's order (index = position + 1).
pub const FIXED_CHAT_TYPES: usize = 94;
/// The extras seeded at boot: `CHANNEL1`…`CHANNEL10`, indices 95–104.
pub const EXTRA_CHAT_TYPES: usize = 10;
/// `0x804710` — name and default RGB, verbatim (chat-color-table.md, "The complete default
/// table"). The order is load-bearing: it *is* the index `GetChatTypeIndex` answers.
const DEFAULTS: [(&str, [u8; 3]); FIXED_CHAT_TYPES] = [
("SAY", [255, 255, 255]),
("PARTY", [170, 170, 255]),
("RAID", [255, 127, 0]),
("GUILD", [64, 255, 64]),
("OFFICER", [64, 192, 64]),
("YELL", [255, 64, 64]),
("WHISPER", [255, 128, 255]),
("WHISPER_INFORM", [255, 128, 255]),
("EMOTE", [255, 128, 64]),
("TEXT_EMOTE", [255, 128, 64]),
("SYSTEM", [255, 255, 0]),
("MONSTER_SAY", [255, 255, 159]),
("MONSTER_YELL", [255, 64, 64]),
("MONSTER_EMOTE", [255, 128, 64]),
("CHANNEL", [255, 192, 192]),
("CHANNEL_JOIN", [192, 128, 128]),
("CHANNEL_LEAVE", [192, 128, 128]),
("CHANNEL_LIST", [192, 128, 128]),
("CHANNEL_NOTICE", [192, 192, 192]),
("CHANNEL_NOTICE_USER", [192, 192, 192]),
("AFK", [255, 128, 255]),
("DND", [255, 128, 255]),
("IGNORED", [255, 0, 0]),
("SKILL", [85, 85, 255]),
("LOOT", [0, 170, 0]),
("COMBAT_MISC_INFO", [128, 128, 255]),
("MONSTER_WHISPER", [179, 179, 179]),
("COMBAT_SELF_HITS", [255, 255, 255]),
("COMBAT_SELF_MISSES", [255, 255, 255]),
("COMBAT_PET_HITS", [255, 255, 255]),
("COMBAT_PET_MISSES", [255, 255, 255]),
("COMBAT_PARTY_HITS", [255, 255, 255]),
("COMBAT_PARTY_MISSES", [255, 255, 255]),
("COMBAT_FRIENDLYPLAYER_HITS", [255, 255, 255]),
("COMBAT_FRIENDLYPLAYER_MISSES", [255, 255, 255]),
("COMBAT_HOSTILEPLAYER_HITS", [255, 255, 255]),
("COMBAT_HOSTILEPLAYER_MISSES", [255, 255, 255]),
("COMBAT_CREATURE_VS_SELF_HITS", [255, 47, 47]),
("COMBAT_CREATURE_VS_SELF_MISSES", [255, 47, 47]),
("COMBAT_CREATURE_VS_PARTY_HITS", [255, 255, 255]),
("COMBAT_CREATURE_VS_PARTY_MISSES", [255, 255, 255]),
("COMBAT_CREATURE_VS_CREATURE_HITS", [255, 255, 255]),
("COMBAT_CREATURE_VS_CREATURE_MISSES", [255, 255, 255]),
("COMBAT_FRIENDLY_DEATH", [255, 255, 255]),
("COMBAT_HOSTILE_DEATH", [255, 255, 255]),
("COMBAT_XP_GAIN", [111, 111, 255]),
("SPELL_SELF_DAMAGE", [255, 255, 0]),
("SPELL_SELF_BUFF", [255, 255, 0]),
("SPELL_PET_DAMAGE", [255, 255, 255]),
("SPELL_PET_BUFF", [255, 255, 255]),
("SPELL_PARTY_DAMAGE", [255, 255, 255]),
("SPELL_PARTY_BUFF", [255, 255, 255]),
("SPELL_FRIENDLYPLAYER_DAMAGE", [255, 255, 255]),
("SPELL_FRIENDLYPLAYER_BUFF", [255, 255, 255]),
("SPELL_HOSTILEPLAYER_DAMAGE", [255, 255, 255]),
("SPELL_HOSTILEPLAYER_BUFF", [255, 255, 255]),
("SPELL_CREATURE_VS_SELF_DAMAGE", [202, 76, 217]),
("SPELL_CREATURE_VS_SELF_BUFF", [255, 255, 255]),
("SPELL_CREATURE_VS_PARTY_DAMAGE", [255, 255, 255]),
("SPELL_CREATURE_VS_PARTY_BUFF", [255, 255, 255]),
("SPELL_CREATURE_VS_CREATURE_DAMAGE", [255, 255, 255]),
("SPELL_CREATURE_VS_CREATURE_BUFF", [255, 255, 255]),
("SPELL_TRADESKILLS", [255, 255, 255]),
("SPELL_DAMAGESHIELDS_ON_SELF", [255, 255, 255]),
("SPELL_DAMAGESHIELDS_ON_OTHERS", [255, 255, 255]),
("SPELL_AURA_GONE_SELF", [255, 255, 255]),
("SPELL_AURA_GONE_PARTY", [255, 255, 255]),
("SPELL_AURA_GONE_OTHER", [255, 255, 255]),
("SPELL_ITEM_ENCHANTMENTS", [255, 255, 255]),
("SPELL_BREAK_AURA", [255, 255, 255]),
("SPELL_PERIODIC_SELF_DAMAGE", [255, 255, 255]),
("SPELL_PERIODIC_SELF_BUFFS", [255, 255, 255]),
("SPELL_PERIODIC_PARTY_DAMAGE", [255, 255, 255]),
("SPELL_PERIODIC_PARTY_BUFFS", [255, 255, 255]),
("SPELL_PERIODIC_FRIENDLYPLAYER_DAMAGE", [255, 255, 255]),
("SPELL_PERIODIC_FRIENDLYPLAYER_BUFFS", [255, 255, 255]),
("SPELL_PERIODIC_HOSTILEPLAYER_DAMAGE", [255, 255, 255]),
("SPELL_PERIODIC_HOSTILEPLAYER_BUFFS", [255, 255, 255]),
("SPELL_PERIODIC_CREATURE_DAMAGE", [255, 255, 255]),
("SPELL_PERIODIC_CREATURE_BUFFS", [255, 255, 255]),
("SPELL_FAILED_LOCALPLAYER", [255, 255, 255]),
("COMBAT_HONOR_GAIN", [224, 202, 10]),
("BG_SYSTEM_NEUTRAL", [255, 120, 10]),
("BG_SYSTEM_ALLIANCE", [0, 174, 239]),
("BG_SYSTEM_HORDE", [255, 0, 0]),
("COMBAT_FACTION_CHANGE", [128, 128, 255]),
("MONEY", [255, 255, 0]),
("RAID_LEADER", [255, 219, 183]),
("RAID_WARNING", [255, 219, 183]),
("FOREIGN_TELL", [255, 128, 255]),
("RAID_BOSS_EMOTE", [255, 219, 183]),
("FILTERED", [255, 0, 0]),
("BATTLEGROUND", [255, 127, 0]),
("BATTLEGROUND_LEADER", [255, 219, 183]),
];
/// The 1-based index of the `CHANNEL` entry — the extras' colour source at seed and reset.
const CHANNEL_INDEX: usize = 15;
/// One registry entry: the type's name and its live colour bytes.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ChatTypeColor {
pub name: String,
pub rgb: [u8; 3],
}
/// The boot seed: the 94 defaults, then `CHANNEL1`…`CHANNEL10` coloured from the live `CHANNEL`.
pub(super) fn seed() -> Vec<ChatTypeColor> {
let mut table: Vec<ChatTypeColor> = DEFAULTS
.iter()
.map(|(name, rgb)| ChatTypeColor {
name: (*name).to_string(),
rgb: *rgb,
})
.collect();
let channel = table[CHANNEL_INDEX - 1].rgb;
for n in 1..=EXTRA_CHAT_TYPES {
table.push(ChatTypeColor {
name: format!("CHANNEL{n}"),
rgb: channel,
});
}
table
}
/// `ResetChatColors`' copy loop: the fixed segment back to `0x804710`, the extras from `CHANNEL`.
fn reset(table: &mut [ChatTypeColor]) {
for (entry, (_, rgb)) in table.iter_mut().zip(DEFAULTS.iter()) {
entry.rgb = *rgb;
}
let channel = table[CHANNEL_INDEX - 1].rgb;
for entry in table.iter_mut().skip(FIXED_CHAT_TYPES) {
entry.rgb = channel;
}
}
/// The reference's compare (`0x64a4c0` → `0x414310`): an `'A'–'Z' + 0x20` fold, i.e. ASCII
/// case-insensitive and nothing wider.
fn position(table: &[ChatTypeColor], name: &str) -> Option<usize> {
table.iter().position(|e| e.name.eq_ignore_ascii_case(name))
}
/// `fmul 255.0; __ftol` — truncation toward zero, then the low byte is what the `mov` stores.
fn to_byte(x: f64) -> u8 {
if !x.is_finite() {
return 0;
}
((x * 255.0).trunc() as i64) as u8
}
/// `fmul dword [0x8026c8]` — the f32 reciprocal, so the event carries `byte / 255` in single
/// precision, widened.
fn from_byte(b: u8) -> f64 {
f64::from(b as f32 * (1.0f32 / 255.0f32))
}
impl super::UiScript {
/// The `COLORS` block of `chat-cache.txt`, applied the way the loader applies it: each named
/// entry overwritten in place, unknown names ignored, absent names left at their defaults.
pub fn set_chat_colors(&mut self, colors: impl IntoIterator<Item = (String, [u8; 3])>) {
let mut model = self.model_mut();
for (name, rgb) in colors {
if let Some(i) = position(&model.chat_colors, &name) {
model.chat_colors[i].rgb = rgb;
}
}
}
/// The live registry, fixed segment first, then the extras.
pub fn chat_colors(&self) -> Vec<ChatTypeColor> {
self.model_mut().chat_colors.clone()
}
/// Whether a Lua-side write (`ChangeChatColor`, `ResetChatColors`) has landed since the last
/// drain — the app's persist cue.
pub fn take_chat_color_changes(&mut self) -> bool {
std::mem::take(&mut self.model_mut().chat_colors_changed)
}
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
g.set(
"GetChatTypeIndex",
lua.create_function(|lua, name: Option<String>| {
let Some(name) = name else {
return Ok(0);
};
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(position(&model.chat_colors, &name).map_or(0, |i| i as i64 + 1))
})?,
)?;
g.set(
"ChangeChatColor",
lua.create_function(
|lua, (name, r, g, b): (String, Option<f64>, Option<f64>, Option<f64>)| {
let written = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let Some(i) = position(&model.chat_colors, &name) else {
return Ok(());
};
let rgb = [
to_byte(r.unwrap_or(0.0)),
to_byte(g.unwrap_or(0.0)),
to_byte(b.unwrap_or(0.0)),
];
model.chat_colors[i].rgb = rgb;
model.chat_colors_changed = true;
(model.chat_colors[i].name.clone(), rgb)
};
let (name, rgb) = written;
super::tick::fire_event_into(
lua,
"UPDATE_CHAT_COLOR",
vec![
super::ScriptValue::Str(name),
super::ScriptValue::Number(from_byte(rgb[0])),
super::ScriptValue::Number(from_byte(rgb[1])),
super::ScriptValue::Number(from_byte(rgb[2])),
],
);
Ok(())
},
)?,
)?;
g.set(
"ResetChatColors",
lua.create_function(|lua, _ignored: MultiValue| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
reset(&mut model.chat_colors);
model.chat_colors_changed = true;
Ok(Value::Nil)
})?,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::script::UiScript;
#[test]
fn the_index_is_one_based_case_folded_and_zero_on_a_miss() {
let s = UiScript::new().unwrap();
assert_eq!(s.eval::<i64>("return GetChatTypeIndex('say')").unwrap(), 1);
assert_eq!(
s.eval::<i64>("return GetChatTypeIndex('BATTLEGROUND_LEADER')")
.unwrap(),
94
);
assert_eq!(
s.eval::<i64>("return GetChatTypeIndex('channel3')")
.unwrap(),
97,
"extras are 95… — count_before_match + 0x5f"
);
assert_eq!(s.eval::<i64>("return GetChatTypeIndex('NOPE')").unwrap(), 0);
assert_eq!(s.eval::<i64>("return GetChatTypeIndex()").unwrap(), 0);
}
#[test]
fn change_chat_color_truncates_stores_and_fires_the_renormalised_bytes() {
let mut s = UiScript::new().unwrap();
s.run(
"local f = CreateFrame('Frame') f:RegisterEvent('UPDATE_CHAT_COLOR') \
f:SetScript('OnEvent', function() SEEN = {arg1, arg2, arg3, arg4} end) \
ChangeChatColor('say', 0.5, 1.0, 0.999)",
)
.unwrap();
let say = &s.chat_colors()[0];
assert_eq!(say.name, "SAY");
assert_eq!(say.rgb, [127, 255, 254], "truncate, never round");
assert_eq!(
s.eval::<String>("return SEEN[1]").unwrap(),
"SAY",
"the registry's spelling, not the caller's"
);
let heard = s.eval::<f64>("return SEEN[2]").unwrap();
assert!(
(heard - f64::from(127.0f32 * (1.0f32 / 255.0f32))).abs() < 1e-12,
"the just-written byte re-normalised, not the 0.5 that was passed: {heard}"
);
assert!(s.take_chat_color_changes());
assert!(!s.take_chat_color_changes(), "the cue is one-shot");
}
#[test]
fn an_unknown_type_writes_nothing_and_fires_nothing() {
let mut s = UiScript::new().unwrap();
s.run(
"local f = CreateFrame('Frame') f:RegisterEvent('UPDATE_CHAT_COLOR') \
f:SetScript('OnEvent', function() SEEN = true end) \
ChangeChatColor('NOPE', 0.5, 0.5, 0.5)",
)
.unwrap();
assert!(s.eval::<bool>("return SEEN == nil").unwrap());
assert!(!s.take_chat_color_changes());
}
#[test]
fn the_cache_block_overwrites_in_place_and_reset_restores_the_seed() {
let mut s = UiScript::new().unwrap();
s.set_chat_colors([
("system".to_string(), [1, 2, 3]),
("CHANNEL".to_string(), [9, 9, 9]),
("BOGUS".to_string(), [7, 7, 7]),
]);
let table = s.chat_colors();
assert_eq!(table.len(), FIXED_CHAT_TYPES + EXTRA_CHAT_TYPES);
assert_eq!(table[10].rgb, [1, 2, 3], "SYSTEM, matched case-folded");
assert_eq!(
table[FIXED_CHAT_TYPES].rgb,
[255, 192, 192],
"an extra keeps its seed colour — the cache does not re-seed it from CHANNEL"
);
s.run("ResetChatColors()").unwrap();
let table = s.chat_colors();
assert_eq!(table[10].rgb, [255, 255, 0]);
assert_eq!(table[14].rgb, [255, 192, 192]);
assert_eq!(table[FIXED_CHAT_TYPES + 9].name, "CHANNEL10");
assert!(s.take_chat_color_changes());
}
#[test]
fn reset_recolours_the_extras_from_the_live_channel_entry() {
let s = UiScript::new().unwrap();
s.run("ChangeChatColor('CHANNEL', 0.0, 0.0, 0.0) ChangeChatColor('CHANNEL2', 1, 1, 1)")
.unwrap();
// Reset copies the fixed defaults first, so CHANNEL is back to FFC0C0 by the time the
// extras are recoloured from it.
s.run("ResetChatColors()").unwrap();
assert_eq!(s.chat_colors()[FIXED_CHAT_TYPES + 1].rgb, [255, 192, 192]);
}
}
+605 -35
View File
@@ -115,6 +115,7 @@
use mlua::{Lua, MultiValue, Value};
use super::channel::ZoneChannelRow;
use super::Model;
/// How many chat windows the client's own settings array holds — **10**, both in the engine
@@ -129,21 +130,6 @@ pub(super) const ENGINE_CHAT_WINDOW_SLOTS: usize = 10;
/// the shipped XML so the two cannot drift.
pub(super) const NUM_CHAT_WINDOWS: usize = 7;
/// `(shown, docked)` per window, 1-based, as the tuple's *Lua* values: `None` where the reference
/// answers `nil`. Everything else in the tuple is identical across all seven windows on a stock
/// client — name `""`, size `0`, colour `0,0,0`, alpha `0`, `LOCKED 1` — so only the two that
/// differ are tabulated. `locked` starts at that same stock `1` for every window but is no longer
/// a constant: it lives in [`ChatWindowLook`], because the tab menu can now move it.
const WINDOW_STATE: [(Option<i64>, Option<i64>); NUM_CHAT_WINDOWS] = [
(Some(1), Some(1)), // 1 "General" — shown, dock position 1
(None, Some(2)), // 2 "Combat Log" — docked at position 2, not shown (the dock shows one)
(None, None), // 3 — hidden, undocked
(None, None), // 4
(None, None), // 5
(None, None), // 6
(None, None), // 7
];
/// One chat window's **look** — the mutable slice of the engine's per-window record (decision
/// 1589): the background tint, the background alpha, and the font size. The colour is bytes
/// because the engine's colour is bytes (`0xb4fe50 + n*0x98 + 0x88..0x8b`, packed BGRA,
@@ -153,7 +139,93 @@ const WINDOW_STATE: [(Option<i64>, Option<i64>); NUM_CHAT_WINDOWS] = [
/// [`Self::default`] is the stock `chat-cache.txt` row — `COLOR 0 0 0 0`, `SIZE 0`, `LOCKED 1` —
/// i.e. a black box at alpha 0 that cannot be dragged, which is the classic "chat is text over the
/// world until you mouse over it".
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
/// `CHATMSGGROUP` — the client's 68-entry message-group table at `0x805fb0` (wow-re
/// `system/ui/scratch/chat-cache-grammar.md` §2): `(name, defaultOn, addedVersion)`, stride 0xc,
/// in the order that IS the record's `+0x20` flag index. **Not** the 94-entry colour table: 67
/// names are shared, `CREATURE` exists only here, and 27 colour names (RAID, OFFICER,
/// WHISPER_INFORM, EMOTE, TEXT_EMOTE, MONSTER_*, CHANNEL_JOIN/LEAVE/LIST/NOTICE[_USER], AFK, DND,
/// IGNORED, BG_SYSTEM_*, RAID_LEADER/WARNING/BOSS_EMOTE, FOREIGN_TELL, FILTERED, BATTLEGROUND[_LEADER])
/// have no group. `defaultOn` is what the boot init copies into window 2's flags (§4);
/// `addedVersion` is the loader's back-fill for a file older than `ADDEDVERSION 2` (§3).
pub const MESSAGE_GROUPS: [(&str, bool, u8); 68] = [
("SYSTEM", true, 0),
("SAY", true, 0),
("YELL", true, 0),
("WHISPER", true, 0),
("PARTY", true, 0),
("GUILD", true, 0),
("CREATURE", true, 0),
("CHANNEL", true, 0),
("SKILL", true, 0),
("LOOT", true, 0),
("COMBAT_MISC_INFO", true, 0),
("COMBAT_SELF_HITS", true, 0),
("COMBAT_SELF_MISSES", true, 0),
("COMBAT_PET_HITS", true, 0),
("COMBAT_PET_MISSES", true, 0),
("COMBAT_PARTY_HITS", false, 0),
("COMBAT_PARTY_MISSES", false, 0),
("COMBAT_FRIENDLYPLAYER_HITS", false, 0),
("COMBAT_FRIENDLYPLAYER_MISSES", false, 0),
("COMBAT_HOSTILEPLAYER_HITS", true, 0),
("COMBAT_HOSTILEPLAYER_MISSES", true, 0),
("COMBAT_CREATURE_VS_SELF_HITS", true, 0),
("COMBAT_CREATURE_VS_SELF_MISSES", true, 0),
("COMBAT_CREATURE_VS_PARTY_HITS", false, 0),
("COMBAT_CREATURE_VS_PARTY_MISSES", false, 0),
("COMBAT_CREATURE_VS_CREATURE_HITS", false, 0),
("COMBAT_CREATURE_VS_CREATURE_MISSES", false, 0),
("COMBAT_FRIENDLY_DEATH", true, 0),
("COMBAT_HOSTILE_DEATH", true, 0),
("COMBAT_XP_GAIN", true, 0),
("SPELL_SELF_DAMAGE", true, 0),
("SPELL_SELF_BUFF", true, 0),
("SPELL_PET_DAMAGE", true, 0),
("SPELL_PET_BUFF", true, 0),
("SPELL_PARTY_DAMAGE", false, 0),
("SPELL_PARTY_BUFF", false, 0),
("SPELL_FRIENDLYPLAYER_DAMAGE", false, 0),
("SPELL_FRIENDLYPLAYER_BUFF", false, 0),
("SPELL_HOSTILEPLAYER_DAMAGE", true, 0),
("SPELL_HOSTILEPLAYER_BUFF", true, 0),
("SPELL_CREATURE_VS_SELF_DAMAGE", true, 0),
("SPELL_CREATURE_VS_SELF_BUFF", true, 0),
("SPELL_CREATURE_VS_PARTY_DAMAGE", false, 0),
("SPELL_CREATURE_VS_PARTY_BUFF", false, 0),
("SPELL_CREATURE_VS_CREATURE_DAMAGE", false, 0),
("SPELL_CREATURE_VS_CREATURE_BUFF", false, 0),
("SPELL_TRADESKILLS", true, 0),
("SPELL_DAMAGESHIELDS_ON_SELF", true, 0),
("SPELL_DAMAGESHIELDS_ON_OTHERS", false, 0),
("SPELL_AURA_GONE_SELF", true, 0),
("SPELL_AURA_GONE_PARTY", false, 0),
("SPELL_AURA_GONE_OTHER", false, 0),
("SPELL_ITEM_ENCHANTMENTS", true, 0),
("SPELL_BREAK_AURA", true, 0),
("SPELL_PERIODIC_SELF_DAMAGE", true, 0),
("SPELL_PERIODIC_SELF_BUFFS", true, 0),
("SPELL_PERIODIC_PARTY_DAMAGE", false, 0),
("SPELL_PERIODIC_PARTY_BUFFS", false, 0),
("SPELL_PERIODIC_FRIENDLYPLAYER_DAMAGE", false, 0),
("SPELL_PERIODIC_FRIENDLYPLAYER_BUFFS", false, 0),
("SPELL_PERIODIC_HOSTILEPLAYER_DAMAGE", true, 0),
("SPELL_PERIODIC_HOSTILEPLAYER_BUFFS", true, 0),
("SPELL_PERIODIC_CREATURE_DAMAGE", true, 0),
("SPELL_PERIODIC_CREATURE_BUFFS", true, 0),
("SPELL_FAILED_LOCALPLAYER", false, 0),
("COMBAT_HONOR_GAIN", true, 0),
("COMBAT_FACTION_CHANGE", true, 1),
("MONEY", true, 2),
];
/// The flag index of a group name, matched the loader's way (`SStrCmpI` — an ASCII case-fold).
pub fn message_group_index(name: &str) -> Option<usize> {
MESSAGE_GROUPS
.iter()
.position(|(n, _, _)| n.eq_ignore_ascii_case(name))
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct ChatWindowLook {
/// Background tint, `0..=255` each. The engine renormalises by 1/255 on the way out.
pub r: u8,
@@ -186,6 +258,16 @@ pub struct ChatWindowLook {
/// `FCF_DockFrame`/`FCF_UnDockFrame` existed there was nothing that could move it, so it was a
/// per-window constant read straight out of [`WINDOW_STATE`]. There is now, so it is state.
pub docked: Option<u8>,
/// `+0x00` — the window's name as `SetChatWindowName` stored it: `""` until FrameXML names
/// it (the getter's first return; the `NAME` line is emitted only when non-empty).
pub name: String,
/// `+0x94` — `SHOWN`; the getter answers 1 or nil off it.
pub shown: bool,
/// `+0x20` — the enabled message-type names, the `MESSAGES … END` block.
pub messages: Vec<String>,
/// `+0x64` / `+0x74` — the channels this window shows, `(name, zone channel id)` pairs: the
/// `CHANNELS … END` block.
pub channels: Vec<(String, u32)>,
}
impl Default for ChatWindowLook {
@@ -207,25 +289,90 @@ impl ChatWindowLook {
// seeds each slot from [`WINDOW_STATE`] afterwards. `None` here is only the value a
// slot holds before that seeding, and the value a *hand-built* look starts at.
docked: None,
name: String::new(),
shown: false,
messages: Vec::new(),
channels: Vec::new(),
};
/// The stock row for window `index` (0-based) — [`Self::DEFAULT`] with that window's own
/// `DOCKED` position from [`WINDOW_STATE`]. The seed for the model's array and for the
/// settings parser's per-line default, so a file that never mentions `DOCKED` keeps the
/// shipped dock rather than silently undocking both windows.
/// The boot init `0x4982c0` (wow-re chat-cache-grammar.md §4) — the record a client with no
/// `chat-cache.txt` runs from, and what FrameXML's own `FloatingChatFrame_Update` then docks,
/// hides and saves: window 1 shown and undocked (it IS the dock — `FCF_DockFrame(ChatFrame1, 1)`
/// at FloatingChatFrame.xml's file scope), groups 1–10 by a literal fill; window 2 shown and
/// `docked 1`, every `defaultOn` group of 11–68 (the 34 the stock file lists); windows 3–10
/// hidden with nothing enabled. Every window locked, size 0, colour 0, name empty, ten empty
/// channel slots. The init fires no event; the loader does (§8).
pub fn stock(index: usize) -> Self {
Self {
docked: WINDOW_STATE
.get(index)
.and_then(|(_, d)| *d)
.and_then(|d| u8::try_from(d).ok()),
..Self::DEFAULT
let groups = |range: std::ops::Range<usize>, all: bool| -> Vec<String> {
MESSAGE_GROUPS[range]
.iter()
.filter(|(_, on, _)| all || *on)
.map(|(n, _, _)| (*n).to_string())
.collect()
};
match index {
0 => Self {
shown: true,
messages: groups(0..10, true),
..Self::DEFAULT
},
1 => Self {
shown: true,
docked: Some(1),
messages: groups(10..68, false),
..Self::DEFAULT
},
_ => Self::DEFAULT,
}
}
/// The loader's discipline over the message set — unknown names dropped, duplicates folded,
/// **table order** — so `GetChatWindowMessages` answers the way a flag walk does (§5: "table
/// order 0..67") whatever order a file or a caller listed them in.
pub fn normalize_messages(&mut self) {
let mut flags = [false; MESSAGE_GROUPS.len()];
for m in &self.messages {
if let Some(i) = message_group_index(m) {
flags[i] = true;
}
}
self.messages = MESSAGE_GROUPS
.iter()
.zip(flags)
.filter(|(_, on)| *on)
.map(|((n, _, _), _)| (*n).to_string())
.collect();
}
}
/// `x` in the reference's `0..1` float domain → the byte its `__ftol(x · 255.0)` stores, clamped
/// (see the module docs' divergence note). `NaN` lands on `0` rather than propagating.
/// `SStrToInt` — the leading decimal integer of a string (an optional sign, then digits), 0 when
/// there is none.
fn leading_int(s: &str) -> i64 {
let s = s.trim_start();
let (neg, digits) = match s.strip_prefix('-') {
Some(rest) => (true, rest),
None => (false, s.strip_prefix('+').unwrap_or(s)),
};
let n: i64 = digits
.chars()
.take_while(|c| c.is_ascii_digit())
.fold(0i64, |acc, c| {
acc.saturating_mul(10)
.saturating_add(i64::from(c as u8 - b'0'))
});
if neg {
-n
} else {
n
}
}
fn to_byte(x: f64) -> u8 {
if !x.is_finite() {
return 0;
@@ -263,6 +410,7 @@ impl super::UiScript {
for (i, look) in looks {
if let Some(slot) = model.chat_window_looks.get_mut(i) {
*slot = look;
slot.normalize_messages();
}
}
}
@@ -308,8 +456,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let look = lua
.app_data_ref::<Model>()
.expect("model app_data")
.chat_window_looks[i];
let (shown, _) = WINDOW_STATE[i];
.chat_window_looks[i]
.clone();
let shown = if look.shown { Some(1) } else { None };
// `docked` is the LIVE position now, not the table's — `FCF_DockFrame`/
// `FCF_UnDockFrame` move it through `SetChatWindowDocked` below.
let docked = look.docked.map(i64::from);
@@ -318,8 +467,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
None => Value::Nil,
};
Ok(MultiValue::from_vec(vec![
// name: "" — never a label. See the module docs' trap 1.
Value::String(lua.create_string("")?),
// name: "" until `SetChatWindowName` stores one — never a label of ours. See
// the module docs' trap 1.
Value::String(lua.create_string(&look.name)?),
// The three the setters below own (1589). Stock is SIZE 0 / COLOR 0 0 0 0 — the
// same tuple this getter answered as a constant before those setters existed.
Value::Integer(i64::from(look.font_size)),
@@ -356,7 +506,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
r: to_byte(r),
g: to_byte(g),
b: to_byte(b),
..*look
..look.clone()
};
if next != *look {
*look = next;
@@ -509,6 +659,228 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// ── The rest of the 0x98 record: name, shown, message types, channels ──────────────────
//
// `SetChatWindowName 0x4a13f0` / `SetChatWindowShown 0x4a1730` are registrar entries beside
// the look setters (chat-window-record.md §8); `Add/Remove/GetChatWindowMessages`
// (`0x4a0e80`/`0x4a0f40`/`0x4a0d20`) and `Add/Remove/GetChatWindowChannel(s)`
// (`0x4a1000`/`0x4a1260`/`0x4a0dc0`) read and write the `+0x20` flags and the `+0x64`/`+0x74`
// arrays the same census bounded (§7). Every write that moves the record queues the window
// for the app's persist pass, like the look setters.
fn with_look<T>(
lua: &Lua,
id: i64,
f: impl FnOnce(&mut ChatWindowLook) -> T,
) -> mlua::Result<T> {
let i = window_index(id)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let before = model.chat_window_looks[i].clone();
let out = f(&mut model.chat_window_looks[i]);
if model.chat_window_looks[i] != before {
model.chat_window_changes.insert(i);
}
Ok(out)
}
// The names, resolved against `CHATMSGGROUP` the loader's way; a name with no group is
// silently nothing (`0x4a0ea9` scan, no match → no write).
fn names_of(args: MultiValue) -> Vec<&'static str> {
args.iter()
.filter_map(|v| match v {
Value::String(s) => s.to_str().ok().and_then(|s| message_group_index(s.trim())),
_ => None,
})
.map(|i| MESSAGE_GROUPS[i].0)
.collect()
}
// SetChatWindowName(id, name) — `SStrCopy(record+0, name, 0x20)`: 31 bytes and the NUL; a
// non-string second argument is the empty string (`0x4a1436`).
lua.globals().set(
"SetChatWindowName",
lua.create_function(|lua, (id, name): (i64, Option<String>)| {
let mut name = name.unwrap_or_default();
if name.len() > 31 {
let mut cut = 31;
while !name.is_char_boundary(cut) {
cut -= 1;
}
name.truncate(cut);
}
with_look(lua, id, |look| look.name = name)
})?,
)?;
// SetChatWindowShown(id [, shown]) — `+0x94 = 0x6f1c10(L, 2, default 1)`: NO second argument
// stores 1; an explicit nil, `false`, `0` or `"off"` store 0 — the reference's own flag
// coercion (a boolean, a number by `__ftol`, a string by its digits or by
// on/off/enabled/disabled).
lua.globals().set(
"SetChatWindowShown",
lua.create_function(|lua, (id, rest): (i64, MultiValue)| {
let shown = match rest.front() {
None => true,
Some(Value::Nil) | Some(Value::Boolean(false)) => false,
Some(Value::Boolean(true)) => true,
Some(Value::Integer(n)) => *n != 0,
Some(Value::Number(n)) => n.is_finite() && n.trunc() != 0.0,
Some(Value::String(s)) => {
let s = s
.to_str()
.map(|s| s.trim().to_ascii_lowercase())
.unwrap_or_default();
if s.starts_with(|c: char| c.is_ascii_digit() || c == '-') {
leading_int(&s) != 0
} else {
!(s == "off" || s == "disabled")
}
}
Some(_) => true,
};
with_look(lua, id, |look| look.shown = shown)
})?,
)?;
// AddChatWindowMessages(id, "TYPE", "TYPE", …) — each name set in the window's flags.
lua.globals().set(
"AddChatWindowMessages",
lua.create_function(|lua, (id, names): (i64, MultiValue)| {
let names = names_of(names);
with_look(lua, id, |look| {
for name in names {
if !look.messages.iter().any(|m| m == name) {
look.messages.push(name.to_string());
}
}
look.normalize_messages();
})
})?,
)?;
lua.globals().set(
"RemoveChatWindowMessages",
lua.create_function(|lua, (id, names): (i64, MultiValue)| {
let names = names_of(names);
with_look(lua, id, |look| {
look.messages.retain(|m| !names.contains(&m.as_str()));
})
})?,
)?;
// GetChatWindowMessages(id) — the enabled names as a vararg list, which
// `ChatFrame_RegisterForMessages(GetChatWindowMessages(id))` consumes directly.
lua.globals().set(
"GetChatWindowMessages",
lua.create_function(|lua, id: i64| {
let i = window_index(id)?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
let mut out = Vec::new();
for name in &model.chat_window_looks[i].messages {
out.push(Value::String(lua.create_string(name)?));
}
Ok(MultiValue::from_vec(out))
})?,
)?;
// AddChatWindowChannel(id, "channel") — `0x4a1000` (chat-cache-grammar.md §5): (i) the
// `ChatChannels.dbc` walk compares the argument with each row's **Shortcut**, whole, case-
// folded; no match → id 0 and the Lua string; a match with no zone text yet → **nil** (0
// values) and nothing stored; a match → the row's id and **the DBC's own Shortcut string**,
// not the argument. (ii) The id is the one push. (iii) A name already in the window answers
// the id and stores nothing. `ChatFrame_AddChannel` keys its bookkeeping on the answer's
// truthiness, and 0 is truthy.
lua.globals().set(
"AddChatWindowChannel",
lua.create_function(|lua, (id, name): (i64, Option<String>)| {
let Some(name) = name.map(|n| n.trim().to_string()).filter(|n| !n.is_empty()) else {
return Ok(MultiValue::new());
};
let matched = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model
.zone_channel_catalog
.iter()
.find(|r| r.shortcut.eq_ignore_ascii_case(&name))
.cloned()
};
let (name, zone_id) = match matched {
None => (name, 0),
Some(ZoneChannelRow { resolved: None, .. }) => return Ok(MultiValue::new()),
Some(row) => (row.shortcut, row.id),
};
with_look(lua, id, |look| {
if !look
.channels
.iter()
.any(|(c, _)| c.eq_ignore_ascii_case(&name))
{
look.channels.push((name, zone_id));
}
})?;
Ok(MultiValue::from_vec(vec![Value::Integer(i64::from(
zone_id,
))]))
})?,
)?;
// RemoveChatWindowChannel(id, "channel" | n) — `0x4a1260`: a numeric argument (`SStrToInt`,
// n ≠ 0) names joined slot n, whose name is the key — or nothing, when no such slot; 0 (no
// digits) keeps the string. Then the shortcut walk above resolves the key to the DBC's own
// spelling, and the slot with that name is freed. 0 values.
lua.globals().set(
"RemoveChatWindowChannel",
lua.create_function(|lua, (id, key): (i64, Option<String>)| {
let Some(key) = key else {
return Ok(());
};
let key = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let n = leading_int(&key);
let key = if n > 0 {
match usize::try_from(n)
.ok()
.and_then(|n| model.joined_channels.get(n - 1))
.and_then(|c| c.clone())
{
Some(name) => name,
None => return Ok(()),
}
} else {
key.trim().to_string()
};
match model
.zone_channel_catalog
.iter()
.find(|r| r.shortcut.eq_ignore_ascii_case(&key))
{
Some(ZoneChannelRow { resolved: None, .. }) => return Ok(()),
Some(row) => row.shortcut.clone(),
None => key,
}
};
with_look(lua, id, |look| {
look.channels.retain(|(c, _)| !c.eq_ignore_ascii_case(&key));
})
})?,
)?;
// GetChatWindowChannels(id) — name, zoneId, name, zoneId, … which
// `ChatFrame_RegisterForChannels` walks two at a time.
lua.globals().set(
"GetChatWindowChannels",
lua.create_function(|lua, id: i64| {
let i = window_index(id)?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
let mut out = Vec::new();
for (name, zone_id) in &model.chat_window_looks[i].channels {
out.push(Value::String(lua.create_string(name)?));
out.push(Value::Integer(i64::from(*zone_id)));
}
Ok(MultiValue::from_vec(out))
})?,
)?;
Ok(())
}
@@ -541,9 +913,11 @@ mod tests {
}
}
/// Trap 2: `shown`/`docked` are `nil` where the cache stores 0, because FrameXML branches on
/// them bare and `0` is true in Lua. Window 1 is shown at dock position 1; window 2 is docked
/// at position 2 but not shown; 3..7 are neither.
/// Trap 2: `shown`/`docked` are `nil` where the record stores 0, because FrameXML branches
/// on them bare and `0` is true in Lua. At the boot init (chat-cache-grammar.md §4) window 1
/// is shown and undocked — it is the dock, `FCF_DockFrame(ChatFrame1, 1)` at file scope —
/// window 2 is shown with `docked 1`, and 3..7 are neither; the `DOCKED 1`/`DOCKED 2`,
/// `SHOWN 1`/`SHOWN 0` a stock file carries are what FrameXML's own dock pass then saved.
#[test]
fn hidden_and_undocked_windows_answer_nil_never_zero() {
let s = UiScript::new().unwrap();
@@ -560,8 +934,8 @@ mod tests {
.unwrap();
(shown, docked)
};
assert_eq!(probe(1), ("number".into(), "number".into()));
assert_eq!(probe(2), ("nil".into(), "number".into()));
assert_eq!(probe(1), ("number".into(), "nil".into()));
assert_eq!(probe(2), ("number".into(), "number".into()));
for id in 3..=7 {
assert_eq!(probe(id), ("nil".into(), "nil".into()), "window {id}");
}
@@ -574,20 +948,28 @@ mod tests {
.unwrap());
}
/// Trap 3: `docked` is the dock POSITION — window 2 answers 2, not 1.
/// Trap 3: `docked` is the dock POSITION `FCF_DockFrame(frame, index)` inserts at — the
/// init's window 2 answers 1 (§4, `mov ds:0xb4ff78, 1`), and once FrameXML has saved its
/// dock (`FCF_SaveDock`) the stored positions are whatever it wrote.
#[test]
fn docked_is_a_dock_position_not_a_flag() {
let s = UiScript::new().unwrap();
let mut s = UiScript::new().unwrap();
assert!(s
.eval::<bool>("local _,_,_,_,_,_,_,_,d = GetChatWindowInfo(1) return d == nil")
.unwrap());
assert_eq!(
s.eval::<i64>("local _,_,_,_,_,_,_,_,d = GetChatWindowInfo(1) return d")
s.eval::<i64>("local _,_,_,_,_,_,_,_,d = GetChatWindowInfo(2) return d")
.unwrap(),
1
);
s.run("SetChatWindowDocked(1, 1) SetChatWindowDocked(2, 2)")
.unwrap();
assert_eq!(
s.eval::<i64>("local _,_,_,_,_,_,_,_,d = GetChatWindowInfo(2) return d")
.unwrap(),
2
);
let _ = s.take_chat_window_changes();
}
/// The corpus's own debug-window walk (MikScrollingBattleText, EnhTooltip) runs to completion
@@ -741,6 +1123,7 @@ mod tests {
font_size: 14,
locked: true,
docked: Some(1),
..Default::default()
},
)]);
assert!(
@@ -778,3 +1161,190 @@ mod tests {
);
}
}
#[cfg(test)]
mod record_tests {
use super::{ChatWindowLook, MESSAGE_GROUPS};
use crate::script::{UiScript, ZoneChannelRow};
/// The boot init (chat-cache-grammar.md §4): window 1 shown with groups 1–10, window 2 shown
/// at dock index 1 with the 34 `defaultOn` groups of 11–68, the rest empty and hidden.
#[test]
fn the_stock_records_are_the_boot_init() {
let s = UiScript::new().unwrap();
let looks = s.chat_window_looks();
assert!(looks[0].shown && looks[1].shown && !looks[2].shown);
assert_eq!((looks[0].docked, looks[1].docked), (None, Some(1)));
let general: Vec<String> = MESSAGE_GROUPS[..10]
.iter()
.map(|(n, _, _)| (*n).to_string())
.collect();
assert_eq!(looks[0].messages, general);
assert_eq!(looks[1].messages.len(), 34);
assert_eq!(looks[1].messages[0], "COMBAT_MISC_INFO");
assert_eq!(looks[1].messages[33], "MONEY");
assert!(!looks[1].messages.iter().any(|m| m == "COMBAT_PARTY_HITS"));
assert!(looks[2].messages.is_empty());
assert!(looks
.iter()
.all(|l| l.channels.is_empty() && l.name.is_empty()));
assert_eq!(
s.eval::<Vec<String>>("return {GetChatWindowMessages(1)}")
.unwrap(),
general
);
}
#[test]
fn name_and_shown_round_trip_through_the_getter_and_cue_the_persist() {
let mut s = UiScript::new().unwrap();
s.run("SetChatWindowName(3, 'Loot') SetChatWindowShown(3) SetChatWindowShown(1, nil)")
.unwrap();
assert_eq!(
s.eval::<(String, Option<i64>)>(
"local n, _, _, _, _, _, sh = GetChatWindowInfo(3) return n, sh"
)
.unwrap(),
("Loot".to_string(), Some(1))
);
assert!(s
.eval::<bool>("return select(7, GetChatWindowInfo(1)) == nil")
.unwrap());
assert_eq!(s.take_chat_window_changes(), vec![0, 2]);
s.run("SetChatWindowName(3, 'Loot')").unwrap();
assert!(s.take_chat_window_changes().is_empty(), "no move, no cue");
// The flag coercion (`0x6f1c10`, default 1) and the name cap (`SStrCopy`, 0x20).
s.run(
"SetChatWindowShown(4, 'off') SetChatWindowShown(5, '1') SetChatWindowShown(6, 0) \
SetChatWindowName(4, 'abcdefghijklmnopqrstuvwxyz0123456789')",
)
.unwrap();
let looks = s.chat_window_looks();
assert_eq!(
(looks[3].shown, looks[4].shown, looks[5].shown),
(false, true, false)
);
assert_eq!(looks[3].name.len(), 31, "31 bytes and the NUL");
}
/// The flags: a name resolves against `CHATMSGGROUP` case-folded, an unknown name is
/// nothing, and the answer is the table's order whatever order the calls came in.
#[test]
fn message_types_are_flags_answered_in_table_order() {
let mut s = UiScript::new().unwrap();
s.run(
"AddChatWindowMessages(3, 'yell', 'SAY', 'BOGUS', 'SAY', 'money') \
RemoveChatWindowMessages(1, 'Say', 'LOOT', 'NOPE') \
AddChatWindowMessages(3)",
)
.unwrap();
assert_eq!(
s.eval::<Vec<String>>("return {GetChatWindowMessages(3)}")
.unwrap(),
vec!["SAY".to_string(), "YELL".to_string(), "MONEY".to_string()]
);
let general = s.chat_window_looks()[0].messages.clone();
assert!(!general.iter().any(|m| m == "SAY" || m == "LOOT"));
assert_eq!(general.len(), 8);
assert_eq!(s.take_chat_window_changes(), vec![0, 2]);
// A host-loaded set is normalised the same way.
s.set_chat_window_looks([(
4,
ChatWindowLook {
messages: vec!["money".into(), "bogus".into(), "SAY".into(), "say".into()],
..Default::default()
},
)]);
assert_eq!(
s.chat_window_looks()[4].messages,
vec!["SAY".to_string(), "MONEY".to_string()]
);
}
/// `AddChatWindowChannel`'s legs (chat-cache-grammar.md §5): a shortcut match stores the
/// DBC's own spelling with its id, a custom name stores as typed with 0, a shortcut with no
/// zone text yet is nil and nothing; a duplicate answers the id and stores nothing.
#[test]
fn channels_carry_the_zone_id_and_answer_it_on_add() {
let mut s = UiScript::new().unwrap();
s.set_zone_channel_catalog(vec![
ZoneChannelRow {
id: 1,
shortcut: "General".into(),
resolved: Some("General - Elwynn Forest".into()),
listed: true,
},
ZoneChannelRow {
id: 2,
shortcut: "Trade".into(),
resolved: None,
listed: false,
},
]);
s.run(
"A = {AddChatWindowChannel(1, 'general')} \
B = {AddChatWindowChannel(1, 'MyChan')} \
C = {AddChatWindowChannel(1, 'mychan')} \
D = {AddChatWindowChannel(1, 'Trade')} \
E = {AddChatWindowChannel(1, '')}",
)
.unwrap();
assert_eq!(
s.eval::<(i64, i64, i64)>("return A[1], B[1], C[1]")
.unwrap(),
(1, 0, 0)
);
assert!(s
.eval::<bool>("return table.getn(D) == 0 and table.getn(E) == 0")
.unwrap());
assert_eq!(
s.eval::<Vec<mlua::Value>>("return {GetChatWindowChannels(1)}")
.unwrap()
.len(),
4,
"name, id, name, id — the duplicate was not stored twice"
);
assert_eq!(
s.chat_window_looks()[0].channels,
vec![("General".to_string(), 1), ("MyChan".to_string(), 0)],
"the DBC's spelling for the shortcut, the typed one for the custom channel"
);
s.run("RemoveChatWindowChannel(1, 'MYCHAN') RemoveChatWindowChannel(1, '3')")
.unwrap();
assert_eq!(
s.chat_window_looks()[0].channels,
vec![("General".to_string(), 1)],
"a number names a joined slot; with none joined it removes nothing"
);
assert_eq!(s.take_chat_window_changes(), vec![0]);
}
#[test]
fn a_host_loaded_record_is_what_the_verbs_read() {
let mut s = UiScript::new().unwrap();
s.set_chat_window_looks([(
4,
ChatWindowLook {
name: "Trade".into(),
shown: true,
messages: vec!["CHANNEL".into()],
channels: vec![("Trade".into(), 2)],
..Default::default()
},
)]);
assert_eq!(
s.eval::<Vec<String>>("return {GetChatWindowMessages(5)}")
.unwrap(),
vec!["CHANNEL".to_string()]
);
assert_eq!(
s.eval::<(String, i64)>("return GetChatWindowChannels(5)")
.unwrap(),
("Trade".to_string(), 2)
);
assert!(
s.take_chat_window_changes().is_empty(),
"the load path never echoes"
);
}
}
+33 -17
View File
@@ -182,6 +182,38 @@ pub(super) fn set_from_engine(model: &mut Model, name: &str, value: String) {
}
/// Push the warn-once for an unknown CVar name into the model's warning stream.
/// The `SetCVar` write, shared with `ConsoleExec` (which is `/console name value`): the slot
/// matched case-insensitively, the change queued for the host only when the value moved, and
/// `CVAR_UPDATE` fired only when a token was passed. `false` = no such CVar (warned once).
pub(super) fn write_cvar(
model: &mut Model,
name: &str,
value: String,
token: Option<String>,
) -> bool {
let key = name.to_ascii_lowercase();
match model.cvars.get_mut(&key) {
Some(slot) => {
if slot.value != value {
slot.value = value.clone();
let reg_name = slot.name.clone();
model.cvar_changes.push((reg_name, value.clone()));
if let Some(token) = token {
model.pending_events.push((
"CVAR_UPDATE".to_string(),
vec![ScriptValue::Str(token), ScriptValue::Str(value)],
));
}
}
true
}
None => {
warn_unknown(model, name);
false
}
}
}
fn warn_unknown(model: &mut Model, name: &str) {
let key = name.to_ascii_lowercase();
if model.cvars_warned.insert(key) {
@@ -371,23 +403,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// CVar's own name as the token. Transcribed as found, not repaired.)
let token = it.next().and_then(value_to_string);
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let key = name.to_ascii_lowercase();
match model.cvars.get_mut(&key) {
Some(slot) => {
if slot.value != value {
slot.value = value.clone();
let reg_name = slot.name.clone();
model.cvar_changes.push((reg_name, value.clone()));
if let Some(token) = token {
model.pending_events.push((
"CVAR_UPDATE".to_string(),
vec![ScriptValue::Str(token), ScriptValue::Str(value)],
));
}
}
}
None => warn_unknown(&mut model, &name),
}
write_cvar(&mut model, &name, value, token);
Ok(())
})?,
)?;
@@ -134,6 +134,26 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// GetInputLanguage() / ToggleInputLanguage() — `0x799550` / `0x799610`, the edit box's
// **IME** language, not the chat language: `ChatEdit_OnInputLanguageChanged` shows
// `INPUT_<name>` on the box's language button, and the toggle is what a Korean client
// flips between its two input modes. On a client with no IME the answer is the Roman
// alphabet and the toggle moves nothing; benilla has no IME.
m.set(
"GetInputLanguage",
lua.create_function(|lua, this: Table| {
frame_handle_of(lua, &this)?;
Ok("ROMAN")
})?,
)?;
m.set(
"ToggleInputLanguage",
lua.create_function(|lua, this: Table| {
frame_handle_of(lua, &this)?;
Ok(())
})?,
)?;
// HighlightText([start [, end]]) — defaults (0, -1) = select-all.
m.set(
"HighlightText",
@@ -102,7 +102,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
m.set(
"AddMessage",
lua.create_function(
|lua, (this, text, r, g, b): (Table, Value, Value, Value, Value)| {
|lua, (this, text, r, g, b, id): (Table, Value, Value, Value, Value, Value)| {
let Some(text) = super::message_text(lua, &text) else {
return Ok(());
};
@@ -115,7 +115,36 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
} else {
(1.0, 1.0, 1.0)
};
with_smf(lua, &this, |smf| smf.add(text, r, g, b))
// The fifth argument is the chat-type index (`GetChatTypeIndex`) the line is
// printed under — `ChatFrame.lua` passes `info.id` — and is what
// `UpdateColorByID` keys on. Absent or non-numeric is 0: a line no recolour
// will ever find.
let id = match &id {
Value::Integer(i) => u32::try_from(*i).unwrap_or(0),
Value::Number(n) if n.is_finite() && *n >= 0.0 => *n as u32,
_ => 0,
};
with_smf(lua, &this, |smf| smf.add_with_id(text, r, g, b, id))
},
)?,
)?;
// UpdateColorByID(id, r, g, b) — `0x7932b0`. `ChatFrame_OnEvent`'s UPDATE_CHAT_COLOR arm
// calls it with the type's index and the event's floats so the lines already in the
// window take the new colour; lines printed under another id are untouched.
m.set(
"UpdateColorByID",
lua.create_function(
|lua, (this, id, r, g, b): (Table, Value, Value, Value, Value)| {
let id = match &id {
Value::Integer(i) => u32::try_from(*i).unwrap_or(0),
Value::Number(n) if n.is_finite() && *n >= 0.0 => *n as u32,
_ => return Ok(()),
};
let (r, g, b) = (num_f32(&r), num_f32(&g), num_f32(&b));
with_smf(lua, &this, |smf| {
smf.update_color_by_id(id, r, g, b);
})
},
)?,
)?;
@@ -772,6 +801,41 @@ mod tests {
assert!(!s.add_chat_message("PlainFrame", "x", 1.0, 1.0, 1.0));
}
/// `UpdateColorByID` recolours exactly the lines printed under that id — the stock
/// `ChatFrame_OnEvent` repaints a type's history when its colour changes, and nothing else.
#[test]
fn update_color_by_id_recolours_only_that_ids_lines() {
let mut smf = ScrollingMessageState::default();
smf.add_with_id("a".into(), 1.0, 1.0, 1.0, 11);
smf.add_with_id("b".into(), 1.0, 1.0, 1.0, 1);
smf.add_with_id("c".into(), 1.0, 1.0, 1.0, 11);
smf.add("d".into(), 1.0, 1.0, 1.0);
let gen = smf.lines_gen;
assert_eq!(smf.update_color_by_id(11, 0.0, 0.5, 1.0), 2);
let colors: Vec<[u8; 3]> = smf.lines.iter().map(|l| l.color).collect();
assert_eq!(
colors,
vec![
[0, 128, 255],
[255, 255, 255],
[0, 128, 255],
[255, 255, 255]
]
);
assert_ne!(smf.lines_gen, gen, "a recolour is a redraw");
assert_eq!(smf.update_color_by_id(11, 0.0, 0.5, 1.0), 0, "idempotent");
assert_eq!(smf.update_color_by_id(99, 0.0, 0.0, 0.0), 0, "no such id");
// And the Lua surface: the fifth argument tags, the method recolours, silently.
let s = UiScript::new().unwrap();
s.run(
"local f = CreateFrame('ScrollingMessageFrame') \
f:AddMessage('a', 1, 1, 1, 11) f:AddMessage('b') \
f:UpdateColorByID(11, 0, 0.5, 1) f:UpdateColorByID('x', 0, 0, 0)",
)
.unwrap();
}
#[test]
fn lua_addmessage_and_scroll_surface() {
let s = UiScript::new().unwrap();
+8 -1
View File
@@ -51,7 +51,9 @@ mod button;
mod camera_view;
mod channel;
mod char_stats;
mod chat_misc;
mod chat_send;
mod chat_types;
mod chat_window;
mod clip;
mod colorselect;
@@ -160,12 +162,15 @@ pub use backdrop::{inset_atlas_bleed, pieces, Backdrop, BackdropPiece, Insets};
pub use bank::BankState;
pub use bind_confirm::PendingEquipAnswer;
pub use camera_view::{CameraViewRequest, CAMERA_VIEW_COUNT};
pub use channel::{ChannelCommand, ZoneChannelRow};
pub use char_stats::{
weapon_subclass_skill, BankBagSlots, InvSlotView, InventorySlots, UnitCombatStats,
BANK_BAG_SLOT_COUNT, INVENTORY_SLOT_COUNT, SKILL_DEFENSE, SKILL_UNARMED,
};
pub use chat_misc::EmoteRequest;
pub use chat_send::ChatSend;
pub use chat_window::ChatWindowLook;
pub use chat_types::ChatTypeColor;
pub use chat_window::{message_group_index, ChatWindowLook, MESSAGE_GROUPS};
pub use container::{
BagAutoStore, ContainerMove, ContainerSlot, ContainerState, EnchantView, PendingWrap,
PetitionSlotView, RandomPropertyView, UiCursorMode,
@@ -555,6 +560,8 @@ impl UiScript {
addon::install(&lua)?;
addon_message::install(&lua)?;
chat_send::install(&lua)?;
chat_types::install(&lua)?;
chat_misc::install(&lua)?;
channel::install(&lua)?;
chat_window::install(&lua)?;
client::install(&lua)?;
+37
View File
@@ -517,6 +517,31 @@ pub(crate) struct Model {
/// [`super::UiScript::take_chat_window_changes`] drain — the persist cue. A set, so a slider
/// drag costs one entry however many steps it took.
pub(crate) chat_window_changes: HashSet<usize>,
/// The chat-type colour registry (`0xb4e518`): 94 fixed entries + 10 `CHANNELn` extras
/// (`chat_types`). Seeded from the compiled defaults; the app overlays `chat-cache.txt`'s
/// `COLORS` block through [`super::UiScript::set_chat_colors`].
pub(crate) chat_colors: Vec<super::chat_types::ChatTypeColor>,
/// A Lua-side colour write landed — [`super::UiScript::take_chat_color_changes`].
pub(crate) chat_colors_changed: bool,
/// The languages this character knows, `Languages.dbc` row order
/// ([`super::UiScript::set_known_languages`]).
pub(crate) known_languages: Vec<String>,
/// The zone-channel rows for the current zone ([`super::UiScript::set_zone_channel_catalog`]).
pub(crate) zone_channel_catalog: Vec<super::channel::ZoneChannelRow>,
/// Channel verbs since the last [`super::UiScript::take_channel_commands`] drain.
pub(crate) channel_commands: Vec<super::channel::ChannelCommand>,
/// `DoEmote` calls since the last drain.
pub(crate) emote_requests: Vec<super::chat_misc::EmoteRequest>,
/// `RandomRoll` calls since the last drain.
pub(crate) roll_requests: Vec<(u32, u32)>,
/// `UninviteByName` calls since the last drain.
pub(crate) uninvite_requests: Vec<String>,
/// `ConsoleExec` lines that were not CVar writes.
pub(crate) console_lines: Vec<String>,
/// `LoggingChat` / `LoggingCombat` — the two flags and their change cue.
pub(crate) logging_chat: bool,
pub(crate) logging_combat: bool,
pub(crate) logging_changed: bool,
/// Whether a **user-placed** frame's geometry moved since the app's last
/// [`super::UiScript::take_user_placed_change`] drain — the layout cache's persist cue
/// ([`super::layout_cache`]). One bit rather than a set of handles, because the file is
@@ -1643,6 +1668,18 @@ impl Model {
// seven (`ChatWindowLook::stock`): 1 and 2 are the dock, 3..7 are undocked.
chat_window_looks: std::array::from_fn(chat_window::ChatWindowLook::stock),
chat_window_changes: HashSet::new(),
chat_colors: super::chat_types::seed(),
chat_colors_changed: false,
known_languages: Vec::new(),
zone_channel_catalog: Vec::new(),
channel_commands: Vec::new(),
emote_requests: Vec::new(),
roll_requests: Vec::new(),
uninvite_requests: Vec::new(),
console_lines: Vec::new(),
logging_chat: false,
logging_combat: false,
logging_changed: false,
user_placed_changed: false,
default_language: None,
duel_requests: Vec::new(),
+122 -24
View File
@@ -229,9 +229,46 @@ fn is_pet_book(book_type: &str) -> bool {
book_type.eq_ignore_ascii_case(BOOKTYPE_PET)
}
/// The book-id → 0-based slot-list index seam (module docs). `None` for an id of `0` (the ref's
/// ids start at 1, so `id - 1` would otherwise underflow) — the reference's own `arg1 - 1` with its
/// `[0, 0x400)` bound, which our slot lists enforce by being shorter than that anyway.
/// The shared argument marshaller of the reference's spell-slot bindings (`0x4b3ec0`, wow-re
/// `getspellname-return-contract.md` §6 — `GetSpellName`, `GetSpellTexture`, `IsCurrentCast`,
/// `GetSpellCooldown`, `IsSpellPassive`, `CastSpell`, `PickupSpell` and the two autocast verbs all
/// call it): arg 1 must be a number and arg 2 a number or string — the book type is **not**
/// optional; the index is `trunc(arg1 - 1)` and must land in `[0, 0x400)`, else the binding
/// raises `Invalid spell slot in <Verb>` and abandons the caller's statement. `"pet"`
/// (case-insensitively) selects the pet list; any other book type, `"spell"` included, reads the
/// player's. Answers the 1-based id the rest of this module indexes by ([`slot_index`]), and the
/// book type normalised to the two names the model knows.
fn spell_slot_args(id: Value, book_type: Value, verb: &str) -> mlua::Result<(u32, String)> {
let invalid = || mlua::Error::runtime(format!("Invalid spell slot in {verb}"));
// `lua_isnumber` + `lua_tonumber` (`0x6f34d0`/`0x6f3620`): a numeric string is a number.
let index = match id {
Value::Integer(i) => i as f64,
Value::Number(n) => n,
Value::String(s) => s
.to_str()
.ok()
.and_then(|s| s.trim().parse::<f64>().ok())
.ok_or_else(invalid)?,
_ => return Err(invalid()),
};
let book = match book_type {
Value::String(s) => s.to_str().map(|s| s.to_string()).map_err(|_| invalid())?,
Value::Integer(_) | Value::Number(_) => String::new(),
_ => return Err(invalid()),
};
let index = (index - 1.0).trunc();
if !(0.0..1024.0).contains(&index) {
return Err(invalid());
}
let book = if is_pet_book(&book) {
BOOKTYPE_PET
} else {
BOOKTYPE_SPELL
};
Ok((index as u32 + 1, book.to_string()))
}
/// The book-id → 0-based slot-list index seam (module docs).
fn slot_index(id: u32) -> Option<usize> {
usize::try_from(id.checked_sub(1)?).ok()
}
@@ -530,17 +567,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// as nil at the Lua boundary; the reference renders it as the empty string the pointer targets.
g.set(
"GetSpellName",
lua.create_function(|lua, (id, book_type): (u32, String)| {
// An out-of-range index RAISES rather than answering nil. `0x4b3ec0` gates the index to
// `[0, 0x400)` (`0x4b3f0a`/`0x4b3f11`) before the binding proper runs, and the miss arm
// is `luaL_error("Invalid spell slot in GetSpellName")`, which longjmps and abandons
// the caller's statement (`super::binding_abi`). The in-binding bound check at
// `0x4b4018` is unreachable-as-taken. This file previously assumed the bound was
// "enforced by our slot lists being shorter than that anyway" — a short list answers
// nil, which is a different thing entirely.
if id >= 0x400 {
return Err(mlua::Error::runtime("Invalid spell slot in GetSpellName"));
}
lua.create_function(|lua, (id, book_type): (Value, Value)| {
let (id, book_type) = spell_slot_args(id, book_type, "GetSpellName")?;
// An out-of-range index RAISES rather than answering nil (`spell_slot_args`); the
// in-binding bound check at `0x4b4018` is unreachable-as-taken. This file previously
// assumed the bound was "enforced by our slot lists being shorter than that anyway" —
// a short list answers nil, which is a different thing entirely.
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(slot) = book_slot(&model, id, &book_type) else {
// An unfilled slot inside the range answers **two** nils (`0x4b4086` → `mov eax,0x2`
@@ -557,7 +589,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"GetSpellTexture",
lua.create_function(|lua, (id, book_type): (u32, String)| {
lua.create_function(|lua, (id, book_type): (Value, Value)| {
let (id, book_type) = spell_slot_args(id, book_type, "GetSpellTexture")?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
let tex = book_slot(&model, id, &book_type).and_then(|s| s.texture.clone());
match tex {
@@ -573,7 +606,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// ([`SpellSlotView::current`]); this only reads it back.
g.set(
"IsCurrentCast",
lua.create_function(|lua, (id, book_type): (u32, String)| {
lua.create_function(|lua, (id, book_type): (Value, Value)| {
let (id, book_type) = spell_slot_args(id, book_type, "IsCurrentCast")?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
let current = book_slot(&model, id, &book_type).is_some_and(|s| s.current);
// The ref's binding convention: 1 or nil, never false.
@@ -591,7 +625,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// out-of-range answers the cold `(0, 0, 1)` — the ref's own no-cooldown shape.
g.set(
"GetSpellCooldown",
lua.create_function(|lua, (id, book_type): (u32, String)| {
lua.create_function(|lua, (id, book_type): (Value, Value)| {
let (id, book_type) = spell_slot_args(id, book_type, "GetSpellCooldown")?;
let now: f64 = lua.globals().get("__benilla_now").unwrap_or(0.0);
let model = lua.app_data_ref::<Model>().expect("model app_data");
let cooldown = book_slot(&model, id, &book_type).and_then(|s| s.cooldown);
@@ -612,7 +647,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"IsSpellPassive",
lua.create_function(|lua, (id, book_type): (u32, String)| {
lua.create_function(|lua, (id, book_type): (Value, Value)| {
let (id, book_type) = spell_slot_args(id, book_type, "IsSpellPassive")?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(book_slot(&model, id, &book_type).is_some_and(|s| s.passive))
})?,
@@ -631,7 +667,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// (`0x4b34af`-`0x4b34bb`), exactly as `CastPetAction` does; the app supplies it at the drain.
g.set(
"CastSpell",
lua.create_function(|lua, (id, book_type): (u32, String)| {
lua.create_function(|lua, (id, book_type): (Value, Value)| {
let (id, book_type) = spell_slot_args(id, book_type, "CastSpell")?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if let Some(slot) = book_slot(&model, id, &book_type) {
if !slot.passive {
@@ -679,7 +716,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// no-record path uses, so the arity is 2 on every path including a bad index.
g.set(
"GetSpellAutocast",
lua.create_function(move |lua, (id, book_type): (u32, String)| {
lua.create_function(move |lua, (id, book_type): (Value, Value)| {
let (id, book_type) = spell_slot_args(id, book_type, "GetSpellAutocast")?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
let (allowed, enabled) = book_slot(&model, id, &book_type)
.filter(|_| is_pet_book(&book_type))
@@ -701,7 +739,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// owns, so it happens at the drain.
g.set(
"ToggleSpellAutocast",
lua.create_function(|lua, (id, book_type): (u32, String)| {
lua.create_function(|lua, (id, book_type): (Value, Value)| {
let (id, book_type) = spell_slot_args(id, book_type, "ToggleSpellAutocast")?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let spell_id = book_slot(&model, id, &book_type)
.filter(|_| is_pet_book(&book_type))
@@ -736,7 +775,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"PickupSpell",
lua.create_function(|lua, (id, book_type): (u32, String)| {
lua.create_function(|lua, (id, book_type): (Value, Value)| {
let (id, book_type) = spell_slot_args(id, book_type, "PickupSpell")?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
Ok(pickup_spell(&mut model, id, &book_type))
})?,
@@ -992,6 +1032,63 @@ mod tests {
assert!(s.eval::<bool>("return GetSpellTabInfo(3) == nil").unwrap());
}
/// The shared marshaller's law (`0x4b3ec0`): the index is `trunc(arg - 1)` gated to
/// `[0, 0x400)` and a miss RAISES; the book type is mandatory and `"pet"` is matched without
/// case; anything else is the player's book.
#[test]
fn the_slot_marshaller_gates_the_index_and_names_the_book() {
let mut s = UiScript::new().unwrap();
s.set_spellbook(book());
s.set_pet_book(pet_book());
for bad in ["0", "-11", "1025", "nil", "\"x\"", "{}"] {
let err = s
.eval::<mlua::Value>(&format!("return GetSpellTexture({bad}, BOOKTYPE_SPELL)"))
.expect_err(bad)
.to_string();
assert!(
err.contains("Invalid spell slot in GetSpellTexture"),
"{bad}: {err}"
);
}
assert!(
s.eval::<mlua::Value>("return GetSpellTexture(1)")
.unwrap_err()
.to_string()
.contains("Invalid spell slot in GetSpellTexture"),
"the book type is not optional"
);
// Inside the range but past the book: nil, not an error — the bound is the marshaller's,
// the emptiness is the slot's.
assert!(s
.eval::<bool>("return GetSpellTexture(1024, BOOKTYPE_SPELL) == nil")
.unwrap());
// Truncation toward zero: 1.9 is slot 1, and so is 0.99 (`trunc(-0.01)` is 0). A numeric
// string is a number (`lua_isnumber`).
for one in ["1.9", "0.99", "\"1\""] {
assert_eq!(
s.eval::<String>(&format!("return GetSpellName({one}, BOOKTYPE_SPELL)"))
.unwrap(),
"Fireball",
"{one}"
);
}
let (name, _) = s
.eval::<(String, String)>("return GetSpellName(1, \"PET\")")
.unwrap();
assert_eq!(
name,
pet_book().slots[0].name,
"the pet list, case-insensitively"
);
let (name, _) = s
.eval::<(String, String)>("return GetSpellName(1, 7)")
.unwrap();
assert_eq!(
name, "Fireball",
"a number is an accepted, player-book type"
);
}
#[test]
fn name_and_rank_read_through_the_book_id_seam() {
let mut s = UiScript::new().unwrap();
@@ -1460,9 +1557,10 @@ mod tests {
.eval::<bool>(r#"local a, b = GetSpellName(9, "spell") return a == nil and b == nil"#)
.unwrap());
// Past the reference's [0, 0x400) gate it RAISES rather than answering nil.
// Past the reference's [0, 0x400) gate — an id of 1025 is index 1024 — it RAISES rather
// than answering nil.
let err = s
.run(r#"GetSpellName(1024, "spell")"#)
.run(r#"GetSpellName(1025, "spell")"#)
.expect_err("an out-of-range slot must raise");
assert!(
format!("{err}").contains("Invalid spell slot in GetSpellName"),
+4
View File
@@ -41,6 +41,10 @@ pub enum SelectionRequest {
/// `AssistUnit(unit)` — select the token's own `UNIT_FIELD_TARGET`; a basis with no target is
/// a silent no-op (the reference's shared assist tail bails before any send).
Assist(String),
/// `AssistByName(name)` (`0x489c40`) — the `/assist Bob` handler's verb: select what the
/// *named player* is targeting. A name, not a unit token, so it is the app's name→unit
/// resolution (`AssistRequest`) rather than `Assist`'s token walk.
AssistByName(String),
/// `TargetLastEnemy()` — select the last *attackable* unit that was committed.
LastEnemy,
}
@@ -12,6 +12,10 @@ pub struct MessageLine {
/// The RGB the line draws at, **byte-quantized** at insert (`AddMessage` `trunc(x*255+0.5)`,
/// round-half-up; alpha is never stored — it is forced opaque and then driven by the fade).
pub color: [u8; 3],
/// `AddMessage`'s fifth argument — the chat-type index the line was printed under
/// (`GetChatTypeIndex`), so `UpdateColorByID` can find every line of a type when
/// `UPDATE_CHAT_COLOR` lands. 0 for a line printed without one.
pub id: u32,
/// Remaining phase-1 countdown (the `timeVisible` snapshot ticking down); while `> 0` the line
/// holds full alpha.
pub time_left: f32,
@@ -120,10 +124,36 @@ impl ScrollingMessageState {
/// `timeVisible`/`fadeDuration` onto the new line, and push it at the ring's newest slot,
/// dropping the oldest when over `max_lines`. A view scrolled up stays anchored on the same
/// content (the ring cursor is a slot, not an offset — msgframe-runtime.md).
/// `ScrollingMessageFrame:UpdateColorByID(id, r, g, b)` (`0x7932b0`) — every line tagged
/// `id` takes the new colour, quantised the way `AddMessage` quantised the old one.
/// Returns how many lines moved; a recolour bumps the generation so a settled frame
/// redraws.
pub fn update_color_by_id(&mut self, id: u32, r: f32, g: f32, b: f32) -> usize {
let rgb = [quantize_u8(r), quantize_u8(g), quantize_u8(b)];
let mut moved = 0;
for line in self.lines.iter_mut().filter(|l| l.id == id) {
if line.color != rgb {
line.color = rgb;
moved += 1;
}
}
if moved > 0 {
self.lines_gen = self.lines_gen.wrapping_add(1);
}
moved
}
pub fn add(&mut self, text: String, r: f32, g: f32, b: f32) {
self.add_with_id(text, r, g, b, 0);
}
/// `AddMessage(text, r, g, b, id)` — the line, tagged with the chat-type index it was
/// printed under so a later `UpdateColorByID(id, …)` can recolour it.
pub fn add_with_id(&mut self, text: String, r: f32, g: f32, b: f32, id: u32) {
self.lines_gen = self.lines_gen.wrapping_add(1);
let line = MessageLine {
text,
id,
color: [quantize_u8(r), quantize_u8(g), quantize_u8(b)],
time_left: self.time_visible,
fade_left: self.fade_duration,
@@ -466,6 +496,7 @@ impl MessageFrameState {
self.lines_gen = self.lines_gen.wrapping_add(1);
self.lines.push_back(MessageLine {
text,
id: 0,
color: [quantize_u8(r), quantize_u8(g), quantize_u8(b)],
time_left: self.time_visible,
fade_left: self.fade_duration,
+6
View File
@@ -13,6 +13,7 @@ use benilla_assets::materials::{WowModelExt, WowModelMaterial, VANILLA_ALPHA_KEY
mod batch;
pub mod lazy;
pub mod park;
mod visibility;
pub use batch::{BatchVariants, M2BatchMaterials, ModelMaterials, SkyboxBatch};
@@ -1130,6 +1131,11 @@ pub fn plugin(app: &mut App) {
// Deferred realization (`lazy`): a built material becomes an asset the frame something
// visible binds it. `Last`, after every writer and the `PostUpdate` twin spawns.
app.add_systems(Last, lazy::realize_bound);
// Parking (`park`): a long-hidden streamed part puts its `Mesh3d` down. `Update`, after the
// authority, on last frame's propagated verdict — the order the module doc explains.
if park::enabled() {
app.add_systems(Update, park::park_hidden_parts.after(ModelVisSet));
}
}
/// Expire the material dedup by **distance** (decision 0785).
@@ -0,0 +1,157 @@
//! **Parking hidden world submeshes** — a streamed model part that has been hidden for a while
//! puts its `Mesh3d` down, and picks it up the frame its placement is shown again.
//!
//! Why: the retained pass draws the world, and the entity path keeps the batches it declines
//! resident — 5.8k ADT-doodad submeshes at the Stormwind auction house, 5.3k of them hidden by
//! the outdoor election or the far clip at any instant (decision 1947's census: WMO props whose
//! class cannot merge, shared-geometry batches, exterior fader props). Bevy's own sweeps walk
//! every `Mesh3d` whether it draws or not: the asset-changed marks, the specialization check,
//! `calculate_bounds`, the previous-transform update, the visibility reset — ~0.4 ms of an
//! alone city frame on the sampled profile, for rows that draw nothing. Without `Mesh3d` an
//! entity is in none of those queries, and everything else it carries stays: the transform,
//! the material handle, the tag, the pick mesh, the fade state.
//!
//! **The hysteresis is the point.** The election flips as the camera turns; parking on the
//! first hidden frame would move a component per flip per part. A part parks after
//! [`PARK_AFTER_FRAMES`] consecutive hidden frames and unparks the moment its root is shown —
//! so a glance across the square costs nothing, and only what stays hidden gets cheap.
//!
//! **Timing, and the one landmine.** The root election writes in `PostUpdate`; this runs in
//! `Update` on the next frame's verdict, which puts the re-inserted `Mesh3d` in place before
//! that frame's `check_entities_needing_specialization` and visibility check. Inserting it
//! later — after the visibility check, on an entity already view-visible — is the
//! `specialize_material_meshes` tick unwrap `static_gx::cull` documents (B2, 1431), and the
//! price of the safe order is one frame between a placement's election and its first draw.
//! Every world model part takes part — the streamer's placements and the units' bodies alike
//! (a raid standing round the camera keeps half its bodies behind it) — except parts on a
//! render layer of their own: a portrait booth's mirror parts show and hide with the booth's
//! wake, and its bake window is four frames.
use bevy::prelude::*;
use super::ModelPart;
/// Consecutive hidden frames before a part parks — half a second at 60 Hz.
const PARK_AFTER_FRAMES: u16 = 30;
/// The mesh a parked part put down, restored verbatim on unpark.
#[derive(Component)]
pub struct ParkedMesh(pub Handle<Mesh>);
/// Consecutive frames this part has been hidden (its own or an ancestor's verdict).
#[derive(Component, Default)]
pub struct HiddenFrames(pub u16);
/// `Update`, after the visibility authority: count hidden frames, park the long-hidden,
/// unpark the shown. Reads last `PostUpdate`'s `InheritedVisibility` — the propagated verdict
/// of this part's whole ancestry.
#[allow(clippy::type_complexity)]
pub(super) fn park_hidden_parts(
mut commands: Commands,
mut parts: Query<
(
Entity,
&InheritedVisibility,
Option<&mut HiddenFrames>,
Option<&Mesh3d>,
Option<&ParkedMesh>,
),
(
With<ModelPart>,
Without<bevy::camera::visibility::RenderLayers>,
),
>,
) {
for (entity, inherited, frames, mesh, parked) in &mut parts {
if inherited.get() {
if let Some(parked) = parked {
commands
.entity(entity)
.insert(Mesh3d(parked.0.clone()))
.remove::<ParkedMesh>();
}
if let Some(mut f) = frames {
if f.0 != 0 {
f.0 = 0;
}
}
continue;
}
let Some(mut frames) = frames else {
commands.entity(entity).insert(HiddenFrames(1));
continue;
};
frames.0 = frames.0.saturating_add(1);
if frames.0 == PARK_AFTER_FRAMES {
if let Some(mesh) = mesh {
commands
.entity(entity)
.insert(ParkedMesh(mesh.0.clone()))
.remove::<Mesh3d>();
}
}
}
}
/// `WOW_NO_MESH_PARK=1` — the A/B lever.
pub(super) fn enabled() -> bool {
std::env::var_os("WOW_NO_MESH_PARK").is_none()
}
#[cfg(test)]
mod tests {
use super::*;
fn world() -> (World, Entity) {
let mut w = World::new();
let e = w
.spawn((
ModelPart {
kind: super::super::ModelKind::Doodad,
blend: benilla_formats::ModelBlend::Opaque,
},
Mesh3d(Handle::default()),
InheritedVisibility::HIDDEN,
))
.id();
(w, e)
}
fn run(w: &mut World, n: usize) {
let mut sys = IntoSystem::into_system(park_hidden_parts);
sys.initialize(w);
for _ in 0..n {
sys.run((), w).unwrap();
sys.apply_deferred(w);
}
}
#[test]
fn a_part_parks_after_the_hysteresis_and_unparks_when_shown() {
let (mut w, e) = world();
run(&mut w, PARK_AFTER_FRAMES as usize - 1);
assert!(w.entity(e).contains::<Mesh3d>(), "not yet");
run(&mut w, 1);
assert!(!w.entity(e).contains::<Mesh3d>(), "parked");
assert!(w.entity(e).contains::<ParkedMesh>());
w.entity_mut(e).insert(InheritedVisibility::VISIBLE);
run(&mut w, 1);
assert!(
w.entity(e).contains::<Mesh3d>(),
"unparked on the shown frame"
);
assert!(!w.entity(e).contains::<ParkedMesh>());
assert_eq!(w.entity(e).get::<HiddenFrames>().unwrap().0, 0);
}
#[test]
fn a_glance_never_parks() {
let (mut w, e) = world();
run(&mut w, 10);
w.entity_mut(e).insert(InheritedVisibility::VISIBLE);
run(&mut w, 1);
w.entity_mut(e).insert(InheritedVisibility::HIDDEN);
run(&mut w, 10);
assert!(w.entity(e).contains::<Mesh3d>(), "the count restarted");
}
}
@@ -414,16 +414,25 @@ pub fn spawn_model_entities(
} else {
why = match site {
crate::static_gx::GxSite::Doodad { .. } if is_wmo => "doodad-site-wmo",
crate::static_gx::GxSite::Doodad { .. } if !class.merges() => "no-merge",
crate::static_gx::GxSite::Doodad { .. } if class.excluded => "no-merge-anim",
crate::static_gx::GxSite::Doodad { .. } if !class.merges() => {
"no-merge-transparent"
}
crate::static_gx::GxSite::Doodad { .. } if class.interior_prop => {
"interior-prop"
}
crate::static_gx::GxSite::Doodad { .. } => "fader-lane-off",
crate::static_gx::GxSite::Wmo { .. } if !is_wmo => "wmo-site-m2",
crate::static_gx::GxSite::Wmo { .. } if !class.merges() => "wmo-no-merge",
crate::static_gx::GxSite::Wmo { .. } if class.excluded => "wmo-no-merge-anim",
crate::static_gx::GxSite::Wmo { .. } if !class.merges() => {
"wmo-no-merge-transparent"
}
crate::static_gx::GxSite::Wmo { .. } => "wmo-no-group",
crate::static_gx::GxSite::Prop { .. } if is_wmo => "prop-site-wmo",
crate::static_gx::GxSite::Prop { .. } if !class.merges() => "prop-no-merge",
crate::static_gx::GxSite::Prop { .. } if class.excluded => "prop-no-merge-anim",
crate::static_gx::GxSite::Prop { .. } if !class.merges() => {
"prop-no-merge-transparent"
}
crate::static_gx::GxSite::Prop { .. } => "exterior-fader-prop",
};
match site {