the colour wheel draws, chat windows move, and the minimap ping returns

- the colour picker can pick a colour: the hue disc and the value strip are engine-generated pixels (no BLP in the chain is a wheel), reproduced from the reference's own lattice — a hard-cut circle at a literal V = 1.0, the brightness marker moving over a disc that never dims
- chat windows move and resize once unlocked (Lock/Unlock Window on the tab menu; the reference's own 296×75 to 608×400 bounds), the border textures doubling as the grips, and the placement is remembered per character in benilla-config/layout; both windows ship locked, as 1.12's reset leaves them
- the minimap ping is back and pinned: one world point the engine draws, the click converted in the right unit (the old path was ~27% off at 1080p), and no 10-yard auto-clear — that was the gossip marker's rule, and walking to your own ping was deleting it
- the Lightwell can be clicked: a billboard card is a world root with no parent hop, so the hit resolved to nothing and the whole shaft published a null mouseover; casting a hunter shot chains Auto Shot through `Spell.dbc`'s modalNextSpell column (the mechanism nobody had read — the next snapshot carries the seam that makes it fire in play); tradeskill list rows stop tooltipping (that hover was ours, not Blizzard's), and OnMouseUp goes to the frame that took the press

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-25 18:49:16 +07:00
co-authored by Claude Fable 5
parent 7f0ba00cea
commit 26fb25a3f8
53 changed files with 5035 additions and 280 deletions
+497 -74
View File
@@ -254,6 +254,27 @@
REMOVE_CHAT_DELAY = "0"
RegisterForSave("REMOVE_CHAT_DELAY")
-- CHAT_LOCKED — the master "no chat window may be moved or resized" switch, the reference's
-- own saved variable. The reference declares it in `UIOptionsFrame.lua` l.96-97 (`"0"` plus
-- `RegisterForSave`) because that is where its *Lock Chat Settings* checkbox lives; ours is
-- declared here for the reason the row above is — the file that owns what it changes owns
-- the variable, and a row whose defining file was not loaded gets a nil `applyFunc` and is
-- skipped rather than half-applied.
--
-- **`"0"` — off.** Note the reference's own two-value disagreement: its checkbox table says
-- `default = "1"` (`UIOptionsFrame.lua:44`) while the variable is initialised `"0"` two
-- lines later, and the initialiser is what a fresh client actually runs. `"0"` here too.
-- The per-window lock (`FCF_SetLocked`, the tab menu's row) is the one that ships ON; this
-- one is the blanket override on top of it.
CHAT_LOCKED = "0"
RegisterForSave("CHAT_LOCKED")
-- The tab menu's lock row labels (GlobalStrings l.2590 / l.4448). Live text comes off the
-- player's own `GlobalStrings.lua`; these are the headless-test fallback this file already
-- uses everywhere else.
LOCK_WINDOW = LOCK_WINDOW or "Lock Window"
UNLOCK_WINDOW = UNLOCK_WINDOW or "Unlock Window"
-- 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,
@@ -293,17 +314,28 @@
flashLeft = 0, -- remaining flash seconds (ref: 60s, blink 0.25/0.25)
}
-- The per-window texture sets the hover fade drives (ref CHAT_FRAME_TEXTURES: the
-- background + the 8 border pieces; benilla authors the borders as plain textures —
-- resizing is settings, the hover LOOK is not). Global: the load-time tint below needs it.
-- CHAT_FRAME_TEXTURES — the reference's own list (FloatingChatFrame.lua l.14-24), verbatim
-- in content and in spelling: the background plus the eight RESIZE GRIPS' textures. The
-- window's border art IS the grips, which is why one list drives both the hover fade
-- (`FCF_OnUpdate`) and the tint/alpha setters (`FCF_SetWindowColor`/`FCF_SetWindowAlpha`) —
-- the ring appears with the box and is invisible with it.
--
-- benilla used to author these as plain `$parentBorder*` textures ("the hover LOOK without
-- the resize mechanics"); the mechanics landed with the move/resize arc, so the suffixes
-- are the reference's again and the pieces are its buttons' children.
CHAT_FRAME_TEXTURES = {
"Background",
"ResizeTopLeftTexture", "ResizeTopRightTexture",
"ResizeBottomLeftTexture", "ResizeBottomRightTexture",
"ResizeTopTexture", "ResizeBottomTexture",
"ResizeLeftTexture", "ResizeRightTexture",
}
-- The live texture objects of one window's set. Global: the load-time tint below needs it.
function BenillaFCF_Textures(id)
local n = "ChatFrame" .. id
local list = {}
for _, suffix in ipairs({
"Background",
"BorderTopLeft", "BorderTopRight", "BorderBottomLeft", "BorderBottomRight",
"BorderTop", "BorderBottom", "BorderLeft", "BorderRight",
}) do
for _, suffix in ipairs(CHAT_FRAME_TEXTURES) do
local t = getglobal(n .. suffix)
if t then table.insert(list, t) end
end
@@ -375,6 +407,143 @@
return base + (DEFAULT_CHATFRAME_ALPHA - base) * reveal
end
-- ── The lock, the move and the resize (ref FloatingChatFrame.lua) ────────────────────
--
-- Three gates decide whether a window may be dragged or resized, and the reference applies
-- all three at both verbs (`FCF_Resize` l.583-601, `FCF_Tab_OnClick`'s move arm l.1247-1256):
-- 1 · `FCF_Get_ChatLocked()` — the blanket `CHAT_LOCKED` saved variable;
-- 2 · `chatFrame.isLocked` — this window's own lock, the tab menu's row;
-- 3 · docked-and-not-the-default — a docked window's rect belongs to the DOCK, so only
-- `DEFAULT_CHAT_FRAME` may move it. ChatFrame2's anchors ARE ChatFrame1's corners:
-- resizing it directly would stretch it off the window it follows.
-- The eight grips' name suffixes, the reference's own (see `CHAT_FRAME_TEXTURES`).
local RESIZE_GRIPS = {
"TopLeft", "TopRight", "BottomLeft", "BottomRight",
"Top", "Bottom", "Left", "Right",
}
-- May this window be moved/resized at all? The three gates above, in one place because
-- both verbs and the lock's own plumbing ask the same question.
function FCF_CanMove(chatFrame)
if FCF_Get_ChatLocked() then return nil end
if chatFrame.isLocked then return nil end
if chatFrame.isDocked and chatFrame ~= DEFAULT_CHAT_FRAME then return nil end
return 1
end
-- Hand the window's EDGES and its BODY to the mouse, or take them back. benilla's own —
-- the reference needs nothing like it because its chat window is `enableMouse="false"` and
-- so competes for no click. Ours does (`OnClick`, `OnHyperlinkClick`), so a locked window
-- has to be exactly as click-through as it was before this arc: eight live grip buttons
-- would shadow the outer 16px of the window, and the bottom strip is where the newest chat
-- line — the one with the item link in it — is drawn.
function BenillaFCF_ApplyLock(chatFrame)
local free = FCF_CanMove(chatFrame)
local name = chatFrame:GetName()
for _, grip in ipairs(RESIZE_GRIPS) do
local b = getglobal(name .. "Resize" .. grip)
if b then b:EnableMouse(free and true or false) end
end
local tab = getglobal(name .. "Tab")
if free then
chatFrame:RegisterForDrag("LeftButton")
if tab then tab:RegisterForDrag("LeftButton") end
else
chatFrame:RegisterForDrag()
if tab then tab:RegisterForDrag() end
end
end
-- ref l.802-805, verbatim: the frame field and the engine store, together.
-- `BenillaFCF_ApplyLock` is ours, and it is what makes the flag do something structurally
-- rather than only in the two early returns below.
function FCF_SetLocked(chatFrame, isLocked)
chatFrame.isLocked = isLocked
SetChatWindowLocked(chatFrame:GetID(), isLocked)
BenillaFCF_ApplyLock(chatFrame)
end
-- ref l.785-800. **Its undock branch is deliberately absent**: the reference, on unlocking
-- a docked frame that is not the default one, calls `FCF_UnDockFrame`, re-centres it on the
-- screen and re-seats its tab. benilla has no undock (0288 §2), so that arm has nothing to
-- run — named here rather than stubbed (1134 §4), and reachable only from the Combat Log's
-- tab, whose own gate above already refuses to move it.
function FCF_ToggleLock()
local chatFrame = FCF_GetCurrentChatFrame()
if chatFrame.isLocked then
FCF_SetLocked(chatFrame, nil)
else
FCF_SetLocked(chatFrame, 1)
end
end
-- ref l.1428-1441, verbatim. `CHAT_LOCKED` is the blanket switch over every window; the
-- re-apply is ours, for the reason `FCF_SetLocked`'s is.
function FCF_Set_ChatLocked(isLocked)
-- The saved variable is a STRING, not a boolean — the uvar contract every options row
-- in this client shares, and what `RegisterForSave` writes out.
CHAT_LOCKED = isLocked and "1" or "0"
for i = 1, 2 do
local frame = getglobal("ChatFrame" .. i)
if frame then BenillaFCF_ApplyLock(frame) end
end
end
function FCF_Get_ChatLocked()
-- `1`/nil rather than a boolean, because every caller here is an `if` and the
-- reference's own callers pass the result around as one.
if CHAT_LOCKED == "1" then return 1 end
return nil
end
-- ref l.583-601. `this` is the grip button; the window is its parent. The commented-out
-- `DEFAULT_CHAT_FRAME.resizing = 1` block in the reference is left out with it.
function FCF_Resize(anchorPoint)
local chatFrame = this:GetParent()
if not FCF_CanMove(chatFrame) then return end
chatFrame.resizing = 1
chatFrame:StartSizing(anchorPoint)
end
-- ref l.603-609. The reference also calls `FCF_DockUpdate()` when the parent is the default
-- frame; ours has no such function and needs none — the tab is anchored to the window's own
-- background, so it follows a resize by anchor rather than by a re-layout pass.
function FCF_StopResize()
local chatFrame = this:GetParent()
chatFrame:StopMovingOrSizing()
chatFrame.resizing = nil
end
-- The move half. The reference reaches `StartMoving`/`StopMovingOrSizing` from
-- `FCF_Tab_OnClick`'s press/release arms; ours reaches them from the drag trio, for the
-- reasons written at the template's `OnDragStart`. Same two engine verbs, same three gates.
--
-- `MOVING_CHATFRAME` (the reference's global) is not set: its only readers there are
-- `FCF_IsValidChatFrame` and the dock-region hit test, neither of which exists here, and a
-- global nothing reads is state that starts lying the first time a path forgets to clear it.
function FCF_StartMove(chatFrame)
if not FCF_CanMove(chatFrame) then return end
chatFrame.moving = 1
chatFrame:StartMoving()
end
function FCF_StopMove(chatFrame)
chatFrame:StopMovingOrSizing()
chatFrame.moving = nil
end
-- Is either dock window mid-drag? The hover fade holds the chrome visible while it is —
-- ref FCF_OnUpdate's own `(MouseIsOver(...) or chatFrame.resizing)`. Without it the box
-- fades out under a moving cursor and takes the grip out of the hand holding it.
function BenillaFCF_Dragging()
for i = 1, 2 do
local f = getglobal("ChatFrame" .. i)
if f and (f.resizing or f.moving) then return 1 end
end
return nil
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
@@ -383,9 +552,15 @@
for i = 1, 2 do
local frame = getglobal("ChatFrame" .. i)
if frame then
local _, fontSize, r, g, b, a = GetChatWindowInfo(i)
local _, fontSize, r, g, b, a, _, locked = GetChatWindowInfo(i)
FCF_SetWindowColor(frame, r, g, b, 1)
FCF_SetWindowAlpha(frame, a, 1)
-- ref l.56, in the same block and for the same reason as the two above: the
-- window's stored LOCKED is seated onto the frame. It ships `1` (the stock
-- chat-cache row), so both dock windows start locked exactly as a fresh 1.12
-- client's do — and the write back through `SetChatWindowLocked` is idempotent,
-- so seating what was just read dirties nothing.
FCF_SetLocked(frame, locked)
-- 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)
@@ -520,6 +695,18 @@
return
end
-- Window options — the reference's FIRST row (ref l.236-246), and the only one of its
-- four window verbs benilla can honour. Rename, Create New and Remove each need the
-- window management 0288 §2 holds out (there is no rename, no undock, no create/close),
-- so they are named here rather than stubbed: a row that looks like a setting and
-- changes nothing cannot be told from a working one by looking (1134 §4).
local current = FCF_GetCurrentChatFrame()
info = {}
info.text = (current and current.isLocked) and UNLOCK_WINDOW or LOCK_WINDOW
info.func = FCF_ToggleLock
info.notCheckable = 1
UIDropDownMenu_AddButton(info)
-- Display header (ref l.273-279).
info = {}
info.text = DISPLAY
@@ -690,6 +877,10 @@
s.revealTarget = 0
end
if s.flashTab ~= 0 then s.revealTarget = 1 end
-- ref FCF_OnUpdate's `(MouseIsOver(...) or chatFrame.resizing)`: a drag in flight pins
-- the chrome open. The cursor is moving during a resize, which would otherwise keep
-- resetting the stationary-hover delay and fade the grips out from under it.
if BenillaFCF_Dragging() then s.revealTarget = 1 end
-- 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
@@ -1012,17 +1203,29 @@
The chrome the template carries: displayDuration 120 / maxLines 128 (ref ChatFrameTemplate);
a background stretched -2/+2 x and +3/-6 y past the frame (ref $parentBackground anchors)
and the 8 border slices ringing it (the ref draws those through its resize buttons'
textures — benilla authors them as plain textures, the hover LOOK without the resize
mechanics); the scroll column; and the per-frame scripts. Everything starts alpha 0;
BenillaFCF fades it.
and the 8 resize grips ringing it, which are the border art AND the handles (ref
CHAT_FRAME_TEXTURES — see the block below); the scroll column; and the per-frame scripts.
Everything starts alpha 0; BenillaFCF fades it.
`movable`/`resizable`/`<ResizeBounds>` are the reference's own attributes at the reference's
own numbers (FloatingChatFrame.xml l.211, l.222-230): min 296×75, max 608×400. The
`<TitleRegion setAllPoints="true"/>` that sits beside them THERE is deliberately absent
here — see the drag scripts at the foot of this template for why, and for what replaces it.
**`EXECUTE_CHAT_LINE` is gated to window 1 on purpose** — see the OnLoad below. It is the
one script here that is genuinely ChatFrame1's rather than the dock's, so the template
carries the gate rather than the registration.
-->
<ScrollingMessageFrame name="BenillaDockedChatFrameTemplate" virtual="true"
frameStrata="BACKGROUND" displayDuration="120.0" maxLines="128">
frameStrata="BACKGROUND" displayDuration="120.0" maxLines="128"
movable="true" resizable="true">
<!-- ref FloatingChatFrame.xml l.222-230, verbatim. The only `<ResizeBounds>` in the whole
of 1.12.1's FrameXML, and the numbers are the window's whole size domain: nothing else
clamps a chat window. -->
<ResizeBounds>
<minResize><AbsDimension x="296" y="75"/></minResize>
<maxResize><AbsDimension x="608" y="400"/></maxResize>
</ResizeBounds>
<Layers>
<Layer level="BACKGROUND">
<Texture name="$parentBackground" file="Interface\ChatFrame\ChatFrameBackground" alpha="0">
@@ -1031,63 +1234,212 @@
<Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="2" y="-6"/></Offset></Anchor>
</Anchors>
</Texture>
<!-- The ChatFrameBorder ring (ref ChatFrameBorder slices; texcoords quoted from
the ref resize buttons). Corners 16×16, edges stretched between them. -->
<Texture name="$parentBorderTopLeft" file="Interface\ChatFrame\ChatFrameBorder" alpha="0">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentBackground"><Offset><AbsDimension x="-2" y="2"/></Offset></Anchor></Anchors>
<TexCoords left="0" right="0.25" top="0" bottom="0.125"/>
</Texture>
<Texture name="$parentBorderTopRight" file="Interface\ChatFrame\ChatFrameBorder" alpha="0">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors><Anchor point="TOPRIGHT" relativeTo="$parentBackground"><Offset><AbsDimension x="2" y="2"/></Offset></Anchor></Anchors>
<TexCoords left="0.75" right="1.0" top="0" bottom="0.125"/>
</Texture>
<Texture name="$parentBorderBottomLeft" file="Interface\ChatFrame\ChatFrameBorder" alpha="0">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors><Anchor point="BOTTOMLEFT" relativeTo="$parentBackground"><Offset><AbsDimension x="-2" y="-3"/></Offset></Anchor></Anchors>
<TexCoords left="0" right="0.25" top="0.7265625" bottom="0.8515625"/>
</Texture>
<Texture name="$parentBorderBottomRight" file="Interface\ChatFrame\ChatFrameBorder" alpha="0">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors><Anchor point="BOTTOMRIGHT" relativeTo="$parentBackground"><Offset><AbsDimension x="2" y="-3"/></Offset></Anchor></Anchors>
<TexCoords left="0.75" right="1.0" top="0.7265625" bottom="0.8515625"/>
</Texture>
<Texture name="$parentBorderTop" file="Interface\ChatFrame\ChatFrameBorder" alpha="0">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors>
<Anchor point="LEFT" relativeTo="$parentBorderTopLeft" relativePoint="RIGHT"/>
<Anchor point="RIGHT" relativeTo="$parentBorderTopRight" relativePoint="LEFT"/>
</Anchors>
<TexCoords left="0.25" right="0.75" top="0" bottom="0.125"/>
</Texture>
<Texture name="$parentBorderBottom" file="Interface\ChatFrame\ChatFrameBorder" alpha="0">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors>
<Anchor point="LEFT" relativeTo="$parentBorderBottomLeft" relativePoint="RIGHT"/>
<Anchor point="RIGHT" relativeTo="$parentBorderBottomRight" relativePoint="LEFT"/>
</Anchors>
<TexCoords left="0.25" right="0.75" top="0.7265625" bottom="0.8515625"/>
</Texture>
<Texture name="$parentBorderLeft" file="Interface\ChatFrame\ChatFrameBorder" alpha="0">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors>
<Anchor point="TOP" relativeTo="$parentBorderTopLeft" relativePoint="BOTTOM"/>
<Anchor point="BOTTOM" relativeTo="$parentBorderBottomLeft" relativePoint="TOP"/>
</Anchors>
<TexCoords left="0" right="0.25" top="0.125" bottom="0.7265625"/>
</Texture>
<Texture name="$parentBorderRight" file="Interface\ChatFrame\ChatFrameBorder" alpha="0">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors>
<Anchor point="TOP" relativeTo="$parentBorderTopRight" relativePoint="BOTTOM"/>
<Anchor point="BOTTOM" relativeTo="$parentBorderBottomRight" relativePoint="TOP"/>
</Anchors>
<TexCoords left="0.75" right="1.0" top="0.125" bottom="0.7265625"/>
</Texture>
</Layer>
</Layers>
<Frames>
<!-- ── The eight RESIZE GRIPS (ref FloatingChatFrame.xml l.260-540) ─────────────────
The window's border art and its resize handles are the same eight objects: four
16×16 corners and four strips stretched between them, each a Button whose
`$parentTexture` is one slice of `ChatFrameBorder`. `CHAT_FRAME_TEXTURES` names
them, so they ride the hover fade and the tab menu's tint/opacity exactly as the
background does — they appear when the box appears and are invisible otherwise.
They were plain `$parentBorder*` textures until this arc (the look without the
mechanics); the anchors and TexCoords below are unchanged from those, so the ring
draws pixel-for-pixel where it did.
**Two of the reference's clauses are deliberately NOT transcribed, and both are
downstream of ONE divergence: its chat window is `enableMouse="false"` and ours is
not** (a `<ScrollingMessageFrame>` takes the mouse by construction here, and our
window uses it — `OnClick` dismisses a held spell, 0843, and `OnHyperlinkClick`
opens an item link, 0288 P2). In the reference the window competes for nothing, so:
· **No `this:SetFrameLevel(this:GetFrameLevel()-1)` OnLoad.** There it only sinks
the border art under the background wash. Here it would sink the grips under a
mouse-enabled parent, and every grip STRADDLES the window's edge (the top-left
corner overlaps its interior by 12×11 px, the edge strips by most of their
width) — so the overlapping half would stop hitting the grip and the handle
would be the outer sliver only. Level unchanged also keeps the ring drawing
over the wash, which is where benilla's border textures already were (they sat
after `$parentBackground` in the same layer).
· **The grips take the mouse only while the window is UNLOCKED**
(`BenillaFCF_ApplyLock`). Eight always-live buttons would shadow the outer
16px of the window — including the bottom strip, where the newest chat line
is — so an item link on the last line would stop being clickable. Locked is
the shipped state, so out of the box the window behaves exactly as it did;
unlocking it hands the edges over, which is what unlocking means. This is
benilla's stand-in for the click-through the reference gets for free.
`FCF_Resize`/`FCF_StopResize` still carry the reference's own three gates on top —
they are what `CHAT_LOCKED` and the docked-but-not-default case need. ── -->
<Button name="$parentResizeTopLeft">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentBackground"><Offset><AbsDimension x="-2" y="2"/></Offset></Anchor>
</Anchors>
<Layers>
<Layer level="BACKGROUND">
<Texture name="$parentTexture" file="Interface\ChatFrame\ChatFrameBorder" alpha="0">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
<TexCoords left="0" right="0.25" top="0" bottom="0.125"/>
</Texture>
</Layer>
</Layers>
<Scripts>
<OnMouseDown>FCF_Resize("TOPLEFT")</OnMouseDown>
<OnMouseUp>FCF_StopResize()</OnMouseUp>
</Scripts>
</Button>
<Button name="$parentResizeTopRight">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors>
<Anchor point="TOPRIGHT" relativeTo="$parentBackground"><Offset><AbsDimension x="2" y="2"/></Offset></Anchor>
</Anchors>
<Layers>
<Layer level="BACKGROUND">
<Texture name="$parentTexture" file="Interface\ChatFrame\ChatFrameBorder" alpha="0">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
<TexCoords left="0.75" right="1.0" top="0" bottom="0.125"/>
</Texture>
</Layer>
</Layers>
<Scripts>
<OnMouseDown>FCF_Resize("TOPRIGHT")</OnMouseDown>
<OnMouseUp>FCF_StopResize()</OnMouseUp>
</Scripts>
</Button>
<Button name="$parentResizeBottomLeft">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors>
<Anchor point="BOTTOMLEFT" relativeTo="$parentBackground"><Offset><AbsDimension x="-2" y="-3"/></Offset></Anchor>
</Anchors>
<Layers>
<Layer level="BACKGROUND">
<Texture name="$parentTexture" file="Interface\ChatFrame\ChatFrameBorder" alpha="0">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
<TexCoords left="0" right="0.25" top="0.7265625" bottom="0.8515625"/>
</Texture>
</Layer>
</Layers>
<Scripts>
<OnMouseDown>FCF_Resize("BOTTOMLEFT")</OnMouseDown>
<OnMouseUp>FCF_StopResize()</OnMouseUp>
</Scripts>
</Button>
<Button name="$parentResizeBottomRight">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors>
<Anchor point="BOTTOMRIGHT" relativeTo="$parentBackground"><Offset><AbsDimension x="2" y="-3"/></Offset></Anchor>
</Anchors>
<Layers>
<Layer level="BACKGROUND">
<Texture name="$parentTexture" file="Interface\ChatFrame\ChatFrameBorder" alpha="0">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
<TexCoords left="0.75" right="1.0" top="0.7265625" bottom="0.8515625"/>
</Texture>
</Layer>
</Layers>
<Scripts>
<OnMouseDown>FCF_Resize("BOTTOMRIGHT")</OnMouseDown>
<OnMouseUp>FCF_StopResize()</OnMouseUp>
</Scripts>
</Button>
<Button name="$parentResizeTop">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors>
<Anchor point="LEFT" relativeTo="$parentResizeTopLeft" relativePoint="RIGHT"/>
<Anchor point="RIGHT" relativeTo="$parentResizeTopRight" relativePoint="LEFT"/>
</Anchors>
<Layers>
<Layer level="BACKGROUND">
<Texture name="$parentTexture" file="Interface\ChatFrame\ChatFrameBorder" alpha="0">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors>
<Anchor point="LEFT"/>
<Anchor point="RIGHT"/>
</Anchors>
<TexCoords left="0.25" right="0.75" top="0" bottom="0.125"/>
</Texture>
</Layer>
</Layers>
<Scripts>
<OnMouseDown>FCF_Resize("TOP")</OnMouseDown>
<OnMouseUp>FCF_StopResize()</OnMouseUp>
</Scripts>
</Button>
<Button name="$parentResizeBottom">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors>
<Anchor point="LEFT" relativeTo="$parentResizeBottomLeft" relativePoint="RIGHT"/>
<Anchor point="RIGHT" relativeTo="$parentResizeBottomRight" relativePoint="LEFT"/>
</Anchors>
<Layers>
<Layer level="BACKGROUND">
<Texture name="$parentTexture" file="Interface\ChatFrame\ChatFrameBorder" alpha="0">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors>
<Anchor point="LEFT"/>
<Anchor point="RIGHT"/>
</Anchors>
<TexCoords left="0.25" right="0.75" top="0.7265625" bottom="0.8515625"/>
</Texture>
</Layer>
</Layers>
<Scripts>
<OnMouseDown>FCF_Resize("BOTTOM")</OnMouseDown>
<OnMouseUp>FCF_StopResize()</OnMouseUp>
</Scripts>
</Button>
<Button name="$parentResizeLeft">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors>
<Anchor point="TOP" relativeTo="$parentResizeTopLeft" relativePoint="BOTTOM"/>
<Anchor point="BOTTOM" relativeTo="$parentResizeBottomLeft" relativePoint="TOP"/>
</Anchors>
<Layers>
<Layer level="BACKGROUND">
<Texture name="$parentTexture" file="Interface\ChatFrame\ChatFrameBorder" alpha="0">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors>
<Anchor point="TOP"/>
<Anchor point="BOTTOM"/>
</Anchors>
<TexCoords left="0" right="0.25" top="0.125" bottom="0.7265625"/>
</Texture>
</Layer>
</Layers>
<Scripts>
<OnMouseDown>FCF_Resize("LEFT")</OnMouseDown>
<OnMouseUp>FCF_StopResize()</OnMouseUp>
</Scripts>
</Button>
<Button name="$parentResizeRight">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors>
<Anchor point="TOP" relativeTo="$parentResizeTopRight" relativePoint="BOTTOM"/>
<Anchor point="BOTTOM" relativeTo="$parentResizeBottomRight" relativePoint="TOP"/>
</Anchors>
<Layers>
<Layer level="BACKGROUND">
<Texture name="$parentTexture" file="Interface\ChatFrame\ChatFrameBorder" alpha="0">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors>
<Anchor point="TOP"/>
<Anchor point="BOTTOM"/>
</Anchors>
<TexCoords left="0.75" right="1.0" top="0.125" bottom="0.7265625"/>
</Texture>
</Layer>
</Layers>
<Scripts>
<OnMouseDown>FCF_Resize("RIGHT")</OnMouseDown>
<OnMouseUp>FCF_StopResize()</OnMouseUp>
</Scripts>
</Button>
<!-- The scroll column, outside bottom-left (ref: BottomButton at (-32,-4), Down and
Up stacked above it, 2px overlaps). Sounds + hold-repeat per the ref scripts. -->
<Button name="$parentBottomButton">
@@ -1184,6 +1536,20 @@
this:RegisterEvent("EXECUTE_CHAT_LINE")
this:RegisterEvent("UPDATE_CHAT_WINDOWS")
end
-- `isDocked` — the reference's own flag, and it is LOAD-BEARING now rather than an
-- advertisement. Only this template is inherited, and only ChatFrame1/ChatFrame2
-- inherit it, so every frame that reaches this line is docked: they share one rect
-- (window 2's anchors are window 1's corners) and one tab strip.
--
-- This file used to withhold the flag on the grounds that it "belongs to a dock
-- MODEL (dock/undock, FCF_DockUpdate's re-anchoring) that 0288 §2 holds out", i.e.
-- setting it would advertise verbs we do not have. What changed is that the
-- reference's move and resize gates READ it — `chatFrame.isDocked and chatFrame ~=
-- DEFAULT_CHAT_FRAME` is the clause that keeps the Combat Log from being resized
-- out of the dock it is anchored into — so withholding it now means the gate that
-- protects window 2 silently never fires. The flag is a fact about our two windows;
-- the verbs remain out.
this.isDocked = 1
</OnLoad>
<OnEvent>
if event == "EXECUTE_CHAT_LINE" then
@@ -1205,6 +1571,43 @@
<OnHyperlinkClick>
SetItemRef(arg1, arg2, arg3)
</OnHyperlinkClick>
<!-- ── Dragging the window ──────────────────────────────────────────────────────────
**What the reference does, and why we cannot copy the declaration.** Its template
carries `<TitleRegion setAllPoints="true"/>` — the engine drag where a mouse-down
anywhere in the frame moves it — and that region is INERT there, because the same
line declares `enableMouse="false"`. So 1.12 never drags a chat window by its body;
it drags it by the TAB (`FCF_Tab_OnClick`'s `chatFrame:StartMoving()`, and the
tab's own newbie tip says so in as many words: *"Left-click and drag to move the
window"*).
Ours is mouse-enabled (a `<ScrollingMessageFrame>` takes the mouse by construction,
and this window uses it for `OnClick` and `OnHyperlinkClick`), so transcribing the
title region would have three consequences the reference never pays: a press on the
chat body would be SWALLOWED before `OnClick` (0843's held-spell dismissal dies), a
LOCKED window would drag anyway (the engine title path has no lock gate — wow-re Q6
records that the movable bit is not read on it at all), and there would be no way to
turn it off again (an anchorless region falls back to its owner's rect, 1349, so
`ClearAllPoints` widens it rather than removing it). It is also inert for a fourth,
duller reason: our loader has no `<TitleRegion>` element — the region is
Lua-created only.
So the gesture is the drag TRIO instead, which is the reference's own layering
(`RegisterForDrag` + `OnDragStart`/`OnDragStop` → `StartMoving`/
`StopMovingOrSizing`) and buys exactly what the title region costs: a CLICK still
reaches `OnClick` because an un-started gesture leaves the click path alone, the
lock is a Lua gate like every other one here, and `OnDragStop` fires on the SOURCE
frame, so a drag released anywhere at all still ends the move.
The registration itself is switched by `BenillaFCF_ApplyLock` — a locked window is
not drag-registered, so it behaves exactly as it did before this arc. Both the body
(benilla's addition, the affordance the title region would have given) and the tab
(the reference's own, on ChatFrame<n>Tab) reach the same two verbs. ── -->
<OnDragStart>
FCF_StartMove(this)
</OnDragStart>
<OnDragStop>
FCF_StopMove(this)
</OnDragStop>
</Scripts>
<FontString inherits="ChatFontNormal" justifyH="LEFT"/>
</ScrollingMessageFrame>
@@ -1287,12 +1690,12 @@
then `vTab:GetText()`, unguarded — but only INSIDE `if vChatFrame:IsVisible() or
vChatFrame.isDocked`, so a hidden, `isDocked`-less window never reaches it.
`isDocked` is not set on ChatFrame2 either, and the reason has CHANGED: it used to be
that window 2 had no tab for Outfitter to read, so claiming dockedness would have walked
it into a nil. 1575 built the tab, so that hazard is gone — what is left is that
`isDocked` belongs to a dock MODEL (dock/undock, FCF_DockUpdate's re-anchoring) that
0288 §2 holds out. Setting the flag would advertise a model we do not implement; our two
windows are docked by their anchors, which is the part that matters.
`isDocked` is NOT set on these five, and that is the whole point of the flag now: the two
dock windows set it in the shared template's `OnLoad` (it is what the reference's move and
resize gates read), and ChatFrame3..7 are the frames that genuinely are not docked. The
Outfitter hazard the flag used to carry — `if vChatFrame:IsVisible() or vChatFrame.isDocked`
then `vTab:GetText()` on a window with no tab — is why it stays off here: these have no
tabs, and they are hidden, so neither arm reaches the nil.
No background/border art: the ref's own defaults put every one of these at window alpha
0 (COLOR's fourth byte), and BenillaFCF only fades the dock's two. A shown ChatFrame5
@@ -1431,6 +1834,16 @@
<OnClick>
BenillaFCF_TabClick(1, arg1)
</OnClick>
<!-- The reference's own move handle (ref FCF_Tab_OnClick's press/release arms, and the
tip below, which says "Left-click and drag to move the window" in as many words).
It reaches the same two verbs as the window body does; the registration that arms
it is `BenillaFCF_ApplyLock`'s, so a locked window's tab still only selects. -->
<OnDragStart>
FCF_StartMove(getglobal("ChatFrame" .. this:GetID()))
</OnDragStart>
<OnDragStop>
FCF_StopMove(getglobal("ChatFrame" .. this:GetID()))
</OnDragStop>
<!-- 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. -->
@@ -1536,6 +1949,16 @@
<OnClick>
BenillaFCF_TabClick(2, arg1)
</OnClick>
<!-- The reference's own move handle (ref FCF_Tab_OnClick's press/release arms, and the
tip below, which says "Left-click and drag to move the window" in as many words).
It reaches the same two verbs as the window body does; the registration that arms
it is `BenillaFCF_ApplyLock`'s, so a locked window's tab still only selects. -->
<OnDragStart>
FCF_StartMove(getglobal("ChatFrame" .. this:GetID()))
</OnDragStart>
<OnDragStop>
FCF_StopMove(getglobal("ChatFrame" .. this:GetID()))
</OnDragStop>
<!-- 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. -->
@@ -41,36 +41,29 @@
says why the widget could not be a few methods hung on this one frame (TipBuddy declares two
`<ColorSelect>` frames of its own).
── THE ONE THING THIS WINDOW CANNOT DO, AND IT IS THE BIG ONE ──────────────────────────────
**There is no colour wheel, so there is no way to CHOOSE a colour in it yet.** The reference
ends its `<ColorSelect>` with four sub-elements this file does not carry (ref l.186-221):
── THE WHEEL IS ENGINE ART, AND THAT IS WHY IT TOOK A §5 ───────────────────────────────────
Look at what `<ColorWheelTexture>` and `<ColorValueTexture>` below do NOT have: a `file=`.
The hue disc and the brightness strip are **generated by the client**, not loaded — there is
no BLP anywhere in the MPQ chain that is a colour wheel, only the two little thumb markers
that ride on top of them. So the four elements are declared here for their names and their
geometry, and the *pixels* are the app renderer's
(`benilla-app/src/ui_script/extract/colorselect.rs`), the same division of labour the
`<Minimap>` widget's content hole uses.
<ColorWheelTexture name="ColorPickerWheel"/> 128x128, TOPLEFT (16, -32)
<ColorWheelThumbTexture/> 10x10, Interface\Buttons\UI-ColorPicker-Buttons,
TexCoords 0 / 0.15625 / 0 / 0.625
<ColorValueTexture/> 32x128, LEFT of ColorPickerWheel's RIGHT at (24, 0)
<ColorValueThumbTexture/> 48x14, same sheet, TexCoords 0.25 / 1.0 / 0 / 0.875
For two years this file shipped without them and said so. What unblocked it was wow-re's
`system/ui/scratch/colorselect-drawn-appearance.md` (decision 1592), which carved what the
client actually draws — a 128×128 generated ARGB texture on an integer lattice with a hard
alpha cut, and a solid `HSV(H,S,1)` tile under a black→white vertex gradient — and, in
passing, corrected the obvious guess: **the wheel is drawn at a literal `V = 1.0` and never
reads the widget's brightness.** Lowering the opacity/value marker moves the marker; the
disc behind it does not dim. Read that module's header before touching any of this.
Look at what the first and third do NOT have: a `file=`. The hue wheel and the value strip
are **generated by the client**, not loaded — there is no BLP anywhere in the MPQ chain that
is the colour wheel, only the two little thumb markers that ride on top of it. Rendering
them is an app-renderer job (an HSV wheel + a value ramp drawn for a `FrameKind::ColorSelect`
rect), not a FrameXML one, so the elements are omitted rather than shipped inert — their
geometry is recorded above, and dropping them back in is the day that renderer exists.
(RF-28 has the loader hooks: `<ColorWheelTexture>` `0x78b580`, `<ColorWheelThumbTexture>`
`0x78b850`, `<ColorValueTexture>` `0x78b8a0`, `<ColorValueThumbTexture>` `0x78ba90` — and the
INTERACTION half is already carved too, so that day is cheaper than it looks: wow-re's
`colorselect-color-law.md` §5 has the press/drag handler verbatim — a click jumps the colour
immediately, `H = (atan2(ny, nx) + π)·180/π`, `S = min(radius, 1)`, `V = clamp((y − bottom) /
(top − bottom), 0, 1)`, all raw f32 straight into the widget's HSV, no quantizer.)
Everything ELSE works, and it is most of what the corpus actually exercises: an addon sets a
colour and the swatch shows it, the opacity slider is live and drives `opacityFunc` on every
drag, Okay commits through `func` + `opacityFunc`, Cancel restores through `cancelFunc`, and
both buttons are real named buttons whose `OnClick` an addon can fetch and chain. The
reference's own geometry is kept exactly — including the 128x128 gap on the left where the
wheel belongs, and the 365-wide / 305-wide `OnShow` swap around it — because half a window at
the right size is honest and a re-designed window is not.
The two thumbs carry `file=` and **no `<Anchors>`**, which is not an omission either: the
client `ClearAllPoints`es them and re-`SetPoint`s from C++ on every colour change
(`0x78b850`/`0x78ba90`), so an authored anchor would be discarded. benilla derives their
rects the same way, in `script::colorselect::wheel_thumb_rect`/`value_thumb_rect` — including
the client's two asymmetries (the wheel marker scales its *vertical* offset by the wheel's
**width**; the value marker scales by the **wheel's** height while anchoring to the strip).
OTHER DEVIATIONS, each forced:
· **`<OnKeyDown>` (ref l.177-184) is not transcribed** — this engine gives plain frames no
@@ -270,5 +263,32 @@
end
</OnColorSelect>
</Scripts>
<!-- ref l.186-221. The reference declares all four AFTER `<Scripts>`, and the order is not
cosmetic: `<ColorValueTexture>` anchors to `ColorPickerWheel` by name, so the wheel has
to be published first. The wheel's name is the reference's own and unprefixed — an
addon re-skinning the picker addresses it. -->
<ColorWheelTexture name="ColorPickerWheel">
<Size><AbsDimension x="128" y="128"/></Size>
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="16" y="-32"/></Offset></Anchor>
</Anchors>
</ColorWheelTexture>
<!-- The one BLP in this window: a 4x2 sheet of markers, of which these two crops are the
wheel dot and the strip's arrow pair. Unnamed in the reference, and unanchored — see
the header. -->
<ColorWheelThumbTexture file="Interface\Buttons\UI-ColorPicker-Buttons">
<Size><AbsDimension x="10" y="10"/></Size>
<TexCoords left="0" right="0.15625" top="0" bottom="0.625"/>
</ColorWheelThumbTexture>
<ColorValueTexture>
<Size><AbsDimension x="32" y="128"/></Size>
<Anchors>
<Anchor point="LEFT" relativeTo="ColorPickerWheel" relativePoint="RIGHT"><Offset><AbsDimension x="24" y="0"/></Offset></Anchor>
</Anchors>
</ColorValueTexture>
<ColorValueThumbTexture file="Interface\Buttons\UI-ColorPicker-Buttons">
<Size><AbsDimension x="48" y="14"/></Size>
<TexCoords left="0.25" right="1.0" top="0" bottom="0.875"/>
</ColorValueThumbTexture>
</ColorSelect>
</Ui>
+8 -4
View File
@@ -111,10 +111,14 @@
region (the ref's `Craft<n>Text`/`Craft<n>SubText`, `ClassTrainerSkillButtonTemplate`'s
auto-created text object); the persistent selection glow is the separate
`BenillaCraftHighlightFrame` (TradeSkillFrame.xml's own selection-highlight precedent)
instead of Lock/UnlockHighlight — no independent hover glow beyond it, and (verified
against Craft's OWN ref, unlike TradeSkill's row template) `CraftButtonTemplate` never
overrides `OnEnter` for a tooltip either — this window's list rows carry NO hover tooltip
at all, faithfully (TradeSkill's own row hover IS a real ref behavior; Craft's is not).
instead of Lock/UnlockHighlight — no independent hover glow beyond it, and
`CraftButtonTemplate` never overrides `OnEnter` for a tooltip either — this window's list
rows carry NO hover tooltip at all, faithfully. (An earlier revision of this line added
"TradeSkill's own row hover IS a real ref behavior; Craft's is not" — WRONG, and never
checked: `TradeSkillSkillButtonTemplate` overrides `OnClick` alone, and the shared base
`ClassTrainerSkillButtonTemplate` (`Interface\FrameXML\ClassTrainerFrameTemplates.xml`)
only recolours `$parentSubText` on hover. NEITHER window's rows tooltip. TradeSkill's
stray OnEnter is gone; see that file's row-template comment.)
- `Button:GetID()`/`:SetID()` have no native method (same grep) — the ref's
`craftButton:SetID(craftIndex)` + `this:GetID()` pair becomes a plain Lua field,
`button.craftIndex`, set in the Update loop and read by the click handler
@@ -15,8 +15,18 @@
UPDATE_PENDING_MAIL + HasNewMail(), and the tracking icon (MiniMapTrackingFrame) driven by
PLAYER_AURAS_CHANGED + GetTrackingTexture() (the aura rebuild's tracking-global law, `ui_aura`).
The minimap PING (decision 1596) is IN as a click entry only: `Minimap_OnClick` below, on the
widget's OnMouseUp, exactly where the reference puts it — because one corpus addon
(CleanMinimap) REPLACES that global and expects the client to call the replacement. Everything
past the click is the engine's: the marker is pinned to a WORLD point and drawn by
`minimap/ping.rs` in the same pass as the blips, so there is no `MiniMapPing` frame, no Lua
timer and no per-frame SetPoint here. Owning the display in Lua is what made the first attempt
flaky, and no corpus addon touches any of that surface (measured: 0 of 218 for MiniMapPing /
Minimap_SetPing / MINIMAP_PING / PingLocation / GetPingPosition).
OUT (no wire / later arc slices, per 0203): meeting stone and battlefield buttons;
the ping model + MINIMAP_PING; the rotate-minimap CVar. The day/night indicator now lives in
the ping MODEL (the reference's MiniMapPing is a <Model> on MinimapPing.mdx — ours is a flat
stand-in at an INTERIM size, `minimap::ping::PING_PX`); the rotate-minimap CVar. The day/night indicator now lives in
its own GameTime.xml (the reference's own file split), loaded right after this file. The
reference's minimapPlayerModel/.mdx arrow renders app-side from its .blp for now (0203 flags
the simplification). The reference's <HitRectInsets> on the cluster is dropped (it only
@@ -25,6 +35,18 @@
-->
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<Script><![CDATA[
-- The minimap click (decision 1596). A global, and thin on purpose: it is the seam an
-- addon replaces (CleanMinimap swaps it out to make a modifier-drag move the cluster and
-- calls the original otherwise), so everything it could get wrong is behind the engine.
-- The reference's own body also range-tests the disc and divides by GetEffectiveScale
-- here; both belong on the far side of PingLocation, which knows the map's real geometry
-- this frame — in UI units the widget's radius is not a number this file can know.
function Minimap_OnClick()
local x, y = GetCursorPosition();
local cx, cy = Minimap:GetCenter();
Minimap:PingLocation(x - cx, y - cy);
end
-- ref-Minimap.lua ToggleMinimap(), verbatim (the toggle button hides the MAP, not the chrome).
function ToggleMinimap()
if ( Minimap:IsVisible() ) then
@@ -557,6 +579,13 @@
</Frames>
</Frame>
</Frames>
<Scripts>
<!-- ref-Minimap.xml l.498: the ping is the widget's own OnMouseUp, on any
button (the reference tests no button here either). -->
<OnMouseUp>
Minimap_OnClick();
</OnMouseUp>
</Scripts>
</Minimap>
</Frames>
<Scripts>
@@ -114,10 +114,15 @@
`benilla-ui/src/script/button.rs` ships texture-pairs/SetText/Enable/Disable/
RegisterForClicks/SetButtonState/Click only) — row coloring routes through a named child
FontString (`$parentName`, TrainerFrame.xml's own row-template shape) instead of the
button itself; the persistent selection glow is the separate `BenillaTradeSkillHighlight`
Frame (TrainerFrame.xml's own selection-highlight precedent) instead of Lock/
UnlockHighlight — no independent hover glow is added beyond it (TrainerFrame's own rows
carry none either).
button itself; the persistent selection glow is the separate
`BenillaTradeSkillHighlightFrame` (TrainerFrame.xml's own selection-highlight precedent)
instead of Lock/UnlockHighlight — no independent hover glow is added beyond it
(TrainerFrame's own rows carry none either). **The Frame is the thing shown, hidden and
positioned; `BenillaTradeSkillHighlight` is only its texture, and only ever gets
`SetVertexColor`** (ref l.99/142-143 vs l.200) — Update addressed the texture for all
four until decision 1598, which left the frame `hidden="true"` for the window's whole
life and meant NO selection ever painted. CraftFrame.xml/TrainerFrame.xml already split
the two correctly; this window was the outlier.
- `SecondsToTime`/`BuildColoredListString` (ref `UIParent.lua`) have no engine equivalent
(they're Lua-side FrameXML utilities, not C API) — transcribed as GUARDED GLOBALS
(`if not X then function X(...) ... end end`), BuffFrame.xml's own
@@ -416,7 +421,11 @@
FauxScrollFrame_Update(scrollFrame, numTradeSkills, TRADE_SKILLS_DISPLAYED, TRADE_SKILL_HEIGHT)
local skillOffset = FauxScrollFrame_GetOffset(scrollFrame)
local highlight = getglobal("BenillaTradeSkillHighlight")
-- The FRAME is what shows/hides/positions (ref l.99/142-143); its texture is only ever
-- vertex-coloured (SetSelection, ref l.200). Addressing the texture here instead left the
-- glow's frame `hidden="true"` forever, so no selection ever painted (decision 1598) —
-- CraftFrame.xml and TrainerFrame.xml both already split the two correctly.
local highlight = getglobal("BenillaTradeSkillHighlightFrame")
highlight:Hide()
local selection = GetTradeSkillSelectionIndex()
@@ -515,25 +524,6 @@
BenillaTradeSkillFrame_Update()
end
-- List-row hover tooltip (the product item, keyed by row index — GameTooltip:SetTradeSkillItem
-- with no reagent index). Guarded against a HEADER row here rather than in the engine binding
-- (`super::tooltip_item`'s `SetTradeSkillItem` is outside this change's file scope — see
-- tradeskill.rs's own module doc "KNOWN GAP" note): a header's own visible index would
-- otherwise misresolve through that binding's still-flat `recipes` indexing.
function BenillaTradeSkillSkillButton_OnEnter(button)
local index = button.skillIndex
if not index then return end
local _, skillType = GetTradeSkillInfo(index)
if skillType == "header" then return end
GameTooltip:SetOwner(button, "ANCHOR_RIGHT")
GameTooltip:SetTradeSkillItem(index)
GameTooltip:Show()
end
function BenillaTradeSkillSkillButton_OnLeave()
GameTooltip:Hide()
end
-- The window's wheel handler (bubbled up the parent chain from a row) → one row via the faux
-- kit (TrainerFrame.xml's own note: the engine bubbles a spin up the parent chain, so the
-- handler lives on the window, not a child).
@@ -821,11 +811,22 @@
One list row (ref TradeSkillSkillButtonTemplate inherits ClassTrainerSkillButtonTemplate,
l.343-414 for the instances): a 293×16 row — the +/- fold slot at the left (meaningful for a
header row, blanked for a recipe row; wow-re `tradeskill` TU-B, deviation 1) + the name text.
`ClassTrainerSkillButtonTemplate` isn't in this session's ref extraction (only
`TradeSkillSkillButtonTemplate` itself, which just inherits it, is) — TrainerFrame.xml's own
`BenillaTrainerServiceTemplate` geometry stands in for the un-extracted base (that file's own
reagent-slot precedent for reconstructing one), minus its separate rank-subtext column this
window's rows never carry.
`ClassTrainerSkillButtonTemplate` lives in `Interface\FrameXML\ClassTrainerFrameTemplates.xml`
(LOCATED — earlier revisions of this comment said it "isn't in this session's ref extraction"
and reconstructed it from TrainerFrame.xml's own `BenillaTrainerServiceTemplate`; the geometry
that guess produced matches, so it stays). The base is 293×16 with a 16×16 NormalTexture and a
16×16 `$parentHighlight` both at LEFT+3, ButtonText hung off `$parentHighlight`'s RIGHT, and a
BACKGROUND `$parentSubText` this window never fills.
**NO ROW HOVER TOOLTIP** — the base's `OnEnter`/`OnLeave` only recolour `$parentSubText` to
HIGHLIGHT_FONT_COLOR and back, and `TradeSkillSkillButtonTemplate` overrides `OnClick` ALONE.
Nothing in the reference chain calls GameTooltip for a list row: the product tooltip is the
detail pane's `TradeSkillSkillIcon`, the reagent tooltips are the reagent slots, and the list
is bare. An earlier revision shipped a `GameTooltip:SetTradeSkillItem` OnEnter here as if it
were the ref's; it never was, and CraftFrame.xml's own deviation 7 repeated the claim ("TradeSkill's
own row hover IS a real ref behavior") on the strength of it. Both are corrected: no OnEnter.
Since `$parentSubText` is empty for every row of this window, the base's own hover recolour is
invisible here too, so the row carries no hover script at all.
-->
<Button name="BenillaTradeSkillSkillButtonTemplate" virtual="true" hidden="true">
<Size><AbsDimension x="293" y="16"/></Size>
@@ -854,8 +855,6 @@
<Scripts>
<OnLoad>self.name = getglobal(self:GetName() .. "Name")</OnLoad>
<OnClick>BenillaTradeSkillSkillButton_OnClick(self, arg1)</OnClick>
<OnEnter>BenillaTradeSkillSkillButton_OnEnter(self)</OnEnter>
<OnLeave>BenillaTradeSkillSkillButton_OnLeave()</OnLeave>
</Scripts>
</Button>
+17
View File
@@ -357,6 +357,23 @@
_G[index] = xOffset
elseif ( row.isVar ) then
_G[index] = yOffset
elseif ( frame.IsUserPlaced and frame:IsUserPlaced() ) then
-- **A frame the PLAYER placed is not this pass's to seat.** benilla's rule,
-- not the reference's: 1.12 has no *movable* managed frame — its ChatFrame1
-- is docked and its tab click refuses to drag a docked window — so the case
-- cannot arise there. Ours does, since an unlocked chat window drags and
-- resizes, and without this the next pass would `ClearAllPoints` the window
-- and drop it back on the bottom-stack seat. That is not a rare path: the
-- stance bar fires this on every show and hide.
--
-- `IsUserPlaced` is exactly the right question rather than a chat-specific
-- one: the engine sets that bit at the drag that moved the frame, and the
-- layout cache (`benilla_app::ui_layout`) sets it again for every frame it
-- seats out of the player's file — so "the player owns this frame's
-- position" and "do not manage it" are the same fact.
--
-- The `isVar` rows above are untouched: they write a NUMBER and seat no
-- frame, so there is no position for a player to own.
else
-- ClearAllPoints first: the row states the frame's WHOLE seat, so the pass
-- must own it outright. `SetPoint` only replaces the same-NAMED point, so
@@ -1007,6 +1007,23 @@ pub(super) fn seed_ui_fixture(
warn!("capture: ui-options-chat seed failed: {e}");
}
}
UiFixture::ColorPicker => {
let Some(script) = script else {
return;
};
// The corpus's own opening move (Dewdrop-2.0's colour row, `ColorPickerFrame.xml`'s
// header): set the colour, ask for opacity, show the window. A saturated mid-hue at
// three-quarter brightness puts BOTH markers off their defaults — the wheel's off
// centre and off the axes, the strip's a quarter down — so a mirrored axis or a
// swapped marker is visible in the still.
if let Err(e) = script.run(
"ColorPickerFrame.hasOpacity = 1; ColorPickerFrame.opacity = 0.3; \
ColorPickerFrame:SetColorRGB(0.15, 0.55, 0.75); \
ShowUIPanel(ColorPickerFrame)",
) {
warn!("capture: ui-color-picker seed failed: {e}");
}
}
UiFixture::OptionsWorldDetail => {
let Some(mut script) = script else {
return;
@@ -134,6 +134,11 @@ pub(super) enum UiFixture {
/// 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 **colour picker with its wheel** (decision 1592): the hue disc and the brightness strip,
/// both generated art with no BLP behind them, plus the two markers riding on them. Seeded at a
/// known colour so the markers sit somewhere checkable rather than at the default white's
/// centre. The one fixture whose subject is pixels this client computes.
ColorPicker,
/// 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.
@@ -1031,6 +1036,16 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
minute: 720,
ui: Some(UiFixture::OptionsChat),
},
// The colour picker, wheel and all (1592). Run with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-color-picker`.
Scenario {
name: "ui-color-picker",
map: MAP_AZEROTH,
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
ui: Some(UiFixture::ColorPicker),
},
// The Graphics page with the Environment Detail dropdown OPEN (0992). Run with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-options-worlddetail`.
Scenario {
+7
View File
@@ -122,6 +122,7 @@ mod ui_honor;
mod ui_inspect;
mod ui_item_text;
mod ui_items;
mod ui_layout;
mod ui_logout;
mod ui_loot;
mod ui_loot_roll;
@@ -193,6 +194,7 @@ use ui_gossip::UiGossipPlugin;
use ui_guild::UiGuildPlugin;
use ui_item_text::UiItemTextPlugin;
use ui_items::UiItemsPlugin;
use ui_layout::UiLayoutPlugin;
use ui_logout::UiLogoutPlugin;
use ui_loot::UiLootPlugin;
use ui_loot_roll::UiLootRollPlugin;
@@ -720,6 +722,11 @@ pub fn run(build: BuildId) -> AppExit {
// QuestLogFrame.xml over the Era quest-log API.
.add_plugins(UiQuestLogPlugin)
.add_plugins(UiChatPlugin)
// The layout cache: the geometry of every window the player has dragged or resized, restored
// at world entry and written back a quiet second after the last drag
// (`benilla-config/layout/<realm>-<character>.txt`). The consumer of the engine's userPlaced
// bit, which nothing read before it.
.add_plugins(UiLayoutPlugin)
// Print screen (decision 1487): the SCREENSHOT binding's engine half — one PNG per
// `Screenshot()` call into `benilla-config/Screenshots/` (never the install — decision 1486),
// answered to the UI as SCREENSHOT_SUCCEEDED/FAILED so the status text can never be in the
+35 -6
View File
@@ -208,10 +208,14 @@ 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
pub(crate) fn saved_account_path() -> Option<PathBuf> {
home().map(|h| h.join("account"))
}
/// `benilla-config/chat/<realm>-<character>.txt` — the chat windows' saved state (decision 1589):
/// the background tint, the background alpha, the font size a chat tab's right-click menu sets,
/// and the window's lock. **Character-scoped**, where the reference keeps the same four 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> {
@@ -222,8 +226,22 @@ pub(crate) fn chat_character_path(realm: &str, character: &str) -> Option<PathBu
)))
}
pub(crate) fn saved_account_path() -> Option<PathBuf> {
home().map(|h| h.join("account"))
/// `benilla-config/layout/<realm>-<character>.txt` — the **layout cache**: where every frame the
/// player has moved or resized sits, so a dragged chat window is still there after a relog.
///
/// The reference's own resident is `WTF/Account/<ACC>/<REALM>/<CHAR>/layout-cache.txt`, written by
/// the engine for exactly the frames carrying the userPlaced bit — so the scope is its scope and
/// the name is its name, one folder flatter like every other resident here ([`home`] is already
/// account-scoped by the binary it sits beside). Character-scoped because the state IS per
/// character: a raid alt wants the combat log wide where a questing alt wants it out of the way.
///
/// The file's shape, and the seam that fills it, are [`crate::ui_layout`]'s.
pub(crate) fn layout_character_path(realm: &str, character: &str) -> Option<PathBuf> {
Some(home()?.join("layout").join(format!(
"{}-{}.txt",
file_token(realm),
file_token(character)
)))
}
/// `benilla-config/shots.txt` — the framing instrument's appended camera poses (decision 0600). A dev
@@ -362,6 +380,12 @@ mod tests {
chat_character_path("Hydraxian Waterlords", "Probeone"),
Some(tmp.join("benilla-config/chat/Hydraxian_Waterlords-Probeone.txt"))
);
// The layout cache — the userPlaced frames' geometry, character-scoped like its neighbours
// and like the reference's own `layout-cache.txt`.
assert_eq!(
layout_character_path("Hydraxian Waterlords", "Probeone"),
Some(tmp.join("benilla-config/layout/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
@@ -400,6 +424,11 @@ mod tests {
None,
"a capture reads no player's chat look"
);
assert_eq!(
layout_character_path("Hydraxian Waterlords", "Probeone"),
None,
"a capture reads no player's window layout"
);
std::fs::remove_dir_all(&tmp).ok();
}
+8 -1
View File
@@ -273,6 +273,8 @@ pub(super) type BlipInputs<'w, 's> = (
Option<Res<'w, crate::go_templates::Locks>>,
Res<'w, crate::poi_marker::PoiMarker>,
Option<Res<'w, crate::area_poi::AreaPoiRes>>,
ResMut<'w, super::MinimapPing>,
Option<NonSendMut<'w, UiScript>>,
);
/// Every streamed object the tracking classifier considers (our own avatar excluded — the
@@ -309,12 +311,17 @@ pub(super) struct BlipCtx {
/// anchor point (the reference seats the blip tooltip at the cursor; director-verified,
/// exact engine offset pending the anchor-law pin).
pub(super) cursor_ui: Option<Vec2>,
/// THE seam scale (decision 0582): **window px per UI unit**. Everything else in this struct
/// is window px; anything arriving from Lua ([`Minimap:PingLocation`](super::ping)) is in UI
/// units, and this is the one number that crosses them. Mixing the two silently is decision
/// 1596's first root cause.
pub(super) seam: f32,
}
impl BlipCtx {
/// A WoW world point → its screen offset from the widget centre (north-up: up = +X north,
/// left = +Y west — the same mapping the tiles and the corpse blip use).
fn offset(&self, w: [f32; 3]) -> Vec2 {
pub(super) fn offset(&self, w: [f32; 3]) -> Vec2 {
Vec2::new(
(self.wy - w[1]) * self.px_per_yd,
-(w[0] - self.wx) * self.px_per_yd,
+81 -11
View File
@@ -33,6 +33,9 @@ mod blips;
pub(crate) use blips::party_member_pos;
mod composite;
mod interior;
/// The minimap ping (decision 1596) — engine-owned and pinned to a world point.
mod ping;
pub(crate) use ping::MinimapPing;
use std::collections::HashMap;
@@ -83,6 +86,21 @@ const ZOOM_CHUNKS: [f32; 6] = [14.0, 12.0, 10.0, 8.0, 6.0, 4.0];
/// the field through a computed pointer. Superseded in wow-re; do not "restore" a constant here.
const INTERIOR_ZOOM_RADIUS: [f32; 6] = [150.0, 120.0, 90.0, 60.0, 40.0, 25.0];
/// **How far the map reaches, in yards** — the one function that answers it. Indoors that is the
/// interior radius table indexed by the *indoor* zoom; outdoors the chunk-count table's half-extent
/// indexed by the outdoor zoom. Both branches of [`emit_minimap`] and the ping's world→normalized
/// relay go through here, so "the view radius" can only ever mean one thing (the twin of
/// [`blips::party_member_pos`]'s reason for existing: two surfaces disagreeing about the same
/// number is a bug you find months later).
fn view_radius_yd(zoom: u8, inside_zoom: u8, inside: bool) -> f32 {
let clamp = |z: u8| usize::from(z.min(5));
if inside {
INTERIOR_ZOOM_RADIUS[clamp(inside_zoom)]
} else {
ZOOM_CHUNKS[clamp(zoom)] * 0.5 * CHUNK_YARDS
}
}
/// The **outer-edge bleed**: a minimap tile on the boundary of its group's grid is drawn 1.0 yd
/// larger on that side, so a group's art extends 1 yd past its bbox all the way round and two
/// groups whose boxes touch overlap by 2 yd. Interior cell edges are shared exactly. Byte-verified
@@ -197,6 +215,12 @@ fn probe_minimap_widget(mut widget: ResMut<MinimapWidget>, windows: Query<&Windo
#[derive(Resource, Default)]
pub(crate) struct MinimapWidget(pub(crate) Option<MinimapSlot>);
/// The player's WMO-containment verdict as of this frame — [`feed_minimap_inside`]'s answer, kept
/// so anything that has to know "which zoom table is live" can read it instead of re-running the
/// portal flood-fill. The client keeps one flag for both consumers (`0xceaa60`) and so do we.
#[derive(Resource, Default)]
pub(crate) struct MinimapInside(pub(crate) bool);
/// The **persisted** half of the minimap zoom (decision 1131) — the client's two CVar objects
/// `minimapZoom` / `minimapInsideZoom`, whose registered default is `"3"` in both cases (wow-re,
/// VERIFIED at the `RegisterCVar 0x63db90` argument slot). The *live* indices are the widget's
@@ -250,6 +274,11 @@ struct MinimapAssets {
/// (`Rotating-MinimapArrow.mdx`) the reference re-animates per blip source. See
/// [`blips::RimArrow`] for the sequence→layer table and why there are four of them.
rim_arrows: blips::RimArrowArt,
/// The ping marker's art, in draw order (decision 1596). The reference's `MiniMapPing` is a
/// `<Model>` frame — `Interface\MiniMap\Ping\MinimapPing.mdx`, XML `scale="0.4"`, 50×50 —
/// so this is the flat stand-in for it: the model's own `ping2`/`ping5` textures stacked, the
/// same pair the paused first version shipped. Empty = no art, and the ping draws nothing.
ping: Vec<Handle<Image>>,
/// The unit-blip atlas (`Interface\Minimap\ObjectIcons`, five 32-px dot cells) — the
/// quest-giver dots.
object_icons: Option<Handle<Image>>,
@@ -295,6 +324,16 @@ fn setup_minimap(
}
let object_icons =
assets.sprite_texture("Interface\\Minimap\\ObjectIcons", &mut images);
let ping: Vec<_> = [
"Interface\\Minimap\\Ping\\ping2",
"Interface\\Minimap\\Ping\\ping5",
]
.into_iter()
.filter_map(|p| assets.sprite_texture(p, &mut images))
.collect();
if ping.is_empty() {
warn!("minimap: no Ping art — a minimap ping will place but draw nothing");
}
if mask.is_none() {
warn!("minimap: MinimapMask.blp missing — the map will draw square");
}
@@ -315,6 +354,7 @@ fn setup_minimap(
poi,
rim_arrows,
object_icons,
ping,
forms,
});
}
@@ -410,9 +450,16 @@ fn emit_minimap(
locks,
poi_marker,
pois,
mut ping,
script,
) = blip_inputs;
// Hover resets every frame; the blip pass below re-establishes it while the map draws.
*blip_hover = blips::MinimapBlipHover::None;
// Drain this frame's `Minimap:PingLocation` click BEFORE any early return below, so it is
// always spent in the frame it was made. Held across frames it would seat against geometry
// the player never clicked on; and a click made on a frame the map does not draw is simply
// not a ping (decision 1596).
let click = script.and_then(|mut s| s.take_minimap_ping_request());
let (Some(slot), Some(assets), Some(map), Some(catalog)) =
(widget.0.as_ref(), assets, map, catalog)
else {
@@ -433,10 +480,9 @@ fn emit_minimap(
// persisted index, so the override stands in for both.
let zoom_override = std::env::var("WOW_MM_ZOOM")
.ok()
.and_then(|s| s.parse::<usize>().ok())
.map(|z| z.min(5));
let zoom = zoom_override.unwrap_or(usize::from(slot.zoom.min(5)));
let inside_zoom = zoom_override.unwrap_or(usize::from(slot.inside_zoom.min(5)));
.and_then(|s| s.parse::<u8>().ok());
let zoom = zoom_override.unwrap_or(slot.zoom);
let inside_zoom = zoom_override.unwrap_or(slot.inside_zoom);
let center = (slot.rect.min + slot.rect.max) * 0.5;
let wow = bevy_to_wow(player.pos);
@@ -455,6 +501,9 @@ fn emit_minimap(
// The player's containment verdict, kept as a bool for the quest-dot grey (the branch
// below consumes `interior` itself).
let player_indoors = interior.is_some();
// How far the map reaches this frame — decided once, by the one function that decides it, and
// used by whichever family draws below.
let view_radius = view_radius_yd(zoom, inside_zoom, player_indoors);
if let Some((world_from_local, model, stem, in_group)) = interior {
// INTERIOR: the WMO's own per-group tiles, drawn FULL WHITE (the day-night tint is outdoor-
// only). The tiles are baked in the WMO's MODEL frame (north = model +X, sized to the model
@@ -470,7 +519,7 @@ fn emit_minimap(
// `radiusTable[inside_zoom]` in raw yards (150 widest … 25 tightest), not the outdoor chunk
// half-extent. The zoom buttons drive `inside_zoom` while you're inside, and it persists
// separately from the outdoor level (wow-re finding 2 Q7 CORRECTION).
let radius = INTERIOR_ZOOM_RADIUS[inside_zoom];
let radius = view_radius;
let px_per_yd = (side * 0.5) / radius;
blip_px_per_yd = px_per_yd;
@@ -629,7 +678,7 @@ fn emit_minimap(
} else if let Some(dir) = catalog.0.directory(map.0) {
// OUTDOOR: the ADT terrain tiles, MODULATEd by the day-night light tint (not full white —
// else too bright, the reference's CWorldFrame minimap draw). Absent lighting ⇒ white.
let half_extent = ZOOM_CHUNKS[zoom] * 0.5 * CHUNK_YARDS;
let half_extent = view_radius;
let px_per_yd = (side * 0.5) / half_extent;
blip_px_per_yd = px_per_yd;
if cache.map_id != Some(map.0) {
@@ -716,6 +765,7 @@ fn emit_minimap(
}
let win = window.iter().next();
let cursor = win.and_then(|w| w.cursor_position());
let seam = win.map_or(1.0, |w| crate::ui_script::seam_scale(w.height(), ui_scale.0));
// The player's pan term, quantized to a half-logical-pixel grid (one device px at 2×):
// every blip offset — and the rim arrows' bearing — derives from `wx`/`wy`, so the whole
// blip layer steps together a few times a second instead of re-emitting sub-pixel-shifted
@@ -737,10 +787,10 @@ fn emit_minimap(
cursor,
// The same point in UI space (y-up, ÷s through the 0582/0584 seam — the tooltip's
// anchor resolves in the VM's 768-virtual units, not window px): the cursor seat.
cursor_ui: cursor.zip(win).map(|(c, w)| {
let s = crate::ui_script::seam_scale(w.height(), ui_scale.0);
Vec2::new(c.x / s, (w.height() - c.y) / s)
}),
cursor_ui: cursor
.zip(win)
.map(|(c, w)| Vec2::new(c.x / seam, (w.height() - c.y) / seam)),
seam,
}
});
let mut hover = blips::MinimapBlipHover::None;
@@ -866,6 +916,16 @@ fn emit_minimap(
}
}
}
// The ping draws LAST — above the dots and the player arrow. In the reference `MiniMapPing` is
// a Lua Frame child of the Minimap, so it composites over everything the engine drew into the
// widget's hole; ours is engine-drawn but keeps that place in the order.
//
// This is also where a `Minimap:PingLocation` click is drained: the geometry it must resolve
// against is the geometry standing right here, this frame (decision 1596).
if let Some(ctx) = &blip_ctx {
ping::emit_ping(ctx, &mut ping, click, map.0, &assets.ping, &mut quads);
}
}
/// Push the player's WMO-containment state onto the Minimap widget (the client's `0xceaa60`), so the
@@ -893,11 +953,13 @@ fn feed_minimap_inside(
instances: Query<&WmoPortalInstance>,
wmos: Res<Assets<WmoModel>>,
asset_server: Res<AssetServer>,
mut inside_res: ResMut<MinimapInside>,
mut was_inside: Local<crate::ui_script::VmMemo<Option<bool>>>,
mut pushed_at: Local<crate::ui_script::VmMemo<u64>>,
) {
let Some(mut script) = script else { return };
let inside = minimap_interior(&player, &instances, &wmos, &world, &asset_server).is_some();
inside_res.0 = inside;
let Some(mut script) = script else { return };
let edge = *was_inside.get(&script) != Some(inside);
let created = script.minimap_widgets_created();
if edge || *pushed_at.get(&script) != created {
@@ -950,6 +1012,8 @@ impl Plugin for MinimapPlugin {
.init_resource::<MinimapZoom>()
.init_resource::<MinimapTileCache>()
.init_resource::<blips::MinimapBlipHover>()
.init_resource::<MinimapInside>()
.init_resource::<MinimapPing>()
.init_resource::<composite::MinimapComposite>()
.add_systems(Startup, setup_minimap.after(AssetSet::Open))
.add_systems(Startup, composite::setup_composite)
@@ -968,6 +1032,12 @@ impl Plugin for MinimapPlugin {
// Before the script tick, so a zoom button pressed this frame routes to the
// indoor/outdoor index that matches where the player actually is.
feed_minimap_inside.before(crate::ui_script::UiInput),
// Before the script tick, and after the containment verdict it reads: the
// `MINIMAP_PING` event and `Minimap:GetPingPosition()`'s value land in the
// same tick, on a ping the renderer already drew at the end of last frame.
ping::drive_minimap_ping
.after(feed_minimap_inside)
.before(crate::ui_script::UiInput),
// Before the script tick, so GameTimeFrame's OnUpdate reads this frame's
// minute, not last frame's.
feed_game_time.before(crate::ui_script::UiInput),
+479
View File
@@ -0,0 +1,479 @@
//! The minimap **ping** (decision 1596; the feature 0471 paused and this brings back).
//!
//! ## The pin
//!
//! A ping marks a **place in the world**, so a world point `(x, y)` is the only thing this module
//! stores. Where it lands on screen is *derived*, every frame, by [`BlipCtx::offset`] — the exact
//! same function the party dots, the quest dots and the corpse blip go through. It therefore
//! cannot drift from the map, cannot lag the pan by a frame, and cannot survive a zoom change at
//! the old scale: there is no second copy of the position to fall out of step with.
//!
//! That is the whole difference from the first attempt (decision 0453 / 0471), which stored the
//! world point in the engine but drew the marker from **Lua** — a `MiniMapPing` frame re-seated by
//! `Minimap_OnUpdate` through `SetPoint` from a normalized offset the app pushed. Four
//! independent ways to be wrong, and it was wrong in three of them; 1596 §2 has the autopsy.
//!
//! ## The three legs
//!
//! - **In** — a click reaches Lua's `Minimap_OnClick` (ours, and hookable: the corpus's
//! `CleanMinimap` replaces that global outright), which calls `Minimap:PingLocation(dx, dy)`
//! with centre-relative offsets in **UI units**. [`emit_ping`]'s caller drains it in the *same
//! frame it draws the map*, converting through that frame's own geometry: UI units × the 0582
//! seam scale = window px, ÷ `px_per_yd` = yards. (Skipping that seam multiply is what put the
//! first version's ping ~27 % too far from the player at 1080p.)
//! - **Across** — our own ping sends `MSG_MINIMAP_PING` (raw world floats; the server relays them
//! verbatim to the rest of the group and nowhere else). A group member's arrives through the
//! session event and seats the same way. A ping is drawn **locally at click time**, never waited
//! for off the wire: vanilla pings work solo.
//! - **Out** — `MINIMAP_PING (unitToken, nx, ny)` fires for addons, with the same normalized
//! offsets the byte-verified relay `0x4ee330` hands Lua (`(−dy·k, dx·k)`, `k = 1/(2·radius)` —
//! wow-re `party-group-wire.md` §TU-D). `Minimap:GetPingPosition()` reads the live value back.
//!
//! ## Lifetime
//!
//! 5 s hold, then the reference's 0.5 s "fade" — see [`PING_ALPHA_IS_A_POP`]. A map change drops
//! it (the point is not here any more). **Nothing else clears it**, and in particular *not*
//! proximity: the first version applied the client's 10-yd `d² < 100` auto-clear to the party
//! ping, and that clear belongs to the **`SMSG_GOSSIP_POI` marker** — a different feature in a
//! different slot (wow-re `party-group-wire.md` §TU-D corrects it explicitly; `MSG_MINIMAP_PING`
//! has no C-side storage at all). Walking to your own ping used to delete it mid-hold.
use bevy::prelude::*;
use benilla_assets::coords::bevy_to_wow;
use benilla_ui::script::{ScriptValue, UiScript};
use super::blips::BlipCtx;
use crate::net::{ClientCommand, Guid, NetCommands, SelfPlayer};
use crate::player::Player;
use crate::ui_pass::{UiQuad, UiQuads};
/// How long the ping holds at full strength — the reference's `MINIMAPPING_TIMER`.
const PING_HOLD: f32 = 5.0;
/// The tail after the hold — the reference's `MINIMAPPING_FADE_TIMER`.
const PING_FADE: f32 = 0.5;
/// **The reference's ping does not fade; it pops.** `Minimap_OnUpdate` writes
/// `SetAlpha(255 * (t / MINIMAPPING_FADE_TIMER))` — a 0..255 alpha handed to an API that clamps to
/// 0..1, so the marker stays fully opaque until `t` falls below `0.5/255` ≈ 2 ms and then vanishes
/// in one frame. That is what the real client looks like, so it is what we draw. It is a
/// *behaviour* of theirs, not a quirk of ours to hide: flip this to `false` for a real linear fade
/// and nothing else changes.
const PING_ALPHA_IS_A_POP: bool = true;
/// The marker's on-screen size, as a fraction of the widget side on the client's byte-pinned
/// 140.8-px minimap basis — the same basis every other blip constant is frozen against
/// ([`super::blips::BLIP_BASIS_PX`]).
///
/// **INTERIM — an eyeball, not a measurement.** What the reference declares is verified (ref
/// `Minimap.xml` l.396): `MiniMapPing` is a `<Model>` frame on
/// `Interface\MiniMap\Ping\MinimapPing.mdx`, XML `scale="0.4"`, viewport 50×50. What that
/// *renders* at is not: the on-screen size is the model's own quad extent times the frame's
/// px-per-model-unit, the way [`super::blips::ARROW_QUAD_PX`] is 0.0500 · 768. Nobody has measured
/// this model's quad, so 40 px is the paused first version's eyeball carried forward unchanged —
/// deliberately, so the pin can be judged without the art also moving. Out for RE with the model's
/// animation; the rim arrows' precedent says a model like this is a plain textured quad, in which
/// case the flat stand-in becomes geometry-exact at the model's own size.
const PING_PX: f32 = 40.0;
/// The live ping. One at a time — the reference keeps no list either.
struct LivePing {
/// **The pin**: the WoW `(x, y)` this ping marks. The only stored position; the screen seat is
/// re-derived from it every frame.
world: (f32, f32),
/// The map it was placed on. A map change drops the ping rather than re-projecting a point
/// that does not exist here.
map: u32,
/// Seconds since it was seated.
age: f32,
/// The pinger's guid, `0` = ourselves — resolved to the `MINIMAP_PING` event's unit token, and
/// the test for "this is ours, put it on the wire".
sender: u64,
}
/// The engine-owned ping state (decision 1596). Seated by a click (drained in the renderer, with
/// that frame's geometry) or by a group member's `MSG_MINIMAP_PING`; aged, announced and expired
/// by [`drive_minimap_ping`].
#[derive(Resource, Default)]
pub(crate) struct MinimapPing {
live: Option<LivePing>,
/// A ping seated since the last [`drive_minimap_ping`] — it still owes the world an outbound
/// `MSG_MINIMAP_PING` (if it is ours) and a `MINIMAP_PING` event (either way).
fresh: bool,
}
impl MinimapPing {
/// Seat a ping at a world point. Re-pinging replaces: the reference tolerates the same, and a
/// group echo of our own click lands on the spot we already drew (`Minimap_SetPing` twice on
/// one spot just restarts the timer).
pub(crate) fn seat(&mut self, world: (f32, f32), map: u32, sender: u64) {
self.live = Some(LivePing {
world,
map,
age: 0.0,
sender,
});
self.fresh = true;
}
/// The ping's alpha at its current age, or `None` once it is over.
fn alpha(&self) -> Option<f32> {
let age = self.live.as_ref()?.age;
if age <= PING_HOLD {
return Some(1.0);
}
let tail = (PING_HOLD + PING_FADE - age) / PING_FADE;
if tail <= 0.0 {
None
} else if PING_ALPHA_IS_A_POP {
// The reference's `255 * tail`, clamped by SetAlpha's own 0..1 — full until the last
// ~2 ms. Written as the clamp rather than as a constant 1.0 so the mechanism is
// visible and the `false` branch is a real alternative, not a rewrite.
Some((255.0 * tail).min(1.0))
} else {
Some(tail)
}
}
}
/// Convert a `Minimap:PingLocation(x, y)` click into the world point it names, drain-side.
///
/// `ui` is centre-relative in **UI units** (x right, y up — `GetCursorPosition()`'s space);
/// `seam` is window px per UI unit ([`crate::ui_script::seam_scale`]), and `ctx` is the geometry
/// of the map **as drawn this frame**. The mapping is [`BlipCtx::offset`]'s inverse: screen right
/// = −WoW y (west), screen up = +WoW x (north).
///
/// `None` when the click is outside the disc — the reference's `Minimap_OnClick` makes the same
/// test in Lua (`sqrt(x² + y²) < width/2`), stated here in yards because that is the space the
/// answer lives in.
fn click_to_world(ctx: &BlipCtx, ui: (f32, f32), seam: f32) -> Option<(f32, f32)> {
if ctx.px_per_yd <= 0.0 || seam <= 0.0 {
return None;
}
let right_yd = ui.0 * seam / ctx.px_per_yd;
let up_yd = ui.1 * seam / ctx.px_per_yd;
if right_yd.hypot(up_yd) >= ctx.radius_yd {
return None;
}
Some((ctx.wx + up_yd, ctx.wy - right_yd))
}
/// Seat this frame's `Minimap:PingLocation` click and draw the live ping — both inside the
/// renderer, against the geometry the player actually clicked on and the map actually drew at.
///
/// The seat happens here rather than in a system of its own precisely so there is no window in
/// which a click is held against a *stale* view scale: the first version parked the click for a
/// separate system that read the scale the renderer had left behind on the previous frame, and
/// dropped the click outright whenever that leftover was still zero. (The *drain* is the caller's,
/// one step earlier, so the click is spent even on a frame that draws no map — see there.)
pub(super) fn emit_ping(
ctx: &BlipCtx,
ping: &mut MinimapPing,
click: Option<(f32, f32)>,
map: u32,
art: &[Handle<Image>],
quads: &mut UiQuads,
) {
if let Some(world) = click.and_then(|c| click_to_world(ctx, c, ctx.seam)) {
ping.seat(world, map, 0);
}
let Some(alpha) = ping.alpha() else { return };
let Some(live) = ping.live.as_ref() else {
return;
};
let (px, py) = live.world;
// The reference's Lua hides the marker outside the disc and keeps the ping alive
// (`Minimap_SetPing`'s else-branch is `MiniMapPing:Hide()`, not a clear) — so walking back
// into range brings it back for the rest of its 5 s.
let d = (px - ctx.wx).hypot(py - ctx.wy);
if d >= ctx.radius_yd {
return;
}
let rect = Rect::from_center_size(
ctx.center + ctx.offset([px, py, 0.0]),
Vec2::splat(ctx.side * (PING_PX / super::blips::BLIP_BASIS_PX)),
);
for layer in art {
quads.overlays.push(UiQuad {
rect,
z_key: ctx.z,
texture: Some(layer.clone()),
color: [1.0, 1.0, 1.0, alpha * ctx.alpha],
..default()
});
}
}
/// Age the ping, announce a fresh one, and expire it — everything that is *not* geometry.
///
/// Runs before the script tick so the `MINIMAP_PING` event and the position behind
/// `Minimap:GetPingPosition()` land in the same tick, and so an addon's handler sees a ping that
/// is already on screen (the renderer seated and drew it at the end of the previous frame).
#[allow(clippy::too_many_arguments)] // one Bevy system's full input set
pub(super) fn drive_minimap_ping(
script: Option<bevy::ecs::system::NonSendMut<UiScript>>,
mut ping: ResMut<MinimapPing>,
time: Res<Time>,
player: Res<Player>,
map: Option<Res<benilla_world::world_map::CurrentMap>>,
widget: Res<super::MinimapWidget>,
inside: Res<super::MinimapInside>,
group: Res<crate::ui_party::GroupState>,
self_q: Query<&Guid, With<SelfPlayer>>,
commands: Res<NetCommands>,
) {
// Ageing and expiry run before the VM guard: a ping seated with no UI up (the wire can land
// one across a world-enter) must still run out, rather than waiting to start its five seconds
// whenever a VM next appears.
if let Some(live) = ping.live.as_mut() {
live.age += time.delta_secs();
}
// A map change drops it: the point it marks is not on this map.
if let (Some(live), Some(map)) = (ping.live.as_ref(), map.as_ref()) {
if live.map != map.0 {
ping.live = None;
}
}
if ping.alpha().is_none() {
ping.live = None;
}
if ping.live.is_none() {
ping.fresh = false;
}
let Some(mut script) = script else { return };
let Some(live) = ping.live.as_ref() else {
script.set_minimap_ping(None);
return;
};
// The normalized offsets, recomputed from the pin every tick against the live view radius —
// the byte-verified relay's own `(−dy·k, dx·k)`, `k = 1/(2·radius)`. With the map hidden there
// is no live index to read (the extract publishes no slot), so the event's numbers fall back
// to the registered default zoom: an addon still hears the ping, at the scale the map would
// have if it were up.
let wow = bevy_to_wow(player.pos);
let radius = super::view_radius_yd(
widget
.0
.as_ref()
.map_or(super::MINIMAP_DEFAULT_ZOOM, |s| s.zoom),
widget
.0
.as_ref()
.map_or(super::MINIMAP_DEFAULT_ZOOM, |s| s.inside_zoom),
inside.0,
);
let k = 1.0 / (2.0 * radius);
let norm = ((wow[1] - live.world.1) * k, (live.world.0 - wow[0]) * k);
script.set_minimap_ping(Some(norm));
if !std::mem::take(&mut ping.fresh) {
return;
}
let Some(live) = ping.live.as_ref() else {
return;
};
// Ours goes on the wire — raw world floats; the server relays them to the group and does
// nothing at all when we are solo (which is why the marker was drawn locally, not awaited).
if live.sender == 0 {
let _ = commands.0.send(ClientCommand::MinimapPing {
x: live.world.0,
y: live.world.1,
});
}
// The event's unit token: ourselves, or the sender's party slot. A sender we cannot resolve
// (they left the group mid-flight) still pings — the reference's own Lua ignores arg1.
let self_guid = self_q.iter().next().map(|g| g.0);
let token = if live.sender == 0 || Some(live.sender) == self_guid {
"player".to_string()
} else {
group
.party_slots()
.position(|m| m.guid == live.sender)
.map_or_else(|| "party1".to_string(), |i| format!("party{}", i + 1))
};
script.fire_event(
"MINIMAP_PING",
vec![
ScriptValue::Str(token),
ScriptValue::Number(f64::from(norm.0)),
ScriptValue::Number(f64::from(norm.1)),
],
);
}
#[cfg(test)]
mod tests {
use super::*;
/// A `BlipCtx` for a 140-px-side map at a 100-yd view radius, player at the origin.
fn ctx() -> BlipCtx {
let side = 140.0;
let radius = 100.0;
BlipCtx {
center: Vec2::new(500.0, 200.0),
side,
px_per_yd: (side * 0.5) / radius,
radius_yd: radius,
z: 0,
alpha: 1.0,
wx: 0.0,
wy: 0.0,
wz: 0.0,
cursor: None,
cursor_ui: None,
seam: 1.0,
}
}
/// **The first version's ping landed in the wrong place** (decision 1596 §2.1): the click
/// arrives in UI units and the map's `px_per_yd` is in *window* px, and it divided one by the
/// other. At the shipped default (0.9 uiScale on a 1080p window) the seam is ≈1.27, so every
/// ping seated ≈27 % further from the player than the player clicked — worse the further out
/// you clicked, which is exactly what "it pings somewhere else" looks like.
#[test]
fn a_click_converts_through_the_seam_scale() {
let c = ctx();
let seam = 1080.0 / 768.0 * 0.9; // the shipped default at 1080p
// 20 UI units right of centre → 20·seam window px → ÷ px_per_yd yards WEST (−y).
let (x, y) = click_to_world(&c, (20.0, 0.0), seam).expect("inside the disc");
let expect_yd = 20.0 * seam / c.px_per_yd;
assert!((x - 0.0).abs() < 1e-3, "no northing from a due-east click");
assert!(
(y + expect_yd).abs() < 1e-3,
"screen right is WoW −y (west): {y} vs {}",
-expect_yd
);
// The bug: dropping the seam multiply shortens every click by the same factor.
let naive = 20.0 / c.px_per_yd;
assert!(
(expect_yd - naive).abs() > 5.0,
"the seam is load-bearing, not a rounding difference"
);
}
/// Screen up is WoW +x (north) — [`BlipCtx::offset`]'s inverse, so a ping seated from a click
/// draws back under the cursor.
#[test]
fn a_click_round_trips_through_the_blip_mapping() {
let c = ctx();
let ui = (18.0, -25.0);
let world = click_to_world(&c, ui, 1.0).expect("inside the disc");
let back = c.offset([world.0, world.1, 0.0]);
// `offset` is y-DOWN screen space; the click was y-up.
assert!((back.x - ui.0).abs() < 1e-3, "{back:?} vs {ui:?}");
assert!((back.y + ui.1).abs() < 1e-3, "{back:?} vs {ui:?}");
}
/// The reference's own disc test, in yards: a click outside the map's radius is not a ping.
#[test]
fn a_click_outside_the_disc_is_no_ping() {
let c = ctx();
// The disc is 70 px of the 140-px side; 69 px in is a ping, 71 px out is not.
assert!(click_to_world(&c, (69.0, 0.0), 1.0).is_some());
assert!(click_to_world(&c, (71.0, 0.0), 1.0).is_none());
assert!(
click_to_world(&c, (50.0, 50.0), 1.0).is_none(),
"the corner"
);
}
/// **The pin.** The stored form is a world point, so walking moves the marker across the map
/// by exactly the player's displacement — no re-seating, no second copy to drift.
#[test]
fn the_marker_tracks_the_world_as_the_player_walks() {
let mut c = ctx();
let mut ping = MinimapPing::default();
ping.seat((30.0, 0.0), 0, 0); // 30 yd north of the player
let live = ping.live.as_ref().unwrap();
let before = c.offset([live.world.0, live.world.1, 0.0]);
assert!(before.y < 0.0, "north draws UP the screen: {before:?}");
// Walk 10 yd north. The ping is now 20 yd away, so it draws 10 yd closer to the centre.
c.wx += 10.0;
let after = c.offset([live.world.0, live.world.1, 0.0]);
assert!(
(after.y - (before.y + 10.0 * c.px_per_yd)).abs() < 1e-3,
"{before:?} → {after:?}"
);
}
/// **No proximity clear** (decision 1596 §2.2). The first version applied the client's 10-yd
/// `d² < 100` auto-clear to the party ping; wow-re `party-group-wire.md` §TU-D shows that
/// clear belongs to the `SMSG_GOSSIP_POI` marker, and that `MSG_MINIMAP_PING` has no C-side
/// storage to clear at all. Standing on your own ping must not delete it.
#[test]
fn reaching_the_ping_does_not_clear_it() {
let mut ping = MinimapPing::default();
ping.seat((1.0, 1.0), 0, 0);
assert!(ping.alpha().is_some());
// Age it well inside the hold, standing right on top of the point.
ping.live.as_mut().unwrap().age = 2.0;
assert_eq!(ping.alpha(), Some(1.0), "a reached ping still holds");
}
/// The hold, then the reference's clamped tail, then gone.
#[test]
fn the_ping_holds_five_seconds_and_pops() {
let mut ping = MinimapPing::default();
ping.seat((0.0, 0.0), 0, 0);
for (age, want) in [(0.0, Some(1.0)), (4.9, Some(1.0)), (5.4, Some(1.0))] {
ping.live.as_mut().unwrap().age = age;
assert_eq!(ping.alpha(), want, "at {age}s");
}
// The last ~2 ms is the only part of the "fade" that is below full.
ping.live.as_mut().unwrap().age = PING_HOLD + PING_FADE - 0.0005;
let a = ping.alpha().expect("still alive");
assert!(a > 0.0 && a < 1.0, "the pop's one dim frame: {a}");
ping.live.as_mut().unwrap().age = PING_HOLD + PING_FADE;
assert_eq!(ping.alpha(), None, "over");
}
/// **It draws, and where.** The pin's whole claim is that the marker's rect comes out of
/// [`BlipCtx::offset`] like every other blip's — so this drives the real emitter and checks
/// the rect, one quad per art layer, rather than trusting the caller.
#[test]
fn the_emitter_puts_a_quad_per_layer_at_the_pinned_point() {
let c = ctx();
let mut ping = MinimapPing::default();
let mut quads = UiQuads::default();
let art = vec![Handle::<Image>::default(), Handle::<Image>::default()];
// No ping: nothing drawn.
emit_ping(&c, &mut ping, None, 0, &art, &mut quads);
assert!(quads.overlays.is_empty());
// A click 30 UI units up (north) at seam 1 seats a ping 30/px_per_yd yards north...
emit_ping(&c, &mut ping, Some((0.0, 30.0)), 0, &art, &mut quads);
assert_eq!(quads.overlays.len(), 2, "one quad per layer");
let want = c.center + c.offset([30.0 / c.px_per_yd, 0.0, 0.0]);
for q in &quads.overlays {
let mid = (q.rect.min + q.rect.max) * 0.5;
assert!((mid - want).length() < 1e-3, "{mid:?} vs {want:?}");
let side = c.side * (PING_PX / super::super::blips::BLIP_BASIS_PX);
assert!((q.rect.width() - side).abs() < 1e-3);
}
// ...and once it is out of range it stops drawing WITHOUT dying: walk 200 yd away, then
// back. (The reference's Lua hides the marker off-disc; it does not clear the ping.)
quads.overlays.clear();
let mut far = ctx();
far.wx = -200.0;
emit_ping(&far, &mut ping, None, 0, &art, &mut quads);
assert!(quads.overlays.is_empty(), "off the disc: hidden");
emit_ping(&c, &mut ping, None, 0, &art, &mut quads);
assert_eq!(quads.overlays.len(), 2, "back in range: visible again");
}
/// A degenerate frame (the widget has not drawn yet) drops the click rather than seating a
/// ping at a garbage point — and, unlike the first version, that is the *only* case in which
/// a click is dropped for want of a scale.
#[test]
fn a_click_before_the_map_has_drawn_is_dropped() {
let mut c = ctx();
c.px_per_yd = 0.0;
assert!(click_to_world(&c, (10.0, 10.0), 1.0).is_none());
}
}
+4
View File
@@ -1463,6 +1463,10 @@ pub(crate) enum ClientCommand {
/// submenu, decision 0434 §5): wire `icon` 0..7, `guid` 0 clears that icon's slot. Leader/
/// assistant only server-side; echoes back as the delta form.
SetRaidTarget { icon: u8, guid: u64 },
/// `MSG_MINIMAP_PING` (a minimap click, decision 1596): raw world `(x, y)`. The server relays
/// them verbatim to the rest of the group and does nothing at all when we are solo — which is
/// why the marker is drawn locally at click time rather than awaited off the wire.
MinimapPing { x: f32, y: f32 },
// ── The raid-management family (decision 1549's Raid tab) ─────────────────────────────────
/// Move a raid member to another subgroup (`CMSG_GROUP_CHANGE_SUB_GROUP`): the member's NAME
/// and the **0-based** subgroup the wire wants. Leader/assistant only; echoes as a fresh
+18 -3
View File
@@ -229,6 +229,9 @@ pub(super) fn apply_net_updates(
// 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>,
// The minimap ping (decision 1596) — a group member's `MSG_MINIMAP_PING` seats the
// world point here and the minimap renderer derives everything else from it.
ResMut<crate::minimap::MinimapPing>,
),
),
),
@@ -249,6 +252,10 @@ pub(super) fn apply_net_updates(
ResMut<crate::ui_action::CastErrors>,
ResMut<crate::ui_action::MountErrors>,
Option<Res<crate::target::ring::Factions>>,
// The `modalNextSpell` chain's outbox (1597) — `cast_result` fills it, the ui_action
// drain sends it through the one cast path. Rides here for the same reason the
// catalog does: this is where the ceiling left room.
ResMut<crate::ui_action::ChainCasts>,
),
ResMut<crate::ui_items::EquipErrors>,
ResMut<crate::ui_merchant::MerchantErrors>,
@@ -399,6 +406,7 @@ pub(super) fn apply_net_updates(
mut inspect_honor,
mut auction_open,
guild_notify,
mut ping,
),
),
) = caches;
@@ -857,6 +865,7 @@ pub(super) fn apply_net_updates(
&mut ui_actions.12,
ui_actions.11.as_deref(),
&net_commands,
&mut ui_actions.1 .3,
play_seq.next(),
),
SessionEvent::InventoryFailure {
@@ -959,9 +968,15 @@ pub(super) fn apply_net_updates(
// storing it would be state with no reader.
SessionEvent::ReadyCheckRequest => group.apply_ready_check(),
SessionEvent::RaidInstanceInfo { entries } => group.apply_raid_instance_info(entries),
// Ping is still removed (decision 0460); the protocol decodes the wire, the client
// ignores it until that feature returns.
SessionEvent::MinimapPing { .. } | SessionEvent::ReadyCheckAnswer { .. } => {}
// A group member pinged (decision 1596). The wire carries raw world floats and the
// relay is stateless in the reference too — we seat them as the pin and the minimap
// derives the rest. `map` is the map we are standing on: the server only relays a ping
// between people who are grouped, and a ping from another map would be dropped by the
// renderer's own map test anyway.
SessionEvent::MinimapPing { guid, x, y } => {
ping.seat((x, y), current_map.as_ref().map_or(0, |m| m.0), guid);
}
SessionEvent::ReadyCheckAnswer { .. } => {}
// ── The duel family (decision 0633): the session mirror + the two DisplayError
// lines the handlers emit inline; the Era events fire off the mirror's edges in
// `ui_duel::feed_duel`, and the countdown ticks in its own system ──
+212
View File
@@ -110,9 +110,16 @@ pub(super) fn cast_result(
auto_repeat: &mut AutoRepeatActive,
spells: Option<&Spells>,
net: &crate::net::NetCommands,
// The `modalNextSpell` chain's outbox (`0x6e74aa`) — filled here, sent by the one cast path.
chain: &mut crate::ui_action::ChainCasts,
seq: u64,
) {
debug!("net: cast result — spell {spell_id} success={success} reason={reason:?}");
// **Is this the reply to the cast we have outstanding?** (`0x6e7408 cmp ecx,[0xceca88]`.) Read
// BEFORE either arm touches the guard, because both the failure arm's clear below and the
// chain's own clear are the reference's ONE `0x6e741a call 0x6e4940(0x1c)` — the in-flight slot
// is finished on either outcome, and only then is column 38 read.
let in_flight = pending.current(Instant::now()) == Some(spell_id);
if !success {
// The cast-fail cooldown edges (the client's `HandleCastFailed 0x6e1a00`, wow-re
// `wave-cooldown.md` + the 2026-07-10 §5): a plain interactive-cast failure clears ONLY
@@ -198,6 +205,40 @@ pub(super) fn cast_result(
});
}
}
// **The `modalNextSpell` chain — how a hunter starts shooting** (`HandleCastResult 0x6e7330`
// @ `0x6e7408`–`0x6e74aa`; wow-re `spell/scratch/modalnext-chain-cast.md`, decision 1597).
//
// The reply to our in-flight cast finishes that cast's slot and then reads **column 38** of
// the spell it names. Non-zero, and not already the running repeat ⇒ the client casts it,
// itself, at the null target guid. Every hunter shot's column 38 is **75, Auto Shot** — so
// casting Serpent Sting starts Auto Shot one round-trip later, with no input and no addon.
//
// Three details, each load-bearing:
// - **It fires on SUCCESS as well as failure.** `0x6e7356 cmp [ebp+0xf],0x2` / `0x6e735a jne`
// sends a non-failure result *straight* to the block the failure path falls into at
// `0x6e73eb`. That is the whole reason it was missed: the success arm of this handler used
// to do nothing at all here, and a successful sting is the ordinary case.
// - **The slot is cleared first.** The reference's `0x6e741a` finishes the in-flight cast
// before chaining, and it must: `TryCast`'s own IsCasting rung (`0x6e4d97`, our reason
// `0x61`) would refuse the chained cast otherwise. Ours is [`PendingCast`], cleared here on
// both outcomes for the matching spell — where before, success left it to `SPELL_GO`.
// vmangos sends `SMSG_CAST_RESULT` before `SMSG_SPELL_GO` (`Spell::cast`: `SendCastResult`
// at 3669, `SendSpellGo` at 3703), so the guard is still armed when we get here.
// - **Equal means re-arm, not re-cast** (`0x6e745b`'s equal branch → `0x6e745d`): a second
// sting while Auto Shot is already running must NOT re-cast it, or every special shot would
// restart the repeat and reset its swing timer. We have no pending-record to refresh, so the
// equal branch is simply "send nothing" — the same observable.
if in_flight {
pending.clear_if(spell_id);
if let Some(next) = spells
.and_then(|s| s.catalog.get(spell_id))
.map(|d| d.modal_next_spell)
.filter(|&next| next != 0 && Some(next) != auto_repeat.0)
{
debug!("net: cast result {spell_id} chains modalNextSpell {next}");
chain.0.push(next);
}
}
}
/// A unit began a non-triggered cast (`SMSG_SPELL_START`), instants included (`cast_time_ms == 0`)
@@ -1481,6 +1522,7 @@ mod tests {
&mut auto_repeat,
None,
&net,
&mut crate::ui_action::ChainCasts::default(),
1,
);
},
@@ -1720,4 +1762,174 @@ mod tests {
"somebody else's Backstab is not our attack-start"
);
}
/// **The `modalNextSpell` chain** (`HandleCastResult 0x6e7330` @ `0x6e7408`–`0x6e74aa`,
/// decision 1597) — how a hunter starts shooting, and the fix for bug B280.
///
/// The reply to our in-flight cast reads **`Spell.dbc` column 38** of the spell it names and,
/// if that is non-zero and is not already the running repeat, the client casts it itself. Every
/// hunter shot's column 38 is 75 (Auto Shot). Five things, each a way this could be wrong:
/// - a successful sting chains — **the success arm is the ordinary case**, and it is the one
/// this handler used to ignore entirely (`0x6e735a jne` sends a non-failure straight to the
/// chain block);
/// - a *failed* sting chains too (both paths converge at `0x6e73eb`);
/// - the in-flight guard is cleared before the chain, or `TryCast`'s IsCasting rung (our `0x61`)
/// would refuse the chained cast;
/// - Auto Shot already running ⇒ **nothing** is sent (`0x6e745b`'s equal branch), so a second
/// shot never restarts the repeat or resets its swing timer;
/// - a reply for a spell we do not have in flight is not ours to chain from (`0x6e7408`).
#[test]
fn a_hunter_shots_cast_result_chains_auto_shot_exactly_once() {
use crate::net::{Guid, SelfPlayer};
use bevy::ecs::system::RunSystemOnce;
const SERPENT_STING: u32 = 1978;
const AUTO_SHOT: u32 = 75;
let spells = || crate::ui_action::Spells {
catalog: benilla_formats::SpellCatalog::from_displays(
[
(
SERPENT_STING,
benilla_formats::SpellDisplay {
name: "Serpent Sting".into(),
// The shipped 5875 row: ranged slot, and column 38 = 75.
attributes: 0x0001_0002,
attributes_ex2: 0x0002_0000,
modal_next_spell: AUTO_SHOT,
..Default::default()
},
),
(
AUTO_SHOT,
benilla_formats::SpellDisplay {
name: "Auto Shot".into(),
attributes: 0x0005_0012,
attributes_ex2: 0x20,
// Auto Shot's own column 38 is 0 — this is what makes the chain
// exactly one hop instead of a loop.
modal_next_spell: 0,
..Default::default()
},
),
]
.into_iter()
.collect(),
),
forms: Default::default(),
ranges: Default::default(),
cast_times: Default::default(),
durations: Default::default(),
radii: Default::default(),
};
// One CAST_RESULT for `spell_id`, with `in_flight` armed as the outstanding cast and
// `running` as the live auto-repeat. Returns (what got queued, is the guard still armed).
let fire = |spell_id: u32, success: bool, in_flight: Option<u32>, running: Option<u32>| {
let mut app = App::new();
app.add_message::<CastEvent>()
.init_resource::<GuidIndex>()
.init_resource::<SelfGuid>()
.init_resource::<CastErrors>()
.init_resource::<CastBarFeed>()
.init_resource::<PendingCast>()
.init_resource::<QueuedMeleeSpell>()
.init_resource::<Cooldowns>()
.init_resource::<AutoRepeatActive>()
.init_resource::<crate::ui_action::ChainCasts>();
let self_e = app.world_mut().spawn((Guid(10), SelfPlayer)).id();
app.world_mut()
.resource_mut::<GuidIndex>()
.0
.insert(10, self_e);
app.world_mut().resource_mut::<SelfGuid>().0 = Some(10);
app.world_mut().resource_mut::<AutoRepeatActive>().0 = running;
if let Some(id) = in_flight {
app.world_mut()
.resource_mut::<PendingCast>()
.arm(id, Instant::now());
}
let (tx, _rx) = crossbeam_channel::unbounded();
let cat = spells();
app.world_mut()
.run_system_once(
move |mut commands: Commands,
self_guid: Res<SelfGuid>,
index: Res<GuidIndex>,
mut cast_errors: ResMut<CastErrors>,
casting: Query<&Casting>,
mut cast_events: MessageWriter<CastEvent>,
mut cast_bar: ResMut<CastBarFeed>,
mut pending: ResMut<PendingCast>,
mut queued_melee: ResMut<QueuedMeleeSpell>,
mut cooldowns: ResMut<Cooldowns>,
mut auto_repeat: ResMut<AutoRepeatActive>,
mut chain: ResMut<crate::ui_action::ChainCasts>| {
let net = crate::net::NetCommands(tx.clone());
cast_result(
spell_id,
success,
if success { None } else { Some(0x1b) },
None,
&mut commands,
&self_guid,
&index,
&mut cast_errors,
&casting,
&mut cast_events,
&mut cast_bar,
&mut pending,
&mut queued_melee,
&mut cooldowns,
&mut auto_repeat,
Some(&cat),
&net,
&mut chain,
1,
);
},
)
.unwrap();
let queued = app
.world()
.resource::<crate::ui_action::ChainCasts>()
.0
.clone();
let still_armed = app
.world()
.resource::<PendingCast>()
.in_flight(Instant::now());
(queued, still_armed)
};
// The ordinary case: the sting lands, and Auto Shot follows by itself.
assert_eq!(
fire(SERPENT_STING, true, Some(SERPENT_STING), None),
(vec![AUTO_SHOT], false),
"a successful sting chains Auto Shot, and clears the in-flight guard first"
);
// And a failed one does too — both results converge on the same block.
assert_eq!(
fire(SERPENT_STING, false, Some(SERPENT_STING), None).0,
vec![AUTO_SHOT],
"a FAILED sting chains it as well (`0x6e735a jne` → the same `0x6e73eb`)"
);
// Already shooting: re-arm, never re-cast.
assert_eq!(
fire(SERPENT_STING, true, Some(SERPENT_STING), Some(AUTO_SHOT)).0,
Vec::<u32>::new(),
"Auto Shot already running: the equal branch sends nothing"
);
// Auto Shot's own replies terminate the chain.
assert_eq!(
fire(AUTO_SHOT, true, Some(AUTO_SHOT), Some(AUTO_SHOT)).0,
Vec::<u32>::new(),
"Auto Shot's own column 38 is 0 — no second hop"
);
// Not our in-flight cast (a proc's result, a stale reply): not ours to chain from.
assert_eq!(
fire(SERPENT_STING, true, Some(133), None).0,
Vec::<u32>::new(),
"a reply for a spell we do not have in flight chains nothing"
);
}
}
+1
View File
@@ -973,6 +973,7 @@ fn writer_loop(
threshold,
} => w.loot_method(method, master, threshold),
ClientCommand::SetRaidTarget { icon, guid } => w.raid_target_set(icon, guid),
ClientCommand::MinimapPing { x, y } => w.minimap_ping(x, y),
ClientCommand::GroupChangeSubGroup { name, group } => {
w.group_change_sub_group(&name, group)
}
+139 -12
View File
@@ -17,6 +17,7 @@ use crate::creature_anim::AnimDriver;
use crate::net::{Guid, NetEntity, ObjectStore, SelfPlayer};
use crate::player::CameraControl;
use crate::ui_script::PointerOverUi;
use benilla_world::billboard::BillboardCard;
use benilla_world::collision::PickOccluder;
use benilla_world::interact::{
ray_mesh_bounds, ray_posed_mesh, CreaturePickPart, GoPickPart, PickParts,
@@ -363,6 +364,55 @@ pub(super) struct GoPickSet<'w, 's> {
/// Last frame's GO pick, for pass 2's sticky-hover (the reference's anti-flicker cache, by
/// net entity — the same rung the unit picker keeps).
last_pick: Local<'s, Option<Entity>>,
/// Every pickable billboard card, for [`net_entity_of`]'s first hop. Bundled here rather than
/// taken as its own `SystemParam` because [`update_hovered_object`] is at Bevy's 16-param
/// function-system ceiling.
cards: Query<'w, 's, &'static BillboardCard>,
}
/// The number of `ChildOf` hops [`net_entity_of`] will climb before giving up — a malformed-data
/// guard, not a real depth. The deepest real chain is a card on a nested joint (bone hierarchies
/// in the shipped corpus are single digits deep), so this is orders of margin.
const NET_WALK_HOPS: usize = 64;
/// A picked GameObject part → the **net entity** that carries its `Guid` (the object the mouseover
/// publishes, the cursor classifies and the right-click USEs).
///
/// Two shapes reach here and they attach differently. An ordinary mesh part is a direct child of
/// the net entity — one `ChildOf` hop. A **billboard card is a world ROOT** (decision 0153: a card
/// writes an absolute world transform and lives at the root/identity), so it has no `ChildOf` edge
/// at all: its link to the model is [`BillboardCard::follows`] — the joint it rides on a rigged
/// host, the mirror anchor under the net entity otherwise — and the climb continues from there.
///
/// Before the card hop existed a card hit resolved to the **card**, which carries no `Guid`, so
/// [`update_hovered_object`] bailed and published the **null** mouseover: no cursor, no tooltip and
/// a dead right-click over exactly the screen area the card covers. The Lightwell found it (B169's
/// second half) — its light shaft is a lock-Z card standing 4.5 yd out of a 1.09 yd bowl, so the
/// card swallowed nearly every ray and only the sliver of bowl around it ever answered.
///
/// The reference has no such split to reconcile: the resolve `0x7089c0`'s narrow phase walks the
/// ONE model's render batches — *"the actual visible RENDER MESH, posed to the current animation"*,
/// billboard batch included, skinned through the live bone matrices (wow-re `object-layer.md`) — so
/// a hit on the shaft **is** a hit on the GameObject, which is what this restores.
fn net_entity_of(
e: Entity,
cards: &Query<&BillboardCard>,
guids: &Query<&Guid>,
child_of: &Query<&ChildOf>,
) -> Entity {
let mut cur = cards
.get(e)
.ok()
.and_then(BillboardCard::follows)
.unwrap_or(e);
for _ in 0..NET_WALK_HOPS {
if guids.contains(cur) {
return cur;
}
let Ok(parent) = child_of.get(cur) else { break };
cur = parent.parent();
}
cur
}
#[allow(clippy::too_many_arguments)]
@@ -397,6 +447,9 @@ pub(super) fn update_hovered_object(
let removed_parts = &mut cache.removed;
let pickable = &mut *cache.cache;
let last_pick = &mut *cache.last_pick;
let cards = &cache.cards;
// The hit part → its guid-bearing net entity ([`net_entity_of`]: the card hop, then the climb).
let resolve_net = |e: Entity| net_entity_of(e, cards, &guids, &child_of);
// The pick set (bevy's `HashSet` — the caster's type, not the `std` one this module uses
// elsewhere), rebuilt on GO-part stream edges ONLY — the `WaterIndex` shape. It used to be
// collected from every streamed GO part on every cursor frame (ChildOf hop + store probe +
@@ -416,9 +469,11 @@ pub(super) fn update_hovered_object(
if removed_parts.read().next().is_some() || !added_parts.is_empty() {
pickable.clear();
pickable.extend(go_parts.iter().filter(|&e| {
let root = child_of.get(e).map_or(e, |c| c.parent());
// Through the same resolver the hit uses, so a card and its mesh siblings can never
// disagree about which GameObject they belong to (a transport's card would otherwise
// stay in the set while its meshes were filtered out).
!stores
.get(root)
.get(resolve_net(e))
.is_ok_and(|s| matches!(s.0.gameobject_type_id(), 11 | 14 | 15))
}));
}
@@ -442,16 +497,6 @@ pub(super) fn update_hovered_object(
let Ok(ray) = camera.viewport_to_world(cam_tf, cursor) else {
return;
};
// The hit is a mesh part; its GameObject net entity (which carries the `Guid`) is one `ChildOf`
// hop up — the attach convention (the same one `update_hover`'s fallback walks). Guard the rare
// case the pickable itself is the guid-bearing entity.
let resolve_net = |e: Entity| {
if guids.contains(e) {
e
} else {
child_of.get(e).map_or(e, |c| c.parent())
}
};
let self_store = self_q.single().ok();
// Pass 1 — the exact resident geometry, pure nearest-wins (priority-independent).
@@ -560,3 +605,85 @@ pub(super) fn update_hovered_object(
hovered.guid = Some(guid.0);
hovered.distance = distance;
}
#[cfg(test)]
mod tests {
use super::*;
use bevy::ecs::system::RunSystemOnce;
fn card(bone: u16, owner: Entity) -> BillboardCard {
let info = benilla_assets::BillboardInfo {
pivot: Vec3::ZERO,
bone,
kind: benilla_formats::BillboardKind::LockZ,
scale_anim: None,
seq_translations: vec![],
};
BillboardCard::following(&info, owner)
}
/// **A billboard card's hit belongs to its GameObject** — bug B169's second half, the Lightwell
/// you could see but not click (2026-08-25).
///
/// A card is a world ROOT, so the old one-`ChildOf`-hop resolve answered with the card itself;
/// the card carries no `Guid`, so `update_hovered_object` bailed and published the null
/// mouseover — no cursor, no tooltip, a dead right-click — over the whole screen area the card
/// covers. On `G_HolyLightWell.m2` that is a 4.5 yd shaft standing out of a 1.09 yd bowl, so
/// nearly every ray landed on the dead card and only the ring of bowl around it answered:
/// *"very hard to find the right position, and even when I see the cog nothing happens"*.
///
/// Both card shapes are covered, because `spawn_billboard_part` picks between them on whether
/// the host is rigged: following the **mirror anchor** (a direct child of the net entity) and
/// following a **joint** (nested under the model root, so the climb is more than one hop).
#[test]
fn a_billboard_cards_hit_resolves_to_its_gameobject() {
let mut world = World::new();
let net = world.spawn(Guid(0xdead_beef)).id();
// An ordinary mesh part: the direct child the old one-hop resolve was written for.
let mesh_part = world.spawn(ChildOf(net)).id();
// The rigless shape: the card follows the mirror anchor under the net entity.
let anchor = world.spawn(ChildOf(net)).id();
let anchor_card = world.spawn(card(0, anchor)).id();
// The rigged shape: net → model root → root-bone joint → child joint, card on the deepest.
let root = world.spawn(ChildOf(net)).id();
let joint0 = world.spawn(ChildOf(root)).id();
let joint1 = world.spawn(ChildOf(joint0)).id();
let joint_card = world.spawn(card(1, joint1)).id();
// A card whose owner is gone (despawned mid-frame) must not climb into someone else's tree.
let orphan_card = world
.spawn(card(2, Entity::from_raw_u32(9999).unwrap()))
.id();
let resolved = world
.run_system_once(
move |cards: Query<&BillboardCard>,
guids: Query<&Guid>,
child_of: Query<&ChildOf>|
-> Vec<Entity> {
[mesh_part, anchor_card, joint_card, orphan_card, net]
.into_iter()
.map(|e| net_entity_of(e, &cards, &guids, &child_of))
.collect()
},
)
.unwrap();
assert_eq!(
resolved[0], net,
"mesh part: the one-hop case still resolves"
);
assert_eq!(
resolved[1], net,
"anchor-following card: the world root hops through `follows()`, not `ChildOf`"
);
assert_eq!(
resolved[2], net,
"joint-following card: the climb continues up the joint hierarchy, not one hop"
);
assert_ne!(
resolved[3], net,
"a card whose owner is gone resolves to nothing that carries a guid"
);
assert_eq!(resolved[4], net, "the net entity resolves to itself");
}
}
+28
View File
@@ -135,6 +135,34 @@ pub(super) fn attack_target_binding(
}
}
/// **The `modalNextSpell` chain** — `HandleCastResult 0x6e7330`'s tail (`0x6e7447`–`0x6e74aa`),
/// the client casting a spell at itself with no user input. `cast_result` decides *whether*
/// (the column read, the in-flight test, the already-running test — all of it is the packet
/// handler's, so it stays there); this only carries the decision to the one send path.
///
/// The cast goes out at the **null target guid** — `0x6e74a6 push ebx; push ebx` with `ebx = 0`
/// — so the chained Auto Shot binds through the ordinary target walk (`ArmCast 0x6e5250`:
/// main-hand item bit, then the explicit guid, then the current selection), which is what
/// [`cast_target::CastTargeting::context`] hands the ladder when no guid is passed.
///
/// And it takes **every rung**: the reference chains through `0x6e5a90` → `TryCast 0x6e4b60`, the
/// same entry a button press uses, so a chained Auto Shot is range-checked, form-checked and
/// GCD-checked exactly like a pressed one, and refuses with the same red line.
pub(super) fn drain_chain_casts(
mut queue: ResMut<crate::ui_action::ChainCasts>,
targeting: cast_target::CastTargeting,
mut ladder: CastLadder,
) {
if queue.0.is_empty() {
return;
}
let ctx = targeting.context();
for spell_id in std::mem::take(&mut queue.0) {
debug!("ui_action: modalNextSpell chain casts {spell_id}");
ladder.send(spell_id, &ctx, CastCommit::Spell);
}
}
pub(super) fn drain_action_uses(
script: Option<NonSendMut<UiScript>>,
actions: Res<PlayerActions>,
+19
View File
@@ -126,6 +126,19 @@ pub(crate) struct PlayerActions {
#[derive(Resource, Default)]
pub(crate) struct AutoRepeatActive(pub Option<u32>);
/// **The `modalNextSpell` chain's queue** — spells the *client* casts on its own, one per
/// `SMSG_CAST_RESULT` that names a spell with a non-zero `Spell.dbc` column 38
/// ([`benilla_formats::SpellDisplay::modal_next_spell`]). Written by the net drain's
/// `cast_result`, drained through the one cast path by [`drain::drain_chain_casts`].
///
/// It exists because the reference's chain runs *inside* `HandleCastResult 0x6e7330`
/// (`0x6e74aa call 0x6e5a90` → `TryCast`) and ours cannot: the net-apply drain and
/// [`CastLadder`] want the same half-dozen resources, so a direct call is a Bevy param conflict.
/// A one-frame queue is the seam — and it keeps the rule that nothing sends a cast except the
/// ladder.
#[derive(Resource, Default)]
pub(crate) struct ChainCasts(pub(crate) Vec<u32>);
/// The spell display catalog + the shapeshift bonus-bar map (absent when the client data isn't —
/// every consumer tolerates that). `pub(crate)`: the cast-visual router
/// (`crate::creature_anim::spell_visual`) resolves spell → visual through the same catalog — one
@@ -279,6 +292,7 @@ impl Plugin for UiActionPlugin {
.init_resource::<UiErrorKeys>()
.init_resource::<crate::cooldowns::Cooldowns>()
.init_resource::<AutoRepeatActive>()
.init_resource::<ChainCasts>()
.init_resource::<cast_target::AutoSelfCast>()
.init_resource::<targeting::SpellTargeting>()
.init_resource::<targeting::EnchantConfirmItem>()
@@ -310,6 +324,11 @@ impl Plugin for UiActionPlugin {
// The T binding (0997): the attack arm's twin door, after the dispatch wrote
// this frame's fires.
drain::attack_target_binding.after(UiInput),
// The `modalNextSpell` chain (1597): a hunter shot's CAST_RESULT queues
// Auto Shot, and it goes out through the same ladder every other caster
// takes. After the input pass like the other drains — the queue is filled by
// the net drain, which runs earlier in the frame.
drain::drain_chain_casts.after(UiInput),
// The learned-ability latches must be current before the target chain's
// cursor classifier reads them; the book feed runs in `UnitFeed`, so sitting
// right after it is enough.
@@ -157,6 +157,9 @@ pub(super) struct ChatProbes<'w, 's> {
/// "leader" on this wire is a guid. Here rather than as a 17th drain parameter for the
/// reason this struct exists at all — the drain is at Bevy's 16-param ceiling.
self_guid: Res<'w, crate::net::SelfGuid>,
/// The map `/partytest ping` stamps its synthetic ping with — a ping carries no map on the
/// wire either, so "the one we are standing on" is the client's answer in both paths.
map: Option<Res<'w, benilla_world::world_map::CurrentMap>>,
}
/// Everything the drain **queues into another subsystem's one setter** rather than applying itself,
@@ -176,6 +179,12 @@ pub(super) struct ChatOut<'w> {
target: MessageWriter<'w, crate::target::TargetByNameRequest>,
assist: MessageWriter<'w, crate::target::AssistRequest>,
follow: MessageWriter<'w, crate::player::FollowRequest>,
/// **`/partytest ping`** (decision 1596) — the minimap ping's setter. The LOCAL leg needs no
/// instrument (click the map), but a *group member's* ping otherwise needs a second client
/// logged in and standing somewhere else; this seats one as if Alice had sent it, through the
/// same `seat` the wire arm calls, so the remote leg — the `partyN` token, the marker, the
/// pin holding while you walk — is exercisable solo.
ping: ResMut<'w, crate::minimap::MinimapPing>,
}
// One parameter per concern — the chat drain fans out to every command's consumer.
@@ -215,6 +224,7 @@ pub(super) fn drain_chat_input(
guids,
kinds,
self_guid,
map,
} = &probes;
let Some(mut script) = script else {
return;
@@ -513,6 +523,31 @@ pub(super) fn drain_chat_input(
}
}
"invite" => group.pending_invite = Some("Partner".to_string()),
// A group member's ping, without the group member (decision 1596). 35 yd
// north-east: off both axes so a mirrored sign is obvious, and inside every
// outdoor view radius (the tightest is 66.7 yd) — indoors at the two tightest
// zooms it is off the disc, which is itself worth seeing (the marker hides and
// the ping survives, so walking back brings it into view).
"ping" => {
let line = if let Ok((_, _, tf)) = self_player.single() {
let w = benilla_assets::coords::bevy_to_wow(tf.translation());
// +x is north, −y is east (0203's north-up mapping).
let at = (w[0] + 24.75, w[1] - 24.75);
chat_out
.ping
.seat(at, map.as_ref().map_or(0, |m| m.0), 0xF001);
format!(
"partytest: Alice pinged ({:.0}, {:.0}) — 35 yd NE, party1, 5 s",
at.0, at.1
)
} else {
"partytest: no player position — cannot place a ping".to_string()
};
chat_log.push_event(super::event::ChatEvent::text_only(
super::event::ChatEventKind::System,
line,
));
}
// Serverless mark eyeball: skull the current target on the LOCAL board (the
// real send round-trips through the server's echo, which /partytest lacks).
"mark" => {
@@ -159,7 +159,7 @@ pub(in crate::ui_chat) enum ParsedChat {
/// opens the panel and fills its edit box, a bare `/ignore` opens the ignore list), and
/// keeping them in the FrameXML is what stops that behaviour being re-derived here.
Social { verb: SocialVerb, arg: String },
/// `/partytest [lead|raid|invite|mark|off]` — the party-frame dev instrument (decision 0434, the
/// `/partytest [lead|raid|invite|mark|ping|off]` — the party-frame dev instrument (decision 0434, the
/// `/chattest` pattern): a synthetic roster through the real apply path (`lead` = the same
/// roster with US leading, for the leader-only popup rows), a fake pending invite for the
/// popup, a local skull on the current target (the mark renders without a server echo), or
@@ -169,7 +169,9 @@ pub(in crate::ui_chat) enum ParsedChat {
/// surface is exercisable serverless. `raid` is the same idea one level out (decision 1549):
/// a synthetic 25-member RAID with us leading, which is the only way the Raid tab's 8×5 grid
/// is eyeballable without forty accounts — the drag, Ready Check and the kick all apply to
/// the local mirror there too.
/// the local mirror there too. `ping` (decision 1596) seats a synthetic *group member's*
/// minimap ping 35 yd north-east — the one leg of the ping that otherwise needs a second
/// client.
PartyTest { arg: String },
/// `/target [name]` — select by name (`TargetByName`, decision 0886). Resolves creatures AND
/// players, case-insensitively, by whole name or longest common prefix, with no range, cone,
+44 -11
View File
@@ -1,6 +1,7 @@
//! The chat windows' **saved look** — the background tint, the background alpha and the font size
//! The chat windows' **saved state** — the background tint, the background alpha and the font size
//! the player picks off a chat tab's right-click menu (decision 1589, fixing **B246**: *"no chat
//! options at all — background transparency has no home, and chat can be hard to read"*).
//! options at all — background transparency has no home, and chat can be hard to read"*), plus the
//! window's **lock**, which joined them when the windows became movable and resizable.
//!
//! The state itself lives in the VM ([`benilla_ui::script::ChatWindowLook`], written by the
//! reference's own `SetChatWindowColor`/`SetChatWindowAlpha`/`SetChatWindowSize` and read straight
@@ -16,6 +17,10 @@
//! WINDOW 1 SIZE 0 COLOR 0 0 0 0 LOCKED 1 DOCKED 1 SHOWN 1
//! ```
//!
//! `LOCKED` is an `i32` in the record (`CHATWINDOW+0x8c`) but the cache writer booleanises it
//! through `setne`, so only `{0,1}` round-trip there — which is why ours is a `bool` and writes the
//! same two digits.
//!
//! `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.
@@ -33,10 +38,12 @@
//! 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.
//! `DOCKED`/`SHOWN`, the per-window `MESSAGES`/`CHANNELS` registration and the whole `COLORS`
//! table; benilla has no rename, no undock and no per-type colour editor (0288 §2), so writing
//! those keys would persist state nothing can move — the honest-tree rule (1134 §4) at the
//! persistence layer. Each joins the file the day something can change it, **which is exactly what
//! `LOCKED` just did**: the tab menu's *Lock/Unlock Window* row moves it, so a file that dropped it
//! would reset the player's unlocked window at every login.
//!
//! ## Why per character, and not a CVar
//!
@@ -69,10 +76,11 @@ 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
# benilla chat window state (decision 1589) — the tint, alpha, font size and lock a chat tab's
# right-click menu sets. A SUBSET of the reference's chat-cache.txt: benilla has no rename,
# undock or per-type colour editor yet, so those keys are absent rather than invented.
# COLOR is four bytes (0-255), the engine's own storage; SIZE is 0 for \"the font's own height\".
# COLOR is four bytes (0-255), the engine's own storage; SIZE is 0 for \"the font's own height\";
# LOCKED is 1 or 0, and 1 is the stock row (a fresh window cannot be dragged until you unlock it).
";
/// Which character's file we are on, where it lives, and whether it is owed a write.
@@ -97,13 +105,14 @@ 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",
"WINDOW {} SIZE {} COLOR {} {} {} {} LOCKED {}\n",
i + 1,
l.font_size,
l.r,
l.g,
l.b,
l.a
l.a,
i32::from(l.locked)
));
}
out
@@ -151,6 +160,11 @@ fn parse(text: &str) -> Vec<(usize, ChatWindowLook)> {
look.g = byte(it.next());
look.b = byte(it.next());
look.a = byte(it.next());
} else if key.eq_ignore_ascii_case("LOCKED") {
// Anything that is not the literal `0` is locked — the reference's own `setne`
// read, and the lenient half of it: a file from a build that wrote nothing here
// keeps the stock `LOCKED 1` default rather than silently unlocking the window.
look.locked = it.next().is_none_or(|v| v.trim() != "0");
}
// 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.
@@ -291,6 +305,7 @@ mod tests {
b,
a,
font_size,
locked: true,
}
}
@@ -325,7 +340,7 @@ mod tests {
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 2 SHOWN 1 COLOR 1 2 3 4 DOCKED 2\n\
WINDOW 3 SIZE 12\n",
);
assert_eq!(
@@ -338,6 +353,24 @@ mod tests {
);
}
/// `LOCKED` round-trips, and a file that never mentions it keeps the stock **locked** row —
/// the lenient default that matters, because reading it as "unlocked" would hand every
/// pre-existing player's chat window to a stray drag on their next login.
#[test]
fn the_lock_round_trips_and_an_absent_key_stays_locked() {
let unlocked = ChatWindowLook {
locked: false,
..look(0, 0, 0, 64, 14)
};
assert!(render(&[unlocked]).contains("LOCKED 0"));
assert_eq!(parse(&render(&[unlocked])), vec![(0, unlocked)]);
assert_eq!(
parse("WINDOW 1 SIZE 14 COLOR 0 0 0 64\n"),
vec![(0, look(0, 0, 0, 64, 14))],
"no LOCKED key = the stock LOCKED 1"
);
}
/// Junk costs the line it is on and nothing more.
#[test]
fn junk_costs_only_its_own_line() {
+38 -11
View File
@@ -1960,20 +1960,27 @@ fn the_combat_log_window_has_the_docks_rect() {
/// and ChatFrame2 had only a background, no borders. Selecting it therefore hid ChatFrame1's
/// chrome (its textures are ChatFrame1's children) and put nothing in its place: the background
/// and border vanished, which is what the director saw.
///
/// The ring is the eight **resize grips** since the move/resize arc, so the same check now covers
/// both halves of each piece: the texture the fade paints and the button that grabs it.
#[test]
fn both_dock_windows_carry_the_same_chrome() {
let s = chat_vm();
for suffix in [
"Background",
"BorderTopLeft",
"BorderTopRight",
"BorderBottomLeft",
"BorderBottomRight",
"BorderTop",
"BorderBottom",
"BorderLeft",
"BorderRight",
] {
// The reference's own `CHAT_FRAME_TEXTURES`, which is also the list the file declares — read
// out of the VM rather than restated here, so a piece that leaves the list cannot leave this
// check with it.
let suffixes: Vec<String> = (1..=9)
.map(|i| {
s.eval::<String>(&format!("return CHAT_FRAME_TEXTURES[{i}]"))
.unwrap()
})
.collect();
assert_eq!(
suffixes.len(),
9,
"the background plus the eight resize grips' textures"
);
for suffix in &suffixes {
for id in [1, 2] {
assert!(
s.eval::<bool>(&format!("return ChatFrame{id}{suffix} ~= nil"))
@@ -1982,6 +1989,26 @@ fn both_dock_windows_carry_the_same_chrome() {
);
}
}
// The grips themselves, not only their art: each texture's owning Button has to exist too, or
// the ring is painted and nothing can grab it.
for grip in [
"TopLeft",
"TopRight",
"BottomLeft",
"BottomRight",
"Top",
"Bottom",
"Left",
"Right",
] {
for id in [1, 2] {
assert!(
s.eval::<bool>(&format!("return ChatFrame{id}Resize{grip} ~= nil"))
.unwrap(),
"ChatFrame{id}Resize{grip} is missing — the window has art where a handle should be"
);
}
}
assert_eq!(
s.eval::<i64>("return table.getn(BenillaFCF_Textures(2))")
.unwrap(),
+381
View File
@@ -0,0 +1,381 @@
//! The **layout cache** — where the windows the player has moved or resized are, so they are still
//! there after a relog.
//!
//! The engine has carried the client's userPlaced bit for a long time
//! ([`benilla_ui::script`]'s `movable` cluster — `frame+0xb4 & 0x1000`, set by `SetUserPlaced` and
//! by the drag entries themselves) and **nothing read it**: a chat window dragged across the screen
//! went back to its authored anchor at the next login. This module is the half that was missing.
//! The engine half — enumerate, restore, dirty-bit — is `benilla_ui::script`'s `layout_cache`; this
//! is the two ends the VM cannot own: *where the file lives* and *when it is written*.
//!
//! ## The file, and how it relates to the reference's
//!
//! 1.12 writes `WTF/Account/<ACC>/<REALM>/<CHAR>/layout-cache.txt`, and a real one off the pinned
//! install reads, in full:
//!
//! ```text
//! Frame: ChatFrame2
//! FrameLevel: 4
//! X: 32
//! Y: -578
//! W: 430
//! H: 120
//! ```
//!
//! Ours is `benilla-config/layout/<realm>-<character>.txt`
//! ([`crate::local_state::layout_character_path`]) — the same scope, one folder flatter, with that
//! file's `Frame:`/`W:`/`H:` spellings kept. **Two of its keys are deliberately different, and both
//! are about benilla's own move model rather than about taste:**
//!
//! - **`Point:` lines instead of `X:`/`Y:`.** The reference collapses a dragged frame to a single
//! screen-space position and re-seats it from a `TOPLEFT` anchor; benilla's drag pump keeps the
//! frame's authored anchor SET and shifts every offset in it (the divergence `movable`'s own doc
//! states). Writing an absolute pair would therefore *lose* what the drag produced — ChatFrame1
//! is anchored `BOTTOMLEFT` to UIParent, and restoring it as a TOPLEFT position would change how
//! it follows a resolution change. So the file carries what `GetPoint` answers, one line per
//! anchor: `Point: <point> <relativeTo> <relativePoint> <x> <y>`, with `-` for the screen root
//! (which is where `GetPoint` answers `nil`).
//! - **No `FrameLevel:`.** The reference persists it because an undocked chat window is raised
//! above its neighbours and that raise has to survive; benilla's raise is transient and its
//! docked windows share one level. It joins the file the day a window can be raised for good —
//! the honest-tree rule (1134 §4), the same one the chat-look file applies next door.
//!
//! ## The write posture — [`crate::ui_chat`]'s `settings`, verbatim
//!
//! Dirty flag keyed to the VM ([`VmMemo`]), one quiet second, plus both session edges
//! (`OnExit(InWorld)` and `AppExit`). The VM key is the load-bearing part and it is one-way: the
//! geometry lives in the VM, so a plain `bool` surviving a VM replacement would let a save compose
//! the player's file out of a fresh tree that has no user-placed frame in it at all — i.e. wipe it.
//! A fresh VM starts undirty and cannot write until a drag writes.
//!
//! A drag is a slider-shaped gesture, so the debounce matters for the same reason it does there:
//! a resize writes on every mouse-move, and the quiet second coalesces the lot into one file write.
use std::path::PathBuf;
use bevy::prelude::*;
use benilla_ui::script::{FrameLayout, LayoutPoint, UiScript};
use crate::ui_script::VmMemo;
/// How long a moved window sits before the save fires — [`crate::cvars`]'s own constant and its own
/// reasoning, which a drag fits exactly: long enough to coalesce one gesture, short enough that a
/// crash costs a gesture rather than a session.
const SAVE_QUIET: std::time::Duration = std::time::Duration::from_secs(1);
/// The screen root's spelling in the file — where `GetPoint` answers `nil`. A literal `-` rather
/// than `UIParent`, because benilla has a real frame by that name (`UIParent.xml`) and the two
/// would then be indistinguishable on the way back in.
const SCREEN_TOKEN: &str = "-";
/// The file's header — what these lines are and where the law lives.
const HEADER: &str = "\
# benilla window layout — every frame the player has moved or resized (the client's userPlaced
# bit). A relative of the reference's layout-cache.txt: same scope, same Frame:/W:/H: keys, but
# anchors instead of its X:/Y: pair, because a benilla drag moves a frame's anchors rather than
# collapsing it to a screen position. `-` as a Point: target means the screen root.
";
/// Which character's file we are on, where it lives, whether it has been restored into this VM,
/// and whether it is owed a write.
#[derive(Resource, Default)]
pub(crate) struct LayoutFile {
path: Option<PathBuf>,
/// The `(realm, character)` [`Self::path`] was built for. Session-keyed (1290) like the chat
/// look and the macro loads: the *same* character coming back still meets a fresh VM whose
/// frames are all back on their authored anchors.
identity: VmMemo<Option<(String, String)>>,
/// Whether **this VM** has unsaved drags. Session-keyed for the one-way reason in the module
/// doc: a `bool` that outlived its VM could compose the file from a tree with nothing placed
/// in it.
dirty: VmMemo<bool>,
last_change: Option<std::time::Instant>,
}
/// Render the cache exactly as [`parse`] reads it, frames in the order the engine hands them
/// (name-sorted, so the file is stable across sessions).
///
/// `pub(crate)` for one reason: `ui_script::chat_resize_tests` drives the *whole* round trip — drag
/// the shipped window, snapshot it, write the text, read it back into a fresh VM — and a test that
/// stops at the engine seam would not have caught a file that cannot express what the seam
/// produced.
pub(crate) fn render(frames: &[FrameLayout]) -> String {
let mut out = String::from(HEADER);
for f in frames {
out.push_str(&format!("Frame: {}\n", f.name));
out.push_str(&format!("W: {}\n", f.width));
out.push_str(&format!("H: {}\n", f.height));
for p in &f.points {
out.push_str(&format!(
"Point: {} {} {} {} {}\n",
p.point,
p.relative_to.as_deref().unwrap_or(SCREEN_TOKEN),
p.relative_point,
p.x,
p.y
));
}
}
out
}
/// Parse the cache back. Permissive the way the reference's own readers are, and for the same
/// reason — a hand edit and a later build's extra key must each cost at most the line they are on:
/// keys match case-insensitively, an unknown key is skipped, and a malformed `Point:` drops that
/// anchor rather than the file. A `Point:`/`W:`/`H:` before any `Frame:` has no owner and is
/// dropped.
pub(crate) fn parse(text: &str) -> Vec<FrameLayout> {
let mut out: Vec<FrameLayout> = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((key, rest)) = line.split_once(':') else {
continue;
};
let (key, rest) = (key.trim(), rest.trim());
if key.eq_ignore_ascii_case("Frame") {
if rest.is_empty() {
continue;
}
out.push(FrameLayout {
name: rest.to_owned(),
width: 0.0,
height: 0.0,
points: Vec::new(),
});
continue;
}
let Some(frame) = out.last_mut() else {
continue; // a value line with no `Frame:` above it owns nothing
};
if key.eq_ignore_ascii_case("W") {
frame.width = rest.parse().unwrap_or(0.0);
} else if key.eq_ignore_ascii_case("H") {
frame.height = rest.parse().unwrap_or(0.0);
} else if key.eq_ignore_ascii_case("Point") {
let f: Vec<&str> = rest.split_whitespace().collect();
if f.len() != 5 {
warn!("layout: malformed Point line ignored: {line}");
continue;
}
let (Ok(x), Ok(y)) = (f[3].parse::<f32>(), f[4].parse::<f32>()) else {
warn!("layout: Point line with unparsable offsets ignored: {line}");
continue;
};
frame.points.push(LayoutPoint {
point: f[0].to_owned(),
relative_to: (f[1] != SCREEN_TOKEN).then(|| f[1].to_owned()),
relative_point: f[2].to_owned(),
x,
y,
});
}
}
out
}
/// Seat the player's saved geometry into the VM, once per character per VM.
///
/// Runs in `Update` under `InWorld`, which is *after* the UI tree is built and after decision
/// 0272's load-time `UIParent_ManageFramePositions()` bootstrap — both of which matter: the frames
/// have to exist to be looked up by name, and the managed pass has to have had its say first,
/// because from here on it skips these frames (`IsUserPlaced`, `UIParent.xml`).
fn load_layout(
script: Option<NonSendMut<UiScript>>,
roster: Res<crate::char_select::Roster>,
mut file: ResMut<LayoutFile>,
) {
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::layout_character_path(&id.0, &id.1);
*file.identity.get(&script) = Some(id);
// A fresh VM has nothing placed, so a character with no file needs nothing pushed — and the
// watcher below must not read a change this load 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
};
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!("layout: cannot read {}: {e}", path.display());
return;
}
};
let frames = parse(&text);
if frames.is_empty() {
return;
}
info!(
"layout: {} frames restored from {}",
frames.len(),
path.display()
);
script.restore_user_placed_layouts(frames);
// The restore moved anchors the drag pump would otherwise have moved; drain the engine's own
// dirty bit so the very first save is not a rewrite of what was just read.
script.take_user_placed_change();
}
/// Drain the engine's "a user-placed frame moved" bit into the dirty flag. Cheap on a steady frame
/// — the drain is a `take` of a `bool`.
fn watch_layout(script: Option<NonSendMut<UiScript>>, mut file: ResMut<LayoutFile>) {
let Some(mut script) = script else { return };
if !script.take_user_placed_change() {
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_layout(
script: Option<NonSendMut<UiScript>>,
mut file: ResMut<LayoutFile>,
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.user_placed_layouts());
if let Err(e) = crate::local_state::write_atomic(&path, &body) {
// …and don't retry every frame into the same error.
warn!("layout: 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,
/// the chat look 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<LayoutFile>) {
let Some(script) = script else { return };
if !*file.dirty.get(&script) {
return;
}
if let Some(path) = file.path.clone() {
let body = render(&script.user_placed_layouts());
if let Err(e) = crate::local_state::write_atomic(&path, &body) {
warn!("layout: cannot write {}: {e}", path.display());
}
}
*file.dirty.get(&script) = false;
}
/// The layout cache's plugin — the chat look's shape one store over.
pub(crate) struct UiLayoutPlugin;
impl Plugin for UiLayoutPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<LayoutFile>()
.add_systems(
Update,
(load_layout, watch_layout)
.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 a drag to the process ending.
crate::shutdown::on_app_exit(app, save_layout.into_configs());
}
}
#[cfg(test)]
mod tests {
use super::*;
fn point(p: &str, rel: Option<&str>, rp: &str, x: f32, y: f32) -> LayoutPoint {
LayoutPoint {
point: p.into(),
relative_to: rel.map(str::to_owned),
relative_point: rp.into(),
x,
y,
}
}
/// The file round-trips — what `render` writes is exactly what `parse` reads back, the screen
/// root's `-` included.
#[test]
fn the_file_round_trips() {
let frames = vec![
FrameLayout {
name: "ChatFrame1".into(),
width: 512.0,
height: 180.5,
points: vec![point(
"BOTTOMLEFT",
Some("UIParent"),
"BOTTOMLEFT",
40.0,
120.0,
)],
},
FrameLayout {
name: "Floater".into(),
width: 0.0,
height: 0.0,
points: vec![
point("TOPLEFT", None, "TOPLEFT", -1.5, 2.0),
point("BOTTOMRIGHT", Some("ChatFrame1"), "TOPRIGHT", 0.0, 0.0),
],
},
];
assert_eq!(parse(&render(&frames)), frames);
}
/// 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 windows on the second launch.
#[test]
fn the_header_is_skipped_not_parsed() {
assert!(render(&[]).starts_with('#'));
assert_eq!(parse(HEADER), vec![]);
}
/// Junk costs the line it is on: an orphan value line, a short `Point:`, an unknown key.
#[test]
fn junk_costs_only_its_own_line() {
let got = parse(
"W: 100\n\
Frame: A\n\
FrameLevel: 4\n\
Point: TOPLEFT -\n\
W: 200\n\
point: bottomleft - BOTTOMLEFT 1 2\n",
);
assert_eq!(
got,
vec![FrameLayout {
name: "A".into(),
width: 200.0,
height: 0.0,
points: vec![point("bottomleft", None, "BOTTOMLEFT", 1.0, 2.0)],
}]
);
}
}
@@ -137,25 +137,33 @@ fn right_clicking_a_chat_tab_opens_its_options_menu() {
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).
// The window verb, then the DISPLAY block, in the reference's own order
// (FloatingChatFrame.lua l.236-307).
assert_eq!(
s.eval::<i64>("return DropDownList1.numButtons").unwrap(),
3,
"Display title + Font Size + Background"
4,
"Lock Window + Display title + Font Size + Background"
);
// **Unlock**, not Lock: both dock windows ship LOCKED (the stock chat-cache `LOCKED 1`), so
// the row offers the way out of it. `chat_resize_tests` drives the toggle itself.
assert_eq!(
s.eval::<String>("return DropDownList1Button1:GetText()")
.unwrap(),
"Display"
"Unlock Window"
);
assert_eq!(
s.eval::<String>("return DropDownList1Button2:GetText()")
.unwrap(),
"Font Size"
"Display"
);
assert_eq!(
s.eval::<String>("return DropDownList1Button3:GetText()")
.unwrap(),
"Font Size"
);
assert_eq!(
s.eval::<String>("return DropDownList1Button4:GetText()")
.unwrap(),
"Background"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -209,13 +217,13 @@ fn the_background_row_opens_the_picker_and_its_opacity_slider_drives_the_window(
);
// …so the reversed slider is seeded at its far end.
assert_eq!(
s.eval::<f64>("return DropDownList1Button3.opacity")
s.eval::<f64>("return DropDownList1Button4.opacity")
.unwrap(),
1.0,
"info.opacity is 1 - a (the ref's own 'the slider is reversed')"
);
left_click(&mut s, "DropDownList1Button3ColorSwatch");
left_click(&mut s, "DropDownList1Button4ColorSwatch");
assert!(
s.eval::<bool>("return ColorPickerFrame:IsVisible()")
.unwrap(),
@@ -298,7 +306,7 @@ fn the_font_size_submenu_resizes_the_window_and_stores_the_pick() {
// Open the submenu the way a player does — hover the `hasArrow` row (UIDropDownMenu.xml's
// row OnEnter → `UIDropDownMenuRow_OpenSubmenu`).
hover(&mut s, "DropDownList1Button2");
hover(&mut s, "DropDownList1Button3");
assert_eq!(
s.eval::<i64>("return DropDownList2.numButtons").unwrap(),
4,
@@ -341,7 +349,7 @@ fn each_tabs_menu_writes_its_own_window() {
s.eval::<i64>("return FCF_GetCurrentChatFrameID()").unwrap(),
2
);
left_click(&mut s, "DropDownList1Button3ColorSwatch");
left_click(&mut s, "DropDownList1Button4ColorSwatch");
s.run("OpacitySliderFrame:SetValue(0)").unwrap(); // reversed: 0 = fully opaque
assert_eq!(
s.eval::<f64>("local _,_,_,_,_,a = GetChatWindowInfo(2) return a")
@@ -371,7 +379,7 @@ fn cancelling_the_picker_puts_the_window_back() {
.unwrap();
right_click(&mut s, "ChatFrame1Tab");
left_click(&mut s, "DropDownList1Button3ColorSwatch");
left_click(&mut s, "DropDownList1Button4ColorSwatch");
s.run("OpacitySliderFrame:SetValue(0)").unwrap();
assert_eq!(
s.eval::<f64>("local _,_,_,_,_,a = GetChatWindowInfo(1) return a")
@@ -406,6 +414,7 @@ fn the_restore_event_repaints_the_window_from_the_store() {
b: 0,
a: 255,
font_size: 18,
locked: true,
},
)]);
s.fire_event("UPDATE_CHAT_WINDOWS", vec![]);
@@ -0,0 +1,582 @@
//! The chat window's **move, resize and lock**, end to end (decision 1594).
//!
//! Like `chat_options_tests` next door, these drive the shipped `ChatFrame.xml` from the mouse
//! event inward — a press on the real grip button, a drag of the real window body, a click on the
//! real menu row — because what is being built is a *path*, not a function. Every hop it names is
//! a hop where the feature could be absent while every unit underneath it passed: is the grip a
//! hit target at all? does the lock stop it? does the clamp hold? does the geometry reach the file
//! and come back?
//!
//! The manifest is `chat_options_tests`' — `benilla.toc`'s own order, up to `ChatFrame.xml`.
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 chat dock with everything its tab menu and its grips reach under it.
fn chat_ui() -> 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);
}
s.set_screen_size(1600.0, 900.0);
s.resolve();
s
}
/// A frame's centre, through the real layout.
fn centre(s: &mut UiScript, frame: &str) -> (f32, f32) {
s.resolve();
let (x, y): (f64, f64) = s
.eval(&format!("return {frame}:GetCenter()"))
.unwrap_or_else(|e| panic!("{frame}:GetCenter(): {e}"));
(x as f32, y as f32)
}
fn width(s: &mut UiScript) -> f32 {
s.resolve();
s.eval::<f64>("return ChatFrame1:GetWidth()").unwrap() as f32
}
fn height(s: &mut UiScript) -> f32 {
s.resolve();
s.eval::<f64>("return ChatFrame1:GetHeight()").unwrap() as f32
}
fn left(s: &mut UiScript) -> f32 {
s.resolve();
s.eval::<f64>("return ChatFrame1:GetLeft()").unwrap() as f32
}
/// Right-click a frame's centre through the real hit path.
fn right_click(s: &mut UiScript, frame: &str) {
let (x, y) = centre(s, frame);
s.mouse_button(x, y, "RightButton", true);
s.mouse_button(x, y, "RightButton", false);
s.resolve();
}
/// Left-click a frame's centre through the real hit path.
fn left_click(s: &mut UiScript, frame: &str) {
let (x, y) = centre(s, frame);
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
s.resolve();
}
/// Press a grip and walk the cursor to `(x, y)` — the real gesture, without releasing.
fn grab_grip(s: &mut UiScript, grip: &str) -> (f32, f32) {
let (x, y) = centre(s, grip);
s.mouse_button(x, y, "LeftButton", true);
s.resolve();
(x, y)
}
/// The reference's stock state: **both dock windows ship locked**, and that is what
/// `GetChatWindowInfo` answers, because it is the chat-cache's own `LOCKED 1`.
#[test]
fn both_dock_windows_ship_locked() {
let s = chat_ui();
for i in 1..=2 {
assert_eq!(
s.eval::<i64>(&format!(
"local _,_,_,_,_,_,_,locked = GetChatWindowInfo({i}) return locked"
))
.unwrap(),
1,
"window {i} ships locked (the stock chat-cache row)"
);
assert!(
s.eval::<bool>(&format!("return ChatFrame{i}.isLocked ~= nil"))
.unwrap(),
"…and the frame was seated from it"
);
}
// The blanket switch ships OFF — the reference's own initialiser, not its checkbox table's
// disagreeing `default = "1"`.
assert_eq!(s.eval::<String>("return CHAT_LOCKED").unwrap(), "0");
assert!(s
.eval::<Option<i64>>("return FCF_Get_ChatLocked()")
.unwrap()
.is_none());
}
/// **The tab menu's Lock row: it is there, it toggles, and its label flips.**
///
/// The reference's FIRST row (FloatingChatFrame.lua l.236-246): `UNLOCK_WINDOW` when the frame is
/// locked, `LOCK_WINDOW` when it is not, `notCheckable`.
#[test]
fn the_tab_menus_lock_row_toggles_the_window_and_flips_its_label() {
let mut s = chat_ui();
right_click(&mut s, "ChatFrame1Tab");
// Shipped locked, so the row offers the way OUT of it.
assert_eq!(
s.eval::<String>("return DropDownList1Button1:GetText()")
.unwrap(),
"Unlock Window"
);
assert_eq!(
s.eval::<i64>("return DropDownList1Button1.notCheckable")
.unwrap(),
1,
"a verb, not a tick"
);
left_click(&mut s, "DropDownList1Button1");
assert!(
s.eval::<Option<i64>>("return ChatFrame1.isLocked")
.unwrap()
.is_none(),
"the row unlocked the window"
);
assert!(
s.eval::<Option<i64>>("local _,_,_,_,_,_,_,l = GetChatWindowInfo(1) return l")
.unwrap()
.is_none(),
"…and wrote it through SetChatWindowLocked, so it can be persisted"
);
// Re-open: the same row is now the other verb.
right_click(&mut s, "ChatFrame1Tab");
assert_eq!(
s.eval::<String>("return DropDownList1Button1:GetText()")
.unwrap(),
"Lock Window"
);
left_click(&mut s, "DropDownList1Button1");
assert_eq!(
s.eval::<i64>("return ChatFrame1.isLocked").unwrap(),
1,
"and back"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **A locked window refuses the grip.** Two things have to hold and only one of them is the
/// early return: the grip must not take the click at all (or it would shadow the chat line under
/// it — see the template's note), and `FCF_Resize` must refuse even if something did reach it.
#[test]
fn a_locked_window_refuses_a_grip_drag() {
let mut s = chat_ui();
let before = width(&mut s);
assert_eq!(before, 430.0, "the authored width");
// The grip is where it should be — this is not a test that passes because nothing is there.
assert!(s
.eval::<bool>("return ChatFrame1ResizeBottomRight ~= nil")
.unwrap());
assert!(
!s.eval::<bool>("return ChatFrame1ResizeBottomRight:IsMouseEnabled()")
.unwrap(),
"a locked window's grips are click-through"
);
// The press falls past the disabled button — to whatever is behind it, which at this corner
// is the world (the grip straddles the window's edge and its centre sits just outside).
let (x, y) = centre(&mut s, "ChatFrame1ResizeBottomRight");
assert_ne!(
s.hit_test_name(x, y).as_deref(),
Some("ChatFrame1ResizeBottomRight"),
"a disabled grip does not eat the click"
);
grab_grip(&mut s, "ChatFrame1ResizeBottomRight");
s.mouse_move(x + 60.0, y - 40.0);
assert_eq!(width(&mut s), before, "a locked window does not resize");
// …and the direct call refuses too, which is the reference's own gate rather than ours.
s.run("ChatFrame1ResizeBottomRight:EnableMouse(true)")
.unwrap();
grab_grip(&mut s, "ChatFrame1ResizeBottomRight");
s.mouse_move(x + 60.0, y - 40.0);
assert_eq!(
width(&mut s),
before,
"FCF_Resize's isLocked gate refuses even a live grip"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **An unlocked window resizes from its BOTTOMRIGHT grip, and stops at the reference's bounds.**
///
/// 430×120 authored, `<ResizeBounds>` 296×75 … 608×400. The clamp is the half a naive pump gets
/// wrong twice over: without the rebate the size pins at the bound while the anchor keeps sliding,
/// so a window held past its minimum stops shrinking and starts *walking*.
#[test]
fn an_unlocked_bottom_right_grip_resizes_and_clamps_at_the_bounds() {
let mut s = chat_ui();
s.run("FCF_SetLocked(ChatFrame1, nil)").unwrap();
assert!(
s.eval::<bool>("return ChatFrame1ResizeBottomRight:IsMouseEnabled()")
.unwrap(),
"unlocking hands the edges to the mouse"
);
let (x, y) = centre(&mut s, "ChatFrame1ResizeBottomRight");
assert_eq!(
s.hit_test_name(x, y).as_deref(),
Some("ChatFrame1ResizeBottomRight"),
"the grip is the topmost hit — a lowered frame level would put the window on top of it"
);
let bottom_before: f64 = s.eval("return ChatFrame1:GetBottom()").unwrap();
grab_grip(&mut s, "ChatFrame1ResizeBottomRight");
// Right and DOWN: the BOTTOMRIGHT corner follows the cursor, so both grow.
s.mouse_move(x + 50.0, y - 30.0);
assert_eq!(width(&mut s), 480.0, "the right edge followed the cursor");
assert_eq!(height(&mut s), 150.0, "and the bottom edge with it");
let bottom_after: f64 = s.eval("return ChatFrame1:GetBottom()").unwrap();
assert!(
(bottom_after - (bottom_before - 30.0)).abs() < 1e-3,
"the planted TOP edge stayed put: {bottom_before} -> {bottom_after}"
);
// Past the maximum: the edge lands ON the bound and stays there.
s.mouse_move(x + 900.0, y - 900.0);
assert_eq!(width(&mut s), 608.0, "maxResize x");
assert_eq!(height(&mut s), 400.0, "maxResize y");
// Past the minimum, from there.
s.mouse_move(x - 900.0, y + 900.0);
assert_eq!(width(&mut s), 296.0, "minResize x");
assert_eq!(height(&mut s), 75.0, "minResize y");
s.run("ChatFrame1:StopMovingOrSizing()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The release ends the resize even when the cursor is nowhere near the grip.**
///
/// The failure this pins is not hypothetical: drag past `maxResize` and the grip *stops following*
/// (the clamp plants the edge while the cursor runs on), so the release lands on empty screen. If
/// `OnMouseUp` went to whatever is under the cursor rather than to the button that took the press,
/// `FCF_StopResize` would never run and the window would stay glued to the mouse for the rest of
/// the session.
#[test]
fn the_release_ends_the_resize_from_anywhere_on_screen() {
let mut s = chat_ui();
s.run("FCF_SetLocked(ChatFrame1, nil)").unwrap();
let (x, y) = grab_grip(&mut s, "ChatFrame1ResizeBottomRight");
s.mouse_move(x + 900.0, y);
assert_eq!(width(&mut s), 608.0, "held against the maximum");
// The cursor is 900px past the grip; release there.
s.mouse_button(x + 900.0, y, "LeftButton", false);
s.resolve();
assert!(
s.eval::<Option<i64>>("return ChatFrame1.resizing")
.unwrap()
.is_none(),
"FCF_StopResize ran"
);
// The falsifier: with the drag still live, walking back would shrink the window.
s.mouse_move(x - 200.0, y);
assert_eq!(
width(&mut s),
608.0,
"the drag is over; the window is not moving"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The window moves by a body drag, and only while unlocked.** A plain click is untouched — the
/// gesture has to cross the drag threshold before anything moves, which is what keeps 0843's
/// held-spell dismissal working on the same button.
#[test]
fn the_body_drag_moves_an_unlocked_window_and_a_locked_one_stays_put() {
let mut s = chat_ui();
let (cx, cy) = centre(&mut s, "ChatFrame1");
let start = left(&mut s);
// Locked: not drag-registered at all, so the gesture never arms.
s.mouse_button(cx, cy, "LeftButton", true);
s.mouse_move(cx + 80.0, cy);
s.mouse_button(cx + 80.0, cy, "LeftButton", false);
assert_eq!(left(&mut s), start, "a locked window does not drag");
s.run("FCF_SetLocked(ChatFrame1, nil)").unwrap();
let (cx, cy) = centre(&mut s, "ChatFrame1");
s.mouse_button(cx, cy, "LeftButton", true);
s.mouse_move(cx + 5.0, cy); // past the 4px threshold: OnDragStart -> StartMoving
s.mouse_move(cx + 80.0, cy);
// **75, not 80.** The gesture's first 5px are spent crossing the drag threshold, and
// `StartMoving` samples the cursor where it is when `OnDragStart` fires — so the window follows
// from *there*, not from the press. That is the engine's own shape (`cursor::maybe_start_drag`
// runs after the move pump in the same `mouse_move`), and it is what every drag-to-move frame
// in the client does.
assert_eq!(
left(&mut s),
start + 75.0,
"the window followed the cursor from where the drag started"
);
s.mouse_button(cx + 80.0, cy, "LeftButton", false);
s.mouse_move(cx + 400.0, cy);
assert_eq!(
left(&mut s),
start + 75.0,
"OnDragStop ended it — a move that outlived the button would keep going"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The dragged window is **user-placed**, and `UIParent_ManageFramePositions` therefore leaves it
/// alone. Without that the next stance-bar show/hide would `ClearAllPoints` the window and drop it
/// back on the bottom-stack seat.
#[test]
fn a_moved_window_is_user_placed_and_the_managed_pass_skips_it() {
let mut s = chat_ui();
// The managed pass owns the seat to begin with — the control for the assertion below.
s.run("UIParent_ManageFramePositions()").unwrap();
let seated = left(&mut s);
assert!(
!s.eval::<bool>("return ChatFrame1:IsUserPlaced()").unwrap(),
"nothing has placed it yet"
);
s.run("FCF_SetLocked(ChatFrame1, nil)").unwrap();
let (cx, cy) = centre(&mut s, "ChatFrame1");
s.mouse_button(cx, cy, "LeftButton", true);
s.mouse_move(cx + 5.0, cy);
s.mouse_move(cx + 120.0, cy);
s.mouse_button(cx + 120.0, cy, "LeftButton", false);
let moved = left(&mut s);
assert_eq!(
moved,
seated + 115.0,
"120 less the 5px spent on the threshold"
);
assert!(
s.eval::<bool>("return ChatFrame1:IsUserPlaced()").unwrap(),
"the drag set the userPlaced bit"
);
s.run("UIParent_ManageFramePositions()").unwrap();
assert_eq!(
left(&mut s),
moved,
"the managed pass does not re-seat a frame the player placed"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The geometry survives a relog** — the whole round trip, in one test: drag and resize the
/// shipped window, snapshot it through the engine seam, write the file text, read it back, and
/// seat it into a *fresh* VM whose windows are all on their authored anchors.
#[test]
fn the_geometry_round_trips_through_the_save_file() {
let mut s = chat_ui();
s.run("FCF_SetLocked(ChatFrame1, nil)").unwrap();
// Resize from the bottom-right, then move the whole window.
let (gx, gy) = grab_grip(&mut s, "ChatFrame1ResizeBottomRight");
s.mouse_move(gx + 70.0, gy - 40.0);
s.mouse_button(gx + 70.0, gy - 40.0, "LeftButton", false);
let (cx, cy) = centre(&mut s, "ChatFrame1");
s.mouse_button(cx, cy, "LeftButton", true);
s.mouse_move(cx + 5.0, cy + 5.0);
s.mouse_move(cx + 90.0, cy + 60.0);
s.mouse_button(cx + 90.0, cy + 60.0, "LeftButton", false);
s.resolve();
let want = (
width(&mut s),
height(&mut s),
left(&mut s),
s.eval::<f64>("return ChatFrame1:GetBottom()").unwrap() as f32,
);
assert_ne!(want.0, 430.0, "the resize happened");
assert_ne!(want.2, 32.0, "the move happened");
// The engine seam, then the file.
let saved = s.user_placed_layouts();
assert_eq!(saved.len(), 1, "one window was placed: {saved:?}");
assert_eq!(saved[0].name, "ChatFrame1");
let text = crate::ui_layout::render(&saved);
let read_back = crate::ui_layout::parse(&text);
assert_eq!(
read_back, saved,
"the file expresses what the seam produced"
);
// A fresh VM — the relog. Everything starts on its authored anchors.
let mut fresh = chat_ui();
assert_eq!(width(&mut fresh), 430.0);
fresh.restore_user_placed_layouts(read_back);
fresh.resolve();
assert_eq!(
(
width(&mut fresh),
height(&mut fresh),
left(&mut fresh),
fresh.eval::<f64>("return ChatFrame1:GetBottom()").unwrap() as f32
),
want,
"the window came back exactly where it was left"
);
assert!(
fresh
.eval::<bool>("return ChatFrame1:IsUserPlaced()")
.unwrap(),
"a restored window keeps the bit, so the managed pass keeps its hands off"
);
assert!(
fresh.errors().is_empty(),
"script errors: {:?}",
fresh.errors()
);
}
/// The **grip art rides the window's own reveal**, which is what the reference's
/// `CHAT_FRAME_TEXTURES` is for: the eight pieces and the background are tinted and faded as one,
/// so the handles appear with the box and are invisible without it.
#[test]
fn the_grip_art_follows_the_windows_reveal_and_tint() {
let mut s = chat_ui();
// Off-hover, at the shipped alpha-0 base: everything in the set is invisible.
s.run("BenillaFCF.hover = false FCF_OnUpdate(1.0)").unwrap();
for piece in ["Background", "ResizeBottomRightTexture", "ResizeTopTexture"] {
assert_eq!(
s.eval::<f64>(&format!("return ChatFrame1{piece}:GetAlpha()"))
.unwrap(),
0.0,
"{piece} is invisible at rest"
);
}
// Park the cursor in the window and let the stationary delay and the ramp run out.
let (cx, cy) = centre(&mut s, "ChatFrame1");
s.mouse_move(cx, cy);
for _ in 0..25 {
s.tick(0.016);
s.resolve();
}
for piece in ["Background", "ResizeBottomRightTexture", "ResizeTopTexture"] {
assert_eq!(
s.eval::<f64>(&format!("return ChatFrame1{piece}:GetAlpha()"))
.unwrap(),
0.25,
"{piece} reveals with the box (DEFAULT_CHATFRAME_ALPHA)"
);
}
// And the tab menu's tint reaches them too — one list, one colour.
s.run("FCF_SetWindowColor(ChatFrame1, 1, 0, 0)").unwrap();
let (r, g, b): (f64, f64, f64) = s
.eval("return ChatFrame1ResizeTopTexture:GetVertexColor()")
.unwrap();
assert_eq!((r, g, b), (1.0, 0.0, 0.0));
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **A drag in flight pins the chrome open.** The cursor moves during a resize, which keeps
/// resetting the stationary-hover delay — without the reference's own `or chatFrame.resizing`
/// clause the box would fade out from under the hand holding it.
#[test]
fn a_drag_in_flight_holds_the_chrome_visible() {
let mut s = chat_ui();
s.run("FCF_SetLocked(ChatFrame1, nil)").unwrap();
grab_grip(&mut s, "ChatFrame1ResizeBottomRight");
// Drag the cursor right out of the window's hover rect. Everything the fade normally keys on
// now says "conceal": the cursor is nowhere near the dock, so only `chatFrame.resizing` can
// hold the grips on screen.
s.mouse_move(1500.0, 820.0);
s.run("FCF_OnUpdate(1.0)").unwrap();
assert_eq!(
s.eval::<f64>("return BenillaFCF.reveal").unwrap(),
1.0,
"the resize holds the reveal open with the cursor off the window"
);
// The falsifier is the release: with the drag over, the same cursor position conceals.
s.mouse_button(1500.0, 820.0, "LeftButton", false);
s.run("FCF_OnUpdate(1.0)").unwrap();
assert_eq!(
s.eval::<f64>("return BenillaFCF.reveal").unwrap(),
0.0,
"and lets go when the drag ends"
);
}
/// The **blanket** `CHAT_LOCKED` switch overrides the per-window lock, both ways — the reference's
/// `FCF_Get_ChatLocked()` gate, which is the first line of both verbs.
#[test]
fn the_chat_locked_global_overrides_the_per_window_lock() {
let s = chat_ui();
s.run("FCF_SetLocked(ChatFrame1, nil)").unwrap();
assert!(s
.eval::<bool>("return FCF_CanMove(ChatFrame1) == 1")
.unwrap());
s.run("FCF_Set_ChatLocked(1)").unwrap();
assert_eq!(s.eval::<String>("return CHAT_LOCKED").unwrap(), "1");
assert!(
s.eval::<Option<i64>>("return FCF_CanMove(ChatFrame1)")
.unwrap()
.is_none(),
"the blanket switch wins over an unlocked window"
);
assert!(
!s.eval::<bool>("return ChatFrame1ResizeBottomRight:IsMouseEnabled()")
.unwrap(),
"…and takes the edges back"
);
s.run("FCF_Set_ChatLocked(nil)").unwrap();
assert!(s
.eval::<bool>("return FCF_CanMove(ChatFrame1) == 1")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **ChatFrame2 is not independently movable or resizable**, whatever its own lock says: it takes
/// its rect from ChatFrame1's corners, so a drag of its own would stretch it off the dock. The
/// reference's third gate — `isDocked and chatFrame ~= DEFAULT_CHAT_FRAME`.
#[test]
fn the_docked_combat_log_cannot_be_moved_or_resized_on_its_own() {
let mut s = chat_ui();
s.run("FCF_SetLocked(ChatFrame2, nil)").unwrap();
assert!(
s.eval::<Option<i64>>("return FCF_CanMove(ChatFrame2)")
.unwrap()
.is_none(),
"a docked window that is not the default one owns no geometry"
);
assert!(!s
.eval::<bool>("return ChatFrame2ResizeBottomRight:IsMouseEnabled()")
.unwrap());
// …and it still follows window 1 when window 1 does move.
s.run("FCF_SetLocked(ChatFrame1, nil)").unwrap();
let (cx, cy) = centre(&mut s, "ChatFrame1");
s.mouse_button(cx, cy, "LeftButton", true);
s.mouse_move(cx + 5.0, cy);
s.mouse_move(cx + 60.0, cy);
s.mouse_button(cx + 60.0, cy, "LeftButton", false);
s.resolve();
let one: f64 = s.eval("return ChatFrame1:GetLeft()").unwrap();
let two: f64 = s.eval("return ChatFrame2:GetLeft()").unwrap();
assert_eq!(one, two, "the dock moved as one");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+15 -2
View File
@@ -557,14 +557,27 @@ fn the_lazypig_window_walk_survives_all_seven_indices() {
/// ChatFrame3..7 ship hidden and with no `isDocked`, the reference's own chat-cache state
/// (`DOCKED 0 / SHOWN 0`). This is not cosmetic: `Outfitter.lua:3099` reaches an UNGUARDED
/// `getglobal('ChatFrame'..i..'Tab'):GetText()` for any window that is visible *or* docked, and we
/// build no tabs past ChatFrame1Tab. The same reason keeps `isDocked` off ChatFrame2, whose
/// "Combat Log" tab is deliberately unbuilt.
/// build no tabs past ChatFrame2Tab.
///
/// **ChatFrame1 and ChatFrame2 DO carry it**, and that is the half this test gained with the
/// move/resize arc: the reference's own gates read `chatFrame.isDocked` — `isDocked and chatFrame
/// ~= DEFAULT_CHAT_FRAME` is what stops the Combat Log being dragged out of the dock it is
/// anchored into — so the flag has a consumer now rather than being an advertisement. Both have
/// tabs (1575), so the Outfitter walk below is still safe.
#[test]
fn the_undocked_windows_are_hidden_and_carry_no_is_docked() {
let s = chat_frame();
for i in 1..=2 {
let docked: bool = s
.eval(&format!("return ChatFrame{i}.isDocked ~= nil"))
.unwrap();
assert!(docked, "ChatFrame{i} is docked and says so");
}
for i in 2..=7 {
let shown: bool = s.eval(&format!("return ChatFrame{i}:IsShown()")).unwrap();
assert!(!shown, "ChatFrame{i} ships hidden");
}
for i in 3..=7 {
let docked: bool = s
.eval(&format!("return ChatFrame{i}.isDocked ~= nil"))
.unwrap();
@@ -525,3 +525,161 @@ fn a_row_without_the_flag_has_no_swatch() {
.eval::<bool>("return DropDownList1Button1ColorSwatch:IsVisible()")
.unwrap());
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// The wheel (decision 1592) — the shipped window's generated art, end to end
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// The four elements the reference declares are installed on the SHIPPED window, at the
/// reference's own geometry, and `ColorPickerWheel` publishes under the reference's name — which is
/// load-bearing twice over: `<ColorValueTexture>` anchors to it by name, and an addon re-skinning
/// the picker addresses it by name.
#[test]
fn the_shipped_window_carries_the_wheel_at_the_references_geometry() {
let mut s = picker();
s.run("ShowUIPanel(ColorPickerFrame)").unwrap();
s.resolve();
let (wl, wb, ww, wh): (f32, f32, f32, f32) = s
.eval(
"local r = ColorPickerWheel; \
return r:GetLeft(), r:GetBottom(), r:GetWidth(), r:GetHeight()",
)
.unwrap();
assert_eq!((ww, wh), (128.0, 128.0), "ref l.186-195: a 128 square");
// The window is 365x200 centred on a 1024x768 screen; the wheel is TOPLEFT (16, -32) in it.
let (fl, ft): (f32, f32) = s
.eval("return ColorPickerFrame:GetLeft(), ColorPickerFrame:GetTop()")
.unwrap();
assert_eq!(wl, fl + 16.0);
assert_eq!(wb, ft - 32.0 - 128.0);
let (sl, sw, sh): (f32, f32, f32) = s
.eval(
"local r = ColorPickerFrame:GetColorValueTexture(); \
return r:GetLeft(), r:GetWidth(), r:GetHeight()",
)
.unwrap();
assert_eq!((sw, sh), (32.0, 128.0), "ref l.203-215");
assert_eq!(
sl,
wl + ww + 24.0,
"the strip anchors LEFT to the wheel's RIGHT at +24 — BY NAME"
);
// The markers: the one BLP in this window, at the reference's two crops.
for (getter, want_w, want_h) in [
("GetColorWheelThumbTexture", 10.0, 10.0),
("GetColorValueThumbTexture", 48.0, 14.0),
] {
let (w, h): (f32, f32) = s
.eval(&format!(
"local r = ColorPickerFrame:{getter}(); return r:GetWidth(), r:GetHeight()"
))
.unwrap();
assert_eq!((w, h), (want_w, want_h), "{getter}");
let file: String = s
.eval(&format!("return ColorPickerFrame:{getter}():GetTexture()"))
.unwrap();
assert!(file.contains("UI-ColorPicker-Buttons"), "{getter}: {file}");
}
}
/// **The window can now be used for what it is for**: Dewdrop opens it, the user clicks a hue, and
/// the caller's `func` sees the colour under the cursor. This is the whole of B246's picker path
/// and the thing that was impossible before 1592 — every other test in this file could pass with no
/// wheel at all.
///
/// The click is at the wheel's right rim, which the pick law makes 180° — cyan.
#[test]
fn a_click_on_the_wheel_reaches_the_callers_func() {
let mut s = picker();
// Dewdrop-2.0.lua l.425-465's block, as transcribed in this file's other tests.
s.run(
r#"
picked = nil
ColorPickerFrame.func = function()
local r, g, b = ColorPickerFrame:GetColorRGB()
picked = { r = r, g = g, b = b }
end
ColorPickerFrame.hasOpacity = nil
ColorPickerFrame:SetColorRGB(1, 1, 1)
ShowUIPanel(ColorPickerFrame)
"#,
)
.unwrap();
s.resolve();
let (l, b, w, h): (f32, f32, f32, f32) = s
.eval(
"local r = ColorPickerWheel; \
return r:GetLeft(), r:GetBottom(), r:GetWidth(), r:GetHeight()",
)
.unwrap();
s.mouse_button(l + w - 1.0, b + h * 0.5, "LeftButton", true);
s.mouse_button(l + w - 1.0, b + h * 0.5, "LeftButton", false);
assert!(s.errors().is_empty(), "{:?}", s.errors());
let (r, g, bl): (f64, f64, f64) = s
.eval("return picked.r, picked.g, picked.b")
.expect("func ran with a colour");
assert!(
r < 0.05 && g > 0.95 && bl > 0.95,
"the right rim is 180° — cyan, got ({r:.3}, {g:.3}, {bl:.3})"
);
// And the window's own preview swatch followed, through OnColorSelect.
let swatch = s.extract().into_iter().any(|q| {
matches!(&q.content, QuadContent::Texture { color: Some(c), .. }
if c[0] < 0.05 && c[1] > 0.95)
});
assert!(swatch, "ColorSwatch repainted to the picked colour");
}
/// The two file-less regions reach the renderer as their own content kinds, carrying exactly what
/// changes their pixels and nothing more: the disc carries **no colour at all** (it is drawn at a
/// literal `V = 1`), and the strip carries hue and saturation but **not value**. Anything more
/// would churn these quads on every step of a drag for no pixel — the extract gate is only honest
/// if the content is (decision 1592 §4).
#[test]
fn the_generated_art_reaches_the_renderer_carrying_only_what_moves_a_pixel() {
let mut s = picker();
s.run("ColorPickerFrame:SetColorRGB(0.15, 0.55, 0.75); ShowUIPanel(ColorPickerFrame)")
.unwrap();
s.resolve();
let quads = s.extract();
assert!(
quads
.iter()
.any(|q| matches!(q.content, QuadContent::ColorWheel)),
"the disc draws"
);
let strip = quads
.iter()
.find_map(|q| match q.content {
QuadContent::ColorValue { hue, sat } => Some((hue, sat)),
_ => None,
})
.expect("the strip draws");
// (0.15, 0.55, 0.75) is a cyan-blue: hue 200°, saturation 0.80 (through the client's own
// quantize on the way in — `set_rgb` stores what the 8-bit lattice can hold).
assert!(
(strip.0 - 200.0).abs() < 0.5 && (strip.1 - 0.801).abs() < 0.01,
"the strip carries the live hue/sat, got {strip:?}"
);
// Dropping the brightness moves NO strip pixel — the same quad content comes back.
s.run("ColorPickerFrame:SetColorRGB(0.05, 0.18, 0.25)")
.unwrap();
s.resolve();
let after = s
.extract()
.into_iter()
.find_map(|q| match q.content {
QuadContent::ColorValue { hue, sat } => Some((hue, sat)),
_ => None,
})
.expect("the strip still draws");
assert!(
(after.0 - strip.0).abs() < 2.0 && (after.1 - strip.1).abs() < 0.02,
"a third of the brightness, the same strip: {strip:?} -> {after:?}"
);
}
@@ -0,0 +1,274 @@
//! The colour picker's **generated art** — the hue disc and the brightness ramp.
//!
//! The reference's `<ColorWheelTexture>` and `<ColorValueTexture>` (`ColorPickerFrame.xml` l.186-221)
//! carry no `file=`, and that is not an oversight: there is no BLP anywhere in the 1.12 MPQ chain
//! that is a colour wheel. The client computes those pixels. The engine core therefore hands us the
//! rect and the widget's HSV ([`QuadContent::ColorWheel`]/[`QuadContent::ColorValue`]) and the
//! pixels are made here — the same division of labour the `<Minimap>` slot uses.
//!
//! **Both images are static**, which is why a live drag regenerates nothing: the disc does not
//! depend on the colour at all, and the strip is `rgb(h, s, 1)` times a fixed greyscale ramp
//! (`hsv_to_rgb` scales all three outputs by `v` and by nothing else, so `rgb(h, s, v)` really is
//! `v · rgb(h, s, 1)`).
//!
//! Everything below is byte-verified in wow-re's `system/ui/scratch/colorselect-drawn-appearance.md`
//! (a §5 cross-check dispatched from this repo for exactly this window: six workers in three pairs,
//! one of each briefed cold, plus the orchestrator's own contiguous derivation). What it settled,
//! and what each fact cost to get wrong:
//!
//! * **The disc is a 128×128 generated ARGB texture** (`0x78b580`, 16384 dwords into a
//! function-local static, uploaded once per process), drawn unmodulated — not a vertex-coloured
//! fan. It never reaches the gradient helper `0x77f910`.
//! * **It is drawn at `V = 1.0`, a `mov`-immediate (`0x78b68b mov [ebp-0x24],0x3f800000`), never
//! the widget's `[this+0x330]`.** Lowering the brightness slider does not darken the wheel — the
//! thumb moves and nothing else does. This file first shipped the opposite, as a flagged guess.
//! * **The texel lattice is integers**, `X, Y ∈ [−64, 63]`, drawn iff `X² + Y² ≤ 0x1000` —
//! boundary *inclusive* — and outside is `0x00000000`, a hard alpha cut with no mask and no
//! feathering. `S = √(X²+Y²)/64`; `H = (atan2(Y, X) + π)·180/π`, the exact inverse of the pick
//! law (`colorselect-color-law.md` §5), which is what makes clicking a texel select the colour
//! that texel shows. Row 0 is `Y = +63`: the top.
//! * **The strip is an 8×8 solid texture of `HSV(H, S, 1)` times a vertical vertex gradient**,
//! black at the bottom and white at the top (`0x78b8a0` + `0x78b92a call 0x77f910`, and
//! `0x7705b0` fixes the winding TL/BL/TR/BR). The product is `HSV(H, S, v)` at every row — so
//! `V` moves no strip pixel either. We compute the same product the other way round, one static
//! greyscale ramp times the hue tint, which is exact because **this pass composites in gamma
//! bytes** (decision 0254): the quad's `color × texel` multiply *is* the client's.
use benilla_ui::widget::ColorSelectState;
/// The disc's texel size — the client's own `0x80`, and the size of the reference's element, so at
/// the reference's 1024×768 the image draws 1:1.
pub(super) const WHEEL_PX: u32 = 128;
/// The ramp's texel height (its width is 1 — every column is identical). 256 rows is one step per
/// output level, so the gradient has no banding of its own to add.
pub(super) const RAMP_PX: u32 = 256;
/// The generated-sprite cache keys ([`benilla_assets::WorldAssets::generated_sprite`]).
pub(super) const WHEEL_KEY: &str = "colorselect/wheel";
pub(super) const RAMP_KEY: &str = "colorselect/value-ramp";
/// A colour channel `0.0..=1.0` as the byte an sRGB-format texture wants. The client's colours are
/// already gamma-space values — the same space `Rgba8UnormSrgb` art is authored in — so this is a
/// scale, not a transfer function.
fn byte(c: f32) -> u8 {
(c.clamp(0.0, 1.0) * 255.0 + 0.5) as u8
}
/// The hue disc: `WHEEL_PX²` RGBA texels, `0x00000000` outside the unit circle.
///
/// Faithful to `0x78b580` down to the lattice. The client walks **integer** `X, Y ∈ [−64, 63]` and
/// tests `X² + Y² ≤ 0x1000` in integers — so the circle is centred half a texel off the image
/// centre, its boundary texels are *inside*, and its edge is a hard alpha cut with no feathering.
/// Each of those is visible at the rim, and each is reproduced rather than improved: a
/// half-texel-recentred, anti-aliased disc would be a different circle from the one the pick law
/// inverts, and the pick law is what decides which colour a click lands on.
///
/// Row 0 is the image's TOP and `Y = +63` — the first dword the client writes is `(X=−64, Y=+63)`.
/// The widget's y runs up and the pick law reads the *cursor* in that space, so a flipped row order
/// would mirror every hue about the horizontal.
pub(super) fn wheel_pixels() -> (u32, u32, Vec<u8>) {
let n = WHEEL_PX as usize;
let half = (WHEEL_PX / 2) as i32;
let mut rgba = vec![0u8; n * n * 4];
for row in 0..n {
// `Y = +63 … −64` down the image.
let y = half - 1 - row as i32;
for col in 0..n {
let x = col as i32 - half;
let r2 = x * x + y * y;
if r2 > half * half {
continue; // outside: left as the transparent black it was initialised to
}
let hue = (y as f32).atan2(x as f32).to_degrees() + 180.0;
let sat = (r2 as f32).sqrt() / half as f32;
let rgb = ColorSelectState::hsv_to_rgb(&[hue, sat, 1.0]);
let i = (row * n + col) * 4;
rgba[i] = byte(rgb[0]);
rgba[i + 1] = byte(rgb[1]);
rgba[i + 2] = byte(rgb[2]);
rgba[i + 3] = 255;
}
}
(WHEEL_PX, WHEEL_PX, rgba)
}
/// The brightness ramp: one column, white at the top and black at the bottom, which the strip's
/// quad tints to the live hue. `V` runs 0 at the strip's bottom (the pick law's
/// `(y − bottom)/(top − bottom)`), and row 0 is the top, so row 0 is `V = 1`.
pub(super) fn ramp_pixels() -> (u32, u32, Vec<u8>) {
let h = RAMP_PX as usize;
let mut rgba = vec![0u8; h * 4];
for row in 0..h {
let v = 1.0 - (row as f32 + 0.5) / RAMP_PX as f32;
let c = byte(v);
let i = row * 4;
rgba[i] = c;
rgba[i + 1] = c;
rgba[i + 2] = c;
rgba[i + 3] = 255;
}
(1, RAMP_PX, rgba)
}
/// The tint the **disc** quad draws with: nothing but the frame's alpha.
///
/// The wheel is drawn unmodulated at `V = 1`. That is a `mov`-immediate in the fill loop
/// (`0x78b68b`), not a read of `[this+0x330]`, and a census of `[edi+0x3NN]` over the whole
/// function finds only the two `+0x318` hits — so the brightness slider genuinely does not dim the
/// disc. It reads odd next to every other picker on the planet, and it is what the client does.
pub(super) fn wheel_tint(alpha: f32) -> [f32; 4] {
[1.0, 1.0, 1.0, alpha]
}
/// The tint the **ramp** quad draws with: the current hue and saturation at full value. The ramp's
/// own greyscale supplies the `v` factor, so the product is `rgb(h, s, v)` at every row.
pub(super) fn ramp_tint(hue: f32, sat: f32, alpha: f32) -> [f32; 4] {
let rgb = ColorSelectState::hsv_to_rgb(&[hue, sat, 1.0]);
[rgb[0], rgb[1], rgb[2], alpha]
}
#[cfg(test)]
mod tests {
use super::*;
/// The texel's own `(X, Y)` — the client's integer lattice, `X, Y ∈ [−64, 63]`, row 0 at the
/// top. Stated once here so a test cannot quietly disagree with [`wheel_pixels`] about it.
fn lattice(col: usize, row: usize) -> (i32, i32) {
let half = (WHEEL_PX / 2) as i32;
(col as i32 - half, half - 1 - row as i32)
}
/// The one property the disc must have: the colour a texel *shows* is the colour a click on
/// that texel *selects*. The right-hand side here is the pick law from wow-re
/// `colorselect-color-law.md` §5, which `benilla-ui`'s own `wheel_hs` transcribes — if the two
/// ever part company, one of these sides moves and this fails.
#[test]
fn every_texel_shows_the_colour_a_click_on_it_would_pick() {
let (w, h, rgba) = wheel_pixels();
assert_eq!((w, h), (WHEEL_PX, WHEEL_PX));
let half = f32::from(u16::try_from(WHEEL_PX / 2).unwrap());
for row in 0..h as usize {
for col in 0..w as usize {
let (x, y) = lattice(col, row);
let i = (row * w as usize + col) * 4;
if x * x + y * y > (WHEEL_PX / 2).pow(2) as i32 {
continue;
}
// The pick law, in the widget's own normalised coordinates.
let (nx, ny) = (x as f32 / half, y as f32 / half);
let hue = (ny.atan2(nx) + std::f32::consts::PI).to_degrees();
let want =
ColorSelectState::hsv_to_rgb(&[hue, (nx * nx + ny * ny).sqrt().min(1.0), 1.0]);
assert_eq!(
(rgba[i], rgba[i + 1], rgba[i + 2]),
(byte(want[0]), byte(want[1]), byte(want[2])),
"texel ({col}, {row}) shows a colour a click there would not pick"
);
}
}
}
/// The disc's cardinal points, spelled out — this is what pins the *orientation* of the whole
/// image, and it is the half a sign error would silently mirror. wow-re read it off the fill
/// loop: **red LEFT, chartreuse BOTTOM, cyan RIGHT, violet TOP**, hue rising counter-clockwise.
#[test]
fn the_discs_cardinal_hues_are_the_pick_laws() {
let (w, h, rgba) = wheel_pixels();
let at = |col: usize, row: usize| {
let i = (row * w as usize + col) * 4;
[rgba[i], rgba[i + 1], rgba[i + 2]]
};
// The row/col where the lattice coordinate is 0 — not the image centre, because the
// integer lattice is half a texel off it (`Y = 63 − row` has no zero-crossing at 63.5).
let mid = (WHEEL_PX / 2) as usize;
let left = at(1, mid);
let right = at(w as usize - 2, mid);
assert!(
left[0] > 200 && left[1] < 40 && left[2] < 40,
"the left rim is 0°/360° — red, got {left:?}"
);
assert!(
right[0] < 40 && right[1] > 200 && right[2] > 200,
"the right rim is 180° — cyan, got {right:?}"
);
let top = at(mid, 1);
let bottom = at(mid, h as usize - 2);
assert!(
top[2] > 200 && top[1] < 40,
"the top rim is 270° — violet, got {top:?}"
);
assert!(
bottom[1] > 200 && bottom[2] < 40,
"the bottom rim is 90° — chartreuse, got {bottom:?}"
);
}
/// The alpha is a hard cut at `X² + Y² ≤ 0x1000`, boundary **inclusive** — no feathering, no
/// mask. A disc with an opaque square around it would paint a black box over the window; a
/// disc one texel small would clip the fully-saturated rim the pick law can still reach.
#[test]
fn the_alpha_is_the_clients_hard_inclusive_cut() {
let (w, _, rgba) = wheel_pixels();
let n = w as usize;
let alpha = |col: usize, row: usize| rgba[(row * n + col) * 4 + 3];
let half = (WHEEL_PX / 2) as i32;
for row in 0..n {
for col in 0..n {
let (x, y) = lattice(col, row);
let inside = x * x + y * y <= half * half;
assert_eq!(
alpha(col, row) == 255,
inside,
"texel ({col}, {row}) = ({x}, {y}), r² = {}",
x * x + y * y
);
}
}
// The boundary itself: (0, −64) is exactly r² = 0x1000 and the client keeps it (`jg`).
assert_eq!(alpha(half as usize, n - 1), 255, "the cut is inclusive");
// Saturation 0 is white, and the lattice's zero is at (64, 63) — one texel up from centre.
let c = ((half - 1) as usize * n + half as usize) * 4;
assert!(
rgba[c] > 250 && rgba[c + 1] > 250 && rgba[c + 2] > 250,
"saturation 0 is white, got {:?}",
&rgba[c..c + 3]
);
}
/// The ramp times its tint reproduces `hsv_to_rgb` at that row's `V` — the identity the whole
/// two-static-images design rests on, and the reason this can be one image instead of one per
/// hue. Checked in the gamma space both sides live in (decision 0254).
#[test]
fn the_ramp_times_its_tint_is_the_colour_at_that_brightness() {
let (_, h, rgba) = ramp_pixels();
for (hue, sat) in [(0.0, 1.0), (120.0, 0.5), (275.0, 1.0), (60.0, 0.25)] {
let tint = ramp_tint(hue, sat, 1.0);
for row in (0..h as usize).step_by(17) {
let v = 1.0 - (row as f32 + 0.5) / h as f32;
let want = ColorSelectState::hsv_to_rgb(&[hue, sat, v]);
let ramp = f32::from(rgba[row * 4]) / 255.0;
for ch in 0..3 {
let got = ramp * tint[ch];
assert!(
(got - want[ch]).abs() <= 2.0 / 255.0,
"row {row} (V={v:.3}) hue {hue}: channel {ch} is {got:.4}, \
hsv_to_rgb says {:.4}",
want[ch]
);
}
}
}
}
/// The disc does not read the widget's brightness at all (`0x78b68b`'s literal `1.0f`).
#[test]
fn the_disc_ignores_the_brightness_slider() {
assert_eq!(wheel_tint(1.0), [1.0, 1.0, 1.0, 1.0]);
assert_eq!(
wheel_tint(0.5),
[1.0, 1.0, 1.0, 0.5],
"only the frame alpha rides"
);
}
}
@@ -15,6 +15,7 @@ use crate::ui_pass::{UiQuad, UiQuads, UvRect};
use crate::ui_text::UiFontAtlas;
use benilla_assets::WorldAssets;
mod colorselect;
mod cooldown;
mod text;
use cooldown::cooldown_quads;
@@ -571,9 +572,13 @@ pub(super) fn drive_script(
// autocast-shine token (decision 1383) is a side-channel kind wearing a Texture's
// clothes, so it is excluded by path.
let simple = |eq: &benilla_ui::script::ExtractedQuad| match &eq.content {
QuadContent::Frame | QuadContent::Cooldown { .. } | QuadContent::Backdrop { .. } => {
true
}
QuadContent::Frame
| QuadContent::Cooldown { .. }
| QuadContent::Backdrop { .. }
// Both colour-picker arms write one quad and nothing else — and they change on every
// step of a drag, which is exactly the traffic the splice exists for.
| QuadContent::ColorWheel
| QuadContent::ColorValue { .. } => true,
QuadContent::Texture {
portrait_unit: None,
path,
@@ -1070,6 +1075,43 @@ fn convert_entry(
..default()
});
}
// The colour picker's hue disc: a generated image (there is no BLP that is a colour
// wheel), tinted by the widget's value. See [`colorselect`] for why one static image
// covers every colour.
QuadContent::ColorWheel => {
let Some(assets) = assets.as_mut() else {
return;
};
let handle =
assets.generated_sprite(colorselect::WHEEL_KEY, images, colorselect::wheel_pixels);
out.push(UiQuad {
rect,
z_key: eq.z,
texture: Some(handle),
uv: UvRect::FULL,
color: colorselect::wheel_tint(eq.alpha),
clip,
..default()
});
}
// Its brightness strip: one greyscale ramp tinted to the live hue at full value, which is
// exactly `rgb(h, s, v)` at every row.
QuadContent::ColorValue { hue, sat } => {
let Some(assets) = assets.as_mut() else {
return;
};
let handle =
assets.generated_sprite(colorselect::RAMP_KEY, images, colorselect::ramp_pixels);
out.push(UiQuad {
rect,
z_key: eq.z,
texture: Some(handle),
uv: UvRect::FULL,
color: colorselect::ramp_tint(hue, sat, eq.alpha),
clip,
..default()
});
}
QuadContent::Backdrop {
path,
color,
@@ -230,3 +230,87 @@ fn hovering_the_indicator_shows_and_live_updates_the_game_time_tooltip() {
"the 6-px left inset band is not hoverable"
);
}
/// **The ping's click path, driven by a real mouse event** (decision 1596).
///
/// The point of the test is the *path*, not the arithmetic: `Minimap_OnClick` is only reached if
/// the widget is mouse-enabled, hit-tests, and its `OnMouseUp` fires — and what it parks has to be
/// centre-relative UI units, because that is the contract the app's conversion is written against.
/// Calling `Minimap_OnClick()` from `s.run` would prove none of it (1234 §2).
#[test]
fn a_click_on_the_minimap_parks_a_centre_relative_ping_request() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run("function GetMinimapZoneText() return '' end")
.unwrap();
s.run("function PlaySound() end").unwrap();
load_xml(&s, "Fonts.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "MinimapCluster.xml");
s.resolve();
let cx = s
.eval::<f32>("local x = Minimap:GetCenter(); return x")
.unwrap();
let cy = s
.eval::<f32>("local _, y = Minimap:GetCenter(); return y")
.unwrap();
assert!(cx > 0.0 && cy > 0.0, "the widget resolved: ({cx}, {cy})");
// Nothing is parked until somebody clicks.
assert_eq!(s.take_minimap_ping_request(), None);
// A click 20 UI units right and 12 up of the centre — inside the 70-unit disc.
s.mouse_button(cx + 20.0, cy + 12.0, "LeftButton", true);
s.mouse_button(cx + 20.0, cy + 12.0, "LeftButton", false);
let (dx, dy) = s
.take_minimap_ping_request()
.expect("OnMouseUp → Minimap_OnClick → PingLocation");
assert!((dx - 20.0).abs() < 0.01, "x right of centre: {dx}");
assert!((dy - 12.0).abs() < 0.01, "y UP from centre: {dy}");
// Draining is a drain: the app must not see the same click twice.
assert_eq!(s.take_minimap_ping_request(), None);
// A click that misses the widget entirely never reaches the handler.
s.mouse_button(10.0, 10.0, "LeftButton", true);
s.mouse_button(10.0, 10.0, "LeftButton", false);
assert_eq!(s.take_minimap_ping_request(), None, "off-widget is no ping");
}
/// `Minimap:GetPingPosition()` answers **nil** with no ping, and the live pair once the app
/// publishes one — never `(0, 0)` for "there isn't one", which a caller cannot tell from a ping
/// under its own feet.
#[test]
fn get_ping_position_is_nil_until_there_is_a_ping() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run("function GetMinimapZoneText() return '' end")
.unwrap();
s.run("function PlaySound() end").unwrap();
load_xml(&s, "Fonts.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "MinimapCluster.xml");
assert_eq!(
s.eval::<Option<f32>>("return (Minimap:GetPingPosition())")
.unwrap(),
None
);
s.set_minimap_ping(Some((0.25, -0.125)));
let x = s
.eval::<f32>("local x = Minimap:GetPingPosition(); return x")
.unwrap();
let y = s
.eval::<f32>("local _, y = Minimap:GetPingPosition(); return y")
.unwrap();
assert!(
(x - 0.25).abs() < 1e-6 && (y + 0.125).abs() < 1e-6,
"{x} {y}"
);
s.set_minimap_ping(None);
assert_eq!(
s.eval::<Option<f32>>("return (Minimap:GetPingPosition())")
.unwrap(),
None
);
}
+3
View File
@@ -691,6 +691,9 @@ mod unit_frame_tests;
#[cfg(test)]
mod unit_popup_tests;
#[cfg(test)]
mod chat_resize_tests;
#[cfg(test)]
mod dropdown_tests;
@@ -361,3 +361,109 @@ fn reagent_slots_carry_the_questitemtemplate_shape_in_both_windows() {
);
}
}
/// **A row click paints the selection glow, and a row hover paints nothing** — the two things the
/// director saw wrong in a live window, pinned in both directions (decision 1598).
///
/// Neither was subtle. Both survived because this suite drove the window's tabs, dropdowns and
/// reagent slots without ever clicking or hovering a LIST ROW:
///
/// * `BenillaTradeSkillFrame_Update` addressed `BenillaTradeSkillHighlight` — the *texture* — for
/// the Hide/SetPoint/Show that the reference does on `TradeSkillHighlightFrame`, the *frame*
/// (Blizzard_TradeSkillUI.lua l.99/142-143; only l.200's `SetVertexColor` is the texture's).
/// The frame is declared `hidden="true"`, so it never once became visible and no selection ever
/// highlighted. `CraftFrame.xml`/`TrainerFrame.xml` both split the two correctly already.
/// * The row template carried a `GameTooltip:SetTradeSkillItem` OnEnter that the reference has no
/// trace of: `TradeSkillSkillButtonTemplate` overrides `OnClick` alone, and its base
/// `ClassTrainerSkillButtonTemplate` (`Interface\FrameXML\ClassTrainerFrameTemplates.xml`) only
/// recolours `$parentSubText` on hover. The real window's list is bare on hover.
///
/// The hover half asserts through the row's own script table rather than a synthetic mouse-over, so
/// it fails on the *existence* of a hover handler — re-adding one "harmlessly" goes red here even if
/// the tooltip it opens happens to be empty in a headless VM.
#[test]
fn a_row_click_shows_the_selection_glow_and_a_row_hover_shows_nothing() {
let mut s = UiScript::new().unwrap();
load_ui(&s);
s.set_trade_skill(Some(state()));
s.fire_event("TRADE_SKILL_SHOW", vec![]);
// Row 1 is the "Mail" header, row 2 the first recipe (the two-group book `state()` builds).
assert_eq!(
s.eval::<String>("local _, t = GetTradeSkillInfo(2) return t")
.unwrap(),
"medium",
"row 2 is a recipe, not a header (state()'s own difficulty)"
);
// Opening already selects `GetFirstTradeSkill()` (the OnEvent path), so the glow is up before
// any click — exactly what the director's screenshot should have shown and didn't.
let glow_on_row = |s: &UiScript, n: i64| {
let (glow, row) = (
s.eval::<f64>("return BenillaTradeSkillHighlightFrame:GetTop()")
.unwrap(),
s.eval::<f64>(&format!("return BenillaTradeSkillSkill{n}:GetTop()"))
.unwrap(),
);
(glow - row).abs() < 0.01
};
assert!(
s.eval::<bool>("return BenillaTradeSkillHighlightFrame:IsShown()")
.unwrap(),
"the show-time auto-selection glows — the FRAME, not just its texture"
);
assert!(glow_on_row(&s, 2), "and it is parked on the first recipe");
// A click on the OTHER Mail recipe moves it, rather than leaving it at the window's TOPLEFT.
s.run("BenillaTradeSkillSkill3:Click()").unwrap();
pump(&mut s);
assert_eq!(
s.eval::<i64>("return GetTradeSkillSelectionIndex()")
.unwrap(),
3,
"the click selected row 3"
);
assert!(
s.eval::<bool>("return BenillaTradeSkillHighlightFrame:IsShown()")
.unwrap()
&& glow_on_row(&s, 3),
"the glow followed the click to row 3"
);
// Fold every group away and no recipe row is visible to carry it.
s.run("BenillaTradeSkillCollapseAllButton:Click()").unwrap();
pump(&mut s);
assert!(
!s.eval::<bool>("return BenillaTradeSkillHighlightFrame:IsShown()")
.unwrap(),
"no recipe row on screen → no glow (headers never take the selection)"
);
// The hover half. Positive control first, so a `GetScript` that answered nil for everything
// could not quietly pass the real assertions below: the reagent slot DOES tooltip on hover.
assert!(
s.eval::<bool>("return BenillaTradeSkillReagent1:GetScript(\"OnEnter\") ~= nil")
.unwrap(),
"control: a reagent slot has an OnEnter, so GetScript reports real handlers"
);
// The list row has no hover script at all, in the template or as a global.
for handler in ["OnEnter", "OnLeave"] {
assert!(
s.eval::<bool>(&format!(
"return BenillaTradeSkillSkill2:GetScript(\"{handler}\") == nil"
))
.unwrap(),
"a list row must carry no {handler} — the reference's rows never tooltip"
);
}
for f in [
"BenillaTradeSkillSkillButton_OnEnter",
"BenillaTradeSkillSkillButton_OnLeave",
] {
assert!(
s.eval::<bool>(&format!("return getglobal(\"{f}\") == nil"))
.unwrap(),
"{f} must not exist"
);
}
}
@@ -70,6 +70,11 @@ pub struct WorldAssets {
/// (decision 0203). Its own cache for the same reason as the others: the format is baked into
/// the GPU image.
masks: HashMap<String, Option<Handle<Image>>>,
/// **Generated** UI sprites — pixels this client computes rather than decodes, keyed by a
/// caller-chosen static name ([`Self::generated_sprite`]). Its own cache for the same reason as
/// every other one: the sampler bakes into the GPU image. Nothing about the *content* lives
/// here — the caller supplies the RGBA — so this stays a cache, not a second art pipeline.
generated: HashMap<&'static str, Handle<Image>>,
/// The **loose-file root for `Interface\AddOns\` sprite paths** — the AddOns folder, installed
/// by the app ([`Self::set_loose_sprite_root`]), `None` until then and in every capture run
/// (the app resolves it through `local_state`, which is hermetic under `$WOW_CAPTURE`).
@@ -335,6 +340,7 @@ impl WorldAssets {
tiled_sprites: HashMap::new(),
portraits: HashMap::new(),
masks: HashMap::new(),
generated: HashMap::new(),
loose_root: None,
model_materials: SpatialCache::default(),
shared_light,
@@ -429,6 +435,32 @@ impl WorldAssets {
loaded
}
/// A sprite whose pixels this client **computes**, cached under `key` and built at most once.
/// `make` returns `(width, height, rgba8)` and runs only on the first ask.
///
/// The customer is the colour picker: the reference's `<ColorWheelTexture>` and
/// `<ColorValueTexture>` carry no `file=` at all, because the hue disc and the brightness ramp
/// are generated by the client and no BLP in the chain is either of them. The generation itself
/// deliberately stays with the caller — the pixel law belongs to the widget, not to the asset
/// layer, which knows only how to cache and upload.
///
/// Uploaded through [`sprite_image`], so it samples exactly like every other UI sprite:
/// sRGB, clamp-to-edge, linear, no mips.
pub fn generated_sprite(
&mut self,
key: &'static str,
images: &mut Assets<Image>,
make: impl FnOnce() -> (u32, u32, Vec<u8>),
) -> Handle<Image> {
if let Some(cached) = self.generated.get(key) {
return cached.clone();
}
let (w, h, rgba) = make();
let handle = images.add(sprite_image(w, h, rgba));
self.generated.insert(key, handle.clone());
handle
}
/// A **portrait** sprite: [`Self::sprite_texture`]'s clamp/sRGB decode with a circular alpha mask
/// baked in ([`portrait_image`]) — the unit-frame portrait, round like the client's. Same
/// extensionless→`.blp` resolve; its own cache (the mask bakes into the GPU image, so a path
@@ -827,6 +827,72 @@ fn real_spell_catalog_classifies_combat_initiation() {
);
}
/// **`modalNextSpell` — `Spell.dbc` column 38** on the real build-5875 file (decision 1597, bug
/// B280). This is the column that makes casting a hunter shot start Auto Shot, so a column slip
/// here is a silently-broken hunter; and the shape of the census is itself the evidence that the
/// column is the one wow-re named — non-zero on 57 of 22357 rows, 52 of those naming spell 75.
/// Skips without client data.
#[test]
fn real_spell_catalog_reads_modal_next_spell() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_spell_catalog(&mut chain).expect("load Spell/SpellIcon");
// Rank 1 of each hunter shot, and the two spells that terminate the chain.
for (id, name, next) in [
(1978u32, "Serpent Sting", 75u32),
(3044, "Arcane Shot", 75),
(2643, "Multi-Shot", 75),
(5116, "Concussive Shot", 75),
(19434, "Aimed Shot", 75),
(3034, "Viper Sting", 75),
(3043, "Scorpid Sting", 75),
(3674, "Black Arrow", 75),
(14274, "Distracting Shot", 75),
// Auto Shot's own column 38 is 0 — the chain is one hop, and cannot loop.
(75, "Auto Shot", 0),
// A melee strike and an ordinary cast carry nothing here.
(78, "Heroic Strike", 0),
(133, "Fireball", 0),
] {
let d = cat
.get(id)
.unwrap_or_else(|| panic!("{name} ({id}) in the catalog"));
assert_eq!(
d.modal_next_spell, next,
"{name} ({id}) modalNextSpell (column 38)"
);
}
// The census — and the reason to believe the column map. A wrong column would not land on a
// 57-row population that is 91 % one value, and that value the spell every one of those rows
// exists to resume.
let chained: Vec<(u32, u32)> = cat
.iter()
.filter(|(_, d)| d.modal_next_spell != 0)
.map(|(id, d)| (id, d.modal_next_spell))
.collect();
assert_eq!(
chained.len(),
57,
"5875 ships 57 rows with a non-zero column 38, got {}",
chained.len()
);
let to_auto_shot = chained.iter().filter(|&&(_, next)| next == 75).count();
assert_eq!(
to_auto_shot, 52,
"52 of the 57 name Auto Shot (the rest: three (TEST) bow shot rows → 59, two Minigun → 23675)"
);
// Nothing points at a spell that would chain onward — the one-hop property, in data.
for &(id, next) in &chained {
let onward = cat.get(next).map(|d| d.modal_next_spell).unwrap_or(0);
assert!(
onward == 0 || onward == next,
"spell {id} chains {next}, which itself chains {onward} — the chain must not walk"
);
}
}
/// The crafting columns (decision 0437) on the real build-5875 `Spell.dbc`: `EffectItemType`
/// (103-105) and `RequiresSpellFocus` (15), cross-checked against the live vmangos
/// `spell_template` rows queried at pin time (2963 → creates 2996, 2738 → 2845, 3920 → 8067 with
+41 -8
View File
@@ -50,6 +50,35 @@ pub struct SpellDisplay {
/// `AttributesEx3` (column 9, `SpellRec+0x24`) — only bit `0x8000` is consumed
/// ([`Self::melee_white_damage`]).
pub attributes_ex3: u32,
/// **`modalNextSpell`** (`Spell.dbc` column 38, `SpellRec + 0x98`) — the spell this one makes
/// the client cast **by itself**, one server round-trip later, with no user input and no addon.
/// `0` for all but 57 of the 22357 shipped rows.
///
/// `HandleCastResult 0x6e7330` — the `SMSG_CAST_RESULT` (0x130) handler — reads it for the
/// spell the reply names, **on success as well as failure** (`0x6e7356 cmp [ebp+0xf],0x2` /
/// `0x6e735a jne` sends a non-failure straight to the same block the failure path falls into
/// at `0x6e73eb`), provided the reply is for the in-flight cast (`0x6e7408` vs `[0xceca88]`).
/// Then, at `0x6e7447`:
///
/// - `0` → nothing happens (`0x6e744f je`);
/// - `== [0xceac30]`, the **running** auto-repeat → the pending-cast record is re-armed and
/// nothing is cast (`0x6e745d`) — which is why a second sting does not restart Auto Shot or
/// reset its swing timer;
/// - otherwise → the client **casts it** (`0x6e74aa call 0x6e5a90` → `TryCast`), at the null
/// target guid, through the ordinary ladder.
///
/// **This is how a hunter starts shooting.** Every rank of every hunter shot — Serpent Sting,
/// Arcane Shot, Multi-Shot, Concussive Shot, Aimed Shot, Viper/Scorpid Sting, Black Arrow,
/// Distracting Shot — carries **75 (Auto Shot)** here, and Auto Shot's own column 38 is `0`, so
/// the chain is exactly one hop and cannot loop. The only other non-zero values in the shipped
/// file are three "(TEST) bow shot" rows → 59 and two `Minigun` rows → 23675 (self-referential,
/// absorbed by the equal-branch).
///
/// wow-re `spell/scratch/modalnext-chain-cast.md` (§5 round, 7 agents + orchestrator byte
/// arbitration); benilla decision 1597. It **corrects** the reading in 0994 §4 — the client
/// really does not start the repeat from the sting's *own* send, and then starts it from a
/// *second cast it issues itself*.
pub modal_next_spell: u32,
/// `Attributes & 0x40` (`SPELL_ATTR_PASSIVE`, module docs) — the spellbook's gray-and-refuse
/// gate (consumed by `benilla-ui/src/script/spellbook.rs`, decision 0216 §8).
pub passive: bool,
@@ -271,6 +300,7 @@ impl Default for SpellDisplay {
attributes: 0,
attributes_ex: 0,
attributes_ex2: 0,
modal_next_spell: 0,
attributes_ex3: 0,
passive: false,
cast_ui: 0,
@@ -640,14 +670,17 @@ impl SpellDisplay {
/// 6e8402 call 0x6131a0(ecx=caster, guid) ; START MELEE AUTO-ATTACK
/// ```
///
/// **Only the bit20 leg is modelled**, deliberately. The `rec+0x54 bit3` leg can only add
/// spells that already pass `0x6e5200` with bit20 CLEAR — and those started their attack at
/// the *send* (`initiates_auto_attack`), so by their GO the reference's `[+0xc48]` is set and
/// `0x6e83e7` refuses. It is unreachable-in-effect, and `rec+0x54`'s column is still only
/// INFERRED in wow-re (`combat-feel-law.md` §A3 owes it to the DBC), so building it would be
/// guessing at no gain. Modelling it would also be actively *wrong* here: our engaged mirror
/// is the server-echoed `Engaged`, not a local lock, so a leg that the reference silences
/// with `[+0xc48]` would fire a second `CMSG_ATTACKSWING` on our side.
/// **Only the bit20 leg is modelled**, deliberately — and the §5 that closed B280 measured the
/// other one rather than leaving it to the argument below. `rec+0x54` is `Spell.dbc` **column
/// 21, `InterruptFlags`** (VERIFIED position; bit 3's *semantics* stay INFERRED), which is
/// [`Self::interrupt_flags`] — so we could build the leg today. Its live set in the shipped
/// file is **8 rows, 3 names** (Slam, Shield Slam, Polymorphic Ray) and is **disjoint from the
/// 36** bit20 rows, so every one of them passes `0x6e5200` with bit20 CLEAR and therefore
/// started its attack at the *send* ([`Self::initiates_auto_attack`]); by their GO the
/// reference's `[+0xc48]` is set and `0x6e83e7` refuses. Unreachable-in-effect, measured, not
/// assumed. Modelling it would also be actively *wrong* here: our engaged mirror is the
/// server-echoed `Engaged`, not a local lock, so a leg the reference silences with `[+0xc48]`
/// would fire a second `CMSG_ATTACKSWING` on our side.
///
/// The 36 spells that reach this and nothing else are the stealth openers and positional
/// strikes — Backstab, Garrote, Ambush, Cheap Shot, Shred, Ravage, Pounce — plus Judgement:
+12
View File
@@ -185,6 +185,17 @@ const COL_MANA_PER_SECOND: usize = 34;
/// `GetMinMaxRange 0x6e3480` resolves (wow-re `wave-cooldown.md`). Same empirical pin (Auto
/// Shot/Aimed Shot 114, Throw 74, Charge 95, Fireball 35).
const COL_RANGE_INDEX: usize = 36;
/// `modalNextSpell` — `Spell.dbc` **column 38** (`SpellRec + 0x98`): a spell the client casts **by
/// itself**, with no user input, when the server's `SMSG_CAST_RESULT` for this spell arrives. See
/// [`SpellDisplay::modal_next_spell`] for the whole law and the shipped-file census.
///
/// The column's position is chain-locked the same way the interrupt trio's is (offset = column×4,
/// between the verified `+0x90` = 36 rangeIndex and `+0xf4` = 61 Effect[0]); the name is wow-re's
/// (`modalnext-chain-cast.md` §3/§11) and the community's. This repo's own reason to trust it is
/// the distribution: non-zero on 57 of 22357 rows, and 52 of those 57 name spell **75, Auto Shot**,
/// every one of them a hunter shot. A column that fell where this one falls by accident could not
/// look like that.
const COL_MODAL_NEXT_SPELL: usize = 38;
/// `StartRecoveryCategory` (`SpellRec+0x274`, `0x274/4 == 157`) / `StartRecoveryTime` (`+0x278`,
/// 158) — the **global-cooldown** pair `StartGlobalCooldown 0x6e2de0` reads at the local
/// cast-send (`0x6e58fb`, wow-re `wave-cast.md`, VERIFIED). Category 133 / 1500 ms for ordinary
@@ -776,6 +787,7 @@ pub fn load_spell_catalog(chain: &mut Chain) -> Result<SpellCatalog> {
mana_cost_per_level: u32_at(r, COL_MANA_COST_PER_LEVEL).unwrap_or(0),
mana_per_second: u32_at(r, COL_MANA_PER_SECOND).unwrap_or(0),
range_index: u32_at(r, COL_RANGE_INDEX).unwrap_or(0),
modal_next_spell: u32_at(r, COL_MODAL_NEXT_SPELL).unwrap_or(0),
targets: u32_at(r, COL_TARGETS).unwrap_or(0),
implicit_target_a1: u32_at(r, COL_IMPLICIT_TARGET_A1).unwrap_or(0),
stances: u32_at(r, COL_STANCES).unwrap_or(0),
+1
View File
@@ -797,6 +797,7 @@ impl Loader<'_> {
// instead of calling SetNormalTexture on something that has no such method.
self.apply_statusbar(el, wrapper, dbg_name);
self.apply_slider(el, wrapper, self_name, dbg_name);
self.apply_colorselect(el, wrapper, self_name, dbg_name);
self.apply_button(el, wrapper, self_name, dbg_name);
self.apply_editbox(el, wrapper, dbg_name);
self.apply_messageframe(el, wrapper, dbg_name);
+80
View File
@@ -453,6 +453,7 @@ impl Loader<'_> {
self.call_region(&region, "SetTexCoord", tc, dbg);
}
if let Some(rname) = tt.name().map(|raw| framexml::resolve_name(raw, self_name)) {
crate::script::region::publish_region_name(self.lua(), &rname, &region);
if let Err(e) = self.lua().globals().set(rname.clone(), region) {
self.report
.warnings
@@ -463,6 +464,85 @@ impl Loader<'_> {
}
}
/// `<ColorSelect>`'s four texture sub-elements (RF-28 loader hooks `0x78b580`, `0x78b850`,
/// `0x78b8a0`, `0x78ba90`) — the hue wheel, the brightness strip, and a marker for each.
/// Structurally `apply_slider`'s `<ThumbTexture>` loop, four times over.
///
/// The one thing that is NOT like a thumb: **two of the four carry no `file=`, and that is not
/// an omission.** The client generates the wheel and the strip; there is no BLP in the chain
/// that is a colour wheel. So the setter is still called — with nothing — because the *region*
/// must exist regardless: it is what layout resolves, what the press handler hit-tests, and
/// what the app renderer paints into. An element that skipped the setter would leave the picker
/// with no wheel to click.
pub(super) fn apply_colorselect(
&mut self,
el: &Element,
wrapper: &Table,
self_name: &str,
dbg: &str,
) {
if !el.tag.eq_ignore_ascii_case("ColorSelect") {
return;
}
let layer = el.attr("drawLayer").map(str::to_string);
for (tag, setter, getter) in [
(
"ColorWheelTexture",
"SetColorWheelTexture",
"GetColorWheelTexture",
),
(
"ColorWheelThumbTexture",
"SetColorWheelThumbTexture",
"GetColorWheelThumbTexture",
),
(
"ColorValueTexture",
"SetColorValueTexture",
"GetColorValueTexture",
),
(
"ColorValueThumbTexture",
"SetColorValueThumbTexture",
"GetColorValueThumbTexture",
),
] {
for raw in children_named(el, tag) {
let expanded = self.expand_region(raw);
let tt = &expanded;
if let Some(file) = tt.attr("file") {
self.call(wrapper, setter, (file.to_string(), layer.clone()), dbg);
} else if let Some(c) = children_named(tt, "Color").next().map(color_of) {
self.call(wrapper, setter, (c[0], c[1], c[2], c[3]), dbg);
} else {
// The file-less form — create the region and leave it for the renderer.
self.call(wrapper, setter, (), dbg);
}
if let Ok(region) = wrapper.call_method::<Table>(getter, ()) {
if let Some(mode) = tt.attr("alphaMode") {
self.call_region(&region, "SetBlendMode", mode.to_string(), dbg);
}
self.apply_region_layout(tt, &region, self_name, dbg);
if let Some(tc) = tex_coords_of(tt) {
self.call_region(&region, "SetTexCoord", tc, dbg);
}
if let Some(rname) = tt.name().map(|raw| framexml::resolve_name(raw, self_name))
{
// Into the region-name registry as well as `_G`: `<ColorValueTexture>`
// anchors to `ColorPickerWheel` BY NAME, and a name that only reaches the
// globals resolves to nothing there.
crate::script::region::publish_region_name(self.lua(), &rname, &region);
if let Err(e) = self.lua().globals().set(rname.clone(), region) {
self.report
.warnings
.push(format!("{dbg}: {tag} global '{rname}': {e}"));
}
}
}
}
}
}
/// `<SimpleHTML>` LoadXML extras — `CSimpleHTML::LoadXML 0x78a130`, whose whole job past the
/// base `CSimpleFrame::LoadXML 0x769820` is to fill the four **element fonts** and the
/// hyperlink format (wow-re `simplehtml-markup-engine.md` §5.5):
+70 -12
View File
@@ -60,6 +60,7 @@
//! | `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` |
//! | `SetChatWindowLocked(id, isLocked)` | `0x4a1650` | the cache's `LOCKED` |
//!
//! (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
@@ -92,9 +93,12 @@
//! 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).
//! **`locked` joined them the day the windows could move.** `SetChatWindowLocked(id, isLocked)` is
//! the fourth setter here now: the chat tab's *Lock/Unlock Window* row is what turns the resize
//! grips and the tab drag on, so a value that used to be a constant has a player-reachable writer
//! and belongs in the record with the rest. The other five stay constants, and that is the honest
//! tree rather than an omission: benilla has no rename, no undock and no window create/close, so
//! `name`, `shown` and `docked` still have nothing that could move them (0288 §2).
//!
//! 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
@@ -127,9 +131,9 @@ pub(super) const NUM_CHAT_WINDOWS: usize = 7;
/// `(shown, docked)` per window, 1-based, as the tuple's *Lua* values: `None` where the reference
/// answers `nil`. Everything else in the tuple is identical across all seven windows on a stock
/// client — name `""`, size `0`, colour `0,0,0`, alpha `0` — so only the two that differ are
/// tabulated. `locked` is `1` for every window (the cache's `LOCKED 1`), and benilla has no unlock
/// path at all, so it is a constant below rather than a column here.
/// client — name `""`, size `0`, colour `0,0,0`, alpha `0`, `LOCKED 1` — so only the two that
/// differ are tabulated. `locked` starts at that same stock `1` for every window but is no longer
/// a constant: it lives in [`ChatWindowLook`], because the tab menu can now move it.
const WINDOW_STATE: [(Option<i64>, Option<i64>); NUM_CHAT_WINDOWS] = [
(Some(1), Some(1)), // 1 "General" — shown, dock position 1
(None, Some(2)), // 2 "Combat Log" — docked at position 2, not shown (the dock shows one)
@@ -146,9 +150,10 @@ const WINDOW_STATE: [(Option<i64>, Option<i64>); NUM_CHAT_WINDOWS] = [
/// `__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)]
/// [`Self::default`] is the stock `chat-cache.txt` row — `COLOR 0 0 0 0`, `SIZE 0`, `LOCKED 1` —
/// i.e. a black box at alpha 0 that cannot be dragged, which is the classic "chat is text over the
/// world until you mouse over it".
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct ChatWindowLook {
/// Background tint, `0..=255` each. The engine renormalises by 1/255 on the way out.
pub r: u8,
@@ -163,6 +168,21 @@ pub struct ChatWindowLook {
/// 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,
/// The cache's `LOCKED` — whether the window refuses to be dragged or resized.
///
/// **`true` out of the box**, because the stock row is `LOCKED 1` for every window: a fresh
/// character's chat box cannot be nudged out of place by a stray drag, and unlocking it is a
/// deliberate trip through the tab menu (`FCF_ToggleLock`). It joined this struct with the
/// move/resize arc: until something could *move* a window, writing the key would have
/// persisted state nothing could change, which is the honest-tree rule (1134 §4) at the
/// persistence layer, and the reason 1589 §6 left it out.
pub locked: bool,
}
impl Default for ChatWindowLook {
fn default() -> Self {
Self::DEFAULT
}
}
impl ChatWindowLook {
@@ -173,6 +193,7 @@ impl ChatWindowLook {
b: 0,
a: 0,
font_size: 0,
locked: true,
};
}
@@ -276,9 +297,15 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
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
num(shown), // 1 or nil, never 0
// locked — the cache's LOCKED, stock `1`, moved by `SetChatWindowLocked` below.
// `1` or nil like `shown`, never `0`: the reference's own boolean-in-a-number.
if look.locked {
Value::Integer(1)
} else {
Value::Nil
},
num(docked), // the dock POSITION (1, 2) or nil
]))
})?,
)?;
@@ -329,6 +356,36 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// SetChatWindowLocked(id, isLocked) — `0x4a1650`, writing the record's `+0x8c` locked field
// (wow-re `system/ui/ledger.tsv:9325` + `scratch/chat-window-record.md` §2, VERIFIED:
// `0x4a16a2 mov [esi+0xb4fedc],eax`, initialised to **1** at `0x4984e4`, read back as
// `GetChatWindowInfo`'s 8th return at `0x4a0cbf`).
//
// **A `bool` is the faithful store even though the field is an `i32`**: the cache writer
// booleanises it through `setne` (`0x499e8b`, fmt `LOCKED %d`), so nothing outside `{0,1}`
// survives a file round trip — the same note records that as the difference between this field
// and `SIZE`, which does round-trip arbitrary values.
//
// The one value of the tuple the *player* moves rather than the layout: `FCF_SetLocked`
// (FloatingChatFrame.lua l.802-805) writes the frame field and this store in the same breath,
// and `FloatingChatFrame_Update` (l.56) seats the frame back from here at load.
//
// Lua truthiness, not a strict boolean, because the reference's callers pass `1` and `nil` —
// `FCF_ToggleLock`'s two arms and `FCF_OpenNewWindow`'s `SetChatWindowLocked(i, nil)`. mlua
// marshals both the way the reference binding's own `toboolean` does.
lua.globals().set(
"SetChatWindowLocked",
lua.create_function(|lua, (id, locked): (i64, bool)| {
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];
if locked != look.locked {
look.locked = locked;
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
@@ -621,6 +678,7 @@ mod tests {
b: 3,
a: 4,
font_size: 14,
locked: true,
},
)]);
assert!(
+689 -13
View File
@@ -36,22 +36,30 @@
//! Dewdrop colour row opens, and how the reference's own `ColorPickerFrame.xml` paints its preview
//! swatch (nothing else ever writes it).
//!
//! **NOT carried, and each waits for a customer (decision 1195):** `SetColorHSV`/`GetColorHSV`
//! (`0x78e920`/`0x78ea00`) — zero callers in the 218-addon corpus, though the state they would read
//! and write is now the right shape for them (`SetColorHSV` stores three *raw* `f32` — no clamp, no
//! quantize, zero `fcom` in its body — and fires the same slot); and the four wheel/value texture
//! accessors `SetColorWheelTexture` `0x78de90` / `SetColorWheelThumbTexture` `0x78e160` /
//! `SetColorValueTexture` `0x78e450` / `SetColorValueThumbTexture` `0x78e720` (+ the getters
//! `0x78dd80`/`0x78e070`), which address art this engine cannot draw: the wheel and the value strip
//! are generated by the client, not loaded — their `<ColorWheelTexture>`/`<ColorValueTexture>`
//! elements carry no `file=` at all. See `assets/ui/ColorPickerFrame.xml`'s header for what that
//! costs the window.
//! **The four texture accessors are carried** (`SetColorWheelTexture` `0x78de90` /
//! `SetColorWheelThumbTexture` `0x78e160` / `SetColorValueTexture` `0x78e450` /
//! `SetColorValueThumbTexture` `0x78e720`, plus the getters `0x78dd80`/`0x78e070`). They had zero
//! callers in the 218-addon corpus and waited for one under decision 1195; the customer that
//! arrived is our own `ColorPickerFrame.xml`, whose four elements the XML loader installs through
//! exactly these — two of them with **no file at all**, because the disc and the strip are pixels
//! the client computes and the app renderer now computes too (decision 1592, and wow-re
//! `system/ui/scratch/colorselect-drawn-appearance.md` for what it draws).
//!
//! **NOT carried, still waiting for a customer (decision 1195):** `SetColorHSV`/`GetColorHSV`
//! (`0x78e920`/`0x78ea00`) — zero corpus callers, though the state they would read and write is
//! now the right shape for them, and [`ColorSelectState::set_hsv`] is already the store they'd
//! use (three *raw* `f32` — no clamp, no quantize, zero `fcom` in its body), because the drag path
//! needs exactly that.
use mlua::{Lua, Table, Value};
use super::object::frame_handle_of;
use super::{event, Model};
use crate::widget::{ColorSelectState, KindState};
use super::object::{draw_layer_from_str, frame_handle_of};
use super::pointer::point_in_rect;
use super::region::region_wrapper;
use super::{event, Model, RegionData};
use crate::layout::Rect;
use crate::order::DrawLayer;
use crate::widget::{ColorSelectState, FrameHandle, KindState, RegionHandle, RegionKind};
/// Registry key of the ColorSelect method table (the MAXCSTACK discipline: Lua-side root, named
/// key).
@@ -77,6 +85,165 @@ fn with_colorselect<T>(
}
}
/// One of the widget's four texture sub-objects. The wheel and the strip are the two the press
/// handler hit-tests (`[+0x318]+0x24` and `[+0x320]+0x24`, wow-re `colorselect-color-law.md` §5);
/// the two thumbs are pure output, positioned from the HSV at extract.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Slot {
Wheel,
WheelThumb,
ValueStrip,
ValueThumb,
}
impl Slot {
fn get(self, s: &ColorSelectState) -> Option<RegionHandle> {
match self {
Slot::Wheel => s.wheel,
Slot::WheelThumb => s.wheel_thumb,
Slot::ValueStrip => s.value_strip,
Slot::ValueThumb => s.value_thumb,
}
}
fn set(self, s: &mut ColorSelectState, rh: RegionHandle) {
let slot = match self {
Slot::Wheel => &mut s.wheel,
Slot::WheelThumb => &mut s.wheel_thumb,
Slot::ValueStrip => &mut s.value_strip,
Slot::ValueThumb => &mut s.value_thumb,
};
*slot = Some(rh);
}
/// The layer a slot's region is born on — VERIFIED, not guessed: wow-re
/// `colorselect-drawn-appearance.md` reads the wheel bound at layer 2 (ARTWORK) through
/// `0x77fd10`, with the two markers on OVERLAY above the art they ride.
fn layer(self) -> DrawLayer {
match self {
Slot::Wheel | Slot::ValueStrip => DrawLayer::Artwork,
Slot::WheelThumb | Slot::ValueThumb => DrawLayer::Overlay,
}
}
}
/// Get-or-create one slot's texture region; `layer` re-layers an existing one. Returns the region
/// id (for wrapper lookup). The Slider's `ensure_thumb` is the same function one slot wider.
fn ensure_slot(lua: &Lua, this: &Table, slot: Slot, layer: Option<DrawLayer>) -> mlua::Result<u32> {
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let existing = match &model
.arena
.frame(h)
.ok_or_else(|| mlua::Error::runtime("stale frame handle"))?
.kind_state
{
KindState::ColorSelect(s) => slot.get(s),
_ => return Err(mlua::Error::runtime("not a ColorSelect")),
};
let rh = match existing {
Some(rh) => {
if let (Some(l), Some(region)) = (layer, model.arena.region_mut(rh)) {
region.draw_layer = l;
}
rh
}
None => {
let rh = model
.arena
.create_region(h, RegionKind::Texture, layer.unwrap_or(slot.layer()), 0)
.ok_or_else(|| mlua::Error::runtime("stale frame handle"))?;
model.region_data.insert(rh, RegionData::default());
model.touch_layout(); // a region entered the layout gate's read set (decision 0740)
if let Some(frame) = model.arena.frame_mut(h) {
if let KindState::ColorSelect(s) = &mut frame.kind_state {
slot.set(s, rh);
}
}
rh
}
};
Ok(model.region_id(rh))
}
/// `Set<Slot>Texture([path [, drawLayer]] | r, g, b [, a])`, the Slider's `SetThumbTexture` two-form
/// plus a **third**: called with nothing at all it just creates the region. That empty form is not a
/// convenience — it is the only one the two file-less elements can use, and it is what
/// `<ColorWheelTexture/>` means. A slot left file-less is where the app renderer paints.
fn install_slot_texture(
lua: &Lua,
m: &Table,
slot: Slot,
setter: &str,
getter: &str,
) -> mlua::Result<()> {
m.set(
setter,
lua.create_function(
move |lua, (this, a1, a2, a3, a4): (Table, Value, Value, Value, Value)| {
let layer = match &a2 {
Value::String(s) => s.to_str().ok().and_then(|l| draw_layer_from_str(&l)),
_ => None,
};
let id = ensure_slot(lua, &this, slot, layer)?;
let rh = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
*model.id_to_region.get(&id).expect("colorselect region id")
};
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let data = model.region_data.entry(rh).or_default();
match &a1 {
Value::String(s) => {
data.texture = Some(s.to_str()?.to_string());
data.fill = None;
}
Value::Number(_) | Value::Integer(_) => {
data.texture = None;
data.fill = Some([
num_f32(&a1),
num_f32(&a2),
num_f32(&a3),
match &a4 {
Value::Nil => 1.0,
v => num_f32(v),
},
]);
}
// The empty form: the region now exists and stays file-less.
_ => {}
}
Ok(())
},
)?,
)?;
m.set(
getter,
lua.create_function(move |lua, this: Table| {
let existing = with_colorselect(lua, &this, |s| slot.get(s))?;
let id = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
existing.map(|rh| model.region_id(rh))
};
match id {
Some(id) => Ok(Value::Table(region_wrapper(lua, id)?)),
None => Ok(Value::Nil),
}
})?,
)?;
Ok(())
}
/// A Lua number argument as `f32`, 0 for anything else — the Slider's own colour-form helper.
fn num_f32(v: &Value) -> f32 {
match v {
Value::Number(n) => *n as f32,
Value::Integer(i) => *i as f32,
_ => 0.0,
}
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let m = lua.create_table()?;
@@ -97,6 +264,39 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua.create_function(|lua, this: Table| with_colorselect(lua, &this, |s| s.rgb_f64()))?,
)?;
// The four texture accessors (`0x78de90`/`0x78e160`/`0x78e450`/`0x78e720` and the getters
// `0x78dd80`/`0x78e070`). They had zero corpus callers and waited for one (decision 1195); the
// customer that arrived is our own `ColorPickerFrame.xml`, whose four elements the XML loader
// installs through exactly these.
install_slot_texture(
lua,
&m,
Slot::Wheel,
"SetColorWheelTexture",
"GetColorWheelTexture",
)?;
install_slot_texture(
lua,
&m,
Slot::WheelThumb,
"SetColorWheelThumbTexture",
"GetColorWheelThumbTexture",
)?;
install_slot_texture(
lua,
&m,
Slot::ValueStrip,
"SetColorValueTexture",
"GetColorValueTexture",
)?;
install_slot_texture(
lua,
&m,
Slot::ValueThumb,
"SetColorValueThumbTexture",
"GetColorValueThumbTexture",
)?;
lua.set_named_registry_value(REG_COLORSELECT_METHODS, m)?;
Ok(())
}
@@ -124,6 +324,203 @@ fn fire_color_select(lua: &Lua, this: &Table, r: f64, g: f64, b: f64) -> mlua::R
Ok(())
}
/// The wheel's normalised coordinates for a cursor at `(x, y)`: the offset from the wheel rect's
/// centre, scaled by its **half-extents** — so a non-square wheel normalises to a disc, not an
/// ellipse (`0x78bdd0`..: `nx=(x−cx)/((right−left)·0.5)`, `ny=(y−cy)/((top−bottom)·0.5)`, wow-re
/// `colorselect-color-law.md` §5). y is up, this arena's convention and the client's.
fn wheel_norm(r: Rect, x: f32, y: f32) -> (f32, f32) {
let hw = (r.right - r.left) * 0.5;
let hh = (r.top - r.bottom) * 0.5;
let cx = (r.left + r.right) * 0.5;
let cy = (r.bottom + r.top) * 0.5;
// A degenerate rect (an unresolved region) would divide by zero; the client cannot reach that
// state because it only hit-tests a laid-out texture, and neither can we — but 0/0 is NaN, and
// NaN in the HSV would poison every colour after it.
let nx = if hw != 0.0 { (x - cx) / hw } else { 0.0 };
let ny = if hh != 0.0 { (y - cy) / hh } else { 0.0 };
(nx, ny)
}
/// The hue/saturation a press or drag at `(x, y)` writes, given the wheel's rect. `H` runs from
/// `atan2(ny, nx) + π` in degrees — so the wheel's **left** edge is 0°/360° (red) and its right edge
/// 180° (cyan) — and `S` is the radius, clamped at the rim. Raw f32 the whole way: the drag path
/// never touches a quantizer.
fn wheel_hs(r: Rect, x: f32, y: f32) -> (f32, f32) {
let (nx, ny) = wheel_norm(r, x, y);
let radius = (nx * nx + ny * ny).sqrt();
let hue = (ny.atan2(nx) + std::f32::consts::PI).to_degrees();
(hue, radius.min(1.0))
}
/// The brightness a press or drag at `y` writes, given the strip's rect: the fraction of the way up
/// it, clamped — `V = clamp((y − bottom) / (top − bottom), 0, 1)` (`0x78beed`). x is ignored, which
/// is why a drag that wanders sideways off the strip keeps working.
fn strip_value(r: Rect, y: f32) -> f32 {
let span = r.top - r.bottom;
if span == 0.0 {
return 0.0;
}
((y - r.bottom) / span).clamp(0.0, 1.0)
}
/// The wheel thumb's rect: the marker seated at the point the current `(H, S)` *came from*. It is
/// the inverse of [`wheel_hs`], which is the whole invariant — click a pixel, the marker lands on
/// that pixel.
///
/// Byte-exact to `0x78bc20` (wow-re `colorselect-drawn-appearance.md` §4), which does
/// `SetPoint(CENTER, wheel, CENTER, −m·cos θ, −m·sin θ)` with `θ = H·π/180` and
/// `m = GetWidth(wheel)·0.5·S`. Its two `fchs` are the pick law's `+π` seen from the other side:
/// drop either sign and the marker sits diametrically opposite the colour it marks.
///
/// **The width is used on both axes**, deliberately — the client reads `GetWidth` for the vertical
/// offset too, while the pick law divides `y` by half-*height*. On a non-square wheel the two
/// therefore disagree, and that asymmetry is reproduced rather than fixed. The reference's wheel is
/// square, so it costs nothing there and would cost fidelity anywhere else.
///
/// `S == 0` puts it dead centre whatever the hue is, which is what makes the `-1` grey sentinel
/// (`ColorSelectState::hsv`) harmless here: it is multiplied by a zero radius.
pub(super) fn wheel_thumb_rect(wheel: Rect, thumb_size: Option<(f32, f32)>, hsv: [f32; 3]) -> Rect {
let (tw, th) = thumb_size.unwrap_or((0.0, 0.0));
let cx = (wheel.left + wheel.right) * 0.5;
let cy = (wheel.bottom + wheel.top) * 0.5;
let m = (wheel.right - wheel.left) * 0.5 * hsv[1];
let theta = hsv[0].to_radians();
let (x, y) = (cx - m * theta.cos(), cy - m * theta.sin());
Rect::new(y - th * 0.5, x - tw * 0.5, y + th * 0.5, x + tw * 0.5)
}
/// The value thumb's rect: centred on the strip horizontally (the reference's marker is 48 wide
/// over a 32-wide strip, so it deliberately overhangs) and seated `V` of the way up from its
/// BOTTOM — `0x78bcf0`'s `SetPoint(CENTER, strip, BOTTOM, 0, V·GetHeight)`.
///
/// **The height it scales by is the WHEEL's, not the strip's** — `0x78bcf0` dereferences
/// `[this+0x318]`, the wheel, with no null guard of its own, while anchoring to the strip. The two
/// are both 128 tall in the reference, so it is invisible there; it is reproduced because a client
/// that "fixed" it would place the marker somewhere the real one does not the moment an addon
/// declares a strip of its own height. A widget with no wheel falls back to the strip, which is the
/// nearest thing to the client's unguarded read that does not put a NaN in a rect.
pub(super) fn value_thumb_rect(
strip: Rect,
wheel: Option<Rect>,
thumb_size: Option<(f32, f32)>,
hsv: [f32; 3],
) -> Rect {
let (tw, th) = thumb_size.unwrap_or((0.0, 0.0));
let cx = (strip.left + strip.right) * 0.5;
let scale_height = wheel.map_or(strip.top - strip.bottom, |w| w.top - w.bottom);
let y = strip.bottom + hsv[2].clamp(0.0, 1.0) * scale_height;
Rect::new(y - th * 0.5, cx - tw * 0.5, y + th * 0.5, cx + tw * 0.5)
}
/// The in-flight colour drag. The client keeps **two independent flags** (`+0x314` wheel, `+0x315`
/// strip), both set by the same press if the rects overlap at the cursor, and its cursor-position
/// handler applies whichever are set — so this is a pair of bools, not an enum. It also explains the
/// feel: a drag that starts on the wheel keeps writing hue and saturation no matter where the cursor
/// wanders, because the flag, not the current position, decides.
#[derive(Clone, Copy)]
pub(crate) struct ColorDrag {
pub(crate) frame: FrameHandle,
wheel: bool,
strip: bool,
}
/// The two hit rects of a ColorSelect frame — its wheel region's and its value strip's, as resolved
/// by layout. `None` for a slot with no region, which is every ColorSelect that never declared one.
fn hit_rects(model: &Model, h: FrameHandle) -> (Option<Rect>, Option<Rect>) {
let (wheel, strip) = match model.arena.frame(h).map(|f| &f.kind_state) {
Some(KindState::ColorSelect(s)) => (s.wheel, s.value_strip),
_ => (None, None),
};
let rect = |rh: Option<RegionHandle>| rh.and_then(|rh| model.region_resolved.get(&rh).copied());
(rect(wheel), rect(strip))
}
/// On a LeftButton press at `(x, y)` whose hit frame is `hit`: if that frame is a ColorSelect and
/// the cursor is inside its wheel or its value strip, capture the drag and **apply it immediately**
/// — `0x78bf10` calls the cursor-position handler through `[eax+0x3c]` before it returns, so a
/// single click jumps the colour to the click point and fires. Returns what the caller must fire.
pub(super) fn begin_drag(
model: &mut Model,
hit: Option<FrameHandle>,
x: f32,
y: f32,
) -> Option<(u32, f64, f64, f64)> {
let h = hit?;
let (wheel, strip) = hit_rects(model, h);
let on_wheel = wheel.is_some_and(|r| point_in_rect(r, x, y));
let on_strip = strip.is_some_and(|r| point_in_rect(r, x, y));
if !on_wheel && !on_strip {
return None;
}
model.color_drag = Some(ColorDrag {
frame: h,
wheel: on_wheel,
strip: on_strip,
});
drag_move(model, x, y)
}
/// On a pointer move at `(x, y)` while a colour drag is captured: rewrite whichever of H/S and V the
/// capture owns, straight into the widget's HSV floats, and hand the caller the frame id plus the
/// colour to fire `OnColorSelect` with. Returns `Some` on **every** captured move, with no
/// change-gate — the widget deliberately has none (`0x78bafd` tests only whether a handler is
/// bound), which is the opposite of the Slider next door.
pub(super) fn drag_move(model: &mut Model, x: f32, y: f32) -> Option<(u32, f64, f64, f64)> {
let ColorDrag {
frame,
wheel,
strip,
} = *model.color_drag.as_ref()?;
let (wheel_rect, strip_rect) = hit_rects(model, frame);
let hs = if wheel {
wheel_rect.map(|r| wheel_hs(r, x, y))
} else {
None
};
let v = if strip {
strip_rect.map(|r| strip_value(r, y))
} else {
None
};
if hs.is_none() && v.is_none() {
return None;
}
let rgb = match model.arena.frame_mut(frame).map(|f| &mut f.kind_state) {
Some(KindState::ColorSelect(s)) => {
let mut hsv = s.hsv;
if let Some((hue, sat)) = hs {
hsv[0] = hue;
hsv[1] = sat;
}
if let Some(v) = v {
hsv[2] = v;
}
s.set_hsv(hsv[0], hsv[1], hsv[2]);
s.rgb_f64()
}
_ => return None, // the frame died mid-drag
};
Some((model.frame_id(frame), rgb.0, rgb.1, rgb.2))
}
/// Release any in-flight colour drag (LeftButton up, or the pointer leaving the window) — the
/// widget's `0x78bf90`, which clears both flags.
pub(super) fn end_drag(model: &mut Model) {
model.color_drag = None;
}
/// Fire `OnColorSelect` for a frame the pointer path just recoloured. The setter's own
/// [`fire_color_select`] needs the caller's `this` table; the pointer has only an id, so this is the
/// same two lines against [`event::fire_widget_handler`] directly.
pub(super) fn fire_by_id(lua: &Lua, id: u32, r: f64, g: f64, b: f64) -> mlua::Result<()> {
event::fire_widget_handler(
lua,
id,
"OnColorSelect",
vec![Value::Number(r), Value::Number(g), Value::Number(b)],
)
}
#[cfg(test)]
mod tests {
use crate::script::UiScript;
@@ -256,4 +653,283 @@ mod tests {
assert!(s.eval::<bool>("return plain.SetColorRGB == nil").unwrap());
assert!(s.eval::<bool>("return plain.GetColorRGB == nil").unwrap());
}
// ─────────────────────────────────────────────────────────────────────────────────────────
// The wheel and the strip — the pick, the drag, and the two markers
// ─────────────────────────────────────────────────────────────────────────────────────────
/// A `ColorSelect` with the reference's own geometry, laid out and ready to click: a 128×128
/// wheel at the frame's top-left and a 32×128 strip to its right. Built through the XML loader
/// so the four elements' whole install path — `apply_colorselect`, the file-less setter form,
/// the getter round-trip that carries `<Size>`/`<Anchors>` — is what these tests exercise.
fn picker() -> UiScript {
let mut s = UiScript::new().unwrap();
let xml = r#"<Ui>
<ColorSelect name="TestPicker" parent="UIParent" enableMouse="true">
<Size><AbsDimension x="365" y="200"/></Size>
<Anchors><Anchor point="BOTTOMLEFT"/></Anchors>
<ColorWheelTexture name="TestPickerWheel">
<Size><AbsDimension x="128" y="128"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="16" y="-32"/></Offset></Anchor></Anchors>
</ColorWheelTexture>
<ColorWheelThumbTexture file="Interface\Buttons\UI-ColorPicker-Buttons">
<Size><AbsDimension x="10" y="10"/></Size>
</ColorWheelThumbTexture>
<ColorValueTexture>
<Size><AbsDimension x="32" y="128"/></Size>
<Anchors><Anchor point="LEFT" relativeTo="TestPickerWheel" relativePoint="RIGHT"><Offset><AbsDimension x="24" y="0"/></Offset></Anchor></Anchors>
</ColorValueTexture>
<ColorValueThumbTexture file="Interface\Buttons\UI-ColorPicker-Buttons">
<Size><AbsDimension x="48" y="14"/></Size>
</ColorValueThumbTexture>
</ColorSelect>
</Ui>"#;
let doc = crate::framexml::parse(xml).unwrap();
let report = crate::loader::load(&s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"loader errors: {:?}",
report.errors
);
s.set_screen_size(1024.0, 768.0);
s.resolve();
s
}
/// The wheel's rect, from the region the loader published as a global.
fn wheel_rect(s: &mut UiScript) -> (f32, f32, f32, f32) {
let (l, b, w, h): (f32, f32, f32, f32) = s
.eval(
"local r = TestPickerWheel; \
return r:GetLeft(), r:GetBottom(), r:GetWidth(), r:GetHeight()",
)
.unwrap();
(l, b, w, h)
}
/// The four elements install as real regions with the authored geometry, and the wheel's name
/// publishes as a global — which is what `<ColorValueTexture>`'s own anchor resolves against.
#[test]
fn the_four_elements_install_with_their_authored_geometry() {
let mut s = picker();
let (l, b, w, h) = wheel_rect(&mut s);
assert_eq!((w, h), (128.0, 128.0), "the wheel is 128 square");
// TOPLEFT (16, −32) inside a 365×200 frame seated at the screen's bottom-left.
assert_eq!(l, 16.0);
assert_eq!(b, 200.0 - 32.0 - 128.0);
let (sl, sw, sh): (f32, f32, f32) = s
.eval(
"local r = TestPicker:GetColorValueTexture(); \
return r:GetLeft(), r:GetWidth(), r:GetHeight()",
)
.unwrap();
assert_eq!((sw, sh), (32.0, 128.0), "the strip is 32×128");
assert_eq!(sl, 16.0 + 128.0 + 24.0, "seated 24 right of the wheel");
// The thumbs exist and carry the one BLP in the window.
let file: String = s
.eval("return TestPicker:GetColorWheelThumbTexture():GetTexture()")
.unwrap();
assert!(file.contains("UI-ColorPicker-Buttons"), "got {file}");
}
/// **A click on the wheel picks the colour that pixel shows.** The disc's law and the pick law
/// are inverses (wow-re `colorselect-color-law.md` §5 / `colorselect-drawn-appearance.md`), so
/// this is stated at the four cardinal points, where the hue is nameable: LEFT is red, RIGHT
/// cyan, TOP violet, BOTTOM chartreuse. A sign error anywhere in the chain mirrors one axis and
/// two of these four flip.
#[test]
fn a_click_on_the_wheel_picks_the_hue_under_the_cursor() {
let mut s = picker();
let (l, b, w, h) = wheel_rect(&mut s);
let (cx, cy) = (l + w * 0.5, b + h * 0.5);
// One pixel inside each rim, so saturation is ~1 and the hue is pure.
for (name, x, y, want) in [
("left", l + 1.0, cy, [1.0, 0.0, 0.0]),
("right", l + w - 1.0, cy, [0.0, 1.0, 1.0]),
("top", cx, b + h - 1.0, [0.5, 0.0, 1.0]),
("bottom", cx, b + 1.0, [0.5, 1.0, 0.0]),
] {
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
let (r, g, bl): (f64, f64, f64) = s.eval("return TestPicker:GetColorRGB()").unwrap();
for (ch, (got, wanted)) in [r, g, bl].iter().zip(want).enumerate() {
assert!(
(got - wanted).abs() < 0.06,
"{name} rim: channel {ch} is {got:.3}, expected ~{wanted}"
);
}
}
}
/// The centre is saturation 0 — white — and it stays white however the hue would have come out,
/// because the `-1` grey sentinel is inert on the way back through `hsv_to_rgb`.
#[test]
fn the_wheels_centre_is_unsaturated() {
let mut s = picker();
let (l, b, w, h) = wheel_rect(&mut s);
s.mouse_button(l + w * 0.5, b + h * 0.5, "LeftButton", true);
let (r, g, bl): (f64, f64, f64) = s.eval("return TestPicker:GetColorRGB()").unwrap();
assert!(
r > 0.99 && g > 0.99 && bl > 0.99,
"the centre is white, got ({r:.3}, {g:.3}, {bl:.3})"
);
}
/// **The press IS the first move of the gesture**, and the drag keeps writing the wheel even
/// after the cursor leaves it — the capture is a flag on the widget, not a hit test per move
/// (`0x78bd80` is gated on `+0x314`, never re-tested). Dragging from red round to cyan without
/// lifting proves both.
#[test]
fn a_wheel_drag_keeps_the_capture_when_the_cursor_wanders_off() {
let mut s = picker();
let (l, b, w, h) = wheel_rect(&mut s);
let cy = b + h * 0.5;
s.mouse_button(l + 1.0, cy, "LeftButton", true);
// Straight across and well past the right rim — outside the wheel's rect entirely.
s.mouse_move(l + w + 200.0, cy);
let (r, g, bl): (f64, f64, f64) = s.eval("return TestPicker:GetColorRGB()").unwrap();
assert!(
r < 0.05 && g > 0.95 && bl > 0.95,
"the drag followed the cursor past the rim, got ({r:.3}, {g:.3}, {bl:.3})"
);
// Saturation clamps at the rim rather than running past it.
s.mouse_button(l + w + 200.0, cy, "LeftButton", false);
// Released: further moves write nothing.
s.mouse_move(l + w * 0.5, cy);
let after: (f64, f64, f64) = s.eval("return TestPicker:GetColorRGB()").unwrap();
assert_eq!(
after,
(r, g, bl),
"a move after the release must not touch the colour"
);
}
/// The strip writes **only** brightness: dragging it down to black and back up leaves the hue
/// exactly where the wheel put it.
#[test]
fn a_strip_drag_writes_brightness_and_leaves_the_hue_alone() {
let mut s = picker();
let (l, b, w, h) = wheel_rect(&mut s);
s.mouse_button(l + 1.0, b + h * 0.5, "LeftButton", true); // red
s.mouse_button(l + 1.0, b + h * 0.5, "LeftButton", false);
let (sl, sb, sw, sh): (f32, f32, f32, f32) = s
.eval(
"local r = TestPicker:GetColorValueTexture(); \
return r:GetLeft(), r:GetBottom(), r:GetWidth(), r:GetHeight()",
)
.unwrap();
let sx = sl + sw * 0.5;
s.mouse_button(sx, sb + sh * 0.5, "LeftButton", true);
let (r, g, bl): (f64, f64, f64) = s.eval("return TestPicker:GetColorRGB()").unwrap();
assert!(
(r - 0.5).abs() < 0.02 && g < 0.02 && bl < 0.02,
"half-way up the strip is half-brightness red, got ({r:.3}, {g:.3}, {bl:.3})"
);
s.mouse_move(sx, sb - 50.0); // dragged below the strip: clamps to black
let black: (f64, f64, f64) = s.eval("return TestPicker:GetColorRGB()").unwrap();
assert_eq!(black, (0.0, 0.0, 0.0), "V clamps at 0");
// (Black is exact whatever the hue: `hsv_to_rgb` scales every channel by V.)
s.mouse_move(sx, sb + sh + 50.0); // and above it: clamps to full
let full: (f64, f64, f64) = s.eval("return TestPicker:GetColorRGB()").unwrap();
assert!(
full.0 == 1.0 && full.1 < 0.02 && full.2 < 0.02,
"V clamps at 1 and the hue is untouched, got {full:?}"
);
// `w` and the unused wheel bounds are only here to seat the click above.
let _ = (w, l);
}
/// `OnColorSelect` fires on **every** step of a drag, including one that lands on the colour
/// the widget already holds — the widget has no change-gate (`0x78bafd` tests only whether a
/// handler is bound), which is the opposite of the Slider next door.
#[test]
fn the_drag_fires_on_every_move_with_no_change_gate() {
let mut s = picker();
s.run("fires = 0; TestPicker:SetScript('OnColorSelect', function() fires = fires + 1 end)")
.unwrap();
let (l, b, w, h) = wheel_rect(&mut s);
let (cx, cy) = (l + w * 0.5, b + h * 0.5);
s.mouse_button(cx, cy, "LeftButton", true);
let after_press: i64 = s.eval("return fires").unwrap();
assert_eq!(after_press, 1, "the press itself fires once");
for _ in 0..3 {
s.mouse_move(cx, cy); // the SAME point — no change, still fires
}
let after_moves: i64 = s.eval("return fires").unwrap();
assert_eq!(after_moves, 4, "no change-gate: every captured move fires");
}
/// A press that lands on neither the wheel nor the strip captures nothing — the surrounding
/// window is not a giant colour surface.
#[test]
fn a_press_outside_both_rects_captures_nothing() {
let mut s = picker();
let before: (f64, f64, f64) = s.eval("return TestPicker:GetColorRGB()").unwrap();
s.mouse_button(340.0, 20.0, "LeftButton", true); // inside the frame, below both
s.mouse_move(20.0, 150.0); // ... and now over the wheel, uncaptured
let after: (f64, f64, f64) = s.eval("return TestPicker:GetColorRGB()").unwrap();
assert_eq!(after, before, "no capture, no colour change");
}
/// The two markers sit where the colour came from. Checked through **extract**, not `GetLeft`:
/// the markers carry no anchors — the client `ClearAllPoints`es them and re-`SetPoint`s from
/// C++, and benilla derives their rects at extract for the same reason — so the layout resolver
/// has nothing to answer with. That is the same shape as the Slider thumb's, and it is the one
/// place a corpus addon could tell the difference; nothing in the 218 reads a picker marker's
/// rect.
///
/// The wheel marker is checked by round trip — click a point, the marker's centre lands back on
/// it — which is the invariant that matters and the one either `fchs` breaks.
#[test]
fn the_markers_land_where_the_colour_came_from() {
let mut s = picker();
let (l, b, w, h) = wheel_rect(&mut s);
let (cx, cy) = (l + w * 0.5, b + h * 0.5);
for (dx, dy) in [(40.0, 0.0), (0.0, 40.0), (-30.0, -30.0), (0.0, 0.0)] {
s.mouse_button(cx + dx, cy + dy, "LeftButton", true);
s.mouse_button(cx + dx, cy + dy, "LeftButton", false);
s.resolve();
let r = marker_rect(&s, 10.0).expect("the wheel marker draws");
let (mx, my) = ((r.left + r.right) * 0.5, (r.bottom + r.top) * 0.5);
assert!(
(mx - (cx + dx)).abs() < 1.0 && (my - (cy + dy)).abs() < 1.0,
"clicked ({}, {}), marker centred at ({mx}, {my})",
cx + dx,
cy + dy
);
}
// The value marker rides V from the strip's bottom — and scales by the WHEEL's height,
// which is the client's own unguarded `[this+0x318]` read. Both are 128 here.
let (sb, sh): (f32, f32) = s
.eval(
"local r = TestPicker:GetColorValueTexture(); return r:GetBottom(), r:GetHeight()",
)
.unwrap();
for v in [0.0_f32, 0.25, 1.0] {
s.run(&format!("TestPicker:SetColorRGB({v}, 0, 0)"))
.unwrap();
s.resolve();
let r = marker_rect(&s, 48.0).expect("the value marker draws");
let my = (r.bottom + r.top) * 0.5;
assert!(
(my - (sb + v * sh)).abs() < 1.0,
"V={v}: marker at {my}, expected {}",
sb + v * sh
);
}
}
/// The extracted rect of the marker whose authored width is `width` — the two markers share
/// one BLP and are told apart by their size (10 for the wheel's, 48 for the strip's), which is
/// also the assertion that they kept it.
fn marker_rect(s: &UiScript, width: f32) -> Option<crate::layout::Rect> {
s.extract().into_iter().find_map(|q| {
let r = q.rect?;
let is_marker = matches!(
&q.content,
crate::script::QuadContent::Texture { path: Some(p), .. }
if p.contains("UI-ColorPicker-Buttons")
);
(is_marker && (r.right - r.left - width).abs() < 0.01).then_some(r)
})
}
}
+48 -3
View File
@@ -8,7 +8,7 @@ use crate::order::{self, ZTarget};
use crate::widget::FrameHandle;
use super::clip::{effective_clip, scroll_clip_sources};
use super::{slider, ExtractedQuad, FontObject, QuadContent, TexCoords, UiScript};
use super::{colorselect, slider, ExtractedQuad, FontObject, QuadContent, TexCoords, UiScript};
impl UiScript {
/// **Every live draw target in the VM**, whether or not it is currently visible — each frame's
@@ -170,6 +170,44 @@ impl UiScript {
thumb_fill = true;
}
}
// The colour picker's four sub-textures. The wheel and the strip are ordinary
// authored regions — they keep their own anchors and resolve normally; all
// they need from here is a content kind that says "the app paints this one".
// The two markers are the opposite: their rects are DERIVED from the widget's
// HSV, so the pixel you clicked is the pixel the marker lands on
// (`colorselect::wheel_thumb_rect` is the exact inverse of the pick law).
let mut color_art: Option<QuadContent> = None;
let mut color_thumb = false;
if let Some(crate::widget::KindState::ColorSelect(cs)) =
owner_frame.map(|f| &f.kind_state)
{
let tsize = model.region_data.get(&rh).and_then(|d| d.size);
if cs.wheel_thumb == Some(rh) {
rect = cs
.wheel
.and_then(|w| model.region_resolved.get(&w).copied())
.map(|w| colorselect::wheel_thumb_rect(w, tsize, cs.hsv));
color_thumb = true;
} else if cs.value_thumb == Some(rh) {
// The strip anchors it; the WHEEL scales it (the client's own
// unguarded `[this+0x318]` read — `colorselect::value_thumb_rect`).
let wheel = cs
.wheel
.and_then(|w| model.region_resolved.get(&w).copied());
rect = cs
.value_strip
.and_then(|v| model.region_resolved.get(&v).copied())
.map(|v| colorselect::value_thumb_rect(v, wheel, tsize, cs.hsv));
color_thumb = true;
} else if cs.wheel == Some(rh) {
color_art = Some(QuadContent::ColorWheel);
} else if cs.value_strip == Some(rh) {
color_art = Some(QuadContent::ColorValue {
hue: cs.hsv[0],
sat: cs.hsv[1],
});
}
}
// A Button's state textures show by interaction state (the texture-array
// "current" pointer): a non-current state texture emits no quad this frame.
// The ButtonText additionally re-points to the current STATE's font object
@@ -309,14 +347,21 @@ impl UiScript {
if let Some(sb) = bar_fill {
data.tex_coords = Some(bar_fill_uv(data.tex_coords, sb));
}
if bar_fill.is_none() && !thumb_fill {
if bar_fill.is_none() && !thumb_fill && !color_thumb {
rect = model.region_resolved.get(&rh).copied();
}
let is_text = matches!(
region.map(|r| r.kind),
Some(crate::widget::RegionKind::FontString)
);
let content = if is_text {
// The generated art wins only over an EMPTY slot: an addon that sets a real
// texture on the wheel region gets its texture, the way it would on any other
// region. (`ColorPickerFrame.xml` sets none, which is the whole point.)
let content = if let Some(art) =
color_art.filter(|_| data.texture.is_none() && data.fill.is_none())
{
art
} else if is_text {
QuadContent::Text {
text: data.text,
color: data.vertex_color,
@@ -0,0 +1,203 @@
//! The **layout cache** — the consumer `Frame::user_placed` never had.
//!
//! `SetUserPlaced` / the drag entries set the client's userPlaced bit (`frame+0xb4 & 0x1000`,
//! [`super::object::movable`]), and its whole meaning is *"the player put this window here; put it
//! back next time"*. The real client honours that by writing a per-character `layout-cache.txt` at
//! logout and seating the frames from it at load; benilla had the bit and nothing that read it, so
//! a dragged or resized window went back to its authored anchors on the next login.
//!
//! This module is the **engine half** of that: a snapshot out, a restore in, and a dirty bit for
//! the host to debounce on. Where the file lives, when it is written and which character owns it
//! are the app's ([`benilla_app::ui_layout`] — `benilla-config/layout/<realm>-<character>.txt`,
//! through `local_state`, like every other resident of that folder).
//!
//! ## Why a host seam at all, when `GetPoint`/`SetPoint` exist
//!
//! Everything *about one frame* is already Lua-reachable — `GetNumPoints`, `GetPoint`, `GetWidth`,
//! `GetHeight`, `IsUserPlaced`, `SetPoint`, `SetWidth`, `SetHeight` — and if the saver knew which
//! frames to ask about, it would need nothing here. **Enumeration is the part Lua cannot do**:
//! there is no `EnumerateFrames` in 1.12 and inventing one would be a far wider surface than the
//! three functions below. The alternative — a hand-maintained list of names in FrameXML — is a
//! list that goes stale the first time a window becomes movable and nobody remembers to add it,
//! which is the failure mode the *bit* exists to prevent.
//!
//! So the seam is deliberately three calls wide and mirrors the chat-look seam next door
//! (`chat_window_looks` / `set_chat_window_looks` / `take_chat_window_changes`, decision 1589):
//! [`super::UiScript::user_placed_layouts`], [`super::UiScript::restore_user_placed_layouts`] and
//! [`super::UiScript::take_user_placed_change`].
//!
//! ## What a saved frame carries
//!
//! Its **name**, its **width/height**, and its **whole anchor list** — point, target, relative
//! point, offsets — because that is what `GetPoint` answers and what `SetPoint` takes. A frame is
//! addressed by name in the file for the obvious reason: handles are minted per session and mean
//! nothing across a relog.
//!
//! Two anchor targets survive the round trip and everything else drops the frame's row rather than
//! guessing:
//!
//! - the **screen root** ([`super::SCREEN`]) — written as no target, which is exactly what
//! `GetPoint` answers there (`nil`, decision 0068's stated `UIParent` divergence);
//! - a **named frame** — written by name.
//!
//! An anchor onto an unnamed frame or onto a *region* cannot be re-addressed at load, so a frame
//! carrying one is skipped **whole**. Restoring half an anchor set would seat a window somewhere
//! nobody put it, which is worse than leaving it on its authored anchors.
use crate::layout::Anchor;
use crate::widget::FrameHandle;
use super::object::{point_from_str, point_name};
use super::{Model, SCREEN};
/// One anchor of a saved frame, in the spelling `GetPoint`/`SetPoint` use.
#[derive(Clone, Debug, PartialEq)]
pub struct LayoutPoint {
/// The point on the saved frame (`"BOTTOMLEFT"`, …).
pub point: String,
/// The frame this anchor is relative to, by name; `None` is the screen root, which is what
/// `GetPoint` reports as `nil`.
pub relative_to: Option<String>,
/// The point on the target.
pub relative_point: String,
/// The anchor's x offset, in the frame's own (pre-scale) units — `CAnchor+0x4`.
pub x: f32,
/// The anchor's y offset — `CAnchor+0x8`.
pub y: f32,
}
/// One user-placed frame's persisted geometry: what a layout cache has to hold to put a window
/// back exactly where the player left it.
#[derive(Clone, Debug, PartialEq)]
pub struct FrameLayout {
/// The frame's global name — how the row is addressed across sessions.
pub name: String,
/// The frame's authored width (`LayoutInput::width`), which is what a resize drag writes.
pub width: f32,
/// The frame's authored height.
pub height: f32,
/// Every anchor the frame carries, in `GetPoint` order.
pub points: Vec<LayoutPoint>,
}
impl super::UiScript {
/// Snapshot every frame carrying the userPlaced bit — the save path.
///
/// Sorted by name so the file is stable: a rewrite that reorders its own lines makes every
/// diff useless and every "did this change?" check a false positive. Frames whose anchors
/// cannot be re-addressed by name are dropped (see the module doc), as are unnamed ones —
/// there is nothing to write them under.
pub fn user_placed_layouts(&self) -> Vec<FrameLayout> {
let model = self.model_ref();
let mut out: Vec<FrameLayout> = model
.arena
.iter_frames()
.filter(|(_, f)| f.user_placed)
.filter_map(|(h, f)| {
let name = f.name.clone()?;
let input = model.layout_inputs.get(&h)?;
let points = input
.anchors
.iter()
.map(|a| saved_point(&model, a))
.collect::<Option<Vec<_>>>()?;
Some(FrameLayout {
name,
width: input.width,
height: input.height,
points,
})
})
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
/// Seat saved geometry back onto the frames that still exist — the load path.
///
/// Each row is applied whole or not at all: a name this build has no frame for, a point name
/// that does not parse, or a target frame that is not loaded leaves the frame on its authored
/// anchors rather than half-seated. Restoring **sets the userPlaced bit**, which is what keeps
/// the position: `UIParent_ManageFramePositions` skips a user-placed frame, so the managed
/// bottom-stack pass does not re-seat the window the player moved.
///
/// It marks nothing dirty — the values came *from* the file, and an echo would re-dirty what
/// was just read (`set_chat_window_looks`' reasoning, one store over).
pub fn restore_user_placed_layouts(&mut self, layouts: impl IntoIterator<Item = FrameLayout>) {
let mut model = self.model_mut();
for l in layouts {
let Some(h) = model.arena.lookup(&l.name) else {
continue; // a window this build does not have — the file outlives a rename
};
let mut anchors: Vec<Anchor> = Vec::with_capacity(l.points.len());
let mut usable = true;
for p in &l.points {
// `frame_id`, not a `frame_to_id` read: ids are minted lazily, so a target frame
// that nothing has anchored to yet has none — and refusing the row for that would
// make the restore depend on load order.
let rel = match &p.relative_to {
None => Some(SCREEN),
Some(n) => model.arena.lookup(n).map(|t| model.frame_id(t)),
};
match (
point_from_str(&p.point),
rel,
point_from_str(&p.relative_point),
) {
(Some(point), Some(rel), Some(rp)) => {
anchors.push(Anchor::new(point, rel, rp, p.x, p.y));
}
_ => {
usable = false;
break;
}
}
}
if !usable {
continue;
}
let Some(input) = model.layout_inputs.get_mut(&h) else {
continue;
};
let changed = input.anchors != anchors
|| input.width.to_bits() != l.width.to_bits()
|| input.height.to_bits() != l.height.to_bits();
input.anchors = anchors;
input.width = l.width;
input.height = l.height;
if let Some(f) = model.arena.frame_mut(h) {
f.user_placed = true;
}
if changed {
// The whole graph, not the frame alone: a restore REPOINTS anchors (a saved row
// may name a different target than the authored one), and decision 1388's
// frame-scoped touch is only valid while every anchor keeps its target.
model.touch_layout();
}
}
}
/// Has a user-placed frame moved or resized since the last call? The host's cue to persist —
/// a single bit rather than a set, because the file is written whole either way.
pub fn take_user_placed_change(&mut self) -> bool {
std::mem::take(&mut self.model_mut().user_placed_changed)
}
}
/// One live anchor → its saved form, or `None` when the target cannot be named across a session
/// (an unnamed frame, or a region — see the module doc).
fn saved_point(model: &Model, a: &Anchor) -> Option<LayoutPoint> {
let relative_to = if a.relative_to == SCREEN {
None
} else {
let h: FrameHandle = model.id_to_frame.get(&a.relative_to).copied()?;
Some(model.arena.frame(h)?.name.clone()?)
};
Some(LayoutPoint {
point: point_name(a.point).to_owned(),
relative_to,
relative_point: point_name(a.relative_point).to_owned(),
x: a.x_off,
y: a.y_off,
})
}
+44
View File
@@ -8,6 +8,12 @@
//! (decision 0203). The model attrs (`minimapPlayerModel=`/`minimapArrowModel=`) are not
//! modeled yet — deferred with phase 3's blips.
//!
//! **The ping** (decision 1596) is the same split, one rung further out: the two methods here are
//! pure seam — `PingLocation` parks a click and `GetPingPosition` reads back what the app
//! published — because the ping's only stored form is a WORLD point the app pins it to, and the
//! engine core has no world. Nothing here holds the ping's position, its lifetime, or its art;
//! putting any of that in Lua is what made the first attempt flaky.
//!
//! **The level persists** (decision 1131). The client keeps two halves: the live indices
//! (`0x86f698` outdoor / `0x86f69c` indoor — our [`MinimapState`]) and the two CVar objects
//! `minimapZoom`/`minimapInsideZoom` (both registered default `"3"`), which are what `Config.wtf`
@@ -93,6 +99,44 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
with_minimap(lua, &this, |_| MINIMAP_ZOOM_LEVELS)
})?,
)?;
m.set(
"PingLocation",
// The minimap click (our own `Minimap_OnClick`, and any addon that replaced it): centre-
// relative offsets in **UI units**, x right / y up — exactly `GetCursorPosition()` minus
// `Minimap:GetCenter()`, both of which are UI-space. Parked, not converted: the app owns
// the view scale, and it drains this in the SAME frame it draws the map so the click
// resolves against the geometry the player actually clicked on (decision 1596).
//
// The receiver is validated but the value lives on the model — one pending click, not one
// per Minimap widget.
lua.create_function(|lua, (this, x, y): (Table, f32, f32)| {
with_minimap(lua, &this, |_| ())?;
lua.app_data_mut::<Model>()
.expect("model app_data")
.minimap_ping_request = Some((x, y));
Ok(())
})?,
)?;
m.set(
"GetPingPosition",
// The live ping's normalized offsets from the widget centre (fractions of the widget
// side, x right / y up — the `MINIMAP_PING` event's own arg2/arg3 space), recomputed by
// the app from the ping's world point every frame one is live. So a caller polling this
// while walking sees the value MOVE, which is the whole point: the ping is pinned to the
// world, not to the map.
//
// Returns **nil, nil** when no ping is live, rather than `(0, 0)`: zero is a real answer
// meaning "the ping is under your feet", and handing it back for "there is no ping" is a
// lie a caller cannot tell from the truth (1203).
lua.create_function(|lua, this: Table| {
with_minimap(lua, &this, |_| ())?;
let ping = lua
.app_data_ref::<Model>()
.expect("model app_data")
.minimap_ping;
Ok(ping.map_or((None, None), |(x, y)| (Some(x), Some(y))))
})?,
)?;
lua.set_named_registry_value(REG_MINIMAP_METHODS, m)?;
Ok(())
}
+22 -1
View File
@@ -82,6 +82,7 @@ mod item_text;
pub mod keybind;
mod keyboard;
mod layout;
mod layout_cache;
mod loot;
mod loot_roll;
mod lua50;
@@ -100,7 +101,7 @@ mod pointer;
mod pvp;
mod quest;
mod quest_log;
mod region;
pub(crate) mod region;
/// The 19 Region-map methods, shared by frames and regions the way the reference shares them
/// (decision 1501) — its header is the byte-verified chain and the bug that found the split.
mod region_map;
@@ -173,6 +174,7 @@ pub use guild::{
pub use inspect::{InspectView, UnitReach};
pub use item_stats::{item_usable, ItemSetView, ItemTemplateView, PlayerReqState};
pub use item_text::ItemTextState;
pub use layout_cache::{FrameLayout, LayoutPoint};
pub use loot::{LootRow, LootState};
pub use loot_roll::{LootRollEntry, LootRollsState};
pub use macros::{MacroState, MacroView, MAX_MACROS, MAX_MACRO_BODY, MAX_MACRO_NAME};
@@ -748,6 +750,25 @@ impl UiScript {
}
}
/// Publish the live minimap ping's **normalized** offsets from the widget centre (fractions
/// of the widget side, x right / y up), or `None` when no ping is live —
/// `Minimap:GetPingPosition()`'s source. The app recomputes this from the ping's WORLD point
/// every frame a ping is live, before the tick, so a poller sees it track the world as the
/// player walks.
///
/// One field, not one per widget: there is exactly one ping (decision 1596), and the old
/// per-widget push walked the whole ~3k-frame arena every frame a ping was live.
pub fn set_minimap_ping(&mut self, ping: Option<(f32, f32)>) {
self.model_mut().minimap_ping = ping;
}
/// Drain a `Minimap:PingLocation(x, y)` call — centre-relative offsets in **UI units**
/// (x right, y up). The app converts: UI units × the seam scale = window px, ÷ the live
/// `px_per_yd` = yards, and the click resolves against the geometry drawn in the same frame.
pub fn take_minimap_ping_request(&mut self) -> Option<(f32, f32)> {
self.model_mut().minimap_ping_request.take()
}
/// Monotonic count of Minimap widgets ever created in this VM — the O(1) signal that a new
/// one exists and needs the containment state pushed.
pub fn minimap_widgets_created(&self) -> u64 {
+31 -4
View File
@@ -4,10 +4,10 @@ use crate::layout::{LayoutInput, LayoutSolver, Rect};
use crate::widget::{FrameHandle, RegionHandle, WidgetArena};
use super::{
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,
auction, backdrop, bank, char_stats, chat_window, colorselect, 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,
};
@@ -445,6 +445,11 @@ pub(crate) struct Model {
/// [`super::UiScript::take_chat_window_changes`] drain — the persist cue. A set, so a slider
/// drag costs one entry however many steps it took.
pub(crate) chat_window_changes: HashSet<usize>,
/// Whether a **user-placed** frame's geometry moved since the app's last
/// [`super::UiScript::take_user_placed_change`] drain — the layout cache's persist cue
/// ([`super::layout_cache`]). One bit rather than a set of handles, because the file is
/// rewritten whole either way; set by the move/resize pumps and by `SetUserPlaced` itself.
pub(crate) user_placed_changed: bool,
/// 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**
@@ -795,6 +800,10 @@ pub(crate) struct Model {
/// like a scrollbar dragging in the real client, no Lua involved). `None` between drags
/// ([`slider::SliderDrag`]).
pub(crate) slider_drag: Option<slider::SliderDrag>,
/// The in-flight ColorSelect drag — the colour picker's wheel/value-strip twin of
/// [`Self::slider_drag`], held from press to release. The widget keeps two independent capture
/// flags rather than one target ([`colorselect::ColorDrag`]).
pub(crate) color_drag: Option<colorselect::ColorDrag>,
/// The open gossip menu the app pushes (`None` = no menu), the `SelectGossipOption` intents it
/// drains, and whether `CloseGossip` was called — the gossip seam ([`gossip`]).
@@ -1221,6 +1230,20 @@ pub(crate) struct Model {
/// saw (UI space: logical px, y-up — the same frame `resolve` rects live in). Behind Lua's
/// `GetCursorPosition()`; the reference world map polls it every OnUpdate for hover/click math.
pub(crate) cursor_pos: (f32, f32),
/// A `Minimap:PingLocation(x, y)` call, parked for the app to drain **in the same frame**
/// ([`UiScript::take_minimap_ping_request`]): centre-relative offsets in **UI units**
/// (x right, y up — `GetCursorPosition`'s own space, which is NOT the window-pixel space
/// the app's minimap geometry lives in; the app applies the 0582 seam scale on the way in,
/// and getting that wrong is decision 1596's first root cause).
pub(crate) minimap_ping_request: Option<(f32, f32)>,
/// The live minimap ping's **normalized** offsets from the widget centre (fractions of the
/// widget side, x right / y up — the `MINIMAP_PING` event's arg2/arg3 space), republished
/// by the app every frame a ping is live and cleared when the ping ends. Behind
/// `Minimap:GetPingPosition()`. There is exactly one ping, so it lives here rather than on
/// each Minimap widget's [`KindState`](crate::widget::KindState) — one write, one read,
/// no arena walk (decision 1596).
pub(crate) minimap_ping: Option<(f32, f32)>,
/// The realm this session is on, behind `GetRealmName()` (decision 1195). `""` until the app
/// pushes one — the glue screen's own answer, and never `nil`, because the corpus idiom is
/// `db[GetRealmName()] = …` at file scope and a nil index errors one call deeper.
@@ -1393,6 +1416,7 @@ impl Model {
chat_window_looks: [chat_window::ChatWindowLook::DEFAULT;
chat_window::NUM_CHAT_WINDOWS],
chat_window_changes: HashSet::new(),
user_placed_changed: false,
default_language: None,
duel_requests: Vec::new(),
follow_requests: Vec::new(),
@@ -1470,6 +1494,7 @@ impl Model {
moving: None,
sizing: None,
slider_drag: None,
color_drag: None,
gossip: None,
gossip_selects: Vec::new(),
gossip_close: false,
@@ -1574,6 +1599,8 @@ impl Model {
worldstate: super::worldstate::WorldStateUiState::default(),
pending_events: Vec::new(),
cursor_pos: (0.0, 0.0),
minimap_ping_request: None,
minimap_ping: None,
chat_sends: Vec::new(),
addon_sends: Vec::new(),
played_time_asks: 0,
+1 -1
View File
@@ -42,7 +42,7 @@ pub(super) fn id_to_lud(id: u32) -> LightUserData {
}
/// Read the id out of a wrapper table's `T[0]` lightuserdata (RF-0023).
pub(super) fn decode_id(this: &Table) -> mlua::Result<u32> {
pub(crate) fn decode_id(this: &Table) -> mlua::Result<u32> {
match this.raw_get::<Value>(0)? {
Value::LightUserData(l) => Ok(l.0 as usize as u32),
_ => Err(mlua::Error::runtime(
+47 -19
View File
@@ -73,10 +73,10 @@
//! dragged frame before it takes the drag slot, and [`start_moving`] does the same through
//! [`super::toplevel::raise`]. Like every raise it is occlusion-gated and confined to the frame's
//! own stratum, so grabbing a movable window that overlaps nothing still changes no draw order.
//! - **No resize drag.** `StartSizing 0x776830` and the eight resize cases are not built.
//! `SetResizable`/`IsResizable` are the flag only, and `StopMovingOrSizing` — which is one verb
//! for both halves in the reference too — already covers the stop side of a resize that does
//! not exist yet.
//! - **The resize drag is built** (`StartSizing 0x776830` + [`advance_size`]), including the
//! min/max clamp and its rebate; what is *not* transcribed is which edges travel — the reference
//! collapses the frame to one planted TOPLEFT anchor at `StartSizing` and we keep the authored
//! anchor set, which [`advance_size`]'s own note states rather than hides.
use mlua::{Lua, MultiValue, Table, Value};
@@ -243,11 +243,12 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// three that is GUARDED: `776adb: test ah,0x3` refuses unless the frame is movable OR
// resizable, raising the third of this family's error strings.
//
// benilla stores and reports the flag and **nothing consumes it yet**: persisting a frame's
// position belongs with the layout cache, not with the drag that moved it, and building a
// private position store here would put a second one beside the one that should own it. The
// overwhelmingly common addon shape — set the bit, then save your own coordinates — is
// unaffected either way.
// **The consumer is [`crate::script::layout_cache`]**, which is where it belongs: persisting a
// frame's position is the layout cache's job, not the drag's, and a private position store
// here would have put a second one beside the one that should own it. The bit is what that
// module enumerates on, so setting it is how a frame opts into being remembered — and the
// overwhelmingly common addon shape (set the bit, then save your own coordinates) still works
// either way.
m.set(
"SetUserPlaced",
lua.create_function(|lua, (this, flag): (Table, bool)| {
@@ -261,7 +262,13 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
return Err(not_flagged(&model, h, "movable or resizable"));
}
if let Some(f) = model.arena.frame_mut(h) {
f.user_placed = flag;
if f.user_placed != flag {
f.user_placed = flag;
// The layout cache's persist cue ([`crate::script::layout_cache`]): a frame
// that has just BECOME user-placed owes the file a row, and one that stopped
// owes it the row's removal. Both are the same "rewrite it" bit.
model.user_placed_changed = true;
}
}
Ok(())
})?,
@@ -316,9 +323,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// for every `StartSizing` whatever it was handed, and only the pump's per-grip switch
// is selective. So both happen before the grip is looked at.
super::toplevel::raise(&mut model, h);
if let Some(f) = model.arena.frame_mut(h) {
f.user_placed = true;
}
set_user_placed(&mut model, h);
let sample = model.cursor_pos;
// **`StartSizing("CENTER")` is a MOVE, not a resize** — and it is a real case, not a
// caller error. The pump's switch is on the anchor point-id, and case 4 (CENTER) is the
@@ -442,9 +447,7 @@ fn start_moving(model: &mut Model, h: FrameHandle) -> mlua::Result<()> {
// the drag slot, which is the order kept here. Grabbing a window brings it forward; the worker
// supplies the toplevel gate (a non-toplevel movable frame raises nothing).
super::toplevel::raise(model, h);
if let Some(f) = model.arena.frame_mut(h) {
f.user_placed = true;
}
set_user_placed(model, h);
model.moving = Some(FrameMove {
frame: h,
sample: model.cursor_pos,
@@ -472,9 +475,7 @@ pub(crate) fn start_title_move(model: &mut Model, h: FrameHandle) -> bool {
return false;
}
super::toplevel::raise(model, h);
if let Some(f) = model.arena.frame_mut(h) {
f.user_placed = true;
}
set_user_placed(model, h);
model.moving = Some(FrameMove {
frame: h,
sample: model.cursor_pos,
@@ -483,6 +484,31 @@ pub(crate) fn start_title_move(model: &mut Model, h: FrameHandle) -> bool {
true
}
/// Set the userPlaced bit the way the client's drag entry `0x7652b0` does, and dirty the layout
/// cache **only on the transition** — a press inside a title region runs this on every click, and
/// dirtying on each would put the saver's debounce back to work for a frame nothing moved.
fn set_user_placed(model: &mut Model, h: FrameHandle) {
if let Some(f) = model.arena.frame_mut(h) {
if !f.user_placed {
f.user_placed = true;
model.user_placed_changed = true;
}
}
}
/// A drag pump moved a frame; if that frame is **user-placed**, the layout cache owes the player's
/// file a rewrite ([`crate::script::layout_cache`]).
///
/// The gate is the bit rather than "any drag" on purpose: `StartMoving`/`StartSizing` set the bit
/// themselves, so the only frames this can miss are ones an addon moves with `SetPoint` — and those
/// are the frames whose position the addon is saving itself, which is the reference's own division
/// of labour (the engine's layout cache holds userPlaced frames; addons hold their own).
fn mark_user_placed_change(model: &mut Model, h: FrameHandle) {
if model.arena.frame(h).is_some_and(|f| f.user_placed) {
model.user_placed_changed = true;
}
}
/// Apply a frame's `SetMinResize`/`SetMaxResize` bounds to a proposed size, per axis.
///
/// The reference's predicate, transcribed (decision 1505; wow-re
@@ -630,6 +656,7 @@ pub(crate) fn advance_size(model: &mut Model, pos: (f32, f32)) {
// the difference between a smooth window resize and one that re-derives 13,656 nodes on
// every mouse-move.
model.touch_layout_frame(sz.frame);
mark_user_placed_change(model, sz.frame);
}
model.sizing = Some(FrameSizing { sample: pos, ..sz });
}
@@ -671,6 +698,7 @@ pub(crate) fn advance_move(model: &mut Model, pos: (f32, f32)) {
// delta returned above, so this write always moved something). Translating every
// anchor moves no TARGET, so the frame names itself (decision 1388).
model.touch_layout_frame(mv.frame);
mark_user_placed_change(model, mv.frame);
}
}
model.moving = Some(FrameMove { sample: pos, ..mv });
+89 -8
View File
@@ -188,17 +188,21 @@ impl UiScript {
editbox::drag_update(&self.lua, x, y);
let new_id = self.hit_test(x, y);
#[allow(clippy::type_complexity)]
let (old_id, drag_start, boundary_crossed, slider_change): (
let (old_id, drag_start, boundary_crossed, slider_change, color_change): (
Option<u32>,
Option<(u32, String)>,
bool,
Option<(u32, f32)>,
Option<(u32, f64, f64, f64)>,
) = {
let mut model = self.model_mut();
model.cursor_pos = (x, y);
// Advance an in-flight Slider thumb drag first — dragging within one frame crosses no
// hover boundary, so this must run before the early return below (decision 0250 §5).
let slider_change = super::slider::drag_move(&mut model, x, y);
// The colour picker's wheel/strip drag, for the identical reason — and it fires on
// every move, not only on a change (the widget has no change-gate).
let color_change = super::colorselect::drag_move(&mut model, x, y);
// A frame in flight from `StartMoving()` follows the cursor here for the same reason
// (`object::movable`): the frame moves *under* the cursor, so the hover boundary never
// crosses and a pump behind the early return would only ever advance when the cursor
@@ -208,7 +212,8 @@ impl UiScript {
let drag_start = cursor::maybe_start_drag(&mut model, (x, y));
let new_handle = new_id.and_then(|id| model.id_to_frame.get(&id).copied());
if new_handle == model.mouseover {
(None, drag_start, false, slider_change) // no boundary crossed
// no boundary crossed
(None, drag_start, false, slider_change, color_change)
} else {
// Fire OnLeave only if the frame we're leaving is still live.
let old_id = model.mouseover.and_then(|h| {
@@ -220,7 +225,7 @@ impl UiScript {
.flatten()
});
model.mouseover = new_handle;
(old_id, drag_start, true, slider_change)
(old_id, drag_start, true, slider_change, color_change)
}
};
// The thumb-drag value change fires OnValueChanged (outside the borrow) — the scrollbar's
@@ -235,6 +240,11 @@ impl UiScript {
self.push_error(e);
}
}
if let Some((id, r, g, b)) = color_change {
if let Err(e) = super::colorselect::fire_by_id(&self.lua, id, r, g, b) {
self.push_error(e);
}
}
if let Some((id, button)) = drag_start {
let btn = match self.lua.create_string(&button) {
Ok(s) => Value::String(s),
@@ -285,7 +295,9 @@ impl UiScript {
/// A mouse button transition at `(x, y)`: `button` is the WoW name (`"LeftButton"`,
/// `"RightButton"`, …), `down` is press vs release. Fires `OnMouseDown(self, button)` on press or
/// `OnMouseUp(self, button)` on release, on the captured frame.
/// `OnMouseUp(self, button)` on release. **The press fires on the hit frame and the release on
/// the frame that took the press** — the client's mouse capture (`root+0x80`); see the dispatch
/// itself for the bytes and for the stuck-resize it prevents.
///
/// Separately, `OnClick` fires per the hit frame's `RegisterForClicks` set
/// ([`button::wants_click`]): a **press** fires immediately when the frame registered
@@ -352,6 +364,18 @@ impl UiScript {
{
self.model_mut().moving = None;
}
// The capture this release belongs to — read HERE because the borrow below *drains*
// `mouse_down_on` for this button. See the `OnMouseUp` dispatch further down for why the
// press target rather than the hit frame.
let captured_id: Option<u32> = if down {
None
} else {
let model = self.model_ref();
model
.mouse_down_on
.get(button)
.and_then(|h| model.frame_to_id.get(h).copied())
};
// The session clock, read before the borrow below — the double-click detector's only input
// beyond the hit ([`Model::last_click`]). Same `GetTime()` seconds every script sees.
let now = self.now();
@@ -368,13 +392,22 @@ impl UiScript {
// "<Button>ButtonDown"; a release fires it when press+release landed on the same frame AND
// it registered "<Button>ButtonUp" — UNLESS a started drag is being resolved instead.
#[allow(clippy::type_complexity)]
let (click_id, drag_release, world_dropped, slider_jump, double_id, abandoned): (
let (
click_id,
drag_release,
world_dropped,
slider_jump,
double_id,
abandoned,
color_jump,
): (
Option<u32>,
Option<cursor::DragRelease>,
bool,
Option<(u32, f32)>,
Option<u32>,
Option<crate::widget::FrameHandle>,
Option<(u32, f64, f64, f64)>,
) = {
let mut model = self.model_mut();
let hit_handle = hit_id.and_then(|id| model.id_to_frame.get(&id).copied());
@@ -401,16 +434,27 @@ impl UiScript {
} else {
None
};
// A left press inside a ColorSelect's wheel or value strip captures the colour
// drag AND jumps the colour to the click point in the same call — the widget's
// press handler invokes its own cursor-position handler before returning
// (`0x78bf70 call [eax+0x3c]`), so there is no such thing as a press that only
// arms.
let color_jump = if button == "LeftButton" {
super::colorselect::begin_drag(&mut model, hit_handle, x, y)
} else {
None
};
let wants = format!("{button}Down");
let click = hit_handle
.filter(|&h| button::wants_click(&model, h, &wants))
.and(hit_id);
(click, None, false, jump, None, abandoned)
(click, None, false, jump, None, abandoned, color_jump)
} else {
let pressed = model.mouse_down_on.remove(button);
// A left release ends any in-flight thumb drag (decision 0250 §5).
if button == "LeftButton" {
super::slider::end_drag(&mut model);
super::colorselect::end_drag(&mut model);
}
let same_frame = matches!(
(pressed, hit_handle),
@@ -478,7 +522,7 @@ impl UiScript {
let double_id = double.and_then(|h| model.frame_to_id.get(&h).copied());
// Exclusive, not additive — the double leg is taken *instead of* the single one.
let click = if double_id.is_some() { None } else { click };
(click, release, dropped, None, double_id, None)
(click, release, dropped, None, double_id, None, None)
}
};
// A track press's value jump fires OnValueChanged first (outside the borrow), the same
@@ -493,8 +537,45 @@ impl UiScript {
self.push_error(e);
}
}
// The press's colour jump fires OnColorSelect the same way.
if let Some((id, r, g, b)) = color_jump {
if let Err(e) = super::colorselect::fire_by_id(&self.lua, id, r, g, b) {
self.push_error(e);
}
}
// ── `OnMouseUp` goes to the frame that took the PRESS, not to whatever is under the
// cursor now — the mouse CAPTURE (`root+0x80`).
//
// The client's mouse-DOWN handler `0x7662c0` "resolves target = root+0x80 (existing
// capture) else root+0x7c (hover frame) … then title-region drag or **capture**+OnMouseDown"
// (wow-re ledger `0x7662c0`, VERIFIED), so a press takes the pointer.
//
// ⚠ **The release half is INFERRED, not carved.** That the capture is what the *UP*
// handler dispatches through is an extrapolation from the DOWN handler: the sibling
// `0x766420` (category `0xe`) carries only a bulk wave-classify label in wow-re's ledger,
// not a derivation. The inference is strong — a capture nothing consults on release would
// be pointless, and something has to clear `root+0x80` — but it is an inference, and this
// is a whole-UI change (every press-on-A-release-on-B in the client now delivers to A). A
// §5 is dispatched for `0x766420`'s target resolution (decision 1594); when it lands this
// comment says VERIFIED or this code changes.
//
// We already track exactly the capture
// — [`Model::mouse_down_on`], the press target per button, which `OnClick`'s same-frame
// rule reads — and firing `OnMouseUp` at the *hit* frame instead was the one place it went
// unused. This function's own doc has always said "on the captured frame"; the code did
// not.
//
// It is not a nicety: a chat window's resize grip is a 16×16 button that the drag pulls
// out from under the cursor the moment the size clamps at `SetMinResize`/`SetMaxResize`
// (the rebate plants the edge on the bound while the cursor keeps going). Its
// `OnMouseUp → FCF_StopResize` then never ran, and `Model::sizing` stayed held for the
// rest of the session — the stuck-drag failure `cursor::abandon_drag` exists to prevent on
// the other path.
//
// A press that captured NOTHING (over the world, or swallowed by a title region) falls
// back to the hit frame, which is `root+0x7c`'s arm of the same resolve.
let script = if down { "OnMouseDown" } else { "OnMouseUp" };
if let Some(id) = hit_id {
if let Some(id) = if down { hit_id } else { captured_id.or(hit_id) } {
if let Err(e) = event::fire_widget_handler(&self.lua, id, script, vec![btn.clone()]) {
self.push_error(e);
}
+18
View File
@@ -512,6 +512,24 @@ pub(super) fn region_name_of(model: &Model, id: u32) -> Option<String> {
.map(|(k, _)| k.clone())
}
/// Publish a region's global name into the **region-name registry** — the table a sibling's
/// `SetPoint`/`SetAllPoints` resolves a named `relativeTo` through
/// ([`super::object::layout_methods`]). First-wins, the same rule frames follow.
///
/// `CreateTexture(name)` does this for itself; the paths that *don't* are the typed widgets' own
/// sub-textures, which are created by a setter (`SetThumbTexture`, `SetColorWheelTexture`, …) and
/// named afterwards by the XML loader. Without this they land in `_G` and nowhere else, and a
/// `relativeTo="ColorPickerWheel"` on a sibling silently falls back to the parent frame — which is
/// exactly how the colour picker's value strip first ended up 224 px to the right of where the
/// reference puts it, anchored to the window instead of to the wheel.
pub(crate) fn publish_region_name(lua: &Lua, name: &str, region: &Table) {
let Ok(id) = super::object::decode_id(region) else {
return;
};
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.region_names.entry(name.to_string()).or_insert(id);
}
/// The string `GetObjectType()` answers for this region, and the leaf `IsObjectType` matches.
///
/// The reference reads a per-class `.data` `const char*` through `vtable[+0x1c]`; ours reads the
+28
View File
@@ -129,6 +129,34 @@ pub enum QuadContent {
/// (`SetItemButtonDesaturated(button, 1, 0.65, 0.65, 0.65)`) and expect both to land.
desaturated: bool,
},
/// A `ColorSelect`'s **hue disc** — its `<ColorWheelTexture>` region. That element carries no
/// `file=` in the reference either: the wheel is generated by the client, not loaded, and there
/// is no BLP anywhere in the MPQ chain that is a colour wheel. So the engine core hands the
/// renderer the rect and the widget's HSV and the app makes the pixels — the same division the
/// [`QuadContent::Minimap`] slot uses, for the same reason.
///
/// The disc it must draw is fixed by the *pick* law it has to invert (wow-re
/// `colorselect-color-law.md` §5): at normalised offset `(nx, ny)` from the rect's centre the
/// pixel is `HSV(atan2(ny, nx)·180/π + 180, min(|n|, 1), …)`, so clicking a pixel selects the
/// colour that pixel shows.
///
/// **It carries no colour**, and that is the byte law rather than an omission: the fill loop
/// draws every texel at a literal `V = 1.0` (`0x78b68b`) and never reads the widget's own
/// value, so the disc is the same image at every colour. Handing the renderer an HSV it must
/// ignore would also churn this quad on every step of a drag, for no pixel.
ColorWheel,
/// A `ColorSelect`'s **brightness strip** — its `<ColorValueTexture>` region, the wheel's
/// file-less twin. A vertical ramp of the currently selected hue and saturation, black at the
/// bottom, which is the inverse of the strip's own pick law
/// (`V = clamp((y − bottom)/(top − bottom), 0, 1)`).
ColorValue {
/// The widget's hue, degrees.
hue: f32,
/// The widget's saturation, `0..=1`. Together these are the whole strip: its top is
/// `HSV(hue, sat, 1)` and its bottom is black, so the widget's **value** moves no pixel of
/// it (only its marker).
sat: f32,
},
/// One frame `Backdrop` piece (the tiled bg, or one of the 8 border pieces) — emitted at the
/// owning frame's own draw slot, behind its regions ([`UiScript::extract`](super::UiScript::extract)). A textured quad
/// whose UVs are four explicit per-corner pairs (`[TL, TR, BR, BL]`, screen order) because the
+32 -1
View File
@@ -744,6 +744,22 @@ pub struct ColorSelectState {
/// `[hue-degrees, saturation, value]` — the widget's `+0x328`/`+0x32c`/`+0x330`. Hue is `-1.0`
/// whenever saturation is 0 (the grey sentinel).
pub hsv: [f32; 3],
/// `<ColorWheelTexture>` / `SetColorWheelTexture` (`0x78de90`), the widget's `+0x318` — the hue
/// disc. The **rect of this region is the wheel's hit box**: the press handler tests the cursor
/// against `[+0x318]+0x24`, not against the frame. Created on first set, like a Slider's thumb.
pub wheel: Option<RegionHandle>,
/// `<ColorWheelThumbTexture>` / `SetColorWheelThumbTexture` (`0x78e160`) — the little marker
/// that rides the disc. Its rect is *derived* from `hsv` at extract, and an anchor authored on
/// it in XML is **discarded**: `0x78b850` calls `ClearAllPoints 0x767ed0` and re-`SetPoint`s
/// from C++ on every colour change. The two thumbs are the only elements here with a `file=`
/// and the only ones the reference gives no `<Anchors>` — those two facts are the same fact.
pub wheel_thumb: Option<RegionHandle>,
/// `<ColorValueTexture>` / `SetColorValueTexture` (`0x78e450`), the widget's `+0x320` — the
/// brightness strip, and the second hit box (`[+0x320]+0x24`).
pub value_strip: Option<RegionHandle>,
/// `<ColorValueThumbTexture>` / `SetColorValueThumbTexture` (`0x78e720`) — the strip's marker,
/// rect derived from `hsv[2]`.
pub value_thumb: Option<RegionHandle>,
}
impl Default for ColorSelectState {
@@ -751,6 +767,10 @@ impl Default for ColorSelectState {
fn default() -> ColorSelectState {
ColorSelectState {
hsv: [0.0, 0.0, 1.0],
wheel: None,
wheel_thumb: None,
value_strip: None,
value_thumb: None,
}
}
}
@@ -861,7 +881,7 @@ impl ColorSelectState {
/// what makes the `-1` sentinel inert on the way out. Otherwise the 6-sector decode, with the
/// sector floored by the same magic-512 trick as quantizer B and clamped to `≤ 5`. The
/// `f32(1/60) = 0x3c888889` on the way back is the lossy step the whole `-1` drift comes from.
fn hsv_to_rgb(hsv: &[f32; 3]) -> [f32; 3] {
pub fn hsv_to_rgb(hsv: &[f32; 3]) -> [f32; 3] {
let f = f64::from;
let (h, s, v) = (hsv[0], hsv[1], hsv[2]);
if s == 0.0 {
@@ -887,6 +907,17 @@ impl ColorSelectState {
}
}
/// Store HSV **raw** — no clamp, no quantize, no round trip through RGB. This is what the
/// widget's own drag handler does (`0x78bd80`: `fstp [esi+0x328]` / `[+0x32c]` / `[+0x330]`
/// straight off the geometry) and what `SetColorHSV 0x78e920` does (wow-re
/// `colorselect-color-law.md` §4/§5). The reachable state set is therefore *strictly finer*
/// than [`Self::set_rgb`]'s, which can only ever land on the HSV image of the 8-bit lattice —
/// the picker's wheel resolves colours the Lua boundary cannot name. What Lua *reads back* is
/// quantized identically either way, because the quantize lives on the outbound leg.
pub fn set_hsv(&mut self, h: f32, s: f32, v: f32) {
self.hsv = [h, s, v];
}
/// `SetColorRGB`'s whole store, in the binary's order (`0x78eb7e`…`0x78ecfa`): clamp each
/// argument to `[0, 1]` as `f32` → quantizer A → back to `f32` through `0x7bbf20`'s `1/255` →
/// `0x7bbc80` RGB→HSV → store. Always stores — there is deliberately **no change-gate**, unlike