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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -453,6 +453,7 @@ impl Loader<'_> {
|
||||
self.call_region(®ion, "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, ®ion);
|
||||
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(®ion, "SetBlendMode", mode.to_string(), dbg);
|
||||
}
|
||||
self.apply_region_layout(tt, ®ion, self_name, dbg);
|
||||
if let Some(tc) = tex_coords_of(tt) {
|
||||
self.call_region(®ion, "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, ®ion);
|
||||
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):
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user