the chat tab's right-click menu, and Options gets its Chat page

- right-click a chat tab for its menu: background colour and alpha through the colour picker, font size — three of GetChatWindowInfo's nine values are real state now, persisted per character in benilla-config/chat, and the hover fade interpolates from the window's own stored alpha as the reference's guard does (the default look is unchanged, pinned)
- the Social page becomes the Chat page 1.12 has, in the reference's anchor order: Remove Chat Hover Delay and Detailed Loot Information join, and `showLootSpam` off collapses the roll chatter to the winner line with its number; Guild Member Alert is registered at the reference's default, off
- two guild-line defects found while settling that CVar: the sign-on line's fourth conjunct is a friends-list test (an ignored guildmate was silenced, a guildmate who is also a friend announced twice), and vmangos broadcasts your own sign-on, which benilla announced to you at every login

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-25 18:45:45 +07:00
co-authored by Claude Fable 5
parent 4eeed067a1
commit fd928d9cb3
26 changed files with 2363 additions and 149 deletions
+396 -28
View File
@@ -230,10 +230,53 @@
chatType("BATTLEGROUND", 1, 93, 255, 127, 0)
chatType("BATTLEGROUND_LEADER", 0, 94, 255, 219, 183)
-- ── BenillaFCF: the FloatingChatFrame behaviors, re-expressed (ref FloatingChatFrame.lua)
-- Quoted constants: CHAT_TAB_SHOW_DELAY=0.2, CHAT_FRAME_FADE_TIME=0.15,
-- DEFAULT_CHATFRAME_ALPHA=0.25, CHAT_BUTTON_FLASH_TIME=0.5,
-- ── The FloatingChatFrame constants, as the GLOBALS the reference declares ───────────
-- These were comments here until decision 1589 (B246): the Chat options page's *Remove
-- Chat Hover Delay* row writes the first two through `SetChatMouseOverDelay`, so they
-- have to be values something can move rather than numbers inlined in the fade. Names,
-- spellings and values are the reference's own (FloatingChatFrame.lua l.8-12), and two of
-- them — `CHAT_FRAME_FADE_TIME` and `CHAT_FRAME_TEXTURES` — are in the captured 1.12 `_G`
-- as `framexml` globals, so an addon can read them.
CHAT_TAB_SHOW_DELAY = 0.2
CHAT_FRAME_FADE_TIME = 0.15
DEFAULT_CHATFRAME_ALPHA = 0.25
DEFAULT_CHATFRAME_COLOR = { r = 0, g = 0, b = 0 }
-- Still quoted rather than declared (nothing writes them): CHAT_BUTTON_FLASH_TIME=0.5,
-- MESSAGE_SCROLLBUTTON_INITIAL_DELAY=0, MESSAGE_SCROLLBUTTON_SCROLL_DELAY=0.05.
-- REMOVE_CHAT_DELAY — the Chat options page's *Remove Chat Hover Delay* row (decision
-- 1589), a saved variable exactly as the reference has it (`UIOptionsFrameCheckButtons`
-- CheckButton18, `uvar = "REMOVE_CHAT_DELAY"`, `default = "0"`). Declared HERE because
-- this is the file that owns what it changes — a row whose defining file was not loaded
-- gets a nil `applyFunc` and is skipped, which is what keeps a partial harness honest
-- (OptionsFrame.xml's `OptionsFrame_ApplySavedSettings`). The `RegisterForSave` sits on
-- the line under the assignment, per 1128's load order.
REMOVE_CHAT_DELAY = "0"
RegisterForSave("REMOVE_CHAT_DELAY")
-- The row's applyFunc. The reference has no such function because it calls
-- `SetChatMouseOverDelay(REMOVE_CHAT_DELAY)` inline from two places (its options load,
-- l.216, and the checkbox's own commit); ours needs a zero-argument name to hand the row,
-- and this is it — one line, in the file that owns both halves.
function ChatFrame_ApplyMouseOverDelay()
SetChatMouseOverDelay(REMOVE_CHAT_DELAY)
end
-- SetChatMouseOverDelay(noDelay) — ref FloatingChatFrame.lua l.1456-1464, verbatim in
-- effect: "1" collapses BOTH the stationary-hover delay and the fade to zero, so the box
-- appears the instant the cursor crosses it. The options row's `applyFunc` is the only
-- caller, and `UIOptionsFrame_Load` calls it at login for the same reason ours does.
function SetChatMouseOverDelay(noDelay)
if noDelay == "1" then
CHAT_TAB_SHOW_DELAY = 0
CHAT_FRAME_FADE_TIME = 0
else
CHAT_TAB_SHOW_DELAY = 0.2
CHAT_FRAME_FADE_TIME = 0.15
end
end
-- ── BenillaFCF: the FloatingChatFrame behaviors, re-expressed (ref FloatingChatFrame.lua)
BenillaFCF = {
selected = 1, -- SELECTED_DOCK_FRAME (1 = General, 2 = Combat Log)
hover = false, -- cursor inside the dock's padded rect
@@ -267,6 +310,90 @@
return list
end
-- ── The per-window LOOK: the tint and the base alpha (decision 1589, B246) ───────────
-- `frame.oldAlpha` and the CHAT_FRAME_TEXTURES vertex colour in the reference; a pair of
-- constants here (alpha 0, black) until B246 asked for a way to change them. The store is
-- the engine's — `GetChatWindowInfo`/`SetChatWindowColor`/`SetChatWindowAlpha`, which is
-- what an addon reads too — and this table is the per-frame cache the fade reads each
-- frame instead of re-crossing that seam.
BenillaFCF_Look = {}
local function lookFor(id)
local l = BenillaFCF_Look[id]
if not l then
l = { r = 0, g = 0, b = 0, a = 0 }
BenillaFCF_Look[id] = l
end
return l
end
-- FCF_SetWindowColor(frame, r, g, b, doNotSave) — ref FloatingChatFrame.lua l.696-704.
-- The tint is applied here and now (a vertex colour is not part of the fade); the save is
-- what makes it outlive the session, and `doNotSave` is the reference's own flag for
-- "this value CAME from the store" (its `FloatingChatFrame_Update` passes it).
function FCF_SetWindowColor(frame, r, g, b, doNotSave)
local id = frame:GetID()
local l = lookFor(id)
l.r, l.g, l.b = r, g, b
for _, t in ipairs(BenillaFCF_Textures(id)) do
t:SetVertexColor(r, g, b)
end
if not doNotSave then
SetChatWindowColor(id, r, g, b)
end
end
-- FCF_SetWindowAlpha(frame, alpha, doNotSave) — ref l.706-716.
--
-- The reference writes the nine textures' alpha straight here; ours cannot, because the
-- hover fade below is the single writer of those alphas (decision 1396's latch is what
-- keeps an idle dock from rewriting them every frame). So this moves the BASE the fade
-- interpolates from and invalidates that latch, which makes the next frame's apply the
-- one write — the reference's own `frame.oldAlpha` bookkeeping, minus the double write.
function FCF_SetWindowAlpha(frame, alpha, doNotSave)
local id = frame:GetID()
lookFor(id).a = alpha
frame.oldAlpha = alpha -- ref: "Remember the alpha"
BenillaFCF.appliedReveal = -1
if not doNotSave then
SetChatWindowAlpha(id, alpha)
end
end
-- The alpha a window's chrome actually wears at a given point in the hover fade.
--
-- **The reference only fades a window that is DIMMER than the hover value**: both the fade
-- in and the fade out are guarded by `chatFrame.oldAlpha < DEFAULT_CHATFRAME_ALPHA`
-- (FloatingChatFrame.lua l.871 / l.913). So a player who has made their chat box solid
-- keeps it solid when the cursor leaves — the box does not dim itself back down — while
-- the shipped default (alpha 0) rides the full 0 → 0.25 ramp exactly as before.
local function windowAlpha(id, reveal)
local base = lookFor(id).a
if base >= DEFAULT_CHATFRAME_ALPHA then
return base
end
return base + (DEFAULT_CHATFRAME_ALPHA - base) * reveal
end
-- FloatingChatFrame_Update's look half (ref l.49-57): pull every window's stored tint and
-- alpha out of the engine and seat them, WITHOUT saving them back (they came from there).
-- Runs at load and on `UPDATE_CHAT_WINDOWS`, which is the event the host fires after it
-- restores the player's file.
function BenillaFCF_LoadWindowLooks()
for i = 1, 2 do
local frame = getglobal("ChatFrame" .. i)
if frame then
local _, fontSize, r, g, b, a = GetChatWindowInfo(i)
FCF_SetWindowColor(frame, r, g, b, 1)
FCF_SetWindowAlpha(frame, a, 1)
-- SIZE 0 is the stock "use the font's own height" and applies nothing.
if fontSize and fontSize > 0 then
FCF_SetChatWindowFontSize(frame, fontSize, 1)
end
end
end
end
-- FCF_SelectDockFrame: show the selected frame, hide the other; selected tab full alpha,
-- unselected half (both still scaled by the reveal fade below). Stops the selected tab's
-- whisper flash (ref FCF_SelectDockFrame → UIFrameFlashRemoveFrame). The tab getglobal
@@ -318,12 +445,181 @@
BenillaFCF_SelectDock(id)
end
function BenillaFCF_TabClick(id)
-- ref FCF_Tab_OnClick (docked, left click): select; UChatScrollButton per click.
function BenillaFCF_TabClick(id, button)
-- ref FCF_Tab_OnClick l.1219-1226: a RIGHT click opens the window's options menu and
-- returns — it does not select the tab, and it plays no click sound (the ref's sound
-- is on the OnClick *after* the call, which the early return skips). The ref also
-- calls `chatFrame:StopMovingOrSizing()` first; benilla has no drag, so there is
-- nothing in flight to stop.
if button == "RightButton" then
ToggleDropDownMenu(1, nil, getglobal("ChatFrame" .. id .. "TabDropDown"),
"ChatFrame" .. id .. "Tab", 0, 0)
return
end
-- ref FCF_Tab_OnClick (docked, left click): close any open menu, then select.
CloseDropDownMenus()
BenillaFCF_SelectDock(id)
PlaySound("UChatScrollButton")
end
-- ── The chat tab's options menu (decision 1589, B246) ────────────────────────────────
-- ref `FCFOptionsDropDown_Initialize` (FloatingChatFrame.lua l.87-393). The reference's
-- level-1 menu has four blocks: the window verbs (Lock/Unlock · Rename · Create New ·
-- Remove), a **DISPLAY** header over Font Size and Background, a **FILTERS** header over
-- seven message-group submenus, and Join New Channel.
--
-- benilla ships the DISPLAY block, and that is the honest tree rather than a first pass:
-- every window verb needs the window management 0288 §2 deliberately left out (there is
-- no lock, no rename, no undock, no create/close), and the FILTERS block needs a runtime
-- message-group editor over `ui_chat::frames`' registration, which is its own feature and
-- not the one B246 reports missing. Each block lands with the machinery under it.
--
-- The menu is per tab (`ChatFrame<n>TabDropDown`), because `FCF_GetCurrentChatFrameID`
-- reads the open menu's PARENT id — that is the reference's contract and the reason the
-- capsule is declared inside the tab rather than shared.
-- ref l.81-85 is `UIDropDownMenu_Initialize(this, …, "MENU")` plus two width setters.
-- Ours assigns the two fields that call would have set, and skips the widths — both
-- departures are about OUR kit, not about the reference:
-- · `UIDropDownMenu_Initialize` clears every row of both shared lists, which is work at
-- LOAD time for a menu nobody has opened — and it would make this file's load order
-- depend on the dropdown kit's, and through it on GameTooltip's
-- `TOOLTIP_DEFAULT_COLOR`. `ToggleDropDownMenu` runs that init on every open anyway,
-- which is where the reference's own re-init happens too.
-- · `UIDropDownMenu_SetWidth(50)` means something DIFFERENT here: our kit reads a
-- declared width as `noResize` + a fixed list width (UIDropDownMenu.xml l.672-679),
-- so transcribing it would pin the menu at 50 px and cut "Background" in half. In the
-- reference, MENU mode never draws the capsule at all, so its width is inert there.
-- `BenillaFriendsDropDown` (FriendsFrame.xml) is already this same shape.
function FCFOptionsDropDown_OnLoad()
this.initialize = FCFOptionsDropDown_Initialize
this.displayMode = "MENU"
end
function FCFOptionsDropDown_Initialize()
local _, _, r, g, b, a = GetChatWindowInfo(FCF_GetCurrentChatFrameID())
local info
-- Level 2: the font-height list.
if UIDROPDOWNMENU_MENU_LEVEL == 2 then
if UIDROPDOWNMENU_MENU_VALUE == FONT_SIZE then
local _, fontHeight = FCF_GetCurrentChatFrame():GetFont()
for _, value in ipairs(CHAT_FONT_HEIGHTS) do
info = {}
info.text = format(FONT_SIZE_TEMPLATE, value)
info.value = value
info.func = FCF_SetChatWindowFontSize
-- ref: `value == floor(fontHeight+0.5)` — the tick follows the font the
-- frame is actually wearing, not the stored SIZE (which is 0 until the
-- player picks one).
if fontHeight and value == floor(fontHeight + 0.5) then
info.checked = 1
end
UIDropDownMenu_AddButton(info, UIDROPDOWNMENU_MENU_LEVEL)
end
end
return
end
-- Display header (ref l.273-279).
info = {}
info.text = DISPLAY
info.notClickable = 1
info.isTitle = 1
info.notCheckable = 1
UIDropDownMenu_AddButton(info)
-- Font size (ref l.281-288).
info = {}
info.text = FONT_SIZE
info.hasArrow = 1
info.func = nil
info.notCheckable = 1
UIDropDownMenu_AddButton(info)
-- Background — the colour swatch plus the opacity slider (ref l.290-307). This row is
-- B246's actual fix: `hasOpacity` is what puts `OpacitySliderFrame` beside the colour
-- wheel, and `opacityFunc` is what makes the chat box follow the thumb live.
--
-- **`info.opacity = 1 - a` is the reference's own line**, comment included ("Done
-- because the slider is reversed"): the opacity slider reads 0 at the TOP, so the
-- value it is seeded with and the value read back off it are both complements. The
-- read-back half is `FCF_SetChatWindowOpacity` below.
info = {}
info.text = BACKGROUND
info.hasColorSwatch = 1
info.r, info.g, info.b = r, g, b
if a then
a = 1 - a
end
info.opacity = a
info.swatchFunc = FCF_SetChatWindowBackGroundColor
info.func = UIDropDownMenuButton_OpenColorPicker
info.hasOpacity = 1
info.opacityFunc = FCF_SetChatWindowOpacity
info.cancelFunc = FCF_CancelWindowColorSettings
UIDropDownMenu_AddButton(info)
end
-- ref l.723-733: the id/frame the open menu belongs to. The menu's capsule is a child of
-- the tab, and the tab's id is its window's — which is why the tabs carry `id=` at all.
function FCF_GetCurrentChatFrameID()
return UIDropDownMenu_GetCurrentDropDown():GetParent():GetID()
end
-- ref l.729-735, fallback included: `UIDropDownMenu_GetCurrentDropDown` answers `this`
-- when no menu has ever been opened, so the lookup can miss — and then the caller IS a
-- row of the menu, whose parent is the capsule.
function FCF_GetCurrentChatFrame()
local current = getglobal("ChatFrame"
.. UIDropDownMenu_GetCurrentDropDown():GetParent():GetID())
if not current then
current = getglobal("ChatFrame" .. this:GetParent():GetID())
end
return current
end
-- ref l.740-744. The picker drives this on every drag of the colour wheel.
function FCF_SetChatWindowBackGroundColor()
local r, g, b = ColorPickerFrame:GetColorRGB()
FCF_SetWindowColor(FCF_GetCurrentChatFrame(), r, g, b)
end
-- ref l.746-749, the reversed slider's other half (see the Background row above).
function FCF_SetChatWindowOpacity()
FCF_SetWindowAlpha(FCF_GetCurrentChatFrame(), 1.0 - OpacitySliderFrame:GetValue())
end
-- Cancel restores what the row was opened with. The reference names this
-- `FCF_CancelWindowColorSettings` and hands it the pre-open r/g/b/opacity as arguments;
-- ours reads them off the button the picker was opened from, which is where
-- `UIDropDownMenuButton_OpenColorPicker` already stashes them (UIDropDownMenu.xml) — the
-- same values, one hop closer, and no argument contract to get wrong.
function FCF_CancelWindowColorSettings(prev)
local frame = FCF_GetCurrentChatFrame()
if prev and prev.r then
FCF_SetWindowColor(frame, prev.r, prev.g, prev.b)
end
if prev and prev.opacity then
FCF_SetWindowAlpha(frame, 1.0 - prev.opacity)
end
end
-- ref l.751-763: the visible half is the frame's own font, the stored half is SIZE.
-- `doNotSave` is ours, for the load path (the value already came from the store).
function FCF_SetChatWindowFontSize(chatFrame, fontSize, doNotSave)
if not chatFrame then
chatFrame = FCF_GetCurrentChatFrame()
end
if not fontSize then
fontSize = this.value
end
local fontFile, unused, fontFlags = chatFrame:GetFont()
chatFrame:SetFont(fontFile, fontSize, fontFlags)
if not doNotSave then
SetChatWindowSize(chatFrame:GetID(), fontSize)
end
end
-- The whisper tab flash (ref FCF_FlashTab: never the selected tab; 0.25s on/off for 60s,
-- and a flashing tab holds the dock tabs visible). Fired by the app on a received
-- whisper landing in an unselected window (0288 P4). No-tab windows can't flash.
@@ -387,7 +683,7 @@
s.hoverTime = 0
s.oldX, s.oldY = x, y
end
if s.hoverTime > 0.2 then s.revealTarget = 1 end
if s.hoverTime > CHAT_TAB_SHOW_DELAY then s.revealTarget = 1 end
else
s.hover = false
s.hoverTime = 0
@@ -395,16 +691,26 @@
end
if s.flashTab ~= 0 then s.revealTarget = 1 end
-- The 0.15s linear fade toward the target (ref UIFrameFadeIn/Out over
-- CHAT_FRAME_FADE_TIME).
if s.reveal < s.revealTarget then
s.reveal = math.min(s.revealTarget, s.reveal + elapsed / 0.15)
-- The linear fade toward the target (ref UIFrameFadeIn/Out over CHAT_FRAME_FADE_TIME).
-- **Zero is a real setting, not a guard against nothing**: the Chat page's *Remove
-- Chat Hover Delay* row sets both this and the delay to 0 (ref SetChatMouseOverDelay),
-- and the division would be 0/0 on the frame `elapsed` is also zero. The reference
-- lands in the same place by a different road — `UIFrameFadeIn` with timeToFade 0
-- finishes on its first tick.
local fade = CHAT_FRAME_FADE_TIME
if fade <= 0 then
s.reveal = s.revealTarget
elseif s.reveal < s.revealTarget then
s.reveal = math.min(s.revealTarget, s.reveal + elapsed / fade)
elseif s.reveal > s.revealTarget then
s.reveal = math.max(s.revealTarget, s.reveal - elapsed / 0.15)
s.reveal = math.max(s.revealTarget, s.reveal - elapsed / fade)
end
-- Apply: tabs at 1.0 (selected) / 0.5 (unselected) × reveal; the selected window's
-- background+borders at 0.25 × reveal (ref: fade oldAlpha↔DEFAULT_CHATFRAME_ALPHA).
-- Apply: tabs at 1.0 (selected) / 0.5 (unselected) × reveal; each window's
-- background+borders at its own `windowAlpha` (ref: fade oldAlpha↔DEFAULT_CHATFRAME_
-- ALPHA). Until 1589 that second term was the constant `0.25 * reveal`, which is what
-- `windowAlpha` still computes for the shipped base of 0 — the default look is
-- unchanged, it is now just one case of a rule instead of the only case.
--
-- GATED on everything the writes below depend on — the reveal, the selected tab, and
-- whether the label measurements have landed (decision 1396). **The reference needs
@@ -421,8 +727,9 @@
if tab then
tab:SetAlpha(((i == s.selected) and 1.0 or 0.5) * s.reveal)
end
local a = windowAlpha(i, s.reveal)
for _, t in ipairs(BenillaFCF_Textures(i)) do
t:SetAlpha(0.25 * s.reveal)
t:SetAlpha(a)
end
end
s.appliedReveal = s.reveal
@@ -865,10 +1172,25 @@
SubmitChatInput stands in for ChatEdit_SendText: the grammar behind it is Rust
(0881), which is the only part of that chain we replace. -->
<OnLoad>
if this:GetID() == 1 then this:RegisterEvent("EXECUTE_CHAT_LINE") end
<!-- UPDATE_CHAT_WINDOWS — the reference's own event for "a window's stored
settings moved", and the one FloatingChatFrame_OnEvent listens for
(FloatingChatFrame.lua l.34-38) to re-read GetChatWindowInfo. Ours is fired
by the host once it has restored the player's per-character file
(ui_chat::settings), which lands after this file has already run. It rides
window 1's registration for the same reason EXECUTE_CHAT_LINE does: the
template is shared by all seven, the handler re-reads EVERY window, and seven
registrations would run it seven times per restore. -->
if this:GetID() == 1 then
this:RegisterEvent("EXECUTE_CHAT_LINE")
this:RegisterEvent("UPDATE_CHAT_WINDOWS")
end
</OnLoad>
<OnEvent>
if event == "EXECUTE_CHAT_LINE" then SubmitChatInput(arg1) end
if event == "EXECUTE_CHAT_LINE" then
SubmitChatInput(arg1)
elseif event == "UPDATE_CHAT_WINDOWS" then
BenillaFCF_LoadWindowLooks()
end
</OnEvent>
<OnUpdate>
ChatFrame_OnUpdate(arg1)
@@ -1053,7 +1375,7 @@
line pipeline doesn't exist), so the tab just selected an empty window. ChatFrame2
itself stays above (the router keeps feeding it) so the tab can return whole with the
combat-log arc — director's call, 2026-07-11. The FCF loops guard the missing tab. ── -->
<Button name="ChatFrame1Tab" frameStrata="LOW" alpha="0">
<Button name="ChatFrame1Tab" frameStrata="LOW" alpha="0" id="1">
<Size><AbsDimension x="70" y="32"/></Size>
<Anchors>
<Anchor point="BOTTOMLEFT" relativeTo="ChatFrame1Background" relativePoint="TOPLEFT"><Offset><AbsDimension x="2" y="0"/></Offset></Anchor>
@@ -1090,11 +1412,35 @@
</Layer>
</Layers>
</Frame>
<!-- The window's options menu (decision 1589). A child of the TAB, because
FCF_GetCurrentChatFrameID reads the open menu's parent id — the reference's own
contract, and why the tab carries `id` above. -->
<Frame name="$parentDropDown" inherits="UIDropDownMenuTemplate" hidden="true">
<Scripts>
<OnLoad>FCFOptionsDropDown_OnLoad()</OnLoad>
</Scripts>
</Frame>
</Frames>
<Scripts>
<!-- ref FloatingChatFrame.xml l.121: the tab takes the right button too, which is
what opens the options menu. A Button's default set is LeftButtonUp only, so
without this the right-click never reaches OnClick at all. -->
<OnLoad>
this:RegisterForClicks("LeftButtonUp", "RightButtonUp")
</OnLoad>
<OnClick>
BenillaFCF_TabClick(1)
BenillaFCF_TabClick(1, arg1)
</OnClick>
<!-- ref l.129-134. The tip is how the menu is DISCOVERABLE: nothing on screen says a
chat tab has a right-click menu, which is half of why B246 read as "no chat
options at all". `noNormalText` is the reference's own 6th argument. -->
<OnEnter>
BenillaTooltip_AddNewbieTipFor(this, CHAT_OPTIONS_LABEL, 1.0, 1.0, 1.0,
NEWBIE_TOOLTIP_CHATOPTIONS, 1)
</OnEnter>
<OnLeave>
GameTooltip:Hide()
</OnLeave>
</Scripts>
<ButtonText name="$parentText" text="GENERAL">
<Anchors>
@@ -1134,7 +1480,7 @@
Anchored LEFT-to-RIGHT off tab 1; `BenillaFCF_TabResize` sizes both to their measured label
widths, so the abutment survives a locale whose words are longer.
-->
<Button name="ChatFrame2Tab" frameStrata="LOW" alpha="0">
<Button name="ChatFrame2Tab" frameStrata="LOW" alpha="0" id="2">
<Size><AbsDimension x="70" y="32"/></Size>
<Anchors>
<Anchor point="LEFT" relativeTo="ChatFrame1Tab" relativePoint="RIGHT"/>
@@ -1171,11 +1517,35 @@
</Layer>
</Layers>
</Frame>
<!-- The window's options menu (decision 1589). A child of the TAB, because
FCF_GetCurrentChatFrameID reads the open menu's parent id — the reference's own
contract, and why the tab carries `id` above. -->
<Frame name="$parentDropDown" inherits="UIDropDownMenuTemplate" hidden="true">
<Scripts>
<OnLoad>FCFOptionsDropDown_OnLoad()</OnLoad>
</Scripts>
</Frame>
</Frames>
<Scripts>
<!-- ref FloatingChatFrame.xml l.121: the tab takes the right button too, which is
what opens the options menu. A Button's default set is LeftButtonUp only, so
without this the right-click never reaches OnClick at all. -->
<OnLoad>
this:RegisterForClicks("LeftButtonUp", "RightButtonUp")
</OnLoad>
<OnClick>
BenillaFCF_TabClick(2)
BenillaFCF_TabClick(2, arg1)
</OnClick>
<!-- ref l.129-134. The tip is how the menu is DISCOVERABLE: nothing on screen says a
chat tab has a right-click menu, which is half of why B246 read as "no chat
options at all". `noNormalText` is the reference's own 6th argument. -->
<OnEnter>
BenillaTooltip_AddNewbieTipFor(this, CHAT_OPTIONS_LABEL, 1.0, 1.0, 1.0,
NEWBIE_TOOLTIP_CHATOPTIONS, 1)
</OnEnter>
<OnLeave>
GameTooltip:Hide()
</OnLeave>
</Scripts>
<ButtonText name="$parentText" text="COMBAT_LOG">
<Anchors>
@@ -1405,10 +1775,12 @@
</EditBox>
<!-- The load-order hack, like the ref's own trailing FCF_DockFrame(ChatFrame1, 1): select
the General tab once everything above exists — and tint the window chrome BLACK
(FCF_SetWindowColor: the ref vertex-colors every CHAT_FRAME_TEXTURES piece with the
the General tab once everything above exists — and seat every window's stored LOOK
(FCF_SetWindowColor/Alpha: the ref vertex-colors every CHAT_FRAME_TEXTURES piece with the
chat-cache window COLOR, which ships 0 0 0; the ChatFrameBackground art itself is
white, so untinted it reads as a white wash over the world). -->
white, so untinted it reads as a white wash over the world). Since 1589 that colour is
read out of the engine rather than written as a literal, because the player can now
change it — a fresh store still answers 0 0 0, so a first run looks exactly as before. -->
<Script><![CDATA[
-- `DEFAULT_CHAT_FRAME = ChatFrame1` — the reference's own line (ChatFrame.lua), and the
-- name half of FrameXML actually prints through. It sits here rather than in the header
@@ -1419,10 +1791,6 @@
DEFAULT_CHAT_FRAME = ChatFrame1
BenillaFCF_SelectDock(1)
for i = 1, 2 do
for _, t in ipairs(BenillaFCF_Textures(i)) do
t:SetVertexColor(0, 0, 0)
end
end
BenillaFCF_LoadWindowLooks()
]]></Script>
</Ui>
+11 -4
View File
@@ -123,10 +123,17 @@
for token, rgb in pairs(classRGB) do
RAID_CLASS_COLORS[token] = { r = rgb[1], g = rgb[2], b = rgb[3] }
end
-- The three OTHER tables the reference's Fonts.xml `<Script>` block carries —
-- CHAT_FONT_HEIGHTS, MATERIAL_TEXT_COLOR_TABLE, MATERIAL_TITLETEXT_COLOR_TABLE — are
-- deliberately not here: **zero corpus addons name any of them**, and nothing benilla ships
-- reads them. They cost nine lines each the day something does.
-- CHAT_FONT_HEIGHTS — the chat window's four selectable font heights, the reference's own
-- table verbatim (ref Fonts.xml l.21-26). It sat in the note below as "costs nine lines the
-- day something reads it" until decision 1589 (B246) built the chat tab's **Font Size**
-- submenu, which is the reference's only reader too (`FCFOptionsDropDown_Initialize`
-- iterates it). Still zero corpus addons, so the demand here is ours, not theirs.
CHAT_FONT_HEIGHTS = { 12, 14, 16, 18 }
-- The two OTHER tables the reference's Fonts.xml `<Script>` block carries —
-- MATERIAL_TEXT_COLOR_TABLE and MATERIAL_TITLETEXT_COLOR_TABLE — are deliberately not
-- here: **zero corpus addons name either**, and nothing benilla ships reads them. They
-- cost nine lines each the day something does.
]]></Script>
<!-- MasterFont: the shadow-only root the GameFont* families chain through (ref l.56). -->
+94 -14
View File
@@ -194,7 +194,7 @@
-- name suffixes; the visible label doubles as the page title (the era does the same —
-- Settings.RegisterCategory names both from one string).
OPTIONS_CATEGORY_KEYS = {
"Controls", "Interface", "ActionBars", "Combat", "Social", "Keybindings", "Nameplates",
"Controls", "Interface", "ActionBars", "Combat", "Chat", "Keybindings", "Nameplates",
"Graphics", "Audio",
}
@@ -639,10 +639,29 @@
"RowInstantQuestText", "RowBuffDurations", "RowAutoQuestWatch",
"RowNewbieTips",
},
-- The two chat-bubble switches (decision 1139), in the reference's relative order —
-- its Advanced Options chat chain runs CheckButton37 → 46 (Loot Spam, no knob of ours)
-- → 38, so ours are adjacent with that gap named rather than silently closed.
Social = { "RowChatBubbles", "RowPartyChatBubbles" },
-- 1.12's Advanced Options **Chat** box (decision 1589, B246 — "no chat section in
-- options"). The page was called `Social` and held exactly the two chat-bubble
-- switches 1139 put on it; it is the reference's own `CHAT_LABEL` box now, and grew
-- into it. Order is the reference's own anchor chain (UIOptionsFrame.xml l.751-840:
-- CheckButton 7 → 8 → 13 → 18 → 37 → 46 → 38 → 51 → 68), with the rows we have no
-- mechanism for skipped rather than stubbed:
-- · 7 Simple Chat — needs FCF_Set_SimpleChat's re-layout (0288 §2)
-- · 8 Lock Chat Settings — needs drag/undock/resize to lock (0288 §2)
-- · 51 Auto-join Guild Recruitment Channel — carries **no cvar, uvar or func in the
-- reference either** (UIOptionsFrame.lua l.51 is a bare `{ index = 51 }`), so
-- it is inert there; a working row here would be an invention, not a fix
-- · 68 Disable Spam Filter — benilla filters no chat text. Unlike 51 this is a REAL
-- omission and not an inert row: `spamFilter` is registered "1" at `0x402e8e`
-- with a change callback, and its one reader is the chat chokepoint `0x49a870`,
-- which drops a `CHAT_MSG_FILTERED` line on a word-list hit unless the type is
-- whisper (§5, wow-re `system/ui/scratch/spam-filter-cvar.md`). The row lands
-- with the filter.
-- 1139's note about the 37 → 46 → 38 gap is closed by this record: 46 is Loot Spam,
-- and it has a knob now.
Chat = {
"RowGuildMemberAlert", "RowRemoveChatDelay", "RowChatBubbles", "RowLootSpam",
"RowPartyChatBubbles",
},
-- 1.12's Advanced Options "Action Bars" group, whole (decision 1500 completing 1136).
-- Its six rows, the four bar toggles then the grid switch then the lock. That is the
-- DIRECTOR's order, not the reference's: 1.12's box is two columns and leads with the
@@ -1793,12 +1812,12 @@
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentRowActionBars" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>OptionsCategoryRow_OnLoad(self, "Combat", "Combat")</OnLoad></Scripts>
</Button>
<Button name="$parentRowSocial" inherits="OptionsCategoryRowTemplate">
<Button name="$parentRowChat" inherits="OptionsCategoryRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentRowCombat" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>OptionsCategoryRow_OnLoad(self, "Social", "Social")</OnLoad></Scripts>
<Scripts><OnLoad>OptionsCategoryRow_OnLoad(self, "Chat", "Chat")</OnLoad></Scripts>
</Button>
<Button name="$parentRowKeybindings" inherits="OptionsCategoryRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentRowSocial" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentRowChat" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>OptionsCategoryRow_OnLoad(self, "Keybindings", "Keybindings")</OnLoad></Scripts>
</Button>
<!-- Nameplates — the era Gameplay group's own last row (NAMEPLATE_OPTIONS_LABEL,
@@ -2783,27 +2802,88 @@
in the reference's relative order. Neither is a dependency child:
the client gates party lines on their own CVar, so party bubbles
survive turning say/yell bubbles off. -->
<Frame name="$parentSocial" hidden="true">
<!-- The Chat page (decision 1589 — B246, "no chat section in
options"). 1.12's Advanced Options CHAT_LABEL box, in its own
anchor-chain order; which of its nine rows we ship and why the
other five are absent is the OPTIONS_PAGE_ROWS entry's note.
The window's *look* — background tint, opacity, font size —
is deliberately NOT here: the reference puts it on the chat
tab's right-click menu (ChatFrame.xml), and inventing a second
home for it would be a divergence, not a convenience. -->
<Frame name="$parentChat" hidden="true">
<Anchors>
<Anchor point="TOPLEFT"/>
<Anchor point="BOTTOMRIGHT"/>
</Anchors>
<Frames>
<Frame name="$parentRowChatBubbles" inherits="OptionsCheckboxRowTemplate">
<Frame name="$parentRowGuildMemberAlert" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="10" y="-12"/></Offset></Anchor>
<Anchor point="TOPRIGHT"><Offset><AbsDimension x="-20" y="-12"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
<!-- Registered "0" — the one row on this page that
ships its feature OFF, and byte-read rather
than chosen (cvars.rs cites the site). No
applyFunc: the guild sign-on handler reads the
knob as each event lands. -->
OptionsRow_OnLoad(self, "guildMemberNotify",
"Guild Member Alert",
"OPTION_TOOLTIP_GUILDMEMBER_ALERT")
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowRemoveChatDelay" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowGuildMemberAlert" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowGuildMemberAlert" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
<!-- A uvar, like the reference's (CheckButton18).
Its applyFunc is the whole feature: the two
fade constants live in ChatFrame.xml and
SetChatMouseOverDelay is what moves them. The
row needs one because nothing re-reads the
global per frame — the fade reads the
constants, and only this writes them. -->
OptionsGlobalRow_OnLoad(self, "REMOVE_CHAT_DELAY",
"Remove Chat Hover Delay",
"OPTION_TOOLTIP_REMOVE_CHAT_DELAY")
self.applyFunc = ChatFrame_ApplyMouseOverDelay
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowChatBubbles" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowRemoveChatDelay" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowRemoveChatDelay" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "ChatBubbles",
"Show Chat Bubbles", "OPTION_TOOLTIP_CHAT_BUBBLES")
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowPartyChatBubbles" inherits="OptionsCheckboxRowTemplate">
<Frame name="$parentRowLootSpam" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowChatBubbles" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowChatBubbles" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
<!-- "Detailed Loot Information" is the label; the
CVar is `showLootSpam` and it is about GROUP
LOOT ROLLS, not about loot messages generally
(its own tooltip: "Uncheck this to hide
individual loot roll messages and only show
the winner"). No applyFunc: the loot-roll
lines read the CVar as each one is composed. -->
OptionsRow_OnLoad(self, "showLootSpam",
"Detailed Loot Information",
"OPTION_TOOLTIP_SHOW_LOOT_SPAM")
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowPartyChatBubbles" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowLootSpam" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowLootSpam" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "ChatBubblesParty",
"Show Party Chat Bubbles", "OPTION_TOOLTIP_PARTY_CHAT_BUBBLES")
@@ -3153,10 +3233,10 @@
getglobal(self:GetName() .. "Label"):SetText("Interface")
</OnLoad></Scripts>
</Button>
<Button name="$parentSearchHeadSocial" inherits="OptionsSearchHeadTemplate">
<Button name="$parentSearchHeadChat" inherits="OptionsSearchHeadTemplate">
<Scripts><OnLoad>
self.categoryKey = "Social"
getglobal(self:GetName() .. "Label"):SetText("Social")
self.categoryKey = "Chat"
getglobal(self:GetName() .. "Label"):SetText("Chat")
</OnLoad></Scripts>
</Button>
<Button name="$parentSearchHeadNameplates" inherits="OptionsSearchHeadTemplate">
@@ -992,6 +992,21 @@ pub(super) fn seed_ui_fixture(
warn!("capture: ui-options-graphics seed failed: {e}");
}
}
UiFixture::OptionsChat => {
let Some(mut script) = script else {
return;
};
// The Chat page (1589), the page fixtures' posture: the real CVar set, then the live
// open path. Its Remove Chat Hover Delay row reads a saved-variable global that
// `ChatFrame.xml` declares at file scope, so a hermetic capture sees the shipped "0"
// and the row paints unchecked — which is the shipped default, not a missing load.
script.register_cvars(crate::cvars::REGISTERED.iter().copied());
if let Err(e) =
script.run("ShowUIPanel(OptionsFrame); OptionsFrameCategoryListRowChat:Click()")
{
warn!("capture: ui-options-chat seed failed: {e}");
}
}
UiFixture::OptionsWorldDetail => {
let Some(mut script) = script else {
return;
+3 -2
View File
@@ -208,8 +208,9 @@ fn glue_screen() -> Option<GlueScreen> {
/// 1.0), `WOW_FX_AZ`/`WOW_FX_EL` (camera orbit degrees, default 0/10), `WOW_FX_DIST` (yards,
/// default 5), `WOW_FX_FLY` (yd/s along the model's facing — a missile only trails in motion;
/// default 0), `WOW_FX_YAW` (model facing, degrees, default 0), `WOW_FX_TURN` (deg/s the fixture
/// keeps turning after spawn — the attached-model heading-since-birth fan, wow-re
/// `part-kit-effect-attach-orient.md`; default 0), `WOW_FX_GROUND` (=1 seats the
/// keeps turning after spawn — a host that changes heading mid-effect, which is how you see that
/// a world-mode cloud does NOT swing with it (decisions 1585/1591; the "heading-since-birth fan"
/// this knob was built for turned out not to exist); default 0), `WOW_FX_GROUND` (=1 seats the
/// fixture ON the terrain via a down-ray — required to see a ground-anchored effect's projected
/// surface decals, `crate::ground_fx`; default 0 = the mid-air point), `WOW_FX_HOLD` (=1 keeps
/// the fixture alive past one sequence pass — previewing a persistent HOLD kit's steady state;
@@ -129,6 +129,11 @@ pub(super) enum UiFixture {
/// read the CVar registration defaults (hermetic capture = no config file), so the pixels
/// move only with the window, the atlas seam, or a registered default.
OptionsGraphics,
/// The Options window ON THE CHAT PAGE (decision 1589 — B246's "no chat section in options").
/// The page 1.12 calls `CHAT_LABEL`: four checkbox rows over three stores at once (a saved
/// variable, three CVars), which is what makes it worth a baseline of its own — the row art is
/// the Audio page's, but the page is the first to mix stores in one column.
OptionsChat,
/// The Graphics page with the Environment Detail MENU OPEN (0992) — the dropdown-list look
/// instrument: the shared DropDownList1 at the window's effective scale (the kit's uiScale
/// correction), three entries with High checked, the kit's dialog backdrop.
@@ -1016,6 +1021,16 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
minute: 720,
ui: Some(UiFixture::OptionsGraphics),
},
// The same window on the CHAT page (1589). Run with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-options-chat`.
Scenario {
name: "ui-options-chat",
map: MAP_AZEROTH,
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
ui: Some(UiFixture::OptionsChat),
},
// The Graphics page with the Environment Detail dropdown OPEN (0992). Run with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-options-worlddetail`.
Scenario {
+2 -2
View File
@@ -86,7 +86,7 @@ use benilla_assets::{AssetSet, WorldAssets};
use benilla_world::view::WorldCamera;
/// The two CVars (registrar `0x603280`), host side. **Registered knobs since decision 1139** —
/// they were a pair of `const bool` from 0598 until the options window had a Social page to put
/// they were a pair of `const bool` from 0598 until the options window had a page to put
/// them on, which is exactly the shape 1134 calls a row over a frozen gate. The defaults are the
/// values that were frozen: `ChatBubbles` the reference's own "1"; `ChatBubblesParty` flipped ON
/// against the binary's "0" (the director's ask: `/p` bubbles), the same deliberate deviation
@@ -271,7 +271,7 @@ pub(crate) struct BubbleQueue(Vec<(u64, ChatEventKind, String)>);
impl BubbleQueue {
/// Queue a routed wire line for a bubble. Drops non-bubbling kinds, disabled CVars, and
/// senderless lines here; the live-unit/range/plate gates run in the driver. The switch is
/// read at PUSH time, so a Social-page click takes the very next line either way.
/// read at PUSH time, so a Chat-page click takes the very next line either way.
pub(crate) fn push(
&mut self,
cfg: &BubbleConfig,
+51 -1
View File
@@ -162,6 +162,25 @@ pub(crate) const REGISTERED: &[(&str, &str)] = &[
// registers "0" — the director's `/p` ask, mirrored from BubbleConfig::default().
("ChatBubbles", "1"),
("ChatBubblesParty", "1"),
// *Detailed Loot Information* (1589, the Chat page) — 1.12's `showLootSpam`, whose subject is
// group LOOT ROLLS (its own tooltip: "Uncheck this to hide individual loot roll messages and
// only show the winner"). Registered `"1"`, **byte-read**: wow-re's census of `0xb4e2bc`
// (`lootroll-chat-and-lifecycle.md` §4) has the register site at `0x48fd1c`, name `0x8430a0`,
// default string `0x82e748` = "1", **category** 5 — and exactly four references to the global,
// one writer and three readers, all in the roll-line composers. The knob is
// [`crate::ui_loot::LootConfig::show_loot_spam`], welded to that default below.
("showLootSpam", "1"),
// *Guild Member Alert* (1589, the Chat page) — 1.12's `guildMemberNotify`, whose registered
// help string says what it does: "Receive notification when guild members log on/off".
//
// Registered **`"0"`** — this is one of the few rows that ships a feature OFF, and it is
// byte-read rather than chosen: the register site `0x5e24c7` pushes default `0x82e570` = "0"
// (§5, wow-re `system/object-layer/scratch/guild-signon-cvar-gate.md`). A stock 1.12 client is
// silent when a guildmate logs in, and a whole-image census of the record global `0xc4d3c4`
// finds exactly two readers, both inside `SMSG_GUILD_EVENT`'s handler. The knob is
// [`crate::ui_guild::GuildMemberNotify`]; the other three conjuncts of the line's display
// condition live on `ui_guild::apply::event`.
("guildMemberNotify", "0"),
// The minimap's two zoom indices (1131). Byte-verified 1.12 CVars, both registered `"3"`
// (wow-re, at the `RegisterCVar 0x63db90` argument slot). No options row drives these — the
// +/- buttons on the minimap do, through `Minimap:SetZoom`, exactly as in the reference, where
@@ -290,6 +309,7 @@ pub(crate) struct KnobParams<'w> {
follow: ResMut<'w, FollowConfig>,
video: ResMut<'w, VideoConfig>,
pane_rate: ResMut<'w, PaneRate>,
guild_notify: ResMut<'w, crate::ui_guild::GuildMemberNotify>,
}
impl KnobParams<'_> {
@@ -317,6 +337,7 @@ impl KnobParams<'_> {
follow: &mut self.follow,
video: &mut self.video,
pane_rate: &mut self.pane_rate,
guild_notify: &mut self.guild_notify,
}
}
}
@@ -339,6 +360,7 @@ struct Knobs<'a> {
follow: &'a mut FollowConfig,
video: &'a mut VideoConfig,
pane_rate: &'a mut PaneRate,
guild_notify: &'a mut crate::ui_guild::GuildMemberNotify,
}
/// Apply one CVar to its knob resource (parse + the knob's own clamp). `false` = not a knob this
@@ -393,6 +415,10 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool {
// The two bubble switches (1139) — flags, like every other pair here.
"chatbubbles" => knobs.bubbles.all = v != 0.0,
"chatbubblesparty" => knobs.bubbles.party = v != 0.0,
// The loot-roll detail switch (1589) — a flag over the roll-line composer's two shapes.
"showlootspam" => knobs.loot.show_loot_spam = v != 0.0,
// Guild Member Alert (1589) — conjunct 2 of the sign-on/sign-off line's condition.
"guildmembernotify" => knobs.guild_notify.0 = v != 0.0,
// The panel's 0/1/2 lands as the density multiplier ×1/×2/×3; the clamp is the 1.12
// slider's own range (an off-grid hand-edit rides between stops, like every slider).
"worlddetail" => knobs.clutter.density = v.clamp(0.0, 2.0) + 1.0,
@@ -514,6 +540,7 @@ fn sync_cvars(
follow,
video,
pane_rate,
guild_notify,
} = &params;
// The config file's values go in FIRST (decision 1291): registration — ours below, or an
// addon's `RegisterCVar` later — starts a key at its saved value. This is what carries a
@@ -529,7 +556,7 @@ fn sync_cvars(
);
script.register_cvars(REGISTERED.iter().copied());
let flag = |b: bool| if b { "1" } else { "0" }.to_string();
let session: [(&str, String); 31] = [
let session: [(&str, String); 33] = [
("MasterVolume", sound.master.to_string()),
("SoundVolume", sound.sfx.to_string()),
("MusicVolume", sound.music.to_string()),
@@ -552,6 +579,8 @@ fn sync_cvars(
),
("cameraYawSmoothSpeed", follow.yaw_speed.to_string()),
("autoLootDefault", flag(loot.auto_loot)),
("showLootSpam", flag(loot.show_loot_spam)),
("guildMemberNotify", flag(guild_notify.0)),
("UnitNamePlayer", flag(names.player)),
("UnitNameNPC", flag(names.npc)),
("UnitNameOwn", flag(names.own)),
@@ -782,6 +811,18 @@ mod tests {
assert_eq!(d["cameraYawSmoothSpeed"], follow.yaw_speed);
assert_eq!(FollowStyle::default(), FollowStyle::Smart);
assert_eq!(d["autoLootDefault"] != 0.0, LootConfig::default().auto_loot);
// The roll-detail switch (1589) welds to the same knob's default — and that default IS
// the binary's registered "1", so this row agrees with the reference on both sides.
assert_eq!(
d["showLootSpam"] != 0.0,
LootConfig::default().show_loot_spam
);
// …and the one row that ships a feature OFF, byte-read at `0x5e24c7` (§5).
assert_eq!(
d["guildMemberNotify"] != 0.0,
crate::ui_guild::GuildMemberNotify::default().0
);
assert_eq!(d["guildMemberNotify"], 0.0, "the binary registers \"0\"");
// The name trio (0992) welds to NameConfig's defaults the same way.
let names = NameConfig::default();
assert_eq!(d["UnitNamePlayer"] != 0.0, names.player);
@@ -837,6 +878,7 @@ mod tests {
let mut follow = FollowConfig::default();
let mut video = VideoConfig::default();
let mut pane_rate = PaneRate::default();
let mut guild_notify = crate::ui_guild::GuildMemberNotify::default();
let mut knobs = Knobs {
sound: &mut sound,
scale: &mut scale,
@@ -853,6 +895,7 @@ mod tests {
follow: &mut follow,
video: &mut video,
pane_rate: &mut pane_rate,
guild_notify: &mut guild_notify,
};
assert!(apply_to_knobs("MusicVolume", "0.7", &mut knobs));
assert_eq!(knobs.sound.music, 0.7);
@@ -902,6 +945,11 @@ mod tests {
assert_eq!(knobs.zoom.max, 30.0);
assert!(apply_to_knobs("autoLootDefault", "1", &mut knobs));
assert!(knobs.loot.auto_loot);
assert!(apply_to_knobs("showLootSpam", "0", &mut knobs));
assert!(!knobs.loot.show_loot_spam);
// Guild Member Alert (1589) — the row that ships OFF, so its ON is the interesting write.
assert!(apply_to_knobs("guildMemberNotify", "1", &mut knobs));
assert!(knobs.guild_notify.0);
// The name trio lands on its gates (0992).
assert!(apply_to_knobs("UnitNameNPC", "0", &mut knobs));
assert!(!knobs.names.npc);
@@ -1002,6 +1050,7 @@ mod tests {
.init_resource::<FollowConfig>()
.init_resource::<VideoConfig>()
.init_resource::<PaneRate>()
.init_resource::<crate::ui_guild::GuildMemberNotify>()
.add_plugins(CvarPlugin);
app.insert_non_send_resource(UiScript::new().unwrap());
@@ -1074,6 +1123,7 @@ mod tests {
.init_resource::<FollowConfig>()
.init_resource::<VideoConfig>()
.init_resource::<PaneRate>()
.init_resource::<crate::ui_guild::GuildMemberNotify>()
.add_plugins(CvarPlugin);
app.insert_non_send_resource(UiScript::new().unwrap());
app.update();
@@ -785,8 +785,11 @@ pub(super) fn attach_entity_visuals(
placement,
particles::EmitterFrames {
owner: Some(owner),
// A unit's OWN model is not an attached model (`[model+0x17c]` = 0).
// The cloud anchors at the unit; bones compose births only.
// The cloud SORTS at the unit; bones compose births only. Since 1585
// a world-mode store is frozen at birth, so neither can move a live
// particle. (`[model+0x17c]` is the TRANSPORT matrix — 1591 — so it is
// 0 for a unit on foot and this is not the attached/unattached split
// it used to claim to be.)
anchor: Some(entity),
// The unit's model going away IS this emitter's model going away
// (stream-out, a visual rebuild) — free the pool with it (0826).
+24
View File
@@ -208,6 +208,20 @@ pub(crate) fn camera_character_path(realm: &str, character: &str) -> Option<Path
/// never mentioned it). That split a player's state across two folders and — the part that
/// actually bit — skipped [`home`]'s hermetic guard, so a **capture read the account name off
/// whatever machine it ran on** and rendered it into the login screen it was photographing.
/// `benilla-config/chat/<realm>-<character>.txt` — the chat windows' saved look (decision 1589):
/// the background tint, the background alpha and the font size a chat tab's right-click menu sets.
/// **Character-scoped**, where the reference keeps the same three inside its per-character
/// `chat-cache.txt` — a raid alt and a questing alt want different chat boxes. See
/// [`crate::ui_chat`]'s `settings` module for the file's shape and why it is a subset of its
/// ancestor.
pub(crate) fn chat_character_path(realm: &str, character: &str) -> Option<PathBuf> {
Some(home()?.join("chat").join(format!(
"{}-{}.txt",
file_token(realm),
file_token(character)
)))
}
pub(crate) fn saved_account_path() -> Option<PathBuf> {
home().map(|h| h.join("account"))
}
@@ -343,6 +357,11 @@ mod tests {
camera_character_path("Hydraxian Waterlords", "Probeone"),
Some(tmp.join("benilla-config/camera/Hydraxian_Waterlords-Probeone.txt"))
);
// The chat windows' saved look (decision 1589) — character-scoped, same key shape again.
assert_eq!(
chat_character_path("Hydraxian Waterlords", "Probeone"),
Some(tmp.join("benilla-config/chat/Hydraxian_Waterlords-Probeone.txt"))
);
// The login screen's remembered account name (decision 0539 §4) and the framing
// instrument's pose log (0600) — the two residents that computed their own path in
@@ -376,6 +395,11 @@ mod tests {
None,
"a capture writes no player screenshots"
);
assert_eq!(
chat_character_path("Hydraxian Waterlords", "Probeone"),
None,
"a capture reads no player's chat look"
);
std::fs::remove_dir_all(&tmp).ok();
}
+15 -4
View File
@@ -226,6 +226,9 @@ pub(super) fn apply_net_updates(
// The auctioneer session (decision 1511) — the hello reply opens it, the three
// list results fill it, and `crate::ui_auction` feeds it to the window.
ResMut<crate::ui_auction::AuctionOpen>,
// Guild Member Alert (decision 1589) — the CVar knob the sign-on/sign-off pair's
// display condition reads; see `ui_guild::apply::event` for the four conjuncts.
Res<crate::ui_guild::GuildMemberNotify>,
),
),
),
@@ -390,6 +393,7 @@ pub(super) fn apply_net_updates(
current_map,
mut inspect_honor,
mut auction_open,
guild_notify,
),
),
) = caches;
@@ -1017,10 +1021,17 @@ pub(super) fn apply_net_updates(
}
SessionEvent::GuildRoster(roster) => crate::ui_guild::apply::roster(&mut guild, roster),
// The sign-on/sign-off pair's trailing guid exists for exactly one purpose — the
// ignore check that suppresses their line — which is why this arm reads `social`.
SessionEvent::GuildEvent(notice) => {
crate::ui_guild::apply::event(&mut guild, &mut chat_log, &social, notice)
}
// four-conjunct display condition on their line — which is why this arm reads
// `social`, the notify knob and our own guid (decision 1589; the condition and its
// byte addresses are on `ui_guild::apply::event`).
SessionEvent::GuildEvent(notice) => crate::ui_guild::apply::event(
&mut guild,
&mut chat_log,
&social,
&guild_notify,
self_guid.0,
notice,
),
SessionEvent::GuildCommandResult(result) => {
crate::ui_guild::apply::command_result(&mut guild, &mut chat_log, result)
}
+5
View File
@@ -26,6 +26,9 @@ mod frames;
mod input;
/// The language gate — the exemptions and the fluency lookup behind the chat garble (B262).
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 settings;
#[cfg(test)]
mod tests;
@@ -139,6 +142,8 @@ impl Plugin for UiChatPlugin {
OnExit(crate::char_select::ClientState::InWorld),
(channels::end_session_channels, end_session_chat),
);
// The per-character saved look (B246) — its own load/watch/save edges.
settings::plugin(app);
}
}
+347
View File
@@ -0,0 +1,347 @@
//! The chat windows' **saved look** — 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"*).
//!
//! 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**.
//!
//! ## Character-scoped, like the reference
//!
//! 1.12 keeps these in `WTF/Account/<ACC>/<REALM>/<CHAR>/chat-cache.txt`, whose per-window block a
//! stock client writes as
//!
//! ```text
//! WINDOW 1 SIZE 0 COLOR 0 0 0 0 LOCKED 1 DOCKED 1 SHOWN 1
//! ```
//!
//! `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.
//!
//! **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 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
//! `LOCKED`/`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.
//!
//! ## Why per character, and not a CVar
//!
//! Both halves matter. *Per character*, because it is where the reference puts it and because it
//! is what the setting means — a raid alt reading a 40-man combat log wants a solid box where a
//! questing alt wants glass. And *not a CVar*, because `SetChatWindowAlpha` is the API 1.12 addons
//! are written against: an addon that reads a window's alpha calls `GetChatWindowInfo`, and
//! routing benilla's store through `config.toml` instead would have given the same player setting
//! two different names depending on who asked.
//!
//! ## The write posture
//!
//! **Debounced by one quiet second, plus both session edges** — the colour picker's opacity slider
//! drives `FCF_SetChatWindowOpacity` on *every drag step*, so this is a slider, not a discrete
//! edit: [`crate::cvars`]'s `SAVE_QUIET` reasoning applies verbatim ("long enough to coalesce a
//! slider drag, short enough that a crash loses one gesture, not a session"). The edges are
//! `OnExit(InWorld)` and `AppExit`, the same two the camera pose and the saved variables use.
use std::path::PathBuf;
use bevy::prelude::*;
use benilla_ui::script::{ChatWindowLook, UiScript};
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.
const HEADER: &str = "\
# benilla chat window looks (decision 1589) — the tint, alpha and font size 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\".
";
/// Which character's file we are on, where it lives, and whether it is owed a write.
#[derive(Resource, Default)]
pub(super) struct ChatWindowFile {
path: Option<PathBuf>,
/// The `(realm, character)` [`Self::path`] was built for. Session-keyed (1290) like the macro
/// and binding loads: the *same* character coming back still meets a fresh VM whose look table
/// is back at the stock row.
identity: VmMemo<Option<(String, String)>>,
/// Whether **this VM** has unsaved writes. Session-keyed for a reason that is one-way and
/// therefore worth the wrapper: the values live in the VM, so a plain `bool` surviving a VM
/// replacement would let a save compose the player's file out of a table that is back at the
/// stock row — the "refusing to compose the file from nothing" hazard `crate::cvars` guards
/// against, one store over. A fresh VM starts undirty and cannot write until Lua writes.
dirty: VmMemo<bool>,
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 {
let mut out = String::from(HEADER);
for (i, l) in looks.iter().enumerate() {
out.push_str(&format!(
"WINDOW {} SIZE {} COLOR {} {} {} {}\n",
i + 1,
l.font_size,
l.r,
l.g,
l.b,
l.a
));
}
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();
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let mut it = line.split_whitespace();
let Some(head) = it.next() else { continue };
if !head.eq_ignore_ascii_case("WINDOW") {
continue;
}
let Some(index) = it.next().and_then(|n| n.parse::<usize>().ok()) else {
warn!("chat looks: WINDOW line with no number ignored: {line}");
continue;
};
if index == 0 {
continue;
}
let mut look = ChatWindowLook::default();
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());
}
// 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));
}
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.
fn load_chat_looks(
script: Option<NonSendMut<UiScript>>,
roster: Res<crate::char_select::Roster>,
mut file: ResMut<ChatWindowFile>,
) {
let Some(mut script) = script else { return };
let Some(id) = crate::ui_macro::identity(&roster) else {
return;
};
if file.identity.get(&script).as_ref() == Some(&id) {
return; // already restored for this character, into the VM that is live now
}
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;
}
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.
script.fire_event("UPDATE_CHAT_WINDOWS", vec![]);
}
/// Drain the VM's writes into the dirty flag. Cheap on a steady frame — the drain is a `take` of
/// an empty set.
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() {
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 save_chat_looks(
script: Option<NonSendMut<UiScript>>,
mut file: ResMut<ChatWindowFile>,
mut exits: MessageReader<AppExit>,
) {
let exiting = exits.read().next().is_some();
let Some(script) = script else { return };
if !*file.dirty.get(&script) {
return;
}
if !(exiting || file.last_change.is_none_or(|t| t.elapsed() >= SAVE_QUIET)) {
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());
}
*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>) {
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());
}
}
*file.dirty.get(&script) = false;
}
pub(super) fn plugin(app: &mut App) {
app.init_resource::<ChatWindowFile>()
.add_systems(
Update,
(load_chat_looks, watch_chat_looks)
.chain()
.run_if(in_state(crate::char_select::ClientState::InWorld)),
)
.add_systems(
OnExit(crate::char_select::ClientState::InWorld),
save_on_session_end,
);
// The quit flush rides the exit edge rather than `Update` for decision 1528's reason: the
// close button's `AppExit` is not written until `PostUpdate`, so a save chained beside the
// watcher would lose the last second of drags to the process ending.
crate::shutdown::on_app_exit(app, save_chat_looks.into_configs());
}
#[cfg(test)]
mod tests {
use super::*;
fn look(r: u8, g: u8, b: u8, a: u8, font_size: i32) -> ChatWindowLook {
ChatWindowLook {
r,
g,
b,
a,
font_size,
}
}
/// The file round-trips — what `render` writes is exactly what `parse` reads back, indices
/// included.
#[test]
fn the_file_round_trips() {
let looks = vec![
look(0, 0, 0, 64, 14),
look(255, 128, 0, 255, 0),
ChatWindowLook::default(),
];
let parsed = parse(&render(&looks));
assert_eq!(
parsed,
vec![(0, looks[0]), (1, looks[1]), (2, looks[2])],
"0-based indices, values intact"
);
}
/// 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![]);
}
/// 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.
#[test]
fn the_parse_is_permissive_the_way_the_reference_cache_is() {
let got = parse(
"window 1 size 16 color 10 20 30 40\n\
WINDOW 2 LOCKED 1 COLOR 1 2 3 4 DOCKED 2\n\
WINDOW 3 SIZE 12\n",
);
assert_eq!(
got,
vec![
(0, look(10, 20, 30, 40, 16)),
(1, look(1, 2, 3, 4, 0)),
(2, look(0, 0, 0, 0, 12)),
]
);
}
/// 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(0, 0, 0, 0, 18))]);
}
}
+12 -1
View File
@@ -417,7 +417,18 @@ fn channel_notices_compose_by_the_notice_law() {
/// here is the real window carrying its real `<OnEvent>`.
fn chat_vm() -> benilla_ui::script::UiScript {
let mut s = benilla_ui::script::UiScript::new().unwrap();
for file in ["Fonts.xml", "ChatFrame.xml"] {
// GameTooltip.xml + UIDropDownMenu.xml are real RUNTIME dependencies of the chat tabs since
// decision 1589: a left click closes any open menu (`CloseDropDownMenus`, the reference's own
// first move) and a right click opens the window's options menu. `benilla.toc` already orders
// both ahead of ChatFrame.xml (l.60/64 vs l.399); the harness says so too, rather than a guard
// that would hide a real ordering fault. (The tooltip file is the dropdown kit's own
// dependency — its MenuBackdrop reads `TOOLTIP_DEFAULT_COLOR`.)
for file in [
"Fonts.xml",
"GameTooltip.xml",
"UIDropDownMenu.xml",
"ChatFrame.xml",
] {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
+34 -13
View File
@@ -101,10 +101,20 @@ fn fill_d(template: &str, args: &[u32]) -> String {
/// The line one `SMSG_GUILD_EVENT` prints, if any.
///
/// `ignored` is [`crate::ui_social::SocialState::is_ignored`] on the event's trailing guid — the
/// reference runs exactly that check on the sign-on/sign-off pair (`0x5ae810`) and prints nothing
/// for an ignored guildmate. That check is the entire reason those two arms read a guid at all.
pub(super) fn event_line(notice: &GuildEventNotice, ignored: bool) -> Option<String> {
/// `announce_signon` is the sign-on/sign-off pair's **whole display condition**, resolved by the
/// caller because three of its four conjuncts need state this function does not see. See
/// [`super::apply::event`] for the conjuncts and their byte addresses; what matters here is that
/// the pair is the only place in this table with a condition at all, and that the guid those two
/// arms carry exists to answer it.
///
/// **This argument used to be `ignored`, and that was a mislabel** (corrected 1589, from a wow-re
/// §5 dispatched for exactly this): `0x5ae810` is `FriendList::FindFriendSlot`, a **friends-list**
/// membership test — base `this+8`, stride `0x20`, bound `0x32` — not the ignore-list check at
/// `this+0x650`. Reading it as "ignore" got the behaviour backwards on both sides: an *ignored*
/// guildmate was silenced where the reference announces them, and a guildmate who is also a
/// *friend* was announced twice, because `SMSG_FRIEND_STATUS` says the same thing with no gate of
/// its own. The repo's own `system/net` ledger had `0x5ae810` right the whole time.
pub(super) fn event_line(notice: &GuildEventNotice, announce_signon: bool) -> Option<String> {
let p = |i: usize| notice.params.get(i).map(String::as_str).unwrap_or_default();
match notice.event {
guild_event::PROMOTION => Some(fill(ERR_GUILD_PROMOTE_SSS, &[p(0), p(1), p(2)])),
@@ -115,11 +125,13 @@ pub(super) fn event_line(notice: &GuildEventNotice, ignored: bool) -> Option<Str
guild_event::LEADER_IS => Some(fill(ERR_GUILD_LEADER_IS_S, &[p(0)])),
guild_event::LEADER_CHANGED => Some(fill(ERR_GUILD_LEADER_CHANGED_SS, &[p(0), p(1)])),
guild_event::DISBANDED => Some(ERR_GUILD_DISBANDED.to_string()),
guild_event::SIGNED_ON if !ignored => Some(fill(ERR_FRIEND_ONLINE_SS, &[p(0), p(0)])),
guild_event::SIGNED_OFF if !ignored => Some(fill(ERR_FRIEND_OFFLINE_S, &[p(0)])),
guild_event::SIGNED_ON if announce_signon => {
Some(fill(ERR_FRIEND_ONLINE_SS, &[p(0), p(0)]))
}
guild_event::SIGNED_OFF if announce_signon => Some(fill(ERR_FRIEND_OFFLINE_S, &[p(0)])),
// MOTD's line is the FrameXML's (module doc); UPDATE_RANK_NAME and UPDATE_ROSTER are
// silent in the reference too; TABARD_CHANGE, an ignored guildmate's sign-on, and whatever
// the server invents past 0x0d show nothing. (The reference's default arm displays catalog
// silent in the reference too; TABARD_CHANGE, a sign-on the condition refuses, and
// whatever the server invents past 0x0d show nothing. (The reference's default arm displays catalog
// id 0x69, whose GlobalStrings key is not settled — an unnamed key shows nothing, which is
// the GlobalStrings data-suppression every other absent key in this client wears.)
_ => None,
@@ -223,27 +235,36 @@ mod tests {
params: vec!["Tigole".into(), "Furor".into(), "Officer".into()],
guid: None,
};
// Every other arm ignores the flag — a promotion prints whatever the condition says.
assert_eq!(
event_line(&notice, false).as_deref(),
Some("Tigole has promoted Furor to Officer.")
);
}
/// The sign-on/sign-off lines are the friend-list ones — and an ignored guildmate's are
/// suppressed entirely, which is the whole reason those two arms carry a guid.
/// The sign-on/sign-off lines are the friend-list ones, and they are the only two arms in the
/// table with a display condition — which is the whole reason those two carry a guid.
///
/// **The polarity flipped in 1589** and this test says so on purpose: the flag used to mean
/// "ignored" (suppress) and now means "announce", because `0x5ae810` turned out to be a
/// friends-list test, not the ignore check the transcription named.
#[test]
fn sign_on_lines_are_the_friend_lines_and_honour_the_ignore_list() {
fn sign_on_lines_are_the_friend_lines_and_obey_their_condition() {
let notice = GuildEventNotice {
event: guild_event::SIGNED_ON,
params: vec!["Tigole".into()],
guid: Some(9),
};
assert_eq!(
event_line(&notice, false).as_deref(),
event_line(&notice, true).as_deref(),
Some("|Hplayer:Tigole|h[Tigole]|h has come online."),
"the name fills the link and the bracket both"
);
assert_eq!(event_line(&notice, true), None, "ignored prints nothing");
assert_eq!(
event_line(&notice, false),
None,
"a refused condition prints nothing at all"
);
}
/// The MOTD's line belongs to the FrameXML, and the two silent events stay silent.
+131 -11
View File
@@ -129,6 +129,19 @@ impl RosterUpdate {
}
}
/// **Guild Member Alert** — 1.12's `guildMemberNotify`, the Chat options page's row (decision
/// 1589): *"Receive notification when guild members log on/off"* (the CVar's own registered help
/// string, `0x860320`).
///
/// A knob rather than a field on [`GuildState`] because it is a *setting*, not session state:
/// GuildState is cleared on disconnect, and a CVar must not be.
///
/// **Off by default, and that is byte-read, not a guess**: the register site `0x5e24c7` pushes
/// `0x82e570` = `"0"` (§5, wow-re `system/object-layer/scratch/guild-signon-cvar-gate.md`). A
/// stock 1.12 client prints nothing when a guildmate logs in, and neither do we.
#[derive(Resource, Default)]
pub(crate) struct GuildMemberNotify(pub(crate) bool);
/// The guild session mirror. Filled by the net drain's guild arms, read by the feed, cleared on
/// disconnect beside the other per-login resources.
#[derive(Resource, Default)]
@@ -522,17 +535,63 @@ pub(crate) mod apply {
guild.apply_roster(roster);
}
/// `SMSG_GUILD_EVENT`. The trailing guid rides only on the sign-on/sign-off pair, and the
/// ignore check is exactly what the reference does with it (`0x5ae810`).
/// `SMSG_GUILD_EVENT`. The trailing guid rides only on the sign-on/sign-off pair, and it is
/// there to answer that pair's **display condition** — which the reference builds out of
/// **four conjuncts**, all of them in the handler's `0x0c`/`0x0d` arms, each branching to the
/// same silent exit `0x5e74c9` (wow-re `system/object-layer/scratch/guild-signon-cvar-gate.md`,
/// the §5 dispatched for decision 1589; it corrects `guild-api-carve.md` §5, which recorded
/// these arms with only one of the four):
///
/// 1. **there is a local player object.** Ours is "we know our own guid" — the same fact, and
/// it is what conjunct 3 needs anyway.
/// 2. **`guildMemberNotify` is on** (`0x5e733f` / `0x5e73e7`, reading the record's `+0x28`).
/// Registered `"0"` — off out of the box, and the reason a stock client is silent here.
/// 3. **the subject is not you.** The reference compares the wire NAME against the local
/// player's (`0x609210` + `SStrCmp`); ours compares the wire GUID against [`SelfGuid`] —
/// the same subject by a different key, and the key this arm already carries. It is not
/// hypothetical: vmangos's `Guild::BroadcastPacket` walks **every** member including the
/// one who just signed on (`Guild.cpp:651-656`), so without this you announce yourself at
/// every login.
/// 4. **the subject is not on your friends list** — `FriendList::FindFriendSlot 0x5ae810`,
/// and this is the conjunct benilla had wrong. It read the call as an *ignore* check, so an
/// ignored guildmate was silenced (the reference announces them) and a guildmate who is
/// also a friend was announced **twice**: `SMSG_FRIEND_STATUS` (`0x5acde6`/`0x5ace08`)
/// emits the same two chat ids with no CVar gate at all, and de-duplicating against it is
/// this conjunct's entire job.
pub(crate) fn event(
guild: &mut GuildState,
chat_log: &mut ChatLog,
social: &SocialState,
notify: &GuildMemberNotify,
self_guid: Option<u64>,
notice: GuildEventNotice,
) {
let ignored = notice.guid.is_some_and(|g| social.is_ignored(g));
let announce = announce_signon(social, notify, self_guid, notice.guid);
guild.apply_event(&notice);
push_lines(chat_log, lines::event_line(&notice, ignored));
push_lines(chat_log, lines::event_line(&notice, announce));
}
/// The sign-on/sign-off pair's four-conjunct display condition, as one predicate — see
/// [`event`] for each conjunct's byte address and why conjunct 4 is a *friends* test.
///
/// Named and separate because it is the part that was wrong, and because a predicate is
/// testable where a `push_lines` side effect is not.
pub(super) fn announce_signon(
social: &SocialState,
notify: &GuildMemberNotify,
self_guid: Option<u64>,
subject: Option<u64>,
) -> bool {
if !notify.0 {
return false; // conjunct 2
}
match (self_guid, subject) {
// conjuncts 3 and 4.
(Some(me), Some(subject)) => subject != me && !social.is_friend(subject),
// No local player (conjunct 1), or a pair carrying no guid at all: the condition is
// unanswerable, and the reference's silent exit is the honest answer to that.
_ => false,
}
}
/// `SMSG_GUILD_COMMAND_RESULT`.
@@ -573,19 +632,22 @@ pub(crate) struct UiGuildPlugin;
impl Plugin for UiGuildPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<GuildState>().add_systems(
Update,
(
feed::feed_guild.before(UiInput),
feed::drain_guild.after(UiInput),
),
);
app.init_resource::<GuildState>()
.init_resource::<GuildMemberNotify>()
.add_systems(
Update,
(
feed::feed_guild.before(UiInput),
feed::drain_guild.after(UiInput),
),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ui_social::SocialState;
use benilla_protocol::messages::{guild_command, guild_command_error, guild_presence};
fn identity(name: &str, ranks: &[&str]) -> Identity {
@@ -617,6 +679,64 @@ mod tests {
}
}
/// The sign-on/sign-off line's **four-conjunct** display condition (decision 1589, from the
/// wow-re §5 dispatched for it). Every conjunct gets its own case, because the two that were
/// wrong were wrong in *opposite* directions and a single happy-path assertion would have
/// caught neither.
#[test]
fn the_signon_condition_is_all_four_conjuncts() {
let mut social = SocialState::default();
crate::ui_social::apply::friend_list(
&mut social,
vec![benilla_protocol::messages::FriendEntry {
guid: 7,
..Default::default()
}],
);
crate::ui_social::apply::ignore_list(&mut social, vec![9]);
let on = GuildMemberNotify(true);
let off = GuildMemberNotify(false);
let me = Some(1);
// 2 · the CVar, which ships OFF — so the default client says nothing at all.
assert!(
!apply::announce_signon(&social, &off, me, Some(5)),
"guildMemberNotify off silences the whole family"
);
assert!(apply::announce_signon(&social, &on, me, Some(5)));
// 3 · not you. vmangos broadcasts the sign-on to EVERY member including the signer
// (`Guild::BroadcastPacket`, Guild.cpp:651-656), so without this you announce yourself at
// every login.
assert!(
!apply::announce_signon(&social, &on, me, Some(1)),
"your own sign-on is not announced to you"
);
// 4 · not a friend — de-duplication against SMSG_FRIEND_STATUS, which says the same thing
// with no CVar gate of its own. THIS is the conjunct benilla read as an ignore check.
assert!(
!apply::announce_signon(&social, &on, me, Some(7)),
"a guildmate who is also a friend is announced by the friend path, not twice"
);
// …and the same mislabel's other half: an IGNORED guildmate IS announced. The ignore list
// has nothing to do with this line.
assert!(
social.is_ignored(9),
"the fixture's ignore really is an ignore"
);
assert!(
apply::announce_signon(&social, &on, me, Some(9)),
"the reference announces an ignored guildmate — 0x5ae810 is not the ignore check"
);
// 1 · a local player, and the pair's own guid: either missing leaves the condition
// unanswerable, and the reference's silent exit is the answer.
assert!(!apply::announce_signon(&social, &on, None, Some(5)));
assert!(!apply::announce_signon(&social, &on, me, None));
}
/// `show_offline` changes what `GetNumGuildMembers` counts and how the rows are ordered —
/// **and never what the roster contains**. The three index-taking bindings address the whole
/// array, so filtering it would make a stale index name a different member, or none.
+22 -1
View File
@@ -320,9 +320,30 @@ pub(crate) struct LootErrors(pub Vec<u8>);
/// every row itself at the open edge. A held SHIFT inverts the setting — era's
/// `AUTOLOOTTOGGLE` modified click, default SHIFT (Bindings_Vanilla.xml l.1467), the same
/// gesture that WAS vanilla's whole auto-loot.
#[derive(Resource, Default)]
///
/// `show_loot_spam` is 1.12's own `showLootSpam` — the *Detailed Loot Information* checkbox, whose
/// subject is **group loot rolls**, not loot messages generally (decision 1589, the Chat page).
/// It rides here rather than on [`crate::ui_loot_roll`] because it is one loot knob among the
/// loot knobs and `cvars::KnobParams` fetches this resource already. VERIFIED at the bytes (wow-re
/// `system/object-layer/scratch/lootroll-chat-and-lifecycle.md` §4): the CVar is `0xb4e2bc`,
/// registered at `0x48fd1c` with default `"1"` and flags 5, and a byte census over the whole
/// binary finds exactly four references — one writer and three readers, all three inside the
/// loot-roll line composers.
#[derive(Resource)]
pub(crate) struct LootConfig {
pub(crate) auto_loot: bool,
pub(crate) show_loot_spam: bool,
}
impl Default for LootConfig {
fn default() -> Self {
Self {
// No 1.12 CVar; era's registrar default is off (see the doc above).
auto_loot: false,
// The reference's registered `"1"` — detail on, which is 1.12's out-of-box chat.
show_loot_spam: true,
}
}
}
/// The client-local **loot-target latch** — the mirror of the real client's `[player+0x1d28]`
+161 -18
View File
@@ -264,14 +264,51 @@ impl Plugin for UiLootRollPlugin {
/// The *vote* lines keep their `_SELF` split (`LOOT_ROLL_NEED_SELF` &c. are among the referenced
/// 15), as does `LOOT_ROLL_YOU_WON`.
///
/// **The `NO_SPAM` variants are deliberately not used**, now for a *verified* reason rather than an
/// open question: they are the `showLootSpam == 0` branch, and that CVar defaults to `"1"`, under
/// which the plain `LOOT_ROLL_WON`/`_YOU_WON` (no roll number) are what the client prints. benilla
/// has no CVar layer yet — decision 0594 records the whole gated flow for when it does.
/// **The `NO_SPAM` variants are the `showLootSpam == 0` branch**, and since decision 1589 (B246's
/// Chat options page) that CVar has a row, so `detailed` is a real argument rather than a constant
/// `true`. 0594 §3 recorded the whole gated flow waiting for exactly this; wow-re's
/// `lootroll-chat-and-lifecycle.md` §4 is the byte census behind it:
///
/// | `showLootSpam` | the per-vote / per-dice line (`0x61c0b0`) | the WON line (`0x61b9e0`) |
/// |---|---|---|
/// | `1` (default) | emitted | `LOOT_ROLL_WON` / `_YOU_WON` — **no roll number** |
/// | `0` | suppressed entirely | `*_NO_SPAM_NEED` / `_GREED` — **carries the roll number** |
///
/// Two things about that table are easy to get backwards and both are VERIFIED: it is the *winner*
/// line that changes shape (turning detail OFF makes it say MORE, because it is now the only line
/// you get), and **`SMSG_LOOT_ALL_PASSED` is never gated on either side** — `0x61b640` contains no
/// read of the CVar at all. The `NO_SPAM` discriminator is `rollType == 1` (need), so anything that
/// is not need renders as greed.
///
/// Returns `None` for the one case the reference drops on the floor: a vote/dice line with detail
/// off.
///
/// Kept free of the resource lookups (`name`/`link` arrive resolved) so the whole table is directly
/// testable — it is the piece most likely to be got subtly wrong.
fn format_line(line: &RollLine, name: Option<&str>, is_self: bool, link: &str) -> String {
fn format_line(
line: &RollLine,
name: Option<&str>,
is_self: bool,
link: &str,
detailed: bool,
) -> Option<String> {
if !detailed {
if let RollLine::Announce(_) = line {
// `0x61c0b9`: the composer frees the node and returns.
return None;
}
}
Some(format_line_detailed(line, name, is_self, link, detailed))
}
/// [`format_line`]'s body once the suppression fork is out of the way.
fn format_line_detailed(
line: &RollLine,
name: Option<&str>,
is_self: bool,
link: &str,
detailed: bool,
) -> String {
match line {
// A real dice result (roll_number in 1..=100) — checked BEFORE the vote shapes.
RollLine::Announce(p) if p.is_dice() => {
@@ -302,6 +339,25 @@ fn format_line(line: &RollLine, name: Option<&str>, is_self: bool, link: &str) -
}
(false, _) => format!("{} passed on: {link}", name.unwrap_or_default()),
},
// LOOT_ROLL_*_NO_SPAM_NEED / _GREED (l.2629/2630, l.2632/2633) — the detail-off winner
// line, which is the ONLY line that roll produces, so it carries the number the suppressed
// dice line would have shown. The grey is the GlobalString's own `|cff818181`.
RollLine::Won(p) if !detailed => {
let kind = if p.roll_type == roll_vote::NEED {
"Need"
} else {
"Greed"
};
let n = p.roll_number;
if is_self {
format!("You won: {link} |cff818181({kind} - {n})|r")
} else {
format!(
"{} won: {link} |cff818181({kind} - {n})|r",
name.unwrap_or_default()
)
}
}
// LOOT_ROLL_YOU_WON (l.2631) / LOOT_ROLL_WON (l.2628).
RollLine::Won(_) if is_self => format!("You won: {link}"),
RollLine::Won(_) => format!("{} won: {link}", name.unwrap_or_default()),
@@ -319,6 +375,7 @@ fn render(
names: &mut NameCache,
commands: &NetCommands,
rolls: crate::items::RollCatalogs,
detailed: bool,
) -> Option<String> {
// Every line embeds the item link, so the template must be in hand before any of them render.
let (looted_item, roll, roller) = match line {
@@ -356,7 +413,9 @@ fn render(
_ => None,
};
Some(format_line(line, name.as_deref(), is_self, &link))
// Never `None` here: the suppression fork is the caller's (a suppressed line must be DROPPED,
// where a `None` from this function means "retry, a name is still in flight").
format_line(line, name.as_deref(), is_self, &link, detailed)
}
/// Surface the queued announcement lines in the chat window once their names resolve, colored
@@ -371,11 +430,20 @@ fn drain_lines(
commands: &NetCommands,
chat: &mut ChatLog,
catalogs: crate::items::RollCatalogs,
detailed: bool,
) {
let pending = std::mem::take(&mut rolls.pending);
let mut still = Vec::new();
for mut p in pending {
match render(&p.line, self_guid, items, names, commands, catalogs) {
// Detail off drops the vote/dice lines outright — and *drops* them, never retries them,
// which is why the fork is here and not inside `render`'s `None` (that one means "a name
// is still in flight, come back next frame").
if !detailed && matches!(p.line, RollLine::Announce(_)) {
continue;
}
match render(
&p.line, self_guid, items, names, commands, catalogs, detailed,
) {
Some(text) => chat.push_event(ChatEvent::text_only(ChatEventKind::Loot, text)),
None => {
p.tries += 1;
@@ -482,6 +550,9 @@ fn feed_loot_rolls(
// The random-suffix roll's catalogs (1547): the rolled name the frame and its chat lines show.
props: Option<Res<crate::items::RandomProperties>>,
enchants: Option<Res<crate::items::Enchants>>,
// `showLootSpam` (1589) — read here, at the moment each line is composed, exactly where the
// reference reads it (`0x61ba3a`/`0x61bafe`/`0x61c0b9`, all three inside the composers).
loot: Res<crate::ui_loot::LootConfig>,
) {
rolls.tick(time.delta().as_millis() as u32);
@@ -501,6 +572,7 @@ fn feed_loot_rolls(
&commands,
&mut chat,
catalogs,
loot.show_loot_spam,
);
// The snapshot goes in FIRST — a GroupLootFrame claimed by the START_LOOT_ROLL below reads its
@@ -666,10 +738,10 @@ mod tests {
),
];
for (p, name, is_self, expect) in cases {
let got = format_line(&RollLine::Announce(*p), *name, *is_self, LINK);
let got = format_line(&RollLine::Announce(*p), *name, *is_self, LINK, true);
assert_eq!(
got,
expect.replace("{L}", LINK),
Some(expect.replace("{L}", LINK)),
"roll_number {} roll_type {} name {name:?} is_self {is_self}",
p.roll_number,
p.roll_type
@@ -690,7 +762,9 @@ mod tests {
Some("Sam"),
true, // is_self
LINK,
);
true,
)
.expect("detail on emits the dice line");
assert!(
got.ends_with("by Sam"),
"our own roll must name us third-person: {got}"
@@ -711,13 +785,17 @@ mod tests {
Some("Bob"),
false,
LINK,
);
true,
)
.unwrap();
let dice = format_line(
&RollLine::Announce(announce(1, 57, roll_vote::GREED)),
Some("Bob"),
false,
LINK,
);
true,
)
.unwrap();
assert_ne!(vote, dice);
assert!(vote.contains("has selected Greed"), "{vote}");
assert!(dice.contains("Greed Roll - 57"), "{dice}");
@@ -735,13 +813,13 @@ mod tests {
roll_type: roll_vote::NEED,
};
assert_eq!(
format_line(&RollLine::Won(won), Some("Bob"), false, LINK),
format!("Bob won: {LINK}")
format_line(&RollLine::Won(won), Some("Bob"), false, LINK, true),
Some(format!("Bob won: {LINK}"))
);
// LOOT_ROLL_YOU_WON *does* exist and is referenced — the won line keeps its self split.
assert_eq!(
format_line(&RollLine::Won(won), None, true, LINK),
format!("You won: {LINK}")
format_line(&RollLine::Won(won), None, true, LINK, true),
Some(format!("You won: {LINK}"))
);
let passed = LootAllPassed {
looted_target: 0xAA,
@@ -751,8 +829,73 @@ mod tests {
};
// Names nobody — the `name`/`is_self` arguments are irrelevant on this arm.
assert_eq!(
format_line(&RollLine::AllPassed(passed), Some("Bob"), false, LINK),
format!("Everyone passed on: {LINK}")
format_line(&RollLine::AllPassed(passed), Some("Bob"), false, LINK, true),
Some(format!("Everyone passed on: {LINK}"))
);
}
/// `showLootSpam == 0` — the whole gated flow 0594 §3 recorded and 1589 finally wired, all
/// three of its claims in one place (wow-re `lootroll-chat-and-lifecycle.md` §4).
#[test]
fn detail_off_suppresses_the_roll_lines_and_reshapes_the_winner() {
// 1 · every vote and every dice line is dropped outright.
for p in [
announce(1, 128, roll_vote::NEED),
announce(1, 128, roll_vote::GREED),
announce(1, 128, roll_vote::PASS),
announce(1, 57, roll_vote::NEED),
announce(1, 57, roll_vote::GREED),
] {
assert_eq!(
format_line(&RollLine::Announce(p), Some("Bob"), false, LINK, false),
None,
"roll_number {} roll_type {}",
p.roll_number,
p.roll_type
);
}
// 2 · the WINNER line grows the roll number it would otherwise have left to the dice
// line, and its NEED/GREED word is decided by `rollType == 1`.
let mut won = LootRollWon {
looted_target: 0xAA,
item_slot: 0,
item_id: 17182,
random_property_id: 0,
winner: 1,
roll_number: 84,
roll_type: roll_vote::NEED,
};
assert_eq!(
format_line(&RollLine::Won(won), Some("Bob"), false, LINK, false),
Some(format!("Bob won: {LINK} |cff818181(Need - 84)|r"))
);
assert_eq!(
format_line(&RollLine::Won(won), None, true, LINK, false),
Some(format!("You won: {LINK} |cff818181(Need - 84)|r"))
);
won.roll_type = roll_vote::GREED;
assert_eq!(
format_line(&RollLine::Won(won), Some("Bob"), false, LINK, false),
Some(format!("Bob won: {LINK} |cff818181(Greed - 84)|r"))
);
// "anything that is not need renders as greed" — the discriminator is `== 1`, not a
// two-way match, so a PASS-typed win (server bookkeeping we never expect) reads Greed.
won.roll_type = roll_vote::PASS;
assert!(format_line(&RollLine::Won(won), None, true, LINK, false)
.unwrap()
.contains("(Greed - 84)"));
// 3 · ALL_PASSED is not gated on either side — `0x61b640` reads the CVar not at all.
let passed = LootAllPassed {
looted_target: 0xAA,
item_slot: 0,
item_id: 17182,
random_property_id: 0,
};
assert_eq!(
format_line(&RollLine::AllPassed(passed), None, false, LINK, false),
Some(format!("Everyone passed on: {LINK}"))
);
}
@@ -0,0 +1,433 @@
//! The chat tab's **options menu**, end to end (decision 1589, fixing **B246**: *"no chat options
//! at all — background transparency has no home, and chat can be hard to read"*).
//!
//! These drive the shipped `ChatFrame.xml` through the shipped dropdown kit and the shipped colour
//! picker, from the mouse event inward — a right-click on the real tab, a click on the real
//! **Background** row, a drag of the real `OpacitySliderFrame` — because the report is about a
//! path, not about a function. Every hop it names (is the menu reachable? does the row open the
//! picker? does the slider move the chat box? does the value survive?) is a hop where the feature
//! could be absent while every unit underneath it passed.
//!
//! The manifest is `color_picker_tests`' (fonts → panel manager → widget kit → picker) plus
//! `ChatFrame.xml` last, which is `benilla.toc`'s own order.
use benilla_ui::script::UiScript;
/// Load one shipped `assets/ui/<file>` into `s`, panicking on any loader error.
fn load_xml(s: &UiScript, file: &str) {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
}
/// The GlobalStrings the menu labels itself with. The app runs the real
/// `Interface\FrameXML\GlobalStrings.lua` off the player's own patch chain at boot
/// (`load_global_strings`); these stand in for it here, at their real 1.12.1 values —
/// `BACKGROUND` l.131 (whose own trailing comment in that file reads *"Title in the chat
/// preferences menu"*, i.e. this exact row), `DISPLAY` l.937, `FONT_SIZE` l.1983,
/// `FONT_SIZE_TEMPLATE` l.1984, `CHAT_OPTIONS_LABEL` l.673, `NEWBIE_TOOLTIP_CHATOPTIONS` l.2724.
fn bake_strings(s: &UiScript) {
s.run(
r#"
BACKGROUND = "Background"
DISPLAY = "Display"
FONT_SIZE = "Font Size"
FONT_SIZE_TEMPLATE = "%d pt"
CHAT_OPTIONS_LABEL = "Chat Options"
NEWBIE_TOOLTIP_CHATOPTIONS = "Right-click to get a list of customizable options for this window. Left-click and drag to move the window."
"#,
)
.unwrap();
}
/// The chat window with everything its tab menu reaches under it.
fn chat_with_menu() -> UiScript {
let mut s = UiScript::new().unwrap();
for file in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"UIParent.xml",
"GameTooltip.xml",
"UIDropDownMenu.xml",
"ScrollTemplates.xml",
"UIPanelTemplates.xml",
"ColorPickerFrame.xml",
"ChatFrame.xml",
] {
load_xml(&s, file);
}
bake_strings(&s);
s.set_screen_size(1600.0, 900.0);
s.resolve();
s
}
/// Right-click a frame's centre through the real hit path.
fn right_click(s: &mut UiScript, frame: &str) {
let (x, y) = s
.eval::<(f64, f64)>(&format!("return {frame}:GetCenter()"))
.unwrap();
s.mouse_button(x as f32, y as f32, "RightButton", true);
s.mouse_button(x as f32, y as f32, "RightButton", false);
s.resolve();
}
/// Move the mouse onto a frame's centre through the real hit path (fires its `OnEnter`).
fn hover(s: &mut UiScript, frame: &str) {
let (x, y) = s
.eval::<(f64, f64)>(&format!("return {frame}:GetCenter()"))
.unwrap();
s.mouse_move(x as f32, y as f32);
s.resolve();
}
/// Settle the dock's hover fade all the way open: park the cursor in the middle of the window and
/// run past the 0.2 s stationary arm plus the 0.15 s ramp (`chat_tests`' own idiom).
fn reveal_dock(s: &mut UiScript) {
let (x, y): (f32, f32) = s
.eval(
"return (ChatFrame1:GetLeft() + ChatFrame1:GetRight()) / 2, \
(ChatFrame1:GetBottom() + ChatFrame1:GetTop()) / 2",
)
.unwrap();
s.mouse_move(x, y);
for _ in 0..25 {
s.tick(0.016);
s.resolve();
}
}
/// Left-click a frame's centre through the real hit path.
fn left_click(s: &mut UiScript, frame: &str) {
let (x, y) = s
.eval::<(f64, f64)>(&format!("return {frame}:GetCenter()"))
.unwrap();
s.mouse_button(x as f32, y as f32, "LeftButton", true);
s.mouse_button(x as f32, y as f32, "LeftButton", false);
s.resolve();
}
/// **B246's first half: the menu exists and the right button reaches it.**
///
/// Before 1589 the tab registered only `LeftButtonUp`, so the right-click was swallowed by the
/// engine's default click set and no handler ever ran — which is why the report reads "no chat
/// options at all" rather than "the menu is missing a row".
#[test]
fn right_clicking_a_chat_tab_opens_its_options_menu() {
let mut s = chat_with_menu();
assert!(
s.eval::<bool>("return not DropDownList1:IsVisible()")
.unwrap(),
"no menu before any click"
);
right_click(&mut s, "ChatFrame1Tab");
assert!(
s.eval::<bool>("return DropDownList1:IsVisible()").unwrap(),
"the tab's right-click opens the options menu"
);
// The DISPLAY block, in the reference's own order (FloatingChatFrame.lua l.273-307).
assert_eq!(
s.eval::<i64>("return DropDownList1.numButtons").unwrap(),
3,
"Display title + Font Size + Background"
);
assert_eq!(
s.eval::<String>("return DropDownList1Button1:GetText()")
.unwrap(),
"Display"
);
assert_eq!(
s.eval::<String>("return DropDownList1Button2:GetText()")
.unwrap(),
"Font Size"
);
assert_eq!(
s.eval::<String>("return DropDownList1Button3:GetText()")
.unwrap(),
"Background"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A LEFT click still selects the tab and does not open the menu — the control that must not
/// change. (The reference's own fork: the right-button arm returns before the select.)
#[test]
fn a_left_click_still_selects_the_tab_and_opens_no_menu() {
let mut s = chat_with_menu();
left_click(&mut s, "ChatFrame2Tab");
assert_eq!(
s.eval::<i64>("return BenillaFCF.selected").unwrap(),
2,
"left-click selects"
);
assert!(
s.eval::<bool>("return not DropDownList1:IsVisible()")
.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");
assert!(
s.eval::<bool>("return not DropDownList1:IsVisible()")
.unwrap(),
"the left click closed the open menu"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **B246's actual ask: the background slider.** Background → the colour picker with its opacity
/// slider → dragging the thumb moves the chat window's stored alpha *and* the pixels.
///
/// The two numbers this pins are the ones a wrong implementation gets wrong in opposite
/// directions: the slider is **reversed** (0 at the top is fully opaque), so the seed is `1 - a`
/// and the read-back is `1 - value`; and the store is a **byte**, so 0.8 comes back as
/// `204/255`, not as 0.8.
#[test]
fn the_background_row_opens_the_picker_and_its_opacity_slider_drives_the_window() {
let mut s = chat_with_menu();
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 DropDownList1Button3.opacity")
.unwrap(),
1.0,
"info.opacity is 1 - a (the ref's own 'the slider is reversed')"
);
left_click(&mut s, "DropDownList1Button3ColorSwatch");
assert!(
s.eval::<bool>("return ColorPickerFrame:IsVisible()")
.unwrap(),
"the Background swatch opens the colour picker"
);
assert!(
s.eval::<bool>("return OpacitySliderFrame:IsVisible()")
.unwrap(),
"and the picker wears its opacity slider — B246's 'background slider'"
);
assert_eq!(
s.eval::<f64>("return OpacitySliderFrame:GetValue()")
.unwrap(),
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();
assert!(
(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();
let painted: f64 = s.eval("return ChatFrame1Background:GetAlpha()").unwrap();
assert!(
(painted - 0.8).abs() < 1e-6,
"a window the player made solid stays solid off-hover: {painted}"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The default look is **unchanged** by all of the above — the alpha rule generalised, it did not
/// move. A window at the shipped base of 0 still rides the full 0 → `DEFAULT_CHATFRAME_ALPHA`
/// hover ramp, which is the look the director signed off in 0288.
#[test]
fn the_shipped_window_still_fades_zero_to_a_quarter() {
let mut s = chat_with_menu();
s.mouse_move(1500.0, 850.0);
for _ in 0..4 {
s.tick(0.016);
s.resolve();
}
assert_eq!(
s.eval::<f64>("return ChatFrame1Background:GetAlpha()")
.unwrap(),
0.0,
"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"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The **Font Size** submenu — B246's other half, *"chat can be hard to read"*. The four heights
/// are the reference's `CHAT_FONT_HEIGHTS`, the tick follows the font the frame is wearing, and a
/// pick moves both the live font and the stored `SIZE`.
#[test]
fn the_font_size_submenu_resizes_the_window_and_stores_the_pick() {
let mut s = chat_with_menu();
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, "DropDownList1Button2");
assert_eq!(
s.eval::<i64>("return DropDownList2.numButtons").unwrap(),
4,
"CHAT_FONT_HEIGHTS is 12, 14, 16, 18"
);
assert_eq!(
s.eval::<String>("return DropDownList2Button1:GetText()")
.unwrap(),
"12 pt"
);
// ChatFontNormal is 14 px, so the second row is the one already ticked.
assert!(
s.eval::<bool>("return DropDownList2Button2Check:IsVisible()")
.unwrap(),
"the tick follows the font the frame is actually wearing"
);
left_click(&mut s, "DropDownList2Button3"); // 16 pt
let (_, height): (String, f64) = s
.eval("local f, h = ChatFrame1:GetFont() return f, h")
.unwrap();
assert_eq!(height, 16.0, "the live font moved");
assert_eq!(
s.eval::<i64>("local _, size = GetChatWindowInfo(1) return size")
.unwrap(),
16,
"and the pick is stored as the cache's SIZE"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The menu belongs to the tab that opened it: window 2's menu writes window 2. This is what the
/// `id` on the tab and the per-tab capsule buy — `FCF_GetCurrentChatFrameID` reads the open menu's
/// parent, so a single shared capsule would have written window 1 whichever tab was clicked.
#[test]
fn each_tabs_menu_writes_its_own_window() {
let mut s = chat_with_menu();
right_click(&mut s, "ChatFrame2Tab");
assert_eq!(
s.eval::<i64>("return FCF_GetCurrentChatFrameID()").unwrap(),
2
);
left_click(&mut s, "DropDownList1Button3ColorSwatch");
s.run("OpacitySliderFrame:SetValue(0)").unwrap(); // reversed: 0 = fully opaque
assert_eq!(
s.eval::<f64>("local _,_,_,_,_,a = GetChatWindowInfo(2) return a")
.unwrap(),
1.0,
"window 2 took the write"
);
assert_eq!(
s.eval::<f64>("local _,_,_,_,_,a = GetChatWindowInfo(1) return a")
.unwrap(),
0.0,
"window 1 did not"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Cancel restores the colour and the alpha the row was opened with — the picker's own
/// `previousValues` contract, which the reference reaches through the same `cancelFunc` field.
#[test]
fn cancelling_the_picker_puts_the_window_back() {
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();
right_click(&mut s, "ChatFrame1Tab");
left_click(&mut s, "DropDownList1Button3ColorSwatch");
s.run("OpacitySliderFrame:SetValue(0)").unwrap();
assert_eq!(
s.eval::<f64>("local _,_,_,_,_,a = GetChatWindowInfo(1) return a")
.unwrap(),
1.0,
"the drag previewed live"
);
left_click(&mut s, "ColorPickerCancelButton");
let after: f64 = s
.eval("local _,_,_,_,_,a = GetChatWindowInfo(1) return a")
.unwrap();
assert!(
(after - before).abs() < 1e-9,
"Cancel restored the alpha: {after} vs {before}"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The `UPDATE_CHAT_WINDOWS` seam: the host restores a player's saved file into the engine table
/// and fires the reference's own event; the window re-reads it and repaints. Without this the
/// restore would land in the store and show up only after something else happened to invalidate
/// the fade latch.
#[test]
fn the_restore_event_repaints_the_window_from_the_store() {
let mut s = chat_with_menu();
s.set_chat_window_looks([(
0,
benilla_ui::script::ChatWindowLook {
r: 255,
g: 0,
b: 0,
a: 255,
font_size: 18,
},
)]);
s.fire_event("UPDATE_CHAT_WINDOWS", vec![]);
s.run("BenillaFCF.hover = false FCF_OnUpdate(1.0)").unwrap();
assert_eq!(
s.eval::<f64>("return ChatFrame1Background:GetAlpha()")
.unwrap(),
1.0,
"the restored alpha is on screen"
);
let (r, g, b): (f64, f64, f64) = s
.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"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -28,6 +28,14 @@ fn load_xml(s: &UiScript, file: &str) {
fn chat_frame() -> UiScript {
let mut s = UiScript::new().unwrap();
load_xml(&s, "Fonts.xml");
// GameTooltip.xml + UIDropDownMenu.xml are real RUNTIME dependencies of the chat tabs since
// decision 1589: a left click closes any open menu (`CloseDropDownMenus`, the reference's own
// first move in `FCF_Tab_OnClick`) and a right click opens the window's options menu.
// `benilla.toc` already orders both ahead of ChatFrame.xml (l.60/64 vs l.399); the harness
// says so too, rather than a guard that would hide a real ordering fault. (The tooltip file is
// the dropdown kit's own dependency — its MenuBackdrop reads `TOOLTIP_DEFAULT_COLOR`.)
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "UIDropDownMenu.xml");
load_xml(&s, "ChatFrame.xml");
s.set_screen_size(1600.0, 900.0);
s.resolve();
+6
View File
@@ -769,6 +769,12 @@ mod group_loot_tests;
#[cfg(test)]
mod chat_tests;
/// The chat tab's options menu, end to end (decision 1589 / B246) — its own file because it needs
/// the whole dropdown + colour-picker stack under `ChatFrame.xml`, where `chat_tests` deliberately
/// runs on the window alone.
#[cfg(test)]
mod chat_options_tests;
#[cfg(test)]
mod bag_tests;
#[cfg(test)]
+160 -25
View File
@@ -165,7 +165,7 @@ fn clicking_a_row_moves_the_selection_and_the_page_title() {
///
/// This test used to ride a rowless page here to watch Defaults stay disabled, and the stand-in
/// kept moving as the arc filled pages in — Controls until 0961, Interface until 1136, Social
/// until 1139. **There is no rowless category left**, which is the milestone rather than a gap;
/// (now Chat) until 1139. **There is no rowless category left**, which is the milestone rather than a gap;
/// the guard itself is pinned by `the_defaults_button_is_armed_by_rows_not_by_a_category` below.
#[test]
fn the_close_button_hides_the_window() {
@@ -690,6 +690,30 @@ fn interface_harness() -> UiScript {
harness_on(s)
}
/// The Chat page's harness (decision 1589), the same posture as `combat_harness`: the **real**
/// `ChatFrame.xml` ahead of the window, so the *Remove Chat Hover Delay* row captures the same
/// file-scope `REMOVE_CHAT_DELAY = "0"` and the same `ChatFrame_ApplyMouseOverDelay` it captures in
/// the client. Its own chain is the manifest's: `UIParent.xml` for the managed bottom stack the
/// dock sits in, and `GameTooltip.xml` + `UIDropDownMenu.xml` because the tabs' options menus are
/// dropdown capsules and a tab click reaches `CloseDropDownMenus`.
fn chat_harness() -> UiScript {
let mut s = audio_harness();
s.set_screen_size(1024.0, 768.0);
load_definers(
&s,
&[
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"UIParent.xml",
"GameTooltip.xml",
"UIDropDownMenu.xml",
"ChatFrame.xml",
],
);
harness_on(s)
}
/// The Action Bars page's harness (1136's lock row, 1500's five switches): `ActionBar.xml` declares
/// `LOCK_ACTIONBAR` and `MultiBars.xml` the four `SHOW_MULTI_ACTIONBAR_*` globals plus
/// `ALWAYS_SHOW_MULTIBARS` (whose file-scope "0" IS the row's registered default), and both need
@@ -750,12 +774,12 @@ fn the_audio_page_reads_the_cvar_table_on_select() {
// Off to another page: the Audio body goes away with the selection (the swap is what the
// page loop does, and it is the reason a stale row can never be read from the wrong page).
s.run("OptionsFrameCategoryListRowSocial:Click()").unwrap();
s.run("OptionsFrameCategoryListRowChat:Click()").unwrap();
assert!(!s
.eval::<bool>("return OptionsFrameContainerBodyAudio:IsVisible()")
.unwrap());
assert!(s
.eval::<bool>("return OptionsFrameContainerBodySocial:IsVisible()")
.eval::<bool>("return OptionsFrameContainerBodyChat:IsVisible()")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -1901,18 +1925,19 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
);
checked += 1;
}
// 22 CVar rows (Social's two bubble switches are 1139's; Status Bar Text, Mouse Sensitivity
// 25 CVar rows (the Chat page's two bubble switches are 1139's and its Detailed Loot
// Information + Guild Member Alert are 1589's; Status Bar Text, Mouse Sensitivity
// and Max Camera Distance 1140's; Graphics' Vertical Sync is 1394's; Camera Following Style
// 1493's; Terrain Distance 1513's) + the Combat page's 14 saved-variable rows (1134) + the Interface page's 6 (3 from
// 1136, Buff Durations 1139, the target-of-target pair 1576) and the Action Bars page's 2 (the
// 1136, Buff Durations 1139, the target-of-target pair 1576), the Action Bars page's 2 (the
// lock 1136, Always Show
// ActionBars 1500) + 6 API rows (the Interface page's Show Cloak / Show Helm, 1472; the Action
// ActionBars 1500) and the Chat page's 1 (Remove Chat Hover Delay, 1589) + 6 API rows (the Interface page's Show Cloak / Show Helm, 1472; the Action
// Bars page's four multibar switches, 1500) — which is the point of counting here rather than
// per page: the third store's rows are held to the same "the key is 1.12's own and it resolves"
// bar as the other two. Camera Following Style is counted on the key it wears at rest (Smart's
// OPTION_TOOLTIP_CAMERA1) and Show When on its own (Always's OPTION_TOOLTIP_TARGETOFTARGET5);
// their other entries ride the same census as the selection moves.
assert_eq!(checked, 50, "every tipped row carries a live 1.12 key");
assert_eq!(checked, 53, "every tipped row carries a live 1.12 key");
assert_eq!(
untipped,
vec![
@@ -1967,7 +1992,7 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
"Combat",
"Interface",
"ActionBars",
"Social",
"Chat",
] {
s.run(&format!("OptionsFrameCategoryListRow{page}:Click()"))
.unwrap();
@@ -2008,14 +2033,15 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
s.errors()
);
}
// 22 of the 23 CVar rows (Social's two are 1139's; Status Bar Text, Mouse Sensitivity and
// 25 of the 26 CVar rows (the Chat page's two bubble switches are 1139's and its Detailed
// Loot Information + Guild Member Alert 1589's; Status Bar Text, Mouse Sensitivity and
// Max Camera Distance 1140's; Vertical Sync 1394's; Camera Following Style 1493's; Terrain
// Distance 1513's), plus the Combat page's 14 saved-variable rows (1134), the Interface
// page's 6 (1136, + Buff Durations 1139, + the target-of-target pair 1576), Action Bars' 2
// (the lock 1136, Always Show
// ActionBars 1500) and 6 API rows (Show Cloak / Show Helm, 1472; the four multibar switches,
// 1500).
assert_eq!(raised, 50, "every row but Auto Loot has a 1.12 description");
// ActionBars 1500), the Chat page's 1 (Remove Chat Hover Delay, 1589) and 6 API rows (Show
// Cloak / Show Helm, 1472; the four multibar switches, 1500).
assert_eq!(raised, 53, "every row but Auto Loot has a 1.12 description");
}
/// The **Combat page** (decision 1134) — the first rows in this window whose store is a
@@ -2778,9 +2804,10 @@ fn the_buff_durations_row_repitches_the_bar_and_the_pitch_survives_a_restart() {
);
}
/// **Every category in this window now leads somewhere** (decision 1139). Social was the last one
/// **Every category in this window now leads somewhere** (decision 1139). Chat — which was called
/// `Social` until 1589 — was the last one
/// that opened onto an empty page, and the arc that started at 1134 — rows over the second store —
/// closes here: Controls, Interface, Action Bars, Combat, Social, Nameplates, Graphics and Audio
/// closes here: Controls, Interface, Action Bars, Combat, Chat, Nameplates, Graphics and Audio
/// all carry rows, and Keybindings runs its own machinery. A category added without a page fails
/// this, which is the point: the honest tree (0950) is now a property the test holds, not a
/// promise the reader has to check.
@@ -2830,7 +2857,8 @@ fn the_defaults_button_is_armed_by_rows_not_by_a_category() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The **Social page** (decision 1139) — the two chat-bubble switches, and the last category in
/// The **Chat page**'s two bubble switches (decision 1139; the page was `Social` until 1589 grew
/// it into 1.12's own `CHAT_LABEL` box) — and the last category in
/// this window to stop opening onto nothing. Both are CVar rows: `chat_bubble.rs` transcribed the
/// client's `ChatBubbles`/`ChatBubblesParty` spawn gate faithfully in 0598 and then froze it at a
/// pair of `const bool`, so this is the action-bar lock's shape again — the knob had to become
@@ -2839,29 +2867,29 @@ fn the_defaults_button_is_armed_by_rows_not_by_a_category() {
/// client gates party lines on their own CVar, which is why party bubbles survive turning
/// say/yell bubbles off).
#[test]
fn the_social_page_toggles_the_chat_bubble_cvars() {
fn the_chat_page_toggles_the_chat_bubble_cvars() {
let mut s = audio_harness();
s.set_cvar_host("ChatBubblesParty", "0");
let mut s = harness_on(s);
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.run("OptionsFrameCategoryListRowSocial:Click()").unwrap();
s.run("OptionsFrameCategoryListRowChat:Click()").unwrap();
assert!(s
.eval::<bool>("return OptionsFrameContainerBodySocial:IsVisible()")
.eval::<bool>("return OptionsFrameContainerBodyChat:IsVisible()")
.unwrap());
assert!(s
.eval::<bool>("return OptionsFrameContainerDefaults:IsEnabled()")
.unwrap());
// Read from the table, not from a restated default: bubbles on, party bubbles off.
assert!(s
.eval::<bool>("return OptionsFrameContainerBodySocialRowChatBubblesCheck:GetChecked()")
.eval::<bool>("return OptionsFrameContainerBodyChatRowChatBubblesCheck:GetChecked()")
.unwrap());
assert!(!s
.eval::<bool>("return OptionsFrameContainerBodySocialRowPartyChatBubblesCheck:GetChecked()")
.eval::<bool>("return OptionsFrameContainerBodyChatRowPartyChatBubblesCheck:GetChecked()")
.unwrap());
let _ = s.take_sounds();
s.run("OptionsFrameContainerBodySocialRowPartyChatBubblesCheck:Click()")
s.run("OptionsFrameContainerBodyChatRowPartyChatBubblesCheck:Click()")
.unwrap();
assert_eq!(
s.take_cvar_changes(),
@@ -2872,28 +2900,135 @@ fn the_social_page_toggles_the_chat_bubble_cvars() {
.contains(&SoundRequest::KitName("igMainMenuOptionCheckBoxOn".into())));
// Turning say/yell bubbles off writes only its own switch — party keeps the value above.
s.run("OptionsFrameContainerBodySocialRowChatBubblesCheck:Click()")
s.run("OptionsFrameContainerBodyChatRowChatBubblesCheck:Click()")
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("ChatBubbles".to_string(), "0".to_string())]
);
assert!(s
.eval::<bool>("return OptionsFrameContainerBodySocialRowPartyChatBubblesCheck:GetChecked()")
.eval::<bool>("return OptionsFrameContainerBodyChatRowPartyChatBubblesCheck:GetChecked()")
.unwrap());
// Defaults walks the page back to the registered pair — including the ChatBubblesParty "1"
// that deliberately disagrees with the binary's registered "0" (the director's /p ask, 0598).
s.run("OptionsFrameContainerDefaults:Click()").unwrap();
assert!(s
.eval::<bool>("return OptionsFrameContainerBodySocialRowChatBubblesCheck:GetChecked()")
.eval::<bool>("return OptionsFrameContainerBodyChatRowChatBubblesCheck:GetChecked()")
.unwrap());
assert!(s
.eval::<bool>("return OptionsFrameContainerBodySocialRowPartyChatBubblesCheck:GetChecked()")
.eval::<bool>("return OptionsFrameContainerBodyChatRowPartyChatBubblesCheck:GetChecked()")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The **Chat page**'s two new rows (decision 1589 — B246, "no chat section in options"), the two
/// that make it 1.12's `CHAT_LABEL` box rather than a two-row bubble page.
///
/// They are deliberately over *different stores*, and that is what this pins: **Remove Chat Hover
/// Delay** is a saved-variable global with an `applyFunc` (its consumer is a pair of constants
/// nothing re-reads per frame, so the write alone would change nothing), and **Detailed Loot
/// Information** is a CVar with none (the loot-roll composer reads it as each line is built —
/// 1136's rule for when a row needs one, seen from both sides on one page).
#[test]
fn the_chat_page_writes_the_hover_delay_global_and_the_loot_spam_cvar() {
let mut s = chat_harness();
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.run("OptionsFrameCategoryListRowChat:Click()").unwrap();
let _ = s.take_sounds();
let _ = s.take_cvar_changes();
// Read: the shipped values, off the two files that declare them — not restated here.
assert_eq!(
s.eval::<String>("return REMOVE_CHAT_DELAY").unwrap(),
"0",
"ChatFrame.xml's own file-scope value, and the reference's declared default"
);
assert!(!s
.eval::<bool>("return OptionsFrameContainerBodyChatRowRemoveChatDelayCheck:GetChecked()")
.unwrap());
assert!(
s.eval::<bool>("return OptionsFrameContainerBodyChatRowLootSpamCheck:GetChecked()")
.unwrap(),
"showLootSpam is registered \"1\" — the binary's own default"
);
// The hover-delay row: the write is the global, and the applyFunc is what makes it mean
// something. Both fade constants collapse to zero — the reference's SetChatMouseOverDelay.
assert_eq!(
s.eval::<(f64, f64)>("return CHAT_TAB_SHOW_DELAY, CHAT_FRAME_FADE_TIME")
.unwrap(),
(0.2, 0.15)
);
s.run("OptionsFrameContainerBodyChatRowRemoveChatDelayCheck:Click()")
.unwrap();
assert_eq!(s.eval::<String>("return REMOVE_CHAT_DELAY").unwrap(), "1");
assert_eq!(
s.eval::<(f64, f64)>("return CHAT_TAB_SHOW_DELAY, CHAT_FRAME_FADE_TIME")
.unwrap(),
(0.0, 0.0),
"the box appears the instant the cursor crosses it"
);
assert!(
s.take_cvar_changes().is_empty(),
"a saved-variable row reaches the CVar table not at all"
);
// …and back, which is the half a one-way applyFunc would break.
s.run("OptionsFrameContainerBodyChatRowRemoveChatDelayCheck:Click()")
.unwrap();
assert_eq!(s.eval::<String>("return REMOVE_CHAT_DELAY").unwrap(), "0");
assert_eq!(
s.eval::<(f64, f64)>("return CHAT_TAB_SHOW_DELAY, CHAT_FRAME_FADE_TIME")
.unwrap(),
(0.2, 0.15)
);
// The loot-spam row: a plain CVar write, no applyFunc, nothing else on the page moved.
s.run("OptionsFrameContainerBodyChatRowLootSpamCheck:Click()")
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("showLootSpam".to_string(), "0".to_string())]
);
assert!(s
.eval::<bool>("return OptionsFrameContainerBodyChatRowChatBubblesCheck:GetChecked()")
.unwrap());
// Defaults walks the page back across BOTH stores in one click.
s.run("OptionsFrameContainerDefaults:Click()").unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("showLootSpam".to_string(), "1".to_string())],
"only the row that had moved is written back"
);
assert_eq!(s.eval::<String>("return REMOVE_CHAT_DELAY").unwrap(), "0");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The saved *Remove Chat Hover Delay* survives a restart — the `applyFunc` runs at
/// `VARIABLES_LOADED`, which is the only thing that can re-collapse the fade constants after a
/// fresh VM has re-run `ChatFrame.xml`'s file-scope `"0"`.
#[test]
fn a_saved_hover_delay_is_applied_when_the_variables_land() {
let s = chat_harness();
// What the saved-variables chunk does: assign the global, then the window's VARIABLES_LOADED.
s.run("REMOVE_CHAT_DELAY = \"1\"").unwrap();
assert_eq!(
s.eval::<(f64, f64)>("return CHAT_TAB_SHOW_DELAY, CHAT_FRAME_FADE_TIME")
.unwrap(),
(0.2, 0.15),
"the bare assignment changes nothing on its own — that is why the row has an applyFunc"
);
s.run("OptionsFrame_ApplySavedSettings()").unwrap();
assert_eq!(
s.eval::<(f64, f64)>("return CHAT_TAB_SHOW_DELAY, CHAT_FRAME_FADE_TIME")
.unwrap(),
(0.0, 0.0)
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **Status Bar Text** (decision 1140) — the row that finally reaches `TextStatusBar.xml`. That
/// file has been transcribed whole since 1082, with a `CVAR_UPDATE` watcher and a `statusBarText`
/// read on every repaint, and nothing in the client could move the variable: `GetCVar` answered nil
+12
View File
@@ -122,6 +122,18 @@ impl SocialState {
guid != 0 && self.ignores.contains(&guid)
}
/// Is `guid` on the FRIEND list? The reference's `FriendList::FindFriendSlot 0x5ae810` —
/// base `this+8`, stride `0x20`, bound `0x32`, the same triple `GetNumFriends 0x5ae490`
/// counts over (§5, wow-re `system/object-layer/scratch/guild-signon-cvar-gate.md`).
///
/// Its one consumer is the guild sign-on/sign-off line's fourth conjunct, and its purpose is
/// **de-duplication, not suppression**: `SMSG_FRIEND_STATUS` emits the same two chat ids with
/// no CVar gate of its own, so a guildmate who is also a friend would otherwise be announced
/// twice. [`crate::ui_guild`]'s line path is where that matters.
pub(crate) fn is_friend(&self, guid: u64) -> bool {
guid != 0 && self.friends.iter().any(|f| f.guid == guid)
}
/// `SMSG_FRIEND_LIST` — replace the list wholesale (it is never a delta).
fn apply_friend_list(&mut self, friends: Vec<FriendEntry>) {
self.friends = friends;
+383 -18
View File
@@ -46,6 +46,56 @@
//! reads it; the options dropdown checks `FCF_GetCurrentChatFrame():GetFont()` instead), and no
//! corpus addon reads it.
//!
//! ## The three setters, and why this getter stopped being a constant (decision 1589)
//!
//! Until 1589 the whole tuple above was a frozen table: benilla had no way to *change* a window's
//! look, so answering with the stock cache row was the honest answer. B246 ("no chat options at
//! all — background transparency has no home") is the report that ends that, and the reference's
//! own home for it is the chat tab's right-click menu → **Background**, a colour swatch with an
//! opacity slider. So three of the nine values are now real state, written by the reference's own
//! three setters and read straight back out of this getter:
//!
//! | binding | address | what it stores |
//! |---|---|---|
//! | `SetChatWindowColor(id, r, g, b)` | `0x4a14f0` | `__ftol(x · 255.0)` per channel |
//! | `SetChatWindowAlpha(id, a)` | `0x4a15d0` | `__ftol(a · 255.0)` |
//! | `SetChatWindowSize(id, size)` | `0x4a1470` | the cache's `SIZE` |
//!
//! (wow-re `system/ui/ledger.tsv:9449-9451` + `scratch/item17-frameapi-fullcarve.md` l.17-18,
//! VERIFIED; `[0x806498]` is the 255.0 the first two multiply by.) **Bytes, not floats** — that is
//! why the getter renormalises by 1/255, and why [`ChatWindowLook`] stores the colour as `u8`: a
//! set→get round trip through the real client quantises, and a store of `f32` here would
//! round-trip values the reference cannot hold. `SetChatWindowAlpha(1, 0.4)` answers
//! `102/255 = 0.4`, not `0.4`.
//!
//! **The record's own layout**, from the §5 dispatched for this work
//! (`system/ui/scratch/chat-window-record.md`): the colour is ONE packed `CImVector` at
//! **`+0x88` B, `+0x89` G, `+0x8a` R, `+0x8b` A** — note the **BGRA** order — and the font size is
//! an `i32` at **`+0x84`**, not a byte and with no `× 255` anywhere near it. (Both correct this
//! module's earlier `+0xd8..+0xdb`, which was the low half of the *absolute* operand
//! `[esi + 0xb4fed8]`: `0xb4fed8 − 0xb4fe50 = 0x88`.) `chat-cache.txt`'s `COLOR` line is written
//! **R G B A** from `+0x8a,+0x89,+0x88,+0x8b` and parsed straight back as bytes, so the file
//! round-trip is bit-exact and the `×255` / `×1/255` pair exists only at the Lua boundary.
//!
//! Two behaviours of the reference's setters that are easy to miss and are transcribed here:
//!
//! - **`SetChatWindowSize` silently drops a size `<= 0`** (`0x4a14bc jle`). A stock cache holds
//! `SIZE 0`, so "no size stored" and "cannot store 0" coexist: the field is only ever written by
//! a real pick off the Font Size menu.
//! - **Nothing clamps.** `__ftol` (`0x40a2b0`) truncates, and the setters store the low byte of
//! the result with no bound: on a real client `SetChatWindowAlpha(1, 2.0)` stores **254**,
//! `(1, -1.0)` stores **1**, and `0.5` stores **127**, not 128.
//!
//! **We clamp where it wraps, and that is the one deliberate divergence.** Nothing in FrameXML or
//! the corpus can reach the out-of-domain case (the colour picker's channels and its opacity
//! slider are all `0..1`), and the camera pose file already set this posture: a value nothing can
//! produce is not a thing to be faithful to. The truncation itself IS kept — `0.5` stores 127 here
//! too.
//!
//! The other six values stay constants, and that is the honest tree rather than an omission:
//! benilla has no rename, no undock, no window create/close, so `name`, `shown`, `locked` and
//! `docked` have nothing that could move them (0288 §2 — window management is a later arc).
//!
//! Measured demand: **3 of the 5 corpus addons that iterate `NUM_CHAT_WINDOWS` call this on the
//! very next line** — `EnhTooltip/Tooltip.lua:1302`, `MikScrollingBattleText.lua:1951` and
//! `Enchantrix/EnxConfig.lua:110`. The first two are the same idiom (look for a window the user
@@ -68,12 +118,12 @@ use super::Model;
/// FrameXML declares `ChatFrame1TabDockRegion`..`ChatFrame10TabDockRegion` to match, and then
/// builds only seven `ChatFrame`s: `NUM_CHAT_WINDOWS = 7` is the *UI's* count, not the engine's.
/// Recorded so the 7 below reads as a deliberate choice rather than a miscount.
const ENGINE_CHAT_WINDOW_SLOTS: usize = 10;
pub(super) const ENGINE_CHAT_WINDOW_SLOTS: usize = 10;
/// The windows benilla actually builds — `ChatFrame1`..`ChatFrame7` in the app's `ChatFrame.xml`,
/// which is also `NUM_CHAT_WINDOWS`. `benilla_app`'s `chat_tests` cross-checks every entry against
/// the shipped XML so the two cannot drift.
const NUM_CHAT_WINDOWS: usize = 7;
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
@@ -90,6 +140,102 @@ const WINDOW_STATE: [(Option<i64>, Option<i64>); NUM_CHAT_WINDOWS] = [
(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,
/// `__ftol(x · 255.0)` in and `× 1/255` out); the font size is an `i32` because the engine's is
/// (`+0x84`). See this module's docs for why that quantisation is kept rather than smoothed over.
///
/// [`Self::default`] is the stock `chat-cache.txt` row — `COLOR 0 0 0 0`, `SIZE 0` — i.e. a black
/// box at alpha 0, which is the classic "chat is text over the world until you mouse over it".
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub struct ChatWindowLook {
/// Background tint, `0..=255` each. The engine renormalises by 1/255 on the way out.
pub r: u8,
/// See [`Self::r`].
pub g: u8,
/// See [`Self::r`].
pub b: u8,
/// Background alpha, `0..=255`. `0` is the stock value and what benilla shipped as a constant
/// until 1589 — the hover fade lifts it to `DEFAULT_CHATFRAME_ALPHA` and drops it back.
pub a: u8,
/// The cache's `SIZE` — the chat font height in points, or `0` for "the font's own height".
/// The reference's own values are `CHAT_FONT_HEIGHTS = {12, 14, 16, 18}`. An `i32` because the
/// record's field is (`+0x84`), and never `<= 0` once set: the setter drops those.
pub font_size: i32,
}
impl ChatWindowLook {
/// The stock `chat-cache.txt` row, as a const so [`super::Model`]'s array literal can use it.
pub(super) const DEFAULT: Self = Self {
r: 0,
g: 0,
b: 0,
a: 0,
font_size: 0,
};
}
/// `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.
fn to_byte(x: f64) -> u8 {
if !x.is_finite() {
return 0;
}
(x * 255.0).trunc().clamp(0.0, 255.0) as u8
}
/// The byte the engine stores → the `0..1` float `GetChatWindowInfo` answers.
fn from_byte(b: u8) -> f64 {
f64::from(b) / 255.0
}
/// The 1-based Lua window id → a live index, or the raise every out-of-range ask gets. Shared by
/// the getter and the three setters so a window that cannot be read cannot be written either.
fn window_index(id: i64) -> mlua::Result<usize> {
if id < 1 || id as usize > NUM_CHAT_WINDOWS {
return Err(mlua::Error::runtime(format!(
"chat window {id} out of range — benilla builds ChatFrame1..ChatFrame{NUM_CHAT_WINDOWS} \
(the client's settings array holds {ENGINE_CHAT_WINDOW_SLOTS} slots, but only \
{NUM_CHAT_WINDOWS} have frames)"
)));
}
Ok(id as usize - 1)
}
impl super::UiScript {
/// Seed the per-window looks from the host's persisted store — the load path, so it queues no
/// change (an echo would re-dirty the file it was just read from; [`Self::set_cvar_host`]'s
/// reason, one store over).
pub fn set_chat_window_looks(
&mut self,
looks: impl IntoIterator<Item = (usize, ChatWindowLook)>,
) {
let mut model = self.model_mut();
for (i, look) in looks {
if let Some(slot) = model.chat_window_looks.get_mut(i) {
*slot = look;
}
}
}
/// Snapshot every window's look, index 0 = `ChatFrame1` — what the saver writes out.
pub fn chat_window_looks(&self) -> Vec<ChatWindowLook> {
self.model_mut().chat_window_looks.to_vec()
}
/// Drain the 0-based indices whose look Lua moved since the last call — the host's cue to
/// persist. Deduplicated and ascending, so a slider drag that wrote one window forty times
/// costs the saver one entry.
pub fn take_chat_window_changes(&mut self) -> Vec<usize> {
let mut v: Vec<usize> = std::mem::take(&mut self.model_mut().chat_window_changes)
.into_iter()
.collect();
v.sort_unstable();
v
}
}
impl super::UiScript {
/// Drain the `ChatFrame_OpenChat` requests queued since the last call — each is the text the
/// caller wants the chat edit box to open prefilled with. The app opens and focuses the box,
@@ -110,14 +256,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua.globals().set(
"GetChatWindowInfo",
lua.create_function(|lua, id: i64| {
if id < 1 || id as usize > NUM_CHAT_WINDOWS {
return Err(mlua::Error::runtime(format!(
"GetChatWindowInfo: window {id} out of range — benilla builds \
ChatFrame1..ChatFrame{NUM_CHAT_WINDOWS} (the client's settings array holds \
{ENGINE_CHAT_WINDOW_SLOTS} slots, but only {NUM_CHAT_WINDOWS} have frames)"
)));
}
let (shown, docked) = WINDOW_STATE[id as usize - 1];
let i = window_index(id)?;
let look = lua
.app_data_ref::<Model>()
.expect("model app_data")
.chat_window_looks[i];
let (shown, docked) = WINDOW_STATE[i];
let num = |v: Option<i64>| match v {
Some(n) => Value::Integer(n),
None => Value::Nil,
@@ -125,18 +269,96 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
Ok(MultiValue::from_vec(vec![
// name: "" — never a label. See the module docs' trap 1.
Value::String(lua.create_string("")?),
Value::Integer(0), // fontSize — the cache's SIZE 0
Value::Number(0.0), // r ┐ COLOR 0 0 0 0, renormalised by the engine's 1/255,
Value::Number(0.0), // g │ and the value ChatFrame.xml's load-time
Value::Number(0.0), // b ┘ SetVertexColor(0, 0, 0) already applies
Value::Number(0.0), // a — window alpha 0: the box only exists on hover
num(shown), // 1 or nil, never 0
Value::Integer(1), // locked — LOCKED 1, and benilla has no unlock path
num(docked), // the dock POSITION (1, 2) or nil
// 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)),
Value::Number(from_byte(look.r)),
Value::Number(from_byte(look.g)),
Value::Number(from_byte(look.b)),
Value::Number(from_byte(look.a)),
num(shown), // 1 or nil, never 0
Value::Integer(1), // locked — LOCKED 1, and benilla has no unlock path
num(docked), // the dock POSITION (1, 2) or nil
]))
})?,
)?;
// SetChatWindowColor(id, r, g, b) — 0x4a14f0, `__ftol(x · 255.0)` per channel.
//
// FrameXML's caller is `FCF_SetWindowColor` (FloatingChatFrame.lua l.696-704), which tints the
// nine CHAT_FRAME_TEXTURES and then saves through here; the tab menu's Background swatch
// reaches it via `FCF_SetChatWindowBackGroundColor`. The write is what makes the tint survive
// a session — the *visible* tint is the Lua SetVertexColor, not this.
lua.globals().set(
"SetChatWindowColor",
lua.create_function(|lua, (id, r, g, b): (i64, f64, f64, f64)| {
let i = window_index(id)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let look = &mut model.chat_window_looks[i];
let next = ChatWindowLook {
r: to_byte(r),
g: to_byte(g),
b: to_byte(b),
..*look
};
if next != *look {
*look = next;
model.chat_window_changes.insert(i);
}
Ok(())
})?,
)?;
// SetChatWindowAlpha(id, alpha) — 0x4a15d0, `__ftol(alpha · 255.0)`.
//
// FrameXML's caller is `FCF_SetWindowAlpha` (l.706-716), the other half of the tab menu's
// Background row: its opacity slider drives `FCF_SetChatWindowOpacity` on every drag step.
// `frame.oldAlpha` — the value the hover fade returns to — is that same number.
lua.globals().set(
"SetChatWindowAlpha",
lua.create_function(|lua, (id, alpha): (i64, f64)| {
let i = window_index(id)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let look = &mut model.chat_window_looks[i];
let a = to_byte(alpha);
if a != look.a {
look.a = a;
model.chat_window_changes.insert(i);
}
Ok(())
})?,
)?;
// SetChatWindowSize(id, fontSize) — 0x4a1470.
//
// FrameXML's caller is `FCF_SetChatWindowFontSize` (l.752-763), which does the visible half
// (`chatFrame:SetFont(file, size, flags)`) and then saves through here. A size the reference
// never stores is `0` — "the font's own height" — which is what a stock cache holds and what
// benilla ships until the player picks one off the Font Size submenu.
lua.globals().set(
"SetChatWindowSize",
lua.create_function(|lua, (id, size): (i64, f64)| {
let i = window_index(id)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let look = &mut model.chat_window_looks[i];
// The cache's SIZE is a small integer; anything unrepresentable clamps rather than
// wrapping (the module docs' divergence, same reason).
// `<= 0` is dropped, not stored — the reference's own `jle` at `0x4a14bc`. That is
// why a stock `SIZE 0` and "the player picked a size" are different states rather
// than the same one written twice.
let s = if size.is_finite() && size >= 1.0 {
size.trunc().min(f64::from(i32::MAX)) as i32
} else {
return Ok(());
};
if s != look.font_size {
look.font_size = s;
model.chat_window_changes.insert(i);
}
Ok(())
})?,
)?;
// ChatFrame_OpenChat(text, chatFrame) — open the chat edit box prefilled with `text`.
//
// **The second argument is inert, and that is the reference's doing, not a shortcut.** The ref
@@ -276,6 +498,149 @@ mod tests {
assert!(s.eval::<i64>("return (GetChatWindowInfo(0))").is_err());
}
/// A size `<= 0` is DROPPED, not stored — the reference's own `jle` at `0x4a14bc`, so a stock
/// `SIZE 0` cannot be re-written by a caller handing it 0.
#[test]
fn a_non_positive_font_size_is_dropped_not_stored() {
let s = UiScript::new().unwrap();
s.run("SetChatWindowSize(1, 16)").unwrap();
for bad in ["0", "-1", "0.5"] {
s.run(&format!("SetChatWindowSize(1, {bad})")).unwrap();
assert_eq!(
s.eval::<i64>("local _, size = GetChatWindowInfo(1) return size")
.unwrap(),
16,
"SetChatWindowSize(1, {bad}) must change nothing"
);
}
}
/// The three setters round-trip through the getter — and they round-trip through the
/// reference's BYTE quantisation, not through the float they were handed. `0.4 × 255 = 102`,
/// and `102/255` is what comes back — and `0.5` comes back `127/255`, not `128/255`, because
/// `__ftol` truncates (§5-verified: a real client stores 127 there).
#[test]
fn the_setters_round_trip_through_the_engine_byte() {
let s = UiScript::new().unwrap();
s.run("SetChatWindowAlpha(1, 0.4)").unwrap();
let a: f64 = s
.eval("local _,_,_,_,_,a = GetChatWindowInfo(1) return a")
.unwrap();
assert_eq!(a, 102.0 / 255.0, "alpha quantises to the stored byte");
s.run("SetChatWindowColor(1, 1, 0.5, 0)").unwrap();
let (r, g, b): (f64, f64, f64) = s
.eval("local _,_,r,g,b = GetChatWindowInfo(1) return r, g, b")
.unwrap();
assert_eq!((r, g, b), (1.0, 127.0 / 255.0, 0.0));
s.run("SetChatWindowSize(1, 16)").unwrap();
let size: i64 = s
.eval("local _, size = GetChatWindowInfo(1) return size")
.unwrap();
assert_eq!(size, 16);
// The truncation, on the value that shows it: `0.5 × 255 = 127.5`.
s.run("SetChatWindowAlpha(1, 0.5)").unwrap();
let a: f64 = s
.eval("local _,_,_,_,_,a = GetChatWindowInfo(1) return a")
.unwrap();
assert_eq!(
a,
127.0 / 255.0,
"__ftol truncates; 128/255 would be a round"
);
}
/// Windows are independent: writing window 2 leaves window 1 on the stock row.
#[test]
fn a_setter_moves_only_the_window_it_names() {
let s = UiScript::new().unwrap();
s.run("SetChatWindowAlpha(2, 1)").unwrap();
let one: f64 = s
.eval("local _,_,_,_,_,a = GetChatWindowInfo(1) return a")
.unwrap();
let two: f64 = s
.eval("local _,_,_,_,_,a = GetChatWindowInfo(2) return a")
.unwrap();
assert_eq!(one, 0.0);
assert_eq!(two, 1.0);
}
/// The setters raise on the same range the getter does — a window benilla has no frame for
/// cannot be written any more than it can be read.
#[test]
fn the_setters_raise_on_a_window_with_no_frame() {
let s = UiScript::new().unwrap();
assert!(s.run("SetChatWindowAlpha(8, 1)").is_err());
assert!(s.run("SetChatWindowColor(0, 1, 1, 1)").is_err());
assert!(s.run("SetChatWindowSize(8, 14)").is_err());
}
/// We clamp where the reference wraps (the module docs' one named divergence): a real client
/// stores the low byte of `ftol(2.0 × 255) = 510` and answers `254/255`.
#[test]
fn an_out_of_domain_alpha_clamps_rather_than_wrapping() {
let s = UiScript::new().unwrap();
s.run("SetChatWindowAlpha(1, 2.0)").unwrap();
let a: f64 = s
.eval("local _,_,_,_,_,a = GetChatWindowInfo(1) return a")
.unwrap();
assert_eq!(a, 1.0);
s.run("SetChatWindowAlpha(1, -1)").unwrap();
let a: f64 = s
.eval("local _,_,_,_,_,a = GetChatWindowInfo(1) return a")
.unwrap();
assert_eq!(a, 0.0);
}
/// The host seam: Lua writes queue one index per touched window however many steps the drag
/// took, the host load path queues nothing, and the drain is a take.
#[test]
fn the_host_seam_dedupes_writes_and_stays_quiet_on_load() {
let mut s = UiScript::new().unwrap();
for step in 0..40 {
s.run(&format!(
"SetChatWindowAlpha(1, {})",
f64::from(step) / 40.0
))
.unwrap();
}
s.run("SetChatWindowColor(2, 0.2, 0.2, 0.2)").unwrap();
assert_eq!(s.take_chat_window_changes(), vec![0, 1]);
assert!(
s.take_chat_window_changes().is_empty(),
"the drain is a take"
);
s.set_chat_window_looks([(
0,
crate::script::ChatWindowLook {
r: 1,
g: 2,
b: 3,
a: 4,
font_size: 14,
},
)]);
assert!(
s.take_chat_window_changes().is_empty(),
"the load path never echoes"
);
assert_eq!(s.chat_window_looks()[0].font_size, 14);
}
/// A write that changes nothing queues nothing — the reference's own setters are called on
/// every colour-picker drag step, including the ones that land on the value already stored.
#[test]
fn a_write_that_moves_nothing_queues_nothing() {
let mut s = UiScript::new().unwrap();
s.run("SetChatWindowAlpha(1, 0)").unwrap();
s.run("SetChatWindowColor(1, 0, 0, 0)").unwrap();
s.run("SetChatWindowSize(1, 0)").unwrap();
assert!(s.take_chat_window_changes().is_empty());
}
/// `ChatFrame_OpenChat` queues the text for the app; the optional frame argument is accepted
/// and dropped (in 1.12 every chat frame shares one edit box — see the install comment).
#[test]
+1
View File
@@ -152,6 +152,7 @@ pub use char_stats::{
SKILL_DEFENSE, SKILL_UNARMED,
};
pub use chat_send::ChatSend;
pub use chat_window::ChatWindowLook;
pub use container::{
ContainerMove, ContainerSlot, ContainerState, EnchantView, RandomPropertyView, UiCursorMode,
};
+17 -5
View File
@@ -4,11 +4,11 @@ use crate::layout::{LayoutInput, LayoutSolver, Rect};
use crate::widget::{FrameHandle, RegionHandle, WidgetArena};
use super::{
auction, backdrop, bank, char_stats, container, craft, cursor, death, duel, follow, gossip,
guild, inspect, item_text, loot, loot_roll, macros, mail, merchant, party, pvp, quest,
quest_log, reputation, session, simplehtml, skills, slider, social, spellbook, taxi, trade,
tradeskill, trainer, weapon_enchant, ActionSlot, AuraState, FontObject, ItemTemplateView,
PlayerReqState, RegionData, ScriptValue, SoundRequest, UnitState,
auction, backdrop, bank, char_stats, chat_window, container, craft, cursor, death, duel,
follow, gossip, guild, inspect, item_text, loot, loot_roll, macros, mail, merchant, party, pvp,
quest, quest_log, reputation, session, simplehtml, skills, slider, social, spellbook, taxi,
trade, tradeskill, trainer, weapon_enchant, ActionSlot, AuraState, FontObject,
ItemTemplateView, PlayerReqState, RegionData, ScriptValue, SoundRequest, UnitState,
};
// ─────────────────────────────────────────────────────────────────────────────────────────────
@@ -436,6 +436,15 @@ pub(crate) struct Model {
/// [`super::UiScript::take_open_chat_requests`] drain — `tell_requests`' sibling, one step
/// less resolved: a name there, a whole draft line here ([`chat_window`] registers the global).
pub(crate) open_chat_requests: Vec<String>,
/// Per-chat-window **look** — background tint, background alpha, font size — index 0 =
/// `ChatFrame1` (decision 1589, [`chat_window`]). The engine's own per-window record narrowed
/// to the three fields the tab menu's Display block can move; the host seeds it from the
/// player's saved file and persists what Lua writes back.
pub(crate) chat_window_looks: [chat_window::ChatWindowLook; chat_window::NUM_CHAT_WINDOWS],
/// The 0-based window indices whose look Lua moved since the app's last
/// [`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 player's default chat language name, app-resolved from `ChrRaces.BaseLanguage` ×
/// `Languages.dbc` ([`super::UiScript::set_default_language`]). `None` = the reference's
/// no-player-object state, where `GetDefaultLanguage()` returns **zero Lua values**
@@ -1380,6 +1389,9 @@ impl Model {
guild_requests: Vec::new(),
tell_requests: Vec::new(),
open_chat_requests: Vec::new(),
chat_window_looks: [chat_window::ChatWindowLook::DEFAULT;
chat_window::NUM_CHAT_WINDOWS],
chat_window_changes: HashSet::new(),
default_language: None,
duel_requests: Vec::new(),
follow_requests: Vec::new(),