the world becomes the UI's backdrop, and recipe rows go white on hover

- every translucent UI pixel over the 3D world composited in the wrong space: the world lane and the UI lane each blend in gamma bytes the way the reference's fixed-function device does, but the one blend that mixes them — the UI camera's finished image onto the sRGB swapchain — ran in linear, so a chat tab whose body is black at alpha 102/255 left the scene at 77.5% of its brightness instead of 60%, and the dock's hover glow (an ADD with no plate under it) read as a lozenge floating on grass; the world camera now renders to an off-screen image drawn as the first quad of the UI pass, so every UI blend over the world is the same blend as every UI blend over UI — after, the selected tab plate lands at 0.593–0.600 of the bare scene against 0.60 predicted, and bare-world pixels move at most 1 LSB
- the backdrop image is un-encoded `Rgba16Float`, because the UI arc composites in gamma and takes its one decode at the end — pre-encoding would land a second encode in that chain, and quantizing un-encoded values to 8 bits is the banding collapse measured long ago; and hiding the UI keeps the world: the toggle used to retire every batch in the pass, which would now be a black screen — hidden means the two UI lanes draw nothing while the backdrop, which is the world, still draws, with the no-world glue screens pinned on the old behaviour
- a button's highlighted label is its own font instance: the client swaps which of three embedded instances the label reads — it never merges colour across states — so the `highlight_color.or(normal_color)` fallback is gone, and `LockHighlight()` now enters the label, not just the highlight texture (the reference blanks a recipe row's highlight texture to "" and then locks the row it selected — with nothing left to pin, the lock's only possible effect is the label, and that is the white text on a selected recipe)
- tradeskill, craft and class-trainer rows read white on hover and on select: all three windows carried the row name as a child FontString no per-state font can reach, behind a "no engine method" note that was true when written and stale ever since — the name is now the button's own `<ButtonText>` under the base template's `<NormalFont>`/`<HighlightFont>`/`<DisabledFont>` trio, and the windows call `SetTextColor`/`LockHighlight`/`UnlockHighlight` where the reference calls them

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-27 15:06:05 +07:00
co-authored by Claude Fable 5
parent d0b06578e9
commit 1da3fb044f
11 changed files with 969 additions and 128 deletions
+46 -22
View File
@@ -104,21 +104,24 @@
out of scope (`craft.rs`'s own note).
7. ENGINE API gaps (TradeSkillFrame.xml's own "no engine equivalent" posture, restated per
substitution actually used here):
- `Button:SetTextColor`/`:LockHighlight`/`:UnlockHighlight` have no native method (grepped;
`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`/`$parentSubText`) instead of the button's own inherited text
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
`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
- (CLOSED — the row name is the button's own label now.) An earlier revision recorded
`Button:SetTextColor`/`:LockHighlight`/`:UnlockHighlight` as having no engine method and
routed row colouring through a `$parentName` child FontString. The engine has shipped
all three for a long time; the note went stale and the workaround outlived it, costing
this window the white hover/selected label the real client has (decision 1605). The row
name is now a `<ButtonText>` (the ref's `Craft<n>Text`) under the base's
`<NormalFont>/<HighlightFont>/<DisabledFont>` trio, and Update calls `SetTextColor` /
`LockHighlight` / `UnlockHighlight` where the ref does (l.190/234/236). `$parentSubText`
stays a child FontString, as it is in the ref, and is whitened by hand for the selected
row (l.232). NOT taken: the ref's per-row `craftButton.r/g/b` stash and the base's own
`OnEnter`/Craft's `OnLeave` subtext recolour dance (l.191-193, xml l.22-26) — with
Beast Training out of scope (deviation 6) `craftSubSpellName` is always `""`, so every
one of those paints an empty string. The row still carries NO hover tooltip: the ref
has none either. (An earlier revision of this line added "TradeSkill's own row hover IS
a real ref behavior; Craft's is not" — WRONG about *tooltips*, 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.)
only recolours `$parentSubText` on hover. NEITHER window's rows tooltip.)
- `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
@@ -357,12 +360,13 @@
button.craftIndex = craftIndex
local color = CraftTypeColor[craftType]
if color then
button.name:SetTextColor(color.r, color.g, color.b)
button:SetTextColor(color.r, color.g, color.b) -- ref l.190
button.subtext:SetTextColor(color.r, color.g, color.b) -- ref l.195
end
if (numAvailable or 0) == 0 then
button.name:SetText(" " .. craftName)
button:SetText(" " .. craftName)
else
button.name:SetText(" " .. craftName .. " [" .. numAvailable .. "]")
button:SetText(" " .. craftName .. " [" .. numAvailable .. "]")
end
if craftSubSpellName and craftSubSpellName ~= "" then
button.subtext:SetText(format(PARENS_TEMPLATE, craftSubSpellName))
@@ -370,9 +374,19 @@
button.subtext:SetText("")
end
button:Show()
-- TWO things paint a selected row, and the ref does both here (l.229-236): the
-- glow bar is BenillaCraftHighlightFrame, and the WHITE LABEL is the row's own
-- LockHighlight() — the template's `<HighlightFont inherits="GameFontHighlight">`
-- instance winning over the difficulty colour SetTextColor just wrote, which
-- reaches the NORMAL instance only. The subtext is whitened by hand because it is
-- a child FontString, not the button's label, exactly as the ref does it (l.232).
if selection == craftIndex then
highlight:SetPoint("TOPLEFT", button, "TOPLEFT", 0, 0)
highlight:Show()
button:LockHighlight()
button.subtext:SetTextColor(1.0, 1.0, 1.0)
else
button:UnlockHighlight()
end
else
button.craftIndex = nil
@@ -567,19 +581,29 @@
<Size><AbsDimension x="293" y="16"/></Size>
<Layers>
<Layer level="ARTWORK">
<FontString name="$parentName" inherits="GameFontNormal" justifyH="LEFT">
<Size><AbsDimension x="180" y="16"/></Size>
<Anchors><Anchor point="LEFT" relativePoint="LEFT"><Offset><AbsDimension x="4" y="0"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="$parentSubText" inherits="GameFontNormalSmall" justifyH="LEFT">
<Size><AbsDimension x="99" y="16"/></Size>
<Anchors><Anchor point="LEFT" relativePoint="LEFT"><Offset><AbsDimension x="190" y="0"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
<!-- THE ROW NAME IS THE BUTTON'S OWN ButtonText (the ref's `Craft<n>Text`), under the base's
font TRIO — that is the whole white-on-hover / white-on-selected mechanism, and no script
does it: `GameFontHighlight` is white, the engine swaps the highlighted instance in under
the cursor, and `LockHighlight()` holds it there for the selected row. It was a
`$parentName` FontString until decision 1605, on this file's own deviation 7 note that
`Button:SetTextColor`/`LockHighlight` had no engine method — long since untrue.
TradeSkillFrame.xml's row template is the twin, and both keep their own column geometry
(LEFT+4 / 180×16 here) rather than the base's auto-size, so the switch moved no pixel. -->
<ButtonText name="$parentText">
<Size><AbsDimension x="180" y="16"/></Size>
<Anchors><Anchor point="LEFT" relativePoint="LEFT"><Offset><AbsDimension x="4" y="0"/></Offset></Anchor></Anchors>
</ButtonText>
<NormalFont inherits="GameFontNormal" justifyH="LEFT"/>
<HighlightFont inherits="GameFontHighlight" justifyH="LEFT"/>
<DisabledFont inherits="GameFontDisable" justifyH="LEFT"/>
<Scripts>
<OnLoad>
self.name = getglobal(self:GetName() .. "Name")
self.subtext = getglobal(self:GetName() .. "SubText")
</OnLoad>
<OnClick>BenillaCraftSkillButton_OnClick(self, arg1)</OnClick>
@@ -110,19 +110,22 @@
onto the ONE input-box instance in `BenillaTradeSkillFrame_OnLoad`
(`function box:SetNumber(n) self:SetText(tostring(floor(n or 0))) end`), so every call
site below still reads `TradeSkillInputBox:SetNumber(...)` exactly like the reference.
- `Button:SetTextColor`/`:LockHighlight`/`:UnlockHighlight` have no native method (grepped;
`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
`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
- (CLOSED — the row is the reference's shape now.) An earlier revision of this bullet
recorded `Button:SetTextColor`/`:LockHighlight`/`:UnlockHighlight` as having no engine
method, and routed row colouring through a child `$parentName` FontString instead of
the button's own label. The engine has shipped all three for a long time; the note went
stale and the workaround outlived it, which cost this window the white hover/selected
label the real client has (decision 1605). The row now carries a `<ButtonText>` under
the base's `<NormalFont>/<HighlightFont>/<DisabledFont>` trio, Update calls
`button:SetTextColor` / `LockHighlight` / `UnlockHighlight` exactly where the ref does
(l.113/127/144/146), and the white text is the HighlightFont instance, not a repaint.
**The selection is still TWO things.** The orange bar is
`BenillaTradeSkillHighlightFrame` — and **the Frame is what is 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
`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.
life and meant NO selection ever painted. The white label is the row's own highlight
lock, and the two are independent: 1598 restored the bar and the row stayed unlit.
- `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
@@ -441,16 +444,13 @@
button.skillIndex = skillIndex
local color = TradeSkillTypeColor[skillType]
if color then
button.name:SetTextColor(color.r, color.g, color.b)
button:SetTextColor(color.r, color.g, color.b)
end
-- Header row (ref l.118-127, wow-re `tradeskill` TU-B): the group name
-- verbatim, no "[n]" suffix, the +/- fold icon + its hover glow swapped on
-- isExpanded. UnlockHighlight() (ref l.127) is dropped — no native
-- Lock/UnlockHighlight (this file's header comment, deviation 7); the
-- persistent selection glow below is the separate BenillaTradeSkillHighlight
-- texture, never this button's own highlight lock.
-- isExpanded.
if skillType == "header" then
button.name:SetText(skillName)
button:SetText(skillName)
if isExpanded then
button:SetNormalTexture("Interface\\Buttons\\UI-MinusButton-Up")
else
@@ -461,17 +461,29 @@
button:SetNormalTexture("")
getglobal(button:GetName() .. "Highlight"):SetTexture("")
if (numAvailable or 0) == 0 then
button.name:SetText(" " .. skillName)
button:SetText(" " .. skillName)
else
button.name:SetText(" " .. skillName .. " [" .. numAvailable .. "]")
button:SetText(" " .. skillName .. " [" .. numAvailable .. "]")
end
end
button:Show()
-- The selection glow only ever lands on a recipe row — SetSelection never
-- selects a header (deviation 1), so `selection` can't be one's index.
--
-- TWO separate things paint a selected row, and the ref does both here
-- (l.142-146): the orange bar is BenillaTradeSkillHighlightFrame, and the
-- WHITE LABEL is the row's own LockHighlight() — the base's
-- `<HighlightFont inherits="GameFontHighlight">` over the difficulty colour
-- SetTextColor just wrote. The lock reaches nothing else on a recipe row: the
-- branch above blanked that row's HighlightTexture. Unlocking every other row
-- covers both of the ref's own UnlockHighlight calls at once — l.146's for a
-- passed-over recipe and l.127's for a header, which can never be `selection`.
if selection == skillIndex then
highlight:SetPoint("TOPLEFT", button, "TOPLEFT", 0, 0)
highlight:Show()
button:LockHighlight()
else
button:UnlockHighlight()
end
end
else
@@ -827,17 +839,16 @@
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.
**The row still lights up on hover, and no script does it.** That is the base's
`<HighlightFont inherits="GameFontHighlight">` — the button's own highlighted font instance,
swapped in by the engine while the cursor is on the row and held by `LockHighlight()` on the
selected one. Reading 1598's "bare on hover" as "nothing happens on hover" is the trap this
paragraph exists to close: what the reference has no trace of is a *tooltip*, not the white
label (decision 1605).
-->
<Button name="BenillaTradeSkillSkillButtonTemplate" virtual="true" hidden="true">
<Size><AbsDimension x="293" y="16"/></Size>
<Layers>
<Layer level="ARTWORK">
<FontString name="$parentName" inherits="GameFontNormal" justifyH="LEFT">
<Size><AbsDimension x="267" y="16"/></Size>
<Anchors><Anchor point="LEFT" relativePoint="LEFT"><Offset><AbsDimension x="22" y="0"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
<!-- The +/- fold slot + its hover glow (TrainerFrame.xml's own BenillaTrainerServiceTemplate
shape): Update sets the NormalTexture to +/- for a header row and blanks it for a recipe
row (ref l.122-132); the named HighlightTexture is addressed by `getglobal(name.."Highlight")`
@@ -852,8 +863,23 @@
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors><Anchor point="LEFT" relativePoint="LEFT"><Offset><AbsDimension x="3" y="0"/></Offset></Anchor></Anchors>
</HighlightTexture>
<!-- THE ROW LABEL IS THE BUTTON'S OWN ButtonText, not a child FontString — that is what
carries the white-on-hover/white-on-selected look (decision 1605). The base declares the
font TRIO, and the per-state instance is the whole mechanism: `GameFontHighlight` is white,
so a row `SetTextColor`'d to its difficulty still turns white under the cursor, and
`LockHighlight()` on the selected row holds it white with the cursor elsewhere. It was a
`$parentName` FontString in ARTWORK until 1605, back when this file's deviation 7 recorded
`Button:SetTextColor`/`LockHighlight` as having no engine method — long since untrue.
Kept at this file's own LEFT+22 / 267×16 rather than the base's `$parentHighlight`-relative
`x=0,y=13` auto-size, so the switch moved no pixel. -->
<ButtonText name="$parentText">
<Size><AbsDimension x="267" y="16"/></Size>
<Anchors><Anchor point="LEFT" relativePoint="LEFT"><Offset><AbsDimension x="22" y="0"/></Offset></Anchor></Anchors>
</ButtonText>
<NormalFont inherits="GameFontNormal" justifyH="LEFT"/>
<HighlightFont inherits="GameFontHighlight" justifyH="LEFT"/>
<DisabledFont inherits="GameFontDisable" justifyH="LEFT"/>
<Scripts>
<OnLoad>self.name = getglobal(self:GetName() .. "Name")</OnLoad>
<OnClick>BenillaTradeSkillSkillButton_OnClick(self, arg1)</OnClick>
</Scripts>
</Button>
+52 -20
View File
@@ -276,9 +276,15 @@
if index <= numServices then
local name, subText, serviceType, isExpanded = GetTrainerServiceInfo(index)
if not name then name = UNKNOWN_LABEL end
-- Every row is unlocked here and the selected one re-locked below (ref l.183/190).
-- The lock is what paints a selected row's name white: the template's
-- `<HighlightFont inherits="GameFontHighlight">` instance wins over the state
-- colour SetTextColor writes, which reaches the NORMAL instance only. It reaches
-- nothing else on a service row — the branch below blanks that row's fold glow.
button:UnlockHighlight()
if serviceType == "header" then
button.name:SetText(name)
button.name:SetTextColor(NORMAL_FONT_COLOR.r, NORMAL_FONT_COLOR.g, NORMAL_FONT_COLOR.b)
button:SetText(name)
button:SetTextColor(NORMAL_FONT_COLOR.r, NORMAL_FONT_COLOR.g, NORMAL_FONT_COLOR.b)
button.subtext:SetText("")
if isExpanded then
button:SetNormalTexture("Interface\\Buttons\\UI-MinusButton-Up")
@@ -289,20 +295,24 @@
else
button:SetNormalTexture("")
button.foldHighlight:SetTexture("")
button.name:SetText(" " .. name)
button:SetText(" " .. name)
if subText and subText ~= "" then
button.subtext:SetText(format(PARENS_TEMPLATE, subText))
else
button.subtext:SetText("")
end
-- The rank rides the NAME's right edge, wherever the name ended (ref l.157,
-- B253's flow row) — re-anchored every repaint because the name's width is
-- the thing that changes.
button.subtext:SetPoint("LEFT", getglobal(button:GetName() .. "Text"), "RIGHT", 10, 0)
if serviceType == "available" then
button.name:SetTextColor(0, 1.0, 0)
button:SetTextColor(0, 1.0, 0)
button.subR, button.subG, button.subB = 0, 0.6, 0
elseif serviceType == "used" then
button.name:SetTextColor(GRAY_FONT_COLOR.r, GRAY_FONT_COLOR.g, GRAY_FONT_COLOR.b)
button:SetTextColor(GRAY_FONT_COLOR.r, GRAY_FONT_COLOR.g, GRAY_FONT_COLOR.b)
button.subR, button.subG, button.subB = 0.5, 0.5, 0.5
else
button.name:SetTextColor(0.9, 0, 0)
button:SetTextColor(0.9, 0, 0)
button.subR, button.subG, button.subB = 0.6, 0, 0
end
button.subtext:SetTextColor(button.subR, button.subG, button.subB)
@@ -311,7 +321,8 @@
button:Show()
else
button.serviceIndex = nil
button.name:SetText("")
button:UnlockHighlight()
button:SetText("")
button.subtext:SetText("")
button:SetNormalTexture("")
button.foldHighlight:SetTexture("")
@@ -341,12 +352,15 @@
end
hl:SetPoint("TOPLEFT", selButton, "TOPLEFT", 0, 0)
hl:Show()
-- The selected row's text goes white (HIGHLIGHT) — a deliberate divergence from the ref,
-- which whitens only the subtext + LockHighlight and leaves the name state-coloured: the
-- director wants the whole selected row legible against its colour glow (a red/gray glow
-- swallows red/gray text). Runs AFTER the per-row state colouring above, so it wins for
-- the selected row; subR/subG/subB go white too so a hover-then-leave keeps it white.
selButton.name:SetTextColor(HIGHLIGHT_FONT_COLOR.r, HIGHLIGHT_FONT_COLOR.g, HIGHLIGHT_FONT_COLOR.b)
-- The whole selected row goes white, and it is the ref's own pair of moves (l.183-184):
-- LockHighlight for the NAME (the button's label, so the white comes from its
-- HighlightFont instance, not from a repaint that the next Update would undo) and an
-- explicit whiten for the SUBTEXT, which is a child FontString the lock cannot reach.
-- An earlier revision called the white name a deliberate divergence "the ref does not
-- do"; the ref does it, through the lock this window had no engine method for at the
-- time (decision 1605). subR/subG/subB go white too so a hover-then-leave keeps the
-- subtext white.
selButton:LockHighlight()
selButton.subtext:SetTextColor(HIGHLIGHT_FONT_COLOR.r, HIGHLIGHT_FONT_COLOR.g, HIGHLIGHT_FONT_COLOR.b)
selButton.subR, selButton.subG, selButton.subB =
HIGHLIGHT_FONT_COLOR.r, HIGHLIGHT_FONT_COLOR.g, HIGHLIGHT_FONT_COLOR.b
@@ -558,14 +572,33 @@
<Size><AbsDimension x="293" y="16"/></Size>
<Layers>
<Layer level="ARTWORK">
<FontString name="$parentName" inherits="GameFontNormal" justifyH="LEFT">
<Anchors><Anchor point="LEFT" relativePoint="LEFT"><Offset><AbsDimension x="22" y="0"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="$parentSubText" inherits="GameFontNormalSmall" justifyH="LEFT">
<Anchors><Anchor point="LEFT" relativeTo="$parentName" relativePoint="RIGHT"><Offset><AbsDimension x="10" y="0"/></Offset></Anchor></Anchors>
</FontString>
<!-- NO <Anchors> — Update SetPoints this to the row's own label every repaint, the
ref's own move (`skillSubText:SetPoint("LEFT", <row>Text, "RIGHT", 10, 0)`,
Blizzard_TrainerUI.lua l.157). A `relativeTo="$parentText"` here CANNOT work and
fails quietly: `<Layers>` is loader step 5 and the `<ButtonText>` is step 5b, so
the label does not exist yet and the anchor falls back to the row (the loader
does warn — `resolve_target`'s "does not resolve" line). The ref anchors it at
runtime for its own reason anyway: the rank must follow a name whose width is
only known once the name is measured (B253). -->
<FontString name="$parentSubText" inherits="GameFontNormalSmall" justifyH="LEFT"/>
</Layer>
</Layers>
<!-- THE SERVICE NAME IS THE BUTTON'S OWN ButtonText (the ref's `ClassTrainerSkill<n>Text`),
under the base's font TRIO — that is what turns a row white under the cursor and holds
the selected row white, with no script involved: `GameFontHighlight` is white, the engine
swaps the highlighted font instance in on hover, and `LockHighlight()` pins it for the
selection. It was a `$parentName` FontString until decision 1605, on a stale note that
`Button:SetTextColor`/`LockHighlight` had no engine method. Carries NO `<Size>` — B253's
flow row: the name measures to its own text and `$parentSubText` follows its right edge,
which is the reference's own `<ButtonText>`-at-width-0 plus per-row
`SetPoint("LEFT", <row>Text, "RIGHT", 10, 0)`. TradeSkillFrame.xml and CraftFrame.xml are
the twins (both fixed-column, so both keep an explicit width). -->
<ButtonText name="$parentText" justifyH="LEFT">
<Anchors><Anchor point="LEFT" relativePoint="LEFT"><Offset><AbsDimension x="22" y="0"/></Offset></Anchor></Anchors>
</ButtonText>
<NormalFont inherits="GameFontNormal" justifyH="LEFT"/>
<HighlightFont inherits="GameFontHighlight" justifyH="LEFT"/>
<DisabledFont inherits="GameFontDisable" justifyH="LEFT"/>
<!-- The +/- fold slot + its hover glow: meaningful for header rows (Update sets +/- there),
blanked for services. -->
<NormalTexture file="Interface\Buttons\UI-MinusButton-UP">
@@ -578,7 +611,6 @@
</HighlightTexture>
<Scripts>
<OnLoad>
self.name = getglobal(self:GetName() .. "Name")
self.subtext = getglobal(self:GetName() .. "SubText")
self.foldHighlight = self:GetHighlightTexture()
</OnLoad>
+6
View File
@@ -158,6 +158,7 @@ mod ui_unit;
mod ui_world_map;
mod video;
mod vplates;
mod world_backdrop;
mod world_state;
mod world_state_ui;
@@ -224,6 +225,7 @@ use ui_trade::UiTradePlugin;
use ui_tradeskill::UiTradeSkillPlugin;
use ui_trainer::UiTrainerPlugin;
use ui_unit::UiUnitPlugin;
use world_backdrop::WorldBackdropPlugin;
// The `benilla` launcher shim (the bin package) is this library's only caller: it stamps the
// build id at compile time and hands it into [`run`]. Re-exported so the shim needs no bevy
@@ -532,6 +534,10 @@ pub fn run(build: BuildId) -> AppExit {
// The player-UI quad pass (decision 0068 §2): its own composited-above-the-world,
// below-the-egui-dev-overlays camera + sorted-quad renderer. `$WOW_UI_DEMO=1` seeds a proof scene.
.add_plugins(PlayerUiPlugin)
// The world's frame, rendered off-screen and handed to the UI pass as its first quad — the
// seam that puts the UI-over-world blend back into gamma bytes (0161/0254's last piece).
// Registered AFTER the UI pass: it writes `UiQuads`, which that plugin owns.
.add_plugins(WorldBackdropPlugin)
// The HUD minimap (decision 0203 phase 1): fills the `<Minimap>` widget's extracted hole with
// the streamed tile window + mask + player arrow, and feeds the zone text.
.add_plugins(minimap::MinimapPlugin)
+114 -23
View File
@@ -276,6 +276,17 @@ impl Default for UiQuad {
/// whole UI re-batched every frame (the 0365 live-city churn).
#[derive(Resource, Default)]
pub(crate) struct UiQuads {
/// The **world backdrop** ([`crate::world_backdrop`]): the frame the world camera rendered,
/// drawn before every other quad so the UI blends over it in the same gamma bytes it blends
/// over itself in. Not part of either lane and not sorted with them — it is not content
/// competing for a `z_key`, it is the ground. `None` whenever there is no world to paint (the
/// glue screens, the loading screen, a gated camera).
///
/// It changes no batching decision beyond being first, and it flags [`Self::dirty`] only when
/// the QUAD changes — arrival, departure, a resize. Its image's contents change every frame and
/// deliberately do not flag anything: the batch holds the handle and the material samples
/// whatever the world camera just rendered into it.
pub backdrop: Option<UiQuad>,
pub quads: Vec<UiQuad>,
/// The append lane — see the struct doc. Compared by the rebuild, never flagged.
pub overlays: Vec<UiQuad>,
@@ -566,13 +577,21 @@ fn spawn_ui_camera(mut commands: Commands) {
Camera {
order: UI_CAMERA_ORDER,
output_mode: CameraOutputMode::Write {
// PREMULTIPLIED, not `ALPHA_BLENDING`: `ui_quad.wgsl` writes premultiplied colour
// (rgb·a), so a `SrcAlpha` factor here would weight it by alpha a SECOND time —
// translucent UI over the world came out `rgb·a²`, and a pure-additive quad (a = 0)
// over the world was multiplied clean away. Opaque panels (a = 1) hid it.
blend_state: Some(
bevy::render::render_resource::BlendState::PREMULTIPLIED_ALPHA_BLENDING,
),
// **No blend at all** — this camera now carries the world too
// ([`crate::world_backdrop`]), so its target is a whole opaque frame and the blit
// is a copy. That is the point: the blend that used to happen HERE, against the
// sRGB swapchain view, was the frame's one linear composite, and it was the only
// one that mixed UI with world. Moving the world into the UI's own byte buffer
// moves that blend into `ui_quad.wgsl`'s gamma target, where every other UI blend
// already lives.
//
// It also retires the hazard 0254 patched around here: `ui_quad.wgsl` writes
// PREMULTIPLIED colour, so the original `ALPHA_BLENDING`'s `SrcAlpha` factor
// weighted it by alpha twice (`rgb·a²`), and a pure-additive quad (a = 0) over the
// world was multiplied clean away. `PREMULTIPLIED_ALPHA_BLENDING` fixed the
// arithmetic but kept the blend — and kept it in the wrong space. With nothing to
// blend against, neither factor can be wrong.
blend_state: None,
clear_color: ClearColorConfig::None,
},
// An overlay must composite ONLY its own pixels. `ClearColorConfig::None` made this
@@ -884,23 +903,27 @@ fn rebuild_ui_mesh(
let (meshes, materials) = (&mut stores.0, &mut stores.1);
let q = quads.as_mut();
// TOGGLEUI hides at the *draw*, not at the producers: both lanes keep filling, so the UI comes
// back exactly as it was (see [`crate::ui_hide::UiHidden`]). Retire the batches on the toggle's
// down edge; on the way back up, force one full rebuild — while dark we swallow each frame's
// change flag and keep the append-lane mirror current, so neither lane can hand the rebuild a
// stale "nothing changed" the moment the UI returns. The edge is the resource's own change tick
// (`UiHidden` is written only by the binding and the world-exit reset), not a `Local` mirror.
if hidden.0 {
if hidden.is_changed() {
retire_batches(&mut pools, &mut commands);
}
q.dirty = false;
q.last_overlays.clone_from(&q.overlays);
return;
}
// back exactly as it was (see [`crate::ui_hide::UiHidden`]).
//
// **It hides the two LANES, never the backdrop.** The world reaches the screen as this pass's
// own first quad now ([`crate::world_backdrop`]), so the older "retire every batch while
// hidden" would black the screen — the exact inverse of a binding whose stated point is to
// leave "the world and nothing else". While dark we still swallow each frame's change flag and
// keep the append-lane mirror current, so neither lane can hand the rebuild a stale "nothing
// changed" the moment the UI returns. The edge is the resource's own change tick (`UiHidden` is
// written only by the binding and the world-exit reset), not a `Local` mirror.
if hidden.is_changed() {
q.dirty = true;
}
if !q.dirty && q.overlays == q.last_overlays {
let lanes_hidden = hidden.0;
if lanes_hidden {
q.last_overlays.clone_from(&q.overlays);
// Nothing either lane produces can move a pixel while dark, so only the toggle edge and
// the backdrop's own arrival/departure (which sets `dirty`) reach the rebuild below.
if !q.dirty {
return;
}
} else if !q.dirty && q.overlays == q.last_overlays {
return;
}
// `WOW_UI_DIFF=1` — WHO re-triggers the rebuild? Names the first differing overlay quad (or
@@ -947,8 +970,11 @@ fn rebuild_ui_mesh(
retire_batches(&mut pools, &mut commands);
return;
};
if q.quads.is_empty() && q.overlays.is_empty() {
let lanes_empty = lanes_hidden || (q.quads.is_empty() && q.overlays.is_empty());
if q.backdrop.is_none() && lanes_empty {
retire_batches(&mut pools, &mut commands);
q.dirty = false;
q.last_overlays.clone_from(&q.overlays);
return;
}
@@ -961,8 +987,19 @@ fn rebuild_ui_mesh(
// Stable sort by z_key: the WoW-style total order. Stable so equal-z_key quads keep the producer's
// original relative order (their own decl-order tiebreak, if any). Base lane first, append lane
// after — the same relative order the one-Vec era produced.
let mut sorted: Vec<&UiQuad> = q.quads.iter().chain(q.overlays.iter()).collect();
let mut sorted: Vec<&UiQuad> = if lanes_hidden {
Vec::new()
} else {
q.quads.iter().chain(q.overlays.iter()).collect()
};
sorted.sort_by_key(|q| q.z_key);
// The backdrop is PREPENDED, not sorted in. Giving it a `z_key` would mean picking a number
// below every other producer's and trusting all of them to stay above it — and the append
// lane's lowest band is already 0 (`overlay_z::WORLD_TEXT`), so there is no room under it
// without renumbering a total order that encodes fidelity facts. Position, not arithmetic.
if let Some(backdrop) = q.backdrop.as_ref() {
sorted.insert(0, backdrop);
}
// Geometry probe (`WOW_UI_PROBE=1`): dump each textured quad's screen rect once — the
// capture-harness companion for diagnosing extracted-vs-rendered geometry by data instead of
@@ -1426,6 +1463,60 @@ mod tests {
);
}
/// **TOGGLEUI hides the UI, not the world.** Since the world arrives as this pass's own
/// backdrop quad ([`crate::world_backdrop`]), the dark path can no longer mean "retire every
/// batch" — that would black the screen, which is the exact inverse of what the binding is
/// for. One batch survives while dark (the backdrop's), and the lanes come back on top of it.
///
/// This is the regression the change itself created: every earlier version of the hidden path
/// asserted zero batches, and zero batches is now a black screen.
#[test]
fn toggleui_keeps_the_world_backdrop() {
let mut app = rebuild_app();
let backdrop = UiQuad {
rect: Rect::from_corners(Vec2::ZERO, Vec2::new(800.0, 600.0)),
texture: None,
..UiQuad::default()
};
{
let mut q = app.world_mut().resource_mut::<UiQuads>();
q.backdrop = Some(backdrop);
q.dirty = true;
}
app.update();
let lit = batches(&mut app);
assert!(lit >= 1, "content drawn to begin with");
set_hidden(&mut app, true);
app.update();
assert_eq!(
batches(&mut app),
1,
"dark ⇒ the backdrop alone; anything less is a black screen"
);
set_hidden(&mut app, false);
app.update();
assert_eq!(
batches(&mut app),
lit,
"the UI comes back over the same world"
);
}
/// With no world to paint (the glue screens, the loading screen, a gated camera) the dark path
/// is the old one: nothing at all. The backdrop earns an exemption because it IS the world,
/// not because it is first in the list.
#[test]
fn toggleui_with_no_backdrop_still_retires_everything() {
let mut app = rebuild_app();
app.update();
assert_eq!(batches(&mut app), 1, "one batch drawn to begin with");
set_hidden(&mut app, true);
app.update();
assert_eq!(batches(&mut app), 0, "no world, no backdrop, nothing drawn");
}
/// **The desaturation flag reaches the MATERIAL, and splits the run** (decision 1327).
///
/// Everything upstream of here can be right — the Lua sets it, extract carries it, the quad
@@ -230,12 +230,12 @@ fn shipped_trainer_frame_drives_end_to_end() {
// Row 1 renders the "Arms" header (its name, no indent); row 2 the first service.
assert_eq!(
s.eval::<String>("return BenillaTrainerService1Name:GetText()")
s.eval::<String>("return BenillaTrainerService1Text:GetText()")
.unwrap(),
"Arms"
);
assert_eq!(
s.eval::<String>("return BenillaTrainerService2Name:GetText()")
s.eval::<String>("return BenillaTrainerService2Text:GetText()")
.unwrap(),
" Heroic Strike"
);
@@ -318,7 +318,7 @@ fn clicking_a_header_row_collapses_its_group() {
// Full tree: 2 headers + 4 services = 6 rows; row 2 is Heroic Strike.
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 6);
assert_eq!(
s.eval::<String>("return BenillaTrainerService2Name:GetText()")
s.eval::<String>("return BenillaTrainerService2Text:GetText()")
.unwrap(),
" Heroic Strike"
);
@@ -329,7 +329,7 @@ fn clicking_a_header_row_collapses_its_group() {
.unwrap();
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 4);
assert_eq!(
s.eval::<String>("return BenillaTrainerService2Name:GetText()")
s.eval::<String>("return BenillaTrainerService2Text:GetText()")
.unwrap(),
"Fury",
"Arms folded; its header (row 1) now abuts the Fury header (row 2)"
@@ -475,11 +475,11 @@ fn wheel_over_a_row_scrolls_the_list() {
s.fire_event("TRAINER_SHOW", vec![ScriptValue::Str("Sana".into())]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
let row1 = "return BenillaTrainerService1Name:GetText()";
let row1 = "return BenillaTrainerService1Text:GetText()";
// At the top: row 1 is the "Arms" header, row 2 the first service.
assert_eq!(s.eval::<String>(row1).unwrap(), "Arms");
assert_eq!(
s.eval::<String>("return BenillaTrainerService2Name:GetText()")
s.eval::<String>("return BenillaTrainerService2Text:GetText()")
.unwrap(),
" Service 01"
);
@@ -506,17 +506,35 @@ fn wheel_over_a_row_scrolls_the_list() {
assert_eq!(s.eval::<String>(row1).unwrap(), " Service 01");
}
/// The selected service row's text renders white (HIGHLIGHT), legible against its colour glow — a
/// deliberate divergence from the ref (which whitens only the subtext, keeping the name state-coloured).
/// Selecting the unavailable Cleave (red when unselected) whitens its name; selecting away restores it.
/// The selected service row's name renders white (HIGHLIGHT), legible against its colour glow — and
/// so does a HOVERED one. Both are the reference's own behaviour, through one mechanism: the row's
/// name is the button's `<ButtonText>` under a `<HighlightFont inherits="GameFontHighlight">`, the
/// engine swaps that instance in while the cursor is on the row, and `LockHighlight()` pins it for
/// the selection (Blizzard_TrainerUI.lua l.183). `SetTextColor` writes the NORMAL instance only, so
/// the state colour cannot follow the label into either state — which is exactly why it goes white.
///
/// An earlier revision of this test called the white name "a deliberate divergence from the ref
/// (which whitens only the subtext)". The ref whitens the subtext *by hand* precisely BECAUSE the
/// subtext is a child FontString the lock cannot reach; the name it leaves to the lock. Ours could
/// not, because the name was a child FontString too and the engine's highlighted label fell back to
/// the normal state's colour — so the white was hand-painted here and absent on hover entirely
/// (decision 1605).
#[test]
fn selected_service_row_text_is_white() {
fn a_selected_or_hovered_service_row_paints_its_name_white() {
let mut s = trainer_script();
s.set_money(50);
s.set_trainer(Some(menu()));
s.fire_event("TRAINER_SHOW", vec![ScriptValue::Str("Sana".into())]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// The name really is the button's own label now — a child FontString would leave
// GetFontString() nil and no per-state font could reach it.
assert!(
s.eval::<bool>("return BenillaTrainerService1:GetFontString() ~= nil")
.unwrap(),
"the row name is the Button's ButtonText, the only region per-state fonts reach"
);
// Cleave (index 3) is unavailable → red when not selected. Select it → its name goes white.
s.run("SelectTrainerService(3); BenillaTrainerFrame_Update()")
.unwrap();
@@ -534,6 +552,31 @@ fn selected_service_row_text_is_white() {
text_has_color(&s.extract(), "Cleave", [0.9, 0.0, 0.0]),
"an unselected unavailable row is red again"
);
// Now hover it, selecting nothing: the HighlightFont instance takes over and the row lights up.
let (x, y) = text_center(&s.extract(), "Cleave");
s.mouse_move(x, y);
s.resolve();
assert!(
text_has_color(&s.extract(), "Cleave", [1.0, 1.0, 1.0]),
"a hovered row's name renders white, with no script doing it"
);
assert!(
text_has_color(&s.extract(), "Heroic Strike", [1.0, 1.0, 1.0]),
"and the SELECTED row stays white while another is hovered"
);
// Cursor off the list: the hovered row falls back to its state colour, the selected one holds.
s.mouse_move(1000.0, 20.0);
s.resolve();
assert!(
text_has_color(&s.extract(), "Cleave", [0.9, 0.0, 0.0]),
"cursor away: red again"
);
assert!(
text_has_color(&s.extract(), "Heroic Strike", [1.0, 1.0, 1.0]),
"the selection's white is the LOCK, not the hover"
);
}
/// Scrolling is silent, faithfully: the mouse WHEEL plays no sound (the ref's
@@ -767,7 +810,8 @@ fn recipe_menu() -> TrainerState {
/// (`ClassTrainerFrameTemplates.xml`'s `<ButtonText>` at width 0 + the per-row
/// `SetPoint("LEFT", <row>Text, "RIGHT", 10, 0)`), read off the player's chain.
///
/// The mutation check is the first assertion: put a `<Size>` back on `$parentName` and the request
/// The mutation check is the first assertion: put a `<Size>` back on the row's `<ButtonText>` and
/// the request
/// carries a wrap width, the long name measures two lines, and its height passes the row height.
#[test]
fn a_long_row_name_stays_on_one_line_and_carries_its_rank_along() {
@@ -818,7 +862,7 @@ fn a_long_row_name_stays_on_one_line_and_carries_its_rank_along() {
let row_of = |s: &mut UiScript, needle: &str| -> i64 {
s.eval::<i64>(&format!(
"for i = 1, 11 do local b = getglobal('BenillaTrainerService' .. i) \
local t = b.name:GetText() if t and strfind(t, '{needle}', 1, 1) then return i end \
local t = b:GetText() if t and strfind(t, '{needle}', 1, 1) then return i end \
end return 0"
))
.unwrap()
@@ -830,7 +874,8 @@ fn a_long_row_name_stays_on_one_line_and_carries_its_rank_along() {
let geom = |s: &mut UiScript, row: i64| -> (f32, f32, f32, f32) {
s.eval::<(f32, f32, f32, f32)>(&format!(
"local b = getglobal('BenillaTrainerService{row}') \
return b.name:GetHeight(), b.name:GetRight(), b.subtext:GetLeft(), b:GetHeight()"
local n = b:GetFontString() \
return n:GetHeight(), n:GetRight(), b.subtext:GetLeft(), b:GetHeight()"
))
.unwrap()
};
+244
View File
@@ -0,0 +1,244 @@
//! The world as the UI's **backdrop quad** — the seam that put the UI-over-world blend back into
//! gamma bytes (the third and last piece of the composite lane 0161 and 0254 built).
//!
//! ## The seam that was left
//!
//! Both halves of the frame already composite the way the reference's fixed-function device does,
//! and each does it by the same trick. The world (0161): every world shader emits raw gamma bytes,
//! the blur runs on them, and the FFXGlow combine takes the frame's one `srgb_to_linear` so the
//! sRGB present-encode restores the byte. The UI (0254): `ui_quad.wgsl` emits gamma values into an
//! `Rgba8UnormSrgb` target, so the store encodes, the blend's destination read decodes, and every
//! hardware blend is therefore arithmetic on the gamma value — `alphaMode="ADD"` really is
//! `dst + texel·α`, clamped, exactly as EGxBlend 3 does it.
//!
//! Two correct byte lanes, and **the UI-over-world blend fell in the seam between them.** The UI
//! camera composited its finished image onto the swapchain through its output blit, and the
//! swapchain view is sRGB, so that one blend — the only one that mixes UI with world — ran in
//! linear. 0254 named it as a residual and described it as "a small deviation on antialiased UI
//! edges over the world". It is not an edge artefact: it is every translucent UI pixel over the
//! 3D world, at full area.
//!
//! Measured on the chat dock, which is the most translucent surface the client has.
//! `ChatFrameTab`'s body is black at α = 102/255, so a docked tab must leave the scene at **60 %**
//! of its brightness; the unselected tab (frame α 0.5) at 80 %. Against a bare-scene capture of
//! the same pixels (`ui-chat-tabhover` at `$WOW_TABHOVER=9`) they measured **77.5 %** and **89 %** —
//! and a linear composite predicts exactly that, to within a byte, at every point checked
//! (scene 93 → 72 vs 72.5 predicted; 70 → 54 vs 53.9; 95 → 74 vs 74.1). The tab had almost no
//! plate, so the hover glow — an ADD, at full strength — sat on nothing and read as a blue lozenge
//! floating on grass, louder than the tab that was actually selected. That is what the director
//! reported, and it was never a chat bug.
//!
//! ## The fix: put the world *inside* the UI's byte buffer
//!
//! Not a third lane. The world camera renders to an off-screen image instead of the swapchain, and
//! that image is drawn as the **first quad of the UI pass** — the ground everything else is painted
//! on. Every UI blend over the world is then the same blend as every UI blend over UI: the one
//! 0254 already verified, in the target it already verified it in. The output blit stops blending
//! entirely (it now carries an opaque frame), which retires the `rgb·a²` hazard 0254 had to patch
//! `PREMULTIPLIED_ALPHA_BLENDING` around.
//!
//! **The image is un-encoded float, and both halves of that are borrowed rather than invented** —
//! it is the portrait booths' own target format, chosen there for the same two reasons
//! ([`crate::portrait`]'s `new_target_image`). Un-encoded, because the UI arc composites in gamma
//! and takes its one decode at the end: a backdrop that pre-encoded would land a second encode in
//! that chain. `ui_quad.wgsl`'s ordinary arm re-encodes what it samples (`linear_to_srgb`), which
//! turns FFXGlow's linear output back into the client's byte — the same round trip a booth image
//! takes, and exact in f32. Float rather than `Rgba8Unorm`, because quantizing *un-encoded* values
//! to 8 bits is B126's banding collapse (decision 0804): below display byte 100 an un-encoded 8-bit
//! grid reaches ~25 levels where the gamma backbuffer has 100.
//!
//! Nothing about the world lane changes: FFXGlow keeps its decode, the frame still holds exactly
//! one, and an opaque world pixel must come out byte-identical to before. That identity is the
//! regression test, the same one 0161 used.
use bevy::asset::RenderAssetUsages;
use bevy::camera::RenderTarget;
use bevy::prelude::*;
use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat, TextureUsages};
use bevy::window::PrimaryWindow;
use crate::ui_pass::{UiQuad, UiQuads};
use benilla_world::view::WorldCamera;
/// The off-screen image the world camera renders into, and which the UI pass draws first.
///
/// Sized in **physical** pixels to match the swapchain 1:1 — the backdrop quad covers the window
/// exactly, so the sample is an identity resample and opaque world pixels survive untouched.
#[derive(Resource)]
pub(crate) struct WorldBackdrop {
pub(crate) image: Handle<Image>,
/// The size the image was last built at, in physical px — the resize gate.
size: UVec2,
}
/// A fresh backdrop image at `size` physical px. See the module doc for the format's two halves.
fn new_backdrop_image(size: UVec2) -> Image {
let mut image = Image::new_fill(
Extent3d {
width: size.x.max(1),
height: size.y.max(1),
depth_or_array_layers: 1,
},
TextureDimension::D2,
&[0; 8],
TextureFormat::Rgba16Float,
RenderAssetUsages::default(),
);
image.texture_descriptor.usage =
TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST | TextureUsages::RENDER_ATTACHMENT;
image
}
fn window_physical_size(window: &Window) -> UVec2 {
UVec2::new(
window.physical_width().max(1),
window.physical_height().max(1),
)
}
fn setup_backdrop(
mut commands: Commands,
mut images: ResMut<Assets<Image>>,
windows: Query<&Window, With<PrimaryWindow>>,
) {
let size = windows
.single()
.map(window_physical_size)
.unwrap_or(UVec2::new(1280, 720));
let image = images.add(new_backdrop_image(size));
commands.insert_resource(WorldBackdrop { image, size });
}
/// Keep the backdrop the window's size. A stale-sized backdrop would still *work* (the quad covers
/// the window either way) but the sample would stop being 1:1 and opaque world pixels would start
/// moving under a resample — the one property this whole lane is built to preserve.
fn track_window_size(
mut backdrop: ResMut<WorldBackdrop>,
mut images: ResMut<Assets<Image>>,
windows: Query<&Window, With<PrimaryWindow>>,
) {
let Ok(window) = windows.single() else {
return;
};
let size = window_physical_size(window);
if size == backdrop.size {
return;
}
let handle = backdrop.image.clone();
if let Some(image) = images.get_mut(&handle) {
*image = new_backdrop_image(size);
backdrop.size = size;
}
}
/// Point the world camera at the backdrop instead of the swapchain.
///
/// A system rather than a component on the spawn, because there are two spawn sites for the same
/// camera (the real one and `player::setup`'s no-client-data fallback) and neither should have to
/// know about the composite lane. `benilla-worldview` is unaffected — it links `benilla-world`, not
/// this crate, and has no UI camera to composite with, so its world camera keeps the swapchain.
///
/// `Added` rather than a per-frame compare: `RenderTarget` carries no `PartialEq`, and nothing in
/// the tree rewrites a world camera's target once set (the only other `Camera` writer is the gate
/// that flips `is_active`). Retarget it when it appears, and leave it alone after.
fn retarget_world_camera(
backdrop: Res<WorldBackdrop>,
mut cameras: Query<&mut RenderTarget, Added<WorldCamera>>,
) {
for mut current in &mut cameras {
*current = RenderTarget::Image(backdrop.image.clone().into());
}
}
/// Publish the backdrop quad for this frame — full-window, opaque, ahead of every other quad.
///
/// Emitted only while the world camera is actually drawing. With no world (the glue screens, the
/// loading screen, a gated camera) the image holds a stale or never-written frame, and painting it
/// would be worse than the transparent clear the UI pass falls back to.
fn emit_backdrop_quad(
backdrop: Res<WorldBackdrop>,
mut quads: ResMut<UiQuads>,
cameras: Query<&Camera, With<WorldCamera>>,
windows: Query<&Window, With<PrimaryWindow>>,
) {
let drawing = cameras.iter().any(|c| c.is_active);
let next = windows.single().ok().filter(|_| drawing).map(|window| {
// Logical px, y-down from the top-left — `rebuild_ui_mesh`'s own rect space.
UiQuad {
rect: Rect::from_corners(Vec2::ZERO, Vec2::new(window.width(), window.height())),
texture: Some(backdrop.image.clone()),
..UiQuad::default()
}
});
// **Flag the rebuild only when the quad itself changes** — its arrival, its departure, a
// resize. Its CONTENTS change every frame and must not: the mesh batch holds the image handle
// and the material samples whatever the world camera just rendered into it, so a per-frame
// `dirty` here would drag every batch in the pass through a full rebuild for a picture that
// did not move — the 0365 live-city churn, re-introduced from the one producer that runs every
// single frame.
if quads.backdrop != next {
quads.backdrop = next;
quads.dirty = true;
}
}
/// Owns the backdrop image, the world camera's target, and the quad. See the module doc.
pub(crate) struct WorldBackdropPlugin;
impl Plugin for WorldBackdropPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Startup, setup_backdrop).add_systems(
Update,
(track_window_size, retarget_world_camera, emit_backdrop_quad)
.chain()
.before(crate::ui_pass::UiQuadAppend),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use bevy::image::TextureFormatPixelInfo as _;
/// The backdrop is float and un-encoded — the two halves the module doc argues for, and the
/// pair a future "make it 8-bit, it's only a backdrop" edit would quietly break (an sRGB label
/// double-encodes through `ui_quad`; an 8-bit un-encoded grid is B126's banding).
#[test]
fn the_backdrop_is_unencoded_float() {
let image = new_backdrop_image(UVec2::new(320, 200));
assert_eq!(image.texture_descriptor.format, TextureFormat::Rgba16Float);
assert!(
!image.texture_descriptor.format.is_srgb(),
"an sRGB label would land a second encode in a chain that decodes once, at the end"
);
assert!(
!TextureFormat::Rgba16Float.is_srgb(),
"the swapchain's own view is sRGB — the backdrop deliberately is not"
);
}
/// It must be usable as a camera target AND samplable by the UI pass. Dropping either usage
/// bit fails at device level, far from here.
#[test]
fn the_backdrop_is_both_a_target_and_a_texture() {
let image = new_backdrop_image(UVec2::new(320, 200));
let usage = image.texture_descriptor.usage;
assert!(usage.contains(TextureUsages::RENDER_ATTACHMENT));
assert!(usage.contains(TextureUsages::TEXTURE_BINDING));
}
/// Physical, not logical: the backdrop matches the swapchain 1:1 so the UI pass's sample is an
/// identity resample. A zero-size window (minimised on some platforms) must still produce a
/// legal texture rather than a device error.
#[test]
fn a_degenerate_size_still_builds_a_legal_texture() {
let image = new_backdrop_image(UVec2::ZERO);
assert_eq!(image.texture_descriptor.size.width, 1);
assert_eq!(image.texture_descriptor.size.height, 1);
assert_eq!(
image.data.as_ref().map(Vec::len),
TextureFormat::Rgba16Float.pixel_size().ok()
);
}
}
+230 -1
View File
@@ -11,7 +11,8 @@
//! text, a REAL menu-row click driving the exclusive filter, the "All" row restoring it).
use benilla_ui::script::{
TradeSkillDifficulty, TradeSkillReagent, TradeSkillRecipe, TradeSkillState, UiScript,
CraftRecipe, CraftState, CraftTooltip, TradeSkillDifficulty, TradeSkillReagent,
TradeSkillRecipe, TradeSkillState, UiScript,
};
const UI_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/ui");
@@ -467,3 +468,231 @@ fn a_row_click_shows_the_selection_glow_and_a_row_hover_shows_nothing() {
);
}
}
/// **The recipe list lights its rows white — under the cursor, and on the selected one.** The
/// director put the real client's Blacksmithing window next to ours: there, the hovered row and the
/// selected row are both white, while every other recipe wears its difficulty colour. Ours wore the
/// difficulty colour everywhere, hover and selection included.
///
/// One mechanism does both, and it is the row BUTTON's, not a script's.
/// `ClassTrainerSkillButtonTemplate` — the base under this window's rows, Craft's and the class
/// trainer's — declares `<HighlightFont inherits="GameFontHighlight">` (white) beside its
/// `<NormalFont>`, and `TradeSkillFrame_Update` paints each row with
/// `skillButton:SetTextColor(difficulty)` and then calls `LockHighlight()` on the selected one
/// (Blizzard_TradeSkillUI.lua l.113/144). A `SetTextColor` writes the NORMAL font instance only, so
/// it cannot reach a highlighted label — which is exactly why the reference's rows still turn white.
///
/// Ours could not, for two reasons that had to be fixed together (decision 1605): the row's label
/// was a child `$parentName` FontString rather than the button's own `<ButtonText>`, so no per-state
/// font could reach it; and the engine's highlighted label fell back to the normal state's colour,
/// so even a ButtonText would have stayed orange. This test is the end-to-end pin — it reads the
/// colour off the extracted text quad, not off any Lua state, so it fails if either half regresses.
#[test]
fn a_hovered_or_selected_recipe_row_paints_its_label_white() {
let mut s = UiScript::new().unwrap();
load_ui(&s);
s.set_screen_size(1024.0, 768.0);
s.set_trade_skill(Some(state()));
s.fire_event("TRADE_SKILL_SHOW", vec![]);
// Row 1 is the "Mail" header; rows 2 and 3 are its two Medium recipes, and opening the window
// auto-selects the first of them.
assert_eq!(
s.eval::<i64>("return GetTradeSkillSelectionIndex()")
.unwrap(),
2,
"the show-time auto-selection landed on the first recipe"
);
// The label really is the button's own ButtonText now — a child FontString would leave
// GetFontString() nil and take every per-state font with it.
assert!(
s.eval::<bool>("return BenillaTradeSkillSkill2:GetFontString() ~= nil")
.unwrap(),
"the row label is the Button's ButtonText, the only region per-state fonts reach"
);
let row_color = |s: &mut UiScript, n: i64| -> [f32; 4] {
let text = s
.eval::<String>(&format!("return BenillaTradeSkillSkill{n}:GetText()"))
.unwrap();
s.resolve();
s.extract()
.into_iter()
.find_map(|q| match q.content {
benilla_ui::script::QuadContent::Text {
text: Some(t),
color,
..
} if t == text => color,
_ => None,
})
.unwrap_or_else(|| panic!("no text quad for row {n} (\"{text}\")"))
};
let park_cursor_off_the_list = |s: &mut UiScript| {
s.resolve();
s.mouse_move(1000.0, 20.0);
};
let hover_row = |s: &mut UiScript, n: i64| {
s.resolve();
let (x, y) = (
s.eval::<f64>(&format!(
"return (BenillaTradeSkillSkill{n}:GetLeft() + BenillaTradeSkillSkill{n}:GetRight()) / 2"
))
.unwrap(),
s.eval::<f64>(&format!(
"return (BenillaTradeSkillSkill{n}:GetTop() + BenillaTradeSkillSkill{n}:GetBottom()) / 2"
))
.unwrap(),
);
s.mouse_move(x as f32, y as f32);
};
const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
const MEDIUM: [f32; 4] = [1.0, 1.0, 0.0, 1.0]; // TradeSkillTypeColor["medium"], the book's own
park_cursor_off_the_list(&mut s);
assert_eq!(
row_color(&mut s, 2),
WHITE,
"the SELECTED row is white with the cursor nowhere near it — LockHighlight, not a repaint"
);
assert_eq!(
row_color(&mut s, 3),
MEDIUM,
"an unselected, unhovered recipe wears its difficulty colour"
);
hover_row(&mut s, 3);
assert_eq!(
row_color(&mut s, 3),
WHITE,
"hovered: the HighlightFont instance is in force over SetTextColor's difficulty paint"
);
assert_eq!(
row_color(&mut s, 2),
WHITE,
"and the selected row stays lit while another row is hovered"
);
// Click row 3: the white follows the selection, and row 2 falls back to its difficulty colour.
s.run("BenillaTradeSkillSkill3:Click()").unwrap();
pump(&mut s);
park_cursor_off_the_list(&mut s);
assert_eq!(
s.eval::<i64>("return GetTradeSkillSelectionIndex()")
.unwrap(),
3
);
assert_eq!(row_color(&mut s, 3), WHITE, "the new selection is white");
assert_eq!(
row_color(&mut s, 2),
MEDIUM,
"the old selection is UNLOCKED back to its difficulty colour, not left lit"
);
}
/// **The craft list lights the same way, off the same base template.** `CraftButtonTemplate` and
/// `TradeSkillSkillButtonTemplate` both inherit `ClassTrainerSkillButtonTemplate`, so its
/// `<HighlightFont inherits="GameFontHighlight">` is one mechanism serving both windows — and
/// `Craft_Update` locks its selected row exactly as the tradeskill one does (Blizzard_CraftUI.lua
/// l.234). Ours had the same `$parentName`-FontString workaround in both files, so fixing only the
/// window the director was looking at would have left the enchanting book wrong beside it
/// (decision 1605).
#[test]
fn a_hovered_or_selected_craft_row_paints_its_label_white() {
let mut s = UiScript::new().unwrap();
load_ui(&s);
s.set_screen_size(1024.0, 768.0);
let recipe = |spell_id: u32, name: &str| CraftRecipe {
spell_id,
name: name.into(),
sub_name: String::new(),
difficulty: TradeSkillDifficulty::Medium,
num_available: 1,
icon: Some("Interface\\Icons\\Spell_Holy_Heal".into()),
description: None,
needs_item_target: false,
reagents: vec![],
tools: vec![],
tooltip: CraftTooltip::Spell(spell_id),
spell_level: 0,
};
s.set_craft(Some(CraftState {
name: "Enchanting".into(),
rank: 100,
max_rank: 150,
craft_type: 3,
recipes: vec![
recipe(7420, "Enchant Bracer - Minor Health"),
recipe(7426, "Enchant Chest - Minor Absorption"),
],
}));
s.fire_event("CRAFT_SHOW", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
s.eval::<bool>("return BenillaCraft1:GetFontString() ~= nil")
.unwrap(),
"the craft row name is the Button's ButtonText"
);
let row_color = |s: &mut UiScript, n: i64| -> [f32; 4] {
let text = s
.eval::<String>(&format!("return BenillaCraft{n}:GetText()"))
.unwrap();
s.resolve();
s.extract()
.into_iter()
.find_map(|q| match q.content {
benilla_ui::script::QuadContent::Text {
text: Some(t),
color,
..
} if t == text => color,
_ => None,
})
.unwrap_or_else(|| panic!("no text quad for craft row {n} (\"{text}\")"))
};
const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
const MEDIUM: [f32; 4] = [1.0, 1.0, 0.0, 1.0]; // CraftTypeColor["medium"]
// Row 1 is the show-time selection; row 2 is not.
s.run("SelectCraft(1); BenillaCraftFrame_Update()").unwrap();
s.resolve();
s.mouse_move(1000.0, 20.0);
assert_eq!(
row_color(&mut s, 1),
WHITE,
"the selected craft row is white"
);
assert_eq!(
row_color(&mut s, 2),
MEDIUM,
"an unselected one wears its difficulty colour"
);
// Hover row 2.
s.resolve();
let (x, y) = (
s.eval::<f64>("return (BenillaCraft2:GetLeft() + BenillaCraft2:GetRight()) / 2")
.unwrap(),
s.eval::<f64>("return (BenillaCraft2:GetTop() + BenillaCraft2:GetBottom()) / 2")
.unwrap(),
);
s.mouse_move(x as f32, y as f32);
assert_eq!(row_color(&mut s, 2), WHITE, "hovered: white");
// And the selection follows a click, releasing the old row's lock.
s.run("SelectCraft(2); BenillaCraftFrame_Update()").unwrap();
s.resolve();
s.mouse_move(1000.0, 20.0);
assert_eq!(row_color(&mut s, 2), WHITE);
assert_eq!(
row_color(&mut s, 1),
MEDIUM,
"the old selection is UNLOCKED, not left lit"
);
}
+43 -17
View File
@@ -210,9 +210,9 @@ impl UiScript {
}
// 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
// (disabled > hovered > normal; hover falls back to normal) — the client's
// per-state label font swap (UIPanelButtonTemplate's gold/white/gray trio).
// The ButtonText additionally re-points to the current STATE's font instance
// (disabled > highlighted > normal) — the client's per-state label font swap
// (UIPanelButtonTemplate's gold/white/gray trio).
let mut state_font: Option<&FontObject> = None;
let mut state_color: Option<[f32; 4]> = None;
// `Button:SetFont` — the face/size/flags written on the button's own embedded
@@ -230,24 +230,50 @@ impl UiScript {
continue;
}
if bs.text == Some(rh) {
let name = if !bs.enabled {
bs.disabled_font.as_ref()
} else if hovered {
bs.highlight_font.as_ref().or(bs.normal_font.as_ref())
// The label inherits ONE of the button's three embedded font instances
// WHOLE (normal `+0x33c` / highlight `+0x3b8` / disabled `+0x434`): the
// client swaps which instance the label reads, it does not merge axes
// across them. So the object and the colour are picked as a PAIR, and a
// state with no font object of its own is not in force at all — the
// label stays on the normal instance, face and colour together.
//
// The pairing is the load-bearing half, and it is what this used to get
// wrong (the colour fell back to `normal_color` while the font did not).
// `UIDropDownMenu.lua` sets every row colour through `SetTextColor`
// **and** `SetHighlightTextColor` with the same values (l.216-220,
// l.829-830) — a second call it would never need if the first reached
// the hovered state. The tradeskill list leans on the other direction:
// its rows are `SetTextColor`'d to the recipe's difficulty and still
// turn white under the cursor, because `<HighlightFont
// inherits="GameFontHighlight">` is the instance in force there
// (`ClassTrainerFrameTemplates.xml` l.74) and a normal-instance colour
// cannot reach it.
//
// `LockHighlight()` counts as highlighted HERE too, not only for the
// HighlightTexture in `region_visible`. `TradeSkillFrame_Update` blanks
// a recipe row's highlight texture to `""` and *then* locks the row it
// selected (Blizzard_TradeSkillUI.lua l.131/144) — with no texture left
// to pin, the lock's only possible effect on that row is this label
// swap, which is the white text on the selected recipe. Craft
// (l.234) and the class trainer (l.183) lock the same way.
//
// INFERRED, not byte-verified: that an unset state instance leaves the
// label on the normal one. A null slot in the *texture* array draws
// nothing (decision 0227) and a font instance with no object cannot
// work that way — a disabled button with no `<DisabledFont>` still
// shows its label. Every state-colour caller in our own UI ships the
// matching font object, so the two readings agree on all of them.
let highlighted = hovered || bs.locked_highlight;
let (name, color) = if !bs.enabled && bs.disabled_font.is_some() {
(bs.disabled_font.as_ref(), bs.disabled_color)
} else if bs.enabled && highlighted && bs.highlight_font.is_some() {
(bs.highlight_font.as_ref(), bs.highlight_color)
} else {
bs.normal_font.as_ref()
(bs.normal_font.as_ref(), bs.normal_color)
};
state_font = name.and_then(|n| model.font_object(n));
button_font = bs.font.as_ref();
// The per-state color override (Button:Set*TextColor) — hover falls
// back to the normal color, mirroring the font fallback above.
state_color = if !bs.enabled {
bs.disabled_color
} else if hovered {
bs.highlight_color.or(bs.normal_color)
} else {
bs.normal_color
};
state_color = color;
}
}
// A TITLE REGION NEVER DRAWS. It is a hit rectangle, not a visual: wow-re
@@ -485,6 +485,117 @@ fn button_label_repaints_by_state_font_object() {
assert!((c[0] - 0.1).abs() < 1e-6 && (c[1] - 0.2).abs() < 1e-6);
}
/// **The highlighted label is its own font instance — and `LockHighlight` enters it.** The two
/// halves of the recipe-list look the director asked for: a row turns white under the cursor, and
/// the SELECTED row stays white with the cursor elsewhere.
///
/// Both are one mechanism in the reference. `ClassTrainerSkillButtonTemplate` (the base under
/// TradeSkill's, Craft's and the class trainer's rows) declares
/// `<HighlightFont inherits="GameFontHighlight">`, and each window paints its rows by difficulty
/// with `skillButton:SetTextColor(...)` — orange, yellow, green, gray. If a `SetTextColor` reached
/// the highlighted state, hovering a row would leave it orange; it does not, because the highlight
/// instance is a different font instance and the normal one's colour cannot reach it. That is the
/// first assertion below.
///
/// The second is `LockHighlight`. `TradeSkillFrame_Update` blanks a recipe row's highlight texture
/// (`getglobal("TradeSkillSkill"..i.."Highlight"):SetTexture("")`, Blizzard_TradeSkillUI.lua l.131)
/// and *then* locks the row it selected (l.144) — with no texture left to pin, the lock can only be
/// reaching the label. Craft (Blizzard_CraftUI.lua l.234) and the trainer
/// (Blizzard_TrainerUI.lua l.183) do the same.
#[test]
fn a_locked_or_hovered_button_wears_its_highlight_font_over_its_normal_color() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.register_font_object(
"RowNormal",
FontObject {
color: Some([1.0, 0.82, 0.0, 1.0]),
height: Some(12.0),
..Default::default()
},
);
s.register_font_object(
"RowHighlight",
FontObject {
color: Some([1.0, 1.0, 1.0, 1.0]),
height: Some(12.0),
..Default::default()
},
);
s.run(
r#"
b = CreateFrame("Button", "RowBtn")
b:SetPoint("BOTTOMLEFT", 100, 100); b:SetSize(100, 20)
b:SetText("Rough Copper Vest")
b:SetTextFontObject("RowNormal")
b:SetHighlightFontObject("RowHighlight")
-- The difficulty paint every one of the three list windows applies to every row.
b:SetTextColor(1.0, 0.5, 0.25)
"#,
)
.unwrap();
let label_color = |s: &mut crate::script::UiScript| {
s.resolve();
s.extract()
.into_iter()
.find_map(|q| match q.content {
QuadContent::Text {
text: Some(t),
color,
..
} if t == "Rough Copper Vest" => Some(color),
_ => None,
})
.expect("label text quad")
};
s.resolve();
s.mouse_move(400.0, 300.0); // nowhere near the row
assert_eq!(
label_color(&mut s),
Some([1.0, 0.5, 0.25, 1.0]),
"at rest the row wears the difficulty colour SetTextColor gave it"
);
s.resolve();
s.mouse_move(150.0, 110.0); // over the row
assert_eq!(
label_color(&mut s),
Some([1.0, 1.0, 1.0, 1.0]),
"hovered: the HIGHLIGHT instance is in force, and the normal colour cannot reach it"
);
// Cursor away again, but the row is the selected one.
s.resolve();
s.mouse_move(400.0, 300.0);
s.run("b:LockHighlight()").unwrap();
assert_eq!(
label_color(&mut s),
Some([1.0, 1.0, 1.0, 1.0]),
"locked: white with the cursor elsewhere — the selected recipe row"
);
s.run("b:UnlockHighlight()").unwrap();
assert_eq!(
label_color(&mut s),
Some([1.0, 0.5, 0.25, 1.0]),
"unlocked: back to the difficulty colour"
);
// A button with no highlight instance keeps the normal one whole, hover or not — the shape
// every plain labelled Button in the corpus has.
s.run(r#"b:SetHighlightFontObject(nil); b:SetText("Rough Copper Vest")"#)
.unwrap();
s.resolve();
s.mouse_move(150.0, 110.0);
assert_eq!(
label_color(&mut s),
Some([1.0, 0.5, 0.25, 1.0]),
"no <HighlightFont> → hover changes nothing"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// **`Button:GetTextWidth` / `GetTextHeight`** — the reference's own Button text-extent readers
/// (`0x782290` / `0x782390`; wow-re `widget-api-batch-benilla.md` Q8 carves them present on Button
/// and `GetStringWidth` **absent**). Both forward to the label FontString's own extent slots, which
+11 -4
View File
@@ -441,7 +441,9 @@ pub struct ButtonState {
/// object — the client's per-state CFontString font swap (UIPanelButtonTemplate's gold
/// normal / white highlight / gray disabled label). `None` = keep the label's own paint.
pub normal_font: Option<String>,
/// See [`ButtonState::normal_font`] — the hovered state (falls back to `normal_font`).
/// See [`ButtonState::normal_font`] — the highlighted state (hovered **or**
/// [`locked_highlight`](ButtonState::locked_highlight)). `None` means the button has no
/// highlight instance at all and the label stays on the normal one, colour included.
pub highlight_font: Option<String>,
/// See [`ButtonState::normal_font`] — the disabled state.
pub disabled_font: Option<String>,
@@ -462,13 +464,18 @@ pub struct ButtonState {
/// (`info.textR/G/B`, isTitle's NORMAL-yellow and notClickable's HIGHLIGHT-white recolors of
/// a disabled row). `None` = the state font's paint.
pub normal_color: Option<[f32; 4]>,
/// See [`ButtonState::normal_color`] — the hovered state (falls back to `normal_color`).
/// See [`ButtonState::normal_color`] — the highlighted state. It does **not** fall back to
/// `normal_color`: each state is its own font instance, so a `SetTextColor` cannot reach the
/// highlighted label (which is why `UIDropDownMenu.lua` always pairs the two setters).
pub highlight_color: Option<[f32; 4]>,
/// See [`ButtonState::normal_color`] — the disabled state.
pub disabled_color: Option<[f32; 4]>,
/// `LockHighlight()` — the HighlightTexture draws regardless of hover until
/// `LockHighlight()` — the button reads as highlighted regardless of hover until
/// `UnlockHighlight()` (ref `CButton::LockHighlight`; the dropdown kit keeps a checked row's
/// highlight lit).
/// highlight lit). That covers BOTH halves of the highlighted look: the HighlightTexture
/// ([`Self::region_visible`]) and the label's font instance (`script::extract`). The list
/// windows lean on the second alone — a tradeskill/craft/trainer recipe row blanks its
/// highlight texture to `""` and locks the selected row anyway, purely for the white label.
pub locked_highlight: bool,
}