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:
@@ -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>
|
||||
|
||||
@@ -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). -->
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
} = ¶ms;
|
||||
// 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).
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))]);
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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(¬ice, 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(¬ice, false).as_deref(),
|
||||
event_line(¬ice, 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(¬ice, true), None, "ignored prints nothing");
|
||||
assert_eq!(
|
||||
event_line(¬ice, false),
|
||||
None,
|
||||
"a refused condition prints nothing at all"
|
||||
);
|
||||
}
|
||||
|
||||
/// The MOTD's line belongs to the FrameXML, and the two silent events stay silent.
|
||||
|
||||
@@ -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(¬ice);
|
||||
push_lines(chat_log, lines::event_line(¬ice, ignored));
|
||||
push_lines(chat_log, lines::event_line(¬ice, 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.
|
||||
|
||||
@@ -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]`
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user